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/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/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(){}\\'"*?[\]]/; -/** 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,12 +98,53 @@ 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; } /** 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 @@ -86,6 +152,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 +162,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 ?? []) { @@ -122,8 +192,11 @@ 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); + const value = resolvePlaceholder(def, config, key, filePath, deps); if (def.pattern !== undefined && !new RegExp(def.pattern).test(value)) { throw new StreamctlError( "CONFIG_INVALID", @@ -131,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/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 e8176ba..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 { @@ -359,8 +383,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/src/manifest/schema.ts b/src/manifest/schema.ts index 073b5e9..61c192c 100644 --- a/src/manifest/schema.ts +++ b/src/manifest/schema.ts @@ -13,8 +13,24 @@ import { isStructuredPath } from "../paths"; const CONFIG_STEM = "streamctl.config"; const LEGACY_CONFIG_DIR = ".streamctl"; -/** A payload declaring any other `schemaVersion` maps to `SCHEMA_UNSUPPORTED`. Manifest and package versions are otherwise independent. */ -export const SUPPORTED_SCHEMA_VERSION = 2; +/** + * The current manifest schema version — what a new payload should declare. Kept under this + * name because base-config's `scripts/validate-presets.mjs` imports it from + * `@sidebase/streamctl/manifest`. Manifest and package versions are otherwise independent. + */ +export const SUPPORTED_SCHEMA_VERSION = 3; + +/** + * Every version this CLI accepts; anything else maps to `SCHEMA_UNSUPPORTED`. v2 payloads + * keep working unchanged, v3 additionally allows placeholder `fromDependency`. + */ +export const SUPPORTED_SCHEMA_VERSIONS: ReadonlySet = 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/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/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", () => { diff --git a/test/render-generic.test.ts b/test/render-generic.test.ts index 1f3d3f4..d1d3de1 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,154 @@ 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: 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 = { 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); + }); +}); 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" });