Files
msd-core/src/frontmatter.cts
Tom Boucher 507db38404 fix(#3497): unescape double-quoted scalars on parse so round-trips stop doubling backslashes (#3521)
* fix(#3497): unescape double-quoted scalars on parse so round-trips stop doubling backslashes

* chore(#3497): add changeset fragment for PR #3521

---------

Co-authored-by: sim <sim@local>
2026-08-14 23:00:10 -04:00

960 lines
47 KiB
TypeScript

/**
* 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<string, unknown>;
type Frontmatter = Record<string, FrontmatterValue>;
// ─── 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<string, ...>) 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<string, string[]>; 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<string, unknown> | 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<string, unknown> | unknown[] = value === '[' ? [] : {};
(current.obj as Record<string, unknown>)[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<string, unknown>)[key] = splitInlineArray(value.slice(1, -1));
current.key = null;
} else {
// Simple key: value
(current.obj as Record<string, unknown>)[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<string, unknown>)[k] === current.obj) {
(parent.obj as Record<string, unknown>)[k] = [itemValue];
current.obj = (parent.obj as Record<string, unknown>)[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<symbol, unknown>)[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<symbol, unknown>)[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<string, unknown>)) {
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<symbol, unknown>)[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<symbol, unknown>)[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<string> = 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<string, unknown> = a as Record<string, unknown>;
const bo: Record<string, unknown> = b as Record<string, unknown>;
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<string, unknown> | 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<string, { required: string[]; requiredValues?: Record<string, FrontmatterValue> }> = {
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<string, FrontmatterValue>;
try { mergeData = JSON.parse(data as string) as Record<string, FrontmatterValue>; } 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,
};