diff --git a/README.md b/README.md index 815efd4..ba1d7a4 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 the 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..b288ca1 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -133,6 +133,129 @@ 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`). 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 + +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 + 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 (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. + +### 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: 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. + +### 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 + +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 + +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 | +| -------- | ------- | +| `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` | 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 | + +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. 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 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 + +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. + +### 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/docs/release.md b/docs/release.md index 8c61dba..dbe9e60 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 @@ -70,8 +70,30 @@ 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). +- **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, + 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. +- 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. +- 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/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/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 new file mode 100644 index 0000000..9e3b7ee --- /dev/null +++ b/src/directives.ts @@ -0,0 +1,265 @@ +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; + /** Absent for `unevaluable-value`, which is raised at render time. */ + line?: number; + reason: DirectiveReason; + directive?: string; + condition?: string; + expected?: string; + openedAt?: number; + /** 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 { + /** Condition path without `!`. */ + condition: string; + negated: boolean; + /** 1-based. */ + ifLine: number; + elseLine?: number; + endLine: number; +} + +type Verb = "if" | "else" | "end"; + +interface DirectiveLine { + 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, "\\$&"); +} + +type DetailExtras = Omit, "file" | "line" | "reason">; + +function fail(path: string, line: number, reason: DirectiveReason, message: string, extra: DetailExtras = {}): never { + const details: DirectiveErrorDetails = { ...extra, file: path, line, reason }; + 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"; +} + +interface DirectiveParseError { + reason: DirectiveReason; + message: string; + extra: DetailExtras; +} + +function parseDirective(indent: string, rawRest: string, suffix: string): DirectiveLine | DirectiveParseError { + 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)) { + let hint = " Expected streamctl:if, streamctl:else or streamctl:end."; + if (RESERVED_VERBS.has(verb)) { + hint = ` "streamctl:${verb}" is reserved for a future version.`; + } else if (verb === "" && words[0] !== undefined && isVerb(words[0])) { + hint = ` No space is allowed after "streamctl:"; write "streamctl:${words[0]}".`; + } + return { reason: "unknown-directive", message: `unknown directive "streamctl:${verb}".${hint}`, extra: { directive: verb } }; + } + if (!hasSuffix) { + 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) { + return { reason: "missing-condition", message: `"streamctl:${verb}" requires a condition.`, extra: { directive: verb } }; + } + if (extra.length > 0) { + 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)) { + 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 { 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; +} + +/** + * 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(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); + 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(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 }; + + 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; +} + +/** + * Drop every directive line, the `if` branch of false blocks and the `else` branch + * of true blocks. Always rescans; other lines are kept byte for byte. + */ +export function applyDirectives(text: string, path: string, evaluate: (conditionPath: string) => boolean): string { + const blocks = scanDirectives(text, path); + if (blocks.length === 0) { + return text; + } + // 0-based indices; block line numbers are 1-based. + const drop = new Set(); + for (const block of blocks) { + const kept = evaluate(block.condition) !== block.negated; + const branchEnd = block.elseLine ?? block.endLine; + const [from, to] = kept ? [branchEnd, block.endLine] : [block.ifLine, branchEnd]; + drop.add(block.ifLine - 1); + drop.add(block.endLine - 1); + if (block.elseLine !== undefined) { + drop.add(block.elseLine - 1); + } + for (let lineNo = from + 1; lineNo < to; lineNo++) { + drop.add(lineNo - 1); + } + } + return text.split("\n").filter((_, index) => !drop.has(index)).join("\n"); +} diff --git a/src/engine/block.ts b/src/engine/block.ts index bad6d7d..eead03e 100644 --- a/src/engine/block.ts +++ b/src/engine/block.ts @@ -1,43 +1,4 @@ -import { basename, extname } from "node:path"; - -/** 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; - } -} +import { commentSyntaxFor } from "../comment-syntax"; export type BlockResult = | { ok: true; content: string } diff --git a/src/engine/compose.ts b/src/engine/compose.ts index 040455e..06f647c 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,35 @@ 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 (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 = await payload.read(file.source); + const resolve = (text: string, path: string): string => + 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; } 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] = resolve(await payload.read(source), 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..4bc647a --- /dev/null +++ b/src/engine/conditions.ts @@ -0,0 +1,27 @@ +import type { StreamctlConfig } from "../config/types"; +import type { DirectiveErrorDetails } from "../directives"; +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 } satisfies DirectiveErrorDetails, + ); +} diff --git a/src/engine/init.ts b/src/engine/init.ts index 74465b4..9574335 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)).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(); 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/src/engine/manifest.ts b/src/engine/manifest.ts index b2502dc..39cbea8 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, DirectiveErrorDetails } from "../directives"; import type { ConfigKeyType, ManagedFile as ManifestManagedFile, PayloadManifest, PresetManifest, RenderDef } from "../manifest/schema"; import type { PayloadHandle } from "../payload/resolve"; +import { findDirectiveLine, 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 { 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"; @@ -125,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; } @@ -145,6 +160,93 @@ 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); + } + // 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); + } catch (error) { + throw relocateDirectiveError(error, target.scanPath, file); + } + const first = blocks[0]; + if (first === undefined) { + continue; + } + 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" } satisfies DirectiveErrorDetails, + ); + } + 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" } satisfies DirectiveErrorDetails, + ); + } + configPathRefs.push({ + where: `${target.kind} "${file}" line ${block.ifLine}`, + path: block.condition, + details: { ...details, reason: "undeclared-condition" }, + }); + } + } } /** @@ -244,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); @@ -252,6 +360,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 +387,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/src/engine/render.ts b/src/engine/render.ts index 0dc72e5..d772fed 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; @@ -203,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( @@ -221,19 +239,20 @@ 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 ?? []); - // Callback form: a plain replacement string would honor `$&`/`$1` patterns in the value. - // Config values are data and must land verbatim. - const substitutePass = (text: string): string => { - let next = text; - for (const [key, value] of values) { - next = next.replaceAll(`\${${key}}`, () => value); - } - return next; - }; + // 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] ?? ""; + 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++) { const next = substitutePass(output); 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/src/manifest/schema.ts b/src/manifest/schema.ts index 7cd9b3c..9f25c0b 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,21 @@ 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; + +/** 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 is single-pass; any leftover `${…}` fails render.ts's + * 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. * * `fromDependency` names an npm package whose pin in the consumer's package.json supplies @@ -58,7 +66,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/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", () => { diff --git a/test/directives.compose.test.ts b/test/directives.compose.test.ts new file mode 100644 index 0000000..7ae5277 --- /dev/null +++ b/test/directives.compose.test.ts @@ -0,0 +1,162 @@ +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) => { + let caught: unknown; + try { + 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: "eslint.flag", reason: "unevaluable-value", found }, + }); + }); +}); + +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", + "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", + }); + + 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("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 }; + 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/); + }); + + 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/eslint-flag" }; + await expect(compose(file, payload, null, cfg({ eslint: { flag: 3 } }))).rejects.toMatchObject({ + 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" }, + }); + }); +}); diff --git a/test/directives.payload.test.ts b/test/directives.payload.test.ts new file mode 100644 index 0000000..0819268 --- /dev/null +++ b/test/directives.payload.test.ts @@ -0,0 +1,98 @@ +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}`); + } + // Windows checkouts turn fixture line endings into CRLF. + return result.targetContent.replaceAll("\r\n", "\n"); +} + +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/directives.test.ts b/test/directives.test.ts new file mode 100644 index 0000000..deec292 --- /dev/null +++ b/test/directives.test.ts @@ -0,0 +1,308 @@ +import type { DirectiveErrorDetails } from "../src/directives"; +import { describe, expect, it, vi } from "vitest"; +import { applyDirectives, findDirectiveLine, 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); + } catch (error) { + if (!(error instanceof StreamctlError)) { + throw error; + } + expect(error.code).toBe("CONFIG_INVALID"); + 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"); +} + +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 when a space follows streamctl:", () => { + const { details, message } = scanError("# streamctl: if x\n"); + expect(details).toMatchObject({ reason: "unknown-directive", directive: "" }); + expect(message).toContain("No space is allowed after"); + }); + + 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("\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"; + + it("returns text without directives unchanged", () => { + expect(applyDirectives("a\nb", ".npmrc", always(true))).toBe("a\nb"); + }); + + it("strips directive lines and keeps the body when true", () => { + expect(applyDirectives("a\n# streamctl:if x\nb\n# streamctl:end x\nc\n", ".npmrc", always(true))).toBe("a\nb\nc\n"); + }); + + it("removes the body when false", () => { + expect(applyDirectives("a\n# streamctl:if x\nb\n# streamctl:end x\nc\n", ".npmrc", always(false))).toBe("a\nc\n"); + }); + + it("keeps the if branch of an else block when true", () => { + expect(applyDirectives(ifElse, ".npmrc", always(true))).toBe("a\nyes\nz\n"); + }); + + it("keeps the else branch when false", () => { + expect(applyDirectives(ifElse, ".npmrc", always(false))).toBe("a\nno\nz\n"); + }); + + it("inverts negated conditions", () => { + const text = "# streamctl:if !x\nb\n# streamctl:else !x\nc\n# streamctl:end !x\n"; + expect(applyDirectives(text, ".npmrc", always(true))).toBe("c\n"); + expect(applyDirectives(text, ".npmrc", always(false))).toBe("b\n"); + }); + + it("allows empty branches", () => { + const text = "a\n# streamctl:if x\n# streamctl:else x\n# streamctl:end x\n"; + expect(applyDirectives(text, ".npmrc", always(true))).toBe("a\n"); + expect(applyDirectives(text, ".npmrc", always(false))).toBe("a\n"); + }); + + it("preserves trailing newline state", () => { + expect(applyDirectives("a\n# streamctl:if x\nb\n# streamctl:end x", ".npmrc", always(true))).toBe("a\nb"); + expect(applyDirectives("# streamctl:if x\nb\n# streamctl:end x\n", ".npmrc", always(false))).toBe(""); + }); + + it("preserves \\r on CRLF text", () => { + expect(applyDirectives("a\r\n# streamctl:if x\r\nb\r\n# streamctl:end x\r\nc\r\n", ".npmrc", always(true))).toBe("a\r\nb\r\nc\r\n"); + }); + + it("handles HTML and // syntax", () => { + expect(applyDirectives("\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); + }); +}); 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/init.test.ts b/test/init.test.ts index 3cb2ef3..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(); @@ -159,6 +161,47 @@ 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("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"); + }); + 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"); diff --git a/test/manifest-exports.test.ts b/test/manifest-exports.test.ts new file mode 100644 index 0000000..46f0949 --- /dev/null +++ b/test/manifest-exports.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { StreamctlError } from "../src/errors"; +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", () => { + 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("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); + expect(COMMA_JOIN_MIN_SCHEMA_VERSION).toBe(5); + }); +}); diff --git a/test/manifest-loader.test.ts b/test/manifest-loader.test.ts index 4b5d060..5d6a8a7 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]; @@ -87,19 +91,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"); }); @@ -175,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"); @@ -422,3 +458,124 @@ 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("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"); + 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" }); + }); +}); diff --git a/test/manifest-schema.test.ts b/test/manifest-schema.test.ts index 1d7d766..a8f7ef5 100644 --- a/test/manifest-schema.test.ts +++ b/test/manifest-schema.test.ts @@ -2,11 +2,14 @@ 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, presetManifestSchema, renderDefSchema, SUPPORTED_SCHEMA_VERSION, + SUPPORTED_SCHEMA_VERSION_LIST, SUPPORTED_SCHEMA_VERSIONS, zodToIssues, } from "../src/manifest/schema"; @@ -222,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); }); @@ -292,14 +299,21 @@ 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((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. 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); }); diff --git a/test/render-generic.test.ts b/test/render-generic.test.ts index 75024a7..faf8182 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", () => { @@ -249,6 +268,74 @@ 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("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("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"); + }); + + 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", () => { + 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";