From 93f7f5fb6474f2cd9a977453ae75ef01253e7834 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 19:20:30 +0200 Subject: [PATCH 01/32] refactor: move commentSyntaxFor into pure top-level comment-syntax module --- src/comment-syntax.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ src/engine/block.ts | 41 ++--------------------------------------- 2 files changed, 45 insertions(+), 39 deletions(-) create mode 100644 src/comment-syntax.ts diff --git a/src/comment-syntax.ts b/src/comment-syntax.ts new file mode 100644 index 0000000..ae5ff67 --- /dev/null +++ b/src/comment-syntax.ts @@ -0,0 +1,43 @@ +import { basename, extname } from "node:path"; + +// Pure comment-syntax map importing only `node:path`, so both `engine/*` and the +// public `manifest/*` subpath (directive parser) can use it without engine imports. + +/** Marker comment delimiters for a file format. Line comments leave `suffix` empty. */ +export interface CommentSyntax { + prefix: string; + suffix: string; +} + +const HASH: CommentSyntax = { prefix: "# ", suffix: "" }; +const SLASH: CommentSyntax = { prefix: "// ", suffix: "" }; +const HTML: CommentSyntax = { prefix: "" }; + +export function commentSyntaxFor(path: string): CommentSyntax { + const base = basename(path).toLowerCase(); + if (base === "dockerfile") { + return HASH; + } + switch (extname(base)) { + case ".js": + case ".mjs": + case ".cjs": + case ".ts": + case ".mts": + case ".cts": + case ".json": + case ".jsonc": + case ".json5": + // JSONC-tolerant config files (oxlintrc/tsconfig) use `//`, never `#`. + return SLASH; + case ".html": + case ".htm": + case ".md": + case ".markdown": + case ".vue": + return HTML; + // `#` covers npmrc, gitignore, editorconfig, yaml/yml, toml, ini, conf, sh. + default: + return HASH; + } +} diff --git a/src/engine/block.ts b/src/engine/block.ts index bad6d7d..1f099d1 100644 --- a/src/engine/block.ts +++ b/src/engine/block.ts @@ -1,43 +1,6 @@ -import { basename, extname } from "node:path"; +import { commentSyntaxFor } from "../comment-syntax"; -/** Marker comment delimiters for a file format. Line comments leave `suffix` empty. */ -interface CommentSyntax { - prefix: string; - suffix: string; -} - -const HASH: CommentSyntax = { prefix: "# ", suffix: "" }; -const SLASH: CommentSyntax = { prefix: "// ", suffix: "" }; -const HTML: CommentSyntax = { prefix: "" }; - -export function commentSyntaxFor(path: string): CommentSyntax { - const base = basename(path).toLowerCase(); - if (base === "dockerfile") { - return HASH; - } - switch (extname(base)) { - case ".js": - case ".mjs": - case ".cjs": - case ".ts": - case ".mts": - case ".cts": - case ".json": - case ".jsonc": - case ".json5": - // JSONC-tolerant config files (oxlintrc/tsconfig) use `//`, never `#`. - return SLASH; - case ".html": - case ".htm": - case ".md": - case ".markdown": - case ".vue": - return HTML; - // `#` covers npmrc, gitignore, editorconfig, yaml/yml, toml, ini, conf, sh. - default: - return HASH; - } -} +export { commentSyntaxFor }; export type BlockResult = | { ok: true; content: string } From f9dce9fe960284e4166b330109fc883dee56a981 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 19:23:12 +0200 Subject: [PATCH 02/32] feat: add scanDirectives parser for template if/else/end directives --- src/directives.ts | 204 ++++++++++++++++++++++++++++++++++++++++ test/directives.test.ts | 198 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 402 insertions(+) create mode 100644 src/directives.ts create mode 100644 test/directives.test.ts diff --git a/src/directives.ts b/src/directives.ts new file mode 100644 index 0000000..7ec37db --- /dev/null +++ b/src/directives.ts @@ -0,0 +1,204 @@ +import { commentSyntaxFor } from "./comment-syntax"; +import { StreamctlError } from "./errors"; +import { structuredFormat } from "./paths"; + +// Pure template-directive parser importing no engine code, so the public +// `manifest/*` subpath can re-export it. + +export type DirectiveReason + = | "unknown-directive" + | "missing-condition" + | "malformed-condition" + | "unopened-end" + | "unopened-else" + | "duplicate-else" + | "unclosed-if" + | "end-mismatch" + | "else-mismatch" + | "nested-if" + | "indent-mismatch" + | "body-outdented" + | "structured-json" + | "merge-strategy" + | "schema-version" + | "undeclared-condition" + | "object-condition" + | "unevaluable-value"; + +export interface DirectiveErrorDetails { + file: string; + line: number; + reason: DirectiveReason; + directive?: string; + condition?: string; + expected?: string; + openedAt?: number; + found?: string; +} + +export interface DirectiveBlock { + /** Condition path without `!`. */ + condition: string; + negated: boolean; + /** 1-based. */ + ifLine: number; + elseLine?: number; + endLine: number; +} + +type Verb = "if" | "else" | "end"; + +interface DirectiveLine { + line: number; + indent: string; + verb: Verb; + rawCondition: string; +} + +interface OpenBlock { + raw: string; + line: number; + indent: string; + elseLine?: number; +} + +const CONDITION_RE = /^!?[\w-]+(?:\.[\w-]+)*$/; +const RESERVED_VERBS = new Set(["each", "insert", "raw"]); + +function escapeRegExp(value: string): string { + return value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function fail(path: string, line: number, reason: DirectiveReason, message: string, extra: Partial = {}): never { + const details: DirectiveErrorDetails = { file: path, line, reason, ...extra }; + throw new StreamctlError("CONFIG_INVALID", `${path}:${line}: ${message}`, details); +} + +/** Matches any line starting with the file's comment token followed by `streamctl:`. */ +function buildCandidateMatcher(path: string): { candidate: RegExp; suffix: string } { + const { prefix, suffix } = commentSyntaxFor(path); + const token = escapeRegExp(prefix.trim()); + return { candidate: new RegExp(`^([ \\t]*)${token}[ \\t]*streamctl:(.*)$`), suffix: suffix.trim() }; +} + +function isVerb(value: string): value is Verb { + return value === "if" || value === "else" || value === "end"; +} + +function parseDirective(path: string, lineNo: number, indent: string, rawRest: string, suffix: string): DirectiveLine { + let rest = rawRest.trimEnd(); + const hasSuffix = suffix === "" || rest.endsWith(suffix); + if (suffix !== "" && hasSuffix) { + rest = rest.slice(0, -suffix.length).trimEnd(); + } + const verbEnd = rest.search(/\s/); + const verb = verbEnd === -1 ? rest : rest.slice(0, verbEnd); + const words = (verbEnd === -1 ? "" : rest.slice(verbEnd)).split(/\s+/).filter(word => word !== ""); + + if (!isVerb(verb)) { + const hint = RESERVED_VERBS.has(verb) + ? ` "streamctl:${verb}" is reserved for a future version.` + : " Expected streamctl:if, streamctl:else or streamctl:end."; + fail(path, lineNo, "unknown-directive", `unknown directive "streamctl:${verb}".${hint}`, { directive: verb }); + } + if (!hasSuffix) { + fail(path, lineNo, "malformed-condition", `"streamctl:${verb}" must be closed with "${suffix}" on the same line.`, { directive: verb }); + } + const [rawCondition, ...extra] = words; + if (rawCondition === undefined) { + fail(path, lineNo, "missing-condition", `"streamctl:${verb}" requires a condition.`, { directive: verb }); + } + if (extra.length > 0) { + fail(path, lineNo, "malformed-condition", `"streamctl:${verb} ${rawCondition}" is followed by unexpected text "${extra.join(" ")}".`, { directive: verb, condition: rawCondition }); + } + if (!CONDITION_RE.test(rawCondition)) { + fail(path, lineNo, "malformed-condition", `"${rawCondition}" is not a valid condition; use a dotted config path such as "features.prisma", optionally prefixed with "!".`, { directive: verb, condition: rawCondition }); + } + return { line: lineNo, indent, verb, rawCondition }; +} + +/** YAML lint: no non-blank body line may sit left of (or diverge from) the directive indent. */ +function checkYamlBody(path: string, lines: string[], open: OpenBlock, endLine: number): void { + for (let lineNo = open.line + 1; lineNo < endLine; lineNo++) { + if (lineNo === open.elseLine) { + continue; + } + const content = (lines[lineNo - 1] ?? "").replace(/\r$/, ""); + if (content.trim() !== "" && !content.startsWith(open.indent)) { + fail(path, lineNo, "body-outdented", `line is indented less than its "streamctl:if ${open.raw}" directive (opened at line ${open.line}).`, { condition: open.raw, openedAt: open.line }); + } + } +} + +function closeBlock(open: OpenBlock, endLine: number): DirectiveBlock { + const negated = open.raw.startsWith("!"); + const block: DirectiveBlock = { + condition: negated ? open.raw.slice(1) : open.raw, + negated, + ifLine: open.line, + endLine, + }; + if (open.elseLine !== undefined) { + block.elseLine = open.elseLine; + } + return block; +} + +/** Parse and validate every directive in `text`; throws `CONFIG_INVALID` on the first error. */ +export function scanDirectives(text: string, path: string): DirectiveBlock[] { + const { candidate, suffix } = buildCandidateMatcher(path); + const format = structuredFormat(path); + const lines = text.split("\n"); + const blocks: DirectiveBlock[] = []; + let open: OpenBlock | null = null; + + for (const [index, rawLine] of lines.entries()) { + const lineNo = index + 1; + const match = candidate.exec(rawLine.replace(/\r$/, "")); + if (match === null) { + continue; + } + if (format === "json" || format === "json5") { + fail(path, lineNo, "structured-json", "template directives are not supported in JSON files."); + } + const directive = parseDirective(path, lineNo, match[1] ?? "", match[2] ?? "", suffix); + const { verb, rawCondition, indent } = directive; + const extra = { directive: verb, condition: rawCondition }; + + if (verb === "if") { + if (open !== null) { + fail(path, lineNo, "nested-if", `"streamctl:if ${rawCondition}" cannot be nested inside the open block "${open.raw}" (opened at line ${open.line}).`, { ...extra, openedAt: open.line }); + } + open = { raw: rawCondition, line: lineNo, indent }; + continue; + } + + if (open === null) { + fail(path, lineNo, verb === "end" ? "unopened-end" : "unopened-else", `"streamctl:${verb} ${rawCondition}" has no open "streamctl:if" block.`, extra); + } + if (verb === "else" && open.elseLine !== undefined) { + fail(path, lineNo, "duplicate-else", `block "${open.raw}" already has an else branch at line ${open.elseLine} (opened at line ${open.line}).`, { ...extra, openedAt: open.line }); + } + if (rawCondition !== open.raw) { + fail(path, lineNo, verb === "end" ? "end-mismatch" : "else-mismatch", `"streamctl:${verb} ${rawCondition}" does not match the open block "${open.raw}" (opened at line ${open.line}).`, { ...extra, expected: open.raw, openedAt: open.line }); + } + if (indent !== open.indent) { + fail(path, lineNo, "indent-mismatch", `"streamctl:${verb} ${rawCondition}" must use the same indentation as its "streamctl:if" (opened at line ${open.line}).`, { ...extra, openedAt: open.line }); + } + + if (verb === "else") { + open.elseLine = lineNo; + continue; + } + if (format === "yaml") { + checkYamlBody(path, lines, open, lineNo); + } + blocks.push(closeBlock(open, lineNo)); + open = null; + } + + if (open !== null) { + fail(path, open.line, "unclosed-if", `"streamctl:if ${open.raw}" is never closed with "streamctl:end ${open.raw}".`, { directive: "if", condition: open.raw, openedAt: open.line }); + } + return blocks; +} diff --git a/test/directives.test.ts b/test/directives.test.ts new file mode 100644 index 0000000..96a5386 --- /dev/null +++ b/test/directives.test.ts @@ -0,0 +1,198 @@ +import type { DirectiveErrorDetails } from "../src/directives"; +import { describe, expect, it } from "vitest"; +import { scanDirectives } from "../src/directives"; +import { StreamctlError } from "../src/errors"; + +function scanError(text: string, path = "ci.yaml"): { message: string; details: DirectiveErrorDetails } { + try { + scanDirectives(text, path); + } catch (error) { + if (!(error instanceof StreamctlError)) { + throw error; + } + expect(error.code).toBe("CONFIG_INVALID"); + return { message: error.message, details: error.details as DirectiveErrorDetails }; + } + throw new Error("expected scanDirectives to throw"); +} + +describe("scanDirectives: content", () => { + it("returns [] for text without directives", () => { + expect(scanDirectives("a: 1\nb: 2\n", "ci.yaml")).toEqual([]); + expect(scanDirectives("", "ci.yaml")).toEqual([]); + }); + + it("treats a mid-line streamctl: as content", () => { + expect(scanDirectives("run: echo \"# streamctl:if x\"\n", "ci.yaml")).toEqual([]); + }); + + it("ignores directives written with another file's comment token", () => { + expect(scanDirectives("// streamctl:if x\n", "ci.yaml")).toEqual([]); + expect(scanDirectives("# streamctl:if x\n", "app.ts")).toEqual([]); + expect(scanDirectives("# streamctl:if x\n", "README.md")).toEqual([]); + }); + + it("leaves plain JSON templates untouched", () => { + expect(scanDirectives("{\n \"a\": \"# streamctl:if x\"\n}\n", "tsconfig.json")).toEqual([]); + }); +}); + +describe("scanDirectives: blocks", () => { + it("parses an if/end block with # syntax", () => { + expect(scanDirectives("a\n# streamctl:if features.prisma\nb\n# streamctl:end features.prisma\nc\n", "ci.yaml")).toEqual([ + { condition: "features.prisma", negated: false, ifLine: 2, endLine: 4 }, + ]); + }); + + it("parses // syntax", () => { + expect(scanDirectives("// streamctl:if a\nx\n// streamctl:end a\n", "eslint.config.ts")).toEqual([ + { condition: "a", negated: false, ifLine: 1, endLine: 3 }, + ]); + }); + + it("parses syntax", () => { + expect(scanDirectives("\nx\n\n", "README.md")).toEqual([ + { condition: "a", negated: false, ifLine: 1, endLine: 3 }, + ]); + }); + + it("derives negated and condition from a ! prefix", () => { + expect(scanDirectives("# streamctl:if !a.b\n# streamctl:end !a.b\n", ".gitignore")).toEqual([ + { condition: "a.b", negated: true, ifLine: 1, endLine: 2 }, + ]); + }); + + it("records the else line", () => { + expect(scanDirectives("# streamctl:if a\nx\n# streamctl:else a\ny\n# streamctl:end a\n", ".npmrc")).toEqual([ + { condition: "a", negated: false, ifLine: 1, elseLine: 3, endLine: 5 }, + ]); + }); + + it("allows empty bodies and empty else branches", () => { + expect(scanDirectives("# streamctl:if a\n# streamctl:else a\n# streamctl:end a\n", ".npmrc")).toEqual([ + { condition: "a", negated: false, ifLine: 1, elseLine: 2, endLine: 3 }, + ]); + }); + + it("parses several sequential blocks", () => { + const blocks = scanDirectives("# streamctl:if a\n# streamctl:end a\n# streamctl:if b\n# streamctl:end b\n", ".npmrc"); + expect(blocks.map(block => block.condition)).toEqual(["a", "b"]); + }); + + it("tolerates a trailing \\r", () => { + expect(scanDirectives("# streamctl:if a\r\nx\r\n# streamctl:end a\r\n", "ci.yaml")).toEqual([ + { condition: "a", negated: false, ifLine: 1, endLine: 3 }, + ]); + }); + + it("accepts indented directives and tolerates spaces after the token", () => { + expect(scanDirectives(" #streamctl:if a\n x\n # streamctl:end a \n", "ci.yaml")).toHaveLength(1); + }); +}); + +describe("scanDirectives: errors", () => { + it("rejects an unknown verb", () => { + const { details, message } = scanError("# streamctl: the CLI\n"); + expect(details).toMatchObject({ file: "ci.yaml", line: 1, reason: "unknown-directive" }); + expect(message).toMatch(/^ci\.yaml:1: /); + expect(message).not.toContain("reserved"); + }); + + it("hints at reserved verbs", () => { + for (const verb of ["each", "insert", "raw"]) { + const { details, message } = scanError(`# streamctl:${verb} a\n`); + expect(details).toMatchObject({ reason: "unknown-directive", directive: verb }); + expect(message).toContain("reserved for a future version"); + } + }); + + it("rejects a missing condition", () => { + expect(scanError("# streamctl:if\n").details).toMatchObject({ line: 1, reason: "missing-condition" }); + expect(scanError("# streamctl:if a\n# streamctl:end\n").details).toMatchObject({ line: 2, reason: "missing-condition" }); + }); + + it("rejects a malformed condition", () => { + expect(scanError("# streamctl:if a..b\n").details).toMatchObject({ reason: "malformed-condition", condition: "a..b" }); + expect(scanError("# streamctl:if a=b\n").details).toMatchObject({ reason: "malformed-condition" }); + }); + + it("rejects extra tokens", () => { + expect(scanError("# streamctl:if a b\n").details).toMatchObject({ reason: "malformed-condition", condition: "a" }); + }); + + it("rejects a missing --> in HTML-comment files", () => { + expect(scanError("\nb\n\nc", "README.md", always(false))).toBe("c"); + expect(applyDirectives("// streamctl:if x\nb\n// streamctl:end x\nc", "a.ts", always(true))).toBe("b\nc"); + }); + + it("is idempotent", () => { + const once = applyDirectives(ifElse, ".npmrc", always(true)); + expect(applyDirectives(once, ".npmrc", always(false))).toBe(once); + }); + + it("calls evaluate once per block with the bare condition", () => { + const evaluate = vi.fn((path: string) => path === "a"); + const text = "# streamctl:if !a\nx\n# streamctl:end !a\n# streamctl:if b.c\ny\n# streamctl:end b.c\n"; + expect(applyDirectives(text, ".npmrc", evaluate)).toBe(""); + expect(evaluate.mock.calls).toEqual([["a"], ["b.c"]]); + }); + + it("propagates scan errors", () => { + expect(() => applyDirectives("# streamctl:if x\n", ".npmrc", always(true))).toThrow(StreamctlError); + }); + + it("propagates evaluate errors unchanged", () => { + const failure = new Error("boom"); + let caught: unknown; + try { + applyDirectives("# streamctl:if x\n# streamctl:end x\n", ".npmrc", () => { + throw failure; + }); + } catch (error) { + caught = error; + } + expect(caught).toBe(failure); + }); +}); From b2b8caa822e71146ba01c53bea2e0729fc595df0 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 19:27:25 +0200 Subject: [PATCH 04/32] feat(engine): evaluate directive conditions and apply them in compose --- src/directives.ts | 4 +- src/engine/compose.ts | 23 ++++-- src/engine/conditions.ts | 26 ++++++ test/directives.compose.test.ts | 138 ++++++++++++++++++++++++++++++++ 4 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 src/engine/conditions.ts create mode 100644 test/directives.compose.test.ts diff --git a/src/directives.ts b/src/directives.ts index 4434866..3dafc4d 100644 --- a/src/directives.ts +++ b/src/directives.ts @@ -222,9 +222,7 @@ export function applyDirectives(text: string, path: string, evaluate: (condition for (const block of blocks) { const kept = evaluate(block.condition) !== block.negated; const branchEnd = block.elseLine ?? block.endLine; - const [from, to] = kept - ? [block.elseLine ?? block.endLine, block.endLine] - : [block.ifLine, branchEnd]; + const [from, to] = kept ? [branchEnd, block.endLine] : [block.ifLine, branchEnd]; drop.add(block.ifLine - 1); drop.add(block.endLine - 1); if (block.elseLine !== undefined) { diff --git a/src/engine/compose.ts b/src/engine/compose.ts index 040455e..3e5d5bf 100644 --- a/src/engine/compose.ts +++ b/src/engine/compose.ts @@ -1,7 +1,9 @@ import type { ManagedFile, StreamctlConfig } from "../config/types"; import type { PayloadHandle } from "../payload/resolve"; +import { applyDirectives } from "../directives"; import { StreamctlError } from "../errors"; import { composeBlock } from "./block"; +import { evaluateCondition } from "./conditions"; import { composeMerge } from "./merge"; import { renderFile } from "./render"; import { validateStructured } from "./structural"; @@ -40,22 +42,33 @@ function finalizeComposed( return validation.ok ? composed : { status: "structural-error", path, reason: validation.reason }; } -// Fragment sources are read from the payload here so `renderFile` itself stays pure. +/** + * Directive pass first: `if`/`else`/`end` blocks are resolved on the template body + * and on each fragment body, before fragments are appended and placeholders + * substituted, so tokens inside a dropped block never need a value. Fragment + * sources are read here so `renderFile` itself stays pure. + */ async function renderManagedTemplate(file: ManagedFile, payload: PayloadHandle, config: StreamctlConfig | undefined, deps: Record): Promise { - const raw = await payload.read(file.source); + const raw = applyDirectives(await payload.read(file.source), file.path, condition => evaluateCondition(config, condition, file.path)); if (file.renderDef === undefined) { return raw; } const fragmentSources: Record = {}; for (const fragment of file.renderDef.fragments ?? []) { - if (!(fragment.source in fragmentSources)) { - fragmentSources[fragment.source] = await payload.read(fragment.source); + const source = fragment.source; + if (!(source in fragmentSources)) { + fragmentSources[source] = applyDirectives(await payload.read(source), source, condition => evaluateCondition(config, condition, source)); } } 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. */ +/** + * The template (and its fragments) go through the directive pass, then `renderFile` + * when a `renderDef` exists, then the strategy below. `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, diff --git a/src/engine/conditions.ts b/src/engine/conditions.ts new file mode 100644 index 0000000..96d26e5 --- /dev/null +++ b/src/engine/conditions.ts @@ -0,0 +1,26 @@ +import type { StreamctlConfig } from "../config/types"; +import { StreamctlError } from "../errors"; +import { readConfigPath } from "./render"; + +/** + * Truthiness of a directive condition: `true`, a non-empty string or a non-empty + * array. Absent, `false`, `""` and `[]` are false; any other type is an error. + */ +export function evaluateCondition(config: StreamctlConfig | undefined, path: string, filePath: string): boolean { + const value = readConfigPath(config, path); + if (value === undefined || typeof value === "boolean") { + return value === true; + } + if (typeof value === "string") { + return value !== ""; + } + if (Array.isArray(value)) { + return value.length > 0; + } + const found = value === null ? "null" : typeof value; + throw new StreamctlError( + "CONFIG_INVALID", + `${filePath}: condition "${path}" has a ${found} value in the config, which cannot be used as a condition (boolean, string or string[] expected).`, + { file: filePath, condition: path, reason: "unevaluable-value", found }, + ); +} diff --git a/test/directives.compose.test.ts b/test/directives.compose.test.ts new file mode 100644 index 0000000..c422ab4 --- /dev/null +++ b/test/directives.compose.test.ts @@ -0,0 +1,138 @@ +import type { ManagedFile, StreamctlConfig } from "../src/config/types"; +import type { ComposeResult } from "../src/engine/compose"; +import type { PayloadHandle } from "../src/payload/resolve"; +import { describe, expect, it } from "vitest"; +import { compose } from "../src/engine/compose"; +import { evaluateCondition } from "../src/engine/conditions"; +import { StreamctlError } from "../src/errors"; + +function payloadOf(files: Record): PayloadHandle { + return { + version: "1.0.0", + async read(source) { + const value = files[source]; + if (value === undefined) { + throw new Error(`missing fixture source: ${source}`); + } + return value; + }, + async list() { + return Object.keys(files).sort(); + }, + }; +} + +function cfg(extra: Partial = {}): StreamctlConfig { + return { package: "@acme/config", base: "base", version: "1.0.0", profile: "nuxt-4", ...extra }; +} + +function content(result: ComposeResult): string { + if (result.status !== "composed") { + throw new Error(`expected composed, got ${result.status}`); + } + return result.targetContent; +} + +const block = "a\n# streamctl:if ci.prisma\nprisma\n# streamctl:else ci.prisma\nno-prisma\n# streamctl:end ci.prisma\nz\n"; + +describe("evaluateCondition", () => { + it.each([ + [true, true], + [false, false], + [undefined, false], + ["x", true], + ["", false], + [["a"], true], + [[], false], + ])("treats %j as %s", (value, expected) => { + const config = value === undefined ? cfg() : cfg({ ci: { flag: value } }); + expect(evaluateCondition(config, "ci.flag", "ci.yaml")).toBe(expected); + }); + + it("is false without a config", () => { + expect(evaluateCondition(undefined, "ci.flag", "ci.yaml")).toBe(false); + }); + + it.each([ + [1, "number"], + [null, "null"], + [{ a: 1 }, "object"], + ])("rejects %j as unevaluable", (value, found) => { + const config: unknown = { ...cfg(), ci: { flag: value } }; + let caught: unknown; + try { + evaluateCondition(config as StreamctlConfig, "ci.flag", "ci.yaml"); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(StreamctlError); + expect(caught).toMatchObject({ + code: "CONFIG_INVALID", + details: { file: "ci.yaml", condition: "ci.flag", reason: "unevaluable-value", found }, + }); + }); +}); + +describe("compose: directive pass", () => { + const payload = payloadOf({ + "base/npmrc": block, + "base/ci.yaml": "jobs:\n # streamctl:if ci.deploy\n deploy: ${DEPLOY}\n # streamctl:end ci.deploy\n lint: true\n", + "base/ci-main.yaml": "jobs:\n", + "base/toggle.job": " # streamctl:if ci.prisma\n prisma: true\n # streamctl:end ci.prisma\n test: true\n", + "base/env.job": " deploy-${ITEM}:\n # streamctl:if !ci.prisma\n plain: true\n # streamctl:end !ci.prisma\n", + }); + + it("applies directives to a full file without render", async () => { + const file: ManagedFile = { path: ".npmrc", strategy: "full", source: "base/npmrc" }; + expect(content(await compose(file, payload, null, cfg({ ci: { prisma: true } })))).toBe("a\nprisma\nz\n"); + expect(content(await compose(file, payload, null, cfg()))).toBe("a\nno-prisma\nz\n"); + }); + + it("applies directives to the payload region of a block file", async () => { + const file: ManagedFile = { path: ".npmrc", strategy: "block", source: "base/npmrc", blockMark: "core" }; + const current = "local=1\n# BEGIN streamctl MANAGED BLOCK core\nold\n# END streamctl MANAGED BLOCK core\ntail\n"; + expect(content(await compose(file, payload, current, cfg({ ci: { prisma: true } })))).toBe( + "local=1\n# BEGIN streamctl MANAGED BLOCK core\na\nprisma\nz\n# END streamctl MANAGED BLOCK core\ntail\n", + ); + }); + + it("applies directives to a scaffold file", async () => { + const file: ManagedFile = { path: ".npmrc", strategy: "scaffold", source: "base/npmrc" }; + const result = await compose(file, payload, null, cfg()); + expect(result).toMatchObject({ status: "composed", writeMode: "if-absent", driftChecked: false }); + expect(content(result)).toBe("a\nno-prisma\nz\n"); + }); + + it("needs no placeholder value for a token inside a dropped block", async () => { + const file: ManagedFile = { path: ".github/workflows/ci.yaml", strategy: "full", source: "base/ci.yaml", renderDef: {} }; + expect(content(await compose(file, payload, null, cfg()))).toBe("jobs:\n lint: true\n"); + }); + + it("still fails on a token inside a kept block without a placeholder", async () => { + const file: ManagedFile = { path: ".github/workflows/ci.yaml", strategy: "full", source: "base/ci.yaml", renderDef: {} }; + await expect(compose(file, payload, null, cfg({ ci: { deploy: true } }))).rejects.toThrow(/unresolved render token/); + }); + + it("applies directives inside toggle and forEach fragments", async () => { + const file: ManagedFile = { + path: ".github/workflows/ci.yaml", + strategy: "full", + source: "base/ci-main.yaml", + renderDef: { fragments: [{ toggle: "ci.test", source: "base/toggle.job" }, { forEach: "ci.envs", source: "base/env.job" }] }, + }; + const output = content(await compose(file, payload, null, cfg({ ci: { test: true, envs: ["dev", "prod"] } }))); + expect(output).not.toContain("streamctl:"); + expect(output).not.toContain("prisma"); + expect(output).toContain(" test: true\n"); + expect(output).toContain(" deploy-dev:\n plain: true\n"); + expect(output).toContain(" deploy-prod:\n plain: true\n"); + }); + + it("reports unevaluable values against the target path", async () => { + const file: ManagedFile = { path: ".npmrc", strategy: "full", source: "base/npmrc" }; + const config: unknown = { ...cfg(), ci: { prisma: 3 } }; + await expect(compose(file, payload, null, config as StreamctlConfig)).rejects.toMatchObject({ + details: { file: ".npmrc", reason: "unevaluable-value", found: "number" }, + }); + }); +}); From 98f1382e397b6bd0daf04ab6dc8b9f535a9a7420 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 19:28:45 +0200 Subject: [PATCH 05/32] test: use eslint config paths instead of casts for unevaluable conditions --- test/directives.compose.test.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/test/directives.compose.test.ts b/test/directives.compose.test.ts index c422ab4..b5a9de1 100644 --- a/test/directives.compose.test.ts +++ b/test/directives.compose.test.ts @@ -58,17 +58,16 @@ describe("evaluateCondition", () => { [null, "null"], [{ a: 1 }, "object"], ])("rejects %j as unevaluable", (value, found) => { - const config: unknown = { ...cfg(), ci: { flag: value } }; let caught: unknown; try { - evaluateCondition(config as StreamctlConfig, "ci.flag", "ci.yaml"); + evaluateCondition(cfg({ eslint: { flag: value } }), "eslint.flag", "ci.yaml"); } catch (error) { caught = error; } expect(caught).toBeInstanceOf(StreamctlError); expect(caught).toMatchObject({ code: "CONFIG_INVALID", - details: { file: "ci.yaml", condition: "ci.flag", reason: "unevaluable-value", found }, + details: { file: "ci.yaml", condition: "eslint.flag", reason: "unevaluable-value", found }, }); }); }); @@ -76,6 +75,7 @@ describe("evaluateCondition", () => { describe("compose: directive pass", () => { const payload = payloadOf({ "base/npmrc": block, + "base/eslint-flag": "# streamctl:if eslint.flag\nx\n# streamctl:end eslint.flag\n", "base/ci.yaml": "jobs:\n # streamctl:if ci.deploy\n deploy: ${DEPLOY}\n # streamctl:end ci.deploy\n lint: true\n", "base/ci-main.yaml": "jobs:\n", "base/toggle.job": " # streamctl:if ci.prisma\n prisma: true\n # streamctl:end ci.prisma\n test: true\n", @@ -129,9 +129,8 @@ describe("compose: directive pass", () => { }); it("reports unevaluable values against the target path", async () => { - const file: ManagedFile = { path: ".npmrc", strategy: "full", source: "base/npmrc" }; - const config: unknown = { ...cfg(), ci: { prisma: 3 } }; - await expect(compose(file, payload, null, config as StreamctlConfig)).rejects.toMatchObject({ + const file: ManagedFile = { path: ".npmrc", strategy: "full", source: "base/eslint-flag" }; + await expect(compose(file, payload, null, cfg({ eslint: { flag: 3 } }))).rejects.toMatchObject({ details: { file: ".npmrc", reason: "unevaluable-value", found: "number" }, }); }); From 7221a1a5eec70283c536bff394c3c5d37eb88f11 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 19:29:28 +0200 Subject: [PATCH 06/32] feat(manifest): accept schemaVersion 5 and add directives gate constant --- src/manifest/schema.ts | 12 ++++++++---- test/manifest-loader.test.ts | 16 +++++++++++----- test/manifest-schema.test.ts | 12 ++++++++++-- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/manifest/schema.ts b/src/manifest/schema.ts index 7cd9b3c..ecc1ad6 100644 --- a/src/manifest/schema.ts +++ b/src/manifest/schema.ts @@ -18,14 +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 = 4; +export const SUPPORTED_SCHEMA_VERSION = 5; /** * Every version this CLI accepts; anything else maps to `SCHEMA_UNSUPPORTED`. v2 payloads * keep working unchanged, v3 additionally allows placeholder `fromDependency`, v4 - * placeholder `omitLineIfEmpty`. + * placeholder `omitLineIfEmpty`, v5 template directives (`streamctl:if`/`else`/`end`). */ -export const SUPPORTED_SCHEMA_VERSIONS: ReadonlySet = new Set([2, 3, SUPPORTED_SCHEMA_VERSION]); +export const SUPPORTED_SCHEMA_VERSIONS: ReadonlySet = new Set([2, 3, 4, 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(", "); @@ -36,13 +36,17 @@ 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; +/** Lowest `schemaVersion` a payload may declare while using template directives (`streamctl:if`/`else`/`end`). */ +export const DIRECTIVES_MIN_SCHEMA_VERSION = 5; + /** 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]; /** * A `string[]` value is deduped + sorted, then joined per `join` (`space` or - * `lines`). Substitution is single-pass; any leftover `${…}` fails render.ts's + * `lines`). Substitution repeats until the text stops changing (at most 10 passes, + * so a self-referencing value fails); any leftover `${…}` then fails render.ts's * leftover-token check. * * `fromDependency` names an npm package whose pin in the consumer's package.json supplies diff --git a/test/manifest-loader.test.ts b/test/manifest-loader.test.ts index 4b5d060..8afd63a 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 v5 payload", async () => { + const payload = payloadOf({ "manifest.json": { schemaVersion: 5, presets: ["base"], profiles: [], defaultBase: "base" } }); + const manifest = await loadPayloadManifest(payload); + expect(manifest.schemaVersion).toBe(5); + }); + // 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" } }); + const payload = payloadOf({ "manifest.json": { schemaVersion: 6, presets: ["base"], profiles: [], defaultBase: "base" } }); const error = await caught(loadPayloadManifest(payload)); expect(error.code).toBe("SCHEMA_UNSUPPORTED"); - expect(error.message).toContain("schemaVersion 5"); - expect(error.message).toContain("schemaVersion 2, 3, 4"); + expect(error.message).toContain("schemaVersion 6"); + expect(error.message).toContain("schemaVersion 2, 3, 4, 5"); expect(error.message).toContain("Upgrade the CLI or the payload"); - expect(error.details).toMatchObject({ found: 5, supported: [2, 3, 4] }); + expect(error.details).toMatchObject({ found: 6, supported: [2, 3, 4, 5] }); }); it("surfaces through resolvePresetChain too", async () => { - const payload = payloadOf({ "manifest.json": { schemaVersion: 5, presets: ["base"], profiles: [], defaultBase: "base" } }); + const payload = payloadOf({ "manifest.json": { schemaVersion: 6, presets: ["base"], profiles: [], defaultBase: "base" } }); const error = await caught(resolvePresetChain(payload, "base", "nuxt-4")); expect(error.code).toBe("SCHEMA_UNSUPPORTED"); }); diff --git a/test/manifest-schema.test.ts b/test/manifest-schema.test.ts index 1d7d766..ef13aef 100644 --- a/test/manifest-schema.test.ts +++ b/test/manifest-schema.test.ts @@ -2,11 +2,13 @@ import type { ZodError } from "zod"; import { describe, expect, it } from "vitest"; import { CONFIG_FILE, LEGACY_CONFIG_FILE } from "../src/config/resolve"; import { + DIRECTIVES_MIN_SCHEMA_VERSION, managedFileSchema, payloadManifestSchema, presetManifestSchema, renderDefSchema, SUPPORTED_SCHEMA_VERSION, + SUPPORTED_SCHEMA_VERSION_LIST, SUPPORTED_SCHEMA_VERSIONS, zodToIssues, } from "../src/manifest/schema"; @@ -292,14 +294,20 @@ 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(4); + expect(SUPPORTED_SCHEMA_VERSION).toBe(5); expect(SUPPORTED_SCHEMA_VERSIONS.has(SUPPORTED_SCHEMA_VERSION)).toBe(true); }); + it("lists 2 through 5 and gates directives at 5", () => { + expect([...SUPPORTED_SCHEMA_VERSIONS].sort()).toEqual([2, 3, 4, 5]); + expect(SUPPORTED_SCHEMA_VERSION_LIST).toBe("2, 3, 4, 5"); + expect(DIRECTIVES_MIN_SCHEMA_VERSION).toBe(5); + }); + // 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: 5 }).success).toBe(false); + expect(payloadManifestSchema.safeParse({ ...payload(), schemaVersion: 6 }).success).toBe(false); expect(payloadManifestSchema.safeParse({ ...payload(), schemaVersion: 2.5 }).success).toBe(false); }); From 1d337d39b4fcd81006d2c95536b147c11d1a925e Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 19:33:30 +0200 Subject: [PATCH 07/32] test: sort schema versions numerically --- test/manifest-schema.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/manifest-schema.test.ts b/test/manifest-schema.test.ts index ef13aef..6781a83 100644 --- a/test/manifest-schema.test.ts +++ b/test/manifest-schema.test.ts @@ -299,7 +299,7 @@ describe("PayloadManifest schemaVersion contract", () => { }); it("lists 2 through 5 and gates directives at 5", () => { - expect([...SUPPORTED_SCHEMA_VERSIONS].sort()).toEqual([2, 3, 4, 5]); + expect([...SUPPORTED_SCHEMA_VERSIONS].sort((a, b) => a - b)).toEqual([2, 3, 4, 5]); expect(SUPPORTED_SCHEMA_VERSION_LIST).toBe("2, 3, 4, 5"); expect(DIRECTIVES_MIN_SCHEMA_VERSION).toBe(5); }); From c4bc5fca5da3d2d37bbf208fbaebb9da01acbf3e Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 19:33:30 +0200 Subject: [PATCH 08/32] feat(engine): validate template directives when resolving the preset chain --- src/engine/manifest.ts | 101 ++++++++++++++++- test/directives.payload.test.ts | 97 +++++++++++++++++ test/fixtures/directives-payload/package.json | 5 + .../presets/app/Containerfile | 7 ++ .../directives-payload/presets/app/NOTES.md | 9 ++ .../presets/app/fragments/lint-job.yml | 8 ++ .../presets/app/github/workflows/pipeline.yml | 19 ++++ .../presets/app/preset.json | 30 +++++ .../directives-payload/presets/manifest.json | 6 + test/manifest-loader.test.ts | 103 ++++++++++++++++++ 10 files changed, 383 insertions(+), 2 deletions(-) create mode 100644 test/directives.payload.test.ts create mode 100644 test/fixtures/directives-payload/package.json create mode 100644 test/fixtures/directives-payload/presets/app/Containerfile create mode 100644 test/fixtures/directives-payload/presets/app/NOTES.md create mode 100644 test/fixtures/directives-payload/presets/app/fragments/lint-job.yml create mode 100644 test/fixtures/directives-payload/presets/app/github/workflows/pipeline.yml create mode 100644 test/fixtures/directives-payload/presets/app/preset.json create mode 100644 test/fixtures/directives-payload/presets/manifest.json diff --git a/src/engine/manifest.ts b/src/engine/manifest.ts index b2502dc..00a554f 100644 --- a/src/engine/manifest.ts +++ b/src/engine/manifest.ts @@ -1,9 +1,11 @@ import type { ZodError } from "zod"; import type { ManagedFile as EngineManagedFile } from "../config/types"; +import type { DirectiveBlock } from "../directives"; import type { ConfigKeyType, ManagedFile as ManifestManagedFile, PayloadManifest, PresetManifest, RenderDef } from "../manifest/schema"; import type { PayloadHandle } from "../payload/resolve"; +import { scanDirectives } from "../directives"; import { StreamctlError } from "../errors"; -import { FROM_DEPENDENCY_MIN_SCHEMA_VERSION, OMIT_LINE_MIN_SCHEMA_VERSION, payloadManifestSchema, presetManifestSchema, SUPPORTED_SCHEMA_VERSION_LIST, SUPPORTED_SCHEMA_VERSIONS, zodToIssues } from "../manifest/schema"; +import { DIRECTIVES_MIN_SCHEMA_VERSION, 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"; @@ -145,6 +147,88 @@ function toEngineFile(file: ManifestManagedFile): EngineManagedFile { interface ConfigPathRef { where: string; path: string; + /** Extra error details, e.g. the directive location. */ + details?: Record; +} + +interface DirectiveScanTarget { + source: string; + /** Drives comment syntax, the YAML body rule and JSON rejection. */ + scanPath: string; + kind: "template" | "fragment"; + strategy?: EngineManagedFile["strategy"]; +} + +/** Re-point a parser error from the scan path to the payload-relative source file. */ +function relocateDirectiveError(error: unknown, scanPath: string, file: string): unknown { + if (!(error instanceof StreamctlError) || !isRecord(error.details) || typeof error.details.line !== "number") { + return error; + } + const prefix = `${scanPath}:${error.details.line}: `; + const sentence = error.message.startsWith(prefix) ? error.message.slice(prefix.length) : error.message; + return new StreamctlError(error.code, `${file}:${error.details.line}: ${sentence}`, { ...error.details, file }); +} + +/** + * Scan every template and fragment of the resolved chain, including files later + * switched off, so a directive mistake fails every command up front. Pushes each + * condition into `configPathRefs` for the shared declaration check. + */ +async function validateDirectives( + payload: PayloadHandle, + targets: DirectiveScanTarget[], + schemaVersion: number, + configKeys: Record, + configPathRefs: ConfigPathRef[], +): Promise { + const reads = new Map>(); + for (const target of targets) { + const file = `presets/${target.source}`; + let text = reads.get(target.source); + if (text === undefined) { + text = payload.read(target.source); + reads.set(target.source, text); + } + let blocks: DirectiveBlock[]; + try { + blocks = scanDirectives(await text, target.scanPath); + } catch (error) { + throw relocateDirectiveError(error, target.scanPath, file); + } + const first = blocks[0]; + if (first === undefined) { + continue; + } + if (schemaVersion < DIRECTIVES_MIN_SCHEMA_VERSION) { + throw new StreamctlError( + "CONFIG_INVALID", + `${file}:${first.ifLine}: uses template directives, which requires schemaVersion ${DIRECTIVES_MIN_SCHEMA_VERSION}, but ${PAYLOAD_MANIFEST} declares schemaVersion ${schemaVersion}.`, + { file, line: first.ifLine, reason: "schema-version", found: schemaVersion, required: DIRECTIVES_MIN_SCHEMA_VERSION }, + ); + } + if (target.strategy === "merge") { + throw new StreamctlError( + "CONFIG_INVALID", + `${file}:${first.ifLine}: template directives are not supported in merge-strategy files.`, + { file, line: first.ifLine, reason: "merge-strategy" }, + ); + } + for (const block of blocks) { + const details = { file, line: block.ifLine, condition: block.condition }; + if (configKeys[block.condition] === "object") { + throw new StreamctlError( + "CONFIG_INVALID", + `${file}:${block.ifLine}: condition "${block.condition}" names an object config key; use one of its fields instead.`, + { ...details, reason: "object-condition" }, + ); + } + configPathRefs.push({ + where: `${target.kind} "${file}" line ${block.ifLine}`, + path: block.condition, + details: { ...details, reason: "undeclared-condition" }, + }); + } + } } /** @@ -252,6 +336,19 @@ export async function resolvePresetChain( file.renderDef = def; } } + + const scanTargets: DirectiveScanTarget[] = files.map(file => ({ source: file.source, scanPath: file.path, kind: "template", strategy: file.strategy })); + const fragmentSources = new Set(); + for (const key of new Set(renderKeyByPath.values())) { + for (const fragment of renders.get(key)?.fragments ?? []) { + if (!fragmentSources.has(fragment.source)) { + fragmentSources.add(fragment.source); + scanTargets.push({ source: fragment.source, scanPath: fragment.source, kind: "fragment" }); + } + } + } + await validateDirectives(payload, scanTargets, manifest.schemaVersion, configKeys, configPathRefs); + // A referenced config path is satisfied when it names a declared configKey exactly, // or descends into one declared `object` (the only declared type with addressable // children). Matching the root namespace alone was too loose: `ci.upgradePr` against a @@ -266,7 +363,7 @@ export async function resolvePresetChain( throw new StreamctlError( "CONFIG_INVALID", `${ref.where} references config path "${ref.path}", which no preset declares in configKeys.${hint}`, - { path: ref.path, declared: known }, + { path: ref.path, declared: known, ...ref.details }, ); } } diff --git a/test/directives.payload.test.ts b/test/directives.payload.test.ts new file mode 100644 index 0000000..f65d2cc --- /dev/null +++ b/test/directives.payload.test.ts @@ -0,0 +1,97 @@ +import type { ManagedFile, StreamctlConfig } from "../src/config/types"; +import type { ComposeResult } from "../src/engine/compose"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { parse as parseYaml } from "yaml"; +import { z } from "zod"; +import { compose } from "../src/engine/compose"; +import { resolvePresetChain } from "../src/engine/manifest"; +import { resolvePayload } from "../src/payload/resolve"; + +// Schema-5 @acme/directives payload: if/else/end in a YAML workflow and its toggle +// fragment, a `#` block in a Containerfile and an HTML-comment block in Markdown. +const fixturesDir = dirname(fileURLToPath(import.meta.url)); +const pkgDir = resolve(fixturesDir, "fixtures/directives-payload"); +const PKG = "@acme/directives"; +const VERSION = "1.0.0"; + +const cfg = (ci: StreamctlConfig["ci"] = {}): StreamctlConfig => ({ package: PKG, base: "app", version: VERSION, profile: "std", ci }); + +let payloadPromise: ReturnType | undefined; +function payload(): ReturnType { + payloadPromise ??= resolvePayload(pkgDir, PKG, VERSION, { resolvePackageDir: () => pkgDir }); + return payloadPromise; +} + +async function fileByPath(path: string): Promise { + const { files } = await resolvePresetChain(await payload(), "app", "std"); + const found = files.find(f => f.path === path); + if (found === undefined) { + throw new Error(`app did not resolve "${path}"`); + } + return found; +} + +const workflowSchema = z.object({ jobs: z.record(z.string(), z.object({ steps: z.array(z.unknown()) })) }); + +function stepCounts(yaml: string): Record { + const { jobs } = workflowSchema.parse(parseYaml(yaml)); + return Object.fromEntries(Object.entries(jobs).map(([name, job]) => [name, job.steps.length])); +} + +async function render(path: string, config: StreamctlConfig): Promise { + const result: ComposeResult = await compose(await fileByPath(path), await payload(), null, config); + if (result.status !== "composed") { + throw new Error(`expected composed, got ${result.status}`); + } + return result.targetContent; +} + +describe("directives payload fixture", () => { + it("resolves the chain", async () => { + const { files } = await resolvePresetChain(await payload(), "app", "std"); + expect(files.map(f => f.path).sort()).toEqual([".github/workflows/pipeline.yml", "Containerfile", "NOTES.md"]); + }); + + it("renders the workflow with every condition on", async () => { + const output = await render(".github/workflows/pipeline.yml", cfg({ cache: true, deploy: true, deployTarget: "prod", lint: true, strictLint: true })); + expect(output).not.toContain("streamctl:"); + expect(output).toContain("actions/cache@v4"); + expect(output).not.toContain("cache disabled"); + expect(output).toContain("acme deploy --target prod"); + expect(output).toContain("acme lint --strict"); + expect(stepCounts(output)).toEqual({ build: 4, lint: 3 }); + }); + + it("renders the workflow with every condition off", async () => { + const output = await render(".github/workflows/pipeline.yml", cfg({ lint: true })); + expect(output).not.toContain("streamctl:"); + expect(output).not.toContain("actions/cache@v4"); + expect(output).toContain("cache disabled"); + expect(output).not.toContain("acme deploy"); + expect(output).not.toContain("--strict"); + expect(stepCounts(output)).toEqual({ build: 3, lint: 2 }); + }); + + it("applies # blocks in the Containerfile", async () => { + expect(await render("Containerfile", cfg({ containerDebug: true }))).toContain("apt-get install"); + const off = await render("Containerfile", cfg()); + expect(off).not.toContain("apt-get"); + expect(off).not.toContain("streamctl:"); + }); + + it("applies HTML-comment blocks in Markdown", async () => { + expect(await render("NOTES.md", cfg({ extendedDocs: true }))).toContain("## Extended"); + expect(await render("NOTES.md", cfg())).toBe("# Notes\n\nManaged by streamctl.\n"); + }); +}); + +describe("existing fixtures still resolve", () => { + it("resolves synthetic-payload (schemaVersion 2)", async () => { + const dir = resolve(fixturesDir, "fixtures/synthetic-payload"); + const synthetic = await resolvePayload(dir, "@acme/payload", "1.4.0", { resolvePackageDir: () => dir }); + const { files } = await resolvePresetChain(synthetic, "app", "std"); + expect(files.length).toBeGreaterThan(0); + }); +}); diff --git a/test/fixtures/directives-payload/package.json b/test/fixtures/directives-payload/package.json new file mode 100644 index 0000000..7fc56b9 --- /dev/null +++ b/test/fixtures/directives-payload/package.json @@ -0,0 +1,5 @@ +{ + "name": "@acme/directives", + "version": "1.0.0", + "type": "module" +} diff --git a/test/fixtures/directives-payload/presets/app/Containerfile b/test/fixtures/directives-payload/presets/app/Containerfile new file mode 100644 index 0000000..45eadec --- /dev/null +++ b/test/fixtures/directives-payload/presets/app/Containerfile @@ -0,0 +1,7 @@ +FROM docker.io/library/node:20-slim + +# streamctl:if ci.containerDebug +RUN apt-get update && apt-get install -y --no-install-recommends curl +# streamctl:end ci.containerDebug +COPY . /app +WORKDIR /app diff --git a/test/fixtures/directives-payload/presets/app/NOTES.md b/test/fixtures/directives-payload/presets/app/NOTES.md new file mode 100644 index 0000000..6bffb0b --- /dev/null +++ b/test/fixtures/directives-payload/presets/app/NOTES.md @@ -0,0 +1,9 @@ +# Notes + +Managed by streamctl. + + +## Extended + +Extra guidance for this project. + diff --git a/test/fixtures/directives-payload/presets/app/fragments/lint-job.yml b/test/fixtures/directives-payload/presets/app/fragments/lint-job.yml new file mode 100644 index 0000000..6390156 --- /dev/null +++ b/test/fixtures/directives-payload/presets/app/fragments/lint-job.yml @@ -0,0 +1,8 @@ + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: acme lint + # streamctl:if ci.strictLint + - run: acme lint --strict + # streamctl:end ci.strictLint diff --git a/test/fixtures/directives-payload/presets/app/github/workflows/pipeline.yml b/test/fixtures/directives-payload/presets/app/github/workflows/pipeline.yml new file mode 100644 index 0000000..1a437c7 --- /dev/null +++ b/test/fixtures/directives-payload/presets/app/github/workflows/pipeline.yml @@ -0,0 +1,19 @@ +name: pipeline +on: + pull_request: +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # streamctl:if ci.cache + - uses: actions/cache@v4 + with: + path: node_modules + # streamctl:else ci.cache + - run: echo "cache disabled" + # streamctl:end ci.cache + - run: acme build + # streamctl:if ci.deploy + - run: acme deploy --target ${DEPLOY_TARGET} + # streamctl:end ci.deploy diff --git a/test/fixtures/directives-payload/presets/app/preset.json b/test/fixtures/directives-payload/presets/app/preset.json new file mode 100644 index 0000000..e2df781 --- /dev/null +++ b/test/fixtures/directives-payload/presets/app/preset.json @@ -0,0 +1,30 @@ +{ + "name": "app", + "files": [ + { "path": ".github/workflows/pipeline.yml", "strategy": "full", "source": "app/github/workflows/pipeline.yml", "render": "pipeline" }, + { "path": "Containerfile", "strategy": "full", "source": "app/Containerfile" }, + { "path": "NOTES.md", "strategy": "full", "source": "app/NOTES.md" } + ], + "renders": { + "pipeline": { + "placeholders": { + "DEPLOY_TARGET": { "configPath": "ci.deployTarget", "default": "staging", "pattern": "^[a-z-]+$" } + }, + "fragments": [ + { "toggle": "ci.lint", "source": "app/fragments/lint-job.yml" } + ] + } + }, + "configKeys": { + "ci.cache": "boolean", + "ci.deploy": "boolean", + "ci.deployTarget": "string", + "ci.lint": "boolean", + "ci.strictLint": "boolean", + "ci.containerDebug": "boolean", + "ci.extendedDocs": "boolean" + }, + "versionProfiles": { + "std": {} + } +} diff --git a/test/fixtures/directives-payload/presets/manifest.json b/test/fixtures/directives-payload/presets/manifest.json new file mode 100644 index 0000000..ed77490 --- /dev/null +++ b/test/fixtures/directives-payload/presets/manifest.json @@ -0,0 +1,6 @@ +{ + "schemaVersion": 5, + "presets": ["app"], + "profiles": [{ "name": "std" }], + "defaultBase": "app" +} diff --git a/test/manifest-loader.test.ts b/test/manifest-loader.test.ts index 8afd63a..c018730 100644 --- a/test/manifest-loader.test.ts +++ b/test/manifest-loader.test.ts @@ -9,6 +9,10 @@ function payloadOf(files: Record): PayloadHandle { version: "2.0.0", async read(source) { if (!(source in files)) { + // Templates are scanned for directives at chain time; most cases don't need content. + if (source !== "manifest.json" && !source.endsWith("/preset.json")) { + return ""; + } throw new Error(`missing fixture source: ${source}`); } const value = files[source]; @@ -428,3 +432,102 @@ describe("manifest is required", () => { expect(error.message).toContain("has no presets/manifest.json"); }); }); + +describe("chain-time directive validation", () => { + const block = "# streamctl:if ci.deploy\ndeploy: true\n# streamctl:end ci.deploy\n"; + + function directivePayload(over: { schemaVersion?: number; files?: unknown[]; configKeys?: Record; sources?: Record; renders?: Record } = {}): PayloadHandle { + return payloadOf({ + "manifest.json": { schemaVersion: over.schemaVersion ?? 5, presets: ["base"], profiles: [], defaultBase: "base" }, + "base/preset.json": { + name: "base", + files: over.files ?? [{ path: ".github/workflows/ci.yaml", strategy: "full", source: "base/ci.yaml" }], + configKeys: over.configKeys ?? { "ci.deploy": "boolean" }, + ...(over.renders === undefined ? {} : { renders: over.renders }), + }, + "base/ci.yaml": block, + ...over.sources, + }); + } + + it("accepts declared conditions on a v5 payload", async () => { + const { files } = await resolvePresetChain(directivePayload(), "base", "nuxt-4"); + expect(files.map(f => f.path)).toEqual([".github/workflows/ci.yaml"]); + }); + + it("reads each source once", async () => { + const payload = directivePayload({ + files: [ + { path: "a.yaml", strategy: "full", source: "base/ci.yaml", render: "r" }, + { path: "b.yaml", strategy: "full", source: "base/ci.yaml", render: "r" }, + ], + renders: { r: { fragments: [{ toggle: "ci.deploy", source: "base/frag.yml" }] } }, + sources: { "base/frag.yml": "" }, + }); + const reads: string[] = []; + const counting: PayloadHandle = { ...payload, read: async (source) => { + reads.push(source); + return payload.read(source); + } }; + await resolvePresetChain(counting, "base", "nuxt-4"); + expect(reads.filter(source => source === "base/ci.yaml")).toHaveLength(1); + expect(reads.filter(source => source === "base/frag.yml")).toHaveLength(1); + }); + + it("rejects directives on a schema-4 payload", async () => { + const error = await caught(resolvePresetChain(directivePayload({ schemaVersion: 4 }), "base", "nuxt-4")); + expect(error.code).toBe("CONFIG_INVALID"); + expect(error.message).toContain("requires schemaVersion 5, but presets/manifest.json declares schemaVersion 4"); + expect(error.details).toMatchObject({ file: "presets/base/ci.yaml", line: 1, reason: "schema-version" }); + }); + + it("rejects an undeclared condition, naming file and line", async () => { + const error = await caught(resolvePresetChain(directivePayload({ configKeys: { "ci.lint": "boolean" } }), "base", "nuxt-4")); + expect(error.message).toContain("template \"presets/base/ci.yaml\" line 1 references config path \"ci.deploy\", which no preset declares in configKeys"); + expect(error.details).toMatchObject({ file: "presets/base/ci.yaml", line: 1, reason: "undeclared-condition", path: "ci.deploy" }); + }); + + it("rejects directives in a merge-strategy template", async () => { + const payload = directivePayload({ + files: [{ path: ".vscode/settings.txt", strategy: "merge", source: "base/settings" }], + sources: { "base/settings": block }, + }); + const error = await caught(resolvePresetChain(payload, "base", "nuxt-4")); + expect(error.details).toMatchObject({ file: "presets/base/settings", line: 1, reason: "merge-strategy" }); + }); + + it("rejects a condition naming an object key exactly", async () => { + const payload = directivePayload({ sources: { "base/ci.yaml": "# streamctl:if ci\n# streamctl:end ci\n" }, configKeys: { ci: "object" } }); + const error = await caught(resolvePresetChain(payload, "base", "nuxt-4")); + expect(error.details).toMatchObject({ file: "presets/base/ci.yaml", line: 1, reason: "object-condition", condition: "ci" }); + }); + + it("accepts a condition descending into an object key", async () => { + await expect(resolvePresetChain(directivePayload({ configKeys: { ci: "object" } }), "base", "nuxt-4")).resolves.toBeDefined(); + }); + + it("relocates parser errors to the payload-relative source", async () => { + const payload = directivePayload({ sources: { "base/ci.yaml": "x\n# streamctl:if ci.deploy\n" } }); + const error = await caught(resolvePresetChain(payload, "base", "nuxt-4")); + expect(error.message).toMatch(/^presets\/base\/ci\.yaml:2: /); + expect(error.details).toMatchObject({ file: "presets/base/ci.yaml", line: 2, reason: "unclosed-if", openedAt: 2 }); + }); + + it("scans fragments with the fragment source as path", async () => { + const payload = directivePayload({ + files: [{ path: "ci.yaml", strategy: "full", source: "base/ci.yaml", render: "r" }], + renders: { r: { fragments: [{ toggle: "ci.deploy", source: "base/frag.yml" }] } }, + sources: { "base/frag.yml": "# streamctl:if ci.missing\n# streamctl:end ci.missing\n" }, + }); + const error = await caught(resolvePresetChain(payload, "base", "nuxt-4")); + expect(error.message).toContain("fragment \"presets/base/frag.yml\" line 1"); + }); + + it("scans files that are switched off by enabledBy", async () => { + const payload = directivePayload({ + files: [{ path: "ci.yaml", strategy: "full", source: "base/ci.yaml", enabledBy: "ci.deploy" }], + sources: { "base/ci.yaml": "# streamctl:end ci.deploy\n" }, + }); + expect((await caught(resolvePresetChain(payload, "base", "nuxt-4"))).details).toMatchObject({ reason: "unopened-end" }); + }); +}); From c54b6da2a3c45cb09e91041862615c32d04bfac8 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 19:35:40 +0200 Subject: [PATCH 09/32] feat(render): indent continuation lines of multiline placeholder values --- src/engine/render.ts | 15 +++++++++------ test/render-generic.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/engine/render.ts b/src/engine/render.ts index 0dc72e5..9e49885 100644 --- a/src/engine/render.ts +++ b/src/engine/render.ts @@ -225,15 +225,18 @@ export function renderFile( 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; + // Per line, so continuation lines of a multiline value get the token line's leading + // whitespace (empty ones stay empty). 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 => text.split("\n").map((line) => { + const indent = /^[ \t]*/.exec(line)?.[0] ?? ""; + let next = line; for (const [key, value] of values) { - next = next.replaceAll(`\${${key}}`, () => value); + const indented = indent === "" ? value : value.split("\n").map((part, index) => index === 0 || part === "" ? part : indent + part).join("\n"); + next = next.replaceAll(`\${${key}}`, () => indented); } return next; - }; + }).join("\n"); for (let pass = 1; ; pass++) { const next = substitutePass(output); diff --git a/test/render-generic.test.ts b/test/render-generic.test.ts index 75024a7..27dd344 100644 --- a/test/render-generic.test.ts +++ b/test/render-generic.test.ts @@ -249,6 +249,35 @@ describe("renderFile: nested placeholder tokens", () => { }); }); +describe("renderFile: indent-aware substitution", () => { + const script = (value: string): RenderDef => ({ placeholders: { SCRIPT: { configPath: "ci.script", default: value } } }); + + it("indents continuation lines like the token line", () => { + expect(renderFile("run: |\n ${SCRIPT}\n", script("a\nb\nc"), cfg(), {})).toBe("run: |\n a\n b\n c\n"); + }); + + it("leaves empty continuation lines empty", () => { + expect(renderFile(" ${SCRIPT}\n", script("a\n\nb"), cfg(), {})).toBe(" a\n\n b\n"); + }); + + it("keeps a column-0 multiline value as-is", () => { + expect(renderFile("${SCRIPT}\nend\n", script("a\n b\nc"), cfg(), {})).toBe("a\n b\nc\nend\n"); + }); + + it("leaves single-line values unchanged", () => { + expect(renderFile(" key: ${SCRIPT}\n", script("value"), cfg(), {})).toBe(" key: value\n"); + }); + + it("uses the same indent for two tokens on one line", () => { + const def: RenderDef = { placeholders: { A: { configPath: "ci.a", default: "a1\na2" }, B: { configPath: "ci.b", default: "b1\nb2" } } }; + expect(renderFile(" ${A} ${B}\n", def, cfg(), {})).toBe(" a1\n a2 b1\n b2\n"); + }); + + it("lands $& and $1 verbatim", () => { + expect(renderFile(" ${SCRIPT}\n", script("x $& y\n$1"), cfg(), {})).toBe(" x $& y\n $1\n"); + }); +}); + 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"; From ce01e7a8e758da829558668711926c4f54870411 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 19:37:12 +0200 Subject: [PATCH 10/32] fix(render): make nested multiline indent independent of placeholder order --- src/engine/render.ts | 27 ++++++++++++++++++--------- test/render-generic.test.ts | 8 ++++++++ 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/engine/render.ts b/src/engine/render.ts index 9e49885..99f85b9 100644 --- a/src/engine/render.ts +++ b/src/engine/render.ts @@ -225,18 +225,27 @@ export function renderFile( const passthrough = new Set(renderDef.passthrough ?? []); - // Per line, so continuation lines of a multiline value get the token line's leading - // whitespace (empty ones stay empty). 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 => text.split("\n").map((line) => { - const indent = /^[ \t]*/.exec(line)?.[0] ?? ""; - let next = line; + // Per key, then per line, so continuation lines of a multiline value get the leading + // whitespace of the line holding the token (not the token's column); empty ones stay + // empty. Re-splitting per key keeps nested tokens independent of declaration order. + // 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) { - const indented = indent === "" ? value : value.split("\n").map((part, index) => index === 0 || part === "" ? part : indent + part).join("\n"); - next = next.replaceAll(`\${${key}}`, () => indented); + const token = `\${${key}}`; + if (!next.includes(token)) { + continue; + } + const parts = value.split("\n"); + next = next.split("\n").map((line) => { + const indent = /^[ \t]*/.exec(line)?.[0] ?? ""; + const indented = parts.map((part, index) => index === 0 || part === "" ? part : indent + part).join("\n"); + return line.replaceAll(token, () => indented); + }).join("\n"); } return next; - }).join("\n"); + }; for (let pass = 1; ; pass++) { const next = substitutePass(output); diff --git a/test/render-generic.test.ts b/test/render-generic.test.ts index 27dd344..ad91c2d 100644 --- a/test/render-generic.test.ts +++ b/test/render-generic.test.ts @@ -273,6 +273,14 @@ describe("renderFile: indent-aware substitution", () => { expect(renderFile(" ${A} ${B}\n", def, cfg(), {})).toBe(" a1\n a2 b1\n b2\n"); }); + it("indents nested multiline tokens independent of placeholder order", () => { + const a = { configPath: "ci.a", default: "x\n ${B}" }; + const b = { configPath: "ci.b", default: "b1\nb2" }; + const expected = " x\n b1\n b2\n"; + expect(renderFile(" ${A}\n", { placeholders: { A: a, B: b } }, cfg(), {})).toBe(expected); + expect(renderFile(" ${A}\n", { placeholders: { B: b, A: a } }, cfg(), {})).toBe(expected); + }); + it("lands $& and $1 verbatim", () => { expect(renderFile(" ${SCRIPT}\n", script("x $& y\n$1"), cfg(), {})).toBe(" x $& y\n $1\n"); }); From ca239e1d43e549dce4e611363e08ecdee6ad4a32 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 19:38:58 +0200 Subject: [PATCH 11/32] feat(init): run the first sync with the scaffolded config --- src/engine/init.ts | 15 +++++++++++++-- test/init.test.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/engine/init.ts b/src/engine/init.ts index 74465b4..32b25d6 100644 --- a/src/engine/init.ts +++ b/src/engine/init.ts @@ -6,6 +6,7 @@ import type { SyncDecider, SyncPreview, SyncResult } from "./sync"; import type { LatestVersionProbe, VersionExistsProbe } from "./versions"; import { existsSync } from "node:fs"; import { join } from "node:path"; +import { loadStreamctlConfig } from "../config/load"; import { CONFIG_FILE, resolveConfigFile } from "../config/resolve"; import { validateStreamctlConfigWithKeys } from "../config/validate"; import { StreamctlError } from "../errors"; @@ -440,10 +441,20 @@ export async function runInit(opts: RunInitOptions): Promise { return { base, profile, version, cliVersion, sync: null }; } - const config: StreamctlConfig = { package: opts.package, base, version, profile }; + // Sync with the file just written, so wrapper defaults (toggles, `enabledBy`, + // directive conditions) match what a later `sync` renders. The bare fallback keeps + // init finishing when the config cannot be loaded (e.g. an import that fails). + let config: StreamctlConfig; + try { + config = (await loadStreamctlConfig(cwd, { logger })).config; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + logger.warn(`could not load the scaffolded ${CONFIG_FILE} (${reason}); running the first sync with package, base, version and profile only.`); + config = { package: opts.package, base, version, profile }; + } const payload = await getPayload(); const { files: managedFiles, baseline, configKeys } = await resolvePresetChain(payload, base, profile); - // Stage 2. The freshly-scaffolded config carries no knobs, so this is a guard. + // Stage 2 against the payload's configKeys. validateStreamctlConfigWithKeys(config, configKeys); const sync = await runSync({ cwd, diff --git a/test/init.test.ts b/test/init.test.ts index 3cb2ef3..fd17667 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -159,6 +159,46 @@ describe("runInit", () => { expect(config).not.toContain("__PROFILE__"); }); + it("runs the first sync with the scaffolded config's knobs", async () => { + await writeFile(join(repo, "package.json"), JSON.stringify({ name: "app", devDependencies: { nuxt: "^4.0.0" } })); + await writeFile( + join(configPkg, "presets", "config.template.ts"), + "export default { package: \"__PACKAGE__\", base: \"__BASE__\", version: \"__VERSION__\", profile: \"__PROFILE__\", ci: { lint: true } };\n", + ); + await writeFile(join(configPkg, "presets", "base", "preset.json"), JSON.stringify({ + name: "base", + files: [ + { path: ".editorconfig", strategy: "full", source: "base/editorconfig" }, + { path: "lint.txt", strategy: "full", source: "base/lint.txt", enabledBy: "ci.lint" }, + ], + configKeys: { "ci.lint": "boolean" }, + })); + await writeFile(join(configPkg, "presets", "base", "lint.txt"), "lint on\n"); + const stderr = captureStderr(); + + const result = await runInit(baseOpts()); + + expect(await readFile(join(repo, "lint.txt"), "utf8")).toBe("lint on\n"); + expect(result.sync?.written).toContain("lint.txt"); + expect(stderr.join("")).not.toContain("could not load the scaffolded"); + }); + + it("falls back to the bare config when the scaffolded one fails to load", async () => { + await writeFile(join(repo, "package.json"), JSON.stringify({ name: "app", devDependencies: { nuxt: "^4.0.0" } })); + await writeFile( + join(configPkg, "presets", "config.template.ts"), + "throw new Error(\"template exploded\");\nexport default { package: \"__PACKAGE__\", base: \"__BASE__\", version: \"__VERSION__\", profile: \"__PROFILE__\" };\n", + ); + const stderr = captureStderr(); + + const result = await runInit(baseOpts()); + + const warnings = stderr.join("").split("\n").filter(line => line.includes("could not load the scaffolded")); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("template exploded"); + expect(result.sync?.written).toContain(".editorconfig"); + }); + it("does not overwrite a pre-existing wrapper", async () => { await writeFile(join(repo, "package.json"), JSON.stringify({ name: "app", devDependencies: { nuxt: "^4.0.0" } })); await writeFile(join(repo, "eslint.config.ts"), "export default localOverride()\n"); From 175ca49b6ca23764db739b7cfb6e8c1fae8417c9 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 19:41:03 +0200 Subject: [PATCH 12/32] fix(init): name the config file and keep the load-failure reason to one line --- src/engine/init.ts | 4 ++-- test/init.test.ts | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/engine/init.ts b/src/engine/init.ts index 32b25d6..9574335 100644 --- a/src/engine/init.ts +++ b/src/engine/init.ts @@ -448,8 +448,8 @@ export async function runInit(opts: RunInitOptions): Promise { try { config = (await loadStreamctlConfig(cwd, { logger })).config; } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - logger.warn(`could not load the scaffolded ${CONFIG_FILE} (${reason}); running the first sync with package, base, version and profile only.`); + const reason = (error instanceof Error ? error.message : String(error)).split("\n")[0]; + logger.warn(`could not load the scaffolded ${CONFIG_FILE}.ts; running the first sync with package, base, version and profile only. Reason: ${reason}`); config = { package: opts.package, base, version, profile }; } const payload = await getPayload(); diff --git a/test/init.test.ts b/test/init.test.ts index fd17667..4d64949 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -84,6 +84,8 @@ function baseOpts(overrides: Partial = {}): RunInitOptions { } beforeEach(async () => { + // Silences init's warnings (e.g. the config-load fallback); tests that read them use captureStderr. + vi.spyOn(process.stderr, "write").mockImplementation(() => true); repo = await mkdtemp(join(tmpdir(), "streamctl-init-repo-")); configPkg = await mkdtemp(join(tmpdir(), "streamctl-init-pkg-")); await makeConfigPackage(); @@ -195,7 +197,8 @@ describe("runInit", () => { const warnings = stderr.join("").split("\n").filter(line => line.includes("could not load the scaffolded")); expect(warnings).toHaveLength(1); - expect(warnings[0]).toContain("template exploded"); + expect(warnings[0]).toContain("could not load the scaffolded streamctl.config.ts; running the first sync"); + expect(warnings[0]).toMatch(/Reason: .*template exploded/); expect(result.sync?.written).toContain(".editorconfig"); }); From b37306961e213b12eea3906381a90aba4b8a3da3 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 19:41:51 +0200 Subject: [PATCH 13/32] feat(manifest): export scanDirectives and commentSyntaxFor on the manifest subpath --- src/manifest/index.ts | 6 +++++- test/manifest-exports.test.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 test/manifest-exports.test.ts diff --git a/src/manifest/index.ts b/src/manifest/index.ts index 0594f84..8a7f1a4 100644 --- a/src/manifest/index.ts +++ b/src/manifest/index.ts @@ -1,4 +1,8 @@ // Public payload-authoring contract, re-exported at the CLI package's `./manifest` // subpath so a payload package validates its own presets against the same source -// of truth the CLI loads them with. +// of truth the CLI loads them with, including scanning templates for directives. +export type { CommentSyntax } from "../comment-syntax"; +export { commentSyntaxFor } from "../comment-syntax"; +export type { DirectiveBlock, DirectiveErrorDetails, DirectiveReason } from "../directives"; +export { scanDirectives } from "../directives"; export * from "./schema"; diff --git a/test/manifest-exports.test.ts b/test/manifest-exports.test.ts new file mode 100644 index 0000000..e5c7036 --- /dev/null +++ b/test/manifest-exports.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { commentSyntaxFor, DIRECTIVES_MIN_SCHEMA_VERSION, scanDirectives, SUPPORTED_SCHEMA_VERSION } from "../src/manifest"; + +describe("@sidebase/streamctl/manifest exports", () => { + it("exposes the directive scanner and comment syntax", () => { + expect(scanDirectives("# streamctl:if ci.lint\n# streamctl:end ci.lint\n", "ci.yaml")).toEqual([ + { condition: "ci.lint", negated: false, ifLine: 1, endLine: 2 }, + ]); + expect(commentSyntaxFor("README.md")).toEqual({ prefix: "" }); + }); + + it("exposes the schema constants", () => { + expect(SUPPORTED_SCHEMA_VERSION).toBe(5); + expect(DIRECTIVES_MIN_SCHEMA_VERSION).toBe(5); + }); +}); From 7591ae6cab45dc0f380340d14b8e97136b29b77e Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 19:44:21 +0200 Subject: [PATCH 14/32] docs: document template directives, indent-aware substitution and schema 5 --- README.md | 22 ++++++++++ docs/reference.md | 107 ++++++++++++++++++++++++++++++++++++++++++++++ docs/release.md | 22 ++++++++-- 3 files changed, 147 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 815efd4..e2b0915 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,28 @@ When `sync` cannot proceed on a file, the conflict carries a `kind`, so the repo Structured files (`.json*`, `.ya?ml`) get extra safety: composed output is parse-validated before writing, and the `block` strategy is forbidden on them. Details in [structured-file safety](docs/reference.md#structured-file-safety). +### Template directives + +A payload template can mark whole lines as conditional on a config knob. Directive lines are comments in the file's own syntax, so the template stays valid YAML, Dockerfile, TS or Markdown: + +```yaml +steps: + - run: pnpm typecheck + # streamctl:if ci.lint + - run: pnpm lint + # streamctl:else ci.lint + - run: echo "lint disabled" + # streamctl:end ci.lint +``` + +- `if`, optional `else`, and `end` all name the same condition; `!` negates it. No nesting. +- A condition is a config path declared in some preset's `configKeys`; `true`, a non-empty string or a non-empty array is true. +- Directive lines are always removed; the inactive branch is removed with them. +- `if`/`else`/`end` share one indentation; JSON files and `merge` files reject directives. +- Needs manifest `schemaVersion: 5`. Every mistake fails with `CONFIG_INVALID`, naming file and line. + +Full rules in [template directives](docs/reference.md#template-directives). + ## Configuration `init` scaffolds `streamctl.config.ts` at the repo root, from the payload's own template. The payload exports a typed define function (via its `./config` subpath), so the knobs get full editor inference: diff --git a/docs/reference.md b/docs/reference.md index 9c44eeb..f41e87c 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -133,6 +133,113 @@ Per-file `state` in `streamctl status` is one of: The `--json` `data` is `{ files: { path, state, strategy }[], payload: { package, pinned, installed, latest? }, profile, cliVersion, lockfileStale }`. +## Template directives + +Payload templates and fragments can mark runs of whole lines as conditional. Requires manifest `schemaVersion: 5` (`DIRECTIVES_MIN_SCHEMA_VERSION`). + +### Grammar + +One directive per line: optional indent, the file's comment token, optional spaces, `streamctl:`, the verb, one or more spaces, the condition, and ` -->` for HTML-comment files. Nothing else may follow. + +``` + streamctl:if + streamctl:else (optional, at most one) + streamctl:end +``` + +- The condition is `[!]segment(.segment)*`, segments `[A-Za-z0-9_-]+`. `!` negates. +- `else` and `end` repeat the `if` condition byte for byte, `!` included. +- Blocks do not nest. Empty bodies and empty `else` branches are allowed. +- A trailing `\r` is tolerated; every other line is kept byte for byte. +- Only a line that starts (after whitespace) with the comment token and `streamctl:` is a directive. `run: echo "# streamctl:if x"` is content. + +### Where directives work + +| Comment token | Files | +| ------------- | ----- | +| `#` | YAML, Dockerfile, ignore files, `.editorconfig`, ini, sh, any unknown extension | +| `//` | js, mjs, cjs, ts, mts, cts | +| `` | md, markdown, html, htm, vue | + +`full`, `scaffold` and `block` templates accept directives, with or without a `render` definition, as do fragment sources. `.json`, `.jsonc` and `.json5` targets reject them (`structured-json`), and so do `merge` templates (`merge-strategy`). The token is chosen by the target path for templates and by the source path for fragments. + +### Pipeline order + +1. When the preset chain resolves, every template and fragment source is read and scanned, including files switched off by `files: off` or `enabledBy`. Syntax, placement, schema-version and condition-declaration errors fail every command here. +2. Per active file: the directive pass runs on the template body, then on each fragment body. +3. Fragments are appended (`toggle`, `forEach`). +4. Placeholders are substituted. A `${TOKEN}` inside a dropped block is gone and needs no value. +5. Structured output (`.json*`, `.ya?ml`) is parse-checked. + +The directive pass removes every directive line, the `if` branch of a false block and the `else` branch of a true one. + +### Condition truthiness + +The condition must be declared in the merged `configKeys`, either exactly or under an `object` key (`ci` declared as `object` allows `ci.lint`). Naming the `object` key itself is an error. + +| Config value | Result | +| ------------ | ------ | +| `true` | true | +| non-empty string | true | +| array with at least one element | true | +| absent, `false`, `""`, `[]` | false | +| number, object, `null` | `CONFIG_INVALID` (`unevaluable-value`) | + +### Placement rules + +- `if`, `else` and `end` use identical leading whitespace. +- In `.yaml`/`.yml` targets every non-blank line of both branches starts with the directives' indentation. YAML itself allows comments at any indentation; this is a deliberate lint. +- With `else`, the template must stay valid with both branches present. Alternative list items, Dockerfile instructions, Markdown paragraphs or TS statements are fine. Two versions of the same YAML mapping key are not (the YAML language server flags the duplicate); use a placeholder for key-level variants. + +### Reserved namespace + +`streamctl:` right after a comment token is reserved. Prose like `# streamctl: the CLI` is an `unknown-directive` error. The verbs `each`, `insert` and `raw` are reserved for a future version and fail the same way with a hint. + +### Errors + +Every directive error is `CONFIG_INVALID` (exit `1`). The message reads `:: `; `details` carries `file`, `line`, `reason` and, where relevant, `directive`, `condition`, `expected`, `openedAt`, `found`. + +| `reason` | Meaning | +| -------- | ------- | +| `unknown-directive` | Verb is not `if`, `else` or `end` (reserved verbs get a hint) | +| `missing-condition` | Directive without a condition | +| `malformed-condition` | Condition fails the grammar, extra text on the line, or missing `-->` | +| `unopened-end` | `end` with no open block | +| `unopened-else` | `else` with no open block | +| `duplicate-else` | Second `else` in one block | +| `unclosed-if` | `if` without `end` before the end of the text | +| `end-mismatch` | `end` names a different condition than the open `if` | +| `else-mismatch` | `else` names a different condition than the open `if` | +| `nested-if` | `if` while another block is open | +| `indent-mismatch` | `else` or `end` indented differently from its `if` | +| `body-outdented` | YAML only: a body line indented less than the directives | +| `structured-json` | Directive in a `.json`/`.jsonc`/`.json5` target | +| `merge-strategy` | Directive in a `merge` template | +| `schema-version` | Directive present but the manifest declares `schemaVersion` below 5 | +| `undeclared-condition` | Condition not declared in any preset's `configKeys` | +| `object-condition` | Condition names an `object` key exactly | +| `unevaluable-value` | At render: the consumer config holds a number, object or `null` at the condition | + +Payload tooling can list a template's blocks with `scanDirectives(text, path)` from `@sidebase/streamctl/manifest`. + +### Caveats + +- `scaffold` files evaluate directives once, when they are written. Later config changes do not touch them. +- Dockerfile parser directives (`# syntax=`, `# escape=`) must stay the first lines. Docker stops looking for them after the first ordinary comment, so put streamctl directives after them. Comment lines inside a `RUN \` continuation are removed by Docker, so a block inside a continuation works as long as the continuation does not end on a removed line. +- Inside a YAML block scalar (`run: |`) a `# streamctl:if` line is string content to YAML but still a directive to streamctl. It is removed before YAML parses the output. +- Flipping a knob that changes a block in a `full` file shows up as drift, like any other render change (see [Known caveats](#known-caveats)). + +### Indent-aware substitution + +A multiline placeholder value takes the indentation of the line holding its token: continuation lines get that line's leading whitespace, empty continuation lines stay empty. Tokens at column 0 render as before. This is not gated by `schemaVersion`. + +```yaml +run: | + ${SCRIPT} +``` + +With `SCRIPT` = `"pnpm lint\npnpm test"` this renders both commands at the `run` body's indentation. + ## Known caveats **Type-aware lint needs generated types first.** With `LINT_TYPEAWARE=true`, the Prisma client and the Nuxt types have to exist before `lint` (and `typecheck`) runs, so run `prisma generate` and `nuxi prepare` first. The managed `ci.yaml` already orders `prisma generate` ahead of `lint`, and `postinstall: "nuxt prepare"` covers local installs. There is no preflight check for this. diff --git a/docs/release.md b/docs/release.md index 8c61dba..e5651c1 100644 --- a/docs/release.md +++ b/docs/release.md @@ -70,8 +70,22 @@ anything that moves that envelope, the exit codes, or `schemaVersion`. Generated notes only cover commit subjects, so add these by hand. -- **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. +- **Manifest schemaVersion 5: template directives.** Templates and fragments can + wrap lines in `streamctl:if ` / `streamctl:else ` / + `streamctl:end ` comments; inactive branches and all directive lines are + removed at render. See [template directives](reference.md#template-directives). +- `scanDirectives`, `commentSyntaxFor` and `DIRECTIVES_MIN_SCHEMA_VERSION` are + exported from `@sidebase/streamctl/manifest`; `SUPPORTED_SCHEMA_VERSION` is now 5. +- Chain resolution now reads and scans every template and fragment source, + including files that are `off` or disabled by `enabledBy`. A payload missing a + template source therefore fails every command up front, not only when the file + is active. +- `init` loads the `streamctl.config.ts` it just scaffolded and runs the first sync + with it, so payload wrapper defaults apply right away. If the file cannot be + loaded, `init` prints a one-line warning and falls back to package, base, + version and profile only, as before. +- Indent-aware substitution: continuation lines of a multiline placeholder value + get the token line's indentation. Not gated; tokens at column 0 render as before. +- v2 to v4 payloads parse unchanged; directives need `schemaVersion: 5` and this + CLI or newer. - Minor bump: new schema version, no removals. From d5464a2c01ca764fa5d27bf88ce4e28d478dbdfc Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 19:44:39 +0200 Subject: [PATCH 15/32] test: assert directive errors from the public manifest import --- test/manifest-exports.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/manifest-exports.test.ts b/test/manifest-exports.test.ts index e5c7036..fde7987 100644 --- a/test/manifest-exports.test.ts +++ b/test/manifest-exports.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { StreamctlError } from "../src/errors"; import { commentSyntaxFor, DIRECTIVES_MIN_SCHEMA_VERSION, scanDirectives, SUPPORTED_SCHEMA_VERSION } from "../src/manifest"; describe("@sidebase/streamctl/manifest exports", () => { @@ -9,6 +10,17 @@ describe("@sidebase/streamctl/manifest exports", () => { expect(commentSyntaxFor("README.md")).toEqual({ prefix: "" }); }); + it("throws StreamctlError with a reason for directive mistakes", () => { + let caught: unknown; + try { + scanDirectives("# streamctl:if ci.lint\n", "ci.yaml"); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(StreamctlError); + expect(caught).toMatchObject({ code: "CONFIG_INVALID", details: { file: "ci.yaml", line: 1, reason: "unclosed-if" } }); + }); + it("exposes the schema constants", () => { expect(SUPPORTED_SCHEMA_VERSION).toBe(5); expect(DIRECTIVES_MIN_SCHEMA_VERSION).toBe(5); From ed077d77ebe8a93313307dab413b2907f44fe0d4 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 19:46:42 +0200 Subject: [PATCH 16/32] docs: correct directive error shapes and caveats in the reference --- README.md | 2 +- docs/reference.md | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e2b0915..ba1d7a4 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ steps: - A condition is a config path declared in some preset's `configKeys`; `true`, a non-empty string or a non-empty array is true. - Directive lines are always removed; the inactive branch is removed with them. - `if`/`else`/`end` share one indentation; JSON files and `merge` files reject directives. -- Needs manifest `schemaVersion: 5`. Every mistake fails with `CONFIG_INVALID`, naming file and line. +- Needs manifest `schemaVersion: 5`. Every mistake fails with `CONFIG_INVALID`, naming the file (and line). Full rules in [template directives](docs/reference.md#template-directives). diff --git a/docs/reference.md b/docs/reference.md index f41e87c..cdd6e8c 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -139,7 +139,7 @@ Payload templates and fragments can mark runs of whole lines as conditional. Req ### Grammar -One directive per line: optional indent, the file's comment token, optional spaces, `streamctl:`, the verb, one or more spaces, the condition, and ` -->` for HTML-comment files. Nothing else may follow. +One directive per line: optional indent, the file's comment token, optional spaces, `streamctl:`, the verb, one or more spaces, the condition, and for HTML-comment files optional spaces then `-->`. Nothing else may follow. ``` streamctl:if @@ -158,7 +158,7 @@ One directive per line: optional indent, the file's comment token, optional spac | Comment token | Files | | ------------- | ----- | | `#` | YAML, Dockerfile, ignore files, `.editorconfig`, ini, sh, any unknown extension | -| `//` | js, mjs, cjs, ts, mts, cts | +| `//` | js, mjs, cjs, ts, mts, cts (json, jsonc and json5 also use `//` but reject directives) | | `` | md, markdown, html, htm, vue | `full`, `scaffold` and `block` templates accept directives, with or without a `render` definition, as do fragment sources. `.json`, `.jsonc` and `.json5` targets reject them (`structured-json`), and so do `merge` templates (`merge-strategy`). The token is chosen by the target path for templates and by the source path for fragments. @@ -197,7 +197,11 @@ The condition must be declared in the merged `configKeys`, either exactly or und ### Errors -Every directive error is `CONFIG_INVALID` (exit `1`). The message reads `:: `; `details` carries `file`, `line`, `reason` and, where relevant, `directive`, `condition`, `expected`, `openedAt`, `found`. +Every directive error is `CONFIG_INVALID` (exit `1`), with `details.reason` set to one of the values below. + +- Parse and chain-time errors read `presets/:: `. `details` carries `file`, `line`, `reason` and, where relevant, `directive`, `condition`, `expected`, `openedAt`, `found`, `required`. +- `undeclared-condition` uses the message shared with placeholders and `enabledBy`: `template "presets/" line N references config path "...", which no preset declares in configKeys.` (`fragment "..."` for fragments). `details` carries `path`, `declared`, `file`, `line`, `condition`, `reason`. +- `unevaluable-value` is raised at render time against the target path: `: condition "..." has a value in the config, ...`. `details` is `{ file, condition, reason, found }`, without `line`. | `reason` | Meaning | | -------- | ------- | @@ -225,9 +229,9 @@ Payload tooling can list a template's blocks with `scanDirectives(text, path)` f ### Caveats - `scaffold` files evaluate directives once, when they are written. Later config changes do not touch them. -- Dockerfile parser directives (`# syntax=`, `# escape=`) must stay the first lines. Docker stops looking for them after the first ordinary comment, so put streamctl directives after them. Comment lines inside a `RUN \` continuation are removed by Docker, so a block inside a continuation works as long as the continuation does not end on a removed line. +- Dockerfile parser directives (`# syntax=`, `# escape=`) must stay the first lines. Docker stops looking for them after the first ordinary comment, so put streamctl directives after them. A block inside a `RUN \` continuation must not hold the line that ends the continuation: dropping it leaves a dangling `\`. - Inside a YAML block scalar (`run: |`) a `# streamctl:if` line is string content to YAML but still a directive to streamctl. It is removed before YAML parses the output. -- Flipping a knob that changes a block in a `full` file shows up as drift, like any other render change (see [Known caveats](#known-caveats)). +- Flipping a knob that changes a block in a `full` file is a render change like any other: `check` reports drift (exit `3`), `status` shows the file as `conflict`, and `sync` blocks it as an `edit` conflict until you resolve it with `--interactive` or `--force` (see [Known caveats](#known-caveats)). ### Indent-aware substitution From b43d0f89957f1322fe80c0ef8afacb16365366c0 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 19:53:13 +0200 Subject: [PATCH 17/32] feat(manifest): accept join: "comma" on placeholders, gated at schema 5 --- src/engine/manifest.ts | 15 ++++++++++++++- src/manifest/schema.ts | 9 ++++++--- test/manifest-exports.test.ts | 3 ++- test/manifest-loader.test.ts | 26 ++++++++++++++++++++++++++ test/manifest-schema.test.ts | 6 ++++++ 5 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/engine/manifest.ts b/src/engine/manifest.ts index 00a554f..ceee3cd 100644 --- a/src/engine/manifest.ts +++ b/src/engine/manifest.ts @@ -5,7 +5,7 @@ import type { ConfigKeyType, ManagedFile as ManifestManagedFile, PayloadManifest import type { PayloadHandle } from "../payload/resolve"; import { scanDirectives } from "../directives"; import { StreamctlError } from "../errors"; -import { DIRECTIVES_MIN_SCHEMA_VERSION, FROM_DEPENDENCY_MIN_SCHEMA_VERSION, OMIT_LINE_MIN_SCHEMA_VERSION, payloadManifestSchema, presetManifestSchema, SUPPORTED_SCHEMA_VERSION_LIST, SUPPORTED_SCHEMA_VERSIONS, zodToIssues } from "../manifest/schema"; +import { COMMA_JOIN_MIN_SCHEMA_VERSION, DIRECTIVES_MIN_SCHEMA_VERSION, 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"; @@ -127,6 +127,19 @@ export async function loadPresetManifest(payload: PayloadHandle, name: string, m } } } + if (manifest.schemaVersion < COMMA_JOIN_MIN_SCHEMA_VERSION) { + for (const [render, def] of Object.entries(preset.renders ?? {})) { + for (const [key, placeholder] of Object.entries(def.placeholders ?? {})) { + if (placeholder.join === "comma") { + throw new StreamctlError( + "CONFIG_INVALID", + `${file} renders.${render} placeholder "${key}" uses join: "comma", which requires schemaVersion ${COMMA_JOIN_MIN_SCHEMA_VERSION}, but ${PAYLOAD_MANIFEST} declares schemaVersion ${manifest.schemaVersion}.`, + { file, preset: name, render, placeholder: key, found: manifest.schemaVersion, required: COMMA_JOIN_MIN_SCHEMA_VERSION }, + ); + } + } + } + } return preset; } diff --git a/src/manifest/schema.ts b/src/manifest/schema.ts index ecc1ad6..4770d3f 100644 --- a/src/manifest/schema.ts +++ b/src/manifest/schema.ts @@ -39,13 +39,16 @@ export const OMIT_LINE_MIN_SCHEMA_VERSION = 4; /** Lowest `schemaVersion` a payload may declare while using template directives (`streamctl:if`/`else`/`end`). */ export const DIRECTIVES_MIN_SCHEMA_VERSION = 5; +/** Lowest `schemaVersion` a payload may declare while using placeholder `join: "comma"`. */ +export const COMMA_JOIN_MIN_SCHEMA_VERSION = 5; + /** 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]; /** - * A `string[]` value is deduped + sorted, then joined per `join` (`space` or - * `lines`). Substitution repeats until the text stops changing (at most 10 passes, + * A `string[]` value is deduped + sorted, then joined per `join` (`space`, `lines` or + * `comma`; `comma` requires `schemaVersion` {@link COMMA_JOIN_MIN_SCHEMA_VERSION}). Substitution repeats until the text stops changing (at most 10 passes, * so a self-referencing value fails); any leftover `${…}` then fails render.ts's * leftover-token check. * @@ -62,7 +65,7 @@ export const placeholderSchema = z.strictObject({ fromDependency: z.string().min(1).optional(), default: z.string(), pattern: z.string().min(1).optional(), - join: z.enum(["space", "lines"]).optional(), + join: z.enum(["space", "lines", "comma"]).optional(), omitLineIfEmpty: z.boolean().optional(), }); diff --git a/test/manifest-exports.test.ts b/test/manifest-exports.test.ts index fde7987..46f0949 100644 --- a/test/manifest-exports.test.ts +++ b/test/manifest-exports.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { StreamctlError } from "../src/errors"; -import { commentSyntaxFor, DIRECTIVES_MIN_SCHEMA_VERSION, scanDirectives, SUPPORTED_SCHEMA_VERSION } from "../src/manifest"; +import { COMMA_JOIN_MIN_SCHEMA_VERSION, commentSyntaxFor, DIRECTIVES_MIN_SCHEMA_VERSION, scanDirectives, SUPPORTED_SCHEMA_VERSION } from "../src/manifest"; describe("@sidebase/streamctl/manifest exports", () => { it("exposes the directive scanner and comment syntax", () => { @@ -24,5 +24,6 @@ describe("@sidebase/streamctl/manifest exports", () => { it("exposes the schema constants", () => { expect(SUPPORTED_SCHEMA_VERSION).toBe(5); expect(DIRECTIVES_MIN_SCHEMA_VERSION).toBe(5); + expect(COMMA_JOIN_MIN_SCHEMA_VERSION).toBe(5); }); }); diff --git a/test/manifest-loader.test.ts b/test/manifest-loader.test.ts index c018730..a9618c7 100644 --- a/test/manifest-loader.test.ts +++ b/test/manifest-loader.test.ts @@ -185,6 +185,32 @@ describe("omitLineIfEmpty feature gate", () => { }); }); +describe("join: comma feature gate", () => { + function withCommaJoin(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: { BUILT: { configPath: "pnpm.onlyBuiltDependencies", default: "", join: "comma" } } } }, + configKeys: { "pnpm.onlyBuiltDependencies": "string[]" }, + }, + }); + } + + it("lets a v5 payload declare it", async () => { + const { files } = await resolvePresetChain(withCommaJoin(5), "base", "nuxt-4"); + expect(files.find(f => f.path === "Dockerfile")?.renderDef?.placeholders?.BUILT?.join).toBe("comma"); + }); + + it("refuses it under a v4 payload, naming the placeholder and the version it needs", async () => { + const error = await caught(resolvePresetChain(withCommaJoin(4), "base", "nuxt-4")); + expect(error.code).toBe("CONFIG_INVALID"); + expect(error.message).toBe("presets/base/preset.json renders.dockerfile placeholder \"BUILT\" uses join: \"comma\", which requires schemaVersion 5, but presets/manifest.json declares schemaVersion 4."); + expect(error.details).toMatchObject({ file: "presets/base/preset.json", render: "dockerfile", placeholder: "BUILT", found: 4, required: 5 }); + }); +}); + 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 6781a83..a8f7ef5 100644 --- a/test/manifest-schema.test.ts +++ b/test/manifest-schema.test.ts @@ -2,6 +2,7 @@ import type { ZodError } from "zod"; import { describe, expect, it } from "vitest"; import { CONFIG_FILE, LEGACY_CONFIG_FILE } from "../src/config/resolve"; import { + COMMA_JOIN_MIN_SCHEMA_VERSION, DIRECTIVES_MIN_SCHEMA_VERSION, managedFileSchema, payloadManifestSchema, @@ -224,6 +225,10 @@ describe("RenderDef", () => { expect(renderDefSchema.safeParse({ fragments: [{ toggle: "ci.e2e", source: "a" }, { forEach: "ci.deploy", source: "b" }] }).success).toBe(true); }); + it("accepts join: comma", () => { + expect(renderDefSchema.safeParse({ placeholders: { X: { configPath: "a", default: "", join: "comma" } } }).success).toBe(true); + }); + it("rejects an invalid placeholder join", () => { expect(renderDefSchema.safeParse({ placeholders: { X: { configPath: "a", default: "", join: "csv" } } }).success).toBe(false); }); @@ -302,6 +307,7 @@ describe("PayloadManifest schemaVersion contract", () => { expect([...SUPPORTED_SCHEMA_VERSIONS].sort((a, b) => a - b)).toEqual([2, 3, 4, 5]); expect(SUPPORTED_SCHEMA_VERSION_LIST).toBe("2, 3, 4, 5"); expect(DIRECTIVES_MIN_SCHEMA_VERSION).toBe(5); + expect(COMMA_JOIN_MIN_SCHEMA_VERSION).toBe(5); }); // Plain refinement failure here; the loader is what turns it into SCHEMA_UNSUPPORTED. From 3f166574fde38965b4a60dbebd5e0aadfd6a75b1 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 19:53:50 +0200 Subject: [PATCH 18/32] fix(manifest): type directive error found/required as the values actually emitted --- src/directives.ts | 5 ++++- src/engine/manifest.ts | 8 ++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/directives.ts b/src/directives.ts index 3dafc4d..8c1f366 100644 --- a/src/directives.ts +++ b/src/directives.ts @@ -33,7 +33,10 @@ export interface DirectiveErrorDetails { condition?: string; expected?: string; openedAt?: number; - found?: string; + /** Offending value: a type name (`unevaluable-value`) or the declared `schemaVersion` (`schema-version`). */ + found?: string | number; + /** Minimum `schemaVersion` (`schema-version`). */ + required?: number; } export interface DirectiveBlock { diff --git a/src/engine/manifest.ts b/src/engine/manifest.ts index ceee3cd..b45cfeb 100644 --- a/src/engine/manifest.ts +++ b/src/engine/manifest.ts @@ -1,6 +1,6 @@ import type { ZodError } from "zod"; import type { ManagedFile as EngineManagedFile } from "../config/types"; -import type { DirectiveBlock } from "../directives"; +import type { DirectiveBlock, DirectiveErrorDetails } from "../directives"; import type { ConfigKeyType, ManagedFile as ManifestManagedFile, PayloadManifest, PresetManifest, RenderDef } from "../manifest/schema"; import type { PayloadHandle } from "../payload/resolve"; import { scanDirectives } from "../directives"; @@ -216,14 +216,14 @@ async function validateDirectives( throw new StreamctlError( "CONFIG_INVALID", `${file}:${first.ifLine}: uses template directives, which requires schemaVersion ${DIRECTIVES_MIN_SCHEMA_VERSION}, but ${PAYLOAD_MANIFEST} declares schemaVersion ${schemaVersion}.`, - { file, line: first.ifLine, reason: "schema-version", found: schemaVersion, required: DIRECTIVES_MIN_SCHEMA_VERSION }, + { file, line: first.ifLine, reason: "schema-version", found: schemaVersion, required: DIRECTIVES_MIN_SCHEMA_VERSION } satisfies DirectiveErrorDetails, ); } if (target.strategy === "merge") { throw new StreamctlError( "CONFIG_INVALID", `${file}:${first.ifLine}: template directives are not supported in merge-strategy files.`, - { file, line: first.ifLine, reason: "merge-strategy" }, + { file, line: first.ifLine, reason: "merge-strategy" } satisfies DirectiveErrorDetails, ); } for (const block of blocks) { @@ -232,7 +232,7 @@ async function validateDirectives( throw new StreamctlError( "CONFIG_INVALID", `${file}:${block.ifLine}: condition "${block.condition}" names an object config key; use one of its fields instead.`, - { ...details, reason: "object-condition" }, + { ...details, reason: "object-condition" } satisfies DirectiveErrorDetails, ); } configPathRefs.push({ From 2f47786b4729a8fecb204eea3558e6c4d0034a2e Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 19:53:50 +0200 Subject: [PATCH 19/32] test: narrow directive error details with a type guard instead of a cast --- test/directives.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/directives.test.ts b/test/directives.test.ts index 5284e2f..f78ba06 100644 --- a/test/directives.test.ts +++ b/test/directives.test.ts @@ -3,6 +3,10 @@ import { describe, expect, it, vi } from "vitest"; import { applyDirectives, scanDirectives } from "../src/directives"; import { StreamctlError } from "../src/errors"; +function isDirectiveErrorDetails(value: unknown): value is DirectiveErrorDetails { + return typeof value === "object" && value !== null && "file" in value && typeof value.file === "string" && "line" in value && typeof value.line === "number" && "reason" in value && typeof value.reason === "string"; +} + function scanError(text: string, path = "ci.yaml"): { message: string; details: DirectiveErrorDetails } { try { scanDirectives(text, path); @@ -11,7 +15,10 @@ function scanError(text: string, path = "ci.yaml"): { message: string; details: throw error; } expect(error.code).toBe("CONFIG_INVALID"); - return { message: error.message, details: error.details as DirectiveErrorDetails }; + if (!isDirectiveErrorDetails(error.details)) { + throw new Error(`unexpected directive error details: ${JSON.stringify(error.details)}`); + } + return { message: error.message, details: error.details }; } throw new Error("expected scanDirectives to throw"); } From 99778adf265fde0237acb877adadda46cb9d4707 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 19:55:49 +0200 Subject: [PATCH 20/32] feat(render): join string[] placeholders with ", " for join: "comma" --- docs/reference.md | 12 ++++++++++++ src/engine/render.ts | 31 ++++++++++++++++++------------- test/render-generic.test.ts | 19 +++++++++++++++++++ 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index cdd6e8c..d2522a8 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -244,6 +244,18 @@ run: | With `SCRIPT` = `"pnpm lint\npnpm test"` this renders both commands at the `run` body's indentation. +### Placeholder join modes + +A `string[]` placeholder value is deduped and sorted alphabetically (not config order), then joined per `join`: + +| `join` | Separator | Shell-metacharacter check | Needs `schemaVersion` | +| --- | --- | --- | --- | +| `space` (default) | `" "` | yes, per item | 2 | +| `lines` | `"\n"` | no | 2 | +| `comma` | `", "` | no | 5 | + +The metacharacter check rejects whitespace and `` ; | & $ ` < > ( ) { } \ ' " * ? [ ] `` in each item, because space-joined values typically land in an unquoted shell interpolation. `comma` targets YAML flow sequences such as `branches: [${BRANCHES}]` and skips it, so `release/*` passes; guard it with the placeholder `pattern`, which is tested against the whole joined value in every mode. + ## Known caveats **Type-aware lint needs generated types first.** With `LINT_TYPEAWARE=true`, the Prisma client and the Nuxt types have to exist before `lint` (and `typecheck`) runs, so run `prisma generate` and `nuxi prepare` first. The managed `ci.yaml` already orders `prisma generate` ahead of `lint`, and `postinstall: "nuxt prepare"` covers local installs. There is no preflight check for this. diff --git a/src/engine/render.ts b/src/engine/render.ts index 99f85b9..8326cdb 100644 --- a/src/engine/render.ts +++ b/src/engine/render.ts @@ -45,7 +45,8 @@ function configStringList(config: StreamctlConfig | undefined, path: string): st // Space-joined values land in a single unquoted shell interpolation (e.g. // `apt-get install $APT_PACKAGES`), so any of these chars could inject commands. -// A floor, independent of the payload's optional `pattern`. +// A floor, independent of the payload's optional `pattern`. `comma` skips it: comma +// lists target YAML flow sequences, not shell interpolation, so `pattern` is the guard. const SHELL_META_RE = /[\s;|&$`<>(){}\\'"*?[\]]/; /** A full `x.y.z` triple with an optional prerelease tail. */ @@ -77,20 +78,24 @@ function resolvePlaceholder(def: Placeholder, config: StreamctlConfig | undefine const raw = readConfigPath(config, def.configPath); if (Array.isArray(raw)) { const list = [...new Set(raw.filter((item): item is string => typeof item === "string"))].sort(); - if (def.join !== "lines") { - // Reject metacharacters per item, before the join spaces get added. - for (const item of list) { - if (SHELL_META_RE.test(item)) { - throw new StreamctlError( - "CONFIG_INVALID", - `render placeholder "${key}" for "${filePath}" has a space-joined value ${JSON.stringify(item)} containing a shell metacharacter (whitespace or one of ; | & $ \` < > ( ) { } \\ ' " * ? [ ]).`, - { file: filePath, placeholder: key, value: item }, - ); + switch (def.join) { + case "lines": + return list.join("\n"); + case "comma": + return list.join(", "); + default: + // Reject metacharacters per item, before the join spaces get added. + for (const item of list) { + if (SHELL_META_RE.test(item)) { + throw new StreamctlError( + "CONFIG_INVALID", + `render placeholder "${key}" for "${filePath}" has a space-joined value ${JSON.stringify(item)} containing a shell metacharacter (whitespace or one of ; | & $ \` < > ( ) { } \\ ' " * ? [ ]).`, + { file: filePath, placeholder: key, value: item }, + ); + } } - } - return list.join(" "); + return list.join(" "); } - return list.join("\n"); } if (typeof raw === "string") { return raw; diff --git a/test/render-generic.test.ts b/test/render-generic.test.ts index ad91c2d..f57060f 100644 --- a/test/render-generic.test.ts +++ b/test/render-generic.test.ts @@ -99,6 +99,25 @@ describe("renderFile: placeholders", () => { const def: RenderDef = { placeholders: { ENVS: { configPath: "aptPackages", default: "", join: "lines" } } }; expect(renderFile("${ENVS}\n", def, cfg({ aptPackages: ["A=1;2", "B=3"] }), {})).toBe("A=1;2\nB=3\n"); }); + + it("comma-joins a deduped, sorted string[] for a YAML flow sequence", () => { + const def: RenderDef = { placeholders: { BRANCHES: { configPath: "aptPackages", default: "", join: "comma" } } }; + expect(renderFile("branches: [${BRANCHES}]\n", def, cfg({ aptPackages: ["main", "dev", "main"] }), {})).toBe("branches: [dev, main]\n"); + }); + + it("skips the shell-meta floor for comma-joined values but keeps it for space", () => { + const comma: RenderDef = { placeholders: { BRANCHES: { configPath: "aptPackages", default: "", join: "comma" } } }; + expect(renderFile("branches: [${BRANCHES}]\n", comma, cfg({ aptPackages: ["release/*", "main"] }), {})).toBe("branches: [main, release/*]\n"); + + const space: RenderDef = { placeholders: { BRANCHES: { configPath: "aptPackages", default: "", join: "space" } } }; + expect(() => renderFile("${BRANCHES}\n", space, cfg({ aptPackages: ["release/*"] }), {})).toThrow(/shell metacharacter/); + }); + + it("tests `pattern` against the whole comma-joined value", () => { + const def: RenderDef = { placeholders: { BRANCHES: { configPath: "aptPackages", default: "", join: "comma", pattern: "^[a-z]+(, [a-z]+)*$" } } }; + expect(renderFile("[${BRANCHES}]\n", def, cfg({ aptPackages: ["main", "dev"] }), {})).toBe("[dev, main]\n"); + expect(() => renderFile("[${BRANCHES}]\n", def, cfg({ aptPackages: ["main", "re]lease"] }), {})).toThrow(/has value "main, re\]lease", which violates its pattern/); + }); }); describe("renderFile: fromDependency", () => { From 98343ca3899afc823c2039091cec55c98664bcdc Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 19:55:49 +0200 Subject: [PATCH 21/32] docs(release): bump schemaVersion to 5 in versioning and note comma join --- docs/release.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release.md b/docs/release.md index e5651c1..8098aae 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 -`4`), not the package version. A CLI accepts a set of versions — `{2, 3, 4}` today — +`5`), not the package version. A CLI accepts a set of versions — `{2, 3, 4, 5}` 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 @@ -74,6 +74,10 @@ Generated notes only cover commit subjects, so add these by hand. wrap lines in `streamctl:if ` / `streamctl:else ` / `streamctl:end ` comments; inactive branches and all directive lines are removed at render. See [template directives](reference.md#template-directives). +- **Placeholder `join: "comma"`** (schemaVersion 5): a `string[]` knob renders as + `a, b, c`, e.g. for YAML flow sequences like `branches: [${BRANCHES}]`. No + shell-metacharacter check in this mode; use the placeholder `pattern`. Gate + constant `COMMA_JOIN_MIN_SCHEMA_VERSION` is exported from `./manifest`. - `scanDirectives`, `commentSyntaxFor` and `DIRECTIVES_MIN_SCHEMA_VERSION` are exported from `@sidebase/streamctl/manifest`; `SUPPORTED_SCHEMA_VERSION` is now 5. - Chain resolution now reads and scans every template and fragment source, From fcf8948848b9bb413f5b9287dd1b0b697d84efcf Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 19:55:49 +0200 Subject: [PATCH 22/32] style(manifest): re-wrap placeholder join JSDoc --- src/manifest/schema.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/manifest/schema.ts b/src/manifest/schema.ts index 4770d3f..9f25c0b 100644 --- a/src/manifest/schema.ts +++ b/src/manifest/schema.ts @@ -48,8 +48,9 @@ export type ConfigKeyType = (typeof CONFIG_KEY_TYPES)[number]; /** * A `string[]` value is deduped + sorted, then joined per `join` (`space`, `lines` or - * `comma`; `comma` requires `schemaVersion` {@link COMMA_JOIN_MIN_SCHEMA_VERSION}). Substitution repeats until the text stops changing (at most 10 passes, - * so a self-referencing value fails); any leftover `${…}` then fails render.ts's + * `comma`; `comma` requires `schemaVersion` {@link COMMA_JOIN_MIN_SCHEMA_VERSION}). + * Substitution repeats until the text stops changing (at most 10 passes, so a + * self-referencing value fails); any leftover `${…}` then fails render.ts's * leftover-token check. * * `fromDependency` names an npm package whose pin in the consumer's package.json supplies From 774073f4649193fe8b2ac9a7d0213e9e5927bf42 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 20:34:09 +0200 Subject: [PATCH 23/32] fix(render): only resolve placeholders reachable from the rendered text --- docs/reference.md | 2 +- docs/release.md | 5 +++++ src/engine/render.ts | 20 +++++++++++++++++--- test/directives.compose.test.ts | 7 +++++++ test/render-generic.test.ts | 17 +++++++++++++++++ 5 files changed, 47 insertions(+), 4 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index d2522a8..0a8eebd 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -168,7 +168,7 @@ One directive per line: optional indent, the file's comment token, optional spac 1. When the preset chain resolves, every template and fragment source is read and scanned, including files switched off by `files: off` or `enabledBy`. Syntax, placement, schema-version and condition-declaration errors fail every command here. 2. Per active file: the directive pass runs on the template body, then on each fragment body. 3. Fragments are appended (`toggle`, `forEach`). -4. Placeholders are substituted. A `${TOKEN}` inside a dropped block is gone and needs no value. +4. Placeholders are substituted. A `${TOKEN}` inside a dropped block is gone and needs no value: placeholders are only resolved and pattern-checked when their token is reachable from the rendered text. 5. Structured output (`.json*`, `.ya?ml`) is parse-checked. The directive pass removes every directive line, the `if` branch of a false block and the `else` branch of a true one. diff --git a/docs/release.md b/docs/release.md index 8098aae..9c6aeed 100644 --- a/docs/release.md +++ b/docs/release.md @@ -90,6 +90,11 @@ Generated notes only cover commit subjects, so add these by hand. version and profile only, as before. - Indent-aware substitution: continuation lines of a multiline placeholder value get the token line's indentation. Not gated; tokens at column 0 render as before. +- Placeholders are resolved and pattern-checked only when their token is reachable + from the rendered text, directly or through another placeholder's value. A + declared placeholder whose token is unused (or sits in a dropped directive block) + no longer fails the render. Nested values resolve one level per substitution pass; + the 10-pass cap is unchanged. - v2 to v4 payloads parse unchanged; directives need `schemaVersion: 5` and this CLI or newer. - Minor bump: new schema version, no removals. diff --git a/src/engine/render.ts b/src/engine/render.ts index 8326cdb..b4acc2c 100644 --- a/src/engine/render.ts +++ b/src/engine/render.ts @@ -208,11 +208,24 @@ 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. + // Values come from the config and the dependency map, never from the output being + // assembled. Only placeholders reachable from the assembled text (directly or through + // another value) are resolved and pattern-checked, so a token in a dropped directive + // block needs no valid value. + const defs = renderDef.placeholders ?? {}; const values = new Map(); const omitTokens: string[] = []; - for (const [key, def] of Object.entries(renderDef.placeholders ?? {})) { + const pending = [...output.matchAll(LEFTOVER_TOKEN_RE)].map(match => match[1] ?? ""); + const seen = new Set(); + for (let key = pending.shift(); key !== undefined; key = pending.shift()) { + if (seen.has(key) || !Object.hasOwn(defs, key)) { + continue; + } + const def = defs[key]; + if (def === undefined) { + continue; + } + seen.add(key); const value = resolvePlaceholder(def, config, key, filePath, deps); if (def.pattern !== undefined && !new RegExp(def.pattern).test(value)) { throw new StreamctlError( @@ -226,6 +239,7 @@ export function renderFile( continue; } values.set(key, value); + pending.push(...[...value.matchAll(LEFTOVER_TOKEN_RE)].map(match => match[1] ?? "")); } const passthrough = new Set(renderDef.passthrough ?? []); diff --git a/test/directives.compose.test.ts b/test/directives.compose.test.ts index b5a9de1..67224d7 100644 --- a/test/directives.compose.test.ts +++ b/test/directives.compose.test.ts @@ -108,6 +108,13 @@ describe("compose: directive pass", () => { expect(content(await compose(file, payload, null, cfg()))).toBe("jobs:\n lint: true\n"); }); + it("skips the pattern check of a placeholder used only in a dropped block", async () => { + const renderDef = { placeholders: { DEPLOY: { configPath: "ci.target", default: "", pattern: "^[a-z]+$" } } }; + const file: ManagedFile = { path: ".github/workflows/ci.yaml", strategy: "full", source: "base/ci.yaml", renderDef }; + expect(content(await compose(file, payload, null, cfg()))).toBe("jobs:\n lint: true\n"); + await expect(compose(file, payload, null, cfg({ ci: { deploy: true } }))).rejects.toThrow(/violates its pattern/); + }); + it("still fails on a token inside a kept block without a placeholder", async () => { const file: ManagedFile = { path: ".github/workflows/ci.yaml", strategy: "full", source: "base/ci.yaml", renderDef: {} }; await expect(compose(file, payload, null, cfg({ ci: { deploy: true } }))).rejects.toThrow(/unresolved render token/); diff --git a/test/render-generic.test.ts b/test/render-generic.test.ts index f57060f..395d330 100644 --- a/test/render-generic.test.ts +++ b/test/render-generic.test.ts @@ -305,6 +305,23 @@ describe("renderFile: indent-aware substitution", () => { }); }); +describe("renderFile: unreachable placeholders", () => { + const def: RenderDef = { placeholders: { TARGET: { configPath: "ci.target", default: "", pattern: "^[a-z]+$" } } }; + + it("skips resolving and pattern-checking a placeholder whose token is absent", () => { + expect(renderFile("deploy: off\n", def, cfg(), {})).toBe("deploy: off\n"); + }); + + it("still checks it once the token is present", () => { + expect(() => renderFile("target: ${TARGET}\n", def, cfg(), {})).toThrow(/violates its pattern/); + }); + + it("checks a placeholder reachable only through another value", () => { + const nested: RenderDef = { placeholders: { ...def.placeholders, CMD: { configPath: "ci.cmd", default: "deploy ${TARGET}" } } }; + expect(() => renderFile("run: ${CMD}\n", nested, cfg(), {})).toThrow(/TARGET/); + }); +}); + 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"; From 3ce4b16ab8a48fcee61fa988a85b41951eee755e Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 20:34:09 +0200 Subject: [PATCH 24/32] fix(render): substitute all tokens of a line against its original indent --- src/engine/render.ts | 38 +++++++++++++++++-------------------- test/render-generic.test.ts | 8 ++++++++ 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/src/engine/render.ts b/src/engine/render.ts index b4acc2c..74bb442 100644 --- a/src/engine/render.ts +++ b/src/engine/render.ts @@ -244,27 +244,23 @@ export function renderFile( const passthrough = new Set(renderDef.passthrough ?? []); - // Per key, then per line, so continuation lines of a multiline value get the leading - // whitespace of the line holding the token (not the token's column); empty ones stay - // empty. Re-splitting per key keeps nested tokens independent of declaration order. - // 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) { - const token = `\${${key}}`; - if (!next.includes(token)) { - continue; - } - const parts = value.split("\n"); - next = next.split("\n").map((line) => { - const indent = /^[ \t]*/.exec(line)?.[0] ?? ""; - const indented = parts.map((part, index) => index === 0 || part === "" ? part : indent + part).join("\n"); - return line.replaceAll(token, () => indented); - }).join("\n"); - } - return next; - }; + // All tokens of a line are replaced in one go against that line's leading whitespace (not + // the token's column), so continuation lines of a multiline value line up and empty ones + // stay empty. Inserted values are not rescanned within the pass: nested tokens resolve on + // the next pass against the line they landed on, which keeps the output independent of + // placeholder declaration order. 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 => + text.split("\n").map((line) => { + const indent = /^[ \t]*/.exec(line)?.[0] ?? ""; + return line.replace(LEFTOVER_TOKEN_RE, (token: string, name: string) => { + const value = values.get(name); + if (value === undefined) { + return token; + } + return value.split("\n").map((part, index) => index === 0 || part === "" ? part : indent + part).join("\n"); + }); + }).join("\n"); for (let pass = 1; ; pass++) { const next = substitutePass(output); diff --git a/test/render-generic.test.ts b/test/render-generic.test.ts index 395d330..3988bb7 100644 --- a/test/render-generic.test.ts +++ b/test/render-generic.test.ts @@ -303,6 +303,14 @@ describe("renderFile: indent-aware substitution", () => { it("lands $& and $1 verbatim", () => { expect(renderFile(" ${SCRIPT}\n", script("x $& y\n$1"), cfg(), {})).toBe(" x $& y\n $1\n"); }); + + it("indents two multiline tokens on one line independent of placeholder order", () => { + const a = { configPath: "ci.a", default: "a1\n a2" }; + const b = { configPath: "ci.b", default: "b1\nb2" }; + const expected = " a1\n a2 b1\n b2\n"; + expect(renderFile(" ${A} ${B}\n", { placeholders: { A: a, B: b } }, cfg(), {})).toBe(expected); + expect(renderFile(" ${A} ${B}\n", { placeholders: { B: b, A: a } }, cfg(), {})).toBe(expected); + }); }); describe("renderFile: unreachable placeholders", () => { From cef3c56b5748cccd0dbbb3400e4b9ed2384ea73d Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 21:06:56 +0200 Subject: [PATCH 25/32] fix(manifest): treat streamctl: comments as content below schemaVersion 5 --- docs/reference.md | 4 ++-- docs/release.md | 4 ++-- src/config/types.ts | 2 ++ src/directives.ts | 40 +++++++++++++++++++++++++++------ src/engine/compose.ts | 14 +++++++----- src/engine/manifest.ts | 27 +++++++++++++++------- test/directives.compose.test.ts | 5 +++++ test/directives.test.ts | 19 +++++++++++++++- test/manifest-loader.test.ts | 22 ++++++++++++++++++ 9 files changed, 111 insertions(+), 26 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index 0a8eebd..8e32827 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -135,7 +135,7 @@ The `--json` `data` is `{ files: { path, state, strategy }[], payload: { package ## Template directives -Payload templates and fragments can mark runs of whole lines as conditional. Requires manifest `schemaVersion: 5` (`DIRECTIVES_MIN_SCHEMA_VERSION`). +Payload templates and fragments can mark runs of whole lines as conditional. Requires manifest `schemaVersion: 5` (`DIRECTIVES_MIN_SCHEMA_VERSION`). Below schemaVersion 5, `streamctl:` comments are plain content; only a well-formed `if`/`else`/`end` line is detected, to raise the `schema-version` error. ### Grammar @@ -219,7 +219,7 @@ Every directive error is `CONFIG_INVALID` (exit `1`), with `details.reason` set | `body-outdented` | YAML only: a body line indented less than the directives | | `structured-json` | Directive in a `.json`/`.jsonc`/`.json5` target | | `merge-strategy` | Directive in a `merge` template | -| `schema-version` | Directive present but the manifest declares `schemaVersion` below 5 | +| `schema-version` | Well-formed directive present but the manifest declares `schemaVersion` below 5 | | `undeclared-condition` | Condition not declared in any preset's `configKeys` | | `object-condition` | Condition names an `object` key exactly | | `unevaluable-value` | At render: the consumer config holds a number, object or `null` at the condition | diff --git a/docs/release.md b/docs/release.md index 9c6aeed..730f0ad 100644 --- a/docs/release.md +++ b/docs/release.md @@ -95,6 +95,6 @@ Generated notes only cover commit subjects, so add these by hand. declared placeholder whose token is unused (or sits in a dropped directive block) no longer fails the render. Nested values resolve one level per substitution pass; the 10-pass cap is unchanged. -- v2 to v4 payloads parse unchanged; directives need `schemaVersion: 5` and this - CLI or newer. +- v2 to v4 payloads parse unchanged, including comments that start with + `streamctl:`; directives need `schemaVersion: 5` and this CLI or newer. - Minor bump: new schema version, no removals. diff --git a/src/config/types.ts b/src/config/types.ts index 228eaeb..4d2ce77 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -34,6 +34,8 @@ export interface ManagedFile { renderDef?: RenderDef; /** Config boolean dot-path. Absent or `false` excludes the file, same as `files: off`. */ enabledBy?: string; + /** Set by `resolvePresetChain` below schemaVersion 5: template and fragments skip the directive pass. */ + skipDirectives?: boolean; /** * Marks a file the payload is newly taking over, so a pre-existing differing * `full` file is classified as an `adoption` conflict rather than an `edit`. diff --git a/src/directives.ts b/src/directives.ts index 8c1f366..c1a2bd1 100644 --- a/src/directives.ts +++ b/src/directives.ts @@ -90,7 +90,13 @@ function isVerb(value: string): value is Verb { return value === "if" || value === "else" || value === "end"; } -function parseDirective(path: string, lineNo: number, indent: string, rawRest: string, suffix: string): DirectiveLine { +interface DirectiveParseError { + reason: DirectiveReason; + message: string; + extra: DetailExtras; +} + +function parseDirective(lineNo: number, indent: string, rawRest: string, suffix: string): DirectiveLine | DirectiveParseError { let rest = rawRest.trimEnd(); const hasSuffix = suffix === "" || rest.endsWith(suffix); if (suffix !== "" && hasSuffix) { @@ -107,20 +113,20 @@ function parseDirective(path: string, lineNo: number, indent: string, rawRest: s } else if (verb === "" && words[0] !== undefined && isVerb(words[0])) { hint = ` No space is allowed after "streamctl:"; write "streamctl:${words[0]}".`; } - fail(path, lineNo, "unknown-directive", `unknown directive "streamctl:${verb}".${hint}`, { directive: verb }); + return { reason: "unknown-directive", message: `unknown directive "streamctl:${verb}".${hint}`, extra: { directive: verb } }; } if (!hasSuffix) { - fail(path, lineNo, "malformed-condition", `"streamctl:${verb}" must be closed with "${suffix}" on the same line.`, { directive: verb }); + return { reason: "malformed-condition", message: `"streamctl:${verb}" must be closed with "${suffix}" on the same line.`, extra: { directive: verb } }; } const [rawCondition, ...extra] = words; if (rawCondition === undefined) { - fail(path, lineNo, "missing-condition", `"streamctl:${verb}" requires a condition.`, { directive: verb }); + return { reason: "missing-condition", message: `"streamctl:${verb}" requires a condition.`, extra: { directive: verb } }; } if (extra.length > 0) { - fail(path, lineNo, "malformed-condition", `"streamctl:${verb} ${rawCondition}" is followed by unexpected text "${extra.join(" ")}".`, { directive: verb, condition: rawCondition }); + return { reason: "malformed-condition", message: `"streamctl:${verb} ${rawCondition}" is followed by unexpected text "${extra.join(" ")}".`, extra: { directive: verb, condition: rawCondition } }; } if (!CONDITION_RE.test(rawCondition)) { - fail(path, lineNo, "malformed-condition", `"${rawCondition}" is not a valid condition; use a dotted config path such as "features.prisma", optionally prefixed with "!".`, { directive: verb, condition: rawCondition }); + return { reason: "malformed-condition", message: `"${rawCondition}" is not a valid condition; use a dotted config path such as "features.prisma", optionally prefixed with "!".`, extra: { directive: verb, condition: rawCondition } }; } return { line: lineNo, indent, verb, rawCondition }; } @@ -152,6 +158,23 @@ function closeBlock(open: OpenBlock, endLine: number): DirectiveBlock { return block; } +/** + * Line (1-based) of the first well-formed `if`/`else`/`end` directive, ignoring block + * structure; any other `streamctl:` comment counts as content. Payloads below + * schemaVersion 5 use this, so prose comments keep rendering verbatim while a real + * directive still trips the schemaVersion gate. + */ +export function findDirectiveLine(text: string, path: string): number | undefined { + const { candidate, suffix } = buildCandidateMatcher(path); + for (const [index, rawLine] of text.split("\n").entries()) { + const match = candidate.exec(rawLine.replace(/\r$/, "")); + if (match !== null && !("reason" in parseDirective(index + 1, match[1] ?? "", match[2] ?? "", suffix))) { + return index + 1; + } + } + return undefined; +} + /** Parse and validate every directive in `text`; throws `CONFIG_INVALID` on the first error. */ export function scanDirectives(text: string, path: string): DirectiveBlock[] { const { candidate, suffix } = buildCandidateMatcher(path); @@ -169,7 +192,10 @@ export function scanDirectives(text: string, path: string): DirectiveBlock[] { if (format === "json" || format === "json5") { fail(path, lineNo, "structured-json", "template directives are not supported in JSON files."); } - const directive = parseDirective(path, lineNo, match[1] ?? "", match[2] ?? "", suffix); + const directive = parseDirective(lineNo, match[1] ?? "", match[2] ?? "", suffix); + if ("reason" in directive) { + fail(path, lineNo, directive.reason, directive.message, directive.extra); + } const { verb, rawCondition, indent } = directive; const extra = { directive: verb, condition: rawCondition }; diff --git a/src/engine/compose.ts b/src/engine/compose.ts index 3e5d5bf..8fe1a98 100644 --- a/src/engine/compose.ts +++ b/src/engine/compose.ts @@ -43,13 +43,15 @@ function finalizeComposed( } /** - * Directive pass first: `if`/`else`/`end` blocks are resolved on the template body - * and on each fragment body, before fragments are appended and placeholders - * substituted, so tokens inside a dropped block never need a value. Fragment - * sources are read here so `renderFile` itself stays pure. + * Directive pass first (skipped below schemaVersion 5): `if`/`else`/`end` blocks are + * resolved on the template body and on each fragment body, before fragments are + * appended and placeholders substituted, so tokens inside a dropped block never need + * a value. Fragment sources are read here so `renderFile` itself stays pure. */ async function renderManagedTemplate(file: ManagedFile, payload: PayloadHandle, config: StreamctlConfig | undefined, deps: Record): Promise { - const raw = applyDirectives(await payload.read(file.source), file.path, condition => evaluateCondition(config, condition, file.path)); + const resolve = (text: string, path: string): string => + file.skipDirectives === true ? text : applyDirectives(text, path, condition => evaluateCondition(config, condition, path)); + const raw = resolve(await payload.read(file.source), file.path); if (file.renderDef === undefined) { return raw; } @@ -57,7 +59,7 @@ async function renderManagedTemplate(file: ManagedFile, payload: PayloadHandle, for (const fragment of file.renderDef.fragments ?? []) { const source = fragment.source; if (!(source in fragmentSources)) { - fragmentSources[source] = applyDirectives(await payload.read(source), source, condition => evaluateCondition(config, condition, source)); + fragmentSources[source] = resolve(await payload.read(source), source); } } return renderFile(raw, file.renderDef, config, fragmentSources, file.path, deps); diff --git a/src/engine/manifest.ts b/src/engine/manifest.ts index b45cfeb..39cbea8 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 { DirectiveBlock, DirectiveErrorDetails } from "../directives"; import type { ConfigKeyType, ManagedFile as ManifestManagedFile, PayloadManifest, PresetManifest, RenderDef } from "../manifest/schema"; import type { PayloadHandle } from "../payload/resolve"; -import { scanDirectives } from "../directives"; +import { findDirectiveLine, scanDirectives } from "../directives"; import { StreamctlError } from "../errors"; import { COMMA_JOIN_MIN_SCHEMA_VERSION, DIRECTIVES_MIN_SCHEMA_VERSION, FROM_DEPENDENCY_MIN_SCHEMA_VERSION, OMIT_LINE_MIN_SCHEMA_VERSION, payloadManifestSchema, presetManifestSchema, SUPPORTED_SCHEMA_VERSION_LIST, SUPPORTED_SCHEMA_VERSIONS, zodToIssues } from "../manifest/schema"; @@ -202,6 +202,18 @@ async function validateDirectives( text = payload.read(target.source); reads.set(target.source, text); } + // Below the gate, `streamctl:` prose stays content; only a well-formed directive fails. + if (schemaVersion < DIRECTIVES_MIN_SCHEMA_VERSION) { + const line = findDirectiveLine(await text, target.scanPath); + if (line !== undefined) { + throw new StreamctlError( + "CONFIG_INVALID", + `${file}:${line}: uses template directives, which requires schemaVersion ${DIRECTIVES_MIN_SCHEMA_VERSION}, but ${PAYLOAD_MANIFEST} declares schemaVersion ${schemaVersion}.`, + { file, line, reason: "schema-version", found: schemaVersion, required: DIRECTIVES_MIN_SCHEMA_VERSION } satisfies DirectiveErrorDetails, + ); + } + continue; + } let blocks: DirectiveBlock[]; try { blocks = scanDirectives(await text, target.scanPath); @@ -212,13 +224,6 @@ async function validateDirectives( if (first === undefined) { continue; } - if (schemaVersion < DIRECTIVES_MIN_SCHEMA_VERSION) { - throw new StreamctlError( - "CONFIG_INVALID", - `${file}:${first.ifLine}: uses template directives, which requires schemaVersion ${DIRECTIVES_MIN_SCHEMA_VERSION}, but ${PAYLOAD_MANIFEST} declares schemaVersion ${schemaVersion}.`, - { file, line: first.ifLine, reason: "schema-version", found: schemaVersion, required: DIRECTIVES_MIN_SCHEMA_VERSION } satisfies DirectiveErrorDetails, - ); - } if (target.strategy === "merge") { throw new StreamctlError( "CONFIG_INVALID", @@ -341,6 +346,12 @@ export async function resolvePresetChain( } } + if (manifest.schemaVersion < DIRECTIVES_MIN_SCHEMA_VERSION) { + for (const file of files) { + file.skipDirectives = true; + } + } + // renderRefs above guarantees every key resolves. for (const [path, key] of renderKeyByPath) { const def = renders.get(key); diff --git a/test/directives.compose.test.ts b/test/directives.compose.test.ts index 67224d7..14d5ad9 100644 --- a/test/directives.compose.test.ts +++ b/test/directives.compose.test.ts @@ -108,6 +108,11 @@ describe("compose: directive pass", () => { expect(content(await compose(file, payload, null, cfg()))).toBe("jobs:\n lint: true\n"); }); + it("passes the template through verbatim when skipDirectives is set", async () => { + const file: ManagedFile = { path: ".github/workflows/ci.yaml", strategy: "full", source: "base/ci.yaml", skipDirectives: true }; + expect(content(await compose(file, payload, null, cfg()))).toBe("jobs:\n # streamctl:if ci.deploy\n deploy: ${DEPLOY}\n # streamctl:end ci.deploy\n lint: true\n"); + }); + it("skips the pattern check of a placeholder used only in a dropped block", async () => { const renderDef = { placeholders: { DEPLOY: { configPath: "ci.target", default: "", pattern: "^[a-z]+$" } } }; const file: ManagedFile = { path: ".github/workflows/ci.yaml", strategy: "full", source: "base/ci.yaml", renderDef }; diff --git a/test/directives.test.ts b/test/directives.test.ts index f78ba06..deec292 100644 --- a/test/directives.test.ts +++ b/test/directives.test.ts @@ -1,6 +1,6 @@ import type { DirectiveErrorDetails } from "../src/directives"; import { describe, expect, it, vi } from "vitest"; -import { applyDirectives, scanDirectives } from "../src/directives"; +import { applyDirectives, findDirectiveLine, scanDirectives } from "../src/directives"; import { StreamctlError } from "../src/errors"; function isDirectiveErrorDetails(value: unknown): value is DirectiveErrorDetails { @@ -210,6 +210,23 @@ describe("scanDirectives: errors", () => { }); }); +describe("findDirectiveLine", () => { + it("ignores streamctl: prose and malformed lines", () => { + expect(findDirectiveLine("# streamctl: managed file\n# streamctl:if bad cond!\n# streamctl:each x\n", "ci.yaml")).toBeUndefined(); + expect(findDirectiveLine("\n\r\n", "NOTES.md")).toBe(1); + }); + + it("ignores directives written with another file's comment token", () => { + expect(findDirectiveLine("// streamctl:if a\n", "ci.yaml")).toBeUndefined(); + }); +}); + describe("applyDirectives", () => { const always = (value: boolean) => () => value; const ifElse = "a\n# streamctl:if x\nyes\n# streamctl:else x\nno\n# streamctl:end x\nz\n"; diff --git a/test/manifest-loader.test.ts b/test/manifest-loader.test.ts index a9618c7..5d6a8a7 100644 --- a/test/manifest-loader.test.ts +++ b/test/manifest-loader.test.ts @@ -507,6 +507,28 @@ describe("chain-time directive validation", () => { expect(error.details).toMatchObject({ file: "presets/base/ci.yaml", line: 1, reason: "schema-version" }); }); + it("treats streamctl: prose as content on a schema-4 payload and marks files to skip directives", async () => { + const prose = "# streamctl: managed file\nname: ci\n"; + const { files } = await resolvePresetChain(directivePayload({ schemaVersion: 4, sources: { "base/ci.yaml": prose } }), "base", "nuxt-4"); + expect(files[0]).toMatchObject({ path: ".github/workflows/ci.yaml", skipDirectives: true }); + }); + + it("still rejects a well-formed directive below prose on a schema-4 payload", async () => { + const payload = directivePayload({ schemaVersion: 4, sources: { "base/ci.yaml": `# streamctl: managed file\n${block}` } }); + const error = await caught(resolvePresetChain(payload, "base", "nuxt-4")); + expect(error.details).toMatchObject({ file: "presets/base/ci.yaml", line: 2, reason: "schema-version" }); + }); + + it("leaves skipDirectives unset on a schema-5 payload", async () => { + const { files } = await resolvePresetChain(directivePayload(), "base", "nuxt-4"); + expect(files[0]?.skipDirectives).toBeUndefined(); + }); + + it("rejects streamctl: prose on a schema-5 payload", async () => { + const error = await caught(resolvePresetChain(directivePayload({ sources: { "base/ci.yaml": "# streamctl: managed file\n" } }), "base", "nuxt-4")); + expect(error.details).toMatchObject({ file: "presets/base/ci.yaml", line: 1, reason: "unknown-directive" }); + }); + it("rejects an undeclared condition, naming file and line", async () => { const error = await caught(resolvePresetChain(directivePayload({ configKeys: { "ci.lint": "boolean" } }), "base", "nuxt-4")); expect(error.message).toContain("template \"presets/base/ci.yaml\" line 1 references config path \"ci.deploy\", which no preset declares in configKeys"); From 8c4778c8866a4a9eb8083de658ffba4eb9fd5f79 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 21:06:56 +0200 Subject: [PATCH 26/32] refactor(engine): drop the commentSyntaxFor re-export from block.ts --- src/engine/block.ts | 2 -- test/block.test.ts | 3 ++- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/engine/block.ts b/src/engine/block.ts index 1f099d1..eead03e 100644 --- a/src/engine/block.ts +++ b/src/engine/block.ts @@ -1,7 +1,5 @@ import { commentSyntaxFor } from "../comment-syntax"; -export { commentSyntaxFor }; - export type BlockResult = | { ok: true; content: string } | { ok: false; reason: string }; diff --git a/test/block.test.ts b/test/block.test.ts index 26e07d2..4df2e8e 100644 --- a/test/block.test.ts +++ b/test/block.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { commentSyntaxFor, composeBlock } from "../src/engine/block"; +import { commentSyntaxFor } from "../src/comment-syntax"; +import { composeBlock } from "../src/engine/block"; describe("commentSyntaxFor", () => { it("uses # for npmrc, gitignore, yaml, editorconfig and dockerfiles", () => { From 996803e174d17c419b8c07752d93e0372fe0fac5 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 21:22:10 +0200 Subject: [PATCH 27/32] fix(render): indent content that follows a value ending in a newline --- src/engine/render.ts | 9 ++------- test/render-generic.test.ts | 6 ++++++ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/engine/render.ts b/src/engine/render.ts index 74bb442..d8280c1 100644 --- a/src/engine/render.ts +++ b/src/engine/render.ts @@ -253,13 +253,8 @@ export function renderFile( const substitutePass = (text: string): string => text.split("\n").map((line) => { const indent = /^[ \t]*/.exec(line)?.[0] ?? ""; - return line.replace(LEFTOVER_TOKEN_RE, (token: string, name: string) => { - const value = values.get(name); - if (value === undefined) { - return token; - } - return value.split("\n").map((part, index) => index === 0 || part === "" ? part : indent + part).join("\n"); - }); + const replaced = line.replace(LEFTOVER_TOKEN_RE, (token: string, name: string) => values.get(name) ?? token); + return replaced.split("\n").map((part, index) => index === 0 || part === "" ? part : indent + part).join("\n"); }).join("\n"); for (let pass = 1; ; pass++) { diff --git a/test/render-generic.test.ts b/test/render-generic.test.ts index 3988bb7..faf8182 100644 --- a/test/render-generic.test.ts +++ b/test/render-generic.test.ts @@ -300,6 +300,12 @@ describe("renderFile: indent-aware substitution", () => { expect(renderFile(" ${A}\n", { placeholders: { B: b, A: a } }, cfg(), {})).toBe(expected); }); + it("indents content that follows a value ending in a newline", () => { + const def: RenderDef = { placeholders: { A: { configPath: "ci.a", default: "a\n" }, B: { configPath: "ci.b", default: "b" } } }; + expect(renderFile("run: |\n ${A}${B}\n", def, cfg(), {})).toBe("run: |\n a\n b\n"); + expect(renderFile(" ${A}tail\n", def, cfg(), {})).toBe(" a\n tail\n"); + }); + it("lands $& and $1 verbatim", () => { expect(renderFile(" ${SCRIPT}\n", script("x $& y\n$1"), cfg(), {})).toBe(" x $& y\n $1\n"); }); From 0039b2b40f005ea54bf087c59b6feb387d31b87d Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 21:22:16 +0200 Subject: [PATCH 28/32] fix(compose): report fragment condition errors against the target path --- src/engine/compose.ts | 2 +- test/directives.compose.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/engine/compose.ts b/src/engine/compose.ts index 8fe1a98..06f647c 100644 --- a/src/engine/compose.ts +++ b/src/engine/compose.ts @@ -50,7 +50,7 @@ function finalizeComposed( */ async function renderManagedTemplate(file: ManagedFile, payload: PayloadHandle, config: StreamctlConfig | undefined, deps: Record): Promise { const resolve = (text: string, path: string): string => - file.skipDirectives === true ? text : applyDirectives(text, path, condition => evaluateCondition(config, condition, path)); + file.skipDirectives === true ? text : applyDirectives(text, path, condition => evaluateCondition(config, condition, file.path)); const raw = resolve(await payload.read(file.source), file.path); if (file.renderDef === undefined) { return raw; diff --git a/test/directives.compose.test.ts b/test/directives.compose.test.ts index 14d5ad9..7ae5277 100644 --- a/test/directives.compose.test.ts +++ b/test/directives.compose.test.ts @@ -79,6 +79,7 @@ describe("compose: directive pass", () => { "base/ci.yaml": "jobs:\n # streamctl:if ci.deploy\n deploy: ${DEPLOY}\n # streamctl:end ci.deploy\n lint: true\n", "base/ci-main.yaml": "jobs:\n", "base/toggle.job": " # streamctl:if ci.prisma\n prisma: true\n # streamctl:end ci.prisma\n test: true\n", + "base/flag.job": " # streamctl:if eslint.flag\n x: true\n # streamctl:end eslint.flag\n", "base/env.job": " deploy-${ITEM}:\n # streamctl:if !ci.prisma\n plain: true\n # streamctl:end !ci.prisma\n", }); @@ -146,4 +147,16 @@ describe("compose: directive pass", () => { details: { file: ".npmrc", reason: "unevaluable-value", found: "number" }, }); }); + + it("reports unevaluable values in a fragment against the target path", async () => { + const file: ManagedFile = { + path: ".github/workflows/ci.yaml", + strategy: "full", + source: "base/ci-main.yaml", + renderDef: { fragments: [{ toggle: "ci.test", source: "base/flag.job" }] }, + }; + await expect(compose(file, payload, null, cfg({ ci: { test: true }, eslint: { flag: 3 } }))).rejects.toMatchObject({ + details: { file: ".github/workflows/ci.yaml", reason: "unevaluable-value", found: "number" }, + }); + }); }); From 50036d7ae9c6b15e1c398bfc80ab67a2041ae5dc Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 21:22:32 +0200 Subject: [PATCH 29/32] refactor(directives): drop unused line field from parsed directives --- src/directives.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/directives.ts b/src/directives.ts index c1a2bd1..6815508 100644 --- a/src/directives.ts +++ b/src/directives.ts @@ -52,7 +52,6 @@ export interface DirectiveBlock { type Verb = "if" | "else" | "end"; interface DirectiveLine { - line: number; indent: string; verb: Verb; rawCondition: string; @@ -96,7 +95,7 @@ interface DirectiveParseError { extra: DetailExtras; } -function parseDirective(lineNo: number, indent: string, rawRest: string, suffix: string): DirectiveLine | DirectiveParseError { +function parseDirective(indent: string, rawRest: string, suffix: string): DirectiveLine | DirectiveParseError { let rest = rawRest.trimEnd(); const hasSuffix = suffix === "" || rest.endsWith(suffix); if (suffix !== "" && hasSuffix) { @@ -128,7 +127,7 @@ function parseDirective(lineNo: number, indent: string, rawRest: string, suffix: if (!CONDITION_RE.test(rawCondition)) { return { reason: "malformed-condition", message: `"${rawCondition}" is not a valid condition; use a dotted config path such as "features.prisma", optionally prefixed with "!".`, extra: { directive: verb, condition: rawCondition } }; } - return { line: lineNo, indent, verb, rawCondition }; + return { indent, verb, rawCondition }; } /** YAML lint: no non-blank body line may sit left of (or diverge from) the directive indent. */ @@ -168,7 +167,7 @@ export function findDirectiveLine(text: string, path: string): number | undefine const { candidate, suffix } = buildCandidateMatcher(path); for (const [index, rawLine] of text.split("\n").entries()) { const match = candidate.exec(rawLine.replace(/\r$/, "")); - if (match !== null && !("reason" in parseDirective(index + 1, match[1] ?? "", match[2] ?? "", suffix))) { + if (match !== null && !("reason" in parseDirective(match[1] ?? "", match[2] ?? "", suffix))) { return index + 1; } } @@ -192,7 +191,7 @@ export function scanDirectives(text: string, path: string): DirectiveBlock[] { if (format === "json" || format === "json5") { fail(path, lineNo, "structured-json", "template directives are not supported in JSON files."); } - const directive = parseDirective(lineNo, match[1] ?? "", match[2] ?? "", suffix); + const directive = parseDirective(match[1] ?? "", match[2] ?? "", suffix); if ("reason" in directive) { fail(path, lineNo, directive.reason, directive.message, directive.extra); } From 02fd39b73cba0475059bfc8727006f32568866b6 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 21:25:25 +0200 Subject: [PATCH 30/32] test(directives): normalize CRLF in payload fixture renders --- test/directives.payload.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/directives.payload.test.ts b/test/directives.payload.test.ts index f65d2cc..0819268 100644 --- a/test/directives.payload.test.ts +++ b/test/directives.payload.test.ts @@ -45,7 +45,8 @@ async function render(path: string, config: StreamctlConfig): Promise { if (result.status !== "composed") { throw new Error(`expected composed, got ${result.status}`); } - return result.targetContent; + // Windows checkouts turn fixture line endings into CRLF. + return result.targetContent.replaceAll("\r\n", "\n"); } describe("directives payload fixture", () => { From 07d738d566406d504fe84c3b6c817df02c65154e Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Mon, 19 Oct 2026 21:51:31 +0200 Subject: [PATCH 31/32] fix(directives): make line optional in DirectiveErrorDetails --- src/directives.ts | 3 ++- src/engine/conditions.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/directives.ts b/src/directives.ts index 6815508..9e3b7ee 100644 --- a/src/directives.ts +++ b/src/directives.ts @@ -27,7 +27,8 @@ export type DirectiveReason export interface DirectiveErrorDetails { file: string; - line: number; + /** Absent for `unevaluable-value`, which is raised at render time. */ + line?: number; reason: DirectiveReason; directive?: string; condition?: string; diff --git a/src/engine/conditions.ts b/src/engine/conditions.ts index 96d26e5..4bc647a 100644 --- a/src/engine/conditions.ts +++ b/src/engine/conditions.ts @@ -1,4 +1,5 @@ import type { StreamctlConfig } from "../config/types"; +import type { DirectiveErrorDetails } from "../directives"; import { StreamctlError } from "../errors"; import { readConfigPath } from "./render"; @@ -21,6 +22,6 @@ export function evaluateCondition(config: StreamctlConfig | undefined, path: str throw new StreamctlError( "CONFIG_INVALID", `${filePath}: condition "${path}" has a ${found} value in the config, which cannot be used as a condition (boolean, string or string[] expected).`, - { file: filePath, condition: path, reason: "unevaluable-value", found }, + { file: filePath, condition: path, reason: "unevaluable-value", found } satisfies DirectiveErrorDetails, ); } From a004cdc47010c6dd3a7f8883c8582265e02d8bee Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Tue, 20 Oct 2026 09:56:59 +0200 Subject: [PATCH 32/32] docs: tighten substitution comment and directive docs --- docs/reference.md | 2 +- docs/release.md | 3 +-- src/engine/render.ts | 9 +++------ 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index 8e32827..b288ca1 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -193,7 +193,7 @@ The condition must be declared in the merged `configKeys`, either exactly or und ### Reserved namespace -`streamctl:` right after a comment token is reserved. Prose like `# streamctl: the CLI` is an `unknown-directive` error. The verbs `each`, `insert` and `raw` are reserved for a future version and fail the same way with a hint. +From `schemaVersion: 5`, `streamctl:` right after a comment token is reserved. Prose like `# streamctl: the CLI` is an `unknown-directive` error. The verbs `each`, `insert` and `raw` are reserved for a future version and fail the same way with a hint. ### Errors diff --git a/docs/release.md b/docs/release.md index 730f0ad..dbe9e60 100644 --- a/docs/release.md +++ b/docs/release.md @@ -93,8 +93,7 @@ Generated notes only cover commit subjects, so add these by hand. - Placeholders are resolved and pattern-checked only when their token is reachable from the rendered text, directly or through another placeholder's value. A declared placeholder whose token is unused (or sits in a dropped directive block) - no longer fails the render. Nested values resolve one level per substitution pass; - the 10-pass cap is unchanged. + no longer fails the render. - v2 to v4 payloads parse unchanged, including comments that start with `streamctl:`; directives need `schemaVersion: 5` and this CLI or newer. - Minor bump: new schema version, no removals. diff --git a/src/engine/render.ts b/src/engine/render.ts index d8280c1..d772fed 100644 --- a/src/engine/render.ts +++ b/src/engine/render.ts @@ -244,12 +244,9 @@ export function renderFile( const passthrough = new Set(renderDef.passthrough ?? []); - // All tokens of a line are replaced in one go against that line's leading whitespace (not - // the token's column), so continuation lines of a multiline value line up and empty ones - // stay empty. Inserted values are not rescanned within the pass: nested tokens resolve on - // the next pass against the line they landed on, which keeps the output independent of - // placeholder declaration order. Callback form: a plain replacement string would honor - // `$&`/`$1` patterns in the value; config values are data and must land verbatim. + // Continuation lines get the line's leading whitespace, not the token's column. Nested + // tokens resolve on the next pass, so the output does not depend on placeholder order. + // Callback form: a replacement string would expand `$&`/`$1` inside config values. const substitutePass = (text: string): string => text.split("\n").map((line) => { const indent = /^[ \t]*/.exec(line)?.[0] ?? "";