From 5efe0bf97821c4f91c980ec84a60a4ca3f128c25 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Fri, 14 Aug 2026 09:22:08 +0200 Subject: [PATCH 1/4] feat(manifest): add placeholder fromDependency behind schemaVersion 3 The CLI now accepts schemaVersion {2, 3} instead of a single version, so v2 payloads keep parsing. Placeholders may declare `fromDependency`; a payload using it while declaring schemaVersion 2 is rejected in loadPresetManifest, because zod strictness alone only protects older CLIs. --- docs/release.md | 8 +++-- src/engine/manifest.ts | 31 ++++++++++++++---- src/manifest/schema.ts | 32 +++++++++++++++--- test/manifest-loader.test.ts | 63 +++++++++++++++++++++++++++++++++--- test/manifest-schema.test.ts | 31 +++++++++++++++--- 5 files changed, 142 insertions(+), 23 deletions(-) diff --git a/docs/release.md b/docs/release.md index e400fab..7f0f67d 100644 --- a/docs/release.md +++ b/docs/release.md @@ -51,9 +51,11 @@ Two things worth knowing: ## Versioning What couples the CLI to a payload is the manifest's `schemaVersion` (currently -`2`), not the package version. A CLI major supports exactly one, exported at the -`./manifest` subpath. **Bumping it is a CLI major**, and the supporting CLI has -to ship before any payload adopts it. +`3`), not the package version. A CLI accepts a set of versions — `{2, 3}` today — +exported at the `./manifest` subpath as `SUPPORTED_SCHEMA_VERSION` (the current +one) plus `SUPPORTED_SCHEMA_VERSIONS` (the accepted set). Adding a version is a +minor when older ones keep parsing, **a major once one is dropped**; either way +the supporting CLI ships before any payload adopts it. The config file's `version` pin governs the payload package only. `upgrade` moves that pin and leaves the CLI version alone. The CLI and the payload release on diff --git a/src/engine/manifest.ts b/src/engine/manifest.ts index c213992..7e45898 100644 --- a/src/engine/manifest.ts +++ b/src/engine/manifest.ts @@ -3,7 +3,7 @@ import type { ManagedFile as EngineManagedFile } from "../config/types"; import type { ConfigKeyType, ManagedFile as ManifestManagedFile, PayloadManifest, PresetManifest, RenderDef } from "../manifest/schema"; import type { PayloadHandle } from "../payload/resolve"; import { StreamctlError } from "../errors"; -import { payloadManifestSchema, presetManifestSchema, SUPPORTED_SCHEMA_VERSION, zodToIssues } from "../manifest/schema"; +import { FROM_DEPENDENCY_MIN_SCHEMA_VERSION, payloadManifestSchema, presetManifestSchema, SUPPORTED_SCHEMA_VERSION_LIST, SUPPORTED_SCHEMA_VERSIONS, zodToIssues } from "../manifest/schema"; const PAYLOAD_MANIFEST = "presets/manifest.json"; @@ -16,9 +16,9 @@ function summarize(error: ZodError): string { } /** - * A `schemaVersion` pre-check runs before the full zod parse; an integer other - * than the supported one throws `SCHEMA_UNSUPPORTED`, so a v3 payload gets the - * version message instead of a wall of zod issues. + * A `schemaVersion` pre-check runs before the full zod parse; an integer outside + * the supported set throws `SCHEMA_UNSUPPORTED`, so a payload from the future gets + * the version message instead of a wall of zod issues. */ export async function loadPayloadManifest(payload: PayloadHandle): Promise { if (!(await payload.list()).includes("manifest.json")) { @@ -37,11 +37,11 @@ export async function loadPayloadManifest(payload: PayloadHandle): Promise = new Set([2, SUPPORTED_SCHEMA_VERSION]); + +/** Sorted rendering of {@link SUPPORTED_SCHEMA_VERSIONS} for error messages. */ +export const SUPPORTED_SCHEMA_VERSION_LIST = [...SUPPORTED_SCHEMA_VERSIONS].sort((a, b) => a - b).join(", "); + +/** Lowest `schemaVersion` a payload may declare while using placeholder `fromDependency`. */ +export const FROM_DEPENDENCY_MIN_SCHEMA_VERSION = 3; /** The CLI checks shape only; semantics stay payload-owned. `"object"` is a presence + `typeof` check with no deep validation. */ export const CONFIG_KEY_TYPES = ["boolean", "string", "string[]", "object"] as const; @@ -24,9 +40,14 @@ export type ConfigKeyType = (typeof CONFIG_KEY_TYPES)[number]; * A `string[]` value is deduped + sorted, then joined per `join` (`space` or * `lines`). Substitution is single-pass; any leftover `${…}` fails render.ts's * leftover-token check. + * + * `fromDependency` names an npm package whose pin in the consumer's package.json supplies + * the value (stripped to its range floor) when `configPath` yields nothing. Requires payload + * `schemaVersion` {@link FROM_DEPENDENCY_MIN_SCHEMA_VERSION}, enforced in `loadPresetManifest`. */ export const placeholderSchema = z.strictObject({ configPath: z.string().min(1), + fromDependency: z.string().min(1).optional(), default: z.string(), pattern: z.string().min(1).optional(), join: z.enum(["space", "lines"]).optional(), @@ -169,9 +190,12 @@ export const presetManifestSchema = z.strictObject({ } }); -/** The top-level payload index (`presets/manifest.json`, v2). */ +/** The top-level payload index (`presets/manifest.json`). */ export const payloadManifestSchema = z.strictObject({ - schemaVersion: z.literal(SUPPORTED_SCHEMA_VERSION), + schemaVersion: z.number().int().refine( + version => SUPPORTED_SCHEMA_VERSIONS.has(version), + { message: `must be one of ${SUPPORTED_SCHEMA_VERSION_LIST}` }, + ), presets: z.array(z.string().min(1)), profiles: z.array(profileDefSchema), defaultBase: z.string().min(1), diff --git a/test/manifest-loader.test.ts b/test/manifest-loader.test.ts index 7c6d108..91c5a5e 100644 --- a/test/manifest-loader.test.ts +++ b/test/manifest-loader.test.ts @@ -80,22 +80,75 @@ describe("loadPayloadManifest", () => { }); describe("schemaVersion contract", () => { - // Checked ahead of zod so the user sees both version numbers instead of a literal-mismatch issue. - it("names the payload's version and ours when they disagree", async () => { + // v2 is still accepted: the CLI supports a set, not a single version. + it("accepts a v3 payload", async () => { const payload = payloadOf({ "manifest.json": { schemaVersion: 3, presets: ["base"], profiles: [], defaultBase: "base" } }); + const manifest = await loadPayloadManifest(payload); + expect(manifest.schemaVersion).toBe(3); + }); + + // Checked ahead of zod so the user sees both version numbers instead of a refinement issue. + it("names the payload's version and ours when they disagree", async () => { + const payload = payloadOf({ "manifest.json": { schemaVersion: 4, presets: ["base"], profiles: [], defaultBase: "base" } }); const error = await caught(loadPayloadManifest(payload)); expect(error.code).toBe("SCHEMA_UNSUPPORTED"); - expect(error.message).toContain("schemaVersion 3"); - expect(error.message).toContain("schemaVersion 2"); + expect(error.message).toContain("schemaVersion 4"); + expect(error.message).toContain("schemaVersion 2, 3"); + expect(error.message).toContain("Upgrade the CLI or the payload"); + expect(error.details).toMatchObject({ found: 4, supported: [2, 3] }); }); it("surfaces through resolvePresetChain too", async () => { - const payload = payloadOf({ "manifest.json": { schemaVersion: 3, presets: ["base"], profiles: [], defaultBase: "base" } }); + const payload = payloadOf({ "manifest.json": { schemaVersion: 4, presets: ["base"], profiles: [], defaultBase: "base" } }); const error = await caught(resolvePresetChain(payload, "base", "nuxt-4")); expect(error.code).toBe("SCHEMA_UNSUPPORTED"); }); }); +describe("fromDependency feature gate", () => { + /** Single-preset payload whose dockerfile render derives a placeholder from the prisma pin. */ + function withFromDependency(schemaVersion: number): PayloadHandle { + return payloadOf({ + "manifest.json": { schemaVersion, presets: ["base"], profiles: [], defaultBase: "base" }, + "base/preset.json": { + name: "base", + files: [{ path: "Dockerfile", strategy: "full", source: "Dockerfile", render: "dockerfile" }], + renders: { + dockerfile: { + placeholders: { + PRISMA_VERSION_DEFAULT: { configPath: "docker.prismaVersion", fromDependency: "prisma", default: "6.19.1" }, + }, + }, + }, + configKeys: { "docker.prismaVersion": "string" }, + }, + }); + } + + it("lets a v3 payload declare it", async () => { + const { files } = await resolvePresetChain(withFromDependency(3), "base", "nuxt-4"); + expect(files.find(f => f.path === "Dockerfile")?.renderDef?.placeholders?.PRISMA_VERSION_DEFAULT?.fromDependency).toBe("prisma"); + }); + + // zod strictness only protects an OLD CLI; this CLI must refuse the key itself, or a + // forgotten schemaVersion bump ships a payload that breaks every 0.2.x repo. + it("refuses it under a v2 payload, naming the placeholder and the version it needs", async () => { + const error = await caught(resolvePresetChain(withFromDependency(2), "base", "nuxt-4")); + expect(error.code).toBe("CONFIG_INVALID"); + expect(error.message).toContain("PRISMA_VERSION_DEFAULT"); + expect(error.message).toContain("fromDependency"); + expect(error.message).toContain("requires schemaVersion 3"); + expect(error.details).toMatchObject({ file: "presets/base/preset.json", render: "dockerfile", placeholder: "PRISMA_VERSION_DEFAULT" }); + }); + + it("leaves a v2 payload without the key alone", async () => { + const manifest = await loadPayloadManifest(validPayload()); + expect(manifest.schemaVersion).toBe(2); + const { files } = await resolvePresetChain(validPayload(), "nuxt-app", "nuxt-4"); + expect(files.length).toBeGreaterThan(0); + }); +}); + describe("resolvePresetChain (v2 payload)", () => { it("walks the extends chain parents-first and merges the baseline", async () => { const { files, baseline } = await resolvePresetChain(validPayload(), "nuxt-app", "nuxt-4"); diff --git a/test/manifest-schema.test.ts b/test/manifest-schema.test.ts index 5b7dd35..9bada7c 100644 --- a/test/manifest-schema.test.ts +++ b/test/manifest-schema.test.ts @@ -7,6 +7,7 @@ import { presetManifestSchema, renderDefSchema, SUPPORTED_SCHEMA_VERSION, + SUPPORTED_SCHEMA_VERSIONS, zodToIssues, } from "../src/manifest/schema"; @@ -225,6 +226,18 @@ describe("RenderDef", () => { expect(renderDefSchema.safeParse({ placeholders: { X: { configPath: "a", default: "", join: "csv" } } }).success).toBe(false); }); + // The version gate lives in the loader (it needs the payload manifest); the schema only + // owns the shape, so a v2 payload's placeholder still parses here. + it("accepts a placeholder that derives from a dependency pin", () => { + const placeholder = { configPath: "docker.prismaVersion", fromDependency: "prisma", default: "6.19.1" }; + expect(renderDefSchema.safeParse({ placeholders: { PRISMA_VERSION_DEFAULT: placeholder } }).success).toBe(true); + }); + + it("rejects an empty or non-string fromDependency", () => { + expect(renderDefSchema.safeParse({ placeholders: { X: { configPath: "a", fromDependency: "", default: "" } } }).success).toBe(false); + expect(renderDefSchema.safeParse({ placeholders: { X: { configPath: "a", fromDependency: 1, default: "" } } }).success).toBe(false); + }); + it("accepts a passthrough token list", () => { expect(renderDefSchema.safeParse({ passthrough: ["PRISMA_VERSION", "OTHER"] }).success).toBe(true); }); @@ -270,14 +283,24 @@ describe("PresetManifest", () => { }); describe("PayloadManifest schemaVersion contract", () => { - it(`accepts exactly schemaVersion ${SUPPORTED_SCHEMA_VERSION}`, () => { - expect(payloadManifestSchema.safeParse(payload()).success).toBe(true); + it("accepts every supported version", () => { + for (const version of SUPPORTED_SCHEMA_VERSIONS) { + expect(payloadManifestSchema.safeParse({ ...payload(), schemaVersion: version }).success, String(version)).toBe(true); + } + }); + + // The exported constant is base-config's `validate-presets.mjs` contract: it writes this + // value into new payloads, so it has to be the current one, not just a supported one. + it("names the current version, which is supported", () => { + expect(SUPPORTED_SCHEMA_VERSION).toBe(3); + expect(SUPPORTED_SCHEMA_VERSIONS.has(SUPPORTED_SCHEMA_VERSION)).toBe(true); }); - // Plain literal failure here; the loader is what turns it into SCHEMA_UNSUPPORTED. + // Plain refinement failure here; the loader is what turns it into SCHEMA_UNSUPPORTED. it("rejects anything older or newer", () => { expect(payloadManifestSchema.safeParse({ ...payload(), schemaVersion: 1 }).success).toBe(false); - expect(payloadManifestSchema.safeParse({ ...payload(), schemaVersion: 3 }).success).toBe(false); + expect(payloadManifestSchema.safeParse({ ...payload(), schemaVersion: 4 }).success).toBe(false); + expect(payloadManifestSchema.safeParse({ ...payload(), schemaVersion: 2.5 }).success).toBe(false); }); it("rejects an unknown top-level key", () => { From d2642a233b5142b130a249c97b1b0212eda857d3 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Fri, 14 Aug 2026 09:27:46 +0200 Subject: [PATCH 2/4] feat(render): resolve placeholders from the consumer's dependency pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolvePlaceholder gains a middle step between the config value and the static default: a placeholder declaring `fromDependency` takes the consumer's pin for that package, floored via the now-exported parseRangeMin. Only full x.y.z(-pre) floors are used — a partial core like `^6` would render different bytes as the registry moves, so it falls through to the default. The dependency map arrives as data through a new optional renderFile param, so rendering stays pure. --- src/engine/render.ts | 41 +++++++++++++++++++++-- src/engine/versions.ts | 5 ++- test/render-generic.test.ts | 65 +++++++++++++++++++++++++++++++++++++ test/versions.test.ts | 30 ++++++++++++++++- 4 files changed, 136 insertions(+), 5 deletions(-) diff --git a/src/engine/render.ts b/src/engine/render.ts index 7e0c2db..77c877f 100644 --- a/src/engine/render.ts +++ b/src/engine/render.ts @@ -2,6 +2,7 @@ import type { ManagedFile, StreamctlConfig } from "../config/types"; import type { Placeholder, RenderDef } from "../manifest/schema"; import { StreamctlError } from "../errors"; import { isIndexable } from "./jsonc"; +import { parseRangeMin } from "./versions"; // Generic renderer (v2): manifest-driven placeholders, fragments, enabledBy gates. // Pure and deterministic, so output is byte-stable and a re-sync is idempotent. @@ -47,8 +48,32 @@ function configStringList(config: StreamctlConfig | undefined, path: string): st // A floor, independent of the payload's optional `pattern`. const SHELL_META_RE = /[\s;|&$`<>(){}\\'"*?[\]]/; -/** Resolve a placeholder to its substitution string: config value, falling back to `default`; `string[]` gets deduped, sorted, and joined. */ -function resolvePlaceholder(def: Placeholder, config: StreamctlConfig | undefined, key: string, filePath: string): string { +/** A full `x.y.z` triple with an optional prerelease tail. */ +const FULL_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?$/i; + +/** + * The consumer's pin for `name`, stripped to its range floor (`^6.19.3` → `6.19.3`), or + * `null` when there is nothing reproducible to use. `parseRangeMin` also yields partial + * cores (`^6` → `"6"`, `~1.2` → `"1.2"`), which would let the rendered version drift + * between builds, so only full triples pass — everything else falls back to `default`. + */ +function dependencyFloor(deps: Record, name: string): string | null { + const spec = deps[name]; + if (spec === undefined) { + return null; + } + const floor = parseRangeMin(spec); + return floor !== null && FULL_VERSION_RE.test(floor) ? floor : null; +} + +/** + * Resolve a placeholder to its substitution string: config value, then the `fromDependency` + * floor, falling back to `default`; `string[]` gets deduped, sorted, and joined. + * + * `deps` is the consumer's merged dependency map, passed in as data — this stays pure and + * never touches the filesystem. + */ +function resolvePlaceholder(def: Placeholder, config: StreamctlConfig | undefined, key: string, filePath: string, deps: Record): string { const raw = readConfigPath(config, def.configPath); if (Array.isArray(raw)) { const list = [...new Set(raw.filter((item): item is string => typeof item === "string"))].sort(); @@ -73,6 +98,12 @@ function resolvePlaceholder(def: Placeholder, config: StreamctlConfig | undefine if (typeof raw === "boolean" || typeof raw === "number") { return String(raw); } + if (def.fromDependency !== undefined) { + const floor = dependencyFloor(deps, def.fromDependency); + if (floor !== null) { + return floor; + } + } return def.default; } @@ -86,6 +117,9 @@ const LEFTOVER_TOKEN_RE = /\$\{(?!\{)([^}]*)\}/g; * * A `${TOKEN}` left unresolved throws `CONFIG_INVALID`, unless declared in * `renderDef.passthrough` (e.g. a Dockerfile build `ARG`). + * + * `deps` feeds placeholder `fromDependency`; omitting it just means no placeholder resolves + * that way. */ export function renderFile( sourceContent: string, @@ -93,6 +127,7 @@ export function renderFile( config: StreamctlConfig | undefined, fragmentSources: Record, filePath = "(template)", + deps: Record = {}, ): string { const parts = [sourceContent.replace(/\n+$/, "")]; for (const fragment of renderDef.fragments ?? []) { @@ -123,7 +158,7 @@ export function renderFile( let output = `${parts.join("\n")}\n`; for (const [key, def] of Object.entries(renderDef.placeholders ?? {})) { - const value = resolvePlaceholder(def, config, key, filePath); + const value = resolvePlaceholder(def, config, key, filePath, deps); if (def.pattern !== undefined && !new RegExp(def.pattern).test(value)) { throw new StreamctlError( "CONFIG_INVALID", diff --git a/src/engine/versions.ts b/src/engine/versions.ts index e8176ba..79fa4fb 100644 --- a/src/engine/versions.ts +++ b/src/engine/versions.ts @@ -359,8 +359,11 @@ function isSemverCore(value: string): boolean { * Extracts the comparable minimum of a version specifier: strips a leading range operator or a * `packageManager` pin's `@` prefix, returning the bare semver core. `null` when unparseable * (git/URL/tag/alias), so callers fall back to exact-string comparison rather than guess a direction. + * + * Exported for `engine/render.ts`, which floors a `fromDependency` pin with it. Mind that a + * partial range yields a partial core (`^6` → `"6"`), which that caller rejects. */ -function parseRangeMin(spec: string): string | null { +export function parseRangeMin(spec: string): string | null { // a protocol/alias spec (file:, link:, npm:foo@1.2.3, git:...) has no // orderable min - its `@` is an alias delimiter, not a version pin if (/^[a-z][a-z0-9+.-]*:/i.test(spec.trim())) { diff --git a/test/render-generic.test.ts b/test/render-generic.test.ts index 1f3d3f4..3a2c408 100644 --- a/test/render-generic.test.ts +++ b/test/render-generic.test.ts @@ -5,6 +5,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { validateStreamctlConfig } from "../src/config/validate"; import { detectDrift } from "../src/engine/drift"; import { isFileEnabled, readConfigPath, renderFile } from "../src/engine/render"; import { runSync } from "../src/engine/sync"; @@ -100,6 +101,70 @@ describe("renderFile: placeholders", () => { }); }); +describe("renderFile: fromDependency", () => { + const def: RenderDef = { placeholders: { PRISMA: { configPath: "docker.prismaVersion", fromDependency: "prisma", default: "6.19.1" } } }; + // `docker` is a payload knob, not a CLI-universal key, so it reaches the engine the way a + // real config does: through the loose stage-1 validation. + const withDocker = (prismaVersion: string): StreamctlConfig => validateStreamctlConfig({ ...cfg(), docker: { prismaVersion } }); + const render = (config: StreamctlConfig, deps: Record): string => + renderFile("ARG PRISMA_VERSION=${PRISMA}\n", def, config, {}, "Dockerfile", deps); + + it("prefers the config value over the dependency pin", () => { + expect(render(withDocker("6.20.0"), { prisma: "^6.19.3" })).toBe("ARG PRISMA_VERSION=6.20.0\n"); + }); + + it("prefers the dependency floor over the static default", () => { + expect(render(cfg(), { prisma: "^6.19.3" })).toBe("ARG PRISMA_VERSION=6.19.3\n"); + }); + + it("falls back to the default when the dependency is absent", () => { + expect(render(cfg(), {})).toBe("ARG PRISMA_VERSION=6.19.1\n"); + }); + + it("floors the common range spellings, prereleases included", () => { + for (const [spec, expected] of [ + ["^6.19.3", "6.19.3"], + ["~6.19.3", "6.19.3"], + [">=6.19.3", "6.19.3"], + ["6.19.1", "6.19.1"], + ["v6.19.1", "6.19.1"], + ["6.20.0-rc.1", "6.20.0-rc.1"], + ["^6.20.0-rc.1", "6.20.0-rc.1"], + ] as const) { + expect(render(cfg(), { prisma: spec }), spec).toBe(`ARG PRISMA_VERSION=${expected}\n`); + } + }); + + // A partial core would let the same commit render different bytes as the registry moves, + // which breaks both image reproducibility and the `check` drift gate. + it("falls back to the default for anything that is not a full triple", () => { + for (const spec of ["^6", "~1.2", "6", "6.19", "*", "latest", "workspace:*", "file:../prisma", "npm:@acme/prisma@6.19.3", "git+https://github.com/prisma/prisma.git#v6.19.3"]) { + expect(render(cfg(), { prisma: spec }), spec).toBe("ARG PRISMA_VERSION=6.19.1\n"); + } + }); + + it("validates `pattern` against the derived value like any other", () => { + const patterned: RenderDef = { + placeholders: { PRISMA: { configPath: "docker.prismaVersion", fromDependency: "prisma", default: "6.19.1", pattern: "^6\\.19\\.\\d+$" } }, + }; + const error = (() => { + try { + renderFile("ARG PRISMA_VERSION=${PRISMA}\n", patterned, cfg(), {}, "Dockerfile", { prisma: "^7.0.0" }); + } catch (e) { + return e; + } + })(); + expect(error).toBeInstanceOf(StreamctlError); + expect((error as StreamctlError).code).toBe("CONFIG_INVALID"); + expect((error as StreamctlError).details).toMatchObject({ file: "Dockerfile", placeholder: "PRISMA", value: "7.0.0" }); + }); + + // Every existing caller omits the argument; it must resolve exactly as it did before. + it("resolves to the default when the caller passes no deps at all", () => { + expect(renderFile("ARG PRISMA_VERSION=${PRISMA}\n", def, cfg(), {})).toBe("ARG PRISMA_VERSION=6.19.1\n"); + }); +}); + describe("renderFile: fragments", () => { const frags = { a: "FRAG-A", b: "FRAG-B", c: "FRAG-C" }; const def: RenderDef = { diff --git a/test/versions.test.ts b/test/versions.test.ts index 9c630eb..deb1359 100644 --- a/test/versions.test.ts +++ b/test/versions.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { runCheck } from "../src/engine/check"; -import { checkUpdateAvailable, detectVersionSkew, reconcileVersions } from "../src/engine/versions"; +import { checkUpdateAvailable, detectVersionSkew, parseRangeMin, reconcileVersions } from "../src/engine/versions"; import { StreamctlError } from "../src/errors"; import { exitCodeFor } from "../src/exit-codes"; @@ -673,6 +673,34 @@ describe("detectVersionSkew", () => { }); }); +// Exported for `engine/render.ts`'s `fromDependency` floor, so its contract — including the +// partial cores that caller has to reject — is pinned here. +describe("parseRangeMin", () => { + it("strips range operators and a leading v", () => { + expect(parseRangeMin("^6.19.3")).toBe("6.19.3"); + expect(parseRangeMin("~6.19.3")).toBe("6.19.3"); + expect(parseRangeMin(">=6.19.3")).toBe("6.19.3"); + expect(parseRangeMin("v6.19.3")).toBe("6.19.3"); + expect(parseRangeMin("6.19.3")).toBe("6.19.3"); + expect(parseRangeMin("6.20.0-rc.1")).toBe("6.20.0-rc.1"); + }); + + it("keeps a `@` pin's version", () => { + expect(parseRangeMin("pnpm@10.29.1")).toBe("10.29.1"); + }); + + it("returns a PARTIAL core for a partial range", () => { + expect(parseRangeMin("^6")).toBe("6"); + expect(parseRangeMin("~1.2")).toBe("1.2"); + }); + + it("returns null for protocol, alias and unorderable specs", () => { + for (const spec of ["workspace:*", "file:../prisma", "npm:@acme/prisma@6.19.3", "git+https://github.com/prisma/prisma.git#v6.19.3", "latest", "*", "^9 || ^10"]) { + expect(parseRangeMin(spec), spec).toBeNull(); + } + }); +}); + describe("checkUpdateAvailable, with the registry probe injected", () => { it("reports a newer release", async () => { expect(await checkUpdateAvailable(cwd, "1.0.0", "@acme/payload", async () => "1.2.0")).toEqual({ current: "1.0.0", latest: "1.2.0" }); From 1a611acf9bf589e997f2316b6c44b1d7bad1dfb4 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Fri, 14 Aug 2026 09:33:36 +0200 Subject: [PATCH 3/4] feat(render): substitute placeholders to a fixpoint with a cycle guard A placeholder value carrying another placeholder's token used to resolve only if the manifest happened to declare the keys in the right order. Substitution now repeats until the output is stable, capped at 10 passes; on the cap it throws CONFIG_INVALID naming the file and every token in the cycle, found by following the values rather than reading the last pass's leftovers (a cycle leaves only one member behind at a time). Values are resolved and pattern-checked once, up front, and still land verbatim through the callback replacement form. --- src/engine/render.ts | 67 ++++++++++++++++++++++++++++- test/render-generic.test.ts | 84 +++++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) diff --git a/src/engine/render.ts b/src/engine/render.ts index 77c877f..7f08465 100644 --- a/src/engine/render.ts +++ b/src/engine/render.ts @@ -110,6 +110,41 @@ function resolvePlaceholder(def: Placeholder, config: StreamctlConfig | undefine /** Extract the inner token name of every streamctl `${TOKEN}`; the `(?!\{)` guard excludes GitHub `${{ ... }}` expressions. */ const LEFTOVER_TOKEN_RE = /\$\{(?!\{)([^}]*)\}/g; +/** + * Substitution repeats until the output stops changing, so a placeholder value that carries + * another placeholder's token resolves whatever order the manifest declares them in. The cap + * turns a cyclic manifest (A's value names B, B's names A) into a loud error instead of a + * hang; ten levels of nesting is far past anything a payload legitimately needs. + */ +const MAX_SUBSTITUTION_PASSES = 10; + +/** + * Every `${TOKEN}` still in `text`, plus the tokens reachable through the values those name. + * A cycle leaves only one of its members in the output at any given pass (the others were + * just substituted), so following the values is what lets the error name the whole loop. + */ +function unresolvedTokens(text: string, values: ReadonlyMap, passthrough: ReadonlySet): string[] { + const names: string[] = []; + const seen = new Set(); + const collect = (source: string): void => { + for (const match of source.matchAll(LEFTOVER_TOKEN_RE)) { + const name = match[1] ?? ""; + if (!passthrough.has(name) && !seen.has(name)) { + seen.add(name); + names.push(name); + } + } + }; + collect(text); + for (let index = 0; index < names.length; index++) { + const value = values.get(names[index] ?? ""); + if (value !== undefined) { + collect(value); + } + } + return names.sort().map(name => `\${${name}}`); +} + /** * Assemble fragments in declared order (`toggle` includes when true, `forEach` * repeats per `string[]` item as `${ITEM}`), then substitute `${KEY}` placeholders @@ -157,6 +192,9 @@ export function renderFile( let output = `${parts.join("\n")}\n`; + // Values are resolved (and pattern-checked) once, ahead of substitution: they come from + // the config and the dependency map, never from the output being assembled. + const values = new Map(); for (const [key, def] of Object.entries(renderDef.placeholders ?? {})) { const value = resolvePlaceholder(def, config, key, filePath, deps); if (def.pattern !== undefined && !new RegExp(def.pattern).test(value)) { @@ -166,10 +204,37 @@ export function renderFile( { file: filePath, placeholder: key, value }, ); } - output = output.replaceAll(`\${${key}}`, () => value); + values.set(key, value); } const passthrough = new Set(renderDef.passthrough ?? []); + + // Callback form: a plain replacement string would honor `$&`/`$1` patterns in the value. + // Config values are data and must land verbatim. + const substitutePass = (text: string): string => { + let next = text; + for (const [key, value] of values) { + next = next.replaceAll(`\${${key}}`, () => value); + } + return next; + }; + + for (let pass = 1; ; pass++) { + const next = substitutePass(output); + if (next === output) { + break; // fixpoint + } + output = next; + if (pass >= MAX_SUBSTITUTION_PASSES) { + const tokens = unresolvedTokens(output, values, passthrough); + throw new StreamctlError( + "CONFIG_INVALID", + `render for "${filePath}" did not stabilize after ${MAX_SUBSTITUTION_PASSES} substitution passes; still unresolved: ${tokens.join(", ")}. A placeholder value references itself, directly or through another placeholder.`, + { file: filePath, tokens, passes: MAX_SUBSTITUTION_PASSES }, + ); + } + } + for (const match of output.matchAll(LEFTOVER_TOKEN_RE)) { const token = match[0]; const name = match[1] ?? ""; diff --git a/test/render-generic.test.ts b/test/render-generic.test.ts index 3a2c408..d1d3de1 100644 --- a/test/render-generic.test.ts +++ b/test/render-generic.test.ts @@ -165,6 +165,90 @@ describe("renderFile: fromDependency", () => { }); }); +describe("renderFile: nested placeholder tokens", () => { + const thrown = (run: () => unknown): StreamctlError => { + const error = (() => { + try { + run(); + } catch (e) { + return e; + } + })(); + expect(error).toBeInstanceOf(StreamctlError); + return error as StreamctlError; + }; + + // Substitution used to be a single pass in manifest key order, so this resolved or not + // depending on which key came first — an invisible trap for the payload author. + it("resolves a token inside a value whichever order the keys are declared in", () => { + const inner = { configPath: "ci.prismaVersion", default: "6.19.1" }; + const outer = { configPath: "ci.prismaRuntime", default: "RUN npm i -D prisma@${INNER}" }; + + const innerFirst: RenderDef = { placeholders: { INNER: inner, OUTER: outer } }; + const outerFirst: RenderDef = { placeholders: { OUTER: outer, INNER: inner } }; + expect(renderFile("${OUTER}\n", innerFirst, cfg(), {})).toBe("RUN npm i -D prisma@6.19.1\n"); + expect(renderFile("${OUTER}\n", outerFirst, cfg(), {})).toBe("RUN npm i -D prisma@6.19.1\n"); + }); + + it("resolves a chain several levels deep", () => { + const def: RenderDef = { + placeholders: { + A: { configPath: "ci.a", default: "a-${B}" }, + B: { configPath: "ci.b", default: "b-${C}" }, + C: { configPath: "ci.c", default: "c" }, + }, + }; + expect(renderFile("${A}\n", def, cfg(), {})).toBe("a-b-c\n"); + }); + + it("a two-node cycle fails loud, naming the file and both tokens", () => { + const def: RenderDef = { + placeholders: { + A: { configPath: "ci.a", default: "a ${B}" }, + B: { configPath: "ci.b", default: "b ${A}" }, + }, + }; + const error = thrown(() => renderFile("${A}\n", def, cfg(), {}, "Dockerfile")); + expect(error.code).toBe("CONFIG_INVALID"); + expect(error.message).toContain("Dockerfile"); + expect(error.message).toContain("did not stabilize"); + expect(error.details).toMatchObject({ file: "Dockerfile", tokens: ["${A}", "${B}"], passes: 10 }); + }); + + it("a self-referencing config value fails rather than looping", () => { + const def: RenderDef = { placeholders: { SELF: { configPath: "ci.self", default: "" } } }; + const error = thrown(() => renderFile("v: ${SELF}\n", def, cfg({ ci: { self: "x ${SELF}" } }), {}, "f")); + expect(error.code).toBe("CONFIG_INVALID"); + expect(error.message).toContain("${SELF}"); + }); + + // The base-config dockerfile render in miniature: the runtime block's default embeds the + // build-time ARG, which the template owns and the renderer must not touch. + it("leaves a passthrough token inside a placeholder value verbatim", () => { + const def: RenderDef = { + placeholders: { + DOCKER_PRISMA_RUNTIME: { + configPath: "docker.prismaRuntime", + default: "ARG PRISMA_VERSION=6.19.1\nRUN npm i -D prisma@${PRISMA_VERSION}", + }, + }, + passthrough: ["PRISMA_VERSION"], + }; + expect(renderFile("${DOCKER_PRISMA_RUNTIME}\n", def, cfg(), {}, "Dockerfile")) + .toBe("ARG PRISMA_VERSION=6.19.1\nRUN npm i -D prisma@${PRISMA_VERSION}\n"); + }); + + it("substitutes a value containing $-patterns verbatim across passes", () => { + const def: RenderDef = { + placeholders: { + OUTER: { configPath: "ci.outer", default: "[${INNER}]" }, + INNER: { configPath: "ci.inner", default: "" }, + }, + }; + expect(renderFile("v: ${OUTER}\n", def, cfg({ ci: { inner: "$& $1 $$" } }), {})).toBe("v: [$& $1 $$]\n"); + }); +}); + describe("renderFile: fragments", () => { const frags = { a: "FRAG-A", b: "FRAG-B", c: "FRAG-C" }; const def: RenderDef = { From 4da0a8b46fdae98867994911628915e55647a790 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Fri, 14 Aug 2026 09:41:42 +0200 Subject: [PATCH 4/4] feat(engine): feed the consumer dependency map into every render path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync, check/drift and status now build the same package.json dependency map once per run (dependencies shadowing devDependencies) and thread it through compose into renderFile, so all three commands compose identical bytes and a check after a sync stays clean. sync reads package.json before the plan pass instead of after it and no longer skips the read when versionSync is off, since rendering needs the map either way — a malformed package.json therefore fails before any managed write on that path too. --- src/engine/compose.ts | 8 ++-- src/engine/drift.ts | 6 ++- src/engine/status.ts | 11 +++-- src/engine/sync.ts | 17 ++++---- src/engine/versions.ts | 24 +++++++++++ test/compose.test.ts | 29 +++++++++++++ test/drift.test.ts | 98 +++++++++++++++++++++++++++++++++++++++++- test/status.test.ts | 84 +++++++++++++++++++++++++++++++++++- test/sync.test.ts | 43 ++++++++++++++++++ 9 files changed, 301 insertions(+), 19 deletions(-) diff --git a/src/engine/compose.ts b/src/engine/compose.ts index 0a74bc1..040455e 100644 --- a/src/engine/compose.ts +++ b/src/engine/compose.ts @@ -41,7 +41,7 @@ function finalizeComposed( } // Fragment sources are read from the payload here so `renderFile` itself stays pure. -async function renderManagedTemplate(file: ManagedFile, payload: PayloadHandle, config?: StreamctlConfig): Promise { +async function renderManagedTemplate(file: ManagedFile, payload: PayloadHandle, config: StreamctlConfig | undefined, deps: Record): Promise { const raw = await payload.read(file.source); if (file.renderDef === undefined) { return raw; @@ -52,16 +52,18 @@ async function renderManagedTemplate(file: ManagedFile, payload: PayloadHandle, fragmentSources[fragment.source] = await payload.read(fragment.source); } } - return renderFile(raw, file.renderDef, config, fragmentSources, file.path); + return renderFile(raw, file.renderDef, config, fragmentSources, file.path, deps); } +/** `deps` is the consumer's dependency map (see `dependencyMap`), built once per run by the caller and fed to placeholder `fromDependency` resolution. */ export async function compose( file: ManagedFile, payload: PayloadHandle, currentContent: string | null = null, config?: StreamctlConfig, + deps: Record = {}, ): Promise { - const template = await renderManagedTemplate(file, payload, config); + const template = await renderManagedTemplate(file, payload, config, deps); switch (file.strategy) { case "full": diff --git a/src/engine/drift.ts b/src/engine/drift.ts index a0a654a..c226812 100644 --- a/src/engine/drift.ts +++ b/src/engine/drift.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { compose } from "./compose"; import { contentEquals } from "./jsonc"; import { isFileEnabled } from "./render"; +import { dependencyMap, readPackageJson } from "./versions"; import { readFileOrNull } from "./write"; export type DriftKind = "content" | "missing" | "extra"; @@ -48,6 +49,9 @@ export async function detectDrift( ): Promise { const drift: DriftEntry[] = []; const structuralFaults: StructuralFault[] = []; + // Read once per run: every file composes against the same dependency snapshot, which is + // what keeps `check` byte-identical to `sync`. + const deps = dependencyMap(await readPackageJson(cwd)); for (const file of managedFiles) { // `files: off` opt-out, or a v2 `enabledBy` gate that is false, means not managed. @@ -56,7 +60,7 @@ export async function detectDrift( } const current = await readFileOrNull(join(cwd, file.path)); - const result = await compose(file, payload, current, config); + const result = await compose(file, payload, current, config, deps); if (result.status === "structural-error") { structuralFaults.push({ path: result.path, reason: result.reason }); diff --git a/src/engine/status.ts b/src/engine/status.ts index 3e02517..de70a43 100644 --- a/src/engine/status.ts +++ b/src/engine/status.ts @@ -10,7 +10,7 @@ import { contentEquals } from "./jsonc"; import { resolvePresetChain } from "./manifest"; import { lockfileExists } from "./pm"; import { isFileActive, isFileEnabled } from "./render"; -import { checkUpdateAvailable, detectVersionSkew } from "./versions"; +import { checkUpdateAvailable, dependencyMap, detectVersionSkew, readPackageJson } from "./versions"; import { readFileOrNull } from "./write"; /** @@ -56,7 +56,7 @@ export interface RunStatusOptions { } /** Classify one managed file's state from a read-only compose (mirrors the sync plan-pass, but writes nothing). */ -async function fileState(file: ManagedFile, payload: PayloadHandle, config: StreamctlConfig, cwd: string): Promise { +async function fileState(file: ManagedFile, payload: PayloadHandle, config: StreamctlConfig, cwd: string, deps: Record): Promise { if (config.files?.[file.path] === "off") { return "off"; } @@ -65,7 +65,7 @@ async function fileState(file: ManagedFile, payload: PayloadHandle, config: Stre } const current = await readFileOrNull(join(cwd, file.path)); - const result = await compose(file, payload, current, config); + const result = await compose(file, payload, current, config, deps); if (result.status === "structural-error" || result.status === "marker-error" || result.status === "merge-error") { return "fault"; @@ -103,9 +103,12 @@ export async function runStatus( // Stage 2: a malformed config here is a hard exit-1. validateStreamctlConfigWithKeys(config, configKeys); + // Read once per run, so `status` composes the same bytes `sync`/`check` do. + const deps = dependencyMap(await readPackageJson(cwd)); + const files: StatusEntry[] = []; for (const file of managedFiles) { - files.push({ path: file.path, strategy: file.strategy, state: await fileState(file, payload, config, cwd) }); + files.push({ path: file.path, strategy: file.strategy, state: await fileState(file, payload, config, cwd, deps) }); } const hasEslintConfig = managedFiles.some(file => file.path.endsWith("eslint.config.ts") && isFileActive(file, config)); diff --git a/src/engine/sync.ts b/src/engine/sync.ts index 884e84c..1a381c5 100644 --- a/src/engine/sync.ts +++ b/src/engine/sync.ts @@ -10,7 +10,7 @@ import { dirtyTrackedPaths } from "./git"; import { contentEquals } from "./jsonc"; import { lockfileExists } from "./pm"; import { isFileActive, isFileEnabled } from "./render"; -import { applyReconcile, planReconcile, readPackageJson } from "./versions"; +import { applyReconcile, dependencyMap, planReconcile, readPackageJson } from "./versions"; import { atomicWrite, readFileOrNull } from "./write"; /** @@ -202,11 +202,18 @@ export async function runSync(opts: RunSyncOptions): Promise { activeFiles.push(file); } + // Read once per run, ahead of the plan pass: renders resolve placeholder + // `fromDependency` against it, and the reconcile below reuses the same parse. A + // malformed package.json therefore throws CONFIG_INVALID before any managed write — + // now also when `versionSync` is off, since rendering needs it either way. + const parsedPkg = await readPackageJson(cwd); + const deps = dependencyMap(parsedPkg); + // Plan pass: compose and validate everything, write nothing. for (const file of activeFiles) { const abs = join(cwd, file.path); const current = await readFileOrNull(abs); - const result = await compose(file, payload, current, config); + const result = await compose(file, payload, current, config, deps); // Never write output that does not parse. `--force` cannot fix corrupt output. if (result.status === "structural-error") { @@ -248,12 +255,6 @@ export async function runSync(opts: RunSyncOptions): Promise { pending.push({ change, abs, content: result.targetContent, file }); } - // Still the plan pass. A malformed package.json throws CONFIG_INVALID here, before - // any managed write, rather than inside the post-write reconcile, which would leave - // managed files already on disk. Threaded into `planReconcile` so the file is - // read once per run. - const parsedPkg = config.versionSync === false ? null : await readPackageJson(cwd); - // jiti is reconciled only for an ACTIVE eslint.config.ts: a file turned off or gated // out by `enabledBy` is not managed here, so its version key must not move either. const hasEslintConfig = opts.managedFiles.some(file => file.path.endsWith("eslint.config.ts") && isFileActive(file, config)); diff --git a/src/engine/versions.ts b/src/engine/versions.ts index 79fa4fb..6355ad2 100644 --- a/src/engine/versions.ts +++ b/src/engine/versions.ts @@ -70,6 +70,30 @@ export async function readPackageJson(cwd: string): Promise { + const deps: Record = {}; + if (pkg === null) { + return deps; + } + for (const section of ["devDependencies", "dependencies"]) { + const entries = pkg.value[section]; + if (!isPlainObject(entries)) { + continue; + } + for (const [name, spec] of Object.entries(entries)) { + if (typeof spec === "string") { + deps[name] = spec; + } + } + } + return deps; +} + export type LatestVersionProbe = (cwd: string, packageName: string) => Promise; function isOptedOut(key: string, config: StreamctlConfig): boolean { diff --git a/test/compose.test.ts b/test/compose.test.ts index 003148d..d277169 100644 --- a/test/compose.test.ts +++ b/test/compose.test.ts @@ -101,3 +101,32 @@ describe("compose: merge", () => { expect(result.reason).toContain("invalid JSONC"); }); }); + +describe("compose: dependency map", () => { + const dockerPayload = stubPayload({ "base/Dockerfile": "ARG PRISMA_VERSION=${PRISMA}\n" }); + const dockerfile: ManagedFile = { + path: "Dockerfile", + strategy: "full", + source: "base/Dockerfile", + renderDef: { placeholders: { PRISMA: { configPath: "docker.prismaVersion", fromDependency: "prisma", default: "6.19.1" } } }, + }; + + it("feeds the caller's map into placeholder resolution", async () => { + const result = await compose(dockerfile, dockerPayload, null, undefined, { prisma: "^6.19.3" }); + expect(result).toMatchObject({ status: "composed", targetContent: "ARG PRISMA_VERSION=6.19.3\n" }); + }); + + it("falls back to the placeholder default when the caller passes no map", async () => { + const result = await compose(dockerfile, dockerPayload); + expect(result).toMatchObject({ status: "composed", targetContent: "ARG PRISMA_VERSION=6.19.1\n" }); + }); + + // `check` diffs its composed bytes against what `sync` wrote, so identical inputs must + // produce identical output every time. + it("composes byte-identically on repeat", async () => { + const deps = { prisma: "^6.19.3" }; + const first = await compose(dockerfile, dockerPayload, null, undefined, deps); + const second = await compose(dockerfile, dockerPayload, null, undefined, deps); + expect(first).toEqual(second); + }); +}); diff --git a/test/drift.test.ts b/test/drift.test.ts index f7a7266..9106f11 100644 --- a/test/drift.test.ts +++ b/test/drift.test.ts @@ -3,8 +3,17 @@ import type { PayloadHandle } from "../src/payload/resolve"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { detectDrift } from "../src/engine/drift"; +import { readFileOrNull } from "../src/engine/write"; +import { StreamctlError } from "../src/errors"; + +// Pass-through spy: behavior is the actual implementation, we only count the reads so a +// per-file package.json read can never creep back in. +vi.mock("../src/engine/write", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readFileOrNull: vi.fn(actual.readFileOrNull) }; +}); const config: StreamctlConfig = { package: "@acme/payload", base: "nuxt-app", version: "1.0.0", profile: "nuxt-4" }; @@ -116,3 +125,90 @@ describe("detectDrift", () => { expect(report).toEqual({ inSync: true, drift: [], structuralFaults: [] }); }); }); + +describe("detectDrift: dependency-derived renders", () => { + const dockerPayload: PayloadHandle = { + version: "1.0.0", + async read(source) { + if (source !== "base/Dockerfile") { + throw new Error(`missing fixture source: ${source}`); + } + return "ARG PRISMA_VERSION=${PRISMA}\n"; + }, + async list() { + return []; + }, + }; + const DOCKERFILE: ManagedFile = { + path: "Dockerfile", + strategy: "full", + source: "base/Dockerfile", + renderDef: { placeholders: { PRISMA: { configPath: "docker.prismaVersion", fromDependency: "prisma", default: "6.19.1" } } }, + }; + + const pkg = (deps: Record>): string => JSON.stringify({ name: "consumer", ...deps }, null, 2); + + it("composes against the repo's pin", async () => { + await write("package.json", pkg({ devDependencies: { prisma: "^6.19.3" } })); + await write("Dockerfile", "ARG PRISMA_VERSION=6.19.3\n"); + expect(await detectDrift([DOCKERFILE], dockerPayload, config, root)).toEqual({ inSync: true, drift: [], structuralFaults: [] }); + }); + + // The point of the feature: a prisma bump makes the committed Dockerfile stale, and + // `check` is what says so. + it("reports drift once the pin moves ahead of the rendered file", async () => { + await write("package.json", pkg({ devDependencies: { prisma: "^6.20.0" } })); + await write("Dockerfile", "ARG PRISMA_VERSION=6.19.3\n"); + const report = await detectDrift([DOCKERFILE], dockerPayload, config, root); + expect(report.drift).toEqual([{ path: "Dockerfile", kind: "content" }]); + }); + + it("dependencies shadow devDependencies", async () => { + await write("package.json", pkg({ dependencies: { prisma: "6.21.0" }, devDependencies: { prisma: "^6.19.3" } })); + await write("Dockerfile", "ARG PRISMA_VERSION=6.21.0\n"); + expect(await detectDrift([DOCKERFILE], dockerPayload, config, root)).toEqual({ inSync: true, drift: [], structuralFaults: [] }); + }); + + it("uses the placeholder default in a repo with no package.json", async () => { + await write("Dockerfile", "ARG PRISMA_VERSION=6.19.1\n"); + expect(await detectDrift([DOCKERFILE], dockerPayload, config, root)).toEqual({ inSync: true, drift: [], structuralFaults: [] }); + }); + + // Same strict parser the version paths already used; render paths now surface it too. + it("fails loud on a malformed package.json", async () => { + await write("package.json", "{ not json"); + await write("Dockerfile", "ARG PRISMA_VERSION=6.19.1\n"); + const error = await detectDrift([DOCKERFILE], dockerPayload, config, root).catch((e: unknown) => e); + expect(error).toBeInstanceOf(StreamctlError); + expect((error as StreamctlError).code).toBe("CONFIG_INVALID"); + expect((error as StreamctlError).message).toContain("package.json is not valid JSON"); + }); +}); + +describe("detectDrift package.json reads", () => { + const dockerPayload: PayloadHandle = { + version: "1.0.0", + async read() { + return "ARG PRISMA_VERSION=${PRISMA}\n"; + }, + async list() { + return []; + }, + }; + const renderDef = { placeholders: { PRISMA: { configPath: "docker.prismaVersion", fromDependency: "prisma", default: "6.19.1" } } }; + + it("reads package.json once per run, not once per file", async () => { + await write("package.json", JSON.stringify({ name: "consumer", devDependencies: { prisma: "^6.19.3" } })); + const files: ManagedFile[] = Array.from({ length: 4 }, (_, index) => ({ + path: `Dockerfile.${index}`, + strategy: "full", + source: "base/Dockerfile", + renderDef, + })); + + vi.mocked(readFileOrNull).mockClear(); + await detectDrift(files, dockerPayload, config, root); + const pkgReads = vi.mocked(readFileOrNull).mock.calls.filter(([path]) => path.endsWith("package.json")); + expect(pkgReads).toHaveLength(1); + }); +}); diff --git a/test/status.test.ts b/test/status.test.ts index 64f0166..8382483 100644 --- a/test/status.test.ts +++ b/test/status.test.ts @@ -1,11 +1,14 @@ -import type { StreamctlConfig } from "../src/config/types"; +import type { ManagedFile, StreamctlConfig } from "../src/config/types"; import type { FileState } from "../src/engine/status"; import type { PayloadHandle } from "../src/payload/resolve"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { detectDrift } from "../src/engine/drift"; +import { resolvePresetChain } from "../src/engine/manifest"; import { runStatus } from "../src/engine/status"; +import { runSync } from "../src/engine/sync"; import { StreamctlError } from "../src/errors"; function BLOCK(registry: string): string { @@ -167,3 +170,80 @@ describe("runStatus --outdated probe", () => { expect((error as StreamctlError).code).toBe("CONFIG_INVALID"); }); }); + +// One consumer repo, three commands: the whole point of threading the dependency map +// through `compose` is that sync, check and status can never disagree about the bytes. +describe("dependency-derived render across sync, check and status", () => { + const V3_SOURCES: Record = { + "manifest.json": JSON.stringify({ schemaVersion: 3, presets: ["base"], profiles: [], defaultBase: "base" }), + "base/preset.json": JSON.stringify({ + name: "base", + files: [{ path: "Dockerfile", strategy: "full", source: "base/Dockerfile", render: "dockerfile" }], + renders: { + dockerfile: { placeholders: { PRISMA: { configPath: "docker.prismaVersion", fromDependency: "prisma", default: "6.19.1" } } }, + }, + configKeys: { "docker.prismaVersion": "string" }, + }), + "base/Dockerfile": "ARG PRISMA_VERSION=${PRISMA}\n", + }; + + const v3Payload: PayloadHandle = { + version: "1.0.0", + async read(source) { + const content = V3_SOURCES[source]; + if (content === undefined) { + throw new Error(`missing fixture source: ${source}`); + } + return content; + }, + async list() { + return Object.keys(V3_SOURCES).sort(); + }, + }; + + const v3Config: StreamctlConfig = { package: "@acme/payload", base: "base", version: "1.0.0", profile: "nuxt-4" }; + + async function pinPrisma(spec: string): Promise { + await writeFile(join(cwd, "package.json"), `${JSON.stringify({ name: "consumer", devDependencies: { prisma: spec } }, null, 2)}\n`); + } + + async function managedFiles(): Promise { + return (await resolvePresetChain(v3Payload, "base", "nuxt-4")).files; + } + + it("all three commands see the same derived bytes", async () => { + await pinPrisma("^6.19.3"); + const files = await managedFiles(); + + const result = await runSync({ cwd, payload: v3Payload, config: v3Config, managedFiles: files }); + expect(result.written).toEqual(["Dockerfile"]); + expect(await readFile(join(cwd, "Dockerfile"), "utf8")).toBe("ARG PRISMA_VERSION=6.19.3\n"); + expect(await detectDrift(files, v3Payload, v3Config, cwd)).toEqual({ inSync: true, drift: [], structuralFaults: [] }); + + const status = await runStatus(cwd, v3Payload, v3Config, { cliVersion: "9.9.9" }); + expect(status.files).toEqual([{ path: "Dockerfile", strategy: "full", state: "in-sync" }]); + }); + + it("a pin bump drifts in check and status until sync re-renders", async () => { + await pinPrisma("^6.19.3"); + const files = await managedFiles(); + await runSync({ cwd, payload: v3Payload, config: v3Config, managedFiles: files }); + + await pinPrisma("^6.20.1"); + expect((await detectDrift(files, v3Payload, v3Config, cwd)).drift).toEqual([{ path: "Dockerfile", kind: "content" }]); + const status = await runStatus(cwd, v3Payload, v3Config, { cliVersion: "9.9.9" }); + expect(status.files).toEqual([{ path: "Dockerfile", strategy: "full", state: "conflict" }]); + + await runSync({ cwd, payload: v3Payload, config: v3Config, managedFiles: files, force: true }); + expect(await readFile(join(cwd, "Dockerfile"), "utf8")).toBe("ARG PRISMA_VERSION=6.20.1\n"); + expect(await detectDrift(files, v3Payload, v3Config, cwd)).toEqual({ inSync: true, drift: [], structuralFaults: [] }); + }); + + it("renders the default in a repo with no package.json at all", async () => { + const files = await managedFiles(); + await runSync({ cwd, payload: v3Payload, config: v3Config, managedFiles: files }); + expect(await readFile(join(cwd, "Dockerfile"), "utf8")).toBe("ARG PRISMA_VERSION=6.19.1\n"); + const status = await runStatus(cwd, v3Payload, v3Config, { cliVersion: "9.9.9" }); + expect(status.files).toEqual([{ path: "Dockerfile", strategy: "full", state: "in-sync" }]); + }); +}); diff --git a/test/sync.test.ts b/test/sync.test.ts index 23d2846..72c3059 100644 --- a/test/sync.test.ts +++ b/test/sync.test.ts @@ -512,3 +512,46 @@ describe("runSync batch decider", () => { expect(result.written.sort()).toEqual([".editorconfig", ".npmrc"]); }); }); + +describe("runSync dependency map", () => { + const dockerPayload: PayloadHandle = { + version: "1.0.0", + async read(source) { + if (!source.startsWith("base/Dockerfile")) { + throw new Error(`missing fixture source: ${source}`); + } + return "ARG PRISMA_VERSION=${PRISMA}\n"; + }, + async list() { + return []; + }, + }; + const renderDef = { placeholders: { PRISMA: { configPath: "docker.prismaVersion", fromDependency: "prisma", default: "6.19.1" } } }; + const DOCKER_FILES: ManagedFile[] = [ + { path: "Dockerfile", strategy: "full", source: "base/Dockerfile", renderDef }, + { path: "Dockerfile.worker", strategy: "full", source: "base/Dockerfile.worker", renderDef }, + ]; + + it("writes the pin-derived value", async () => { + await writeFile(join(cwd, "package.json"), JSON.stringify({ name: "consumer", devDependencies: { prisma: "^6.19.3" } })); + await runSync({ cwd, payload: dockerPayload, config, managedFiles: DOCKER_FILES }); + expect(await readFile(join(cwd, "Dockerfile"), "utf8")).toBe("ARG PRISMA_VERSION=6.19.3\n"); + expect(await readFile(join(cwd, "Dockerfile.worker"), "utf8")).toBe("ARG PRISMA_VERSION=6.19.3\n"); + }); + + // The map is read once per run, not per file: it feeds rendering AND the reconcile, and + // `versionSync: false` only opts out of the latter. + it("still derives the value when versionSync is off", async () => { + await writeFile(join(cwd, "package.json"), JSON.stringify({ name: "consumer", devDependencies: { prisma: "^6.19.3" } })); + await runSync({ cwd, payload: dockerPayload, config: { ...config, versionSync: false }, managedFiles: DOCKER_FILES }); + expect(await readFile(join(cwd, "Dockerfile"), "utf8")).toBe("ARG PRISMA_VERSION=6.19.3\n"); + }); + + it("refuses to write anything when package.json is malformed", async () => { + await writeFile(join(cwd, "package.json"), "{ not json"); + const error = await runSync({ cwd, payload: dockerPayload, config, managedFiles: DOCKER_FILES }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(StreamctlError); + expect((error as StreamctlError).code).toBe("CONFIG_INVALID"); + expect(await exists("Dockerfile")).toBe(false); + }); +});