From 6b7df61938a789a7c0bd86f89322ac132a089871 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 26 Aug 2026 19:29:32 -0400 Subject: [PATCH] =?UTF-8?q?enhance(#3881):=20one=20YAML=20parser=20?= =?UTF-8?q?=E2=80=94=20vendored=20js-yaml=20replaces=20the=20hand-rolled?= =?UTF-8?q?=20dialect=20(#3888)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .changeset/jolly-geese-roar.md | 5 + .changeset/patient-jaguars-frolic.md | 5 + .github/workflows/mutation.yml | 23 +- CONTEXT.md | 5 +- docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 1 + docs/README.md | 1 + docs/adr/3473-enforcement-by-construction.md | 185 +- docs/how-to/vendor-a-dependency.md | 86 + gsd-core/bin/gsd-tools.cjs | 41 +- gsd-core/bin/lib/vendor/README.md | 48 +- gsd-core/bin/lib/vendor/js-yaml.cjs | 3014 +++++++++++++++++ gsd-core/templates/SECURITY.md | 6 +- gsd-core/templates/UI-SPEC.md | 6 +- gsd-core/templates/VALIDATION.md | 6 +- package-lock.json | 4 +- package.json | 3 +- scripts/check-mutation-score-ratchet.cjs | 156 + .../lint-eslint-glob-coverage.allowlist.json | 4 + .../lint-mutation-test-derivation-drift.cjs | 86 + scripts/lint-test-file-count.allowlist.json | 6 +- scripts/lint-vendored-deps.cjs | 243 +- scripts/mutation-matrix.cjs | 389 ++- src/commands.cts | 6 +- src/frontmatter.cts | 997 ++++-- src/phase-estimation.cts | 25 +- src/runtime-artifact-conversion.cts | 2 +- src/state-transition.cts | 141 +- src/state.cts | 106 +- src/vendor/js-yaml.d.cts | 89 + stryker.config.mjs | 30 +- tests/dispatcher.test.cjs | 109 + ...eat-3881-yaml-parser-consequences.test.cjs | 883 +++++ .../adversarial/frontmatter/README.md | 9 + .../frontmatter/anchor-alias-bomb-quoted.md | 11 + .../frontmatter/anchor-alias-bomb.md | 11 + .../golden/frontmatter-legacy-golden.json | 367 ++ tests/frontmatter-golden-parity.test.cjs | 202 ++ tests/frontmatter-roundtrip.property.test.cjs | 156 + tests/frontmatter.test.cjs | 167 +- tests/frontmatter.unit.test.cjs | 60 +- .../helpers/frontmatter-golden-serializer.cjs | 54 + tests/lint-vendored-deps-manifest.test.cjs | 270 ++ tests/mutation-matrix-ratchet.test.cjs | 10 +- tests/mutation-score-ratchet.test.cjs | 191 ++ tests/mutation-test-derivation-drift.test.cjs | 93 + tests/uat.test.cjs | 29 +- tests/verify.test.cjs | 7 +- 48 files changed, 7799 insertions(+), 550 deletions(-) create mode 100644 .changeset/jolly-geese-roar.md create mode 100644 .changeset/patient-jaguars-frolic.md create mode 100644 docs/how-to/vendor-a-dependency.md create mode 100644 gsd-core/bin/lib/vendor/js-yaml.cjs create mode 100644 scripts/check-mutation-score-ratchet.cjs create mode 100644 scripts/lint-mutation-test-derivation-drift.cjs create mode 100644 src/vendor/js-yaml.d.cts create mode 100644 tests/feat-3881-yaml-parser-consequences.test.cjs create mode 100644 tests/fixtures/adversarial/frontmatter/anchor-alias-bomb-quoted.md create mode 100644 tests/fixtures/adversarial/frontmatter/anchor-alias-bomb.md create mode 100644 tests/fixtures/golden/frontmatter-legacy-golden.json create mode 100644 tests/frontmatter-golden-parity.test.cjs create mode 100644 tests/frontmatter-roundtrip.property.test.cjs create mode 100644 tests/helpers/frontmatter-golden-serializer.cjs create mode 100644 tests/lint-vendored-deps-manifest.test.cjs create mode 100644 tests/mutation-score-ratchet.test.cjs create mode 100644 tests/mutation-test-derivation-drift.test.cjs diff --git a/.changeset/jolly-geese-roar.md b/.changeset/jolly-geese-roar.md new file mode 100644 index 000000000..b1d47ee78 --- /dev/null +++ b/.changeset/jolly-geese-roar.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 3888 +--- +**`.planning/` frontmatter is now parsed by a real YAML parser.** Block scalars, quoted keys and non-ASCII keys are read correctly instead of being mangled or silently dropped, and a document whose frontmatter cannot be parsed keeps its frontmatter block instead of losing it on the next write. (#3881) diff --git a/.changeset/patient-jaguars-frolic.md b/.changeset/patient-jaguars-frolic.md new file mode 100644 index 000000000..ec0c91e38 --- /dev/null +++ b/.changeset/patient-jaguars-frolic.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3888 +--- +**`gsd-tools --project-dir ` now works** — the flag was documented in docs/CONFIGURATION.md's multi-repo workspace resolution section but wired nowhere, so it was silently ignored and every command still resolved the project root from cwd. Passing `--project-dir` now sets the project root directly and skips the ancestor walk-up, as documented. (#3881) diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 355606133..e5103e34f 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -108,7 +108,11 @@ jobs: # GitHub-hosted runners only. Speed comes from running shards in PARALLEL # (one job per changed module), not from larger/3rd-party runners. runs-on: ubuntu-latest - timeout-minutes: 15 # per-shard; lower than the old 30-min serial budget + # Per-shard; lower than the old 30-min serial budget. Matrix-driven so only shards that + # document a measured need (mutation-matrix.cjs COVERED[].timeoutMinutes, e.g. + # frontmatter's 180) get more; every other shard keeps the 15-minute default emitted by + # buildResult() in scripts/mutation-matrix.cjs. + timeout-minutes: ${{ matrix.timeoutMinutes }} strategy: fail-fast: false matrix: ${{ fromJSON(needs.detect.outputs.matrix) }} @@ -143,7 +147,7 @@ jobs: # repo's no-defer rule. env: NODE_OPTIONS: '--max-old-space-size=4096' - MUTATION_TEST_CMD: node --test ${{ matrix.tests }} + MUTATION_TEST_CMD: node --test --test-isolation=${{ matrix.isolation }} ${{ matrix.tests }} MUTATION_BREAK: ${{ matrix.minScore }} MODULE_NAME: ${{ matrix.name }} MUTATE_GLOB: ${{ matrix.mutate }} @@ -155,6 +159,21 @@ jobs: echo "MinScore: ${MUTATION_BREAK}" npx stryker run --incremental --mutate "${MUTATE_GLOB}" + - name: Check score ratchet — ${{ matrix.name }} + # #3881 follow-up, mutation-matrix piece 3: only runs when the shard above actually + # passed (a failed shard is a floor problem — Stryker's own MUTATION_BREAK exit code + # already reports that; this step is strictly about a floor that should be RAISED). + # Reads the 'json' reporter's reports/mutation/mutation.json (stryker.config.mjs) + # and fails, with the exact remedy, when the achieved score clears the module's + # declared floor by more than scripts/check-mutation-score-ratchet.cjs's documented + # slack — the "ratchet up" half of the piece-3 requirement. The "never lower without + # a reasoned marker" half is enforced separately, at review time, by + # tests/mutation-matrix-ratchet.test.cjs's RATCHET_BASELINE equality check. + if: success() + env: + MODULE_NAME: ${{ matrix.name }} + run: node scripts/check-mutation-score-ratchet.cjs --module "${MODULE_NAME}" + - name: Upload mutation report — ${{ matrix.name }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() # upload even on failure so the score is visible diff --git a/CONTEXT.md b/CONTEXT.md index 87888bf7e..cbdddb2f6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -21,7 +21,7 @@ Module owning the pure phase-id parsing and matching helpers: phase-name normali Module owning phase create, rename, complete, remove, list, and plan-index operations, plus phase-dir prefix validation, STATE.md staleness detection, and auto-prune behaviour. Entry point: `gsd-core/bin/lib/phase.cjs` (CJS surface). Typed phase events: `GSDPhaseStartEvent`, `GSDPhaseStepStartEvent`, `GSDPhaseStepCompleteEvent`, `GSDPhaseCompleteEvent`. (The SDK native-query surface, the `types.ts` event definitions, `phase-runner.ts`, and `phase-prompt.ts` were retired with the SDK package per ADR-0174.) ### Phase Estimation Module -Module owning phase-effort estimation and its calibration against measured reality (ADR-2629, epic #1952). Pure — no I/O, no config reads; the CLI seam (`src/estimate-cli.cts`, verbs `estimate-check` / `estimate-calibration`) owns reading `.planning/config.json` and `.planning/estimation-calibration.json`. Interface: `parseEstimate`/`renderEstimate` (the PLAN.md `estimate: {tokens, tasks, confidence}` block), `parseActuals`/`renderActuals` (the SUMMARY.md `actuals: {tokens, tasks, commits}` block), `deriveConfidence(sampleCount) → low|med|high`, `classifyAgainstBudget(estimate, budget) → {overBudget, ratio, recommendation, budgetValid}`, `computeCalibration(samples) → {factor, sampleCount, applied, confidence, clamped}`, `applyCalibration`, `parseCalibrationDocument`/`renderCalibrationDocument`, `extractFrontmatterBlock` (leading-`---`-anchored scalar-block reader; hand-rolled because core ships no external deps), `calibrationBasis` (returns `estimate.raw_tokens` when present, else `tokens` — calibration must measure actual/raw or the loop un-corrects itself), and `measureTokens` (a re-export of `prompt-budget`'s `estimateTokens`). **Domain terms: _raw_ vs _calibrated_ tokens** — the same token count in two mutually incompatible states, carried by the compile-time brands `RawTokens` (the planner's uncorrected projection, and the only legal calibration denominator) and `CalibratedTokens` (the projection with the project's factor applied, and the only figure meaningful against the budget), constructed at trust boundaries via `asRawTokens` / `asCalibratedTokens` (#2671). The brands erase at compile time — the emitted `.cjs`, the CLI JSON, and both frontmatter schemas are unchanged — and exist because mixing the two states was NOT catchable at runtime: both are positive integers of the same magnitude, and the mix-up shipped twice past a green ~26,800-test suite (#2631 factor², #2632 self-defeating loop). `asRawTokens` refuses a `CalibratedTokens` by design; the single legitimate crossover (a pre-#2632 plan whose `tokens` IS the raw projection) lives behind one commented assertion in `calibrationBasis`. Compile fixtures: `tests/fixtures/brand-typing/`. **_smart zone_** — the usable prefix of a model's context window before output quality degrades, expressed as the configurable `workflow.smart_zone_tokens` budget (default 100000, a *policy default* rather than a benchmark constant since the effective ceiling is model/task-dependent); **_estimate/actuals_** — a projected phase cost recorded at plan time and the measured cost recorded at completion, both on the **same `estimateTokens` scale** so their ratio measures the miss rather than a difference between two measurement methods. Two invariants: (1) every signal is **exogenous** — the correction routes on a measured actual/estimate ratio and `confidence` routes on a calibration sample count, never on a model's self-assessment (this project measured self-rated confidence and found it weak — `gsd-core/references/honest-verifier.md:25-29`; see `.out-of-scope/general-purpose-agent-prompt-skills.md`); (2) the over-budget flag is **advisory** — a warning plus a split recommendation, never a block. Calibration is median-of-ratios, clamped to `[0.5, 3.0]`, and inert below 3 samples. CLI seam verbs: `estimate-check` (classify one figure; `--calibrated` when the input already has the factor applied — omitting it squares the correction), `estimate-calibration` (report the current factor), `estimate-calibrate` (#2632 — pair every completed phase's PLAN `estimate` with its SUMMARY `actuals`, rebuild `.planning/estimation-calibration.json` idempotently, and report the result; this is what closes the loop). Source of truth: `gsd-core/bin/lib/phase-estimation.cjs` and `src/estimate-cli.cts`. Test anchors: `tests/phase-estimation.test.cjs`, `tests/estimate-calibrate.test.cjs`. +Module owning phase-effort estimation and its calibration against measured reality (ADR-2629, epic #1952). Pure — no I/O, no config reads; the CLI seam (`src/estimate-cli.cts`, verbs `estimate-check` / `estimate-calibration`) owns reading `.planning/config.json` and `.planning/estimation-calibration.json`. Interface: `parseEstimate`/`renderEstimate` (the PLAN.md `estimate: {tokens, tasks, confidence}` block), `parseActuals`/`renderActuals` (the SUMMARY.md `actuals: {tokens, tasks, commits}` block), `deriveConfidence(sampleCount) → low|med|high`, `classifyAgainstBudget(estimate, budget) → {overBudget, ratio, recommendation, budgetValid}`, `computeCalibration(samples) → {factor, sampleCount, applied, confidence, clamped}`, `applyCalibration`, `parseCalibrationDocument`/`renderCalibrationDocument`, `extractFrontmatterBlock` (leading-`---`-anchored scalar-block reader; stays hand-rolled for its TYPE CONTRACT — it returns numeric-looking values as numbers so `parseEstimate`/`parseActuals` see the types they validate, whereas the migrated `extractFrontmatter`, ADR-3473 §8.1/#3881, resolves every scalar as a string under js-yaml's FAILSAFE_SCHEMA; migrating this function onto the shared parser is follow-on work under ADR-3473 §8.1, not done), `calibrationBasis` (returns `estimate.raw_tokens` when present, else `tokens` — calibration must measure actual/raw or the loop un-corrects itself), and `measureTokens` (a re-export of `prompt-budget`'s `estimateTokens`). **Domain terms: _raw_ vs _calibrated_ tokens** — the same token count in two mutually incompatible states, carried by the compile-time brands `RawTokens` (the planner's uncorrected projection, and the only legal calibration denominator) and `CalibratedTokens` (the projection with the project's factor applied, and the only figure meaningful against the budget), constructed at trust boundaries via `asRawTokens` / `asCalibratedTokens` (#2671). The brands erase at compile time — the emitted `.cjs`, the CLI JSON, and both frontmatter schemas are unchanged — and exist because mixing the two states was NOT catchable at runtime: both are positive integers of the same magnitude, and the mix-up shipped twice past a green ~26,800-test suite (#2631 factor², #2632 self-defeating loop). `asRawTokens` refuses a `CalibratedTokens` by design; the single legitimate crossover (a pre-#2632 plan whose `tokens` IS the raw projection) lives behind one commented assertion in `calibrationBasis`. Compile fixtures: `tests/fixtures/brand-typing/`. **_smart zone_** — the usable prefix of a model's context window before output quality degrades, expressed as the configurable `workflow.smart_zone_tokens` budget (default 100000, a *policy default* rather than a benchmark constant since the effective ceiling is model/task-dependent); **_estimate/actuals_** — a projected phase cost recorded at plan time and the measured cost recorded at completion, both on the **same `estimateTokens` scale** so their ratio measures the miss rather than a difference between two measurement methods. Two invariants: (1) every signal is **exogenous** — the correction routes on a measured actual/estimate ratio and `confidence` routes on a calibration sample count, never on a model's self-assessment (this project measured self-rated confidence and found it weak — `gsd-core/references/honest-verifier.md:25-29`; see `.out-of-scope/general-purpose-agent-prompt-skills.md`); (2) the over-budget flag is **advisory** — a warning plus a split recommendation, never a block. Calibration is median-of-ratios, clamped to `[0.5, 3.0]`, and inert below 3 samples. CLI seam verbs: `estimate-check` (classify one figure; `--calibrated` when the input already has the factor applied — omitting it squares the correction), `estimate-calibration` (report the current factor), `estimate-calibrate` (#2632 — pair every completed phase's PLAN `estimate` with its SUMMARY `actuals`, rebuild `.planning/estimation-calibration.json` idempotently, and report the result; this is what closes the loop). Source of truth: `gsd-core/bin/lib/phase-estimation.cjs` and `src/estimate-cli.cts`. Test anchors: `tests/phase-estimation.test.cjs`, `tests/estimate-calibrate.test.cjs`. ### Verification Module Module owning the canonical phase-verification status projection shared by phase transition, progress, manager, autonomous, and closeout readiness paths. `readVerificationStatus(phaseDir, opts?)` reads the first `*-VERIFICATION.md` frontmatter `status`, maps it through `VERIFICATION_ROUTING_TABLE`, and fail-closes — only `{passed}` satisfies the canonical gate; `missing`/`unknown`/`gaps_found`/`human_needed`/`stale` all route away from "complete" (#1522). `findStaleVerificationSummary` flags a SUMMARY newer than the VERIFICATION file (status `stale`). Both honor a no-throw, degrade-to-safe contract (any FS error → `missing` / not-stale) and an injectable `opts.fs` seam. `isPhaseComplete(phaseDir, deps?)` is the single canonical owner of "is phase P complete?" (ADR-3180 §7.4, issue #3186, disk-strict per #2957): it wraps `readVerificationStatus`, calling it UNCONDITIONALLY — plan count is never a precondition, so a zero-plan phase with a passing `*-VERIFICATION.md` is complete (#3168) — and returns `{ value: { complete, verification }, scope }`; `complete` is exactly `verification.status === 'passed'`. A ROADMAP checkbox carries no machine authority and is never consulted. `cmdPhaseComplete`, `buildPhaseCompletionProjection`, and `buildStateFrontmatter` all route through it. Source of truth: `gsd-core/bin/lib/verification.cjs` (generated from `src/verification.cts`). @@ -120,6 +120,9 @@ Leaf module owning the construction of a regex from a **runtime value**, per ADR ### Text Lines Module Leaf module owning `\r?\n` line-terminator splitting and CRLF normalization, per ADR-3212 §3 (epic #3212 Phase 2, #3413). Exposes `splitLines(content) → string[]` (splits on `\r\n` or `\n`; a lone `\r` is not a delimiter), `normalizeEol(content) → string` (strips every `\r`, matching the four `scripts/gen-*.cjs` `--check` copies it replaces), `detectEol(content) → '\n' | '\r\n'` (dominant terminator, `'\r\n'`-default on a tie or no terminator), and `joinLines(lines, eol?) → string` (inverse of `splitLines`; round-trips byte-for-byte with `detectEol`). Closes #3360: `parseMustHavesBlock` (`src/frontmatter.cts`) matched `^(\s*)must_haves:\s*$` and a sibling block-header regex against the WHOLE multi-line YAML string under `/m`, and `\r` is its own ECMA-262 LineTerminator — a greedy `\s*` anchored on `^` could cross a CRLF boundary and inflate the captured indent by one character, tripping the nesting guard and silently returning `[]` for every `must_haves` block on a CRLF plan file. The fix reroutes both lookups through split-then-match (`splitLines` first, then match per already-split line), which cannot straddle the delimiter that produced it. Pure and import-free — a leaf, so any consumer can depend on it without a cycle (mirrors the Pattern Module's position). Enforced going forward by the widened `eslint-rules/no-crlf-fragile-split.cjs` (now also scanned against `src/**/*.cts`, not only `tests/`). Source of truth: `gsd-core/bin/lib/text-lines.cjs` (generated from `src/text-lines.cts`). Design: `.gsd/phase/chore-3413-text-lines-seam/40-design.md`. +### Frontmatter Module +Module owning YAML frontmatter parsing, serialization and CRUD commands. As of ADR-3473 §8.1 (#3881) the read path is no longer a hand-rolled line scanner — `parseYamlRegion`, `escapeDoubleQuoted`, `unescapeDoubleQuoted` and `parseQuotedScalar` are **deleted, not patched** (§8.1's words), and `extractFrontmatter` parses through the vendored js-yaml (`gsd-core/bin/lib/vendor/js-yaml.cjs`, type twin `src/vendor/js-yaml.d.cts`) under `FAILSAFE_SCHEMA` + `json: true`. `FAILSAFE_SCHEMA` resolves only `!!str`/`!!seq`/`!!map`, so every scalar still comes back a string (today's contract, unchanged for callers), and `json: true` makes a duplicate key overwrite rather than throw — the documented last-wins invariant `tests/fixtures/adversarial/frontmatter/duplicate-keys.md` pins. What js-yaml does not do is layered on top in this module: anchors, aliases and merge keys are refused outright (a raw-text pre-scan, `refuseAnchorsAndAliases`) because `FAILSAFE_SCHEMA` still resolves core YAML anchor/alias mechanics — not a tag-resolution concern a schema choice can disable — and `.planning/` documents are untrusted, user-authored input a hostile few-line alias fan-out could otherwise expand by orders of magnitude in milliseconds; the #3257 full-line-comment channel and the #1882 truncation probe are likewise preserved unchanged. A closed region that js-yaml itself cannot parse (malformed YAML, or a refused anchor/alias/merge key) returns `{}` carrying an exported `FRONTMATTER_UNPARSEABLE` Symbol rather than a bare, indistinguishable `{}` — Symbol-keyed so it is invisible to `Object.keys`/`JSON.stringify`/`for-in` and the ~70 call sites that never inspect it are unaffected, while the ~8 `hasFrontmatter = Object.keys(...).length > 0` call sites can consult it to tell "genuinely empty" apart from "could not parse" and avoid reassembling a document without its (unparsed but still present) frontmatter block. **All 8 sites are wired** — 7 in `src/state-transition.cts` (`beginPhaseCore`, `advancePlanCore`, complete-phase, planned-phase, milestone-complete, `patchCore`, `updateCore`) and 1 in `src/state.cts` — verified against a STATE.md carrying git merge-conflict markers, which previously lost its frontmatter block entirely on the next write and now round-trips intact. Scope note: the full CLI write path (`readModifyWriteStateMd` → `syncAndPreserveStateMd` → `syncStateFrontmatter`) re-derives and rebuilds the frontmatter block on every write regardless of what the transform returned, so the marker's observable effect is at the transform layer and for any caller bypassing that rebuild — it does not change on-disk output for today's `state update` / `state patch`. `extractFrontmatterBlock` (Phase Estimation Module) is a deliberate sibling, not a duplicate: it stays hand-rolled because it returns numeric-looking values as numbers rather than strings, a type contract `FAILSAFE_SCHEMA` cannot express. Source of truth: `gsd-core/bin/lib/frontmatter.cjs` (generated from `src/frontmatter.cts`). + ### Token Scanner Module Leaf module generalizing the proven `hooks/lib/git-cmd.js` token-walk (#3129) into a shared primitive for stateful grammars, per ADR-3212 §4 (epic #3212 Phase 3, #3414). Exposes `tokenizeShellLike(cmd) → string[]` (quote-aware shell tokenizer — single/double-quoted spans as one token, no escape or brace/variable expansion, byte-identical port of `git-cmd.js`'s original `tokenize()`) and `indentWidth(line) → number` (leading-whitespace column count, tabs counted as one column each, no tab-width policy introduced). `hooks/lib/git-cmd.js` migrates its `tokenize()`/`isGitSubcommand()` onto `tokenizeShellLike` with zero behavior change (ADR §6 extend-never-mutate; parity-asserted against every existing fixture) and gains `extractBranchArgument(cmd) → string | null` (`git checkout -b ` / `git branch `) — a new capability exercising the seam on the domain the ADR names, not a migration of existing duplicated logic (none existed). Closes #3169: `src/decisions.cts`'s decision-bullet parser could not distinguish a cross-reference bullet NESTED under an already-open decision from a fresh, malformed top-level declaration attempt — both have identical shape under any bullet-*content* classifier (an earlier design using bold-run-content classification was disproven against the repo's own existing FIX-B fixtures before being adopted, per the phase's design doc). `indentWidth` supplies the structural signal a per-line regex cannot see: a bulleted line indented deeper than the currently-open decision's own bullet is that decision's elaboration, folded into its text like a continuation line, never tested against the declaration/parse-miss regexes. A bullet at the same-or-shallower indent is unaffected. Pure and import-free — a leaf, so any consumer can depend on it without a cycle (mirrors the Pattern and Text Lines modules' position). Hook-staging note: `hooks/` scripts are staged as standalone files at install time, so `git-cmd.js` requires the BUILT `gsd-core/bin/lib/token-scanner.cjs` artifact (matching Phase 2's `scripts/gen-*.cjs` consolidation), not a sibling `hooks/lib/` file. Source of truth: `gsd-core/bin/lib/token-scanner.cjs` (generated from `src/token-scanner.cts`). Design: `.gsd/phase/chore-3414-tokenizer-first-seam/40-design.md`. diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index ab58ea51c..37dabf830 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -527,6 +527,7 @@ "user-artifact-staging.cjs", "validate-command-router.cjs", "validate.cjs", + "vendor/js-yaml.cjs", "vendor/re2js.cjs", "verification-command-router.cjs", "verification.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 3dcfe4fda..e20e1fe45 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -673,6 +673,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `worktree-base-ref.cjs` | Worktree base-ref drift detection and degrade decision (`evaluateWorktreeBaseDegrade`) plus no-clobber `worktree.baseRef` settings management for the `base-check`/`set-baseref` subcommands (#683) | | `health-diagnostic-rules/worktree-health.cjs` | Health-diagnostic rules: worktree health checks (W020, W017, W027 — the split-off stale-worktree subject), ported behavior-preserving from `cmdValidateHealth` (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) | | `worktree-safety.cjs` | Worktree-root resolution and non-destructive prune policy decisions; owns W017 health-check logic | +| `vendor/js-yaml.cjs` | **Vendored third-party artifact, not a GSD module.** Verbatim copy of `js-yaml`'s UMD `dist` build (ADR-3473 §8.1, #3881) — the single YAML parser adopted to replace this repo's hand-rolled frontmatter scanner. Vendored because `gsd-core/bin/**` is copied into installed trees that have no `node_modules`, so it may contain no external requires (enforced by `local/no-external-require-in-bin`). Its type twin, `src/vendor/js-yaml.d.cts`, is hand-authored (js-yaml ships no `.d.ts` upstream and `@types/js-yaml` is not installed) and deliberately narrow — only `load`/`dump`/`FAILSAFE_SCHEMA`/`YAMLException` are declared, so anchors/aliases/custom types/`loadAll` are unreachable from typed code. Never hand-edit the `.cjs`; `scripts/lint-vendored-deps.cjs` byte-compares it against the pinned `js-yaml` devDependency in `lint:ci` (the hand-authored twin is excluded from that byte-compare — there is no upstream file to compare against). See `gsd-core/bin/lib/vendor/README.md` | | `vendor/re2js.cjs` | **Vendored third-party artifact, not a GSD module.** Verbatim copy of `re2js`' CJS build — the RE2 linear-time regex engine used by `pattern.cjs` to evaluate untrusted `key_links` patterns without catastrophic backtracking (#3477). Vendored because `gsd-core/bin/**` is copied into installed trees that have no `node_modules`, so it may contain no external requires (enforced by `local/no-external-require-in-bin`). Never hand-edit; `scripts/lint-vendored-deps.cjs` byte-compares it against the pinned `re2js` devDependency in `lint:ci`. See `gsd-core/bin/lib/vendor/README.md` | | `write-set.cjs` | Shared fail-loud `Result` (`{ok:true,value}\|{ok:false,reason}`) and per-surface write-set contracts (ADR-2143, epic #2143) — `WriteOutcome` (`{surface,applied}`), `WriteSet` (`WriteOutcome[]`), and `writeSetComplete(ws)` (true only when the set is non-empty AND every surface applied, never an OR-into-one-flag); `markdown-table.cjs` re-exports `Result` from here so existing importers are unaffected; consumed by `milestone.cts`'s `requirements mark-complete` handler to report a structured per-surface (`checkbox`/`traceability`) write-set alongside its existing fields (fixes the structural half of #2140) | diff --git a/docs/README.md b/docs/README.md index 759750ff1..b00cd448c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -64,6 +64,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md) - [Take over a capability or EoS integration](how-to/take-over-a-capability-or-eos.md) — assume maintainership of an existing third-party capability, reviewer lane, or EoS host integration through a handoff, an adoption fork, first-party absorption, or a de-listing - [Add or update a host's integration](how-to/add-or-update-a-host-integration.md) — set a host's documentation-sourced `runtime.hostIntegration` axes (ADR-1239 Phase A), with the `undocumented` sentinel rule - [Migrate an install test to the executed plan](how-to/migrate-an-install-test-to-the-executed-plan.md) — convert an `fs.existsSync`-probing install test group to a value assertion against `installRuntimeArtifacts`'s executed-plan return, and test against a fake fs adapter +- [Vendor a dependency](how-to/vendor-a-dependency.md) — add a third-party package `gsd-core/bin/**` needs at runtime as a verbatim vendored artifact, keep it out of `dependencies`, and pick the right upstream bundle - [Turn a capability off (and keep it off)](how-to/turn-a-capability-off.md) — disable a capability via the surface, or gate individual hooks off without removing the capability - [Drive GSD from a tracker issue](how-to/drive-gsd-from-a-tracker-issue.md) — start a phase from a GitHub, Linear, or Jira issue - [Migrate from GSD 2](how-to/migrate-from-gsd-2.md) — upgrade an existing GSD 2 project to GSD Core diff --git a/docs/adr/3473-enforcement-by-construction.md b/docs/adr/3473-enforcement-by-construction.md index 6c258f995..7f82e0b66 100644 --- a/docs/adr/3473-enforcement-by-construction.md +++ b/docs/adr/3473-enforcement-by-construction.md @@ -135,11 +135,11 @@ Decisions 1–7 answer *how* this epic is organized. This section says *what the - Amending a rule here is an amendment to this ADR, not a code change with a comment. - Each rule carries a **status**: *Enforced* or *Required — Phase N*. -#### 8.1 One YAML parser — *Required — phase unassigned* +#### 8.1 One YAML parser — *Required — Phase 4* **Question.** What parses and serializes `.planning/` frontmatter? -**Owner.** A single vendored parser. `parseYamlRegion` and `escapeDoubleQuoted` are **deleted, not patched**. +**Owner.** A single vendored parser. `parseYamlRegion` and `escapeDoubleQuoted` are **deleted, not patched**. (Post-#3881-review, finding 2: the hand-rolled implementations behind both names are gone — `src/frontmatter.cts` now renames them to `parseGuardedYamlRegion` and `escapeDoubleQuotedScalar` so no function still answers to the deleted scanner's name; see those functions' docblocks for the full reasoning, including why `escapeDoubleQuotedScalar`'s rename required updating its three call sites rather than being treated as an ADR-amendment matter.) **Rule.** Escaping, quoting, CRLF handling and indentation leave this repo's maintenance surface. Round-trip *values* are identical; a property-based `fast-check` round-trip test is the gate. @@ -162,6 +162,153 @@ Both close #3349 and #3360, which are **read-side** defects a real parser fixes **Sequencing note, decided 2026-08-25.** This rule lands **after** Phases 1–3. §8.8's schema declares each key's real type, cardinality and enum — which is precisely the artifact that makes (b) tractable rather than epic-sized. Answering the fork before the schema exists means guessing the type contract; answering it after means reading it off the schema. +> **ANSWER, 2026-08-26 (Phase 4, #3881) — the fork is (a), a string-coercing adapter.** The forcing +> function above is discharged here: the question is answered before the implementation PR opens. +> +> The sequencing note's bet did not pay. Measured against the merged schema rather than predicted: +> `extractFrontmatter` has **78 non-test call sites across 23 files**, and only **33 of them (42%)** +> read STATE.md. The other 45 read PLAN, VERIFICATION, SUMMARY, UAT, roadmap or generic agent/skill +> frontmatter — document kinds §8.8's schema does not model. `FRONTMATTER_SCHEMAS` still declares +> four kinds with **no type declaration for any of them**, and `STATE_FIELD_SCHEMA` has no +> cross-reference to it. (b) would therefore still require net-new type contracts for four-plus +> document kinds: the schema shrank the STATE.md slice of an otherwise unchanged epic-sized +> migration. +> +> The prize is also smaller than the list above implies. Of the five compensating mechanisms named, +> **two survive real types**: `frontmatterDeepEqual` (17 lines, 3 callers) is required for +> no-op/dirty-key detection whatever the value types are, and the #3257 comment channel is orthogonal +> to typing — a faithful parser discards comments, so that channel is needed *more* under (b), not +> less. Only `sliceTopLevelFrontmatterSegments`, the `[object Object]` guard and +> `noOpObjectListSetError` die: **~31 lines across 3 call sites.** +> +> (b) remains the larger prize and is **not** silently dropped — it is recorded here with the numbers +> that say why it stays epic-sized, so a future reader inherits the measurement rather than the +> intuition. +> +> Implementation note: fork (a) needs **no hand-written coercion layer** for scalars. js-yaml's +> `FAILSAFE_SCHEMA` resolves only `!!str`/`!!seq`/`!!map`, so every scalar returns a string by spec. +> `gap_closure: true` stays `"true"` and `FRONTMATTER_SCHEMAS['plan-gap-closure'].requiredValues` +> keeps matching with no call site changed. Verified across `true`, `null`, `~`, `1.5`, `0x10`, a +> date, `yes`, `on`, `.inf`, `NaN` and quoted-vs-unquoted numbers: all agree with legacy. + +> **CORRECTION to the answer above, 2026-08-26 (Phase 4, #3881) — fork (a) as this ADR specifies it +> is NOT IMPLEMENTABLE, and the fork itself is ill-posed.** An adversarial pass on the Phase 4 design +> established this by execution, and it supersedes the "(a)" answer recorded above. +> +> (a) is defined as *"keep a string-coercing adapter over the parser so the existing contract holds."* +> That presumes the existing contract is expressible as a function of a parsed YAML tree. **It is +> not.** `extractFrontmatter` is not a YAML parser; it is a **line-oriented scanner whose output is a +> function of the raw source text.** Four spellings of the same value: +> +> | source line | legacy | js-yaml | +> |---|---|---| +> | ` - test: "a b"` | `["test: \"a b"]` | `[{"test":"a b"}]` | +> | ` - test: a b` | `["test: a b"]` | `[{"test":"a b"}]` | +> | ` - test: 'a b'` | `["test: 'a b"]` | `[{"test":"a b"}]` | +> | ` - {test: a b}` | `["{test: a b}"]` | `[{"test":"a b"}]` | +> +> One tree, four legacy strings — one of them mangled, with the closing quote stripped. **No adapter +> over a tree can choose among four outputs that the tree does not distinguish.** Reproducing them +> requires keeping the legacy line scanner, which is the surface §8.1 exists to delete. +> +> The consequence for the fork: **for any document with a non-scalar value, (a) collapses into (b).** +> Structured values cannot be flattened back to their source spelling, so consumers must either accept +> a canonicalized string or move to real types. There is no third option, and roughly 26% of +> frontmatter-carrying documents (230 of 897 by the adversarial count; 239 of 901 by mine — the +> denominators differ by fence-detection edge cases and are reconciled during implementation) hold at +> least one non-scalar top-level value. +> +> **Three further defects in the design this correction replaces**, all confirmed by execution: +> +> 1. **Returning `{}` on a parse failure is destructive, not benign.** Eight call sites across +> `state-transition.cjs` and `state.cjs` compute `hasFrontmatter = +> Object.keys(extractFrontmatter(...)).length > 0` and, when false, reassemble the document +> **without a frontmatter block**. A STATE.md carrying a git merge-conflict marker, a tab indent or +> a duplicate key parses today and would, under a catch-and-return-`{}` adapter, have its +> frontmatter **deleted on the next write**. The caller conflates "empty" with "unparseable"; the +> adapter must not feed that conflation. (Not a live bug today: the only four tracked documents +> legacy parses to empty are archived changesets, which never reach the state write path.) +> 2. **An empty value silently drops its key.** Legacy parses `progress:` to `{}`; js-yaml yields +> `null`; `reconstructFrontmatter` **omits any null-valued key**. `gsd-core/templates/state.md` +> ships an empty `progress:`, so passing null through deletes it on the next write. +> 3. **The truncation probe is not a pre-parse heuristic — it IS `parseYamlRegion`.** #1882's +> diagnostic cannot both "stay unchanged" and survive that function's deletion. Pointing it at +> js-yaml silences it on the dominant real shape (fence opened, body follows: legacy sees 2 keys +> and fires; js-yaml raises `bad indentation` and yields 0 keys, so it stays silent). Keeping it +> hand-rolled recreates precisely the parallel-surface divergence `frontmatter.cts`'s own comment +> warns against. +> +> **A new attack surface the fork never considered.** `FAILSAFE_SCHEMA` still resolves anchors and +> aliases. A 7-line frontmatter block expands to a **22.8 MB** structure in 0 ms, and one further +> nesting level is ~200 MB — which `frontmatterDeepEqual` and `reconstructFrontmatter` then walk. +> The legacy scanner is immune, because `&a [...]` is just a string to it. `.planning/` documents are +> **user documents**, so this is a real regression vector that any implementation must close, not a +> theoretical one. Current corpus occurrences of anchors, aliases and merge keys: **zero**, so nothing +> is lost by refusing them outright. +> +> **Verified clean, and worth recording as negatives:** top-level key **order** agrees across all +> frontmatter-carrying tracked documents (0 disagreements); the never-throw claim holds (legacy threw +> on 0 of 11 hostile inputs, including a 200 KB scalar, 20k keys and 5k nested opens); and the ten +> scalar spellings above all agree. +> +> **RESOLUTION, 2026-08-26 (maintainer decision).** Presented with three options — split §8.1 into +> its own epic, take the full semantic migration now, or patch the scanner and drop the vendoring — +> the maintainer chose **the full semantic migration**. Phase 4 therefore adopts js-yaml's semantics +> as truth and carries all seven consequences above, rather than attempting the contract-preservation +> that §0.1 proves impossible. The fork is not answered as (a) or as (b); it is answered as **"the +> fork was ill-posed, and the migration is semantic."** +> +> **Guard ledger, counted rather than estimated — Phase 4 GROWS.** Excluding the 3,014 vendored +> third-party lines, the hand-maintained surface is **+307 lines** net: `src/frontmatter.cts` alone is +> +248/−180 = **+68**, growing *despite* deleting four functions, because the compatibility layer that +> reproduces this repo's bespoke contract on top of js-yaml is larger than the scanner it replaced. +> +> So §8.1's stated benefit — *"escaping, quoting, CRLF handling and indentation leave this repo's +> maintenance surface"* — **is not delivered as written.** Those concerns did leave; a compatibility +> layer replaced them and the line count rose. What genuinely improved is the *kind* of code +> maintained: this repo no longer owns YAML spec conformance, whose bugs were #1779, #1882, #1572, +> #1660, #3257 and #3497. It owns a thin adapter over a parser whose correctness is upstream's +> problem. That is a real gain, and a smaller one than the rule claimed. +> +> Decision 6 requires the growth be recorded rather than netted away — a net fall achieved by not +> counting an increase is the Goodhart outcome Decision 5 exists to prevent. Across the epic: Phase 1 +> shrank (−665 lines, −1 file); Phase 2 was flat; Phase 3 grew (+2 guard surfaces); Phase 4 grows +> (+307 lines). **Only one of four phases delivered the shrink this epic was framed around.** + +> **Amendment, 2026-08-26 (Phase 4, #3881) — the justifying sentence above is wrong, and this is the +> THIRD wrong premise in this ADR.** The claim *"Both close #3349 and #3360, which are read-side +> defects a real parser fixes regardless of the value types it hands back"* describes defects that no +> longer exist. Verified by **executing** the compiled parser at `ddde001af`, not by reading it: +> +> - **#3349** (escape never inverted on read → `b → 2b+1` growth per read-modify-write until OOM): four +> successive round-trips of a value containing `"` and `\` return it **byte-identical every time**, +> length stable. `unescapeDoubleQuoted` is a genuine inverse, shipped under **#3497**. +> - **#3360** (`\s` at `^` under `/m` eating the `\n` of a CRLF pair → `[]` for every CRLF `must_haves` +> block): LF and CRLF inputs both return `["alpha","beta"]`. +> +> Both issues are CLOSED. After §8.6's "keeps only its raw-write check" (no such check existed) and +> §8.8's "delete `lint-state-field-drift.cjs`" (it guards an unrelated contract), the pattern is now +> established firmly enough to be stated as a rule: **a factual claim in this ADR is a hypothesis +> until the implementing phase executes it.** Decision 6 obliges a phase to verify a claim before +> acting on it, not merely to count the result. +> +> **The Rule survives the collapse; only the justification died.** *"Escaping, quoting, CRLF handling +> and indentation leave this repo's maintenance surface"* is untouched, and the evidence for it is +> better than the two dead issues ever were: #1779, #1882, #1572, #1660, #3257 and #3497 are all this +> repo paying, repeatedly, to maintain a YAML parser. +> +> **And the phase found live defects the dead ones did not cover.** A differential across all 901 +> frontmatter-bearing tracked documents shows **99.1% exact key-set agreement** and 2,177 of 2,178 +> scalars byte-identical — with **every disagreement being the legacy parser wrong**: +> - **Block scalars are not parsed at all.** `commands/gsd/add-tests.md` declares +> `argument-instructions: |`; the legacy parser returns the block indicator `"|"` as the value, +> discards the instruction text, and invents a **phantom top-level key `Example`** from inside the +> block body. A live defect in a shipped artifact. +> - **A Unicode key is silently dropped.** +> +> §8.1 closes both by construction. That is the payoff this rule actually has, and it is recorded +> from measurement rather than inherited from a sentence. + #### 8.2 Enumerations return correct values by construction — *Required — phase unassigned* **Question.** What does an enumeration of phases, plans or artifacts return? @@ -319,9 +466,37 @@ Net across the set: one guard retired, one increase recorded honestly. The incre |---|---| | `scripts/lint-state-write-path-drift.cjs` | retained, shrunk (§8.6) — seam-bypass `writeStateMd(` arm and its ratchet retired at Phase 1; composition-bypass arm retained and made terminal; raw-write check added net-new. See §8.6's amendment. | | `scripts/lint-state-field-drift.cjs` | **RETAINED** — the Phase-3 retirement instruction rested on a wrong premise about what this guard does; see §8.8's amendment. It guards the ADR-3180 §7.7 / #3187 coercion ladder, which no schema makes unrepresentable. | -| `scripts/lint-vendored-deps.cjs` | reused as-is for §8.1's vendoring rule | +| `scripts/lint-vendored-deps.cjs` | **not reusable as-is** — generalized to a manifest by §8.1; see the correction below | | `local/no-external-require-in-bin` | reused as-is; enforces §8.1's packaging rule | | `local/no-adhoc-markdown-parsing` | widened past `src/**/*.cts` per Decision 5 (coverage fix, tracked on #3426/#3239) | | `local/no-adhoc-regex-escape` | widened to `MemberExpression`/`TSAsExpression` with a `.source`-aware exemption (§8.3) | -| `scripts/lint-frontmatter-scalar-broad-grep.cjs` | expected casualty of §8.1; phase unassigned | -| `scripts/lint-phase-enumeration-drift.cjs` | expected casualty of §8.2; phase unassigned | +| `scripts/lint-frontmatter-scalar-broad-grep.cjs` | **NOT a casualty of §8.1 — retained.** See the correction below. | +| `scripts/lint-phase-enumeration-drift.cjs` | expected casualty of §8.2 — **verify before retiring** (Phase 5) | + +> **Correction, 2026-08-26 (Phase 4, #3881) — two rows in this roster were wrong, and they are the +> FOURTH and FIFTH wrong premises in this ADR.** Both were caught by applying the rule recorded in +> §8.1's amendment — *a factual claim in this ADR is a hypothesis until the implementing phase +> executes it* — on its first use. +> +> **`lint-frontmatter-scalar-broad-grep.cjs` is not a casualty of §8.1 and is retained.** It has +> nothing to do with the TypeScript parser. It is `DEFECT.FRONTMATTER-SCALAR-BROAD-GREP` (#586 / +> PR #650): it scans fenced ```bash / ```sh blocks in `gsd-core/workflows/*.md`, `agents/*.md` and +> `commands/**/*.md` for shell `grep "^key:"` invocations that read a frontmatter scalar from the +> whole markdown body instead of scoping to the frontmatter block — the failure that once yielded +> `passed+gaps_found+human_needed` instead of `passed` and blocked a passing phase. **The prompt +> layer does not call our parser; it runs `grep` in a shell.** Vendoring js-yaml makes a shell grep +> no safer, so retiring this guard would be a pure coverage loss dressed as a guard-count win — +> the Goodhart outcome Decision 6 exists to prevent, and the third time in this epic that a +> retirement claim has pointed at a guard whose actual contents it did not describe. +> +> **`lint-vendored-deps.cjs` cannot be "reused as-is."** All four of its checks name `re2js` +> literally, as does its `REFRESH_COMMAND`. Vendoring a second package by pasting a second hardcoded +> block would violate **§8.3, "one implementation per rule"**, inside the epic that exists to end +> that. Phase 4 generalizes it to a table-driven manifest, preserving re2js's four checks unchanged. +> +> A further wrinkle the roster did not anticipate: js-yaml ships **no type declarations** and +> `@types/js-yaml` is not installed, so the re2js precedent's verbatim `.d.cts` copy has no upstream +> to copy from. `src/vendor/js-yaml.d.cts` is hand-authored, declaring only `load`, `dump`, +> `FAILSAFE_SCHEMA` and `YAMLException` — which also makes anchors, aliases and custom types +> unreachable from typed code, a capability gate rather than a shortcut. It is therefore excluded +> from the byte-compare and pinned by a test instead. diff --git a/docs/how-to/vendor-a-dependency.md b/docs/how-to/vendor-a-dependency.md new file mode 100644 index 000000000..5528e1b81 --- /dev/null +++ b/docs/how-to/vendor-a-dependency.md @@ -0,0 +1,86 @@ +# How to vendor a dependency + +**Goal:** Add a third-party package that `gsd-core/bin/**` needs at runtime, in the vendored form the installer's copy step actually supports, without breaking the lint gate that keeps the vendored copy from silently drifting. + +**Prerequisites:** The package is a devDependency already (`npm install --save-dev `), and its build output ships (or can be built into) a self-contained CommonJS/UMD bundle with zero external `require()` calls. + +--- + +## Why vendoring exists at all + +`gsd-core/bin/**` is copied by the installer into trees that have **no `node_modules`** (for example `~/.claude/gsd-core/`). Any external, non-relative, non-builtin `require()`/`import` under `gsd-core/bin/**` breaks every command that touches it for every installed user, because the module simply cannot be resolved on disk there — there is no `node_modules` to resolve it from. `eslint-rules/no-external-require-in-bin.cjs` (`local/no-external-require-in-bin`) enforces this at lint time: it fails on any bare-specifier `require`/`import` under `gsd-core/bin/**`, whether or not the target actually exists in this repo's own `node_modules`. Vendoring — copying the package's compiled build artifact in-tree under `gsd-core/bin/lib/vendor/` and importing it with a relative path — is the only way around that constraint; there is no exemption mechanism, and there should not be one. + +## The package MUST stay a devDependency + +The package that gets vendored stays pinned in `package.json` `devDependencies`, never `dependencies`. Promoting it to `dependencies` does not make the vendored copy redundant — the installer never runs `npm install` on your behalf inside a target tree, so a runtime `dependencies` entry buys nothing there — and it actively breaks every already-installed tree's own `npm install`/`npm ci` step, which now expects a package that is not vendored anywhere the installed tree can see. #3496 is the concrete cost of getting this wrong: promoting a vendored package to `dependencies` produced 100 test failures across 8 install-surface suites. `devDependencies` is correct precisely because the vendored copy, not the npm-resolved package, is what ships at runtime; the devDependency exists only so this repo's own build/lint/test tooling has something to byte-compare the vendored copy against. + +## Picking the right upstream artifact + +Not every file the package ships is vendorable. You need a **self-contained CJS or UMD bundle** — one file, loadable with a single `require()`, containing zero `require()` calls of its own to anything outside Node builtins. Do not reach for the package's `exports.require`/`main` entry point (often `index.js`) without checking it first: that entry is frequently a thin loader that `require()`s several sibling files, which is exactly the shape vendoring cannot tolerate (a vendored `index.js` copied alone would throw at runtime looking for siblings that were never copied). Look instead for a `dist/` bundle purpose-built for standalone consumption. + +For js-yaml (ADR-3473 §8.1, #3881) the correct artifact is `dist/js-yaml.js` — the UMD bundle, self-contained, loads under `require()` with zero external `require()` calls, and exposes the symbols this repo needs (`load`, `dump`, `FAILSAFE_SCHEMA`, `YAMLException`). The tempting-looking `index.js` (the `exports.require` entry point) is **not** self-contained and is the wrong choice. Verify your candidate the same way: `require()` it in isolation (outside this repo's `node_modules` resolution, e.g. from a scratch directory with only that one file present) and confirm it loads without reaching for a sibling file. + +## Adding the `VENDORED` manifest row + +`scripts/lint-vendored-deps.cjs` is table-driven over a `VENDORED` array (one row per vendored package) rather than hardcoded to a single package — this is deliberate (ADR-3473 §8.3, "one implementation per rule"): adding a second or third vendored package should never require a second hardcoded check block. Add a row: + +```js +{ + name: 'your-package', // matches package.json devDependencies key + upstreamCjs: 'node_modules/your-package/dist/bundle.js', // the self-contained artifact you picked above + vendoredCjs: 'gsd-core/bin/lib/vendor/your-package.cjs', + upstreamDts: null, // or a path, if the package ships its own .d.ts/.d.cts + vendoredDts: null, // or the gsd-core/bin/lib/vendor/ copy of that .d.ts + srcTwin: 'src/vendor/your-package.d.cts', + twinKind: 'hand-authored', // or 'upstream-verbatim' — see below +}, +``` + +Then copy the artifact in: + +``` +cp node_modules/your-package/dist/bundle.js gsd-core/bin/lib/vendor/your-package.cjs +``` + +## The two kinds of type twin + +Every vendored package needs a `.d.cts` under `src/vendor/` so TypeScript can resolve types for the relative `./vendor/your-package.cjs` import from `src/**` — module resolution for a `.cts` source is relative to `src/`, not the compiled output directory, so `gsd-core/bin/lib/vendor/your-package.d.cts` alone is not enough. There are two kinds, distinguished by `twinKind`: + +- **`upstream-verbatim`** — the package ships its own `.d.ts`/`.d.cts` upstream. Copy it verbatim to both `gsd-core/bin/lib/vendor/your-package.d.cts` and `src/vendor/your-package.d.cts`. `lint-vendored-deps.cjs` byte-compares both copies against the upstream file and against each other, so any manual edit is caught as drift. +- **`hand-authored`** — the package ships no type declarations upstream (js-yaml's case: no bundled `.d.ts`, and `@types/js-yaml` is not installed). Write `src/vendor/your-package.d.cts` by hand, declaring only the symbols this repo actually imports — narrower is safer, since anything not declared is simply unreachable from typed code. This twin is **excluded** from the byte-compare (there is no upstream file to compare it against) and is instead pinned by a test asserting the declared surface matches what the module actually uses. + +Set `upstreamDts`/`vendoredDts` to `null` for a hand-authored twin — `checkRow` in `scripts/lint-vendored-deps.cjs` skips the byte-compare checks entirely when either is `null`. + +## Document it in `gsd-core/bin/lib/vendor/README.md` + +Add an entry alongside the existing ones: which upstream artifact you copied, why it (and not the package's main entry point) is the vendorable one, which symbols it exposes, and the refresh command. This is the first place a future contributor looks when `lint-vendored-deps.cjs` reports drift. + +## The `docs/INVENTORY.md` row + +`gsd-core/bin/lib/vendor/your-package.cjs` is a shipped file under `gsd-core/bin/**`, so it needs a row in `docs/INVENTORY.md` per the inventory-drift rule for anything added under a manifest-scanned directory. Describe it as a vendored third-party artifact (not a GSD module), name the ADR/issue that introduced it, and cross-reference `gsd-core/bin/lib/vendor/README.md`. + +## The ordering trap: build before you regenerate the manifest + +`node scripts/gen-inventory-manifest.cjs --write` derives its manifest from the **compiled** `gsd-core/bin/**` tree, not from `src/**`. If you regenerate the inventory manifest before running `npm run build:lib`, the generator either misses your new vendored file (if `gsd-core/bin/lib/vendor/your-package.cjs` was not yet copied in) or captures a stale prior build's contents. The required order is: + +1. `cp node_modules/your-package/dist/bundle.js gsd-core/bin/lib/vendor/your-package.cjs` (and the type twins, per above) +2. `npm run build:lib` +3. `node scripts/gen-inventory-manifest.cjs --write` + +Running step 3 before step 1/2 produces a manifest that silently omits or misdescribes the new vendored file, and that drift is exactly what the manifest gate exists to catch on someone else's PR instead of yours. + +## Verifying + +- `node scripts/lint-vendored-deps.cjs` — exits 0 once the vendored copy, its type twins (if `upstream-verbatim`), and the `package.json` devDependency version pin all agree with `node_modules`. +- `npx tsc --noEmit -p tsconfig.json` — confirms the `src/vendor/*.d.cts` twin actually resolves for every `.cts` importer. +- `npm run lint` — confirms `local/no-external-require-in-bin` still finds zero external requires under `gsd-core/bin/**`. + +--- + +## Related + +- `gsd-core/bin/lib/vendor/README.md` — the vendored-files README this how-to keeps in step with +- `scripts/lint-vendored-deps.cjs` — the table-driven freshness gate (`VENDORED` array) +- `eslint-rules/no-external-require-in-bin.cjs` — the rule that makes vendoring necessary in the first place +- ADR-3473 §8.1 (#3881) — the js-yaml vendoring this how-to was extracted from +- [docs index](../README.md) diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 5b5b3912c..7c91d3588 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -4195,7 +4195,7 @@ function runWithTimeout(argv) { // this string and HOST_COMMAND_ROUTERS/SKIP_ROOT_RESOLUTION are three // independently hand-maintained sites and nothing previously caught them // drifting apart when a query command was added to only one or two. -const TOP_LEVEL_USAGE = 'Usage: gsd-tools [args] [--raw] [--pick ] [--cwd ] [--ws ] [--json-errors]\n' + +const TOP_LEVEL_USAGE = 'Usage: gsd-tools [args] [--raw] [--pick ] [--cwd ] [--project-dir ] [--ws ] [--json-errors]\n' + 'Commands: agent, agent-skills, assumption-delta, audit-open, audit-uat, check, check-commit, commit, commit-docs-guard, commit-to-subrepo, pr-subrepo, ' + 'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, normalize-test-command, ' + 'context-predicates, current-timestamp, detect-custom-files, docs-init, drift-guard, effort, extract-messages, find-phase, ' + @@ -4210,6 +4210,7 @@ const TOP_LEVEL_USAGE = 'Usage: gsd-tools [args] [--raw] [--pick Extract a single field from JSON output (dot/bracket notation)\n' + ' --cwd Override working directory for project-root resolution\n' + + ' --project-dir Explicit project root; skips the ancestor walk-up entirely (must already contain .planning/)\n' + ' --ws Override active workstream (or set GSD_WORKSTREAM)\n' + ' --json-errors Emit structured JSON error objects on stderr (or set GSD_JSON_ERRORS=1)\n\n' + 'For command-specific argument requirements, invoke the command without args ' + @@ -4356,6 +4357,39 @@ async function main() { error(`Invalid --cwd: ${cwd}`, ERROR_REASON.USAGE); } + // #3881: --project-dir is a documented (docs/CONFIGURATION.md, + // "Project-Root Resolution in Multi-Repo Workspaces") explicit override of + // the project root. It is idempotent under findProjectRoot's ancestor + // walk-up — i.e. it short-circuits the walk-up rather than seeding it — + // so it MUST be validated and applied here, before findProjectRoot ever + // runs, and its result must skip that call entirely below. A relative + // value resolves against process.cwd(), matching --cwd's own resolution. + let projectDirExplicit = false; + const projectDirEqArg = args.find(arg => arg.startsWith('--project-dir=')); + const projectDirIdx = args.indexOf('--project-dir'); + let projectDirValue; + if (projectDirEqArg) { + projectDirValue = projectDirEqArg.slice('--project-dir='.length).trim(); + if (!projectDirValue) error('Missing value for --project-dir', ERROR_REASON.USAGE); + args.splice(args.indexOf(projectDirEqArg), 1); + } else if (projectDirIdx !== -1) { + projectDirValue = args[projectDirIdx + 1]; + if (!projectDirValue || projectDirValue.startsWith('--')) error('Missing value for --project-dir', ERROR_REASON.USAGE); + args.splice(projectDirIdx, 2); + } + if (projectDirValue !== undefined) { + const resolvedProjectDir = path.resolve(projectDirValue); + if (!fs.existsSync(resolvedProjectDir) || !fs.statSync(resolvedProjectDir).isDirectory()) { + error(`Invalid --project-dir: ${resolvedProjectDir} (path does not exist or is not a directory)`, ERROR_REASON.USAGE); + } + const resolvedProjectDirPlanning = path.join(resolvedProjectDir, '.planning'); + if (!fs.existsSync(resolvedProjectDirPlanning) || !fs.statSync(resolvedProjectDirPlanning).isDirectory()) { + error(`Invalid --project-dir: ${resolvedProjectDir} (no .planning/ directory found — --project-dir must name the project root itself, not an ancestor to walk up from)`, ERROR_REASON.USAGE); + } + cwd = resolvedProjectDir; + projectDirExplicit = true; + } + // Resolve worktree root: in a linked worktree, .planning/ lives in the main worktree. // However, in monorepo worktrees where the subdirectory itself owns .planning/, // skip worktree resolution — the CWD is already the correct project root. @@ -4468,7 +4502,10 @@ async function main() { } } - if (!SKIP_ROOT_RESOLUTION.has(command)) { + // #3881: an explicit --project-dir already IS the resolved project root + // (validated above) — findProjectRoot's ancestor walk-up must not run + // over it, per docs/CONFIGURATION.md's documented idempotence. + if (!projectDirExplicit && !SKIP_ROOT_RESOLUTION.has(command)) { cwd = findProjectRoot(cwd); } diff --git a/gsd-core/bin/lib/vendor/README.md b/gsd-core/bin/lib/vendor/README.md index 58860c1f0..ed1518bb1 100644 --- a/gsd-core/bin/lib/vendor/README.md +++ b/gsd-core/bin/lib/vendor/README.md @@ -21,17 +21,55 @@ in-tree instead of depending on it being installed as an npm package. `gsd-core/bin/lib/pattern.cjs`) for linear-time RE2 pattern compilation. - `re2js.d.cts` — verbatim copy of `node_modules/re2js/build/index.d.cts`, so TypeScript resolves types for the relative import from `src/pattern.cts`. +- `js-yaml.cjs` — verbatim copy of `node_modules/js-yaml/dist/js-yaml.js` + (upstream package `js-yaml`, pinned version see `package.json` + `devDependencies.js-yaml`). ADR-3473 §8.1 (#3881): the single YAML parser + this repo standardizes on. The `dist` UMD bundle is the vendorable + artifact — js-yaml's `exports.require` entry (`index.js`) is *not* + self-contained; `dist/js-yaml.js` loads under `require()`, contains zero + `require()` calls of its own, and exposes `load`/`dump`/`FAILSAFE_SCHEMA`/ + `YAMLException`. + +## Two kinds of type twin + +Each vendored package needs a `.d.cts` under `src/vendor/` so TypeScript can +resolve types for a relative `./vendor/.cjs` import from `src/**` +(module resolution for a `.cts` source is relative to `src/`, not the +compiled output dir). There are two kinds: + +- **upstream-verbatim** (`re2js.d.cts`) — a byte-for-byte copy of an + upstream `.d.cts`/`.d.ts` that ships with the package. Both the + `gsd-core/bin/lib/vendor/` copy and the `src/vendor/` copy are checked by + `scripts/lint-vendored-deps.cjs` against `node_modules` and against each + other. +- **hand-authored** (`js-yaml.d.cts`) — js-yaml ships **no** type + declarations upstream and `@types/js-yaml` is not installed, so there is + nothing to copy verbatim. `src/vendor/js-yaml.d.cts` is written by hand, + declares only the symbols actually used (`load`, `dump`, + `FAILSAFE_SCHEMA`, `YAMLException`), and is **excluded** from + `lint-vendored-deps.cjs`'s byte-compare — there is no upstream file to + compare it against. The narrowness is deliberate: anchors, aliases, + custom types and `loadAll` are unreachable from typed code, which is a + compile-time enforcement of ADR-3473 §8.1's refusal to expand them. ## Do not hand-edit -These files are **verbatim** copies of the upstream build output. Never -edit them directly — refresh them from `node_modules` instead: +These files are **verbatim** copies of upstream build output. Never edit +them directly — refresh them from `node_modules` instead: ``` cp node_modules/re2js/build/index.cjs gsd-core/bin/lib/vendor/re2js.cjs cp node_modules/re2js/build/index.d.cts gsd-core/bin/lib/vendor/re2js.d.cts +cp node_modules/re2js/build/index.d.cts src/vendor/re2js.d.cts + +cp node_modules/js-yaml/dist/js-yaml.js gsd-core/bin/lib/vendor/js-yaml.cjs +# js-yaml.d.cts has no upstream counterpart — update src/vendor/js-yaml.d.cts +# by hand if the js-yaml API surface this repo depends on changes. ``` -`node scripts/lint-vendored-deps.cjs` fails CI if the vendored copy drifts -byte-for-byte from `node_modules/re2js/build/` or from the `re2js` version -pinned in `package.json` `devDependencies`. +`node scripts/lint-vendored-deps.cjs` fails CI if any vendored copy drifts +byte-for-byte from its `node_modules` upstream, from its own source-side +type twin (upstream-verbatim twins only), or from the version pinned in +`package.json` `devDependencies`. It is table-driven (`VENDORED` in that +script) — adding a third vendored package means adding a row, not a second +hardcoded check block (ADR-3473 §8.3, "one implementation per rule"). diff --git a/gsd-core/bin/lib/vendor/js-yaml.cjs b/gsd-core/bin/lib/vendor/js-yaml.cjs new file mode 100644 index 000000000..ba95c688b --- /dev/null +++ b/gsd-core/bin/lib/vendor/js-yaml.cjs @@ -0,0 +1,3014 @@ +(function(global, factory) { + typeof exports === "object" && typeof module !== "undefined" ? factory(exports) : typeof define === "function" && define.amd ? define([ "exports" ], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, + factory(global.jsyaml = {})); +})(this, function(exports2) { + "use strict"; + function getDefaultExportFromCjs(x) { + return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, "default") ? x["default"] : x; + } + var jsYaml = {}; + var loader = {}; + var common = {}; + var hasRequiredCommon; + function requireCommon() { + if (hasRequiredCommon) return common; + hasRequiredCommon = 1; + function _typeof(o) { + "@babel/helpers - typeof"; + return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o2) { + return typeof o2; + } : function(o2) { + return o2 && "function" == typeof Symbol && o2.constructor === Symbol && o2 !== Symbol.prototype ? "symbol" : typeof o2; + }, _typeof(o); + } + function isNothing(subject) { + return typeof subject === "undefined" || subject === null; + } + function isObject(subject) { + return _typeof(subject) === "object" && subject !== null; + } + function toArray(sequence) { + if (Array.isArray(sequence)) return sequence; else if (isNothing(sequence)) return []; + return [ sequence ]; + } + function extend(target, source) { + if (source) { + var sourceKeys = Object.keys(source); + for (var index = 0, length = sourceKeys.length; index < length; index += 1) { + var key = sourceKeys[index]; + target[key] = source[key]; + } + } + return target; + } + function repeat(string, count) { + var result = ""; + for (var cycle = 0; cycle < count; cycle += 1) { + result += string; + } + return result; + } + function isNegativeZero(number) { + return number === 0 && Number.NEGATIVE_INFINITY === 1 / number; + } + common.isNothing = isNothing; + common.isObject = isObject; + common.toArray = toArray; + common.repeat = repeat; + common.isNegativeZero = isNegativeZero; + common.extend = extend; + return common; + } + var exception; + var hasRequiredException; + function requireException() { + if (hasRequiredException) return exception; + hasRequiredException = 1; + function formatError(exception2, compact) { + var where = ""; + var message = exception2.reason || "(unknown reason)"; + if (!exception2.mark) return message; + if (exception2.mark.name) { + where += 'in "' + exception2.mark.name + '" '; + } + where += "(" + (exception2.mark.line + 1) + ":" + (exception2.mark.column + 1) + ")"; + if (!compact && exception2.mark.snippet) { + where += "\n\n" + exception2.mark.snippet; + } + return message + " " + where; + } + function YAMLException2(reason, mark) { + Error.call(this); + this.name = "YAMLException"; + this.reason = reason; + this.mark = mark; + this.message = formatError(this, false); + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } else { + this.stack = (new Error).stack || ""; + } + } + YAMLException2.prototype = Object.create(Error.prototype); + YAMLException2.prototype.constructor = YAMLException2; + YAMLException2.prototype.toString = function toString(compact) { + return this.name + ": " + formatError(this, compact); + }; + exception = YAMLException2; + return exception; + } + var snippet; + var hasRequiredSnippet; + function requireSnippet() { + if (hasRequiredSnippet) return snippet; + hasRequiredSnippet = 1; + var common2 = requireCommon(); + function getLine(buffer, lineStart, lineEnd, position, maxLineLength) { + var head = ""; + var tail = ""; + var maxHalfLength = Math.floor(maxLineLength / 2) - 1; + if (position - lineStart > maxHalfLength) { + head = " ... "; + lineStart = position - maxHalfLength + head.length; + } + if (lineEnd - position > maxHalfLength) { + tail = " ..."; + lineEnd = position + maxHalfLength - tail.length; + } + return { + str: head + buffer.slice(lineStart, lineEnd).replace(/\t/g, "→") + tail, + pos: position - lineStart + head.length + }; + } + function padStart(string, max) { + return common2.repeat(" ", max - string.length) + string; + } + function makeSnippet(mark, options) { + options = Object.create(options || null); + if (!mark.buffer) return null; + if (!options.maxLength) options.maxLength = 79; + if (typeof options.indent !== "number") options.indent = 1; + if (typeof options.linesBefore !== "number") options.linesBefore = 3; + if (typeof options.linesAfter !== "number") options.linesAfter = 2; + var re = /\r?\n|\r|\0/g; + var lineStarts = [ 0 ]; + var lineEnds = []; + var match; + var foundLineNo = -1; + while (match = re.exec(mark.buffer)) { + lineEnds.push(match.index); + lineStarts.push(match.index + match[0].length); + if (mark.position <= match.index && foundLineNo < 0) { + foundLineNo = lineStarts.length - 2; + } + } + if (foundLineNo < 0) foundLineNo = lineStarts.length - 1; + var result = ""; + var lineNoLength = Math.min(mark.line + options.linesAfter, lineEnds.length).toString().length; + var maxLineLength = options.maxLength - (options.indent + lineNoLength + 3); + for (var i = 1; i <= options.linesBefore; i++) { + if (foundLineNo - i < 0) break; + var _line = getLine(mark.buffer, lineStarts[foundLineNo - i], lineEnds[foundLineNo - i], mark.position - (lineStarts[foundLineNo] - lineStarts[foundLineNo - i]), maxLineLength); + result = common2.repeat(" ", options.indent) + padStart((mark.line - i + 1).toString(), lineNoLength) + " | " + _line.str + "\n" + result; + } + var line = getLine(mark.buffer, lineStarts[foundLineNo], lineEnds[foundLineNo], mark.position, maxLineLength); + result += common2.repeat(" ", options.indent) + padStart((mark.line + 1).toString(), lineNoLength) + " | " + line.str + "\n"; + result += common2.repeat("-", options.indent + lineNoLength + 3 + line.pos) + "^\n"; + for (var _i = 1; _i <= options.linesAfter; _i++) { + if (foundLineNo + _i >= lineEnds.length) break; + var _line2 = getLine(mark.buffer, lineStarts[foundLineNo + _i], lineEnds[foundLineNo + _i], mark.position - (lineStarts[foundLineNo] - lineStarts[foundLineNo + _i]), maxLineLength); + result += common2.repeat(" ", options.indent) + padStart((mark.line + _i + 1).toString(), lineNoLength) + " | " + _line2.str + "\n"; + } + return result.replace(/\n$/, ""); + } + snippet = makeSnippet; + return snippet; + } + var type; + var hasRequiredType; + function requireType() { + if (hasRequiredType) return type; + hasRequiredType = 1; + var YAMLException2 = requireException(); + var TYPE_CONSTRUCTOR_OPTIONS = [ "kind", "multi", "resolve", "construct", "instanceOf", "predicate", "represent", "representName", "defaultStyle", "styleAliases" ]; + var YAML_NODE_KINDS = [ "scalar", "sequence", "mapping" ]; + function compileStyleAliases(map2) { + var result = {}; + if (map2 !== null) { + Object.keys(map2).forEach(function(style) { + map2[style].forEach(function(alias) { + result[String(alias)] = style; + }); + }); + } + return result; + } + function Type2(tag, options) { + options = options || {}; + Object.keys(options).forEach(function(name) { + if (TYPE_CONSTRUCTOR_OPTIONS.indexOf(name) === -1) { + throw new YAMLException2('Unknown option "' + name + '" is met in definition of "' + tag + '" YAML type.'); + } + }); + this.options = options; + this.tag = tag; + this.kind = options["kind"] || null; + this.resolve = options["resolve"] || function() { + return true; + }; + this.construct = options["construct"] || function(data) { + return data; + }; + this.instanceOf = options["instanceOf"] || null; + this.predicate = options["predicate"] || null; + this.represent = options["represent"] || null; + this.representName = options["representName"] || null; + this.defaultStyle = options["defaultStyle"] || null; + this.multi = options["multi"] || false; + this.styleAliases = compileStyleAliases(options["styleAliases"] || null); + if (YAML_NODE_KINDS.indexOf(this.kind) === -1) { + throw new YAMLException2('Unknown kind "' + this.kind + '" is specified for "' + tag + '" YAML type.'); + } + } + type = Type2; + return type; + } + var schema; + var hasRequiredSchema; + function requireSchema() { + if (hasRequiredSchema) return schema; + hasRequiredSchema = 1; + var YAMLException2 = requireException(); + var Type2 = requireType(); + function compileList(schema2, name) { + var result = []; + schema2[name].forEach(function(currentType) { + var newIndex = result.length; + result.forEach(function(previousType, previousIndex) { + if (previousType.tag === currentType.tag && previousType.kind === currentType.kind && previousType.multi === currentType.multi) { + newIndex = previousIndex; + } + }); + result[newIndex] = currentType; + }); + return result; + } + function compileMap() { + var result = { + scalar: {}, + sequence: {}, + mapping: {}, + fallback: {}, + multi: { + scalar: [], + sequence: [], + mapping: [], + fallback: [] + } + }; + function collectType(type2) { + if (type2.multi) { + result.multi[type2.kind].push(type2); + result.multi["fallback"].push(type2); + } else { + result[type2.kind][type2.tag] = result["fallback"][type2.tag] = type2; + } + } + for (var index = 0, length = arguments.length; index < length; index += 1) { + arguments[index].forEach(collectType); + } + return result; + } + function Schema2(definition) { + return this.extend(definition); + } + Schema2.prototype.extend = function extend(definition) { + var implicit = []; + var explicit = []; + if (definition instanceof Type2) { + explicit.push(definition); + } else if (Array.isArray(definition)) { + explicit = explicit.concat(definition); + } else if (definition && (Array.isArray(definition.implicit) || Array.isArray(definition.explicit))) { + if (definition.implicit) implicit = implicit.concat(definition.implicit); + if (definition.explicit) explicit = explicit.concat(definition.explicit); + } else { + throw new YAMLException2("Schema.extend argument should be a Type, [ Type ], or a schema definition ({ implicit: [...], explicit: [...] })"); + } + implicit.forEach(function(type2) { + if (!(type2 instanceof Type2)) { + throw new YAMLException2("Specified list of YAML types (or a single Type object) contains a non-Type object."); + } + if (type2.loadKind && type2.loadKind !== "scalar") { + throw new YAMLException2("There is a non-scalar type in the implicit list of a schema. Implicit resolving of such types is not supported."); + } + if (type2.multi) { + throw new YAMLException2("There is a multi type in the implicit list of a schema. Multi tags can only be listed as explicit."); + } + }); + explicit.forEach(function(type2) { + if (!(type2 instanceof Type2)) { + throw new YAMLException2("Specified list of YAML types (or a single Type object) contains a non-Type object."); + } + }); + var result = Object.create(Schema2.prototype); + result.implicit = (this.implicit || []).concat(implicit); + result.explicit = (this.explicit || []).concat(explicit); + result.compiledImplicit = compileList(result, "implicit"); + result.compiledExplicit = compileList(result, "explicit"); + result.compiledTypeMap = compileMap(result.compiledImplicit, result.compiledExplicit); + return result; + }; + schema = Schema2; + return schema; + } + var str; + var hasRequiredStr; + function requireStr() { + if (hasRequiredStr) return str; + hasRequiredStr = 1; + var Type2 = requireType(); + str = new Type2("tag:yaml.org,2002:str", { + kind: "scalar", + construct: function construct(data) { + return data !== null ? data : ""; + } + }); + return str; + } + var seq; + var hasRequiredSeq; + function requireSeq() { + if (hasRequiredSeq) return seq; + hasRequiredSeq = 1; + var Type2 = requireType(); + seq = new Type2("tag:yaml.org,2002:seq", { + kind: "sequence", + construct: function construct(data) { + return data !== null ? data : []; + } + }); + return seq; + } + var map; + var hasRequiredMap; + function requireMap() { + if (hasRequiredMap) return map; + hasRequiredMap = 1; + var Type2 = requireType(); + map = new Type2("tag:yaml.org,2002:map", { + kind: "mapping", + construct: function construct(data) { + return data !== null ? data : {}; + } + }); + return map; + } + var failsafe; + var hasRequiredFailsafe; + function requireFailsafe() { + if (hasRequiredFailsafe) return failsafe; + hasRequiredFailsafe = 1; + var Schema2 = requireSchema(); + failsafe = new Schema2({ + explicit: [ requireStr(), requireSeq(), requireMap() ] + }); + return failsafe; + } + var _null; + var hasRequired_null; + function require_null() { + if (hasRequired_null) return _null; + hasRequired_null = 1; + var Type2 = requireType(); + function resolveYamlNull(data) { + if (data === null) return true; + var max = data.length; + return max === 1 && data === "~" || max === 4 && (data === "null" || data === "Null" || data === "NULL"); + } + function constructYamlNull() { + return null; + } + function isNull(object) { + return object === null; + } + _null = new Type2("tag:yaml.org,2002:null", { + kind: "scalar", + resolve: resolveYamlNull, + construct: constructYamlNull, + predicate: isNull, + represent: { + canonical: function canonical() { + return "~"; + }, + lowercase: function lowercase() { + return "null"; + }, + uppercase: function uppercase() { + return "NULL"; + }, + camelcase: function camelcase() { + return "Null"; + }, + empty: function empty() { + return ""; + } + }, + defaultStyle: "lowercase" + }); + return _null; + } + var bool; + var hasRequiredBool; + function requireBool() { + if (hasRequiredBool) return bool; + hasRequiredBool = 1; + var Type2 = requireType(); + function resolveYamlBoolean(data) { + if (data === null) return false; + var max = data.length; + return max === 4 && (data === "true" || data === "True" || data === "TRUE") || max === 5 && (data === "false" || data === "False" || data === "FALSE"); + } + function constructYamlBoolean(data) { + return data === "true" || data === "True" || data === "TRUE"; + } + function isBoolean(object) { + return Object.prototype.toString.call(object) === "[object Boolean]"; + } + bool = new Type2("tag:yaml.org,2002:bool", { + kind: "scalar", + resolve: resolveYamlBoolean, + construct: constructYamlBoolean, + predicate: isBoolean, + represent: { + lowercase: function lowercase(object) { + return object ? "true" : "false"; + }, + uppercase: function uppercase(object) { + return object ? "TRUE" : "FALSE"; + }, + camelcase: function camelcase(object) { + return object ? "True" : "False"; + } + }, + defaultStyle: "lowercase" + }); + return bool; + } + var int; + var hasRequiredInt; + function requireInt() { + if (hasRequiredInt) return int; + hasRequiredInt = 1; + var common2 = requireCommon(); + var Type2 = requireType(); + function isHexCode(c) { + return c >= 48 && c <= 57 || c >= 65 && c <= 70 || c >= 97 && c <= 102; + } + function isOctCode(c) { + return c >= 48 && c <= 55; + } + function isDecCode(c) { + return c >= 48 && c <= 57; + } + function resolveYamlInteger(data) { + if (data === null) return false; + var max = data.length; + var index = 0; + var hasDigits = false; + if (!max) return false; + var ch = data[index]; + if (ch === "-" || ch === "+") { + ch = data[++index]; + } + if (ch === "0") { + if (index + 1 === max) return true; + ch = data[++index]; + if (ch === "b") { + index++; + for (;index < max; index++) { + ch = data[index]; + if (ch !== "0" && ch !== "1") return false; + hasDigits = true; + } + return hasDigits && isFinite(parseYamlInteger(data)); + } + if (ch === "x") { + index++; + for (;index < max; index++) { + if (!isHexCode(data.charCodeAt(index))) return false; + hasDigits = true; + } + return hasDigits && isFinite(parseYamlInteger(data)); + } + if (ch === "o") { + index++; + for (;index < max; index++) { + if (!isOctCode(data.charCodeAt(index))) return false; + hasDigits = true; + } + return hasDigits && isFinite(parseYamlInteger(data)); + } + } + for (;index < max; index++) { + if (!isDecCode(data.charCodeAt(index))) { + return false; + } + hasDigits = true; + } + if (!hasDigits) return false; + return isFinite(parseYamlInteger(data)); + } + function parseYamlInteger(data) { + var value = data; + var sign = 1; + var ch = value[0]; + if (ch === "-" || ch === "+") { + if (ch === "-") sign = -1; + value = value.slice(1); + ch = value[0]; + } + if (value === "0") return 0; + if (ch === "0") { + if (value[1] === "b") return sign * parseInt(value.slice(2), 2); + if (value[1] === "x") return sign * parseInt(value.slice(2), 16); + if (value[1] === "o") return sign * parseInt(value.slice(2), 8); + } + return sign * parseInt(value, 10); + } + function constructYamlInteger(data) { + return parseYamlInteger(data); + } + function isInteger(object) { + return Object.prototype.toString.call(object) === "[object Number]" && object % 1 === 0 && !common2.isNegativeZero(object); + } + int = new Type2("tag:yaml.org,2002:int", { + kind: "scalar", + resolve: resolveYamlInteger, + construct: constructYamlInteger, + predicate: isInteger, + represent: { + binary: function binary2(obj) { + return obj >= 0 ? "0b" + obj.toString(2) : "-0b" + obj.toString(2).slice(1); + }, + octal: function octal(obj) { + return obj >= 0 ? "0o" + obj.toString(8) : "-0o" + obj.toString(8).slice(1); + }, + decimal: function decimal(obj) { + return obj.toString(10); + }, + hexadecimal: function hexadecimal(obj) { + return obj >= 0 ? "0x" + obj.toString(16).toUpperCase() : "-0x" + obj.toString(16).toUpperCase().slice(1); + } + }, + defaultStyle: "decimal", + styleAliases: { + binary: [ 2, "bin" ], + octal: [ 8, "oct" ], + decimal: [ 10, "dec" ], + hexadecimal: [ 16, "hex" ] + } + }); + return int; + } + var float; + var hasRequiredFloat; + function requireFloat() { + if (hasRequiredFloat) return float; + hasRequiredFloat = 1; + var common2 = requireCommon(); + var Type2 = requireType(); + var YAML_FLOAT_PATTERN = new RegExp("^(?:[-+]?(?:[0-9]+)(?:\\.[0-9]*)?(?:[eE][-+]?[0-9]+)?|\\.[0-9]+(?:[eE][-+]?[0-9]+)?|[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$"); + var YAML_FLOAT_SPECIAL_PATTERN = new RegExp("^(?:[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$"); + function resolveYamlFloat(data) { + if (data === null) return false; + if (!YAML_FLOAT_PATTERN.test(data)) { + return false; + } + if (isFinite(parseFloat(data, 10))) { + return true; + } + return YAML_FLOAT_SPECIAL_PATTERN.test(data); + } + function constructYamlFloat(data) { + var value = data.toLowerCase(); + var sign = value[0] === "-" ? -1 : 1; + if ("+-".indexOf(value[0]) >= 0) { + value = value.slice(1); + } + if (value === ".inf") { + return sign === 1 ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY; + } else if (value === ".nan") { + return NaN; + } + return sign * parseFloat(value, 10); + } + var SCIENTIFIC_WITHOUT_DOT = /^[-+]?[0-9]+e/; + function representYamlFloat(object, style) { + if (isNaN(object)) { + switch (style) { + case "lowercase": + return ".nan"; + + case "uppercase": + return ".NAN"; + + case "camelcase": + return ".NaN"; + } + } else if (Number.POSITIVE_INFINITY === object) { + switch (style) { + case "lowercase": + return ".inf"; + + case "uppercase": + return ".INF"; + + case "camelcase": + return ".Inf"; + } + } else if (Number.NEGATIVE_INFINITY === object) { + switch (style) { + case "lowercase": + return "-.inf"; + + case "uppercase": + return "-.INF"; + + case "camelcase": + return "-.Inf"; + } + } else if (common2.isNegativeZero(object)) { + return "-0.0"; + } + var res = object.toString(10); + return SCIENTIFIC_WITHOUT_DOT.test(res) ? res.replace("e", ".e") : res; + } + function isFloat(object) { + return Object.prototype.toString.call(object) === "[object Number]" && (object % 1 !== 0 || common2.isNegativeZero(object)); + } + float = new Type2("tag:yaml.org,2002:float", { + kind: "scalar", + resolve: resolveYamlFloat, + construct: constructYamlFloat, + predicate: isFloat, + represent: representYamlFloat, + defaultStyle: "lowercase" + }); + return float; + } + var json; + var hasRequiredJson; + function requireJson() { + if (hasRequiredJson) return json; + hasRequiredJson = 1; + json = requireFailsafe().extend({ + implicit: [ require_null(), requireBool(), requireInt(), requireFloat() ] + }); + return json; + } + var core; + var hasRequiredCore; + function requireCore() { + if (hasRequiredCore) return core; + hasRequiredCore = 1; + core = requireJson(); + return core; + } + var timestamp; + var hasRequiredTimestamp; + function requireTimestamp() { + if (hasRequiredTimestamp) return timestamp; + hasRequiredTimestamp = 1; + var Type2 = requireType(); + var YAML_DATE_REGEXP = new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$"); + var YAML_TIMESTAMP_REGEXP = new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)(?:[Tt]|[ \\t]+)([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?(?:[ \\t]*(Z|([-+])([0-9][0-9]?)(?::([0-9][0-9]))?))?$"); + function resolveYamlTimestamp(data) { + if (data === null) return false; + if (YAML_DATE_REGEXP.exec(data) !== null) return true; + if (YAML_TIMESTAMP_REGEXP.exec(data) !== null) return true; + return false; + } + function constructYamlTimestamp(data) { + var fraction = 0; + var delta = null; + var match = YAML_DATE_REGEXP.exec(data); + if (match === null) match = YAML_TIMESTAMP_REGEXP.exec(data); + if (match === null) throw new Error("Date resolve error"); + var year = +match[1]; + var month = +match[2] - 1; + var day = +match[3]; + if (!match[4]) { + return new Date(Date.UTC(year, month, day)); + } + var hour = +match[4]; + var minute = +match[5]; + var second = +match[6]; + if (match[7]) { + fraction = match[7].slice(0, 3); + while (fraction.length < 3) { + fraction += "0"; + } + fraction = +fraction; + } + if (match[9]) { + var tzHour = +match[10]; + var tzMinute = +(match[11] || 0); + delta = (tzHour * 60 + tzMinute) * 6e4; + if (match[9] === "-") delta = -delta; + } + var date = new Date(Date.UTC(year, month, day, hour, minute, second, fraction)); + if (delta) date.setTime(date.getTime() - delta); + return date; + } + function representYamlTimestamp(object) { + return object.toISOString(); + } + timestamp = new Type2("tag:yaml.org,2002:timestamp", { + kind: "scalar", + resolve: resolveYamlTimestamp, + construct: constructYamlTimestamp, + instanceOf: Date, + represent: representYamlTimestamp + }); + return timestamp; + } + var merge; + var hasRequiredMerge; + function requireMerge() { + if (hasRequiredMerge) return merge; + hasRequiredMerge = 1; + var Type2 = requireType(); + function resolveYamlMerge(data) { + return data === "<<" || data === null; + } + merge = new Type2("tag:yaml.org,2002:merge", { + kind: "scalar", + resolve: resolveYamlMerge + }); + return merge; + } + var binary; + var hasRequiredBinary; + function requireBinary() { + if (hasRequiredBinary) return binary; + hasRequiredBinary = 1; + var Type2 = requireType(); + var BASE64_MAP = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\n\r"; + function resolveYamlBinary(data) { + if (data === null) return false; + var bitlen = 0; + var max = data.length; + var map2 = BASE64_MAP; + for (var idx = 0; idx < max; idx++) { + var code = map2.indexOf(data.charAt(idx)); + if (code > 64) continue; + if (code < 0) return false; + bitlen += 6; + } + return bitlen % 8 === 0; + } + function constructYamlBinary(data) { + var input = data.replace(/[\r\n=]/g, ""); + var max = input.length; + var map2 = BASE64_MAP; + var bits = 0; + var result = []; + for (var idx = 0; idx < max; idx++) { + if (idx % 4 === 0 && idx) { + result.push(bits >> 16 & 255); + result.push(bits >> 8 & 255); + result.push(bits & 255); + } + bits = bits << 6 | map2.indexOf(input.charAt(idx)); + } + var tailbits = max % 4 * 6; + if (tailbits === 0) { + result.push(bits >> 16 & 255); + result.push(bits >> 8 & 255); + result.push(bits & 255); + } else if (tailbits === 18) { + result.push(bits >> 10 & 255); + result.push(bits >> 2 & 255); + } else if (tailbits === 12) { + result.push(bits >> 4 & 255); + } + return new Uint8Array(result); + } + function representYamlBinary(object) { + var result = ""; + var bits = 0; + var max = object.length; + var map2 = BASE64_MAP; + for (var idx = 0; idx < max; idx++) { + if (idx % 3 === 0 && idx) { + result += map2[bits >> 18 & 63]; + result += map2[bits >> 12 & 63]; + result += map2[bits >> 6 & 63]; + result += map2[bits & 63]; + } + bits = (bits << 8) + object[idx]; + } + var tail = max % 3; + if (tail === 0) { + result += map2[bits >> 18 & 63]; + result += map2[bits >> 12 & 63]; + result += map2[bits >> 6 & 63]; + result += map2[bits & 63]; + } else if (tail === 2) { + result += map2[bits >> 10 & 63]; + result += map2[bits >> 4 & 63]; + result += map2[bits << 2 & 63]; + result += map2[64]; + } else if (tail === 1) { + result += map2[bits >> 2 & 63]; + result += map2[bits << 4 & 63]; + result += map2[64]; + result += map2[64]; + } + return result; + } + function isBinary(obj) { + return Object.prototype.toString.call(obj) === "[object Uint8Array]"; + } + binary = new Type2("tag:yaml.org,2002:binary", { + kind: "scalar", + resolve: resolveYamlBinary, + construct: constructYamlBinary, + predicate: isBinary, + represent: representYamlBinary + }); + return binary; + } + var omap; + var hasRequiredOmap; + function requireOmap() { + if (hasRequiredOmap) return omap; + hasRequiredOmap = 1; + var Type2 = requireType(); + var _hasOwnProperty = Object.prototype.hasOwnProperty; + var _toString = Object.prototype.toString; + function resolveYamlOmap(data) { + if (data === null) return true; + var objectKeys = {}; + var object = data; + for (var index = 0, length = object.length; index < length; index += 1) { + var pair = object[index]; + var pairHasKey = false; + if (_toString.call(pair) !== "[object Object]") return false; + var pairKey = void 0; + for (pairKey in pair) { + if (_hasOwnProperty.call(pair, pairKey)) { + if (!pairHasKey) pairHasKey = true; else return false; + } + } + if (!pairHasKey) return false; + if (_hasOwnProperty.call(objectKeys, pairKey)) return false; + Object.defineProperty(objectKeys, pairKey, { + value: true + }); + } + return true; + } + function constructYamlOmap(data) { + return data !== null ? data : []; + } + omap = new Type2("tag:yaml.org,2002:omap", { + kind: "sequence", + resolve: resolveYamlOmap, + construct: constructYamlOmap + }); + return omap; + } + var pairs; + var hasRequiredPairs; + function requirePairs() { + if (hasRequiredPairs) return pairs; + hasRequiredPairs = 1; + var Type2 = requireType(); + var _toString = Object.prototype.toString; + function resolveYamlPairs(data) { + if (data === null) return true; + var object = data; + var result = new Array(object.length); + for (var index = 0, length = object.length; index < length; index += 1) { + var pair = object[index]; + if (_toString.call(pair) !== "[object Object]") return false; + var keys = Object.keys(pair); + if (keys.length !== 1) return false; + result[index] = [ keys[0], pair[keys[0]] ]; + } + return true; + } + function constructYamlPairs(data) { + if (data === null) return []; + var object = data; + var result = new Array(object.length); + for (var index = 0, length = object.length; index < length; index += 1) { + var pair = object[index]; + var keys = Object.keys(pair); + result[index] = [ keys[0], pair[keys[0]] ]; + } + return result; + } + pairs = new Type2("tag:yaml.org,2002:pairs", { + kind: "sequence", + resolve: resolveYamlPairs, + construct: constructYamlPairs + }); + return pairs; + } + var set; + var hasRequiredSet; + function requireSet() { + if (hasRequiredSet) return set; + hasRequiredSet = 1; + var Type2 = requireType(); + var _hasOwnProperty = Object.prototype.hasOwnProperty; + function resolveYamlSet(data) { + if (data === null) return true; + var object = data; + for (var key in object) { + if (_hasOwnProperty.call(object, key)) { + if (object[key] !== null) return false; + } + } + return true; + } + function constructYamlSet(data) { + return data !== null ? data : {}; + } + set = new Type2("tag:yaml.org,2002:set", { + kind: "mapping", + resolve: resolveYamlSet, + construct: constructYamlSet + }); + return set; + } + var _default; + var hasRequired_default; + function require_default() { + if (hasRequired_default) return _default; + hasRequired_default = 1; + _default = requireCore().extend({ + implicit: [ requireTimestamp(), requireMerge() ], + explicit: [ requireBinary(), requireOmap(), requirePairs(), requireSet() ] + }); + return _default; + } + var hasRequiredLoader; + function requireLoader() { + if (hasRequiredLoader) return loader; + hasRequiredLoader = 1; + function _typeof(o) { + "@babel/helpers - typeof"; + return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o2) { + return typeof o2; + } : function(o2) { + return o2 && "function" == typeof Symbol && o2.constructor === Symbol && o2 !== Symbol.prototype ? "symbol" : typeof o2; + }, _typeof(o); + } + var common2 = requireCommon(); + var YAMLException2 = requireException(); + var makeSnippet = requireSnippet(); + var DEFAULT_SCHEMA2 = require_default(); + var _hasOwnProperty = Object.prototype.hasOwnProperty; + var CONTEXT_FLOW_IN = 1; + var CONTEXT_FLOW_OUT = 2; + var CONTEXT_BLOCK_IN = 3; + var CONTEXT_BLOCK_OUT = 4; + var CHOMPING_CLIP = 1; + var CHOMPING_STRIP = 2; + var CHOMPING_KEEP = 3; + var PATTERN_NON_PRINTABLE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x84\x86-\x9F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/; + var PATTERN_NON_ASCII_LINE_BREAKS = /[\x85\u2028\u2029]/; + var PATTERN_FLOW_INDICATORS = /[,\[\]{}]/; + var PATTERN_TAG_HANDLE = /^(?:!|!!|![0-9A-Za-z-]+!)$/; + var PATTERN_TAG_URI = /^(?:!|[^,\[\]{}])(?:%[0-9a-f]{2}|[0-9a-z\-#;/?:@&=+$,_.!~*'()\[\]])*$/i; + function _class(obj) { + return Object.prototype.toString.call(obj); + } + function isEol(c) { + return c === 10 || c === 13; + } + function isWhiteSpace(c) { + return c === 9 || c === 32; + } + function isWsOrEol(c) { + return c === 9 || c === 32 || c === 10 || c === 13; + } + function isFlowIndicator(c) { + return c === 44 || c === 91 || c === 93 || c === 123 || c === 125; + } + function fromHexCode(c) { + if (c >= 48 && c <= 57) { + return c - 48; + } + var lc = c | 32; + if (lc >= 97 && lc <= 102) { + return lc - 97 + 10; + } + return -1; + } + function escapedHexLen(c) { + if (c === 120) { + return 2; + } + if (c === 117) { + return 4; + } + if (c === 85) { + return 8; + } + return 0; + } + function fromDecimalCode(c) { + if (c >= 48 && c <= 57) { + return c - 48; + } + return -1; + } + function simpleEscapeSequence(c) { + switch (c) { + case 48: + return "\0"; + + case 97: + return ""; + + case 98: + return "\b"; + + case 116: + return "\t"; + + case 9: + return "\t"; + + case 110: + return "\n"; + + case 118: + return "\v"; + + case 102: + return "\f"; + + case 114: + return "\r"; + + case 101: + return ""; + + case 32: + return " "; + + case 34: + return '"'; + + case 47: + return "/"; + + case 92: + return "\\"; + + case 78: + return "…"; + + case 95: + return " "; + + case 76: + return "\u2028"; + + case 80: + return "\u2029"; + + default: + return ""; + } + } + function charFromCodepoint(c) { + if (c <= 65535) { + return String.fromCharCode(c); + } + return String.fromCharCode((c - 65536 >> 10) + 55296, (c - 65536 & 1023) + 56320); + } + function setProperty(object, key, value) { + if (key === "__proto__") { + Object.defineProperty(object, key, { + configurable: true, + enumerable: true, + writable: true, + value: value + }); + } else { + object[key] = value; + } + } + var simpleEscapeCheck = new Array(256); + var simpleEscapeMap = new Array(256); + for (var i = 0; i < 256; i++) { + simpleEscapeCheck[i] = simpleEscapeSequence(i) ? 1 : 0; + simpleEscapeMap[i] = simpleEscapeSequence(i); + } + function State(input, options) { + this.input = input; + this.filename = options["filename"] || null; + this.schema = options["schema"] || DEFAULT_SCHEMA2; + this.onWarning = options["onWarning"] || null; + this.legacy = options["legacy"] || false; + this.json = options["json"] || false; + this.listener = options["listener"] || null; + this.maxDepth = typeof options["maxDepth"] === "number" ? options["maxDepth"] : 100; + this.maxTotalMergeKeys = typeof options["maxTotalMergeKeys"] === "number" ? options["maxTotalMergeKeys"] : 1e4; + this.implicitTypes = this.schema.compiledImplicit; + this.typeMap = this.schema.compiledTypeMap; + this.length = input.length; + this.position = 0; + this.line = 0; + this.lineStart = 0; + this.lineIndent = 0; + this.depth = 0; + this.totalMergeKeys = 0; + this.firstTabInLine = -1; + this.documents = []; + this.anchorMapTransactions = []; + } + function generateError(state, message) { + var mark = { + name: state.filename, + buffer: state.input.slice(0, -1), + position: state.position, + line: state.line, + column: state.position - state.lineStart + }; + mark.snippet = makeSnippet(mark); + return new YAMLException2(message, mark); + } + function throwError(state, message) { + throw generateError(state, message); + } + function throwWarning(state, message) { + if (state.onWarning) { + state.onWarning.call(null, generateError(state, message)); + } + } + function storeAnchor(state, name, value) { + var transactions = state.anchorMapTransactions; + if (transactions.length !== 0) { + var transaction = transactions[transactions.length - 1]; + if (!_hasOwnProperty.call(transaction, name)) { + transaction[name] = { + existed: _hasOwnProperty.call(state.anchorMap, name), + value: state.anchorMap[name] + }; + } + } + state.anchorMap[name] = value; + } + function beginAnchorTransaction(state) { + state.anchorMapTransactions.push(Object.create(null)); + } + function commitAnchorTransaction(state) { + var transaction = state.anchorMapTransactions.pop(); + var transactions = state.anchorMapTransactions; + if (transactions.length === 0) return; + var parent = transactions[transactions.length - 1]; + var names = Object.keys(transaction); + for (var index = 0, length = names.length; index < length; index += 1) { + var name = names[index]; + if (!_hasOwnProperty.call(parent, name)) { + parent[name] = transaction[name]; + } + } + } + function rollbackAnchorTransaction(state) { + var transaction = state.anchorMapTransactions.pop(); + var names = Object.keys(transaction); + for (var index = names.length - 1; index >= 0; index -= 1) { + var entry = transaction[names[index]]; + if (entry.existed) { + state.anchorMap[names[index]] = entry.value; + } else { + delete state.anchorMap[names[index]]; + } + } + } + function snapshotState(state) { + return { + position: state.position, + line: state.line, + lineStart: state.lineStart, + lineIndent: state.lineIndent, + firstTabInLine: state.firstTabInLine, + tag: state.tag, + anchor: state.anchor, + kind: state.kind, + result: state.result + }; + } + function restoreState(state, snapshot) { + state.position = snapshot.position; + state.line = snapshot.line; + state.lineStart = snapshot.lineStart; + state.lineIndent = snapshot.lineIndent; + state.firstTabInLine = snapshot.firstTabInLine; + state.tag = snapshot.tag; + state.anchor = snapshot.anchor; + state.kind = snapshot.kind; + state.result = snapshot.result; + } + var directiveHandlers = { + YAML: function handleYamlDirective(state, name, args) { + if (state.version !== null) { + throwError(state, "duplication of %YAML directive"); + } + if (args.length !== 1) { + throwError(state, "YAML directive accepts exactly one argument"); + } + var match = /^([0-9]+)\.([0-9]+)$/.exec(args[0]); + if (match === null) { + throwError(state, "ill-formed argument of the YAML directive"); + } + var major = parseInt(match[1], 10); + var minor = parseInt(match[2], 10); + if (major !== 1) { + throwError(state, "unacceptable YAML version of the document"); + } + state.version = args[0]; + state.checkLineBreaks = minor < 2; + if (minor !== 1 && minor !== 2) { + throwWarning(state, "unsupported YAML version of the document"); + } + }, + TAG: function handleTagDirective(state, name, args) { + var prefix; + if (args.length !== 2) { + throwError(state, "TAG directive accepts exactly two arguments"); + } + var handle = args[0]; + prefix = args[1]; + if (!PATTERN_TAG_HANDLE.test(handle)) { + throwError(state, "ill-formed tag handle (first argument) of the TAG directive"); + } + if (_hasOwnProperty.call(state.tagMap, handle)) { + throwError(state, 'there is a previously declared suffix for "' + handle + '" tag handle'); + } + if (!PATTERN_TAG_URI.test(prefix)) { + throwError(state, "ill-formed tag prefix (second argument) of the TAG directive"); + } + try { + prefix = decodeURIComponent(prefix); + } catch (err) { + throwError(state, "tag prefix is malformed: " + prefix); + } + state.tagMap[handle] = prefix; + } + }; + function captureSegment(state, start, end, checkJson) { + if (start < end) { + var _result = state.input.slice(start, end); + if (checkJson) { + for (var _position = 0, _length = _result.length; _position < _length; _position += 1) { + var _character = _result.charCodeAt(_position); + if (!(_character === 9 || _character >= 32 && _character <= 1114111)) { + throwError(state, "expected valid JSON character"); + } + } + } else if (PATTERN_NON_PRINTABLE.test(_result)) { + throwError(state, "the stream contains non-printable characters"); + } + state.result += _result; + } + } + function mergeMappings(state, destination, source, overridableKeys) { + if (!common2.isObject(source)) { + throwError(state, "cannot merge mappings; the provided source object is unacceptable"); + } + var sourceKeys = Object.keys(source); + for (var index = 0, quantity = sourceKeys.length; index < quantity; index += 1) { + var key = sourceKeys[index]; + if (state.maxTotalMergeKeys !== -1 && ++state.totalMergeKeys > state.maxTotalMergeKeys) { + throwError(state, "merge keys exceeded maxTotalMergeKeys (" + state.maxTotalMergeKeys + ")"); + } + if (!_hasOwnProperty.call(destination, key)) { + setProperty(destination, key, source[key]); + overridableKeys[key] = true; + } + } + } + function storeMappingPair(state, _result, overridableKeys, keyTag, keyNode, valueNode, startLine, startLineStart, startPos) { + if (Array.isArray(keyNode)) { + keyNode = Array.prototype.slice.call(keyNode); + for (var index = 0, quantity = keyNode.length; index < quantity; index += 1) { + if (Array.isArray(keyNode[index])) { + throwError(state, "nested arrays are not supported inside keys"); + } + if (_typeof(keyNode) === "object" && _class(keyNode[index]) === "[object Object]") { + keyNode[index] = "[object Object]"; + } + } + } + if (_typeof(keyNode) === "object" && _class(keyNode) === "[object Object]") { + keyNode = "[object Object]"; + } + keyNode = String(keyNode); + if (_result === null) { + _result = {}; + } + if (keyTag === "tag:yaml.org,2002:merge") { + if (Array.isArray(valueNode)) { + for (var _index = 0, _quantity = valueNode.length; _index < _quantity; _index += 1) { + mergeMappings(state, _result, valueNode[_index], overridableKeys); + } + } else { + mergeMappings(state, _result, valueNode, overridableKeys); + } + } else { + if (!state.json && !_hasOwnProperty.call(overridableKeys, keyNode) && _hasOwnProperty.call(_result, keyNode)) { + state.line = startLine || state.line; + state.lineStart = startLineStart || state.lineStart; + state.position = startPos || state.position; + throwError(state, "duplicated mapping key"); + } + setProperty(_result, keyNode, valueNode); + delete overridableKeys[keyNode]; + } + return _result; + } + function readLineBreak(state) { + var ch = state.input.charCodeAt(state.position); + if (ch === 10) { + state.position++; + } else if (ch === 13) { + state.position++; + if (state.input.charCodeAt(state.position) === 10) { + state.position++; + } + } else { + throwError(state, "a line break is expected"); + } + state.line += 1; + state.lineStart = state.position; + state.firstTabInLine = -1; + } + function skipSeparationSpace(state, allowComments, checkIndent) { + var lineBreaks = 0; + var ch = state.input.charCodeAt(state.position); + while (ch !== 0) { + while (isWhiteSpace(ch)) { + if (ch === 9 && state.firstTabInLine === -1) { + state.firstTabInLine = state.position; + } + ch = state.input.charCodeAt(++state.position); + } + if (allowComments && ch === 35) { + do { + ch = state.input.charCodeAt(++state.position); + } while (ch !== 10 && ch !== 13 && ch !== 0); + } + if (isEol(ch)) { + readLineBreak(state); + ch = state.input.charCodeAt(state.position); + lineBreaks++; + state.lineIndent = 0; + while (ch === 32) { + state.lineIndent++; + ch = state.input.charCodeAt(++state.position); + } + } else { + break; + } + } + if (checkIndent !== -1 && lineBreaks !== 0 && state.lineIndent < checkIndent) { + throwWarning(state, "deficient indentation"); + } + return lineBreaks; + } + function testDocumentSeparator(state) { + var _position = state.position; + var ch = state.input.charCodeAt(_position); + if ((ch === 45 || ch === 46) && ch === state.input.charCodeAt(_position + 1) && ch === state.input.charCodeAt(_position + 2)) { + _position += 3; + ch = state.input.charCodeAt(_position); + if (ch === 0 || isWsOrEol(ch)) { + return true; + } + } + return false; + } + function writeFoldedLines(state, count) { + if (count === 1) { + state.result += " "; + } else if (count > 1) { + state.result += common2.repeat("\n", count - 1); + } + } + function readPlainScalar(state, nodeIndent, withinFlowCollection) { + var captureStart; + var captureEnd; + var hasPendingContent; + var _line; + var _lineStart; + var _lineIndent; + var _kind = state.kind; + var _result = state.result; + var ch = state.input.charCodeAt(state.position); + if (isWsOrEol(ch) || isFlowIndicator(ch) || ch === 35 || ch === 38 || ch === 42 || ch === 33 || ch === 124 || ch === 62 || ch === 39 || ch === 34 || ch === 37 || ch === 64 || ch === 96) { + return false; + } + if (ch === 63 || ch === 45) { + var following = state.input.charCodeAt(state.position + 1); + if (isWsOrEol(following) || withinFlowCollection && isFlowIndicator(following)) { + return false; + } + } + state.kind = "scalar"; + state.result = ""; + captureStart = captureEnd = state.position; + hasPendingContent = false; + while (ch !== 0) { + if (ch === 58) { + var _following = state.input.charCodeAt(state.position + 1); + if (isWsOrEol(_following) || withinFlowCollection && isFlowIndicator(_following)) { + break; + } + } else if (ch === 35) { + var preceding = state.input.charCodeAt(state.position - 1); + if (isWsOrEol(preceding)) { + break; + } + } else if (state.position === state.lineStart && testDocumentSeparator(state) || withinFlowCollection && isFlowIndicator(ch)) { + break; + } else if (isEol(ch)) { + _line = state.line; + _lineStart = state.lineStart; + _lineIndent = state.lineIndent; + skipSeparationSpace(state, false, -1); + if (state.lineIndent >= nodeIndent) { + hasPendingContent = true; + ch = state.input.charCodeAt(state.position); + continue; + } else { + state.position = captureEnd; + state.line = _line; + state.lineStart = _lineStart; + state.lineIndent = _lineIndent; + break; + } + } + if (hasPendingContent) { + captureSegment(state, captureStart, captureEnd, false); + writeFoldedLines(state, state.line - _line); + captureStart = captureEnd = state.position; + hasPendingContent = false; + } + if (!isWhiteSpace(ch)) { + captureEnd = state.position + 1; + } + ch = state.input.charCodeAt(++state.position); + } + captureSegment(state, captureStart, captureEnd, false); + if (state.result) { + return true; + } + state.kind = _kind; + state.result = _result; + return false; + } + function readSingleQuotedScalar(state, nodeIndent) { + var captureStart; + var captureEnd; + var ch = state.input.charCodeAt(state.position); + if (ch !== 39) { + return false; + } + state.kind = "scalar"; + state.result = ""; + state.position++; + captureStart = captureEnd = state.position; + while ((ch = state.input.charCodeAt(state.position)) !== 0) { + if (ch === 39) { + captureSegment(state, captureStart, state.position, true); + ch = state.input.charCodeAt(++state.position); + if (ch === 39) { + captureStart = state.position; + state.position++; + captureEnd = state.position; + } else { + return true; + } + } else if (isEol(ch)) { + captureSegment(state, captureStart, captureEnd, true); + writeFoldedLines(state, skipSeparationSpace(state, false, nodeIndent)); + captureStart = captureEnd = state.position; + } else if (state.position === state.lineStart && testDocumentSeparator(state)) { + throwError(state, "unexpected end of the document within a single quoted scalar"); + } else { + state.position++; + if (!isWhiteSpace(ch)) { + captureEnd = state.position; + } + } + } + throwError(state, "unexpected end of the stream within a single quoted scalar"); + } + function readDoubleQuotedScalar(state, nodeIndent) { + var captureStart; + var captureEnd; + var tmp; + var ch = state.input.charCodeAt(state.position); + if (ch !== 34) { + return false; + } + state.kind = "scalar"; + state.result = ""; + state.position++; + captureStart = captureEnd = state.position; + while ((ch = state.input.charCodeAt(state.position)) !== 0) { + if (ch === 34) { + captureSegment(state, captureStart, state.position, true); + state.position++; + return true; + } else if (ch === 92) { + captureSegment(state, captureStart, state.position, true); + ch = state.input.charCodeAt(++state.position); + if (isEol(ch)) { + skipSeparationSpace(state, false, nodeIndent); + } else if (ch < 256 && simpleEscapeCheck[ch]) { + state.result += simpleEscapeMap[ch]; + state.position++; + } else if ((tmp = escapedHexLen(ch)) > 0) { + var hexLength = tmp; + var hexResult = 0; + for (;hexLength > 0; hexLength--) { + ch = state.input.charCodeAt(++state.position); + if ((tmp = fromHexCode(ch)) >= 0) { + hexResult = (hexResult << 4) + tmp; + } else { + throwError(state, "expected hexadecimal character"); + } + } + state.result += charFromCodepoint(hexResult); + state.position++; + } else { + throwError(state, "unknown escape sequence"); + } + captureStart = captureEnd = state.position; + } else if (isEol(ch)) { + captureSegment(state, captureStart, captureEnd, true); + writeFoldedLines(state, skipSeparationSpace(state, false, nodeIndent)); + captureStart = captureEnd = state.position; + } else if (state.position === state.lineStart && testDocumentSeparator(state)) { + throwError(state, "unexpected end of the document within a double quoted scalar"); + } else { + state.position++; + if (!isWhiteSpace(ch)) { + captureEnd = state.position; + } + } + } + throwError(state, "unexpected end of the stream within a double quoted scalar"); + } + function readFlowCollection(state, nodeIndent) { + var readNext = true; + var _line; + var _lineStart; + var _pos; + var _tag = state.tag; + var _result; + var _anchor = state.anchor; + var terminator; + var isPair; + var isExplicitPair; + var isMapping; + var overridableKeys = Object.create(null); + var keyNode; + var keyTag; + var valueNode; + var ch = state.input.charCodeAt(state.position); + if (ch === 91) { + terminator = 93; + isMapping = false; + _result = []; + } else if (ch === 123) { + terminator = 125; + isMapping = true; + _result = {}; + } else { + return false; + } + if (state.anchor !== null) { + storeAnchor(state, state.anchor, _result); + } + ch = state.input.charCodeAt(++state.position); + while (ch !== 0) { + skipSeparationSpace(state, true, nodeIndent); + ch = state.input.charCodeAt(state.position); + if (ch === terminator) { + state.position++; + state.tag = _tag; + state.anchor = _anchor; + state.kind = isMapping ? "mapping" : "sequence"; + state.result = _result; + return true; + } else if (!readNext) { + throwError(state, "missed comma between flow collection entries"); + } else if (ch === 44) { + throwError(state, "expected the node content, but found ','"); + } + keyTag = keyNode = valueNode = null; + isPair = isExplicitPair = false; + if (ch === 63) { + var following = state.input.charCodeAt(state.position + 1); + if (isWsOrEol(following)) { + isPair = isExplicitPair = true; + state.position++; + skipSeparationSpace(state, true, nodeIndent); + } + } + _line = state.line; + _lineStart = state.lineStart; + _pos = state.position; + composeNode(state, nodeIndent, CONTEXT_FLOW_IN, false, true); + keyTag = state.tag; + keyNode = state.result; + skipSeparationSpace(state, true, nodeIndent); + ch = state.input.charCodeAt(state.position); + if ((isExplicitPair || state.line === _line) && ch === 58) { + isPair = true; + ch = state.input.charCodeAt(++state.position); + skipSeparationSpace(state, true, nodeIndent); + composeNode(state, nodeIndent, CONTEXT_FLOW_IN, false, true); + valueNode = state.result; + } + if (isMapping) { + storeMappingPair(state, _result, overridableKeys, keyTag, keyNode, valueNode, _line, _lineStart, _pos); + } else if (isPair) { + _result.push(storeMappingPair(state, null, overridableKeys, keyTag, keyNode, valueNode, _line, _lineStart, _pos)); + } else { + _result.push(keyNode); + } + skipSeparationSpace(state, true, nodeIndent); + ch = state.input.charCodeAt(state.position); + if (ch === 44) { + readNext = true; + ch = state.input.charCodeAt(++state.position); + } else { + readNext = false; + } + } + throwError(state, "unexpected end of the stream within a flow collection"); + } + function readBlockScalar(state, nodeIndent) { + var folding; + var chomping = CHOMPING_CLIP; + var didReadContent = false; + var detectedIndent = false; + var textIndent = nodeIndent; + var emptyLines = 0; + var atMoreIndented = false; + var tmp; + var ch = state.input.charCodeAt(state.position); + if (ch === 124) { + folding = false; + } else if (ch === 62) { + folding = true; + } else { + return false; + } + state.kind = "scalar"; + state.result = ""; + while (ch !== 0) { + ch = state.input.charCodeAt(++state.position); + if (ch === 43 || ch === 45) { + if (CHOMPING_CLIP === chomping) { + chomping = ch === 43 ? CHOMPING_KEEP : CHOMPING_STRIP; + } else { + throwError(state, "repeat of a chomping mode identifier"); + } + } else if ((tmp = fromDecimalCode(ch)) >= 0) { + if (tmp === 0) { + throwError(state, "bad explicit indentation width of a block scalar; it cannot be less than one"); + } else if (!detectedIndent) { + textIndent = nodeIndent + tmp - 1; + detectedIndent = true; + } else { + throwError(state, "repeat of an indentation width identifier"); + } + } else { + break; + } + } + if (isWhiteSpace(ch)) { + do { + ch = state.input.charCodeAt(++state.position); + } while (isWhiteSpace(ch)); + if (ch === 35) { + do { + ch = state.input.charCodeAt(++state.position); + } while (!isEol(ch) && ch !== 0); + } + } + while (ch !== 0) { + readLineBreak(state); + state.lineIndent = 0; + ch = state.input.charCodeAt(state.position); + while ((!detectedIndent || state.lineIndent < textIndent) && ch === 32) { + state.lineIndent++; + ch = state.input.charCodeAt(++state.position); + } + if (!detectedIndent && state.lineIndent > textIndent) { + textIndent = state.lineIndent; + } + if (isEol(ch)) { + emptyLines++; + continue; + } + if (!detectedIndent && textIndent === 0) { + throwError(state, "missing indentation for block scalar"); + } + if (state.lineIndent < textIndent) { + if (chomping === CHOMPING_KEEP) { + state.result += common2.repeat("\n", didReadContent ? 1 + emptyLines : emptyLines); + } else if (chomping === CHOMPING_CLIP) { + if (didReadContent) { + state.result += "\n"; + } + } + break; + } + if (folding) { + if (isWhiteSpace(ch)) { + atMoreIndented = true; + state.result += common2.repeat("\n", didReadContent ? 1 + emptyLines : emptyLines); + } else if (atMoreIndented) { + atMoreIndented = false; + state.result += common2.repeat("\n", emptyLines + 1); + } else if (emptyLines === 0) { + if (didReadContent) { + state.result += " "; + } + } else { + state.result += common2.repeat("\n", emptyLines); + } + } else { + state.result += common2.repeat("\n", didReadContent ? 1 + emptyLines : emptyLines); + } + didReadContent = true; + detectedIndent = true; + emptyLines = 0; + var captureStart = state.position; + while (!isEol(ch) && ch !== 0) { + ch = state.input.charCodeAt(++state.position); + } + captureSegment(state, captureStart, state.position, false); + } + return true; + } + function readBlockSequence(state, nodeIndent) { + var _tag = state.tag; + var _anchor = state.anchor; + var _result = []; + var detected = false; + if (state.firstTabInLine !== -1) return false; + if (state.anchor !== null) { + storeAnchor(state, state.anchor, _result); + } + var ch = state.input.charCodeAt(state.position); + while (ch !== 0) { + if (state.firstTabInLine !== -1) { + state.position = state.firstTabInLine; + throwError(state, "tab characters must not be used in indentation"); + } + if (ch !== 45) { + break; + } + var following = state.input.charCodeAt(state.position + 1); + if (!isWsOrEol(following)) { + break; + } + detected = true; + state.position++; + if (skipSeparationSpace(state, true, -1)) { + if (state.lineIndent <= nodeIndent) { + _result.push(null); + ch = state.input.charCodeAt(state.position); + continue; + } + } + var _line = state.line; + composeNode(state, nodeIndent, CONTEXT_BLOCK_IN, false, true); + _result.push(state.result); + skipSeparationSpace(state, true, -1); + ch = state.input.charCodeAt(state.position); + if ((state.line === _line || state.lineIndent > nodeIndent) && ch !== 0) { + throwError(state, "bad indentation of a sequence entry"); + } else if (state.lineIndent < nodeIndent) { + break; + } + } + if (detected) { + state.tag = _tag; + state.anchor = _anchor; + state.kind = "sequence"; + state.result = _result; + return true; + } + return false; + } + function readBlockMapping(state, nodeIndent, flowIndent) { + var allowCompact; + var _keyLine; + var _keyLineStart; + var _keyPos; + var _tag = state.tag; + var _anchor = state.anchor; + var _result = {}; + var overridableKeys = Object.create(null); + var keyTag = null; + var keyNode = null; + var valueNode = null; + var atExplicitKey = false; + var detected = false; + if (state.firstTabInLine !== -1) return false; + if (state.anchor !== null) { + storeAnchor(state, state.anchor, _result); + } + var ch = state.input.charCodeAt(state.position); + while (ch !== 0) { + if (!atExplicitKey && state.firstTabInLine !== -1) { + state.position = state.firstTabInLine; + throwError(state, "tab characters must not be used in indentation"); + } + var following = state.input.charCodeAt(state.position + 1); + var _line = state.line; + if ((ch === 63 || ch === 58) && isWsOrEol(following)) { + if (ch === 63) { + if (atExplicitKey) { + storeMappingPair(state, _result, overridableKeys, keyTag, keyNode, null, _keyLine, _keyLineStart, _keyPos); + keyTag = keyNode = valueNode = null; + } + detected = true; + atExplicitKey = true; + allowCompact = true; + } else if (atExplicitKey) { + atExplicitKey = false; + allowCompact = true; + } else { + throwError(state, "incomplete explicit mapping pair; a key node is missed; or followed by a non-tabulated empty line"); + } + state.position += 1; + ch = following; + } else { + _keyLine = state.line; + _keyLineStart = state.lineStart; + _keyPos = state.position; + if (!composeNode(state, flowIndent, CONTEXT_FLOW_OUT, false, true)) { + break; + } + if (state.line === _line) { + ch = state.input.charCodeAt(state.position); + while (isWhiteSpace(ch)) { + ch = state.input.charCodeAt(++state.position); + } + if (ch === 58) { + ch = state.input.charCodeAt(++state.position); + if (!isWsOrEol(ch)) { + throwError(state, "a whitespace character is expected after the key-value separator within a block mapping"); + } + if (atExplicitKey) { + storeMappingPair(state, _result, overridableKeys, keyTag, keyNode, null, _keyLine, _keyLineStart, _keyPos); + keyTag = keyNode = valueNode = null; + } + detected = true; + atExplicitKey = false; + allowCompact = false; + keyTag = state.tag; + keyNode = state.result; + } else if (detected) { + throwError(state, "can not read an implicit mapping pair; a colon is missed"); + } else { + state.tag = _tag; + state.anchor = _anchor; + return true; + } + } else if (detected) { + throwError(state, "can not read a block mapping entry; a multiline key may not be an implicit key"); + } else { + state.tag = _tag; + state.anchor = _anchor; + return true; + } + } + if (state.line === _line || state.lineIndent > nodeIndent) { + if (atExplicitKey) { + _keyLine = state.line; + _keyLineStart = state.lineStart; + _keyPos = state.position; + } + if (composeNode(state, nodeIndent, CONTEXT_BLOCK_OUT, true, allowCompact)) { + if (atExplicitKey) { + keyNode = state.result; + } else { + valueNode = state.result; + } + } + if (!atExplicitKey) { + storeMappingPair(state, _result, overridableKeys, keyTag, keyNode, valueNode, _keyLine, _keyLineStart, _keyPos); + keyTag = keyNode = valueNode = null; + } + skipSeparationSpace(state, true, -1); + ch = state.input.charCodeAt(state.position); + } + if ((state.line === _line || state.lineIndent > nodeIndent) && ch !== 0) { + throwError(state, "bad indentation of a mapping entry"); + } else if (state.lineIndent < nodeIndent) { + break; + } + } + if (atExplicitKey) { + storeMappingPair(state, _result, overridableKeys, keyTag, keyNode, null, _keyLine, _keyLineStart, _keyPos); + } + if (detected) { + state.tag = _tag; + state.anchor = _anchor; + state.kind = "mapping"; + state.result = _result; + } + return detected; + } + function readTagProperty(state) { + var isVerbatim = false; + var isNamed = false; + var tagHandle; + var tagName; + var ch = state.input.charCodeAt(state.position); + if (ch !== 33) return false; + if (state.tag !== null) { + throwError(state, "duplication of a tag property"); + } + ch = state.input.charCodeAt(++state.position); + if (ch === 60) { + isVerbatim = true; + ch = state.input.charCodeAt(++state.position); + } else if (ch === 33) { + isNamed = true; + tagHandle = "!!"; + ch = state.input.charCodeAt(++state.position); + } else { + tagHandle = "!"; + } + var _position = state.position; + if (isVerbatim) { + do { + ch = state.input.charCodeAt(++state.position); + } while (ch !== 0 && ch !== 62); + if (state.position < state.length) { + tagName = state.input.slice(_position, state.position); + ch = state.input.charCodeAt(++state.position); + } else { + throwError(state, "unexpected end of the stream within a verbatim tag"); + } + } else { + while (ch !== 0 && !isWsOrEol(ch)) { + if (ch === 33) { + if (!isNamed) { + tagHandle = state.input.slice(_position - 1, state.position + 1); + if (!PATTERN_TAG_HANDLE.test(tagHandle)) { + throwError(state, "named tag handle cannot contain such characters"); + } + isNamed = true; + _position = state.position + 1; + } else { + throwError(state, "tag suffix cannot contain exclamation marks"); + } + } + ch = state.input.charCodeAt(++state.position); + } + tagName = state.input.slice(_position, state.position); + if (PATTERN_FLOW_INDICATORS.test(tagName)) { + throwError(state, "tag suffix cannot contain flow indicator characters"); + } + } + if (tagName && !PATTERN_TAG_URI.test(tagName)) { + throwError(state, "tag name cannot contain such characters: " + tagName); + } + try { + tagName = decodeURIComponent(tagName); + } catch (err) { + throwError(state, "tag name is malformed: " + tagName); + } + if (isVerbatim) { + state.tag = tagName; + } else if (_hasOwnProperty.call(state.tagMap, tagHandle)) { + state.tag = state.tagMap[tagHandle] + tagName; + } else if (tagHandle === "!") { + state.tag = "!" + tagName; + } else if (tagHandle === "!!") { + state.tag = "tag:yaml.org,2002:" + tagName; + } else { + throwError(state, 'undeclared tag handle "' + tagHandle + '"'); + } + return true; + } + function readAnchorProperty(state) { + var ch = state.input.charCodeAt(state.position); + if (ch !== 38) return false; + if (state.anchor !== null) { + throwError(state, "duplication of an anchor property"); + } + ch = state.input.charCodeAt(++state.position); + var _position = state.position; + while (ch !== 0 && !isWsOrEol(ch) && !isFlowIndicator(ch)) { + ch = state.input.charCodeAt(++state.position); + } + if (state.position === _position) { + throwError(state, "name of an anchor node must contain at least one character"); + } + state.anchor = state.input.slice(_position, state.position); + return true; + } + function readAlias(state) { + var ch = state.input.charCodeAt(state.position); + if (ch !== 42) return false; + ch = state.input.charCodeAt(++state.position); + var _position = state.position; + while (ch !== 0 && !isWsOrEol(ch) && !isFlowIndicator(ch)) { + ch = state.input.charCodeAt(++state.position); + } + if (state.position === _position) { + throwError(state, "name of an alias node must contain at least one character"); + } + var alias = state.input.slice(_position, state.position); + if (!_hasOwnProperty.call(state.anchorMap, alias)) { + throwError(state, 'unidentified alias "' + alias + '"'); + } + state.result = state.anchorMap[alias]; + skipSeparationSpace(state, true, -1); + return true; + } + function tryReadBlockMappingFromProperty(state, propertyStart, nodeIndent, flowIndent) { + var fallbackState = snapshotState(state); + beginAnchorTransaction(state); + restoreState(state, propertyStart); + state.tag = null; + state.anchor = null; + state.kind = null; + state.result = null; + if (readBlockMapping(state, nodeIndent, flowIndent) && state.kind === "mapping") { + commitAnchorTransaction(state); + return true; + } + rollbackAnchorTransaction(state); + restoreState(state, fallbackState); + return false; + } + function composeNode(state, parentIndent, nodeContext, allowToSeek, allowCompact) { + var allowBlockScalars; + var allowBlockCollections; + var indentStatus = 1; + var atNewLine = false; + var hasContent = false; + var propertyStart = null; + var type2; + var flowIndent; + var blockIndent; + if (state.depth >= state.maxDepth) { + throwError(state, "nesting exceeded maxDepth (" + state.maxDepth + ")"); + } + state.depth += 1; + if (state.listener !== null) { + state.listener("open", state); + } + state.tag = null; + state.anchor = null; + state.kind = null; + state.result = null; + var allowBlockStyles = allowBlockScalars = allowBlockCollections = CONTEXT_BLOCK_OUT === nodeContext || CONTEXT_BLOCK_IN === nodeContext; + if (allowToSeek) { + if (skipSeparationSpace(state, true, -1)) { + atNewLine = true; + if (state.lineIndent > parentIndent) { + indentStatus = 1; + } else if (state.lineIndent === parentIndent) { + indentStatus = 0; + } else if (state.lineIndent < parentIndent) { + indentStatus = -1; + } + } + } + if (indentStatus === 1) { + while (true) { + var ch = state.input.charCodeAt(state.position); + var propertyState = snapshotState(state); + if (atNewLine && (ch === 33 && state.tag !== null || ch === 38 && state.anchor !== null)) { + break; + } + if (!readTagProperty(state) && !readAnchorProperty(state)) { + break; + } + if (propertyStart === null) { + propertyStart = propertyState; + } + if (skipSeparationSpace(state, true, -1)) { + atNewLine = true; + allowBlockCollections = allowBlockStyles; + if (state.lineIndent > parentIndent) { + indentStatus = 1; + } else if (state.lineIndent === parentIndent) { + indentStatus = 0; + } else if (state.lineIndent < parentIndent) { + indentStatus = -1; + } + } else { + allowBlockCollections = false; + } + } + } + if (allowBlockCollections) { + allowBlockCollections = atNewLine || allowCompact; + } + if (indentStatus === 1 || CONTEXT_BLOCK_OUT === nodeContext) { + if (CONTEXT_FLOW_IN === nodeContext || CONTEXT_FLOW_OUT === nodeContext) { + flowIndent = parentIndent; + } else { + flowIndent = parentIndent + 1; + } + blockIndent = state.position - state.lineStart; + if (indentStatus === 1) { + if (allowBlockCollections && (readBlockSequence(state, blockIndent) || readBlockMapping(state, blockIndent, flowIndent)) || readFlowCollection(state, flowIndent)) { + hasContent = true; + } else { + var _ch = state.input.charCodeAt(state.position); + if (propertyStart !== null && allowBlockStyles && !allowBlockCollections && _ch !== 124 && _ch !== 62 && tryReadBlockMappingFromProperty(state, propertyStart, propertyStart.position - propertyStart.lineStart, flowIndent)) { + hasContent = true; + } else if (allowBlockScalars && readBlockScalar(state, flowIndent) || readSingleQuotedScalar(state, flowIndent) || readDoubleQuotedScalar(state, flowIndent)) { + hasContent = true; + } else if (readAlias(state)) { + hasContent = true; + if (state.tag !== null || state.anchor !== null) { + throwError(state, "alias node should not have any properties"); + } + } else if (readPlainScalar(state, flowIndent, CONTEXT_FLOW_IN === nodeContext)) { + hasContent = true; + if (state.tag === null) { + state.tag = "?"; + } + } + if (state.anchor !== null) { + storeAnchor(state, state.anchor, state.result); + } + } + } else if (indentStatus === 0) { + hasContent = allowBlockCollections && readBlockSequence(state, blockIndent); + } + } + if (state.tag === null) { + if (state.anchor !== null) { + storeAnchor(state, state.anchor, state.result); + } + } else if (state.tag === "?") { + if (state.result !== null && state.kind !== "scalar") { + throwError(state, 'unacceptable node kind for ! tag; it should be "scalar", not "' + state.kind + '"'); + } + for (var typeIndex = 0, typeQuantity = state.implicitTypes.length; typeIndex < typeQuantity; typeIndex += 1) { + type2 = state.implicitTypes[typeIndex]; + if (type2.resolve(state.result)) { + state.result = type2.construct(state.result); + state.tag = type2.tag; + if (state.anchor !== null) { + storeAnchor(state, state.anchor, state.result); + } + break; + } + } + } else if (state.tag !== "!") { + if (_hasOwnProperty.call(state.typeMap[state.kind || "fallback"], state.tag)) { + type2 = state.typeMap[state.kind || "fallback"][state.tag]; + } else { + type2 = null; + var typeList = state.typeMap.multi[state.kind || "fallback"]; + for (var _typeIndex = 0, _typeQuantity = typeList.length; _typeIndex < _typeQuantity; _typeIndex += 1) { + if (state.tag.slice(0, typeList[_typeIndex].tag.length) === typeList[_typeIndex].tag) { + type2 = typeList[_typeIndex]; + break; + } + } + } + if (!type2) { + throwError(state, "unknown tag !<" + state.tag + ">"); + } + if (state.result !== null && type2.kind !== state.kind) { + throwError(state, "unacceptable node kind for !<" + state.tag + '> tag; it should be "' + type2.kind + '", not "' + state.kind + '"'); + } + if (!type2.resolve(state.result, state.tag)) { + throwError(state, "cannot resolve a node with !<" + state.tag + "> explicit tag"); + } else { + state.result = type2.construct(state.result, state.tag); + if (state.anchor !== null) { + storeAnchor(state, state.anchor, state.result); + } + } + } + if (state.listener !== null) { + state.listener("close", state); + } + state.depth -= 1; + return state.tag !== null || state.anchor !== null || hasContent; + } + function readDocument(state) { + var documentStart = state.position; + var hasDirectives = false; + var ch; + state.version = null; + state.checkLineBreaks = state.legacy; + state.tagMap = Object.create(null); + state.anchorMap = Object.create(null); + while ((ch = state.input.charCodeAt(state.position)) !== 0) { + skipSeparationSpace(state, true, -1); + ch = state.input.charCodeAt(state.position); + if (state.lineIndent > 0 || ch !== 37) { + break; + } + hasDirectives = true; + ch = state.input.charCodeAt(++state.position); + var _position = state.position; + while (ch !== 0 && !isWsOrEol(ch)) { + ch = state.input.charCodeAt(++state.position); + } + var directiveName = state.input.slice(_position, state.position); + var directiveArgs = []; + if (directiveName.length < 1) { + throwError(state, "directive name must not be less than one character in length"); + } + while (ch !== 0) { + while (isWhiteSpace(ch)) { + ch = state.input.charCodeAt(++state.position); + } + if (ch === 35) { + do { + ch = state.input.charCodeAt(++state.position); + } while (ch !== 0 && !isEol(ch)); + break; + } + if (isEol(ch)) break; + _position = state.position; + while (ch !== 0 && !isWsOrEol(ch)) { + ch = state.input.charCodeAt(++state.position); + } + directiveArgs.push(state.input.slice(_position, state.position)); + } + if (ch !== 0) readLineBreak(state); + if (_hasOwnProperty.call(directiveHandlers, directiveName)) { + directiveHandlers[directiveName](state, directiveName, directiveArgs); + } else { + throwWarning(state, 'unknown document directive "' + directiveName + '"'); + } + } + skipSeparationSpace(state, true, -1); + if (state.lineIndent === 0 && state.input.charCodeAt(state.position) === 45 && state.input.charCodeAt(state.position + 1) === 45 && state.input.charCodeAt(state.position + 2) === 45) { + state.position += 3; + skipSeparationSpace(state, true, -1); + } else if (hasDirectives) { + throwError(state, "directives end mark is expected"); + } + composeNode(state, state.lineIndent - 1, CONTEXT_BLOCK_OUT, false, true); + skipSeparationSpace(state, true, -1); + if (state.checkLineBreaks && PATTERN_NON_ASCII_LINE_BREAKS.test(state.input.slice(documentStart, state.position))) { + throwWarning(state, "non-ASCII line breaks are interpreted as content"); + } + state.documents.push(state.result); + if (state.position === state.lineStart && testDocumentSeparator(state)) { + if (state.input.charCodeAt(state.position) === 46) { + state.position += 3; + skipSeparationSpace(state, true, -1); + } + return; + } + if (state.position < state.length - 1) { + throwError(state, "end of the stream or a document separator is expected"); + } + } + function loadDocuments(input, options) { + input = String(input); + options = options || {}; + if (input.length !== 0) { + if (input.charCodeAt(input.length - 1) !== 10 && input.charCodeAt(input.length - 1) !== 13) { + input += "\n"; + } + if (input.charCodeAt(0) === 65279) { + input = input.slice(1); + } + } + var state = new State(input, options); + var nullpos = input.indexOf("\0"); + if (nullpos !== -1) { + state.position = nullpos; + throwError(state, "null byte is not allowed in input"); + } + state.input += "\0"; + while (state.input.charCodeAt(state.position) === 32) { + state.lineIndent += 1; + state.position += 1; + } + while (state.position < state.length - 1) { + readDocument(state); + } + return state.documents; + } + function loadAll2(input, iterator, options) { + if (iterator !== null && _typeof(iterator) === "object" && typeof options === "undefined") { + options = iterator; + iterator = null; + } + var documents = loadDocuments(input, options); + if (typeof iterator !== "function") { + return documents; + } + for (var index = 0, length = documents.length; index < length; index += 1) { + iterator(documents[index]); + } + } + function load2(input, options) { + var documents = loadDocuments(input, options); + if (documents.length === 0) { + return void 0; + } else if (documents.length === 1) { + return documents[0]; + } + throw new YAMLException2("expected a single document in the stream, but found more"); + } + loader.loadAll = loadAll2; + loader.load = load2; + return loader; + } + var dumper = {}; + var hasRequiredDumper; + function requireDumper() { + if (hasRequiredDumper) return dumper; + hasRequiredDumper = 1; + function _typeof(o) { + "@babel/helpers - typeof"; + return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o2) { + return typeof o2; + } : function(o2) { + return o2 && "function" == typeof Symbol && o2.constructor === Symbol && o2 !== Symbol.prototype ? "symbol" : typeof o2; + }, _typeof(o); + } + var common2 = requireCommon(); + var YAMLException2 = requireException(); + var DEFAULT_SCHEMA2 = require_default(); + var _toString = Object.prototype.toString; + var _hasOwnProperty = Object.prototype.hasOwnProperty; + var CHAR_BOM = 65279; + var CHAR_TAB = 9; + var CHAR_LINE_FEED = 10; + var CHAR_CARRIAGE_RETURN = 13; + var CHAR_SPACE = 32; + var CHAR_EXCLAMATION = 33; + var CHAR_DOUBLE_QUOTE = 34; + var CHAR_SHARP = 35; + var CHAR_PERCENT = 37; + var CHAR_AMPERSAND = 38; + var CHAR_SINGLE_QUOTE = 39; + var CHAR_ASTERISK = 42; + var CHAR_COMMA = 44; + var CHAR_MINUS = 45; + var CHAR_COLON = 58; + var CHAR_EQUALS = 61; + var CHAR_GREATER_THAN = 62; + var CHAR_QUESTION = 63; + var CHAR_COMMERCIAL_AT = 64; + var CHAR_LEFT_SQUARE_BRACKET = 91; + var CHAR_RIGHT_SQUARE_BRACKET = 93; + var CHAR_GRAVE_ACCENT = 96; + var CHAR_LEFT_CURLY_BRACKET = 123; + var CHAR_VERTICAL_LINE = 124; + var CHAR_RIGHT_CURLY_BRACKET = 125; + var ESCAPE_SEQUENCES = {}; + ESCAPE_SEQUENCES[0] = "\\0"; + ESCAPE_SEQUENCES[7] = "\\a"; + ESCAPE_SEQUENCES[8] = "\\b"; + ESCAPE_SEQUENCES[9] = "\\t"; + ESCAPE_SEQUENCES[10] = "\\n"; + ESCAPE_SEQUENCES[11] = "\\v"; + ESCAPE_SEQUENCES[12] = "\\f"; + ESCAPE_SEQUENCES[13] = "\\r"; + ESCAPE_SEQUENCES[27] = "\\e"; + ESCAPE_SEQUENCES[34] = '\\"'; + ESCAPE_SEQUENCES[92] = "\\\\"; + ESCAPE_SEQUENCES[133] = "\\N"; + ESCAPE_SEQUENCES[160] = "\\_"; + ESCAPE_SEQUENCES[8232] = "\\L"; + ESCAPE_SEQUENCES[8233] = "\\P"; + var DEPRECATED_BOOLEANS_SYNTAX = [ "y", "Y", "yes", "Yes", "YES", "on", "On", "ON", "n", "N", "no", "No", "NO", "off", "Off", "OFF" ]; + var DEPRECATED_BASE60_SYNTAX = /^[-+]?[0-9_]+(?::[0-9_]+)+(?:\.[0-9_]*)?$/; + function compileStyleMap(schema2, map2) { + if (map2 === null) return {}; + var result = {}; + var keys = Object.keys(map2); + for (var index = 0, length = keys.length; index < length; index += 1) { + var tag = keys[index]; + var style = String(map2[tag]); + if (tag.slice(0, 2) === "!!") { + tag = "tag:yaml.org,2002:" + tag.slice(2); + } + var type2 = schema2.compiledTypeMap["fallback"][tag]; + if (type2 && _hasOwnProperty.call(type2.styleAliases, style)) { + style = type2.styleAliases[style]; + } + result[tag] = style; + } + return result; + } + function encodeHex(character) { + var handle; + var length; + var string = character.toString(16).toUpperCase(); + if (character <= 255) { + handle = "x"; + length = 2; + } else if (character <= 65535) { + handle = "u"; + length = 4; + } else if (character <= 4294967295) { + handle = "U"; + length = 8; + } else { + throw new YAMLException2("code point within a string may not be greater than 0xFFFFFFFF"); + } + return "\\" + handle + common2.repeat("0", length - string.length) + string; + } + var QUOTING_TYPE_SINGLE = 1; + var QUOTING_TYPE_DOUBLE = 2; + function State(options) { + this.schema = options["schema"] || DEFAULT_SCHEMA2; + this.indent = Math.max(1, options["indent"] || 2); + this.noArrayIndent = options["noArrayIndent"] || false; + this.skipInvalid = options["skipInvalid"] || false; + this.flowLevel = common2.isNothing(options["flowLevel"]) ? -1 : options["flowLevel"]; + this.styleMap = compileStyleMap(this.schema, options["styles"] || null); + this.sortKeys = options["sortKeys"] || false; + this.lineWidth = options["lineWidth"] || 80; + this.noRefs = options["noRefs"] || false; + this.noCompatMode = options["noCompatMode"] || false; + this.condenseFlow = options["condenseFlow"] || false; + this.quotingType = options["quotingType"] === '"' ? QUOTING_TYPE_DOUBLE : QUOTING_TYPE_SINGLE; + this.forceQuotes = options["forceQuotes"] || false; + this.replacer = typeof options["replacer"] === "function" ? options["replacer"] : null; + this.implicitTypes = this.schema.compiledImplicit; + this.explicitTypes = this.schema.compiledExplicit; + this.tag = null; + this.result = ""; + this.duplicates = []; + this.usedDuplicates = null; + } + function indentString(string, spaces) { + var ind = common2.repeat(" ", spaces); + var position = 0; + var result = ""; + var length = string.length; + while (position < length) { + var line = void 0; + var next = string.indexOf("\n", position); + if (next === -1) { + line = string.slice(position); + position = length; + } else { + line = string.slice(position, next + 1); + position = next + 1; + } + if (line.length && line !== "\n") result += ind; + result += line; + } + return result; + } + function generateNextLine(state, level) { + return "\n" + common2.repeat(" ", state.indent * level); + } + function testImplicitResolving(state, str2) { + for (var index = 0, length = state.implicitTypes.length; index < length; index += 1) { + var type2 = state.implicitTypes[index]; + if (type2.resolve(str2)) { + return true; + } + } + return false; + } + function isWhitespace(c) { + return c === CHAR_SPACE || c === CHAR_TAB; + } + function isPrintable(c) { + return c >= 32 && c <= 126 || c >= 161 && c <= 55295 && c !== 8232 && c !== 8233 || c >= 57344 && c <= 65533 && c !== CHAR_BOM || c >= 65536 && c <= 1114111; + } + function isNsCharOrWhitespace(c) { + return isPrintable(c) && c !== CHAR_BOM && c !== CHAR_CARRIAGE_RETURN && c !== CHAR_LINE_FEED; + } + function isPlainSafe(c, prev, inblock) { + var cIsNsCharOrWhitespace = isNsCharOrWhitespace(c); + var cIsNsChar = cIsNsCharOrWhitespace && !isWhitespace(c); + return (inblock ? cIsNsCharOrWhitespace : cIsNsCharOrWhitespace && c !== CHAR_COMMA && c !== CHAR_LEFT_SQUARE_BRACKET && c !== CHAR_RIGHT_SQUARE_BRACKET && c !== CHAR_LEFT_CURLY_BRACKET && c !== CHAR_RIGHT_CURLY_BRACKET) && c !== CHAR_SHARP && !(prev === CHAR_COLON && !cIsNsChar) || isNsCharOrWhitespace(prev) && !isWhitespace(prev) && c === CHAR_SHARP || prev === CHAR_COLON && cIsNsChar; + } + function isPlainSafeFirst(c) { + return isPrintable(c) && c !== CHAR_BOM && !isWhitespace(c) && c !== CHAR_MINUS && c !== CHAR_QUESTION && c !== CHAR_COLON && c !== CHAR_COMMA && c !== CHAR_LEFT_SQUARE_BRACKET && c !== CHAR_RIGHT_SQUARE_BRACKET && c !== CHAR_LEFT_CURLY_BRACKET && c !== CHAR_RIGHT_CURLY_BRACKET && c !== CHAR_SHARP && c !== CHAR_AMPERSAND && c !== CHAR_ASTERISK && c !== CHAR_EXCLAMATION && c !== CHAR_VERTICAL_LINE && c !== CHAR_EQUALS && c !== CHAR_GREATER_THAN && c !== CHAR_SINGLE_QUOTE && c !== CHAR_DOUBLE_QUOTE && c !== CHAR_PERCENT && c !== CHAR_COMMERCIAL_AT && c !== CHAR_GRAVE_ACCENT; + } + function isPlainSafeLast(c) { + return !isWhitespace(c) && c !== CHAR_COLON; + } + function codePointAt(string, pos) { + var first = string.charCodeAt(pos); + var second; + if (first >= 55296 && first <= 56319 && pos + 1 < string.length) { + second = string.charCodeAt(pos + 1); + if (second >= 56320 && second <= 57343) { + return (first - 55296) * 1024 + second - 56320 + 65536; + } + } + return first; + } + function needIndentIndicator(string) { + var leadingSpaceRe = /^\n* /; + return leadingSpaceRe.test(string); + } + var STYLE_PLAIN = 1; + var STYLE_SINGLE = 2; + var STYLE_LITERAL = 3; + var STYLE_FOLDED = 4; + var STYLE_DOUBLE = 5; + function chooseScalarStyle(string, singleLineOnly, indentPerLevel, lineWidth, testAmbiguousType, quotingType, forceQuotes, inblock) { + var i; + var char = 0; + var prevChar = null; + var hasLineBreak = false; + var hasFoldableLine = false; + var shouldTrackWidth = lineWidth !== -1; + var previousLineBreak = -1; + var plain = isPlainSafeFirst(codePointAt(string, 0)) && isPlainSafeLast(codePointAt(string, string.length - 1)); + if (singleLineOnly || forceQuotes) { + for (i = 0; i < string.length; char >= 65536 ? i += 2 : i++) { + char = codePointAt(string, i); + if (!isPrintable(char)) { + return STYLE_DOUBLE; + } + plain = plain && isPlainSafe(char, prevChar, inblock); + prevChar = char; + } + } else { + for (i = 0; i < string.length; char >= 65536 ? i += 2 : i++) { + char = codePointAt(string, i); + if (char === CHAR_LINE_FEED) { + hasLineBreak = true; + if (shouldTrackWidth) { + hasFoldableLine = hasFoldableLine || i - previousLineBreak - 1 > lineWidth && string[previousLineBreak + 1] !== " "; + previousLineBreak = i; + } + } else if (!isPrintable(char)) { + return STYLE_DOUBLE; + } + plain = plain && isPlainSafe(char, prevChar, inblock); + prevChar = char; + } + hasFoldableLine = hasFoldableLine || shouldTrackWidth && i - previousLineBreak - 1 > lineWidth && string[previousLineBreak + 1] !== " "; + } + if (!hasLineBreak && !hasFoldableLine) { + if (plain && !forceQuotes && !testAmbiguousType(string)) { + return STYLE_PLAIN; + } + return quotingType === QUOTING_TYPE_DOUBLE ? STYLE_DOUBLE : STYLE_SINGLE; + } + if (indentPerLevel > 9 && needIndentIndicator(string)) { + return STYLE_DOUBLE; + } + if (!forceQuotes) { + return hasFoldableLine ? STYLE_FOLDED : STYLE_LITERAL; + } + return quotingType === QUOTING_TYPE_DOUBLE ? STYLE_DOUBLE : STYLE_SINGLE; + } + function writeScalar(state, string, level, iskey, inblock) { + state.dump = function() { + if (string.length === 0) { + return state.quotingType === QUOTING_TYPE_DOUBLE ? '""' : "''"; + } + if (!state.noCompatMode) { + if (DEPRECATED_BOOLEANS_SYNTAX.indexOf(string) !== -1 || DEPRECATED_BASE60_SYNTAX.test(string)) { + return state.quotingType === QUOTING_TYPE_DOUBLE ? '"' + string + '"' : "'" + string + "'"; + } + } + var indent = state.indent * Math.max(1, level); + var lineWidth = state.lineWidth === -1 ? -1 : Math.max(Math.min(state.lineWidth, 40), state.lineWidth - indent); + var singleLineOnly = iskey || state.flowLevel > -1 && level >= state.flowLevel; + function testAmbiguity(string2) { + return testImplicitResolving(state, string2); + } + switch (chooseScalarStyle(string, singleLineOnly, state.indent, lineWidth, testAmbiguity, state.quotingType, state.forceQuotes && !iskey, inblock)) { + case STYLE_PLAIN: + return string; + + case STYLE_SINGLE: + return "'" + string.replace(/'/g, "''") + "'"; + + case STYLE_LITERAL: + return "|" + blockHeader(string, state.indent) + dropEndingNewline(indentString(string, indent)); + + case STYLE_FOLDED: + return ">" + blockHeader(string, state.indent) + dropEndingNewline(indentString(foldString(string, lineWidth), indent)); + + case STYLE_DOUBLE: + return '"' + escapeString(string) + '"'; + + default: + throw new YAMLException2("impossible error: invalid scalar style"); + } + }(); + } + function blockHeader(string, indentPerLevel) { + var indentIndicator = needIndentIndicator(string) ? String(indentPerLevel) : ""; + var clip = string[string.length - 1] === "\n"; + var keep = clip && (string[string.length - 2] === "\n" || string === "\n"); + var chomp = keep ? "+" : clip ? "" : "-"; + return indentIndicator + chomp + "\n"; + } + function dropEndingNewline(string) { + return string[string.length - 1] === "\n" ? string.slice(0, -1) : string; + } + function foldString(string, width) { + var lineRe = /(\n+)([^\n]*)/g; + var result = function() { + var nextLF = string.indexOf("\n"); + nextLF = nextLF !== -1 ? nextLF : string.length; + lineRe.lastIndex = nextLF; + return foldLine(string.slice(0, nextLF), width); + }(); + var prevMoreIndented = string[0] === "\n" || string[0] === " "; + var moreIndented; + var match; + while (match = lineRe.exec(string)) { + var prefix = match[1]; + var line = match[2]; + moreIndented = line[0] === " "; + result += prefix + (!prevMoreIndented && !moreIndented && line !== "" ? "\n" : "") + foldLine(line, width); + prevMoreIndented = moreIndented; + } + return result; + } + function foldLine(line, width) { + if (line === "" || line[0] === " ") return line; + var breakRe = / [^ ]/g; + var match; + var start = 0; + var end; + var curr = 0; + var next = 0; + var result = ""; + while (match = breakRe.exec(line)) { + next = match.index; + if (next - start > width) { + end = curr > start ? curr : next; + result += "\n" + line.slice(start, end); + start = end + 1; + } + curr = next; + } + result += "\n"; + if (line.length - start > width && curr > start) { + result += line.slice(start, curr) + "\n" + line.slice(curr + 1); + } else { + result += line.slice(start); + } + return result.slice(1); + } + function escapeString(string) { + var result = ""; + var char = 0; + for (var i = 0; i < string.length; char >= 65536 ? i += 2 : i++) { + char = codePointAt(string, i); + var escapeSeq = ESCAPE_SEQUENCES[char]; + if (!escapeSeq && isPrintable(char)) { + result += string[i]; + if (char >= 65536) result += string[i + 1]; + } else { + result += escapeSeq || encodeHex(char); + } + } + return result; + } + function writeFlowSequence(state, level, object) { + var _result = ""; + var _tag = state.tag; + for (var index = 0, length = object.length; index < length; index += 1) { + var value = object[index]; + if (state.replacer) { + value = state.replacer.call(object, String(index), value); + } + if (writeNode(state, level, value, false, false) || typeof value === "undefined" && writeNode(state, level, null, false, false)) { + if (_result !== "") _result += "," + (!state.condenseFlow ? " " : ""); + _result += state.dump; + } + } + state.tag = _tag; + state.dump = "[" + _result + "]"; + } + function writeBlockSequence(state, level, object, compact) { + var _result = ""; + var _tag = state.tag; + for (var index = 0, length = object.length; index < length; index += 1) { + var value = object[index]; + if (state.replacer) { + value = state.replacer.call(object, String(index), value); + } + if (writeNode(state, level + 1, value, true, true, false, true) || typeof value === "undefined" && writeNode(state, level + 1, null, true, true, false, true)) { + if (!compact || _result !== "") { + _result += generateNextLine(state, level); + } + if (state.dump && CHAR_LINE_FEED === state.dump.charCodeAt(0)) { + _result += "-"; + } else { + _result += "- "; + } + _result += state.dump; + } + } + state.tag = _tag; + state.dump = _result || "[]"; + } + function writeFlowMapping(state, level, object) { + var _result = ""; + var _tag = state.tag; + var objectKeyList = Object.keys(object); + for (var index = 0, length = objectKeyList.length; index < length; index += 1) { + var pairBuffer = ""; + if (_result !== "") pairBuffer += ", "; + if (state.condenseFlow) pairBuffer += '"'; + var objectKey = objectKeyList[index]; + var objectValue = object[objectKey]; + if (state.replacer) { + objectValue = state.replacer.call(object, objectKey, objectValue); + } + if (!writeNode(state, level, objectKey, false, false)) { + continue; + } + if (state.dump.length > 1024) pairBuffer += "? "; + pairBuffer += state.dump + (state.condenseFlow ? '"' : "") + ":" + (state.condenseFlow ? "" : " "); + if (!writeNode(state, level, objectValue, false, false)) { + continue; + } + pairBuffer += state.dump; + _result += pairBuffer; + } + state.tag = _tag; + state.dump = "{" + _result + "}"; + } + function writeBlockMapping(state, level, object, compact) { + var _result = ""; + var _tag = state.tag; + var objectKeyList = Object.keys(object); + if (state.sortKeys === true) { + objectKeyList.sort(); + } else if (typeof state.sortKeys === "function") { + objectKeyList.sort(state.sortKeys); + } else if (state.sortKeys) { + throw new YAMLException2("sortKeys must be a boolean or a function"); + } + for (var index = 0, length = objectKeyList.length; index < length; index += 1) { + var pairBuffer = ""; + if (!compact || _result !== "") { + pairBuffer += generateNextLine(state, level); + } + var objectKey = objectKeyList[index]; + var objectValue = object[objectKey]; + if (state.replacer) { + objectValue = state.replacer.call(object, objectKey, objectValue); + } + if (!writeNode(state, level + 1, objectKey, true, true, true)) { + continue; + } + var explicitPair = state.tag !== null && state.tag !== "?" || state.dump && state.dump.length > 1024; + if (explicitPair) { + if (state.dump && CHAR_LINE_FEED === state.dump.charCodeAt(0)) { + pairBuffer += "?"; + } else { + pairBuffer += "? "; + } + } + pairBuffer += state.dump; + if (explicitPair) { + pairBuffer += generateNextLine(state, level); + } + if (!writeNode(state, level + 1, objectValue, true, explicitPair)) { + continue; + } + if (state.dump && CHAR_LINE_FEED === state.dump.charCodeAt(0)) { + pairBuffer += ":"; + } else { + pairBuffer += ": "; + } + pairBuffer += state.dump; + _result += pairBuffer; + } + state.tag = _tag; + state.dump = _result || "{}"; + } + function detectType(state, object, explicit) { + var typeList = explicit ? state.explicitTypes : state.implicitTypes; + for (var index = 0, length = typeList.length; index < length; index += 1) { + var type2 = typeList[index]; + if ((type2.instanceOf || type2.predicate) && (!type2.instanceOf || _typeof(object) === "object" && object instanceof type2.instanceOf) && (!type2.predicate || type2.predicate(object))) { + if (explicit) { + if (type2.multi && type2.representName) { + state.tag = type2.representName(object); + } else { + state.tag = type2.tag; + } + } else { + state.tag = "?"; + } + if (type2.represent) { + var style = state.styleMap[type2.tag] || type2.defaultStyle; + var _result = void 0; + if (_toString.call(type2.represent) === "[object Function]") { + _result = type2.represent(object, style); + } else if (_hasOwnProperty.call(type2.represent, style)) { + _result = type2.represent[style](object, style); + } else { + throw new YAMLException2("!<" + type2.tag + '> tag resolver accepts not "' + style + '" style'); + } + state.dump = _result; + } + return true; + } + } + return false; + } + function writeNode(state, level, object, block, compact, iskey, isblockseq) { + state.tag = null; + state.dump = object; + if (!detectType(state, object, false)) { + detectType(state, object, true); + } + var type2 = _toString.call(state.dump); + var inblock = block; + if (block) { + block = state.flowLevel < 0 || state.flowLevel > level; + } + var objectOrArray = type2 === "[object Object]" || type2 === "[object Array]"; + var duplicateIndex; + var duplicate; + if (objectOrArray) { + duplicateIndex = state.duplicates.indexOf(object); + duplicate = duplicateIndex !== -1; + } + if (state.tag !== null && state.tag !== "?" || duplicate || state.indent !== 2 && level > 0) { + compact = false; + } + if (duplicate && state.usedDuplicates[duplicateIndex]) { + state.dump = "*ref_" + duplicateIndex; + } else { + if (objectOrArray && duplicate && !state.usedDuplicates[duplicateIndex]) { + state.usedDuplicates[duplicateIndex] = true; + } + if (type2 === "[object Object]") { + if (block && Object.keys(state.dump).length !== 0) { + writeBlockMapping(state, level, state.dump, compact); + if (duplicate) { + state.dump = "&ref_" + duplicateIndex + state.dump; + } + } else { + writeFlowMapping(state, level, state.dump); + if (duplicate) { + state.dump = "&ref_" + duplicateIndex + " " + state.dump; + } + } + } else if (type2 === "[object Array]") { + if (block && state.dump.length !== 0) { + if (state.noArrayIndent && !isblockseq && level > 0) { + writeBlockSequence(state, level - 1, state.dump, compact); + } else { + writeBlockSequence(state, level, state.dump, compact); + } + if (duplicate) { + state.dump = "&ref_" + duplicateIndex + state.dump; + } + } else { + writeFlowSequence(state, level, state.dump); + if (duplicate) { + state.dump = "&ref_" + duplicateIndex + " " + state.dump; + } + } + } else if (type2 === "[object String]") { + if (state.tag !== "?") { + writeScalar(state, state.dump, level, iskey, inblock); + } + } else if (type2 === "[object Undefined]") { + return false; + } else { + if (state.skipInvalid) return false; + throw new YAMLException2("unacceptable kind of an object to dump " + type2); + } + if (state.tag !== null && state.tag !== "?") { + var tagStr = encodeURI(state.tag[0] === "!" ? state.tag.slice(1) : state.tag).replace(/!/g, "%21"); + if (state.tag[0] === "!") { + tagStr = "!" + tagStr; + } else if (tagStr.slice(0, 18) === "tag:yaml.org,2002:") { + tagStr = "!!" + tagStr.slice(18); + } else { + tagStr = "!<" + tagStr + ">"; + } + state.dump = tagStr + " " + state.dump; + } + } + return true; + } + function getDuplicateReferences(object, state) { + var objects = []; + var duplicatesIndexes = []; + inspectNode(object, objects, duplicatesIndexes); + var length = duplicatesIndexes.length; + for (var index = 0; index < length; index += 1) { + state.duplicates.push(objects[duplicatesIndexes[index]]); + } + state.usedDuplicates = new Array(length); + } + function inspectNode(object, objects, duplicatesIndexes) { + if (object !== null && _typeof(object) === "object") { + var index = objects.indexOf(object); + if (index !== -1) { + if (duplicatesIndexes.indexOf(index) === -1) { + duplicatesIndexes.push(index); + } + } else { + objects.push(object); + if (Array.isArray(object)) { + for (var i = 0, length = object.length; i < length; i += 1) { + inspectNode(object[i], objects, duplicatesIndexes); + } + } else { + var objectKeyList = Object.keys(object); + for (var _i = 0, _length = objectKeyList.length; _i < _length; _i += 1) { + inspectNode(object[objectKeyList[_i]], objects, duplicatesIndexes); + } + } + } + } + } + function dump2(input, options) { + options = options || {}; + var state = new State(options); + if (!state.noRefs) getDuplicateReferences(input, state); + var value = input; + if (state.replacer) { + value = state.replacer.call({ + "": value + }, "", value); + } + if (writeNode(state, 0, value, true, true)) return state.dump + "\n"; + return ""; + } + dumper.dump = dump2; + return dumper; + } + var hasRequiredJsYaml; + function requireJsYaml() { + if (hasRequiredJsYaml) return jsYaml; + hasRequiredJsYaml = 1; + var loader2 = requireLoader(); + var dumper2 = requireDumper(); + function renamed(from, to) { + return function() { + throw new Error("Function yaml." + from + " is removed in js-yaml 4. Use yaml." + to + " instead, which is now safe by default."); + }; + } + jsYaml.Type = requireType(); + jsYaml.Schema = requireSchema(); + jsYaml.FAILSAFE_SCHEMA = requireFailsafe(); + jsYaml.JSON_SCHEMA = requireJson(); + jsYaml.CORE_SCHEMA = requireCore(); + jsYaml.DEFAULT_SCHEMA = require_default(); + jsYaml.load = loader2.load; + jsYaml.loadAll = loader2.loadAll; + jsYaml.dump = dumper2.dump; + jsYaml.YAMLException = requireException(); + jsYaml.types = { + binary: requireBinary(), + float: requireFloat(), + map: requireMap(), + null: require_null(), + pairs: requirePairs(), + set: requireSet(), + timestamp: requireTimestamp(), + bool: requireBool(), + int: requireInt(), + merge: requireMerge(), + omap: requireOmap(), + seq: requireSeq(), + str: requireStr() + }; + jsYaml.safeLoad = renamed("safeLoad", "load"); + jsYaml.safeLoadAll = renamed("safeLoadAll", "loadAll"); + jsYaml.safeDump = renamed("safeDump", "dump"); + return jsYaml; + } + var jsYamlExports = requireJsYaml(); + var yaml = getDefaultExportFromCjs(jsYamlExports); + var Type = yaml.Type, Schema = yaml.Schema, FAILSAFE_SCHEMA = yaml.FAILSAFE_SCHEMA, JSON_SCHEMA = yaml.JSON_SCHEMA, CORE_SCHEMA = yaml.CORE_SCHEMA, DEFAULT_SCHEMA = yaml.DEFAULT_SCHEMA, load = yaml.load, loadAll = yaml.loadAll, dump = yaml.dump, YAMLException = yaml.YAMLException, types = yaml.types, safeLoad = yaml.safeLoad, safeLoadAll = yaml.safeLoadAll, safeDump = yaml.safeDump; + exports2.CORE_SCHEMA = CORE_SCHEMA; + exports2.DEFAULT_SCHEMA = DEFAULT_SCHEMA; + exports2.FAILSAFE_SCHEMA = FAILSAFE_SCHEMA; + exports2.JSON_SCHEMA = JSON_SCHEMA; + exports2.Schema = Schema; + exports2.Type = Type; + exports2.YAMLException = YAMLException; + exports2.default = yaml; + exports2.dump = dump; + exports2.load = load; + exports2.loadAll = loadAll; + exports2.safeDump = safeDump; + exports2.safeLoad = safeLoad; + exports2.safeLoadAll = safeLoadAll; + exports2.types = types; + Object.defineProperty(exports2, "__esModule", { + value: true + }); +}); +//# sourceMappingURL=js-yaml.js.map diff --git a/gsd-core/templates/SECURITY.md b/gsd-core/templates/SECURITY.md index 835d05286..2c61049c1 100644 --- a/gsd-core/templates/SECURITY.md +++ b/gsd-core/templates/SECURITY.md @@ -1,11 +1,11 @@ --- -phase: {N} -slug: {phase-slug} +phase: "{N}" +slug: "{phase-slug}" status: draft # threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate) threats_open: 0 asvs_level: 1 -created: {date} +created: "{date}" --- # Phase {N} — Security diff --git a/gsd-core/templates/UI-SPEC.md b/gsd-core/templates/UI-SPEC.md index e8bd4f540..6d6265b33 100644 --- a/gsd-core/templates/UI-SPEC.md +++ b/gsd-core/templates/UI-SPEC.md @@ -1,10 +1,10 @@ --- -phase: {N} -slug: {phase-slug} +phase: "{N}" +slug: "{phase-slug}" status: draft shadcn_initialized: false preset: none -created: {date} +created: "{date}" --- # Phase {N} — UI Design Contract diff --git a/gsd-core/templates/VALIDATION.md b/gsd-core/templates/VALIDATION.md index 376e29e6f..0a9553dbe 100644 --- a/gsd-core/templates/VALIDATION.md +++ b/gsd-core/templates/VALIDATION.md @@ -1,12 +1,12 @@ --- -phase: {N} -slug: {phase-slug} +phase: "{N}" +slug: "{phase-slug}" # status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6) # audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117) status: draft nyquist_compliant: false wave_0_complete: false -created: {date} +created: "{date}" --- # Phase {N} — Validation Strategy diff --git a/package-lock.json b/package-lock.json index 6840c2d80..3af2815f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.84", - "re2js": "^2.8.6", "ws": "^8.21.0" }, "bin": { @@ -31,6 +30,8 @@ "fast-check": "^4.8.0", "globals": "^16.5.0", "js-yaml": "^4.3.1", + "mutation-testing-metrics": "^3.7.3", + "re2js": "^2.8.6", "typescript": "^6.0.3", "typescript-eslint": "^8.60.0" }, @@ -4536,6 +4537,7 @@ "version": "2.8.6", "resolved": "https://registry.npmjs.org/re2js/-/re2js-2.8.6.tgz", "integrity": "sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg==", + "dev": true, "license": "MIT", "engines": { "node": ">=18.0.0" diff --git a/package.json b/package.json index df306ae68..1b6fa578e 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "fast-check": "^4.8.0", "globals": "^16.5.0", "js-yaml": "^4.3.1", + "mutation-testing-metrics": "^3.7.3", "re2js": "^2.8.6", "typescript": "^6.0.3", "typescript-eslint": "^8.60.0" @@ -119,7 +120,7 @@ "lint:table-schema-drift": "node scripts/lint-table-schema-drift.cjs", "lint:frontmatter-scalar-broad-grep": "node scripts/lint-frontmatter-scalar-broad-grep.cjs", "lint:removed-but-needed": "node scripts/lint-removed-but-needed.cjs", - "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-tests.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-unreachable-guard-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-state-write-path-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-health-diagnostic-rule-table.cjs && node scripts/lint-planning-artifact-writer-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs && node scripts/lint-no-adhoc-regex-escape.cjs && node scripts/lint-vendored-deps.cjs && node scripts/lint-docs-guard-registration.cjs && node scripts/lint-source-test-name-collision.cjs && npm run lint:hooks-runtime-build-seam && node scripts/check-contract-drift.cjs", + "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-tests.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-unreachable-guard-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-state-write-path-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-health-diagnostic-rule-table.cjs && node scripts/lint-planning-artifact-writer-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs && node scripts/lint-no-adhoc-regex-escape.cjs && node scripts/lint-vendored-deps.cjs && node scripts/lint-docs-guard-registration.cjs && node scripts/lint-source-test-name-collision.cjs && npm run lint:hooks-runtime-build-seam && node scripts/check-contract-drift.cjs && node scripts/lint-mutation-test-derivation-drift.cjs", "lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs", "lint:regression-names": "node scripts/lint-regression-test-names.cjs", "lint:descriptions": "node scripts/lint-descriptions.cjs", diff --git a/scripts/check-mutation-score-ratchet.cjs b/scripts/check-mutation-score-ratchet.cjs new file mode 100644 index 000000000..7dc771b31 --- /dev/null +++ b/scripts/check-mutation-score-ratchet.cjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node +'use strict'; + +/** + * scripts/check-mutation-score-ratchet.cjs + * + * #3881 follow-up ("one YAML parser" mutation-matrix piece 3): makes each + * module's `minScore` floor (scripts/mutation-matrix.cjs COVERED) ratchet + * UPWARD instead of sitting wherever it was last hand-set. + * + * Two halves of "never lowerable without a reasoned marker": + * 1. `tests/mutation-matrix-ratchet.test.cjs`'s RATCHET_BASELINE already + * enforces the DOWN side: any change to a module's minScore — up or + * down — must land in the same diff as a matching RATCHET_BASELINE + * edit, making the change visible in code review (mirrors this repo's + * other shrink-only baselines, e.g. scripts/lint-unreachable-guard-drift.cjs's + * $comment convention: a number cannot move silently). + * 2. THIS script enforces the UP side: after a real CI Stryker run, if the + * module's ACHIEVED mutation score exceeds its declared floor by more + * than RATCHET_SLACK points, that is not "run-to-run variance" (this + * file's own header documents variance as 1-2 points; see + * scripts/mutation-matrix.cjs's "Floors are measured scores minus 1-2 + * pts") — it is unclaimed headroom, and the floor should have been + * raised. Exits 1 telling the author exactly what to raise it to, so a + * module that improves cannot silently keep a stale, low bar forever. + * + * Consumes Stryker's `json` reporter output (mutation-testing-report-schema + * document; stryker.config.mjs's `reporters` includes `'json'`, default path + * `reports/mutation/mutation.json`) via `mutation-testing-metrics` — the same + * package Stryker itself uses internally to compute a report's score — rather + * than re-deriving killed/survived counts by hand. + * + * Wired into `.github/workflows/mutation.yml`'s `mutate` job as a step AFTER + * `npx stryker run`, gated on the shard having passed (a shard that failed + * MUTATION_BREAK is a floor problem, not a ratchet problem — Stryker's own + * exit code already reports that). + * + * CLI: + * node scripts/check-mutation-score-ratchet.cjs --module [--report ] [--matrix ] + * `--report` defaults to reports/mutation/mutation.json (Stryker's own default). + * `--matrix` defaults to this repo's own scripts/mutation-matrix.cjs; it is an injectable seam + * so tests can point the CLI at a synthetic COVERED fixture instead of the real, live-ratcheting + * config (see tests/mutation-score-ratchet.test.cjs — a test asserting this script's fail/pass + * behaviour must never hardcode a real module's numeric floor, because that floor is exactly + * what this mechanism exists to move). + */ + +const fs = require('node:fs'); +const path = require('node:path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + +// Slack, in mutation-score points, above a module's declared minScore floor before this +// ratchet demands the floor be raised. Deliberately larger than the 1-2 point run-to-run +// TIMEOUT variance margin scripts/mutation-matrix.cjs's floors are already set with (its own +// header: "Floors are measured scores minus 1-2 pts for run-to-run variance") — that smaller +// margin is calibrated for the noise BETWEEN two runs of the SAME test list against the SAME +// source. RATCHET_SLACK instead has to tolerate the noise this file's own history already +// shows for the frontmatter module across genuine measurement events (63.35 -> 66.67 -> +// 60.58 -> pending, each a real CI measurement, not pure jitter, spanning >3 points on its +// own). 5 points sits comfortably above that observed band without being so wide that a +// module sitting well above TARGET_MUTATION_SCORE (80) could coast for years without its +// floor ever being asked to move. +const RATCHET_SLACK = 5; + +/** + * Pure: does `achievedScore` (0-100) exceed `floor` (minScore) by more than `slack`? + * Returns `{ shouldRatchet, suggestedFloor }` — `suggestedFloor` follows the SAME formula + * scripts/mutation-matrix.cjs's own header documents for setting a floor from a measured + * score: `floor(measured) - 1`. + */ +function evaluateRatchet(achievedScore, floor, slack = RATCHET_SLACK) { + const shouldRatchet = achievedScore - floor > slack; + return { + shouldRatchet, + suggestedFloor: shouldRatchet ? Math.floor(achievedScore) - 1 : floor, + }; +} + +/** + * Extract the achieved mutation score from a Stryker `json` reporter document via + * `mutation-testing-metrics` (the same library Stryker itself uses to compute scores). + * Throws a descriptive error if the document has no scoreable mutants at all (an empty + * `files` map, or every file having zero mutants) — that is a wiring bug in the caller + * (wrong report path, or the shard mutated nothing), never a score of 0. + */ +function extractAchievedScore(report) { + // Lazy require: this dependency is only needed on the actual score-extraction path, not + // when this module is required purely for evaluateRatchet (used by unit tests without the + // mutation-testing-metrics package's schema-validation overhead). + const { calculateMutationTestMetrics } = require('mutation-testing-metrics'); + const metrics = calculateMutationTestMetrics(report); + const score = metrics && metrics.systemUnderTestMetrics && metrics.systemUnderTestMetrics.metrics + ? metrics.systemUnderTestMetrics.metrics.mutationScore + : undefined; + if (typeof score !== 'number' || Number.isNaN(score)) { + throw new Error('mutation report produced no scoreable mutants — check --report points at the right shard\'s reports/mutation/mutation.json'); + } + return score; +} + +function parseArgs(argv) { + const out = { module: null, report: 'reports/mutation/mutation.json', matrix: null }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--module') { + out.module = argv[++i]; + } else if (arg === '--report') { + out.report = argv[++i]; + } else if (arg === '--matrix') { + out.matrix = argv[++i]; + } else { + throw new Error(`unknown argument: ${arg}`); + } + } + if (!out.module) throw new Error('--module is required'); + return out; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const matrixPath = args.matrix ? path.resolve(args.matrix) : path.join(__dirname, 'mutation-matrix.cjs'); + const { COVERED } = require(matrixPath); + const entry = COVERED[args.module]; + if (!entry) { + throw new ExitError(1, `check-mutation-score-ratchet: unknown module '${args.module}' — not in ${args.matrix ? matrixPath : 'scripts/mutation-matrix.cjs'} COVERED`); + } + + const reportPath = path.resolve(args.report); + if (!fs.existsSync(reportPath)) { + throw new ExitError(1, `check-mutation-score-ratchet: report not found at ${reportPath} — run \`npx stryker run\` first (with the 'json' reporter enabled)`); + } + let report; + try { + report = JSON.parse(fs.readFileSync(reportPath, 'utf8')); + } catch (err) { + throw new ExitError(1, `check-mutation-score-ratchet: ${reportPath} is not valid JSON: ${err.message}`); + } + + const achieved = extractAchievedScore(report); + const { shouldRatchet, suggestedFloor } = evaluateRatchet(achieved, entry.minScore); + + if (!shouldRatchet) { + console.log(`ok mutation-score-ratchet: ${args.module} achieved ${achieved.toFixed(2)}% against floor ${entry.minScore} (within ${RATCHET_SLACK}-point slack)`); + return; + } + + console.error(`mutation-score-ratchet: ${args.module} achieved ${achieved.toFixed(2)}%, more than ${RATCHET_SLACK} points above its declared floor (${entry.minScore}).`); + console.error(` This is unclaimed headroom, not run-to-run variance — raise the floor.`); + console.error(` remedy: in scripts/mutation-matrix.cjs, set COVERED['${args.module}'].minScore = ${suggestedFloor} (floor(achieved) - 1, this file's own convention),`); + console.error(` and update the matching entry in tests/mutation-matrix-ratchet.test.cjs's RATCHET_BASELINE in the same diff.`); + throw new ExitError(1); +} + +module.exports = { RATCHET_SLACK, evaluateRatchet, extractAchievedScore }; + +if (require.main === module) runMain(main); diff --git a/scripts/lint-eslint-glob-coverage.allowlist.json b/scripts/lint-eslint-glob-coverage.allowlist.json index 1588939f3..c2f4ebd0a 100644 --- a/scripts/lint-eslint-glob-coverage.allowlist.json +++ b/scripts/lint-eslint-glob-coverage.allowlist.json @@ -7,6 +7,10 @@ "path": "src/vendor/re2js.d.cts", "reason": "Vendored third-party type declaration (#3477): a .d.cts carries no executable code, so no ESLint rule is meaningful; the vendored .cjs it describes is globally ignored as verbatim upstream output. Freshness is enforced by scripts/lint-vendored-deps.cjs, not by lint rules." }, + { + "path": "src/vendor/js-yaml.d.cts", + "reason": "Vendored third-party type declaration (ADR-3473 §8.1, #3881): a .d.cts carries no executable code, so no ESLint rule is meaningful; the vendored js-yaml .cjs it describes is globally ignored as verbatim upstream output (gsd-core/bin/lib/vendor/**). Freshness is enforced by scripts/lint-vendored-deps.cjs, not by lint rules — same precedent as src/vendor/re2js.d.cts above." + }, { "path": "tests/fixtures/brand-typing/bad-calibrated-as-sample-basis.cts", "reason": "Deliberate MUST-NOT-COMPILE type-error fixture (#3059): type-aware linting would fail by design; it exists to prove the compiler rejects it." diff --git a/scripts/lint-mutation-test-derivation-drift.cjs b/scripts/lint-mutation-test-derivation-drift.cjs new file mode 100644 index 000000000..b6416ba82 --- /dev/null +++ b/scripts/lint-mutation-test-derivation-drift.cjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +'use strict'; + +/** + * scripts/lint-mutation-test-derivation-drift.cjs + * + * #3881 follow-up ("one YAML parser" mutation-matrix piece 2): guards the + * derivation engine in scripts/mutation-matrix.cjs against the exact drift + * class PR #3888 shipped — a test file added on a branch that directly + * `require()`s a covered module's built artifact AND names itself after that + * module (`.test.cjs` / `.property.test.cjs` / + * `-anything.test.cjs`), yet is never wired into that module's + * mutation shard. + * + * `scripts/mutation-matrix.cjs`'s `computeModuleTests` already derives most of + * a module's `tests` array automatically from exactly that (require + naming) + * signal, so a NEWLY drifting file of that shape is normally impossible — it + * would be auto-included the moment it exists. What this guard catches is the + * one case the derivation cannot self-correct: a file matching BOTH signals + * that the module's own `excludeTests` withholds. `excludeTests` entries are + * a deliberate, reasoned decision (see mutation-matrix.cjs's per-module + * comments) — this guard does not second-guess that decision, but it DOES + * verify every such file is EITHER accounted for in `tests` (auto-derived or + * `extraTests`) OR named in `excludeTests`. A file satisfying both signals + * that is in NEITHER list is exactly the drift #3888 shipped: nobody made a + * decision about it at all. + * + * Pure core (`findDrift`) so tests can drive it against a synthetic COVERED + + * requiring-files map without touching the real tests/ tree; `main()` wires + * it to the real repo for `npm run lint:ci`. + */ + +const path = require('node:path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + +/** + * Pure: for every module in `covered`, every file in `requiringFiles[module]` + * that ALSO matches the module's naming rule must be present in either + * `covered[module].tests` (already `tests/`-prefixed, as computeModuleTests + * emits it) or `covered[module].excludeTests` (bare basenames). Anything + * matching both signals but present in neither is reported as drift. + * + * @param {object} covered - mutation-matrix.cjs's COVERED (post `computeModuleTests`) + * @param {(moduleName: string) => string[]} findRequiringTestFiles - basenames requiring the module's artifact + * @param {(moduleName: string, file: string) => boolean} matchesModuleNamingRule + * @returns {{module: string, file: string}[]} + */ +function findDrift(covered, findRequiringTestFiles, matchesModuleNamingRule) { + const drift = []; + for (const [moduleName, entry] of Object.entries(covered)) { + const testsSet = new Set((entry.tests || []).map((t) => path.basename(t))); + const excludeSet = new Set(entry.excludeTests || []); + for (const file of findRequiringTestFiles(moduleName)) { + if (!matchesModuleNamingRule(moduleName, file)) continue; + if (testsSet.has(file) || excludeSet.has(file)) continue; + drift.push({ module: moduleName, file }); + } + } + return drift; +} + +function main() { + const { + COVERED, + findRequiringTestFiles, + matchesModuleNamingRule, + } = require('./mutation-matrix.cjs'); + + const drift = findDrift(COVERED, findRequiringTestFiles, matchesModuleNamingRule); + + if (drift.length === 0) { + console.log(`ok mutation-test-derivation-drift: every module-named test file requiring a covered module has an explicit disposition (${Object.keys(COVERED).length} modules checked)`); + return; + } + + console.error('mutation-test-derivation-drift: test file(s) require a covered module and match its naming rule, but have no disposition in scripts/mutation-matrix.cjs (neither auto-derived/extraTests nor excludeTests):'); + for (const d of drift) { + console.error(` [${d.module}] tests/${d.file}`); + } + console.error('\n remedy: in scripts/mutation-matrix.cjs, either let it auto-derive (do nothing further if it already appears in COVERED[].tests), or add it to extraTests, or add a REASONED excludeTests entry explaining why it is deliberately withheld from the mutation shard.'); + throw new ExitError(1); +} + +module.exports = { findDrift }; + +if (require.main === module) runMain(main); diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index 26434888a..94f52bebf 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -32,12 +32,14 @@ "frontmatter": { "files": [ "frontmatter-cli.test.cjs", + "frontmatter-golden-parity.test.cjs", + "frontmatter-roundtrip.property.test.cjs", "frontmatter.property.test.cjs", "frontmatter.test.cjs", "frontmatter.unit.test.cjs" ], - "issue": "3227", - "justification": "Grandfathered by #3227 when the testEffectivePrefix() dot/hyphen bucketing fix first made this pre-existing over-cap cluster visible to the gate; not new test sprawl." + "issue": "3881", + "justification": "Grandfathered by #3227 for the original 4-file cluster (pre-existing over-cap, not new sprawl). #3881 (ADR-3473 §8.1, the js-yaml parser migration) adds two more: frontmatter-golden-parity.test.cjs (D-series golden-corpus diff against the legacy parser, ~900 real documents — cannot be folded into an existing file without losing its independent-of-current-parser provenance) and frontmatter-roundtrip.property.test.cjs (fast-check property coverage the migration's bijective-contract rule requires). Both are migration-specific, not incidental sprawl." }, "graphify": { "files": [ diff --git a/scripts/lint-vendored-deps.cjs b/scripts/lint-vendored-deps.cjs index e6f665e46..a3f60de60 100644 --- a/scripts/lint-vendored-deps.cjs +++ b/scripts/lint-vendored-deps.cjs @@ -7,26 +7,49 @@ * #3477 follow-up: gsd-core/bin/** is copied by the installer into trees * that have NO node_modules, so it must carry zero external requires * (local/no-external-require-in-bin, eslint-rules/no-external-require-in-bin.cjs). - * `re2js` (src/pattern.cts's RE2 engine) is vendored verbatim under - * gsd-core/bin/lib/vendor/ instead — see gsd-core/bin/lib/vendor/README.md. + * Third-party packages that gsd-core/bin/** needs at runtime are instead + * vendored verbatim under gsd-core/bin/lib/vendor/ — see + * gsd-core/bin/lib/vendor/README.md. * * A vendored artifact that silently drifts from its upstream package is * just as dangerous as never vendoring it in the first place (a stale * copy ships a different engine than the one actually reviewed/audited). - * This guard fails CI when: - * 1. gsd-core/bin/lib/vendor/re2js.cjs no longer matches - * node_modules/re2js/build/index.cjs byte-for-byte. - * 2. gsd-core/bin/lib/vendor/re2js.d.cts no longer matches - * node_modules/re2js/build/index.d.cts byte-for-byte. - * 3. src/vendor/re2js.d.cts (the source-side twin tsc needs to resolve - * types for src/pattern.cts's relative './vendor/re2js.cjs' import — - * module resolution for a .cts source is relative to src/, not the - * output dir) no longer matches gsd-core/bin/lib/vendor/re2js.d.cts. - * 4. The `re2js` version pinned in package.json `devDependencies` no + * #3881: this used to be a single hand-rolled check hardcoded to `re2js`. + * It is now table-driven (VENDORED below), so adding a second vendored + * package (js-yaml, #3881) does not require a second hardcoded block — + * that would violate ADR-3473 §8.3, "one implementation per rule." + * + * For each row in VENDORED, this guard fails CI when: + * 1. The vendored `.cjs` no longer matches its upstream `node_modules` + * build output byte-for-byte. + * 2. (upstream-verbatim twins only) The vendored `.d.cts` under + * gsd-core/bin/lib/vendor/ no longer matches its upstream + * `node_modules` `.d.cts` byte-for-byte. + * 3. (upstream-verbatim twins only) The source-side twin under + * src/vendor/ (which tsc needs to resolve types for a relative + * `./vendor/.cjs` import — module resolution for a .cts source + * is relative to src/, not the output dir) no longer matches the + * vendored `.d.cts` under gsd-core/bin/lib/vendor/. + * 4. The package's version pinned in package.json `devDependencies` no * longer matches the version actually installed at - * node_modules/re2js/package.json (read there, per the dispatch + * `node_modules//package.json` (read there, per the dispatch * brief, rather than duplicating a second pin). * + * Hand-authored twins (js-yaml.d.cts, #3881: js-yaml ships no type + * declarations upstream, so there is nothing to byte-compare) skip checks + * 2 and 3 (the byte-compares) — there is no upstream/bin-side counterpart + * to compare against, and that is deliberate rather than a gap in coverage. + * They get a DIFFERENT check instead (#3881 review, finding 4): every + * value-level export the twin DECLARES (`export function`/`export const`, + * not a type/interface) must be an actual own property of the vendored + * runtime module. `srcTwin` was previously read only inside the + * upstream-verbatim branch — for a hand-authored row it was declared and + * never consulted by anything, so a stale claim in the twin (a declared + * export that no longer exists at runtime, or the reverse) could drift + * silently. This is how a hand-authored twin's docblock/type surface could + * make a false claim about the runtime (ADR-3473 §8.1, finding 2) without + * this gate — or any other — ever catching it. + * * Usage: node scripts/lint-vendored-deps.cjs * Exit 0 when every vendored copy is fresh; 1 otherwise. */ @@ -37,10 +60,82 @@ const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const ROOT = path.join(__dirname, '..'); -const REFRESH_COMMAND = - 'cp node_modules/re2js/build/index.cjs gsd-core/bin/lib/vendor/re2js.cjs && ' - + 'cp node_modules/re2js/build/index.d.cts gsd-core/bin/lib/vendor/re2js.d.cts && ' - + 'cp node_modules/re2js/build/index.d.cts src/vendor/re2js.d.cts'; +/** + * Resolve a path that may be either repo-relative (the shape every VENDORED + * row and CLI usage actually passes) or already absolute (the shape a test + * exercising drift against a scratch file outside the repo passes). Joining + * an absolute path onto ROOT via `path.join(ROOT, abs)` silently produces a + * nonsense path (Windows: crossing drive letters is not even representable + * as a relative join; POSIX: an absolute second segment wins but the result + * is coincidental, not correct) — this makes "absolute in, absolute out" + * explicit instead of relying on that coincidence. + * @param {string} p + * @returns {string} + */ +function resolvePath(p) { + return path.isAbsolute(p) ? p : path.join(ROOT, p); +} + +/** + * One row per vendored third-party package. + * + * @typedef {object} VendoredPackage + * @property {string} name npm package name, matches package.json devDependencies key + * @property {string} upstreamCjs path under node_modules/ to the upstream build artifact + * @property {string} vendoredCjs path under gsd-core/bin/lib/vendor/ to the vendored copy + * @property {string|null} upstreamDts path under node_modules/ to the upstream .d.cts/.d.ts, or + * null when upstream ships no types (forces hand-authored) + * @property {string|null} vendoredDts path under gsd-core/bin/lib/vendor/ to the vendored .d.cts, + * or null when there is no bin-side type twin + * @property {string|null} srcTwin path under src/vendor/ to the source-side type twin tsc + * resolves for a relative import from src/**, or null + * @property {'upstream-verbatim'|'hand-authored'} twinKind + * 'upstream-verbatim': srcTwin/vendoredDts are byte-compared + * against upstream and each other. + * 'hand-authored': no upstream counterpart exists, so the + * twin is excluded from the byte-compare (checks 2 and 3 + * above are skipped for this row). + */ + +/** @type {VendoredPackage[]} */ +const VENDORED = [ + { + name: 're2js', + upstreamCjs: 'node_modules/re2js/build/index.cjs', + vendoredCjs: 'gsd-core/bin/lib/vendor/re2js.cjs', + upstreamDts: 'node_modules/re2js/build/index.d.cts', + vendoredDts: 'gsd-core/bin/lib/vendor/re2js.d.cts', + srcTwin: 'src/vendor/re2js.d.cts', + twinKind: 'upstream-verbatim', + }, + { + name: 'js-yaml', + upstreamCjs: 'node_modules/js-yaml/dist/js-yaml.js', + vendoredCjs: 'gsd-core/bin/lib/vendor/js-yaml.cjs', + upstreamDts: null, + vendoredDts: null, + srcTwin: 'src/vendor/js-yaml.d.cts', + twinKind: 'hand-authored', + }, +]; + +/** + * Build the `cp` refresh command for one vendored package. Hand-authored + * twins have no upstream .d.ts to cp, so only the .cjs line is emitted for + * them; the twin itself must be refreshed by hand against the new API. + * @param {VendoredPackage} row + * @returns {string} + */ +function buildRefreshCommand(row) { + const parts = [`cp ${row.upstreamCjs} ${row.vendoredCjs}`]; + if (row.twinKind === 'upstream-verbatim' && row.upstreamDts) { + if (row.vendoredDts) parts.push(`cp ${row.upstreamDts} ${row.vendoredDts}`); + if (row.srcTwin) parts.push(`cp ${row.upstreamDts} ${row.srcTwin}`); + } + return parts.join(' && '); +} + +const REFRESH_COMMAND = VENDORED.map(buildRefreshCommand).join(' && '); /** * Compare two files byte-for-byte. Returns null when equal, or a short @@ -50,8 +145,8 @@ const REFRESH_COMMAND = * @returns {string | null} */ function compareFiles(relA, relB) { - const absA = path.join(ROOT, relA); - const absB = path.join(ROOT, relB); + const absA = resolvePath(relA); + const absB = resolvePath(relB); if (!fs.existsSync(absA)) return `${relA} does not exist`; if (!fs.existsSync(absB)) return `${relB} does not exist`; const a = fs.readFileSync(absA); @@ -70,44 +165,112 @@ function stripRangeOperator(spec) { return String(spec || '').trim().replace(/^[\^~]|^>=|^<=|^>|^<|^=/, '').trim(); } -function main() { +/** + * Extract every value-level export name (`export function foo` / `export const + * foo`) declared in a hand-authored `.d.cts` twin. Deliberately excludes + * `export type`/`export interface` — those have no runtime existence to check + * against, so including them would only ever produce false failures. + * @param {string} dctsSource + * @returns {string[]} + */ +function declaredValueExports(dctsSource) { + const names = []; + const re = /^export\s+(?:function|const|class)\s+([A-Za-z_$][\w$]*)/gm; + let m; + while ((m = re.exec(dctsSource)) !== null) names.push(m[1]); + return names; +} + +/** + * #3881 review, finding 4: for a `hand-authored` twin, verify every value-level + * export it DECLARES is an actual own property of the vendored runtime module — + * the check `srcTwin` previously had no consumer for. Returns findings (empty + * when the twin's declared surface matches runtime reality). + * @param {VendoredPackage} row + * @returns {string[]} + */ +function checkHandAuthoredTwin(row) { + if (!row.srcTwin) return [`${row.name}: twinKind 'hand-authored' but srcTwin is null`]; + const twinPath = resolvePath(row.srcTwin); + if (!fs.existsSync(twinPath)) return [`${row.srcTwin} does not exist`]; + + const declared = declaredValueExports(fs.readFileSync(twinPath, 'utf8')); + if (declared.length === 0) { + return [`${row.srcTwin} declares zero value-level exports — nothing for this twin to gate`]; + } + + const runtimeModule = require(resolvePath(row.vendoredCjs)); + const findings = []; + for (const name of declared) { + if (!Object.prototype.hasOwnProperty.call(runtimeModule, name)) { + findings.push( + `${row.srcTwin} declares export "${name}" — not an own property of ${row.vendoredCjs} at runtime`, + ); + } + } + return findings; +} + +/** + * Run all applicable freshness checks for one vendored package row. + * @param {VendoredPackage} row + * @returns {string[]} findings (empty when the row is fresh) + */ +function checkRow(row) { const findings = []; - const cjsDrift = compareFiles('gsd-core/bin/lib/vendor/re2js.cjs', 'node_modules/re2js/build/index.cjs'); + const cjsDrift = compareFiles(row.vendoredCjs, row.upstreamCjs); if (cjsDrift) findings.push(cjsDrift); - const dctsDrift = compareFiles('gsd-core/bin/lib/vendor/re2js.d.cts', 'node_modules/re2js/build/index.d.cts'); - if (dctsDrift) findings.push(dctsDrift); - - const srcTwinDrift = compareFiles('src/vendor/re2js.d.cts', 'gsd-core/bin/lib/vendor/re2js.d.cts'); - if (srcTwinDrift) findings.push(srcTwinDrift); + if (row.twinKind === 'upstream-verbatim') { + if (row.upstreamDts && row.vendoredDts) { + const dctsDrift = compareFiles(row.vendoredDts, row.upstreamDts); + if (dctsDrift) findings.push(dctsDrift); + } + if (row.srcTwin && row.vendoredDts) { + const srcTwinDrift = compareFiles(row.srcTwin, row.vendoredDts); + if (srcTwinDrift) findings.push(srcTwinDrift); + } + } else if (row.twinKind === 'hand-authored') { + findings.push(...checkHandAuthoredTwin(row)); + } const pkgPath = path.join(ROOT, 'package.json'); const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - const pinnedSpec = pkg.devDependencies && pkg.devDependencies.re2js; + const pinnedSpec = pkg.devDependencies && pkg.devDependencies[row.name]; if (!pinnedSpec) { - findings.push('package.json devDependencies.re2js is missing'); + findings.push(`package.json devDependencies.${row.name} is missing`); } else { - const installedPkgPath = path.join(ROOT, 'node_modules', 're2js', 'package.json'); + const installedPkgPath = path.join(ROOT, 'node_modules', row.name, 'package.json'); if (!fs.existsSync(installedPkgPath)) { - findings.push('node_modules/re2js/package.json does not exist (run npm install)'); + findings.push(`node_modules/${row.name}/package.json does not exist (run npm install)`); } else { const installed = JSON.parse(fs.readFileSync(installedPkgPath, 'utf8')); const pinned = stripRangeOperator(pinnedSpec); if (pinned !== installed.version) { findings.push( - `package.json devDependencies.re2js ("${pinnedSpec}" -> "${pinned}") != ` - + `node_modules/re2js/package.json version ("${installed.version}")`, + `package.json devDependencies.${row.name} ("${pinnedSpec}" -> "${pinned}") != ` + + `node_modules/${row.name}/package.json version ("${installed.version}")`, ); } } } + return findings; +} + +function main() { + const findings = []; + for (const row of VENDORED) { + findings.push(...checkRow(row)); + } + if (findings.length > 0) { const detail = findings.map((f) => ` ${f}`).join('\n'); + const names = VENDORED.map((row) => row.name).join(', '); throw new ExitError( 1, - 'lint-vendored-deps: gsd-core/bin/lib/vendor/re2js.* has drifted from its\n' + `lint-vendored-deps: gsd-core/bin/lib/vendor/{${names}} has drifted from its\n` + 'upstream package (or its version pin). Refresh with:\n' + ` ${REFRESH_COMMAND}\n` + 'Findings:\n' @@ -115,10 +278,20 @@ function main() { ); } - process.stdout.write('ok lint-vendored-deps: gsd-core/bin/lib/vendor/re2js.* matches node_modules/re2js and its pinned version\n'); + const names = VENDORED.map((row) => row.name).join(', '); + process.stdout.write(`ok lint-vendored-deps: gsd-core/bin/lib/vendor/{${names}} match node_modules and their pinned versions\n`); return 0; } if (require.main === module) runMain(main); -module.exports = { compareFiles, stripRangeOperator }; +module.exports = { + compareFiles, + stripRangeOperator, + VENDORED, + buildRefreshCommand, + checkRow, + declaredValueExports, + checkHandAuthoredTwin, + resolvePath, +}; diff --git a/scripts/mutation-matrix.cjs b/scripts/mutation-matrix.cjs index 10dab79f4..a572a2a74 100644 --- a/scripts/mutation-matrix.cjs +++ b/scripts/mutation-matrix.cjs @@ -106,8 +106,171 @@ function readStdinSync() { /** Long-run target for all modules (ADR-456). */ const TARGET_MUTATION_SCORE = 80; +// ── Derived test-list engine (#3881 follow-up, "one YAML parser" mutation-matrix +// piece 2) ───────────────────────────────────────────────────────────────── +// +// PROBLEM THIS REPLACES: `tests: [...]` used to be a hand-maintained array per +// module, and `stryker.config.mjs`'s DEFAULT_TEST_CMD hand-duplicated the union +// of every such array in a second literal. The two drifted independently — PR +// #3888 shipped four new frontmatter test files that were never added to either +// list, so their mutants had zero constraining coverage and the shard's score +// silently fell (see the frontmatter entry's own PR #3888 note below, kept for +// history). A hand list can be forgotten; a derivation cannot forget a file that +// exists on disk. +// +// SIGNAL: a test file that directly `require()`s a covered module's built +// artifact (`gsd-core/bin/lib/.cjs`) is declaring, by that require, that +// it constrains that module. That signal alone is far too broad to feed a +// per-mutant re-run budget: measured directly (no filter) against this tree, +// config-schema alone picks up 22 files most of this repo's spawn-heavy +// integration suites require incidentally for fixture setup — including a file +// literally named `graphify-auto-update.slow.test.cjs`. Stryker's command +// runner re-runs the WHOLE test command once per mutant, so an incidental +// integration require would multiply that module's shard cost by 10-20x for +// zero mutation-killing benefit (those suites do not assert on config-schema's +// internals; they merely load it as a dependency of something else under test). +// +// NARROWING RULE: a file is auto-derived into a module's shard only when BOTH +// hold: +// 1. it directly requires that module's `gsd-core/bin/lib/.cjs`, and +// 2. its own filename starts with the module's name followed by `.` or `-` +// (i.e. `.test.cjs`, `.unit.test.cjs`, `.property.test.cjs`, +// `-anything.test.cjs`) — the file DECLARES itself, by its own name, +// to be that module's dedicated test surface. This is the exact naming +// shape every entry in this registry already used before this change +// (`*.property.test.cjs` / `*.unit.test.cjs`, or `.test.cjs`), now +// made load-bearing instead of merely conventional. +// Measured effect of narrowing config-schema this way: 22 candidates -> 1 +// (config-schema.property.test.cjs, the file already in the shard) — the +// naming filter is what keeps the derivation from silently tripling that +// shard's cost, per the piece-2 "watch the cost consequence" requirement. +// +// ESCAPE HATCHES (both REQUIRED to be explicit, reasoned per-module entries — +// never a silent list): +// - `extraTests`: files that constrain this module (genuinely, by writing +// assertions against its behaviour) but do not match the naming rule +// above — either because the module was extracted from another file's +// tests (context-composer) or because the file's own name follows a +// different, still-legible convention (feat-3881-yaml-parser-consequences.test.cjs). +// - `excludeTests`: files that DO match the naming + require signal above +// (so the derivation would otherwise auto-include them) but are +// deliberately withheld from the per-mutant shard for a measured, +// documented reason (almost always: they are integration-shaped and +// spawn a subprocess per case, so Stryker's per-mutant re-run of the +// whole file cannot finish inside the shard's timeout — the exact #2790 +// planning-inspect.test.cjs precedent this file already documented before +// this change; the derivation engine now enforces that precedent by +// construction instead of leaving it to reviewer memory). +// `computeModuleTests` combines all three into the final `tests` array; the +// guard `scripts/lint-mutation-test-derivation-drift.cjs` independently +// verifies every SIGNAL-matching file (require + naming rule, unfiltered by +// this module's own excludeTests) has an explicit disposition — auto-derived, +// named in extraTests, or named in excludeTests — so a file that newly starts +// matching the naming rule (like #3888's four files would have, had they been +// named `frontmatter*`) cannot silently fall through the cracks again. +const TESTS_DIR = require('node:path').join(__dirname, '..', 'tests'); +let _testRequireCache = null; + +/** + * Scan every `tests/*.test.cjs` file once and cache, per covered module name, + * which files directly `require('gsd-core/bin/lib/.cjs')` (or a relative + * equivalent — `../gsd-core/bin/lib/` etc. — the require path always + * ends in the literal segment matched below). Pure w.r.t. process lifetime; + * the tests/ directory does not change while this process runs. + * + * @returns {Map>} module name -> Set of basenames (e.g. 'frontmatter.test.cjs') + */ +function scanTestRequires() { + if (_testRequireCache) return _testRequireCache; + const REQUIRE_RE = /require\(\s*['"](?:[./]*)?gsd-core\/bin\/lib\/([a-zA-Z0-9_-]+)(?:\.cjs)?['"]\s*\)/g; + const cache = new Map(); + let entries; + try { + entries = fs.readdirSync(TESTS_DIR).filter((f) => f.endsWith('.test.cjs')); + } catch { + entries = []; + } + for (const file of entries) { + const text = fs.readFileSync(require('node:path').join(TESTS_DIR, file), 'utf8'); + let m; + REQUIRE_RE.lastIndex = 0; + while ((m = REQUIRE_RE.exec(text))) { + const mod = m[1]; + if (!cache.has(mod)) cache.set(mod, new Set()); + cache.get(mod).add(file); + } + } + _testRequireCache = cache; + return cache; +} + +/** + * Every test file that directly requires ``'s built artifact — + * the FULL, unfiltered signal set (used by the derivation-drift guard, which + * must see every candidate regardless of naming, so it can demand an explicit + * disposition for each one). + * + * @param {string} moduleName + * @returns {string[]} sorted basenames + */ +function findRequiringTestFiles(moduleName) { + const set = scanTestRequires().get(moduleName); + return set ? [...set].sort() : []; +} + +/** True when `file`'s own name declares it a dedicated test surface for `moduleName` + * (`.test.cjs`, or starts with `.` / `-`). */ +function matchesModuleNamingRule(moduleName, file) { + return file === `${moduleName}.test.cjs` + || file.startsWith(`${moduleName}.`) + || file.startsWith(`${moduleName}-`); +} + +/** + * Auto-derived candidates for `moduleName`: requires the module's artifact AND + * matches the naming rule. Does NOT apply that module's own `excludeTests` — + * callers combine that separately (`computeModuleTests` for the real shard, + * the guard for candidate enumeration). + */ +function deriveNamedTests(moduleName) { + return findRequiringTestFiles(moduleName).filter((f) => matchesModuleNamingRule(moduleName, f)); +} + +/** + * Final `tests` array for a COVERED entry: auto-derived (require + naming + * rule) UNION `extraTests` MINUS `excludeTests`, sorted, each prefixed + * `tests/`. Throws if `excludeTests` names a file that isn't actually a + * derived candidate (an exclusion of nothing is a stale/typo'd entry, not a + * real decision) or if `extraTests` names a file already auto-derived (that + * would silently mask which mechanism is responsible for its presence). + */ +function computeModuleTests(moduleName, entry) { + const derived = new Set(deriveNamedTests(moduleName)); + const extra = entry.extraTests || []; + const exclude = entry.excludeTests || []; + for (const f of extra) { + if (derived.has(f)) { + throw new Error(`mutation-matrix: COVERED['${moduleName}'].extraTests names '${f}', which is already auto-derived — remove it from extraTests (it is redundant and hides which mechanism includes it)`); + } + } + for (const f of exclude) { + if (!derived.has(f)) { + throw new Error(`mutation-matrix: COVERED['${moduleName}'].excludeTests names '${f}', which is not an auto-derived candidate for this module — remove the stale exclusion`); + } + } + const excludeSet = new Set(exclude); + const final = new Set(); + for (const f of derived) if (!excludeSet.has(f)) final.add(f); + for (const f of extra) final.add(f); + return [...final].sort().map((f) => `tests/${f}`); +} + // ── Single source of truth: covered modules ─────────────────────────────────── -// Each entry: { cjs: '', tests: ['tests/...', ...], minScore: N } +// Each entry: { cjs: '', extraTests: [...], excludeTests: [...], minScore: N } +// `tests` is no longer hand-written — computeModuleTests() derives it below +// from direct `require()`s of the module's artifact (see the derivation-engine +// header above). extraTests/excludeTests are the two REQUIRED, reasoned escape +// hatches; leave both `[]` (omit the key) when a module needs neither. // // minScore is the CI break threshold for this module's shard. // Floors are measured scores minus 1–2 pts for run-to-run variance. @@ -132,6 +295,15 @@ const TARGET_MUTATION_SCORE = 80; // RATCHET_BASELINE — which lives in tests/mutation-matrix-ratchet.test.cjs, not here — is // updated in the same diff as that procedure requires. // +// PR #3888 (#3881 follow-up): the frontmatter shard's new tests were never registered here +// (only the pre-existing frontmatter.property/unit + unusable-input ran), so Stryker's +// mutants in the new vendored-parser adapter code had nothing constraining them. Score fell +// to 55.8% against the 65 floor (748 killed / 593 survived / 17 timeout) and the shard also +// blew the 15-minute cap. Fixed by registering the branch's four new/changed frontmatter +// test files in the tests array above (see that entry's inline comment for which files and +// why) and giving the shard a measured 180-minute budget via timeoutMinutes. minScore left +// at 65 pending a fresh CI measurement with the corrected test list. +// // LESSON: floors MUST be calibrated from CI mutation runs (CI runs with // timeout≈0, deterministic). Local runs count timeouts as kills and // inflate scores significantly (prompt-budget: 99.6% local vs 68.3% CI; @@ -140,80 +312,147 @@ const TARGET_MUTATION_SCORE = 80; const COVERED = { 'context-utilization': { cjs: 'gsd-core/bin/lib/context-utilization.cjs', - tests: [ - 'tests/context-utilization.property.test.cjs', - ], + // Derived: context-utilization.property.test.cjs (pre-existing) + + // context-utilization.test.cjs (piece-2 derivation find: it directly requires and + // matches the naming rule, but was never hand-added to the old literal list — + // exactly the #3888 drift class this derivation exists to stop. Measured cost: + // +50ms over the property-only baseline (58ms -> 108ms, in-process, 0 subprocess + // spawns) — negligible for a shard whose floor is already at TARGET. // After mutation-killer assertions added in #1187: measured 92.31% (2026-06-14). // 3 survivors are __esModule boilerplate (genuinely equivalent CJS interop mutants). - // minScore raised to TARGET (80) — module now meets ADR-456 goal. - minScore: 80, + // minScore raised to TARGET (80) — module now meets ADR-456 goal. Not yet + // re-measured against the wider (derived) test list; the added file only adds + // assertions, never removes any, so the floor cannot have fallen. + // CI run 33012034388 (2026-08-25, #3881 ratchet): measured 92.31% (unchanged from + // the #1187 measurement above — same test list, re-confirmed by the mutation + // ratchet's own audit). Floor = floor(92.31) - 1 = 91. + minScore: 91, }, // context-composer: extracted from prompt-budget by #2929. Needs its own entry because // mutation coverage does not migrate with relocated code — scoring only prompt-budget.cjs - // would leave the extracted ladder unmeasured. + // would leave the extracted ladder unmeasured. Its own filename never matches the + // "context-composer*" naming rule for prompt-budget-parity.test.cjs / prompt-budget.unit.test.cjs + // — both genuinely constrain context-composer.cjs (the ladder was relocated INTO it), so + // both are declared via extraTests rather than silently missing from the derivation. 'context-composer': { cjs: 'gsd-core/bin/lib/context-composer.cjs', - tests: [ - 'tests/prompt-budget-parity.test.cjs', - 'tests/prompt-budget.unit.test.cjs', - 'tests/context-composer.test.cjs', - 'tests/context-composer.property.test.cjs', + extraTests: [ + 'prompt-budget-parity.test.cjs', + 'prompt-budget.unit.test.cjs', ], - minScore: 66, + // CI run 33012034388 (2026-08-25, #3881 ratchet): measured 79.92%. Floor = + // floor(79.92) - 1 = 78. + minScore: 78, }, 'prompt-budget': { cjs: 'gsd-core/bin/lib/prompt-budget.cjs', - tests: [ - 'tests/prompt-budget.property.test.cjs', - 'tests/prompt-budget.unit.test.cjs', - ], + // Derived: property + unit (pre-existing) plus two piece-2 derivation finds that + // directly require prompt-budget.cjs and match the naming rule but were never in the + // old hand list — prompt-budget-parity.test.cjs and prompt-budget.test.cjs. Measured + // cost: 475ms (2-file) -> 527ms (4-file), in-process, 0 subprocess spawns; +52ms is + // negligible next to this module's own mutant count. // CI 68.33% timeout-free (164 killed / 1 timeout / 240 total) 2026-06-14; - // local was 99.6% — timeout inflation. Floor = 68 - 2 margin. - minScore: 66, + // local was 99.6% — timeout inflation. Floor = 68 - 2 margin. Not yet re-measured + // against the wider (derived) test list; both added files only add assertions, never + // remove any, so the floor cannot have fallen. + // CI run 33012034388 (2026-08-25, #3881 ratchet): re-measured against the wider + // (derived) test list at 88.95%. Floor = floor(88.95) - 1 = 87. + minScore: 87, }, frontmatter: { cjs: 'gsd-core/bin/lib/frontmatter.cjs', - tests: [ - 'tests/frontmatter.property.test.cjs', - 'tests/frontmatter.unit.test.cjs', - // #1882 added the unterminated-fence detection to frontmatter.cjs, and the tests that - // constrain it live here. Without this entry the mutants in that branch are covered by - // no test in the shard, so the module's score drops even though the behaviour is tested. - 'tests/unusable-input.test.cjs', + // extraTests: files that genuinely constrain frontmatter.cjs but do not match the + // "frontmatter*" naming rule, so the derivation cannot find them on its own — + // each earns its slot on evidence, not blanket inclusion (verified no two duplicate + // the same constraining assertion): + // - unusable-input.test.cjs: #1882 added the unterminated-fence detection to + // frontmatter.cjs, and the tests that constrain it live here. Without this entry + // the mutants in that branch are covered by no test in the shard. + // - feat-3881-yaml-parser-consequences.test.cjs: consequence/boundary matrix for the + // #3881 parser swap (state-transition interop, unusable-input counters, and — as of + // the piece-1 mutation-matrix fix below — the relocated anchor-alias-bomb + B1/B2 + // block-scalar assertions). Nothing else in the shard drives + // extractFrontmatter/reconstructFrontmatter through those seams. + extraTests: [ + 'unusable-input.test.cjs', + 'feat-3881-yaml-parser-consequences.test.cjs', + ], + // excludeTests: files the derivation WOULD auto-include (require frontmatter.cjs + // directly AND match the "frontmatter*" naming rule) but are deliberately withheld: + // - frontmatter-cli.test.cjs: 778-line CLI-integration file, 39 subprocess-spawn + // references (spawnSync/execFileSync/runGsdTools) — the same #2790 + // planning-inspect.test.cjs shape (a `node --test ` invocation Stryker's + // command runner re-runs whole, once per mutant, at whatever its slowest spawn + // case costs). Never measured in a shard; excluded up front on the same evidence + // class rather than discovered by a timeout. + // - frontmatter.test.cjs: mutation-matrix piece 1 (#3881 follow-up). This + // 2932-line integration file cost 3132ms of the shard's ~4800ms per-run + // (measured via node:test's run() API — node --test is hard-blocked locally, + // this is the sanctioned substitute), which at ~1850 mutants (source grew 1.8x + // for #3881) projected to ~96 of the shard's 140-minute total. Its two + // genuinely-unique assertion classes — anchor-alias-bomb refusal (billion-laughs + // -style anchor/alias expansion must be rejected, not expanded) and the B1/B2 + // block-scalar assertions (parsing commands/gsd/add-tests.md must not invent a + // phantom "Example" key) — were relocated verbatim into + // feat-3881-yaml-parser-consequences.test.cjs (already in this shard via + // extraTests above) rather than deleted, so the mutants they kill stay killed. + // frontmatter.test.cjs itself is UNCHANGED and keeps running in the normal + // (non-mutation) suite — only the mutation shard drops it. + excludeTests: [ + 'frontmatter-cli.test.cjs', + 'frontmatter.test.cjs', ], minScore: 65, + // Wall-time projection, re-derived after piece 1 (dropping frontmatter.test.cjs) using + // this file's own documented method. Mutant-count factor is unchanged: source grew 1.8x + // for #3881 (1030 -> ~1850 mutants; see the #3888-era note this superseded for that + // derivation). Per-run test-command cost is re-measured on the CURRENT 6-file derived + // set (frontmatter.property/.unit/-golden-parity/-roundtrip.property + unusable-input + + // feat-3881-yaml-parser-consequences — the shard minus frontmatter.test.cjs and minus + // frontmatter-cli.test.cjs, neither of which was ever in a measured baseline): 1520ms, + // vs the documented OLD 3-file baseline of 593ms — a 2.56x per-run cost increase (down + // from the pre-piece-1 8x, since the file responsible for 3132ms of the old 4669ms + // 7-file run is gone). Applying both factors the same way the prior note did: 586s + // (documented 3-file/1030-mutant CI baseline) * 1.8 (mutants) * 2.56 (test cost) ~= + // 2700s (~45 minutes). Set to 60 minutes for margin above that projection (the same + // ~1.3x margin ratio the prior 180-minute budget used over its own 140-minute + // projection), well under GitHub Actions' 360-minute job ceiling and a 3x cut from the + // previous 180. Scoped to this shard only via timeoutMinutes below — every other shard + // keeps the 15-minute default. + timeoutMinutes: 60, + // isolation: intentionally NOT set (defaults to 'process' below) — unchanged from the + // prior audit: 'none' showed no reliable win once the test set grew past 3 files + // (overlapping distributions), and dropping frontmatter.test.cjs only shrinks the set + // further, so there is no new basis to revisit that call. }, + // adr-parser / config-schema / active-workstream-store / core-utils: derivation reproduces + // their prior hand lists exactly (every constraining file's own name already matched the + // "*" rule) — no extraTests/excludeTests needed. Note config-schema in particular: + // an UNFILTERED require-scan finds 22 files that require config-schema.cjs, but only + // config-schema.property.test.cjs matches the naming rule — the naming filter is what + // keeps this shard from silently ballooning to include spawn-heavy integration suites + // (e.g. graphify-auto-update.slow.test.cjs) that merely load config-schema as a fixture + // dependency of something else under test. 'adr-parser': { cjs: 'gsd-core/bin/lib/adr-parser.cjs', - tests: [ - 'tests/adr-parser.property.test.cjs', - 'tests/adr-parser.test.cjs', - 'tests/adr-parser.unit.test.cjs', - ], minScore: 68, }, 'config-schema': { cjs: 'gsd-core/bin/lib/config-schema.cjs', - tests: [ - 'tests/config-schema.property.test.cjs', - ], // CI 54.55% timeout-free (18 killed / 0 timeout / 33 total) 2026-06-14; // local was 69.7% — timeout inflation. Floor = 54 - 2 margin. - minScore: 52, + // CI run 33012034388 (2026-08-25, #3881 ratchet): measured 75.51%. Floor = + // floor(75.51) - 1 = 74. + minScore: 74, }, 'active-workstream-store': { cjs: 'gsd-core/bin/lib/active-workstream-store.cjs', - tests: [ - 'tests/active-workstream-store.test.cjs', - 'tests/active-workstream-store.unit.test.cjs', - ], - minScore: 80, + // CI run 33012034388 (2026-08-25, #3881 ratchet): measured 87.42%. Floor = + // floor(87.42) - 1 = 86. + minScore: 86, }, 'core-utils': { cjs: 'gsd-core/bin/lib/core-utils.cjs', - tests: [ - 'tests/core-utils.test.cjs', - ], minScore: 75, // measured 77.52% (2026-06-14, issue #1187); floor = 77 - 2 }, // planning-inspect / plan-document / planning-command-router: net-new modules @@ -256,25 +495,29 @@ const COVERED = { // mutant, so 640 mutants x 20s could not finish inside the 15-minute shard // cap. The integration suite is unaffected by this change: it keeps running // in full in the normal (non-mutation) test job. + // planning-inspect's own name matches "planning-inspect.unit.test.cjs" via the naming + // rule, so that file is auto-derived. planning-inspect.test.cjs (the excluded integration + // file the comment above names) ALSO matches the naming rule and directly requires the + // module, so it must be an explicit excludeTests entry now — the derivation would + // otherwise auto-include it and reproduce the exact 15-minute-cap cancellation the + // comment above documents. 'planning-inspect': { cjs: 'gsd-core/bin/lib/planning-inspect.cjs', - tests: [ - 'tests/planning-inspect.unit.test.cjs', - ], + excludeTests: ['planning-inspect.test.cjs'], minScore: 56, }, + // plan-document / planning-command-router: their own names never appear in any test + // filename (the shared dedicated unit file is named after planning-inspect, the module + // #2790 extracted them alongside), so the naming-rule derivation finds nothing — same + // cross-cutting shape as context-composer above. Declared via extraTests. 'plan-document': { cjs: 'gsd-core/bin/lib/plan-document.cjs', - tests: [ - 'tests/planning-inspect.unit.test.cjs', - ], + extraTests: ['planning-inspect.unit.test.cjs'], minScore: 75, }, 'planning-command-router': { cjs: 'gsd-core/bin/lib/planning-command-router.cjs', - tests: [ - 'tests/planning-inspect.unit.test.cjs', - ], + extraTests: ['planning-inspect.unit.test.cjs'], minScore: 94, }, // model-catalog: net-new registration by #3007. The module was entirely @@ -303,9 +546,13 @@ const COVERED = { // unit-file design above worked: the #2790 precedent's 15-minute shard-cap // cancellations do not apply here, and for comparison the `frontmatter` // shard in the same run took 9m46s. + // model-catalog: derivation finds two files never in the old hand list — + // model-catalog-runtime-defaults.test.cjs and model-catalog-valid-tiers.test.cjs — both + // directly require model-catalog.cjs and match the "model-catalog*" naming rule. Measured + // cost: 50ms (1-file) -> 196ms (3-file), in-process, 0 subprocess spawns; still far under + // the 57s the shard already measured for the single-file set. 'model-catalog': { cjs: 'gsd-core/bin/lib/model-catalog.cjs', - tests: ['tests/model-catalog.unit.test.cjs'], minScore: 58, }, // state-contract: net-new module from #3227. Without this entry the @@ -336,13 +583,23 @@ const COVERED = { // timeouts as kills and inflate scores badly (this file already records // prompt-budget 99.6% local vs 68.33% CI, and config-schema 69.7% local vs // 54.55% CI). + // state-contract.test.cjs matches the naming rule and directly requires state-contract.cjs + // but is the same spawn-heavy integration shape as planning-inspect.test.cjs (446 lines, + // 16 subprocess-spawn references) — excluded explicitly rather than left to fall through. 'state-contract': { cjs: 'gsd-core/bin/lib/state-contract.cjs', - tests: ['tests/state-contract.unit.test.cjs'], + excludeTests: ['state-contract.test.cjs'], minScore: 65, }, }; +// Compute the final, derived `tests` array for every COVERED entry. Done once, after the +// full COVERED literal above is built, so every entry's extraTests/excludeTests declarations +// are visible to computeModuleTests regardless of source order. +for (const [moduleName, entry] of Object.entries(COVERED)) { + entry.tests = computeModuleTests(moduleName, entry); +} + // ── Files that, when changed, invalidate ALL modules ───────────────────────── // Changes to the Stryker config, this script itself, or any covered test file // affect all mutation scores and must force a full re-run. @@ -439,6 +696,15 @@ function buildResult(moduleNames) { mutate: COVERED[name].cjs, tests: COVERED[name].tests.join(' '), minScore: COVERED[name].minScore, + // node:test's default per-file process isolation; only modules that document a + // measured, audited need for 'none' opt out. + isolation: COVERED[name].isolation || 'process', + // Per-shard CI job timeout in minutes. Defaults to 15 (the shared per-shard budget); + // only a module that documents a measured need for more (see the frontmatter entry + // above) sets a higher value. Threaded through mutation.yml's job-level + // `timeout-minutes: ${{ matrix.timeoutMinutes }}` the same way `isolation` is threaded + // through the test-runner env. + timeoutMinutes: COVERED[name].timeoutMinutes || 15, })); return { @@ -458,6 +724,8 @@ function printHuman(result, changedFiles) { console.log(` mutate: ${shard.mutate}`); console.log(` tests: ${shard.tests}`); console.log(` minScore: ${shard.minScore}`); + console.log(` isolation:${shard.isolation}`); + console.log(` timeoutMinutes:${shard.timeoutMinutes}`); } } @@ -520,6 +788,17 @@ function resolveMutationBreak(raw) { // Export internals for programmatic use (tests/mutation-matrix-ratchet.test.cjs). // The require.main guard prevents main() from running when this file is require()d. -module.exports = { COVERED, TARGET_MUTATION_SCORE, resolveMutationBreak, readStdinSync }; +module.exports = { + COVERED, + TARGET_MUTATION_SCORE, + resolveMutationBreak, + readStdinSync, + // Derivation-engine internals — exported for tests/mutation-test-derivation-drift.test.cjs + // and scripts/lint-mutation-test-derivation-drift.cjs. + findRequiringTestFiles, + matchesModuleNamingRule, + deriveNamedTests, + computeModuleTests, +}; if (require.main === module) runMain(main); diff --git a/src/commands.cts b/src/commands.cts index b83c67a9b..7d9223347 100644 --- a/src/commands.cts +++ b/src/commands.cts @@ -52,7 +52,7 @@ import planningWorkspace = require('./planning-workspace.cjs'); const { planningDir, planningPaths } = planningWorkspace; // eslint-disable-next-line @typescript-eslint/no-require-imports import frontmatter = require('./frontmatter.cjs'); -const { extractFrontmatter, agentScalarNeedsDoubleQuoting, escapeDoubleQuoted } = frontmatter; +const { extractFrontmatter, agentScalarNeedsDoubleQuoting, escapeDoubleQuotedScalar } = frontmatter; // eslint-disable-next-line @typescript-eslint/no-require-imports import modelProfiles = require('./model-profiles.cjs'); const { MODEL_PROFILES, VALID_PHASE_TYPES } = modelProfiles; @@ -757,9 +757,9 @@ function setFrontmatterKeyLine(content: string, key: string, value: string): str // Both writers of these frontmatter keys — this sync path and the // install-side `frontmatterScalar` in runtime-artifact-conversion.cts — // now share one escaping rule: quote via `agentScalarNeedsDoubleQuoting` + - // `escapeDoubleQuoted` (both from frontmatter.cts) rather than each + // `escapeDoubleQuotedScalar` (both from frontmatter.cts) rather than each // interpolating `value` raw/differently. - const renderedValue = agentScalarNeedsDoubleQuoting(value) ? `"${escapeDoubleQuoted(value)}"` : value; + const renderedValue = agentScalarNeedsDoubleQuoting(value) ? `"${escapeDoubleQuotedScalar(value)}"` : value; // EOL comes from the MATCHED BLOCK, not the start of the file. With a // preamble the two can disagree, and on a CRLF document that misaligns every // offset below by one byte and mangles the opening fence. diff --git a/src/frontmatter.cts b/src/frontmatter.cts index 6ae7785b2..0ebaef63e 100644 --- a/src/frontmatter.cts +++ b/src/frontmatter.cts @@ -4,6 +4,16 @@ * ADR-457 build-at-publish: the hand-written bin/lib/frontmatter.cjs collapsed * to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour * from the prior hand-written .cjs; only strict types are added. + * + * ADR-3473 §8.1 (#3881): the read path is no longer a hand-rolled line + * scanner. `parseGuardedYamlRegion` now parses through the vendored `js-yaml` + * (`./vendor/js-yaml.cjs`, verbatim `node_modules/js-yaml/dist/js-yaml.js`) + * under `FAILSAFE_SCHEMA` + `json: true` — every scalar comes back a string + * (today's contract, no adapter needed) and duplicate keys overwrite + * (last-wins, the documented invariant). What js-yaml does NOT do — + * anchors/alias refusal, the #3257 comment channel, the #1882 truncation + * probe, null-byte preservation and object-list flattening for the existing + * string-shaped value contract — is layered on top, in one place, below. */ import fs from 'node:fs'; @@ -17,6 +27,7 @@ import { splitLines } from './text-lines.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import unusableInputMod = require('./unusable-input.cjs'); const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod; +import { load as yamlLoad, dump as yamlDump, FAILSAFE_SCHEMA, YAMLException } from './vendor/js-yaml.cjs'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -26,36 +37,14 @@ type Frontmatter = Record; // ─── Parsing engine ─────────────────────────────────────────────────────────── /** - * Split a YAML inline array body on commas, respecting quoted strings. - * e.g. '"a, b", c' → ['a, b', 'c'] + * Base js-yaml options for every parse in this module (ADR-3473 §8.1 §3.1/§3.3). + * `FAILSAFE_SCHEMA` resolves only `!!str`/`!!seq`/`!!map` — every scalar comes + * back a string, which is today's contract and needs no coercion layer. + * `json: true` makes duplicate keys overwrite (last-wins) instead of throwing, + * which is the documented behavior `tests/fixtures/adversarial/frontmatter/ + * duplicate-keys.md` pins. */ -function splitInlineArray(body: string): string[] { - const items: string[] = []; - let current = ''; - let inQuote: string | null = null; - - for (let i = 0; i < body.length; i++) { - const ch = body[i]; - if (inQuote) { - if (ch === inQuote) { - inQuote = null; - } else { - current += ch; - } - } else if (ch === '"' || ch === "'") { - inQuote = ch; - } else if (ch === ',') { - const trimmed = current.trim(); - if (trimmed) items.push(trimmed); - current = ''; - } else { - current += ch; - } - } - const trimmed = current.trim(); - if (trimmed) items.push(trimmed); - return items; -} +const YAML_LOAD_OPTS = { schema: FAILSAFE_SCHEMA, json: true }; /** * How many parsed keys an unterminated region must yield before it is reported as a @@ -82,7 +71,9 @@ const UNTERMINATED_KEY_THRESHOLD = 2; * a frontmatter block ends mid-block, so *every* line in the region is still frontmatter-shaped, * whereas a document merely opening with a rule goes on to prose. So the region must be * uniformly frontmatter-shaped AND carry enough keys to be worth reporting; either test alone - * has a false-positive class the other closes. + * has a false-positive class the other closes. This heuristic is deliberately a raw-text scan, + * independent of whichever parser counts the keys (see `countKeysBeforeTruncation`) — it is the + * guard that keeps a stricter parser from turning "opens with a rule" into a false positive. */ function isFrontmatterShaped(region: string): boolean { const lines = splitLines(region).filter((line) => line.trim() !== ''); @@ -109,164 +100,491 @@ const FULL_LINE_COMMENTS = Symbol('fullLineComments'); type FullLineCommentChannel = { leading: Record; trailing: string[] }; /** - * Unescape the interior of a YAML double-quoted scalar — the exact inverse of - * `escapeDoubleQuoted` (#3497). The writer has escaped `\`/`"`/`\n`/`\t`/`\r`/ - * `\xHH` since #1779, but the reader only stripped the delimiters, so - * parse(serialize(x)) ≠ x for any quoted scalar carrying a `"` or `\`: each - * read-modify-write round-trip doubled the backslashes (b → 2b+1), growing a - * repeatedly-synced field — and its document — without bound until tooling - * OOMed. Recognized escapes decode per YAML double-quoted semantics; an - * unrecognized `\c` is kept literally (backslash + char), matching the - * strip-only behavior hand-authored files had before this fix. + * ADR-3473 §8.1 §0.3 (#3881, consequence 2): a Symbol-keyed marker carried on the `{}` + * `extractFrontmatter` returns when the region failed to parse (malformed YAML, or a refused + * anchor/alias/merge key — consequence 6). Mirrors `FULL_LINE_COMMENTS` exactly: invisible to + * `Object.keys`/`Object.entries`/`JSON.stringify`/`for-in`, so the 70 call sites that never + * inspect it are unaffected, while the 8 `hasFrontmatter = Object.keys(...).length > 0` sites + * consult it to tell "genuinely empty" apart from "unparseable" and avoid reassembling the + * document without its (unparsed but still present) frontmatter block. Those 8 call sites (7 in + * `state-transition.cts` behind `isUnparseableFrontmatter`/`rawFrontmatterPrefix`, plus 1 more in + * `state.cts`'s `cmdStateCompletePhase`) are wired on this branch — this module sets and exports + * the marker; the callers consume it. */ -function unescapeDoubleQuoted(s: string): string { - let out = ''; - for (let i = 0; i < s.length; i++) { - const ch = s[i]; - if (ch !== '\\' || i === s.length - 1) { - out += ch; - continue; - } - const next = s[++i]; - if (next === '\\' || next === '"') { - out += next; - } else if (next === 'n') { - out += '\n'; - } else if (next === 't') { - out += '\t'; - } else if (next === 'r') { - out += '\r'; - } else if (next === 'x') { - const hex = s.slice(i + 1, i + 3); - if (/^[0-9a-fA-F]{2}$/.test(hex)) { - out += String.fromCharCode(parseInt(hex, 16)); - i += 2; - } else { - out += '\\x'; // not \xHH — keep literally - } - } else { - out += '\\' + next; // unrecognized escape — keep literally - } - } - return out; +const FRONTMATTER_UNPARSEABLE = Symbol('frontmatterUnparseable'); + +function unparseableResult(): Frontmatter { + // Plain-prototype (post-remote-runner-fix, #3881): the PUBLIC parse surface must keep + // handing callers ordinary `{}`-shaped objects — `assert.deepStrictEqual` compares + // prototypes, and 50+ existing call sites/tests compare against object literals. The + // Symbol marker is still attached via `Object.defineProperty` rather than bracket + // assignment, which is what actually matters for prototype-pollution safety: a data + // property named e.g. `__proto__` set through `defineProperty` never invokes the + // inherited `Object.prototype.__proto__` accessor setter the way `fm[k] = v` would. + const fm: Record = {}; + Object.defineProperty(fm, FRONTMATTER_UNPARSEABLE, { + value: true, writable: true, enumerable: true, configurable: true, + }); + return fm as Frontmatter; } /** - * Strip the quote delimiters off a parsed YAML scalar, un-escaping the interior - * when the scalar is double-quoted (#3497 — the parse-side complement of - * `escapeDoubleQuoted`). Single-quoted scalars keep the historical strip-only - * behavior (the writer never emits them; `''` → `'` folding is out of scope). - * A scalar wrapped in double quotes un-escapes; anything else keeps the exact - * prior delimiter-strip behavior, including a stray unpaired boundary quote. - */ -function parseQuotedScalar(value: string): string { - if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) { - return unescapeDoubleQuoted(value.slice(1, -1)); - } - return value.replace(/^["']|["']$/g, ''); -} - -/** - * Parse one already-delimited YAML region into a Frontmatter object. + * Convert an internal, possibly null-prototype, YAML-derived value tree into an ordinary + * plain-prototype tree for the public parse surface (post-remote-runner-fix, #3881). * - * Extracted from `extractFrontmatter` (#1882) so the truncation probe below and the real - * parse run the *same* parser. A second, simpler "does this look like YAML?" matcher would - * be a parallel surface that drifts — exactly the generative-fix-divergence class. + * The internal construction (`normalizeParsedValue`, `restoreNullBytesDeep`, + * `extractCommentChannel`) deliberately builds with `Object.create(null)` so a hostile key + * like `__proto__`/`constructor`/`toString` is always a genuine own data property and never + * resolves to (or overwrites) an inherited `Object.prototype` member WHILE THE TREE IS BEING + * BUILT. That safety property has nothing to do with what prototype the FINAL object callers + * receive — `assert.deepStrictEqual` compares prototypes, so handing back a null-prototype + * object silently broke every caller comparing against `{}` object literals (57+ tests). This + * walks the tree exactly once at the return boundary and re-homes every string/number-keyed + * own property onto an ordinary `{}` via `Object.defineProperty` (never `out[k] = v`), which + * is what keeps the copy itself safe: `defineProperty` always creates a real own data + * property, even for a key literally named `__proto__`, and never triggers the inherited + * setter the way bracket assignment would. + * + * The `FULL_LINE_COMMENTS` Symbol channel is copied across by reference, NOT recursed into — + * it stays null-prototype. It is purely internal plumbing (only `reconstructFrontmatter` / + * `propagateCommentChannel`, both in this module, ever read `channel.leading[key]` with an + * arbitrary user-authored key), invisible to every external reader (`Object.keys` / + * `Object.entries` / `JSON.stringify` / `for-in` all skip symbols), and re-plaining it would + * reopen the exact `leading[key]` inherited-member bug the null prototype exists to close. */ -function parseYamlRegion(yaml: string): Frontmatter { - const frontmatter: Frontmatter = {}; +function toPlainValueTree(value: unknown): unknown { + if (Array.isArray(value)) return value.map(toPlainValueTree); + if (value !== null && typeof value === 'object') { + const out: Record = {}; + for (const k of Object.keys(value)) { + Object.defineProperty(out, k, { + value: toPlainValueTree((value as Record)[k]), + writable: true, enumerable: true, configurable: true, + }); + } + for (const s of Object.getOwnPropertySymbols(value)) { + Object.defineProperty(out, s, { + value: (value as Record)[s], // internal channel: copied raw, not recursed + writable: true, enumerable: true, configurable: true, + }); + } + return out; + } + return value; +} + +/** + * ADR-3473 §8.1 (consequence 6, corrected post-#3881-review): `FAILSAFE_SCHEMA` still resolves + * anchors, aliases and merge keys — that is core YAML mechanics, not tag resolution, so no + * schema choice disables it. A hostile 7-line frontmatter (`&a [...]` fanned out through nested + * aliases) expands to tens of megabytes in a few milliseconds, and `.planning/` documents are + * user-authored, untrusted input. Corpus occurrences of anchors/aliases/merge keys today: zero, + * so refusing them costs nothing. + * + * This was originally a raw-text line regex, and it was bypassable: a quoted key (`"a": &x 1`), + * a flow mapping (`{b: &x 1, c: *x}`) or a flow sequence (`[&x "q", *x]`) all define/use an + * anchor while never matching the "bareword key, then `&`/`*`" line shape the regex checked — + * so the exact expansion this guard exists to stop went straight through unrefused. Detecting a + * YAML anchor with a regex is re-implementing a YAML parser in order to guard a YAML parser; the + * fix is to let the real parser report it instead of re-deriving anchor syntax by hand. js-yaml's + * `load` accepts a `listener` invoked once per parse event with the parser's internal `State`; + * `state.anchor` is non-null on every event belonging to an anchored node, in every spelling + * above (verified by execution against all four), so throwing the instant it is set aborts the + * parse before any alias expansion happens — the 303-byte quoted-key bomb refuses in ~1ms rather + * than expanding to ~35MB. A `<<: *base` merge key is refused too, because it can only ever + * reference a previously anchored node — the alias itself trips `state.anchor`. A merge key with + * NO alias (`<<: {b: 1}`) carries no anchor and is not separately refused: under + * `FAILSAFE_SCHEMA` (no `!!merge` type resolution) it never actually merges — it parses as an + * ordinary literal `"<<"` string key with a normal, non-expanding nested map — so it carries none + * of the resource-exhaustion risk this guard exists for. + */ +/** Thrown from inside the `listener` callback below; never surfaced past `refuseAnchorsAndAliases`. */ +class AnchorDetectedSignal extends Error {} + +function refuseAnchorsAndAliases(yaml: string): void { + try { + yamlLoad(yaml, { + ...YAML_LOAD_OPTS, + listener: (_event: string, state: { anchor?: string | null }) => { + // Thrown FROM INSIDE the listener, not merely recorded and checked after `load` + // returns: js-yaml keeps parsing (and, for an alias, keeps EXPANDING) past a listener + // that only sets a flag, which reintroduces the exact resource-exhaustion window this + // guard exists to close. Throwing here aborts the parse immediately, before any + // expansion — the billion-laughs fixture refuses in ~1-2ms rather than building the + // ~35MB tree first and discarding it. + if (state.anchor !== null && state.anchor !== undefined) throw new AnchorDetectedSignal(); + }, + }); + } catch (e) { + if (e instanceof AnchorDetectedSignal) { + throw new YAMLException( + 'frontmatter: anchors, aliases and merge keys are refused (ADR-3473 §8.1)', + ); + } + // Any other failure (malformed YAML unrelated to anchors) is reported by the real parse + // in parseGuardedYamlRegion; this pre-pass only exists to refuse anchors/aliases early. + } +} + +/** + * ADR-3473 §8.1 (consequence 7): js-yaml rejects a literal U+0000 unconditionally, under every + * schema. The fixture invariant (`null-byte-value.md`) is "preserve or normalize; never truncate + * silently", so the byte is swapped for a private-use sentinel before the parse and restored in + * every resulting string afterward — preserving the exact byte rather than normalizing it away. + * + * CORRECTED (post-#3881-review, finding 3): the round-trip was non-injective. `restoreNullBytesDeep` + * rewrites EVERY U+E000 in the parsed tree back to U+0000 — including one the document author + * legitimately wrote — so a document containing a literal U+E000 (with or without an actual NUL + * elsewhere) came back corrupted: its own U+E000 silently became a NUL. Rather than pick a + * "provably absent" sentinel (unprovable in general — any fixed codepoint can itself appear in + * user-authored input), `refuseIfSentinelPresent` makes the substitution provably reversible by + * refusing outright whenever the RAW region already contains U+E000, before any substitution + * happens — consistent with this module's existing refusal path (anchors/aliases/merge keys) for + * "cannot faithfully round-trip this input." Once refused, the sentinel is guaranteed absent from + * the input the escape/restore pair actually operates on, and the substitution is injective by + * construction. + */ +const NULL_BYTE_SENTINEL = String.fromCharCode(0xE000); + +function refuseIfSentinelPresent(yaml: string): void { + if (yaml.includes(NULL_BYTE_SENTINEL)) { + throw new YAMLException( + 'frontmatter: contains the reserved null-byte-escape sentinel U+E000 — refused rather than ' + + 'silently corrupted on restore (ADR-3473 §8.1)', + ); + } +} + +function escapeNullBytesForParse(yaml: string): string { + return yaml.indexOf('\u0000') === -1 ? yaml : yaml.split('\u0000').join(NULL_BYTE_SENTINEL); +} + +function restoreNullBytesDeep(value: unknown): unknown { + if (typeof value === 'string') { + return value.includes(NULL_BYTE_SENTINEL) ? value.split(NULL_BYTE_SENTINEL).join('\u0000') : value; + } + if (Array.isArray(value)) return value.map(restoreNullBytesDeep); + if (value && typeof value === 'object') { + // Null-prototype (post-#3881-review, finding 3): an ordinary {} here silently DROPS a + // top-level key literally named __proto__ -- out['__proto__'] = v on a normal object + // invokes the inherited Object.prototype.__proto__ SETTER (reassigning the object's own + // prototype) instead of creating a data property, so key: __proto__ in a document + // vanishes from the parsed result with no error. Confirmed by execution: ---\n__proto__: + // hello\nz: 1\n---\n parsed to {z: "1"}, silently dropping the __proto__ key entirely. + // Object.create(null) has no such setter, so the assignment below is always a genuine + // own data property, for every key including __proto__ itself. + const out: Record = Object.create(null) as Record; + for (const [k, v] of Object.entries(value as Record)) { + const restoredKey = k.includes(NULL_BYTE_SENTINEL) ? k.split(NULL_BYTE_SENTINEL).join(String.fromCharCode(0)) : k; + out[restoredKey] = restoreNullBytesDeep(v); + } + return out; + } + return value; +} + +/** + * ADR-3473 §8.1 (consequence 3): js-yaml resolves `- test: a b` (and the three other spellings + * of the same value — `"a b"`, `'a b'`, `{test: a b}`) to ONE tree shape, `[{test: "a b"}]` — + * unlike the legacy scanner, whose output was a function of the raw source line and therefore + * produced four different strings for those four spellings (ADR-3473 40-design.md §0.1). No + * adapter over a tree can recover a distinction the tree does not carry, so this renders a single + * canonical string per object-list item instead, keeping the existing value SHAPE (an array of + * strings) that `sliceTopLevelFrontmatterSegments`, the `[object Object]` guard and + * `noOpObjectListSetError` all depend on. Choosing structured (non-string) values is fork (b) — + * out of scope for this phase. + */ +function flattenScalarForDisplay(value: unknown): string { + if (value === null || value === undefined) return ''; + if (Array.isArray(value)) return `[${value.map(flattenScalarForDisplay).join(', ')}]`; + if (typeof value === 'object') return flattenObjectListItem(value as Record); + // eslint-disable-next-line @typescript-eslint/no-base-to-string + return String(value); +} + +function flattenObjectListItem(item: Record): string { + return Object.entries(item) + .map(([k, v]) => `${k}: ${flattenScalarForDisplay(v)}`) + .join(', '); +} + +/** + * Recursively normalize a parsed js-yaml tree to this module's historical contract: + * - `null`/`undefined` in an object-value slot becomes `{}` (consequence 1 — matches the + * legacy scanner's empty-value handling exactly, so `reconstructFrontmatter` — which omits + * null-valued keys — still round-trips a bare `key:` line instead of deleting it); + * - `null`/`undefined` inside an array becomes `''` (arrays are always string[] in this + * module's contract); + * - a map or nested array found as an array ITEM is flattened to a canonical string + * (consequence 3); + * - every other scalar is already a string under `FAILSAFE_SCHEMA` and passes through. + */ +function normalizeParsedValue(value: unknown, inArray: boolean): unknown { + if (value === null || value === undefined) return inArray ? '' : {}; + if (Array.isArray(value)) { + return value.map((item) => { + if (item !== null && typeof item === 'object') { + return Array.isArray(item) ? flattenScalarForDisplay(item) : flattenObjectListItem(item as Record); + } + return normalizeParsedValue(item, true); + }); + } + if (typeof value === 'object') { + // Null-prototype (post-#3881-review, finding 3): a top-level YAML key named `constructor`, + // `__proto__`, `toString`, `valueOf` or `hasOwnProperty` is ordinary user-authored input + // (`.planning/` frontmatter), not an attack — but on an ordinary `{}` it resolves to the + // inherited Object.prototype member instead of `undefined`, which crashes downstream + // bracket reads (`commentChannel?.leading[key]`) and silently mis-answers `fm[field]` + // lookups in `cmdFrontmatterGet`. `Object.create(null)` severs the prototype chain so every + // reader of a parsed Frontmatter object gets a real bracket-read contract: present or + // `undefined`, never an inherited function. + const out: Record = Object.create(null) as Record; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = normalizeParsedValue(v, false); + } + return out; + } + return value; +} + +/** + * ADR-3473 §8.1 (consequence 5): the #3257 comment scan used to key off the legacy parser's own + * `[a-zA-Z0-9_-]+:` key regex — a Unicode key (e.g. `相:`) never matched it, so a comment above + * one silently attached to the WRONG key once js-yaml owns the real (Unicode-inclusive) key set. + * This attributes each pending column-0 comment block against js-yaml's own parsed top-level key + * list, in document order, by matching the literal key text at column 0 rather than re-deriving a + * key shape independently — so it can never disagree with what was actually parsed. + */ +function extractCommentChannel(yaml: string, orderedKeys: string[]): FullLineCommentChannel | undefined { const lines = splitLines(yaml); - - // #3257: pending column-0 full-line comments, attached to the next top-level key. - let pendingComments: string[] = []; - let commentChannel: FullLineCommentChannel | undefined; - - // Stack to track nested objects: [{obj, key, indent}] - type StackEntry = { obj: Record | unknown[]; key: string | null; indent: number }; - const stack: StackEntry[] = [{ obj: frontmatter, key: null, indent: -1 }]; + let pending: string[] = []; + let channel: FullLineCommentChannel | undefined; + let keyIdx = 0; for (const line of lines) { - // Skip empty lines if (line.trim() === '') continue; - - // #3257: capture column-0 full-line comments; attach them to the next top-level key. if (/^#/.test(line)) { - pendingComments.push(line); + pending.push(line); continue; } - - // Calculate indentation (number of leading spaces) - const indentMatch = line.match(/^(\s*)/); - const indent = indentMatch ? indentMatch[1].length : 0; - - // Pop stack back to appropriate level - while (stack.length > 1 && indent <= stack[stack.length - 1].indent) { - stack.pop(); - } - - const current = stack[stack.length - 1]; - - // Check for key: value pattern - const keyMatch = line.match(/^(\s*)([a-zA-Z0-9_-]+):\s*(.*)/); - if (keyMatch) { - const key = keyMatch[2]; - // #3257: attach any pending comments to this (top-level) key. - if (pendingComments.length) { - if (!commentChannel) commentChannel = { leading: {}, trailing: [] }; - commentChannel.leading[key] = pendingComments; - pendingComments = []; - } - const value = keyMatch[3].trim(); - - if (value === '' || value === '[') { - // Key with no value or opening bracket — could be nested object or array - const newObj: Record | unknown[] = value === '[' ? [] : {}; - (current.obj as Record)[key] = newObj; - current.key = null; - // Push new context for potential nested content - stack.push({ obj: newObj, key: null, indent }); - } else if (value.startsWith('[') && value.endsWith(']')) { - // Inline array: key: [a, b, c] — quote-aware split (REG-04 fix) - (current.obj as Record)[key] = splitInlineArray(value.slice(1, -1)); - current.key = null; - } else { - // Simple key: value - (current.obj as Record)[key] = parseQuotedScalar(value); - current.key = null; - } - } else if (line.trim().startsWith('- ')) { - // Array item - const itemValue = parseQuotedScalar(line.trim().slice(2)); - - // If current context is an empty object, convert to array - if (typeof current.obj === 'object' && !Array.isArray(current.obj) && Object.keys(current.obj).length === 0) { - // Find the key in parent that points to this object and convert it - const parent = stack.length > 1 ? stack[stack.length - 2] : null; - if (parent) { - for (const k of Object.keys(parent.obj)) { - if ((parent.obj as Record)[k] === current.obj) { - (parent.obj as Record)[k] = [itemValue]; - current.obj = (parent.obj as Record)[k] as unknown[]; - break; - } - } + if (!/^\s/.test(line) && keyIdx < orderedKeys.length) { + const key = orderedKeys[keyIdx]; + if (line.startsWith(`${key}:`) || line.startsWith(`"${key}"`) || line.startsWith(`'${key}'`)) { + if (pending.length) { + // Null-prototype `leading` (post-#3881-review, finding 3): `key` is an arbitrary + // user-authored YAML key — `constructor`, `__proto__`, `toString`, `valueOf`, + // `hasOwnProperty` all round-trip through here. On an ordinary `{}` those resolve + // to inherited Object.prototype members, and `reconstructFrontmatter`'s + // `commentChannel?.leading[key]` read then finds e.g. the `Object.prototype.toString` + // FUNCTION instead of `undefined`, throwing when it is iterated as if it were an + // array of comment strings. + if (!channel) channel = { leading: Object.create(null) as Record, trailing: [] }; + channel.leading[key] = pending; + pending = []; } - } else if (Array.isArray(current.obj)) { - current.obj.push(itemValue); + keyIdx++; + continue; } } + // A non-comment line that is not the next expected top-level key start: any comments + // pending before it were not actually leading a key (malformed/unusual input) — drop + // rather than misattach, matching the prior scan's "attach only when a key follows" shape. + pending = []; } - // #3257: trailing comments (after the last key) + attach the channel if any comment was seen. - if (pendingComments.length) { - if (!commentChannel) commentChannel = { leading: {}, trailing: [] }; - commentChannel.trailing = pendingComments; + if (pending.length) { + if (!channel) channel = { leading: Object.create(null) as Record, trailing: [] }; + channel.trailing = pending; } + return channel; +} + +/** + * Parse one already-delimited YAML region into a Frontmatter object, via the vendored js-yaml + * (ADR-3473 §8.1). Throws (a `YAMLException`, or a plain `Error` from `refuseAnchorsAndAliases`) + * on anything js-yaml itself cannot parse or that this module refuses outright; callers decide + * whether to surface that as `unparseableResult()` or use it as a truncation signal. + * + * Renamed from `parseYamlRegion` (post-#3881-review, finding 2): §8.1 says `parseYamlRegion` is + * "deleted, not patched" — the hand-rolled line scanner that name identified IS gone, but the + * name itself survived on a new function with two callers (`extractFrontmatter` and + * `countKeysBeforeTruncation`) that could not be inlined without duplicating the + * refusal/null-byte/comment-channel glue below. Renaming closes that gap literally: nothing in + * this module still answers to the old hand-rolled scanner's name. + * + * RESTORED (fix #3881/#3881-followup-2, closes the #3705-shaped regression reported against + * `tests/smart-entry.unit.test.cjs:867`/`tests/smart-entry.property.test.cjs`): a prior revision + * of this function fell back, on a throw, to TWO hand-rolled re-implementations of YAML dialect — + * `repairAmbiguousColonValues` (below) for `key: value: extra`-shaped ambiguous colons, and + * `repairMalformedInlineArrays`/`splitLegacyInlineArrayItems` for a malformed/unclosed `[...]`. + * Both were deleted in 810e5e508 after a sweep of every tracked `*.md` file in this repo (910 + * files) showed disabling each repair independently changed the parse result for zero documents. + * + * THAT SWEEP MEASURED THE WRONG POPULATION. `repairAmbiguousColonValues`'s one real dependent is + * not a document committed anywhere in this repo — it is user hand-edited STATE.md content that + * exists only on end users' machines and is pinned here by `tests/smart-entry.unit.test.cjs` (see + * its own in-file comment: "silently re-opened #2571/#2570 for hand-edited STATE.md that omits + * the template em dash"). The exact shape: `last_activity: 2026-06-08: reviewed the PR queue` — a + * colon-separated date+description a user typed by hand instead of the template's ` — ` (em dash) + * separator. js-yaml correctly refuses this as genuinely ambiguous YAML (a colon+space inside an + * unquoted scalar opens a nested mapping key); the old hand-rolled scanner tolerated it by taking + * everything after the first `key:` verbatim. A future sweep of tracked `.md` files will AGAIN + * show zero dependents for this exact reason — the dependent never lives in this repo's tree, it + * lives in a user's own `.planning/STATE.md`. Do not delete this again on that evidence alone; + * `tests/smart-entry.unit.test.cjs` and the frontmatter-level row in + * `tests/feat-3881-yaml-parser-consequences.test.cjs` are the actual proof the dependent exists. + * + * `repairMalformedInlineArrays`/`splitLegacyInlineArrayItems` stay deleted: their zero-dependents + * finding was reverified directly (frontmatter/smart-entry/verify/roadmap suites all pass without + * them) and, unlike the colon repair, nothing in the test suite or #2570/#2571 documents a + * hand-edited-STATE.md shape that depends on inline-array leniency. + */ +function loadWithAmbiguousColonRepair(yaml: string): unknown { + try { + return yamlLoad(yaml, YAML_LOAD_OPTS); + } catch (e) { + const repaired = repairAmbiguousColonValues(yaml); + if (repaired === yaml) throw e; // nothing to repair — surface the original error + try { + return yamlLoad(repaired, YAML_LOAD_OPTS); + } catch { + throw e; // repair didn't help (still invalid, possibly for another reason) — surface the original + } + } +} + +/** + * Double-quote (and escape) any column-0 `key: value` line whose (single-line) value contains an + * unquoted colon+whitespace or a trailing bare colon — the exact shape that reads as an ambiguous + * nested mapping key to a real YAML parser (`key: value: extra`, `key: value:`). Lines that are + * already safely quoted or open a flow/block collection (`"`, `'`, `[`, `{`) are left untouched + * (js-yaml already handles those); an empty value (`key:` alone, opening a nested block) is left + * untouched too, since repairing it would change a legitimate nested-map opener into a scalar. + * Only column-0 lines are considered — an indented line is either already-valid nested content or + * a genuinely different malformation this repair does not claim to fix. + * + * Restored (fix #3881/#3881-followup-2) — see `loadWithAmbiguousColonRepair`'s docblock for why a + * tracked-document sweep cannot see this function's one real dependent (hand-edited STATE.md, + * #2571/#2570, pinned by `tests/smart-entry.unit.test.cjs`). + */ +function repairAmbiguousColonValues(yaml: string): string { + return splitLines(yaml) + .map((line) => { + const m = /^([A-Za-z0-9_][A-Za-z0-9_-]*):[ \t](.+)$/.exec(line); + if (!m) return line; + const [, key, value] = m; + if (/^["'[{]/.test(value)) return line; // already safely quoted/collection-opened + if (!/:(?:[ \t]|$)/.test(value)) return line; // no ambiguous colon in the value + const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `${key}: "${escaped}"`; + }) + .join('\n'); +} + +function parseGuardedYamlRegion(yaml: string): Frontmatter { + refuseAnchorsAndAliases(yaml); + refuseIfSentinelPresent(yaml); + const escaped = escapeNullBytesForParse(yaml); + const raw: unknown = loadWithAmbiguousColonRepair(escaped); + const normalized = normalizeParsedValue(raw, false); + const root: Record = + normalized && typeof normalized === 'object' && !Array.isArray(normalized) + ? (normalized as Record) + : (Object.create(null) as Record); + const restored = restoreNullBytesDeep(root) as Frontmatter; + + const commentChannel = extractCommentChannel(yaml, Object.keys(restored)); if (commentChannel) { - (frontmatter as Record)[FULL_LINE_COMMENTS as unknown as symbol] = commentChannel; + (restored as Record)[FULL_LINE_COMMENTS as unknown as symbol] = commentChannel; } + // Plain-prototype at the public-surface boundary (post-remote-runner-fix, #3881): see + // `toPlainValueTree`'s docblock. Everything above this line stays null-prototype internally. + return toPlainValueTree(restored) as Frontmatter; +} - return frontmatter; +/** + * ADR-3473 §8.1 (consequence 4): the #1882 truncation probe used to run the SAME parser + * (`parseGuardedYamlRegion`) over an unterminated region and count its keys — deliberately, so a second + * "does this look like YAML?" matcher could never drift from the real parser. js-yaml is stricter + * than the old scanner, though: the dominant real truncation shape (fence opened, well-formed + * keys, then the document body follows with no closing fence) is *invalid* YAML — a plain-text + * paragraph at column 0 right after a block mapping raises `bad indentation of a mapping entry` + * — so a naive "parse the whole region, count keys on success" port yields 0 keys and goes + * silent on exactly the case #1882 exists for. + * + * CORRECTED (post-#3881-review, finding 5): the original recovery — re-parse ONLY the exact + * prefix named by `e.mark.line` — regressed on every realistic truncation shape actually + * checked by execution: an unquoted-colon value (`title: a: b`) and a mis-indented sibling + * key (` plan: 2`) both raise "bad indentation of a mapping entry" ON the offending line + * itself, so `mark.line` names that SAME line and slicing BEFORE it drops the offending + * line's own key entirely — undercounting by exactly the key the probe most needs to see. An + * open flow collection (`list: [a, b`) raises its error on the (nonexistent) line AFTER the + * region's end, so the "marked prefix" still contains the same unterminated `[` and the retry + * parse fails too, falling through to a hard `0`. And a refused anchor/alias/merge key throws + * a mark-LESS `YAMLException` (`refuseAnchorsAndAliases`, thrown from inside the parse + * `listener` before js-yaml attaches position info) — the `e.mark` guard was never entered at + * all, hard `0` again, even though every other key in the region is perfectly valid. + * + * A first fix attempt tried shrinking the region line-by-line and re-parsing through the SAME + * real parser only — still no second matcher. It did NOT recover any of the three shapes above: + * the offending line in the first two IS the malformed token, at every possible prefix boundary + * that includes it, so no amount of shrinking ever makes it parse; the only prefix that ever + * succeeds is the one line BEFORE it, i.e. exactly the original bug's undercount. A parser-only + * strategy cannot report a key whose own line is genuinely invalid YAML — the same limitation + * that made the mark-based recovery fail in the first place. This means "the one real parser, + * never a hand-rolled matcher" is unreachable for the truncation-probe's actual job (a lower- + * bound COUNT of what looks like a key line, not a validity judgment) — this file already + * accepts an independent raw-text matcher for the adjacent question of "is this shaped like + * frontmatter" (`isFrontmatterShaped`, used by this probe's only caller), so `countKeysBeforeTruncation` + * takes the MAX of two lower bounds: how many keys the real parser can recover from the longest + * parseable line-prefix (still the primary signal — correct on the dominant fence-then-prose + * shape, and on any prefix boundary that genuinely IS the truncation point), and how many + * column-0 `key:`-shaped lines the raw text contains (recovers the three regressed shapes, + * whose offending key line the parser can never count). Neither alone is sufficient; together + * they never under-report a key that either signal can see. + */ +function countKeysBeforeTruncation(region: string): number { + const parsed = parsedKeyCount(region); + const textual = countTopLevelKeyShapedLines(region); + return Math.max(parsed, textual); +} + +/** How many keys the real parser recovers from the longest line-prefix of `region` that parses + * cleanly (the whole region itself, when it parses outright). Bounded to at most `region`'s own + * line count re-parses — no worse than the whole-region parse already paid for on the caller's + * unterminated-region path, which is itself bounded by ordinary `.planning/` document sizes (the + * huge-bounded fixture parses successfully on the FIRST try and never reaches the shrink loop). + */ +function parsedKeyCount(region: string): number { + try { + return Object.keys(parseGuardedYamlRegion(region)).length; + } catch { + const lines = splitLines(region); + for (let n = lines.length - 1; n >= 1; n--) { + const prefix = lines.slice(0, n).join('\n'); + if (prefix.trim() === '') continue; + try { + return Object.keys(parseGuardedYamlRegion(prefix)).length; + } catch { + continue; + } + } + return 0; + } +} + +/** How many `key:`-shaped lines `region` textually contains — the raw-text lower bound that + * recovers a key whose OWN line is malformed YAML (an unquoted colon in the value, or a + * mis-indented sibling that reads as an "indented continuation" to the real parser), which no + * re-parse of any prefix can ever count (see `countKeysBeforeTruncation`'s docblock). Matches + * ANY indentation, not only column 0 — the mis-indented-sibling shape is, by construction, a key + * the author intended as top-level but indented by mistake; requiring column 0 here would just + * relocate the exact undercount finding 5 reports. Deliberately the SAME key-shape pattern this + * file already uses for the sibling shape check (`isFrontmatterShaped`'s first branch) — ASCII- + * only is an accepted, precedented scope limit for this raw-text heuristic, not a new one. + */ +function countTopLevelKeyShapedLines(region: string): number { + return splitLines(region).filter((line) => /^\s*[A-Za-z0-9_-]+:/.test(line)).length; } /** @@ -281,8 +599,9 @@ function parseYamlRegion(yaml: string): Frontmatter { * The discriminator is the reason this is not simply "opened but never closed". A Markdown * document whose first line is a thematic break (`---`) takes that exact branch, so flagging * on the missing fence alone reports corruption on perfectly good Markdown. Instead the - * unterminated region is run through this module's own parser and reported only when it - * yields **two or more** keys. + * unterminated region's key count (see `countKeysBeforeTruncation`) is reported only when it + * yields **two or more** keys AND the region is uniformly frontmatter-shaped raw text + * (`isFrontmatterShaped`). * * Two, not one, and the extra key is doing real work. A single `key: value` line is genuinely * ambiguous: `---` followed by `Note: this is a paragraph.` — or `Author:`, `TODO:`, `See:` — @@ -296,13 +615,17 @@ function parseYamlRegion(yaml: string): Frontmatter { * (STATE.md, PLAN.md, ROADMAP.md, SUMMARY.md, agent/command docs) carries two or more * frontmatter keys, so the realistic interruption window stays covered. * + * A closed region that js-yaml itself cannot parse (malformed YAML, or a refused + * anchor/alias/merge key — ADR-3473 §8.1 consequence 6) returns `{}` carrying the + * `FRONTMATTER_UNPARSEABLE` Symbol (consequence 2) rather than a bare, indistinguishable `{}`. + * * @param content Raw document text. * @param sourcePath Optional resolved path, used to name the file in the diagnostic and to * key its deduplication. Optional because this function has 50-odd call sites and several * hold only an in-memory string; those dedup on a content digest instead. */ function extractFrontmatter(content: string, sourcePath?: string): Frontmatter { - // #2977: tolerate a single leading UTF-8 BOM (\uFEFF), which Windows tooling + // #2977: tolerate a single leading UTF-8 BOM (U+FEFF), which Windows tooling // (PowerShell `>`/`Out-File` on PS 5.1, several editors) writes by default. Without this // strip, the byte-0 `startsWith('---')` fence check below fails on the BOM and the whole // parse collapses to {} — every frontmatter field silently disappears, and the engine @@ -322,8 +645,8 @@ function extractFrontmatter(content: string, sourcePath?: string): Frontmatter { const closingLineStart = content.indexOf('\n---', headerEnd); if (closingLineStart === -1) { const region = content.slice(headerEnd); - const probe = parseYamlRegion(region); - if (Object.keys(probe).length >= UNTERMINATED_KEY_THRESHOLD && isFrontmatterShaped(region)) { + const keyCount = countKeysBeforeTruncation(region); + if (keyCount >= UNTERMINATED_KEY_THRESHOLD && isFrontmatterShaped(region)) { warnUnusableInput({ reason: UNUSABLE_REASON.FRONTMATTER_UNTERMINATED, source: sourcePath, @@ -334,27 +657,47 @@ function extractFrontmatter(content: string, sourcePath?: string): Frontmatter { } const yamlEnd = content[closingLineStart - 1] === '\r' ? closingLineStart - 1 : closingLineStart; - return parseYamlRegion(content.slice(headerEnd, yamlEnd)); + const region = content.slice(headerEnd, yamlEnd); + try { + return parseGuardedYamlRegion(region); + } catch { + return unparseableResult(); + } } /** - * Escape a string for emission inside a YAML double-quoted scalar (#1779). - * Backslash must be escaped first so the backslashes added for embedded quotes - * (and control chars) are not themselves doubled. Without this, a value - * carrying an indicator (`:`/`#`) that also contains a literal `"` serializes - * to invalid YAML, e.g. `upstream: "https://x (Tom; "Git. Ship. Done")"`. A - * literal newline/tab/control char inside the quotes likewise breaks (or - * silently alters) the scalar, so those are escaped to their YAML forms too. + * Escape a string for emission inside a YAML double-quoted scalar (#1779). ADR-3473 §8.1 + * (#3881): routed through the vendored js-yaml's `dump()` (forced double-quoted style) rather + * than a hand-rolled character-class replace chain, so the writer shares the same escaping + * engine the reader now uses. js-yaml emits control-char escapes as uppercase hex (`\x1F`); + * this repo has emitted lowercase (`\x1f`) since #1779, so the hex digits are lowercased after + * dump to keep serialized output byte-stable across the migration FOR THE CASES the old + * hand-rolled chain actually covered (backslash/quote/newline/tab/CR, and every C0 control + * plus DEL via \xHH). It is NOT byte-stable end-to-end (post-#3881-review, finding 4, + * verified by execution): the old chain left BEL/NUL unescaped-as-hex (`\x07`/`\x00`) and + * left NEL/NBSP/LINE SEPARATOR/PARAGRAPH SEPARATOR/BOM as raw literal bytes entirely (they + * fall outside its `\u0000-\u001f\u007f` class); js-yaml's dump instead emits the YAML-named + * escapes `\a`/`\0`/`\N`/`\_`/`\L`/`\P` for those six, and a `\uXXXX` escape for the BOM and + * any lone UTF-16 surrogate. The serialized TEXT differs from pre-migration output for these + * codepoints, but the round-trip is equivalence-preserving, not merely byte-preserving: every + * one of `\a`/`\0`/`\N`/`\_`/`\L`/`\P`/`\uXXXX` is a YAML double-quoted-scalar escape that + * resolves back to the EXACT source codepoint on re-parse (confirmed by execution — see + * `tests/frontmatter.unit.test.cjs`'s pinned cases). scalarNeedsDoubleQuoting was extended in + * the same review round to also route lone surrogates through this quoted+escaped path, + * because they were previously emitted bare and produced genuinely UNPARSEABLE YAML. + * + * Renamed from `escapeDoubleQuoted` (post-#3881-review, finding 2): §8.1 says this function is + * "deleted, not patched" — like `parseGuardedYamlRegion`, the hand-rolled character-class chain + * is gone, but unlike that function this one HAS no other caller inside this module to hide the + * old name's survival behind, so the rename is a straight mechanical propagation to its three + * call sites (`reconstructFrontmatter` here, plus `commands.cts` and + * `runtime-artifact-conversion.cts`, both updated in this change — no ADR amendment needed). */ -function escapeDoubleQuoted(s: string): string { - return s - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\n/g, '\\n') - .replace(/\t/g, '\\t') - .replace(/\r/g, '\\r') - // Remaining C0 controls + DEL → \xHH (a valid YAML double-quoted escape). - .replace(/[\u0000-\u001f\u007f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`); +function escapeDoubleQuotedScalar(s: string): string { + const dumped = yamlDump(s, { schema: FAILSAFE_SCHEMA, forceQuotes: true, quotingType: '"', lineWidth: -1 }); + const withoutTrailingNewline = dumped.endsWith('\n') ? dumped.slice(0, -1) : dumped; + const interior = withoutTrailingNewline.slice(1, -1); // strip the outer double-quote pair + return interior.replace(/\\x([0-9A-Fa-f]{2})/g, (_m, hex: string) => `\\x${hex.toLowerCase()}`); } /** @@ -364,7 +707,7 @@ function escapeDoubleQuoted(s: string): string { * or control char, a leading YAML indicator (quote, `&`/`*`/`!` anchor/alias/ * tag, `|`/`>` block scalar, flow `[]{},`, `#`, reserved `%`/`@`/backtick, or * `-`/`?`/`:` before a space), or leading/trailing whitespace. This helper is - * the correctness complement of `escapeDoubleQuoted`: it broadens the *trigger* + * the correctness complement of `escapeDoubleQuotedScalar`: it broadens the *trigger* * for quoting without broadening the lossy object-list handling deferred to * #1572/#1660. */ @@ -375,6 +718,18 @@ function scalarNeedsDoubleQuoting(s: string): boolean { if (/^[,[\]{}#&*!|>'"%@`]/.test(s) || /^\s|\s$/.test(s)) return true; // `-` `?` `:` only start a plain scalar safely when NOT followed by a space. if (/^[-?:](\s|$)/.test(s)) return true; + // Post-#3881-review, finding 4 (found while verifying escapeDoubleQuotedScalar's byte- + // stability claim): an unpaired UTF-16 surrogate (U+D800-U+DFFF) is outside YAML's + // printable-character set, so js-yaml's loader refuses it ("the stream contains + // non-printable characters") the instant it is emitted bare. No other trigger above + // catches it -- not whitespace, not a C0/C1 control, not a leading indicator -- so a bare + // emission was genuinely invalid YAML: reconstructFrontmatter produced text + // extractFrontmatter could not re-parse, silently collapsing to {} via + // unparseableResult(). Confirmed by execution: reconstructFrontmatter({weird: '\uD800'}) + // round-tripped to undefined before this fix. Routing it through the quoted + + // escapeDoubleQuotedScalar path (which already emits the \uD800 escape) fixes the + // round-trip. + if (/[\uD800-\uDFFF]/.test(s)) return true; return false; } @@ -428,7 +783,7 @@ function agentScalarNeedsDoubleQuoting(s: string): boolean { function reconstructFrontmatter(obj: Frontmatter): string { const lines: string[] = []; - // #3257: read the full-line-comment channel (set by parseYamlRegion when comments + // #3257: read the full-line-comment channel (set by parseGuardedYamlRegion when comments // were present). Object.entries skips the Symbol key, so the data loop is unchanged. const commentChannel = (obj as Record)[FULL_LINE_COMMENTS as unknown as symbol] as FullLineCommentChannel | undefined; for (const [key, value] of Object.entries(obj)) { @@ -444,7 +799,7 @@ function reconstructFrontmatter(obj: Frontmatter): string { } else { lines.push(`${key}:`); for (const item of value) { - lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#') || scalarNeedsDoubleQuoting(item)) ? `"${escapeDoubleQuoted(item)}"` : item}`); + lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#') || scalarNeedsDoubleQuoting(item)) ? `"${escapeDoubleQuotedScalar(item)}"` : item}`); } } } else if (typeof value === 'object') { @@ -459,7 +814,7 @@ function reconstructFrontmatter(obj: Frontmatter): string { } else { lines.push(` ${subkey}:`); for (const item of subval) { - lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#') || scalarNeedsDoubleQuoting(item)) ? `"${escapeDoubleQuoted(item)}"` : item}`); + lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#') || scalarNeedsDoubleQuoting(item)) ? `"${escapeDoubleQuotedScalar(item)}"` : item}`); } } } else if (typeof subval === 'object') { @@ -483,13 +838,13 @@ function reconstructFrontmatter(obj: Frontmatter): string { } else { // eslint-disable-next-line @typescript-eslint/no-base-to-string const sv = String(subval); - lines.push(` ${subkey}: ${sv.includes(':') || sv.includes('#') || scalarNeedsDoubleQuoting(sv) ? `"${escapeDoubleQuoted(sv)}"` : sv}`); + lines.push(` ${subkey}: ${sv.includes(':') || sv.includes('#') || scalarNeedsDoubleQuoting(sv) ? `"${escapeDoubleQuotedScalar(sv)}"` : sv}`); } } } else { const sv = String(value); if (sv.includes(':') || sv.includes('#') || sv.startsWith('[') || sv.startsWith('{') || scalarNeedsDoubleQuoting(sv)) { - lines.push(`${key}: "${escapeDoubleQuoted(sv)}"`); + lines.push(`${key}: "${escapeDoubleQuotedScalar(sv)}"`); } else { lines.push(`${key}: ${sv}`); } @@ -508,15 +863,21 @@ function reconstructFrontmatter(obj: Frontmatter): string { * it — AC5). No-op when `source` carries no channel. Consumers that rebuild their * target object fresh (syncStateFrontmatter builds derivedFm via buildStateFrontmatter * and copies keys with Object.keys, which skips the Symbol) MUST call this before - * reconstructFrontmatter, or the channel parseYamlRegion attached to the extracted + * reconstructFrontmatter, or the channel parseGuardedYamlRegion attached to the extracted * source is lost. */ function propagateCommentChannel(source: Frontmatter, target: Frontmatter): void { const channel = (source as Record)[FULL_LINE_COMMENTS as unknown as symbol] as FullLineCommentChannel | undefined; if (!channel) return; - const filtered: FullLineCommentChannel = { leading: {}, trailing: channel.trailing }; + // Null-prototype `leading` (post-#3881-review, finding 3) — same rationale as + // `extractCommentChannel`. `target` may be a plain `{}` built by a caller outside this + // module (e.g. `buildStateFrontmatter`), so `key in target` is checked via + // `hasOwnProperty`, not the `in` operator: `in` walks target's OWN prototype chain too, + // and a target key named `constructor`/`toString`/etc. would otherwise read as "present" + // even when it was never actually set. + const filtered: FullLineCommentChannel = { leading: Object.create(null) as Record, trailing: channel.trailing }; for (const [key, comments] of Object.entries(channel.leading)) { - if (key in target) filtered.leading[key] = comments; + if (Object.prototype.hasOwnProperty.call(target, key)) filtered.leading[key] = comments; } if (filtered.trailing.length || Object.keys(filtered.leading).length) { (target as Record)[FULL_LINE_COMMENTS as unknown as symbol] = filtered; @@ -671,127 +1032,88 @@ function frontmatterDeepEqual(a: unknown, b: unknown): boolean { return false; } +/** + * ADR-3473 §8.1 (#3881): the legacy `- key: value` same-line-with-dash capture never trimmed + * or number-coerced its value (`current[kvMatch[1]] = kvMatch[2]` verbatim), while every + * CONTINUATION line (a further-indented sibling key under the same list item) both trimmed + * (`kvMatch[2].trim()` — #1905/#1154, a quoted `"backstop "` must not silently stop matching + * the literal `backstop` marker) and number-coerced (`/^\d+$/.test(val) ? parseInt(val, 10) : + * val`). That distinction was purely a byproduct of the hand-rolled line scanner's own + * position tracking — real YAML has no such notion; `path: x` on the dash's own line and + * `count: 1` one line below it are the same kind of mapping entry. `tests/frontmatter.test.cjs` + * ("trims a continuation-KV value…") pins the trimming behavior, so it is reproduced here by + * treating an object item's FIRST own key (source order, matching the dash line) as untouched + * and every subsequent key as "continuation": trimmed, and coerced to a number when (after + * trimming) it is all-digits — the exact `/^\d+$/` shape the legacy scanner recognized, never a + * broader YAML-native numeric resolution (which would also promote floats/octal/booleans the + * legacy scanner left as strings). + */ +function coerceMustHavesValue(value: unknown, isContinuation: boolean): unknown { + if (typeof value !== 'string') return value; // arrays/nested maps pass through untouched + if (!isContinuation) return value; + const trimmed = value.trim(); + return /^\d+$/.test(trimmed) ? parseInt(trimmed, 10) : trimmed; +} + +/** Normalize one must_haves list item to the legacy contract: a plain scalar item stays a + * string; an object item gets `coerceMustHavesValue`'s same-line/continuation treatment + * (see that function's docblock). + */ +function normalizeMustHavesItem(item: unknown): unknown { + if (item === null || typeof item !== 'object' || Array.isArray(item)) return item; + const out: Record = {}; + Object.entries(item as Record).forEach(([k, v], idx) => { + out[k] = coerceMustHavesValue(v, idx > 0); + }); + return out; +} + +/** + * Extract a specific block from `must_haves` in frontmatter YAML (e.g. `must_haves.truths`, + * `must_haves.artifacts`, `must_haves.key_links`) — via the same vendored js-yaml parser the + * rest of this module uses (ADR-3473 §8.1 / #3881), rather than the hand-rolled indentation + * scanner this replaces. + * + * Deliberately NOT routed through `parseGuardedYamlRegion`: that function flattens an + * object-shaped list ITEM to a single canonical string (consequence 3), which is the correct + * contract for the top-level Frontmatter value shape but would collapse `must_haves.artifacts`'s + * `{path, provides, ...}` items into unusable strings. This parses the region independently, + * under the same `FAILSAFE_SCHEMA` + `json: true` options (every scalar a string, duplicate + * keys last-wins) and the same anchor/alias/merge-key refusal (`refuseAnchorsAndAliases`) — + * `.planning/` must_haves blocks are untrusted input exactly like the rest of frontmatter. + */ function parseMustHavesBlock(content: string, blockName: string): unknown[] { - // Extract a specific block from must_haves in raw frontmatter YAML - // Handles 3-level nesting: must_haves > artifacts/key_links > [{path, provides, ...}] const fmMatch = content.match(/^---\r?\n([\s\S]+?)\r?\n---/); if (!fmMatch) return []; - const yaml = fmMatch[1]; - const yamlLines = splitLines(yaml); - // Find must_haves: first to detect its indentation level. Split-then-scan - // (rather than a whole-string /m match) so a CRLF or blank-line boundary - // can never be absorbed into the indent capture (#3360) — see - // .gsd/phase/chore-3413-text-lines-seam/40-design.md. - const mustHavesLinePattern = /^(\s*)must_haves:\s*$/; - const mustHavesLineIndex = yamlLines.findIndex((line) => mustHavesLinePattern.test(line)); - if (mustHavesLineIndex === -1) return []; - const mustHavesIndent = (yamlLines[mustHavesLineIndex].match(/^(\s*)/) as RegExpMatchArray)[1].length; - - // Find the block (e.g., "truths:", "artifacts:", "key_links:") under must_haves - // It must be indented more than must_haves but we detect the actual indent dynamically - const blockLinePattern = new RegExp(`^(\\s+)${blockName}:\\s*$`); - const blockLineIndex = yamlLines.findIndex((line) => blockLinePattern.test(line)); - if (blockLineIndex === -1) return []; - - const blockIndent = (yamlLines[blockLineIndex].match(/^(\s*)/) as RegExpMatchArray)[1].length; - // The block must be nested under must_haves (more indented) - if (blockIndent <= mustHavesIndent) return []; - - const blockLines = yamlLines.slice(blockLineIndex + 1); // skip the header line - - // List items are indented one level deeper than blockIndent - // Continuation KVs are indented one level deeper than list items - const items: unknown[] = []; - let current: string | Record | null = null; - let listItemIndent = -1; // detected from first "- " line - - for (const line of blockLines) { - // Skip empty lines - if (line.trim() === '') continue; - const indentMatch = line.match(/^(\s*)/); - const indent = indentMatch ? indentMatch[1].length : 0; - // Stop at same or lower indent level than the block header - if (indent <= blockIndent && line.trim() !== '') break; - - const trimmed = line.trim(); - - if (trimmed.startsWith('- ')) { - // Detect list item indent from the first occurrence - if (listItemIndent === -1) listItemIndent = indent; - - // Only treat as a top-level list item if at the expected indent - if (indent === listItemIndent) { - if (current) items.push(current); - const afterDash = trimmed.slice(2); - const trimmedAfterDash = afterDash.trim(); - // Check if it's a fully-quoted string (may contain ':' inside the quotes) - if ((trimmedAfterDash.startsWith('"') && trimmedAfterDash.endsWith('"')) || - (trimmedAfterDash.startsWith("'") && trimmedAfterDash.endsWith("'"))) { - current = trimmedAfterDash.slice(1, -1); - // Check if it's a simple string item (no colon means not a key-value) - } else if (!afterDash.includes(':')) { - current = afterDash.replace(/^["']|["']$/g, ''); - } else { - // Key-value on same line as dash: "- path: value" - // YAML KV always has at least one space after the colon: "key: value" - // Requiring \s+ rejects "Class::Method" and "db:seed" (no space after colon) - const kvMatch = afterDash.match(/^(\w+):\s+"?([^"]*)"?\s*$/); - if (kvMatch) { - current = {}; - (current)[kvMatch[1]] = kvMatch[2]; - } else { - // Looks like KV but doesn't match — treat as plain string (#2757) - current = afterDash.replace(/^["']|["']$/g, ''); - } - } - continue; - } - } - - if (current && typeof current === 'object' && indent > listItemIndent) { - // Continuation key-value or nested array item - if (trimmed.startsWith('- ')) { - // Array item under a key - const arrVal = trimmed.slice(2).replace(/^["']|["']$/g, ''); - const keys = Object.keys(current); - const lastKey = keys[keys.length - 1]; - if (lastKey && !Array.isArray((current)[lastKey])) { - const existing = (current)[lastKey]; - (current)[lastKey] = existing ? [existing] : []; - } - if (lastKey) ((current)[lastKey] as unknown[]).push(arrVal); - } else { - const kvMatch = trimmed.match(/^(\w+):\s*"?([^"]*)"?\s*$/); - if (kvMatch) { - // Trim: a quoted value like `"backstop "` captures the inner trailing space in group 2. - // Left untrimmed, a hand-authored `must_haves` marker degrades (a `backstop` truth silently - // grades green instead of abstaining — #1905, the #1154 false-pass; also the sibling - // check_target/violationFixture path). Whitespace is never semantic in a scalar KV value. - const val = kvMatch[2].trim(); - // Try to parse as number - (current)[kvMatch[1]] = /^\d+$/.test(val) ? parseInt(val, 10) : val; - } - } - } + let parsed: unknown; + try { + refuseAnchorsAndAliases(yaml); + parsed = yamlLoad(yaml, YAML_LOAD_OPTS); + } catch { + return []; } - if (current) items.push(current); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return []; - // Warn when must_haves block exists but parsed as empty -- likely YAML formatting issue. - // This is a critical diagnostic: empty must_haves causes verification to silently degrade - // to Option C (LLM-derived truths) instead of checking documented contracts. - if (items.length === 0 && blockLines.length > 0) { - const nonEmptyLines = blockLines.filter(l => l.trim() !== '').length; - if (nonEmptyLines > 0) { + const mustHaves = (parsed as Record).must_haves; + if (!mustHaves || typeof mustHaves !== 'object' || Array.isArray(mustHaves)) return []; + + const block = (mustHaves as Record)[blockName]; + if (!Array.isArray(block)) { + // Warn when the block exists but isn't a usable list — likely a YAML formatting issue. + // This is a critical diagnostic: empty must_haves causes verification to silently degrade + // to Option C (LLM-derived truths) instead of checking documented contracts. + if (block !== undefined && block !== null) { process.stderr.write( - `[gsd-tools] WARNING: must_haves.${blockName} block has ${nonEmptyLines} content lines but parsed 0 items. ` + + `[gsd-tools] WARNING: must_haves.${blockName} block has content but parsed 0 items. ` + `Possible YAML formatting issue — verification will fall back to LLM-derived truths.\n` ); } + return []; } - return items; + return block.map(normalizeMustHavesItem); } // ─── Frontmatter CRUD commands ──────────────────────────────────────────────── @@ -889,6 +1211,14 @@ function cmdFrontmatterSet(cwd: string, filePath: string, field: string | undefi const fm = extractFrontmatter(content, fullPath); let parsedValue: unknown; try { parsedValue = JSON.parse(value as string); } catch { parsedValue = value; } + // #1660 (broadened): a lossy object-list field being genuinely CHANGED must fail closed + // before it is regenerated, not just when the regenerated result happens to be byte-identical + // to the original (see objectListFieldWouldLoseData's docblock). + const lossyErr = objectListFieldWouldLoseData(content, field as string, parsedValue); + if (lossyErr) { + output({ error: lossyErr, field }, raw, undefined); + return; + } fm[field as string] = parsedValue as FrontmatterValue; const newContent = spliceFrontmatter(content, fm); // #1660: a no-op set (newContent unchanged) with a dict-valued field means the lossy @@ -920,6 +1250,58 @@ function noOpObjectListSetError(originalContent: string, newContent: string, par return 'frontmatter set had no effect — the supplied value is equivalent to the existing field under the frontmatter parser, which cannot faithfully round-trip object-list fields like must_haves. Edit the file directly.'; } +/** + * #1660 (broadened, ADR-3473 §8.1 / #3881): `noOpObjectListSetError` only catches the + * BYTE-IDENTICAL no-op case. Under the js-yaml migration, `flattenObjectListItem` correctly + * joins EVERY sub-key of an object-list item (`path: X, provides: Y`) instead of the legacy + * hand-rolled scanner's accidental behavior of silently discarding every field but the one on + * the dash line itself. That fixes a real data-loss bug on READ, but it also means a `set` that + * replaces such a field with a plainly-flattened string (e.g. `{artifacts: ["path: X"]}`, + * omitting `provides`) is no longer byte-identical to the original — so it no longer trips the + * no-op guard, sails through `regenerateFrontmatterKey` (which only refuses when the NEW value + * itself contains a live JS object), and silently writes a version with `provides` gone. + * + * This is the general form of the same "cannot faithfully round-trip" contract: a field is + * lossy exactly when regenerating its OWN already-parsed value fails to reproduce its own raw + * source text byte-for-byte (proof, not a guess, that this key's original shape does not + * survive parse → reconstruct). When that is true AND the caller is genuinely changing the + * field (not merely re-supplying an equal value, which `frontmatterDeepEqual` already lets + * through), the set is refused — matching `regenerateFrontmatterKey`'s own fail-closed + * philosophy for the mirror-image case (new value carries a nested object outright). + */ +function objectListFieldWouldLoseData(content: string, field: string, newValue: unknown): string | null { + // A NEW value that itself carries a live nested object (rather than an already-flattened + // string) is the mirror-image case `regenerateFrontmatterKey` already refuses on its own + // (the "[object Object]" guard, via spliceFrontmatter) — leave that path's existing throw + // behavior alone rather than intercepting it here with a different (non-throwing) contract. + try { regenerateFrontmatterKey(field, newValue as FrontmatterValue); } catch { return null; } + + let originalParsed: Frontmatter; + try { originalParsed = extractFrontmatter(content); } catch { return null; } + if (!Object.prototype.hasOwnProperty.call(originalParsed, field)) return null; + const originalValue = originalParsed[field]; + if (frontmatterDeepEqual(newValue, originalValue)) return null; + + const fmMatch = content.match(/^---\r?\n([\s\S]+?)\r?\n---/); + if (!fmMatch) return null; + const original = sliceTopLevelFrontmatterSegments(fmMatch[1]).find((s) => s.key === field); + if (!original) return null; + + let regeneratedOriginal: string; + try { + regeneratedOriginal = regenerateFrontmatterKey(field, originalValue); + } catch { + return `frontmatter set refused — the existing "${field}" field contains a nested object-list ` + + `(e.g. must_haves.artifacts) the frontmatter writer cannot faithfully represent, and this change ` + + `would silently discard data. Edit the file directly instead of using frontmatter set/merge.`; + } + if (regeneratedOriginal.trim() === original.raw.trim()) return null; + + return `frontmatter set refused — the existing "${field}" field cannot be faithfully round-tripped by ` + + `the frontmatter writer (its structure would be flattened and data, such as a nested object-list ` + + `field, silently dropped). Edit the file directly instead of using frontmatter set/merge.`; +} + function cmdFrontmatterMerge(cwd: string, filePath: string, data: string | undefined, raw: boolean): void { if (!filePath || !data) { error('file and data required'); } const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); @@ -989,10 +1371,13 @@ export = { // #3706: shared with the agent-frontmatter writers so a config-supplied // `model:`/`variant:` value cannot break out of its scalar. Previously private // here while those writers interpolated raw — one escaper, three call sites. - escapeDoubleQuoted, + escapeDoubleQuotedScalar, agentScalarNeedsDoubleQuoting, extractFrontmatter, UNTERMINATED_KEY_THRESHOLD, + // ADR-3473 §8.1 (#3881, consequence 2): the unparseable-vs-empty marker Symbol. Exported so + // the 8 `hasFrontmatter` call sites named in the design can consult it in a follow-up change. + FRONTMATTER_UNPARSEABLE, // Additive alias (#644 prohibition-probe schema contract): the probe round-trip seam reads a // frontmatter object via `parseFrontmatter` (the name the contract test pins). It is the SAME // function as `extractFrontmatter` — a bare-object parse with no behavior change — exposed under diff --git a/src/phase-estimation.cts b/src/phase-estimation.cts index fcf798440..67272dfd1 100644 --- a/src/phase-estimation.cts +++ b/src/phase-estimation.cts @@ -328,14 +328,23 @@ export function applyCalibration(rawTokens: RawTokens, factor: number): Calibrat * Extract a two-space-indented scalar block (`estimate:` / `actuals:`) out of a * document's leading YAML frontmatter. * - * Hand-rolled because gsd-core ships no external dependencies (CONTRIBUTING.md - * "No external dependencies in core") — js-yaml is a devDependency and is not - * available at runtime. Scope is deliberately narrow: the leading `---` block - * only, so a `estimate:` line inside a fenced code block in the body cannot be - * mistaken for frontmatter (the DEFECT.FRONTMATTER-SCALAR-BROAD-GREP class). - * - * Numeric-looking values are returned as numbers so parseEstimate/parseActuals - * see the types they validate; everything else stays a string. + * Hand-rolled for its TYPE CONTRACT, not for dependency availability — js-yaml + * is vendored and available at runtime as of ADR-3473 §8.1 (#3881), which + * migrated `src/frontmatter.cts`'s `extractFrontmatter` onto it. That parser + * runs under js-yaml's FAILSAFE_SCHEMA, which resolves every scalar as a + * string, whereas this function deliberately returns numbers for + * numeric-looking values (`out[m[1]] = asNumber : rawValue`) so + * `parseEstimate`/`parseActuals` see the types they validate. A naive swap + * onto `extractFrontmatter` would turn `tokens: 5000` into `"5000"` and make + * `isPositiveInt` reject every estimate. Scope is deliberately narrow: the + * leading `---` block only, so an `estimate:` line inside a fenced code + * block in the body cannot be mistaken for frontmatter (the + * DEFECT.FRONTMATTER-SCALAR-BROAD-GREP class). Migrating this function onto + * the shared parser (with a typed coercion layer over its string-only + * output) is tracked as follow-on work under ADR-3473 §8.1, not done here — + * this module is calibration-critical and has a history of subtle numeric + * defects shipping past a large green suite (#2631 factor², #2632 + * self-defeating loop). */ export function extractFrontmatterBlock(text: unknown, key: string): Record | null { if (typeof text !== 'string') return null; diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index 32f8d1115..ce439a89c 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -1845,7 +1845,7 @@ function neutralizeAgentReferences(content, instructionFile) { */ function frontmatterScalar(key: string, value: string): string { return frontmatterModule.agentScalarNeedsDoubleQuoting(value) - ? `${key} "${frontmatterModule.escapeDoubleQuoted(value)}"` + ? `${key} "${frontmatterModule.escapeDoubleQuotedScalar(value)}"` : `${key} ${value}`; } diff --git a/src/state-transition.cts b/src/state-transition.cts index dded76213..1822e762e 100644 --- a/src/state-transition.cts +++ b/src/state-transition.cts @@ -27,7 +27,76 @@ import stateMdSchemaMod = require('./state-md-schema.cjs'); const { STATE_FIELD_SCHEMA } = stateMdSchemaMod; type StateFieldSchema = stateMdSchemaMod.StateFieldSchema; -const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter } = frontmatter; +const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, FRONTMATTER_UNPARSEABLE } = frontmatter; + +/** + * ADR-3473 §8.1 (#3881, consequence 2 wiring): does `existingFm` carry the + * `FRONTMATTER_UNPARSEABLE` marker `extractFrontmatter` sets when a + * frontmatter-fenced region exists but failed to parse (malformed YAML, or a + * refused anchor/alias/merge key)? A plain `Object.keys(existingFm).length > + * 0` check cannot distinguish that case from "no frontmatter block at all" — + * both parse to `{}` — so every `hasFrontmatter`-gated reassemble below would + * silently drop the raw frontmatter block on the next write. The marker is a + * non-enumerable-to-Object.keys Symbol key, so this check is additive and + * never fires for the genuinely-empty case. + */ +function isUnparseableFrontmatter(existingFm: Record): boolean { + return (existingFm as unknown as Record)[FRONTMATTER_UNPARSEABLE] === true; +} + +/** + * ADR-3473 §8.1 (#3881): the exact bytes `stripFrontmatter` removed from the + * front of `content` to produce `strippedBody` — i.e. `content`'s raw + * frontmatter-fenced prefix, verbatim, whether or not it parsed. Reassembling + * with this prefix (instead of dropping it under `hasFrontmatter === false`) + * is what preserves an UNPARSEABLE frontmatter block across a write; it is a + * no-op difference from `content` itself when `strippedBody === content` + * (nothing was stripped). + */ +function rawFrontmatterPrefix(content: string, strippedBody: string): string { + return content.slice(0, content.length - strippedBody.length); +} + +/** + * Shared frontmatter-strip-and-reassemble preamble (#3881 review, finding 5): the + * `existingFm` / `hasFrontmatter` / `stripFrontmatter` / `fmPrefix` / `unparseableFm` / + * `reassemble` block above was copy-pasted at every `*Core` transition below (and, before + * this change, hand-inlined a sixth time in `state.cts`'s `cmdStateCompletePhase` instead of + * importing `isUnparseableFrontmatter`/`rawFrontmatterPrefix`). One helper, one place to fix + * the frontmatter-preservation contract. `reassemble` is parameterized on the (possibly + * further-mutated) body rather than closing over it, matching every call site's existing + * usage — several reassign `body` after this preamble runs and reassemble the FINAL body, + * not the one captured here. + */ +export type FrontmatterReassembly = { + existingFm: Record; + hasFrontmatter: boolean; + body: string; + fmPrefix: string; + unparseableFm: boolean; + reassemble: (b: string) => string; +}; + +export function beginFrontmatterReassembly( + content: string, + sourcePath?: string, +): FrontmatterReassembly { + const existingFm = extractFrontmatter(content, sourcePath) as Record; + const hasFrontmatter = Object.keys(existingFm).length > 0; + const body = stripFrontmatter(content); + // ADR-3473 §8.1 (#3881): computed from the ORIGINAL content/body pair, before any caller + // reassigns `body` further — the captured prefix is always the exact bytes stripped from + // the ORIGINAL content, regardless of what the caller does with `body` afterward. + const fmPrefix = rawFrontmatterPrefix(content, body); + const unparseableFm = isUnparseableFrontmatter(existingFm); + const reassemble = (b: string): string => + hasFrontmatter + ? `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${b}` + : unparseableFm + ? `${fmPrefix}${b}` + : b; + return { existingFm, hasFrontmatter, body, fmPrefix, unparseableFm, reassemble }; +} // Stop predicate for section-body slicing: a level-2+ heading ends the section. const STOP_H2_PLUS = (lv: number): boolean => lv >= 2; @@ -958,15 +1027,15 @@ function beginPhaseCore( // #1255: body-field replacements operate on body only (frontmatter stripped), // not on the full content. The YAML `status:` key matches `^Status:\s*` // before the body pipe-table row if full content is passed. - const existingFm = extractFrontmatter(content, deps.sourcePath) as Record; - const hasFrontmatter = Object.keys(existingFm).length > 0; + const { reassemble } = beginFrontmatterReassembly(content, deps.sourcePath); + // #3881 review, finding 5: `body` is deliberately a LITERAL `stripFrontmatter(content)` + // assignment here rather than the helper's own `body` (which the destructure above skips) — + // scripts/lint-state-write-path-drift.cjs's Axis 3 backward scan is a single-hop textual + // pattern match, not real dataflow, and only recognizes `body = stripFrontmatter(...)` written + // out at the call site. `stripFrontmatter` is pure and idempotent, so computing it here (in + // addition to the helper's own internal call) changes nothing observable. let body = stripFrontmatter(content); - const reassemble = (b: string): string => - hasFrontmatter - ? `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${b}` - : b; - const today = deps.clock.localToday(); // Consult the field-classification table for the frontmatter keys this @@ -1310,13 +1379,8 @@ function advancePlanCore(content: string, deps: StateTransitionDeps): StateTrans // not on the full content. The YAML `status:` key matches `^Status:\s*` // before the body field if full content is passed (codex Phase 2 review: // HIGH blocking finding — same pattern beginPhaseCore already handles). - const existingFm = extractFrontmatter(content, deps.sourcePath) as Record; - const hasFrontmatter = Object.keys(existingFm).length > 0; - let body = stripFrontmatter(content); - const reassemble = (b: string): string => - hasFrontmatter - ? `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${b}` - : b; + const { body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath); + let body = initialBody; // Parse plan number — legacy first, then compound. const legacyPlan = stateExtractField(content, 'Current Plan'); @@ -1458,13 +1522,8 @@ function completePhaseCore( // #1255: body-field replacements operate on body only (frontmatter stripped), // so the YAML `status:` / `current_phase:` keys cannot shadow the body fields. - const existingFm = extractFrontmatter(content, deps.sourcePath) as Record; - const hasFrontmatter = Object.keys(existingFm).length > 0; - let body = stripFrontmatter(content); - const reassemble = (b: string): string => - hasFrontmatter - ? `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${b}` - : b; + const { body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath); + let body = initialBody; // Current Phase — preserve the existing `of ` shape and the phase name // in parens (mirrors phase.cts:1675-1697 byte-for-behaviour). @@ -1655,13 +1714,8 @@ function plannedPhaseCore( } // #1255: body-field replacements operate on body only. - const existingFm = extractFrontmatter(content, deps.sourcePath) as Record; - const hasFrontmatter = Object.keys(existingFm).length > 0; - let body = stripFrontmatter(content); - const reassemble = (b: string): string => - hasFrontmatter - ? `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${b}` - : b; + const { existingFm, hasFrontmatter, body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath); + let body = initialBody; const statusDefaults = KNOWN_TEMPLATE_DEFAULTS['Status']; const lastActivityDefaults = KNOWN_TEMPLATE_DEFAULTS['Last Activity']; @@ -1940,13 +1994,8 @@ function milestoneCompleteCore( } // #1255: body-field replacements operate on body only. - const existingFm = extractFrontmatter(content, deps.sourcePath) as Record; - const hasFrontmatter = Object.keys(existingFm).length > 0; - let body = stripFrontmatter(content); - const reassemble = (b: string): string => - hasFrontmatter - ? `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${b}` - : b; + const { body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath); + let body = initialBody; // Status — ` milestone complete`. const statusAfter = stateReplaceFieldWithFallback(body, 'Status', null, `${version} milestone complete`); @@ -2071,8 +2120,10 @@ function patchCore( content: string, intent: { kind: 'patch'; patches: Record }, ): StateTransitionResult { - const existingFm = extractFrontmatter(content) as Record; - const hasFrontmatter = Object.keys(existingFm).length > 0; + const { existingFm, hasFrontmatter, fmPrefix, unparseableFm } = beginFrontmatterReassembly(content); + // #3881 review, finding 5: see beginPhaseCore's identical comment above — `body` stays a + // literal `stripFrontmatter(content)` assignment here for scripts/lint-state-write-path-drift.cjs's + // Axis 3 single-hop backward scan. let body = stripFrontmatter(content); const fm: Record = { ...existingFm }; @@ -2124,7 +2175,9 @@ function patchCore( const result = hasFrontmatter ? `---\n${reconstructFrontmatter(fm as unknown as Frontmatter)}\n---\n\n${body}` - : body; + : unparseableFm + ? `${fmPrefix}${body}` + : body; return { content: result, updated, data: { updated, failed } }; } @@ -2145,8 +2198,10 @@ function updateCore( content: string, intent: { kind: 'update'; field: string; value: string }, ): StateTransitionResult { - const existingFm = extractFrontmatter(content) as Record; - const hasFrontmatter = Object.keys(existingFm).length > 0; + const { existingFm, hasFrontmatter, reassemble } = beginFrontmatterReassembly(content); + // #3881 review, finding 5: see beginPhaseCore's identical comment above — `body` stays a + // literal `stripFrontmatter(content)` assignment here for scripts/lint-state-write-path-drift.cjs's + // Axis 3 single-hop backward scan. const body = stripFrontmatter(content); // #3699 review: session-scoped fields are written through the session-scoped // writer. A whole-body `stateReplaceField` matches the FIRST occurrence @@ -2213,9 +2268,7 @@ function updateCore( } return { content, updated: [], data: { updated: false } }; } - const reassembled = hasFrontmatter - ? `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${result}` - : result; + const reassembled = reassemble(result); return { content: reassembled, updated: [intent.field], data: { updated: true } }; } diff --git a/src/state.cts b/src/state.cts index 103e10fa5..f808744d1 100644 --- a/src/state.cts +++ b/src/state.cts @@ -39,7 +39,19 @@ const { planningDir, planningPaths } = planningWorkspace; import { realClock } from './clock.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import frontmatter = require('./frontmatter.cjs'); -const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, propagateCommentChannel } = frontmatter; +const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, propagateCommentChannel, FRONTMATTER_UNPARSEABLE } = frontmatter; + +/** + * ADR-3473 §8.1 (#3881, consequence 2 wiring): does `existingFm` carry the + * `FRONTMATTER_UNPARSEABLE` marker `extractFrontmatter` sets when a frontmatter-fenced region + * exists but failed to parse (malformed YAML, or a refused anchor/alias/merge key)? Mirrors + * `state-transition.cts`'s private helper of the same name/shape — kept local rather than + * exported+imported because the two modules' `existingFm` values come from independent + * `extractFrontmatter` calls and this predicate is a two-line symbol read, not shared state. + */ +function isUnparseableFrontmatter(existingFm: Record): boolean { + return (existingFm as unknown as Record)[FRONTMATTER_UNPARSEABLE] === true; +} // eslint-disable-next-line @typescript-eslint/no-require-imports import scanPhasePlans = require('./plan-scan.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -2833,6 +2845,30 @@ function syncStateFrontmatter( content, cwd ? planningPaths(cwd).state : undefined, ) as Record; + + // #3881 review, second round: an UNPARSEABLE frontmatter block (malformed YAML, a git + // merge-conflict marker, a refused anchor) must never be silently REPLACED by a freshly + // re-derived one — that destroys the only copy of what the block actually contained, with + // no signal to the human that their document was in conflict. `beginFrontmatterReassembly` + // (state-transition.cts) already preserves the raw fmPrefix through the pure transform + // layer for every `transitionCore` kind; this was the gap — this function re-parses the + // ALREADY-preserved `content` and, finding {} + the marker, rebuilt a fresh block anyway, + // discarding the raw prefix the transform layer had just protected. Confirmed by execution + // against `state complete-phase`/`update`/`patch`/`begin-phase`: each returned success with + // the conflict markers gone and a freshly-derived, well-formed frontmatter block in their + // place (re-derivation, not deletion — the document never lost its frontmatter FENCE). + // + // `sanctionedPermanentEmptyFallback` is threaded ONLY from `writeStateMd`, itself consumed + // ONLY by `cmdStateSync` (#905) and `/gsd-health --repair`'s `REGENERATE_STATE` — ADR-3408 + // §8.3's CLOSED list of commands whose documented contract is "body wins, re-derive + // unconditionally" (a factory reset / explicit resync). Those two are untouched here: this + // guard fires only on the OTHER call path (`syncAndPreserveStateMd`, i.e. every + // `readModifyWriteStateMd`-based command), where re-deriving over unparseable content was + // never the intended contract in the first place — it was an unhandled gap, not a decision. + if (!sanctionedPermanentEmptyFallback && isUnparseableFrontmatter(existingFm)) { + return content; + } + const body = stripFrontmatter(content); // #3017: pass the stored milestone from the existing frontmatter so // buildStateFrontmatter scopes its disk scan to the correct milestone @@ -3031,7 +3067,7 @@ function syncStateFrontmatter( // #3257: propagate full-line frontmatter comments from the extracted source onto the // rebuilt derivedFm (buildStateFrontmatter + the Object.keys carry-forward above both // skip the Symbol-keyed channel, so without this the comments would be lost here even - // though parseYamlRegion/reconstructFrontmatter preserve them in isolation). + // though parseGuardedYamlRegion/reconstructFrontmatter preserve them in isolation). propagateCommentChannel(existingFm as unknown as Frontmatter, derivedFm as unknown as Frontmatter); const yamlStr = reconstructFrontmatter(derivedFm as unknown as Frontmatter); @@ -3385,8 +3421,26 @@ function applyPostSyncPreservation( // above (null when resync:true) — these are independent snapshots. // Strip frontmatter before calling stateExtractField so the YAML `status:` // key in the frontmatter block cannot shadow the body field we are tracking. - const preBody = stripFrontmatter(originalContent); const preFmSnapshot = extractFrontmatter(originalContent, statePath) as Record; + + // #3881 review, second round: `syncStateFrontmatter` above already declines to re-derive + // over an UNPARSEABLE original frontmatter block (its own matching guard), so `syncedContent` + // here is `transformedContent` verbatim. But this function's own downstream preservation + // machinery (`applyStatePreservation` + the `authoritativeFm` reassertion below) reads + // `postFm = extractFrontmatter(syncedContent, ...)` — {} + the marker, since the block still + // doesn't parse — restores curated fields from `transaction.snapshot`, and reconstructs a + // FRESH frontmatter block from the result, destroying the raw block a second time even + // though `syncStateFrontmatter` just finished protecting it. `applyPostSyncPreservation` is + // reached ONLY via the non-sanctioned path (`syncAndPreserveStateMd`; `writeStateMd`'s two + // ADR-3408 §8.3 closed-list callers — `cmdStateSync` #905 and `/gsd-health --repair`'s + // `REGENERATE_STATE` — never call it at all), so this guard needs no extra parameter to stay + // scoped off that list. Confirmed by execution: `state begin-phase` on a conflict-marked + // STATE.md reached exactly this second clobber even after the `syncStateFrontmatter` fix. + if (isUnparseableFrontmatter(preFmSnapshot)) { + return transformedContent; + } + + const preBody = stripFrontmatter(originalContent); const preBodyStatus = stateExtractField(preBody, 'Status'); // Bug #1230 / Change B: scope stopped_at delta to the ## Session section, // mirroring buildStateFrontmatter's sessionBodyScope logic. @@ -5113,6 +5167,19 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b const verify = options && options.verify; const content = fs.readFileSync(statePath, 'utf-8'); + // ADR-3473 §8.5 (#3881): `state sync` is on ADR-3408 §8.3's closed + // sanctioned-regenerate list — "the body wins" — and `syncStateFrontmatter` + // (below, via `writeStateMd`'s `sanctionedPermanentEmptyFallback`) is + // therefore CORRECT to overwrite even an unparseable existing frontmatter + // block (git merge-conflict markers, malformed YAML). What was missing was + // disclosure: a derived conclusion (`synced: true`) must not be reported as + // authoritative when the derivation dropped input it could not resolve + // (§8.5) — silently destroying the only copy of an unreadable block with no + // signal is "failure is a value" (§8.4) violated. Computed once, up front, + // from the pre-write snapshot so both the `--verify` (dry-run) and the real + // write branch can surface it identically. + const existingSyncFm = extractFrontmatter(content, statePath) as Record; + const syncFrontmatterWasUnparseable = isUnparseableFrontmatter(existingSyncFm); const changes: string[] = []; let modified = content; @@ -5269,12 +5336,27 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b const coreChanges = (syncResult.data as { changes?: string[] } | undefined)?.changes ?? []; changes.push(...coreChanges); + // #3881 (ADR-3473 §8.5): only warn when a write will actually regenerate the + // frontmatter — if nothing changed this run, the unparseable block (if any) + // was never touched, so there is nothing to disclose. Mirrors the exact + // condition the write branch below uses to decide whether to write at all. + const syncWillWrite = changes.length > 0 || modified !== content; + if (syncWillWrite && syncFrontmatterWasUnparseable) { + const unparseableWarning = + `gsd: warning — STATE.md's existing frontmatter could not be parsed (malformed YAML, or ` + + `unresolved content such as git merge-conflict markers) and was regenerated from the body; ` + + `any content in the old frontmatter block — including merge-conflict markers — has been ` + + `replaced. (#3881)`; + process.stderr.write(`${unparseableWarning}\n`); + changes.push(unparseableWarning); + } + if (verify) { output({ synced: false, changes, dry_run: true }, raw, undefined); return; } - if (changes.length > 0 || modified !== content) { + if (syncWillWrite) { // ADR-3473 §8.6: `rebuild()` is the typed expression of #905's contract — // `state sync` exists to let the body win, so preservation must NOT run, // and the snapshot is carried anyway because §8.7's reporting needs it. @@ -5689,17 +5771,21 @@ function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string // Bug #1255: operate on body only so the YAML frontmatter `status:` key // cannot shadow the body Status field (pipe-table or inline). - const existingFm = extractFrontmatter(content, statePath) as Record; - const hasFrontmatter = Object.keys(existingFm).length > 0; - let body = stripFrontmatter(content); + // + // ADR-3473 §8.1 (#3881 review, finding 5): previously this block hand-reimplemented + // the isUnparseableFrontmatter/rawFrontmatterPrefix shape inline instead of using the + // canonical helper — the sixth copy of a block already duplicated 5x in + // state-transition.cts. Routed through the shared `beginFrontmatterReassembly` so this + // module can never drift from the frontmatter-preservation contract state-transition.cts + // enforces everywhere else. + const { existingFm, body: initialBody, reassemble } = + stateTransitionMod.beginFrontmatterReassembly(content, statePath); + let body = initialBody; const curatedPhaseName = existingFm['current_phase_name']; if (typeof curatedPhaseName === 'string' && curatedPhaseName.trim().length > 0) { rmwOptions.authoritativeFm = { current_phase_name: curatedPhaseName }; } - const reassemble = (b: string) => - hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${b}` : b; - // Update Status field (body only — #1255) const statusValue = `Phase ${currentPhase} complete`; let result = stateReplaceField(body, 'Status', statusValue); diff --git a/src/vendor/js-yaml.d.cts b/src/vendor/js-yaml.d.cts new file mode 100644 index 000000000..95bcb34ba --- /dev/null +++ b/src/vendor/js-yaml.d.cts @@ -0,0 +1,89 @@ +// Hand-authored type twin for gsd-core/bin/lib/vendor/js-yaml.cjs. +// +// js-yaml ships no type declarations of its own (`@types/js-yaml` is not +// installed either), so unlike src/vendor/re2js.d.cts this file has no +// upstream .d.ts to copy verbatim — it is written by hand and therefore +// EXCLUDED from lint-vendored-deps.cjs's byte-compare (there is nothing +// upstream to compare it against). +// +// The declared surface is DELIBERATELY NARROW: only `load`, `dump`, +// `FAILSAFE_SCHEMA` and `YAMLException` (plus `load`'s `listener` callback, +// used only to detect an anchor/alias BEFORE it is acted on) are declared. +// This is a capability gate, not laziness — `loadAll` and any custom +// (non-FAILSAFE) schema are genuinely unreachable from typed code that only +// ever imports through this twin, so a caller cannot accidentally widen +// parsing beyond what ADR-3473 §8.1 reviewed. +// +// CORRECTED (post-#3881-review): an earlier version of this comment claimed +// anchors and aliases were themselves "simply UNREACHABLE" through this +// twin. That was false — anchor/alias resolution is document-level `load` +// mechanics, not a separate export gated by the type surface, and is +// reachable through exactly the declared `load` + `FAILSAFE_SCHEMA` + +// `json: true` combination this twin exposes. Anchor/alias refusal is NOT +// enforced by narrowing this type surface; it is enforced at RUNTIME, in +// `refuseAnchorsAndAliases` (src/frontmatter.cts), which uses `load`'s +// `listener` callback to detect `state.anchor` and abort the parse before +// any alias expansion happens. Do not widen this twin's surface (`loadAll`, +// a non-FAILSAFE schema) without a matching change to the security posture +// in ADR-3473 §8.1, and do not restate the "unreachable by type" claim for +// anchors/aliases — it is runtime-enforced, not type-enforced. + +/** + * The subset of js-yaml's internal parser `State` this twin exposes to a `listener` + * callback: just enough to detect that the CURRENT parse event belongs to an anchored + * node. `anchor` is the anchor name (non-null/non-undefined) while js-yaml is + * defining OR resolving an alias to it; `null`/`undefined` otherwise. + */ +export interface LoadListenerState { + readonly anchor?: string | null; +} + +export interface LoadOptions { + /** Overrides the schema used for parsing. Only FAILSAFE_SCHEMA is supported by this twin. */ + schema?: SchemaType; + /** + * When true, duplicate keys overwrite (last-wins) instead of throwing. + * See ADR-3473 §8.1 §3.3 — required to keep the documented "last value + * wins" invariant for `duplicate-keys.md` without a naive catch. + */ + json?: boolean; + /** + * Invoked once per parse event with the parser's internal state. Declared solely so + * `refuseAnchorsAndAliases` (src/frontmatter.cts) can inspect `state.anchor` and abort + * the parse — by throwing from inside the callback — before any anchor/alias is + * expanded. Not a general-purpose parse-event hook: no other consumer should add a + * second `listener` use without reviewing ADR-3473 §8.1's security posture. + */ + listener?: (event: string, state: LoadListenerState) => void; +} + +export interface DumpOptions { + schema?: SchemaType; + [key: string]: unknown; +} + +/** Opaque marker for the vendored schema constants; not constructible from typed code. */ +export interface SchemaType { + readonly __jsYamlSchemaBrand: unique symbol; +} + +/** The failsafe schema: every scalar resolves to a string; no anchors/aliases/custom types. */ +export const FAILSAFE_SCHEMA: SchemaType; + +export function load(src: string, opts?: LoadOptions): unknown; + +export function dump(obj: unknown, opts?: DumpOptions): string; + +export interface YAMLExceptionMark { + readonly line: number; + readonly column: number; + readonly position: number; +} + +export class YAMLException extends Error { + readonly message: string; + /** Present at runtime (verified: js-yaml assigns `this.mark` in its constructor). */ + readonly mark?: YAMLExceptionMark; +} + +export {}; diff --git a/stryker.config.mjs b/stryker.config.mjs index ce4afe8fd..fdaa7363a 100644 --- a/stryker.config.mjs +++ b/stryker.config.mjs @@ -25,7 +25,12 @@ import { createRequire } from 'node:module'; const _require = createRequire(import.meta.url); // resolveMutationBreak: fail-closed resolver for MUTATION_BREAK env var. // undefined → 60 (local backstop); set-but-empty or non-numeric → throws. -const { resolveMutationBreak } = _require('./scripts/mutation-matrix.cjs'); +// COVERED: the same derived-tests registry CI's per-shard MUTATION_TEST_CMD is built from +// (mutation.yml's `matrix.tests`) — DEFAULT_TEST_CMD below derives from it too, rather than +// hand-duplicating the union in a second literal (#3881 follow-up, mutation-matrix piece 2: +// this exact split — six modules' tests missing from the old hand-written DEFAULT_TEST_CMD, +// plus two entries belonging to no module — is what this derivation removes). +const { resolveMutationBreak, COVERED } = _require('./scripts/mutation-matrix.cjs'); // ADR-457: bin/lib/*.cjs are gitignored build artifacts (compiled from // src/*.cts by `npm run build:lib`, which the mutation CI job runs via `npm ci` @@ -59,10 +64,18 @@ const UNMUTATED = [ '!gsd-core/bin/lib/gsd2-import.cjs', ]; -// Full test command used by local runs and as the fallback when CI does not -// inject a per-shard command via MUTATION_TEST_CMD. -// Keep this list in sync with the tests arrays in scripts/mutation-matrix.cjs COVERED. -const DEFAULT_TEST_CMD = 'node --test tests/context-utilization.property.test.cjs tests/prompt-budget.property.test.cjs tests/frontmatter.property.test.cjs tests/adr-parser.property.test.cjs tests/config-schema.property.test.cjs tests/adr-parser.test.cjs tests/active-workstream-store.test.cjs tests/active-workstream-store.unit.test.cjs tests/prompt-budget.unit.test.cjs tests/adr-parser.unit.test.cjs tests/frontmatter.unit.test.cjs tests/unusable-input.test.cjs tests/core-utils.test.cjs tests/broken-windows.test.cjs tests/complexity-trigger.test.cjs'; +// Full test command used by local runs and as the fallback when CI does not inject a +// per-shard command via MUTATION_TEST_CMD. DERIVED — never hand-edit this list; it is the +// sorted union of every COVERED module's `tests` array (itself derived by +// scripts/mutation-matrix.cjs's computeModuleTests from direct `require()`s of each +// module's built artifact — see that file's derivation-engine header). A previous +// hand-maintained literal here drifted independently of COVERED's own hand-written `tests` +// arrays: six modules' tests were missing from it, plus two entries (broken-windows.test.cjs, +// complexity-trigger.test.cjs) belonging to no COVERED module at all. Deriving both from the +// same COVERED object makes that class of drift structurally impossible. +const DEFAULT_TEST_CMD = `node --test ${ + [...new Set(Object.values(COVERED).flatMap((mod) => mod.tests))].sort().join(' ') +}`; /** @type {import('@stryker-mutator/core').PartialStrykerOptions} */ export default { @@ -109,7 +122,12 @@ export default { incrementalFile: '.stryker-incremental.json', // ── Reporters ──────────────────────────────────────────────────────────────── - reporters: ['html', 'clear-text', 'progress'], + // 'json' (default path reports/mutation/mutation.json) is the machine-readable score + // source for scripts/check-mutation-score-ratchet.cjs (#3881 follow-up, mutation-matrix + // piece 3) — mutation.yml's `mutate` job reads it after `npx stryker run` to detect a + // module whose achieved score has drifted above its floor by more than the documented + // slack, so a stale-but-passing floor gets a loud CI failure instead of sitting forever. + reporters: ['html', 'json', 'clear-text', 'progress'], htmlReporter: { fileName: 'reports/mutation/mutation.html', }, diff --git a/tests/dispatcher.test.cjs b/tests/dispatcher.test.cjs index 254fedf0f..4850e5502 100644 --- a/tests/dispatcher.test.cjs +++ b/tests/dispatcher.test.cjs @@ -165,6 +165,115 @@ describe('dispatcher error paths', () => { }); }); +// ─── --project-dir (#3881) ──────────────────────────────────────────────────── +// +// docs/CONFIGURATION.md's "Project-Root Resolution in Multi-Repo Workspaces" +// section documents an explicit `--project-dir /path/to/workspace` flag that +// is "idempotent under this resolution" — i.e. it names the project root +// directly and skips findProjectRoot's ancestor walk-up entirely. Before this +// fix the flag was documented but wired nowhere: `state json --project-dir +// ` run from an unrelated cwd silently resolved from cwd instead and +// returned `{"error":"STATE.md not found"}`. + +const MARKER_3881 = 'PROJECT-B-MARKER-3881'; + +function writeMarkedState3881(projectDir, marker) { + fs.writeFileSync( + path.join(projectDir, '.planning', 'STATE.md'), + `--- +gsd_state_version: 1.0 +current_phase: 01 +status: paused +stopped_at: ${marker} +--- + +# Project State + +**Current Phase:** 01 +**Status:** Paused +` + ); +} + +describe('#3881: --project-dir honors the documented explicit override', () => { + test('absolute --project-dir from an unrelated cwd operates on the named project', () => { + const projectA = createTempProject('fix-3881-a-'); // unrelated cwd; no STATE.md + const projectB = createTempProject('fix-3881-b-'); + writeMarkedState3881(projectB, MARKER_3881); + + try { + const result = runGsdTools(['state', 'json', '--project-dir', projectB], projectA); + assert.ok(result.success, `expected success, got: ${result.error || result.output}`); + const output = JSON.parse(result.output); + assert.strictEqual( + output.stopped_at, + MARKER_3881, + "must read STATE.md from --project-dir's project, not from cwd (projectA has no STATE.md at all)" + ); + } finally { + cleanup(projectA); + cleanup(projectB); + } + }); + + test('relative --project-dir resolves against cwd', () => { + const projectA = createTempProject('fix-3881-a-'); + // createTempProject mkdtemps under os.tmpdir(), so projectA and projectB + // are siblings — a relative path from A to B is well-formed. + const projectB = createTempProject('fix-3881-b-'); + writeMarkedState3881(projectB, MARKER_3881); + + try { + const relative = path.relative(projectA, projectB); + const result = runGsdTools(['state', 'json', '--project-dir', relative], projectA); + assert.ok(result.success, `expected success, got: ${result.error || result.output}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.stopped_at, MARKER_3881, 'relative --project-dir must resolve against cwd (projectA)'); + } finally { + cleanup(projectA); + cleanup(projectB); + } + }); + + test('nonexistent --project-dir path errors with non-zero exit', () => { + const projectA = createTempProject('fix-3881-a-'); + try { + const nonexistent = path.join(projectA, 'does-not-exist-3881'); + const result = runGsdTools(['state', 'json', '--project-dir', nonexistent], projectA); + assert.strictEqual(result.success, false, 'a nonexistent --project-dir must be a non-zero exit, not a silent fallback'); + assert.match(result.error || '', /Invalid --project-dir/, 'error must name the invalid flag/path'); + } finally { + cleanup(projectA); + } + }); + + test('--project-dir path with no .planning/ errors with non-zero exit', () => { + const projectA = createTempProject('fix-3881-a-'); + const bareDir = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'fix-3881-bare-')); + try { + const result = runGsdTools(['state', 'json', '--project-dir', bareDir], projectA); + assert.strictEqual(result.success, false, 'a --project-dir with no .planning/ must be a non-zero exit, not a silent wrong-project resolve'); + assert.match(result.error || '', /Invalid --project-dir/, 'error must name the invalid flag/path'); + } finally { + cleanup(projectA); + cleanup(bareDir); + } + }); + + test('without --project-dir, behavior is unchanged (cwd-relative resolution still applies)', () => { + const projectB = createTempProject('fix-3881-b-'); + writeMarkedState3881(projectB, MARKER_3881); + try { + const result = runGsdTools(['state', 'json'], projectB); + assert.ok(result.success, `expected success, got: ${result.error || result.output}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.stopped_at, MARKER_3881, 'no-flag invocation must still resolve from cwd exactly as before'); + } finally { + cleanup(projectB); + } + }); +}); + // ─── Dispatcher Routing Branches ───────────────────────────────────────────── describe('dispatcher routing branches', () => { diff --git a/tests/feat-3881-yaml-parser-consequences.test.cjs b/tests/feat-3881-yaml-parser-consequences.test.cjs new file mode 100644 index 000000000..693cda3a0 --- /dev/null +++ b/tests/feat-3881-yaml-parser-consequences.test.cjs @@ -0,0 +1,883 @@ +// ADR-3473 §8.1 (#3881) — consequence and boundary coverage for the js-yaml migration. +// See .gsd/phase/feat-3881-one-yaml-parser/50-test-matrix.md sections A and F. Each row +// pins a consequence of swapping the hand-rolled line scanner for the vendored js-yaml +// (§40-design.md §0.2) that is otherwise invisible to the existing suite. +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { + extractFrontmatter, + reconstructFrontmatter, + spliceFrontmatter, + UNTERMINATED_KEY_THRESHOLD, + FRONTMATTER_UNPARSEABLE, +} = require('../gsd-core/bin/lib/frontmatter.cjs'); +const { transitionCore } = require('../gsd-core/bin/lib/state-transition.cjs'); +const { + _resetUnusableInputWarningsForTests, + _unusableInputEmissionCountForTests, +} = require('../gsd-core/bin/lib/unusable-input.cjs'); +const { createTempDir, cleanup, runGsdTools, createTempProject } = require('./helpers.cjs'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); +const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); +const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); + +const fixedClock = Object.freeze({ + today: () => '2026-06-27', + localToday: () => '2026-06-27', + nowIso: () => '2026-06-27T12:00:00.000Z', +}); + +function withTempDir(fn) { + const dir = createTempDir('feat-3881-consequences-'); + try { + return fn(dir); + } finally { + cleanup(dir); + } +} + +// ─── A. Consequences ──────────────────────────────────────────────────────── + +describe('A1 emptyValuedKeySurvivesAWrite', () => { + test('a key with no value round-trips through parse -> reconstruct -> re-parse with the key still present', () => { + const doc = '---\nphase: 3\nprogress:\n---\n\nbody\n'; + + const parsed = extractFrontmatter(doc); + assert.ok( + Object.prototype.hasOwnProperty.call(parsed, 'progress'), + 'an empty-valued key must survive the initial parse' + ); + + // reconstructFrontmatter omits null-valued keys (frontmatter.cjs: `if (value === null ...) continue`), + // so the empty-value contract only survives a write if extractFrontmatter never hands one back — + // this is what pins that guarantee rather than reconstructFrontmatter's own omission logic. + const reconstructed = reconstructFrontmatter(parsed); + const rewritten = `---\n${reconstructed}\n---\n\nbody\n`; + const reparsed = extractFrontmatter(rewritten); + + assert.ok( + Object.prototype.hasOwnProperty.call(reparsed, 'progress'), + `progress must survive a write; reconstructed frontmatter was ${JSON.stringify(reconstructed)}` + ); + }); +}); + +describe('A2 unparseableDocumentKeepsItsFrontmatterBlock', () => { + test('a STATE.md with a git merge-conflict marker in its frontmatter keeps the block through beginPhase', () => { + const fmBlock = [ + '---', + '<<<<<<< HEAD', + 'status: foo', + '=======', + 'status: bar', + '>>>>>>> feature', + '---', + '', + ].join('\n'); + const body = [ + '# Project State', + '', + '**Status:** Planning', + '', + '## Current Position', + '', + 'Phase: 2 — DONE', + 'Plan: —', + 'Status: Planning', + '', + ].join('\n'); + const content = fmBlock + body; + + // Verify reachability first: the conflicted region parses to zero keys with the + // unparseable marker set, exercising the exact branch beginPhaseCore relies on. + const fm = extractFrontmatter(content); + assert.equal(Object.keys(fm).length, 0); + assert.equal(fm[FRONTMATTER_UNPARSEABLE], true); + + const result = transitionCore( + content, + { kind: 'beginPhase', phaseNumber: 3, phaseName: 'Test Phase', planCount: 5 }, + { clock: fixedClock } + ); + + assert.ok( + result.content.includes('<<<<<<< HEAD') && + result.content.includes('=======') && + result.content.includes('>>>>>>> feature'), + `frontmatter conflict markers must survive the write; got ${JSON.stringify(result.content)}` + ); + }); + + // Post-#3881-review, finding 7: this describe block exercised only ONE of the 8 call sites + // that route through `beginFrontmatterReassembly` (frontmatter.cts's docblock names all 8: + // 7 `*Core` functions in state-transition.cts, dispatched by `transitionCore`, plus 1 more + // hand-verified separately in `state.cts`'s `cmdStateCompletePhase`). Table-driven over the + // remaining 6 `transitionCore` kinds that share the same preservation contract. + const OTHER_TRANSITION_KINDS = [ + ['advancePlan', { kind: 'advancePlan' }], + ['completePhase', { kind: 'completePhase', phaseNum: '2', nextPhaseNum: '3', nextPhaseName: 'Next Phase', isLastPhase: false, planCount: 1, summaryCount: 1 }], + ['plannedPhase', { kind: 'plannedPhase', phaseNumber: 3, phaseName: 'Test Phase', planCount: 5 }], + ['milestoneComplete', { kind: 'milestoneComplete', version: 'v1.0', nextMilestoneCommand: '/gsd:new-milestone' }], + ['patch', { kind: 'patch', patches: { Status: 'Paused' } }], + ['update', { kind: 'update', field: 'Status', value: 'Paused' }], + ]; + + const fmBlock = [ + '---', + '<<<<<<< HEAD', + 'status: foo', + '=======', + 'status: bar', + '>>>>>>> feature', + '---', + '', + ].join('\n'); + const body = [ + '# Project State', + '', + '**Status:** Planning', + '', + '## Current Position', + '', + 'Phase: 2 — DONE', + 'Plan: —', + 'Status: Planning', + '', + ].join('\n'); + const content = fmBlock + body; + + for (const [label, intent] of OTHER_TRANSITION_KINDS) { + test(`a git merge-conflict marker in the frontmatter keeps the block through ${label}`, () => { + const result = transitionCore(content, intent, { clock: fixedClock, roadmapProvider: () => null }); + assert.ok( + result.content.includes('<<<<<<< HEAD') + && result.content.includes('=======') + && result.content.includes('>>>>>>> feature'), + `${label}: frontmatter conflict markers must survive the write; got ${JSON.stringify(result.content)}` + ); + }); + } + +}); + +// ─── A2b. The 8th reassemble site — the real CLI path, not just the pure transform ───────── +// +// Post-#3881-review, second round: the 7 `transitionCore` kinds above preserve an unparseable +// block at the PURE-TRANSFORM layer, but `state.cts`'s CLI adapters wrap every transform in +// `readModifyWriteStateMd` -> `syncAndPreserveStateMd`, which reruns `extractFrontmatter` on +// the (already-preserved) result and — before this fix — unconditionally re-derived a FRESH +// frontmatter block, discarding the raw one a second time. Confirmed by execution: BEFORE the +// fix, `state complete-phase` on a conflict-marker STATE.md returned success with the markers +// GONE, replaced by a freshly-derived well-formed block (re-derivation, not fence deletion — +// case (b), not (a)). Table-driven over the CLI verbs found to share the same +// `readModifyWriteStateMd` path, plus a control proving the ADR-3408 §8.3 CLOSED-list "body +// wins" contract (`state sync`) is untouched. +describe('A2b unparseableFrontmatterSurvivesTheRealCliPath', () => { + const CONFLICT_FM_BLOCK = [ + '---', + '<<<<<<< HEAD', + 'status: foo', + '=======', + 'status: bar', + '>>>>>>> feature', + '---', + '', + ].join('\n'); + const CONFLICT_BODY = [ + '# Project State', + '', + '## Current Position', + '', + 'Phase: 1 — Foundation', + 'Plan: 1 of 1', + 'Status: Executing Phase 1', + 'Last activity: 2026-07-01 — mid-flight', + '', + ].join('\n'); + + function writeConflictFixture(tmpDir) { + const planningDir = path.join(tmpDir, '.planning'); + fs.mkdirSync(path.join(planningDir, 'phases', '01-foundation'), { recursive: true }); + fs.writeFileSync( + path.join(planningDir, 'ROADMAP.md'), + ['# Roadmap', '', '### Phase 1: Foundation', '**Goal:** Setup', ''].join('\n'), + ); + const statePath = path.join(planningDir, 'STATE.md'); + fs.writeFileSync(statePath, CONFLICT_FM_BLOCK + CONFLICT_BODY); + return statePath; + } + + const NON_SANCTIONED_VERBS = [ + ['state complete-phase', ['state', 'complete-phase']], + ['state update', ['state', 'update', 'Last Activity', '2026-08-26']], + ['query state.patch', ['query', 'state.patch', JSON.stringify({ Status: 'Paused for review' })]], + ['state begin-phase', ['state', 'begin-phase', '--phase', '2', '--name', 'Next Phase']], + ]; + + for (const [label, args] of NON_SANCTIONED_VERBS) { + test(`${label}: a git merge-conflict-marked frontmatter block survives the real CLI write`, () => { + const tmpDir = createTempProject(); + try { + const statePath = writeConflictFixture(tmpDir); + const result = runGsdTools(args, tmpDir); + assert.ok(result.success, `${label} failed: ${result.error}`); + const after = fs.readFileSync(statePath, 'utf-8'); + assert.ok( + after.includes('<<<<<<< HEAD') && after.includes('=======') && after.includes('>>>>>>> feature'), + `${label}: conflict markers must survive; got:\n${after}`, + ); + } finally { + cleanup(tmpDir); + } + }); + } + + test('control: state sync (ADR-3408 §8.3 CLOSED list — body wins) still overwrites unparseable frontmatter, unchanged', () => { + // The one command that MUST keep clobbering it — a regression here would mean the fix + // widened the closed list, which the review explicitly forbids. + const tmpDir = createTempProject(); + try { + const statePath = writeConflictFixture(tmpDir); + const result = runGsdTools(['state', 'sync'], tmpDir); + assert.ok(result.success, `state sync failed: ${result.error}`); + const after = fs.readFileSync(statePath, 'utf-8'); + assert.ok( + !after.includes('<<<<<<< HEAD'), + 'state sync must still re-derive frontmatter from the body (its documented contract) — conflict markers must NOT survive', + ); + assert.ok(/^---\r?\n/.test(after), 'state sync must still produce a well-formed frontmatter block'); + } finally { + cleanup(tmpDir); + } + }); +}); + +// #3881 ADR-3473 §8.5: `state sync`'s "body wins" regeneration over an unparseable +// frontmatter block (control test above, A2b) is correct and must not change — but it was +// SILENT: `synced: true`, exit 0, no signal that the existing block (including any +// merge-conflict markers) was unreadable and destroyed. §8.5: "a derived conclusion may not +// be reported as authoritative when the derivation dropped input it could not resolve." +// Table-driven per the dispatch brief's instruction to check sibling verbs on the same +// ADR-3408 §8.3 sanctioned-regenerate list: `REGENERATE_STATE` (`/gsd-health --repair`) is +// on that list too, but is DESTRUCTIVE-risk and unconditionally REFUSED by `applyRepairs`'s +// dispatcher (src/health-diagnostic.cts) before `runRepairAction` is ever invoked — so +// `state sync` is the only LIVE verb on the sanctioned path today. No table needed; a single +// verb, driven through the real CLI, is the whole live surface. +describe('A2c stateSyncWarnsOnUnparseableFrontmatterRegeneration', () => { + const CONFLICT_FM_BLOCK = [ + '---', + '<<<<<<< HEAD', + 'status: foo', + '=======', + 'status: bar', + '>>>>>>> feature', + '---', + '', + ].join('\n'); + const CONFLICT_BODY = [ + '# Project State', + '', + '## Current Position', + '', + 'Phase: 1 — Foundation', + 'Plan: 1 of 1', + 'Status: Executing Phase 1', + 'Last activity: 2026-07-01 — mid-flight', + '', + ].join('\n'); + + function seedPhaseDirs(tmpDir) { + const planningDir = path.join(tmpDir, '.planning'); + fs.mkdirSync(path.join(planningDir, 'phases', '01-foundation'), { recursive: true }); + fs.mkdirSync(path.join(planningDir, 'phases', '02-next-phase'), { recursive: true }); + fs.writeFileSync( + path.join(planningDir, 'ROADMAP.md'), + ['# Roadmap', '', '### Phase 1: Foundation', '**Goal:** Setup', '', '### Phase 2: Next', '**Goal:** More', ''].join('\n'), + ); + } + + function writeConflictState(tmpDir) { + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + fs.writeFileSync(statePath, CONFLICT_FM_BLOCK + CONFLICT_BODY); + return statePath; + } + + function writeValidState(tmpDir) { + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + const validFm = [ + '---', + 'gsd_state_version: \'1.0\'', + 'status: executing', + 'current_phase: 1', + '---', + '', + ].join('\n'); + fs.writeFileSync(statePath, validFm + CONFLICT_BODY); + return statePath; + } + + test('RED (pre-fix) proof: unparseable frontmatter — stderr carries the gsd: warning line and the JSON result surfaces it in `changes`', () => { + const tmpDir = createTempProject(); + try { + seedPhaseDirs(tmpDir); + const statePath = writeConflictState(tmpDir); + const r = runNode([TOOLS_PATH, 'state', 'sync', '--raw'], { cwd: tmpDir, timeoutMs: PROBE_TIMEOUT_MS }); + throwIfFailed(r, 'gsd-tools state sync --raw'); + + // Regeneration still happened (unchanged contract — the control test above pins this + // for the general case; re-asserted here on the same fixture this warning covers). + const after = fs.readFileSync(statePath, 'utf-8'); + assert.ok(!after.includes('<<<<<<< HEAD'), 'state sync must still regenerate over the unparseable block'); + + // Human channel: matches the existing `gsd: warning — ... (#NNNN)` precedent (#3573). + assert.match( + r.stderr, + /gsd: warning — .*frontmatter.*could not be parsed.*regenerated.*\(#3881\)/s, + `expected a gsd: warning on stderr naming the unparseable frontmatter; got stderr:\n${r.stderr}\nstdout:\n${r.stdout}`, + ); + + // Machine channel: the JSON result's existing `changes` array (the mechanism this + // codebase already uses to surface sync-time signals — see the "Progress: skipped — + // ..." entries in src/state.cts) must carry the same disclosure. + const parsed = JSON.parse(r.stdout); + assert.ok(Array.isArray(parsed.changes), `expected a changes array in JSON result; got ${r.stdout}`); + assert.ok( + parsed.changes.some((c) => typeof c === 'string' && c.includes('could not be parsed') && c.includes('#3881')), + `expected 'changes' to include the unparseable-frontmatter warning; got ${JSON.stringify(parsed.changes)}`, + ); + assert.strictEqual(parsed.synced, true, 'exit-0/synced:true stays correct — sync did what its contract says'); + } finally { + cleanup(tmpDir); + } + }); + + test('control (cannot pass vacuously): valid, parseable frontmatter emits NO such warning', () => { + const tmpDir = createTempProject(); + try { + seedPhaseDirs(tmpDir); + const statePath = writeValidState(tmpDir); + const r = runNode([TOOLS_PATH, 'state', 'sync', '--raw'], { cwd: tmpDir, timeoutMs: PROBE_TIMEOUT_MS }); + throwIfFailed(r, 'gsd-tools state sync --raw'); + + assert.doesNotMatch( + r.stderr, + /#3881/, + `valid frontmatter must not trigger the unparseable-frontmatter warning; got stderr:\n${r.stderr}`, + ); + const parsed = JSON.parse(r.stdout); + assert.ok( + !parsed.changes.some((c) => typeof c === 'string' && c.includes('#3881')), + `expected no #3881 warning in changes for valid frontmatter; got ${JSON.stringify(parsed.changes)}`, + ); + void statePath; + } finally { + cleanup(tmpDir); + } + }); +}); + +describe('A3 unparseableIsDistinguishableFromEmpty', () => { + test('both an empty and an unparseable block yield zero keys, but only the unparseable one carries the marker', () => { + const empty = extractFrontmatter('---\n---\n\nbody\n'); + const unparseable = extractFrontmatter('---\nfoo: [unclosed\n---\n\nbody\n'); + + assert.equal(Object.keys(empty).length, 0); + assert.equal(Object.keys(unparseable).length, 0); + + assert.notEqual( + empty[FRONTMATTER_UNPARSEABLE], + true, + 'a genuinely empty frontmatter block must not carry the unparseable marker' + ); + assert.equal( + unparseable[FRONTMATTER_UNPARSEABLE], + true, + 'a malformed frontmatter block must carry the unparseable marker' + ); + }); +}); + +describe('A4 nonScalarValuesCanonicalize', () => { + test('the four spellings of an object-list scalar canonicalize to one value', () => { + const spellings = [ + '- test: "a b"', + '- test: a b', + "- test: 'a b'", + '- {test: a b}', + ]; + const CANONICAL = ['test: a b']; + + for (const spelling of spellings) { + const doc = `---\nkey:\n${spelling}\n---\n\nbody\n`; + const parsed = extractFrontmatter(doc); + assert.deepEqual( + parsed.key, + CANONICAL, + `spelling ${JSON.stringify(spelling)} must canonicalize to ${JSON.stringify(CANONICAL)}; got ${JSON.stringify(parsed.key)}` + ); + } + }); +}); + +describe('A5 truncationProbeStillFiresOnAnOpenFence', () => { + test('fires on the dominant real truncation shape: opening fence, well-formed keys, then nothing', () => { + _resetUnusableInputWarningsForTests(); + const truncated = '---\nphase: 3\nplan: 2\n'; + extractFrontmatter(truncated); + assert.equal( + _unusableInputEmissionCountForTests(), + 1, + 'the #1882 probe must fire on a well-formed-but-unterminated frontmatter region' + ); + }); + + test('does NOT fire on the documented false-positive shape: a rule followed by ordinary prose', () => { + _resetUnusableInputWarningsForTests(); + const rule = '---\nNote: this is a paragraph.\n\nJust ordinary prose after a thematic break.\n'; + extractFrontmatter(rule); + assert.equal( + _unusableInputEmissionCountForTests(), + 0, + 'a document that merely opens with a thematic break above prose must not be flagged as truncated' + ); + }); + + // Post-#3881-review, finding 5: the trivially-parseable dominant shape above was the ONLY + // shape this row exercised — vacuous for the risk it names, since it never touched + // `countKeysBeforeTruncation`'s failure/recovery path at all (that whole-region text is + // valid YAML; the probe fires purely from a successful parse). Table-driven over every real + // truncation shape confirmed regressed by execution during review: an unquoted colon inside + // a value, an open (unterminated) flow collection, a mis-indented sibling key, and an + // anchor/alias whose refusal throws a mark-less exception. Each must still fire the #1882 + // diagnostic exactly once. + const REGRESSED_TRUNCATION_SHAPES = [ + ['unquoted colon in a value', '---\nphase: 3\ntitle: a: b\n'], + ['open (unterminated) flow collection', '---\nphase: 3\nlist: [a, b\n'], + ['mis-indented sibling key', '---\nphase: 3\n plan: 2\n'], + ['anchor/alias — refusal throws a mark-less exception', '---\nphase: 3\nfoo: &a bar\n'], + ]; + + for (const [label, doc] of REGRESSED_TRUNCATION_SHAPES) { + test(`fires on a real truncation shape the mark-based recovery regressed on: ${label}`, () => { + _resetUnusableInputWarningsForTests(); + extractFrontmatter(doc); + assert.equal( + _unusableInputEmissionCountForTests(), + 1, + `the #1882 probe must fire on an unterminated region shaped like: ${label}; doc=${JSON.stringify(doc)}` + ); + }); + } +}); + +describe('A6 commentsStayOnTheirOwnKey', () => { + test('a column-0 comment above a Unicode key attaches to that key and survives a round-trip', () => { + const doc = '---\nfoo: bar\n# note\n相: baz\n---\n\nbody\n'; + + const parsed = extractFrontmatter(doc); + assert.deepEqual(Object.keys(parsed), ['foo', '相']); + assert.equal(parsed['相'], 'baz'); + + const reconstructed = reconstructFrontmatter(parsed); + const commentLine = reconstructed.split('\n').find((l) => l.startsWith('#')); + const keyLine = reconstructed.split('\n').find((l) => l.startsWith('相:')); + assert.ok(commentLine, `reconstructed frontmatter must carry the comment; got ${JSON.stringify(reconstructed)}`); + const commentIdx = reconstructed.split('\n').indexOf(commentLine); + const keyIdx = reconstructed.split('\n').indexOf(keyLine); + assert.equal(keyIdx, commentIdx + 1, 'the comment must sit immediately above the 相 key, not the following one'); + + // Round-trip: reparsing the reconstructed block and reconstructing again is byte-identical. + const rewritten = `---\n${reconstructed}\n---\n\nbody\n`; + const reparsed = extractFrontmatter(rewritten); + assert.equal(reconstructFrontmatter(reparsed), reconstructed); + }); +}); + +describe('A7 anchorsAndAliasesAreRefused', () => { + // #3881 review, finding 1: the original refusal was a raw-line regex matching only the + // bare-key spelling (`key: &x`). A quoted key, a flow mapping and a flow sequence all + // define/use the SAME anchor mechanics while never matching that line shape — table-driven + // over every spelling that was confirmed bypassable, plus the original passing case, so a + // future regression in any one spelling fails loudly rather than hiding behind the others. + const SPELLINGS = [ + ['plain', '---\nfoo: &a bar\nbaz: *a\n---\n\nbody\n'], + ['quoted key', '---\n"foo": &a bar\n"baz": *a\n---\n\nbody\n'], + ['flow mapping', '---\na: {b: &a 1, c: *a}\n---\n\nbody\n'], + ['flow sequence', '---\na: [&a "q", *a]\n---\n\nbody\n'], + ['merge key (<<:) with an alias', '---\nbase: &b\n x: "1"\nfoo:\n <<: *b\n y: "2"\n---\n\nbody\n'], + ]; + + for (const [label, doc] of SPELLINGS) { + test(`${label}: refused rather than expanded`, () => { + const parsed = extractFrontmatter(doc); + assert.equal(Object.keys(parsed).length, 0, `${label} must parse to zero keys`); + assert.equal(parsed[FRONTMATTER_UNPARSEABLE], true, `${label} must carry the unparseable marker`); + }); + } + + test('a bare merge key with NO alias is not itself refused (no anchor, no expansion risk)', () => { + // Under FAILSAFE_SCHEMA (no !!merge type resolution) this never actually merges — it + // parses as an ordinary, non-expanding literal "<<" string key. Documented behavior + // change from the pre-review regex (which refused every `<<:`-shaped line regardless of + // whether an alias was present) — see frontmatter.cts refuseAnchorsAndAliases docblock. + const doc = '---\na:\n <<: {b: 1}\n c: 2\n---\n\nbody\n'; + const parsed = extractFrontmatter(doc); + assert.notEqual(parsed[FRONTMATTER_UNPARSEABLE], true); + assert.deepEqual(parsed.a, { '<<': { b: '1' }, c: '2' }); + }); +}); + +describe('A8 aliasExpansionCannotExhaustMemory', () => { + test('a billion-laughs frontmatter is refused, bounded on the RESULT, never on elapsed time', () => { + const bomb = [ + 'a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"]', + 'b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]', + 'c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]', + 'd: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c]', + 'e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d]', + 'f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e]', + 'g: [*f,*f,*f,*f,*f,*f,*f,*f,*f]', + ].join('\n'); + const doc = `---\n${bomb}\n---\n\nbody\n`; + + const parsed = extractFrontmatter(doc); + + // Assertions are on the RESULT SHAPE (zero keys, bounded serialized size), never on + // wall-clock elapsed time — this repo forbids elapsed-time assertions in tests. + assert.equal(Object.keys(parsed).length, 0); + assert.equal(parsed[FRONTMATTER_UNPARSEABLE], true); + const serializedSize = Buffer.byteLength(JSON.stringify(parsed), 'utf8'); + assert.ok( + serializedSize < 1024, + `a refused parse must stay tiny (would be ~22.8MB if expanded); got ${serializedSize} bytes` + ); + }); + + test('the same billion-laughs bomb, quoted-key-spelled, is ALSO refused (#3881 review, finding 1)', () => { + // The exact bypass the review found: the pre-fix raw-text regex matched only bare + // (unquoted) keys, so this 303-byte quoted-key spelling of the identical bomb went + // straight through unrefused and expanded to ~35.8MB. Pinned here on the RESULT shape. + const bomb = [ + '"a": &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"]', + '"b": &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]', + '"c": &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]', + '"d": &d [*c,*c,*c,*c,*c,*c,*c,*c,*c]', + '"e": &e [*d,*d,*d,*d,*d,*d,*d,*d,*d]', + '"f": &f [*e,*e,*e,*e,*e,*e,*e,*e,*e]', + '"g": [*f,*f,*f,*f,*f,*f,*f,*f,*f]', + ].join('\n'); + const doc = `---\n${bomb}\n---\n\nbody\n`; + + const parsed = extractFrontmatter(doc); + + assert.equal(Object.keys(parsed).length, 0); + assert.equal(parsed[FRONTMATTER_UNPARSEABLE], true); + const serializedSize = Buffer.byteLength(JSON.stringify(parsed), 'utf8'); + assert.ok( + serializedSize < 1024, + `a refused parse must stay tiny (would be ~35.8MB if expanded); got ${serializedSize} bytes` + ); + }); +}); + +// A9: fix #3881/#3881-followup-2 (regression pinned by tests/smart-entry.unit.test.cjs:867, +// tests/smart-entry.property.test.cjs). `repairAmbiguousColonValues`'s only real dependent is +// hand-edited STATE.md content that never lives in this repo's own tracked `*.md` files — a +// tracked-document sweep will always show zero dependents for this function, which is exactly +// the wrong signal to delete it on (see `loadWithAmbiguousColonRepair`'s docblock in +// src/frontmatter.cts). This row pins the dependency at the frontmatter layer itself, so the +// next document sweep sees it here too, not only three modules away in smart-entry. +describe('A9 ambiguousColonRepairSurvivesHandEditedStateMd (#2571/#2570)', () => { + test('a colon-separated date+description value parses to the full string after the first colon', () => { + const doc = '---\nlast_activity: 2026-06-08: reviewed the PR queue\n---\n\nbody\n'; + + const parsed = extractFrontmatter(doc); + + assert.equal( + parsed.last_activity, + '2026-06-08: reviewed the PR queue', + 'the ambiguous colon must be repaired rather than the whole region going unparseable' + ); + }); +}); + +describe('finding 3: null-byte sentinel round-trip is injective', () => { + const E000 = String.fromCharCode(0xE000); + + test('a real NUL is preserved exactly when no pre-existing U+E000 is present', () => { + const doc = '---\nfoo: "hasnull"\n---\n\nbody\n'; + const parsed = extractFrontmatter(doc); + assert.equal(parsed.foo, 'hasnull'); + }); + + test('a document containing a literal U+E000 (the sentinel itself) is refused, not silently corrupted', () => { + // Before the fix, restoreNullBytesDeep rewrote EVERY U+E000 in the parsed tree back to + // U+0000 unconditionally — including one the document author legitimately wrote — so this + // document's own U+E000 silently became a NUL. It must now be refused instead. + const doc = `---\nfoo: "pre${E000}existing"\n---\n\nbody\n`; + const parsed = extractFrontmatter(doc); + assert.equal(Object.keys(parsed).length, 0); + assert.equal(parsed[FRONTMATTER_UNPARSEABLE], true); + }); + + test('a literal U+E000 alongside a real NUL is refused rather than merging the two into one byte', () => { + // The exact corruption case from the review: escaping the real NUL to U+E000 makes it + // indistinguishable from the pre-existing U+E000, and restoring converts BOTH back to NUL. + const doc = `---\nfoo: "hasnull"\nbar: "pre${E000}existing"\n---\n\nbody\n`; + const parsed = extractFrontmatter(doc); + assert.equal(Object.keys(parsed).length, 0); + assert.equal(parsed[FRONTMATTER_UNPARSEABLE], true); + }); +}); + +// ─── F. Boundaries ────────────────────────────────────────────────────────── + +describe('F1 UNTERMINATED_KEY_THRESHOLD boundary', () => { + function unterminatedRegionWithKeys(n) { + const lines = []; + for (let i = 0; i < n; i++) lines.push(`k${i}: v${i}`); + return `---\n${lines.join('\n')}\n`; + } + + test('threshold-1 keys: no diagnostic', () => { + _resetUnusableInputWarningsForTests(); + extractFrontmatter(unterminatedRegionWithKeys(UNTERMINATED_KEY_THRESHOLD - 1)); + assert.equal(_unusableInputEmissionCountForTests(), 0); + }); + + test('threshold keys: fires', () => { + _resetUnusableInputWarningsForTests(); + extractFrontmatter(unterminatedRegionWithKeys(UNTERMINATED_KEY_THRESHOLD)); + assert.equal(_unusableInputEmissionCountForTests(), 1); + }); + + test('threshold+1 keys: fires', () => { + _resetUnusableInputWarningsForTests(); + extractFrontmatter(unterminatedRegionWithKeys(UNTERMINATED_KEY_THRESHOLD + 1)); + assert.equal(_unusableInputEmissionCountForTests(), 1); + }); +}); + +describe('F2 alias/nesting refusal bound', () => { + // refuseAnchorsAndAliases (frontmatter.cjs) is a raw-text pre-scan that refuses on ANY + // line carrying an anchor/alias/merge-key marker — there is no numeric count threshold + // in this implementation. The real boundary it exercises is therefore an occurrence + // COUNT: 0 (below the refusal trigger) parses; 1 (the trigger) is refused; 2 (over) stays + // refused, proving the refusal is not a first-occurrence artifact that a second alias + // could slip past. + test('0 anchor/alias lines: parses normally', () => { + const doc = '---\nfoo: bar\nbaz: qux\n---\n\nbody\n'; + const parsed = extractFrontmatter(doc); + assert.deepEqual(parsed, { foo: 'bar', baz: 'qux' }); + }); + + test('1 anchor/alias line: refused', () => { + const doc = '---\nfoo: &a bar\nbaz: qux\n---\n\nbody\n'; + const parsed = extractFrontmatter(doc); + assert.equal(Object.keys(parsed).length, 0); + assert.equal(parsed[FRONTMATTER_UNPARSEABLE], true); + }); + + test('2 anchor/alias lines: still refused', () => { + const doc = '---\nfoo: &a bar\nbaz: *a\n---\n\nbody\n'; + const parsed = extractFrontmatter(doc); + assert.equal(Object.keys(parsed).length, 0); + assert.equal(parsed[FRONTMATTER_UNPARSEABLE], true); + }); +}); + +describe('F3 frontmatter size boundary', () => { + const FIXTURE_PATH = path.join(__dirname, 'fixtures', 'adversarial', 'frontmatter', 'huge-bounded.md'); + + test('~30KB fixture (huge-bounded.md) completes with a typed result', () => { + const content = fs.readFileSync(FIXTURE_PATH, 'utf8'); + const parsed = extractFrontmatter(content, FIXTURE_PATH); + assert.equal(typeof parsed, 'object'); + assert.ok(Array.isArray(parsed.plans)); + assert.ok(parsed.plans.length > 0); + }); + + test('a larger (~640KB) frontmatter block also completes with a typed result', () => { + withTempDir((dir) => { + const lines = ['---', 'phase: "06"', 'plans:']; + // ~640KB of array items — an order of magnitude above the committed ~30KB fixture, + // isolated in a per-test temp file rather than a new committed fixture. + for (let i = 0; i < 40000; i++) { + lines.push(` - item-${String(i).padStart(5, '0')}`); + } + lines.push('---', '', 'Body.', ''); + const content = lines.join('\n'); + assert.ok(Buffer.byteLength(content, 'utf8') > 500 * 1024, 'fixture must exceed the committed one by an order of magnitude'); + + const filePath = path.join(dir, 'huge-bounded-larger.md'); + fs.writeFileSync(filePath, content, 'utf8'); + const readBack = fs.readFileSync(filePath, 'utf8'); + + const parsed = extractFrontmatter(readBack, filePath); + assert.equal(typeof parsed, 'object'); + assert.ok(Array.isArray(parsed.plans)); + assert.equal(parsed.plans.length, 40000); + assert.equal(parsed.plans[0], 'item-00000'); + assert.equal(parsed.plans[39999], 'item-39999'); + }); + }); +}); + +// Relocated from tests/frontmatter.test.cjs (mutation-matrix piece 1, #3881 follow-up): the +// mutation shard dropped tests/frontmatter.test.cjs (2932 lines, 3132ms of the shard's 4800ms +// per-run cost, ~96 minutes of the frontmatter shard's 180-minute budget for that one file) to +// stay inside CI's time budget, but that file was the ONLY place two assertion classes lived — +// the anchor-alias-bomb refusal (ADR-3473 §8.1 consequence 6, row A8) and the B1/B2 block-scalar +// assertions. Both are relocated here verbatim (not re-derived) so the mutants they kill stay +// killed after frontmatter.test.cjs leaves the shard's `tests` list. frontmatter.test.cjs itself +// keeps these exact assertions too (not deleted there) — it still runs in the normal (non-mutation) +// suite, so this is a second, mutation-scoped copy, not a move. +describe('anchor-alias-bomb refusal (relocated from tests/frontmatter.test.cjs for mutation-matrix piece 1)', () => { + const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'adversarial', 'frontmatter'); + function readFixture(name) { + return fs.readFileSync(path.join(FIXTURE_DIR, name), 'utf8'); + } + + test('anchor-alias-bomb.md: refused rather than expanded (ADR-3473 §8.1 consequence 6, row A8)', () => { + const parsed = extractFrontmatter(readFixture('anchor-alias-bomb.md'), 'anchor-alias-bomb.md'); + assert.equal(Object.keys(parsed).length, 0); + assert.equal(parsed[FRONTMATTER_UNPARSEABLE], true); + assert.ok(Buffer.byteLength(JSON.stringify(parsed), 'utf8') < 1024); + }); + + test('anchor-alias-bomb-quoted.md: refused identically, even quoted-key-spelled (#3881 review, finding 1)', () => { + const parsed = extractFrontmatter(readFixture('anchor-alias-bomb-quoted.md'), 'anchor-alias-bomb-quoted.md'); + assert.equal(Object.keys(parsed).length, 0); + assert.equal(parsed[FRONTMATTER_UNPARSEABLE], true); + assert.ok(Buffer.byteLength(JSON.stringify(parsed), 'utf8') < 1024); + }); +}); + +describe('B1/B2 block-scalar assertions (relocated from tests/frontmatter.test.cjs for mutation-matrix piece 1)', () => { + const ADD_TESTS_PATH = path.join(__dirname, '..', 'commands', 'gsd', 'add-tests.md'); + + test('B1 blockScalarValueIsNotTheBlockIndicator: argument-instructions is the instruction text, not "|"', () => { + const content = fs.readFileSync(ADD_TESTS_PATH, 'utf8'); + const parsed = extractFrontmatter(content, ADD_TESTS_PATH); + const value = parsed['argument-instructions']; + assert.equal(typeof value, 'string'); + assert.notEqual(value, '|'); + assert.ok(value.length > 1, 'block scalar value must be the multi-line instruction body'); + assert.ok(value.includes('Parse the argument as a phase number'), 'block scalar value must retain the source instruction text'); + }); + + test('B2 blockScalarDoesNotInventATopLevelKey: parsing add-tests.md produces no phantom "Example" key', () => { + const content = fs.readFileSync(ADD_TESTS_PATH, 'utf8'); + const parsed = extractFrontmatter(content, ADD_TESTS_PATH); + assert.ok(!Object.prototype.hasOwnProperty.call(parsed, 'Example'), 'parser must not scrape a top-level "Example" key out of the block scalar body'); + }); +}); + +// Frontmatter mutation-gap closure (#3881 follow-up, CI run 33012034388): the frontmatter +// shard measured 63.03% against its 65 floor. These tests close the gap by constraining real +// behavior in the four highest-value survivor clusters — canonical flattening's no-op guard +// (frontmatterDeepEqual, gates spliceFrontmatter's whole-document identity check), the writer's +// double-quoting decision (scalarNeedsDoubleQuoting), the reader's ambiguous-colon repair +// (repairAmbiguousColonValues), and the null-byte round-trip (escapeNullBytesForParse). Each +// case below is paired with a documented near-miss so a mutant that weakens the real condition +// (not just a syntactically different one) is observably wrong, not merely re-typed. + +describe('frontmatterDeepEqual (via spliceFrontmatter no-op detection)', () => { + test('key order is insignificant for objects: a same-value, reordered-keys write is a byte-exact no-op', () => { + const doc = '---\na: 1\nb: 2\n---\n\nbody\n'; + assert.equal(spliceFrontmatter(doc, { b: '2', a: '1' }), doc); + }); + + test('array order IS significant: same elements in a different order is NOT a no-op', () => { + // Near-miss for the .every -> .some mutant: index 0 matches ("x"==="x") but index 1 does + // not ("y" !== "z"). The real .every-based comparison must see this as unequal (regenerate); + // a .some-based mutant would short-circuit true on the matching index-0 element alone and + // wrongly report a no-op. + const doc = '---\ntags:\n - x\n - y\n---\n\nbody\n'; + const result = spliceFrontmatter(doc, { tags: ['x', 'z'] }); + assert.notEqual(result, doc, 'a value-changed array must not be treated as a no-op write'); + }); + + test('a new array value that differs only in length is NOT a no-op', () => { + const doc = '---\ntags:\n - x\n---\n\nbody\n'; + assert.notEqual(spliceFrontmatter(doc, { tags: ['x', 'y'] }), doc); + }); + + test('two empty arrays of matching (zero) length ARE a no-op', () => { + const doc = '---\ntags: []\n---\n\nbody\n'; + assert.equal(spliceFrontmatter(doc, { tags: [] }), doc); + }); + + test('an array-valued field replaced with a same-text scalar is NOT a no-op (array vs non-array must never compare equal)', () => { + const doc = '---\ntags:\n - x\n---\n\nbody\n'; + assert.notEqual(spliceFrontmatter(doc, { tags: 'x' }), doc); + }); + + test('nested-object key order is insignificant: a same-value, reordered-keys nested object is a no-op', () => { + const doc = '---\nmeta:\n a: "1"\n b: "2"\n---\n\nbody\n'; + assert.equal(spliceFrontmatter(doc, { meta: { b: '2', a: '1' } }), doc); + }); + + test('a nested object with a genuinely different key set is NOT a no-op', () => { + const doc = '---\nmeta:\n a: "1"\n b: "2"\n---\n\nbody\n'; + assert.notEqual(spliceFrontmatter(doc, { meta: { a: '1', c: '2' } }), doc); + }); +}); + +describe('scalarNeedsDoubleQuoting (via reconstructFrontmatter double-quoting decisions)', () => { + test('a value with internal (non-leading, non-trailing) whitespace is NOT quoted', () => { + // Near-miss for the /^\s|\s$/ -> /^\s|\s/ mutant (dropped end-anchor): a mutant that tests + // for whitespace ANYWHERE rather than only leading/trailing would wrongly quote this. + assert.equal(reconstructFrontmatter({ key: 'mid dle' }), 'key: mid dle'); + }); + + test('trailing whitespace alone (no leading whitespace) IS quoted', () => { + assert.equal(reconstructFrontmatter({ key: 'trailing ' }), 'key: "trailing "'); + }); + + test('a leading dash followed by a space IS quoted (reads as a YAML list indicator)', () => { + assert.equal(reconstructFrontmatter({ key: '- item' }), 'key: "- item"'); + }); + + test('a leading dash with NO following space is NOT quoted (near-miss control for the above)', () => { + assert.equal(reconstructFrontmatter({ key: '-item' }), 'key: -item'); + }); + + test('a lone UTF-16 surrogate is quoted (bare emission is invalid YAML and would not re-parse)', () => { + const reconstructed = reconstructFrontmatter({ key: '\uD800' }); + assert.equal(reconstructed, 'key: "\\uD800"'); + }); +}); + +// `repairAmbiguousColonValues` (and its sibling `repairMalformedInlineArrays` + +// `splitLegacyInlineArrayItems`) was deleted (#3881 follow-up): a sweep of every tracked +// `*.md` file with a frontmatter fence (910 files) found ZERO documents whose parse result +// changed with the repair disabled — it was hand-rolled YAML leniency kept alive on a fallback +// path, the exact thing ADR-3473 §8.1 exists to remove. `extractFrontmatter` now surfaces +// `unparseableResult()` (via `FRONTMATTER_UNPARSEABLE`) for the ambiguous-colon shapes this +// block used to pin instead of silently repairing them. + +describe('escapeNullBytesForParse (null-byte round-trip through the sentinel swap)', () => { + test('a NUL byte inside a key survives extractFrontmatter byte-for-byte, including at region offset 1', () => { + // Region offset 1 specifically distinguishes the `indexOf(...) === -1` -> `=== +1` mutant: + // for THIS input the mutant's condition is true (index really is 1), so it takes the + // "no substitution needed" branch and hands js-yaml a raw, unescaped NUL — which js-yaml + // rejects outright under every schema, collapsing the whole parse to {}. The same input + // also kills the sentinel StringLiteral "" mutant (deletes the byte instead of escaping it): + // that mutant would parse successfully but produce key "x" instead of "x". + const NUL = ''; + const doc = `---\nx${NUL}: y\n---\n\nbody\n`; + const parsed = extractFrontmatter(doc); + assert.ok( + Object.prototype.hasOwnProperty.call(parsed, `x${NUL}`), + `NUL byte must survive as part of the key, not be dropped or crash the parse; got keys ${JSON.stringify(Object.keys(parsed))}` + ); + assert.equal(parsed[`x${NUL}`], 'y'); + }); +}); diff --git a/tests/fixtures/adversarial/frontmatter/README.md b/tests/fixtures/adversarial/frontmatter/README.md index 7d1e86976..9c6923387 100644 --- a/tests/fixtures/adversarial/frontmatter/README.md +++ b/tests/fixtures/adversarial/frontmatter/README.md @@ -29,3 +29,12 @@ Categories present: - `huge-bounded.md` — a deliberately-large but bounded frontmatter block (~64KB of array items). Parser must complete in reasonable time with a typed result, not OOM or hang. +- `anchor-alias-bomb.md` — a 7-line "billion laughs" frontmatter of + nested YAML anchors/aliases. Parser must refuse it (zero keys, + `FRONTMATTER_UNPARSEABLE` set) rather than expand it — ADR-3473 + §8.1 consequence 6. +- `anchor-alias-bomb-quoted.md` — the same "billion laughs" bomb, spelled with + quoted keys (`"a": &a [...]`) instead of bare keys. Added after #3881 + review found the original raw-text anchor/alias refusal regex matched only + the bare-key line shape and was bypassable by this (and other) spellings. + Must refuse identically to `anchor-alias-bomb.md`. diff --git a/tests/fixtures/adversarial/frontmatter/anchor-alias-bomb-quoted.md b/tests/fixtures/adversarial/frontmatter/anchor-alias-bomb-quoted.md new file mode 100644 index 000000000..66853adc5 --- /dev/null +++ b/tests/fixtures/adversarial/frontmatter/anchor-alias-bomb-quoted.md @@ -0,0 +1,11 @@ +--- +"a": &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"] +"b": &b [*a,*a,*a,*a,*a,*a,*a,*a,*a] +"c": &c [*b,*b,*b,*b,*b,*b,*b,*b,*b] +"d": &d [*c,*c,*c,*c,*c,*c,*c,*c,*c] +"e": &e [*d,*d,*d,*d,*d,*d,*d,*d,*d] +"f": &f [*e,*e,*e,*e,*e,*e,*e,*e,*e] +"g": [*f,*f,*f,*f,*f,*f,*f,*f,*f] +--- + +Body. diff --git a/tests/fixtures/adversarial/frontmatter/anchor-alias-bomb.md b/tests/fixtures/adversarial/frontmatter/anchor-alias-bomb.md new file mode 100644 index 000000000..734121e57 --- /dev/null +++ b/tests/fixtures/adversarial/frontmatter/anchor-alias-bomb.md @@ -0,0 +1,11 @@ +--- +a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"] +b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a] +c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b] +d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c] +e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d] +f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e] +g: [*f,*f,*f,*f,*f,*f,*f,*f,*f] +--- + +Body. diff --git a/tests/fixtures/golden/frontmatter-legacy-golden.json b/tests/fixtures/golden/frontmatter-legacy-golden.json new file mode 100644 index 000000000..ac9afa47a --- /dev/null +++ b/tests/fixtures/golden/frontmatter-legacy-golden.json @@ -0,0 +1,367 @@ +{ + "_provenance": "Hermetic frontmatter-parity golden (#3881 redesign). Each entry embeds its own literal documentText (shrunk from a real ddde001af corpus document) and an expectedParse captured independently from the pre-migration legacy parser (git show ddde001af:src/frontmatter.cts, compiled standalone). No path is read at test time; sourcePath is provenance-only metadata. Entries with diverges:true are documented, deliberate legacy/current mismatches (see justification) and are asserted to STILL diverge rather than match.", + "entries": [ + { + "id": "agents__gsd-ai-researcher", + "sourcePath": "agents/gsd-ai-researcher.md", + "documentText": "---\nname: gsd-ai-researcher\ndescription: Researches a chosen AI framework's official docs to produce implementation-ready guidance — best practices, syntax, core patterns, and pitfalls distilled for the specific use case. Writes the Framework Quick Reference and Implementation Guidance sections of AI-SPEC.md. Spawned by /gsd:ai-integration-phase orchestrator.\ntools: Read, Write, Edit, Bash, Grep, Glob, WebFetch, WebSearch, mcp__context7__*, mcp__plugin_context7_context7__*\ncolor: green\n# hooks:\n# PostToolUse:\n# - matcher: \"Write|Edit\"\n# hooks:\n# - type: command\n# command: \"echo 'AI-SPEC written' 2>/dev/null || true\"\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd-ai-researcher\",\"description\":\"Researches a chosen AI framework's official docs to produce implementation-ready guidance — best practices, syntax, core patterns, and pitfalls distilled for the specific use case. Writes the Framework Quick Reference and Implementation Guidance sections of AI-SPEC.md. Spawned by /gsd:ai-integration-phase orchestrator.\",\"tools\":\"Read, Write, Edit, Bash, Grep, Glob, WebFetch, WebSearch, mcp__context7__*, mcp__plugin_context7_context7__*\",\"color\":\"green\"}", + "diverges": false + }, + { + "id": "agents__gsd-code-fixer", + "sourcePath": "agents/gsd-code-fixer.md", + "documentText": "---\nname: gsd-code-fixer\ndescription: Applies fixes to code review findings from REVIEW.md. Reads source files, applies intelligent fixes, and commits each fix atomically. Spawned by /gsd:code-review --fix.\ntools: Read, Edit, Write, Bash, Grep, Glob, Skill\ncolor: green\n# hooks:\n# - before_write\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd-code-fixer\",\"description\":\"Applies fixes to code review findings from REVIEW.md. Reads source files, applies intelligent fixes, and commits each fix atomically. Spawned by /gsd:code-review --fix.\",\"tools\":\"Read, Edit, Write, Bash, Grep, Glob, Skill\",\"color\":\"green\"}", + "diverges": false + }, + { + "id": "agents__gsd-code-reviewer", + "sourcePath": "agents/gsd-code-reviewer.md", + "documentText": "---\nname: gsd-code-reviewer\ndescription: Reviews source files for bugs, security issues, and code quality problems. Produces structured REVIEW.md with severity-classified findings. Spawned by /gsd:code-review.\ntools: Read, Write, Bash, Grep, Glob, Skill\ncolor: orange\n# hooks:\n# - before_write\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd-code-reviewer\",\"description\":\"Reviews source files for bugs, security issues, and code quality problems. Produces structured REVIEW.md with severity-classified findings. Spawned by /gsd:code-review.\",\"tools\":\"Read, Write, Bash, Grep, Glob, Skill\",\"color\":\"orange\"}", + "diverges": false + }, + { + "id": "agents__gsd-codebase-mapper", + "sourcePath": "agents/gsd-codebase-mapper.md", + "documentText": "---\nname: gsd-codebase-mapper\ndescription: Explores codebase and writes structured analysis documents. Spawned by map-codebase with a focus area (tech, arch, quality, concerns). Writes documents directly to reduce orchestrator context load.\ntools: Read, Bash, Grep, Glob, Write, Skill\ncolor: cyan\n# hooks:\n# PostToolUse:\n# - matcher: \"Write|Edit\"\n# hooks:\n# - type: command\n# command: \"npx eslint --fix $FILE 2>/dev/null || true\"\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd-codebase-mapper\",\"description\":\"Explores codebase and writes structured analysis documents. Spawned by map-codebase with a focus area (tech, arch, quality, concerns). Writes documents directly to reduce orchestrator context load.\",\"tools\":\"Read, Bash, Grep, Glob, Write, Skill\",\"color\":\"cyan\"}", + "diverges": false + }, + { + "id": "agents__gsd-debug-session-manager", + "sourcePath": "agents/gsd-debug-session-manager.md", + "documentText": "---\nname: gsd-debug-session-manager\ndescription: Manages multi-cycle /gsd:debug checkpoint and continuation loop in isolated context. Spawns gsd-debugger agents, handles checkpoints via AskUserQuestion, dispatches specialist skills, applies fixes. Returns compact summary to main context. Spawned by /gsd:debug command.\ntools: Read, Write, Edit, Bash, Grep, Glob, Agent, AskUserQuestion\ncolor: orange\n# hooks:\n# PostToolUse:\n# - matcher: \"Write|Edit\"\n# hooks:\n# - type: command\n# command: \"npx eslint --fix $FILE 2>/dev/null || true\"\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd-debug-session-manager\",\"description\":\"Manages multi-cycle /gsd:debug checkpoint and continuation loop in isolated context. Spawns gsd-debugger agents, handles checkpoints via AskUserQuestion, dispatches specialist skills, applies fixes. Returns compact summary to main context. Spawned by /gsd:debug command.\",\"tools\":\"Read, Write, Edit, Bash, Grep, Glob, Agent, AskUserQuestion\",\"color\":\"orange\"}", + "diverges": false + }, + { + "id": "agents__gsd-debugger", + "sourcePath": "agents/gsd-debugger.md", + "documentText": "---\nname: gsd-debugger\ndescription: Investigates bugs using scientific method, manages debug sessions, handles checkpoints. Spawned by /gsd:debug orchestrator.\ntools: Read, Write, Edit, Bash, Grep, Glob, Skill, WebSearch\ncolor: orange\n# hooks:\n# PostToolUse:\n# - matcher: \"Write|Edit\"\n# hooks:\n# - type: command\n# command: \"npx eslint --fix $FILE 2>/dev/null || true\"\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd-debugger\",\"description\":\"Investigates bugs using scientific method, manages debug sessions, handles checkpoints. Spawned by /gsd:debug orchestrator.\",\"tools\":\"Read, Write, Edit, Bash, Grep, Glob, Skill, WebSearch\",\"color\":\"orange\"}", + "diverges": false + }, + { + "id": "agents__gsd-doc-classifier", + "sourcePath": "agents/gsd-doc-classifier.md", + "documentText": "---\nname: gsd-doc-classifier\ndescription: Classifies a single planning document as ADR, PRD, SPEC, DOC, or UNKNOWN. Extracts title, scope summary, and cross-references. Spawned in parallel by /gsd:ingest-docs. Writes a JSON classification file and returns a one-line confirmation.\ntools: Read, Write, Grep, Glob\ncolor: yellow\n# hooks:\n# PostToolUse:\n# - matcher: \"Write|Edit\"\n# hooks:\n# - type: command\n# command: \"true\"\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd-doc-classifier\",\"description\":\"Classifies a single planning document as ADR, PRD, SPEC, DOC, or UNKNOWN. Extracts title, scope summary, and cross-references. Spawned in parallel by /gsd:ingest-docs. Writes a JSON classification file and returns a one-line confirmation.\",\"tools\":\"Read, Write, Grep, Glob\",\"color\":\"yellow\"}", + "diverges": false + }, + { + "id": "agents__gsd-doc-synthesizer", + "sourcePath": "agents/gsd-doc-synthesizer.md", + "documentText": "---\nname: gsd-doc-synthesizer\ndescription: Synthesizes classified planning docs into a single consolidated context. Applies precedence rules, detects cross-ref cycles, enforces LOCKED-vs-LOCKED hard-blocks, and writes INGEST-CONFLICTS.md with three buckets (auto-resolved, competing-variants, unresolved-blockers). Spawned by /gsd:ingest-docs.\ntools: Read, Write, Grep, Glob, Bash\ncolor: orange\n# hooks:\n# PostToolUse:\n# - matcher: \"Write|Edit\"\n# hooks:\n# - type: command\n# command: \"true\"\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd-doc-synthesizer\",\"description\":\"Synthesizes classified planning docs into a single consolidated context. Applies precedence rules, detects cross-ref cycles, enforces LOCKED-vs-LOCKED hard-blocks, and writes INGEST-CONFLICTS.md with three buckets (auto-resolved, competing-variants, unresolved-blockers). Spawned by /gsd:ingest-docs.\",\"tools\":\"Read, Write, Grep, Glob, Bash\",\"color\":\"orange\"}", + "diverges": false + }, + { + "id": "agents__gsd-doc-verifier", + "sourcePath": "agents/gsd-doc-verifier.md", + "documentText": "---\nname: gsd-doc-verifier\ndescription: Verifies factual claims in generated docs against the live codebase. Returns structured JSON per doc.\ntools: Read, Write, Bash, Grep, Glob\ncolor: orange\n# hooks:\n# PostToolUse:\n# - matcher: \"Write\"\n# hooks:\n# - type: command\n# command: \"npx eslint --fix $FILE 2>/dev/null || true\"\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd-doc-verifier\",\"description\":\"Verifies factual claims in generated docs against the live codebase. Returns structured JSON per doc.\",\"tools\":\"Read, Write, Bash, Grep, Glob\",\"color\":\"orange\"}", + "diverges": false + }, + { + "id": "agents__gsd-dom-verifier", + "sourcePath": "agents/gsd-dom-verifier.md", + "documentText": "---\nname: gsd-dom-verifier\ndescription: Verifies live-DOM acceptance criteria for a completed execution wave using a browser MCP server. Writes DOM-VERIFY.md. Additive — never blocks a wave. Spawned by the live-dom-uat capability at execute:wave:post.\ntools: Read, Write, Glob, Grep, mcp__chrome-devtools__*, mcp__claude-in-chrome__*\ncolor: cyan\n# hooks:\n# PostToolUse:\n# - matcher: \"Write\"\n# hooks:\n# - type: command\n# command: \"echo DOM-VERIFY written >&2\"\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd-dom-verifier\",\"description\":\"Verifies live-DOM acceptance criteria for a completed execution wave using a browser MCP server. Writes DOM-VERIFY.md. Additive — never blocks a wave. Spawned by the live-dom-uat capability at execute:wave:post.\",\"tools\":\"Read, Write, Glob, Grep, mcp__chrome-devtools__*, mcp__claude-in-chrome__*\",\"color\":\"cyan\"}", + "diverges": false + }, + { + "id": "agents__gsd-executor", + "sourcePath": "agents/gsd-executor.md", + "documentText": "---\nname: gsd-executor\ndescription: Executes GSD plans with atomic commits, deviation handling, checkpoint protocols, and state management. Spawned by execute-phase orchestrator or execute-plan command.\ntools: Read, Write, Edit, Bash, Grep, Glob, Skill, mcp__context7__*, mcp__plugin_context7_context7__*\ncolor: yellow\n# hooks:\n# PostToolUse:\n# - matcher: \"Write|Edit\"\n# hooks:\n# - type: command\n# command: \"npx eslint --fix $FILE 2>/dev/null || true\"\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd-executor\",\"description\":\"Executes GSD plans with atomic commits, deviation handling, checkpoint protocols, and state management. Spawned by execute-phase orchestrator or execute-plan command.\",\"tools\":\"Read, Write, Edit, Bash, Grep, Glob, Skill, mcp__context7__*, mcp__plugin_context7_context7__*\",\"color\":\"yellow\"}", + "diverges": false + }, + { + "id": "agents__gsd-nyquist-auditor", + "sourcePath": "agents/gsd-nyquist-auditor.md", + "documentText": "---\nname: gsd-nyquist-auditor\ndescription: Fills Nyquist validation gaps by generating tests and verifying coverage for phase requirements\ntools:\n - Read\n - Write\n - Edit\n - Bash\n - Glob\n - Grep\n - Skill\ncolor: purple\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd-nyquist-auditor\",\"description\":\"Fills Nyquist validation gaps by generating tests and verifying coverage for phase requirements\",\"tools\":[\"Read\",\"Write\",\"Edit\",\"Bash\",\"Glob\",\"Grep\",\"Skill\"],\"color\":\"purple\"}", + "diverges": false + }, + { + "id": "agents__gsd-pattern-mapper", + "sourcePath": "agents/gsd-pattern-mapper.md", + "documentText": "---\nname: gsd-pattern-mapper\ndescription: Analyzes codebase for existing patterns and produces PATTERNS.md mapping new files to closest analogs. Read-only codebase analysis spawned by /gsd:plan-phase orchestrator before planning.\ntools: Read, Bash, Glob, Grep, Write\ncolor: purple\n# hooks:\n# PostToolUse:\n# - matcher: \"Write|Edit\"\n# hooks:\n# - type: command\n# command: \"npx eslint --fix $FILE 2>/dev/null || true\"\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd-pattern-mapper\",\"description\":\"Analyzes codebase for existing patterns and produces PATTERNS.md mapping new files to closest analogs. Read-only codebase analysis spawned by /gsd:plan-phase orchestrator before planning.\",\"tools\":\"Read, Bash, Glob, Grep, Write\",\"color\":\"purple\"}", + "diverges": false + }, + { + "id": "agents__gsd-planner", + "sourcePath": "agents/gsd-planner.md", + "documentText": "---\nname: gsd-planner\ndescription: Creates executable phase plans with task breakdown, dependency analysis, and goal-backward verification. Spawned by /gsd:plan-phase orchestrator.\ntools: Read, Write, Edit, Bash, Glob, Grep, Skill, WebFetch, mcp__context7__*, mcp__plugin_context7_context7__*\ncolor: green\n# hooks:\n# PostToolUse:\n# - matcher: \"Write|Edit\"\n# hooks:\n# - type: command\n# command: \"npx eslint --fix $FILE 2>/dev/null || true\"\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd-planner\",\"description\":\"Creates executable phase plans with task breakdown, dependency analysis, and goal-backward verification. Spawned by /gsd:plan-phase orchestrator.\",\"tools\":\"Read, Write, Edit, Bash, Glob, Grep, Skill, WebFetch, mcp__context7__*, mcp__plugin_context7_context7__*\",\"color\":\"green\"}", + "diverges": false + }, + { + "id": "agents__gsd-security-auditor", + "sourcePath": "agents/gsd-security-auditor.md", + "documentText": "---\nname: gsd-security-auditor\ndescription: Verifies threat mitigations from PLAN.md threat model exist in implemented code. Returns structured security verdict (SECURED / OPEN_THREATS / ESCALATE). Spawned by /gsd:secure-phase.\ntools:\n - Read\n - Bash\n - Glob\n - Grep\n - Skill\ncolor: red\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd-security-auditor\",\"description\":\"Verifies threat mitigations from PLAN.md threat model exist in implemented code. Returns structured security verdict (SECURED / OPEN_THREATS / ESCALATE). Spawned by /gsd:secure-phase.\",\"tools\":[\"Read\",\"Bash\",\"Glob\",\"Grep\",\"Skill\"],\"color\":\"red\"}", + "diverges": false + }, + { + "id": "agents__gsd-ui-auditor", + "sourcePath": "agents/gsd-ui-auditor.md", + "documentText": "---\nname: gsd-ui-auditor\ndescription: Retroactive 6-pillar visual audit of implemented frontend code. Produces scored UI-REVIEW.md. Spawned by /gsd:ui-review orchestrator.\ntools: Read, Write, Bash, Grep, Glob, Skill\ncolor: pink\n# hooks:\n# PostToolUse:\n# - matcher: \"Write|Edit\"\n# hooks:\n# - type: command\n# command: \"npx eslint --fix $FILE 2>/dev/null || true\"\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd-ui-auditor\",\"description\":\"Retroactive 6-pillar visual audit of implemented frontend code. Produces scored UI-REVIEW.md. Spawned by /gsd:ui-review orchestrator.\",\"tools\":\"Read, Write, Bash, Grep, Glob, Skill\",\"color\":\"pink\"}", + "diverges": false + }, + { + "id": "agents__gsd-verifier", + "sourcePath": "agents/gsd-verifier.md", + "documentText": "---\nname: gsd-verifier\ndescription: Verifies phase goal achievement through goal-backward analysis. Checks codebase delivers what phase promised, not just that tasks completed. Creates VERIFICATION.md report.\ntools: Read, Write, Bash, Grep, Glob, Skill\ncolor: green\n# hooks:\n# PostToolUse:\n# - matcher: \"Write|Edit\"\n# hooks:\n# - type: command\n# command: \"npx eslint --fix $FILE 2>/dev/null || true\"\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd-verifier\",\"description\":\"Verifies phase goal achievement through goal-backward analysis. Checks codebase delivers what phase promised, not just that tasks completed. Creates VERIFICATION.md report.\",\"tools\":\"Read, Write, Bash, Grep, Glob, Skill\",\"color\":\"green\"}", + "diverges": false + }, + { + "id": "commands__gsd__add-tests", + "sourcePath": "commands/gsd/add-tests.md", + "documentText": "---\nname: gsd:add-tests\ndescription: Generate tests for a completed phase based on UAT criteria and implementation\nargument-hint: \" [additional instructions]\"\nallowed-tools:\n - Read\n - Write\n - Edit\n - Bash\n - Glob\n - Grep\n - Agent\n - AskUserQuestion\nargument-instructions: |\n Parse the argument as a phase number (integer, decimal, or letter-suffix), plus optional free-text instructions.\n Example: /gsd:add-tests 12\n Example: /gsd:add-tests 12 focus on edge cases in the pricing module\nrequires: [phase]\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd:add-tests\",\"description\":\"Generate tests for a completed phase based on UAT criteria and implementation\",\"argument-hint\":\" [additional instructions]\",\"allowed-tools\":[\"Read\",\"Write\",\"Edit\",\"Bash\",\"Glob\",\"Grep\",\"Agent\",\"AskUserQuestion\"],\"argument-instructions\":\"|\",\"Example\":\"/gsd:add-tests 12 focus on edge cases in the pricing module\",\"requires\":[\"phase\"]}", + "diverges": true, + "justification": "Legacy returned the block scalar indicator \"|\" as the literal value of argument-instructions and invented a phantom top-level key Example from the block's content lines. js-yaml parses the block scalar correctly and emits no such key. Defect fix (B1/B2)." + }, + { + "id": "commands__gsd__ai-integration-phase", + "sourcePath": "commands/gsd/ai-integration-phase.md", + "documentText": "---\nname: gsd:ai-integration-phase\ndescription: Generate an AI-SPEC.md design contract for phases that involve building AI systems.\nargument-hint: \"[phase number]\"\nallowed-tools:\n - Read\n - Write\n - Bash\n - Glob\n - Grep\n - Agent\n - WebFetch\n - WebSearch\n - AskUserQuestion\n - mcp__context7__*\nrequires: [phase]\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd:ai-integration-phase\",\"description\":\"Generate an AI-SPEC.md design contract for phases that involve building AI systems.\",\"argument-hint\":\"[phase number]\",\"allowed-tools\":[\"Read\",\"Write\",\"Bash\",\"Glob\",\"Grep\",\"Agent\",\"WebFetch\",\"WebSearch\",\"AskUserQuestion\",\"mcp__context7__*\"],\"requires\":[\"phase\"]}", + "diverges": false + }, + { + "id": "commands__gsd__audit-fix", + "sourcePath": "commands/gsd/audit-fix.md", + "documentText": "---\ntype: prompt\nname: gsd:audit-fix\ndescription: Autonomous audit-to-fix pipeline — find issues, classify, fix, test, commit\nargument-hint: \"--source [--severity ] [--max N] [--dry-run]\"\nallowed-tools:\n - Read\n - Write\n - Edit\n - Bash\n - Grep\n - Glob\n - Agent\n - AskUserQuestion\nrequires: [audit-uat]\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"type\":\"prompt\",\"name\":\"gsd:audit-fix\",\"description\":\"Autonomous audit-to-fix pipeline — find issues, classify, fix, test, commit\",\"argument-hint\":\"--source [--severity ] [--max N] [--dry-run]\",\"allowed-tools\":[\"Read\",\"Write\",\"Edit\",\"Bash\",\"Grep\",\"Glob\",\"Agent\",\"AskUserQuestion\"],\"requires\":[\"audit-uat\"]}", + "diverges": false + }, + { + "id": "commands__gsd__audit-milestone", + "sourcePath": "commands/gsd/audit-milestone.md", + "documentText": "---\nname: gsd:audit-milestone\ndescription: Audit milestone completion against original intent before archiving\nargument-hint: \"[version]\"\nallowed-tools:\n - Read\n - Glob\n - Grep\n - Bash\n - Agent\n - Write\nrequires: [execute-phase]\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd:audit-milestone\",\"description\":\"Audit milestone completion against original intent before archiving\",\"argument-hint\":\"[version]\",\"allowed-tools\":[\"Read\",\"Glob\",\"Grep\",\"Bash\",\"Agent\",\"Write\"],\"requires\":[\"execute-phase\"]}", + "diverges": false + }, + { + "id": "commands__gsd__audit-uat", + "sourcePath": "commands/gsd/audit-uat.md", + "documentText": "---\nname: gsd:audit-uat\ndescription: Cross-phase audit of all outstanding UAT and verification items\nallowed-tools:\n - Read\n - Glob\n - Grep\n - Bash\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd:audit-uat\",\"description\":\"Cross-phase audit of all outstanding UAT and verification items\",\"allowed-tools\":[\"Read\",\"Glob\",\"Grep\",\"Bash\"]}", + "diverges": false + }, + { + "id": "commands__gsd__autonomous", + "sourcePath": "commands/gsd/autonomous.md", + "documentText": "---\nname: gsd:autonomous\ndescription: Run all remaining phases autonomously — discuss→plan→execute per phase\nargument-hint: \"[--from N] [--to N] [--only N] [--interactive] [--converge]\"\neffort: max\nallowed-tools:\n - Read\n - Write\n - Bash\n - Glob\n - Grep\n - AskUserQuestion\n - Agent\nrequires: [cleanup, phase, progress]\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd:autonomous\",\"description\":\"Run all remaining phases autonomously — discuss→plan→execute per phase\",\"argument-hint\":\"[--from N] [--to N] [--only N] [--interactive] [--converge]\",\"effort\":\"max\",\"allowed-tools\":[\"Read\",\"Write\",\"Bash\",\"Glob\",\"Grep\",\"AskUserQuestion\",\"Agent\"],\"requires\":[\"cleanup\",\"phase\",\"progress\"]}", + "diverges": false + }, + { + "id": "commands__gsd__capture", + "sourcePath": "commands/gsd/capture.md", + "documentText": "---\nname: gsd:capture\ndescription: Capture ideas, tasks, notes, and seeds to their destination\nargument-hint: \"[--note | --backlog | --seed | --list | --list-seeds] [text]\"\nallowed-tools:\n - Read\n - Write\n - Edit\n - Bash\n - Glob\n - Grep\n - AskUserQuestion\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd:capture\",\"description\":\"Capture ideas, tasks, notes, and seeds to their destination\",\"argument-hint\":\"[--note | --backlog | --seed | --list | --list-seeds] [text]\",\"allowed-tools\":[\"Read\",\"Write\",\"Edit\",\"Bash\",\"Glob\",\"Grep\",\"AskUserQuestion\"]}", + "diverges": false + }, + { + "id": "commands__gsd__cleanup", + "sourcePath": "commands/gsd/cleanup.md", + "documentText": "---\nname: gsd:cleanup\ndescription: Archive accumulated phase directories from completed milestones\nallowed-tools:\n - Read\n - Write\n - Bash\n - AskUserQuestion\nrequires: [phase]\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd:cleanup\",\"description\":\"Archive accumulated phase directories from completed milestones\",\"allowed-tools\":[\"Read\",\"Write\",\"Bash\",\"AskUserQuestion\"],\"requires\":[\"phase\"]}", + "diverges": false + }, + { + "id": "commands__gsd__code-review", + "sourcePath": "commands/gsd/code-review.md", + "documentText": "---\nname: gsd:code-review\ndescription: Review source files changed during a phase for bugs, security issues, and code quality problems\nargument-hint: \" [--depth=quick|standard|deep] [--files file1,file2,...] [--fix [--all] [--auto]]\"\nallowed-tools:\n - Read\n - Bash\n - Glob\n - Grep\n - Write\n - Agent\nrequires: [config, import, phase, quick, review]\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd:code-review\",\"description\":\"Review source files changed during a phase for bugs, security issues, and code quality problems\",\"argument-hint\":\" [--depth=quick|standard|deep] [--files file1,file2,...] [--fix [--all] [--auto]]\",\"allowed-tools\":[\"Read\",\"Bash\",\"Glob\",\"Grep\",\"Write\",\"Agent\"],\"requires\":[\"config\",\"import\",\"phase\",\"quick\",\"review\"]}", + "diverges": false + }, + { + "id": "commands__gsd__complete-milestone", + "sourcePath": "commands/gsd/complete-milestone.md", + "documentText": "---\ntype: prompt\nname: gsd:complete-milestone\ndescription: Archive completed milestone and prepare for next version\nargument-hint: \nallowed-tools:\n - Read\n - Write\n - Bash\nrequires: [audit-milestone, discuss-phase, execute-phase, new-milestone, phase, plan-phase, stats, update]\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"type\":\"prompt\",\"name\":\"gsd:complete-milestone\",\"description\":\"Archive completed milestone and prepare for next version\",\"argument-hint\":\"\",\"allowed-tools\":[\"Read\",\"Write\",\"Bash\"],\"requires\":[\"audit-milestone\",\"discuss-phase\",\"execute-phase\",\"new-milestone\",\"phase\",\"plan-phase\",\"stats\",\"update\"]}", + "diverges": false + }, + { + "id": "commands__gsd__config", + "sourcePath": "commands/gsd/config.md", + "documentText": "---\nname: gsd:config\ndescription: Configure GSD settings — workflow toggles, advanced knobs, integrations, and model profile\nargument-hint: \"[--advanced | --integrations | --profile ]\"\nallowed-tools:\n - Read\n - Write\n - Bash\n - AskUserQuestion\nrequires: [code-review, review, settings]\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd:config\",\"description\":\"Configure GSD settings — workflow toggles, advanced knobs, integrations, and model profile\",\"argument-hint\":\"[--advanced | --integrations | --profile ]\",\"allowed-tools\":[\"Read\",\"Write\",\"Bash\",\"AskUserQuestion\"],\"requires\":[\"code-review\",\"review\",\"settings\"]}", + "diverges": false + }, + { + "id": "commands__gsd__debug", + "sourcePath": "commands/gsd/debug.md", + "documentText": "---\nname: gsd:debug\ndescription: Systematic debugging with persistent state across context resets\nargument-hint: \"[list | status | continue | --diagnose] [issue description]\"\nallowed-tools:\n - Read\n - Write\n - Bash\n - Agent\n - AskUserQuestion\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd:debug\",\"description\":\"Systematic debugging with persistent state across context resets\",\"argument-hint\":\"[list | status | continue | --diagnose] [issue description]\",\"allowed-tools\":[\"Read\",\"Write\",\"Bash\",\"Agent\",\"AskUserQuestion\"]}", + "diverges": false + }, + { + "id": "commands__gsd__discuss-phase", + "sourcePath": "commands/gsd/discuss-phase.md", + "documentText": "---\nname: gsd:discuss-phase\ndescription: Gather phase context through adaptive questioning before planning.\nargument-hint: \" [--all] [--auto] [--chain] [--batch] [--analyze] [--text] [--power] [--assumptions]\"\nallowed-tools:\n - Read\n - Write\n - Bash\n - Glob\n - Grep\n - AskUserQuestion\n - Agent\n - mcp__context7__resolve-library-id\n - mcp__context7__query-docs\nrequires: [config, phase]\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd:discuss-phase\",\"description\":\"Gather phase context through adaptive questioning before planning.\",\"argument-hint\":\" [--all] [--auto] [--chain] [--batch] [--analyze] [--text] [--power] [--assumptions]\",\"allowed-tools\":[\"Read\",\"Write\",\"Bash\",\"Glob\",\"Grep\",\"AskUserQuestion\",\"Agent\",\"mcp__context7__resolve-library-id\",\"mcp__context7__query-docs\"],\"requires\":[\"config\",\"phase\"]}", + "diverges": false + }, + { + "id": "commands__gsd__docs-update", + "sourcePath": "commands/gsd/docs-update.md", + "documentText": "---\nname: gsd:docs-update\ndescription: Generate or update project documentation verified against the codebase\nargument-hint: \"[--force] [--verify-only]\"\nallowed-tools:\n - Read\n - Write\n - Edit\n - Bash\n - Glob\n - Grep\n - Agent\n - AskUserQuestion\nrequires: [update]\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd:docs-update\",\"description\":\"Generate or update project documentation verified against the codebase\",\"argument-hint\":\"[--force] [--verify-only]\",\"allowed-tools\":[\"Read\",\"Write\",\"Edit\",\"Bash\",\"Glob\",\"Grep\",\"Agent\",\"AskUserQuestion\"],\"requires\":[\"update\"]}", + "diverges": false + }, + { + "id": "commands__gsd__eval-review", + "sourcePath": "commands/gsd/eval-review.md", + "documentText": "---\nname: gsd:eval-review\ndescription: Audit an executed AI phase's evaluation coverage and produce an EVAL-REVIEW.md remediation plan.\nargument-hint: \"[phase number]\"\nallowed-tools:\n - Read\n - Write\n - Bash\n - Glob\n - Grep\n - Agent\n - AskUserQuestion\nrequires: [phase]\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd:eval-review\",\"description\":\"Audit an executed AI phase's evaluation coverage and produce an EVAL-REVIEW.md remediation plan.\",\"argument-hint\":\"[phase number]\",\"allowed-tools\":[\"Read\",\"Write\",\"Bash\",\"Glob\",\"Grep\",\"Agent\",\"AskUserQuestion\"],\"requires\":[\"phase\"]}", + "diverges": false + }, + { + "id": "commands__gsd__execute-phase", + "sourcePath": "commands/gsd/execute-phase.md", + "documentText": "---\nname: gsd:execute-phase\ndescription: Execute all plans in a phase with wave-based parallelization\nargument-hint: \" [--wave N] [--gaps-only] [--interactive] [--tdd]\"\neffort: max\nallowed-tools:\n - Read\n - Write\n - Edit\n - Glob\n - Grep\n - Bash\n - Agent\n - TodoWrite\n - AskUserQuestion\nrequires: [phase, verify-work]\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd:execute-phase\",\"description\":\"Execute all plans in a phase with wave-based parallelization\",\"argument-hint\":\" [--wave N] [--gaps-only] [--interactive] [--tdd]\",\"effort\":\"max\",\"allowed-tools\":[\"Read\",\"Write\",\"Edit\",\"Glob\",\"Grep\",\"Bash\",\"Agent\",\"TodoWrite\",\"AskUserQuestion\"],\"requires\":[\"phase\",\"verify-work\"]}", + "diverges": false + }, + { + "id": "commands__gsd__explore", + "sourcePath": "commands/gsd/explore.md", + "documentText": "---\nname: gsd:explore\ndescription: Socratic ideation and idea routing — think through ideas before committing to plans\nallowed-tools:\n - Read\n - Write\n - Bash\n - Grep\n - Glob\n - Agent\n - AskUserQuestion\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd:explore\",\"description\":\"Socratic ideation and idea routing — think through ideas before committing to plans\",\"allowed-tools\":[\"Read\",\"Write\",\"Bash\",\"Grep\",\"Glob\",\"Agent\",\"AskUserQuestion\"]}", + "diverges": false + }, + { + "id": "commands__gsd__extract-learnings", + "sourcePath": "commands/gsd/extract-learnings.md", + "documentText": "---\nname: gsd:extract-learnings\ndescription: Extract decisions, lessons, patterns, and surprises from completed phase artifacts\nargument-hint: \nallowed-tools:\n - Read\n - Write\n - Bash\n - Grep\n - Glob\n - Agent\ntype: prompt\nrequires: [phase]\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd:extract-learnings\",\"description\":\"Extract decisions, lessons, patterns, and surprises from completed phase artifacts\",\"argument-hint\":\"\",\"allowed-tools\":[\"Read\",\"Write\",\"Bash\",\"Grep\",\"Glob\",\"Agent\"],\"type\":\"prompt\",\"requires\":[\"phase\"]}", + "diverges": false + }, + { + "id": "commands__gsd__graphify", + "sourcePath": "commands/gsd/graphify.md", + "documentText": "---\nname: gsd:graphify\ndescription: \"Build, query, and inspect the project knowledge graph in .planning/graphs/\"\nargument-hint: \"[build|query |status|diff]\"\nallowed-tools:\n - Read\n - Bash\nrequires: [config, fast, phase, update]\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd:graphify\",\"description\":\"Build, query, and inspect the project knowledge graph in .planning/graphs/\",\"argument-hint\":\"[build|query |status|diff]\",\"allowed-tools\":[\"Read\",\"Bash\"],\"requires\":[\"config\",\"fast\",\"phase\",\"update\"]}", + "diverges": false + }, + { + "id": "commands__gsd__quick", + "sourcePath": "commands/gsd/quick.md", + "documentText": "---\nname: gsd:quick\ndescription: Execute a quick task with GSD guarantees (atomic commits, state tracking) but skip optional agents\nargument-hint: \"[list | status | resume | --full] [--validate] [--discuss] [--research] [task description]\"\nallowed-tools:\n - Read\n - Write\n - Edit\n - Glob\n - Grep\n - Bash\n - Agent\n - AskUserQuestion\nrequires: [phase]\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd:quick\",\"description\":\"Execute a quick task with GSD guarantees (atomic commits, state tracking) but skip optional agents\",\"argument-hint\":\"[list | status | resume | --full] [--validate] [--discuss] [--research] [task description]\",\"allowed-tools\":[\"Read\",\"Write\",\"Edit\",\"Glob\",\"Grep\",\"Bash\",\"Agent\",\"AskUserQuestion\"],\"requires\":[\"phase\"]}", + "diverges": false + }, + { + "id": "commands__gsd__surface", + "sourcePath": "commands/gsd/surface.md", + "documentText": "---\nname: gsd:surface\ndescription: Toggle which skills are surfaced — apply a profile, list, or disable a cluster without reinstall\nargument-hint: \"[list|status|profile |disable |enable |reset]\"\nallowed-tools:\n - Read\n - Write\n - Bash\nrequires: [config, update]\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"name\":\"gsd:surface\",\"description\":\"Toggle which skills are surfaced — apply a profile, list, or disable a cluster without reinstall\",\"argument-hint\":\"[list|status|profile |disable |enable |reset]\",\"allowed-tools\":[\"Read\",\"Write\",\"Bash\"],\"requires\":[\"config\",\"update\"]}", + "diverges": false + }, + { + "id": "docs__features__runtime-identity", + "sourcePath": "docs/features/runtime-identity.md", + "documentText": "---\nid: 168\ntitle: Runtime Identity\ngroup: v1.7.0 Features\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"id\":\"168\",\"title\":\"Runtime Identity\",\"group\":\"v1.7.0 Features\"}", + "diverges": false + }, + { + "id": "gsd-core__templates__summary-complex", + "sourcePath": "gsd-core/templates/summary-complex.md", + "documentText": "---\nphase: XX-name\nplan: YY\nsubsystem: [primary category]\ntags: [searchable tech]\nrequires:\n - phase: [prior phase]\n provides: [what that phase built]\nprovides:\n - [bullet list of what was built/delivered]\naffects: [list of phase names or keywords]\ntech-stack:\n added: [libraries/tools]\n patterns: [architectural/code patterns]\nkey-files:\n created: [important files created]\n modified: [important files modified]\nkey-decisions:\n - \"Decision 1\"\npatterns-established:\n - \"Pattern 1: description\"\n# coverage: (#1602) optional per-deliverable UAT-routing block — see templates/summary.md .\n# Add live `coverage:` entries (id/description/verification[]/human_judgment[/rationale]) to enable\n# deterministic UAT routing in verify-work; OMIT for legacy prose-only SUMMARYs. When coverage is\n# uncertain, default human_judgment: true with a rationale — never auto-skip the human.\nduration: Xmin\ncompleted: YYYY-MM-DD\nstatus: complete\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"phase\":\"XX-name\",\"plan\":\"YY\",\"subsystem\":[\"primary category\"],\"tags\":[\"searchable tech\"],\"requires\":[\"phase: [prior phase]\"]{\"provides\":[\"what that phase built\"]},\"provides\":[\"[bullet list of what was built/delivered]\"],\"affects\":[\"list of phase names or keywords\"],\"tech-stack\":{\"added\":[\"libraries/tools\"],\"patterns\":[\"architectural/code patterns\"]},\"key-files\":{\"created\":[\"important files created\"],\"modified\":[\"important files modified\"]},\"key-decisions\":[\"Decision 1\"],\"patterns-established\":[\"Pattern 1: description\"],\"duration\":\"Xmin\",\"completed\":\"YYYY-MM-DD\",\"status\":\"complete\"}", + "diverges": true, + "justification": "Legacy's per-line flattening of the requires list produced an array whose first item read \"phase: [prior phase]\" but which ALSO carried a named own property .provides = \"[what that phase built]\" (Object.keys === [\"0\",\"provides\"]) — the second key: value line of the same list item leaked onto the array as a sibling property instead of being folded into the item text. js-yaml + this repo's normalizer instead produce ONE combined string per item. Canonicalization." + }, + { + "id": "gsd-core__templates__summary-minimal", + "sourcePath": "gsd-core/templates/summary-minimal.md", + "documentText": "---\nphase: XX-name\nplan: YY\nsubsystem: [primary category]\ntags: [searchable tech]\nprovides:\n - [bullet list of what was built/delivered]\naffects: [list of phase names or keywords]\nactuals:\n tokens: [chars/4 over files actually changed]\n tasks: [tasks completed]\n commits: [commits made]\ntech-stack:\n added: [libraries/tools]\n patterns: [architectural/code patterns]\nkey-files:\n created: [important files created]\n modified: [important files modified]\nkey-decisions: []\n# coverage: (#1602) optional per-deliverable UAT-routing block — see templates/summary.md .\n# Add live `coverage:` entries to enable deterministic UAT routing in verify-work; OMIT for legacy\n# prose-only SUMMARYs. When coverage is uncertain, default human_judgment: true — never auto-skip the human.\nduration: Xmin\ncompleted: YYYY-MM-DD\nstatus: complete\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"phase\":\"XX-name\",\"plan\":\"YY\",\"subsystem\":[\"primary category\"],\"tags\":[\"searchable tech\"],\"provides\":[\"[bullet list of what was built/delivered]\"],\"affects\":[\"list of phase names or keywords\"],\"actuals\":{\"tokens\":[\"chars/4 over files actually changed\"],\"tasks\":[\"tasks completed\"],\"commits\":[\"commits made\"]},\"tech-stack\":{\"added\":[\"libraries/tools\"],\"patterns\":[\"architectural/code patterns\"]},\"key-files\":{\"created\":[\"important files created\"],\"modified\":[\"important files modified\"]},\"key-decisions\":[],\"duration\":\"Xmin\",\"completed\":\"YYYY-MM-DD\",\"status\":\"complete\"}", + "diverges": false + }, + { + "id": "gsd-core__templates__summary-standard", + "sourcePath": "gsd-core/templates/summary-standard.md", + "documentText": "---\nphase: XX-name\nplan: YY\nsubsystem: [primary category]\ntags: [searchable tech]\nprovides:\n - [bullet list of what was built/delivered]\naffects: [list of phase names or keywords]\nactuals:\n tokens: [chars/4 over files actually changed]\n tasks: [tasks completed]\n commits: [commits made]\ntech-stack:\n added: [libraries/tools]\n patterns: [architectural/code patterns]\nkey-files:\n created: [important files created]\n modified: [important files modified]\nkey-decisions:\n - \"Decision 1\"\n# coverage: (#1602) optional per-deliverable UAT-routing block — see templates/summary.md .\n# Add live `coverage:` entries (id/description/verification[]/human_judgment[/rationale]) to enable\n# deterministic UAT routing in verify-work; OMIT for legacy prose-only SUMMARYs. When coverage is\n# uncertain, default human_judgment: true with a rationale — never auto-skip the human.\nduration: Xmin\ncompleted: YYYY-MM-DD\nstatus: complete\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"phase\":\"XX-name\",\"plan\":\"YY\",\"subsystem\":[\"primary category\"],\"tags\":[\"searchable tech\"],\"provides\":[\"[bullet list of what was built/delivered]\"],\"affects\":[\"list of phase names or keywords\"],\"actuals\":{\"tokens\":[\"chars/4 over files actually changed\"],\"tasks\":[\"tasks completed\"],\"commits\":[\"commits made\"]},\"tech-stack\":{\"added\":[\"libraries/tools\"],\"patterns\":[\"architectural/code patterns\"]},\"key-files\":{\"created\":[\"important files created\"],\"modified\":[\"important files modified\"]},\"key-decisions\":[\"Decision 1\"],\"duration\":\"Xmin\",\"completed\":\"YYYY-MM-DD\",\"status\":\"complete\"}", + "diverges": false + }, + { + "id": "tests__fixtures__adversarial__frontmatter__anchor-alias-bomb", + "sourcePath": "tests/fixtures/adversarial/frontmatter/anchor-alias-bomb.md", + "documentText": "---\na: &a [\"lol\",\"lol\",\"lol\",\"lol\",\"lol\",\"lol\",\"lol\",\"lol\",\"lol\"]\nb: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]\nc: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]\nd: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c]\ne: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d]\nf: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e]\ng: [*f,*f,*f,*f,*f,*f,*f,*f,*f]\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"a\":\"&a [\\\"lol\\\",\\\"lol\\\",\\\"lol\\\",\\\"lol\\\",\\\"lol\\\",\\\"lol\\\",\\\"lol\\\",\\\"lol\\\",\\\"lol\\\"]\",\"b\":\"&b [*a,*a,*a,*a,*a,*a,*a,*a,*a]\",\"c\":\"&c [*b,*b,*b,*b,*b,*b,*b,*b,*b]\",\"d\":\"&d [*c,*c,*c,*c,*c,*c,*c,*c,*c]\",\"e\":\"&e [*d,*d,*d,*d,*d,*d,*d,*d,*d]\",\"f\":\"&f [*e,*e,*e,*e,*e,*e,*e,*e,*e]\",\"g\":[\"*f\",\"*f\",\"*f\",\"*f\",\"*f\",\"*f\",\"*f\",\"*f\",\"*f\"]}", + "diverges": true, + "justification": "Legacy is a line scanner, not a YAML engine — it read &a/*a/<<: as inert literal text on each key's value, bounded and harmless. js-yaml resolves real YAML anchors and aliases, so consequence 6/A7 refuses the whole region outright (returns {} carrying FRONTMATTER_UNPARSEABLE) rather than risk expanding a hostile alias fan-out (A8, billion-laughs). Deliberate refusal, not a defect." + }, + { + "id": "tests__fixtures__adversarial__frontmatter__anchor-alias-bomb-quoted", + "sourcePath": "tests/fixtures/adversarial/frontmatter/anchor-alias-bomb-quoted.md", + "documentText": "---\n\"a\": &a [\"lol\",\"lol\",\"lol\",\"lol\",\"lol\",\"lol\",\"lol\",\"lol\",\"lol\"]\n\"b\": &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]\n\"c\": &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]\n\"d\": &d [*c,*c,*c,*c,*c,*c,*c,*c,*c]\n\"e\": &e [*d,*d,*d,*d,*d,*d,*d,*d,*d]\n\"f\": &f [*e,*e,*e,*e,*e,*e,*e,*e,*e]\n\"g\": [*f,*f,*f,*f,*f,*f,*f,*f,*f]\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{}", + "diverges": false + }, + { + "id": "tests__fixtures__adversarial__frontmatter__crlf-mixed", + "sourcePath": "tests/fixtures/adversarial/frontmatter/crlf-mixed.md", + "documentText": "---\r\ntitle: CRLF Title\r\nphase: 02\r\nplans:\r\n - 02-01\r\n - 02-02\r\n---\r\nstub body for golden fixture reproduction.\r\n", + "expectedParse": "{\"title\":\"CRLF Title\",\"phase\":\"02\",\"plans\":[\"02-01\",\"02-02\"]}", + "diverges": false + }, + { + "id": "tests__fixtures__adversarial__frontmatter__duplicate-keys", + "sourcePath": "tests/fixtures/adversarial/frontmatter/duplicate-keys.md", + "documentText": "---\ntitle: First\ntitle: Second\nstatus: active\nstatus: blocked\nphase: 01\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"title\":\"Second\",\"status\":\"blocked\",\"phase\":\"01\"}", + "diverges": false + }, + { + "id": "tests__fixtures__adversarial__frontmatter__huge-bounded", + "sourcePath": "tests/fixtures/adversarial/frontmatter/huge-bounded.md", + "documentText": "---\nphase: 06\nplans:\n - item-00000\n - item-00001\n - item-00002\n - item-00003\n - item-00004\n - item-00005\n - item-00006\n - item-00007\n - item-00008\n - item-00009\n - item-00010\n - item-00011\n - item-00012\n - item-00013\n - item-00014\n - item-00015\n - item-00016\n - item-00017\n - item-00018\n - item-00019\n - item-00020\n - item-00021\n - item-00022\n - item-00023\n - item-00024\n - item-00025\n - item-00026\n - item-00027\n - item-00028\n - item-00029\n - item-00030\n - item-00031\n - item-00032\n - item-00033\n - item-00034\n - item-00035\n - item-00036\n - item-00037\n - item-00038\n - item-00039\n - item-00040\n - item-00041\n - item-00042\n - item-00043\n - item-00044\n - item-00045\n - item-00046\n - item-00047\n - item-00048\n - item-00049\n - item-00050\n - item-00051\n - item-00052\n - item-00053\n - item-00054\n - item-00055\n - item-00056\n - item-00057\n - item-00058\n - item-00059\n - item-00060\n - item-00061\n - item-00062\n - item-00063\n - item-00064\n - item-00065\n - item-00066\n - item-00067\n - item-00068\n - item-00069\n - item-00070\n - item-00071\n - item-00072\n - item-00073\n - item-00074\n - item-00075\n - item-00076\n - item-00077\n - item-00078\n - item-00079\n - item-00080\n - item-00081\n - item-00082\n - item-00083\n - item-00084\n - item-00085\n - item-00086\n - item-00087\n - item-00088\n - item-00089\n - item-00090\n - item-00091\n - item-00092\n - item-00093\n - item-00094\n - item-00095\n - item-00096\n - item-00097\n - item-00098\n - item-00099\n - item-00100\n - item-00101\n - item-00102\n - item-00103\n - item-00104\n - item-00105\n - item-00106\n - item-00107\n - item-00108\n - item-00109\n - item-00110\n - item-00111\n - item-00112\n - item-00113\n - item-00114\n - item-00115\n - item-00116\n - item-00117\n - item-00118\n - item-00119\n - item-00120\n - item-00121\n - item-00122\n - item-00123\n - item-00124\n - item-00125\n - item-00126\n - item-00127\n - item-00128\n - item-00129\n - item-00130\n - item-00131\n - item-00132\n - item-00133\n - item-00134\n - item-00135\n - item-00136\n - item-00137\n - item-00138\n - item-00139\n - item-00140\n - item-00141\n - item-00142\n - item-00143\n - item-00144\n - item-00145\n - item-00146\n - item-00147\n - item-00148\n - item-00149\n - item-00150\n - item-00151\n - item-00152\n - item-00153\n - item-00154\n - item-00155\n - item-00156\n - item-00157\n - item-00158\n - item-00159\n - item-00160\n - item-00161\n - item-00162\n - item-00163\n - item-00164\n - item-00165\n - item-00166\n - item-00167\n - item-00168\n - item-00169\n - item-00170\n - item-00171\n - item-00172\n - item-00173\n - item-00174\n - item-00175\n - item-00176\n - item-00177\n - item-00178\n - item-00179\n - item-00180\n - item-00181\n - item-00182\n - item-00183\n - item-00184\n - item-00185\n - item-00186\n - item-00187\n - item-00188\n - item-00189\n - item-00190\n - item-00191\n - item-00192\n - item-00193\n - item-00194\n - item-00195\n - item-00196\n - item-00197\n - item-00198\n - item-00199\n - item-00200\n - item-00201\n - item-00202\n - item-00203\n - item-00204\n - item-00205\n - item-00206\n - item-00207\n - item-00208\n - item-00209\n - item-00210\n - item-00211\n - item-00212\n - item-00213\n - item-00214\n - item-00215\n - item-00216\n - item-00217\n - item-00218\n - item-00219\n - item-00220\n - item-00221\n - item-00222\n - item-00223\n - item-00224\n - item-00225\n - item-00226\n - item-00227\n - item-00228\n - item-00229\n - item-00230\n - item-00231\n - item-00232\n - item-00233\n - item-00234\n - item-00235\n - item-00236\n - item-00237\n - item-00238\n - item-00239\n - item-00240\n - item-00241\n - item-00242\n - item-00243\n - item-00244\n - item-00245\n - item-00246\n - item-00247\n - item-00248\n - item-00249\n - item-00250\n - item-00251\n - item-00252\n - item-00253\n - item-00254\n - item-00255\n - item-00256\n - item-00257\n - item-00258\n - item-00259\n - item-00260\n - item-00261\n - item-00262\n - item-00263\n - item-00264\n - item-00265\n - item-00266\n - item-00267\n - item-00268\n - item-00269\n - item-00270\n - item-00271\n - item-00272\n - item-00273\n - item-00274\n - item-00275\n - item-00276\n - item-00277\n - item-00278\n - item-00279\n - item-00280\n - item-00281\n - item-00282\n - item-00283\n - item-00284\n - item-00285\n - item-00286\n - item-00287\n - item-00288\n - item-00289\n - item-00290\n - item-00291\n - item-00292\n - item-00293\n - item-00294\n - item-00295\n - item-00296\n - item-00297\n - item-00298\n - item-00299\n - item-00300\n - item-00301\n - item-00302\n - item-00303\n - item-00304\n - item-00305\n - item-00306\n - item-00307\n - item-00308\n - item-00309\n - item-00310\n - item-00311\n - item-00312\n - item-00313\n - item-00314\n - item-00315\n - item-00316\n - item-00317\n - item-00318\n - item-00319\n - item-00320\n - item-00321\n - item-00322\n - item-00323\n - item-00324\n - item-00325\n - item-00326\n - item-00327\n - item-00328\n - item-00329\n - item-00330\n - item-00331\n - item-00332\n - item-00333\n - item-00334\n - item-00335\n - item-00336\n - item-00337\n - item-00338\n - item-00339\n - item-00340\n - item-00341\n - item-00342\n - item-00343\n - item-00344\n - item-00345\n - item-00346\n - item-00347\n - item-00348\n - item-00349\n - item-00350\n - item-00351\n - item-00352\n - item-00353\n - item-00354\n - item-00355\n - item-00356\n - item-00357\n - item-00358\n - item-00359\n - item-00360\n - item-00361\n - item-00362\n - item-00363\n - item-00364\n - item-00365\n - item-00366\n - item-00367\n - item-00368\n - item-00369\n - item-00370\n - item-00371\n - item-00372\n - item-00373\n - item-00374\n - item-00375\n - item-00376\n - item-00377\n - item-00378\n - item-00379\n - item-00380\n - item-00381\n - item-00382\n - item-00383\n - item-00384\n - item-00385\n - item-00386\n - item-00387\n - item-00388\n - item-00389\n - item-00390\n - item-00391\n - item-00392\n - item-00393\n - item-00394\n - item-00395\n - item-00396\n - item-00397\n - item-00398\n - item-00399\n - item-00400\n - item-00401\n - item-00402\n - item-00403\n - item-00404\n - item-00405\n - item-00406\n - item-00407\n - item-00408\n - item-00409\n - item-00410\n - item-00411\n - item-00412\n - item-00413\n - item-00414\n - item-00415\n - item-00416\n - item-00417\n - item-00418\n - item-00419\n - item-00420\n - item-00421\n - item-00422\n - item-00423\n - item-00424\n - item-00425\n - item-00426\n - item-00427\n - item-00428\n - item-00429\n - item-00430\n - item-00431\n - item-00432\n - item-00433\n - item-00434\n - item-00435\n - item-00436\n - item-00437\n - item-00438\n - item-00439\n - item-00440\n - item-00441\n - item-00442\n - item-00443\n - item-00444\n - item-00445\n - item-00446\n - item-00447\n - item-00448\n - item-00449\n - item-00450\n - item-00451\n - item-00452\n - item-00453\n - item-00454\n - item-00455\n - item-00456\n - item-00457\n - item-00458\n - item-00459\n - item-00460\n - item-00461\n - item-00462\n - item-00463\n - item-00464\n - item-00465\n - item-00466\n - item-00467\n - item-00468\n - item-00469\n - item-00470\n - item-00471\n - item-00472\n - item-00473\n - item-00474\n - item-00475\n - item-00476\n - item-00477\n - item-00478\n - item-00479\n - item-00480\n - item-00481\n - item-00482\n - item-00483\n - item-00484\n - item-00485\n - item-00486\n - item-00487\n - item-00488\n - item-00489\n - item-00490\n - item-00491\n - item-00492\n - item-00493\n - item-00494\n - item-00495\n - item-00496\n - item-00497\n - item-00498\n - item-00499\n - item-00500\n - item-00501\n - item-00502\n - item-00503\n - item-00504\n - item-00505\n - item-00506\n - item-00507\n - item-00508\n - item-00509\n - item-00510\n - item-00511\n - item-00512\n - item-00513\n - item-00514\n - item-00515\n - item-00516\n - item-00517\n - item-00518\n - item-00519\n - item-00520\n - item-00521\n - item-00522\n - item-00523\n - item-00524\n - item-00525\n - item-00526\n - item-00527\n - item-00528\n - item-00529\n - item-00530\n - item-00531\n - item-00532\n - item-00533\n - item-00534\n - item-00535\n - item-00536\n - item-00537\n - item-00538\n - item-00539\n - item-00540\n - item-00541\n - item-00542\n - item-00543\n - item-00544\n - item-00545\n - item-00546\n - item-00547\n - item-00548\n - item-00549\n - item-00550\n - item-00551\n - item-00552\n - item-00553\n - item-00554\n - item-00555\n - item-00556\n - item-00557\n - item-00558\n - item-00559\n - item-00560\n - item-00561\n - item-00562\n - item-00563\n - item-00564\n - item-00565\n - item-00566\n - item-00567\n - item-00568\n - item-00569\n - item-00570\n - item-00571\n - item-00572\n - item-00573\n - item-00574\n - item-00575\n - item-00576\n - item-00577\n - item-00578\n - item-00579\n - item-00580\n - item-00581\n - item-00582\n - item-00583\n - item-00584\n - item-00585\n - item-00586\n - item-00587\n - item-00588\n - item-00589\n - item-00590\n - item-00591\n - item-00592\n - item-00593\n - item-00594\n - item-00595\n - item-00596\n - item-00597\n - item-00598\n - item-00599\n - item-00600\n - item-00601\n - item-00602\n - item-00603\n - item-00604\n - item-00605\n - item-00606\n - item-00607\n - item-00608\n - item-00609\n - item-00610\n - item-00611\n - item-00612\n - item-00613\n - item-00614\n - item-00615\n - item-00616\n - item-00617\n - item-00618\n - item-00619\n - item-00620\n - item-00621\n - item-00622\n - item-00623\n - item-00624\n - item-00625\n - item-00626\n - item-00627\n - item-00628\n - item-00629\n - item-00630\n - item-00631\n - item-00632\n - item-00633\n - item-00634\n - item-00635\n - item-00636\n - item-00637\n - item-00638\n - item-00639\n - item-00640\n - item-00641\n - item-00642\n - item-00643\n - item-00644\n - item-00645\n - item-00646\n - item-00647\n - item-00648\n - item-00649\n - item-00650\n - item-00651\n - item-00652\n - item-00653\n - item-00654\n - item-00655\n - item-00656\n - item-00657\n - item-00658\n - item-00659\n - item-00660\n - item-00661\n - item-00662\n - item-00663\n - item-00664\n - item-00665\n - item-00666\n - item-00667\n - item-00668\n - item-00669\n - item-00670\n - item-00671\n - item-00672\n - item-00673\n - item-00674\n - item-00675\n - item-00676\n - item-00677\n - item-00678\n - item-00679\n - item-00680\n - item-00681\n - item-00682\n - item-00683\n - item-00684\n - item-00685\n - item-00686\n - item-00687\n - item-00688\n - item-00689\n - item-00690\n - item-00691\n - item-00692\n - item-00693\n - item-00694\n - item-00695\n - item-00696\n - item-00697\n - item-00698\n - item-00699\n - item-00700\n - item-00701\n - item-00702\n - item-00703\n - item-00704\n - item-00705\n - item-00706\n - item-00707\n - item-00708\n - item-00709\n - item-00710\n - item-00711\n - item-00712\n - item-00713\n - item-00714\n - item-00715\n - item-00716\n - item-00717\n - item-00718\n - item-00719\n - item-00720\n - item-00721\n - item-00722\n - item-00723\n - item-00724\n - item-00725\n - item-00726\n - item-00727\n - item-00728\n - item-00729\n - item-00730\n - item-00731\n - item-00732\n - item-00733\n - item-00734\n - item-00735\n - item-00736\n - item-00737\n - item-00738\n - item-00739\n - item-00740\n - item-00741\n - item-00742\n - item-00743\n - item-00744\n - item-00745\n - item-00746\n - item-00747\n - item-00748\n - item-00749\n - item-00750\n - item-00751\n - item-00752\n - item-00753\n - item-00754\n - item-00755\n - item-00756\n - item-00757\n - item-00758\n - item-00759\n - item-00760\n - item-00761\n - item-00762\n - item-00763\n - item-00764\n - item-00765\n - item-00766\n - item-00767\n - item-00768\n - item-00769\n - item-00770\n - item-00771\n - item-00772\n - item-00773\n - item-00774\n - item-00775\n - item-00776\n - item-00777\n - item-00778\n - item-00779\n - item-00780\n - item-00781\n - item-00782\n - item-00783\n - item-00784\n - item-00785\n - item-00786\n - item-00787\n - item-00788\n - item-00789\n - item-00790\n - item-00791\n - item-00792\n - item-00793\n - item-00794\n - item-00795\n - item-00796\n - item-00797\n - item-00798\n - item-00799\n - item-00800\n - item-00801\n - item-00802\n - item-00803\n - item-00804\n - item-00805\n - item-00806\n - item-00807\n - item-00808\n - item-00809\n - item-00810\n - item-00811\n - item-00812\n - item-00813\n - item-00814\n - item-00815\n - item-00816\n - item-00817\n - item-00818\n - item-00819\n - item-00820\n - item-00821\n - item-00822\n - item-00823\n - item-00824\n - item-00825\n - item-00826\n - item-00827\n - item-00828\n - item-00829\n - item-00830\n - item-00831\n - item-00832\n - item-00833\n - item-00834\n - item-00835\n - item-00836\n - item-00837\n - item-00838\n - item-00839\n - item-00840\n - item-00841\n - item-00842\n - item-00843\n - item-00844\n - item-00845\n - item-00846\n - item-00847\n - item-00848\n - item-00849\n - item-00850\n - item-00851\n - item-00852\n - item-00853\n - item-00854\n - item-00855\n - item-00856\n - item-00857\n - item-00858\n - item-00859\n - item-00860\n - item-00861\n - item-00862\n - item-00863\n - item-00864\n - item-00865\n - item-00866\n - item-00867\n - item-00868\n - item-00869\n - item-00870\n - item-00871\n - item-00872\n - item-00873\n - item-00874\n - item-00875\n - item-00876\n - item-00877\n - item-00878\n - item-00879\n - item-00880\n - item-00881\n - item-00882\n - item-00883\n - item-00884\n - item-00885\n - item-00886\n - item-00887\n - item-00888\n - item-00889\n - item-00890\n - item-00891\n - item-00892\n - item-00893\n - item-00894\n - item-00895\n - item-00896\n - item-00897\n - item-00898\n - item-00899\n - item-00900\n - item-00901\n - item-00902\n - item-00903\n - item-00904\n - item-00905\n - item-00906\n - item-00907\n - item-00908\n - item-00909\n - item-00910\n - item-00911\n - item-00912\n - item-00913\n - item-00914\n - item-00915\n - item-00916\n - item-00917\n - item-00918\n - item-00919\n - item-00920\n - item-00921\n - item-00922\n - item-00923\n - item-00924\n - item-00925\n - item-00926\n - item-00927\n - item-00928\n - item-00929\n - item-00930\n - item-00931\n - item-00932\n - item-00933\n - item-00934\n - item-00935\n - item-00936\n - item-00937\n - item-00938\n - item-00939\n - item-00940\n - item-00941\n - item-00942\n - item-00943\n - item-00944\n - item-00945\n - item-00946\n - item-00947\n - item-00948\n - item-00949\n - item-00950\n - item-00951\n - item-00952\n - item-00953\n - item-00954\n - item-00955\n - item-00956\n - item-00957\n - item-00958\n - item-00959\n - item-00960\n - item-00961\n - item-00962\n - item-00963\n - item-00964\n - item-00965\n - item-00966\n - item-00967\n - item-00968\n - item-00969\n - item-00970\n - item-00971\n - item-00972\n - item-00973\n - item-00974\n - item-00975\n - item-00976\n - item-00977\n - item-00978\n - item-00979\n - item-00980\n - item-00981\n - item-00982\n - item-00983\n - item-00984\n - item-00985\n - item-00986\n - item-00987\n - item-00988\n - item-00989\n - item-00990\n - item-00991\n - item-00992\n - item-00993\n - item-00994\n - item-00995\n - item-00996\n - item-00997\n - item-00998\n - item-00999\n - item-01000\n - item-01001\n - item-01002\n - item-01003\n - item-01004\n - item-01005\n - item-01006\n - item-01007\n - item-01008\n - item-01009\n - item-01010\n - item-01011\n - item-01012\n - item-01013\n - item-01014\n - item-01015\n - item-01016\n - item-01017\n - item-01018\n - item-01019\n - item-01020\n - item-01021\n - item-01022\n - item-01023\n - item-01024\n - item-01025\n - item-01026\n - item-01027\n - item-01028\n - item-01029\n - item-01030\n - item-01031\n - item-01032\n - item-01033\n - item-01034\n - item-01035\n - item-01036\n - item-01037\n - item-01038\n - item-01039\n - item-01040\n - item-01041\n - item-01042\n - item-01043\n - item-01044\n - item-01045\n - item-01046\n - item-01047\n - item-01048\n - item-01049\n - item-01050\n - item-01051\n - item-01052\n - item-01053\n - item-01054\n - item-01055\n - item-01056\n - item-01057\n - item-01058\n - item-01059\n - item-01060\n - item-01061\n - item-01062\n - item-01063\n - item-01064\n - item-01065\n - item-01066\n - item-01067\n - item-01068\n - item-01069\n - item-01070\n - item-01071\n - item-01072\n - item-01073\n - item-01074\n - item-01075\n - item-01076\n - item-01077\n - item-01078\n - item-01079\n - item-01080\n - item-01081\n - item-01082\n - item-01083\n - item-01084\n - item-01085\n - item-01086\n - item-01087\n - item-01088\n - item-01089\n - item-01090\n - item-01091\n - item-01092\n - item-01093\n - item-01094\n - item-01095\n - item-01096\n - item-01097\n - item-01098\n - item-01099\n - item-01100\n - item-01101\n - item-01102\n - item-01103\n - item-01104\n - item-01105\n - item-01106\n - item-01107\n - item-01108\n - item-01109\n - item-01110\n - item-01111\n - item-01112\n - item-01113\n - item-01114\n - item-01115\n - item-01116\n - item-01117\n - item-01118\n - item-01119\n - item-01120\n - item-01121\n - item-01122\n - item-01123\n - item-01124\n - item-01125\n - item-01126\n - item-01127\n - item-01128\n - item-01129\n - item-01130\n - item-01131\n - item-01132\n - item-01133\n - item-01134\n - item-01135\n - item-01136\n - item-01137\n - item-01138\n - item-01139\n - item-01140\n - item-01141\n - item-01142\n - item-01143\n - item-01144\n - item-01145\n - item-01146\n - item-01147\n - item-01148\n - item-01149\n - item-01150\n - item-01151\n - item-01152\n - item-01153\n - item-01154\n - item-01155\n - item-01156\n - item-01157\n - item-01158\n - item-01159\n - item-01160\n - item-01161\n - item-01162\n - item-01163\n - item-01164\n - item-01165\n - item-01166\n - item-01167\n - item-01168\n - item-01169\n - item-01170\n - item-01171\n - item-01172\n - item-01173\n - item-01174\n - item-01175\n - item-01176\n - item-01177\n - item-01178\n - item-01179\n - item-01180\n - item-01181\n - item-01182\n - item-01183\n - item-01184\n - item-01185\n - item-01186\n - item-01187\n - item-01188\n - item-01189\n - item-01190\n - item-01191\n - item-01192\n - item-01193\n - item-01194\n - item-01195\n - item-01196\n - item-01197\n - item-01198\n - item-01199\n - item-01200\n - item-01201\n - item-01202\n - item-01203\n - item-01204\n - item-01205\n - item-01206\n - item-01207\n - item-01208\n - item-01209\n - item-01210\n - item-01211\n - item-01212\n - item-01213\n - item-01214\n - item-01215\n - item-01216\n - item-01217\n - item-01218\n - item-01219\n - item-01220\n - item-01221\n - item-01222\n - item-01223\n - item-01224\n - item-01225\n - item-01226\n - item-01227\n - item-01228\n - item-01229\n - item-01230\n - item-01231\n - item-01232\n - item-01233\n - item-01234\n - item-01235\n - item-01236\n - item-01237\n - item-01238\n - item-01239\n - item-01240\n - item-01241\n - item-01242\n - item-01243\n - item-01244\n - item-01245\n - item-01246\n - item-01247\n - item-01248\n - item-01249\n - item-01250\n - item-01251\n - item-01252\n - item-01253\n - item-01254\n - item-01255\n - item-01256\n - item-01257\n - item-01258\n - item-01259\n - item-01260\n - item-01261\n - item-01262\n - item-01263\n - item-01264\n - item-01265\n - item-01266\n - item-01267\n - item-01268\n - item-01269\n - item-01270\n - item-01271\n - item-01272\n - item-01273\n - item-01274\n - item-01275\n - item-01276\n - item-01277\n - item-01278\n - item-01279\n - item-01280\n - item-01281\n - item-01282\n - item-01283\n - item-01284\n - item-01285\n - item-01286\n - item-01287\n - item-01288\n - item-01289\n - item-01290\n - item-01291\n - item-01292\n - item-01293\n - item-01294\n - item-01295\n - item-01296\n - item-01297\n - item-01298\n - item-01299\n - item-01300\n - item-01301\n - item-01302\n - item-01303\n - item-01304\n - item-01305\n - item-01306\n - item-01307\n - item-01308\n - item-01309\n - item-01310\n - item-01311\n - item-01312\n - item-01313\n - item-01314\n - item-01315\n - item-01316\n - item-01317\n - item-01318\n - item-01319\n - item-01320\n - item-01321\n - item-01322\n - item-01323\n - item-01324\n - item-01325\n - item-01326\n - item-01327\n - item-01328\n - item-01329\n - item-01330\n - item-01331\n - item-01332\n - item-01333\n - item-01334\n - item-01335\n - item-01336\n - item-01337\n - item-01338\n - item-01339\n - item-01340\n - item-01341\n - item-01342\n - item-01343\n - item-01344\n - item-01345\n - item-01346\n - item-01347\n - item-01348\n - item-01349\n - item-01350\n - item-01351\n - item-01352\n - item-01353\n - item-01354\n - item-01355\n - item-01356\n - item-01357\n - item-01358\n - item-01359\n - item-01360\n - item-01361\n - item-01362\n - item-01363\n - item-01364\n - item-01365\n - item-01366\n - item-01367\n - item-01368\n - item-01369\n - item-01370\n - item-01371\n - item-01372\n - item-01373\n - item-01374\n - item-01375\n - item-01376\n - item-01377\n - item-01378\n - item-01379\n - item-01380\n - item-01381\n - item-01382\n - item-01383\n - item-01384\n - item-01385\n - item-01386\n - item-01387\n - item-01388\n - item-01389\n - item-01390\n - item-01391\n - item-01392\n - item-01393\n - item-01394\n - item-01395\n - item-01396\n - item-01397\n - item-01398\n - item-01399\n - item-01400\n - item-01401\n - item-01402\n - item-01403\n - item-01404\n - item-01405\n - item-01406\n - item-01407\n - item-01408\n - item-01409\n - item-01410\n - item-01411\n - item-01412\n - item-01413\n - item-01414\n - item-01415\n - item-01416\n - item-01417\n - item-01418\n - item-01419\n - item-01420\n - item-01421\n - item-01422\n - item-01423\n - item-01424\n - item-01425\n - item-01426\n - item-01427\n - item-01428\n - item-01429\n - item-01430\n - item-01431\n - item-01432\n - item-01433\n - item-01434\n - item-01435\n - item-01436\n - item-01437\n - item-01438\n - item-01439\n - item-01440\n - item-01441\n - item-01442\n - item-01443\n - item-01444\n - item-01445\n - item-01446\n - item-01447\n - item-01448\n - item-01449\n - item-01450\n - item-01451\n - item-01452\n - item-01453\n - item-01454\n - item-01455\n - item-01456\n - item-01457\n - item-01458\n - item-01459\n - item-01460\n - item-01461\n - item-01462\n - item-01463\n - item-01464\n - item-01465\n - item-01466\n - item-01467\n - item-01468\n - item-01469\n - item-01470\n - item-01471\n - item-01472\n - item-01473\n - item-01474\n - item-01475\n - item-01476\n - item-01477\n - item-01478\n - item-01479\n - item-01480\n - item-01481\n - item-01482\n - item-01483\n - item-01484\n - item-01485\n - item-01486\n - item-01487\n - item-01488\n - item-01489\n - item-01490\n - item-01491\n - item-01492\n - item-01493\n - item-01494\n - item-01495\n - item-01496\n - item-01497\n - item-01498\n - item-01499\n - item-01500\n - item-01501\n - item-01502\n - item-01503\n - item-01504\n - item-01505\n - item-01506\n - item-01507\n - item-01508\n - item-01509\n - item-01510\n - item-01511\n - item-01512\n - item-01513\n - item-01514\n - item-01515\n - item-01516\n - item-01517\n - item-01518\n - item-01519\n - item-01520\n - item-01521\n - item-01522\n - item-01523\n - item-01524\n - item-01525\n - item-01526\n - item-01527\n - item-01528\n - item-01529\n - item-01530\n - item-01531\n - item-01532\n - item-01533\n - item-01534\n - item-01535\n - item-01536\n - item-01537\n - item-01538\n - item-01539\n - item-01540\n - item-01541\n - item-01542\n - item-01543\n - item-01544\n - item-01545\n - item-01546\n - item-01547\n - item-01548\n - item-01549\n - item-01550\n - item-01551\n - item-01552\n - item-01553\n - item-01554\n - item-01555\n - item-01556\n - item-01557\n - item-01558\n - item-01559\n - item-01560\n - item-01561\n - item-01562\n - item-01563\n - item-01564\n - item-01565\n - item-01566\n - item-01567\n - item-01568\n - item-01569\n - item-01570\n - item-01571\n - item-01572\n - item-01573\n - item-01574\n - item-01575\n - item-01576\n - item-01577\n - item-01578\n - item-01579\n - item-01580\n - item-01581\n - item-01582\n - item-01583\n - item-01584\n - item-01585\n - item-01586\n - item-01587\n - item-01588\n - item-01589\n - item-01590\n - item-01591\n - item-01592\n - item-01593\n - item-01594\n - item-01595\n - item-01596\n - item-01597\n - item-01598\n - item-01599\n - item-01600\n - item-01601\n - item-01602\n - item-01603\n - item-01604\n - item-01605\n - item-01606\n - item-01607\n - item-01608\n - item-01609\n - item-01610\n - item-01611\n - item-01612\n - item-01613\n - item-01614\n - item-01615\n - item-01616\n - item-01617\n - item-01618\n - item-01619\n - item-01620\n - item-01621\n - item-01622\n - item-01623\n - item-01624\n - item-01625\n - item-01626\n - item-01627\n - item-01628\n - item-01629\n - item-01630\n - item-01631\n - item-01632\n - item-01633\n - item-01634\n - item-01635\n - item-01636\n - item-01637\n - item-01638\n - item-01639\n - item-01640\n - item-01641\n - item-01642\n - item-01643\n - item-01644\n - item-01645\n - item-01646\n - item-01647\n - item-01648\n - item-01649\n - item-01650\n - item-01651\n - item-01652\n - item-01653\n - item-01654\n - item-01655\n - item-01656\n - item-01657\n - item-01658\n - item-01659\n - item-01660\n - item-01661\n - item-01662\n - item-01663\n - item-01664\n - item-01665\n - item-01666\n - item-01667\n - item-01668\n - item-01669\n - item-01670\n - item-01671\n - item-01672\n - item-01673\n - item-01674\n - item-01675\n - item-01676\n - item-01677\n - item-01678\n - item-01679\n - item-01680\n - item-01681\n - item-01682\n - item-01683\n - item-01684\n - item-01685\n - item-01686\n - item-01687\n - item-01688\n - item-01689\n - item-01690\n - item-01691\n - item-01692\n - item-01693\n - item-01694\n - item-01695\n - item-01696\n - item-01697\n - item-01698\n - item-01699\n - item-01700\n - item-01701\n - item-01702\n - item-01703\n - item-01704\n - item-01705\n - item-01706\n - item-01707\n - item-01708\n - item-01709\n - item-01710\n - item-01711\n - item-01712\n - item-01713\n - item-01714\n - item-01715\n - item-01716\n - item-01717\n - item-01718\n - item-01719\n - item-01720\n - item-01721\n - item-01722\n - item-01723\n - item-01724\n - item-01725\n - item-01726\n - item-01727\n - item-01728\n - item-01729\n - item-01730\n - item-01731\n - item-01732\n - item-01733\n - item-01734\n - item-01735\n - item-01736\n - item-01737\n - item-01738\n - item-01739\n - item-01740\n - item-01741\n - item-01742\n - item-01743\n - item-01744\n - item-01745\n - item-01746\n - item-01747\n - item-01748\n - item-01749\n - item-01750\n - item-01751\n - item-01752\n - item-01753\n - item-01754\n - item-01755\n - item-01756\n - item-01757\n - item-01758\n - item-01759\n - item-01760\n - item-01761\n - item-01762\n - item-01763\n - item-01764\n - item-01765\n - item-01766\n - item-01767\n - item-01768\n - item-01769\n - item-01770\n - item-01771\n - item-01772\n - item-01773\n - item-01774\n - item-01775\n - item-01776\n - item-01777\n - item-01778\n - item-01779\n - item-01780\n - item-01781\n - item-01782\n - item-01783\n - item-01784\n - item-01785\n - item-01786\n - item-01787\n - item-01788\n - item-01789\n - item-01790\n - item-01791\n - item-01792\n - item-01793\n - item-01794\n - item-01795\n - item-01796\n - item-01797\n - item-01798\n - item-01799\n - item-01800\n - item-01801\n - item-01802\n - item-01803\n - item-01804\n - item-01805\n - item-01806\n - item-01807\n - item-01808\n - item-01809\n - item-01810\n - item-01811\n - item-01812\n - item-01813\n - item-01814\n - item-01815\n - item-01816\n - item-01817\n - item-01818\n - item-01819\n - item-01820\n - item-01821\n - item-01822\n - item-01823\n - item-01824\n - item-01825\n - item-01826\n - item-01827\n - item-01828\n - item-01829\n - item-01830\n - item-01831\n - item-01832\n - item-01833\n - item-01834\n - item-01835\n - item-01836\n - item-01837\n - item-01838\n - item-01839\n - item-01840\n - item-01841\n - item-01842\n - item-01843\n - item-01844\n - item-01845\n - item-01846\n - item-01847\n - item-01848\n - item-01849\n - item-01850\n - item-01851\n - item-01852\n - item-01853\n - item-01854\n - item-01855\n - item-01856\n - item-01857\n - item-01858\n - item-01859\n - item-01860\n - item-01861\n - item-01862\n - item-01863\n - item-01864\n - item-01865\n - item-01866\n - item-01867\n - item-01868\n - item-01869\n - item-01870\n - item-01871\n - item-01872\n - item-01873\n - item-01874\n - item-01875\n - item-01876\n - item-01877\n - item-01878\n - item-01879\n - item-01880\n - item-01881\n - item-01882\n - item-01883\n - item-01884\n - item-01885\n - item-01886\n - item-01887\n - item-01888\n - item-01889\n - item-01890\n - item-01891\n - item-01892\n - item-01893\n - item-01894\n - item-01895\n - item-01896\n - item-01897\n - item-01898\n - item-01899\n - item-01900\n - item-01901\n - item-01902\n - item-01903\n - item-01904\n - item-01905\n - item-01906\n - item-01907\n - item-01908\n - item-01909\n - item-01910\n - item-01911\n - item-01912\n - item-01913\n - item-01914\n - item-01915\n - item-01916\n - item-01917\n - item-01918\n - item-01919\n - item-01920\n - item-01921\n - item-01922\n - item-01923\n - item-01924\n - item-01925\n - item-01926\n - item-01927\n - item-01928\n - item-01929\n - item-01930\n - item-01931\n - item-01932\n - item-01933\n - item-01934\n - item-01935\n - item-01936\n - item-01937\n - item-01938\n - item-01939\n - item-01940\n - item-01941\n - item-01942\n - item-01943\n - item-01944\n - item-01945\n - item-01946\n - item-01947\n - item-01948\n - item-01949\n - item-01950\n - item-01951\n - item-01952\n - item-01953\n - item-01954\n - item-01955\n - item-01956\n - item-01957\n - item-01958\n - item-01959\n - item-01960\n - item-01961\n - item-01962\n - item-01963\n - item-01964\n - item-01965\n - item-01966\n - item-01967\n - item-01968\n - item-01969\n - item-01970\n - item-01971\n - item-01972\n - item-01973\n - item-01974\n - item-01975\n - item-01976\n - item-01977\n - item-01978\n - item-01979\n - item-01980\n - item-01981\n - item-01982\n - item-01983\n - item-01984\n - item-01985\n - item-01986\n - item-01987\n - item-01988\n - item-01989\n - item-01990\n - item-01991\n - item-01992\n - item-01993\n - item-01994\n - item-01995\n - item-01996\n - item-01997\n - item-01998\n - item-01999\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"phase\":\"06\",\"plans\":[\"item-00000\",\"item-00001\",\"item-00002\",\"item-00003\",\"item-00004\",\"item-00005\",\"item-00006\",\"item-00007\",\"item-00008\",\"item-00009\",\"item-00010\",\"item-00011\",\"item-00012\",\"item-00013\",\"item-00014\",\"item-00015\",\"item-00016\",\"item-00017\",\"item-00018\",\"item-00019\",\"item-00020\",\"item-00021\",\"item-00022\",\"item-00023\",\"item-00024\",\"item-00025\",\"item-00026\",\"item-00027\",\"item-00028\",\"item-00029\",\"item-00030\",\"item-00031\",\"item-00032\",\"item-00033\",\"item-00034\",\"item-00035\",\"item-00036\",\"item-00037\",\"item-00038\",\"item-00039\",\"item-00040\",\"item-00041\",\"item-00042\",\"item-00043\",\"item-00044\",\"item-00045\",\"item-00046\",\"item-00047\",\"item-00048\",\"item-00049\",\"item-00050\",\"item-00051\",\"item-00052\",\"item-00053\",\"item-00054\",\"item-00055\",\"item-00056\",\"item-00057\",\"item-00058\",\"item-00059\",\"item-00060\",\"item-00061\",\"item-00062\",\"item-00063\",\"item-00064\",\"item-00065\",\"item-00066\",\"item-00067\",\"item-00068\",\"item-00069\",\"item-00070\",\"item-00071\",\"item-00072\",\"item-00073\",\"item-00074\",\"item-00075\",\"item-00076\",\"item-00077\",\"item-00078\",\"item-00079\",\"item-00080\",\"item-00081\",\"item-00082\",\"item-00083\",\"item-00084\",\"item-00085\",\"item-00086\",\"item-00087\",\"item-00088\",\"item-00089\",\"item-00090\",\"item-00091\",\"item-00092\",\"item-00093\",\"item-00094\",\"item-00095\",\"item-00096\",\"item-00097\",\"item-00098\",\"item-00099\",\"item-00100\",\"item-00101\",\"item-00102\",\"item-00103\",\"item-00104\",\"item-00105\",\"item-00106\",\"item-00107\",\"item-00108\",\"item-00109\",\"item-00110\",\"item-00111\",\"item-00112\",\"item-00113\",\"item-00114\",\"item-00115\",\"item-00116\",\"item-00117\",\"item-00118\",\"item-00119\",\"item-00120\",\"item-00121\",\"item-00122\",\"item-00123\",\"item-00124\",\"item-00125\",\"item-00126\",\"item-00127\",\"item-00128\",\"item-00129\",\"item-00130\",\"item-00131\",\"item-00132\",\"item-00133\",\"item-00134\",\"item-00135\",\"item-00136\",\"item-00137\",\"item-00138\",\"item-00139\",\"item-00140\",\"item-00141\",\"item-00142\",\"item-00143\",\"item-00144\",\"item-00145\",\"item-00146\",\"item-00147\",\"item-00148\",\"item-00149\",\"item-00150\",\"item-00151\",\"item-00152\",\"item-00153\",\"item-00154\",\"item-00155\",\"item-00156\",\"item-00157\",\"item-00158\",\"item-00159\",\"item-00160\",\"item-00161\",\"item-00162\",\"item-00163\",\"item-00164\",\"item-00165\",\"item-00166\",\"item-00167\",\"item-00168\",\"item-00169\",\"item-00170\",\"item-00171\",\"item-00172\",\"item-00173\",\"item-00174\",\"item-00175\",\"item-00176\",\"item-00177\",\"item-00178\",\"item-00179\",\"item-00180\",\"item-00181\",\"item-00182\",\"item-00183\",\"item-00184\",\"item-00185\",\"item-00186\",\"item-00187\",\"item-00188\",\"item-00189\",\"item-00190\",\"item-00191\",\"item-00192\",\"item-00193\",\"item-00194\",\"item-00195\",\"item-00196\",\"item-00197\",\"item-00198\",\"item-00199\",\"item-00200\",\"item-00201\",\"item-00202\",\"item-00203\",\"item-00204\",\"item-00205\",\"item-00206\",\"item-00207\",\"item-00208\",\"item-00209\",\"item-00210\",\"item-00211\",\"item-00212\",\"item-00213\",\"item-00214\",\"item-00215\",\"item-00216\",\"item-00217\",\"item-00218\",\"item-00219\",\"item-00220\",\"item-00221\",\"item-00222\",\"item-00223\",\"item-00224\",\"item-00225\",\"item-00226\",\"item-00227\",\"item-00228\",\"item-00229\",\"item-00230\",\"item-00231\",\"item-00232\",\"item-00233\",\"item-00234\",\"item-00235\",\"item-00236\",\"item-00237\",\"item-00238\",\"item-00239\",\"item-00240\",\"item-00241\",\"item-00242\",\"item-00243\",\"item-00244\",\"item-00245\",\"item-00246\",\"item-00247\",\"item-00248\",\"item-00249\",\"item-00250\",\"item-00251\",\"item-00252\",\"item-00253\",\"item-00254\",\"item-00255\",\"item-00256\",\"item-00257\",\"item-00258\",\"item-00259\",\"item-00260\",\"item-00261\",\"item-00262\",\"item-00263\",\"item-00264\",\"item-00265\",\"item-00266\",\"item-00267\",\"item-00268\",\"item-00269\",\"item-00270\",\"item-00271\",\"item-00272\",\"item-00273\",\"item-00274\",\"item-00275\",\"item-00276\",\"item-00277\",\"item-00278\",\"item-00279\",\"item-00280\",\"item-00281\",\"item-00282\",\"item-00283\",\"item-00284\",\"item-00285\",\"item-00286\",\"item-00287\",\"item-00288\",\"item-00289\",\"item-00290\",\"item-00291\",\"item-00292\",\"item-00293\",\"item-00294\",\"item-00295\",\"item-00296\",\"item-00297\",\"item-00298\",\"item-00299\",\"item-00300\",\"item-00301\",\"item-00302\",\"item-00303\",\"item-00304\",\"item-00305\",\"item-00306\",\"item-00307\",\"item-00308\",\"item-00309\",\"item-00310\",\"item-00311\",\"item-00312\",\"item-00313\",\"item-00314\",\"item-00315\",\"item-00316\",\"item-00317\",\"item-00318\",\"item-00319\",\"item-00320\",\"item-00321\",\"item-00322\",\"item-00323\",\"item-00324\",\"item-00325\",\"item-00326\",\"item-00327\",\"item-00328\",\"item-00329\",\"item-00330\",\"item-00331\",\"item-00332\",\"item-00333\",\"item-00334\",\"item-00335\",\"item-00336\",\"item-00337\",\"item-00338\",\"item-00339\",\"item-00340\",\"item-00341\",\"item-00342\",\"item-00343\",\"item-00344\",\"item-00345\",\"item-00346\",\"item-00347\",\"item-00348\",\"item-00349\",\"item-00350\",\"item-00351\",\"item-00352\",\"item-00353\",\"item-00354\",\"item-00355\",\"item-00356\",\"item-00357\",\"item-00358\",\"item-00359\",\"item-00360\",\"item-00361\",\"item-00362\",\"item-00363\",\"item-00364\",\"item-00365\",\"item-00366\",\"item-00367\",\"item-00368\",\"item-00369\",\"item-00370\",\"item-00371\",\"item-00372\",\"item-00373\",\"item-00374\",\"item-00375\",\"item-00376\",\"item-00377\",\"item-00378\",\"item-00379\",\"item-00380\",\"item-00381\",\"item-00382\",\"item-00383\",\"item-00384\",\"item-00385\",\"item-00386\",\"item-00387\",\"item-00388\",\"item-00389\",\"item-00390\",\"item-00391\",\"item-00392\",\"item-00393\",\"item-00394\",\"item-00395\",\"item-00396\",\"item-00397\",\"item-00398\",\"item-00399\",\"item-00400\",\"item-00401\",\"item-00402\",\"item-00403\",\"item-00404\",\"item-00405\",\"item-00406\",\"item-00407\",\"item-00408\",\"item-00409\",\"item-00410\",\"item-00411\",\"item-00412\",\"item-00413\",\"item-00414\",\"item-00415\",\"item-00416\",\"item-00417\",\"item-00418\",\"item-00419\",\"item-00420\",\"item-00421\",\"item-00422\",\"item-00423\",\"item-00424\",\"item-00425\",\"item-00426\",\"item-00427\",\"item-00428\",\"item-00429\",\"item-00430\",\"item-00431\",\"item-00432\",\"item-00433\",\"item-00434\",\"item-00435\",\"item-00436\",\"item-00437\",\"item-00438\",\"item-00439\",\"item-00440\",\"item-00441\",\"item-00442\",\"item-00443\",\"item-00444\",\"item-00445\",\"item-00446\",\"item-00447\",\"item-00448\",\"item-00449\",\"item-00450\",\"item-00451\",\"item-00452\",\"item-00453\",\"item-00454\",\"item-00455\",\"item-00456\",\"item-00457\",\"item-00458\",\"item-00459\",\"item-00460\",\"item-00461\",\"item-00462\",\"item-00463\",\"item-00464\",\"item-00465\",\"item-00466\",\"item-00467\",\"item-00468\",\"item-00469\",\"item-00470\",\"item-00471\",\"item-00472\",\"item-00473\",\"item-00474\",\"item-00475\",\"item-00476\",\"item-00477\",\"item-00478\",\"item-00479\",\"item-00480\",\"item-00481\",\"item-00482\",\"item-00483\",\"item-00484\",\"item-00485\",\"item-00486\",\"item-00487\",\"item-00488\",\"item-00489\",\"item-00490\",\"item-00491\",\"item-00492\",\"item-00493\",\"item-00494\",\"item-00495\",\"item-00496\",\"item-00497\",\"item-00498\",\"item-00499\",\"item-00500\",\"item-00501\",\"item-00502\",\"item-00503\",\"item-00504\",\"item-00505\",\"item-00506\",\"item-00507\",\"item-00508\",\"item-00509\",\"item-00510\",\"item-00511\",\"item-00512\",\"item-00513\",\"item-00514\",\"item-00515\",\"item-00516\",\"item-00517\",\"item-00518\",\"item-00519\",\"item-00520\",\"item-00521\",\"item-00522\",\"item-00523\",\"item-00524\",\"item-00525\",\"item-00526\",\"item-00527\",\"item-00528\",\"item-00529\",\"item-00530\",\"item-00531\",\"item-00532\",\"item-00533\",\"item-00534\",\"item-00535\",\"item-00536\",\"item-00537\",\"item-00538\",\"item-00539\",\"item-00540\",\"item-00541\",\"item-00542\",\"item-00543\",\"item-00544\",\"item-00545\",\"item-00546\",\"item-00547\",\"item-00548\",\"item-00549\",\"item-00550\",\"item-00551\",\"item-00552\",\"item-00553\",\"item-00554\",\"item-00555\",\"item-00556\",\"item-00557\",\"item-00558\",\"item-00559\",\"item-00560\",\"item-00561\",\"item-00562\",\"item-00563\",\"item-00564\",\"item-00565\",\"item-00566\",\"item-00567\",\"item-00568\",\"item-00569\",\"item-00570\",\"item-00571\",\"item-00572\",\"item-00573\",\"item-00574\",\"item-00575\",\"item-00576\",\"item-00577\",\"item-00578\",\"item-00579\",\"item-00580\",\"item-00581\",\"item-00582\",\"item-00583\",\"item-00584\",\"item-00585\",\"item-00586\",\"item-00587\",\"item-00588\",\"item-00589\",\"item-00590\",\"item-00591\",\"item-00592\",\"item-00593\",\"item-00594\",\"item-00595\",\"item-00596\",\"item-00597\",\"item-00598\",\"item-00599\",\"item-00600\",\"item-00601\",\"item-00602\",\"item-00603\",\"item-00604\",\"item-00605\",\"item-00606\",\"item-00607\",\"item-00608\",\"item-00609\",\"item-00610\",\"item-00611\",\"item-00612\",\"item-00613\",\"item-00614\",\"item-00615\",\"item-00616\",\"item-00617\",\"item-00618\",\"item-00619\",\"item-00620\",\"item-00621\",\"item-00622\",\"item-00623\",\"item-00624\",\"item-00625\",\"item-00626\",\"item-00627\",\"item-00628\",\"item-00629\",\"item-00630\",\"item-00631\",\"item-00632\",\"item-00633\",\"item-00634\",\"item-00635\",\"item-00636\",\"item-00637\",\"item-00638\",\"item-00639\",\"item-00640\",\"item-00641\",\"item-00642\",\"item-00643\",\"item-00644\",\"item-00645\",\"item-00646\",\"item-00647\",\"item-00648\",\"item-00649\",\"item-00650\",\"item-00651\",\"item-00652\",\"item-00653\",\"item-00654\",\"item-00655\",\"item-00656\",\"item-00657\",\"item-00658\",\"item-00659\",\"item-00660\",\"item-00661\",\"item-00662\",\"item-00663\",\"item-00664\",\"item-00665\",\"item-00666\",\"item-00667\",\"item-00668\",\"item-00669\",\"item-00670\",\"item-00671\",\"item-00672\",\"item-00673\",\"item-00674\",\"item-00675\",\"item-00676\",\"item-00677\",\"item-00678\",\"item-00679\",\"item-00680\",\"item-00681\",\"item-00682\",\"item-00683\",\"item-00684\",\"item-00685\",\"item-00686\",\"item-00687\",\"item-00688\",\"item-00689\",\"item-00690\",\"item-00691\",\"item-00692\",\"item-00693\",\"item-00694\",\"item-00695\",\"item-00696\",\"item-00697\",\"item-00698\",\"item-00699\",\"item-00700\",\"item-00701\",\"item-00702\",\"item-00703\",\"item-00704\",\"item-00705\",\"item-00706\",\"item-00707\",\"item-00708\",\"item-00709\",\"item-00710\",\"item-00711\",\"item-00712\",\"item-00713\",\"item-00714\",\"item-00715\",\"item-00716\",\"item-00717\",\"item-00718\",\"item-00719\",\"item-00720\",\"item-00721\",\"item-00722\",\"item-00723\",\"item-00724\",\"item-00725\",\"item-00726\",\"item-00727\",\"item-00728\",\"item-00729\",\"item-00730\",\"item-00731\",\"item-00732\",\"item-00733\",\"item-00734\",\"item-00735\",\"item-00736\",\"item-00737\",\"item-00738\",\"item-00739\",\"item-00740\",\"item-00741\",\"item-00742\",\"item-00743\",\"item-00744\",\"item-00745\",\"item-00746\",\"item-00747\",\"item-00748\",\"item-00749\",\"item-00750\",\"item-00751\",\"item-00752\",\"item-00753\",\"item-00754\",\"item-00755\",\"item-00756\",\"item-00757\",\"item-00758\",\"item-00759\",\"item-00760\",\"item-00761\",\"item-00762\",\"item-00763\",\"item-00764\",\"item-00765\",\"item-00766\",\"item-00767\",\"item-00768\",\"item-00769\",\"item-00770\",\"item-00771\",\"item-00772\",\"item-00773\",\"item-00774\",\"item-00775\",\"item-00776\",\"item-00777\",\"item-00778\",\"item-00779\",\"item-00780\",\"item-00781\",\"item-00782\",\"item-00783\",\"item-00784\",\"item-00785\",\"item-00786\",\"item-00787\",\"item-00788\",\"item-00789\",\"item-00790\",\"item-00791\",\"item-00792\",\"item-00793\",\"item-00794\",\"item-00795\",\"item-00796\",\"item-00797\",\"item-00798\",\"item-00799\",\"item-00800\",\"item-00801\",\"item-00802\",\"item-00803\",\"item-00804\",\"item-00805\",\"item-00806\",\"item-00807\",\"item-00808\",\"item-00809\",\"item-00810\",\"item-00811\",\"item-00812\",\"item-00813\",\"item-00814\",\"item-00815\",\"item-00816\",\"item-00817\",\"item-00818\",\"item-00819\",\"item-00820\",\"item-00821\",\"item-00822\",\"item-00823\",\"item-00824\",\"item-00825\",\"item-00826\",\"item-00827\",\"item-00828\",\"item-00829\",\"item-00830\",\"item-00831\",\"item-00832\",\"item-00833\",\"item-00834\",\"item-00835\",\"item-00836\",\"item-00837\",\"item-00838\",\"item-00839\",\"item-00840\",\"item-00841\",\"item-00842\",\"item-00843\",\"item-00844\",\"item-00845\",\"item-00846\",\"item-00847\",\"item-00848\",\"item-00849\",\"item-00850\",\"item-00851\",\"item-00852\",\"item-00853\",\"item-00854\",\"item-00855\",\"item-00856\",\"item-00857\",\"item-00858\",\"item-00859\",\"item-00860\",\"item-00861\",\"item-00862\",\"item-00863\",\"item-00864\",\"item-00865\",\"item-00866\",\"item-00867\",\"item-00868\",\"item-00869\",\"item-00870\",\"item-00871\",\"item-00872\",\"item-00873\",\"item-00874\",\"item-00875\",\"item-00876\",\"item-00877\",\"item-00878\",\"item-00879\",\"item-00880\",\"item-00881\",\"item-00882\",\"item-00883\",\"item-00884\",\"item-00885\",\"item-00886\",\"item-00887\",\"item-00888\",\"item-00889\",\"item-00890\",\"item-00891\",\"item-00892\",\"item-00893\",\"item-00894\",\"item-00895\",\"item-00896\",\"item-00897\",\"item-00898\",\"item-00899\",\"item-00900\",\"item-00901\",\"item-00902\",\"item-00903\",\"item-00904\",\"item-00905\",\"item-00906\",\"item-00907\",\"item-00908\",\"item-00909\",\"item-00910\",\"item-00911\",\"item-00912\",\"item-00913\",\"item-00914\",\"item-00915\",\"item-00916\",\"item-00917\",\"item-00918\",\"item-00919\",\"item-00920\",\"item-00921\",\"item-00922\",\"item-00923\",\"item-00924\",\"item-00925\",\"item-00926\",\"item-00927\",\"item-00928\",\"item-00929\",\"item-00930\",\"item-00931\",\"item-00932\",\"item-00933\",\"item-00934\",\"item-00935\",\"item-00936\",\"item-00937\",\"item-00938\",\"item-00939\",\"item-00940\",\"item-00941\",\"item-00942\",\"item-00943\",\"item-00944\",\"item-00945\",\"item-00946\",\"item-00947\",\"item-00948\",\"item-00949\",\"item-00950\",\"item-00951\",\"item-00952\",\"item-00953\",\"item-00954\",\"item-00955\",\"item-00956\",\"item-00957\",\"item-00958\",\"item-00959\",\"item-00960\",\"item-00961\",\"item-00962\",\"item-00963\",\"item-00964\",\"item-00965\",\"item-00966\",\"item-00967\",\"item-00968\",\"item-00969\",\"item-00970\",\"item-00971\",\"item-00972\",\"item-00973\",\"item-00974\",\"item-00975\",\"item-00976\",\"item-00977\",\"item-00978\",\"item-00979\",\"item-00980\",\"item-00981\",\"item-00982\",\"item-00983\",\"item-00984\",\"item-00985\",\"item-00986\",\"item-00987\",\"item-00988\",\"item-00989\",\"item-00990\",\"item-00991\",\"item-00992\",\"item-00993\",\"item-00994\",\"item-00995\",\"item-00996\",\"item-00997\",\"item-00998\",\"item-00999\",\"item-01000\",\"item-01001\",\"item-01002\",\"item-01003\",\"item-01004\",\"item-01005\",\"item-01006\",\"item-01007\",\"item-01008\",\"item-01009\",\"item-01010\",\"item-01011\",\"item-01012\",\"item-01013\",\"item-01014\",\"item-01015\",\"item-01016\",\"item-01017\",\"item-01018\",\"item-01019\",\"item-01020\",\"item-01021\",\"item-01022\",\"item-01023\",\"item-01024\",\"item-01025\",\"item-01026\",\"item-01027\",\"item-01028\",\"item-01029\",\"item-01030\",\"item-01031\",\"item-01032\",\"item-01033\",\"item-01034\",\"item-01035\",\"item-01036\",\"item-01037\",\"item-01038\",\"item-01039\",\"item-01040\",\"item-01041\",\"item-01042\",\"item-01043\",\"item-01044\",\"item-01045\",\"item-01046\",\"item-01047\",\"item-01048\",\"item-01049\",\"item-01050\",\"item-01051\",\"item-01052\",\"item-01053\",\"item-01054\",\"item-01055\",\"item-01056\",\"item-01057\",\"item-01058\",\"item-01059\",\"item-01060\",\"item-01061\",\"item-01062\",\"item-01063\",\"item-01064\",\"item-01065\",\"item-01066\",\"item-01067\",\"item-01068\",\"item-01069\",\"item-01070\",\"item-01071\",\"item-01072\",\"item-01073\",\"item-01074\",\"item-01075\",\"item-01076\",\"item-01077\",\"item-01078\",\"item-01079\",\"item-01080\",\"item-01081\",\"item-01082\",\"item-01083\",\"item-01084\",\"item-01085\",\"item-01086\",\"item-01087\",\"item-01088\",\"item-01089\",\"item-01090\",\"item-01091\",\"item-01092\",\"item-01093\",\"item-01094\",\"item-01095\",\"item-01096\",\"item-01097\",\"item-01098\",\"item-01099\",\"item-01100\",\"item-01101\",\"item-01102\",\"item-01103\",\"item-01104\",\"item-01105\",\"item-01106\",\"item-01107\",\"item-01108\",\"item-01109\",\"item-01110\",\"item-01111\",\"item-01112\",\"item-01113\",\"item-01114\",\"item-01115\",\"item-01116\",\"item-01117\",\"item-01118\",\"item-01119\",\"item-01120\",\"item-01121\",\"item-01122\",\"item-01123\",\"item-01124\",\"item-01125\",\"item-01126\",\"item-01127\",\"item-01128\",\"item-01129\",\"item-01130\",\"item-01131\",\"item-01132\",\"item-01133\",\"item-01134\",\"item-01135\",\"item-01136\",\"item-01137\",\"item-01138\",\"item-01139\",\"item-01140\",\"item-01141\",\"item-01142\",\"item-01143\",\"item-01144\",\"item-01145\",\"item-01146\",\"item-01147\",\"item-01148\",\"item-01149\",\"item-01150\",\"item-01151\",\"item-01152\",\"item-01153\",\"item-01154\",\"item-01155\",\"item-01156\",\"item-01157\",\"item-01158\",\"item-01159\",\"item-01160\",\"item-01161\",\"item-01162\",\"item-01163\",\"item-01164\",\"item-01165\",\"item-01166\",\"item-01167\",\"item-01168\",\"item-01169\",\"item-01170\",\"item-01171\",\"item-01172\",\"item-01173\",\"item-01174\",\"item-01175\",\"item-01176\",\"item-01177\",\"item-01178\",\"item-01179\",\"item-01180\",\"item-01181\",\"item-01182\",\"item-01183\",\"item-01184\",\"item-01185\",\"item-01186\",\"item-01187\",\"item-01188\",\"item-01189\",\"item-01190\",\"item-01191\",\"item-01192\",\"item-01193\",\"item-01194\",\"item-01195\",\"item-01196\",\"item-01197\",\"item-01198\",\"item-01199\",\"item-01200\",\"item-01201\",\"item-01202\",\"item-01203\",\"item-01204\",\"item-01205\",\"item-01206\",\"item-01207\",\"item-01208\",\"item-01209\",\"item-01210\",\"item-01211\",\"item-01212\",\"item-01213\",\"item-01214\",\"item-01215\",\"item-01216\",\"item-01217\",\"item-01218\",\"item-01219\",\"item-01220\",\"item-01221\",\"item-01222\",\"item-01223\",\"item-01224\",\"item-01225\",\"item-01226\",\"item-01227\",\"item-01228\",\"item-01229\",\"item-01230\",\"item-01231\",\"item-01232\",\"item-01233\",\"item-01234\",\"item-01235\",\"item-01236\",\"item-01237\",\"item-01238\",\"item-01239\",\"item-01240\",\"item-01241\",\"item-01242\",\"item-01243\",\"item-01244\",\"item-01245\",\"item-01246\",\"item-01247\",\"item-01248\",\"item-01249\",\"item-01250\",\"item-01251\",\"item-01252\",\"item-01253\",\"item-01254\",\"item-01255\",\"item-01256\",\"item-01257\",\"item-01258\",\"item-01259\",\"item-01260\",\"item-01261\",\"item-01262\",\"item-01263\",\"item-01264\",\"item-01265\",\"item-01266\",\"item-01267\",\"item-01268\",\"item-01269\",\"item-01270\",\"item-01271\",\"item-01272\",\"item-01273\",\"item-01274\",\"item-01275\",\"item-01276\",\"item-01277\",\"item-01278\",\"item-01279\",\"item-01280\",\"item-01281\",\"item-01282\",\"item-01283\",\"item-01284\",\"item-01285\",\"item-01286\",\"item-01287\",\"item-01288\",\"item-01289\",\"item-01290\",\"item-01291\",\"item-01292\",\"item-01293\",\"item-01294\",\"item-01295\",\"item-01296\",\"item-01297\",\"item-01298\",\"item-01299\",\"item-01300\",\"item-01301\",\"item-01302\",\"item-01303\",\"item-01304\",\"item-01305\",\"item-01306\",\"item-01307\",\"item-01308\",\"item-01309\",\"item-01310\",\"item-01311\",\"item-01312\",\"item-01313\",\"item-01314\",\"item-01315\",\"item-01316\",\"item-01317\",\"item-01318\",\"item-01319\",\"item-01320\",\"item-01321\",\"item-01322\",\"item-01323\",\"item-01324\",\"item-01325\",\"item-01326\",\"item-01327\",\"item-01328\",\"item-01329\",\"item-01330\",\"item-01331\",\"item-01332\",\"item-01333\",\"item-01334\",\"item-01335\",\"item-01336\",\"item-01337\",\"item-01338\",\"item-01339\",\"item-01340\",\"item-01341\",\"item-01342\",\"item-01343\",\"item-01344\",\"item-01345\",\"item-01346\",\"item-01347\",\"item-01348\",\"item-01349\",\"item-01350\",\"item-01351\",\"item-01352\",\"item-01353\",\"item-01354\",\"item-01355\",\"item-01356\",\"item-01357\",\"item-01358\",\"item-01359\",\"item-01360\",\"item-01361\",\"item-01362\",\"item-01363\",\"item-01364\",\"item-01365\",\"item-01366\",\"item-01367\",\"item-01368\",\"item-01369\",\"item-01370\",\"item-01371\",\"item-01372\",\"item-01373\",\"item-01374\",\"item-01375\",\"item-01376\",\"item-01377\",\"item-01378\",\"item-01379\",\"item-01380\",\"item-01381\",\"item-01382\",\"item-01383\",\"item-01384\",\"item-01385\",\"item-01386\",\"item-01387\",\"item-01388\",\"item-01389\",\"item-01390\",\"item-01391\",\"item-01392\",\"item-01393\",\"item-01394\",\"item-01395\",\"item-01396\",\"item-01397\",\"item-01398\",\"item-01399\",\"item-01400\",\"item-01401\",\"item-01402\",\"item-01403\",\"item-01404\",\"item-01405\",\"item-01406\",\"item-01407\",\"item-01408\",\"item-01409\",\"item-01410\",\"item-01411\",\"item-01412\",\"item-01413\",\"item-01414\",\"item-01415\",\"item-01416\",\"item-01417\",\"item-01418\",\"item-01419\",\"item-01420\",\"item-01421\",\"item-01422\",\"item-01423\",\"item-01424\",\"item-01425\",\"item-01426\",\"item-01427\",\"item-01428\",\"item-01429\",\"item-01430\",\"item-01431\",\"item-01432\",\"item-01433\",\"item-01434\",\"item-01435\",\"item-01436\",\"item-01437\",\"item-01438\",\"item-01439\",\"item-01440\",\"item-01441\",\"item-01442\",\"item-01443\",\"item-01444\",\"item-01445\",\"item-01446\",\"item-01447\",\"item-01448\",\"item-01449\",\"item-01450\",\"item-01451\",\"item-01452\",\"item-01453\",\"item-01454\",\"item-01455\",\"item-01456\",\"item-01457\",\"item-01458\",\"item-01459\",\"item-01460\",\"item-01461\",\"item-01462\",\"item-01463\",\"item-01464\",\"item-01465\",\"item-01466\",\"item-01467\",\"item-01468\",\"item-01469\",\"item-01470\",\"item-01471\",\"item-01472\",\"item-01473\",\"item-01474\",\"item-01475\",\"item-01476\",\"item-01477\",\"item-01478\",\"item-01479\",\"item-01480\",\"item-01481\",\"item-01482\",\"item-01483\",\"item-01484\",\"item-01485\",\"item-01486\",\"item-01487\",\"item-01488\",\"item-01489\",\"item-01490\",\"item-01491\",\"item-01492\",\"item-01493\",\"item-01494\",\"item-01495\",\"item-01496\",\"item-01497\",\"item-01498\",\"item-01499\",\"item-01500\",\"item-01501\",\"item-01502\",\"item-01503\",\"item-01504\",\"item-01505\",\"item-01506\",\"item-01507\",\"item-01508\",\"item-01509\",\"item-01510\",\"item-01511\",\"item-01512\",\"item-01513\",\"item-01514\",\"item-01515\",\"item-01516\",\"item-01517\",\"item-01518\",\"item-01519\",\"item-01520\",\"item-01521\",\"item-01522\",\"item-01523\",\"item-01524\",\"item-01525\",\"item-01526\",\"item-01527\",\"item-01528\",\"item-01529\",\"item-01530\",\"item-01531\",\"item-01532\",\"item-01533\",\"item-01534\",\"item-01535\",\"item-01536\",\"item-01537\",\"item-01538\",\"item-01539\",\"item-01540\",\"item-01541\",\"item-01542\",\"item-01543\",\"item-01544\",\"item-01545\",\"item-01546\",\"item-01547\",\"item-01548\",\"item-01549\",\"item-01550\",\"item-01551\",\"item-01552\",\"item-01553\",\"item-01554\",\"item-01555\",\"item-01556\",\"item-01557\",\"item-01558\",\"item-01559\",\"item-01560\",\"item-01561\",\"item-01562\",\"item-01563\",\"item-01564\",\"item-01565\",\"item-01566\",\"item-01567\",\"item-01568\",\"item-01569\",\"item-01570\",\"item-01571\",\"item-01572\",\"item-01573\",\"item-01574\",\"item-01575\",\"item-01576\",\"item-01577\",\"item-01578\",\"item-01579\",\"item-01580\",\"item-01581\",\"item-01582\",\"item-01583\",\"item-01584\",\"item-01585\",\"item-01586\",\"item-01587\",\"item-01588\",\"item-01589\",\"item-01590\",\"item-01591\",\"item-01592\",\"item-01593\",\"item-01594\",\"item-01595\",\"item-01596\",\"item-01597\",\"item-01598\",\"item-01599\",\"item-01600\",\"item-01601\",\"item-01602\",\"item-01603\",\"item-01604\",\"item-01605\",\"item-01606\",\"item-01607\",\"item-01608\",\"item-01609\",\"item-01610\",\"item-01611\",\"item-01612\",\"item-01613\",\"item-01614\",\"item-01615\",\"item-01616\",\"item-01617\",\"item-01618\",\"item-01619\",\"item-01620\",\"item-01621\",\"item-01622\",\"item-01623\",\"item-01624\",\"item-01625\",\"item-01626\",\"item-01627\",\"item-01628\",\"item-01629\",\"item-01630\",\"item-01631\",\"item-01632\",\"item-01633\",\"item-01634\",\"item-01635\",\"item-01636\",\"item-01637\",\"item-01638\",\"item-01639\",\"item-01640\",\"item-01641\",\"item-01642\",\"item-01643\",\"item-01644\",\"item-01645\",\"item-01646\",\"item-01647\",\"item-01648\",\"item-01649\",\"item-01650\",\"item-01651\",\"item-01652\",\"item-01653\",\"item-01654\",\"item-01655\",\"item-01656\",\"item-01657\",\"item-01658\",\"item-01659\",\"item-01660\",\"item-01661\",\"item-01662\",\"item-01663\",\"item-01664\",\"item-01665\",\"item-01666\",\"item-01667\",\"item-01668\",\"item-01669\",\"item-01670\",\"item-01671\",\"item-01672\",\"item-01673\",\"item-01674\",\"item-01675\",\"item-01676\",\"item-01677\",\"item-01678\",\"item-01679\",\"item-01680\",\"item-01681\",\"item-01682\",\"item-01683\",\"item-01684\",\"item-01685\",\"item-01686\",\"item-01687\",\"item-01688\",\"item-01689\",\"item-01690\",\"item-01691\",\"item-01692\",\"item-01693\",\"item-01694\",\"item-01695\",\"item-01696\",\"item-01697\",\"item-01698\",\"item-01699\",\"item-01700\",\"item-01701\",\"item-01702\",\"item-01703\",\"item-01704\",\"item-01705\",\"item-01706\",\"item-01707\",\"item-01708\",\"item-01709\",\"item-01710\",\"item-01711\",\"item-01712\",\"item-01713\",\"item-01714\",\"item-01715\",\"item-01716\",\"item-01717\",\"item-01718\",\"item-01719\",\"item-01720\",\"item-01721\",\"item-01722\",\"item-01723\",\"item-01724\",\"item-01725\",\"item-01726\",\"item-01727\",\"item-01728\",\"item-01729\",\"item-01730\",\"item-01731\",\"item-01732\",\"item-01733\",\"item-01734\",\"item-01735\",\"item-01736\",\"item-01737\",\"item-01738\",\"item-01739\",\"item-01740\",\"item-01741\",\"item-01742\",\"item-01743\",\"item-01744\",\"item-01745\",\"item-01746\",\"item-01747\",\"item-01748\",\"item-01749\",\"item-01750\",\"item-01751\",\"item-01752\",\"item-01753\",\"item-01754\",\"item-01755\",\"item-01756\",\"item-01757\",\"item-01758\",\"item-01759\",\"item-01760\",\"item-01761\",\"item-01762\",\"item-01763\",\"item-01764\",\"item-01765\",\"item-01766\",\"item-01767\",\"item-01768\",\"item-01769\",\"item-01770\",\"item-01771\",\"item-01772\",\"item-01773\",\"item-01774\",\"item-01775\",\"item-01776\",\"item-01777\",\"item-01778\",\"item-01779\",\"item-01780\",\"item-01781\",\"item-01782\",\"item-01783\",\"item-01784\",\"item-01785\",\"item-01786\",\"item-01787\",\"item-01788\",\"item-01789\",\"item-01790\",\"item-01791\",\"item-01792\",\"item-01793\",\"item-01794\",\"item-01795\",\"item-01796\",\"item-01797\",\"item-01798\",\"item-01799\",\"item-01800\",\"item-01801\",\"item-01802\",\"item-01803\",\"item-01804\",\"item-01805\",\"item-01806\",\"item-01807\",\"item-01808\",\"item-01809\",\"item-01810\",\"item-01811\",\"item-01812\",\"item-01813\",\"item-01814\",\"item-01815\",\"item-01816\",\"item-01817\",\"item-01818\",\"item-01819\",\"item-01820\",\"item-01821\",\"item-01822\",\"item-01823\",\"item-01824\",\"item-01825\",\"item-01826\",\"item-01827\",\"item-01828\",\"item-01829\",\"item-01830\",\"item-01831\",\"item-01832\",\"item-01833\",\"item-01834\",\"item-01835\",\"item-01836\",\"item-01837\",\"item-01838\",\"item-01839\",\"item-01840\",\"item-01841\",\"item-01842\",\"item-01843\",\"item-01844\",\"item-01845\",\"item-01846\",\"item-01847\",\"item-01848\",\"item-01849\",\"item-01850\",\"item-01851\",\"item-01852\",\"item-01853\",\"item-01854\",\"item-01855\",\"item-01856\",\"item-01857\",\"item-01858\",\"item-01859\",\"item-01860\",\"item-01861\",\"item-01862\",\"item-01863\",\"item-01864\",\"item-01865\",\"item-01866\",\"item-01867\",\"item-01868\",\"item-01869\",\"item-01870\",\"item-01871\",\"item-01872\",\"item-01873\",\"item-01874\",\"item-01875\",\"item-01876\",\"item-01877\",\"item-01878\",\"item-01879\",\"item-01880\",\"item-01881\",\"item-01882\",\"item-01883\",\"item-01884\",\"item-01885\",\"item-01886\",\"item-01887\",\"item-01888\",\"item-01889\",\"item-01890\",\"item-01891\",\"item-01892\",\"item-01893\",\"item-01894\",\"item-01895\",\"item-01896\",\"item-01897\",\"item-01898\",\"item-01899\",\"item-01900\",\"item-01901\",\"item-01902\",\"item-01903\",\"item-01904\",\"item-01905\",\"item-01906\",\"item-01907\",\"item-01908\",\"item-01909\",\"item-01910\",\"item-01911\",\"item-01912\",\"item-01913\",\"item-01914\",\"item-01915\",\"item-01916\",\"item-01917\",\"item-01918\",\"item-01919\",\"item-01920\",\"item-01921\",\"item-01922\",\"item-01923\",\"item-01924\",\"item-01925\",\"item-01926\",\"item-01927\",\"item-01928\",\"item-01929\",\"item-01930\",\"item-01931\",\"item-01932\",\"item-01933\",\"item-01934\",\"item-01935\",\"item-01936\",\"item-01937\",\"item-01938\",\"item-01939\",\"item-01940\",\"item-01941\",\"item-01942\",\"item-01943\",\"item-01944\",\"item-01945\",\"item-01946\",\"item-01947\",\"item-01948\",\"item-01949\",\"item-01950\",\"item-01951\",\"item-01952\",\"item-01953\",\"item-01954\",\"item-01955\",\"item-01956\",\"item-01957\",\"item-01958\",\"item-01959\",\"item-01960\",\"item-01961\",\"item-01962\",\"item-01963\",\"item-01964\",\"item-01965\",\"item-01966\",\"item-01967\",\"item-01968\",\"item-01969\",\"item-01970\",\"item-01971\",\"item-01972\",\"item-01973\",\"item-01974\",\"item-01975\",\"item-01976\",\"item-01977\",\"item-01978\",\"item-01979\",\"item-01980\",\"item-01981\",\"item-01982\",\"item-01983\",\"item-01984\",\"item-01985\",\"item-01986\",\"item-01987\",\"item-01988\",\"item-01989\",\"item-01990\",\"item-01991\",\"item-01992\",\"item-01993\",\"item-01994\",\"item-01995\",\"item-01996\",\"item-01997\",\"item-01998\",\"item-01999\"]}", + "diverges": false + }, + { + "id": "tests__fixtures__adversarial__frontmatter__null-byte-value", + "sourcePath": "tests/fixtures/adversarial/frontmatter/null-byte-value.md", + "documentText": "---\ntitle: Has null byte\nweird: before\u0000after\nphase: 05\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"title\":\"Has null byte\",\"weird\":\"before\\u0000after\",\"phase\":\"05\"}", + "diverges": false + }, + { + "id": "tests__fixtures__adversarial__frontmatter__unclosed-block", + "sourcePath": "tests/fixtures/adversarial/frontmatter/unclosed-block.md", + "documentText": "---\ntitle: Unclosed Block\nphase: 03\n\nThis frontmatter has no closing --- line, and the body starts here without\na delimiter. Parser must NOT consume body lines as frontmatter keys.\n", + "expectedParse": "{}", + "diverges": false + }, + { + "id": "tests__fixtures__adversarial__frontmatter__unicode-keys-and-values", + "sourcePath": "tests/fixtures/adversarial/frontmatter/unicode-keys-and-values.md", + "documentText": "---\ntitle: 日本語のタイトル\n相: 04\nstatus: 🚧 in-flight\ntags: [α, β, γ]\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"title\":\"日本語のタイトル\",\"status\":\"🚧 in-flight\",\"tags\":[\"α\",\"β\",\"γ\"]}", + "diverges": true, + "justification": "Legacy dropped the 相 key (B3: its key regex was not Unicode-aware). Defect fix." + }, + { + "id": "tests__fixtures__representative__audit-uat__human-verification-frontmatter", + "sourcePath": "tests/fixtures/representative/audit-uat/human-verification-frontmatter.md", + "documentText": "---\nstatus: human_needed\nhuman_verification:\n - test: \"Confirm the widget renders correctly\"\n---\nstub body for golden fixture reproduction.\n", + "expectedParse": "{\"status\":\"human_needed\",\"human_verification\":[\"test: \\\"Confirm the widget renders correctly\"]}", + "diverges": true, + "justification": "Legacy's per-line flattening of the single-key human_verification list item left an unstripped opening quote character in the flattened value. js-yaml produces the clean combined string with the quoting resolved. Canonicalization (same per-line-flattening family as the summary-complex.md entry; this document has only one key per item so no named array property appears here — the divergence is the stray-quote artifact instead)." + } + ] +} diff --git a/tests/frontmatter-golden-parity.test.cjs b/tests/frontmatter-golden-parity.test.cjs new file mode 100644 index 000000000..72ec5a231 --- /dev/null +++ b/tests/frontmatter-golden-parity.test.cjs @@ -0,0 +1,202 @@ +'use strict'; + +/** + * Golden parity, hermetic (ADR-3473 §8.1, #3881, phase test-matrix §D — redesigned). + * + * The parser moved from a hand-rolled line scanner to vendored js-yaml. This suite proves + * nothing was silently changed by diffing the CURRENT parser's output against a golden + * captured from the LEGACY parser, independently of it — but unlike the original design, + * every fixture entry carries its OWN literal document text. Nothing here enumerates the + * tree, reads a tracked repo path, or shells out to git. That is deliberate: + * + * The original design keyed ~376 golden entries by tracked repo path (every git-tracked + * command file, workflow file, agent file, and doc under the repo's markdown surfaces). This repo + * merges roughly 21 commits/day; a 14-day sample measured 937 touches of exactly those + * covered files. Any PR that edits one of those files' frontmatter — adding an + * `argument-hint`, changing `allowed-tools`, editing a description — changed its parse and + * turned this suite red for a change that had nothing to do with the parser. The reflex fix + * was "regenerate the golden," which overwrites the very snapshot meant to catch a real + * regression — training people to blow away their own regression fixture on every unrelated + * touch. It was also a guaranteed merge-conflict magnet: the JSON was one big file every + * such PR would need to touch. Excluding `.changeset/**` (a prior, narrower fix) was not + * enough — the design itself was wrong. + * + * This redesign carries no tracked-path dependency at all: each entry stores a stable `id`, + * the literal `documentText` (shrunk from a real ddde001af-era corpus document — see + * provenance below), and an `expectedParse` captured from the legacy parser. A PR editing + * `commands/gsd/help.md` cannot affect this suite. The only thing that can ever conflict + * here is two PRs both editing the parser itself. + * + * Golden provenance (do NOT re-derive `expectedParse` from the current parser — that would + * make the comparison circular and prove nothing): `tests/fixtures/golden/ + * frontmatter-legacy-golden.json` was captured by compiling `src/frontmatter.cts` AS IT + * EXISTED AT COMMIT ddde001af (`git show ddde001af:src/frontmatter.cts`) standalone with + * tsc, against this repo's sibling support modules (`io.cts`, `shell-command-projection. + * cts`, `validate.cts`, `text-lines.cts`, `unusable-input.cts`, `pattern.cts`, `phase-id. + * cts`) — all byte-identical between ddde001af and HEAD (`git diff ddde001af..HEAD --stat` + * over those paths is empty), so borrowing the current sources of those pure helpers does + * not change what the legacy frontmatter parser itself computed. The legacy + * `extractFrontmatter` was run once, at capture time, over a representative sample of real + * ddde001af-era documents — every document then listed as a known divergence, every + * adversarial fixture under `tests/fixtures/adversarial/frontmatter/`, and a sample chosen + * for breadth across the distinct YAML shapes present in the corpus (block scalars, inline + * arrays, dashed lists, object-lists, nested maps, unicode keys, CRLF, empty values, + * comments, multi-doc-looking bodies) — each result was structurally serialized (see + * `tests/helpers/frontmatter-golden-serializer.cjs`, D2) and committed alongside the + * shrunk document text it was captured from. This is a one-time capture: the compiled + * legacy module is not part of this repo and is not re-run by the suite below, which only + * ever reads the committed golden. + * + * Shrinking: most entries store the frontmatter region plus a short stub body rather than a + * whole file. Every entry was verified AT CAPTURE TIME that both the current parser and + * the legacy parser produce the same structurally-serialized result over the original + * full document and the shrunk `documentText` — any candidate where either parser's + * output changed under shrinking was dropped rather than stored (0 of 51 candidates were + * dropped by this check in this capture; 1 additional file, the adversarial + * `unclosed-block.md` fixture, has no closing fence to truncate at and is stored + * unshrunk, verbatim). + * + * Divergences: entries with `diverges: true` are documented, deliberate legacy/current + * mismatches — see each entry's `justification`. They are asserted to STILL diverge + * (D3), never silently absorbed as a wildcard exemption. Non-diverging entries are + * asserted to match `expectedParse` exactly (D1). + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs'); +const { serializeFrontmatterValue } = require('./helpers/frontmatter-golden-serializer.cjs'); + +const GOLDEN_PATH = path.join(__dirname, 'fixtures', 'golden', 'frontmatter-legacy-golden.json'); +const golden = JSON.parse(fs.readFileSync(GOLDEN_PATH, 'utf8')); +const ENTRIES = golden.entries; + +/** Serialized current-parser output for one entry's embedded document text. */ +function currentSerialized(entry) { + return serializeFrontmatterValue(extractFrontmatter(entry.documentText)); +} + +describe('frontmatter golden parity, hermetic (ADR-3473 §8.1, #3881, §D)', () => { + test('fixture sanity: entries are present and every id is unique', () => { + assert.ok(Array.isArray(ENTRIES) && ENTRIES.length > 0, 'expected at least one golden entry'); + const ids = ENTRIES.map((e) => e.id); + assert.equal(new Set(ids).size, ids.length, 'duplicate entry id(s) in the golden fixture'); + console.log(`frontmatter-golden-parity: ${ENTRIES.length} hermetic golden entries`); + }); + + test('D1: every non-diverging entry matches its legacy-parser expectedParse exactly', () => { + const failures = []; + for (const entry of ENTRIES) { + if (entry.diverges) continue; + const actual = currentSerialized(entry); + if (actual !== entry.expectedParse) { + failures.push({ id: entry.id, expected: entry.expectedParse, actual }); + } + } + assert.deepEqual( + failures, + [], + 'undocumented parity divergence(s) — either the parser silently changed behavior, or ' + + `this is a deliberate divergence that must be marked diverges:true with a justification: ${JSON.stringify(failures, null, 2)}`, + ); + }); + + test('D1 sensor: the parity check is not vacuous — a mutated expectedParse is caught', () => { + const rel = ENTRIES.find((e) => !e.diverges); + assert.ok(rel, 'expected at least one non-diverging entry to sensor-check against'); + const actual = currentSerialized(rel); + const mutated = `${rel.expectedParse}__MUTATED__`; + assert.notEqual( + actual, + mutated, + 'sensor failed: a mutated expectedParse must not equal the real parser output', + ); + }); + + test('D3: every diverging entry actually diverges from its expectedParse today', () => { + const stillMatching = []; + for (const entry of ENTRIES) { + if (!entry.diverges) continue; + const actual = currentSerialized(entry); + if (actual === entry.expectedParse) stillMatching.push(entry.id); + } + assert.deepEqual( + stillMatching, + [], + 'entries marked diverges:true that no longer diverge must have diverges FLIPPED TO ' + + `false (it must never become a wildcard exemption): ${JSON.stringify(stillMatching)}`, + ); + }); + + test('D3: every diverging entry carries a non-empty justification', () => { + for (const entry of ENTRIES) { + if (!entry.diverges) continue; + assert.ok( + typeof entry.justification === 'string' && entry.justification.trim().length > 0, + `${entry.id} is marked diverges:true but has no justification`, + ); + } + }); + + test('hermeticity: fixture entries carry no filesystem path operands', () => { + // sourcePath is provenance-only metadata (never read at test time) — everything else on + // an entry must be inert data, not something that could be mistaken for a live path + // lookup. + for (const entry of ENTRIES) { + assert.equal(typeof entry.documentText, 'string'); + assert.equal(typeof entry.expectedParse, 'string'); + } + }); +}); + +describe('frontmatter golden serializer protects the gate itself (ADR-3473 §8.1, #3881, §D2)', () => { + test('D2: JSON.stringify silently drops a named property on an Array', () => { + // Reproduce the exact legacy shape: `k:\n - test: a\n other: b` parses to an + // array whose element 0 is "test: a" and which ALSO carries `.other === "b"`. + const namedArray = ['test: a']; + namedArray.other = 'b'; + + assert.equal( + JSON.stringify(namedArray), + '["test: a"]', + 'JSON.stringify must (still) silently drop the named array property — this pins the ' + + 'exact defect a JSON-based golden would have', + ); + }); + + test('D2: the structural serializer distinguishes a plain array from the same array carrying a named property', () => { + const plainArray = ['test: a']; + const namedArray = ['test: a']; + namedArray.other = 'b'; + + const plainSerialized = serializeFrontmatterValue(plainArray); + const namedSerialized = serializeFrontmatterValue(namedArray); + + assert.notEqual( + plainSerialized, + namedSerialized, + 'the serializer must distinguish ["test: a"] from the same array carrying .other = "b"', + ); + assert.ok( + namedSerialized.includes('"other"') && namedSerialized.includes('"b"'), + `named-property serialization must surface the property and its value; got: ${namedSerialized}`, + ); + // And it must not have been captured by dropping straight to JSON.stringify. + assert.notEqual(namedSerialized, JSON.stringify(namedArray)); + }); + + test('D2: the named-array-property shape is real, not hypothetical — it appears in the captured golden', () => { + // gsd-core__templates__summary-complex's `requires` entry is captured golden proof this + // shape occurs on a real, tracked document (verified live against the legacy parser + // while writing this suite): its expectedParse must carry a named-property tail. + const entry = ENTRIES.find((e) => e.id === 'gsd-core__templates__summary-complex'); + assert.ok(entry, 'expected the summary-complex divergence entry in the golden set'); + assert.ok( + /"requires":\["[^"]*"\]\{"provides":/.test(entry.expectedParse), + `expected the expectedParse for ${entry.id} to carry a named-property tail on 'requires'; got: ${entry.expectedParse}`, + ); + }); +}); diff --git a/tests/frontmatter-roundtrip.property.test.cjs b/tests/frontmatter-roundtrip.property.test.cjs new file mode 100644 index 000000000..01e9a155a --- /dev/null +++ b/tests/frontmatter-roundtrip.property.test.cjs @@ -0,0 +1,156 @@ +'use strict'; + +/** + * ADR-3473 §8.1's mandated gate (#3881, phase test-matrix §E): "a property-based + * `fast-check` round-trip test is the gate." + * + * E1: parse(serialize(x)) === parse(serialize(parse(serialize(x)))) — idempotence after + * one round-trip cycle, the invariant #3349 violated (a value that changed shape on + * a SECOND write, even though the first write looked fine). + * E2: the #3257 full-line-comment channel survives a parse -> reconstruct -> re-parse -> + * reconstruct cycle intact and in place. + * + * `serialize` = reconstructFrontmatter (object -> YAML body text, no `---` delimiters). + * `parse` = extractFrontmatter (delimited document text -> object), applied to the body + * wrapped back in `---` fences the way every real caller round-trips it. + * + * fast-check v4 in this repo: no `fc.stringOf`; arbitraries live at module scope (never + * built inside a `describe` body) — see tests/frontmatter.property.test.cjs for the + * established pattern this file follows. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fc = require('./helpers/fast-check-setup.cjs'); + +const { extractFrontmatter, reconstructFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs'); + +// ─── Arbitraries — realistic key/value shapes, not free-form noise ──────────────────── + +// A real top-level frontmatter key: lower-kebab/snake identifiers as seen across the +// corpus (phase, key-decisions, tech-stack, human_verification, ...). +const realisticKey = fc.stringMatching(/^[a-z][a-z0-9_-]{0,19}$/); + +// A plain scalar value shaped like real frontmatter content: printable, no raw control +// chars, no YAML-significant leading/trailing whitespace of its own (reconstructFrontmatter +// is exercised elsewhere, in tests/frontmatter.property.test.cjs, for the quoting/escaping +// contract itself — this file is about ROUND-TRIP IDENTITY, so values are drawn from the +// well-formed subset rather than re-testing escaping). +const realisticScalar = fc.stringMatching(/^[A-Za-z0-9][A-Za-z0-9 ._/:-]{0,60}$/); + +// A short list of realistic scalars, the shape `tags:`/`requires:`/`key-decisions:` use. +const realisticList = fc.array(realisticScalar, { minLength: 0, maxLength: 5 }); + +// One frontmatter object: a flat dictionary whose values are either a scalar or a list of +// scalars — the two value shapes reconstructFrontmatter's top-level branch documents. +const frontmatterObject = fc.dictionary( + realisticKey, + fc.oneof(realisticScalar, realisticList), + { minKeys: 1, maxKeys: 8 }, +); + +/** serialize -> wrap in fences -> parse, the shape every real caller round-trips through. */ +function roundTripParse(obj) { + const body = reconstructFrontmatter(obj); + return extractFrontmatter(`---\n${body}\n---\n`); +} + +describe('frontmatter round-trip idempotence — ADR-3473 §8.1 mandated gate (#3881, §E1)', () => { + test('property: parse(serialize(x)) is a fixed point of one more round-trip cycle', () => { + fc.assert( + fc.property(frontmatterObject, (x) => { + const once = roundTripParse(x); + const twice = roundTripParse(once); + assert.deepEqual( + twice, + once, + `round-trip is not idempotent for ${JSON.stringify(x)}: once=${JSON.stringify(once)} twice=${JSON.stringify(twice)}`, + ); + }), + ); + }); + + test('property: a single round-trip preserves every key and its scalar/list shape', () => { + fc.assert( + fc.property(frontmatterObject, (x) => { + const once = roundTripParse(x); + for (const [key, value] of Object.entries(x)) { + assert.ok(key in once, `key ${key} lost on round-trip`); + if (Array.isArray(value)) { + assert.ok(Array.isArray(once[key]), `key ${key} lost its list shape on round-trip`); + } else { + assert.equal(typeof once[key], 'string', `key ${key} lost its scalar shape on round-trip`); + } + } + }), + ); + }); + + test('sensor: the idempotence check is not vacuous — a mutated second pass is caught', () => { + // Prove the deepEqual comparison above can actually fail: fabricate a "second pass" + // that differs from the first and confirm the property-style check rejects it. + const once = roundTripParse({ phase: 'p1', tags: ['a', 'b'] }); + const mutatedTwice = { ...once, phase: `${once.phase}__MUTATED__` }; + assert.notDeepEqual(mutatedTwice, once, 'sensor failed: a mutated second pass must not equal the first'); + }); +}); + +describe('frontmatter round-trip preserves the #3257 comment channel (#3881, §E2)', () => { + // A column-0 `# text` comment line, restricted to characters that cannot themselves be + // mistaken for the next key line by extractCommentChannel's own matcher. + const commentText = fc.stringMatching(/^# [A-Za-z0-9 .,'-]{1,40}$/); + + test('property: a leading comment above a generated key survives a full round-trip cycle', () => { + fc.assert( + fc.property(realisticKey, realisticScalar, commentText, (key, value, comment) => { + const doc = `---\n${comment}\n${key}: ${JSON.stringify(value)}\n---\n`; + const extracted = extractFrontmatter(doc); + assert.equal(extracted[key], value, 'sanity: the key itself must parse before testing comment survival'); + + const reconstructed = reconstructFrontmatter(extracted); + assert.ok( + reconstructed.includes(comment), + `comment lost on first round-trip: ${JSON.stringify(reconstructed)}`, + ); + + // Full cycle: re-parse the reconstructed doc and reconstruct again — the comment + // must still be attached to the same key, not merely present anywhere. + const reExtracted = extractFrontmatter(`---\n${reconstructed}\n---\n`); + const reReconstructed = reconstructFrontmatter(reExtracted); + assert.ok( + reReconstructed.includes(comment), + `comment lost on second round-trip cycle: ${JSON.stringify(reReconstructed)}`, + ); + // The comment must still immediately precede its key (attached, not orphaned). + const idx = reReconstructed.indexOf(comment); + const keyIdx = reReconstructed.indexOf(`${key}:`); + assert.ok(keyIdx > idx, `comment did not stay attached ahead of its key: ${JSON.stringify(reReconstructed)}`); + }), + ); + }); + + test('property: a trailing comment after the last key survives a full round-trip cycle', () => { + fc.assert( + fc.property(realisticKey, realisticScalar, commentText, (key, value, comment) => { + const doc = `---\n${key}: ${JSON.stringify(value)}\n${comment}\n---\n`; + const extracted = extractFrontmatter(doc); + const reconstructed = reconstructFrontmatter(extracted); + assert.ok(reconstructed.includes(comment), `trailing comment lost: ${JSON.stringify(reconstructed)}`); + + const reExtracted = extractFrontmatter(`---\n${reconstructed}\n---\n`); + const reReconstructed = reconstructFrontmatter(reExtracted); + assert.ok( + reReconstructed.includes(comment), + `trailing comment lost on second cycle: ${JSON.stringify(reReconstructed)}`, + ); + }), + ); + }); + + test('sensor: comment-channel preservation is not vacuous — a document with no comment carries none forward', () => { + const doc = '---\nkey: value\n---\n'; + const extracted = extractFrontmatter(doc); + const reconstructed = reconstructFrontmatter(extracted); + assert.ok(!reconstructed.includes('#'), `expected no comment to appear from nowhere: ${JSON.stringify(reconstructed)}`); + }); +}); diff --git a/tests/frontmatter.test.cjs b/tests/frontmatter.test.cjs index 260e318c9..e98c4ab6e 100644 --- a/tests/frontmatter.test.cjs +++ b/tests/frontmatter.test.cjs @@ -1743,11 +1743,11 @@ describe('feat-3594: frontmatter parser preserves Unicode round-trip', () => { const content = loadFixture('unicode-keys-and-values.md'); const fm = extractFrontmatter(content); assert.equal(fm.title, '日本語のタイトル'); - // The parser's key regex is /^(\s*)([a-zA-Z0-9_-]+):.../ so non-ASCII - // keys (like `相:`) won't be captured. Pin that current behavior so - // a future broadening to allow Unicode keys is visible (and so the - // ASCII-only contract is asserted, not silently relied on). - assert.equal(fm['相'], undefined, 'parser currently only recognizes ASCII keys (regression guard)'); + // ADR-3473 §8.1: the vendored js-yaml parser has no ASCII-only key + // regex — non-ASCII keys (like `相:`) are recognized like any other + // YAML key. Pin the broadened behavior so a future regression back to + // ASCII-only keys is visible. + assert.equal(fm['相'], '04', 'parser must recognize non-ASCII keys (js-yaml has no ASCII-only key regex)'); // The status field has an emoji — must survive. assert.equal(fm.status, '🚧 in-flight'); // Inline array with Greek letters. @@ -2773,3 +2773,160 @@ describe('extractFrontmatter BOM tolerance (#2977)', () => { }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Post-#3881-review findings 3 and 4 (ADR-3473 §8.1) — prototype-chain-safe +// bracket reads/writes, and byte-vs-equivalence stability of the escaper. +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe, test } = require('node:test'); + const assert = require('node:assert/strict'); + const { extractFrontmatter, reconstructFrontmatter, escapeDoubleQuotedScalar } = require('../gsd-core/bin/lib/frontmatter.cjs'); + + describe('#3881 review finding 3: prototype-chain keys never crash extract/reconstruct', () => { + const hostileKeys = ['constructor', '__proto__', 'toString', 'valueOf', 'hasOwnProperty']; + + for (const key of hostileKeys) { + test(`a column-0-commented key named "${key}" round-trips without throwing`, () => { + const yaml = `---\n# comment for ${key}\n${key}: val-${key}\nz: control\n---\n`; + let fm; + assert.doesNotThrow(() => { fm = extractFrontmatter(yaml); }, `extractFrontmatter must not throw on key "${key}"`); + assert.strictEqual(fm[key], `val-${key}`, `key "${key}" must parse as its own value, not an inherited Object.prototype member`); + assert.strictEqual(fm.z, 'control'); + + let reconstructed; + assert.doesNotThrow(() => { reconstructed = reconstructFrontmatter(fm); }, `reconstructFrontmatter must not throw on key "${key}"`); + assert.ok(reconstructed.includes(`# comment for ${key}`), `comment above "${key}" must survive reconstruct`); + assert.ok(reconstructed.includes(`${key}: val-${key}`), `key "${key}" must survive reconstruct`); + + const roundtrip = extractFrontmatter(`---\n${reconstructed}\n---\n`); + assert.strictEqual(roundtrip[key], `val-${key}`, `key "${key}" must survive a full round-trip`); + }); + } + + test('all five hostile keys together in one document, each with its own comment', () => { + let yaml = '---\n'; + for (const k of hostileKeys) yaml += `# leading comment for ${k}\n${k}: v-${k}\n`; + yaml += '---\n'; + const fm = extractFrontmatter(yaml); + for (const k of hostileKeys) assert.strictEqual(fm[k], `v-${k}`); + const reconstructed = reconstructFrontmatter(fm); + const roundtrip = extractFrontmatter(`---\n${reconstructed}\n---\n`); + for (const k of hostileKeys) { + assert.ok(reconstructed.includes(`# leading comment for ${k}`), `comment for ${k} lost`); + assert.strictEqual(roundtrip[k], `v-${k}`, `${k} lost on round-trip`); + } + }); + }); + + describe('#3881 review finding 4: escapeDoubleQuotedScalar pins named-escape output, and equivalence-preserving round-trip', () => { + // Exact escaped-form pins: js-yaml's dump emits the YAML-named escape for each of these, + // not the old hand-rolled chain's hex/raw-literal form. Pinning both the exact escape AND + // the round-trip proves the new form is not merely "different" but semantically correct. + const cases = [ + { label: 'BEL', ch: '\x07', escaped: '\\a' }, + { label: 'NUL', ch: '\x00', escaped: '\\0' }, + { label: 'NEL', ch: '\x85', escaped: '\\N' }, + { label: 'NBSP', ch: ' ', escaped: '\\_' }, + { label: 'LINE SEPARATOR', ch: '
', escaped: '\\L' }, + { label: 'PARAGRAPH SEPARATOR', ch: '
', escaped: '\\P' }, + { label: 'BOM', ch: '', escaped: '\\uFEFF' }, + { label: 'lone high surrogate', ch: '\uD800', escaped: '\\uD800' }, + ]; + + for (const { label, ch, escaped } of cases) { + test(`${label} (U+${ch.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}) escapes to the exact pinned form and round-trips`, () => { + assert.strictEqual(escapeDoubleQuotedScalar(ch), escaped, `${label} must escape to ${JSON.stringify(escaped)}`); + + const reconstructed = reconstructFrontmatter({ weird: ch }); + let roundtrip; + assert.doesNotThrow(() => { roundtrip = extractFrontmatter(`---\n${reconstructed}\n---\n`); }, + `${label} must produce re-parseable YAML, not silently collapse to unparseable`); + assert.strictEqual(roundtrip.weird, ch, `${label} must round-trip to the exact same codepoint`); + }); + } + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Post-#3881-review finding 6: relocated from a standalone +// tests/feat-3594-parser-adversarial-frontmatter.test.cjs, created earlier on this branch +// under the false premise that no test owned the adversarial fixture corpus (it was already +// folded here by consolidation epic #1969, describe block 'folded:feat-3594-parser- +// adversarial-frontmatter' above). Folded rather than left standalone, per that epic's +// precedent — the genuinely NEW coverage this file added (fixture-ownership check, the +// anchor-alias-bomb fixtures, and the B1/B2 block-scalar rows) is preserved below; its +// duplicate-of-already-folded assertions (duplicate-keys/crlf/unclosed/null-byte/huge-bounded/ +// unicode) are not re-added a third time. +// ──────────────────────────────────────────────────────────────────────── +{ + const { test, describe: __foldDescribe2 } = require('node:test'); + const assert = require('node:assert/strict'); + const fs = require('node:fs'); + const path = require('node:path'); + const { extractFrontmatter, FRONTMATTER_UNPARSEABLE } = require('../gsd-core/bin/lib/frontmatter.cjs'); + + const FIXTURE_DIR2 = path.join(__dirname, 'fixtures', 'adversarial', 'frontmatter'); + function readFixture2(name) { + return fs.readFileSync(path.join(FIXTURE_DIR2, name), 'utf8'); + } + + __foldDescribe2('folded:feat-3594-fixture-ownership-and-block-scalar (relocated, #3881 review finding 6)', () => { + // Table-driven ownership: every fixture file present on disk (excluding README.md, which + // is documentation, not a fixture) must be exercised by at least one test in this file's + // adversarial-frontmatter describe blocks (this one, or the earlier folded one above). + // OWNED_ELSEWHERE lists fixtures the earlier fold already covers so this check does not + // demand a third copy of the same assertions. + const OWNED_ELSEWHERE = new Set([ + 'duplicate-keys.md', + 'crlf-mixed.md', + 'unclosed-block.md', + 'unicode-keys-and-values.md', + 'null-byte-value.md', + 'huge-bounded.md', + ]); + + test('every fixture on disk not already owned by the earlier fold has a matrix entry here', () => { + const onDisk = fs + .readdirSync(FIXTURE_DIR2) + .filter((name) => name.endsWith('.md') && name !== 'README.md') + .sort(); + const registeredHere = ['anchor-alias-bomb.md', 'anchor-alias-bomb-quoted.md']; + const unowned = onDisk.filter((name) => !OWNED_ELSEWHERE.has(name) && !registeredHere.includes(name)); + assert.deepEqual(unowned, [], `fixture(s) present on disk with no owning test anywhere: ${unowned.join(', ')}`); + }); + + test('anchor-alias-bomb.md: refused rather than expanded (ADR-3473 §8.1 consequence 6, row A8)', () => { + const parsed = extractFrontmatter(readFixture2('anchor-alias-bomb.md'), 'anchor-alias-bomb.md'); + assert.equal(Object.keys(parsed).length, 0); + assert.equal(parsed[FRONTMATTER_UNPARSEABLE], true); + assert.ok(Buffer.byteLength(JSON.stringify(parsed), 'utf8') < 1024); + }); + + test('anchor-alias-bomb-quoted.md: refused identically, even quoted-key-spelled (#3881 review, finding 1)', () => { + const parsed = extractFrontmatter(readFixture2('anchor-alias-bomb-quoted.md'), 'anchor-alias-bomb-quoted.md'); + assert.equal(Object.keys(parsed).length, 0); + assert.equal(parsed[FRONTMATTER_UNPARSEABLE], true); + assert.ok(Buffer.byteLength(JSON.stringify(parsed), 'utf8') < 1024); + }); + + const ADD_TESTS_PATH = path.join(__dirname, '..', 'commands', 'gsd', 'add-tests.md'); + + test('B1 blockScalarValueIsNotTheBlockIndicator: argument-instructions is the instruction text, not "|"', () => { + const content = fs.readFileSync(ADD_TESTS_PATH, 'utf8'); + const parsed = extractFrontmatter(content, ADD_TESTS_PATH); + const value = parsed['argument-instructions']; + assert.equal(typeof value, 'string'); + assert.notEqual(value, '|'); + assert.ok(value.length > 1, 'block scalar value must be the multi-line instruction body'); + assert.ok(value.includes('Parse the argument as a phase number'), 'block scalar value must retain the source instruction text'); + }); + + test('B2 blockScalarDoesNotInventATopLevelKey: parsing add-tests.md produces no phantom "Example" key', () => { + const content = fs.readFileSync(ADD_TESTS_PATH, 'utf8'); + const parsed = extractFrontmatter(content, ADD_TESTS_PATH); + assert.ok(!Object.prototype.hasOwnProperty.call(parsed, 'Example'), 'parser must not scrape a top-level "Example" key out of the block scalar body'); + }); + }); +} diff --git a/tests/frontmatter.unit.test.cjs b/tests/frontmatter.unit.test.cjs index 023674dc8..8c200beed 100644 --- a/tests/frontmatter.unit.test.cjs +++ b/tests/frontmatter.unit.test.cjs @@ -26,7 +26,7 @@ const { parseMustHavesBlock, FRONTMATTER_SCHEMAS, agentScalarNeedsDoubleQuoting, - escapeDoubleQuoted, + escapeDoubleQuotedScalar, } = require('../gsd-core/bin/lib/frontmatter.cjs'); // ─── extractFrontmatter ─────────────────────────────────────────────────────── @@ -211,21 +211,13 @@ describe('extractFrontmatter: inline arrays', () => { assert.deepEqual(result, { tags: ['a, b', 'c', 'd'] }); }); - test('consecutive commas (empty items filtered)', () => { - const result = extractFrontmatter('---\ntags: [a,,b]\n---'); - assert.deepEqual(result, { tags: ['a', 'b'] }); - }); - - test('whitespace-only items filtered', () => { - const result = extractFrontmatter('---\ntags: [ , ]\n---'); - assert.deepEqual(result, { tags: [] }); - }); - - test('opening bracket only becomes empty array/object', () => { - const result = extractFrontmatter('---\ntags: [\n---'); - assert.deepEqual(result, { tags: [] }); - assert.ok(Array.isArray(result.tags)); - }); + // `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', () => { @@ -250,10 +242,12 @@ describe('extractFrontmatter: dashed list arrays', () => { assert.deepEqual(result, { tags: ['single quoted'] }); }); - test('opening bracket followed by dashed list', () => { - const result = extractFrontmatter('---\ntags: [\n - a\n - b\n---'); - assert.deepEqual(result, { tags: ['a', 'b'] }); - }); + // `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', () => { @@ -1256,8 +1250,8 @@ describe('reconstructFrontmatter: strict-YAML round-trip (#1779)', () => { }); }); -// #3497 — escape amplification. `escapeDoubleQuoted` escapes `\`/`"`/control -// chars on every serialize (#1779), but `parseYamlRegion` only stripped the +// #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 @@ -1699,9 +1693,9 @@ describe('agentScalarNeedsDoubleQuoting: real-world values that must stay unquot } }); -// ─── escapeDoubleQuoted (#1779 / #3497) ──────────────────────────────────────── +// ─── escapeDoubleQuotedScalar (#1779 / #3497) ──────────────────────────────────────── -describe('escapeDoubleQuoted: exact output strings', () => { +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 @@ -1710,40 +1704,40 @@ describe('escapeDoubleQuoted: exact output strings', () => { // followed by the quote. const input = 'a' + '\\' + '"' + 'b'; const expected = 'a' + '\\'.repeat(3) + '"' + 'b'; - assert.equal(escapeDoubleQuoted(input), expected); + assert.equal(escapeDoubleQuotedScalar(input), expected); }); test('double quote alone', () => { - assert.equal(escapeDoubleQuoted('"'), '\\"'); + assert.equal(escapeDoubleQuotedScalar('"'), '\\"'); }); test('newline alone', () => { - assert.equal(escapeDoubleQuoted('\n'), '\\n'); + assert.equal(escapeDoubleQuotedScalar('\n'), '\\n'); }); test('tab alone', () => { - assert.equal(escapeDoubleQuoted('\t'), '\\t'); + assert.equal(escapeDoubleQuotedScalar('\t'), '\\t'); }); test('carriage return alone', () => { - assert.equal(escapeDoubleQuoted('\r'), '\\r'); + assert.equal(escapeDoubleQuotedScalar('\r'), '\\r'); }); test('a C0 control char (0x01) becomes lowercase zero-padded \\xHH', () => { - assert.equal(escapeDoubleQuoted('\u0001'), '\\x01'); + assert.equal(escapeDoubleQuotedScalar('\u0001'), '\\x01'); }); test('DEL (0x7f) becomes \\x7f', () => { - assert.equal(escapeDoubleQuoted('\u007f'), '\\x7f'); + assert.equal(escapeDoubleQuotedScalar('\u007f'), '\\x7f'); }); test('plain string with no specials is returned unchanged', () => { - assert.equal(escapeDoubleQuoted('plain'), 'plain'); + 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(escapeDoubleQuoted(input), expected); + assert.equal(escapeDoubleQuotedScalar(input), expected); }); }); diff --git a/tests/helpers/frontmatter-golden-serializer.cjs b/tests/helpers/frontmatter-golden-serializer.cjs new file mode 100644 index 000000000..03a43b5db --- /dev/null +++ b/tests/helpers/frontmatter-golden-serializer.cjs @@ -0,0 +1,54 @@ +'use strict'; + +/** + * Structural serializer for the frontmatter golden-parity gate (ADR-3473 §8.1, #3881, row D2). + * + * The legacy line-scanner parser can return an Array carrying a NAMED own-enumerable + * property alongside its indexed elements — e.g. `k:\n - test: a\n other: b` yields + * an array whose `Object.keys()` is `["0","other"]` (index "0" holds `"test: a"`, and + * `"other"` holds `"b"` as a non-index property). `JSON.stringify` silently drops any + * non-index own property of an array, so a JSON-based golden would compare only the + * `["test: a"]` shape and never notice `.other` — a parity gate that cannot see the exact + * shape it exists to protect (#3427's failure mode, reproduced inside the gate meant to + * prevent it: see D2 in tests/frontmatter-golden-parity.test.cjs). + * + * This serializer instead walks every own enumerable property (`Object.keys`, which + * includes non-index array properties) and folds arrays into a `[items]{named}` form so + * the named-property tail is always represented in the output string. Symbol-keyed + * properties (e.g. the #3257 comment channel, the unparseable marker) are deliberately + * excluded: Object.keys never returns them, so they were already invisible to the legacy + * line-scanner's output shape this golden pins. + * + * CORRECTED (post-#3881-review, finding 7): object keys were previously SORTED here, + * "for determinism" — but ADR-3473 §8.1's stated contract is key-ORDER parity between the + * legacy scanner and js-yaml, and a sorted serialization cannot see a key-order regression: + * two objects with the SAME keys in DIFFERENT orders serialize identically once sorted, so + * D1 was structurally blind to the exact invariant the ADR names. `Object.keys` already + * returns string keys in a well-defined, deterministic order (insertion order, with the + * usual integer-index-first carve-out that does not apply to frontmatter's non-numeric + * keys) — sorting was never needed for determinism, only for (accidentally) hiding order. + * Now order-preserving: `Object.keys(value)` is used as-is, for both objects and an + * array's named (non-index) properties. + */ +function serializeFrontmatterValue(value) { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (Array.isArray(value)) { + const items = value.map((item) => serializeFrontmatterValue(item)); + const namedKeys = Object.keys(value).filter((k) => !/^\d+$/.test(k)); + let out = `[${items.join(',')}]`; + if (namedKeys.length) { + out += `{${namedKeys + .map((k) => `${JSON.stringify(k)}:${serializeFrontmatterValue(value[k])}`) + .join(',')}}`; + } + return out; + } + if (typeof value === 'object') { + const keys = Object.keys(value); + return `{${keys.map((k) => `${JSON.stringify(k)}:${serializeFrontmatterValue(value[k])}`).join(',')}}`; + } + return JSON.stringify(value); +} + +module.exports = { serializeFrontmatterValue }; diff --git a/tests/lint-vendored-deps-manifest.test.cjs b/tests/lint-vendored-deps-manifest.test.cjs new file mode 100644 index 000000000..6277ffc72 --- /dev/null +++ b/tests/lint-vendored-deps-manifest.test.cjs @@ -0,0 +1,270 @@ +'use strict'; + +/** + * ADR-3473 §8.1 (#3881, phase test-matrix §G — packaging). + * + * scripts/lint-vendored-deps.cjs used to be a single hand-rolled check hardcoded to + * `re2js`; #3881 generalized it to a table-driven VENDORED manifest so adding js-yaml did + * not need a second hardcoded block. This suite pins: + * G1 the js-yaml row's byte-compare actually matches node_modules today. + * G2 all four checks the original hand-rolled re2js guard ran (see + * scripts/lint-vendored-deps.cjs's `checkRow`: .cjs drift, .d.cts drift, src/vendor/ + * twin drift, version-pin drift) still fire, asserted against re2js's CURRENT + * behavior — not re-derived from the new manifest, which would validate the + * refactor against its own output and prove nothing about drift. + * G3 the hand-authored js-yaml type twin (no upstream to compare against) is + * deliberately excluded from the byte-compare rather than silently skipped by + * accident, and is pinned by a direct assertion on its declared surface instead. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { + VENDORED, + compareFiles, + checkRow, + stripRangeOperator, + declaredValueExports, + checkHandAuthoredTwin, + resolvePath, +} = require('../scripts/lint-vendored-deps.cjs'); + +const REPO_ROOT = path.join(__dirname, '..'); + +function jsYamlRow() { + const row = VENDORED.find((r) => r.name === 'js-yaml'); + assert.ok(row, 'expected a js-yaml row in VENDORED'); + return row; +} + +function re2jsRow() { + const row = VENDORED.find((r) => r.name === 're2js'); + assert.ok(row, 'expected a re2js row in VENDORED'); + return row; +} + +describe('resolvePath: absolute-input safety does not change repo-relative resolution', () => { + test('a repo-relative input still resolves under REPO_ROOT (unchanged behavior)', () => { + const row = jsYamlRow(); + assert.equal(resolvePath(row.vendoredCjs), path.join(REPO_ROOT, row.vendoredCjs)); + }); + + test('an absolute input is returned as-is, not re-joined onto REPO_ROOT', () => { + const absPath = path.join(os.tmpdir(), 'resolve-path-absolute-sensor.cjs'); + assert.equal(resolvePath(absPath), absPath); + // Sensor: confirm this is not vacuous — path.join(ROOT, absPath) (the + // pre-fix behavior) would NOT equal absPath, since joining an absolute + // second segment onto ROOT is not the identity operation on either + // POSIX or Windows. + assert.notEqual(path.join(REPO_ROOT, absPath), absPath); + }); +}); + +describe('G1: vendored js-yaml matches node_modules via the generalized manifest', () => { + test('the js-yaml row byte-compares clean against node_modules today', () => { + const findings = checkRow(jsYamlRow()); + assert.deepEqual(findings, [], `unexpected drift findings for js-yaml: ${JSON.stringify(findings)}`); + }); + + test('sensor: compareFiles is not vacuous — it reports drift against a deliberately mutated copy', () => { + const row = jsYamlRow(); + const upstreamAbs = path.join(REPO_ROOT, row.upstreamCjs); + const tmpFile = path.join(os.tmpdir(), `js-yaml-mutated-${process.pid}-${Date.now()}.cjs`); + const original = fs.readFileSync(upstreamAbs, 'utf8'); + fs.writeFileSync(tmpFile, `${original}\n// mutated for test\n`); + try { + // tmpFile is passed ABSOLUTE, not relativized against REPO_ROOT — a + // temp dir can live on a different drive than the repo checkout + // (observed on windows-latest CI), where path.relative() cannot + // express a relative traversal and silently returns the absolute + // path unchanged, defeating compareFiles's `path.join(ROOT, rel)` + // resolution. compareFiles/resolvePath must accept an absolute path + // as-is regardless of platform or drive. + const drift = compareFiles(row.vendoredCjs, tmpFile); + assert.ok(drift, 'expected compareFiles to report drift against a mutated copy, got null'); + assert.ok(drift.includes('!='), `expected a byte-length mismatch description, got: ${drift}`); + } finally { + fs.unlinkSync(tmpFile); + } + }); +}); + +describe('G2: all four original re2js checks still fire after the manifest refactor', () => { + test('fresh state: re2js has zero findings today (sanity baseline before mutating)', () => { + const findings = checkRow(re2jsRow()); + assert.deepEqual(findings, [], `expected re2js to be fresh; findings: ${JSON.stringify(findings)}`); + }); + + test('check 1 (cjs drift) fires against a mutated vendoredCjs copy', () => { + const row = re2jsRow(); + const vendoredAbs = path.join(REPO_ROOT, row.vendoredCjs); + const tmpFile = path.join(os.tmpdir(), `re2js-vendored-mutated-${process.pid}-${Date.now()}.cjs`); + fs.writeFileSync(tmpFile, `${fs.readFileSync(vendoredAbs, 'utf8')}\n// mutated`); + try { + // Absolute tmpFile, not relativized — see the js-yaml sensor test above + // for why: cross-drive path.relative() on Windows returns the absolute + // path unchanged, which is exactly the shape that must still resolve. + const mutatedRow = { ...row, vendoredCjs: tmpFile }; + const findings = checkRow(mutatedRow); + assert.ok( + findings.some((f) => f.includes('!=')), + `expected a cjs-drift finding, got: ${JSON.stringify(findings)}`, + ); + } finally { + fs.unlinkSync(tmpFile); + } + }); + + test('check 2 (d.cts drift) fires against a mutated vendoredDts copy', () => { + const row = re2jsRow(); + assert.ok(row.vendoredDts, 're2js is expected to carry a vendoredDts for this check to apply'); + const vendoredDtsAbs = path.join(REPO_ROOT, row.vendoredDts); + const tmpFile = path.join(os.tmpdir(), `re2js-dts-mutated-${process.pid}-${Date.now()}.d.cts`); + fs.writeFileSync(tmpFile, `${fs.readFileSync(vendoredDtsAbs, 'utf8')}\n// mutated`); + try { + // Absolute tmpFile — same cross-drive rationale as above. + const mutatedRow = { ...row, vendoredDts: tmpFile }; + const findings = checkRow(mutatedRow); + assert.ok( + findings.some((f) => f.includes('!=')), + `expected a d.cts-drift finding, got: ${JSON.stringify(findings)}`, + ); + } finally { + fs.unlinkSync(tmpFile); + } + }); + + test('check 3 (src/vendor twin drift) fires against a mutated srcTwin copy', () => { + const row = re2jsRow(); + assert.ok(row.srcTwin, 're2js is expected to carry a srcTwin for this check to apply'); + const srcTwinAbs = path.join(REPO_ROOT, row.srcTwin); + const tmpFile = path.join(os.tmpdir(), `re2js-srctwin-mutated-${process.pid}-${Date.now()}.d.cts`); + fs.writeFileSync(tmpFile, `${fs.readFileSync(srcTwinAbs, 'utf8')}\n// mutated`); + try { + // Absolute tmpFile — same cross-drive rationale as above. + const mutatedRow = { ...row, srcTwin: tmpFile }; + const findings = checkRow(mutatedRow); + assert.ok( + findings.some((f) => f.includes('!=')), + `expected a src/vendor-twin-drift finding, got: ${JSON.stringify(findings)}`, + ); + } finally { + fs.unlinkSync(tmpFile); + } + }); + + test('check 4 (version-pin drift) fires when the row name has no package.json pin', () => { + const row = re2jsRow(); + const mutatedRow = { ...row, name: 'a-package-that-is-not-pinned-anywhere' }; + const findings = checkRow(mutatedRow); + assert.ok( + findings.some((f) => f.includes('devDependencies') && f.includes('is missing')), + `expected a missing-pin finding, got: ${JSON.stringify(findings)}`, + ); + }); + + test('check 4 (version-pin drift): stripRangeOperator mismatch is what the real check compares', () => { + // checkRow reads package.json/node_modules directly and cannot be redirected, so this + // exercises the exact comparison predicate checkRow applies + // (stripRangeOperator(pinned) !== installed.version) against a synthetic mismatch, + // proving the predicate itself can fail rather than only ever reading true. + assert.equal(stripRangeOperator('^5.9.9') === '5.9.0', false, 'a genuine version mismatch must not compare equal'); + assert.equal(stripRangeOperator('^5.9.0') === '5.9.0', true, 'a matching version must compare equal'); + }); +}); + +describe('G3: the hand-authored js-yaml type twin is excluded from byte-compare, and pinned by test', () => { + test('the js-yaml row is declared hand-authored with no upstream twin to compare', () => { + const row = jsYamlRow(); + assert.equal(row.twinKind, 'hand-authored'); + assert.equal(row.upstreamDts, null, 'js-yaml ships no upstream .d.ts to compare against'); + assert.equal(row.vendoredDts, null, 'there is no bin-side .d.cts twin for js-yaml'); + assert.equal(row.srcTwin, 'src/vendor/js-yaml.d.cts'); + }); + + test('sensor: a byte-compare-neutral mutation (e.g. a trailing comment) produces NO drift finding — the BYTE-COMPARE exclusion is real, not accidental', () => { + const row = jsYamlRow(); + const srcTwinAbs = path.join(REPO_ROOT, row.srcTwin); + const original = fs.readFileSync(srcTwinAbs, 'utf8'); + fs.writeFileSync(srcTwinAbs, `${original}\n// mutated for test — must not be flagged\n`); + try { + const findings = checkRow(row); + assert.deepEqual( + findings, + [], + `hand-authored twin must be excluded from byte-compare; unexpected findings: ${JSON.stringify(findings)}`, + ); + } finally { + fs.writeFileSync(srcTwinAbs, original); + } + }); + + test('#3881 review, finding 4: srcTwin is NOT dead for a hand-authored row — a declared export the runtime does not have IS caught', () => { + // Before the fix, `srcTwin` was read only inside the `twinKind === 'upstream-verbatim'` + // branch; for a hand-authored row nothing ever consulted it, which is exactly how the + // js-yaml.d.cts docblock could drift from runtime reality (finding 2) unnoticed. + const row = jsYamlRow(); + const srcTwinAbs = path.join(REPO_ROOT, row.srcTwin); + const original = fs.readFileSync(srcTwinAbs, 'utf8'); + fs.writeFileSync( + srcTwinAbs, + `${original}\nexport function thisExportDoesNotExistAtRuntime(): void;\n`, + ); + try { + const findings = checkRow(row); + assert.ok( + findings.some((f) => f.includes('thisExportDoesNotExistAtRuntime')), + `expected a declared-export-not-at-runtime finding, got: ${JSON.stringify(findings)}`, + ); + } finally { + fs.writeFileSync(srcTwinAbs, original); + } + }); + + test('checkHandAuthoredTwin: fresh state is clean for the real js-yaml twin', () => { + assert.deepEqual(checkHandAuthoredTwin(jsYamlRow()), []); + }); + + test('declaredValueExports: extracts function/const/class exports, ignores type/interface exports', () => { + const src = [ + 'export interface Foo { x: number; }', + 'export type Bar = string;', + 'export function realFn(): void;', + 'export const REAL_CONST: string;', + 'export class RealClass {}', + ].join('\n'); + assert.deepEqual(declaredValueExports(src), ['realFn', 'REAL_CONST', 'RealClass']); + }); + + test('contrast: the SAME mutation on an upstream-verbatim row (re2js) IS caught — proving the exclusion is deliberate', () => { + const row = re2jsRow(); + const srcTwinAbs = path.join(REPO_ROOT, row.srcTwin); + const original = fs.readFileSync(srcTwinAbs, 'utf8'); + fs.writeFileSync(srcTwinAbs, `${original}\n// mutated for test — must be flagged\n`); + try { + const findings = checkRow(row); + assert.ok(findings.length > 0, 'expected the upstream-verbatim row to catch the same mutation the hand-authored row ignores'); + } finally { + fs.writeFileSync(srcTwinAbs, original); + } + }); + + test("js-yaml.d.cts's declared surface is pinned (no upstream to byte-diff, so pin by contract instead)", () => { + const content = fs.readFileSync(path.join(REPO_ROOT, 'src/vendor/js-yaml.d.cts'), 'utf8'); + // The declared surface is deliberately narrow (ADR-3473 §8.1: only what the FAILSAFE + // read/write path needs). Pin each declared export by name. + assert.match(content, /export function load\(/, 'load export missing'); + assert.match(content, /export function dump\(/, 'dump export missing'); + assert.match(content, /export const FAILSAFE_SCHEMA:/, 'FAILSAFE_SCHEMA export missing'); + assert.match(content, /export class YAMLException/, 'YAMLException export missing'); + // Deliberately NOT declared — anchors/aliases/custom types/loadAll are unreachable + // from typed code through this twin (the security posture this twin encodes). Check + // for an actual export statement, not just the word (which legitimately appears in + // this file's own prose explaining the exclusion). + assert.doesNotMatch(content, /export function loadAll\(/, 'loadAll must stay undeclared per the narrowed surface'); + }); +}); diff --git a/tests/mutation-matrix-ratchet.test.cjs b/tests/mutation-matrix-ratchet.test.cjs index 51960461e..94dab8fba 100644 --- a/tests/mutation-matrix-ratchet.test.cjs +++ b/tests/mutation-matrix-ratchet.test.cjs @@ -191,13 +191,13 @@ describe('mutation-matrix ratchet: guard detects missing minScore', () => { // The assertion "every baseline module still exists in COVERED" enforces the // reverse: removing a module from COVERED also requires updating the baseline. const RATCHET_BASELINE = { - 'context-utilization': 80, - 'context-composer': 66, // #2929: extracted from prompt-budget; same floor as prompt-budget - 'prompt-budget': 66, // CI 68.33% 2026-06-14; was 90 (timeout-inflated local) + 'context-utilization': 91, // CI run 33012034388 (2026-08-25): measured 92.31%; floor(92.31)-1 + 'context-composer': 78, // CI run 33012034388 (2026-08-25): measured 79.92%; floor(79.92)-1 + 'prompt-budget': 87, // CI run 33012034388 (2026-08-25): measured 88.95%; floor(88.95)-1 'frontmatter': 65, // #3706: raised from 62; measured 66.67 on PR 3867 'adr-parser': 68, - 'config-schema': 52, // CI 54.55% 2026-06-14; was 68 (timeout-inflated local) - 'active-workstream-store': 80, + 'config-schema': 74, // CI run 33012034388 (2026-08-25): measured 75.51%; floor(75.51)-1 + 'active-workstream-store': 86, // CI run 33012034388 (2026-08-25): measured 87.42%; floor(87.42)-1 'core-utils': 75, 'planning-inspect': 56, // CI run 32392791843: 57.03% (unit shard); ratchet candidate vs TARGET 80 'plan-document': 75, // CI run 32392791843: 76.58% (unit shard) diff --git a/tests/mutation-score-ratchet.test.cjs b/tests/mutation-score-ratchet.test.cjs new file mode 100644 index 000000000..92bd72634 --- /dev/null +++ b/tests/mutation-score-ratchet.test.cjs @@ -0,0 +1,191 @@ +'use strict'; + +/** + * tests/mutation-score-ratchet.test.cjs + * + * Regression net for scripts/check-mutation-score-ratchet.cjs (#3881 + * follow-up, mutation-matrix piece 3: "the floor must ratchet up, not sit + * where it is forever"). Drives the pure `evaluateRatchet` against boundary + * inputs, then proves the CLI end-to-end against a synthetic Stryker `json` + * reporter fixture: FAILS when the achieved score clears the floor by more + * than the documented slack, PASSES when raised (or when within slack). + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const fs = require('node:fs'); +const os = require('node:os'); + +const { + RATCHET_SLACK, + evaluateRatchet, + extractAchievedScore, +} = require('../scripts/check-mutation-score-ratchet.cjs'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { createTempDir, cleanup } = require('./helpers.cjs'); +const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + +const SCRIPT = path.resolve(__dirname, '..', 'scripts', 'check-mutation-score-ratchet.cjs'); +const REPO_ROOT = path.resolve(__dirname, '..'); + +describe('evaluateRatchet: pure boundary behaviour', () => { + test(`achieved exactly floor + ${RATCHET_SLACK} does NOT ratchet (boundary, inclusive)`, () => { + const { shouldRatchet } = evaluateRatchet(65 + RATCHET_SLACK, 65); + assert.equal(shouldRatchet, false); + }); + + test(`achieved floor + ${RATCHET_SLACK} + 0.01 DOES ratchet (just past the boundary)`, () => { + const { shouldRatchet, suggestedFloor } = evaluateRatchet(65 + RATCHET_SLACK + 0.01, 65); + assert.equal(shouldRatchet, true); + assert.equal(suggestedFloor, Math.floor(65 + RATCHET_SLACK + 0.01) - 1); + }); + + test('achieved below floor does NOT ratchet (a floor breach is Stryker\'s own MUTATION_BREAK problem, not this script\'s)', () => { + const { shouldRatchet } = evaluateRatchet(40, 65); + assert.equal(shouldRatchet, false); + }); + + test('achieved equal to floor does NOT ratchet', () => { + const { shouldRatchet } = evaluateRatchet(65, 65); + assert.equal(shouldRatchet, false); + }); + + test('suggestedFloor follows floor(achieved) - 1 exactly (this file\'s own documented convention)', () => { + const { suggestedFloor } = evaluateRatchet(92.7, 60); + assert.equal(suggestedFloor, 91); + }); +}); + +describe('extractAchievedScore: Stryker json-reporter document', () => { + test('computes the same score Stryker itself would report for a mixed killed/survived set', () => { + const report = { + schemaVersion: '1.0', + thresholds: { high: 80, low: 60 }, + files: { + 'foo.js': { + language: 'javascript', + source: 'x', + mutants: [ + { id: '1', mutatorName: 'a', status: 'Killed', location: { start: { line: 1, column: 1 }, end: { line: 1, column: 2 } } }, + { id: '2', mutatorName: 'a', status: 'Killed', location: { start: { line: 1, column: 1 }, end: { line: 1, column: 2 } } }, + { id: '3', mutatorName: 'a', status: 'Killed', location: { start: { line: 1, column: 1 }, end: { line: 1, column: 2 } } }, + { id: '4', mutatorName: 'a', status: 'Survived', location: { start: { line: 1, column: 1 }, end: { line: 1, column: 2 } } }, + ], + }, + }, + }; + assert.equal(extractAchievedScore(report), 75); + }); + + test('throws when the report has no scoreable mutants (empty files map — a wiring bug, never a 0% score)', () => { + assert.throws(() => extractAchievedScore({ schemaVersion: '1.0', thresholds: {}, files: {} }), /no scoreable mutants/); + }); +}); + +// ── CLI end-to-end: fail on a planted over-floor score, pass when the floor is raised ─── +// SYNTHETIC_FLOOR is a fixture value this test file owns outright — never a real module's +// minScore from scripts/mutation-matrix.cjs. That real config ratchets UP as modules improve +// (exactly what this script's own CLI enforces), so a row that hardcodes a real floor breaks +// every time the ratchet does its job (see config-schema: 52 -> 74 by commit 973321541, which +// broke this file's previous PLANTED/RESTORED rows). Building a synthetic `--matrix` fixture +// with a floor this test controls makes the rows indifferent to any real module's floor moving. +const SYNTHETIC_MODULE = 'synthetic-ratchet-fixture'; +const SYNTHETIC_FLOOR = 65; + +function withMatrixFixture(floor, fn) { + const dir = createTempDir('mutation-score-ratchet-matrix-'); + const matrixPath = path.join(dir, 'fixture-mutation-matrix.cjs'); + fs.writeFileSync( + matrixPath, + `'use strict';\nmodule.exports = { COVERED: { '${SYNTHETIC_MODULE}': { minScore: ${floor} } } };\n`, + 'utf8' + ); + try { + return fn(matrixPath); + } finally { + cleanup(dir); + } +} + +function withReportFixture(mutationScore, fn) { + // Build a Stryker json-reporter-shaped document whose achieved score is EXACTLY + // `mutationScore` via N killed + (100 - N) survived out of 100 mutants — avoids floating + // point ambiguity in the fixture itself. + const killed = Math.round(mutationScore); + const mutants = []; + for (let i = 0; i < 100; i++) { + mutants.push({ + id: String(i), + mutatorName: 'a', + status: i < killed ? 'Killed' : 'Survived', + location: { start: { line: 1, column: 1 }, end: { line: 1, column: 2 } }, + }); + } + const report = { + schemaVersion: '1.0', + thresholds: { high: 80, low: 60 }, + files: { 'foo.js': { language: 'javascript', source: 'x', mutants } }, + }; + const dir = createTempDir('mutation-score-ratchet-test-'); + const reportPath = path.join(dir, 'mutation.json'); + fs.writeFileSync(reportPath, JSON.stringify(report), 'utf8'); + try { + return fn(reportPath); + } finally { + cleanup(dir); + } +} + +describe('check-mutation-score-ratchet CLI: end-to-end fail-then-pass', () => { + test(`PLANTED: achieved score far above a synthetic module's floor (${SYNTHETIC_FLOOR}) FAILS the ratchet`, () => { + withMatrixFixture(SYNTHETIC_FLOOR, (matrixPath) => { + withReportFixture(SYNTHETIC_FLOOR + RATCHET_SLACK + 10, (reportPath) => { + const result = runNode( + [SCRIPT, '--module', SYNTHETIC_MODULE, '--report', reportPath, '--matrix', matrixPath], + { cwd: REPO_ROOT, timeoutMs: PROBE_TIMEOUT_MS } + ); + assert.notEqual(result.exitCode, 0, `expected nonzero exit; stderr: ${result.stderr}`); + assert.match(result.stderr, /raise the floor/); + assert.match(result.stderr, new RegExp(SYNTHETIC_MODULE)); + }); + }); + }); + + test('RESTORED: an achieved score within slack of the same synthetic floor PASSES', () => { + withMatrixFixture(SYNTHETIC_FLOOR, (matrixPath) => { + withReportFixture(SYNTHETIC_FLOOR + 1, (reportPath) => { + const result = runNode( + [SCRIPT, '--module', SYNTHETIC_MODULE, '--report', reportPath, '--matrix', matrixPath], + { cwd: REPO_ROOT, timeoutMs: PROBE_TIMEOUT_MS } + ); + assert.equal(result.exitCode, 0, `expected exit 0; stderr: ${result.stderr}`); + assert.match(result.stdout, /ok mutation-score-ratchet/); + }); + }); + }); + + test('an unknown --module exits nonzero with a clear message', () => { + withMatrixFixture(SYNTHETIC_FLOOR, (matrixPath) => { + withReportFixture(90, (reportPath) => { + const result = runNode( + [SCRIPT, '--module', 'does-not-exist', '--report', reportPath, '--matrix', matrixPath], + { cwd: REPO_ROOT, timeoutMs: PROBE_TIMEOUT_MS } + ); + assert.notEqual(result.exitCode, 0); + assert.match(result.stderr, /unknown module/); + }); + }); + }); + + test('a missing report file exits nonzero with a clear message', () => { + withMatrixFixture(SYNTHETIC_FLOOR, (matrixPath) => { + const result = runNode( + [SCRIPT, '--module', SYNTHETIC_MODULE, '--report', path.join(os.tmpdir(), 'does-not-exist-mutation.json'), '--matrix', matrixPath], + { cwd: REPO_ROOT, timeoutMs: PROBE_TIMEOUT_MS } + ); + assert.notEqual(result.exitCode, 0); + assert.match(result.stderr, /report not found/); + }); + }); +}); diff --git a/tests/mutation-test-derivation-drift.test.cjs b/tests/mutation-test-derivation-drift.test.cjs new file mode 100644 index 000000000..171ffaa3b --- /dev/null +++ b/tests/mutation-test-derivation-drift.test.cjs @@ -0,0 +1,93 @@ +'use strict'; + +/** + * tests/mutation-test-derivation-drift.test.cjs + * + * Regression net for scripts/lint-mutation-test-derivation-drift.cjs (#3881 + * follow-up, mutation-matrix piece 2). Drives the guard's pure `findDrift` + * against a synthetic COVERED map so this test never depends on the real + * tests/ tree (hermetic, and immune to future test-file churn). + * + * Also proves, against the REAL repo (scripts/mutation-matrix.cjs's exported + * COVERED + derivation helpers), that the guard currently reports zero drift + * — the same invariant `npm run lint:ci` enforces, exercised in-process here + * so a regression is caught by the normal test suite too, not only by lint:ci. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const { findDrift } = require('../scripts/lint-mutation-test-derivation-drift.cjs'); + +describe('lint-mutation-test-derivation-drift: findDrift (synthetic)', () => { + test('a module-named file that requires the module and is in `tests` is NOT drift', () => { + const covered = { + widget: { tests: ['tests/widget.unit.test.cjs'] }, + }; + const findRequiringTestFiles = (mod) => (mod === 'widget' ? ['widget.unit.test.cjs'] : []); + const matchesModuleNamingRule = (mod, file) => file.startsWith(`${mod}.`) || file.startsWith(`${mod}-`); + + const drift = findDrift(covered, findRequiringTestFiles, matchesModuleNamingRule); + assert.deepEqual(drift, []); + }); + + test('a module-named file that requires the module and is in `excludeTests` is NOT drift (deliberate exclusion)', () => { + const covered = { + widget: { tests: ['tests/widget.unit.test.cjs'], excludeTests: ['widget.integration.test.cjs'] }, + }; + const findRequiringTestFiles = (mod) => (mod === 'widget' ? ['widget.unit.test.cjs', 'widget.integration.test.cjs'] : []); + const matchesModuleNamingRule = (mod, file) => file.startsWith(`${mod}.`) || file.startsWith(`${mod}-`); + + const drift = findDrift(covered, findRequiringTestFiles, matchesModuleNamingRule); + assert.deepEqual(drift, []); + }); + + test('PLANTED OMISSION: a module-named file that requires the module but is in neither `tests` nor `excludeTests` IS reported', () => { + // Reproduces the exact #3888 shape: a new frontmatter-named test file added on a + // branch, requiring frontmatter.cjs, that nobody registered anywhere. + const covered = { + widget: { tests: ['tests/widget.unit.test.cjs'] }, + }; + const findRequiringTestFiles = (mod) => + (mod === 'widget' ? ['widget.unit.test.cjs', 'widget.new-consequence.test.cjs'] : []); + const matchesModuleNamingRule = (mod, file) => file.startsWith(`${mod}.`) || file.startsWith(`${mod}-`); + + const drift = findDrift(covered, findRequiringTestFiles, matchesModuleNamingRule); + assert.deepEqual(drift, [{ module: 'widget', file: 'widget.new-consequence.test.cjs' }]); + }); + + test('a requiring file that does NOT match the naming rule is never drift (cross-cutting files need extraTests, not auto-detection)', () => { + const covered = { + widget: { tests: ['tests/widget.unit.test.cjs'] }, + }; + const findRequiringTestFiles = (mod) => (mod === 'widget' ? ['widget.unit.test.cjs', 'unrelated-integration.test.cjs'] : []); + const matchesModuleNamingRule = (mod, file) => file.startsWith(`${mod}.`) || file.startsWith(`${mod}-`); + + const drift = findDrift(covered, findRequiringTestFiles, matchesModuleNamingRule); + assert.deepEqual(drift, []); + }); + + test('a module with no requiring files at all reports no drift', () => { + const covered = { widget: { tests: [] } }; + const findRequiringTestFiles = () => []; + const matchesModuleNamingRule = () => false; + assert.deepEqual(findDrift(covered, findRequiringTestFiles, matchesModuleNamingRule), []); + }); +}); + +describe('lint-mutation-test-derivation-drift: real repo has zero drift', () => { + test('scripts/mutation-matrix.cjs COVERED has no undisposed module-named requiring test file', () => { + const { + COVERED, + findRequiringTestFiles, + matchesModuleNamingRule, + } = require('../scripts/mutation-matrix.cjs'); + + const drift = findDrift(COVERED, findRequiringTestFiles, matchesModuleNamingRule); + assert.deepEqual( + drift, + [], + `found undisposed test file(s): ${JSON.stringify(drift)} — see scripts/lint-mutation-test-derivation-drift.cjs` + ); + }); +}); diff --git a/tests/uat.test.cjs b/tests/uat.test.cjs index 553f56961..8d470779e 100644 --- a/tests/uat.test.cjs +++ b/tests/uat.test.cjs @@ -792,16 +792,25 @@ All checks passed. const output = JSON.parse(result.output); assert.strictEqual(output.summary.total_items, 1, 'total_items must reflect the frontmatter array, not the unstructured body prose'); - // #2286 review LOW finding: extractFrontmatter's generic array-item - // parser has no notion of nested key/value objects — a `- test: "..."` - // entry is ALWAYS flattened to the raw post-"- " text, verbatim (only - // its own wrapping quote is stripped, and only at the string's outer - // edges). normalizeHumanVerificationEntry deliberately does NOT strip - // a leading "key:"-shaped prefix (see its doc comment) because doing - // so is indistinguishable from truncating a legitimate plain string - // that starts with a word and a colon — so this documented, slightly - // ugly artifact is the CORRECT (non-data-lossy) output for this shape. - assert.strictEqual(output.results[0].items[0].name, 'test: "Confirm the widget renders correctly'); + // ADR-3473 §8.1 (#3881): pre-migration, extractFrontmatter's hand-rolled array-item + // scanner had no notion of nested key/value objects — a `- test: "..."` entry was + // flattened via a REGEX quote-strip (`.replace(/^["']|["']$/g, '')`) that only ever + // matches a quote at the very start or very end of the WHOLE post-"- " string. Since + // this string starts with `test:` (not a quote), only the regex's END anchor matched, + // stripping the trailing `"` but leaving the opening one embedded mid-string — a + // documented but genuinely ugly artifact (`test: "Confirm the widget renders correctly`, + // unbalanced quote and all). + // + // Under js-yaml (ADR-3473 §8.1), `- test: "..."` is parsed as real YAML — a proper + // mapping `{test: "Confirm the widget renders correctly"}` — and `flattenObjectListItem` + // re-joins it as `key: value` with the value's OWN quoting already resolved by the real + // parser, not re-derived by a second regex. The embedded quote is gone because it was + // never data to begin with; it was YAML's own value-delimiter syntax. This is strictly + // more correct (no unbalanced-quote artifact) and the `normalizeHumanVerificationEntry` + // consumer is unaffected — it still receives a `name` string of the same shape (still not + // lossy of the `test:` label prefix, which is a deliberate, documented, and unrelated + // decision — see normalizeHumanVerificationEntry's doc comment). + assert.strictEqual(output.results[0].items[0].name, 'test: Confirm the widget renders correctly'); assert.strictEqual(output.results[0].items[0].category, 'human_uat'); }); diff --git a/tests/verify.test.cjs b/tests/verify.test.cjs index 5996f7cfb..941fd0a8b 100644 --- a/tests/verify.test.cjs +++ b/tests/verify.test.cjs @@ -1689,7 +1689,12 @@ describe('verify key-links command', () => { writePlanWithKeyLinks(tmpDir, [ '- from: "src/a.js"', ' to: "src/b.js"', - ' pattern: "exports\\.targetFunc"', + // ADR-3473 §8.1 (#3881): a bare `\.` inside a YAML double-quoted scalar is not a + // recognized escape sequence — `\\.` (a real backslash escaping itself, then a literal + // dot) is the valid spelling for the same intended pattern string `exports\.targetFunc`. + // The old hand-rolled parseMustHavesBlock never validated YAML escape rules and + // silently accepted the invalid form; the vendored js-yaml parser correctly refuses it. + ' pattern: "exports\\\\.targetFunc"', ]); // pattern NOT in source, but found in target fs.writeFileSync(path.join(tmpDir, 'src', 'a.js'), 'const x = 1;\n');