* test(#3413): failing-first suite for the line-terminator seam Phase 2 of epic #3212 (ADR-3212 §3/§6/§7). Tests only — src/text-lines.cts does not exist yet, so tests/text-lines.test.cjs fails with MODULE_NOT_FOUND at its require line, which is the intended RED. The frontmatter.test.cjs additions drive #3360 (confirmed-bug) fail-first: parseMustHavesBlock currently returns [] for every must_haves block on a CRLF-authored plan file, because \r is its own LineTerminator in ECMAScript and two /m-anchored \s* patterns can absorb it, inflating a captured indent by one character and tripping the "not nested under must_haves" guard. Verified locally against the current (unfixed) compiled module: both the direct repro and the silent-exit "blank line before must_haves:" variant return [] today. A parity property test (crlf vs lf must deep-equal for every block name) matches a pattern this maintainer has required repeatedly for prior CRLF fixes in this codebase (Cortex-recorded, verify_intent=held). The no-crlf-fragile-split.rule.test.cjs additions lock the eslint rule's future fix-hint text (pointing at splitLines()) and its self-reference non-violation (the seam's own correct \r?\n split must never flag itself). Design: .gsd/phase/chore-3413-text-lines-seam/40-design.md Test matrix: .gsd/phase/chore-3413-text-lines-seam/50-test-matrix.md * chore(#3413): src/text-lines.cts owns line-terminator handling Phase 2 of epic #3212 (ADR-3212 §3/§6/§7). Adds splitLines/normalizeEol/ detectEol/joinLines and migrates frontmatter.cts onto it. parseMustHavesBlock (#3360, confirmed-bug) returned [] for every must_haves block on a CRLF plan file. Root cause: \r is its own LineTerminator in ECMAScript, so under /m two \s*-anchored indentation lookups could match at the position INSIDE a \r\n pair and absorb the terminator, inflating the captured indent by one character and tripping the "not nested under must_haves" guard. Two silent exits, one with a diagnostic and one without (a blank line before must_haves: hits the silent path). Fixed by converting both lookups from a whole-string /m match to split-then-scan — splitLines first, then a per-line, non-/m match — the same structural pattern parseYamlRegion (30 lines away in the same file) already used safely. Nothing downstream of the two lookups changed; blockLines is now sliced from the already-split array instead of re-splitting a substring, but its contents are unchanged for LF input, and the per-line dash/kv parsing loop is untouched. A parity property test (CRLF and LF plans parse to identical must_haves for every block name) matches a pattern this maintainer has required repeatedly for prior CRLF fixes in this file's neighborhood (Cortex: 7 recorded decisions, verify_intent -> held). frontmatter.cts's other .split(/\r?\n/) call sites (parseYamlRegion, isFrontmatterShaped, sliceTopLevelFrontmatterSegments, spliceFrontmatter) are rerouted onto splitLines — a literal 1:1 substitution, zero behavior change, since splitLines IS that same regex plus a type guard. The 4 scripts/normalizeLineEndings copies (gen-registry, gen-loop-host- contract, gen-capability-registry, gen-context-index) are deleted and rerouted onto normalizeEol, which strips a bare unpaired \r exactly like the deleted copies did (not just \r\n pairs) -- verified against each script's own --check mode against its real generated output. local/no-crlf-fragile-split widens from tests/ to src/**/*.cts, with its fix-hint message now naming splitLines() instead of the raw regex -- the prohibition finally has a primitive to point at. Detection logic unchanged in this phase (deliberate scope limit, see design doc Known limits: the rule doesn't yet recognize safeReadFile/platformReadSync as a content source, and has no detector for the \s-adjacent-to-anchor shape that is #3360's actual mechanism -- the CLASS is converged by the direct fix + regression test regardless). joinLines/detectEol are NOT wired into frontmatter.cts's own write path (cmdFrontmatterSet/Merge -> platformWriteSync) -- verified that platformWriteSync already, unconditionally converts CRLF->LF on every .md write today as a pre-existing policy owned by a different module, and ADR-3212's backward-compatibility clause rules out a file-format change in any phase. Stated explicitly in Known limits rather than left for a reader to discover. Six-gate ripple: .gitignore, eslint.config.mjs (src/**/*.cts block), docs/INVENTORY.md + INVENTORY-MANIFEST.json (regenerated), CONTEXT.md glossary (Text Lines Module, mirroring Phase 1's Pattern Module entry). Design: .gsd/phase/chore-3413-text-lines-seam/40-design.md Test matrix: .gsd/phase/chore-3413-text-lines-seam/50-test-matrix.md * fix(#3413): fix 13 pre-existing CRLF-fragile splits the widened rule found Widening local/no-crlf-fragile-split from tests/ to src/**/*.cts (the previous commit) immediately surfaced 13 real, pre-existing violations across 10 files -- undetected until now because the rule never scanned src/. This is the exact defect class ADR-3212 exists to close, playing out again one phase after Phase 1 hit the same shape ("the new lint rule -- once live -- found 27 more"). Per CLAUDE.md's no-defer rule, fixed inline rather than deferred or suppressed; there is no established suppression convention for this rule in src/ and inventing one now would undermine the point of widening it. audit.cts, broken-windows.cts, core-utils.cts, init.cts, milestone.cts, phase.cts (x3), profile-output.cts, roadmap.cts (x2): bare-\n splits or regex character classes widened to \r?\n / [^\r\n], each following the same pattern already established migrating frontmatter.cts. phase-estimation.cts: `\r?(?:\n|$)` restructured to `(?:\r?\n|\r?$)` -- already semantically CRLF-safe, but the rule's lexical scanner doesn't recognize \r? guarding a group (only \r? immediately before a literal \n). Verified the two forms are equivalent across all four EOL/EOF cases before restructuring, not assumed. roadmap-upgrade.cts needed two coupled sites, not the one flagged line: computeMigrationPlan and applyMigration must agree on line representation for the lines[edit.lineIndex] === edit.from equality check to hold, and the write-back needed joinLines + detectEol -- a plain lines.join('\n') was silently flattening a CRLF ROADMAP.md to LF wholesale on every migration. This is the first real production consumer of joinLines/detectEol in this epic (frontmatter.cts's own write path doesn't use them -- see the previous commit's Known limits). Fixing the 13 flagged sites surfaced 4 more adjacent same-shape sites the rule doesn't track (.search() and new RegExp(dynamicString) aren't in its tracked call/construction set). Investigated each empirically -- hand-tracing this exact bug class already produced one wrong conclusion earlier in this phase (a detectEol design-doc arithmetic error), so these were verified with real CRLF fixtures rather than reasoned about on paper: - audit.cts (scanTodos): REAL bug, fixed. `bodyMatch.trim().split ('\n')[0]` leaked a trailing \r into a user-visible todo summary on CRLF input -- .trim() only strips the string's outer edges, not a \r sitting mid-string before the first bare \n. Now splitLines(...) [0]. - phase.cts (cmdPhaseInsert, bullet-style branch): REAL bug, fixed. [^\n]* in targetBulletPattern swallowed a line's trailing \r on CRLF input, shifting the computed insert position to land INSIDE the \r\n pair; combined with a hardcoded '\n' bullet separator, a CRLF ROADMAP.md ended up with a mixed CRLF/LF result after an insert. Fixed with two coupled changes (either alone still corrupts, verified both ways): [^\r\n]* in the pattern, and the new bullet's leading terminator now comes from detectEol(rawContent). - roadmap.cts (cmdRoadmapAnnotateDependencies phase-boundary scan): investigated, genuinely safe, left untouched. The .search(/\n#{2,4} .../) boundary-finder and the [^\n]*-based heading match were empirically verified on a 3-phase CRLF fixture -- the only stray \r ends up at the tail of an intermediate phaseSection string that is only ever used for .test()-based idempotency checks, never for an exact-match comparison or written back to disk. No corruption on round-trip. Every fix re-verified: npm run build:lib clean, npx eslint 'src/**/*.cts' --no-cache reports 0 problems (was 13), and each fixed function's existing LF-input tests were spot-checked unchanged. * fix(#3413): apply orthogonal review findings Two isolated review engines (correctness + security) ran against the full diff and found three majors, one real security issue, and several disclosure-worthy minors. All fixed or explicitly disclosed with evidence; nothing deferred. MAJOR — detectEol's tie-break contradicted its own documented contract. Code returned '\n' on a 1:1 crlf/bare-LF tie; every doc (design doc, CONTEXT.md, the function's own comment) says ties resolve to '\r\n'. The existing test masked this by reusing the same tie fixture the buggy code happened to satisfy, rather than a genuine LF-majority case. Root cause: an Edit attempted earlier in this phase to fix this exact arithmetic error was blocked by the tier guard, and a later dispatch was incorrectly told it had already landed. Fixed: condition is now crlfCount >= bareLfCount; the test fixture corrected to a genuine 2:1 majority, with a new explicit tie-case test. MAJOR — phase.cts's cmdPhaseInsert built an EOL-aware bulletEntry via detectEol(rawContent), justified by a comment claiming a hardcoded '\n' corrupts a CRLF ROADMAP.md. False: this write goes through platformWriteSync, whose normalizeContent/_normalizeMd unconditionally converts CRLF->LF for any .md target — the templating was inert dead code, erased before the file is ever written. Reverted to hardcoded '\n', comment corrected to state the true reasoning. The separate [^\n]* -> [^\r\n]* widening one function up (a real splice-position fix, independent of final EOL) was kept. MAJOR — roadmap-upgrade.cts's stated rationale for switching onto splitLines/joinLines was wrong (both functions always agreed on line representation, before and after — the claimed equality-check risk never existed), and the change it justified introduced a real regression: forcing every line onto one dominant terminator silently rewrites untouched lines' EOL on a mixed-CRLF/LF ROADMAP.md. This write path uses raw fs.writeFileSync, not platformWriteSync, so unlike the phase.cts case above the regression is genuinely live. Fixing this took two attempts. The first attempt (revert to split('\n')/join('\n') plus a suppression comment) was correctly blocked by an agent that discovered local/no-crlf-fragile-split is a PROTECTED_RULES entry in tests/portability-rule-disable-ban.test.cjs — a hard, out-of-band, ADR-1703-governed guardrail banning any eslint-disable of this rule anywhere in src/**/*.cts. That agent also detected and correctly disregarded an injected instruction that appeared in tool output during a git operation, per this session's untrusted-content policy. The actual fix: computeMigrationPlan reverted to roadmapContent.split('\n') (confirmed lint-clean — the rule's data-flow tracking only follows a variable's initializer, and this one is declared empty then reassigned in a try block). applyMigration's write-back now splices edits against the ORIGINAL content string via indexOf('\n', pos) boundary-walking instead of a full split/rejoin, so every untouched character — including every line's own terminator — is copied byte-for-byte. A capture-group split (/(\r\n|\n)/, preserving terminators inline) was tried first and empirically confirmed to still trip the rule before this approach was chosen instead. MINOR (security) — roadmap.cts's cmdRoadmapAnnotateDependencies used the STRING form of String#replace, so $&, $`, $', $1-$9 inside must_haves.truths content (author-controlled) were interpreted as replacement directives, splicing unrelated ROADMAP.md text into the result. Fixed with the function-replacement form, which is never pattern-interpreted. Verified before/after with the reviewer's exact repro. Also disclosed rather than silently left: test matrix row 31 (four planned CRLF-materialized regression tests) was never implemented as separate files — corrected to record the actual verification (a manual --check run plus incidental existing coverage via each script's normalizeLineEndings: normalizeEol alias). parseMustHavesBlock's LF behavior was claimed byte-for-byte unchanged but the old yaml.indexOf(blockMatch[0]) substring search could match an unrelated earlier occurrence of the header text (e.g. inside a quoted value) — the split-then-scan fix incidentally also closes this, a strict improvement now recorded in the design doc rather than left implicit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3413): checkpoint 2 red — missing eslint ignore entry, RuleTester config error Checkpoint 2 came back red with 5 failures on the reviewed sha, both gaps genuinely undetectable by any local gate. eslint.config.mjs was missing the 'gsd-core/bin/lib/text-lines.cjs' ignores-list entry (ADR-457: generated .cjs artifacts are excluded from direct type-aware linting). Phase 1's sibling entry (pattern.cjs) sits two lines above it and was the exact precedent read while researching the six-gate ripple for this module -- missed anyway. Caught by tests/repo-invariants.test.cjs's bin/lib coverage-tracking test, which only runs on the remote suite. tests/no-crlf-fragile-split.rule.test.cjs's row-32 case specified both `messageId` and `message` on the same RuleTester error assertion -- ESLint's RuleTester rejects that combination outright. This existed since the test was first authored and was never caught locally: `npx eslint` only lints the file's syntax, it does not execute RuleTester, and local `node --test` is hard-blocked in this repo -- the assertion had never actually RUN before this checkpoint. It was even present in checkpoint 1's failure list, listed there as one of the "expected RED" tests; I matched it against my expected-failures list by test NAME only and never inspected the actual failure detail closely enough to notice it was failing for the wrong reason (a RuleTester config error, not the intended message-text mismatch). Fixed by keeping `message` (the exact-text assertion the test exists to make) and dropping `messageId`. Verified the crlfFragileSplit message string in eslint-rules/no-crlf-fragile-split.cjs matches this assertion character-for-character, and swept every other invalid case in the file for the same double-specification bug (none found -- all pre-existing cases use messageId alone). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#3413): add Fixed changeset for the #3360 CRLF parsing fix The sole user-visible effect of this phase. No breaking-change label or Changed fragment needed — ADR-3212's Backward Compatibility section names the Node floor (Phase 1, already shipped) as the epic's only breaking change; Phase 2 has none. * chore(#3413): backfill changeset pr number to 3420 --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
902 lines
45 KiB
TypeScript
902 lines
45 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[] };
|
|
|
|
/**
|
|
* 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] = value.replace(/^["']|["']$/g, '');
|
|
current.key = null;
|
|
}
|
|
} else if (line.trim().startsWith('- ')) {
|
|
// Array item
|
|
const itemValue = line.trim().slice(2).replace(/^["']|["']$/g, '');
|
|
|
|
// 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,
|
|
};
|