* 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>
1744 lines
68 KiB
JavaScript
1744 lines
68 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* Unit tests for frontmatter.cjs
|
||
*
|
||
* Module: gsd-core/bin/lib/frontmatter.cjs
|
||
*
|
||
* Covers:
|
||
* - extractFrontmatter: all scalar types, quoted, arrays, nested, edge cases
|
||
* - reconstructFrontmatter: exact output for every branch
|
||
* - spliceFrontmatter: with/without existing frontmatter
|
||
* - parseMustHavesBlock: all branches
|
||
* - FRONTMATTER_SCHEMAS: exact keys
|
||
*/
|
||
|
||
const { describe, test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const yaml = require('js-yaml');
|
||
|
||
const {
|
||
extractFrontmatter,
|
||
reconstructFrontmatter,
|
||
spliceFrontmatter,
|
||
stripFrontmatter,
|
||
noOpObjectListSetError,
|
||
parseMustHavesBlock,
|
||
FRONTMATTER_SCHEMAS,
|
||
agentScalarNeedsDoubleQuoting,
|
||
escapeDoubleQuotedScalar,
|
||
} = require('../gsd-core/bin/lib/frontmatter.cjs');
|
||
|
||
// ─── extractFrontmatter ───────────────────────────────────────────────────────
|
||
|
||
describe('extractFrontmatter: no frontmatter', () => {
|
||
test('plain text returns {}', () => {
|
||
assert.deepEqual(extractFrontmatter('just plain text'), {});
|
||
});
|
||
|
||
test('empty string returns {}', () => {
|
||
assert.deepEqual(extractFrontmatter(''), {});
|
||
});
|
||
|
||
test('--- not at start returns {}', () => {
|
||
assert.deepEqual(extractFrontmatter('content\n---\nkey: val\n---\n'), {});
|
||
});
|
||
|
||
test('--- block without closing delimiter returns {}', () => {
|
||
assert.deepEqual(extractFrontmatter('---\ntitle: Hello\nauthor: World\n'), {});
|
||
});
|
||
|
||
test('only --- returns {}', () => {
|
||
assert.deepEqual(extractFrontmatter('---\n---'), {});
|
||
});
|
||
|
||
test('empty frontmatter block returns {}', () => {
|
||
assert.deepEqual(extractFrontmatter('---\n\n---\nBody'), {});
|
||
});
|
||
|
||
test('heading only returns {}', () => {
|
||
assert.deepEqual(extractFrontmatter('# Just a heading\ncontent'), {});
|
||
});
|
||
});
|
||
|
||
describe('extractFrontmatter: simple scalar values', () => {
|
||
test('single string key-value', () => {
|
||
const result = extractFrontmatter('---\ntitle: Hello\n---\nBody');
|
||
assert.deepEqual(result, { title: 'Hello' });
|
||
});
|
||
|
||
test('multiple string key-values', () => {
|
||
const result = extractFrontmatter('---\ntitle: Hello\nauthor: World\n---\n');
|
||
assert.deepEqual(result, { title: 'Hello', author: 'World' });
|
||
});
|
||
|
||
test('numeric string value preserved as string', () => {
|
||
const result = extractFrontmatter('---\ncount: 42\n---');
|
||
assert.deepEqual(result, { count: '42' });
|
||
assert.equal(result.count, '42');
|
||
});
|
||
|
||
test('boolean string value preserved as string', () => {
|
||
const result = extractFrontmatter('---\nflag: true\n---');
|
||
assert.deepEqual(result, { flag: 'true' });
|
||
assert.equal(result.flag, 'true');
|
||
});
|
||
|
||
test('null string value preserved as string', () => {
|
||
const result = extractFrontmatter('---\nnone: null\n---');
|
||
assert.deepEqual(result, { none: 'null' });
|
||
assert.equal(result.none, 'null');
|
||
});
|
||
|
||
test('false string value preserved as string', () => {
|
||
const result = extractFrontmatter('---\ndone: false\n---');
|
||
assert.deepEqual(result, { done: 'false' });
|
||
});
|
||
|
||
test('value with internal spaces preserved', () => {
|
||
const result = extractFrontmatter('---\nphase: phase one\n---');
|
||
assert.deepEqual(result, { phase: 'phase one' });
|
||
});
|
||
|
||
test('trailing whitespace in value is trimmed', () => {
|
||
const result = extractFrontmatter('---\ntitle: Hello \n---');
|
||
assert.deepEqual(result, { title: 'Hello' });
|
||
});
|
||
|
||
test('key with underscore', () => {
|
||
const result = extractFrontmatter('---\nmy_key: val\n---');
|
||
assert.deepEqual(result, { my_key: 'val' });
|
||
});
|
||
|
||
test('key with hyphen', () => {
|
||
const result = extractFrontmatter('---\nmy-key: val\n---');
|
||
assert.deepEqual(result, { 'my-key': 'val' });
|
||
});
|
||
|
||
test('key with digits', () => {
|
||
const result = extractFrontmatter('---\nkey123: val\n---');
|
||
assert.deepEqual(result, { key123: 'val' });
|
||
});
|
||
|
||
test('empty line in frontmatter is skipped', () => {
|
||
const result = extractFrontmatter('---\nkey1: val1\n\nkey2: val2\n---');
|
||
assert.deepEqual(result, { key1: 'val1', key2: 'val2' });
|
||
});
|
||
|
||
test('body content after closing delimiter is ignored', () => {
|
||
const result = extractFrontmatter('---\ntitle: Hello\n---\n# Heading\nContent here');
|
||
assert.deepEqual(result, { title: 'Hello' });
|
||
assert.equal(Object.keys(result).length, 1);
|
||
});
|
||
});
|
||
|
||
describe('extractFrontmatter: quoted values', () => {
|
||
test('double-quoted value strips quotes', () => {
|
||
const result = extractFrontmatter('---\ntitle: "Hello World"\n---');
|
||
assert.deepEqual(result, { title: 'Hello World' });
|
||
});
|
||
|
||
test('single-quoted value strips quotes', () => {
|
||
const result = extractFrontmatter("---\ntitle: 'Hello World'\n---");
|
||
assert.deepEqual(result, { title: 'Hello World' });
|
||
});
|
||
|
||
test('double-quoted value containing colon', () => {
|
||
const result = extractFrontmatter('---\nurl: "http://example.com"\n---');
|
||
assert.deepEqual(result, { url: 'http://example.com' });
|
||
});
|
||
|
||
test('unquoted value with no special chars', () => {
|
||
const result = extractFrontmatter('---\nname: simple\n---');
|
||
assert.deepEqual(result, { name: 'simple' });
|
||
assert.equal(result.name, 'simple');
|
||
});
|
||
});
|
||
|
||
describe('extractFrontmatter: CRLF line endings', () => {
|
||
test('CRLF frontmatter parses correctly', () => {
|
||
const result = extractFrontmatter('---\r\ntitle: Hello\r\nauthor: World\r\n---\r\nBody');
|
||
assert.deepEqual(result, { title: 'Hello', author: 'World' });
|
||
});
|
||
|
||
test('CRLF with array values', () => {
|
||
const result = extractFrontmatter('---\r\ntags: [a, b, c]\r\n---\r\n');
|
||
assert.deepEqual(result, { tags: ['a', 'b', 'c'] });
|
||
});
|
||
});
|
||
|
||
describe('extractFrontmatter: inline arrays', () => {
|
||
test('empty inline array []', () => {
|
||
const result = extractFrontmatter('---\ntags: []\n---');
|
||
assert.deepEqual(result, { tags: [] });
|
||
assert.ok(Array.isArray(result.tags));
|
||
assert.equal(result.tags.length, 0);
|
||
});
|
||
|
||
test('single item inline array', () => {
|
||
const result = extractFrontmatter('---\ntags: [only]\n---');
|
||
assert.deepEqual(result, { tags: ['only'] });
|
||
assert.equal(result.tags.length, 1);
|
||
});
|
||
|
||
test('two item inline array', () => {
|
||
const result = extractFrontmatter('---\ntags: [a, b]\n---');
|
||
assert.deepEqual(result, { tags: ['a', 'b'] });
|
||
});
|
||
|
||
test('three item inline array', () => {
|
||
const result = extractFrontmatter('---\ntags: [a, b, c]\n---');
|
||
assert.deepEqual(result, { tags: ['a', 'b', 'c'] });
|
||
});
|
||
|
||
test('inline array with spaces around items', () => {
|
||
const result = extractFrontmatter('---\ntags: [ a , b , c ]\n---');
|
||
assert.deepEqual(result, { tags: ['a', 'b', 'c'] });
|
||
});
|
||
|
||
test('inline array with double-quoted item containing comma', () => {
|
||
const result = extractFrontmatter('---\ntags: ["a, b", c]\n---');
|
||
assert.deepEqual(result, { tags: ['a, b', 'c'] });
|
||
});
|
||
|
||
test('inline array with single-quoted item containing comma', () => {
|
||
const result = extractFrontmatter("---\ntags: ['a, b', c]\n---");
|
||
assert.deepEqual(result, { tags: ['a, b', 'c'] });
|
||
});
|
||
|
||
test('inline array with quoted item plus more items', () => {
|
||
const result = extractFrontmatter('---\ntags: ["a, b", c, d]\n---');
|
||
assert.deepEqual(result, { tags: ['a, b', 'c', 'd'] });
|
||
});
|
||
|
||
// `repairMalformedInlineArrays` (and its `splitLegacyInlineArrayItems` helper), which these
|
||
// three cases pinned, 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. A real YAML flow sequence has none of this leniency — `[a,,b]` is an empty
|
||
// flow-sequence-entry syntax error, `[ , ]` is the same, and an unclosed `[` is an unterminated
|
||
// collection — so `extractFrontmatter` now correctly reports these as unparseable
|
||
// (`FRONTMATTER_UNPARSEABLE`) instead of silently repairing them.
|
||
});
|
||
|
||
describe('extractFrontmatter: dashed list arrays', () => {
|
||
test('two-item dashed list', () => {
|
||
const result = extractFrontmatter('---\ntags:\n - a\n - b\n---');
|
||
assert.deepEqual(result, { tags: ['a', 'b'] });
|
||
assert.ok(Array.isArray(result.tags));
|
||
});
|
||
|
||
test('single-item dashed list', () => {
|
||
const result = extractFrontmatter('---\ntags:\n - solo\n---');
|
||
assert.deepEqual(result, { tags: ['solo'] });
|
||
});
|
||
|
||
test('dashed list with double-quoted item', () => {
|
||
const result = extractFrontmatter('---\ntags:\n - "quoted value"\n---');
|
||
assert.deepEqual(result, { tags: ['quoted value'] });
|
||
});
|
||
|
||
test('dashed list with single-quoted item', () => {
|
||
const result = extractFrontmatter("---\ntags:\n - 'single quoted'\n---");
|
||
assert.deepEqual(result, { tags: ['single quoted'] });
|
||
});
|
||
|
||
// `repairMalformedInlineArrays`'s "bare unclosed `key: [` followed by a block-sequence"
|
||
// recovery, which this case pinned, was deleted alongside the rest of that function (#3881
|
||
// follow-up; see the note above `extractFrontmatter: inline arrays`) — zero tracked documents
|
||
// depended on it. A literal `[` with no closing bracket is an unterminated YAML flow
|
||
// collection; `extractFrontmatter` now reports it as unparseable rather than silently
|
||
// reinterpreting it as a block-sequence opener.
|
||
});
|
||
|
||
describe('extractFrontmatter: empty / missing values', () => {
|
||
test('empty value becomes empty object {}', () => {
|
||
const result = extractFrontmatter('---\ntitle:\n---');
|
||
assert.deepEqual(result, { title: {} });
|
||
assert.equal(typeof result.title, 'object');
|
||
assert.ok(!Array.isArray(result.title));
|
||
});
|
||
|
||
test('empty value followed by next key', () => {
|
||
const result = extractFrontmatter('---\ntitle:\nother: val\n---');
|
||
assert.equal(typeof result.title, 'object');
|
||
assert.equal(result.other, 'val');
|
||
});
|
||
});
|
||
|
||
describe('extractFrontmatter: nested objects', () => {
|
||
test('one level of nesting', () => {
|
||
const result = extractFrontmatter('---\nmeta:\n key: val\n count: 10\n---');
|
||
assert.deepEqual(result, { meta: { key: 'val', count: '10' } });
|
||
});
|
||
|
||
test('nested then back to top level', () => {
|
||
const result = extractFrontmatter('---\nmeta:\n sub: val\ntop: parent\n---');
|
||
assert.deepEqual(result, { meta: { sub: 'val' }, top: 'parent' });
|
||
});
|
||
|
||
test('multiple nested objects', () => {
|
||
const result = extractFrontmatter('---\na:\n k1: v1\nb:\n k2: v2\n---');
|
||
assert.deepEqual(result, { a: { k1: 'v1' }, b: { k2: 'v2' } });
|
||
});
|
||
|
||
test('two levels of nesting', () => {
|
||
const result = extractFrontmatter('---\ntop:\n mid:\n deep: value\n---');
|
||
assert.deepEqual(result, { top: { mid: { deep: 'value' } } });
|
||
});
|
||
|
||
test('nested numeric-string value', () => {
|
||
const result = extractFrontmatter('---\nmeta:\n count: 42\n---');
|
||
assert.deepEqual(result, { meta: { count: '42' } });
|
||
});
|
||
});
|
||
|
||
describe('extractFrontmatter: return type invariants', () => {
|
||
test('always returns plain object', () => {
|
||
const result = extractFrontmatter('random content');
|
||
assert.equal(typeof result, 'object');
|
||
assert.ok(result !== null);
|
||
assert.ok(!Array.isArray(result));
|
||
});
|
||
|
||
test('return value is not null', () => {
|
||
const result = extractFrontmatter('');
|
||
assert.ok(result !== null);
|
||
});
|
||
|
||
test('top-level dash item (no parent key) is ignored', () => {
|
||
const result = extractFrontmatter('---\n- item\n---');
|
||
assert.deepEqual(result, {});
|
||
});
|
||
|
||
test('key starting with digit still matches key pattern', () => {
|
||
const result = extractFrontmatter('---\n123key: val\n---');
|
||
assert.equal(result['123key'], 'val');
|
||
});
|
||
});
|
||
|
||
// ─── reconstructFrontmatter ───────────────────────────────────────────────────
|
||
|
||
describe('reconstructFrontmatter: empty input', () => {
|
||
test('empty object returns empty string', () => {
|
||
assert.equal(reconstructFrontmatter({}), '');
|
||
});
|
||
});
|
||
|
||
describe('reconstructFrontmatter: scalar values', () => {
|
||
test('simple string value', () => {
|
||
assert.equal(reconstructFrontmatter({ title: 'Hello' }), 'title: Hello');
|
||
});
|
||
|
||
test('numeric string value', () => {
|
||
assert.equal(reconstructFrontmatter({ count: '42' }), 'count: 42');
|
||
});
|
||
|
||
test('boolean string value', () => {
|
||
assert.equal(reconstructFrontmatter({ flag: 'true' }), 'flag: true');
|
||
});
|
||
|
||
test('null value is skipped', () => {
|
||
assert.equal(reconstructFrontmatter({ title: null }), '');
|
||
});
|
||
|
||
test('undefined value is skipped', () => {
|
||
assert.equal(reconstructFrontmatter({ title: undefined }), '');
|
||
});
|
||
|
||
test('value containing colon is double-quoted', () => {
|
||
assert.equal(reconstructFrontmatter({ url: 'http://example.com' }), 'url: "http://example.com"');
|
||
});
|
||
|
||
test('value containing hash is double-quoted', () => {
|
||
assert.equal(reconstructFrontmatter({ name: 'test#1' }), 'name: "test#1"');
|
||
});
|
||
|
||
test('value starting with [ is double-quoted', () => {
|
||
assert.equal(reconstructFrontmatter({ val: '[thing]' }), 'val: "[thing]"');
|
||
});
|
||
|
||
test('value starting with { is double-quoted', () => {
|
||
assert.equal(reconstructFrontmatter({ val: '{thing}' }), 'val: "{thing}"');
|
||
});
|
||
|
||
test('plain value without special chars is unquoted', () => {
|
||
assert.equal(reconstructFrontmatter({ name: 'simple' }), 'name: simple');
|
||
});
|
||
|
||
test('multiple keys produce newline-joined output', () => {
|
||
assert.equal(
|
||
reconstructFrontmatter({ title: 'Hello', author: 'World' }),
|
||
'title: Hello\nauthor: World'
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('reconstructFrontmatter: arrays', () => {
|
||
test('empty array produces key: []', () => {
|
||
assert.equal(reconstructFrontmatter({ tags: [] }), 'tags: []');
|
||
});
|
||
|
||
test('two-item short array uses inline format', () => {
|
||
assert.equal(reconstructFrontmatter({ tags: ['a', 'b'] }), 'tags: [a, b]');
|
||
});
|
||
|
||
test('three-item short array uses inline format', () => {
|
||
assert.equal(reconstructFrontmatter({ tags: ['a', 'b', 'c'] }), 'tags: [a, b, c]');
|
||
});
|
||
|
||
test('three items whose join is exactly < 60 chars uses inline format', () => {
|
||
const tags = ['aaa', 'bbb', 'ccc'];
|
||
// 'aaa, bbb, ccc' = 13 chars
|
||
assert.equal(reconstructFrontmatter({ tags }), 'tags: [aaa, bbb, ccc]');
|
||
});
|
||
|
||
test('three items whose join >= 60 chars uses block format', () => {
|
||
const tags = ['aaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbb', 'cccccccccccccccccccc'];
|
||
// join is 61+ chars
|
||
assert.equal(
|
||
reconstructFrontmatter({ tags }),
|
||
'tags:\n - aaaaaaaaaaaaaaaaaaa\n - bbbbbbbbbbbbbbbbbbb\n - cccccccccccccccccccc'
|
||
);
|
||
});
|
||
|
||
test('four-item array uses block format', () => {
|
||
assert.equal(
|
||
reconstructFrontmatter({ tags: ['a', 'b', 'c', 'd'] }),
|
||
'tags:\n - a\n - b\n - c\n - d'
|
||
);
|
||
});
|
||
|
||
test('array item with colon is double-quoted in block format', () => {
|
||
// Need >3 items to force block format where quoting applies
|
||
const result = reconstructFrontmatter({ tags: ['a:b', 'c', 'd', 'e'] });
|
||
assert.ok(result.includes(' - "a:b"'), `Expected quoted item, got: ${result}`);
|
||
});
|
||
|
||
test('array item with hash is double-quoted in block format', () => {
|
||
// Need >3 items to force block format where quoting applies
|
||
const result = reconstructFrontmatter({ tags: ['a#b', 'c', 'd', 'e'] });
|
||
assert.ok(result.includes(' - "a#b"'), `Expected quoted hash item, got: ${result}`);
|
||
});
|
||
|
||
test('two-item array uses inline regardless of special chars', () => {
|
||
// Note: inline format for <=3 items uses join without quoting
|
||
assert.equal(reconstructFrontmatter({ tags: ['a:b', 'c'] }), 'tags: [a:b, c]');
|
||
});
|
||
|
||
test('array item without colon or hash is unquoted in block format', () => {
|
||
const result = reconstructFrontmatter({ tags: ['plain', 'also', 'here', 'fourth'] });
|
||
assert.equal(result, 'tags:\n - plain\n - also\n - here\n - fourth');
|
||
});
|
||
});
|
||
|
||
describe('reconstructFrontmatter: nested objects', () => {
|
||
test('simple nested object', () => {
|
||
assert.equal(
|
||
reconstructFrontmatter({ meta: { key: 'val', num: '42' } }),
|
||
'meta:\n key: val\n num: 42'
|
||
);
|
||
});
|
||
|
||
test('nested null subvalue is skipped', () => {
|
||
assert.equal(reconstructFrontmatter({ meta: { key: null } }), 'meta:');
|
||
});
|
||
|
||
test('nested undefined subvalue is skipped', () => {
|
||
assert.equal(reconstructFrontmatter({ meta: { key: undefined } }), 'meta:');
|
||
});
|
||
|
||
test('nested subvalue with colon is double-quoted', () => {
|
||
assert.equal(reconstructFrontmatter({ meta: { url: 'http://x' } }), 'meta:\n url: "http://x"');
|
||
});
|
||
|
||
test('nested subvalue with hash is double-quoted', () => {
|
||
assert.equal(reconstructFrontmatter({ meta: { name: 'x#y' } }), 'meta:\n name: "x#y"');
|
||
});
|
||
|
||
test('nested empty sub-array', () => {
|
||
assert.equal(reconstructFrontmatter({ meta: { items: [] } }), 'meta:\n items: []');
|
||
});
|
||
|
||
test('nested two-item short sub-array uses inline format', () => {
|
||
assert.equal(reconstructFrontmatter({ meta: { items: ['a', 'b'] } }), 'meta:\n items: [a, b]');
|
||
});
|
||
|
||
test('nested four-item sub-array uses block format', () => {
|
||
assert.equal(
|
||
reconstructFrontmatter({ meta: { items: ['a', 'b', 'c', 'd'] } }),
|
||
'meta:\n items:\n - a\n - b\n - c\n - d'
|
||
);
|
||
});
|
||
|
||
test('nested three-item short sub-array uses inline', () => {
|
||
assert.equal(
|
||
reconstructFrontmatter({ meta: { items: ['a', 'b', 'c'] } }),
|
||
'meta:\n items: [a, b, c]'
|
||
);
|
||
});
|
||
|
||
test('nested three-item long sub-array uses block', () => {
|
||
const items = ['aaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbb', 'cccccccccccccccccccc'];
|
||
assert.equal(
|
||
reconstructFrontmatter({ meta: { items } }),
|
||
'meta:\n items:\n - aaaaaaaaaaaaaaaaaaa\n - bbbbbbbbbbbbbbbbbbb\n - cccccccccccccccccccc'
|
||
);
|
||
});
|
||
|
||
test('nested nested object (3 levels)', () => {
|
||
assert.equal(
|
||
reconstructFrontmatter({ top: { mid: { deep: 'value' } } }),
|
||
'top:\n mid:\n deep: value'
|
||
);
|
||
});
|
||
|
||
test('deeply nested null subvalue skipped', () => {
|
||
assert.equal(
|
||
reconstructFrontmatter({ top: { mid: { key: null } } }),
|
||
'top:\n mid:'
|
||
);
|
||
});
|
||
|
||
test('deeply nested empty array', () => {
|
||
assert.equal(
|
||
reconstructFrontmatter({ top: { mid: { items: [] } } }),
|
||
'top:\n mid:\n items: []'
|
||
);
|
||
});
|
||
|
||
test('deeply nested array with items', () => {
|
||
assert.equal(
|
||
reconstructFrontmatter({ top: { mid: { items: ['a', 'b'] } } }),
|
||
'top:\n mid:\n items:\n - a\n - b'
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── spliceFrontmatter ────────────────────────────────────────────────────────
|
||
|
||
describe('spliceFrontmatter: no existing frontmatter', () => {
|
||
test('prepends frontmatter to plain body', () => {
|
||
assert.equal(
|
||
spliceFrontmatter('body text', { title: 'Test' }),
|
||
'---\ntitle: Test\n---\n\nbody text'
|
||
);
|
||
});
|
||
|
||
test('prepends frontmatter to empty string', () => {
|
||
assert.equal(
|
||
spliceFrontmatter('', { title: 'Test' }),
|
||
'---\ntitle: Test\n---\n\n'
|
||
);
|
||
});
|
||
|
||
test('prepends frontmatter with empty object', () => {
|
||
assert.equal(
|
||
spliceFrontmatter('body text', {}),
|
||
'---\n\n---\n\nbody text'
|
||
);
|
||
});
|
||
|
||
test('prepends multi-key frontmatter', () => {
|
||
const result = spliceFrontmatter('# Body', { title: 'T', author: 'A' });
|
||
assert.equal(result, '---\ntitle: T\nauthor: A\n---\n\n# Body');
|
||
});
|
||
});
|
||
|
||
describe('spliceFrontmatter: existing frontmatter', () => {
|
||
test('replaces existing frontmatter, preserves body', () => {
|
||
const input = '---\ntitle: Old\n---\n\nBody here';
|
||
assert.equal(
|
||
spliceFrontmatter(input, { title: 'New' }),
|
||
'---\ntitle: New\n---\n\nBody here'
|
||
);
|
||
});
|
||
|
||
test('replaces existing multi-key frontmatter', () => {
|
||
const input = '---\ntitle: Old\ncount: 5\n---\n\nBody text here';
|
||
assert.equal(
|
||
spliceFrontmatter(input, { title: 'New', count: '5' }),
|
||
'---\ntitle: New\ncount: 5\n---\n\nBody text here'
|
||
);
|
||
});
|
||
|
||
test('CRLF existing frontmatter: body CRLF preserved', () => {
|
||
const input = '---\r\ntitle: Old\r\n---\r\nBody';
|
||
const result = spliceFrontmatter(input, { title: 'New' });
|
||
assert.equal(result, '---\ntitle: New\n---\r\nBody');
|
||
});
|
||
|
||
test('new frontmatter uses LF even if original was CRLF', () => {
|
||
const input = '---\r\ntitle: Old\r\n---\r\nBody';
|
||
const result = spliceFrontmatter(input, { title: 'New' });
|
||
assert.ok(result.startsWith('---\ntitle: New\n---'));
|
||
});
|
||
|
||
test('return type is always string', () => {
|
||
const result = spliceFrontmatter('hello', { k: 'v' });
|
||
assert.equal(typeof result, 'string');
|
||
});
|
||
});
|
||
|
||
// ─── parseMustHavesBlock ──────────────────────────────────────────────────────
|
||
|
||
describe('parseMustHavesBlock: no frontmatter / no block', () => {
|
||
test('no frontmatter returns []', () => {
|
||
assert.deepEqual(parseMustHavesBlock('just content', 'artifacts'), []);
|
||
});
|
||
|
||
test('empty string returns []', () => {
|
||
assert.deepEqual(parseMustHavesBlock('', 'artifacts'), []);
|
||
});
|
||
|
||
test('frontmatter without must_haves returns []', () => {
|
||
const doc = '---\ntitle: Hello\n---\nbody';
|
||
assert.deepEqual(parseMustHavesBlock(doc, 'artifacts'), []);
|
||
});
|
||
|
||
test('must_haves present but requested block absent returns []', () => {
|
||
const doc = '---\nmust_haves:\n other:\n - val\n---';
|
||
assert.deepEqual(parseMustHavesBlock(doc, 'artifacts'), []);
|
||
});
|
||
|
||
test('block at same indent as must_haves is rejected', () => {
|
||
const doc = '---\nmust_haves:\nartifacts:\n - val\n---';
|
||
assert.deepEqual(parseMustHavesBlock(doc, 'artifacts'), []);
|
||
});
|
||
});
|
||
|
||
describe('parseMustHavesBlock: string items', () => {
|
||
test('two plain string items', () => {
|
||
const doc = '---\nmust_haves:\n truths:\n - simple string\n - another string\n---';
|
||
assert.deepEqual(parseMustHavesBlock(doc, 'truths'), ['simple string', 'another string']);
|
||
});
|
||
|
||
test('single string item', () => {
|
||
const doc = '---\nmust_haves:\n truths:\n - only one\n---';
|
||
assert.deepEqual(parseMustHavesBlock(doc, 'truths'), ['only one']);
|
||
});
|
||
|
||
test('double-quoted string items strip quotes', () => {
|
||
const doc = '---\nmust_haves:\n truths:\n - "contains: colon"\n - "another: one"\n---';
|
||
const result = parseMustHavesBlock(doc, 'truths');
|
||
assert.deepEqual(result, ['contains: colon', 'another: one']);
|
||
});
|
||
|
||
test('single-quoted string items strip quotes', () => {
|
||
const doc = "---\nmust_haves:\n truths:\n - 'single quoted'\n---";
|
||
assert.deepEqual(parseMustHavesBlock(doc, 'truths'), ['single quoted']);
|
||
});
|
||
|
||
test('item without colon treated as plain string', () => {
|
||
const doc = '---\nmust_haves:\n truths:\n - plain text here\n---';
|
||
assert.deepEqual(parseMustHavesBlock(doc, 'truths'), ['plain text here']);
|
||
});
|
||
|
||
test('item with colon but no space (Class::Method) is plain string', () => {
|
||
const doc = '---\nmust_haves:\n truths:\n - Class::Method is used\n---';
|
||
assert.deepEqual(parseMustHavesBlock(doc, 'truths'), ['Class::Method is used']);
|
||
});
|
||
|
||
test('item with db:seed (no space after colon) is plain string', () => {
|
||
const doc = '---\nmust_haves:\n truths:\n - db:seed task should run\n---';
|
||
assert.deepEqual(parseMustHavesBlock(doc, 'truths'), ['db:seed task should run']);
|
||
});
|
||
});
|
||
|
||
describe('parseMustHavesBlock: key-value object items', () => {
|
||
test('simple kv item on dash line', () => {
|
||
const doc = '---\nmust_haves:\n artifacts:\n - path: file.ts\n---';
|
||
assert.deepEqual(parseMustHavesBlock(doc, 'artifacts'), [{ path: 'file.ts' }]);
|
||
});
|
||
|
||
test('two kv items', () => {
|
||
const doc = '---\nmust_haves:\n artifacts:\n - path: file.ts\n - path: other.ts\n---';
|
||
assert.deepEqual(parseMustHavesBlock(doc, 'artifacts'), [{ path: 'file.ts' }, { path: 'other.ts' }]);
|
||
});
|
||
|
||
test('kv item with continuation keys', () => {
|
||
const doc = [
|
||
'---',
|
||
'must_haves:',
|
||
' artifacts:',
|
||
' - path: file.ts',
|
||
' provides: something',
|
||
'---',
|
||
].join('\n');
|
||
assert.deepEqual(parseMustHavesBlock(doc, 'artifacts'), [{ path: 'file.ts', provides: 'something' }]);
|
||
});
|
||
|
||
test('kv item with multiple continuation keys', () => {
|
||
const doc = [
|
||
'---',
|
||
'must_haves:',
|
||
' artifacts:',
|
||
' - path: file.ts',
|
||
' provides: exports X',
|
||
' confidence: 90',
|
||
'---',
|
||
].join('\n');
|
||
const result = parseMustHavesBlock(doc, 'artifacts');
|
||
assert.equal(result.length, 1);
|
||
assert.deepEqual(result[0], { path: 'file.ts', provides: 'exports X', confidence: 90 });
|
||
});
|
||
|
||
test('numeric value in continuation key is parsed as integer', () => {
|
||
const doc = [
|
||
'---',
|
||
'must_haves:',
|
||
' artifacts:',
|
||
' - path: file.ts',
|
||
' line: 42',
|
||
'---',
|
||
].join('\n');
|
||
const result = parseMustHavesBlock(doc, 'artifacts');
|
||
assert.equal(result[0].line, 42);
|
||
assert.equal(typeof result[0].line, 'number');
|
||
});
|
||
|
||
test('non-numeric continuation value stays string', () => {
|
||
const doc = [
|
||
'---',
|
||
'must_haves:',
|
||
' artifacts:',
|
||
' - path: file.ts',
|
||
' provides: some text',
|
||
'---',
|
||
].join('\n');
|
||
const result = parseMustHavesBlock(doc, 'artifacts');
|
||
assert.equal(typeof result[0].provides, 'string');
|
||
assert.equal(result[0].provides, 'some text');
|
||
});
|
||
|
||
test('two full kv items with continuations', () => {
|
||
const doc = [
|
||
'---',
|
||
'must_haves:',
|
||
' artifacts:',
|
||
' - path: file.ts',
|
||
' provides: something',
|
||
' - path: other.ts',
|
||
' provides: other',
|
||
'---',
|
||
].join('\n');
|
||
assert.deepEqual(parseMustHavesBlock(doc, 'artifacts'), [
|
||
{ path: 'file.ts', provides: 'something' },
|
||
{ path: 'other.ts', provides: 'other' },
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe('parseMustHavesBlock: nested arrays in items', () => {
|
||
test('item with array continuation', () => {
|
||
const doc = [
|
||
'---',
|
||
'must_haves:',
|
||
' artifacts:',
|
||
' - path: file.ts',
|
||
' tags:',
|
||
' - tag1',
|
||
' - tag2',
|
||
'---',
|
||
].join('\n');
|
||
const result = parseMustHavesBlock(doc, 'artifacts');
|
||
assert.equal(result.length, 1);
|
||
assert.deepEqual(result[0].tags, ['tag1', 'tag2']);
|
||
});
|
||
|
||
test('item with three array elements in continuation', () => {
|
||
const doc = [
|
||
'---',
|
||
'must_haves:',
|
||
' artifacts:',
|
||
' - path: file.ts',
|
||
' tags:',
|
||
' - tag1',
|
||
' - tag2',
|
||
' - tag3',
|
||
'---',
|
||
].join('\n');
|
||
const result = parseMustHavesBlock(doc, 'artifacts');
|
||
assert.deepEqual(result[0].tags, ['tag1', 'tag2', 'tag3']);
|
||
});
|
||
|
||
test('two items where first has array continuation', () => {
|
||
const doc = [
|
||
'---',
|
||
'must_haves:',
|
||
' artifacts:',
|
||
' - path: file.ts',
|
||
' tags:',
|
||
' - tag1',
|
||
' - path: other.ts',
|
||
'---',
|
||
].join('\n');
|
||
const result = parseMustHavesBlock(doc, 'artifacts');
|
||
assert.equal(result.length, 2);
|
||
assert.deepEqual(result[0].tags, ['tag1']);
|
||
assert.equal(result[1].path, 'other.ts');
|
||
});
|
||
});
|
||
|
||
describe('parseMustHavesBlock: return type', () => {
|
||
test('always returns an array', () => {
|
||
const result = parseMustHavesBlock('no content', 'anything');
|
||
assert.ok(Array.isArray(result));
|
||
});
|
||
|
||
test('empty content returns array', () => {
|
||
const result = parseMustHavesBlock('', 'anything');
|
||
assert.ok(Array.isArray(result));
|
||
assert.equal(result.length, 0);
|
||
});
|
||
});
|
||
|
||
// ─── FRONTMATTER_SCHEMAS ──────────────────────────────────────────────────────
|
||
|
||
describe('FRONTMATTER_SCHEMAS', () => {
|
||
test('plan schema has required field', () => {
|
||
assert.ok('required' in FRONTMATTER_SCHEMAS.plan);
|
||
});
|
||
|
||
test('plan schema has exactly 8 required fields', () => {
|
||
assert.equal(FRONTMATTER_SCHEMAS.plan.required.length, 8);
|
||
});
|
||
|
||
test('plan schema required fields are exact', () => {
|
||
assert.deepEqual(FRONTMATTER_SCHEMAS.plan.required, [
|
||
'phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves',
|
||
]);
|
||
});
|
||
|
||
test('summary schema has exactly 6 required fields', () => {
|
||
assert.equal(FRONTMATTER_SCHEMAS.summary.required.length, 6);
|
||
});
|
||
|
||
test('summary schema required fields are exact', () => {
|
||
assert.deepEqual(FRONTMATTER_SCHEMAS.summary.required, [
|
||
'phase', 'plan', 'subsystem', 'tags', 'duration', 'completed',
|
||
]);
|
||
});
|
||
|
||
test('verification schema has exactly 4 required fields', () => {
|
||
assert.equal(FRONTMATTER_SCHEMAS.verification.required.length, 4);
|
||
});
|
||
|
||
test('verification schema required fields are exact', () => {
|
||
assert.deepEqual(FRONTMATTER_SCHEMAS.verification.required, [
|
||
'phase', 'verified', 'status', 'score',
|
||
]);
|
||
});
|
||
|
||
test('four schemas exist: plan, plan-gap-closure, summary, verification (#2847)', () => {
|
||
assert.deepEqual(
|
||
Object.keys(FRONTMATTER_SCHEMAS).sort(),
|
||
['plan', 'plan-gap-closure', 'summary', 'verification']
|
||
);
|
||
});
|
||
|
||
test('plan includes phase field', () => {
|
||
assert.ok(FRONTMATTER_SCHEMAS.plan.required.includes('phase'));
|
||
});
|
||
|
||
test('plan includes must_haves field', () => {
|
||
assert.ok(FRONTMATTER_SCHEMAS.plan.required.includes('must_haves'));
|
||
});
|
||
|
||
test('summary includes completed field', () => {
|
||
assert.ok(FRONTMATTER_SCHEMAS.summary.required.includes('completed'));
|
||
});
|
||
|
||
test('verification includes score field', () => {
|
||
assert.ok(FRONTMATTER_SCHEMAS.verification.required.includes('score'));
|
||
});
|
||
|
||
test('plan does not include score field', () => {
|
||
assert.ok(!FRONTMATTER_SCHEMAS.plan.required.includes('score'));
|
||
});
|
||
|
||
test('verification does not include completed field', () => {
|
||
assert.ok(!FRONTMATTER_SCHEMAS.verification.required.includes('completed'));
|
||
});
|
||
});
|
||
|
||
// ─── FRONTMATTER_SCHEMAS['plan-gap-closure'] (#2847) ──────────────────────────
|
||
//
|
||
// #2847: --gaps does not load planner-gap-closure.md's requirement into the
|
||
// only machine-checked gate — gap-closure plans could pass validation
|
||
// without `gap_closure: true`, so a subsequent `--gaps-only` execute run
|
||
// silently matched zero plans. Fix: a dedicated schema that requires every
|
||
// 'plan' field plus `gap_closure`, kept separate so standard/reviews-mode
|
||
// plans (validated against 'plan', asserted unchanged above) are unaffected.
|
||
|
||
describe("FRONTMATTER_SCHEMAS['plan-gap-closure'] (#2847)", () => {
|
||
// #2847 review: "has required field", "has exactly 9 required fields",
|
||
// "includes gap_closure field", and "is a superset of every plan-required
|
||
// field" were deleted here. Each was strictly subsumed by the deepEqual
|
||
// exact-list test below (a 9-element array that deepEquals a literal
|
||
// containing 'gap_closure' trivially has 9 elements, a 'required' key,
|
||
// and includes 'gap_closure' — none of those checks could ever fail
|
||
// independently of the deepEqual one). The "superset" test was additionally
|
||
// tautological on its own: plan-gap-closure.required is LITERALLY
|
||
// `[...PLAN_REQUIRED_FIELDS, 'gap_closure']` (src/frontmatter.cts), so it
|
||
// cannot fail while that spread exists, regardless of what the deepEqual
|
||
// test above catches.
|
||
test('plan-gap-closure schema required fields are exact', () => {
|
||
assert.deepEqual(FRONTMATTER_SCHEMAS['plan-gap-closure'].required, [
|
||
'phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves', 'gap_closure',
|
||
]);
|
||
});
|
||
|
||
test('plan schema (standard/reviews mode) does NOT include gap_closure — unaffected by #2847 fix', () => {
|
||
assert.ok(!FRONTMATTER_SCHEMAS.plan.required.includes('gap_closure'));
|
||
});
|
||
|
||
// requiredValues (#2847 review finding): presence alone let `gap_closure: false`
|
||
// validate as valid:true — --gaps-only filters strictly on gap_closure === true,
|
||
// so that was a live reproduction of #2847's reported symptom, one value away.
|
||
test('plan-gap-closure requires the value "true" for gap_closure, not mere presence', () => {
|
||
assert.ok('requiredValues' in FRONTMATTER_SCHEMAS['plan-gap-closure']);
|
||
assert.equal(FRONTMATTER_SCHEMAS['plan-gap-closure'].requiredValues.gap_closure, 'true');
|
||
});
|
||
|
||
test('plan schema has no requiredValues — every field is presence-only, unaffected by #2847 fix', () => {
|
||
assert.equal(FRONTMATTER_SCHEMAS.plan.requiredValues, undefined);
|
||
});
|
||
|
||
test('summary and verification schemas have no requiredValues — presence-only, unaffected', () => {
|
||
assert.equal(FRONTMATTER_SCHEMAS.summary.requiredValues, undefined);
|
||
assert.equal(FRONTMATTER_SCHEMAS.verification.requiredValues, undefined);
|
||
});
|
||
});
|
||
|
||
// ─── Tight branch / boundary tests ───────────────────────────────────────────
|
||
|
||
describe('reconstructFrontmatter: array length boundary (<=3 vs >3)', () => {
|
||
test('exactly 3 items: uses inline format', () => {
|
||
const result = reconstructFrontmatter({ x: ['a', 'b', 'c'] });
|
||
assert.equal(result, 'x: [a, b, c]');
|
||
assert.ok(!result.includes('\n - '));
|
||
});
|
||
|
||
test('exactly 4 items: uses block format', () => {
|
||
const result = reconstructFrontmatter({ x: ['a', 'b', 'c', 'd'] });
|
||
assert.ok(result.includes(' - a'));
|
||
assert.ok(result.includes(' - b'));
|
||
assert.ok(result.includes(' - c'));
|
||
assert.ok(result.includes(' - d'));
|
||
});
|
||
|
||
test('exactly 1 item: uses inline format', () => {
|
||
const result = reconstructFrontmatter({ x: ['only'] });
|
||
assert.equal(result, 'x: [only]');
|
||
});
|
||
});
|
||
|
||
describe('reconstructFrontmatter: array join length boundary (< 60)', () => {
|
||
test('3 items joining to exactly 59 chars uses inline', () => {
|
||
// 59 chars: 'aaaaaaaaaaaaaaaaaaa, bbbbbbbbbbbbbbbbbbb, ccccccccccccccccccc' = 60 chars, need 59
|
||
const a = 'aaaaaaaaaaaaaaaaaa'; // 18
|
||
const b = 'bbbbbbbbbbbbbbbbbb'; // 18
|
||
const c = 'ccccccccccccccccccc'; // 19 => join = 18+18+19 + 4 (', ', ', ') = 18+2+18+2+19 = 59
|
||
const joined = [a, b, c].join(', ');
|
||
assert.equal(joined.length, 59);
|
||
const result = reconstructFrontmatter({ x: [a, b, c] });
|
||
assert.equal(result, `x: [${joined}]`);
|
||
});
|
||
|
||
test('3 items joining to exactly 60 chars uses block', () => {
|
||
// 'x' repeated: 19, 19, 18 = 56 + 4 = 60
|
||
const x = 'aaaaaaaaaaaaaaaaaaa'; // 19
|
||
const y = 'bbbbbbbbbbbbbbbbbbb'; // 19
|
||
const z = 'cccccccccccccccccc'; // 18 => 19+2+19+2+18 = 60
|
||
const joined2 = [x, y, z].join(', ');
|
||
assert.equal(joined2.length, 60);
|
||
const result2 = reconstructFrontmatter({ x: [x, y, z] });
|
||
// 60 is NOT < 60, so should use block format
|
||
assert.ok(result2.startsWith('x:\n - '), `Expected block format, got: ${result2}`);
|
||
});
|
||
});
|
||
|
||
describe('reconstructFrontmatter: subarray length boundary', () => {
|
||
test('nested 3 items short uses inline', () => {
|
||
const result = reconstructFrontmatter({ meta: { x: ['a', 'b', 'c'] } });
|
||
assert.equal(result, 'meta:\n x: [a, b, c]');
|
||
});
|
||
|
||
test('nested 4 items uses block', () => {
|
||
const result = reconstructFrontmatter({ meta: { x: ['a', 'b', 'c', 'd'] } });
|
||
assert.equal(result, 'meta:\n x:\n - a\n - b\n - c\n - d');
|
||
});
|
||
});
|
||
|
||
describe('spliceFrontmatter: exact delimiter handling', () => {
|
||
test('output always starts with ---', () => {
|
||
const result = spliceFrontmatter('', { k: 'v' });
|
||
assert.ok(result.startsWith('---\n'));
|
||
});
|
||
|
||
test('existing frontmatter: output uses LF delimiters', () => {
|
||
const input = '---\ntitle: Old\n---\nbody';
|
||
const result = spliceFrontmatter(input, { title: 'New' });
|
||
assert.ok(result.startsWith('---\ntitle: New\n---'));
|
||
});
|
||
|
||
test('no existing frontmatter: body follows after double newline', () => {
|
||
const result = spliceFrontmatter('body', { title: 'T' });
|
||
assert.equal(result, '---\ntitle: T\n---\n\nbody');
|
||
});
|
||
|
||
test('existing frontmatter: body immediately follows closing ---', () => {
|
||
const input = '---\ntitle: T\n---\nbody line';
|
||
const result = spliceFrontmatter(input, { k: 'v' });
|
||
assert.equal(result, '---\nk: v\n---\nbody line');
|
||
});
|
||
});
|
||
|
||
// spliceFrontmatter per-key identity preservation + fail-closed (#1572). These exercise
|
||
// sliceTopLevelFrontmatterSegments, the per-key deepEqual/preserve/regenerate/drop/append
|
||
// loop, and regenerateFrontmatterKey's "[object Object]" fail-closed directly.
|
||
describe('spliceFrontmatter: per-key preservation + fail-closed (#1572)', () => {
|
||
const PLAN = [
|
||
'---', 'phase: 1', 'wave: 1',
|
||
'must_haves:', ' artifacts:', ' - path: src/foo.ts', ' provides: the foo',
|
||
'---', '# body', '',
|
||
].join('\n');
|
||
|
||
test('unchanged object-list key keeps its original raw text (provides survives) when a scalar sibling changes', () => {
|
||
const parsed = extractFrontmatter(PLAN);
|
||
parsed.wave = '2'; // mutate one scalar; must_haves flattened-projection unchanged
|
||
const out = spliceFrontmatter(PLAN, parsed);
|
||
// must_haves.artifacts raw preserved verbatim (provides intact) — NOT regenerated.
|
||
assert.deepEqual(parseMustHavesBlock(out, 'artifacts'), [{ path: 'src/foo.ts', provides: 'the foo' }]);
|
||
// the changed scalar WAS regenerated.
|
||
assert.ok(/^wave: 2$/m.test(out), 'changed scalar wave must be regenerated to 2');
|
||
// original ordering preserved (phase before wave before must_haves).
|
||
const phaseIdx = out.indexOf('phase:');
|
||
const waveIdx = out.indexOf('wave:');
|
||
const mhIdx = out.indexOf('must_haves:');
|
||
assert.ok(phaseIdx < waveIdx && waveIdx < mhIdx, 'top-level key order preserved');
|
||
});
|
||
|
||
test('a changed scalar regenerates only that key (no other key touched)', () => {
|
||
const out = spliceFrontmatter(PLAN, { ...extractFrontmatter(PLAN), phase: '9' });
|
||
assert.ok(/^phase: 9$/m.test(out));
|
||
// wave unchanged → still 1
|
||
assert.ok(/^wave: 1$/m.test(out));
|
||
});
|
||
|
||
test('keys absent from newObj are dropped (key set is defined by newObj)', () => {
|
||
const out = spliceFrontmatter(PLAN, { phase: '1' });
|
||
assert.ok(/^phase: 1$/m.test(out));
|
||
assert.ok(!/wave:/.test(out), 'wave (absent from newObj) must be dropped');
|
||
assert.ok(!/must_haves:/.test(out), 'must_haves (absent from newObj) must be dropped');
|
||
});
|
||
|
||
test('genuinely-new keys (not in original) are appended', () => {
|
||
const out = spliceFrontmatter(PLAN, { ...extractFrontmatter(PLAN), brand_new: 'x' });
|
||
assert.ok(/^brand_new: x$/m.test(out), 'new key appended');
|
||
// existing keys still present
|
||
assert.ok(/^phase: 1$/m.test(out));
|
||
});
|
||
|
||
test('a nested indented block stays attached to its parent key (segment slicer respects indentation)', () => {
|
||
const multi = '---\na: 1\nmust_haves:\n artifacts:\n - path: x\n provides: y\nb: 2\n---\n';
|
||
const out = spliceFrontmatter(multi, { ...extractFrontmatter(multi), b: '3' });
|
||
// The indented artifacts block must be preserved as part of must_haves (not split off),
|
||
// and b regenerated. proves the slicer grouped the nested lines under must_haves.
|
||
assert.deepEqual(parseMustHavesBlock(out, 'artifacts'), [{ path: 'x', provides: 'y' }]);
|
||
assert.ok(/^b: 3$/m.test(out));
|
||
assert.ok(/^a: 1$/m.test(out));
|
||
});
|
||
|
||
test('whole-document no-op returns the input verbatim', () => {
|
||
const out = spliceFrontmatter(PLAN, extractFrontmatter(PLAN));
|
||
assert.equal(out, PLAN);
|
||
});
|
||
|
||
test('changing must_haves to an unrepresentable object-list fails closed (throws, no [object Object])', () => {
|
||
const newObj = { ...extractFrontmatter(PLAN), must_haves: { artifacts: [{ path: 'p', provides: 'q' }] } };
|
||
assert.throws(
|
||
() => spliceFrontmatter(PLAN, newObj),
|
||
/cannot faithfully serialize key "must_haves"/,
|
||
'a changed object-list key must fail closed rather than emit [object Object]',
|
||
);
|
||
});
|
||
|
||
test('no-frontmatter path also fails closed for an unrepresentable object-list value', () => {
|
||
assert.throws(
|
||
() => spliceFrontmatter('body only', { must_haves: { artifacts: [{ path: 'p' }] } }),
|
||
/cannot faithfully serialize the requested frontmatter/,
|
||
'generating fresh frontmatter with an object-list must fail closed',
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('extractFrontmatter: complex real-world documents', () => {
|
||
test('plan document', () => {
|
||
const doc = [
|
||
'---',
|
||
'phase: 1',
|
||
'plan: my-plan',
|
||
'type: feature',
|
||
'wave: 1',
|
||
'depends_on: []',
|
||
'files_modified: []',
|
||
'autonomous: true',
|
||
'must_haves:',
|
||
' artifacts:',
|
||
' - path: src/foo.ts',
|
||
' provides: foo',
|
||
'---',
|
||
'# Plan body',
|
||
].join('\n');
|
||
const result = extractFrontmatter(doc);
|
||
assert.equal(result.phase, '1');
|
||
assert.equal(result.plan, 'my-plan');
|
||
assert.equal(result.type, 'feature');
|
||
assert.equal(result.wave, '1');
|
||
assert.ok(Array.isArray(result.depends_on));
|
||
assert.equal(result.depends_on.length, 0);
|
||
assert.ok(Array.isArray(result.files_modified));
|
||
assert.equal(result.autonomous, 'true');
|
||
});
|
||
|
||
test('summary document', () => {
|
||
const doc = [
|
||
'---',
|
||
'phase: 2',
|
||
'plan: my-plan',
|
||
'subsystem: auth',
|
||
'tags: [security, backend]',
|
||
'duration: 120',
|
||
'completed: true',
|
||
'---',
|
||
].join('\n');
|
||
const result = extractFrontmatter(doc);
|
||
assert.equal(result.phase, '2');
|
||
assert.equal(result.subsystem, 'auth');
|
||
assert.deepEqual(result.tags, ['security', 'backend']);
|
||
assert.equal(result['duration'], '120');
|
||
assert.equal(result.completed, 'true');
|
||
});
|
||
|
||
test('verification document', () => {
|
||
const doc = [
|
||
'---',
|
||
'phase: 3',
|
||
'verified: true',
|
||
'status: pass',
|
||
'score: 95',
|
||
'---',
|
||
].join('\n');
|
||
const result = extractFrontmatter(doc);
|
||
assert.equal(result.verified, 'true');
|
||
assert.equal(result.status, 'pass');
|
||
assert.equal(result.score, '95');
|
||
});
|
||
});
|
||
|
||
describe('reconstructFrontmatter: round-trip', () => {
|
||
test('simple key-value round-trip', () => {
|
||
const original = { title: 'Hello', author: 'World' };
|
||
const reconstructed = reconstructFrontmatter(original);
|
||
const doc = `---\n${reconstructed}\n---\n`;
|
||
const parsed = extractFrontmatter(doc);
|
||
assert.equal(parsed.title, 'Hello');
|
||
assert.equal(parsed.author, 'World');
|
||
});
|
||
|
||
test('value with colon round-trips through quoting', () => {
|
||
const original = { url: 'http://example.com' };
|
||
const reconstructed = reconstructFrontmatter(original);
|
||
assert.equal(reconstructed, 'url: "http://example.com"');
|
||
const doc = `---\n${reconstructed}\n---\n`;
|
||
const parsed = extractFrontmatter(doc);
|
||
assert.equal(parsed.url, 'http://example.com');
|
||
});
|
||
|
||
test('array round-trip (inline)', () => {
|
||
const original = { tags: ['a', 'b', 'c'] };
|
||
const reconstructed = reconstructFrontmatter(original);
|
||
const doc = `---\n${reconstructed}\n---\n`;
|
||
const parsed = extractFrontmatter(doc);
|
||
assert.deepEqual(parsed.tags, ['a', 'b', 'c']);
|
||
});
|
||
|
||
test('empty array round-trip', () => {
|
||
const original = { tags: [] };
|
||
const reconstructed = reconstructFrontmatter(original);
|
||
assert.equal(reconstructed, 'tags: []');
|
||
const doc = `---\n${reconstructed}\n---\n`;
|
||
const parsed = extractFrontmatter(doc);
|
||
assert.ok(Array.isArray(parsed.tags));
|
||
assert.equal(parsed.tags.length, 0);
|
||
});
|
||
});
|
||
|
||
// #1779 — reconstructFrontmatter must emit valid YAML for scalars and block-
|
||
// array items that carry an embedded `"`/`\`, a control char, an empty string,
|
||
// a leading YAML indicator, or surrounding whitespace. The project's own lossy
|
||
// `extractFrontmatter` tolerates the broken output, which is exactly why these
|
||
// assert round-trip through a STRICT parser (js-yaml) instead — the strict path
|
||
// fails on the unescaped/bare emission and passes only once every wrap site
|
||
// escapes and every unsafe-bare value is routed through the quoted form.
|
||
describe('reconstructFrontmatter: strict-YAML round-trip (#1779)', () => {
|
||
// Serialize via the production path, then load with a strict YAML parser.
|
||
const strictRoundTrip = (obj) => yaml.load(reconstructFrontmatter(obj));
|
||
|
||
test('top-level scalar with indicator + embedded quotes (the reported case)', () => {
|
||
const obj = { upstream: 'https://x (Tom; "Git. Ship. Done")' };
|
||
assert.deepEqual(strictRoundTrip(obj), obj);
|
||
});
|
||
|
||
test('nested scalar with indicator + embedded quotes', () => {
|
||
const obj = { meta: { note: 'see: "the docs"' } };
|
||
assert.deepEqual(strictRoundTrip(obj), obj);
|
||
});
|
||
|
||
test('top-level block-array item with indicator + embedded quotes', () => {
|
||
// 4 items forces block form (the inline `[a, b]` branch caps at 3).
|
||
const obj = { tags: ['a: "1"', 'b: "2"', 'c: "3"', 'd: "4"'] };
|
||
assert.deepEqual(strictRoundTrip(obj), obj);
|
||
});
|
||
|
||
test('nested block-array item with indicator + embedded quotes', () => {
|
||
const obj = { meta: { tags: ['a: "1"', 'b: "2"', 'c: "3"', 'd: "4"'] } };
|
||
assert.deepEqual(strictRoundTrip(obj), obj);
|
||
});
|
||
|
||
test('embedded backslash is escaped (not just quotes)', () => {
|
||
const obj = { path: 'a:\\b\\c "x"' };
|
||
assert.deepEqual(strictRoundTrip(obj), obj);
|
||
});
|
||
|
||
test('control chars in a wrapped value are escaped (newline, tab, NUL)', () => {
|
||
const obj = { note: 'line1: a\nline2\twith\x00nul' };
|
||
assert.deepEqual(strictRoundTrip(obj), obj);
|
||
});
|
||
|
||
test('CRLF round-trips (locks the \\r escape for Windows-authored values)', () => {
|
||
const obj = { note: 'line1: a\r\nline2' };
|
||
assert.deepEqual(strictRoundTrip(obj), obj);
|
||
});
|
||
|
||
test('empty string round-trips as "" (bare `k:` would reload as null)', () => {
|
||
assert.deepEqual(strictRoundTrip({ k: '' }), { k: '' });
|
||
});
|
||
|
||
test('embedded/leading quote with NO indicator still round-trips', () => {
|
||
assert.deepEqual(strictRoundTrip({ k: '"x' }), { k: '"x' });
|
||
assert.deepEqual(strictRoundTrip({ k: "'leading single" }), { k: "'leading single" });
|
||
assert.deepEqual(strictRoundTrip({ k: 'say "hi" there' }), { k: 'say "hi" there' });
|
||
});
|
||
|
||
test('leading YAML indicators with no `:`/`#` still round-trip', () => {
|
||
for (const v of ['>x', '|x', '&anchor', '*alias', '!tag', '[flow', '{map', '%pct', '@reserved', '`tick']) {
|
||
assert.deepEqual(strictRoundTrip({ k: v }), { k: v }, `value ${JSON.stringify(v)}`);
|
||
}
|
||
});
|
||
|
||
test('leading/trailing whitespace is preserved (bare would be trimmed)', () => {
|
||
assert.deepEqual(strictRoundTrip({ k: ' leading' }), { k: ' leading' });
|
||
assert.deepEqual(strictRoundTrip({ k: 'trailing ' }), { k: 'trailing ' });
|
||
});
|
||
|
||
test('plain values without indicators are unaffected (no spurious quoting)', () => {
|
||
const obj = { name: 'simple-value', label: 'plain' };
|
||
assert.equal(reconstructFrontmatter(obj), 'name: simple-value\nlabel: plain');
|
||
assert.deepEqual(yaml.load(reconstructFrontmatter(obj)), obj);
|
||
});
|
||
});
|
||
|
||
// #3497 — escape amplification. `escapeDoubleQuotedScalar` escapes `\`/`"`/control
|
||
// chars on every serialize (#1779), but `parseGuardedYamlRegion` only stripped the
|
||
// outer quote delimiters and never un-escaped the interior, so parse ∘ serialize
|
||
// was NOT the identity: every read-modify-write cycle doubled the backslashes
|
||
// (b → 2b+1, i.e. 2ⁿ−1 after n round-trips). A `last_activity_desc` containing
|
||
// one embedded quote grew STATE.md to 134 MB in 26 state writes and OOMed
|
||
// state.record-session. These tests pin the lossy-parser round trip (the actual
|
||
// write seam: extractFrontmatter → reconstructFrontmatter/spliceFrontmatter),
|
||
// not the strict-YAML path — the amplification lived in the project's own
|
||
// parse/serialize pair.
|
||
describe('frontmatter round-trip: escape amplification (#3497)', () => {
|
||
// One full document round trip through the production write seam:
|
||
// parse the frontmatter out of the document, re-serialize it back in.
|
||
const roundTripDoc = (content) => {
|
||
const fm = extractFrontmatter(content);
|
||
return `---\n${reconstructFrontmatter(fm)}\n---\nbody\n`;
|
||
};
|
||
|
||
test('single round trip is byte-exact for embedded double quotes', () => {
|
||
const original = 'Fixed "the bug" in parser';
|
||
const fm = extractFrontmatter(`---\ndesc: "Fixed \\"the bug\\" in parser"\n---\nbody\n`);
|
||
assert.equal(fm.desc, original);
|
||
assert.equal(reconstructFrontmatter({ desc: original }), 'desc: "Fixed \\"the bug\\" in parser"');
|
||
});
|
||
|
||
test('single round trip is byte-exact for embedded backslash', () => {
|
||
const original = 'path a:\\b\\c plus "quote"';
|
||
const fm = extractFrontmatter(`---\ndesc: "path a:\\\\b\\\\c plus \\"quote\\""\n---\nbody\n`);
|
||
assert.equal(fm.desc, original);
|
||
assert.equal(extractFrontmatter(`---\n${reconstructFrontmatter({ desc: original })}\n---\n`).desc, original);
|
||
});
|
||
|
||
test('single round trip is byte-exact for newline/tab/CR/control chars', () => {
|
||
const original = 'l1\nl2\ttab\rCR\x00NUL\x7fDEL';
|
||
const serialized = reconstructFrontmatter({ desc: original });
|
||
assert.equal(extractFrontmatter(`---\n${serialized}\n---\n`).desc, original);
|
||
});
|
||
|
||
test('repeated serialize→parse cycles do not grow (26 cycles, the reported OOM window)', () => {
|
||
let content = '---\ndesc: "he said \\"hi\\" and \\"bye\\""\n---\nbody\n';
|
||
const firstPass = roundTripDoc(content);
|
||
let current = firstPass;
|
||
for (let i = 0; i < 26; i++) {
|
||
current = roundTripDoc(current);
|
||
// After the first cycle the document must be a fixed point: byte-identical
|
||
// forever. Before the fix, backslashes followed b → 2b+1 and unbounded
|
||
// cycling hit a 134 M-char line within 26 passes (asserting per cycle,
|
||
// rather than only after the loop, so the buggy failure is a small clear
|
||
// diff on cycle 1 instead of a runner OOM).
|
||
assert.equal(current, firstPass, `cycle ${i + 1} changed the document`);
|
||
}
|
||
assert.equal(extractFrontmatter(current).desc, 'he said "hi" and "bye"');
|
||
});
|
||
|
||
test('block array items with quotes/backslashes round-trip and stay stable', () => {
|
||
// 4 items force the block `- "..."` form (inline caps at 3), which has its
|
||
// own escape call site and its own quote-strip on parse.
|
||
const original = ['a: "1"', 'b:\\path "x"', 'c: "3"', 'd: "4"'];
|
||
const serialized = reconstructFrontmatter({ tags: original });
|
||
assert.deepEqual(extractFrontmatter(`---\n${serialized}\n---\n`).tags, original);
|
||
const reparsed = extractFrontmatter(`---\n${serialized}\n---\n`);
|
||
assert.equal(reconstructFrontmatter(reparsed), serialized);
|
||
});
|
||
|
||
test('nested object subvalue with quotes/backslashes round-trips and stays stable', () => {
|
||
const original = { meta: { note: 'see: "the \\\\docs\\\\"' } };
|
||
const serialized = reconstructFrontmatter(original);
|
||
assert.deepEqual(extractFrontmatter(`---\n${serialized}\n---\n`).meta, original.meta);
|
||
const reparsed = extractFrontmatter(`---\n${serialized}\n---\n`);
|
||
assert.equal(reconstructFrontmatter(reparsed), serialized);
|
||
});
|
||
|
||
test('spliceFrontmatter write path is a fixed point under repeated no-op writes', () => {
|
||
// The state.cjs seam: read file → merge → spliceFrontmatter → write.
|
||
// Repeating it must not change bytes once the first write lands.
|
||
let content = `---\ndesc: "he said \\"hi\\""\nphase: executing\n---\nbody\n`;
|
||
content = spliceFrontmatter(content, { ...extractFrontmatter(content), phase: 'executing' });
|
||
const firstWrite = content;
|
||
for (let i = 0; i < 10; i++) {
|
||
content = spliceFrontmatter(content, { ...extractFrontmatter(content), phase: 'executing' });
|
||
}
|
||
assert.equal(content, firstWrite);
|
||
assert.equal(extractFrontmatter(content).desc, 'he said "hi"');
|
||
});
|
||
|
||
test('plain scalars that never need quoting still round-trip unquoted and unchanged', () => {
|
||
const obj = { name: 'simple-value', wave: 'W1', count: '42' };
|
||
const serialized = reconstructFrontmatter(obj);
|
||
assert.equal(serialized, 'name: simple-value\nwave: W1\ncount: 42');
|
||
assert.deepEqual(extractFrontmatter(`---\n${serialized}\n---\n`), obj);
|
||
});
|
||
|
||
test('single-quoted scalars keep their existing parse behavior (quotes stripped, no escape processing)', () => {
|
||
// The writer never emits single-quoted output; hand-authored files keep
|
||
// the historical strip-only behavior. A single-quoted scalar has no
|
||
// escape processing in YAML (only '' → '), and changing that is out of
|
||
// scope for #3497 — this pins the current contract so the unescape fix
|
||
// cannot silently broaden into single-quote handling.
|
||
const fm = extractFrontmatter("---\ntitle: 'C:\\real\\path'\n---");
|
||
assert.equal(fm.title, 'C:\\real\\path');
|
||
});
|
||
});
|
||
|
||
describe('extractFrontmatter: boundary — dash at start of file', () => {
|
||
test('--- at byte 0 is treated as frontmatter', () => {
|
||
const result = extractFrontmatter('---\nkey: val\n---\n');
|
||
assert.deepEqual(result, { key: 'val' });
|
||
});
|
||
|
||
test('content before --- means no frontmatter', () => {
|
||
const result = extractFrontmatter(' ---\nkey: val\n---\n');
|
||
assert.deepEqual(result, {});
|
||
});
|
||
|
||
test('newline before --- means no frontmatter', () => {
|
||
const result = extractFrontmatter('\n---\nkey: val\n---\n');
|
||
assert.deepEqual(result, {});
|
||
});
|
||
});
|
||
|
||
describe('parseMustHavesBlock: item accumulation', () => {
|
||
test('last item pushed after loop ends', () => {
|
||
const doc = '---\nmust_haves:\n truths:\n - only item\n---';
|
||
const result = parseMustHavesBlock(doc, 'truths');
|
||
assert.equal(result.length, 1);
|
||
assert.equal(result[0], 'only item');
|
||
});
|
||
|
||
test('items are pushed in order', () => {
|
||
const doc = '---\nmust_haves:\n truths:\n - first\n - second\n - third\n---';
|
||
const result = parseMustHavesBlock(doc, 'truths');
|
||
assert.equal(result[0], 'first');
|
||
assert.equal(result[1], 'second');
|
||
assert.equal(result[2], 'third');
|
||
});
|
||
|
||
test('three items total count', () => {
|
||
const doc = '---\nmust_haves:\n truths:\n - a\n - b\n - c\n---';
|
||
assert.equal(parseMustHavesBlock(doc, 'truths').length, 3);
|
||
});
|
||
});
|
||
|
||
describe('parseMustHavesBlock: indent stopping logic', () => {
|
||
test('items after block ends at same/lower indent are not included', () => {
|
||
const doc = [
|
||
'---',
|
||
'must_haves:',
|
||
' truths:',
|
||
' - item one',
|
||
'other_key: val',
|
||
'---',
|
||
].join('\n');
|
||
const result = parseMustHavesBlock(doc, 'truths');
|
||
assert.equal(result.length, 1);
|
||
assert.equal(result[0], 'item one');
|
||
});
|
||
});
|
||
|
||
describe('reconstructFrontmatter: deeply nested subsubval null/undefined', () => {
|
||
test('3rd level null subsubval skipped', () => {
|
||
const result = reconstructFrontmatter({ top: { mid: { key: null } } });
|
||
assert.equal(result, 'top:\n mid:');
|
||
});
|
||
});
|
||
|
||
describe('reconstructFrontmatter: nested subval plain string', () => {
|
||
test('nested subval without special chars unquoted', () => {
|
||
const result = reconstructFrontmatter({ meta: { name: 'plain' } });
|
||
assert.equal(result, 'meta:\n name: plain');
|
||
});
|
||
|
||
test('nested subval with colon quoted', () => {
|
||
const result = reconstructFrontmatter({ meta: { ref: 'type: value' } });
|
||
assert.equal(result, 'meta:\n ref: "type: value"');
|
||
});
|
||
|
||
test('nested subval with hash quoted', () => {
|
||
const result = reconstructFrontmatter({ meta: { tag: 'issue#42' } });
|
||
assert.equal(result, 'meta:\n tag: "issue#42"');
|
||
});
|
||
});
|
||
|
||
// noOpObjectListSetError (#1660) — pure detection helper, unit-tested directly because the
|
||
// cmdFrontmatterSet path is not in Stryker's property/unit set.
|
||
describe('noOpObjectListSetError (#1660)', () => {
|
||
const ORIG = '---\nphase: 1\n---\n';
|
||
test('changed content (real update) → null', () => {
|
||
assert.equal(noOpObjectListSetError(ORIG, ORIG + 'x', { must_haves: 1 }), null);
|
||
});
|
||
test('scalar value no-op → null (idempotent scalar sets are fine)', () => {
|
||
for (const v of [1, 'str', true, 0, '']) assert.equal(noOpObjectListSetError(ORIG, ORIG, v), null, `scalar ${JSON.stringify(v)}`);
|
||
});
|
||
test('scalar-array value no-op → null (scalar arrays round-trip faithfully)', () => {
|
||
assert.equal(noOpObjectListSetError(ORIG, ORIG, ['a', 'b']), null);
|
||
assert.equal(noOpObjectListSetError(ORIG, ORIG, []), null);
|
||
});
|
||
test('null value no-op → null', () => {
|
||
assert.equal(noOpObjectListSetError(ORIG, ORIG, null), null);
|
||
});
|
||
test('dict value no-op → error message naming the object-list round-trip limit', () => {
|
||
const msg = noOpObjectListSetError(ORIG, ORIG, { artifacts: [{ path: 'p' }] });
|
||
assert.equal(typeof msg, 'string');
|
||
assert.ok(msg.includes('had no effect'), msg);
|
||
assert.ok(msg.includes('object-list'), msg);
|
||
assert.ok(msg.includes('Edit the file directly'), msg);
|
||
});
|
||
});
|
||
|
||
// ─── stripFrontmatter ─────────────────────────────────────────────────────────
|
||
|
||
describe('stripFrontmatter', () => {
|
||
const stacked = ['---', 'a: 1', '---', '---', 'b: 2', '---', '', 'Real body.'].join('\n');
|
||
|
||
test('strips a single block', () => {
|
||
assert.strictEqual(stripFrontmatter(['---', 'a: 1', '---', '', 'Body.'].join('\n')), 'Body.');
|
||
});
|
||
|
||
test('is CRLF-tolerant', () => {
|
||
const crlf = ['---', 'a: 1', '---', '', 'Body.'].join('\r\n');
|
||
assert.strictEqual(stripFrontmatter(crlf), 'Body.');
|
||
});
|
||
|
||
test('defaults to stripping every stacked block (corruption recovery)', () => {
|
||
assert.strictEqual(stripFrontmatter(stacked), 'Real body.');
|
||
});
|
||
|
||
test('an omitted options argument keeps the greedy default', () => {
|
||
// Back-compat: state.cts and state-transition.cts call this with one arg.
|
||
assert.strictEqual(stripFrontmatter(stacked, {}), 'Real body.');
|
||
});
|
||
|
||
test('once: true stops after the first block', () => {
|
||
assert.strictEqual(
|
||
stripFrontmatter(stacked, { once: true }),
|
||
['---', 'b: 2', '---', '', 'Real body.'].join('\n'),
|
||
);
|
||
});
|
||
|
||
test('once: false is the greedy default', () => {
|
||
assert.strictEqual(stripFrontmatter(stacked, { once: false }), 'Real body.');
|
||
});
|
||
|
||
test('returns content unchanged when there is no frontmatter', () => {
|
||
const plain = ['Just prose.', '', 'More prose.'].join('\n');
|
||
assert.strictEqual(stripFrontmatter(plain), plain);
|
||
assert.strictEqual(stripFrontmatter(plain, { once: true }), plain);
|
||
});
|
||
|
||
test('leaves an unterminated block alone under both modes', () => {
|
||
const unterminated = ['---', 'a: 1', 'b: 2'].join('\n');
|
||
assert.strictEqual(stripFrontmatter(unterminated), unterminated);
|
||
assert.strictEqual(stripFrontmatter(unterminated, { once: true }), unterminated);
|
||
});
|
||
|
||
test('empty string round-trips', () => {
|
||
assert.strictEqual(stripFrontmatter(''), '');
|
||
});
|
||
});
|
||
|
||
// ─── agentScalarNeedsDoubleQuoting (#3706) ────────────────────────────────────
|
||
//
|
||
// Mutant-killing discipline: every boolean clause gets a true case AND a
|
||
// near-miss false case one edit away, so flipping any operator/character
|
||
// class in the source changes at least one assertion here.
|
||
|
||
describe('agentScalarNeedsDoubleQuoting: delegates to scalarNeedsDoubleQuoting', () => {
|
||
test('empty string needs quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting(''), true);
|
||
});
|
||
test('embedded double quote needs quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('a"b'), true);
|
||
});
|
||
test('embedded backslash needs quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('a\\b'), true);
|
||
});
|
||
test('embedded control char needs quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('a\u0001b'), true);
|
||
});
|
||
test('leading whitespace needs quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting(' abc'), true);
|
||
});
|
||
test('trailing whitespace needs quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('abc '), true);
|
||
});
|
||
for (const c of ['#', '&', '*', '!', '|', '>', '%', '@', '`', '[', ']', '{', '}', ',', "'"]) {
|
||
test(`leading indicator "${c}" needs quoting`, () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting(`${c}foo`), true);
|
||
});
|
||
}
|
||
test('near-miss: plain word does not need quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('plainword'), false);
|
||
});
|
||
});
|
||
|
||
describe('agentScalarNeedsDoubleQuoting: first character must be alphanumeric', () => {
|
||
for (const v of ['~', '.inf', '.nan', '+1', '-1', '-0', '.5']) {
|
||
test(`non-alphanumeric first char "${v}" needs quoting`, () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting(v), true);
|
||
});
|
||
}
|
||
test('near-miss: alphanumeric-first with internal hyphen does not need quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('a-b'), false);
|
||
});
|
||
test('near-miss: digit-first non-numeric word does not need quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('0abc'), false);
|
||
});
|
||
test('near-miss: alphanumeric-first with trailing hyphen does not need quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('x-'), false);
|
||
});
|
||
});
|
||
|
||
describe('agentScalarNeedsDoubleQuoting: trailing colon', () => {
|
||
test('a bare trailing colon needs quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('foo:'), true);
|
||
});
|
||
test('near-miss: colon followed by more text does not need quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('foo:bar'), false);
|
||
});
|
||
test('near-miss: single-char key:value shape does not need quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('a:b'), false);
|
||
});
|
||
});
|
||
|
||
describe('agentScalarNeedsDoubleQuoting: embedded ": " (colon + whitespace)', () => {
|
||
test('colon followed by space needs quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('a: b'), true);
|
||
});
|
||
test('colon followed by tab needs quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('a:\tb'), true);
|
||
});
|
||
test('near-miss: colon with no following whitespace does not need quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('a:b'), false);
|
||
});
|
||
});
|
||
|
||
describe('agentScalarNeedsDoubleQuoting: embedded " #" (whitespace + hash)', () => {
|
||
test('space followed by hash needs quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('a #b'), true);
|
||
});
|
||
test('tab followed by hash needs quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('a\t#b'), true);
|
||
});
|
||
test('near-miss: hash with no preceding whitespace does not need quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('a#b'), false);
|
||
});
|
||
});
|
||
|
||
describe('agentScalarNeedsDoubleQuoting: boolean/null words (case-insensitive)', () => {
|
||
for (const v of ['y', 'n', 'yes', 'no', 'true', 'false', 'on', 'off', 'null']) {
|
||
test(`lowercase word "${v}" needs quoting`, () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting(v), true);
|
||
});
|
||
}
|
||
for (const v of ['YES', 'No', 'TRUE', 'Null']) {
|
||
test(`mixed-case word "${v}" needs quoting (case-insensitivity)`, () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting(v), true);
|
||
});
|
||
}
|
||
test('near-miss: "yes1" is not an exact word match', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('yes1'), false);
|
||
});
|
||
test('near-miss: "nope" is not an exact word match', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('nope'), false);
|
||
});
|
||
test('near-miss: "nullish" is not an exact word match', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('nullish'), false);
|
||
});
|
||
test('near-miss: "onward" is not an exact word match', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('onward'), false);
|
||
});
|
||
});
|
||
|
||
describe('agentScalarNeedsDoubleQuoting: numeric-looking values', () => {
|
||
for (const v of [
|
||
'1', '123', '1.5', '1.', '1e5', '1E5', '1e+5', '1e-5', '1_000',
|
||
'0x1F', '0b101', '0o17', '0X1f', '12:30', '1:2:3', '12:30.5',
|
||
]) {
|
||
test(`numeric form "${v}" needs quoting`, () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting(v), true);
|
||
});
|
||
}
|
||
test('near-miss: "1a" (digit then letter) does not need quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('1a'), false);
|
||
});
|
||
test('near-miss: "a1" (letter then digit) does not need quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('a1'), false);
|
||
});
|
||
test('near-miss: "x1e5" (non-digit first char) does not need quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('x1e5'), false);
|
||
});
|
||
test('near-miss: "0xzz" (invalid hex digits) does not need quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('0xzz'), false);
|
||
});
|
||
// Verified against the real implementation: the sexagesimal group only
|
||
// consumes a leading [0-5]? then one mandatory digit per ":" segment, so
|
||
// "12:99" cannot fully match YAML_NUMERIC_RE (the second "9" is left over)
|
||
// and no other clause fires either. The code does NOT reject an
|
||
// out-of-range (60-99) minute-like group — pinned here as actual behavior,
|
||
// not the originally assumed "false because minutes must be 0-59".
|
||
test('"12:99" does not need quoting (sexagesimal regex cannot consume the trailing digit)', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('12:99'), false);
|
||
});
|
||
});
|
||
|
||
describe('agentScalarNeedsDoubleQuoting: YAML timestamp', () => {
|
||
for (const v of [
|
||
'2026-08-25', '2026-8-5', '2026-08-25T10:00:00Z', '2026-08-25 10:00:00',
|
||
]) {
|
||
test(`timestamp form "${v}" needs quoting`, () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting(v), true);
|
||
});
|
||
}
|
||
test('near-miss: "2026-08" (missing day) does not need quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('2026-08'), false);
|
||
});
|
||
// Verified against the real implementation: this string never reaches the
|
||
// timestamp clause at all — the pure-digit numeric clause (YAML_NUMERIC_RE's
|
||
// first alternative matches any \d[\d_]* string) fires first and returns
|
||
// true. So "20260825" IS true, but for a different reason than "looks like
|
||
// a date"; it does not exercise YAML_TIMESTAMP_RE.
|
||
test('"20260825" (no hyphens) needs quoting via the numeric clause, not the timestamp clause', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('20260825'), true);
|
||
});
|
||
// This is the case that actually pins YAML_TIMESTAMP_RE's trailing
|
||
// `(?:[Tt ].*)?$` anchor: the numeric clause cannot match (hyphens present,
|
||
// no colon), so only the timestamp regex is left to decide, and it rejects
|
||
// trailing text that isn't introduced by "T"/"t"/" ".
|
||
test('"2026-08-25x" (trailing junk not preceded by T/t/space) does not need quoting', () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting('2026-08-25x'), false);
|
||
});
|
||
});
|
||
|
||
describe('agentScalarNeedsDoubleQuoting: real-world values that must stay unquoted', () => {
|
||
for (const v of [
|
||
'sonnet', 'synthetic/hf:zai-org/GLM-5.2', 'gpt-5.6-luna', 'claude-opus-5',
|
||
'high', 'xhigh', 'minimal', 'a.b', 'GLM-5.2',
|
||
]) {
|
||
test(`"${v}" does not need quoting`, () => {
|
||
assert.equal(agentScalarNeedsDoubleQuoting(v), false);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ─── escapeDoubleQuotedScalar (#1779 / #3497) ────────────────────────────────────────
|
||
|
||
describe('escapeDoubleQuotedScalar: exact output strings', () => {
|
||
test('backslash is escaped before the quote it precedes (ordering matters)', () => {
|
||
// Input: a, \, ", b. If the quote were escaped BEFORE the backslash, the
|
||
// backslash added in front of the quote would then itself get doubled by
|
||
// a subsequent backslash pass, producing a different (wrong) string. The
|
||
// real order (backslash first, then quote) yields exactly 3 backslashes
|
||
// followed by the quote.
|
||
const input = 'a' + '\\' + '"' + 'b';
|
||
const expected = 'a' + '\\'.repeat(3) + '"' + 'b';
|
||
assert.equal(escapeDoubleQuotedScalar(input), expected);
|
||
});
|
||
|
||
test('double quote alone', () => {
|
||
assert.equal(escapeDoubleQuotedScalar('"'), '\\"');
|
||
});
|
||
|
||
test('newline alone', () => {
|
||
assert.equal(escapeDoubleQuotedScalar('\n'), '\\n');
|
||
});
|
||
|
||
test('tab alone', () => {
|
||
assert.equal(escapeDoubleQuotedScalar('\t'), '\\t');
|
||
});
|
||
|
||
test('carriage return alone', () => {
|
||
assert.equal(escapeDoubleQuotedScalar('\r'), '\\r');
|
||
});
|
||
|
||
test('a C0 control char (0x01) becomes lowercase zero-padded \\xHH', () => {
|
||
assert.equal(escapeDoubleQuotedScalar('\u0001'), '\\x01');
|
||
});
|
||
|
||
test('DEL (0x7f) becomes \\x7f', () => {
|
||
assert.equal(escapeDoubleQuotedScalar('\u007f'), '\\x7f');
|
||
});
|
||
|
||
test('plain string with no specials is returned unchanged', () => {
|
||
assert.equal(escapeDoubleQuotedScalar('plain'), 'plain');
|
||
});
|
||
|
||
test('combined input exercising every escape in one pass', () => {
|
||
const input = 'a\\b"c\nd\te\rf\u0001g\u007fh';
|
||
const expected = 'a\\\\b\\"c\\nd\\te\\rf\\x01g\\x7fh';
|
||
assert.equal(escapeDoubleQuotedScalar(input), expected);
|
||
});
|
||
});
|