From f46010472b566c90f37427aa1754ae062b770f17 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Tue, 13 Oct 2026 09:57:21 +0200 Subject: [PATCH 1/6] feat(manifest): drop the line of an empty placeholder with omitLineIfEmpty A placeholder on its own line that resolves to "" left an empty line behind. Payload templates had to place each optional hook where a blank line would be, and a hook that missed this added stray blank lines to every consumer's file, showing up as whitespace-only diffs on upgrade. With omitLineIfEmpty: true, a line holding only the token is removed when the value is empty. Opt-in per placeholder, so existing renders stay byte-identical. Needs manifest schemaVersion 4; 2 and 3 still parse, and the loader refuses the key under an older version so a forgotten bump fails loud. --- docs/release.md | 27 ++++++----------------- src/engine/manifest.ts | 23 +++++++++++++------- src/engine/render.ts | 19 +++++++++++++++- src/manifest/schema.ts | 16 +++++++++++--- test/manifest-loader.test.ts | 42 +++++++++++++++++++++++++++++++----- test/manifest-schema.test.ts | 9 ++++++-- test/render-generic.test.ts | 41 +++++++++++++++++++++++++++++++++++ 7 files changed, 138 insertions(+), 39 deletions(-) diff --git a/docs/release.md b/docs/release.md index 7f0f67d..0eba51a 100644 --- a/docs/release.md +++ b/docs/release.md @@ -51,7 +51,7 @@ Two things worth knowing: ## Versioning What couples the CLI to a payload is the manifest's `schemaVersion` (currently -`3`), not the package version. A CLI accepts a set of versions — `{2, 3}` today — +`4`), not the package version. A CLI accepts a set of versions — `{2, 3, 4}` 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 @@ -70,22 +70,9 @@ anything that moves that envelope, the exit codes, or `schemaVersion`. Generated notes only cover commit subjects, so add these by hand. -- **The default config location moved** to `streamctl.config.ts` in the repo - root. `init` writes it there. -- **`.streamctl/config.*` keeps working, permanently.** Not deprecated, no - warning, no removal planned. Existing repos need to do nothing. -- **One break:** `.config/.streamctl/config.ts` used to resolve through c12's - `.config/` convention and now raises `NOT_INITIALIZED`. Fix: - - ```sh - git mv .config/.streamctl/config.ts streamctl.config.ts - ``` - - Nothing else under `.config/` was ever read. Realistically nobody is affected: - the layout is undocumented, and it only ever worked on trees resolving - c12 >= 3.2.0. -- **New warning:** two extensions at one location (`streamctl.config.js` beside - `streamctl.config.ts`) now warn on stderr which one is being read, since c12 - orders `.js` ahead of `.ts`. Nothing is read differently than before, so a repo - that sees it needs no migration. -- Minor bump: new default, no removals. +- **Manifest schemaVersion 4.** A placeholder can set `omitLineIfEmpty: true`. When + its value is `""`, a line holding only the token is removed instead of rendering + as an empty line, so an unused hook leaves no trace in the output. +- Versions 2 and 3 still parse, so existing payloads need nothing. A payload that + uses the key must declare `schemaVersion: 4` and needs this CLI or newer. +- Minor bump: new schema version, no removals. diff --git a/src/engine/manifest.ts b/src/engine/manifest.ts index 7e45898..b2502dc 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 { FROM_DEPENDENCY_MIN_SCHEMA_VERSION, payloadManifestSchema, presetManifestSchema, SUPPORTED_SCHEMA_VERSION_LIST, SUPPORTED_SCHEMA_VERSIONS, zodToIssues } from "../manifest/schema"; +import { FROM_DEPENDENCY_MIN_SCHEMA_VERSION, OMIT_LINE_MIN_SCHEMA_VERSION, payloadManifestSchema, presetManifestSchema, SUPPORTED_SCHEMA_VERSION_LIST, SUPPORTED_SCHEMA_VERSIONS, zodToIssues } from "../manifest/schema"; const PAYLOAD_MANIFEST = "presets/manifest.json"; @@ -102,17 +102,24 @@ export async function loadPresetManifest(payload: PayloadHandle, name: string, m } } - // Feature gate. zod strictness only stops `fromDependency` on an OLD CLI; without this - // check a payload author who forgets the schemaVersion bump ships a payload this CLI - // accepts and every older one rejects with a raw zod wall. - if (manifest.schemaVersion < FROM_DEPENDENCY_MIN_SCHEMA_VERSION) { + // Feature gates. zod strictness only stops a new key on an OLD CLI; without this check a + // payload author who forgets the schemaVersion bump ships a payload this CLI accepts and + // every older one rejects with a raw zod wall. + const gates = [ + { feature: "fromDependency", required: FROM_DEPENDENCY_MIN_SCHEMA_VERSION }, + { feature: "omitLineIfEmpty", required: OMIT_LINE_MIN_SCHEMA_VERSION }, + ] as const; + for (const { feature, required } of gates) { + if (manifest.schemaVersion >= required) { + continue; + } for (const [render, def] of Object.entries(preset.renders ?? {})) { for (const [key, placeholder] of Object.entries(def.placeholders ?? {})) { - if (placeholder.fromDependency !== undefined) { + if (placeholder[feature] !== undefined) { throw new StreamctlError( "CONFIG_INVALID", - `${file} renders.${render} placeholder "${key}" uses fromDependency, which requires schemaVersion ${FROM_DEPENDENCY_MIN_SCHEMA_VERSION}, but ${PAYLOAD_MANIFEST} declares schemaVersion ${manifest.schemaVersion}.`, - { file, preset: name, render, placeholder: key, found: manifest.schemaVersion, required: FROM_DEPENDENCY_MIN_SCHEMA_VERSION }, + `${file} renders.${render} placeholder "${key}" uses ${feature}, which requires schemaVersion ${required}, but ${PAYLOAD_MANIFEST} declares schemaVersion ${manifest.schemaVersion}.`, + { file, preset: name, render, placeholder: key, found: manifest.schemaVersion, required }, ); } } diff --git a/src/engine/render.ts b/src/engine/render.ts index 7f08465..6f9c4aa 100644 --- a/src/engine/render.ts +++ b/src/engine/render.ts @@ -145,6 +145,14 @@ function unresolvedTokens(text: string, values: ReadonlyMap, pas return names.sort().map(name => `\${${name}}`); } +/** Removes every line whose only content (besides spaces, tabs and a CR) is `token`. */ +function dropTokenLines(text: string, token: string): string { + return text + .split("\n") + .filter(line => line.replace(/^[ \t]+|[ \t\r]+$/g, "") !== token) + .join("\n"); +} + /** * Assemble fragments in declared order (`toggle` includes when true, `forEach` * repeats per `string[]` item as `${ITEM}`), then substitute `${KEY}` placeholders @@ -195,6 +203,7 @@ export function renderFile( // 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(); + const omitKeys = new Set(); 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)) { @@ -205,6 +214,9 @@ export function renderFile( ); } values.set(key, value); + if (def.omitLineIfEmpty === true && value === "") { + omitKeys.add(key); + } } const passthrough = new Set(renderDef.passthrough ?? []); @@ -214,7 +226,12 @@ export function renderFile( const substitutePass = (text: string): string => { let next = text; for (const [key, value] of values) { - next = next.replaceAll(`\${${key}}`, () => value); + const token = `\${${key}}`; + // Per key, so a token brought in by an earlier key's value in this pass is caught too. + if (omitKeys.has(key)) { + next = dropTokenLines(next, token); + } + next = next.replaceAll(token, () => value); } return next; }; diff --git a/src/manifest/schema.ts b/src/manifest/schema.ts index 61c192c..e3fb71d 100644 --- a/src/manifest/schema.ts +++ b/src/manifest/schema.ts @@ -18,13 +18,14 @@ const LEGACY_CONFIG_DIR = ".streamctl"; * 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; +export const SUPPORTED_SCHEMA_VERSION = 4; /** * Every version this CLI accepts; anything else maps to `SCHEMA_UNSUPPORTED`. v2 payloads - * keep working unchanged, v3 additionally allows placeholder `fromDependency`. + * keep working unchanged, v3 additionally allows placeholder `fromDependency`, v4 + * placeholder `omitLineIfEmpty`. */ -export const SUPPORTED_SCHEMA_VERSIONS: ReadonlySet = new Set([2, SUPPORTED_SCHEMA_VERSION]); +export const SUPPORTED_SCHEMA_VERSIONS: ReadonlySet = new Set([2, 3, 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(", "); @@ -32,6 +33,9 @@ export const SUPPORTED_SCHEMA_VERSION_LIST = [...SUPPORTED_SCHEMA_VERSIONS].sort /** Lowest `schemaVersion` a payload may declare while using placeholder `fromDependency`. */ export const FROM_DEPENDENCY_MIN_SCHEMA_VERSION = 3; +/** Lowest `schemaVersion` a payload may declare while using placeholder `omitLineIfEmpty`. */ +export const OMIT_LINE_MIN_SCHEMA_VERSION = 4; + /** 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; export type ConfigKeyType = (typeof CONFIG_KEY_TYPES)[number]; @@ -44,6 +48,11 @@ export type ConfigKeyType = (typeof CONFIG_KEY_TYPES)[number]; * `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`. + * + * `omitLineIfEmpty` removes a line holding only this token (plus spaces/tabs) when the + * value is `""`, so an unused hook leaves no blank line behind. A token sharing its line + * with other text substitutes as usual. Requires `schemaVersion` + * {@link OMIT_LINE_MIN_SCHEMA_VERSION}. */ export const placeholderSchema = z.strictObject({ configPath: z.string().min(1), @@ -51,6 +60,7 @@ export const placeholderSchema = z.strictObject({ default: z.string(), pattern: z.string().min(1).optional(), join: z.enum(["space", "lines"]).optional(), + omitLineIfEmpty: z.boolean().optional(), }); /** diff --git a/test/manifest-loader.test.ts b/test/manifest-loader.test.ts index 91c5a5e..3ab989b 100644 --- a/test/manifest-loader.test.ts +++ b/test/manifest-loader.test.ts @@ -87,19 +87,25 @@ describe("schemaVersion contract", () => { expect(manifest.schemaVersion).toBe(3); }); + it("accepts a v4 payload", async () => { + const payload = payloadOf({ "manifest.json": { schemaVersion: 4, presets: ["base"], profiles: [], defaultBase: "base" } }); + const manifest = await loadPayloadManifest(payload); + expect(manifest.schemaVersion).toBe(4); + }); + // 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 payload = payloadOf({ "manifest.json": { schemaVersion: 5, presets: ["base"], profiles: [], defaultBase: "base" } }); const error = await caught(loadPayloadManifest(payload)); expect(error.code).toBe("SCHEMA_UNSUPPORTED"); - expect(error.message).toContain("schemaVersion 4"); - expect(error.message).toContain("schemaVersion 2, 3"); + expect(error.message).toContain("schemaVersion 5"); + expect(error.message).toContain("schemaVersion 2, 3, 4"); expect(error.message).toContain("Upgrade the CLI or the payload"); - expect(error.details).toMatchObject({ found: 4, supported: [2, 3] }); + expect(error.details).toMatchObject({ found: 5, supported: [2, 3, 4] }); }); it("surfaces through resolvePresetChain too", async () => { - const payload = payloadOf({ "manifest.json": { schemaVersion: 4, presets: ["base"], profiles: [], defaultBase: "base" } }); + const payload = payloadOf({ "manifest.json": { schemaVersion: 5, presets: ["base"], profiles: [], defaultBase: "base" } }); const error = await caught(resolvePresetChain(payload, "base", "nuxt-4")); expect(error.code).toBe("SCHEMA_UNSUPPORTED"); }); @@ -149,6 +155,32 @@ describe("fromDependency feature gate", () => { }); }); +describe("omitLineIfEmpty feature gate", () => { + function withOmitLine(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: { HOOK: { configPath: "docker.hook", default: "", omitLineIfEmpty: true } } } }, + configKeys: { "docker.hook": "string" }, + }, + }); + } + + it("lets a v4 payload declare it", async () => { + const { files } = await resolvePresetChain(withOmitLine(4), "base", "nuxt-4"); + expect(files.find(f => f.path === "Dockerfile")?.renderDef?.placeholders?.HOOK?.omitLineIfEmpty).toBe(true); + }); + + it("refuses it under a v3 payload, naming the placeholder and the version it needs", async () => { + const error = await caught(resolvePresetChain(withOmitLine(3), "base", "nuxt-4")); + expect(error.code).toBe("CONFIG_INVALID"); + expect(error.message).toContain("\"HOOK\" uses omitLineIfEmpty, which requires schemaVersion 4"); + expect(error.details).toMatchObject({ file: "presets/base/preset.json", render: "dockerfile", placeholder: "HOOK", found: 3, required: 4 }); + }); +}); + 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 9bada7c..423907a 100644 --- a/test/manifest-schema.test.ts +++ b/test/manifest-schema.test.ts @@ -238,6 +238,11 @@ describe("RenderDef", () => { expect(renderDefSchema.safeParse({ placeholders: { X: { configPath: "a", fromDependency: 1, default: "" } } }).success).toBe(false); }); + it("accepts a boolean omitLineIfEmpty and rejects anything else", () => { + expect(renderDefSchema.safeParse({ placeholders: { X: { configPath: "a", default: "", omitLineIfEmpty: true } } }).success).toBe(true); + expect(renderDefSchema.safeParse({ placeholders: { X: { configPath: "a", default: "", omitLineIfEmpty: "yes" } } }).success).toBe(false); + }); + it("accepts a passthrough token list", () => { expect(renderDefSchema.safeParse({ passthrough: ["PRISMA_VERSION", "OTHER"] }).success).toBe(true); }); @@ -292,14 +297,14 @@ describe("PayloadManifest schemaVersion contract", () => { // 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_VERSION).toBe(4); expect(SUPPORTED_SCHEMA_VERSIONS.has(SUPPORTED_SCHEMA_VERSION)).toBe(true); }); // 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: 4 }).success).toBe(false); + expect(payloadManifestSchema.safeParse({ ...payload(), schemaVersion: 5 }).success).toBe(false); expect(payloadManifestSchema.safeParse({ ...payload(), schemaVersion: 2.5 }).success).toBe(false); }); diff --git a/test/render-generic.test.ts b/test/render-generic.test.ts index d1d3de1..7654933 100644 --- a/test/render-generic.test.ts +++ b/test/render-generic.test.ts @@ -249,6 +249,47 @@ describe("renderFile: nested placeholder tokens", () => { }); }); +describe("renderFile: omitLineIfEmpty", () => { + const def = (omitLineIfEmpty?: boolean): RenderDef => ({ placeholders: { HOOK: { configPath: "docker.hook", default: "", omitLineIfEmpty } } }); + const source = "FROM a\n${HOOK}\n\nCOPY b\n"; + + it("drops the whole line when the value is empty", () => { + expect(renderFile(source, def(true), cfg(), {})).toBe("FROM a\n\nCOPY b\n"); + }); + + it("keeps the line when the value is set", () => { + expect(renderFile(source, def(true), cfg({ docker: { hook: "RUN x" } }), {})).toBe("FROM a\nRUN x\n\nCOPY b\n"); + }); + + it("leaves an empty line without the flag, as before", () => { + expect(renderFile(source, def(), cfg(), {})).toBe("FROM a\n\n\nCOPY b\n"); + expect(renderFile(source, def(false), cfg(), {})).toBe("FROM a\n\n\nCOPY b\n"); + }); + + it("drops an indented token line, CRLF included", () => { + expect(renderFile("a:\n ${HOOK} \nb\n", def(true), cfg(), {})).toBe("a:\nb\n"); + expect(renderFile("a\r\n\t${HOOK}\r\nb\r\n", def(true), cfg(), {})).toBe("a\r\nb\r\n"); + }); + + it("substitutes a token that shares its line with other text", () => { + expect(renderFile("RUN a ${HOOK}\n", def(true), cfg(), {})).toBe("RUN a \n"); + }); + + it("drops the first and the last line too", () => { + expect(renderFile("${HOOK}\na\n${HOOK}\n", def(true), cfg(), {})).toBe("a\n"); + }); + + it("drops a token line that only appears inside another placeholder's value", () => { + const nested: RenderDef = { + placeholders: { + OUTER: { configPath: "docker.outer", default: "RUN a\n${HOOK}\nRUN b" }, + HOOK: { configPath: "docker.hook", default: "", omitLineIfEmpty: true }, + }, + }; + expect(renderFile("${OUTER}\n", nested, cfg(), {})).toBe("RUN a\nRUN b\n"); + }); +}); + describe("renderFile: fragments", () => { const frags = { a: "FRAG-A", b: "FRAG-B", c: "FRAG-C" }; const def: RenderDef = { From 76763c015ff8448027d8194208e4be3bca04e982 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Tue, 13 Oct 2026 10:38:53 +0200 Subject: [PATCH 2/6] test(manifest): drop redundant omitLineIfEmpty tests --- test/manifest-loader.test.ts | 6 ------ test/manifest-schema.test.ts | 5 ----- test/render-generic.test.ts | 5 ----- 3 files changed, 16 deletions(-) diff --git a/test/manifest-loader.test.ts b/test/manifest-loader.test.ts index 3ab989b..4b5d060 100644 --- a/test/manifest-loader.test.ts +++ b/test/manifest-loader.test.ts @@ -87,12 +87,6 @@ describe("schemaVersion contract", () => { expect(manifest.schemaVersion).toBe(3); }); - it("accepts a v4 payload", async () => { - const payload = payloadOf({ "manifest.json": { schemaVersion: 4, presets: ["base"], profiles: [], defaultBase: "base" } }); - const manifest = await loadPayloadManifest(payload); - expect(manifest.schemaVersion).toBe(4); - }); - // 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: 5, presets: ["base"], profiles: [], defaultBase: "base" } }); diff --git a/test/manifest-schema.test.ts b/test/manifest-schema.test.ts index 423907a..1d7d766 100644 --- a/test/manifest-schema.test.ts +++ b/test/manifest-schema.test.ts @@ -238,11 +238,6 @@ describe("RenderDef", () => { expect(renderDefSchema.safeParse({ placeholders: { X: { configPath: "a", fromDependency: 1, default: "" } } }).success).toBe(false); }); - it("accepts a boolean omitLineIfEmpty and rejects anything else", () => { - expect(renderDefSchema.safeParse({ placeholders: { X: { configPath: "a", default: "", omitLineIfEmpty: true } } }).success).toBe(true); - expect(renderDefSchema.safeParse({ placeholders: { X: { configPath: "a", default: "", omitLineIfEmpty: "yes" } } }).success).toBe(false); - }); - it("accepts a passthrough token list", () => { expect(renderDefSchema.safeParse({ passthrough: ["PRISMA_VERSION", "OTHER"] }).success).toBe(true); }); diff --git a/test/render-generic.test.ts b/test/render-generic.test.ts index 7654933..328a28d 100644 --- a/test/render-generic.test.ts +++ b/test/render-generic.test.ts @@ -263,7 +263,6 @@ describe("renderFile: omitLineIfEmpty", () => { it("leaves an empty line without the flag, as before", () => { expect(renderFile(source, def(), cfg(), {})).toBe("FROM a\n\n\nCOPY b\n"); - expect(renderFile(source, def(false), cfg(), {})).toBe("FROM a\n\n\nCOPY b\n"); }); it("drops an indented token line, CRLF included", () => { @@ -275,10 +274,6 @@ describe("renderFile: omitLineIfEmpty", () => { expect(renderFile("RUN a ${HOOK}\n", def(true), cfg(), {})).toBe("RUN a \n"); }); - it("drops the first and the last line too", () => { - expect(renderFile("${HOOK}\na\n${HOOK}\n", def(true), cfg(), {})).toBe("a\n"); - }); - it("drops a token line that only appears inside another placeholder's value", () => { const nested: RenderDef = { placeholders: { From 45d6e8ad1819fc7e646303e6721fcf70eebd7c38 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Tue, 13 Oct 2026 10:38:53 +0200 Subject: [PATCH 3/6] docs(release): tighten omitLineIfEmpty release notes --- docs/release.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/release.md b/docs/release.md index 0eba51a..8c61dba 100644 --- a/docs/release.md +++ b/docs/release.md @@ -70,9 +70,8 @@ anything that moves that envelope, the exit codes, or `schemaVersion`. Generated notes only cover commit subjects, so add these by hand. -- **Manifest schemaVersion 4.** A placeholder can set `omitLineIfEmpty: true`. When - its value is `""`, a line holding only the token is removed instead of rendering - as an empty line, so an unused hook leaves no trace in the output. -- Versions 2 and 3 still parse, so existing payloads need nothing. A payload that - uses the key must declare `schemaVersion: 4` and needs this CLI or newer. +- **Manifest schemaVersion 4.** With placeholder `omitLineIfEmpty: true`, a line + holding only the token is removed when the value is `""` instead of left blank. +- v2 and v3 payloads parse unchanged. Using the key needs `schemaVersion: 4` and + this CLI or newer. - Minor bump: new schema version, no removals. From a098be124441c20f198a9bf7c3cda001311af4f9 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Tue, 13 Oct 2026 10:52:51 +0200 Subject: [PATCH 4/6] fix(render): drop omitLineIfEmpty lines after other placeholders render --- src/engine/render.ts | 19 ++++++++++--------- test/render-generic.test.ts | 7 +++++++ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/engine/render.ts b/src/engine/render.ts index 6f9c4aa..5699c85 100644 --- a/src/engine/render.ts +++ b/src/engine/render.ts @@ -203,7 +203,7 @@ export function renderFile( // 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(); - const omitKeys = new Set(); + const omitTokens: string[] = []; 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)) { @@ -213,10 +213,11 @@ export function renderFile( { file: filePath, placeholder: key, value }, ); } - values.set(key, value); if (def.omitLineIfEmpty === true && value === "") { - omitKeys.add(key); + omitTokens.push(`\${${key}}`); + continue; } + values.set(key, value); } const passthrough = new Set(renderDef.passthrough ?? []); @@ -226,12 +227,7 @@ export function renderFile( const substitutePass = (text: string): string => { let next = text; for (const [key, value] of values) { - const token = `\${${key}}`; - // Per key, so a token brought in by an earlier key's value in this pass is caught too. - if (omitKeys.has(key)) { - next = dropTokenLines(next, token); - } - next = next.replaceAll(token, () => value); + next = next.replaceAll(`\${${key}}`, () => value); } return next; }; @@ -252,6 +248,11 @@ export function renderFile( } } + // After every other value is in, so a flagged token's line is judged as rendered. + for (const token of omitTokens) { + output = dropTokenLines(output, token).replaceAll(token, ""); + } + 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 328a28d..e2bc3b9 100644 --- a/test/render-generic.test.ts +++ b/test/render-generic.test.ts @@ -283,6 +283,13 @@ describe("renderFile: omitLineIfEmpty", () => { }; expect(renderFile("${OUTER}\n", nested, cfg(), {})).toBe("RUN a\nRUN b\n"); }); + + it("does not depend on placeholder declaration order", () => { + const prefix = { configPath: "docker.prefix", default: "RUN a\n" }; + const hook = { configPath: "docker.hook", default: "", omitLineIfEmpty: true }; + expect(renderFile("${PREFIX}${HOOK}\n", { placeholders: { PREFIX: prefix, HOOK: hook } }, cfg(), {})).toBe("RUN a\n"); + expect(renderFile("${PREFIX}${HOOK}\n", { placeholders: { HOOK: hook, PREFIX: prefix } }, cfg(), {})).toBe("RUN a\n"); + }); }); describe("renderFile: fragments", () => { From 06b01efaec8153cc5ec019a417c91bf71444c09f Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Tue, 13 Oct 2026 10:57:38 +0200 Subject: [PATCH 5/6] refactor(render): drop redundant nested omitLineIfEmpty test and tighten comments --- src/engine/render.ts | 2 +- src/manifest/schema.ts | 7 +++---- test/render-generic.test.ts | 10 ---------- 3 files changed, 4 insertions(+), 15 deletions(-) diff --git a/src/engine/render.ts b/src/engine/render.ts index 5699c85..3bf9b1f 100644 --- a/src/engine/render.ts +++ b/src/engine/render.ts @@ -248,7 +248,7 @@ export function renderFile( } } - // After every other value is in, so a flagged token's line is judged as rendered. + // Last, so "alone on its line" is judged on the rendered text. for (const token of omitTokens) { output = dropTokenLines(output, token).replaceAll(token, ""); } diff --git a/src/manifest/schema.ts b/src/manifest/schema.ts index e3fb71d..45b8d13 100644 --- a/src/manifest/schema.ts +++ b/src/manifest/schema.ts @@ -49,10 +49,9 @@ export type ConfigKeyType = (typeof CONFIG_KEY_TYPES)[number]; * the value (stripped to its range floor) when `configPath` yields nothing. Requires payload * `schemaVersion` {@link FROM_DEPENDENCY_MIN_SCHEMA_VERSION}, enforced in `loadPresetManifest`. * - * `omitLineIfEmpty` removes a line holding only this token (plus spaces/tabs) when the - * value is `""`, so an unused hook leaves no blank line behind. A token sharing its line - * with other text substitutes as usual. Requires `schemaVersion` - * {@link OMIT_LINE_MIN_SCHEMA_VERSION}. + * `omitLineIfEmpty`: when the value is `""`, a line holding only this token (plus + * spaces/tabs) after all other placeholders are substituted is removed; any other occurrence + * becomes `""`. Requires `schemaVersion` {@link OMIT_LINE_MIN_SCHEMA_VERSION}. */ export const placeholderSchema = z.strictObject({ configPath: z.string().min(1), diff --git a/test/render-generic.test.ts b/test/render-generic.test.ts index e2bc3b9..14b37a9 100644 --- a/test/render-generic.test.ts +++ b/test/render-generic.test.ts @@ -274,16 +274,6 @@ describe("renderFile: omitLineIfEmpty", () => { expect(renderFile("RUN a ${HOOK}\n", def(true), cfg(), {})).toBe("RUN a \n"); }); - it("drops a token line that only appears inside another placeholder's value", () => { - const nested: RenderDef = { - placeholders: { - OUTER: { configPath: "docker.outer", default: "RUN a\n${HOOK}\nRUN b" }, - HOOK: { configPath: "docker.hook", default: "", omitLineIfEmpty: true }, - }, - }; - expect(renderFile("${OUTER}\n", nested, cfg(), {})).toBe("RUN a\nRUN b\n"); - }); - it("does not depend on placeholder declaration order", () => { const prefix = { configPath: "docker.prefix", default: "RUN a\n" }; const hook = { configPath: "docker.hook", default: "", omitLineIfEmpty: true }; From ac3314682a3976fd24625c294ca1a9fff6896568 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Tue, 13 Oct 2026 11:10:10 +0200 Subject: [PATCH 6/6] fix(render): judge omitLineIfEmpty lines against all flagged tokens at once --- src/engine/render.ts | 13 +++++++------ src/manifest/schema.ts | 2 +- test/render-generic.test.ts | 8 ++++++++ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/engine/render.ts b/src/engine/render.ts index 3bf9b1f..0dc72e5 100644 --- a/src/engine/render.ts +++ b/src/engine/render.ts @@ -145,11 +145,14 @@ function unresolvedTokens(text: string, values: ReadonlyMap, pas return names.sort().map(name => `\${${name}}`); } -/** Removes every line whose only content (besides spaces, tabs and a CR) is `token`. */ -function dropTokenLines(text: string, token: string): string { +/** Removes every line holding at least one of `tokens` and nothing else but spaces, tabs and a CR. */ +function dropTokenLines(text: string, tokens: readonly string[]): string { return text .split("\n") - .filter(line => line.replace(/^[ \t]+|[ \t\r]+$/g, "") !== token) + .filter((line) => { + const rest = tokens.reduce((acc, token) => acc.replaceAll(token, ""), line); + return rest === line || /[^ \t\r]/.test(rest); + }) .join("\n"); } @@ -249,9 +252,7 @@ export function renderFile( } // Last, so "alone on its line" is judged on the rendered text. - for (const token of omitTokens) { - output = dropTokenLines(output, token).replaceAll(token, ""); - } + output = omitTokens.reduce((acc, token) => acc.replaceAll(token, ""), dropTokenLines(output, omitTokens)); for (const match of output.matchAll(LEFTOVER_TOKEN_RE)) { const token = match[0]; diff --git a/src/manifest/schema.ts b/src/manifest/schema.ts index 45b8d13..7cd9b3c 100644 --- a/src/manifest/schema.ts +++ b/src/manifest/schema.ts @@ -49,7 +49,7 @@ export type ConfigKeyType = (typeof CONFIG_KEY_TYPES)[number]; * the value (stripped to its range floor) when `configPath` yields nothing. Requires payload * `schemaVersion` {@link FROM_DEPENDENCY_MIN_SCHEMA_VERSION}, enforced in `loadPresetManifest`. * - * `omitLineIfEmpty`: when the value is `""`, a line holding only this token (plus + * `omitLineIfEmpty`: when the value is `""`, a line holding only such tokens (plus * spaces/tabs) after all other placeholders are substituted is removed; any other occurrence * becomes `""`. Requires `schemaVersion` {@link OMIT_LINE_MIN_SCHEMA_VERSION}. */ diff --git a/test/render-generic.test.ts b/test/render-generic.test.ts index 14b37a9..75024a7 100644 --- a/test/render-generic.test.ts +++ b/test/render-generic.test.ts @@ -274,6 +274,14 @@ describe("renderFile: omitLineIfEmpty", () => { expect(renderFile("RUN a ${HOOK}\n", def(true), cfg(), {})).toBe("RUN a \n"); }); + it("drops a line of several flagged tokens, in any order", () => { + const a = { configPath: "docker.a", default: "", omitLineIfEmpty: true }; + const b = { configPath: "docker.b", default: "", omitLineIfEmpty: true }; + const source = "before\n${A}${B}${A}\nafter\n"; + expect(renderFile(source, { placeholders: { A: a, B: b } }, cfg(), {})).toBe("before\nafter\n"); + expect(renderFile(source, { placeholders: { B: b, A: a } }, cfg(), {})).toBe("before\nafter\n"); + }); + it("does not depend on placeholder declaration order", () => { const prefix = { configPath: "docker.prefix", default: "RUN a\n" }; const hook = { configPath: "docker.hook", default: "", omitLineIfEmpty: true };