* 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>
960 lines
47 KiB
TypeScript
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,
|
|
};
|