Files
msd-core/tests/feat-3881-yaml-parser-consequences.test.cjs
Tom Boucher 6b7df61938 enhance(#3881): one YAML parser — vendored js-yaml replaces the hand-rolled dialect (#3888)
* 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>
2026-08-26 19:29:32 -04:00

884 lines
40 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ADR-3473 §8.1 (#3881) — consequence and boundary coverage for the js-yaml migration.
// See .gsd/phase/feat-3881-one-yaml-parser/50-test-matrix.md sections A and F. Each row
// pins a consequence of swapping the hand-rolled line scanner for the vendored js-yaml
// (§40-design.md §0.2) that is otherwise invisible to the existing suite.
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {
extractFrontmatter,
reconstructFrontmatter,
spliceFrontmatter,
UNTERMINATED_KEY_THRESHOLD,
FRONTMATTER_UNPARSEABLE,
} = require('../gsd-core/bin/lib/frontmatter.cjs');
const { transitionCore } = require('../gsd-core/bin/lib/state-transition.cjs');
const {
_resetUnusableInputWarningsForTests,
_unusableInputEmissionCountForTests,
} = require('../gsd-core/bin/lib/unusable-input.cjs');
const { createTempDir, cleanup, runGsdTools, createTempProject } = require('./helpers.cjs');
const { runNode } = require('./helpers/process-seam.cjs');
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
const fixedClock = Object.freeze({
today: () => '2026-06-27',
localToday: () => '2026-06-27',
nowIso: () => '2026-06-27T12:00:00.000Z',
});
function withTempDir(fn) {
const dir = createTempDir('feat-3881-consequences-');
try {
return fn(dir);
} finally {
cleanup(dir);
}
}
// ─── A. Consequences ────────────────────────────────────────────────────────
describe('A1 emptyValuedKeySurvivesAWrite', () => {
test('a key with no value round-trips through parse -> reconstruct -> re-parse with the key still present', () => {
const doc = '---\nphase: 3\nprogress:\n---\n\nbody\n';
const parsed = extractFrontmatter(doc);
assert.ok(
Object.prototype.hasOwnProperty.call(parsed, 'progress'),
'an empty-valued key must survive the initial parse'
);
// reconstructFrontmatter omits null-valued keys (frontmatter.cjs: `if (value === null ...) continue`),
// so the empty-value contract only survives a write if extractFrontmatter never hands one back —
// this is what pins that guarantee rather than reconstructFrontmatter's own omission logic.
const reconstructed = reconstructFrontmatter(parsed);
const rewritten = `---\n${reconstructed}\n---\n\nbody\n`;
const reparsed = extractFrontmatter(rewritten);
assert.ok(
Object.prototype.hasOwnProperty.call(reparsed, 'progress'),
`progress must survive a write; reconstructed frontmatter was ${JSON.stringify(reconstructed)}`
);
});
});
describe('A2 unparseableDocumentKeepsItsFrontmatterBlock', () => {
test('a STATE.md with a git merge-conflict marker in its frontmatter keeps the block through beginPhase', () => {
const fmBlock = [
'---',
'<<<<<<< HEAD',
'status: foo',
'=======',
'status: bar',
'>>>>>>> feature',
'---',
'',
].join('\n');
const body = [
'# Project State',
'',
'**Status:** Planning',
'',
'## Current Position',
'',
'Phase: 2 — DONE',
'Plan: —',
'Status: Planning',
'',
].join('\n');
const content = fmBlock + body;
// Verify reachability first: the conflicted region parses to zero keys with the
// unparseable marker set, exercising the exact branch beginPhaseCore relies on.
const fm = extractFrontmatter(content);
assert.equal(Object.keys(fm).length, 0);
assert.equal(fm[FRONTMATTER_UNPARSEABLE], true);
const result = transitionCore(
content,
{ kind: 'beginPhase', phaseNumber: 3, phaseName: 'Test Phase', planCount: 5 },
{ clock: fixedClock }
);
assert.ok(
result.content.includes('<<<<<<< HEAD') &&
result.content.includes('=======') &&
result.content.includes('>>>>>>> feature'),
`frontmatter conflict markers must survive the write; got ${JSON.stringify(result.content)}`
);
});
// Post-#3881-review, finding 7: this describe block exercised only ONE of the 8 call sites
// that route through `beginFrontmatterReassembly` (frontmatter.cts's docblock names all 8:
// 7 `*Core` functions in state-transition.cts, dispatched by `transitionCore`, plus 1 more
// hand-verified separately in `state.cts`'s `cmdStateCompletePhase`). Table-driven over the
// remaining 6 `transitionCore` kinds that share the same preservation contract.
const OTHER_TRANSITION_KINDS = [
['advancePlan', { kind: 'advancePlan' }],
['completePhase', { kind: 'completePhase', phaseNum: '2', nextPhaseNum: '3', nextPhaseName: 'Next Phase', isLastPhase: false, planCount: 1, summaryCount: 1 }],
['plannedPhase', { kind: 'plannedPhase', phaseNumber: 3, phaseName: 'Test Phase', planCount: 5 }],
['milestoneComplete', { kind: 'milestoneComplete', version: 'v1.0', nextMilestoneCommand: '/gsd:new-milestone' }],
['patch', { kind: 'patch', patches: { Status: 'Paused' } }],
['update', { kind: 'update', field: 'Status', value: 'Paused' }],
];
const fmBlock = [
'---',
'<<<<<<< HEAD',
'status: foo',
'=======',
'status: bar',
'>>>>>>> feature',
'---',
'',
].join('\n');
const body = [
'# Project State',
'',
'**Status:** Planning',
'',
'## Current Position',
'',
'Phase: 2 — DONE',
'Plan: —',
'Status: Planning',
'',
].join('\n');
const content = fmBlock + body;
for (const [label, intent] of OTHER_TRANSITION_KINDS) {
test(`a git merge-conflict marker in the frontmatter keeps the block through ${label}`, () => {
const result = transitionCore(content, intent, { clock: fixedClock, roadmapProvider: () => null });
assert.ok(
result.content.includes('<<<<<<< HEAD')
&& result.content.includes('=======')
&& result.content.includes('>>>>>>> feature'),
`${label}: frontmatter conflict markers must survive the write; got ${JSON.stringify(result.content)}`
);
});
}
});
// ─── A2b. The 8th reassemble site — the real CLI path, not just the pure transform ─────────
//
// Post-#3881-review, second round: the 7 `transitionCore` kinds above preserve an unparseable
// block at the PURE-TRANSFORM layer, but `state.cts`'s CLI adapters wrap every transform in
// `readModifyWriteStateMd` -> `syncAndPreserveStateMd`, which reruns `extractFrontmatter` on
// the (already-preserved) result and — before this fix — unconditionally re-derived a FRESH
// frontmatter block, discarding the raw one a second time. Confirmed by execution: BEFORE the
// fix, `state complete-phase` on a conflict-marker STATE.md returned success with the markers
// GONE, replaced by a freshly-derived well-formed block (re-derivation, not fence deletion —
// case (b), not (a)). Table-driven over the CLI verbs found to share the same
// `readModifyWriteStateMd` path, plus a control proving the ADR-3408 §8.3 CLOSED-list "body
// wins" contract (`state sync`) is untouched.
describe('A2b unparseableFrontmatterSurvivesTheRealCliPath', () => {
const CONFLICT_FM_BLOCK = [
'---',
'<<<<<<< HEAD',
'status: foo',
'=======',
'status: bar',
'>>>>>>> feature',
'---',
'',
].join('\n');
const CONFLICT_BODY = [
'# Project State',
'',
'## Current Position',
'',
'Phase: 1 — Foundation',
'Plan: 1 of 1',
'Status: Executing Phase 1',
'Last activity: 2026-07-01 — mid-flight',
'',
].join('\n');
function writeConflictFixture(tmpDir) {
const planningDir = path.join(tmpDir, '.planning');
fs.mkdirSync(path.join(planningDir, 'phases', '01-foundation'), { recursive: true });
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
['# Roadmap', '', '### Phase 1: Foundation', '**Goal:** Setup', ''].join('\n'),
);
const statePath = path.join(planningDir, 'STATE.md');
fs.writeFileSync(statePath, CONFLICT_FM_BLOCK + CONFLICT_BODY);
return statePath;
}
const NON_SANCTIONED_VERBS = [
['state complete-phase', ['state', 'complete-phase']],
['state update', ['state', 'update', 'Last Activity', '2026-08-26']],
['query state.patch', ['query', 'state.patch', JSON.stringify({ Status: 'Paused for review' })]],
['state begin-phase', ['state', 'begin-phase', '--phase', '2', '--name', 'Next Phase']],
];
for (const [label, args] of NON_SANCTIONED_VERBS) {
test(`${label}: a git merge-conflict-marked frontmatter block survives the real CLI write`, () => {
const tmpDir = createTempProject();
try {
const statePath = writeConflictFixture(tmpDir);
const result = runGsdTools(args, tmpDir);
assert.ok(result.success, `${label} failed: ${result.error}`);
const after = fs.readFileSync(statePath, 'utf-8');
assert.ok(
after.includes('<<<<<<< HEAD') && after.includes('=======') && after.includes('>>>>>>> feature'),
`${label}: conflict markers must survive; got:\n${after}`,
);
} finally {
cleanup(tmpDir);
}
});
}
test('control: state sync (ADR-3408 §8.3 CLOSED list — body wins) still overwrites unparseable frontmatter, unchanged', () => {
// The one command that MUST keep clobbering it — a regression here would mean the fix
// widened the closed list, which the review explicitly forbids.
const tmpDir = createTempProject();
try {
const statePath = writeConflictFixture(tmpDir);
const result = runGsdTools(['state', 'sync'], tmpDir);
assert.ok(result.success, `state sync failed: ${result.error}`);
const after = fs.readFileSync(statePath, 'utf-8');
assert.ok(
!after.includes('<<<<<<< HEAD'),
'state sync must still re-derive frontmatter from the body (its documented contract) — conflict markers must NOT survive',
);
assert.ok(/^---\r?\n/.test(after), 'state sync must still produce a well-formed frontmatter block');
} finally {
cleanup(tmpDir);
}
});
});
// #3881 ADR-3473 §8.5: `state sync`'s "body wins" regeneration over an unparseable
// frontmatter block (control test above, A2b) is correct and must not change — but it was
// SILENT: `synced: true`, exit 0, no signal that the existing block (including any
// merge-conflict markers) was unreadable and destroyed. §8.5: "a derived conclusion may not
// be reported as authoritative when the derivation dropped input it could not resolve."
// Table-driven per the dispatch brief's instruction to check sibling verbs on the same
// ADR-3408 §8.3 sanctioned-regenerate list: `REGENERATE_STATE` (`/gsd-health --repair`) is
// on that list too, but is DESTRUCTIVE-risk and unconditionally REFUSED by `applyRepairs`'s
// dispatcher (src/health-diagnostic.cts) before `runRepairAction` is ever invoked — so
// `state sync` is the only LIVE verb on the sanctioned path today. No table needed; a single
// verb, driven through the real CLI, is the whole live surface.
describe('A2c stateSyncWarnsOnUnparseableFrontmatterRegeneration', () => {
const CONFLICT_FM_BLOCK = [
'---',
'<<<<<<< HEAD',
'status: foo',
'=======',
'status: bar',
'>>>>>>> feature',
'---',
'',
].join('\n');
const CONFLICT_BODY = [
'# Project State',
'',
'## Current Position',
'',
'Phase: 1 — Foundation',
'Plan: 1 of 1',
'Status: Executing Phase 1',
'Last activity: 2026-07-01 — mid-flight',
'',
].join('\n');
function seedPhaseDirs(tmpDir) {
const planningDir = path.join(tmpDir, '.planning');
fs.mkdirSync(path.join(planningDir, 'phases', '01-foundation'), { recursive: true });
fs.mkdirSync(path.join(planningDir, 'phases', '02-next-phase'), { recursive: true });
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
['# Roadmap', '', '### Phase 1: Foundation', '**Goal:** Setup', '', '### Phase 2: Next', '**Goal:** More', ''].join('\n'),
);
}
function writeConflictState(tmpDir) {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, CONFLICT_FM_BLOCK + CONFLICT_BODY);
return statePath;
}
function writeValidState(tmpDir) {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const validFm = [
'---',
'gsd_state_version: \'1.0\'',
'status: executing',
'current_phase: 1',
'---',
'',
].join('\n');
fs.writeFileSync(statePath, validFm + CONFLICT_BODY);
return statePath;
}
test('RED (pre-fix) proof: unparseable frontmatter — stderr carries the gsd: warning line and the JSON result surfaces it in `changes`', () => {
const tmpDir = createTempProject();
try {
seedPhaseDirs(tmpDir);
const statePath = writeConflictState(tmpDir);
const r = runNode([TOOLS_PATH, 'state', 'sync', '--raw'], { cwd: tmpDir, timeoutMs: PROBE_TIMEOUT_MS });
throwIfFailed(r, 'gsd-tools state sync --raw');
// Regeneration still happened (unchanged contract — the control test above pins this
// for the general case; re-asserted here on the same fixture this warning covers).
const after = fs.readFileSync(statePath, 'utf-8');
assert.ok(!after.includes('<<<<<<< HEAD'), 'state sync must still regenerate over the unparseable block');
// Human channel: matches the existing `gsd: warning — ... (#NNNN)` precedent (#3573).
assert.match(
r.stderr,
/gsd: warning — .*frontmatter.*could not be parsed.*regenerated.*\(#3881\)/s,
`expected a gsd: warning on stderr naming the unparseable frontmatter; got stderr:\n${r.stderr}\nstdout:\n${r.stdout}`,
);
// Machine channel: the JSON result's existing `changes` array (the mechanism this
// codebase already uses to surface sync-time signals — see the "Progress: skipped —
// ..." entries in src/state.cts) must carry the same disclosure.
const parsed = JSON.parse(r.stdout);
assert.ok(Array.isArray(parsed.changes), `expected a changes array in JSON result; got ${r.stdout}`);
assert.ok(
parsed.changes.some((c) => typeof c === 'string' && c.includes('could not be parsed') && c.includes('#3881')),
`expected 'changes' to include the unparseable-frontmatter warning; got ${JSON.stringify(parsed.changes)}`,
);
assert.strictEqual(parsed.synced, true, 'exit-0/synced:true stays correct — sync did what its contract says');
} finally {
cleanup(tmpDir);
}
});
test('control (cannot pass vacuously): valid, parseable frontmatter emits NO such warning', () => {
const tmpDir = createTempProject();
try {
seedPhaseDirs(tmpDir);
const statePath = writeValidState(tmpDir);
const r = runNode([TOOLS_PATH, 'state', 'sync', '--raw'], { cwd: tmpDir, timeoutMs: PROBE_TIMEOUT_MS });
throwIfFailed(r, 'gsd-tools state sync --raw');
assert.doesNotMatch(
r.stderr,
/#3881/,
`valid frontmatter must not trigger the unparseable-frontmatter warning; got stderr:\n${r.stderr}`,
);
const parsed = JSON.parse(r.stdout);
assert.ok(
!parsed.changes.some((c) => typeof c === 'string' && c.includes('#3881')),
`expected no #3881 warning in changes for valid frontmatter; got ${JSON.stringify(parsed.changes)}`,
);
void statePath;
} finally {
cleanup(tmpDir);
}
});
});
describe('A3 unparseableIsDistinguishableFromEmpty', () => {
test('both an empty and an unparseable block yield zero keys, but only the unparseable one carries the marker', () => {
const empty = extractFrontmatter('---\n---\n\nbody\n');
const unparseable = extractFrontmatter('---\nfoo: [unclosed\n---\n\nbody\n');
assert.equal(Object.keys(empty).length, 0);
assert.equal(Object.keys(unparseable).length, 0);
assert.notEqual(
empty[FRONTMATTER_UNPARSEABLE],
true,
'a genuinely empty frontmatter block must not carry the unparseable marker'
);
assert.equal(
unparseable[FRONTMATTER_UNPARSEABLE],
true,
'a malformed frontmatter block must carry the unparseable marker'
);
});
});
describe('A4 nonScalarValuesCanonicalize', () => {
test('the four spellings of an object-list scalar canonicalize to one value', () => {
const spellings = [
'- test: "a b"',
'- test: a b',
"- test: 'a b'",
'- {test: a b}',
];
const CANONICAL = ['test: a b'];
for (const spelling of spellings) {
const doc = `---\nkey:\n${spelling}\n---\n\nbody\n`;
const parsed = extractFrontmatter(doc);
assert.deepEqual(
parsed.key,
CANONICAL,
`spelling ${JSON.stringify(spelling)} must canonicalize to ${JSON.stringify(CANONICAL)}; got ${JSON.stringify(parsed.key)}`
);
}
});
});
describe('A5 truncationProbeStillFiresOnAnOpenFence', () => {
test('fires on the dominant real truncation shape: opening fence, well-formed keys, then nothing', () => {
_resetUnusableInputWarningsForTests();
const truncated = '---\nphase: 3\nplan: 2\n';
extractFrontmatter(truncated);
assert.equal(
_unusableInputEmissionCountForTests(),
1,
'the #1882 probe must fire on a well-formed-but-unterminated frontmatter region'
);
});
test('does NOT fire on the documented false-positive shape: a rule followed by ordinary prose', () => {
_resetUnusableInputWarningsForTests();
const rule = '---\nNote: this is a paragraph.\n\nJust ordinary prose after a thematic break.\n';
extractFrontmatter(rule);
assert.equal(
_unusableInputEmissionCountForTests(),
0,
'a document that merely opens with a thematic break above prose must not be flagged as truncated'
);
});
// Post-#3881-review, finding 5: the trivially-parseable dominant shape above was the ONLY
// shape this row exercised — vacuous for the risk it names, since it never touched
// `countKeysBeforeTruncation`'s failure/recovery path at all (that whole-region text is
// valid YAML; the probe fires purely from a successful parse). Table-driven over every real
// truncation shape confirmed regressed by execution during review: an unquoted colon inside
// a value, an open (unterminated) flow collection, a mis-indented sibling key, and an
// anchor/alias whose refusal throws a mark-less exception. Each must still fire the #1882
// diagnostic exactly once.
const REGRESSED_TRUNCATION_SHAPES = [
['unquoted colon in a value', '---\nphase: 3\ntitle: a: b\n'],
['open (unterminated) flow collection', '---\nphase: 3\nlist: [a, b\n'],
['mis-indented sibling key', '---\nphase: 3\n plan: 2\n'],
['anchor/alias — refusal throws a mark-less exception', '---\nphase: 3\nfoo: &a bar\n'],
];
for (const [label, doc] of REGRESSED_TRUNCATION_SHAPES) {
test(`fires on a real truncation shape the mark-based recovery regressed on: ${label}`, () => {
_resetUnusableInputWarningsForTests();
extractFrontmatter(doc);
assert.equal(
_unusableInputEmissionCountForTests(),
1,
`the #1882 probe must fire on an unterminated region shaped like: ${label}; doc=${JSON.stringify(doc)}`
);
});
}
});
describe('A6 commentsStayOnTheirOwnKey', () => {
test('a column-0 comment above a Unicode key attaches to that key and survives a round-trip', () => {
const doc = '---\nfoo: bar\n# note\n相: baz\n---\n\nbody\n';
const parsed = extractFrontmatter(doc);
assert.deepEqual(Object.keys(parsed), ['foo', '相']);
assert.equal(parsed['相'], 'baz');
const reconstructed = reconstructFrontmatter(parsed);
const commentLine = reconstructed.split('\n').find((l) => l.startsWith('#'));
const keyLine = reconstructed.split('\n').find((l) => l.startsWith('相:'));
assert.ok(commentLine, `reconstructed frontmatter must carry the comment; got ${JSON.stringify(reconstructed)}`);
const commentIdx = reconstructed.split('\n').indexOf(commentLine);
const keyIdx = reconstructed.split('\n').indexOf(keyLine);
assert.equal(keyIdx, commentIdx + 1, 'the comment must sit immediately above the 相 key, not the following one');
// Round-trip: reparsing the reconstructed block and reconstructing again is byte-identical.
const rewritten = `---\n${reconstructed}\n---\n\nbody\n`;
const reparsed = extractFrontmatter(rewritten);
assert.equal(reconstructFrontmatter(reparsed), reconstructed);
});
});
describe('A7 anchorsAndAliasesAreRefused', () => {
// #3881 review, finding 1: the original refusal was a raw-line regex matching only the
// bare-key spelling (`key: &x`). A quoted key, a flow mapping and a flow sequence all
// define/use the SAME anchor mechanics while never matching that line shape — table-driven
// over every spelling that was confirmed bypassable, plus the original passing case, so a
// future regression in any one spelling fails loudly rather than hiding behind the others.
const SPELLINGS = [
['plain', '---\nfoo: &a bar\nbaz: *a\n---\n\nbody\n'],
['quoted key', '---\n"foo": &a bar\n"baz": *a\n---\n\nbody\n'],
['flow mapping', '---\na: {b: &a 1, c: *a}\n---\n\nbody\n'],
['flow sequence', '---\na: [&a "q", *a]\n---\n\nbody\n'],
['merge key (<<:) with an alias', '---\nbase: &b\n x: "1"\nfoo:\n <<: *b\n y: "2"\n---\n\nbody\n'],
];
for (const [label, doc] of SPELLINGS) {
test(`${label}: refused rather than expanded`, () => {
const parsed = extractFrontmatter(doc);
assert.equal(Object.keys(parsed).length, 0, `${label} must parse to zero keys`);
assert.equal(parsed[FRONTMATTER_UNPARSEABLE], true, `${label} must carry the unparseable marker`);
});
}
test('a bare merge key with NO alias is not itself refused (no anchor, no expansion risk)', () => {
// Under FAILSAFE_SCHEMA (no !!merge type resolution) this never actually merges — it
// parses as an ordinary, non-expanding literal "<<" string key. Documented behavior
// change from the pre-review regex (which refused every `<<:`-shaped line regardless of
// whether an alias was present) — see frontmatter.cts refuseAnchorsAndAliases docblock.
const doc = '---\na:\n <<: {b: 1}\n c: 2\n---\n\nbody\n';
const parsed = extractFrontmatter(doc);
assert.notEqual(parsed[FRONTMATTER_UNPARSEABLE], true);
assert.deepEqual(parsed.a, { '<<': { b: '1' }, c: '2' });
});
});
describe('A8 aliasExpansionCannotExhaustMemory', () => {
test('a billion-laughs frontmatter is refused, bounded on the RESULT, never on elapsed time', () => {
const bomb = [
'a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"]',
'b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]',
'c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]',
'd: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c]',
'e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d]',
'f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e]',
'g: [*f,*f,*f,*f,*f,*f,*f,*f,*f]',
].join('\n');
const doc = `---\n${bomb}\n---\n\nbody\n`;
const parsed = extractFrontmatter(doc);
// Assertions are on the RESULT SHAPE (zero keys, bounded serialized size), never on
// wall-clock elapsed time — this repo forbids elapsed-time assertions in tests.
assert.equal(Object.keys(parsed).length, 0);
assert.equal(parsed[FRONTMATTER_UNPARSEABLE], true);
const serializedSize = Buffer.byteLength(JSON.stringify(parsed), 'utf8');
assert.ok(
serializedSize < 1024,
`a refused parse must stay tiny (would be ~22.8MB if expanded); got ${serializedSize} bytes`
);
});
test('the same billion-laughs bomb, quoted-key-spelled, is ALSO refused (#3881 review, finding 1)', () => {
// The exact bypass the review found: the pre-fix raw-text regex matched only bare
// (unquoted) keys, so this 303-byte quoted-key spelling of the identical bomb went
// straight through unrefused and expanded to ~35.8MB. Pinned here on the RESULT shape.
const bomb = [
'"a": &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"]',
'"b": &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]',
'"c": &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]',
'"d": &d [*c,*c,*c,*c,*c,*c,*c,*c,*c]',
'"e": &e [*d,*d,*d,*d,*d,*d,*d,*d,*d]',
'"f": &f [*e,*e,*e,*e,*e,*e,*e,*e,*e]',
'"g": [*f,*f,*f,*f,*f,*f,*f,*f,*f]',
].join('\n');
const doc = `---\n${bomb}\n---\n\nbody\n`;
const parsed = extractFrontmatter(doc);
assert.equal(Object.keys(parsed).length, 0);
assert.equal(parsed[FRONTMATTER_UNPARSEABLE], true);
const serializedSize = Buffer.byteLength(JSON.stringify(parsed), 'utf8');
assert.ok(
serializedSize < 1024,
`a refused parse must stay tiny (would be ~35.8MB if expanded); got ${serializedSize} bytes`
);
});
});
// A9: fix #3881/#3881-followup-2 (regression pinned by tests/smart-entry.unit.test.cjs:867,
// tests/smart-entry.property.test.cjs). `repairAmbiguousColonValues`'s only real dependent is
// hand-edited STATE.md content that never lives in this repo's own tracked `*.md` files — a
// tracked-document sweep will always show zero dependents for this function, which is exactly
// the wrong signal to delete it on (see `loadWithAmbiguousColonRepair`'s docblock in
// src/frontmatter.cts). This row pins the dependency at the frontmatter layer itself, so the
// next document sweep sees it here too, not only three modules away in smart-entry.
describe('A9 ambiguousColonRepairSurvivesHandEditedStateMd (#2571/#2570)', () => {
test('a colon-separated date+description value parses to the full string after the first colon', () => {
const doc = '---\nlast_activity: 2026-06-08: reviewed the PR queue\n---\n\nbody\n';
const parsed = extractFrontmatter(doc);
assert.equal(
parsed.last_activity,
'2026-06-08: reviewed the PR queue',
'the ambiguous colon must be repaired rather than the whole region going unparseable'
);
});
});
describe('finding 3: null-byte sentinel round-trip is injective', () => {
const E000 = String.fromCharCode(0xE000);
test('a real NUL is preserved exactly when no pre-existing U+E000 is present', () => {
const doc = '---\nfoo: "hasnull"\n---\n\nbody\n';
const parsed = extractFrontmatter(doc);
assert.equal(parsed.foo, 'hasnull');
});
test('a document containing a literal U+E000 (the sentinel itself) is refused, not silently corrupted', () => {
// Before the fix, restoreNullBytesDeep rewrote EVERY U+E000 in the parsed tree back to
// U+0000 unconditionally — including one the document author legitimately wrote — so this
// document's own U+E000 silently became a NUL. It must now be refused instead.
const doc = `---\nfoo: "pre${E000}existing"\n---\n\nbody\n`;
const parsed = extractFrontmatter(doc);
assert.equal(Object.keys(parsed).length, 0);
assert.equal(parsed[FRONTMATTER_UNPARSEABLE], true);
});
test('a literal U+E000 alongside a real NUL is refused rather than merging the two into one byte', () => {
// The exact corruption case from the review: escaping the real NUL to U+E000 makes it
// indistinguishable from the pre-existing U+E000, and restoring converts BOTH back to NUL.
const doc = `---\nfoo: "hasnull"\nbar: "pre${E000}existing"\n---\n\nbody\n`;
const parsed = extractFrontmatter(doc);
assert.equal(Object.keys(parsed).length, 0);
assert.equal(parsed[FRONTMATTER_UNPARSEABLE], true);
});
});
// ─── F. Boundaries ──────────────────────────────────────────────────────────
describe('F1 UNTERMINATED_KEY_THRESHOLD boundary', () => {
function unterminatedRegionWithKeys(n) {
const lines = [];
for (let i = 0; i < n; i++) lines.push(`k${i}: v${i}`);
return `---\n${lines.join('\n')}\n`;
}
test('threshold-1 keys: no diagnostic', () => {
_resetUnusableInputWarningsForTests();
extractFrontmatter(unterminatedRegionWithKeys(UNTERMINATED_KEY_THRESHOLD - 1));
assert.equal(_unusableInputEmissionCountForTests(), 0);
});
test('threshold keys: fires', () => {
_resetUnusableInputWarningsForTests();
extractFrontmatter(unterminatedRegionWithKeys(UNTERMINATED_KEY_THRESHOLD));
assert.equal(_unusableInputEmissionCountForTests(), 1);
});
test('threshold+1 keys: fires', () => {
_resetUnusableInputWarningsForTests();
extractFrontmatter(unterminatedRegionWithKeys(UNTERMINATED_KEY_THRESHOLD + 1));
assert.equal(_unusableInputEmissionCountForTests(), 1);
});
});
describe('F2 alias/nesting refusal bound', () => {
// refuseAnchorsAndAliases (frontmatter.cjs) is a raw-text pre-scan that refuses on ANY
// line carrying an anchor/alias/merge-key marker — there is no numeric count threshold
// in this implementation. The real boundary it exercises is therefore an occurrence
// COUNT: 0 (below the refusal trigger) parses; 1 (the trigger) is refused; 2 (over) stays
// refused, proving the refusal is not a first-occurrence artifact that a second alias
// could slip past.
test('0 anchor/alias lines: parses normally', () => {
const doc = '---\nfoo: bar\nbaz: qux\n---\n\nbody\n';
const parsed = extractFrontmatter(doc);
assert.deepEqual(parsed, { foo: 'bar', baz: 'qux' });
});
test('1 anchor/alias line: refused', () => {
const doc = '---\nfoo: &a bar\nbaz: qux\n---\n\nbody\n';
const parsed = extractFrontmatter(doc);
assert.equal(Object.keys(parsed).length, 0);
assert.equal(parsed[FRONTMATTER_UNPARSEABLE], true);
});
test('2 anchor/alias lines: still refused', () => {
const doc = '---\nfoo: &a bar\nbaz: *a\n---\n\nbody\n';
const parsed = extractFrontmatter(doc);
assert.equal(Object.keys(parsed).length, 0);
assert.equal(parsed[FRONTMATTER_UNPARSEABLE], true);
});
});
describe('F3 frontmatter size boundary', () => {
const FIXTURE_PATH = path.join(__dirname, 'fixtures', 'adversarial', 'frontmatter', 'huge-bounded.md');
test('~30KB fixture (huge-bounded.md) completes with a typed result', () => {
const content = fs.readFileSync(FIXTURE_PATH, 'utf8');
const parsed = extractFrontmatter(content, FIXTURE_PATH);
assert.equal(typeof parsed, 'object');
assert.ok(Array.isArray(parsed.plans));
assert.ok(parsed.plans.length > 0);
});
test('a larger (~640KB) frontmatter block also completes with a typed result', () => {
withTempDir((dir) => {
const lines = ['---', 'phase: "06"', 'plans:'];
// ~640KB of array items — an order of magnitude above the committed ~30KB fixture,
// isolated in a per-test temp file rather than a new committed fixture.
for (let i = 0; i < 40000; i++) {
lines.push(` - item-${String(i).padStart(5, '0')}`);
}
lines.push('---', '', 'Body.', '');
const content = lines.join('\n');
assert.ok(Buffer.byteLength(content, 'utf8') > 500 * 1024, 'fixture must exceed the committed one by an order of magnitude');
const filePath = path.join(dir, 'huge-bounded-larger.md');
fs.writeFileSync(filePath, content, 'utf8');
const readBack = fs.readFileSync(filePath, 'utf8');
const parsed = extractFrontmatter(readBack, filePath);
assert.equal(typeof parsed, 'object');
assert.ok(Array.isArray(parsed.plans));
assert.equal(parsed.plans.length, 40000);
assert.equal(parsed.plans[0], 'item-00000');
assert.equal(parsed.plans[39999], 'item-39999');
});
});
});
// Relocated from tests/frontmatter.test.cjs (mutation-matrix piece 1, #3881 follow-up): the
// mutation shard dropped tests/frontmatter.test.cjs (2932 lines, 3132ms of the shard's 4800ms
// per-run cost, ~96 minutes of the frontmatter shard's 180-minute budget for that one file) to
// stay inside CI's time budget, but that file was the ONLY place two assertion classes lived —
// the anchor-alias-bomb refusal (ADR-3473 §8.1 consequence 6, row A8) and the B1/B2 block-scalar
// assertions. Both are relocated here verbatim (not re-derived) so the mutants they kill stay
// killed after frontmatter.test.cjs leaves the shard's `tests` list. frontmatter.test.cjs itself
// keeps these exact assertions too (not deleted there) — it still runs in the normal (non-mutation)
// suite, so this is a second, mutation-scoped copy, not a move.
describe('anchor-alias-bomb refusal (relocated from tests/frontmatter.test.cjs for mutation-matrix piece 1)', () => {
const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'adversarial', 'frontmatter');
function readFixture(name) {
return fs.readFileSync(path.join(FIXTURE_DIR, name), 'utf8');
}
test('anchor-alias-bomb.md: refused rather than expanded (ADR-3473 §8.1 consequence 6, row A8)', () => {
const parsed = extractFrontmatter(readFixture('anchor-alias-bomb.md'), 'anchor-alias-bomb.md');
assert.equal(Object.keys(parsed).length, 0);
assert.equal(parsed[FRONTMATTER_UNPARSEABLE], true);
assert.ok(Buffer.byteLength(JSON.stringify(parsed), 'utf8') < 1024);
});
test('anchor-alias-bomb-quoted.md: refused identically, even quoted-key-spelled (#3881 review, finding 1)', () => {
const parsed = extractFrontmatter(readFixture('anchor-alias-bomb-quoted.md'), 'anchor-alias-bomb-quoted.md');
assert.equal(Object.keys(parsed).length, 0);
assert.equal(parsed[FRONTMATTER_UNPARSEABLE], true);
assert.ok(Buffer.byteLength(JSON.stringify(parsed), 'utf8') < 1024);
});
});
describe('B1/B2 block-scalar assertions (relocated from tests/frontmatter.test.cjs for mutation-matrix piece 1)', () => {
const ADD_TESTS_PATH = path.join(__dirname, '..', 'commands', 'gsd', 'add-tests.md');
test('B1 blockScalarValueIsNotTheBlockIndicator: argument-instructions is the instruction text, not "|"', () => {
const content = fs.readFileSync(ADD_TESTS_PATH, 'utf8');
const parsed = extractFrontmatter(content, ADD_TESTS_PATH);
const value = parsed['argument-instructions'];
assert.equal(typeof value, 'string');
assert.notEqual(value, '|');
assert.ok(value.length > 1, 'block scalar value must be the multi-line instruction body');
assert.ok(value.includes('Parse the argument as a phase number'), 'block scalar value must retain the source instruction text');
});
test('B2 blockScalarDoesNotInventATopLevelKey: parsing add-tests.md produces no phantom "Example" key', () => {
const content = fs.readFileSync(ADD_TESTS_PATH, 'utf8');
const parsed = extractFrontmatter(content, ADD_TESTS_PATH);
assert.ok(!Object.prototype.hasOwnProperty.call(parsed, 'Example'), 'parser must not scrape a top-level "Example" key out of the block scalar body');
});
});
// Frontmatter mutation-gap closure (#3881 follow-up, CI run 33012034388): the frontmatter
// shard measured 63.03% against its 65 floor. These tests close the gap by constraining real
// behavior in the four highest-value survivor clusters — canonical flattening's no-op guard
// (frontmatterDeepEqual, gates spliceFrontmatter's whole-document identity check), the writer's
// double-quoting decision (scalarNeedsDoubleQuoting), the reader's ambiguous-colon repair
// (repairAmbiguousColonValues), and the null-byte round-trip (escapeNullBytesForParse). Each
// case below is paired with a documented near-miss so a mutant that weakens the real condition
// (not just a syntactically different one) is observably wrong, not merely re-typed.
describe('frontmatterDeepEqual (via spliceFrontmatter no-op detection)', () => {
test('key order is insignificant for objects: a same-value, reordered-keys write is a byte-exact no-op', () => {
const doc = '---\na: 1\nb: 2\n---\n\nbody\n';
assert.equal(spliceFrontmatter(doc, { b: '2', a: '1' }), doc);
});
test('array order IS significant: same elements in a different order is NOT a no-op', () => {
// Near-miss for the .every -> .some mutant: index 0 matches ("x"==="x") but index 1 does
// not ("y" !== "z"). The real .every-based comparison must see this as unequal (regenerate);
// a .some-based mutant would short-circuit true on the matching index-0 element alone and
// wrongly report a no-op.
const doc = '---\ntags:\n - x\n - y\n---\n\nbody\n';
const result = spliceFrontmatter(doc, { tags: ['x', 'z'] });
assert.notEqual(result, doc, 'a value-changed array must not be treated as a no-op write');
});
test('a new array value that differs only in length is NOT a no-op', () => {
const doc = '---\ntags:\n - x\n---\n\nbody\n';
assert.notEqual(spliceFrontmatter(doc, { tags: ['x', 'y'] }), doc);
});
test('two empty arrays of matching (zero) length ARE a no-op', () => {
const doc = '---\ntags: []\n---\n\nbody\n';
assert.equal(spliceFrontmatter(doc, { tags: [] }), doc);
});
test('an array-valued field replaced with a same-text scalar is NOT a no-op (array vs non-array must never compare equal)', () => {
const doc = '---\ntags:\n - x\n---\n\nbody\n';
assert.notEqual(spliceFrontmatter(doc, { tags: 'x' }), doc);
});
test('nested-object key order is insignificant: a same-value, reordered-keys nested object is a no-op', () => {
const doc = '---\nmeta:\n a: "1"\n b: "2"\n---\n\nbody\n';
assert.equal(spliceFrontmatter(doc, { meta: { b: '2', a: '1' } }), doc);
});
test('a nested object with a genuinely different key set is NOT a no-op', () => {
const doc = '---\nmeta:\n a: "1"\n b: "2"\n---\n\nbody\n';
assert.notEqual(spliceFrontmatter(doc, { meta: { a: '1', c: '2' } }), doc);
});
});
describe('scalarNeedsDoubleQuoting (via reconstructFrontmatter double-quoting decisions)', () => {
test('a value with internal (non-leading, non-trailing) whitespace is NOT quoted', () => {
// Near-miss for the /^\s|\s$/ -> /^\s|\s/ mutant (dropped end-anchor): a mutant that tests
// for whitespace ANYWHERE rather than only leading/trailing would wrongly quote this.
assert.equal(reconstructFrontmatter({ key: 'mid dle' }), 'key: mid dle');
});
test('trailing whitespace alone (no leading whitespace) IS quoted', () => {
assert.equal(reconstructFrontmatter({ key: 'trailing ' }), 'key: "trailing "');
});
test('a leading dash followed by a space IS quoted (reads as a YAML list indicator)', () => {
assert.equal(reconstructFrontmatter({ key: '- item' }), 'key: "- item"');
});
test('a leading dash with NO following space is NOT quoted (near-miss control for the above)', () => {
assert.equal(reconstructFrontmatter({ key: '-item' }), 'key: -item');
});
test('a lone UTF-16 surrogate is quoted (bare emission is invalid YAML and would not re-parse)', () => {
const reconstructed = reconstructFrontmatter({ key: '\uD800' });
assert.equal(reconstructed, 'key: "\\uD800"');
});
});
// `repairAmbiguousColonValues` (and its sibling `repairMalformedInlineArrays` +
// `splitLegacyInlineArrayItems`) was deleted (#3881 follow-up): a sweep of every tracked
// `*.md` file with a frontmatter fence (910 files) found ZERO documents whose parse result
// changed with the repair disabled — it was hand-rolled YAML leniency kept alive on a fallback
// path, the exact thing ADR-3473 §8.1 exists to remove. `extractFrontmatter` now surfaces
// `unparseableResult()` (via `FRONTMATTER_UNPARSEABLE`) for the ambiguous-colon shapes this
// block used to pin instead of silently repairing them.
describe('escapeNullBytesForParse (null-byte round-trip through the sentinel swap)', () => {
test('a NUL byte inside a key survives extractFrontmatter byte-for-byte, including at region offset 1', () => {
// Region offset 1 specifically distinguishes the `indexOf(...) === -1` -> `=== +1` mutant:
// for THIS input the mutant's condition is true (index really is 1), so it takes the
// "no substitution needed" branch and hands js-yaml a raw, unescaped NUL — which js-yaml
// rejects outright under every schema, collapsing the whole parse to {}. The same input
// also kills the sentinel StringLiteral "" mutant (deletes the byte instead of escaping it):
// that mutant would parse successfully but produce key "x" instead of "x".
const NUL = '';
const doc = `---\nx${NUL}: y\n---\n\nbody\n`;
const parsed = extractFrontmatter(doc);
assert.ok(
Object.prototype.hasOwnProperty.call(parsed, `x${NUL}`),
`NUL byte must survive as part of the key, not be dropped or crash the parse; got keys ${JSON.stringify(Object.keys(parsed))}`
);
assert.equal(parsed[`x${NUL}`], 'y');
});
});