/** * Frontmatter — YAML frontmatter parsing, serialization, and CRUD commands * * ADR-457 build-at-publish: the hand-written bin/lib/frontmatter.cjs collapsed * to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour * from the prior hand-written .cjs; only strict types are added. */ import fs from 'node:fs'; import path from 'node:path'; // eslint-disable-next-line @typescript-eslint/no-require-imports import ioMod = require('./io.cjs'); const { output, error } = ioMod; import { platformReadSync as safeReadFile, platformWriteSync } from './shell-command-projection.cjs'; import { textEncodingError } from './validate.cjs'; import { splitLines } from './text-lines.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import unusableInputMod = require('./unusable-input.cjs'); const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod; // ─── Types ──────────────────────────────────────────────────────────────────── type FrontmatterValue = string | string[] | Record; type Frontmatter = Record; // ─── Parsing engine ─────────────────────────────────────────────────────────── /** * Split a YAML inline array body on commas, respecting quoted strings. * e.g. '"a, b", c' → ['a, b', 'c'] */ function splitInlineArray(body: string): string[] { const items: string[] = []; let current = ''; let inQuote: string | null = null; for (let i = 0; i < body.length; i++) { const ch = body[i]; if (inQuote) { if (ch === inQuote) { inQuote = null; } else { current += ch; } } else if (ch === '"' || ch === "'") { inQuote = ch; } else if (ch === ',') { const trimmed = current.trim(); if (trimmed) items.push(trimmed); current = ''; } else { current += ch; } } const trimmed = current.trim(); if (trimmed) items.push(trimmed); return items; } /** * How many parsed keys an unterminated region must yield before it is reported as a * truncated frontmatter rather than left alone as ordinary Markdown. See the rationale on * `extractFrontmatter`; exported for tests so the boundary is asserted against the constant * rather than a magic number duplicated in the suite. */ const UNTERMINATED_KEY_THRESHOLD = 2; /** * Does every non-empty line of an unterminated region look like frontmatter? * * The key count alone cannot separate a truncated write from ordinary Markdown, because a * thematic break above a short labelled preamble parses as keys too: * * --- * Author: Jane Doe * Reviewed-by: John Smith * * Ordinary prose, and no second `---` anywhere. * * Raising the threshold only moves that boundary — two labelled lines are as common in prose as * one. What actually distinguishes the two is what follows: a write interrupted part-way through * a frontmatter block ends mid-block, so *every* line in the region is still frontmatter-shaped, * whereas a document merely opening with a rule goes on to prose. So the region must be * uniformly frontmatter-shaped AND carry enough keys to be worth reporting; either test alone * has a false-positive class the other closes. */ function isFrontmatterShaped(region: string): boolean { const lines = splitLines(region).filter((line) => line.trim() !== ''); if (lines.length === 0) return false; return lines.every((line) => ( /^\s*[a-zA-Z0-9_-]+:/.test(line) // key: value || /^\s*-\s+/.test(line) // - list item || /^\s+\S/.test(line) // indented continuation of a nested value )); } /** * #3257: a Symbol-keyed channel that carries full-line (column-0 `#`) YAML * comments through a parse → reconstruct round-trip. Comments are otherwise * unrepresentable on the Frontmatter object (Record) and were * silently dropped by reconstructFrontmatter. The Symbol is invisible to * Object.entries / Object.keys / JSON.stringify / for-in, so every existing * reader is unchanged; only reconstructFrontmatter reads it. Leading comments * are attached to the top-level key that follows them; comments after the last * key go to `trailing`. Only set when a comment is actually seen, so comment-less * frontmatter parses byte-identically to before. */ const FULL_LINE_COMMENTS = Symbol('fullLineComments'); type FullLineCommentChannel = { leading: Record; trailing: string[] }; /** * Unescape the interior of a YAML double-quoted scalar — the exact inverse of * `escapeDoubleQuoted` (#3497). The writer has escaped `\`/`"`/`\n`/`\t`/`\r`/ * `\xHH` since #1779, but the reader only stripped the delimiters, so * parse(serialize(x)) ≠ x for any quoted scalar carrying a `"` or `\`: each * read-modify-write round-trip doubled the backslashes (b → 2b+1), growing a * repeatedly-synced field — and its document — without bound until tooling * OOMed. Recognized escapes decode per YAML double-quoted semantics; an * unrecognized `\c` is kept literally (backslash + char), matching the * strip-only behavior hand-authored files had before this fix. */ function unescapeDoubleQuoted(s: string): string { let out = ''; for (let i = 0; i < s.length; i++) { const ch = s[i]; if (ch !== '\\' || i === s.length - 1) { out += ch; continue; } const next = s[++i]; if (next === '\\' || next === '"') { out += next; } else if (next === 'n') { out += '\n'; } else if (next === 't') { out += '\t'; } else if (next === 'r') { out += '\r'; } else if (next === 'x') { const hex = s.slice(i + 1, i + 3); if (/^[0-9a-fA-F]{2}$/.test(hex)) { out += String.fromCharCode(parseInt(hex, 16)); i += 2; } else { out += '\\x'; // not \xHH — keep literally } } else { out += '\\' + next; // unrecognized escape — keep literally } } return out; } /** * Strip the quote delimiters off a parsed YAML scalar, un-escaping the interior * when the scalar is double-quoted (#3497 — the parse-side complement of * `escapeDoubleQuoted`). Single-quoted scalars keep the historical strip-only * behavior (the writer never emits them; `''` → `'` folding is out of scope). * A scalar wrapped in double quotes un-escapes; anything else keeps the exact * prior delimiter-strip behavior, including a stray unpaired boundary quote. */ function parseQuotedScalar(value: string): string { if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) { return unescapeDoubleQuoted(value.slice(1, -1)); } return value.replace(/^["']|["']$/g, ''); } /** * Parse one already-delimited YAML region into a Frontmatter object. * * Extracted from `extractFrontmatter` (#1882) so the truncation probe below and the real * parse run the *same* parser. A second, simpler "does this look like YAML?" matcher would * be a parallel surface that drifts — exactly the generative-fix-divergence class. */ function parseYamlRegion(yaml: string): Frontmatter { const frontmatter: Frontmatter = {}; const lines = splitLines(yaml); // #3257: pending column-0 full-line comments, attached to the next top-level key. let pendingComments: string[] = []; let commentChannel: FullLineCommentChannel | undefined; // Stack to track nested objects: [{obj, key, indent}] type StackEntry = { obj: Record | unknown[]; key: string | null; indent: number }; const stack: StackEntry[] = [{ obj: frontmatter, key: null, indent: -1 }]; for (const line of lines) { // Skip empty lines if (line.trim() === '') continue; // #3257: capture column-0 full-line comments; attach them to the next top-level key. if (/^#/.test(line)) { pendingComments.push(line); continue; } // Calculate indentation (number of leading spaces) const indentMatch = line.match(/^(\s*)/); const indent = indentMatch ? indentMatch[1].length : 0; // Pop stack back to appropriate level while (stack.length > 1 && indent <= stack[stack.length - 1].indent) { stack.pop(); } const current = stack[stack.length - 1]; // Check for key: value pattern const keyMatch = line.match(/^(\s*)([a-zA-Z0-9_-]+):\s*(.*)/); if (keyMatch) { const key = keyMatch[2]; // #3257: attach any pending comments to this (top-level) key. if (pendingComments.length) { if (!commentChannel) commentChannel = { leading: {}, trailing: [] }; commentChannel.leading[key] = pendingComments; pendingComments = []; } const value = keyMatch[3].trim(); if (value === '' || value === '[') { // Key with no value or opening bracket — could be nested object or array const newObj: Record | unknown[] = value === '[' ? [] : {}; (current.obj as Record)[key] = newObj; current.key = null; // Push new context for potential nested content stack.push({ obj: newObj, key: null, indent }); } else if (value.startsWith('[') && value.endsWith(']')) { // Inline array: key: [a, b, c] — quote-aware split (REG-04 fix) (current.obj as Record)[key] = splitInlineArray(value.slice(1, -1)); current.key = null; } else { // Simple key: value (current.obj as Record)[key] = parseQuotedScalar(value); current.key = null; } } else if (line.trim().startsWith('- ')) { // Array item const itemValue = parseQuotedScalar(line.trim().slice(2)); // If current context is an empty object, convert to array if (typeof current.obj === 'object' && !Array.isArray(current.obj) && Object.keys(current.obj).length === 0) { // Find the key in parent that points to this object and convert it const parent = stack.length > 1 ? stack[stack.length - 2] : null; if (parent) { for (const k of Object.keys(parent.obj)) { if ((parent.obj as Record)[k] === current.obj) { (parent.obj as Record)[k] = [itemValue]; current.obj = (parent.obj as Record)[k] as unknown[]; break; } } } } else if (Array.isArray(current.obj)) { current.obj.push(itemValue); } } } // #3257: trailing comments (after the last key) + attach the channel if any comment was seen. if (pendingComments.length) { if (!commentChannel) commentChannel = { leading: {}, trailing: [] }; commentChannel.trailing = pendingComments; } if (commentChannel) { (frontmatter as Record)[FULL_LINE_COMMENTS as unknown as symbol] = commentChannel; } return frontmatter; } /** * Extract frontmatter from a document. * * Returns `{}` when the document has no frontmatter — and, unchanged since #1882, also * returns `{}` when the frontmatter fence was opened and never closed. That return value is * deliberately preserved: ADR-1411's amendment requires the fallback to stay, because * changing it would break callers that treat "absent" and "unusable" identically. What #1882 * adds is that the second case is no longer *silent*. * * The discriminator is the reason this is not simply "opened but never closed". A Markdown * document whose first line is a thematic break (`---`) takes that exact branch, so flagging * on the missing fence alone reports corruption on perfectly good Markdown. Instead the * unterminated region is run through this module's own parser and reported only when it * yields **two or more** keys. * * Two, not one, and the extra key is doing real work. A single `key: value` line is genuinely * ambiguous: `---` followed by `Note: this is a paragraph.` — or `Author:`, `TODO:`, `See:` — * is ordinary technical writing, a thematic break above a labelled line, and it parses as * exactly one key. There is no textual signal that separates it from a write interrupted * after its first key, so the threshold is set where the ambiguity ends. The cost is a false * negative on a file truncated after exactly one key; the benefit is silence on a very common * Markdown shape. That direction is deliberate and matches the choice already made at zero * keys: a false positive on valid Markdown is worse than a missed edge, because the * diagnostic is unconditional and cannot be turned off. Every GSD artefact this guards * (STATE.md, PLAN.md, ROADMAP.md, SUMMARY.md, agent/command docs) carries two or more * frontmatter keys, so the realistic interruption window stays covered. * * @param content Raw document text. * @param sourcePath Optional resolved path, used to name the file in the diagnostic and to * key its deduplication. Optional because this function has 50-odd call sites and several * hold only an in-memory string; those dedup on a content digest instead. */ function extractFrontmatter(content: string, sourcePath?: string): Frontmatter { // #2977: tolerate a single leading UTF-8 BOM (\uFEFF), which Windows tooling // (PowerShell `>`/`Out-File` on PS 5.1, several editors) writes by default. Without this // strip, the byte-0 `startsWith('---')` fence check below fails on the BOM and the whole // parse collapses to {} — every frontmatter field silently disappears, and the engine // proceeds as though the file had no frontmatter at all. The BOM is a single codepoint; // stripping it here restores byte-0 alignment so the rest of the function is unchanged. // Scope: BOM only. Arbitrary non-BOM content before the fence (leading whitespace/blank // line/comment) is a separate product-intent decision (tolerate vs diagnose) left to a // future change — this fix does not broaden the byte-0 fence rule beyond the BOM. if (content.charCodeAt(0) === 0xFEFF) { content = content.slice(1); } // Match frontmatter only at byte 0 — a `---` block later in the document // body (YAML examples, horizontal rules) must never be treated as frontmatter. const headerEnd = content.startsWith('---\r\n') ? 5 : content.startsWith('---\n') ? 4 : -1; if (headerEnd === -1) return {}; const closingLineStart = content.indexOf('\n---', headerEnd); if (closingLineStart === -1) { const region = content.slice(headerEnd); const probe = parseYamlRegion(region); if (Object.keys(probe).length >= UNTERMINATED_KEY_THRESHOLD && isFrontmatterShaped(region)) { warnUnusableInput({ reason: UNUSABLE_REASON.FRONTMATTER_UNTERMINATED, source: sourcePath, content, }); } return {}; } const yamlEnd = content[closingLineStart - 1] === '\r' ? closingLineStart - 1 : closingLineStart; return parseYamlRegion(content.slice(headerEnd, yamlEnd)); } /** * Escape a string for emission inside a YAML double-quoted scalar (#1779). * Backslash must be escaped first so the backslashes added for embedded quotes * (and control chars) are not themselves doubled. Without this, a value * carrying an indicator (`:`/`#`) that also contains a literal `"` serializes * to invalid YAML, e.g. `upstream: "https://x (Tom; "Git. Ship. Done")"`. A * literal newline/tab/control char inside the quotes likewise breaks (or * silently alters) the scalar, so those are escaped to their YAML forms too. */ function escapeDoubleQuoted(s: string): string { return s .replace(/\\/g, '\\\\') .replace(/"/g, '\\"') .replace(/\n/g, '\\n') .replace(/\t/g, '\\t') .replace(/\r/g, '\\r') // Remaining C0 controls + DEL → \xHH (a valid YAML double-quoted escape). .replace(/[\u0000-\u001f\u007f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`); } /** * A plain (unquoted) scalar that would mis-parse or round-trip lossily when * emitted bare must instead go through the double-quoted + escaped form * (#1779): the empty string (bare `k:` reloads as null), an embedded `"`/`\` * or control char, a leading YAML indicator (quote, `&`/`*`/`!` anchor/alias/ * tag, `|`/`>` block scalar, flow `[]{},`, `#`, reserved `%`/`@`/backtick, or * `-`/`?`/`:` before a space), or leading/trailing whitespace. This helper is * the correctness complement of `escapeDoubleQuoted`: it broadens the *trigger* * for quoting without broadening the lossy object-list handling deferred to * #1572/#1660. */ function scalarNeedsDoubleQuoting(s: string): boolean { if (s === '') return true; if (/["\\\u0000-\u001f\u007f]/.test(s)) return true; // Always-unsafe leading indicators, or leading/trailing whitespace. if (/^[,[\]{}#&*!|>'"%@`]/.test(s) || /^\s|\s$/.test(s)) return true; // `-` `?` `:` only start a plain scalar safely when NOT followed by a space. if (/^[-?:](\s|$)/.test(s)) return true; return false; } function reconstructFrontmatter(obj: Frontmatter): string { const lines: string[] = []; // #3257: read the full-line-comment channel (set by parseYamlRegion when comments // were present). Object.entries skips the Symbol key, so the data loop is unchanged. const commentChannel = (obj as Record)[FULL_LINE_COMMENTS as unknown as symbol] as FullLineCommentChannel | undefined; for (const [key, value] of Object.entries(obj)) { if (value === null || value === undefined) continue; // #3257: re-emit this key's leading full-line comments before the key itself. const leading = commentChannel?.leading[key]; if (leading) for (const c of leading) lines.push(c); if (Array.isArray(value)) { if (value.length === 0) { lines.push(`${key}: []`); } else if (value.every(v => typeof v === 'string') && value.length <= 3 && (value).join(', ').length < 60) { lines.push(`${key}: [${(value).join(', ')}]`); } else { lines.push(`${key}:`); for (const item of value) { lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#') || scalarNeedsDoubleQuoting(item)) ? `"${escapeDoubleQuoted(item)}"` : item}`); } } } else if (typeof value === 'object') { lines.push(`${key}:`); for (const [subkey, subval] of Object.entries(value)) { if (subval === null || subval === undefined) continue; if (Array.isArray(subval)) { if (subval.length === 0) { lines.push(` ${subkey}: []`); } else if (subval.every((v: unknown) => typeof v === 'string') && subval.length <= 3 && (subval).join(', ').length < 60) { lines.push(` ${subkey}: [${(subval).join(', ')}]`); } else { lines.push(` ${subkey}:`); for (const item of subval) { lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#') || scalarNeedsDoubleQuoting(item)) ? `"${escapeDoubleQuoted(item)}"` : item}`); } } } else if (typeof subval === 'object') { lines.push(` ${subkey}:`); for (const [subsubkey, subsubval] of Object.entries(subval as Record)) { if (subsubval === null || subsubval === undefined) continue; if (Array.isArray(subsubval)) { if (subsubval.length === 0) { lines.push(` ${subsubkey}: []`); } else { lines.push(` ${subsubkey}:`); for (const item of subsubval) { lines.push(` - ${item}`); } } } else { // eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions lines.push(` ${subsubkey}: ${subsubval}`); } } } else { // eslint-disable-next-line @typescript-eslint/no-base-to-string const sv = String(subval); lines.push(` ${subkey}: ${sv.includes(':') || sv.includes('#') || scalarNeedsDoubleQuoting(sv) ? `"${escapeDoubleQuoted(sv)}"` : sv}`); } } } else { const sv = String(value); if (sv.includes(':') || sv.includes('#') || sv.startsWith('[') || sv.startsWith('{') || scalarNeedsDoubleQuoting(sv)) { lines.push(`${key}: "${escapeDoubleQuoted(sv)}"`); } else { lines.push(`${key}: ${sv}`); } } } // #3257: re-emit any trailing full-line comments (those after the last key). if (commentChannel?.trailing?.length) { for (const c of commentChannel.trailing) lines.push(c); } return lines.join('\n'); } /** * #3257: copy the full-line-comment channel from `source` onto `target`, filtering * `leading` to keys still present in `target` (a deleted key's annotation goes with * it — AC5). No-op when `source` carries no channel. Consumers that rebuild their * target object fresh (syncStateFrontmatter builds derivedFm via buildStateFrontmatter * and copies keys with Object.keys, which skips the Symbol) MUST call this before * reconstructFrontmatter, or the channel parseYamlRegion attached to the extracted * source is lost. */ function propagateCommentChannel(source: Frontmatter, target: Frontmatter): void { const channel = (source as Record)[FULL_LINE_COMMENTS as unknown as symbol] as FullLineCommentChannel | undefined; if (!channel) return; const filtered: FullLineCommentChannel = { leading: {}, trailing: channel.trailing }; for (const [key, comments] of Object.entries(channel.leading)) { if (key in target) filtered.leading[key] = comments; } if (filtered.trailing.length || Object.keys(filtered.leading).length) { (target as Record)[FULL_LINE_COMMENTS as unknown as symbol] = filtered; } } /** * Slice a frontmatter YAML body into per-top-level-key raw text segments. Each segment * runs from a column-0 `key:` line through the line before the next column-0 key (or the * end), capturing all nested indented content. Used by `spliceFrontmatter` for per-key * identity preservation (#1572): a structurally-unchanged key keeps its original raw * text, so the lossy `reconstructFrontmatter` never touches object-lists the caller did * not modify (e.g. must_haves.artifacts / .prohibitions). */ function sliceTopLevelFrontmatterSegments(yaml: string): Array<{ key: string; raw: string }> { const lines = splitLines(yaml); const segments: Array<{ key: string; raw: string }> = []; let current: { key: string; raw: string[] } | null = null; for (const line of lines) { // A column-0 `key:` (no leading whitespace) starts a new top-level segment. if (/^[A-Za-z0-9_-]+:/.test(line)) { if (current) segments.push({ key: current.key, raw: current.raw.join('\n') }); const keyName = (line.match(/^([A-Za-z0-9_-]+):/) as RegExpMatchArray)[1]; current = { key: keyName, raw: [line] }; } else if (current) { current.raw.push(line); } // Stray lines before the first top-level key (rare in frontmatter) are dropped. } if (current) segments.push({ key: current.key, raw: current.raw.join('\n') }); return segments; } /** * Regenerate one frontmatter key's serialization, fail-closed if the lossy * `reconstructFrontmatter` cannot represent the value (#1572 codex review). Object-list * items (e.g. must_haves.artifacts `{path, provides}` maps) serialize as the literal * string "[object Object]"; rather than silently emit that and destroy the data, refuse * so the caller (cmdFrontmatterSet/Merge) errors out WITHOUT writing — directing the * user to edit the file directly. The reported #1572 case (mutating an UNRELATED field) * is unaffected: unchanged keys preserve their original raw text and never reach here. */ function regenerateFrontmatterKey(key: string, value: FrontmatterValue): string { const rendered = reconstructFrontmatter({ [key]: value }); if (/\[object Object\]/.test(rendered)) { throw new Error( `frontmatter: cannot faithfully serialize key "${key}" — it contains a nested object-list ` + `(e.g. must_haves.artifacts) the frontmatter writer cannot represent, and serializing it would ` + `emit "[object Object]". Edit the file directly instead of using frontmatter set/merge.`, ); } return rendered; } function spliceFrontmatter(content: string, newObj: Frontmatter): string { const match = content.match(/^---\r?\n[\s\S]+?\r?\n---/); if (match) { const fmBlock = match[0]; // Whole-document no-op guard: a true no-op returns content verbatim (byte-exact, // including any formatting the lossy serializer would normalize). try { if (frontmatterDeepEqual(extractFrontmatter(content), newObj)) { return content; } } catch { /* fall through to regeneration on any comparison hiccup */ } // Per-key identity preservation (#1572). `reconstructFrontmatter` is a deliberately // lossy serializer — it cannot faithfully re-emit nested object-list items (e.g. // must_haves.artifacts / .prohibitions, whose items are `{ path, provides }` / // `{ statement, status }` maps; `extractFrontmatter` flattens those to scalar // strings, so a round-trip drops `provides:` and collapses the list to a malformed // inline array). For any top-level key whose value is STRUCTURALLY UNCHANGED between // the original parse and `newObj`, preserve that key's ORIGINAL raw text verbatim; // regenerate only keys that actually changed. This generalizes the whole-document // no-op guard above to per-key fidelity, so mutating `wave` no longer destroys an // unrelated `must_haves` block. Keys absent from the original (genuinely new) are // regenerated and appended; keys absent from `newObj` are preserved (never silently // deleted by a set/merge). const fmLines = splitLines(fmBlock); const inner = fmLines.slice(1, -1).join('\n'); // drop the opening `---` and closing `---` let originalParsed: Frontmatter; try { originalParsed = extractFrontmatter(fmBlock); } catch { originalParsed = {}; } const segments = sliceTopLevelFrontmatterSegments(inner); const emitted: string[] = []; const seen: Set = new Set(); for (const seg of segments) { seen.add(seg.key); if (Object.prototype.hasOwnProperty.call(newObj, seg.key)) { // Key is in newObj: preserve original raw text if structurally unchanged, // otherwise regenerate. The key SET is defined by newObj — keys that were in // the original but are absent from newObj are intentionally dropped (the real // cmdSet/cmdMerge flow always passes the full merged object, so this only // matters for direct unit callers and matches spliceFrontmatter's contract: // the result frontmatter IS newObj). if (frontmatterDeepEqual(newObj[seg.key], originalParsed[seg.key])) { emitted.push(seg.raw); // unchanged → preserve original raw text verbatim } else { emitted.push(regenerateFrontmatterKey(seg.key, newObj[seg.key])); // changed → regenerate (fail-closed on object-lists) } } // else: key absent from newObj → drop (not emitted). } // Append genuinely-new keys not present in the original frontmatter. for (const k of Object.keys(newObj)) { if (!seen.has(k)) { emitted.push(regenerateFrontmatterKey(k, newObj[k])); } } const yamlStr = emitted.join('\n'); return `---\n${yamlStr}\n---` + content.slice(fmBlock.length); } // No existing frontmatter — generate from scratch, fail-closed on unrepresentable values. const yamlStr = reconstructFrontmatter(newObj); if (/\[object Object\]/.test(yamlStr)) { throw new Error( 'frontmatter: cannot faithfully serialize the requested frontmatter — it contains a nested ' + 'object-list (e.g. must_haves.artifacts) the writer cannot represent. Edit the file directly.', ); } return `---\n${yamlStr}\n---\n\n` + content; } /** * Structural deep-equality for two parsed frontmatter objects. Order-sensitive for arrays * (YAML lists are ordered), key-order-insensitive for objects. Used only by `spliceFrontmatter` * to recognize a no-op write-back; intentionally narrow (handles the string / string[] / * nested-object shapes `extractFrontmatter` produces). */ function frontmatterDeepEqual(a: unknown, b: unknown): boolean { if (a === b) return true; if (a == null || b == null) return a === b; if (Array.isArray(a) || Array.isArray(b)) { if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; return a.every((v, i) => frontmatterDeepEqual(v, b[i])); } if (typeof a === 'object' && typeof b === 'object') { const ao: Record = a as Record; const bo: Record = b as Record; const ak = Object.keys(ao); const bk = Object.keys(bo); if (ak.length !== bk.length) return false; return ak.every((k) => Object.prototype.hasOwnProperty.call(bo, k) && frontmatterDeepEqual(ao[k], bo[k]), ); } return false; } function parseMustHavesBlock(content: string, blockName: string): unknown[] { // Extract a specific block from must_haves in raw frontmatter YAML // Handles 3-level nesting: must_haves > artifacts/key_links > [{path, provides, ...}] const fmMatch = content.match(/^---\r?\n([\s\S]+?)\r?\n---/); if (!fmMatch) return []; const yaml = fmMatch[1]; const yamlLines = splitLines(yaml); // Find must_haves: first to detect its indentation level. Split-then-scan // (rather than a whole-string /m match) so a CRLF or blank-line boundary // can never be absorbed into the indent capture (#3360) — see // .gsd/phase/chore-3413-text-lines-seam/40-design.md. const mustHavesLinePattern = /^(\s*)must_haves:\s*$/; const mustHavesLineIndex = yamlLines.findIndex((line) => mustHavesLinePattern.test(line)); if (mustHavesLineIndex === -1) return []; const mustHavesIndent = (yamlLines[mustHavesLineIndex].match(/^(\s*)/) as RegExpMatchArray)[1].length; // Find the block (e.g., "truths:", "artifacts:", "key_links:") under must_haves // It must be indented more than must_haves but we detect the actual indent dynamically const blockLinePattern = new RegExp(`^(\\s+)${blockName}:\\s*$`); const blockLineIndex = yamlLines.findIndex((line) => blockLinePattern.test(line)); if (blockLineIndex === -1) return []; const blockIndent = (yamlLines[blockLineIndex].match(/^(\s*)/) as RegExpMatchArray)[1].length; // The block must be nested under must_haves (more indented) if (blockIndent <= mustHavesIndent) return []; const blockLines = yamlLines.slice(blockLineIndex + 1); // skip the header line // List items are indented one level deeper than blockIndent // Continuation KVs are indented one level deeper than list items const items: unknown[] = []; let current: string | Record | null = null; let listItemIndent = -1; // detected from first "- " line for (const line of blockLines) { // Skip empty lines if (line.trim() === '') continue; const indentMatch = line.match(/^(\s*)/); const indent = indentMatch ? indentMatch[1].length : 0; // Stop at same or lower indent level than the block header if (indent <= blockIndent && line.trim() !== '') break; const trimmed = line.trim(); if (trimmed.startsWith('- ')) { // Detect list item indent from the first occurrence if (listItemIndent === -1) listItemIndent = indent; // Only treat as a top-level list item if at the expected indent if (indent === listItemIndent) { if (current) items.push(current); const afterDash = trimmed.slice(2); const trimmedAfterDash = afterDash.trim(); // Check if it's a fully-quoted string (may contain ':' inside the quotes) if ((trimmedAfterDash.startsWith('"') && trimmedAfterDash.endsWith('"')) || (trimmedAfterDash.startsWith("'") && trimmedAfterDash.endsWith("'"))) { current = trimmedAfterDash.slice(1, -1); // Check if it's a simple string item (no colon means not a key-value) } else if (!afterDash.includes(':')) { current = afterDash.replace(/^["']|["']$/g, ''); } else { // Key-value on same line as dash: "- path: value" // YAML KV always has at least one space after the colon: "key: value" // Requiring \s+ rejects "Class::Method" and "db:seed" (no space after colon) const kvMatch = afterDash.match(/^(\w+):\s+"?([^"]*)"?\s*$/); if (kvMatch) { current = {}; (current)[kvMatch[1]] = kvMatch[2]; } else { // Looks like KV but doesn't match — treat as plain string (#2757) current = afterDash.replace(/^["']|["']$/g, ''); } } continue; } } if (current && typeof current === 'object' && indent > listItemIndent) { // Continuation key-value or nested array item if (trimmed.startsWith('- ')) { // Array item under a key const arrVal = trimmed.slice(2).replace(/^["']|["']$/g, ''); const keys = Object.keys(current); const lastKey = keys[keys.length - 1]; if (lastKey && !Array.isArray((current)[lastKey])) { const existing = (current)[lastKey]; (current)[lastKey] = existing ? [existing] : []; } if (lastKey) ((current)[lastKey] as unknown[]).push(arrVal); } else { const kvMatch = trimmed.match(/^(\w+):\s*"?([^"]*)"?\s*$/); if (kvMatch) { // Trim: a quoted value like `"backstop "` captures the inner trailing space in group 2. // Left untrimmed, a hand-authored `must_haves` marker degrades (a `backstop` truth silently // grades green instead of abstaining — #1905, the #1154 false-pass; also the sibling // check_target/violationFixture path). Whitespace is never semantic in a scalar KV value. const val = kvMatch[2].trim(); // Try to parse as number (current)[kvMatch[1]] = /^\d+$/.test(val) ? parseInt(val, 10) : val; } } } } if (current) items.push(current); // Warn when must_haves block exists but parsed as empty -- likely YAML formatting issue. // This is a critical diagnostic: empty must_haves causes verification to silently degrade // to Option C (LLM-derived truths) instead of checking documented contracts. if (items.length === 0 && blockLines.length > 0) { const nonEmptyLines = blockLines.filter(l => l.trim() !== '').length; if (nonEmptyLines > 0) { process.stderr.write( `[gsd-tools] WARNING: must_haves.${blockName} block has ${nonEmptyLines} content lines but parsed 0 items. ` + `Possible YAML formatting issue — verification will fall back to LLM-derived truths.\n` ); } } return items; } // ─── Frontmatter CRUD commands ──────────────────────────────────────────────── // Shared base for 'plan' and 'plan-gap-closure' below — a plain array reference (not // FRONTMATTER_SCHEMAS.plan.required) because the object literal that defines // FRONTMATTER_SCHEMAS cannot refer to itself mid-initialization (TDZ). const PLAN_REQUIRED_FIELDS = ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves']; // `requiredValues` is optional per schema: when a field name is a key here, the // field must be PRESENT AND strictly equal (===) to the given value to satisfy // the schema — presence alone is not enough. Every other required field (no // entry in requiredValues) keeps the original presence-only contract. const FRONTMATTER_SCHEMAS: Record }> = { plan: { required: PLAN_REQUIRED_FIELDS }, // #2847: gap-closure plans carry every 'plan' field PLUS gap_closure — the flag // execute-phase --gaps-only filters on. A separate schema (not a change to // 'plan') so standard/reviews-mode plans stay unaffected: they validate against // 'plan' and are never required to declare or be checked for gap_closure. // Derived from PLAN_REQUIRED_FIELDS (never hand-duplicated) so the two can't drift. // // requiredValues.gap_closure = true (not just presence): --gaps-only filters // strictly on gap_closure === true (execute-phase.md, partial-wave.md), so a // plan carrying `gap_closure: false` would pass a presence-only check and // still be silently skipped at execute time — the exact symptom #2847 // reports, one value away. Presence-only was flagged in review as a live // reproduction of the bug this schema exists to close. 'plan-gap-closure': { required: [...PLAN_REQUIRED_FIELDS, 'gap_closure'], // extractFrontmatter parses every scalar as a string (FrontmatterValue has // no boolean member — `gap_closure: true` in YAML becomes the JS string // "true", not the boolean true), so the required value is the string here. requiredValues: { gap_closure: 'true' }, }, summary: { required: ['phase', 'plan', 'subsystem', 'tags', 'duration', 'completed'] }, verification: { required: ['phase', 'verified', 'status', 'score'] }, }; /** * Strip frontmatter blocks from the start of `content`. * * Handles CRLF line endings and, by default, multiple stacked blocks * (corruption recovery): greedily strips consecutive `---...---` blocks * separated by optional whitespace, so a doubled/tripled frontmatter header * (e.g. from a botched merge) is fully removed, not just the first block. * * Pass `{ once: true }` to stop after the first block. Callers whose input is * an arbitrary user-authored document — rather than a GSD artefact with a * known doubling failure mode — need this: a body that opens with a * thematic-break-delimited section is lexically indistinguishable from a * second frontmatter block, and the greedy loop deletes it silently (#2703). * * Canonical home for this primitive (#2143 audit dedup): previously * duplicated byte-identically in both `state.cts` and `state-transition.cts`. */ function stripFrontmatter(content: string, opts: { once?: boolean } = {}): string { let result = content; while (true) { const stripped = result.replace(/^\s*---\r?\n[\s\S]*?\r?\n---\s*/, ''); if (stripped === result) break; result = stripped; if (opts.once) break; } return result; } function cmdFrontmatterGet(cwd: string, filePath: string, field: string | undefined, raw: boolean): void { if (!filePath) { error('file path required'); } // Path traversal guard: reject null bytes if (filePath.includes('\0')) { error('file path contains null bytes'); } const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); const content = safeReadFile(fullPath); if (!content) { output({ error: 'File not found', path: filePath }, raw, undefined); return; } // Pass the resolved path so a truncated file is named in the diagnostic and deduplicated // per file rather than per content digest (#1882, ADR-1411 wiring clause). const fm = extractFrontmatter(content, fullPath); if (field) { const value = fm[field]; if (value === undefined) { output({ error: 'Field not found', field }, raw, undefined); return; } output({ [field]: value }, raw, JSON.stringify(value)); } else { output(fm, raw, undefined); } } function cmdFrontmatterSet(cwd: string, filePath: string, field: string | undefined, value: string | undefined, raw: boolean): void { if (!filePath || !field || value === undefined) { error('file, field, and value required'); } // Path traversal guard: reject null bytes if (filePath.includes('\0')) { error('file path contains null bytes'); } const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw, undefined); return; } const content = fs.readFileSync(fullPath, 'utf-8'); // Pass the resolved path so a truncated file is named in the diagnostic and deduplicated // per file rather than per content digest (#1882, ADR-1411 wiring clause). const fm = extractFrontmatter(content, fullPath); let parsedValue: unknown; try { parsedValue = JSON.parse(value as string); } catch { parsedValue = value; } fm[field as string] = parsedValue as FrontmatterValue; const newContent = spliceFrontmatter(content, fm); // #1660: a no-op set (newContent unchanged) with a dict-valued field means the lossy // frontmatter parser made the new value's projection equal the original's — the change // did not apply (bites object-list fields like must_haves). Detection lives in the pure // exported helper noOpObjectListSetError so the mutation gate (property/unit set) covers // it — the cmd path itself is not in that set. const noOpErr = noOpObjectListSetError(content, newContent, parsedValue); if (noOpErr) { output({ error: noOpErr, field }, raw, undefined); return; } platformWriteSync(fullPath, newContent); output({ updated: true, field, value: parsedValue }, raw, 'true'); } /** * #1660: detect a frontmatter `set` that would be a silent no-op on a dict-valued field. * Returns an error message when the splice produced no content change but the new value * is a dict (object-list fields like must_haves, whose `{path, provides}` items flatten to * scalar strings under extractFrontmatter so a replacement can deep-equal the original's * projection), else null. Scalars and scalar arrays round-trip faithfully, so idempotent * sets of those are intentionally NOT flagged. Pure and unit-tested directly (the cmd path * is not in Stryker's property/unit set, so the detection must be testable in isolation). */ function noOpObjectListSetError(originalContent: string, newContent: string, parsedValue: unknown): string | null { if (newContent !== originalContent) return null; if (parsedValue === null || typeof parsedValue !== 'object' || Array.isArray(parsedValue)) return null; return 'frontmatter set had no effect — the supplied value is equivalent to the existing field under the frontmatter parser, which cannot faithfully round-trip object-list fields like must_haves. Edit the file directly.'; } function cmdFrontmatterMerge(cwd: string, filePath: string, data: string | undefined, raw: boolean): void { if (!filePath || !data) { error('file and data required'); } const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw, undefined); return; } const content = fs.readFileSync(fullPath, 'utf-8'); // Pass the resolved path so a truncated file is named in the diagnostic and deduplicated // per file rather than per content digest (#1882, ADR-1411 wiring clause). const fm = extractFrontmatter(content, fullPath); let mergeData: Record; try { mergeData = JSON.parse(data as string) as Record; } catch { error('Invalid JSON for --data'); return; } Object.assign(fm, mergeData); const newContent = spliceFrontmatter(content, fm); platformWriteSync(fullPath, newContent); output({ merged: true, fields: Object.keys(mergeData) }, raw, 'true'); } function cmdFrontmatterValidate(cwd: string, filePath: string, schemaName: string | undefined, raw: boolean): void { if (!filePath || !schemaName) { error('file and schema required'); } if (filePath.includes('\0')) { error('file path contains null bytes'); } // Guard against prototype-chain keys (__proto__, constructor, toString, hasOwnProperty, // valueOf, ...): a bare FRONTMATTER_SCHEMAS[schemaName] lookup resolves those to // Object.prototype members instead of undefined, so a `!schema` check on the raw // lookup never fires and the code crashes later on `schema.required.filter` with an // uncaught TypeError instead of the intended "Unknown schema" message. Confirmed live // with --schema __proto__. Now that --schema is agent-bound (agents/gsd-planner.md's // $SCHEMA), this is reachable from prompt state, not just an unreachable literal. // Checked and rejected BEFORE the lookup (rather than `?? undefined`-ing the lookup // itself) so `schema`'s inferred type stays non-optional and needs no assertion below. if (!Object.prototype.hasOwnProperty.call(FRONTMATTER_SCHEMAS, schemaName as string)) { error(`Unknown schema: ${schemaName}. Available: ${Object.keys(FRONTMATTER_SCHEMAS).join(', ')}`); } const schema = FRONTMATTER_SCHEMAS[schemaName as string]; const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); const content = safeReadFile(fullPath); if (!content) { output({ error: 'File not found', path: filePath }, raw, undefined); return; } // #2701: fail loud on NUL/binary corruption before schema checks. A structurally // intact-but-NUL-corrupted file otherwise passes as valid:true and is then silently // skipped by recursive/binary-skipping searchers, reading downstream as "absent." const encErr = textEncodingError(content, filePath); if (encErr) { output({ valid: false, errors: [encErr], schema: schemaName }, raw, 'invalid'); return; } // Pass the resolved path so a truncated file is named in the diagnostic and deduplicated // per file rather than per content digest (#1882, ADR-1411 wiring clause). const fm = extractFrontmatter(content, fullPath); const requiredValues = schema.requiredValues || {}; // A field satisfies the schema when it is present AND — for fields with a // requiredValues entry — strictly equal to that value. Absent and // wrong-value both surface as `missing` (existing `missing`/`present` // partition of `required` is unchanged — no consumer reads `present` to // mean "physically exists regardless of value," confirmed by searching // every caller before choosing this). But #2847 review: folding silently // made "missing" misleading for a WRONG-valued field the plan author can // plainly see in the file (e.g. `gap_closure: True`) — nothing told them // the field is present but the VALUE is wrong, so they could loop trying // to add a field that is already there. `invalidValue` names exactly that // subset (present, but not the required value) so the message stays // actionable without changing what `missing`/`present` mean. const wrongValue = (f: string): boolean => fm[f] !== undefined && Object.prototype.hasOwnProperty.call(requiredValues, f) && fm[f] !== requiredValues[f]; const satisfies = (f: string): boolean => fm[f] !== undefined && !wrongValue(f); const missing = schema.required.filter(f => !satisfies(f)); const present = schema.required.filter(f => satisfies(f)); const invalidValue = schema.required.filter(wrongValue); output({ valid: missing.length === 0, missing, present, invalidValue, schema: schemaName }, raw, missing.length === 0 ? 'valid' : 'invalid'); } export = { extractFrontmatter, UNTERMINATED_KEY_THRESHOLD, // Additive alias (#644 prohibition-probe schema contract): the probe round-trip seam reads a // frontmatter object via `parseFrontmatter` (the name the contract test pins). It is the SAME // function as `extractFrontmatter` — a bare-object parse with no behavior change — exposed under // the alias so the prohibition schema round-trip and any future caller can use the canonical name. parseFrontmatter: extractFrontmatter, reconstructFrontmatter, spliceFrontmatter, stripFrontmatter, noOpObjectListSetError, parseMustHavesBlock, FRONTMATTER_SCHEMAS, cmdFrontmatterGet, cmdFrontmatterSet, cmdFrontmatterMerge, cmdFrontmatterValidate, propagateCommentChannel, };