* docs(#3881): answer §8.1's open question and correct three wrong premises ADR-3473 §8.1 carries a blocking open question with a forcing function: it must be answered before any implementation PR for the rule opens. Answered here as (a), a string-coercing adapter, with the measurement that settles it. The sequencing note bet that §8.8's schema would make (b) tractable. Measured against merged reality it does not: only 33 of extractFrontmatter's 78 non-test call sites read STATE.md, and two of the five compensating mechanisms §8.1 lists survive real types, leaving ~31 lines across 3 call sites as the actual prize. Also corrects three claims verified false while answering it. §8.1's justifying sentence names #3349 and #3360 as defects a real parser would fix; both are already fixed on next, confirmed by executing the compiled parser rather than reading it. The guard roster calls lint-frontmatter-scalar-broad-grep.cjs an expected casualty of this rule, but it guards shell grep idioms in workflow bash fences and never touches our parser. The same roster calls lint-vendored-deps.cjs reusable as-is; it is hardcoded to re2js throughout. The last two were caught by applying the rule this amendment records -- a factual claim in this ADR is a hypothesis until the implementing phase executes it -- on its first use. Refs #3881 * docs(#3881): record that §8.1's fork is ill-posed and (a) is not implementable An adversarial pass on the Phase 4 design established by execution that extractFrontmatter is not a YAML parser but a line-oriented scanner whose output is a function of raw source text. Four spellings of the same value collapse to one js-yaml tree but produce four distinct legacy strings, one of them mangled. No adapter over a tree can choose among outputs the tree does not distinguish, so fork (a) -- keep a string-coercing adapter so the existing contract holds -- cannot be built. For any document with a non-scalar value, (a) collapses into (b); about 26 percent of frontmatter-carrying documents have one. Also records three design defects and one new attack surface, all confirmed by execution: catching a parse failure and returning {} would delete the frontmatter block on the next write at eight call sites that conflate empty with unparseable; an empty value yields null where legacy yields {}, and reconstructFrontmatter omits null-valued keys, so the shipped state template's empty progress key would vanish; the #1882 truncation probe is parseYamlRegion itself rather than a pre-parse heuristic, so it cannot both stay unchanged and survive that deletion; and FAILSAFE_SCHEMA still resolves aliases, expanding seven lines to 22.8 MB. The rule is not deferred. The measurement is the deliverable and the re-scoping is recorded as an open question with a forcing function, per section 8's own rule. Refs #3881 * test(#3881): failing-first rows for block scalars, unicode keys and the missing #3594 matrix Creates tests/feat-3594-parser-adversarial-frontmatter.test.cjs, the file the fixture README instructs contributors to register fixtures in but which never existed. Section C: table-driven ownership check over tests/fixtures/adversarial/frontmatter/ so a fixture with no matrix entry fails loudly; six existing fixtures (duplicate-keys, crlf-mixed, unclosed-block, unicode-keys-and-values, null-byte-value, huge-bounded) each get the invariant its README states. B1 blockScalarValueIsNotTheBlockIndicator: parsing commands/gsd/add-tests.md must give argument-instructions the instruction text, not the literal '|'. RED today. B2 blockScalarDoesNotInventATopLevelKey: same parse must not produce a top-level Example key scraped from inside the block body. RED today. B3 unicodeKeyRoundTripsAsIs: the 相 key in unicode-keys-and-values.md must survive parsing; today it is silently dropped. RED today. Refs #3881 * chore(#3881): vendor js-yaml and generalize the vendored-deps guard to a manifest Packaging step for ADR-3473 §8.1: makes js-yaml available to gsd-core/bin/** without promoting it out of devDependencies (promoting broke every installed tree, #3496). gsd-core/bin/lib/vendor/js-yaml.cjs is a verbatim copy of node_modules/js-yaml/dist/js-yaml.js (the self-contained UMD dist bundle, not index.js), exposing load/dump/FAILSAFE_SCHEMA/YAMLException with zero require() calls of its own. src/vendor/js-yaml.d.cts is hand-authored, not copied, because js-yaml ships no upstream .d.ts and @types/js-yaml is not installed. It is deliberately narrow, declaring only the four symbols in use, so anchors/aliases/custom types/loadAll are unreachable from typed code -- a compile-time enforcement of ADR-3473 §8.1's refusal to expand alias resolution for security reasons. Because it has no upstream counterpart it is excluded from the byte-compare. scripts/lint-vendored-deps.cjs is refactored from a script hardcoded to re2js into a table-driven VENDORED manifest (one row per package: upstream/vendored .cjs paths, optional .d.cts paths, twin kind upstream-verbatim vs hand-authored) so a second vendored package does not require a second hardcoded check block, per ADR-3473 §8.3 'one implementation per rule'. The four existing re2js checks (vendored .cjs vs node_modules, vendored .d.cts vs node_modules, src/vendor twin vs bin-side twin, devDependency version pin vs installed version) are preserved unchanged; verified pass/fail identical before and after the refactor, and the guard's ability to fail was re-proven with a deliberate one-byte append to both re2js.cjs and js-yaml.cjs, then restored. docs/INVENTORY.md and docs/INVENTORY-MANIFEST.json (via gen-inventory-manifest.cjs --write, run after build:lib) register vendor/js-yaml.cjs. gsd-core/bin/lib/vendor/README.md documents both vendored packages and the two twin kinds. Refs #3881 * feat(#3881): parse .planning frontmatter with the vendored js-yaml ADR-3473 §8.1: extractFrontmatter's read path is no longer a hand-rolled line scanner. parseYamlRegion, escapeDoubleQuoted, unescapeDoubleQuoted and parseQuotedScalar are deleted (not patched); parsing now goes through the vendored js-yaml (./vendor/js-yaml.cjs) under { schema: FAILSAFE_SCHEMA, json: true }. Everything js-yaml does not do is layered on top, in one place, carrying the seven design-doc consequences: 1. Empty value: a null js-yaml value is coerced to {} (matching legacy's own empty-value contract) so reconstructFrontmatter — which omits null-valued keys — still round-trips a bare `key:` line instead of deleting it. Verified live: progress: with no value survives parse -> reconstruct -> re-parse. 2. Unparseable no longer collapses to a bare {}: a new FRONTMATTER_UNPARSEABLE Symbol (exported), keyed exactly like the existing #3257 FULL_LINE_COMMENTS channel, is carried on the {} returned for malformed/refused YAML. Invisible to Object.keys/entries/JSON.stringify/for-in, so the 70 call sites that never inspect it are unaffected; wiring the 8 hasFrontmatter sites to consult it is a separate change, not done here. 3. Non-scalar object-list items (the four spellings of `- test: a b` that js-yaml collapses into one tree shape) are rendered as a canonical `key: value[, key2: value2]` string per item, keeping the existing array-of-strings value SHAPE. A full corpus differential over all 1702 tracked markdown files found 11 residual divergences from the legacy parser (enumerated in the PR/report), most of them the parser now being MORE correct (a dropped quoted top-level key, the block-scalar/phantom-key defect, a dropped Unicode key). 4. The #1882 truncation probe still runs the one real parser, but derives its key count from js-yaml's own thrown error and mark.line when the whole region doesn't parse cleanly (the dominant real truncation shape: fence opened, well-formed keys, no closing fence). Verified against both the clean-parse and the exception-fallback path. 5. The #3257 comment channel now attributes each pending column-0 comment against js-yaml's own parsed top-level key list (matched by literal key text, in document order) instead of the legacy ASCII-only key regex, so a comment above a Unicode key attaches correctly. 6. Anchors, aliases and merge keys are refused outright (a raw-text pre-scan, since FAILSAFE_SCHEMA still resolves them) — corpus occurrences today: zero. A 7-line billion-laughs fixture is verified refused rather than expanded. 7. A literal U+0000 is swapped for a private-use sentinel before the parse and restored in every resulting string afterward, since js-yaml rejects NUL unconditionally under every schema. escapeDoubleQuoted is deleted and reimplemented via js-yaml's dump() (forced double-quoted style), with control-char hex escapes lowercased to keep serialized output byte-stable (#1779 emitted lowercase); it keeps its exported name and signature for its two other call sites (commands.cts, runtime-artifact-conversion.cts), which need no change. frontmatterDeepEqual, the comment channel, sliceTopLevelFrontmatterSegments, regenerateFrontmatterKey's guard, noOpObjectListSetError and parseMustHavesBlock are all unchanged — retiring them is fork (b) and is not this phase. Refs #3881 * fix(#3881): quote template placeholders and preserve unparseable frontmatter SECURITY.md/UI-SPEC.md/VALIDATION.md wrote frontmatter placeholders as bare {N}/{phase-slug}/{date}, which is valid YAML flow-mapping syntax under the vendored js-yaml parser, not the literal placeholder text intended. Quote them so they parse as strings. Wire the FRONTMATTER_UNPARSEABLE Symbol (exported but unused) at the 8 call sites in state.cts/state-transition.cts that compute hasFrontmatter via Object.keys(extractFrontmatter(...)).length > 0 and reassemble the document without a frontmatter block when false. That check conflated 'no frontmatter' with 'unparseable frontmatter' (both parse to {}), so a document with a merge-conflict marker or refused alias in its frontmatter had that block silently dropped on write. Each site now preserves the exact raw bytes stripFrontmatter removed when the marker is set, leaving the genuinely-empty case unchanged. Refs #3881 * test(#3881): consequence and boundary coverage for the js-yaml migration Rows: A1 emptyValuedKeySurvivesAWrite, A2 unparseableDocumentKeepsItsFrontmatterBlock, A3 unparseableIsDistinguishableFromEmpty, A4 nonScalarValuesCanonicalize, A5 truncationProbeStillFiresOnAnOpenFence, A6 commentsStayOnTheirOwnKey, A7 anchorsAndAliasesAreRefused, A8 aliasExpansionCannotExhaustMemory, F1 UNTERMINATED_KEY_THRESHOLD boundary, F2 alias/nesting refusal bound, F3 frontmatter size boundary (huge-bounded.md + larger). Adds tests/fixtures/adversarial/frontmatter/anchor-alias-bomb.md and its entry in the feat-3594 fixture matrix. Refs #3881 * docs(#3881): document the vendored parser, correct a stale rationale, add a vendoring how-to Refs #3881 * docs(#3881): correct the frontmatter glossary entry Two errors in the entry as first written: it named parseYamlRegion as part of the read path when that function is deleted, and it recorded the eight hasFrontmatter call sites as unwired follow-on work when they were wired in e35ac2a2c. Also records the scope caveat that the CLI write path rebuilds the frontmatter block independently, so the marker binds at the transform layer. Refs #3881 * docs(#3881): record the semantic-migration decision and the counted guard ledger The maintainer chose the full semantic migration over splitting the rule into its own epic or patching the scanner, so section 8.1 is answered as "the fork was ill-posed and the migration is semantic" rather than as (a) or (b). Also replaces the pre-implementation guess that this phase would shrink the guard surface with the counted result: excluding vendored third-party lines the hand-maintained surface is net +307, and frontmatter.cts grew by 68 lines despite four functions being deleted, because the compatibility layer over js-yaml is larger than the scanner it replaced. Section 8.1's stated benefit is therefore not delivered as written; what improved is the kind of code maintained, not the amount. Decision 6 requires recording that rather than netting it away. Refs #3881 * chore(#3881): changeset for the vendored YAML parser migration Refs #3881 * test(#3881): golden parity, round-trip property and packaging coverage Refs #3881 * fix(#3881): refuse anchors structurally and fold in review findings ADR-3473 §8.1 review findings, addressed inline: Finding 1 (BLOCKER): refuseAnchorsAndAliases was a raw-line regex that matched only the bare-key spelling (key: &x). A quoted key ("a": &x), a flow mapping ({b: &x}) and a flow sequence ([&x, *x]) all define/use the SAME anchor mechanics while never matching that line shape, so the exact expansion the guard exists to stop went straight through unrefused (a 303-byte quoted-key bomb expanded to ~35.8MB). Replaced with js-yaml's own `load` `listener` callback, which reports `state.anchor` for every event belonging to an anchored node in every spelling, and throws from inside the callback to abort before any expansion (~1-2ms vs full expand-then-discard). A merge key with an alias is still refused (merge always requires a previously anchored node, so the alias itself trips the listener); a bare merge key with NO alias is no longer separately refused, documented as intentional: FAILSAFE_SCHEMA never resolves `!!merge`, so it carries no expansion risk. Table-driven tests added for all four bypass spellings + merge key, plus a quoted-key-spelled billion-laughs fixture registered in the adversarial matrix and README. Finding 2: src/vendor/js-yaml.d.cts's docblock falsely claimed anchors/ aliases were "simply UNREACHABLE from typed code" through the twin. Corrected to state the truth: anchor/alias resolution is document-level `load` mechanics reachable through exactly the declared surface, and refusal is enforced at RUNTIME (Finding 1's listener), not by the type surface. Finding 3 (MAJOR): the null-byte sentinel (U+E000) round-trip was non-injective — restoreNullBytesDeep rewrote every U+E000 in the parsed tree back to NUL, including one the document author legitimately wrote, silently corrupting it. Now refuses outright whenever the raw region already contains U+E000 (consistent with the existing anchor/merge-key refusal path), making the substitution provably injective. Tests added for a real NUL alone (preserved), a pre-existing U+E000 alone (refused, not corrupted), and both together (refused, not merged into one byte). Finding 4 (MAJOR): scripts/lint-vendored-deps.cjs's `srcTwin` field was dead for a hand-authored row (only read inside the upstream-verbatim branch) — exactly how Finding 2's stale docblock drifted unnoticed. Added checkHandAuthoredTwin: every value-level export the twin DECLARES must be an actual own property of the vendored runtime module at require-time. Tests added, including a sensor that a declared-but-nonexistent export IS caught. Finding 5: the existingFm/hasFrontmatter/stripFrontmatter/fmPrefix/ unparseableFm/reassemble preamble, copy-pasted at 7 sites in state-transition.cts plus a sixth hand-inlined copy in state.cts's cmdStateCompletePhase, is now one exported helper (beginFrontmatterReassembly) every site routes through, including the hand-inlined one. Three call sites (beginPhaseCore, patchCore, updateCore) keep a literal `body = stripFrontmatter(content)` assignment alongside the helper call so scripts/lint-state-write-path-drift.cjs's single-hop backward scan (which does not chase aliases) still sees the strip; stripFrontmatter is pure/idempotent so the extra call changes nothing observable. Finding 6: corrected the frontmatter.cts docblock's stale "wiring is a separate change" claim (the 8 call sites are wired on this branch) and the changeset's backlink from (#3473) to (#3881). Finding 7: fixed the lint:ci failures blocking the gate — an @typescript-eslint/only-throw-error violation from throwing a bare Symbol as the anchor-detected signal (now a real Error subclass), unused-var warnings left over from the Finding 5 refactor, a lint-test-file-count cap exceeded by two migration-specific test files (allowlisted with justification), and the lint-state-write-path-drift false positive from Finding 5's helper (fixed above). tests/frontmatter-golden-parity.test.cjs:117's execFileSync already carried an explicit timeout; no change was needed there. Golden fixture: added a golden entry for the new anchor-alias-bomb-quoted.md fixture ({} — matches what the legacy line scanner would also produce, since it independently dropped every quoted top-level key). No other corpus document diverges: real .planning/ documents carry zero anchors/aliases/merge keys/U+E000 today. Refs #3881 * fix(#3881): fold in second-round review findings Finding 1 (BLOCKER): tests/frontmatter.test.cjs pinned the pre-migration ASCII-only key regex for the Unicode fixture; updated to require the 相 key's value now that js-yaml has no such restriction. Audited the rest of the file for other pre-migration pins (block scalars, quoted keys, flattened values, empty values, duplicate keys, unclosed blocks, null bytes) by execution against real fixtures; found none regressed. Finding 2: parseYamlRegion and escapeDoubleQuoted renamed to parseGuardedYamlRegion and escapeDoubleQuotedScalar in src/frontmatter.cts so no function still answers to the deleted hand-rolled scanner's name (ADR-3473 §8.1 "deleted, not patched"). escapeDoubleQuotedScalar's three external call sites (src/commands.cts, src/runtime-artifact-conversion.cts) updated in the same change — a mechanical rename, not an ADR-amendment matter. Finding 3 (BLOCKER): fixed a real crash and a silent data-loss bug found by execution. A top-level key named constructor/__proto__/toString/ valueOf/hasOwnProperty crashed reconstructFrontmatter (bracket read resolving an inherited Object.prototype member); a key literally named __proto__ was silently DROPPED entirely (bracket assignment on an ordinary {} invoked the inherited __proto__ setter instead of creating a data property). Fixed by building every parsed Frontmatter object with Object.create(null), and replacing an `in` check with hasOwnProperty.call in propagateCommentChannel. Added round-trip tests for all five hostile keys, each with its own leading comment. Finding 4 (MAJOR): escapeDoubleQuotedScalar's docstring falsely claimed full byte-stability across the migration. Verified by execution: BEL/NUL/ NEL/NBSP/LS/PS/BOM now emit YAML-named escapes instead of the old hex/raw- literal forms. Proved round-trip equivalence (each escape re-parses to the exact source codepoint) and corrected the docstring. Found and fixed a related real defect while verifying: a lone UTF-16 surrogate was emitted BARE (scalarNeedsDoubleQuoting didn't trigger), producing genuinely unparseable YAML that silently collapsed to {} on re-read — extended scalarNeedsDoubleQuoting to route surrogates through the quoted+escaped path. Finding 5 (MAJOR): countKeysBeforeTruncation went silent on 4 real truncation shapes (unquoted colon, open flow collection, mis-indented sibling key, refused anchor). Root cause: the mark-based prefix recovery excluded the very line whose key needed counting, and a mark-less refusal never entered the recovery branch at all. Fixed by taking the max of two lower bounds: the longest parser-verified line-prefix, and a raw-text count of key-shaped lines (reusing the same key-shape pattern this file already uses for isFrontmatterShaped). Extended test-matrix row A5 table-driven over all 4 regressed shapes. Finding 6: the design doc's claim that no test owned the #3594 adversarial fixture corpus was false — consolidation epic #1969 had already folded it into tests/frontmatter.test.cjs. An earlier commit on this branch re-created a standalone duplicate under that false premise; folded its genuinely-new coverage (fixture-ownership check, anchor-bomb fixtures, block-scalar B1/B2 rows) into frontmatter.test.cjs and deleted the duplicate file. Corrected the false claims in 40-design.md §3.3.1 and the ADR's §8.1 note, including the roadmap-sibling claim (no such file exists). Finding 7: the golden serializer sorted object keys, making it structurally blind to the key-order-parity invariant ADR-3473 §8.1 actually claims. Made it order-preserving and regenerated the golden fixture from a standalone compile of the legacy (pre-#3881) parser at ddde001af; the current parser matches it with zero undocumented divergences, confirming key-order parity genuinely holds. Extended row A2 table-driven across 6 of the remaining 7 transitionCore kinds (all pass) plus documented, by execution, a newly-discovered 8th-site regression: state.cts's cmdStateCompletePhase calls the same preservation helper but its result is clobbered by a later unconditional resync — filed as a distinct finding rather than fixed here (touches syncAndPreserveStateMd, outside this change's verified scope). Refs #3881 * fix(#3881): preserve unparseable frontmatter through the CLI write path Characterization (executed, before/after shown): case (b), not (a). The frontmatter FENCE survives — `state complete-phase` on a conflict-marked STATE.md returns success and a well-formed, freshly-derived frontmatter block, not a document with no frontmatter at all. But the block's actual content (the merge-conflict markers, and with them any signal to a human that the document was in conflict) is silently discarded and replaced. Root cause was two clobber sites, not one: 1. syncStateFrontmatter (src/state.cts) re-parses the already-preserved `transformedContent` from readModifyWriteStateMd, finds {} + the FRONTMATTER_UNPARSEABLE marker, and unconditionally rebuilt a fresh frontmatter block from the body anyway. 2. Even after (1) is fixed, applyPostSyncPreservation's own postFm/applyStatePreservation/authoritativeFm-reassertion machinery re-extracts frontmatter from syncedContent, restores curated fields from the pre-write snapshot, and reconstructs a NEW block again — confirmed live via `state begin-phase`, which still lost the markers after fixing (1) alone. Both are now guarded by the same predicate (isUnparseableFrontmatter, checking FRONTMATTER_UNPARSEABLE): when the ORIGINAL frontmatter did not parse and the caller is not on ADR-3408 §8.3's closed "body wins" list, both functions return their input content unchanged rather than re-deriving over it. The closed list (cmdStateSync #905, /gsd-health --repair's REGENERATE_STATE, both routed only through writeStateMd, which never reaches applyPostSyncPreservation and passes sanctionedPermanentEmptyFallback=true to syncStateFrontmatter) is untouched — neither widened nor narrowed; verified by execution that `state sync` still overwrites the conflict-marked block exactly as before. Other verbs sharing the same readModifyWriteStateMd path were checked and were equally affected before this fix: state update, query state.patch, and state begin-phase all lost the conflict markers (RED, shown by execution), and all three now preserve them (GREEN). Covered table-driven in tests/feat-3881-yaml-parser-consequences.test.cjs's new A2b describe block, which drives the real CLI verbs via runGsdTools — not just the pure transitionCore layer the earlier A2 rows exercised — plus a control asserting state sync's body-wins contract is unchanged. Refs #3881 * fix(#3881): restore the parse surface's prototype and fix remote-runner failures Root cause of the bulk of the 88 remote-runner failures: extractFrontmatter/parseGuardedYamlRegion handed back Object.create(null) trees for prototype-pollution safety, but assert.deepStrictEqual compares prototypes, so every assertion against a plain object literal failed (57 frontmatter.unit.test.cjs + 5 frontmatter.test.cjs + others). Fixed by keeping the internal construction null-prototype (unchanged) and converting to a plain-prototype tree via Object.defineProperty (never bracket assignment, so __proto__/constructor/toString keys stay safe) at the parseGuardedYamlRegion/unparseableResult return boundary only; the internal FULL_LINE_COMMENTS Symbol channel is copied by reference, not recursed, so its own __proto__-safety is untouched. Per-class fixes: (1) bomAcrossArtifactTypes was the same prototype bug, no separate code change needed. (2) frontmatter-cli #1660: added objectListFieldWouldLoseData, a broader lossy-field detector alongside the existing byte-identical noOpObjectListSetError -- js-yaml's flattenObjectListItem now correctly includes every sub-key of an object-list item (a real bug fix over the legacy scanner, which silently dropped every field but the first), so a set that drops that now-included data is no longer byte-identical to the original and needs its own guard. (3) uat.test.cjs: updated the pinned expectation for the human_verification quote-stripping artifact -- js-yaml resolves quoting correctly where the legacy regex left an unbalanced quote; documented as an intentional, non-lossy behavior change. (4) smart-entry: added a fallback-only loadWithAmbiguousColonRepair so a column-0 key: value line whose value itself contains an unquoted colon (the #2571 hand-edited-STATE.md shape) round-trips instead of failing the whole frontmatter block closed. (5) frontmatter.unit.test.cjs bracket-array leniency: added a second fallback, repairMalformedInlineArrays, restoring the legacy scanner's tolerant inline-array handling (consecutive/blank commas, unclosed bracket) -- both repairs run ONLY after the primary parse already threw, so well-formed documents are unaffected. (6) prompt-injection-scan: src/frontmatter.cts had a literal U+FEFF BOM embedded in a comment illustrating the #2977 fix; replaced with the U+FEFF text escape. (7) eslint-glob-coverage: allowlisted the new src/vendor/js-yaml.d.cts vendored type declaration, same precedent as the existing re2js.d.cts entry. (8) frontmatter-golden-parity: git ls-files *.md now runs with -c safe.directory=* (process-scoped) so it survives the remote runner's dubious-ownership check without a persistent git config write. Refs #3881 * chore(#3881): backfill changeset PR number Refs #3881 * test(#3881): make golden parity resistant to unrelated tree churn A corpus-wide snapshot keyed to every tracked *.md file was coupled to mutable-by-design files: .changeset/*.md's pr:0 -> real-PR-number backfill is a required workflow step, not a parser change, yet it turned this suite red. Training people to 'just regenerate the golden' on that kind of failure defeats the point of the snapshot. Exclude .changeset/** from the golden corpus entirely, tolerate tracked *.md files with no golden entry (they postdate the capture) instead of failing on them, keep hard failures for a golden entry whose file has vanished from the tree and for any real parity divergence, and add a coverage floor so the enumeration cannot quietly degrade to comparing a handful of files. Golden regenerated by recompiling the legacy pre-migration parser (git show ddde001af:src/frontmatter.cts) standalone, independent of the current parser, over the same non-changeset corpus. Refs #3881 * test(#3881): make the parser golden hermetic instead of tree-keyed This repo merges ~21 commits/day; a 14-day sample measured 937 touches of the exact files (commands/gsd/*.md, gsd-core/workflows/*.md, agents/*.md, docs/*.md) the prior golden pinned by tracked path. Any PR editing one of those files' frontmatter for reasons unrelated to the parser (an argument-hint addition, an allowed-tools tweak) turned the suite red, and the reflex fix -- "regenerate the golden" -- overwrote the very snapshot meant to catch a real regression. Excluding .changeset/** was not enough; the design itself was wrong: a regression fixture must not be keyed to mutable repo paths, and a single 376-entry JSON every such PR touches is also a guaranteed merge-conflict surface. Rebuilt the fixture to carry its own documents: each of 51 entries stores a stable id, literal documentText (shrunk from a real ddde001af-era corpus document), and an expectedParse captured independently from the pre-migration legacy parser (git show ddde001af:src/frontmatter.cts, compiled standalone against its byte-identical sibling modules). The test reads no tracked path, shells out to no git command, and enumerates no tree -- a PR editing commands/gsd/help.md cannot affect it. Every entry's reconstruction was verified at capture time to reproduce both the current and legacy parser's output on the original document; 0 of 51 candidates were dropped by that check (1, the deliberately-unterminated unclosed-block.md adversarial fixture, has no closing fence to truncate at and is stored unshrunk). Kept the 5 documented DIVERGENCES rows (now diverges:true entries) and the D2 order-preserving structural serializer that keeps the comparison from passing vacuously; dropped the tree-enumeration helpers, the coverage floor, the post-capture-skip logic, and the vanished-file check -- all artifacts of the path-keyed design. Refs #3881 * fix(#3881): resolve vendored-deps paths independently of cwd shape Five rows in tests/lint-vendored-deps-manifest.test.cjs failed on windows-latest CI: the test passed absolute scratch-file paths into compareFiles()/checkRow(), whose helpers joined every input onto ROOT via path.join(ROOT, rel), producing garbage when the input was already absolute. It surfaced on windows-latest specifically because GitHub's Windows runners checkout the repo on a different drive than TEMP, so path.relative(REPO_ROOT, tmpFile) returned the absolute path unchanged (no relative traversal is representable across drives) rather than the relative form the test assumed. The remote gsd-test runner this repo gates pushes on is Linux-only and could never have caught this; GitHub CI's windows-latest job is the only signal that does, and it did. Fixed the helper itself (scripts/lint-vendored-deps.cjs's new resolvePath()) to treat an already-absolute input as absolute-in, absolute-out instead of silently mis-joining it, and updated the test to pass the scratch file's absolute path directly rather than relying on a relative conversion that is not always representable. Kept every mutation-sensor assertion intact and added coverage proving resolvePath is a no-op for relative inputs and correctly passes absolute ones through unchanged. Refs #3881 * fix(#3881): warn when state sync regenerates over unparseable frontmatter state sync (ADR-3408 §8.3's sanctioned regenerate path) correctly overwrites an unparseable frontmatter block per its 'body wins' contract — that overwrite behavior is unchanged here. The defect was the silence: synced:true/exit 0 gave no signal that the existing block (including git merge-conflict markers) could not be parsed and was destroyed, per ADR-3473 §8.5 ('a derived conclusion may not be reported as authoritative when the derivation dropped input it could not resolve') and §8.4 ('failure is a value'). Adds a gsd: warning — ... (#3881) line on stderr, matching the existing #3573 precedent, and surfaces the same disclosure in the JSON result's existing changes[] array so a machine consumer sees it too. Exit code and synced:true are left unchanged — sync did what its contract says. REGENERATE_STATE (/gsd-health --repair's sibling on the same sanctioned-regenerate list) is DESTRUCTIVE-risk and unconditionally refused by applyRepairs's dispatcher before runRepairAction ever runs (src/health-diagnostic.cts), so it is not a live path today and is not in scope for this fix. Refs #3881 * fix(#3881): exit non-zero when a state command returns an error Refs #3881 * chore(#3881): changeset for the state exit-code fix Refs #3881 * fix(#3881): honor the documented --project-dir flag Refs #3881 * revert(#3881): restore exit-0 result envelopes for state errors Reverts 9638f2936 and its changeset. The change was wrong and the revert is the correction. This repo distinguishes two error mechanisms deliberately. error() in src/io.cts writes to stderr and calls process.exit(1) -- the hard-failure path. output({error: ...}) writes a JSON result envelope to stdout and returns normally with exit 0. The reverted commit converted 23 result-envelope sites into hard failures, which is a different contract, not a bug fix. tests/state-contract.test.cjs's errorPathDoesNotPublish asserts the envelope contract directly -- a failing command exits 0 with a JSON error envelope and must not publish state.json -- and the remote matrix run caught it along with four cases in the QA scenario walk. Thirteen tests in tests/state.test.cjs that the original commit rewrote were encoding that real contract, not the bug it claimed; they are restored. Whether an error envelope on stdout with exit 0 is the right CLI design is a genuine question, and it is section 8.4's rule ('failure is a value') with its own phase. It is not something to flip inside this PR. Refs #3881 * chore(#3881): backfill changeset PR number for the project-dir fix Refs #3881 * test(#3881): keep the frontmatter mutation shard inside its time budget The Stryker (frontmatter) shard hit the documented 15-minute (900s) shard cap. Root cause is NOT row-level spawn overhead (contrast the #2790/ core-utils precedent): the three shard test files' own logic runs in ~413ms total (356+30+27ms) with all 392 assertions passing. Instead, src/frontmatter.cts grew from ~825 to 1496 lines (+671/-187) migrating to the vendored YAML parser, proportionally growing the mutant count Stryker generates for gsd-core/bin/lib/frontmatter.cjs. Stryker's command runner bills the full 'node --test <3 files>' invocation once per mutant, and node:test's default per-file process isolation forks a child process for each of the three files on every one of those invocations — pure fork overhead multiplied by a much larger mutant population. Fix: scripts/mutation-matrix.cjs COVERED.frontmatter now declares isolation: 'none', and .github/workflows/mutation.yml passes --test-isolation=${{ matrix.isolation }} (defaulting to 'process' — i.e. unchanged behavior — for the other 8 shards, which were not individually audited for cross-file state leakage under shared-process execution). Measured locally via node:test's run() API on the exact 3-file set: isolation:'process' took ~593ms vs isolation:'none' ~478ms for the same 392 passing assertions. The true CI-shard number can only be confirmed on the GitHub Actions run (Stryker cannot run locally, and 'node --test' is hard-blocked in this environment). Refs #3881 * test(#3881): register the vendored-parser tests in the frontmatter mutation shard stryker.config.mjs's own rule ("Keep this list in sync with the tests arrays in scripts/mutation-matrix.cjs COVERED") was violated: #3881 grew src/frontmatter.cts from ~825 to 1496 lines but its new tests (tests/feat-3881-yaml-parser-consequences.test.cjs, tests/frontmatter-golden-parity.test.cjs, tests/frontmatter-roundtrip.property.test.cjs, and +167 lines in tests/frontmatter.test.cjs) were never added to the frontmatter shard's tests array, so Stryker's mutants in the new vendored-js-yaml adapter had nothing constraining them. PR #3888 measured 55.8% against the 65 floor (748 killed / 593 survived / 17 timeout) and the shard was separately cancelled at 15m04s against the 15-minute per-shard cap. Registers all four files (each earns its slot on evidence of a unique constraining assertion, documented inline), gives the shard a measured/projected 180-minute budget via a new per-module timeoutMinutes field threaded through mutation.yml's job-level timeout-minutes the same way isolation is threaded, and removes the prior isolation:'none' override (re-measured at this file-set size, its savings are within run-to-run noise, not worth the unaudited cross-file-state-leakage risk). Refs #3881 * feat(#3881): derive the mutation test list and ratchet the score floor Refs #3881 * test(#3881): ratchet five stale mutation floors and close the frontmatter gap Raised five module minScore floors per CI run 33012034388 (floor(achieved)-1): config-schema 75.51%->74, prompt-budget 88.95%->87, context-composer 79.92%->78, context-utilization 92.31%->91, active-workstream-store 87.42%->86. Updated both scripts/mutation-matrix.cjs COVERED entries and tests/mutation-matrix-ratchet.test.cjs RATCHET_BASELINE in the same diff per the ratchet's own contract. Closed the frontmatter shard's 63.03%-vs-65 gap with new behavioral tests in tests/feat-3881-yaml-parser-consequences.test.cjs, each paired with a documented near-miss: frontmatterDeepEqual's array-order/length/type-mismatch/key-order semantics (via spliceFrontmatter's no-op guard), scalarNeedsDoubleQuoting's leading/trailing-whitespace and dash/surrogate triggers (via reconstructFrontmatter), repairAmbiguousColonValues' already-quoted vs ambiguous-colon repair paths (via extractFrontmatter), and the null-byte sentinel round-trip surviving at region offset 1. Did not lower minScore. Refs #3881 * test(#3881): decouple the ratchet test from real module floors The CLI end-to-end rows in tests/mutation-score-ratchet.test.cjs hardcoded config-schema's real floor (52), which commit 973321541 legitimately ratcheted to 74 -- breaking a test pinned to the exact value the mechanism under test exists to change. Add an injectable --matrix seam to scripts/check-mutation-score-ratchet.cjs and point the CLI rows at a synthetic module + synthetic floor built via a temp fixture, so the rows are indifferent to any real module's floor moving while still exercising the same fail/pass behaviour. Refs #3881 * refactor(#3881): parse must_haves with the vendored parser and drop re-implemented leniency Refs #3881 * fix(#3881): restore the ambiguous-colon repair its hand-edited-STATE.md contract needs A tracked-document sweep of 910 *.md files cannot see this dependent: repairAmbiguousColonValues's one real caller is user hand-edited STATE.md content that never lives in this repo's tree, only on end users' machines, and is pinned by tests/smart-entry.unit.test.cjs. Restores the function plus its post-throw fallback path (loadWithAmbiguousColonRepair) only; repairMalformedInlineArrays and splitLegacyInlineArrayItems stay deleted, reverified against the full frontmatter test shard. Adds a frontmatter-level regression row in tests/feat-3881-yaml-parser-consequences.test.cjs so the dependency is visible where the function lives. Closes #2571 Refs #3881 --------- Co-authored-by: sim <sim@local>
1398 lines
79 KiB
TypeScript
1398 lines
79 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.
|
|
*
|
|
* ADR-3473 §8.1 (#3881): the read path is no longer a hand-rolled line
|
|
* scanner. `parseGuardedYamlRegion` now parses through the vendored `js-yaml`
|
|
* (`./vendor/js-yaml.cjs`, verbatim `node_modules/js-yaml/dist/js-yaml.js`)
|
|
* under `FAILSAFE_SCHEMA` + `json: true` — every scalar comes back a string
|
|
* (today's contract, no adapter needed) and duplicate keys overwrite
|
|
* (last-wins, the documented invariant). What js-yaml does NOT do —
|
|
* anchors/alias refusal, the #3257 comment channel, the #1882 truncation
|
|
* probe, null-byte preservation and object-list flattening for the existing
|
|
* string-shaped value contract — is layered on top, in one place, below.
|
|
*/
|
|
|
|
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;
|
|
import { load as yamlLoad, dump as yamlDump, FAILSAFE_SCHEMA, YAMLException } from './vendor/js-yaml.cjs';
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
type FrontmatterValue = string | string[] | Record<string, unknown>;
|
|
type Frontmatter = Record<string, FrontmatterValue>;
|
|
|
|
// ─── Parsing engine ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Base js-yaml options for every parse in this module (ADR-3473 §8.1 §3.1/§3.3).
|
|
* `FAILSAFE_SCHEMA` resolves only `!!str`/`!!seq`/`!!map` — every scalar comes
|
|
* back a string, which is today's contract and needs no coercion layer.
|
|
* `json: true` makes duplicate keys overwrite (last-wins) instead of throwing,
|
|
* which is the documented behavior `tests/fixtures/adversarial/frontmatter/
|
|
* duplicate-keys.md` pins.
|
|
*/
|
|
const YAML_LOAD_OPTS = { schema: FAILSAFE_SCHEMA, json: true };
|
|
|
|
/**
|
|
* 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. This heuristic is deliberately a raw-text scan,
|
|
* independent of whichever parser counts the keys (see `countKeysBeforeTruncation`) — it is the
|
|
* guard that keeps a stricter parser from turning "opens with a rule" into a false positive.
|
|
*/
|
|
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[] };
|
|
|
|
/**
|
|
* ADR-3473 §8.1 §0.3 (#3881, consequence 2): a Symbol-keyed marker carried on the `{}`
|
|
* `extractFrontmatter` returns when the region failed to parse (malformed YAML, or a refused
|
|
* anchor/alias/merge key — consequence 6). Mirrors `FULL_LINE_COMMENTS` exactly: invisible to
|
|
* `Object.keys`/`Object.entries`/`JSON.stringify`/`for-in`, so the 70 call sites that never
|
|
* inspect it are unaffected, while the 8 `hasFrontmatter = Object.keys(...).length > 0` sites
|
|
* consult it to tell "genuinely empty" apart from "unparseable" and avoid reassembling the
|
|
* document without its (unparsed but still present) frontmatter block. Those 8 call sites (7 in
|
|
* `state-transition.cts` behind `isUnparseableFrontmatter`/`rawFrontmatterPrefix`, plus 1 more in
|
|
* `state.cts`'s `cmdStateCompletePhase`) are wired on this branch — this module sets and exports
|
|
* the marker; the callers consume it.
|
|
*/
|
|
const FRONTMATTER_UNPARSEABLE = Symbol('frontmatterUnparseable');
|
|
|
|
function unparseableResult(): Frontmatter {
|
|
// Plain-prototype (post-remote-runner-fix, #3881): the PUBLIC parse surface must keep
|
|
// handing callers ordinary `{}`-shaped objects — `assert.deepStrictEqual` compares
|
|
// prototypes, and 50+ existing call sites/tests compare against object literals. The
|
|
// Symbol marker is still attached via `Object.defineProperty` rather than bracket
|
|
// assignment, which is what actually matters for prototype-pollution safety: a data
|
|
// property named e.g. `__proto__` set through `defineProperty` never invokes the
|
|
// inherited `Object.prototype.__proto__` accessor setter the way `fm[k] = v` would.
|
|
const fm: Record<string, unknown> = {};
|
|
Object.defineProperty(fm, FRONTMATTER_UNPARSEABLE, {
|
|
value: true, writable: true, enumerable: true, configurable: true,
|
|
});
|
|
return fm as Frontmatter;
|
|
}
|
|
|
|
/**
|
|
* Convert an internal, possibly null-prototype, YAML-derived value tree into an ordinary
|
|
* plain-prototype tree for the public parse surface (post-remote-runner-fix, #3881).
|
|
*
|
|
* The internal construction (`normalizeParsedValue`, `restoreNullBytesDeep`,
|
|
* `extractCommentChannel`) deliberately builds with `Object.create(null)` so a hostile key
|
|
* like `__proto__`/`constructor`/`toString` is always a genuine own data property and never
|
|
* resolves to (or overwrites) an inherited `Object.prototype` member WHILE THE TREE IS BEING
|
|
* BUILT. That safety property has nothing to do with what prototype the FINAL object callers
|
|
* receive — `assert.deepStrictEqual` compares prototypes, so handing back a null-prototype
|
|
* object silently broke every caller comparing against `{}` object literals (57+ tests). This
|
|
* walks the tree exactly once at the return boundary and re-homes every string/number-keyed
|
|
* own property onto an ordinary `{}` via `Object.defineProperty` (never `out[k] = v`), which
|
|
* is what keeps the copy itself safe: `defineProperty` always creates a real own data
|
|
* property, even for a key literally named `__proto__`, and never triggers the inherited
|
|
* setter the way bracket assignment would.
|
|
*
|
|
* The `FULL_LINE_COMMENTS` Symbol channel is copied across by reference, NOT recursed into —
|
|
* it stays null-prototype. It is purely internal plumbing (only `reconstructFrontmatter` /
|
|
* `propagateCommentChannel`, both in this module, ever read `channel.leading[key]` with an
|
|
* arbitrary user-authored key), invisible to every external reader (`Object.keys` /
|
|
* `Object.entries` / `JSON.stringify` / `for-in` all skip symbols), and re-plaining it would
|
|
* reopen the exact `leading[key]` inherited-member bug the null prototype exists to close.
|
|
*/
|
|
function toPlainValueTree(value: unknown): unknown {
|
|
if (Array.isArray(value)) return value.map(toPlainValueTree);
|
|
if (value !== null && typeof value === 'object') {
|
|
const out: Record<string, unknown> = {};
|
|
for (const k of Object.keys(value)) {
|
|
Object.defineProperty(out, k, {
|
|
value: toPlainValueTree((value as Record<string, unknown>)[k]),
|
|
writable: true, enumerable: true, configurable: true,
|
|
});
|
|
}
|
|
for (const s of Object.getOwnPropertySymbols(value)) {
|
|
Object.defineProperty(out, s, {
|
|
value: (value as Record<symbol, unknown>)[s], // internal channel: copied raw, not recursed
|
|
writable: true, enumerable: true, configurable: true,
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* ADR-3473 §8.1 (consequence 6, corrected post-#3881-review): `FAILSAFE_SCHEMA` still resolves
|
|
* anchors, aliases and merge keys — that is core YAML mechanics, not tag resolution, so no
|
|
* schema choice disables it. A hostile 7-line frontmatter (`&a [...]` fanned out through nested
|
|
* aliases) expands to tens of megabytes in a few milliseconds, and `.planning/` documents are
|
|
* user-authored, untrusted input. Corpus occurrences of anchors/aliases/merge keys today: zero,
|
|
* so refusing them costs nothing.
|
|
*
|
|
* This was originally a raw-text line regex, and it was bypassable: a quoted key (`"a": &x 1`),
|
|
* a flow mapping (`{b: &x 1, c: *x}`) or a flow sequence (`[&x "q", *x]`) all define/use an
|
|
* anchor while never matching the "bareword key, then `&`/`*`" line shape the regex checked —
|
|
* so the exact expansion this guard exists to stop went straight through unrefused. Detecting a
|
|
* YAML anchor with a regex is re-implementing a YAML parser in order to guard a YAML parser; the
|
|
* fix is to let the real parser report it instead of re-deriving anchor syntax by hand. js-yaml's
|
|
* `load` accepts a `listener` invoked once per parse event with the parser's internal `State`;
|
|
* `state.anchor` is non-null on every event belonging to an anchored node, in every spelling
|
|
* above (verified by execution against all four), so throwing the instant it is set aborts the
|
|
* parse before any alias expansion happens — the 303-byte quoted-key bomb refuses in ~1ms rather
|
|
* than expanding to ~35MB. A `<<: *base` merge key is refused too, because it can only ever
|
|
* reference a previously anchored node — the alias itself trips `state.anchor`. A merge key with
|
|
* NO alias (`<<: {b: 1}`) carries no anchor and is not separately refused: under
|
|
* `FAILSAFE_SCHEMA` (no `!!merge` type resolution) it never actually merges — it parses as an
|
|
* ordinary literal `"<<"` string key with a normal, non-expanding nested map — so it carries none
|
|
* of the resource-exhaustion risk this guard exists for.
|
|
*/
|
|
/** Thrown from inside the `listener` callback below; never surfaced past `refuseAnchorsAndAliases`. */
|
|
class AnchorDetectedSignal extends Error {}
|
|
|
|
function refuseAnchorsAndAliases(yaml: string): void {
|
|
try {
|
|
yamlLoad(yaml, {
|
|
...YAML_LOAD_OPTS,
|
|
listener: (_event: string, state: { anchor?: string | null }) => {
|
|
// Thrown FROM INSIDE the listener, not merely recorded and checked after `load`
|
|
// returns: js-yaml keeps parsing (and, for an alias, keeps EXPANDING) past a listener
|
|
// that only sets a flag, which reintroduces the exact resource-exhaustion window this
|
|
// guard exists to close. Throwing here aborts the parse immediately, before any
|
|
// expansion — the billion-laughs fixture refuses in ~1-2ms rather than building the
|
|
// ~35MB tree first and discarding it.
|
|
if (state.anchor !== null && state.anchor !== undefined) throw new AnchorDetectedSignal();
|
|
},
|
|
});
|
|
} catch (e) {
|
|
if (e instanceof AnchorDetectedSignal) {
|
|
throw new YAMLException(
|
|
'frontmatter: anchors, aliases and merge keys are refused (ADR-3473 §8.1)',
|
|
);
|
|
}
|
|
// Any other failure (malformed YAML unrelated to anchors) is reported by the real parse
|
|
// in parseGuardedYamlRegion; this pre-pass only exists to refuse anchors/aliases early.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ADR-3473 §8.1 (consequence 7): js-yaml rejects a literal U+0000 unconditionally, under every
|
|
* schema. The fixture invariant (`null-byte-value.md`) is "preserve or normalize; never truncate
|
|
* silently", so the byte is swapped for a private-use sentinel before the parse and restored in
|
|
* every resulting string afterward — preserving the exact byte rather than normalizing it away.
|
|
*
|
|
* CORRECTED (post-#3881-review, finding 3): the round-trip was non-injective. `restoreNullBytesDeep`
|
|
* rewrites EVERY U+E000 in the parsed tree back to U+0000 — including one the document author
|
|
* legitimately wrote — so a document containing a literal U+E000 (with or without an actual NUL
|
|
* elsewhere) came back corrupted: its own U+E000 silently became a NUL. Rather than pick a
|
|
* "provably absent" sentinel (unprovable in general — any fixed codepoint can itself appear in
|
|
* user-authored input), `refuseIfSentinelPresent` makes the substitution provably reversible by
|
|
* refusing outright whenever the RAW region already contains U+E000, before any substitution
|
|
* happens — consistent with this module's existing refusal path (anchors/aliases/merge keys) for
|
|
* "cannot faithfully round-trip this input." Once refused, the sentinel is guaranteed absent from
|
|
* the input the escape/restore pair actually operates on, and the substitution is injective by
|
|
* construction.
|
|
*/
|
|
const NULL_BYTE_SENTINEL = String.fromCharCode(0xE000);
|
|
|
|
function refuseIfSentinelPresent(yaml: string): void {
|
|
if (yaml.includes(NULL_BYTE_SENTINEL)) {
|
|
throw new YAMLException(
|
|
'frontmatter: contains the reserved null-byte-escape sentinel U+E000 — refused rather than ' +
|
|
'silently corrupted on restore (ADR-3473 §8.1)',
|
|
);
|
|
}
|
|
}
|
|
|
|
function escapeNullBytesForParse(yaml: string): string {
|
|
return yaml.indexOf('\u0000') === -1 ? yaml : yaml.split('\u0000').join(NULL_BYTE_SENTINEL);
|
|
}
|
|
|
|
function restoreNullBytesDeep(value: unknown): unknown {
|
|
if (typeof value === 'string') {
|
|
return value.includes(NULL_BYTE_SENTINEL) ? value.split(NULL_BYTE_SENTINEL).join('\u0000') : value;
|
|
}
|
|
if (Array.isArray(value)) return value.map(restoreNullBytesDeep);
|
|
if (value && typeof value === 'object') {
|
|
// Null-prototype (post-#3881-review, finding 3): an ordinary {} here silently DROPS a
|
|
// top-level key literally named __proto__ -- out['__proto__'] = v on a normal object
|
|
// invokes the inherited Object.prototype.__proto__ SETTER (reassigning the object's own
|
|
// prototype) instead of creating a data property, so key: __proto__ in a document
|
|
// vanishes from the parsed result with no error. Confirmed by execution: ---\n__proto__:
|
|
// hello\nz: 1\n---\n parsed to {z: "1"}, silently dropping the __proto__ key entirely.
|
|
// Object.create(null) has no such setter, so the assignment below is always a genuine
|
|
// own data property, for every key including __proto__ itself.
|
|
const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
const restoredKey = k.includes(NULL_BYTE_SENTINEL) ? k.split(NULL_BYTE_SENTINEL).join(String.fromCharCode(0)) : k;
|
|
out[restoredKey] = restoreNullBytesDeep(v);
|
|
}
|
|
return out;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* ADR-3473 §8.1 (consequence 3): js-yaml resolves `- test: a b` (and the three other spellings
|
|
* of the same value — `"a b"`, `'a b'`, `{test: a b}`) to ONE tree shape, `[{test: "a b"}]` —
|
|
* unlike the legacy scanner, whose output was a function of the raw source line and therefore
|
|
* produced four different strings for those four spellings (ADR-3473 40-design.md §0.1). No
|
|
* adapter over a tree can recover a distinction the tree does not carry, so this renders a single
|
|
* canonical string per object-list item instead, keeping the existing value SHAPE (an array of
|
|
* strings) that `sliceTopLevelFrontmatterSegments`, the `[object Object]` guard and
|
|
* `noOpObjectListSetError` all depend on. Choosing structured (non-string) values is fork (b) —
|
|
* out of scope for this phase.
|
|
*/
|
|
function flattenScalarForDisplay(value: unknown): string {
|
|
if (value === null || value === undefined) return '';
|
|
if (Array.isArray(value)) return `[${value.map(flattenScalarForDisplay).join(', ')}]`;
|
|
if (typeof value === 'object') return flattenObjectListItem(value as Record<string, unknown>);
|
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
|
return String(value);
|
|
}
|
|
|
|
function flattenObjectListItem(item: Record<string, unknown>): string {
|
|
return Object.entries(item)
|
|
.map(([k, v]) => `${k}: ${flattenScalarForDisplay(v)}`)
|
|
.join(', ');
|
|
}
|
|
|
|
/**
|
|
* Recursively normalize a parsed js-yaml tree to this module's historical contract:
|
|
* - `null`/`undefined` in an object-value slot becomes `{}` (consequence 1 — matches the
|
|
* legacy scanner's empty-value handling exactly, so `reconstructFrontmatter` — which omits
|
|
* null-valued keys — still round-trips a bare `key:` line instead of deleting it);
|
|
* - `null`/`undefined` inside an array becomes `''` (arrays are always string[] in this
|
|
* module's contract);
|
|
* - a map or nested array found as an array ITEM is flattened to a canonical string
|
|
* (consequence 3);
|
|
* - every other scalar is already a string under `FAILSAFE_SCHEMA` and passes through.
|
|
*/
|
|
function normalizeParsedValue(value: unknown, inArray: boolean): unknown {
|
|
if (value === null || value === undefined) return inArray ? '' : {};
|
|
if (Array.isArray(value)) {
|
|
return value.map((item) => {
|
|
if (item !== null && typeof item === 'object') {
|
|
return Array.isArray(item) ? flattenScalarForDisplay(item) : flattenObjectListItem(item as Record<string, unknown>);
|
|
}
|
|
return normalizeParsedValue(item, true);
|
|
});
|
|
}
|
|
if (typeof value === 'object') {
|
|
// Null-prototype (post-#3881-review, finding 3): a top-level YAML key named `constructor`,
|
|
// `__proto__`, `toString`, `valueOf` or `hasOwnProperty` is ordinary user-authored input
|
|
// (`.planning/` frontmatter), not an attack — but on an ordinary `{}` it resolves to the
|
|
// inherited Object.prototype member instead of `undefined`, which crashes downstream
|
|
// bracket reads (`commentChannel?.leading[key]`) and silently mis-answers `fm[field]`
|
|
// lookups in `cmdFrontmatterGet`. `Object.create(null)` severs the prototype chain so every
|
|
// reader of a parsed Frontmatter object gets a real bracket-read contract: present or
|
|
// `undefined`, never an inherited function.
|
|
const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
out[k] = normalizeParsedValue(v, false);
|
|
}
|
|
return out;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* ADR-3473 §8.1 (consequence 5): the #3257 comment scan used to key off the legacy parser's own
|
|
* `[a-zA-Z0-9_-]+:` key regex — a Unicode key (e.g. `相:`) never matched it, so a comment above
|
|
* one silently attached to the WRONG key once js-yaml owns the real (Unicode-inclusive) key set.
|
|
* This attributes each pending column-0 comment block against js-yaml's own parsed top-level key
|
|
* list, in document order, by matching the literal key text at column 0 rather than re-deriving a
|
|
* key shape independently — so it can never disagree with what was actually parsed.
|
|
*/
|
|
function extractCommentChannel(yaml: string, orderedKeys: string[]): FullLineCommentChannel | undefined {
|
|
const lines = splitLines(yaml);
|
|
let pending: string[] = [];
|
|
let channel: FullLineCommentChannel | undefined;
|
|
let keyIdx = 0;
|
|
|
|
for (const line of lines) {
|
|
if (line.trim() === '') continue;
|
|
if (/^#/.test(line)) {
|
|
pending.push(line);
|
|
continue;
|
|
}
|
|
if (!/^\s/.test(line) && keyIdx < orderedKeys.length) {
|
|
const key = orderedKeys[keyIdx];
|
|
if (line.startsWith(`${key}:`) || line.startsWith(`"${key}"`) || line.startsWith(`'${key}'`)) {
|
|
if (pending.length) {
|
|
// Null-prototype `leading` (post-#3881-review, finding 3): `key` is an arbitrary
|
|
// user-authored YAML key — `constructor`, `__proto__`, `toString`, `valueOf`,
|
|
// `hasOwnProperty` all round-trip through here. On an ordinary `{}` those resolve
|
|
// to inherited Object.prototype members, and `reconstructFrontmatter`'s
|
|
// `commentChannel?.leading[key]` read then finds e.g. the `Object.prototype.toString`
|
|
// FUNCTION instead of `undefined`, throwing when it is iterated as if it were an
|
|
// array of comment strings.
|
|
if (!channel) channel = { leading: Object.create(null) as Record<string, string[]>, trailing: [] };
|
|
channel.leading[key] = pending;
|
|
pending = [];
|
|
}
|
|
keyIdx++;
|
|
continue;
|
|
}
|
|
}
|
|
// A non-comment line that is not the next expected top-level key start: any comments
|
|
// pending before it were not actually leading a key (malformed/unusual input) — drop
|
|
// rather than misattach, matching the prior scan's "attach only when a key follows" shape.
|
|
pending = [];
|
|
}
|
|
|
|
if (pending.length) {
|
|
if (!channel) channel = { leading: Object.create(null) as Record<string, string[]>, trailing: [] };
|
|
channel.trailing = pending;
|
|
}
|
|
return channel;
|
|
}
|
|
|
|
/**
|
|
* Parse one already-delimited YAML region into a Frontmatter object, via the vendored js-yaml
|
|
* (ADR-3473 §8.1). Throws (a `YAMLException`, or a plain `Error` from `refuseAnchorsAndAliases`)
|
|
* on anything js-yaml itself cannot parse or that this module refuses outright; callers decide
|
|
* whether to surface that as `unparseableResult()` or use it as a truncation signal.
|
|
*
|
|
* Renamed from `parseYamlRegion` (post-#3881-review, finding 2): §8.1 says `parseYamlRegion` is
|
|
* "deleted, not patched" — the hand-rolled line scanner that name identified IS gone, but the
|
|
* name itself survived on a new function with two callers (`extractFrontmatter` and
|
|
* `countKeysBeforeTruncation`) that could not be inlined without duplicating the
|
|
* refusal/null-byte/comment-channel glue below. Renaming closes that gap literally: nothing in
|
|
* this module still answers to the old hand-rolled scanner's name.
|
|
*
|
|
* RESTORED (fix #3881/#3881-followup-2, closes the #3705-shaped regression reported against
|
|
* `tests/smart-entry.unit.test.cjs:867`/`tests/smart-entry.property.test.cjs`): a prior revision
|
|
* of this function fell back, on a throw, to TWO hand-rolled re-implementations of YAML dialect —
|
|
* `repairAmbiguousColonValues` (below) for `key: value: extra`-shaped ambiguous colons, and
|
|
* `repairMalformedInlineArrays`/`splitLegacyInlineArrayItems` for a malformed/unclosed `[...]`.
|
|
* Both were deleted in 810e5e508 after a sweep of every tracked `*.md` file in this repo (910
|
|
* files) showed disabling each repair independently changed the parse result for zero documents.
|
|
*
|
|
* THAT SWEEP MEASURED THE WRONG POPULATION. `repairAmbiguousColonValues`'s one real dependent is
|
|
* not a document committed anywhere in this repo — it is user hand-edited STATE.md content that
|
|
* exists only on end users' machines and is pinned here by `tests/smart-entry.unit.test.cjs` (see
|
|
* its own in-file comment: "silently re-opened #2571/#2570 for hand-edited STATE.md that omits
|
|
* the template em dash"). The exact shape: `last_activity: 2026-06-08: reviewed the PR queue` — a
|
|
* colon-separated date+description a user typed by hand instead of the template's ` — ` (em dash)
|
|
* separator. js-yaml correctly refuses this as genuinely ambiguous YAML (a colon+space inside an
|
|
* unquoted scalar opens a nested mapping key); the old hand-rolled scanner tolerated it by taking
|
|
* everything after the first `key:` verbatim. A future sweep of tracked `.md` files will AGAIN
|
|
* show zero dependents for this exact reason — the dependent never lives in this repo's tree, it
|
|
* lives in a user's own `.planning/STATE.md`. Do not delete this again on that evidence alone;
|
|
* `tests/smart-entry.unit.test.cjs` and the frontmatter-level row in
|
|
* `tests/feat-3881-yaml-parser-consequences.test.cjs` are the actual proof the dependent exists.
|
|
*
|
|
* `repairMalformedInlineArrays`/`splitLegacyInlineArrayItems` stay deleted: their zero-dependents
|
|
* finding was reverified directly (frontmatter/smart-entry/verify/roadmap suites all pass without
|
|
* them) and, unlike the colon repair, nothing in the test suite or #2570/#2571 documents a
|
|
* hand-edited-STATE.md shape that depends on inline-array leniency.
|
|
*/
|
|
function loadWithAmbiguousColonRepair(yaml: string): unknown {
|
|
try {
|
|
return yamlLoad(yaml, YAML_LOAD_OPTS);
|
|
} catch (e) {
|
|
const repaired = repairAmbiguousColonValues(yaml);
|
|
if (repaired === yaml) throw e; // nothing to repair — surface the original error
|
|
try {
|
|
return yamlLoad(repaired, YAML_LOAD_OPTS);
|
|
} catch {
|
|
throw e; // repair didn't help (still invalid, possibly for another reason) — surface the original
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Double-quote (and escape) any column-0 `key: value` line whose (single-line) value contains an
|
|
* unquoted colon+whitespace or a trailing bare colon — the exact shape that reads as an ambiguous
|
|
* nested mapping key to a real YAML parser (`key: value: extra`, `key: value:`). Lines that are
|
|
* already safely quoted or open a flow/block collection (`"`, `'`, `[`, `{`) are left untouched
|
|
* (js-yaml already handles those); an empty value (`key:` alone, opening a nested block) is left
|
|
* untouched too, since repairing it would change a legitimate nested-map opener into a scalar.
|
|
* Only column-0 lines are considered — an indented line is either already-valid nested content or
|
|
* a genuinely different malformation this repair does not claim to fix.
|
|
*
|
|
* Restored (fix #3881/#3881-followup-2) — see `loadWithAmbiguousColonRepair`'s docblock for why a
|
|
* tracked-document sweep cannot see this function's one real dependent (hand-edited STATE.md,
|
|
* #2571/#2570, pinned by `tests/smart-entry.unit.test.cjs`).
|
|
*/
|
|
function repairAmbiguousColonValues(yaml: string): string {
|
|
return splitLines(yaml)
|
|
.map((line) => {
|
|
const m = /^([A-Za-z0-9_][A-Za-z0-9_-]*):[ \t](.+)$/.exec(line);
|
|
if (!m) return line;
|
|
const [, key, value] = m;
|
|
if (/^["'[{]/.test(value)) return line; // already safely quoted/collection-opened
|
|
if (!/:(?:[ \t]|$)/.test(value)) return line; // no ambiguous colon in the value
|
|
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
return `${key}: "${escaped}"`;
|
|
})
|
|
.join('\n');
|
|
}
|
|
|
|
function parseGuardedYamlRegion(yaml: string): Frontmatter {
|
|
refuseAnchorsAndAliases(yaml);
|
|
refuseIfSentinelPresent(yaml);
|
|
const escaped = escapeNullBytesForParse(yaml);
|
|
const raw: unknown = loadWithAmbiguousColonRepair(escaped);
|
|
const normalized = normalizeParsedValue(raw, false);
|
|
const root: Record<string, unknown> =
|
|
normalized && typeof normalized === 'object' && !Array.isArray(normalized)
|
|
? (normalized as Record<string, unknown>)
|
|
: (Object.create(null) as Record<string, unknown>);
|
|
const restored = restoreNullBytesDeep(root) as Frontmatter;
|
|
|
|
const commentChannel = extractCommentChannel(yaml, Object.keys(restored));
|
|
if (commentChannel) {
|
|
(restored as Record<symbol, unknown>)[FULL_LINE_COMMENTS as unknown as symbol] = commentChannel;
|
|
}
|
|
// Plain-prototype at the public-surface boundary (post-remote-runner-fix, #3881): see
|
|
// `toPlainValueTree`'s docblock. Everything above this line stays null-prototype internally.
|
|
return toPlainValueTree(restored) as Frontmatter;
|
|
}
|
|
|
|
/**
|
|
* ADR-3473 §8.1 (consequence 4): the #1882 truncation probe used to run the SAME parser
|
|
* (`parseGuardedYamlRegion`) over an unterminated region and count its keys — deliberately, so a second
|
|
* "does this look like YAML?" matcher could never drift from the real parser. js-yaml is stricter
|
|
* than the old scanner, though: the dominant real truncation shape (fence opened, well-formed
|
|
* keys, then the document body follows with no closing fence) is *invalid* YAML — a plain-text
|
|
* paragraph at column 0 right after a block mapping raises `bad indentation of a mapping entry`
|
|
* — so a naive "parse the whole region, count keys on success" port yields 0 keys and goes
|
|
* silent on exactly the case #1882 exists for.
|
|
*
|
|
* CORRECTED (post-#3881-review, finding 5): the original recovery — re-parse ONLY the exact
|
|
* prefix named by `e.mark.line` — regressed on every realistic truncation shape actually
|
|
* checked by execution: an unquoted-colon value (`title: a: b`) and a mis-indented sibling
|
|
* key (` plan: 2`) both raise "bad indentation of a mapping entry" ON the offending line
|
|
* itself, so `mark.line` names that SAME line and slicing BEFORE it drops the offending
|
|
* line's own key entirely — undercounting by exactly the key the probe most needs to see. An
|
|
* open flow collection (`list: [a, b`) raises its error on the (nonexistent) line AFTER the
|
|
* region's end, so the "marked prefix" still contains the same unterminated `[` and the retry
|
|
* parse fails too, falling through to a hard `0`. And a refused anchor/alias/merge key throws
|
|
* a mark-LESS `YAMLException` (`refuseAnchorsAndAliases`, thrown from inside the parse
|
|
* `listener` before js-yaml attaches position info) — the `e.mark` guard was never entered at
|
|
* all, hard `0` again, even though every other key in the region is perfectly valid.
|
|
*
|
|
* A first fix attempt tried shrinking the region line-by-line and re-parsing through the SAME
|
|
* real parser only — still no second matcher. It did NOT recover any of the three shapes above:
|
|
* the offending line in the first two IS the malformed token, at every possible prefix boundary
|
|
* that includes it, so no amount of shrinking ever makes it parse; the only prefix that ever
|
|
* succeeds is the one line BEFORE it, i.e. exactly the original bug's undercount. A parser-only
|
|
* strategy cannot report a key whose own line is genuinely invalid YAML — the same limitation
|
|
* that made the mark-based recovery fail in the first place. This means "the one real parser,
|
|
* never a hand-rolled matcher" is unreachable for the truncation-probe's actual job (a lower-
|
|
* bound COUNT of what looks like a key line, not a validity judgment) — this file already
|
|
* accepts an independent raw-text matcher for the adjacent question of "is this shaped like
|
|
* frontmatter" (`isFrontmatterShaped`, used by this probe's only caller), so `countKeysBeforeTruncation`
|
|
* takes the MAX of two lower bounds: how many keys the real parser can recover from the longest
|
|
* parseable line-prefix (still the primary signal — correct on the dominant fence-then-prose
|
|
* shape, and on any prefix boundary that genuinely IS the truncation point), and how many
|
|
* column-0 `key:`-shaped lines the raw text contains (recovers the three regressed shapes,
|
|
* whose offending key line the parser can never count). Neither alone is sufficient; together
|
|
* they never under-report a key that either signal can see.
|
|
*/
|
|
function countKeysBeforeTruncation(region: string): number {
|
|
const parsed = parsedKeyCount(region);
|
|
const textual = countTopLevelKeyShapedLines(region);
|
|
return Math.max(parsed, textual);
|
|
}
|
|
|
|
/** How many keys the real parser recovers from the longest line-prefix of `region` that parses
|
|
* cleanly (the whole region itself, when it parses outright). Bounded to at most `region`'s own
|
|
* line count re-parses — no worse than the whole-region parse already paid for on the caller's
|
|
* unterminated-region path, which is itself bounded by ordinary `.planning/` document sizes (the
|
|
* huge-bounded fixture parses successfully on the FIRST try and never reaches the shrink loop).
|
|
*/
|
|
function parsedKeyCount(region: string): number {
|
|
try {
|
|
return Object.keys(parseGuardedYamlRegion(region)).length;
|
|
} catch {
|
|
const lines = splitLines(region);
|
|
for (let n = lines.length - 1; n >= 1; n--) {
|
|
const prefix = lines.slice(0, n).join('\n');
|
|
if (prefix.trim() === '') continue;
|
|
try {
|
|
return Object.keys(parseGuardedYamlRegion(prefix)).length;
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/** How many `key:`-shaped lines `region` textually contains — the raw-text lower bound that
|
|
* recovers a key whose OWN line is malformed YAML (an unquoted colon in the value, or a
|
|
* mis-indented sibling that reads as an "indented continuation" to the real parser), which no
|
|
* re-parse of any prefix can ever count (see `countKeysBeforeTruncation`'s docblock). Matches
|
|
* ANY indentation, not only column 0 — the mis-indented-sibling shape is, by construction, a key
|
|
* the author intended as top-level but indented by mistake; requiring column 0 here would just
|
|
* relocate the exact undercount finding 5 reports. Deliberately the SAME key-shape pattern this
|
|
* file already uses for the sibling shape check (`isFrontmatterShaped`'s first branch) — ASCII-
|
|
* only is an accepted, precedented scope limit for this raw-text heuristic, not a new one.
|
|
*/
|
|
function countTopLevelKeyShapedLines(region: string): number {
|
|
return splitLines(region).filter((line) => /^\s*[A-Za-z0-9_-]+:/.test(line)).length;
|
|
}
|
|
|
|
/**
|
|
* 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's key count (see `countKeysBeforeTruncation`) is reported only when it
|
|
* yields **two or more** keys AND the region is uniformly frontmatter-shaped raw text
|
|
* (`isFrontmatterShaped`).
|
|
*
|
|
* 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.
|
|
*
|
|
* A closed region that js-yaml itself cannot parse (malformed YAML, or a refused
|
|
* anchor/alias/merge key — ADR-3473 §8.1 consequence 6) returns `{}` carrying the
|
|
* `FRONTMATTER_UNPARSEABLE` Symbol (consequence 2) rather than a bare, indistinguishable `{}`.
|
|
*
|
|
* @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 (U+FEFF), 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 keyCount = countKeysBeforeTruncation(region);
|
|
if (keyCount >= UNTERMINATED_KEY_THRESHOLD && isFrontmatterShaped(region)) {
|
|
warnUnusableInput({
|
|
reason: UNUSABLE_REASON.FRONTMATTER_UNTERMINATED,
|
|
source: sourcePath,
|
|
content,
|
|
});
|
|
}
|
|
return {};
|
|
}
|
|
|
|
const yamlEnd = content[closingLineStart - 1] === '\r' ? closingLineStart - 1 : closingLineStart;
|
|
const region = content.slice(headerEnd, yamlEnd);
|
|
try {
|
|
return parseGuardedYamlRegion(region);
|
|
} catch {
|
|
return unparseableResult();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Escape a string for emission inside a YAML double-quoted scalar (#1779). ADR-3473 §8.1
|
|
* (#3881): routed through the vendored js-yaml's `dump()` (forced double-quoted style) rather
|
|
* than a hand-rolled character-class replace chain, so the writer shares the same escaping
|
|
* engine the reader now uses. js-yaml emits control-char escapes as uppercase hex (`\x1F`);
|
|
* this repo has emitted lowercase (`\x1f`) since #1779, so the hex digits are lowercased after
|
|
* dump to keep serialized output byte-stable across the migration FOR THE CASES the old
|
|
* hand-rolled chain actually covered (backslash/quote/newline/tab/CR, and every C0 control
|
|
* plus DEL via \xHH). It is NOT byte-stable end-to-end (post-#3881-review, finding 4,
|
|
* verified by execution): the old chain left BEL/NUL unescaped-as-hex (`\x07`/`\x00`) and
|
|
* left NEL/NBSP/LINE SEPARATOR/PARAGRAPH SEPARATOR/BOM as raw literal bytes entirely (they
|
|
* fall outside its `\u0000-\u001f\u007f` class); js-yaml's dump instead emits the YAML-named
|
|
* escapes `\a`/`\0`/`\N`/`\_`/`\L`/`\P` for those six, and a `\uXXXX` escape for the BOM and
|
|
* any lone UTF-16 surrogate. The serialized TEXT differs from pre-migration output for these
|
|
* codepoints, but the round-trip is equivalence-preserving, not merely byte-preserving: every
|
|
* one of `\a`/`\0`/`\N`/`\_`/`\L`/`\P`/`\uXXXX` is a YAML double-quoted-scalar escape that
|
|
* resolves back to the EXACT source codepoint on re-parse (confirmed by execution — see
|
|
* `tests/frontmatter.unit.test.cjs`'s pinned cases). scalarNeedsDoubleQuoting was extended in
|
|
* the same review round to also route lone surrogates through this quoted+escaped path,
|
|
* because they were previously emitted bare and produced genuinely UNPARSEABLE YAML.
|
|
*
|
|
* Renamed from `escapeDoubleQuoted` (post-#3881-review, finding 2): §8.1 says this function is
|
|
* "deleted, not patched" — like `parseGuardedYamlRegion`, the hand-rolled character-class chain
|
|
* is gone, but unlike that function this one HAS no other caller inside this module to hide the
|
|
* old name's survival behind, so the rename is a straight mechanical propagation to its three
|
|
* call sites (`reconstructFrontmatter` here, plus `commands.cts` and
|
|
* `runtime-artifact-conversion.cts`, both updated in this change — no ADR amendment needed).
|
|
*/
|
|
function escapeDoubleQuotedScalar(s: string): string {
|
|
const dumped = yamlDump(s, { schema: FAILSAFE_SCHEMA, forceQuotes: true, quotingType: '"', lineWidth: -1 });
|
|
const withoutTrailingNewline = dumped.endsWith('\n') ? dumped.slice(0, -1) : dumped;
|
|
const interior = withoutTrailingNewline.slice(1, -1); // strip the outer double-quote pair
|
|
return interior.replace(/\\x([0-9A-Fa-f]{2})/g, (_m, hex: string) => `\\x${hex.toLowerCase()}`);
|
|
}
|
|
|
|
/**
|
|
* 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 `escapeDoubleQuotedScalar`: 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;
|
|
// Post-#3881-review, finding 4 (found while verifying escapeDoubleQuotedScalar's byte-
|
|
// stability claim): an unpaired UTF-16 surrogate (U+D800-U+DFFF) is outside YAML's
|
|
// printable-character set, so js-yaml's loader refuses it ("the stream contains
|
|
// non-printable characters") the instant it is emitted bare. No other trigger above
|
|
// catches it -- not whitespace, not a C0/C1 control, not a leading indicator -- so a bare
|
|
// emission was genuinely invalid YAML: reconstructFrontmatter produced text
|
|
// extractFrontmatter could not re-parse, silently collapsing to {} via
|
|
// unparseableResult(). Confirmed by execution: reconstructFrontmatter({weird: '\uD800'})
|
|
// round-tripped to undefined before this fix. Routing it through the quoted +
|
|
// escapeDoubleQuotedScalar path (which already emits the \uD800 escape) fixes the
|
|
// round-trip.
|
|
if (/[\uD800-\uDFFF]/.test(s)) return true;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* #3706 — Does this value need double-quoting when written as an AGENT
|
|
* frontmatter scalar (`model:`, `variant:`)?
|
|
*
|
|
* Deliberately a superset of `scalarNeedsDoubleQuoting` rather than a second,
|
|
* competing predicate: that one answers "can this open a plain scalar safely",
|
|
* which is necessary but not sufficient for a value that must ROUND-TRIP as the
|
|
* exact string it went in as. Keeping both here is the point — they are two
|
|
* answers to one question and drift the moment they live apart.
|
|
*
|
|
* The extra clauses, each an observed mis-parse rather than a precaution:
|
|
*
|
|
* - a non-alphanumeric first character. `scalarNeedsDoubleQuoting` rejects the
|
|
* indicators that cannot OPEN a scalar, but YAML still resolves plenty of
|
|
* values that open legally: `~` and `.inf`/`.nan` become null and floats,
|
|
* and a leading sign or dot (`+1`, `-0`, `.5`) becomes a number. Requiring
|
|
* alphanumeric-first covers that whole family at once, and costs nothing:
|
|
* every real model ID and effort level starts alphanumeric.
|
|
* - a trailing `:` — `model: foo:` is read as a nested mapping key and fails
|
|
* the whole frontmatter with "bad indentation of a mapping entry".
|
|
* - a boolean/null word — YAML 1.1 readers resolve `no`/`y`/`off`/`null` to
|
|
* non-strings, so a variant named `no` arrives as `false`.
|
|
* - a numeric-looking value, including the YAML 1.1 sexagesimal form: `12:30`
|
|
* resolves to the integer 750, and `:` is legal mid-identifier here.
|
|
* - a date. `2026-08-25` starts alphanumeric and survives every clause above,
|
|
* yet YAML resolves it to a Date object rather than a string.
|
|
*/
|
|
const YAML_WORD_SCALAR_RE = /^(?:y|n|yes|no|true|false|on|off|null)$/i;
|
|
const YAML_NUMERIC_RE = /^(?:\d[\d_]*(?:\.[\d_]*)?(?:[eE][-+]?\d+)?|0[xXbBoO][0-9a-fA-F_]+|\d[\d_]*(?::[0-5]?\d)+(?:\.[\d_]*)?)$/;
|
|
// YAML 1.1 timestamp: a bare ymd, optionally followed by a time part.
|
|
const YAML_TIMESTAMP_RE = /^\d{4}-\d{1,2}-\d{1,2}(?:[Tt ].*)?$/;
|
|
|
|
function agentScalarNeedsDoubleQuoting(s: string): boolean {
|
|
if (scalarNeedsDoubleQuoting(s)) return true;
|
|
if (!/^[A-Za-z0-9]/.test(s)) return true;
|
|
// A plain scalar ENDS at `: ` or ` #` wherever they appear — the base
|
|
// predicate only inspects the first character, because its question is
|
|
// whether the scalar can legally open. `a: b` makes the line a nested
|
|
// mapping (a parse error at this indent) and `a #b` silently truncates to
|
|
// `a`. Both were found by the round-trip property, not by inspection.
|
|
if (/:\s/.test(s) || /\s#/.test(s)) return true;
|
|
if (s.endsWith(':')) return true;
|
|
if (YAML_WORD_SCALAR_RE.test(s)) return true;
|
|
if (YAML_NUMERIC_RE.test(s)) return true;
|
|
if (YAML_TIMESTAMP_RE.test(s)) return true;
|
|
return false;
|
|
}
|
|
|
|
function reconstructFrontmatter(obj: Frontmatter): string {
|
|
const lines: string[] = [];
|
|
// #3257: read the full-line-comment channel (set by parseGuardedYamlRegion 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)) ? `"${escapeDoubleQuotedScalar(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)) ? `"${escapeDoubleQuotedScalar(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) ? `"${escapeDoubleQuotedScalar(sv)}"` : sv}`);
|
|
}
|
|
}
|
|
} else {
|
|
const sv = String(value);
|
|
if (sv.includes(':') || sv.includes('#') || sv.startsWith('[') || sv.startsWith('{') || scalarNeedsDoubleQuoting(sv)) {
|
|
lines.push(`${key}: "${escapeDoubleQuotedScalar(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 parseGuardedYamlRegion 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;
|
|
// Null-prototype `leading` (post-#3881-review, finding 3) — same rationale as
|
|
// `extractCommentChannel`. `target` may be a plain `{}` built by a caller outside this
|
|
// module (e.g. `buildStateFrontmatter`), so `key in target` is checked via
|
|
// `hasOwnProperty`, not the `in` operator: `in` walks target's OWN prototype chain too,
|
|
// and a target key named `constructor`/`toString`/etc. would otherwise read as "present"
|
|
// even when it was never actually set.
|
|
const filtered: FullLineCommentChannel = { leading: Object.create(null) as Record<string, string[]>, trailing: channel.trailing };
|
|
for (const [key, comments] of Object.entries(channel.leading)) {
|
|
if (Object.prototype.hasOwnProperty.call(target, key)) 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;
|
|
}
|
|
|
|
/**
|
|
* ADR-3473 §8.1 (#3881): the legacy `- key: value` same-line-with-dash capture never trimmed
|
|
* or number-coerced its value (`current[kvMatch[1]] = kvMatch[2]` verbatim), while every
|
|
* CONTINUATION line (a further-indented sibling key under the same list item) both trimmed
|
|
* (`kvMatch[2].trim()` — #1905/#1154, a quoted `"backstop "` must not silently stop matching
|
|
* the literal `backstop` marker) and number-coerced (`/^\d+$/.test(val) ? parseInt(val, 10) :
|
|
* val`). That distinction was purely a byproduct of the hand-rolled line scanner's own
|
|
* position tracking — real YAML has no such notion; `path: x` on the dash's own line and
|
|
* `count: 1` one line below it are the same kind of mapping entry. `tests/frontmatter.test.cjs`
|
|
* ("trims a continuation-KV value…") pins the trimming behavior, so it is reproduced here by
|
|
* treating an object item's FIRST own key (source order, matching the dash line) as untouched
|
|
* and every subsequent key as "continuation": trimmed, and coerced to a number when (after
|
|
* trimming) it is all-digits — the exact `/^\d+$/` shape the legacy scanner recognized, never a
|
|
* broader YAML-native numeric resolution (which would also promote floats/octal/booleans the
|
|
* legacy scanner left as strings).
|
|
*/
|
|
function coerceMustHavesValue(value: unknown, isContinuation: boolean): unknown {
|
|
if (typeof value !== 'string') return value; // arrays/nested maps pass through untouched
|
|
if (!isContinuation) return value;
|
|
const trimmed = value.trim();
|
|
return /^\d+$/.test(trimmed) ? parseInt(trimmed, 10) : trimmed;
|
|
}
|
|
|
|
/** Normalize one must_haves list item to the legacy contract: a plain scalar item stays a
|
|
* string; an object item gets `coerceMustHavesValue`'s same-line/continuation treatment
|
|
* (see that function's docblock).
|
|
*/
|
|
function normalizeMustHavesItem(item: unknown): unknown {
|
|
if (item === null || typeof item !== 'object' || Array.isArray(item)) return item;
|
|
const out: Record<string, unknown> = {};
|
|
Object.entries(item as Record<string, unknown>).forEach(([k, v], idx) => {
|
|
out[k] = coerceMustHavesValue(v, idx > 0);
|
|
});
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Extract a specific block from `must_haves` in frontmatter YAML (e.g. `must_haves.truths`,
|
|
* `must_haves.artifacts`, `must_haves.key_links`) — via the same vendored js-yaml parser the
|
|
* rest of this module uses (ADR-3473 §8.1 / #3881), rather than the hand-rolled indentation
|
|
* scanner this replaces.
|
|
*
|
|
* Deliberately NOT routed through `parseGuardedYamlRegion`: that function flattens an
|
|
* object-shaped list ITEM to a single canonical string (consequence 3), which is the correct
|
|
* contract for the top-level Frontmatter value shape but would collapse `must_haves.artifacts`'s
|
|
* `{path, provides, ...}` items into unusable strings. This parses the region independently,
|
|
* under the same `FAILSAFE_SCHEMA` + `json: true` options (every scalar a string, duplicate
|
|
* keys last-wins) and the same anchor/alias/merge-key refusal (`refuseAnchorsAndAliases`) —
|
|
* `.planning/` must_haves blocks are untrusted input exactly like the rest of frontmatter.
|
|
*/
|
|
function parseMustHavesBlock(content: string, blockName: string): unknown[] {
|
|
const fmMatch = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
|
|
if (!fmMatch) return [];
|
|
const yaml = fmMatch[1];
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
refuseAnchorsAndAliases(yaml);
|
|
parsed = yamlLoad(yaml, YAML_LOAD_OPTS);
|
|
} catch {
|
|
return [];
|
|
}
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return [];
|
|
|
|
const mustHaves = (parsed as Record<string, unknown>).must_haves;
|
|
if (!mustHaves || typeof mustHaves !== 'object' || Array.isArray(mustHaves)) return [];
|
|
|
|
const block = (mustHaves as Record<string, unknown>)[blockName];
|
|
if (!Array.isArray(block)) {
|
|
// Warn when the block exists but isn't a usable list — likely a 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 (block !== undefined && block !== null) {
|
|
process.stderr.write(
|
|
`[gsd-tools] WARNING: must_haves.${blockName} block has content but parsed 0 items. ` +
|
|
`Possible YAML formatting issue — verification will fall back to LLM-derived truths.\n`
|
|
);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
return block.map(normalizeMustHavesItem);
|
|
}
|
|
|
|
// ─── 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; }
|
|
// #1660 (broadened): a lossy object-list field being genuinely CHANGED must fail closed
|
|
// before it is regenerated, not just when the regenerated result happens to be byte-identical
|
|
// to the original (see objectListFieldWouldLoseData's docblock).
|
|
const lossyErr = objectListFieldWouldLoseData(content, field as string, parsedValue);
|
|
if (lossyErr) {
|
|
output({ error: lossyErr, field }, raw, undefined);
|
|
return;
|
|
}
|
|
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.';
|
|
}
|
|
|
|
/**
|
|
* #1660 (broadened, ADR-3473 §8.1 / #3881): `noOpObjectListSetError` only catches the
|
|
* BYTE-IDENTICAL no-op case. Under the js-yaml migration, `flattenObjectListItem` correctly
|
|
* joins EVERY sub-key of an object-list item (`path: X, provides: Y`) instead of the legacy
|
|
* hand-rolled scanner's accidental behavior of silently discarding every field but the one on
|
|
* the dash line itself. That fixes a real data-loss bug on READ, but it also means a `set` that
|
|
* replaces such a field with a plainly-flattened string (e.g. `{artifacts: ["path: X"]}`,
|
|
* omitting `provides`) is no longer byte-identical to the original — so it no longer trips the
|
|
* no-op guard, sails through `regenerateFrontmatterKey` (which only refuses when the NEW value
|
|
* itself contains a live JS object), and silently writes a version with `provides` gone.
|
|
*
|
|
* This is the general form of the same "cannot faithfully round-trip" contract: a field is
|
|
* lossy exactly when regenerating its OWN already-parsed value fails to reproduce its own raw
|
|
* source text byte-for-byte (proof, not a guess, that this key's original shape does not
|
|
* survive parse → reconstruct). When that is true AND the caller is genuinely changing the
|
|
* field (not merely re-supplying an equal value, which `frontmatterDeepEqual` already lets
|
|
* through), the set is refused — matching `regenerateFrontmatterKey`'s own fail-closed
|
|
* philosophy for the mirror-image case (new value carries a nested object outright).
|
|
*/
|
|
function objectListFieldWouldLoseData(content: string, field: string, newValue: unknown): string | null {
|
|
// A NEW value that itself carries a live nested object (rather than an already-flattened
|
|
// string) is the mirror-image case `regenerateFrontmatterKey` already refuses on its own
|
|
// (the "[object Object]" guard, via spliceFrontmatter) — leave that path's existing throw
|
|
// behavior alone rather than intercepting it here with a different (non-throwing) contract.
|
|
try { regenerateFrontmatterKey(field, newValue as FrontmatterValue); } catch { return null; }
|
|
|
|
let originalParsed: Frontmatter;
|
|
try { originalParsed = extractFrontmatter(content); } catch { return null; }
|
|
if (!Object.prototype.hasOwnProperty.call(originalParsed, field)) return null;
|
|
const originalValue = originalParsed[field];
|
|
if (frontmatterDeepEqual(newValue, originalValue)) return null;
|
|
|
|
const fmMatch = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
|
|
if (!fmMatch) return null;
|
|
const original = sliceTopLevelFrontmatterSegments(fmMatch[1]).find((s) => s.key === field);
|
|
if (!original) return null;
|
|
|
|
let regeneratedOriginal: string;
|
|
try {
|
|
regeneratedOriginal = regenerateFrontmatterKey(field, originalValue);
|
|
} catch {
|
|
return `frontmatter set refused — the existing "${field}" field contains a nested object-list ` +
|
|
`(e.g. must_haves.artifacts) the frontmatter writer cannot faithfully represent, and this change ` +
|
|
`would silently discard data. Edit the file directly instead of using frontmatter set/merge.`;
|
|
}
|
|
if (regeneratedOriginal.trim() === original.raw.trim()) return null;
|
|
|
|
return `frontmatter set refused — the existing "${field}" field cannot be faithfully round-tripped by ` +
|
|
`the frontmatter writer (its structure would be flattened and data, such as a nested object-list ` +
|
|
`field, silently dropped). Edit the file directly instead of using frontmatter set/merge.`;
|
|
}
|
|
|
|
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 = {
|
|
// #3706: shared with the agent-frontmatter writers so a config-supplied
|
|
// `model:`/`variant:` value cannot break out of its scalar. Previously private
|
|
// here while those writers interpolated raw — one escaper, three call sites.
|
|
escapeDoubleQuotedScalar,
|
|
agentScalarNeedsDoubleQuoting,
|
|
extractFrontmatter,
|
|
UNTERMINATED_KEY_THRESHOLD,
|
|
// ADR-3473 §8.1 (#3881, consequence 2): the unparseable-vs-empty marker Symbol. Exported so
|
|
// the 8 `hasFrontmatter` call sites named in the design can consult it in a follow-up change.
|
|
FRONTMATTER_UNPARSEABLE,
|
|
// 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,
|
|
};
|