Files
msd-core/tests/audit-command-cutover.test.cjs
0xdhx ef9ce3e598 fix(#3702): count asterisk, plus and ordered markers as deferred-items entries (#3739)
* fix(#3702): deferred-items counts `*`, `+` and ordered markers as list items

`deferred-items.md` has no template and no mandated shape, but its parser
recognised only the `- ` hyphen marker. Asterisk bullets, plus bullets and
dot-terminated ordered lists — all lists in CommonMark and GFM — contributed
ZERO entries on both the headless and the heading-delimited path, and a mixed
file dropped its non-hyphen entries while keeping their hyphenated siblings,
under-reporting without ever looking empty.

The restriction was a regex literal inherited from the Gaps seam, where the
template genuinely mandates the hyphen YAML-lite form; nothing in the module's
stated rationale distinguishes `*` from `-`.

Widened on the deferred path only:
- `splitGapsEntriesCore`'s entry opener, `extractGapEntryFields`' line-0 strip
  and `rawGapEntryText`'s line-0 strip take a `BulletMarkers` parameter that
  DEFAULTS to the hyphen-only set, so `## Gaps` keeps its template-mandated
  grammar byte-for-byte and the module still has exactly one grouping pass.
- `splitDeferredHeadingEntries`' body-bullet test, `stripLeadingBulletMarker`
  and `acknowledgeDeferredItem`'s status-field regexes move in lockstep —
  widening what OPENS an entry without widening what is STRIPPED before field
  extraction would surface an entry that can never resolve.

Unchanged, and pinned by tests: prose-only and bare headings still contribute
nothing ("prose is not an item"); a table under a leaf heading still yields
exactly its rows, since table lines are skipped before the body-marker flag can
be set and a `|` row is not a list marker; the paren-terminated ordered form
`1)` is out of this fix's scope.

* docs(#3702): changeset fragment (pr: 0 placeholder pre-create)

* fix(#3702): widen the forensic-audit prose entry rule to match the parser

Sibling site of the same defect class, found by a defect-class sweep of the
deferred-items consumers. `/gsd-progress` check 7 does NOT go through
`gsd-tools query` — it globs `deferred-items.md` and has the model read entries
by a prose rule that mandated "one entry per top-level `- ` line". Left as-is,
the marker widening would hold on the CLI path while the one consumer that
bypasses the parser kept reporting "No unresolved deferred items" for a file
written with `*`, `+` or an ordered marker: the same false negative, surviving
in the only place the fix could not reach by code.

Also pass DEFERRED_BULLET_MARKERS explicitly where the heading path extracts
fields. It was already correct — stripLeadingBulletMarker pre-strips the widened
set from every line, so the default hyphen strip is a no-op there — but relying
on that leaves a detection site and a strip site nominally on different marker
sets, which is exactly the asymmetry the BulletMarkers doc comment warns about.
Explicit is local; inferred is a trap for whoever edits the strip next.

Out of scope, noted rather than fixed: forensic-audit.md globs only
`.planning/phases/*/` and so misses archived milestone phases that
`scanDeferredItems` covers. Pre-existing, a different defect, and not this
issue's ruling.

* docs(#3702): note the milestone-close halt for heading-shape non-hyphen files in the changeset

A heading-delimited deferred-items.md written with */+/ordered markers
previously parsed to zero and closed silently; it now yields entries whose
heading shape acknowledgeDeferredItem refuses, halting complete-milestone
until hand-edited. User-visible, so the fragment states it.

* chore(#3702): set changeset fragment pr to 3739

* fix(#3702): CR-normalise the heading path and the acknowledge writer (review B1, M4, m2)

B1 — `splitDeferredHeadingEntries` stored RAW lines; on a CRLF file every
body line but the last still carried its `\r`, the `$`-anchored marker
strip failed on it, the marker survived into field extraction and the
field was lost — a `**Status:** resolved` that was not the file's final
line resurfaced its entry as open. The heading path now stores CR-stripped
lines like the headless path already did, and the strip regex tolerates a
trailing CR on its own. Round 1's CRLF test put `**Status:**` on the last
line, the one position `collectSection`'s `.trimEnd()` had already
de-CR'd; the new tests put it first and mid-body.

M4 (pre-existing on `next`) — `acknowledgeDeferredItem` found the status
line on a CR-stripped copy but rewrote the raw line with a `$`-anchored
`.*`, which cannot consume `\r`; `replace` returned its input, and the
writer reported `ok` over byte-identical content. The rewrite now runs on
a CR-stripped line. The comment that claimed `.*$` consumed the `\r` is
corrected — it was the bug, stated as the design.

m2 — the indent probe for an inserted `status:` line ran on the raw line
and fell back to indent 0 on CRLF; it is CR-stripped too.

* fix(#3702): derive every deferred-items marker regex from one source (review M3, N1, N2)

M3 — round 1 carried the marker alternation in FOUR places: the
`BulletMarkers` pair and two inline literals inside
`acknowledgeDeferredItem`, under a doc comment saying the interface
existed so a detection site and its strip site could not drift. All four
now derive from `DEFERRED_MARKER_ALT`; drift is impossible rather than
discouraged. A parity test pins the vocabulary against
`markdown-sectionizer`'s `iterateBullets` on everything the two grammars
are meant to agree on, and names the two points they deliberately differ.

N1 — the ordered marker is `\d{1,9}\.` (CommonMark §5.2), not `\d+\.`.

N2 — the marker is followed by `[ \t]`, not `\s`, which also accepted
`\r`; the tab remains accepted (CommonMark-legal) and the divergence from
`iterateBullets`' literal space is pinned rather than papered over.

The four regexes are exported for the parity test only.

* fix(#3702): an ordered marker opens an entry only from `1.` or inside a run (review B2, m1)

B2 — `\d+\.` alone read ordinary prose as a list: "2026. was a bad year
for this module" and, under `### Notes`, "3. is the number of retries we
settled on." both opened an entry on round 1, the second straight through
the "prose is not an item" contract that round's AC4 claimed to preserve.
CommonMark §5.3 faces the same ambiguity when an ordered list would
interrupt a paragraph and resolves it by requiring the list to start with
1; `matchListOpener` applies that rule wherever an ordered marker is seen,
with the run carried per list (headless) or per leaf-heading body. Numbers
after the first are ignored, as CommonMark ignores them. Stated cost,
pinned: a hand-numbered list starting at 2 reads as prose — every ordered
record in the #3702 scan starts at 1.

Both reviewer cases are pinned as prose; the ruling's `1. alpha / 2. beta`
shape still counts.

m1 — the 9-digit boundary is pinned at both sides (`999999999.` opens,
ten digits is not a marker), and the 3-vs-4-space indentation cliff is
pinned as deliberately NOT applied: the parser is indent-lenient because
surfacing a questionable hand-written entry beats dropping a real one.

* fix(#3702): thematic breaks close the list and fenced code never opens an entry (review M1, M2)

M1 — `- - -` was a phantom `"- -"` entry on base; widening the marker set
added `* * *` and `+ + +` to the class, and `* * *` is the separator an
author writing in the `*` style is most likely to use. A CommonMark §4.1
thematic break (plus the `+ + +` gesture, which is the same garbage as an
entry name) now closes the open entry on the headless path and is dropped
from the body on the heading path — neither an item nor a continuation.

M2 — neither splitter was fence-aware, so `+ `-prefixed diff lines and
`1.`-numbered repro steps inside a code block counted as entries; #3702's
wild records carry exactly those blocks. Both splitters now classify lines
by the sectionizer's own `scanFencedBlocks` (so `~~~`, indented and
unterminated fences behave as `stripFencedCode` would): fence content
never opens an entry, is continuation inside an open one — keeping the
span invariant `acknowledgeDeferredItem` re-verifies — and is discarded
before the first.

* test(#3702): range the #2287 deferred-items property over marker × shape × line ending (review B3)

The `#2287` property hard-coded `- ` and filtered `\r\n` out of its
arbitraries, so the widened marker set — an enumerated domain, exactly
what a property is for — was never under it. It now ranges over
`{-, *, +, ordered}` × `{headless, heading}` × `{LF, CRLF}`, with the
heading shape placing `**Status:**` first or last: the review's
prescription (markers × line endings) would not have reached B1, which
lives on the heading path only, so the shape axis is the load-bearing
addition. Ordered entries are numbered from 1, so the B2 run rule is
under the property too.

A second property drives `acknowledgeDeferredItem` over every unresolved
headless entry across the same marker × line-ending grid — the one that
reaches M4 (a CRLF rewrite that reported `ok` and wrote nothing) and m2.

* test(#3702): pin the milestone-close halt on a heading-delimited `*`/`+`/`1.` file (review m3)

A heading-delimited `deferred-items.md` written with a non-hyphen marker
previously parsed to zero entries and let `complete-milestone` close
silently; it now yields entries whose heading shape `acknowledgeDeferredItem`
refuses, which the milestone loop turns into `record_ack_failure` → exit 1.
The loop is prose in a workflow, so the test drives the two CLI calls it
makes: `audit-open --json` must list the entry, and
`audit-open acknowledge --text <the audit's own text>` must refuse with the
heading-delimited message and write nothing.

* docs(#3702): changeset and forensic-audit prose carry the round-2 grammar

The changeset names the CRLF fixes, the ordered start-at-1 rule, thematic
breaks and fences. The `/gsd-progress` forensic-audit step is the one
prose parser of this file and must state the same grammar the code has.

* fix(#3702): round-review refinements — run ends at a paragraph, rejected ordinals unstripped, breaks at any indent, fenced fields, `## Gaps` scope

Findings from the pre-push adversarial review of round 2, each pinned:

- An ordered run ENDS at a paragraph that follows a blank line (CommonMark
  §5.3); a non-indented line with no blank before it is lazy continuation
  and keeps the run open. `1. a` / blank / `paragraph` / blank / `5. x` is
  one entry, not two.
- The heading path strips the marker off every body line before field
  extraction (#3457); a line whose ordinal `matchListOpener` REJECTED must
  not be stripped, or "3. status: resolved" as prose loses its `3. ` and
  reads as a resolved field. `splitDeferredHeadingEntriesDetailed` now
  carries a per-line opener flag and only accepted openers are stripped —
  in headless regions of a heading-shaped file too.
- A thematic break is recognised at any indent, matching the parser's
  indent-lenient reading of items; `    * * *` was a phantom `* *`.
- Fenced lines carry no FIELDS either: a `status: resolved` quoted inside a
  code block no longer resolves its entry on either path.
- Block structure (breaks, fences) is a property of the GRAMMAR, carried as
  `BulletMarkers.blockStructure`: the deferred set opts in, the Gaps set
  does not, so `## Gaps` is byte-for-byte on its `next` behaviour — the
  round-2 M1/M2 change had reached it through the shared splitter.

* test(#3702): the property exercises the rejected-ordinal branch; the N2 control is independent

Round review: the widened #2287 property numbered every ordered run from 1
and so never generated an ordinal the start-at-1 rule rejects — it could
not tell round 1 from round 2 on B2. Each entry may now carry a decoy prose
line beginning with a non-1 ordinal, placed where it cannot end a run
(before the first headless entry; first in a heading body), followed by a
`status: resolved` that must never become a field; and a decoy-only
heading body must yield no entry.

The N2 assertion accepted a tab, which round 1's `\s` accepted too, so a
`[ \t]` → `\s` revert alone stayed green. NBSP, form-feed and vertical-tab
are now asserted refused — the assertion that fails on that revert on its
own, and the disclosure that `[ \t]` narrows what round 1 accepted.

* fix(#3702): the splitter records its own opener flags; an opener clears the blank-line memory

Round-review continuation, two state defects in the ordered-run logic:

- `blankSeen` survived the headless splitter's opener branch, so an opener
  followed by a lazy continuation line read as "paragraph after a blank" and
  ended the run — `1. a` / blank / `2. b` / lazy / `3. c` folded `c` into `b`.
  The opener branch now clears it.
- The heading path re-derived per-line opener flags for headless regions
  without the paragraph reset, re-accepting a rejected `3. status: resolved`
  under a stale run and stripping it into a field. `GapsEntrySpan` now
  carries the flags the splitter itself computed, and the heading path reads
  them; the re-derivation is deleted.

* fix(#3702): ordered-run memory is per indent — nested runs resolve, nested ordinals never inherit the top-level run

Round-review continuation 2: nested openers consulted the TOP-LEVEL run
flag and never wrote their own, so a nested `1. / 2.` run under a hyphen
entry rejected its `2. status: resolved` (round 1 resolved it), while a
nested `3. status: resolved` under a nested `- ` bullet inherited an open
top-level run and was stripped into a false field.

`OrderedRuns` keys the memory by indent: a new opener at indent d resets
every deeper level, a paragraph after a blank at indent d ends the runs at
d and deeper, a thematic break or a heading clears all. Both splitters use
it; the top level still decides entry boundaries, nested levels decide
only which continuation lines are accepted openers for field stripping.
Pinned for LF and CRLF.

* fix(#3702): run levels — one top level at or above the base, CommonMark column indents, a fence ends its level's runs

Round-review continuation 3:

- A dedenting top-level list (`    1.` / `  2.` / `3.`) lost its entry
  boundaries: the exact-indent run lookup rejected the shallower ordinals
  before the boundary check ran. Every indent at or shallower than the
  list's base is now ONE level, in both splitters.
- `indentOf` counted characters, so a tab and a space aliased to one level
  and `\t1. nested` / ` 2. status: resolved` resolved falsely. Indent is now
  measured in CommonMark columns (§2.2: a tab advances to the next multiple
  of 4), for the run level and the entry-boundary check alike.
- A nested run survived a fenced block. A fence is a non-list block: its
  opening delimiter ends the runs at its level and deeper, exactly as a
  paragraph after a blank does.

* fix(#3702): the indent measure is grammar-scoped — Gaps keeps next's character count

`blockStructure: false` promised the Gaps grammar byte-for-byte parity with
`next`, but the CommonMark-column indent measure added for the deferred
grammar was shared by the whole splitter core, so tab-indented Gaps input
changed entry boundaries in BOTH directions:

  `\t- a` / `  - b`  — next folded into one entry, HEAD split into two
  `  - a` / `\t- b`  — next split into two,      HEAD folded into one

`indentWidth` now keys the measure on the grammar: columns for the deferred
set, raw character count for Gaps. The opt-out covers indent semantics, not
only fences and thematic breaks.

Four cases pin both halves — the two flipped Gaps pairs, the two Gaps pairs
that never moved, and the same tab/space pairs on the deferred path returning
the opposite (column-measured) verdict by design.

* fix(#3702): the acknowledge path reads and writes through one classifier

Round 3, Blockers 1 and 3, and Minors 7 and 8 — one mechanism, so one commit.
Every consumer of an entry's lines now reads the splitter's own per-line
verdict instead of a re-derivation of it.

B1. Round 2 widened the WRITER's status-line finder to the deferred marker set
while `extractGapEntryFields` still de-bulleted line 0 only. A nested
`  * status: pending` was therefore selectable by the writer and invisible to
the reader: acknowledge rewrote it in place, returned `ok`, and the item stayed
outstanding on every later audit. Measured against a `next` build, `*`, `+` and
`1.` each resolved on base and stopped resolving at round 2's head — a
regression, not a gap in new behaviour. The hyphen form of the same shape was
already broken on `next` and is fixed here too: one classifier cannot be right
for three markers and wrong for the fourth.

`parseGapEntryFieldLine` is now the single place a line is classified as a
field, and it reports the offset at which the VALUE begins. The rewrite happens
at that offset rather than through a second regex, so a line the classifier can
select is one whose rewrite it has already located — the selection and the
rewrite cannot disagree. Both `DEFERRED_STATUS_FIELD_RE` and
`DEFERRED_STATUS_REWRITE_RE` are deleted rather than widened. A read-back guard
returns `rewrite_not_readable` rather than `ok`; it is unreachable by
construction today and is the fail-loud floor under the next divergence.

B3. This is the end state the round-3 review prescribed on both #3739 and
#3773: #3773's shared classifier, parameterised by this PR's marker set, with
this PR's two status regexes deleted. #3773 lands first. Its hyphen-only strip
is consistent with `next`'s hyphen-only splitter today, so the writer/reader
divergence is created by THIS merge, which is why widening every consumer
belongs to the PR that widens the domain.

m7. The heading path marker-stripped its lines before calling the reader, so
the reader's fence scan ran over text the splitter never saw: `- ```sh` is an
ordinary bullet to the splitter but strips to a fence opener, and a
`**Status:** resolved` after it was suppressed as fence content — a resolved
entry resurfaced as open. Stripping now happens inside the reader, after the
fence scan.

m8. `rawGapEntryText` stripped a marker off line 0 unconditionally, but on the
heading shape line 0 is the heading TEXT: `### 1. Race in the writer` was
silently renamed to `Race in the writer`, and the name is the key acknowledge
matches on. Line 0 is stripped only when the splitter accepted it as an opener.

Also removed: `splitDeferredHeadingEntries`, whose sole caller only null-checked
it (round 3, M4 — the claim was zero callers, which was wrong; the wrapper's
`.map` was waste at the one call site), and `stripLeadingBulletMarker`, which
this change leaves with no callers at all. The export surface narrows to the two
splitter regexes the behavioural parity test reads (M6).

[PEER-ASK pr-order-12d5]
q: Reviewer blocked both on merge order. I'm declaring #3773 lands first and
   building the end-state shape into #3739 now (both my status regexes
   deleted). Does that match your plan?
reply: CONFIRMED - same order, derived independently. #3773 cannot carry the
   fold: `DEFERRED_BULLET_MARKERS`/`BulletMarkers` have zero occurrences at
   `next` (verified), so the prescribed end state is not executable inside
   #3773 without absorbing this PR's work.
deadline: 03:55 UTC (answered before it)
fallback: declare #3773 first, adopt end-state shape in #3739, push+comment
decision: proceeded as stated; #3773 lands first, this PR carries the widening
   of every consumer.

Refs #3740

* test(#3702): pin the detect/strip symmetry, and drop a white-box test that could not reach it

Round 3, Blocker 2 and Minors 6 and 9.

B2. The regression shipped green because no fixture put a marker on a nested
status line. Four markers x {nested status line}, each asserting the entry
READS BACK as acknowledged rather than that acknowledge merely reported `ok` —
reporting `ok` over a line the reader skips is the whole defect. Plus the bare
capitalised `Status:` case (the reader stores it case-sensitively, so the
writer must not select it), and an idempotence test, which is the failure the
defect actually produced: the item resurfaces, is acknowledged again, and never
settles.

Each of these was run against the pre-fix build first: all five fail there and
pass here. Two further assertions in the block are labelled CONTROL because
they held pre-fix — they guard the new offset-based rewrite and the opener-flag
threading against regressing, and calling them regression tests for a reported
defect would overclaim.

M6. The round-2 parity test asserted that four writer-side regexes embedded the
same source string. That is true of a detect/read asymmetry too, so it could
not have caught B1 — and two of the four regexes were widened into `export =`
purely to let it read them. Replaced with a behavioural test that drives the
real seam: every marker that opens an entry must also resolve it through
acknowledge. The structural assertion is kept for the two splitter regexes,
which really are two copies of one alternation.

m9. `expectedResolved` was computed and immediately voided; the loop beneath it
already asserts both polarities.

m7/m8 coverage lands here too: a bullet whose content is a fence opener must
not suppress the entry's fields, and a heading beginning with a list marker
must keep it in the entry name.

* docs(#3702): document the deferred-items entry shape where the file is written

Round 3, Major 5, and #3702's own item 2. The widened grammar was documented in
the reader (`forensic-audit.md`) but not at the write site, where
`executor-examples.md` still said only "log to deferred-items.md" — so the
question the issue actually raised, which shapes count, remained unanswered
anywhere a human writes the file.

States what opens an entry (`-`, `*`, `+`, and `1.` when the list starts at
`1.`), that `1)` is not a marker here, that a separator closes the list and
fenced content is never an entry or a field, and that an entry without an
explicit `status: resolved` stays open by design.

* chore(#3702): regenerate the changeset through the generator

Round 3, Minor 10. The fragment was hand-named against 64 generated names on
`next`, and its body ran ~250 words against CONTRIBUTING's one-sentence form.
Regenerated via `npm run changeset`, which is also what the random three-word
name is for: concurrent PRs never collide.

* fix(#3702): the fence gate lives on the seam both sides call, not just the reader

Found by the pre-push adversarial review of this round, and it is a regression
this round introduced rather than a pre-existing one.

`extractGapEntryFields` applied `fencedLineSet` before classifying; the
acknowledge writer's status-line search did not. So a `status:` line inside a
fenced block was SELECTED by the writer and SKIPPED by the reader — the write
produced a line nothing reads, the read-back guard refused it, and the entry
became impossible to acknowledge at all: `audit acknowledge` raised an internal
error and `complete-milestone` halted on it.

Measured, `- alpha` / fence / `  status: pending` / fence:

  next          ack=ok                   -> reads back "acknowledged"
  round-2 head  ack=ok                   -> reads back ""      (the B1 defect)
  before this   ack=rewrite_not_readable -> refuses entirely   (worse than next)

`entryFieldLines` is now the seam — per line of an entry, the field it declares
or `null`, fences included — and the reader and the writer both go through it.
That makes "the writer cannot select a line the reader will not read back"
structural rather than asserted, which is what the previous commit's message
claimed while a second read-side filter still lived outside the classifier.

Two comments corrected with it. The read-back guard is NOT "unreachable by
construction": this round shipped a reachable path to it, which is precisely
what an invariant asserted in a comment is worth. And the M6 replacement test
put its marker only on the entry opener, so it passed against the defective
build — the exact weakness it was introduced to fix in round 2's test. It now
marks the nested status line too, and fails pre-fix like the rest.

Round-3 tests against the pre-fix build: 10 of 12 fail there, and the 2 that
hold are labelled CONTROL because they guard this round's new code rather than
pin a reported defect.

* fix(#3702): one end-of-file CRLF algorithm, adopting #3773's with its B4 closed

Round-4 M1. Two open PRs shipped two different answers to "what line ending
does an entry that ENDS THE FILE get?", and the review's ruling was that the
disagreement needs one answer, not two. Neither shipped answer was that one.
Measured on builds of both heads:

  case                                     #3739 r3   #3773   here
  undelimited single entry, CRLF preamble    pass      FAIL    pass
  LF-dominant list, one stray CRLF at EOF    FAIL      pass    pass
  (the other five)                           pass      pass    pass

This PR's content.endsWith('\r\n', matchIndexInContent) reads the terminator of
the PREVIOUS line, so it propagated an isolated CRLF into an LF-dominant list --
refuted by #3773's own LF-dominant fixture, ported here. Withdrawn.

#3773's crlfAtEof asks the right question -- does anything before the entry,
within scope, contradict CRLF -- and fails closed. But its scope goes EMPTY for
an undelimited single-entry list, because the entry-list region runs from the
first entry's start to the insertion point and those coincide; crlfAtEof('') is
false by its own before.length > 0 guard, so 'preamble\r\n\r\n- alpha' gained a
bare \n in a CRLF document. That is #3773's B4, verified by driving its head.

Adopted here with the scope widened to everything preceding the insertion point
where the preferred region is empty, rather than asserting LF from no evidence.
That only ever loosens a scope carrying zero information, and the predicate
stays fail-closed over the wider one. An entry at offset 0 of an undelimited
document has no evidence under either scope and stays LF.

Tests: 10 added. Negative control, driven -- 1 of the 10 fails against this
branch's own pre-fix head (the stray-CRLF fixture); B4 fails against #3773's
head; the remaining 8 are the scope counterexamples ported with the function,
which were regression pins in #3773 and are guards here. Each still kills a
simpler algorithm: drop any one and a refuted scope passes again.

Four deferred-items suites 450/450, 0 skipped. npm run lint:ci exit 0.

* fix(#3702): drop the unreachable rewrite_not_readable guard (B3)

Round-4 B3: the status had zero test coverage in either file. The review
offered two branches -- drive it from a test, or delete it and stop carrying an
untested terminal status. Taking the second, with the reason stated rather than
assumed.

Why it cannot be driven. Round 3 added the guard after a fenced `status:` line
proved the writer could select a line the reader would not read back. Round 3
then closed that divergence STRUCTURALLY, by routing the writer's line selection
and the reader's field extraction through one entryFieldLines seam. The guard
now detects a state construction prevents: 21 document shapes were driven
against it -- fence openers on the bullet line for every marker in the widened
set, duplicate and triplicate status lines, bolded and nested variants, fences
between duplicates -- and none reached it. The only seam that would is routing
the internal call through the module's exports so a test could stub it, which
reshapes production surface for a test.

Why leaving it undriven is not free. RULESET.TESTS.mutation-score runs Stryker
incrementally over changed files at an 80% threshold and says to treat a
surviving mutant as a failing test specification. An undriven `if` on a changed
file is exactly that, on both the condition and the .toLowerCase() comparison.

What this gives up, stated rather than hidden: if a future change re-splits the
writer's selection from the reader's extraction, acknowledgeDeferredItem returns
ok over an item that stays outstanding -- the original #3702 defect class. One
correction to the review's framing: match_verification_failed does NOT backfill
it. That check runs BEFORE the write and compares the matched span to the
target, so it cannot see a post-write read-back failure. The protection against
re-splitting is the shared seam and the round-3 tests that pin it, not a runtime
assertion. A comment at the removal site records all of this.

Removing it also drops the union member from both files, which resolves the PR
body's internal contradiction (it claimed no type-signature changes while adding
one) and the duplicate-status surface #3773 collides on.

No test changed behaviour: 450/450 across the four deferred-items suites, 149/149
across the audit suites, npm run lint:ci exit 0 -- the same figures as before the
removal, which is itself the evidence that nothing exercised the branch.

* fix(#3702): the deferred fence gate is indent-unbounded, like the rest of the grammar (M2)

Round-4 M2. scanFencedBlocks is CommonMark, which caps a fence delimiter's
indent at three spaces -- a fourth makes it an indented code block instead. This
grammar had already opted out of that cliff for entry openers ([ \t]*) and for
THEMATIC_BREAK_RE (^[ \t]*), but not for fences. So a fence at four spaces was
not a fence to the gate, and a `status: resolved` line inside it RESOLVED the
entry containing it.

That is not an exotic shape. A fenced block written under a NESTED bullet sits
at four spaces, so ordinary hand-written deferred-items.md files reach it.
Driven before the fix at indents 4, 5, 8 and a leading tab: all four silently
resolved. It is the #3702 silent-resolution defect class in a new place.

gsd-core/references/executor-examples.md, added by this PR, states flatly that
"nothing inside a fenced code block is an entry or a field". The review offered
fixing the parser or bounding that claim in three places. Fixing it -- the claim
is the one users will rely on, and the grammar had already chosen unbounded
indent everywhere else.

NO second fence dialect (the rule blankIndentedFenceDelimiters states). The
classification is still done by scanFencedBlocks, the one exported CommonMark
state machine, over a de-indented VIEW of the same lines. Run lengths, backtick
vs tilde, closer-must-match-and-not-trail, info-string rules and the
unterminated-at-EOF case remain that engine's answers. Indent is the only
dimension hidden from it, and it is exactly the dimension this grammar has
already declared it does not measure. Index alignment is 1:1 -- map preserves
length -- so every returned line index still addresses the original line.

Scope is the deferred grammar only. Both marker-parameterised call sites gate on
markers.blockStructure, which the Gaps set does not set, so Gaps reaches an empty
set. Verified, not asserted: the 47-fixture Gaps differential (marker x
line-ending x separator x fence x break x key-shape x list-shape) is
BYTE-IDENTICAL across this change, 8033 bytes both sides.

Tests: 14 added, of which 8 fail against the pre-fix source and pass here; the
other 6 are the deliberate controls -- indents 0 through 3, which must NOT move,
and the Gaps opt-out guard.

Four deferred-items suites green; the 58 suites touching uat/deferred/sectionizer
run 6045 tests with an IDENTICAL failing set before and after this change (17
pre-existing environment failures -- installs and an unpinned GSD_EMITTED_BASE;
emitted-attribution passes 259/259 in isolation with its base pinned). lint:ci
exit 0.

* fix(#3702): changeset, both prose parsers, and the minors (M3, M4, m1-m3, m5, n1-n2)

M3 -- the changeset omitted a user-BREAKING change. Measured against next: a
heading-delimited deferred-items.md written with `*`, `+` or `1.` went from
"0 entries, so complete-milestone has nothing to acknowledge and closes" to
"1 entry, the CLI writer refuses the heading shape, ACK_FAILURES accumulates,
exit 1". The `-` form already halted and is unchanged. That is release-note
material: a close that used to succeed now fails, and the correct response is to
fix the file, not revert. Also names the fence-indent fix below, and adds #3740
so #3773's issue is attributed here as it is absorbed.

M4 -- gsd-core/workflows/progress/steps/forensic-audit.md is a SECOND,
model-executed parser of the same grammar, and prose cannot carry a parity test.
Its widened text stated the start-at-1 rule, fences and separators but not the
`1)` exclusion nor the nine-digit ordinal cap, both enforced in code with pinned
tests. Both stated now, along with the round-4 fence-indent rule. (No ack
fragment: the size ratchet's currentSizes does a NON-recursive readdirSync of
gsd-core/workflows and agents, so a file under workflows/progress/steps/ is
outside its scope -- verified by reading the helper, not by the green.)

n1 -- executor-examples.md documented that the BOLDED status key is matched
case-insensitively and left the bare key's rule to inference. Driven: bare
`Status: resolved` is NOT read, so the entry stays open with no warning, while
`**Status:**` is. Stated explicitly, with the digit cap and the any-indent fence
rule (n2).

m1 -- boundary coverage was 2/3. limit (999999999.) and limit+1 (1234567890.)
were pinned; limit-1 (12345678.) added, per RULESET.TESTS.boundary-coverage.

m2 -- THEMATIC_BREAK_RE and the tab-expanding indent counter are hand-rolled
CommonMark rules with no in-repo peer to compare against, so the parity
assertion is against the SPEC: eight positive and five negative fixtures, plus
the two DELIBERATE divergences pinned as deliberate (`+` is a separator here but
not in CommonMark, because `+` is a list marker in this grammar and `+ + +`
would otherwise be a phantom entry; indent is unbounded). One fixture was
initially wrong -- `-- -` IS a CommonMark break, since the spec allows free
spacing between the three characters -- and the parser was right.

m3 -- the result union is hand-duplicated in audit.cts as part of a deliberate
structural view of uat.cjs, so the fix is not to delete a copy but to make drift
observable. Every REACHABLE status is now driven from a fixture; four of the six
(ambiguous, unsupported_heading_shape, already_resolved, match_verification_failed)
had no assertion anywhere in the suite before this. match_verification_failed is
still undriven and the test says so rather than omitting it.

m5 -- DECLINED, with the measurement. The review is right that `(\s*)` in the
opener and `/^[ \t]*/` in the reader disagree about \f, \v and NBSP, but its
prescribed narrowing was implemented, driven and REVERTED: as shipped, an entry
indented with any of those surfaces, parses its status field, acknowledges, and
reads back acknowledged -- a complete round-trip. Narrowing turns all three into
SILENTLY DROPPED entries, which is the #3702 defect class itself and the opposite
of this file's stated fail-safe rule. A latent inconsistency in the safe
direction is not worth a live regression in the unsafe one. Pinned by three
round-trip tests so the prescription cannot be re-applied silently; if it is ever
closed, the direction is to make the readers agree with the opener, not to make
the opener reject lines it accepts today.

Four deferred-items suites 475/475, 0 skipped. lint:ci and lint:changeset exit 0.
The 47-fixture Gaps differential is byte-identical at 8033 bytes.

* fix(#3702): the pinned `## Gaps` phantom now cites its issue (m4)

Round-4 m4. The second assertion in the Gaps byte-for-byte test pins a real
defect as expected output: a spaced hyphen thematic break in `## Gaps` is read
as an ITEM, so `- - -` surfaces a phantom open gap named `- -`. Reproduced on
pristine next at 389bc86e0 across nine separator shapes -- every spaced hyphen
form is affected, `---`/`----`/`* * *`/`___` are not, and the dividing line is a
space after the first hyphen (the Gaps opener is /^(\s*)(-)\s/ with no
thematic-break concept at all).

Filed as open-gsd/gsd-core#3898. The pin stays: scope-limiting Gaps is the point
of the blockStructure opt-out, and this assertion is the only thing that would
notice the Gaps path moving. What was missing was the tracking -- a pinned defect
with no issue behind it reads as intended behaviour to the next reader. The
comment now says which it is and what the expectation becomes when #3898 lands.

* fix(#3702): an unterminated fence runs to the end of its entry, never past it (B1, B2)

Round 4 de-indented every line before `scanFencedBlocks`, so a fence
opened at any indent — and `scanFencedBlocks` runs an unterminated
fence to end-of-document — so one stray delimiter swallowed every entry
after it into the entry before it. `- a` / blank / four-space ``` /
blank / `- b` yielded ONE entry where `next` yields two: a widening
that made an already-counted item vanish, on the mixed-file shape #3702
exists to close. Reproduces at indent 0 as well.

The bound is the entry. CommonMark closes a fence with its container
and a container at the next item at its level; this parser extends
that to a document-level stray delimiter, where CommonMark would
swallow to EOF and the fail-safe rule (surface, don't drop) will not.
`scanFencesFrom` reports the unterminated opener and the walk supplies
the bound — the next line shaped like a top-level item — then RESCANS
from it, so a later delimiter is read on its own terms. Still one
fence dialect: every block boundary is `scanFencedBlocks`' answer.
Entry-scoped `fencedLineSet` (the field reader) already ran an
unterminated fence to the end of its lines, so reader and walk agree
by construction.

Tests: the M2 pin that asserted `[]` for a stray fence before an item
flips (the item counts); the round-4 "runs to end-of-file, exactly as
CommonMark says" test is retitled — its assertion stands because the
bound is the entry — and extended with the next entry; a new block pins
the review reproduction at both indents, a terminated deep fence still
gating, the gated status inside the bounded fence, the rescan case, and
the heading-tokenizer caveat (at indent 0 the tokenizer applies
CommonMark's own fence rule, so a heading after a stray delimiter is
body text there, exactly as on `next`).

Reverted in isolation against the final tree: 3 named tests fail.

* fix(#3702): `0.` starts an ordered list (M1)

The start-at-1 rule applied unconditionally dropped ONLY the first item
of a `0.`-numbered list — the run then started at `1.` — which is the
mixed under-report that looks like a clean parse. CommonMark §5.2
permits any 1-9-digit start and a `0.` list is ordinary; a sentence
opening with "0." is not a shape anyone writes. The threshold is now
`> 1`. The cost is restated accurately in the doc comment and pinned:
a list starting at 2 or more, at a paragraph position, reads as prose
until its first `0.`/`1.` line — the prefix, not the whole list.

Boundary tests at the threshold itself: `0.`, `1.`, `2.` starts, `00.`/
`01.`, and the prefix-loss case. Reverted in isolation: 1 named test
fails.

* fix(#3702): a non-1 ordinal is an item wherever a list is already open at its level (M2)

The per-indent run memory recorded whether the previous opener was
ORDERED, so a bullet item closed the run and `1. a` / `- b` / `5. c`
folded `5. c` into `b` — another mixed-file under-report. In CommonMark
`5. c` there opens a fresh ordered list (start=5): a non-1 start is
refused only where it would interrupt a PARAGRAPH (§5.3), and after a
list item it interrupts nothing. `ListRuns` now records "a list is
open here"; the start rule applies where no list is open at the line's
level — the positions a sentence can occupy — so the round-2 B2 pins
(doc start, after a heading, after a paragraph) hold unchanged.

Two round-2 pins move with it, both CommonMark-backed: `1. alpha` /
`- beta` / `2. gamma` is three items, and a nested `3. status:` after
a nested bullet is a nested item (a field line, as `- status:` would
be); the "rejected ordinal is not stripped" pin is re-anchored at a
paragraph position, where it still holds. Reverted in isolation: 4
named tests fail.

* docs(#3702): the two runtime-loaded docs state the grammar the parser ships (B3, M4 parity)

`executor-examples.md` (the write-site doc) and `forensic-audit.md`
check 7 (the model-executed parser) both asserted "never silently drops
a possibly-open item" over a grammar that dropped three measured shapes.
Both now carry the round-5 grammar — `0.`/`1.` starts, a non-1 ordinal
inside an open list, an unclosed fence ending with its own entry — and
the fail-safe sentence is kept with what it does NOT cover named
beside it: a fenced line, a separator, and an ordered list numbered
from `2.` upward at a paragraph position, and nothing else.

* docs(#3702): changeset reflects the merged contract

The "Breaking, and deliberate: … HALTS complete-milestone" paragraph
described a refusal that #3781 removed from `next`; a heading-shaped
file written with a newly recognised marker now surfaces its entries
and `complete-milestone` acknowledges them in place. The fragment cites
#3702 alone — #3740 and #3775 closed on `next` through #3940 and #3989;
this PR's shared reader/writer classifier subsumes both fixes rather
than closing either issue. The round-5 grammar (ordered start, unclosed
fence bound) is stated in the user-facing sentence.

* fix(#3702): the heading-shape insert lands on a line the reader reads, and keeps a closing `#` sequence

Found by the round's pre-push adversarial review. An entry whose body ends
in a fenced block — closed, or unclosed and therefore running to the
entry's end — received `status: acknowledged` AFTER its last non-blank
line, i.e. as fence content: the writer returned `ok` and the reader
never saw the marker, the item stayed outstanding. That is the #3702
class itself (a write nothing reads), on the shape #3781 just opened.
The insert now walks back over blank AND fenced lines, classified by the
reader's own `fencedLineSet`, so the marker lands on a line the reader
reads; pinned as round-trips for an unclosed fence, a closed fence, and
a pending entry ending in an unclosed fence before a heading. Reverted
in isolation: the round-trip test fails.

Separately, the leaf line-0 rewrite (`### status: open ###`) dropped the
closing `#` sequence; it is kept now. Cosmetic, pinned.

* docs(#3702): the prose parser states the bare-key case rule; both docs say what an unclosed fence does, no more

`forensic-audit.md` check 7 called `status: resolved` case-insensitive
where the code reads a bare key lower-case only (the bolded form in any
case; the value case-insensitively) — `executor-examples.md` already said
so, the model-executed parser did not. And both docs claimed "a stray
delimiter cannot hide the entries after it", which overstates B1: an
UNCLOSED fence ends with its entry; a closed pair of delimiters is a
fence, whatever sits between them, as CommonMark reads it. Found by the
round's pre-push review.

* fix(#3702): a heading whose text is a fence delimiter is a heading, not a fence

Second finding of the round's pre-push review, one door over from the
first: for a leaf headed `### ```` (or `~~~`) the entry-level fence scan
read line 0 — the heading TEXT, not a Markdown line — as a fence opener,
so every body line was fenced: the reader read no field under it, and
the writer's marker (placed by the same scan) landed on a line nothing
reads — `ok`, item outstanding. `entryFencedLines` now owns the entry's
fence view for reader and writer alike, and a leaf's line 0 never opens
a fence (the leaf tell is `openerFlags[0] === false`; a pending or
headless entry's line 0 is a marker line, never a delimiter). Pinned for
both delimiters, read and write; reverted in isolation the pin fails.

---------

Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-08-31 13:44:47 -04:00

2570 lines
127 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* audit-command-cutover.test.cjs — ADR-959 phase 4d-impl-3 equivalence tests.
*
* Verifies that `audit-uat` and `audit-open`, after cutover from the hardcoded
* `case 'audit-uat':` and `case 'audit-open':` arms in gsd-tools.cjs to the
* capability registry dispatch path (default → dispatchCapabilityCommand →
* audit-command-router.cjs → routeAuditUat | routeAuditOpen), behave
* identically to the old inline cases.
*
* Test categories:
* 1. UNIT (recording mock) — precise arg/call equivalence for each router
* 2. DISPATCH — commands reach routers via default-case registry dispatch
* 3. BEHAVIOR — subprocess tests with real output-shape assertions
* 4. JSON-ERRORS — structured {ok:false,reason,message} for error paths
* 5. REGISTRY — commandFamilies entries, audit capability in registry
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
const { routeAuditUat, routeAuditOpen } = require('../gsd-core/bin/lib/audit-command-router.cjs');
const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs');
// ─── helpers ─────────────────────────────────────────────────────────────────
function makeErrorRecorder() {
const calls = [];
const fn = (msg, reason) => calls.push({ msg, reason });
fn.calls = calls;
return fn;
}
// ─── 1. UNIT — recording mocks (precise routing equivalence) ─────────────────
describe('audit routers: unit tests via recording mocks', () => {
const CWD = '/fake/cwd';
const RAW = false;
// ── routeAuditUat ──────────────────────────────────────────────────────────
test('routeAuditUat: calls _uat.cmdAuditUat(cwd, raw) exactly once', () => {
const uatCalls = [];
const mockUat = {
cmdAuditUat: (cwd, raw) => uatCalls.push({ cwd, raw }),
};
const errFn = makeErrorRecorder();
routeAuditUat({
args: ['audit-uat'],
cwd: CWD, raw: RAW, error: errFn,
_uat: mockUat,
});
assert.strictEqual(errFn.calls.length, 0, 'error must not be called');
assert.strictEqual(uatCalls.length, 1, 'cmdAuditUat must be called exactly once');
assert.strictEqual(uatCalls[0].cwd, CWD, 'cwd passed through correctly');
assert.strictEqual(uatCalls[0].raw, RAW, 'raw passed through correctly');
});
test('routeAuditUat: raw=true is forwarded correctly', () => {
const uatCalls = [];
const mockUat = {
cmdAuditUat: (cwd, raw) => uatCalls.push({ cwd, raw }),
};
routeAuditUat({
args: ['audit-uat'],
cwd: CWD, raw: true, error: makeErrorRecorder(),
_uat: mockUat,
});
assert.strictEqual(uatCalls[0].raw, true, 'raw=true must be forwarded');
});
// ── routeAuditOpen ─────────────────────────────────────────────────────────
test('routeAuditOpen (no --json): calls auditOpenArtifacts, formatAuditReport; output(null, true, report)', () => {
const auditCalls = [];
const coreCalls = [];
const FAKE_RESULT = { fake: true };
const FAKE_REPORT = 'REPORT TEXT';
const mockAudit = {
auditOpenArtifacts: (cwd) => { auditCalls.push({ fn: 'auditOpenArtifacts', cwd }); return FAKE_RESULT; },
formatAuditReport: (res) => { auditCalls.push({ fn: 'formatAuditReport', res }); return FAKE_REPORT; },
};
// Inject a recording _core stub so no bytes reach the real process stdout.
const mockCore = {
output: (...callArgs) => coreCalls.push(callArgs),
};
routeAuditOpen({
args: ['audit-open'],
cwd: CWD, raw: RAW, error: makeErrorRecorder(),
_audit: mockAudit,
_core: mockCore,
});
// auditOpenArtifacts called first, then formatAuditReport with its result
assert.strictEqual(auditCalls.length, 2, 'must call auditOpenArtifacts then formatAuditReport');
assert.strictEqual(auditCalls[0].fn, 'auditOpenArtifacts', 'first call must be auditOpenArtifacts');
assert.strictEqual(auditCalls[0].cwd, CWD, 'auditOpenArtifacts cwd must match');
assert.strictEqual(auditCalls[1].fn, 'formatAuditReport', 'second call must be formatAuditReport');
assert.strictEqual(auditCalls[1].res, FAKE_RESULT, 'formatAuditReport must receive auditOpenArtifacts result');
// Assert the exact 3-arg core.output call form for text mode:
// core.output(null, true, formatAuditReport(result))
assert.strictEqual(coreCalls.length, 1, 'core.output must be called exactly once');
assert.strictEqual(coreCalls[0][0], null, 'text mode: first arg to core.output must be null');
assert.strictEqual(coreCalls[0][1], true, 'text mode: second arg to core.output must be true');
assert.strictEqual(coreCalls[0][2], FAKE_REPORT, 'text mode: third arg to core.output must be the formatted report');
});
test('routeAuditOpen (--json): calls auditOpenArtifacts but NOT formatAuditReport; output(result, raw)', () => {
const auditCalls = [];
const coreCalls = [];
const FAKE_RESULT = { fake: true };
const mockAudit = {
auditOpenArtifacts: (cwd) => { auditCalls.push({ fn: 'auditOpenArtifacts', cwd }); return FAKE_RESULT; },
formatAuditReport: (res) => { auditCalls.push({ fn: 'formatAuditReport', res }); return 'REPORT'; },
};
// Inject a recording _core stub so no bytes reach the real process stdout.
const mockCore = {
output: (...callArgs) => coreCalls.push(callArgs),
};
routeAuditOpen({
args: ['audit-open', '--json'],
cwd: CWD, raw: RAW, error: makeErrorRecorder(),
_audit: mockAudit,
_core: mockCore,
});
// auditOpenArtifacts called; formatAuditReport must NOT be called for --json
const fmtCalls = auditCalls.filter(c => c.fn === 'formatAuditReport');
assert.strictEqual(fmtCalls.length, 0, '--json mode must NOT call formatAuditReport');
const artifactCalls = auditCalls.filter(c => c.fn === 'auditOpenArtifacts');
assert.strictEqual(artifactCalls.length, 1, '--json mode must call auditOpenArtifacts once');
// Assert the exact 2-arg core.output call form for JSON mode:
// core.output(result, raw)
assert.strictEqual(coreCalls.length, 1, 'core.output must be called exactly once');
assert.strictEqual(coreCalls[0][0], FAKE_RESULT, 'json mode: first arg to core.output must be the result object');
assert.strictEqual(coreCalls[0][1], RAW, 'json mode: second arg to core.output must be raw');
assert.strictEqual(coreCalls[0].length, 2, 'json mode: core.output must be called with exactly 2 args');
});
});
// ─── 2. DISPATCH — commands reach routers via default-case ───────────────────
describe('audit cutover: dispatch path (default-case → capability registry)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('gsd-audit-cutover-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('audit-uat dispatches via capability registry (no "Unknown command" error)', () => {
const result = runGsdTools(['audit-uat'], tmpDir);
// audit-uat with a minimal project may succeed or fail on file-not-found;
// the key assertion is it never emits "Unknown command: audit-uat"
const isUnknownCmd = (result.error || '').includes('Unknown command: audit-uat');
assert.strictEqual(isUnknownCmd, false,
`Must not emit "Unknown command: audit-uat". stderr: ${result.error}`);
assert.ok(result.success,
`audit-uat must exit 0. stderr: ${result.error}`);
});
test('audit-open dispatches via capability registry (no "Unknown command" error)', () => {
const result = runGsdTools(['audit-open'], tmpDir);
const isUnknownCmd = (result.error || '').includes('Unknown command: audit-open');
assert.strictEqual(isUnknownCmd, false,
`Must not emit "Unknown command: audit-open". stderr: ${result.error}`);
assert.ok(result.success,
`audit-open must exit 0. stderr: ${result.error}`);
});
test('audit-open --json dispatches via capability registry', () => {
const result = runGsdTools(['audit-open', '--json'], tmpDir);
const isUnknownCmd = (result.error || '').includes('Unknown command: audit-open');
assert.strictEqual(isUnknownCmd, false,
`Must not emit "Unknown command: audit-open" with --json. stderr: ${result.error}`);
// Must also produce valid JSON output
assert.ok(result.success,
`audit-open --json must succeed. stderr: ${result.error}`);
assert.doesNotThrow(
() => JSON.parse(result.output),
'audit-open --json must produce valid JSON',
);
});
});
// ─── 3. BEHAVIOR — subprocess output shape (equivalence to old inline cases) ──
describe('audit cutover: output shape equivalence', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('gsd-audit-behavior-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('audit-open (text) succeeds and produces non-empty output', () => {
const result = runGsdTools(['audit-open'], tmpDir);
assert.ok(result.success,
`audit-open must succeed. stderr: ${result.error}`);
assert.ok(result.output && result.output.length > 0,
'audit-open text output must be non-empty');
// Must be raw text, not JSON-encoded (regression guard from #2911)
assert.ok(!result.output.startsWith('"'),
'text mode must not start with a JSON quote');
assert.ok(!result.output.includes('\\n'),
'text mode must not contain literal \\n sequences');
});
test('audit-open --json produces valid JSON with expected shape', () => {
const result = runGsdTools(['audit-open', '--json'], tmpDir);
assert.ok(result.success,
`audit-open --json must succeed. stderr: ${result.error}`);
let parsed;
assert.doesNotThrow(
() => { parsed = JSON.parse(result.output); },
'audit-open --json must emit valid JSON',
);
assert.equal(typeof parsed, 'object', 'parsed payload must be an object');
assert.ok(parsed !== null, 'parsed payload must not be null');
// Shape contract from auditOpenArtifacts() (regression guard from #2911)
assert.equal(typeof parsed.scanned_at, 'string', 'must include scanned_at');
assert.equal(typeof parsed.has_open_items, 'boolean', 'must include has_open_items');
assert.equal(typeof parsed.counts, 'object', 'must include counts');
assert.equal(typeof parsed.items, 'object', 'must include items');
});
test('audit-open (text) report title present as standalone line', () => {
const result = runGsdTools(['audit-open'], tmpDir);
assert.ok(result.success,
`audit-open must succeed. stderr: ${result.error}`);
const lines = result.output.split('\n').map(l => l.trim()).filter(Boolean);
assert.ok(
lines.includes('### Milestone Close: Open Artifact Audit'),
`report title must appear as a standalone Markdown heading line; got: ${JSON.stringify(lines.slice(0, 5))}`,
);
});
test('audit-uat succeeds and produces non-empty stdout', () => {
const result = runGsdTools(['audit-uat'], tmpDir);
assert.ok(result.success,
`audit-uat must succeed. stderr: ${result.error}`);
assert.ok(result.output && result.output.length > 0,
'audit-uat must write non-empty output to stdout');
});
test('audit-uat --raw flag passes through (does not break dispatch)', () => {
const result = runGsdTools(['audit-uat', '--raw'], tmpDir);
// --raw is a gsd-tools global flag; it modifies output encoding but
// the command must still succeed and produce output
assert.ok(result.success,
`audit-uat --raw must succeed. stderr: ${result.error}`);
});
});
// ─── 4. JSON-ERRORS — GSD_JSON_ERRORS mode passes through cleanly ────────────
describe('audit cutover: GSD_JSON_ERRORS mode (both commands succeed without structured error)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('gsd-audit-jsonerr-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('audit-open --json with GSD_JSON_ERRORS=1 succeeds (no spurious error payload)', () => {
// Successful commands must not emit JSON error payloads; verify exit 0.
const result = runGsdTools(['audit-open', '--json'], tmpDir, { GSD_JSON_ERRORS: '1' });
assert.ok(result.success,
`audit-open --json must succeed even with GSD_JSON_ERRORS=1; stderr: ${result.error}`);
});
test('audit-open text with GSD_JSON_ERRORS=1 succeeds (no spurious error payload)', () => {
const result = runGsdTools(['audit-open'], tmpDir, { GSD_JSON_ERRORS: '1' });
assert.ok(result.success,
`audit-open text mode must succeed even with GSD_JSON_ERRORS=1; stderr: ${result.error}`);
});
test('audit-uat with GSD_JSON_ERRORS=1 succeeds (no spurious error payload)', () => {
const result = runGsdTools(['audit-uat'], tmpDir, { GSD_JSON_ERRORS: '1' });
assert.ok(result.success,
`audit-uat must succeed even with GSD_JSON_ERRORS=1; stderr: ${result.error}`);
});
});
// ─── 5. REGISTRY — commandFamilies entries ───────────────────────────────────
describe('audit cutover: registry entries correct', () => {
test('commandFamilies["audit-uat"] present and well-shaped', () => {
const entry = registry.commandFamilies['audit-uat'];
assert.ok(entry, 'commandFamilies["audit-uat"] must be present');
assert.strictEqual(entry.capId, 'audit',
'commandFamilies["audit-uat"].capId must be "audit"');
assert.strictEqual(entry.module, 'audit-command-router.cjs',
'commandFamilies["audit-uat"].module must be "audit-command-router.cjs"');
assert.strictEqual(entry.router, 'routeAuditUat',
'commandFamilies["audit-uat"].router must be "routeAuditUat"');
});
test('commandFamilies["audit-open"] present and well-shaped', () => {
const entry = registry.commandFamilies['audit-open'];
assert.ok(entry, 'commandFamilies["audit-open"] must be present');
assert.strictEqual(entry.capId, 'audit',
'commandFamilies["audit-open"].capId must be "audit"');
assert.strictEqual(entry.module, 'audit-command-router.cjs',
'commandFamilies["audit-open"].module must be "audit-command-router.cjs"');
assert.strictEqual(entry.router, 'routeAuditOpen',
'commandFamilies["audit-open"].router must be "routeAuditOpen"');
});
test('capabilities.audit present with role:feature and tier:full', () => {
const cap = registry.capabilities.audit;
assert.ok(cap, 'capabilities.audit must be present');
assert.strictEqual(cap.role, 'feature', 'audit capability must have role: feature');
assert.strictEqual(cap.tier, 'full', 'audit capability must have tier: full');
});
test('capabilities.audit.commands has both audit-uat and audit-open entries', () => {
const cap = registry.capabilities.audit;
assert.ok(Array.isArray(cap.commands) && cap.commands.length === 2,
'audit capability must have exactly 2 commands');
const uatCmd = cap.commands.find(c => c.family === 'audit-uat');
assert.ok(uatCmd, 'commands must include audit-uat family');
assert.strictEqual(uatCmd.module, 'audit-command-router.cjs');
assert.strictEqual(uatCmd.router, 'routeAuditUat');
const openCmd = cap.commands.find(c => c.family === 'audit-open');
assert.ok(openCmd, 'commands must include audit-open family');
assert.strictEqual(openCmd.module, 'audit-command-router.cjs');
assert.strictEqual(openCmd.router, 'routeAuditOpen');
});
test('routeAuditUat and routeAuditOpen are exported functions', () => {
assert.strictEqual(typeof routeAuditUat, 'function',
'routeAuditUat must be an exported function');
assert.strictEqual(typeof routeAuditOpen, 'function',
'routeAuditOpen must be an exported function');
});
test('profileMembership.audit is vacuous (no skills → no skill-cluster entry)', () => {
// audit declares skills:[] → no skill-cluster-based profileMembership entry.
// This is correct: profileMembership tracks skill ownership, not capability existence.
const pm = registry.profileMembership.audit;
assert.strictEqual(pm, undefined,
'profileMembership.audit must be undefined (no skills declared)');
});
test('capabilityClusters.audit is vacuous (no skills → no cluster entry)', () => {
// Same as profileMembership — skill-less capabilities produce no cluster entries.
const clusters = registry.capabilityClusters.audit;
assert.strictEqual(clusters, undefined,
'capabilityClusters.audit must be undefined (no skills declared)');
});
test('audit has no skills — vacuous install/surface (no skill-index entries)', () => {
// audit capability declares no skills, so bySkill has no "audit" entry
// (there is no skill named "audit")
const cap = registry.capabilities.audit;
assert.deepStrictEqual(cap.skills, [],
'audit capability must have empty skills array');
});
test('graphify commandFamilies entry still present (no regression)', () => {
const entry = registry.commandFamilies['graphify'];
assert.ok(entry, 'commandFamilies["graphify"] must still be present');
assert.strictEqual(entry.capId, 'graphify');
assert.strictEqual(entry.module, 'graphify-command-router.cjs');
assert.strictEqual(entry.router, 'routeGraphifyCommand');
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2659-audit-open-crash.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2659-audit-open-crash (consolidation epic #1969 B2 #1971)", () => {
'use strict';
/**
* Regression test for #2659.
*
* The `audit-open` dispatch case in bin/gsd-tools.cjs previously called bare
* `output(...)` on both the --json and text branches. `output` is never in
* local scope — the entire core module is imported as `const core`, so every
* other case uses `core.output(...)`. The bare calls therefore crashed with
* `ReferenceError: output is not defined` the moment `audit-open` ran.
*
* This test runs both invocations against a minimal temp project and asserts
* they exit successfully with non-empty stdout. It fails with the
* ReferenceError on any revision that still has the bare `output(...)` calls.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('audit-open — does not crash with ReferenceError (#2659)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('gsd-bug-2659-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('audit-open (text output) succeeds and produces stdout', () => {
const result = runGsdTools('audit-open', tmpDir);
assert.ok(
result.success,
`audit-open must not crash. stderr: ${result.error}`
);
assert.ok(
!/ReferenceError.*output is not defined/.test(result.error || ''),
`audit-open must not throw ReferenceError. stderr: ${result.error}`
);
assert.ok(
result.output && result.output.length > 0,
'audit-open must write a non-empty report to stdout'
);
});
test('audit-open --json succeeds and produces stdout', () => {
const result = runGsdTools(['audit-open', '--json'], tmpDir);
assert.ok(
result.success,
`audit-open --json must not crash. stderr: ${result.error}`
);
assert.ok(
!/ReferenceError.*output is not defined/.test(result.error || ''),
`audit-open --json must not throw ReferenceError. stderr: ${result.error}`
);
assert.ok(
result.output && result.output.length > 0,
'audit-open --json must write output to stdout'
);
let parsed;
assert.doesNotThrow(
() => { parsed = JSON.parse(result.output); },
'audit-open --json must emit valid JSON'
);
assert.ok(
parsed !== null && typeof parsed === 'object',
'audit-open --json must emit a JSON object or array'
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2911-audit-open-output-shape.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2911-audit-open-output-shape (consolidation epic #1969 B2 #1971)", () => {
'use strict';
/**
* Regression test for #2911.
*
* Two bugs in the `audit-open` dispatch case in bin/gsd-tools.cjs:
*
* 1. Bare `output(...)` calls (only `core.output` is in scope) → ReferenceError.
* 2. Even after switching to `core.output(formatted, raw)`, the human-readable
* branch JSON-stringifies the formatted string because `core.output` only
* bypasses JSON encoding when called as `core.output(null, true, rawValue)`.
* Result: stdout contains `"### Milestone Close: …\n…"` (a JSON string
* literal) instead of the rendered report.
*
* The shape assertions below catch both regressions structurally — never via
* substring matching on serialized output:
*
* - text mode: parse stdout as a sequence of lines and assert the expected
* section headers exist as standalone lines (i.e. raw text, not escaped).
* If the report is JSON-stringified, the stdout is a single line wrapped
* in double quotes with `\n` escapes — line-array assertions fail.
* - --json mode: JSON.parse the stdout and assert the keys returned by
* `auditOpenArtifacts(cwd)` (scanned_at, has_open_items, counts, items)
* are present and well-typed.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// ─── #3458 fixtures: phase-scoped scanners must also see archived phases ─────
//
// scanUatGaps, scanVerificationGaps, scanContextQuestions, and scanDeferredItems
// resolve ONLY the active `.planning/phases/` root. Once a milestone closes and
// its phase dirs move to `.planning/milestones/v<X.Y>-phases/`, items still
// unresolved at that moment become invisible to every later audit — these
// fixtures carry one item of each of the four kinds, in both an "unresolved"
// (must be counted) and a "resolved" (must NOT be counted) shape, verified
// against the real scanner formats before being repurposed for the archived
// layout below.
const UAT_GAP_UNRESOLVED = [
'# UAT',
'',
'## Gaps',
'',
'- truth: an unresolved UAT gap that survived milestone close',
' status: open',
'',
].join('\n');
const UAT_GAP_RESOLVED = [
'---',
'status: resolved',
'---',
'',
'# UAT',
'',
'## Gaps',
'',
'- truth: a gap that was resolved',
'',
].join('\n');
const VERIFICATION_GAP_UNRESOLVED = [
'---',
'status: gaps_found',
'---',
'',
'# Verification',
'',
'Gaps found during verification.',
'',
].join('\n');
const VERIFICATION_GAP_RESOLVED = [
'---',
'status: passed',
'---',
'',
'# Verification',
'',
'All checks passed.',
'',
].join('\n');
const CONTEXT_QUESTION_OPEN = [
'# Context',
'',
'## Open Questions',
'',
'- Should this default to strict mode?',
'',
].join('\n');
const CONTEXT_QUESTION_RESOLVED = [
'# Context',
'',
'## Open Questions',
'',
'None',
'',
].join('\n');
const DEFERRED_ITEM_UNRESOLVED = [
'# Deferred Items',
'',
'- **STILL-OPEN:** an unresolved deferred item that survived milestone close',
'',
].join('\n');
const DEFERRED_ITEM_RESOLVED = [
'# Deferred Items',
'',
'- **RESOLVED-ITEM:** an item that was resolved',
' status: resolved',
'',
].join('\n');
/**
* Write one phase's worth of UAT/VERIFICATION/CONTEXT/deferred-items files
* (one of each of the four scanner-recognized kinds) into `phaseDir`, using
* `phaseNumberPrefix` (e.g. '01') as the file-token so `scopeToPhase` (#3511)
* accepts them for a dir named `<phaseNumberPrefix>-<slug>`.
*/
function writePhaseArtifacts(phaseDir, phaseNumberPrefix, { uat, verification, context, deferred }) {
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, `${phaseNumberPrefix}-UAT.md`), uat);
fs.writeFileSync(path.join(phaseDir, `${phaseNumberPrefix}-VERIFICATION.md`), verification);
fs.writeFileSync(path.join(phaseDir, `${phaseNumberPrefix}-CONTEXT.md`), context);
fs.writeFileSync(path.join(phaseDir, 'deferred-items.md'), deferred);
}
const UNRESOLVED_ARTIFACTS = {
uat: UAT_GAP_UNRESOLVED,
verification: VERIFICATION_GAP_UNRESOLVED,
context: CONTEXT_QUESTION_OPEN,
deferred: DEFERRED_ITEM_UNRESOLVED,
};
const RESOLVED_ARTIFACTS = {
uat: UAT_GAP_RESOLVED,
verification: VERIFICATION_GAP_RESOLVED,
context: CONTEXT_QUESTION_RESOLVED,
deferred: DEFERRED_ITEM_RESOLVED,
};
describe('audit-open — output shape (#2911)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('gsd-bug-2911-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('text mode emits the formatted report as raw text (not JSON-encoded)', () => {
const result = runGsdTools('audit-open', tmpDir);
assert.ok(
result.success,
`audit-open must not crash. stderr: ${result.error}`
);
const lines = result.output.split('\n').map(l => l.trim()).filter(Boolean);
// The first non-empty line must be the rendered Markdown heading row, *not*
// a JSON-encoded string starting with a quote. If core.output JSON-stringified
// the formatted report, the entire payload sits on one line wrapped in
// double quotes ("### Milestone Close: …\n…").
assert.ok(
!result.output.startsWith('"'),
'text-mode stdout must not begin with a JSON quote (would mean the report was JSON.stringified)'
);
assert.ok(
!result.output.includes('\\n'),
'text-mode stdout must not contain literal "\\n" sequences (would mean the report was JSON.stringified)'
);
// Section headers from formatAuditReport that must appear as standalone lines.
assert.ok(
lines.includes('### Milestone Close: Open Artifact Audit'),
`expected report title as a standalone Markdown heading line; got lines: ${JSON.stringify(lines.slice(0, 5))}`
);
assert.ok(
lines.includes('All artifact types clear. Safe to proceed.'),
`expected the empty-state line as standalone text; got lines: ${JSON.stringify(lines)}`
);
});
test('--json mode emits parseable JSON matching auditOpenArtifacts shape', () => {
const result = runGsdTools(['audit-open', '--json'], tmpDir);
assert.ok(
result.success,
`audit-open --json must not crash. stderr: ${result.error}`
);
let parsed;
assert.doesNotThrow(
() => { parsed = JSON.parse(result.output); },
'audit-open --json must emit valid JSON (not a doubly-stringified string)'
);
assert.equal(typeof parsed, 'object', 'parsed payload must be an object');
assert.ok(parsed !== null, 'parsed payload must not be null');
// Shape contract from auditOpenArtifacts() in gsd-core/bin/lib/audit.cjs.
assert.equal(typeof parsed.scanned_at, 'string', 'must include scanned_at ISO timestamp');
assert.equal(typeof parsed.has_open_items, 'boolean', 'must include has_open_items boolean');
assert.equal(typeof parsed.counts, 'object', 'must include counts object');
assert.equal(typeof parsed.items, 'object', 'must include items object');
const expectedCountKeys = [
'debug_sessions', 'quick_tasks', 'threads', 'todos',
'seeds', 'uat_gaps', 'verification_gaps', 'context_questions',
'deferred_items', 'total',
];
for (const key of expectedCountKeys) {
assert.equal(
typeof parsed.counts[key], 'number',
`counts.${key} must be a number`
);
}
const expectedItemKeys = [
'debug_sessions', 'quick_tasks', 'threads', 'todos',
'seeds', 'uat_gaps', 'verification_gaps', 'context_questions',
'deferred_items',
];
for (const key of expectedItemKeys) {
assert.ok(
Array.isArray(parsed.items[key]),
`items.${key} must be an array`
);
}
});
// ── #3458: phase-scoped scanners must also see archived phases ────────────
//
// scanUatGaps, scanVerificationGaps, scanContextQuestions, and
// scanDeferredItems resolve ONLY the active `.planning/phases/` root. Once a
// milestone closes and its phase dirs move to
// `.planning/milestones/v<X.Y>-phases/`, items still unresolved at that
// moment become invisible to every later audit.
test('#3458 archived-only project: unresolved items in .planning/milestones/vX.Y-phases/ are counted', () => {
// The active phases root is scaffolded empty by createTempProject; the bug
// report's own repro has it ABSENT entirely in a fully-archived project —
// remove it so this fixture matches that exactly, not just "empty".
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- removing only the .planning/phases subdir within a still-live fixture (INFO-7 fix for #3458 review: fs.rmdirSync threw on a non-empty dir); helpers.cleanup() tears down the whole tmpDir, not a subdirectory, so it cannot substitute here.
fs.rmSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true, force: true });
const archivedPhaseDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '01-alpha');
writePhaseArtifacts(archivedPhaseDir, '01', UNRESOLVED_ARTIFACTS);
const result = runGsdTools(['audit-open', '--json'], tmpDir);
assert.ok(result.success, `audit-open --json must not crash. stderr: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.equal(parsed.counts.uat_gaps, 1, `uat_gaps: expected 1, got ${parsed.counts.uat_gaps}`);
assert.equal(parsed.counts.verification_gaps, 1, `verification_gaps: expected 1, got ${parsed.counts.verification_gaps}`);
assert.equal(parsed.counts.context_questions, 1, `context_questions: expected 1, got ${parsed.counts.context_questions}`);
assert.equal(parsed.counts.deferred_items, 1, `deferred_items: expected 1, got ${parsed.counts.deferred_items}`);
assert.equal(parsed.has_open_items, true, 'has_open_items must be true when an archived phase carries unresolved items');
});
test('#3458 mixed project: active AND archived phases are both scanned and summed (not one replacing the other)', () => {
const activePhaseDir = path.join(tmpDir, '.planning', 'phases', '01-alpha');
writePhaseArtifacts(activePhaseDir, '01', UNRESOLVED_ARTIFACTS);
const archivedPhaseDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '02-beta');
writePhaseArtifacts(archivedPhaseDir, '02', UNRESOLVED_ARTIFACTS);
const result = runGsdTools(['audit-open', '--json'], tmpDir);
assert.ok(result.success, `audit-open --json must not crash. stderr: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.equal(parsed.counts.uat_gaps, 2, `uat_gaps: expected 2 (1 active + 1 archived), got ${parsed.counts.uat_gaps}`);
assert.equal(parsed.counts.verification_gaps, 2, `verification_gaps: expected 2, got ${parsed.counts.verification_gaps}`);
assert.equal(parsed.counts.context_questions, 2, `context_questions: expected 2, got ${parsed.counts.context_questions}`);
assert.equal(parsed.counts.deferred_items, 2, `deferred_items: expected 2, got ${parsed.counts.deferred_items}`);
assert.equal(parsed.has_open_items, true, 'has_open_items must be true');
});
test('#3458 active-only project: unchanged behavior, unresolved items still counted (guards the pre-existing path)', () => {
const activePhaseDir = path.join(tmpDir, '.planning', 'phases', '01-alpha');
writePhaseArtifacts(activePhaseDir, '01', UNRESOLVED_ARTIFACTS);
const result = runGsdTools(['audit-open', '--json'], tmpDir);
assert.ok(result.success, `audit-open --json must not crash. stderr: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.equal(parsed.counts.uat_gaps, 1, `uat_gaps: expected 1, got ${parsed.counts.uat_gaps}`);
assert.equal(parsed.counts.verification_gaps, 1, `verification_gaps: expected 1, got ${parsed.counts.verification_gaps}`);
assert.equal(parsed.counts.context_questions, 1, `context_questions: expected 1, got ${parsed.counts.context_questions}`);
assert.equal(parsed.counts.deferred_items, 1, `deferred_items: expected 1, got ${parsed.counts.deferred_items}`);
assert.equal(parsed.has_open_items, true, 'has_open_items must be true');
});
test('#3458 archived-only project with all-RESOLVED items: contributes 0 (fix must not blindly count archived files)', () => {
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- removing only the .planning/phases subdir within a still-live fixture (INFO-7 fix for #3458 review: fs.rmdirSync threw on a non-empty dir); helpers.cleanup() tears down the whole tmpDir, not a subdirectory, so it cannot substitute here.
fs.rmSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true, force: true });
const archivedPhaseDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '01-alpha');
writePhaseArtifacts(archivedPhaseDir, '01', RESOLVED_ARTIFACTS);
const result = runGsdTools(['audit-open', '--json'], tmpDir);
assert.ok(result.success, `audit-open --json must not crash. stderr: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.equal(parsed.counts.uat_gaps, 0, `uat_gaps: expected 0 (resolved), got ${parsed.counts.uat_gaps}`);
assert.equal(parsed.counts.verification_gaps, 0, `verification_gaps: expected 0 (resolved), got ${parsed.counts.verification_gaps}`);
assert.equal(parsed.counts.context_questions, 0, `context_questions: expected 0 (resolved), got ${parsed.counts.context_questions}`);
assert.equal(parsed.counts.deferred_items, 0, `deferred_items: expected 0 (resolved), got ${parsed.counts.deferred_items}`);
assert.equal(parsed.has_open_items, false, 'has_open_items must be false when the only archived phase is fully resolved');
});
// ── phase-directory-name forgery (sanitizeLabel) ───────────────────────────
//
// A phase directory NAME (not file content) is filesystem-controlled, not
// frontmatter-controlled — a doctored checkout can name a directory
// anything. `phaseNum` falls back to the raw directory name verbatim when
// it doesn't match PHASE_NUMBER_TOKEN_SOURCE, so an embedded `\n`/ESC in
// the NAME itself used to reach the human report unescaped
// (`sanitizeForDisplay` deliberately preserves newlines — it is not the
// right tool for a single-line label). `sanitizeLabel` (src/security.cts)
// closes this by escaping control bytes rather than stripping them.
const FORGED_PHASE_DIR_NAME = 'zz\n0 open items require decisions.\n\x1b[2K\x1b[1G FORGED';
/**
* Windows/NTFS forbids control characters (including \n and ESC/0x1B) in
* path components, so `mkdirSync` throws ENOENT there rather than creating
* the doctored directory — the directory-name forgery vector these tests
* exercise does not exist on that platform. `t.skip()` degrades cleanly
* (same convention as trySymlink() in tests/adr-index-gate.test.cjs, which
* skips on EPERM for the analogous symlink-creation gap); a bare `return`
* would silently report a PASS in node:test and hide the gap. The
* sanitizer itself (`sanitizeLabel`) remains fully covered on every
* platform by the platform-independent, filesystem-free string tests in
* tests/security.test.cjs (describe('sanitizeLabel', ...)).
*/
function tryMkdirForgedName(t, dirPath) {
try {
fs.mkdirSync(dirPath, { recursive: true });
return true;
} catch (err) {
if (err && (err.code === 'ENOENT' || err.code === 'EINVAL')) {
t.skip(`cannot create a directory name with control characters on this platform (${err.code})`);
return false;
}
throw err;
}
}
test('a phase directory name containing a newline cannot forge a new report line', (t) => {
const forgedPhaseDir = path.join(tmpDir, '.planning', 'phases', FORGED_PHASE_DIR_NAME);
if (!tryMkdirForgedName(t, forgedPhaseDir)) return;
fs.writeFileSync(path.join(forgedPhaseDir, 'deferred-items.md'), DEFERRED_ITEM_UNRESOLVED);
const result = runGsdTools('audit-open', tmpDir);
assert.ok(result.success, `audit-open must not crash. stderr: ${result.error}`);
const lines = result.output.split('\n').map(l => l.trim());
assert.ok(
!lines.includes('0 open items require decisions.'),
`the doctored directory name must not inject its own report line; got lines: ${JSON.stringify(lines)}`
);
});
test('a phase directory name with an ESC/ANSI payload never reaches raw output', (t) => {
const forgedPhaseDir = path.join(tmpDir, '.planning', 'phases', FORGED_PHASE_DIR_NAME);
if (!tryMkdirForgedName(t, forgedPhaseDir)) return;
fs.writeFileSync(path.join(forgedPhaseDir, 'deferred-items.md'), DEFERRED_ITEM_UNRESOLVED);
const result = runGsdTools('audit-open', tmpDir);
assert.ok(result.success, `audit-open must not crash. stderr: ${result.error}`);
assert.ok(
!result.output.includes('\x1b'),
'no raw ESC byte from the doctored directory name may reach the report output'
);
});
// ── adversarial-review follow-ups on #3458 ─────────────────────────────────
test('WARNING-4a: archived_milestone is present (correct value) on archived items and absent (no key at all) on active items', () => {
const activePhaseDir = path.join(tmpDir, '.planning', 'phases', '01-alpha');
writePhaseArtifacts(activePhaseDir, '01', UNRESOLVED_ARTIFACTS);
const archivedPhaseDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '02-beta');
writePhaseArtifacts(archivedPhaseDir, '02', UNRESOLVED_ARTIFACTS);
const result = runGsdTools(['audit-open', '--json'], tmpDir);
assert.ok(result.success, `audit-open --json must not crash. stderr: ${result.error}`);
const parsed = JSON.parse(result.output);
for (const category of ['uat_gaps', 'verification_gaps', 'context_questions', 'deferred_items']) {
const items = parsed.items[category].filter(i => !i.scan_error);
const active = items.find(i => i.phase === '01');
const archived = items.find(i => i.phase === '02');
assert.ok(active, `${category}: expected an active-phase item; got: ${JSON.stringify(items)}`);
assert.ok(archived, `${category}: expected an archived-phase item; got: ${JSON.stringify(items)}`);
assert.strictEqual('archived_milestone' in active, false,
`${category}: active item must not carry the archived_milestone key at all`);
assert.strictEqual(archived.archived_milestone, 'v1.0',
`${category}: archived item must carry archived_milestone: 'v1.0'`);
}
});
test('BLOCKER-1 regression: an unreadable active root (a FILE at .planning/phases) still yields a scan_error sentinel in all four phase-scoped categories', () => {
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- removing only the .planning/phases subdir within a still-live fixture (INFO-7 fix for #3458 review: fs.rmdirSync threw on a non-empty dir); helpers.cleanup() tears down the whole tmpDir, not a subdirectory, so it cannot substitute here.
fs.rmSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true, force: true });
fs.writeFileSync(path.join(tmpDir, '.planning', 'phases'), 'not a directory');
const result = runGsdTools(['audit-open', '--json'], tmpDir);
assert.ok(result.success, `audit-open --json must not crash. stderr: ${result.error}`);
const parsed = JSON.parse(result.output);
for (const category of ['uat_gaps', 'verification_gaps', 'context_questions', 'deferred_items']) {
const sentinel = parsed.items[category].find(i => i.scan_error === true);
assert.ok(sentinel,
`${category}: expected a scan_error sentinel when the active root is unreadable (ENOTDIR); ` +
`got: ${JSON.stringify(parsed.items[category])}`);
}
});
test('an unreadable archived root does not prevent the active half from being scanned (no sentinel for the archive half — see docstring)', () => {
const activePhaseDir = path.join(tmpDir, '.planning', 'phases', '01-alpha');
writePhaseArtifacts(activePhaseDir, '01', UNRESOLVED_ARTIFACTS);
// Make `.planning/milestones` an unreadable FILE instead of a directory so
// getArchivedPhaseDirs's readdirSync throws (ENOTDIR).
fs.writeFileSync(path.join(tmpDir, '.planning', 'milestones'), 'not a directory');
const result = runGsdTools(['audit-open', '--json'], tmpDir);
assert.ok(result.success, `audit-open --json must not crash. stderr: ${result.error}`);
const parsed = JSON.parse(result.output);
// Active half still scanned — the four counts include the active item.
assert.equal(parsed.counts.uat_gaps, 1, `uat_gaps: expected 1 (active only), got ${parsed.counts.uat_gaps}`);
assert.equal(parsed.counts.verification_gaps, 1, `verification_gaps: expected 1, got ${parsed.counts.verification_gaps}`);
assert.equal(parsed.counts.context_questions, 1, `context_questions: expected 1, got ${parsed.counts.context_questions}`);
assert.equal(parsed.counts.deferred_items, 1, `deferred_items: expected 1, got ${parsed.counts.deferred_items}`);
// Pinned decision: an unreadable archive root does NOT get a scan_error
// sentinel (no pre-#3458 contract to preserve for it — see the
// listAuditPhaseTargets docstring).
for (const category of ['uat_gaps', 'verification_gaps', 'context_questions', 'deferred_items']) {
const hasSentinel = parsed.items[category].some(i => i.scan_error === true);
assert.strictEqual(hasSentinel, false,
`${category}: an unreadable archive root must not produce a scan_error sentinel; got: ${JSON.stringify(parsed.items[category])}`);
}
});
test('WARNING-3: duplicate phase name across active and archived roots produces two distinct entries, and the human report distinguishes them', () => {
const activePhaseDir = path.join(tmpDir, '.planning', 'phases', '01-alpha');
writePhaseArtifacts(activePhaseDir, '01', UNRESOLVED_ARTIFACTS);
const archivedPhaseDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '01-alpha');
writePhaseArtifacts(archivedPhaseDir, '01', UNRESOLVED_ARTIFACTS);
const jsonResult = runGsdTools(['audit-open', '--json'], tmpDir);
assert.ok(jsonResult.success, `audit-open --json must not crash. stderr: ${jsonResult.error}`);
const parsed = JSON.parse(jsonResult.output);
const uatEntries = parsed.items.uat_gaps.filter(i => !i.scan_error);
assert.equal(uatEntries.length, 2,
`same-named active + archived phase must produce two distinct uat_gaps entries; got: ${JSON.stringify(uatEntries)}`);
const archivedFlags = uatEntries.map(i => Boolean(i.archived_milestone)).sort();
assert.deepEqual(archivedFlags, [false, true],
'exactly one of the two duplicate-named entries must carry archived_milestone');
const textResult = runGsdTools(['audit-open'], tmpDir);
assert.ok(textResult.success, `audit-open (text) must not crash. stderr: ${textResult.error}`);
const uatLines = textResult.output.split('\n').filter(l => l.includes('01-UAT.md'));
assert.equal(uatLines.length, 2,
`expected two UAT-gap report lines (one active, one archived); got: ${JSON.stringify(uatLines)}`);
assert.notStrictEqual(uatLines[0], uatLines[1],
'the active and archived duplicate-named entries must render as distinguishable lines, not byte-identical duplicates');
assert.ok(uatLines.some(l => l.includes('archived v1.0')),
`expected one report line to be labeled with its archived milestone; got: ${JSON.stringify(uatLines)}`);
});
test('INFO-5: archived milestones v1.0, v1.9, v1.10 sort newest-first, numerically (v1.10 before v1.9, not lexicographically)', () => {
writePhaseArtifacts(path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '01-a'), '01', UNRESOLVED_ARTIFACTS);
writePhaseArtifacts(path.join(tmpDir, '.planning', 'milestones', 'v1.9-phases', '01-b'), '01', UNRESOLVED_ARTIFACTS);
writePhaseArtifacts(path.join(tmpDir, '.planning', 'milestones', 'v1.10-phases', '01-c'), '01', UNRESOLVED_ARTIFACTS);
const result = runGsdTools(['audit-open', '--json'], tmpDir);
assert.ok(result.success, `audit-open --json must not crash. stderr: ${result.error}`);
const parsed = JSON.parse(result.output);
const milestoneOrder = parsed.items.uat_gaps.filter(i => !i.scan_error).map(i => i.archived_milestone);
assert.deepEqual(milestoneOrder, ['v1.10', 'v1.9', 'v1.0'],
`expected numeric-aware newest-first ordering (v1.10, v1.9, v1.0); got: ${JSON.stringify(milestoneOrder)}`);
});
// ── quick-task directory-name forgery (sanitizeLabel) ──────────────────────
//
// scanQuickTasks derives `slug` from the `.planning/quick/<dirName>`
// directory NAME (filesystem-controlled, same shape as the phase-directory
// case above), not from file content. Before sanitizeLabel was applied
// here, an embedded `\n`/ESC byte in the directory name reached the human
// report unescaped via `sanitizeForDisplay` (which deliberately preserves
// newlines — it is not the right tool for a single-line label).
const FORGED_QUICK_TASK_DIR_NAME = 'zz\n0 open items require decisions.\n\x1b[2K\x1b[1G FORGED';
test('a quick-task directory name containing a newline cannot forge a new report line', (t) => {
const forgedQuickDir = path.join(tmpDir, '.planning', 'quick', FORGED_QUICK_TASK_DIR_NAME);
if (!tryMkdirForgedName(t, forgedQuickDir)) return;
const result = runGsdTools('audit-open', tmpDir);
assert.ok(result.success, `audit-open must not crash. stderr: ${result.error}`);
const lines = result.output.split('\n').map(l => l.trim());
assert.ok(
!lines.includes('0 open items require decisions.'),
`the doctored directory name must not inject its own report line; got lines: ${JSON.stringify(lines)}`
);
});
test('a quick-task directory name with an ESC/ANSI payload never reaches raw output', (t) => {
const forgedQuickDir = path.join(tmpDir, '.planning', 'quick', FORGED_QUICK_TASK_DIR_NAME);
if (!tryMkdirForgedName(t, forgedQuickDir)) return;
const result = runGsdTools('audit-open', tmpDir);
assert.ok(result.success, `audit-open must not crash. stderr: ${result.error}`);
assert.ok(
!result.output.includes('\x1b'),
'no raw ESC byte from the doctored directory name may reach the report output'
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2836-audit-open-summary-uat-drift.test.cjs — consolidation epic #1969 (B3 #1972)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2836-audit-open-summary-uat-drift (consolidation epic #1969 B3 #1972)", () => {
/**
* Regression tests for bug #2836
*
* audit-open had two convention drifts vs the documented workflows:
* 1. quick-task scanner looked for bare `SUMMARY.md`, but workflows/quick.md
* mandates `${quick_id}-SUMMARY.md`. Result: every documented quick task
* reported as `status: missing`.
* 2. UAT terminal-status enum only accepted `complete`, but
* workflows/execute-phase.md uses `resolved` post-gap-closure.
* Result: gap-closed UATs reported as open.
*
* Tests structurally invoke auditOpenArtifacts() against real fixtures on disk
* and assert the returned items array — never regex on raw file content.
*/
'use strict';
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const auditModule = require('../gsd-core/bin/lib/audit.cjs');
const { auditOpenArtifacts } = auditModule;
const { cleanup } = require('./helpers.cjs');
function mkTmp() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'bug-2836-'));
}
describe('bug #2836: audit-open quick-task summary filename + UAT terminal status', () => {
// Ensure GSD env vars do not redirect planningDir() away from our fixture.
let prevProject, prevWorkstream;
before(() => {
prevProject = process.env.GSD_PROJECT;
prevWorkstream = process.env.GSD_WORKSTREAM;
delete process.env.GSD_PROJECT;
delete process.env.GSD_WORKSTREAM;
});
after(() => {
if (prevProject !== undefined) process.env.GSD_PROJECT = prevProject;
if (prevWorkstream !== undefined) process.env.GSD_WORKSTREAM = prevWorkstream;
});
test('quick task with ${quick_id}-SUMMARY.md is recognized as complete (not missing)', () => {
const cwd = mkTmp();
try {
const quickId = '260429-test-foo';
const taskDir = path.join(cwd, '.planning', 'quick', quickId);
fs.mkdirSync(taskDir, { recursive: true });
fs.writeFileSync(
path.join(taskDir, `${quickId}-SUMMARY.md`),
'---\nstatus: complete\n---\ntest summary\n',
'utf-8'
);
const result = auditOpenArtifacts(cwd);
const realQuickTasks = result.items.quick_tasks.filter(
i => !i.scan_error && !i._remainder_count
);
assert.equal(
realQuickTasks.length, 0,
`quick task with ${quickId}-SUMMARY.md (status: complete) must not appear ` +
`as an open item; got: ${JSON.stringify(realQuickTasks)}`
);
assert.equal(result.counts.quick_tasks, 0);
} finally {
cleanup(cwd);
}
});
test('UAT with status: resolved is treated as terminal (not an open gap)', () => {
const cwd = mkTmp();
try {
const phaseDir = path.join(cwd, '.planning', 'phases', '01-test');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '01-UAT.md'),
'---\nstatus: resolved\n---\nUAT body — gap closed via execute-phase flow.\n',
'utf-8'
);
const result = auditOpenArtifacts(cwd);
const realUatGaps = result.items.uat_gaps.filter(i => !i.scan_error);
assert.equal(
realUatGaps.length, 0,
`UAT with status: resolved must not appear as an open gap; ` +
`got: ${JSON.stringify(realUatGaps)}`
);
assert.equal(result.counts.uat_gaps, 0);
} finally {
cleanup(cwd);
}
});
test('UAT with status: complete remains terminal (no regression)', () => {
const cwd = mkTmp();
try {
const phaseDir = path.join(cwd, '.planning', 'phases', '02-test');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '02-UAT.md'),
'---\nstatus: complete\n---\nUAT body.\n',
'utf-8'
);
const result = auditOpenArtifacts(cwd);
const realUatGaps = result.items.uat_gaps.filter(i => !i.scan_error);
assert.equal(realUatGaps.length, 0);
} finally {
cleanup(cwd);
}
});
test('UAT with status: pending is still flagged as an open gap', () => {
const cwd = mkTmp();
try {
const phaseDir = path.join(cwd, '.planning', 'phases', '03-test');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '03-UAT.md'),
'---\nstatus: pending\n---\nresult: pending\n',
'utf-8'
);
const result = auditOpenArtifacts(cwd);
const realUatGaps = result.items.uat_gaps.filter(i => !i.scan_error);
assert.equal(realUatGaps.length, 1, 'pending UAT must still be flagged');
assert.equal(realUatGaps[0].status, 'pending');
} finally {
cleanup(cwd);
}
});
test('#3511: scanUatGaps excludes a cross-phase stray UAT file sitting in the same phase dir; this phase\'s own open gap still reports', () => {
const cwd = mkTmp();
try {
const phaseDir = path.join(cwd, '.planning', 'phases', '03-test');
fs.mkdirSync(phaseDir, { recursive: true });
// This phase's own open UAT gap.
fs.writeFileSync(
path.join(phaseDir, '03-UAT.md'),
'---\nstatus: pending\n---\nresult: pending\n',
'utf-8',
);
// Cross-phase stray in the SAME directory — token "04", not "03".
fs.writeFileSync(
path.join(phaseDir, '04-UAT.md'),
'---\nstatus: pending\n---\nresult: pending\n',
'utf-8',
);
const result = auditOpenArtifacts(cwd);
const realUatGaps = result.items.uat_gaps.filter(i => !i.scan_error);
assert.equal(realUatGaps.length, 1,
`only this phase's own gap must report; got: ${JSON.stringify(realUatGaps)}`);
assert.equal(realUatGaps[0].file, '03-UAT.md');
assert.equal(realUatGaps[0].phase, '03');
assert.ok(!realUatGaps.some(i => i.file === '04-UAT.md'),
'the cross-phase stray must not appear in uat_gaps');
assert.equal(result.counts.uat_gaps, 1);
} finally {
cleanup(cwd);
}
});
test('#3511: scanVerificationGaps excludes a cross-phase stray VERIFICATION file sitting in the same phase dir; this phase\'s own open gap still reports', () => {
const cwd = mkTmp();
try {
const phaseDir = path.join(cwd, '.planning', 'phases', '03-test');
fs.mkdirSync(phaseDir, { recursive: true });
// This phase's own open VERIFICATION gap.
fs.writeFileSync(
path.join(phaseDir, '03-VERIFICATION.md'),
'---\nstatus: gaps_found\n---\n# Verification\n',
'utf-8',
);
// Cross-phase stray in the SAME directory — token "04", not "03".
fs.writeFileSync(
path.join(phaseDir, '04-VERIFICATION.md'),
'---\nstatus: human_needed\n---\n# Verification\n',
'utf-8',
);
const result = auditOpenArtifacts(cwd);
const realVerificationGaps = result.items.verification_gaps.filter(i => !i.scan_error);
assert.equal(realVerificationGaps.length, 1,
`only this phase's own gap must report; got: ${JSON.stringify(realVerificationGaps)}`);
assert.equal(realVerificationGaps[0].file, '03-VERIFICATION.md');
assert.equal(realVerificationGaps[0].phase, '03');
assert.ok(!realVerificationGaps.some(i => i.file === '04-VERIFICATION.md'),
'the cross-phase stray must not appear in verification_gaps');
assert.equal(result.counts.verification_gaps, 1);
} finally {
cleanup(cwd);
}
});
test('#3511 follow-up: own gap still reports from a NON-canonical dir shape (over-exclusion check)', () => {
const cwd = mkTmp();
try {
// "1-unpadded" tokenizes to literal "1", but scaffold writes the PADDED
// "01-…" form — a literal token compare excluded the phase's own file.
const phaseDir = path.join(cwd, '.planning', 'phases', '1-unpadded');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '01-UAT.md'),
'---\nstatus: partial\n---\n\n## Tests\n\n### 1. Test\nexpected: works\nresult: pending\n',
'utf-8',
);
fs.writeFileSync(
path.join(phaseDir, '01-VERIFICATION.md'),
'---\nstatus: gaps_found\n---\n# Verification\n',
'utf-8',
);
const result = auditOpenArtifacts(cwd);
const realUatGaps = result.items.uat_gaps.filter(i => !i.scan_error);
const realVerificationGaps = result.items.verification_gaps.filter(i => !i.scan_error);
assert.equal(realUatGaps.length, 1,
`own UAT gap in an unpadded-dir phase must still report; got: ${JSON.stringify(realUatGaps)}`);
assert.equal(realVerificationGaps.length, 1,
`own VERIFICATION gap in an unpadded-dir phase must still report; got: ${JSON.stringify(realVerificationGaps)}`);
} finally {
cleanup(cwd);
}
});
test('quick task without any SUMMARY file is still flagged as missing', () => {
const cwd = mkTmp();
try {
const quickId = '260429-test-bar';
const taskDir = path.join(cwd, '.planning', 'quick', quickId);
fs.mkdirSync(taskDir, { recursive: true });
// No SUMMARY file at all.
const result = auditOpenArtifacts(cwd);
const realQuickTasks = result.items.quick_tasks.filter(
i => !i.scan_error && !i._remainder_count
);
assert.equal(realQuickTasks.length, 1);
assert.equal(realQuickTasks[0].status, 'missing');
} finally {
cleanup(cwd);
}
});
});
describe('bug #2836: workflows/help.md one-liner reconciliation', () => {
test('help.md quick-task one-liner uses ${quick_id}-SUMMARY.md pattern', () => {
// After #3039, help content moved into help/modes/full.md.
const helpPath = path.resolve(
__dirname, '..', 'gsd-core', 'workflows', 'help', 'modes', 'full.md'
);
const content = fs.readFileSync(helpPath, 'utf-8');
// Locate the documented "Result: Creates ..." quick-task one-liner and
// assert it references the per-task SUMMARY filename pattern, not bare
// SUMMARY.md. We parse by line to avoid false positives elsewhere.
const resultLines = content.split(/\r?\n/).filter(l =>
l.includes('Result: Creates') && l.includes('.planning/quick/')
);
assert.ok(resultLines.length > 0, 'expected a quick-task Result line in help.md');
for (const line of resultLines) {
assert.ok(
/\$\{quick_id\}-SUMMARY\.md|NNN-slug-SUMMARY\.md/.test(line),
`help.md quick-task Result line must reference per-task SUMMARY filename ` +
`(e.g. \${quick_id}-SUMMARY.md); got: ${line}`
);
}
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-950-quick-summary-status-complete.test.cjs — consolidation epic #1969 (B3 #1972)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-950-quick-summary-status-complete (consolidation epic #1969 B3 #1972)", () => {
// allow-test-rule: source-text-is-the-product (see #950)
/**
* Regression tests for bug #950
*
* audit-open chronically flagged genuinely-complete quick tasks as [unknown]
* because NO shipped summary template carried a `status:` frontmatter field —
* so status was only emitted when the writing agent improvised it.
*
* The fix: add `status: complete` to all four summary templates and enforce it
* in the executor agent + quick.md workflow. Tests here exercise the scanner
* directly via auditOpenArtifacts() and also guard template text as a secondary
* contract check.
*
* Primary guard: behavioral audit-scanner tests (tasks read by scanQuickTasks)
* Secondary guard: template-contract text checks (template text IS the runtime contract)
* Writer-path guard: contract assertions on quick.md + gsd-executor.md (source-text-is-the-product)
*/
'use strict';
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const auditModule = require('../gsd-core/bin/lib/audit.cjs');
const { auditOpenArtifacts } = auditModule;
const { cleanup } = require('./helpers.cjs');
const TEMPLATES_DIR = path.resolve(__dirname, '..', 'gsd-core', 'templates');
const QUICK_MD = path.resolve(__dirname, '..', 'gsd-core', 'workflows', 'quick.md');
const EXECUTOR_MD = path.resolve(__dirname, '..', 'agents', 'gsd-executor.md');
function mkTmp() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'bug-950-'));
}
/**
* Extract the first YAML frontmatter block from a file's content.
*
* Two layouts are handled:
* - Leading frontmatter: file starts with `---\n…\n---` (summary-minimal/standard/complex.md)
* - Fenced frontmatter: frontmatter lives inside a ```markdown … ``` fence (summary.md,
* whose content IS a markdown example showing the template). In that case we extract
* the `---\n…\n---` block that sits immediately after the opening fence line.
*
* Returns the raw text of the YAML block (between the two `---` delimiters, exclusive),
* or null if no frontmatter could be found.
*/
function extractFrontmatter(content) {
// Case 1: file begins with --- (leading frontmatter)
if (/^---\r?\n/.test(content)) {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
return match ? match[1] : null;
}
// Case 2: frontmatter is embedded inside a fenced block (```markdown\n---\n…\n---\n)
const lines = content.split(/\r?\n/);
for (const block of scanFencedBlocks(lines)) {
if (block.closeLineIdx === -1) continue;
const info = (block.infoString || '').trim().toLowerCase();
if (info !== '' && info !== 'markdown' && info !== 'md') continue;
const fenced = lines.slice(block.openLineIdx + 1, block.closeLineIdx).join('\n');
const inner = fenced.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (inner) return inner[1];
}
return null;
}
describe('bug #950: quick-task SUMMARY must carry status: complete', () => {
// Ensure GSD env vars do not redirect planningDir() away from our fixture.
let prevProject, prevWorkstream;
before(() => {
prevProject = process.env.GSD_PROJECT;
prevWorkstream = process.env.GSD_WORKSTREAM;
delete process.env.GSD_PROJECT;
delete process.env.GSD_WORKSTREAM;
});
after(() => {
if (prevProject !== undefined) process.env.GSD_PROJECT = prevProject;
if (prevWorkstream !== undefined) process.env.GSD_WORKSTREAM = prevWorkstream;
});
// ── Behavioral: scanner recognizes complete quick tasks ───────────────────
test('[PRIMARY] quick task SUMMARY with status: complete is NOT flagged open', () => {
// Simulates an executor that correctly wrote the SUMMARY with status: complete
// (as required after the fix). The scanner must report 0 open quick tasks.
const cwd = mkTmp();
try {
const quickId = '260609-test-status-complete';
const taskDir = path.join(cwd, '.planning', 'quick', quickId);
fs.mkdirSync(taskDir, { recursive: true });
fs.writeFileSync(
path.join(taskDir, `${quickId}-SUMMARY.md`),
[
'---',
'status: complete',
'date: 2026-06-09',
'slug: test-status-complete',
'---',
'',
'# Quick Task Summary',
'',
'Task completed successfully.',
].join('\n'),
'utf-8'
);
const result = auditOpenArtifacts(cwd);
const realQuickTasks = result.items.quick_tasks.filter(
i => !i.scan_error && !i._remainder_count
);
assert.equal(
realQuickTasks.length,
0,
`quick task SUMMARY with status: complete must NOT appear as open; ` +
`got: ${JSON.stringify(realQuickTasks)}`
);
assert.equal(result.counts.quick_tasks, 0);
} finally {
cleanup(cwd);
}
});
test('[PRIMARY] quick task SUMMARY without status: field is still flagged [unknown]', () => {
// Negative case: a SUMMARY that lacks status: still surfaces as [unknown].
// This proves the scanner still catches real gaps — the fix must be on the writer side.
const cwd = mkTmp();
try {
const quickId = '260609-test-no-status';
const taskDir = path.join(cwd, '.planning', 'quick', quickId);
fs.mkdirSync(taskDir, { recursive: true });
fs.writeFileSync(
path.join(taskDir, `${quickId}-SUMMARY.md`),
[
'---',
'date: 2026-06-09',
'slug: test-no-status',
'---',
'',
'# Quick Task Summary',
'',
'Task done, but no status field.',
].join('\n'),
'utf-8'
);
const result = auditOpenArtifacts(cwd);
const realQuickTasks = result.items.quick_tasks.filter(
i => !i.scan_error && !i._remainder_count
);
assert.equal(
realQuickTasks.length,
1,
`quick task SUMMARY without status: must appear as open (unknown); ` +
`got: ${JSON.stringify(realQuickTasks)}`
);
assert.equal(realQuickTasks[0].status, 'unknown', 'expected status to be unknown');
} finally {
cleanup(cwd);
}
});
test('[PRIMARY] quick task without any SUMMARY is still flagged [missing]', () => {
// Proves the missing-SUMMARY case still surfaces.
const cwd = mkTmp();
try {
const quickId = '260609-test-missing-summary';
const taskDir = path.join(cwd, '.planning', 'quick', quickId);
fs.mkdirSync(taskDir, { recursive: true });
// No SUMMARY file at all.
const result = auditOpenArtifacts(cwd);
const realQuickTasks = result.items.quick_tasks.filter(
i => !i.scan_error && !i._remainder_count
);
assert.equal(realQuickTasks.length, 1, 'missing SUMMARY must still be flagged');
assert.equal(realQuickTasks[0].status, 'missing');
} finally {
cleanup(cwd);
}
});
test('[PRIMARY] SUMMARY with status: COMPLETE (uppercase) is also recognized', () => {
// Scanner lowercases before comparing — verify case-insensitivity holds.
const cwd = mkTmp();
try {
const quickId = '260609-test-uppercase-complete';
const taskDir = path.join(cwd, '.planning', 'quick', quickId);
fs.mkdirSync(taskDir, { recursive: true });
fs.writeFileSync(
path.join(taskDir, `${quickId}-SUMMARY.md`),
'---\nstatus: COMPLETE\n---\n# Summary\nDone.\n',
'utf-8'
);
const result = auditOpenArtifacts(cwd);
const realQuickTasks = result.items.quick_tasks.filter(
i => !i.scan_error && !i._remainder_count
);
assert.equal(
realQuickTasks.length,
0,
`quick task SUMMARY with status: COMPLETE (uppercase) must not appear as open; ` +
`got: ${JSON.stringify(realQuickTasks)}`
);
} finally {
cleanup(cwd);
}
});
// ── Secondary: template-contract checks ──────────────────────────────────
// Assertions are scoped to the actual YAML frontmatter block, not the whole file,
// so a stray `status: complete` in prose or examples cannot produce a false green.
test('[TEMPLATE CONTRACT] summary.md contains status: complete in frontmatter', () => {
// summary.md is a documentation template — its frontmatter lives inside a
// ```markdown fence. extractFrontmatter() finds and returns that block.
const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary.md'), 'utf-8');
const fm = extractFrontmatter(content);
assert.ok(
fm !== null,
'gsd-core/templates/summary.md: could not locate a YAML frontmatter block (leading --- or fenced ```markdown --- block)'
);
assert.ok(
/^status:\s*complete\s*$/m.test(fm),
`gsd-core/templates/summary.md: \`status: complete\` not found in the frontmatter block.\n` +
`Frontmatter extracted:\n${fm}`
);
});
test('[TEMPLATE CONTRACT] summary-minimal.md contains status: complete in frontmatter', () => {
const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary-minimal.md'), 'utf-8');
const fm = extractFrontmatter(content);
assert.ok(
fm !== null,
'gsd-core/templates/summary-minimal.md: could not locate a leading YAML frontmatter block'
);
assert.ok(
/^status:\s*complete\s*$/m.test(fm),
`gsd-core/templates/summary-minimal.md: \`status: complete\` not found in the frontmatter block.\n` +
`Frontmatter extracted:\n${fm}`
);
});
test('[TEMPLATE CONTRACT] summary-standard.md contains status: complete in frontmatter', () => {
const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary-standard.md'), 'utf-8');
const fm = extractFrontmatter(content);
assert.ok(
fm !== null,
'gsd-core/templates/summary-standard.md: could not locate a leading YAML frontmatter block'
);
assert.ok(
/^status:\s*complete\s*$/m.test(fm),
`gsd-core/templates/summary-standard.md: \`status: complete\` not found in the frontmatter block.\n` +
`Frontmatter extracted:\n${fm}`
);
});
test('[TEMPLATE CONTRACT] summary-complex.md contains status: complete in frontmatter', () => {
const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary-complex.md'), 'utf-8');
const fm = extractFrontmatter(content);
assert.ok(
fm !== null,
'gsd-core/templates/summary-complex.md: could not locate a leading YAML frontmatter block'
);
assert.ok(
/^status:\s*complete\s*$/m.test(fm),
`gsd-core/templates/summary-complex.md: \`status: complete\` not found in the frontmatter block.\n` +
`Frontmatter extracted:\n${fm}`
);
});
// ── Writer-path contract checks ───────────────────────────────────────────
// Guards quick.md and gsd-executor.md so a future edit removing `status: complete`
// from the SUMMARY-creation instructions would fail the suite before the bug recurs.
// (source-text-is-the-product: the deployed .md text IS the runtime contract for agents)
test('[WRITER-PATH] quick.md constraints require status: complete in SUMMARY frontmatter', () => {
const content = fs.readFileSync(QUICK_MD, 'utf-8');
assert.ok(
/status:\s*complete/.test(content),
'gsd-core/workflows/quick.md must instruct the executor to write `status: complete` in the SUMMARY frontmatter. ' +
'The <constraints> block must contain the `status: complete` requirement so a future edit cannot silently drop it.'
);
});
test('[WRITER-PATH] gsd-executor.md frontmatter spec requires status: complete', () => {
const content = fs.readFileSync(EXECUTOR_MD, 'utf-8');
assert.ok(
/status[\s\S]{0,40}complete/.test(content),
'agents/gsd-executor.md must document `status: complete` as a required SUMMARY frontmatter field. ' +
'The Frontmatter section must include `status: complete` so the executor always emits it.'
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// #3458 follow-up: `audit_acknowledged` suppression seam + the
// `audit-open acknowledge` CLI writer.
//
// BACKGROUND: #3458 made `audit-open` scan archived milestone phase dirs too,
// so an item still unresolved when a milestone closed now resurfaces at
// EVERY later close, forever — `[A] Acknowledge all` documented that
// decision to STATE.md but never suppressed it. This suite verifies the
// suppression seam: `audit_acknowledged` is VERDICT-PRESERVING (never
// touches the artifact's own `status:` verdict) and SELF-INVALIDATING (a
// stale acknowledgment resurfaces automatically the moment the artifact's
// current state stops matching the marker's recorded snapshot).
// ────────────────────────────────────────────────────────────────────────
{
const fs = require('node:fs');
const path = require('node:path');
const { splitLines } = require('../gsd-core/bin/lib/text-lines.cjs');
function readJson(result) {
assert.ok(result.success, `command must succeed. stdout: ${result.output}\nstderr: ${result.error}`);
return JSON.parse(result.output);
}
function ack(tmpDir, args) {
return runGsdTools(['audit-open', 'acknowledge', ...args, '--json'], tmpDir);
}
function audit(tmpDir) {
return readJson(runGsdTools(['audit-open', '--json'], tmpDir));
}
describe('audit-open acknowledge — suppression seam (#3458 follow-up)', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject('gsd-3458-ack-'); });
afterEach(() => { cleanup(tmpDir); });
function planningPath(...segs) {
return path.join(tmpDir, '.planning', ...segs);
}
// ── per-category suppression + verdict preservation ───────────────────
test('debug_sessions: acknowledged item drops out of counts/has_open_items; status: field unchanged', () => {
const debugDir = planningPath('debug');
fs.mkdirSync(debugDir, { recursive: true });
const filePath = path.join(debugDir, 'investigate.md');
fs.writeFileSync(filePath, '---\nstatus: open\n---\n## Current Focus\ndigging\n');
assert.equal(audit(tmpDir).counts.debug_sessions, 1);
const result = ack(tmpDir, ['--category', 'debug_sessions', '--slug', 'investigate', '--milestone', 'v1.0', '--at', '2026-08-15']);
assert.ok(result.success, `acknowledge must succeed. stderr: ${result.error}`);
const after = audit(tmpDir);
assert.equal(after.counts.debug_sessions, 0);
assert.equal(after.acknowledged.debug_sessions, 1);
assert.equal(after.has_open_items, false);
assert.match(fs.readFileSync(filePath, 'utf-8'), /^status: open$/m, 'verdict-preserving: status: must be unchanged');
});
test('quick_tasks: acknowledged item drops out of counts; status: field unchanged', () => {
const taskDir = planningPath('quick', '20260810-fixthing');
fs.mkdirSync(taskDir, { recursive: true });
const filePath = path.join(taskDir, '20260810-fixthing-SUMMARY.md');
fs.writeFileSync(filePath, '---\nstatus: needs_review\n---\nbody\n');
assert.equal(audit(tmpDir).counts.quick_tasks, 1);
const result = ack(tmpDir, ['--category', 'quick_tasks', '--dir', '20260810-fixthing', '--milestone', 'v1.0', '--at', '2026-08-15']);
assert.ok(result.success, `acknowledge must succeed. stderr: ${result.error}`);
const after = audit(tmpDir);
assert.equal(after.counts.quick_tasks, 0);
assert.equal(after.acknowledged.quick_tasks, 1);
assert.match(fs.readFileSync(filePath, 'utf-8'), /^status: needs_review$/m, 'verdict-preserving: status: must be unchanged');
});
test('quick_tasks: task with NO SUMMARY.md at all can still be acknowledged (writer creates the marker file)', () => {
const taskDir = planningPath('quick', '20260811-nosummary');
fs.mkdirSync(taskDir, { recursive: true });
assert.equal(audit(tmpDir).counts.quick_tasks, 1);
const result = ack(tmpDir, ['--category', 'quick_tasks', '--dir', '20260811-nosummary', '--milestone', 'v1.0', '--at', '2026-08-15']);
assert.ok(result.success, `acknowledge must succeed. stderr: ${result.error}`);
const after = audit(tmpDir);
assert.equal(after.counts.quick_tasks, 0);
assert.equal(after.acknowledged.quick_tasks, 1);
});
test('threads: acknowledged item drops out of counts; status: field unchanged', () => {
const threadsDir = planningPath('threads');
fs.mkdirSync(threadsDir, { recursive: true });
const filePath = path.join(threadsDir, 'design-debate.md');
fs.writeFileSync(filePath, '---\nstatus: open\n---\n# Thread: design debate\n');
assert.equal(audit(tmpDir).counts.threads, 1);
const result = ack(tmpDir, ['--category', 'threads', '--slug', 'design-debate', '--milestone', 'v1.0', '--at', '2026-08-15']);
assert.ok(result.success, `acknowledge must succeed. stderr: ${result.error}`);
const after = audit(tmpDir);
assert.equal(after.counts.threads, 0);
assert.equal(after.acknowledged.threads, 1);
assert.match(fs.readFileSync(filePath, 'utf-8'), /^status: open$/m, 'verdict-preserving: status: must be unchanged');
});
test('seeds: acknowledged item drops out of counts; status: field unchanged', () => {
const seedsDir = planningPath('seeds');
fs.mkdirSync(seedsDir, { recursive: true });
const filePath = path.join(seedsDir, 'SEED-idea.md');
fs.writeFileSync(filePath, '---\nstatus: dormant\n---\n# An idea\n');
assert.equal(audit(tmpDir).counts.seeds, 1);
const result = ack(tmpDir, ['--category', 'seeds', '--seed-id', 'SEED-idea', '--milestone', 'v1.0', '--at', '2026-08-15']);
assert.ok(result.success, `acknowledge must succeed. stderr: ${result.error}`);
const after = audit(tmpDir);
assert.equal(after.counts.seeds, 0);
assert.equal(after.acknowledged.seeds, 1);
assert.match(fs.readFileSync(filePath, 'utf-8'), /^status: dormant$/m, 'verdict-preserving: status: must be unchanged');
});
test('todos: acknowledged item drops out of counts (presence-only — no snapshot field)', () => {
const pendingDir = planningPath('todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
const filePath = path.join(pendingDir, 'fix-thing.md');
fs.writeFileSync(filePath, '---\npriority: low\narea: docs\n---\nFix the thing\n');
assert.equal(audit(tmpDir).counts.todos, 1);
const result = ack(tmpDir, ['--category', 'todos', '--filename', 'fix-thing.md', '--milestone', 'v1.0', '--at', '2026-08-15']);
assert.ok(result.success, `acknowledge must succeed. stderr: ${result.error}`);
const after = audit(tmpDir);
assert.equal(after.counts.todos, 0);
assert.equal(after.acknowledged.todos, 1);
});
test('uat_gaps: acknowledged item drops out of counts; status: field unchanged (CLI writer round-trip)', () => {
const phaseDir = planningPath('phases', '01-alpha');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, '01-UAT.md');
fs.writeFileSync(filePath, '---\nstatus: gaps_found\n---\n# UAT\n\n## Gaps\n\n- truth: "something broke"\n status: open\n');
assert.equal(audit(tmpDir).counts.uat_gaps, 1);
const result = ack(tmpDir, ['--category', 'uat_gaps', '--phase', '01', '--file', '01-UAT.md', '--milestone', 'v1.0', '--at', '2026-08-15']);
assert.ok(result.success, `acknowledge must succeed. stderr: ${result.error}`);
const after = audit(tmpDir);
assert.equal(after.counts.uat_gaps, 0);
assert.equal(after.acknowledged.uat_gaps, 1);
assert.equal(after.has_open_items, false);
assert.match(
fs.readFileSync(filePath, 'utf-8'), /^status: gaps_found$/m,
'CLI writer round-trip: the artifact\'s own status: must be UNCHANGED after acknowledge (verdict-preserving)',
);
});
test('verification_gaps: acknowledged item drops out of counts; status: field unchanged', () => {
const phaseDir = planningPath('phases', '01-alpha');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, '01-VERIFICATION.md');
fs.writeFileSync(filePath, '---\nstatus: gaps_found\n---\n# Verification\n\nGaps found.\n');
assert.equal(audit(tmpDir).counts.verification_gaps, 1);
const result = ack(tmpDir, ['--category', 'verification_gaps', '--phase', '01', '--file', '01-VERIFICATION.md', '--milestone', 'v1.0', '--at', '2026-08-15']);
assert.ok(result.success, `acknowledge must succeed. stderr: ${result.error}`);
const after = audit(tmpDir);
assert.equal(after.counts.verification_gaps, 0);
assert.equal(after.acknowledged.verification_gaps, 1);
assert.match(fs.readFileSync(filePath, 'utf-8'), /^status: gaps_found$/m, 'verdict-preserving: status: must be unchanged');
});
test('context_questions: acknowledged item drops out of counts; question_count snapshot recorded', () => {
const phaseDir = planningPath('phases', '01-alpha');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, '01-CONTEXT.md');
fs.writeFileSync(filePath, '# Context\n\n## Open Questions\n\n- Which backend?\n- What about auth?\n');
assert.equal(audit(tmpDir).counts.context_questions, 1);
const result = ack(tmpDir, ['--category', 'context_questions', '--phase', '01', '--file', '01-CONTEXT.md', '--milestone', 'v1.0', '--at', '2026-08-15']);
assert.ok(result.success, `acknowledge must succeed. stderr: ${result.error}`);
const after = audit(tmpDir);
assert.equal(after.counts.context_questions, 0);
assert.equal(after.acknowledged.context_questions, 1);
// WARNING 2 (#3458 follow-up review): the marker snapshots a content
// digest of the FULL question set, not a bare count — a count-only
// snapshot cannot see a same-count REPLACEMENT of every question (see
// the WARNING-2 disproof tests below).
assert.match(fs.readFileSync(filePath, 'utf-8'), /questions_digest: [0-9a-f]{64}/, 'marker records the questions_digest snapshot');
});
test('deferred_items: acknowledged entry drops out of counts; entry text otherwise unchanged', () => {
const phaseDir = planningPath('phases', '01-alpha');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, 'deferred-items.md');
fs.writeFileSync(filePath, '## Deferred Items\n\n- an out of scope thing\n severity: low\n');
assert.equal(audit(tmpDir).counts.deferred_items, 1);
const result = ack(tmpDir, ['--category', 'deferred_items', '--phase', '01', '--file', 'deferred-items.md', '--text', 'an out of scope thing severity: low', '--milestone', 'v1.0', '--at', '2026-08-15']);
assert.ok(result.success, `acknowledge must succeed. stderr: ${result.error}`);
const after = audit(tmpDir);
assert.equal(after.counts.deferred_items, 0);
assert.equal(after.acknowledged.deferred_items, 1);
const content = fs.readFileSync(filePath, 'utf-8');
assert.match(content, /an out of scope thing/, 'entry text is preserved');
assert.match(content, /status: acknowledged/, 'entry now carries status: acknowledged');
assert.match(content, /severity: low/, 'sibling field is preserved');
});
// ── SELF-INVALIDATION: edit the artifact after acknowledging → resurfaces ──
test('SELF-INVALIDATION debug_sessions: status changes after acknowledge → item resurfaces', () => {
const debugDir = planningPath('debug');
fs.mkdirSync(debugDir, { recursive: true });
const filePath = path.join(debugDir, 'investigate.md');
fs.writeFileSync(filePath, '---\nstatus: open\n---\n## Current Focus\ndigging\n');
assert.ok(ack(tmpDir, ['--category', 'debug_sessions', '--slug', 'investigate', '--milestone', 'v1.0', '--at', '2026-08-15']).success);
assert.equal(audit(tmpDir).counts.debug_sessions, 0, 'BEFORE edit: suppressed');
const content = fs.readFileSync(filePath, 'utf-8').replace('status: open', 'status: in_progress');
fs.writeFileSync(filePath, content);
assert.equal(audit(tmpDir).counts.debug_sessions, 1, 'AFTER edit: resurfaces — stale acknowledgment no longer applies');
});
test('SELF-INVALIDATION quick_tasks: status changes after acknowledge → item resurfaces', () => {
const taskDir = planningPath('quick', '20260810-fixthing');
fs.mkdirSync(taskDir, { recursive: true });
const filePath = path.join(taskDir, '20260810-fixthing-SUMMARY.md');
fs.writeFileSync(filePath, '---\nstatus: needs_review\n---\nbody\n');
assert.ok(ack(tmpDir, ['--category', 'quick_tasks', '--dir', '20260810-fixthing', '--milestone', 'v1.0', '--at', '2026-08-15']).success);
assert.equal(audit(tmpDir).counts.quick_tasks, 0, 'BEFORE edit: suppressed');
const content = fs.readFileSync(filePath, 'utf-8').replace('status: needs_review', 'status: in_progress');
fs.writeFileSync(filePath, content);
assert.equal(audit(tmpDir).counts.quick_tasks, 1, 'AFTER edit: resurfaces');
});
test('SELF-INVALIDATION threads: status changes after acknowledge → item resurfaces', () => {
const threadsDir = planningPath('threads');
fs.mkdirSync(threadsDir, { recursive: true });
const filePath = path.join(threadsDir, 'design-debate.md');
fs.writeFileSync(filePath, '---\nstatus: open\n---\n# Thread: design debate\n');
assert.ok(ack(tmpDir, ['--category', 'threads', '--slug', 'design-debate', '--milestone', 'v1.0', '--at', '2026-08-15']).success);
assert.equal(audit(tmpDir).counts.threads, 0, 'BEFORE edit: suppressed');
const content = fs.readFileSync(filePath, 'utf-8').replace('status: open', 'status: in_progress');
fs.writeFileSync(filePath, content);
assert.equal(audit(tmpDir).counts.threads, 1, 'AFTER edit: resurfaces (still open, but a DIFFERENT open status than the snapshot)');
});
test('SELF-INVALIDATION seeds: status changes after acknowledge → item resurfaces', () => {
const seedsDir = planningPath('seeds');
fs.mkdirSync(seedsDir, { recursive: true });
const filePath = path.join(seedsDir, 'SEED-idea.md');
fs.writeFileSync(filePath, '---\nstatus: dormant\n---\n# An idea\n');
assert.ok(ack(tmpDir, ['--category', 'seeds', '--seed-id', 'SEED-idea', '--milestone', 'v1.0', '--at', '2026-08-15']).success);
assert.equal(audit(tmpDir).counts.seeds, 0, 'BEFORE edit: suppressed');
const content = fs.readFileSync(filePath, 'utf-8').replace('status: dormant', 'status: active');
fs.writeFileSync(filePath, content);
assert.equal(audit(tmpDir).counts.seeds, 1, 'AFTER edit: resurfaces');
});
test('SELF-INVALIDATION uat_gaps: status changes after acknowledge → item resurfaces', () => {
const phaseDir = planningPath('phases', '01-alpha');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, '01-UAT.md');
fs.writeFileSync(filePath, '---\nstatus: gaps_found\n---\n# UAT\n\n## Gaps\n\n- truth: "something broke"\n status: open\n');
assert.ok(ack(tmpDir, ['--category', 'uat_gaps', '--phase', '01', '--file', '01-UAT.md', '--milestone', 'v1.0', '--at', '2026-08-15']).success);
assert.equal(audit(tmpDir).counts.uat_gaps, 0, 'BEFORE edit: suppressed');
const content = fs.readFileSync(filePath, 'utf-8').replace('status: gaps_found', 'status: human_needed');
fs.writeFileSync(filePath, content);
assert.equal(audit(tmpDir).counts.uat_gaps, 1, 'AFTER edit: resurfaces');
});
test('SELF-INVALIDATION verification_gaps: status changes after acknowledge → item resurfaces', () => {
const phaseDir = planningPath('phases', '01-alpha');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, '01-VERIFICATION.md');
fs.writeFileSync(filePath, '---\nstatus: gaps_found\n---\n# Verification\n\nGaps found.\n');
assert.ok(ack(tmpDir, ['--category', 'verification_gaps', '--phase', '01', '--file', '01-VERIFICATION.md', '--milestone', 'v1.0', '--at', '2026-08-15']).success);
assert.equal(audit(tmpDir).counts.verification_gaps, 0, 'BEFORE edit: suppressed');
const content = fs.readFileSync(filePath, 'utf-8').replace('status: gaps_found', 'status: human_needed');
fs.writeFileSync(filePath, content);
assert.equal(audit(tmpDir).counts.verification_gaps, 1, 'AFTER edit: resurfaces');
});
test('SELF-INVALIDATION context_questions: question_count changes after acknowledge → item resurfaces', () => {
const phaseDir = planningPath('phases', '01-alpha');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, '01-CONTEXT.md');
fs.writeFileSync(filePath, '# Context\n\n## Open Questions\n\n- Which backend?\n- What about auth?\n');
assert.ok(ack(tmpDir, ['--category', 'context_questions', '--phase', '01', '--file', '01-CONTEXT.md', '--milestone', 'v1.0', '--at', '2026-08-15']).success);
assert.equal(audit(tmpDir).counts.context_questions, 0, 'BEFORE edit: suppressed');
const content = fs.readFileSync(filePath, 'utf-8') + '- What about the third thing?\n';
fs.writeFileSync(filePath, content);
assert.equal(audit(tmpDir).counts.context_questions, 1, 'AFTER a new open question is added: resurfaces');
});
test('SELF-INVALIDATION deferred_items: reopening the entry (status changed away from acknowledged) → item resurfaces', () => {
const phaseDir = planningPath('phases', '01-alpha');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, 'deferred-items.md');
fs.writeFileSync(filePath, '## Deferred Items\n\n- an out of scope thing\n severity: low\n');
assert.ok(ack(tmpDir, ['--category', 'deferred_items', '--phase', '01', '--file', 'deferred-items.md', '--text', 'an out of scope thing severity: low', '--milestone', 'v1.0', '--at', '2026-08-15']).success);
assert.equal(audit(tmpDir).counts.deferred_items, 0, 'BEFORE reopen: suppressed');
const content = fs.readFileSync(filePath, 'utf-8').replace('status: acknowledged', 'status: reopened');
fs.writeFileSync(filePath, content);
assert.equal(audit(tmpDir).counts.deferred_items, 1, 'AFTER reopen: resurfaces');
});
// ── malformed marker never suppresses ──────────────────────────────────
test('malformed audit_acknowledged (not a map) does NOT suppress — item still surfaces', () => {
const debugDir = planningPath('debug');
fs.mkdirSync(debugDir, { recursive: true });
const filePath = path.join(debugDir, 'investigate.md');
// Hand-authored, deliberately malformed: audit_acknowledged is a bare
// scalar, not a map — must be treated as ABSENT, never suppress.
fs.writeFileSync(filePath, '---\nstatus: open\naudit_acknowledged: not-a-map\n---\nstill open\n');
const parsed = audit(tmpDir);
assert.equal(parsed.counts.debug_sessions, 1, 'a malformed marker must never suppress');
assert.equal(parsed.acknowledged.debug_sessions, 0);
});
test('malformed audit_acknowledged (missing milestone/at) does NOT suppress — item still surfaces', () => {
const phaseDir = planningPath('phases', '01-alpha');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, '01-UAT.md');
fs.writeFileSync(
filePath,
'---\nstatus: gaps_found\naudit_acknowledged:\n status: gaps_found\n---\n# UAT\n\n## Gaps\n\n- truth: "x"\n status: open\n',
);
const parsed = audit(tmpDir);
assert.equal(parsed.counts.uat_gaps, 1, 'a marker missing milestone/at must never suppress');
});
// ── deferred_items status matrix ───────────────────────────────────────
test('deferred_items status matrix: acknowledged suppresses, resolved still suppresses, no status still surfaces', () => {
const phaseDir = planningPath('phases', '01-alpha');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, 'deferred-items.md');
fs.writeFileSync(
filePath,
[
'## Deferred Items',
'',
'- an acknowledged item',
' status: acknowledged',
'- a resolved item',
' status: resolved',
'- a plain open item with no status field',
'',
].join('\n'),
);
const parsed = audit(tmpDir);
assert.equal(parsed.counts.deferred_items, 1, 'only the no-status entry is open');
assert.equal(parsed.acknowledged.deferred_items, 1, 'the acknowledged entry is tallied, not silenced');
assert.deepEqual(
parsed.items.deferred_items.map((i) => i.text),
['a plain open item with no status field'],
);
});
// ── BLOCKER 1 (#3458 follow-up review): deferred_items writer must be
// section-anchored, never write into the wrong span, and refuse rather
// than guess on every shape it cannot safely handle ────────────────────
test('BLOCKER 1: an identical bullet OUTSIDE `## Deferred Items` is never targeted — mixed-section fixture', () => {
const phaseDir = planningPath('phases', '01-alpha');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, 'deferred-items.md');
// The SAME bullet text appears once under an unrelated `# Notes`
// section and once under `## Deferred Items`. Before the fix, the
// unanchored regex matched the FIRST occurrence anywhere in the file —
// i.e. the one under `# Notes` — not the one `matches`/`ambiguous`
// were computed over.
fs.writeFileSync(
filePath,
[
'# Notes',
'',
'- Fix the parser',
'',
'## Deferred Items',
'',
'- Fix the parser',
'',
].join('\n'),
);
const result = ack(tmpDir, ['--category', 'deferred_items', '--phase', '01', '--file', 'deferred-items.md', '--text', 'Fix the parser', '--milestone', 'v1.0', '--at', '2026-08-15']);
assert.ok(result.success, `acknowledge must succeed. stderr: ${result.error}`);
const content = fs.readFileSync(filePath, 'utf-8');
const notesSection = content.slice(content.indexOf('# Notes'), content.indexOf('## Deferred Items'));
const deferredSection = content.slice(content.indexOf('## Deferred Items'));
assert.doesNotMatch(notesSection, /status: acknowledged/, 'the UNRELATED # Notes bullet must never be touched');
assert.match(deferredSection, /status: acknowledged/, 'the actual Deferred Items entry must carry the marker');
const after = audit(tmpDir);
assert.equal(after.counts.deferred_items, 0, 're-audit: the correct entry is suppressed');
assert.equal(after.acknowledged.deferred_items, 1);
});
test('BLOCKER 1: --text matching 2+ deferred entries is refused as ambiguous, nothing written', () => {
const phaseDir = planningPath('phases', '01-alpha');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, 'deferred-items.md');
const before = ['## Deferred Items', '', '- duplicated text', '- duplicated text', ''].join('\n');
fs.writeFileSync(filePath, before);
const result = ack(tmpDir, ['--category', 'deferred_items', '--phase', '01', '--file', 'deferred-items.md', '--text', 'duplicated text', '--milestone', 'v1.0']);
assert.equal(result.success, false, 'ambiguous --text must be refused');
assert.match(result.error, /matches more than one/i);
assert.equal(fs.readFileSync(filePath, 'utf-8'), before, 'file must be byte-identical — nothing written on refusal');
});
test('BLOCKER 1: --text matching no deferred entry is refused as not_found', () => {
const phaseDir = planningPath('phases', '01-alpha');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, 'deferred-items.md');
fs.writeFileSync(filePath, '## Deferred Items\n\n- a real entry\n');
const result = ack(tmpDir, ['--category', 'deferred_items', '--phase', '01', '--file', 'deferred-items.md', '--text', 'no such entry', '--milestone', 'v1.0']);
assert.equal(result.success, false, 'unmatched --text must be refused');
assert.match(result.error, /no deferred item matched/i);
});
test('BLOCKER 1 (updated for #3781): heading-delimited (#3457) entries ack via the CLI; only table-embedded spans still refuse', () => {
const phaseDir = planningPath('phases', '01-alpha');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, 'deferred-items.md');
// #3781: the heading shape is now SUPPORTED — a bullet-bearing leaf
// acks through the CLI writer.
const ackable = ['## Deferred Items', '', '### Something out of scope', '', '- a real bullet', ''].join('\n');
fs.writeFileSync(filePath, ackable);
// --text must be the reader-derived identity: rawGapEntryText joins the
// heading text, the blank line, and the bullet with single spaces —
// the blank line contributes an empty segment, hence the double space.
const okResult = ack(tmpDir, ['--category', 'deferred_items', '--phase', '01', '--file', 'deferred-items.md', '--text', 'Something out of scope - a real bullet', '--milestone', 'v1.0']);
assert.equal(okResult.success, true, `heading-shaped entries must be acknowledgeable; stderr: ${okResult.error}`);
assert.match(fs.readFileSync(filePath, 'utf-8'), /status: acknowledged/);
// A table row embedded in the entry's span is still refused — a
// non-contiguous span cannot anchor a safe write. The row sits BETWEEN
// two entry lines: a row after the entry's last line is outside its
// span, and that write is anchored and safe (#3702 round 5).
const tabled = ['## Deferred Items', '', '### Tabled finding', '', '- a bullet', '| x | y |', '- more evidence', ''].join('\n');
fs.writeFileSync(filePath, tabled);
const refuse = ack(tmpDir, ['--category', 'deferred_items', '--phase', '01', '--file', 'deferred-items.md', '--text', 'Tabled finding - a bullet - more evidence', '--milestone', 'v1.0']);
assert.equal(refuse.success, false, 'table-embedded spans must still refuse');
assert.match(refuse.error, /GFM table row/i);
assert.equal(fs.readFileSync(filePath, 'utf-8'), tabled, 'file must be byte-identical — nothing written on refusal');
// Prose-only headings were never items (reader contract) — not_found,
// never a write.
const proseOnly = ['## Deferred Items', '', '### Something out of scope', '', 'Some detail line.', ''].join('\n');
fs.writeFileSync(filePath, proseOnly);
const nf = ack(tmpDir, ['--category', 'deferred_items', '--phase', '01', '--file', 'deferred-items.md', '--text', 'Something out of scope', '--milestone', 'v1.0']);
assert.equal(nf.success, false, 'prose-only headings contribute no entry');
assert.match(nf.error, /no deferred item matched/i);
assert.equal(fs.readFileSync(filePath, 'utf-8'), proseOnly, 'file must be byte-identical');
});
test('#3702 round 2 (m3, updated for #3781): a heading-delimited file written with `*`, `+` or `1.` SURFACES its entries, and the CLI writer acknowledges them', () => {
// Before #3702 such a file parsed to zero entries and `complete-milestone`
// closed over it silently. It now yields entries; under #3781 (merged in
// round 5) the heading shape is acknowledgeable, so the milestone loop
// suppresses them through the same two CLI calls it makes for a hyphen
// file: the listing that puts the entry in front of the writer, and the
// acknowledge that takes it. (Until #3781 the writer refused the shape
// and the loop halted — that contract is gone from `next`.)
const fixtures = [['*', '02-star'], ['+', '03-plus'], ['1.', '04-ordered']];
const files = new Map();
for (const [marker, slug] of fixtures) {
const phaseDir = planningPath('phases', slug);
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, 'deferred-items.md');
const before = ['## Deferred Items', '', `### Out of scope under ${slug}`, '', `${marker} **What:** detail.`, ''].join('\n');
fs.writeFileSync(filePath, before);
files.set(slug, { filePath, before });
}
const listed = audit(tmpDir).items.deferred_items.filter((i) => /^Out of scope under /.test(i.text));
assert.equal(listed.length, fixtures.length, `every marker's heading entry must be listed: ${JSON.stringify(listed)}`);
for (const item of listed) {
const slug = /under (\S+)/.exec(item.text)[1];
const { filePath, before } = files.get(slug);
// `--text` is the audit's own reported text, exactly as the loop passes it.
const result = ack(tmpDir, ['--category', 'deferred_items', '--phase', slug.slice(0, 2), '--file', 'deferred-items.md', '--text', item.text, '--milestone', 'v1.0']);
assert.equal(result.success, true, `${slug}: a widened-marker heading entry must acknowledge; stderr: ${result.error}`);
const after = fs.readFileSync(filePath, 'utf-8');
assert.notEqual(after, before, `${slug}: the file must carry the marker`);
assert.match(after, /status: acknowledged/, slug);
}
// And the loop's next listing no longer surfaces them.
const relisted = audit(tmpDir).items.deferred_items.filter((i) => /^Out of scope under /.test(i.text));
assert.equal(relisted.length, 0, `acknowledged entries must leave the listing: ${JSON.stringify(relisted)}`);
});
// ── F1 (#3458 follow-up review, HIGH): the writer must splice by the
// SELECTED entry's own carried span, never re-find it by searching —
// otherwise a byte-identical substring living inside an EARLIER entry
// (a continuation/quoted line) can steal the write ─────────────────────
test('F1: a target entry text appearing as a continuation line INSIDE an earlier entry is never targeted — the earlier (CRITICAL) entry is untouched', () => {
const phaseDir = planningPath('phases', '03-x');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, 'deferred-items.md');
const before = [
'## Deferred Items',
'',
'- CRITICAL unfixed auth bypass',
' see also: - minor typo',
'- minor typo',
'',
].join('\n');
fs.writeFileSync(filePath, before);
const result = ack(tmpDir, ['--category', 'deferred_items', '--phase', '03', '--file', 'deferred-items.md', '--text', 'minor typo', '--milestone', 'v1.0', '--at', '2026-08-15']);
assert.ok(result.success, `acknowledge must succeed. stderr: ${result.error}`);
const content = fs.readFileSync(filePath, 'utf-8');
// Derive the CRITICAL entry's block by LINES, not by `content.indexOf('- minor typo')`
// on the raw string — that substring also occurs INSIDE the CRITICAL entry's own
// continuation line (" see also: - minor typo"), so an indexOf-based slice truncates
// before the continuation line is fully captured. Walk lines from the CRITICAL bullet
// up to (not including) the next TOP-LEVEL bullet (a line starting with "- ", no
// leading indentation) to get the entry's own span, continuation lines included.
const lines = splitLines(content);
const criticalIdx = lines.findIndex((l) => l.startsWith('- CRITICAL'));
let criticalEndIdx = lines.length;
for (let i = criticalIdx + 1; i < lines.length; i++) {
if (lines[i].startsWith('- ')) { criticalEndIdx = i; break; }
}
const criticalBlock = lines.slice(criticalIdx, criticalEndIdx).join('\n');
assert.doesNotMatch(criticalBlock, /status: acknowledged/, 'the CRITICAL entry (and its continuation line) must NEVER be touched');
assert.match(criticalBlock, /see also: - minor typo/, 'the CRITICAL entry continuation line is preserved verbatim');
// Measured: the write seam's `_normalizeMd` (src/shell-command-projection.cts:837)
// inserts a blank line before a list item whose predecessor is a non-blank, non-list
// line — so a blank line appears between the CRITICAL continuation line and the
// "- minor typo" bullet after this write. That is repo-wide `.md`-write normalization
// (50 callers through the single write seam), not something specific to this feature.
assert.match(content, /- minor typo\n {2}status: acknowledged/, 'the standalone "minor typo" entry (its OWN span) now carries the marker');
const after = audit(tmpDir);
assert.equal(after.counts.deferred_items, 1, 're-audit: the CRITICAL entry is still open');
assert.equal(after.acknowledged.deferred_items, 1, 're-audit: only the typo entry is acknowledged');
assert.deepEqual(
after.items.deferred_items.filter((i) => !i.scan_error).map((i) => i.text),
['CRITICAL unfixed auth bypass see also: - minor typo'],
'the still-open item must be the CRITICAL one, not suppressed',
);
});
test('F1 (weaker/prose variant): the target text also appears as a decoy substring INLINE inside an earlier entry\'s prose — the decoy prose must never be corrupted, and the one real matching entry is acknowledged (pre-fix: the decoy prose line was split mid-sentence, the real entry was never touched, and the CLI still exited 0)', () => {
const phaseDir = planningPath('phases', '03-x');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, 'deferred-items.md');
const before = [
'## Deferred Items',
'',
'- Note: reference - minor typo elsewhere, ignore',
'- minor typo',
'',
].join('\n');
fs.writeFileSync(filePath, before);
const result = ack(tmpDir, ['--category', 'deferred_items', '--phase', '03', '--file', 'deferred-items.md', '--text', 'minor typo', '--milestone', 'v1.0', '--at', '2026-08-15']);
assert.ok(result.success, `acknowledge must succeed — the real "minor typo" entry unambiguously matches. stderr: ${result.error}`);
const content = fs.readFileSync(filePath, 'utf-8');
assert.match(
content,
/- Note: reference - minor typo elsewhere, ignore\n/,
'the decoy prose line must be preserved VERBATIM, never split mid-sentence by an inserted status: field',
);
assert.match(content, /- minor typo\n {2}status: acknowledged/, 'the real, standalone "minor typo" entry (its OWN carried span) is the one acknowledged');
const after = audit(tmpDir);
// The decoy `- Note: reference - minor typo elsewhere, ignore` line is ITSELF a
// separate, un-acknowledged deferred entry — it was never targeted or written to,
// so it remains open. Only the real "minor typo" entry was suppressed.
assert.equal(after.counts.deferred_items, 1, 're-audit: the decoy Note entry remains open — it was never acknowledged');
assert.equal(after.acknowledged.deferred_items, 1, 're-audit: only the real "minor typo" entry is acknowledged');
assert.deepEqual(
after.items.deferred_items.filter((i) => !i.scan_error).map((i) => i.text),
['Note: reference - minor typo elsewhere, ignore'],
'the one remaining open item is the decoy Note entry — proving the REAL entry (not the decoy) was the one suppressed',
);
});
test('F1: --text matching only a SUBSTRING of a prose entry (no entry\'s OWN text equals it) is refused as not_found, not silently corrupted', () => {
const phaseDir = planningPath('phases', '03-x');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, 'deferred-items.md');
const before = [
'## Deferred Items',
'',
'- Some unrelated note mentioning minor typo inline as commentary',
'',
].join('\n');
fs.writeFileSync(filePath, before);
const result = ack(tmpDir, ['--category', 'deferred_items', '--phase', '03', '--file', 'deferred-items.md', '--text', 'minor typo', '--milestone', 'v1.0', '--at', '2026-08-15']);
assert.equal(result.success, false, 'a --text that only matches a SUBSTRING of an entry (not the whole entry) must be refused, never silently split/corrupted');
assert.match(result.error, /no deferred item matched/i);
assert.equal(fs.readFileSync(filePath, 'utf-8'), before, 'file must be byte-identical — nothing written on refusal');
const after = audit(tmpDir);
assert.equal(after.counts.deferred_items, 1, 'the prose entry remains open and intact — not silently acknowledged/corrupted');
});
// ── WARNING 1 (#3458 follow-up review): every `.md` write normalizes to
// LF — a CRLF deferred-items.md is normalized, not byte-preserved,
// matching every other `.md` writer in this codebase ─────────────────
test('WARNING 1: acknowledging an entry in a CRLF deferred-items.md normalizes the whole file to LF (no dead CRLF preservation)', () => {
const phaseDir = planningPath('phases', '01-alpha');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, 'deferred-items.md');
fs.writeFileSync(filePath, '## Deferred Items\r\n\r\n- a crlf entry\r\n severity: low\r\n');
const result = ack(tmpDir, ['--category', 'deferred_items', '--phase', '01', '--file', 'deferred-items.md', '--text', 'a crlf entry severity: low', '--milestone', 'v1.0', '--at', '2026-08-15']);
assert.ok(result.success, `acknowledge must succeed. stderr: ${result.error}`);
const content = fs.readFileSync(filePath, 'utf-8');
assert.ok(!content.includes('\r'), 'the whole file normalizes to LF on any .md write — no stray \\r bytes');
assert.match(content, /status: acknowledged/, 'the entry still carries the marker after normalization');
assert.match(content, /a crlf entry/, 'entry text is preserved');
const after = audit(tmpDir);
assert.equal(after.counts.deferred_items, 0);
assert.equal(after.acknowledged.deferred_items, 1);
});
// ── BLOCKER 2 (#3458 follow-up review): todos beyond the display cap
// must not be permanently hidden by acknowledging the displayed 5 ─────
test('BLOCKER 2: acknowledging the 5 displayed todos surfaces the remaining 2, not zero — filter-before-cap', () => {
const pendingDir = planningPath('todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
for (let i = 1; i <= 7; i++) {
fs.writeFileSync(path.join(pendingDir, `t${i}.md`), `---\npriority: low\narea: misc\n---\ntodo ${i}\n`);
}
const before = audit(tmpDir);
// #3817: the display cap truncates the DETAIL list, not the count —
// 7 pending files means counts.todos is 7 (5 shown + remainder 2).
assert.equal(before.counts.todos, 7, 'counts include the truncation remainder (#3817)');
assert.equal(before.has_open_items, true);
const shown = before.items.todos.filter((i) => !i.scan_error && !i._remainder_count).map((i) => i.filename);
assert.equal(shown.length, 5);
for (const filename of shown) {
const result = ack(tmpDir, ['--category', 'todos', '--filename', filename, '--milestone', 'v1.0', '--at', '2026-08-15']);
assert.ok(result.success, `acknowledge must succeed for ${filename}. stderr: ${result.error}`);
}
const after = audit(tmpDir);
// Pre-fix this was 0 (the 2 unshown files were permanently invisible —
// `mdFiles.length` drove both the cap and the remainder count, so once
// the raw 7 dropped to the still-raw-7-minus-nothing count computation
// never noticed 2 files had never been shown at all).
assert.equal(after.counts.todos, 2, 'the 2 never-displayed todos must still surface');
assert.equal(after.has_open_items, true, 'must not report clean while 2 todos remain unacknowledged');
assert.equal(after.acknowledged.todos, 5);
});
// ── WARNING 2 (#3458 follow-up review): the snapshot must identify
// CONTENT, not just its size — a same-count/same-status change must
// still resurface ───────────────────────────────────────────────────
test('WARNING-2 disproof: replacing every acknowledged open_question with a NEW one (same count) resurfaces the item', () => {
const phaseDir = planningPath('phases', '01-alpha');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, '01-CONTEXT.md');
fs.writeFileSync(filePath, '---\nopen_questions:\n - "Which backend?"\n - "What about auth?"\n---\n# Context\n');
assert.ok(ack(tmpDir, ['--category', 'context_questions', '--phase', '01', '--file', '01-CONTEXT.md', '--milestone', 'v1.0', '--at', '2026-08-15']).success);
assert.equal(audit(tmpDir).counts.context_questions, 0, 'BEFORE replacement: suppressed');
// Same COUNT (2), completely different TEXT.
fs.writeFileSync(filePath, '---\nopen_questions:\n - "BRAND NEW BLOCKER: is data loss possible?"\n - "ANOTHER NEW BLOCKER: auth bypass?"\n---\n# Context\n');
const after = audit(tmpDir);
assert.equal(after.counts.context_questions, 1, 'AFTER replacement: must RESURFACE — a count-only snapshot cannot see this');
assert.deepEqual(
after.items.context_questions.filter((i) => !i.scan_error).map((i) => i.questions),
[['BRAND NEW BLOCKER: is data loss possible?', 'ANOTHER NEW BLOCKER: auth bypass?']],
);
});
// ── F2 (#3458 follow-up review): the digest must see the WHOLE question
// set, not the first-3-display-truncated slice `deriveOpenQuestions` used
// to hash — a 4th+ question was invisible to the snapshot ─────────────
test('F2: a 4th open question added after acknowledging a 3-question body-section set RESURFACES the item (digest was blind past position 3)', () => {
const phaseDir = planningPath('phases', '02-beta');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, '02-CONTEXT.md');
fs.writeFileSync(
filePath,
'# Context\n\n## Open Questions\n\n- Q1?\n- Q2?\n- Q3?\n- Q4?\n',
);
const before = audit(tmpDir);
const beforeItem = before.items.context_questions.find((i) => !i.scan_error && i.file === '02-CONTEXT.md');
assert.equal(beforeItem.question_count, 4, 'question_count must reflect all 4 questions, not the display cap');
const result = ack(tmpDir, ['--category', 'context_questions', '--phase', '02', '--file', '02-CONTEXT.md', '--milestone', 'v1.0', '--at', '2026-08-15']);
assert.ok(result.success, `acknowledge must succeed. stderr: ${result.error}`);
assert.equal(audit(tmpDir).items.context_questions.filter((i) => !i.scan_error && i.file === '02-CONTEXT.md').length, 0, 'BEFORE mutation: suppressed');
// Q1–Q3 UNCHANGED (still the first 3 lines — a pre-fix digest hashing
// only `slice(0, 3)` would see NO difference at all); Q4 replaced with
// two brand-new unanswered blockers.
fs.writeFileSync(
filePath,
'# Context\n\n## Open Questions\n\n- Q1?\n- Q2?\n- Q3?\n- Brand new unanswered blocker A?\n- Brand new unanswered blocker B?\n',
);
const after = audit(tmpDir);
const afterItem = after.items.context_questions.find((i) => !i.scan_error && i.file === '02-CONTEXT.md');
assert.ok(afterItem, 'AFTER replacing Q4 with new blockers: the item must RESURFACE — a slice(0,3) digest cannot see past position 3');
assert.equal(afterItem.question_count, 5);
});
// ── SWEEP finding (#3458 follow-up review): the digest's element-join
// must be unambiguous — two DIFFERENT question sets must never encode to
// the same joined string and collide on the same digest ───────────────
test('SWEEP: two different open-question sets that collide under a naive separator-join must record DIFFERENT digests', () => {
const phase1Dir = planningPath('phases', '03-one');
const phase2Dir = planningPath('phases', '04-two');
fs.mkdirSync(phase1Dir, { recursive: true });
fs.mkdirSync(phase2Dir, { recursive: true });
const file1 = path.join(phase1Dir, '03-CONTEXT.md');
const file2 = path.join(phase2Dir, '04-CONTEXT.md');
// Both sets embed a literal NUL codepoint (via the YAML `\x00`
// double-quoted hex escape) at the exact position needed to make the
// TWO DIFFERENT arrays below encode to the byte-identical string under
// ANY single-character-separator join (a plain space join, OR the
// separator this seam actually shipped with) — the general proof that
// NO fixed separator closes this class, only a length-prefixed,
// self-delimiting encoding does.
// Set 1: ["foo\0bar", "baz"] → "foo\0bar" + SEP + "baz"
// Set 2: ["foo", "bar\0baz"] → "foo" + SEP + "bar\0baz"
// For SEP = "\0" both concatenate to the identical "foo\0bar\0baz".
fs.writeFileSync(file1, '---\nopen_questions:\n - "foo\\x00bar"\n - "baz"\n---\n# Context\n');
fs.writeFileSync(file2, '---\nopen_questions:\n - "foo"\n - "bar\\x00baz"\n---\n# Context\n');
const r1 = ack(tmpDir, ['--category', 'context_questions', '--phase', '03', '--file', '03-CONTEXT.md', '--milestone', 'v1.0', '--at', '2026-08-15']);
const r2 = ack(tmpDir, ['--category', 'context_questions', '--phase', '04', '--file', '04-CONTEXT.md', '--milestone', 'v1.0', '--at', '2026-08-15']);
assert.ok(r1.success, `stderr: ${r1.error}`);
assert.ok(r2.success, `stderr: ${r2.error}`);
const digest1 = fs.readFileSync(file1, 'utf-8').match(/questions_digest:\s*([0-9a-f]{64})/)[1];
const digest2 = fs.readFileSync(file2, 'utf-8').match(/questions_digest:\s*([0-9a-f]{64})/)[1];
assert.notEqual(digest1, digest2, 'two DIFFERENT question sets must never record the same digest, even when they collide under a naive separator-join');
});
test('WARNING-2 disproof: adding more pending scenarios to an acknowledged UAT gap (status unchanged) resurfaces the item', () => {
const phaseDir = planningPath('phases', '01-alpha');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, '01-UAT.md');
fs.writeFileSync(filePath, '---\nstatus: gaps_found\n---\n# UAT\n\n## Scenarios\n\n1. result: pending\n');
assert.ok(ack(tmpDir, ['--category', 'uat_gaps', '--phase', '01', '--file', '01-UAT.md', '--milestone', 'v1.0', '--at', '2026-08-15']).success);
let after = audit(tmpDir);
assert.equal(after.counts.uat_gaps, 0, 'BEFORE: 1 pending scenario, suppressed');
// status: stays `gaps_found` — only the scenario count moves, 1 → 6.
fs.writeFileSync(
filePath,
'---\nstatus: gaps_found\n---\n# UAT\n\n## Scenarios\n\n1. result: pending\n2. result: pending\n3. result: pending\n4. result: pending\n5. result: pending\n6. result: pending\n',
);
after = audit(tmpDir);
assert.equal(after.counts.uat_gaps, 1, 'AFTER: same status, MORE pending scenarios — must RESURFACE');
const item = after.items.uat_gaps.find((i) => !i.scan_error);
assert.equal(item.open_scenario_count, 6);
});
// ── WARNING 3 (#3458 follow-up review): the human report must carry the
// same "clean vs silenced" signal --json already did ──────────────────
test('WARNING 3: human-readable report shows the acknowledged tally, per-category and in the all-clear footer', () => {
const phaseDir = planningPath('phases', '01-alpha');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, '01-UAT.md');
fs.writeFileSync(filePath, '---\nstatus: gaps_found\n---\n# UAT\n\n## Gaps\n\n- truth: "x"\n status: open\n');
assert.ok(ack(tmpDir, ['--category', 'uat_gaps', '--phase', '01', '--file', '01-UAT.md', '--milestone', 'v1.0', '--at', '2026-08-15']).success);
// All-clear case: the only item left is a previously-acknowledged one.
const clearReport = runGsdTools(['audit-open'], tmpDir);
assert.ok(clearReport.success, `stderr: ${clearReport.error}`);
assert.match(clearReport.output, /1 previously acknowledged item/i, 'all-clear footer must disclose the suppressed item');
// Now add a genuinely NEW open item so has_open_items is true, and
// confirm the per-category line also discloses the acknowledged one
// still sitting alongside it.
const debugDir = planningPath('debug');
fs.mkdirSync(debugDir, { recursive: true });
fs.writeFileSync(path.join(debugDir, 'investigate.md'), '---\nstatus: open\n---\n## Current Focus\ndigging\n');
const openReport = runGsdTools(['audit-open'], tmpDir);
assert.ok(openReport.success, `stderr: ${openReport.error}`);
assert.match(openReport.output, /previously acknowledged item/i, 'footer must still disclose the acknowledged item while other items are open');
});
// ── writer refuses a path outside the project ──────────────────────────
test('writer refuses to acknowledge a path that escapes the project (path traversal)', () => {
const result = ack(tmpDir, ['--category', 'debug_sessions', '--slug', '../../../../etc/passwd', '--milestone', 'v1.0']);
assert.equal(result.success, false, 'a traversal-shaped --slug must be refused, not written');
});
test('writer refuses to acknowledge a category with a required flag missing', () => {
const result = ack(tmpDir, ['--category', 'uat_gaps', '--milestone', 'v1.0']); // no --phase/--file
assert.equal(result.success, false, 'missing --phase/--file must be refused');
});
// ── mixed-frame fix (security review, #3078-CR follow-up): the writer's
// snapshot value and the scanners' recomputed value must share ONE
// frame (both normalized) even though the writer's SPLICE stays raw. A
// lone-CR artifact discriminates this: normalizing shifts every byte
// offset, so if the splice used normalized text it would corrupt the
// file, and if the snapshot used raw text it would never match the
// scanner's normalized recomputation — acknowledge would silently never
// suppress. An LF control proves the round trip isn't accidentally
// broken for the common case while fixing the CR case. ──────────────
test('mixed-frame fix: context_questions acknowledge on a lone-CR artifact actually suppresses the item', () => {
// A lone-CR CONTEXT.md is the clean discriminator: `deriveOpenQuestions`
// splits the `## Open Questions` body on `\n` — raw lone-CR content has
// NO `\n` at all, so pre-fix the writer's raw-content digest is
// computed over a ZERO-question set (sha256 of ''), which can never
// equal what the scanner (reading normalized content) recomputes — the
// acknowledge is a silent no-op. This file carries no frontmatter
// fence, so it is not entangled with the separate, pre-existing
// splice-vs-lone-CR-fence limitation a UAT/VERIFICATION file with an
// EXISTING lone-CR frontmatter block would hit.
const phaseDir = planningPath('phases', '01-alpha');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, '01-CONTEXT.md');
fs.writeFileSync(filePath, '# Context\r\r## Open Questions\r\r- Which backend?\r- What about auth?\r');
const before = audit(tmpDir);
assert.equal(before.counts.context_questions, 1, 'lone-CR CONTEXT file must be parsed as having open questions before acknowledge');
const result = ack(tmpDir, ['--category', 'context_questions', '--phase', '01', '--file', '01-CONTEXT.md', '--milestone', 'v1.0', '--at', '2026-08-15']);
assert.ok(result.success, `acknowledge must succeed. stderr: ${result.error}`);
assert.notEqual(
JSON.parse(result.output).questions_digest,
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
'the recorded digest must reflect the REAL 2-question set, not sha256 of an empty set (the pre-fix raw-content bug)',
);
const after = audit(tmpDir);
assert.equal(after.counts.context_questions, 0, 'lone-CR context_questions item must be SUPPRESSED, not resurface as a silent no-op');
assert.equal(after.acknowledged.context_questions, 1);
assert.deepEqual(
(after.items.context_questions || []).filter((i) => !i.scan_error).map((i) => i.file),
[],
'the specific acknowledged file must be gone from the open items, by identity — not just a smaller count',
);
});
test('mixed-frame fix control: context_questions acknowledge on an LF artifact still suppresses the item (round trip not broken by the CR fix)', () => {
const phaseDir = planningPath('phases', '02-beta');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, '02-CONTEXT.md');
fs.writeFileSync(filePath, '# Context\n\n## Open Questions\n\n- Which backend?\n- What about auth?\n');
const before = audit(tmpDir);
assert.equal(before.counts.context_questions, 1);
const result = ack(tmpDir, ['--category', 'context_questions', '--phase', '02', '--file', '02-CONTEXT.md', '--milestone', 'v1.0', '--at', '2026-08-15']);
assert.ok(result.success, `acknowledge must succeed. stderr: ${result.error}`);
const after = audit(tmpDir);
assert.equal(after.counts.context_questions, 0, 'LF context_questions item must still be suppressed after the fix');
assert.equal(after.acknowledged.context_questions, 1);
assert.deepEqual(
(after.items.context_questions || []).filter((i) => !i.scan_error).map((i) => i.file),
[],
'the specific acknowledged file must be gone from the open items, by identity',
);
assert.ok(!fs.readFileSync(filePath, 'utf-8').includes('\r'), 'LF file stays LF — the fix must not introduce CR bytes on the common path');
});
test('mixed-frame fix compatibility: a HAND-AUTHORED pre-existing LF acknowledgment marker (never touched by this change\'s writer) is still recognised', () => {
const phaseDir = planningPath('phases', '03-gamma');
fs.mkdirSync(phaseDir, { recursive: true });
const filePath = path.join(phaseDir, '03-UAT.md');
// The marker's `gap_snapshot` value is computed BY HAND here, per
// `deriveUatGapSnapshotValue`'s documented shape
// (`${status}::scenarios=${openScenarioCount}`) — NOT produced by
// calling the writer — so this exercises the scanner's READ side in
// isolation against a marker that predates this change entirely. This
// content has zero `result: pending`/`[pending]` matches, so the open
// scenario count is 0.
fs.writeFileSync(
filePath,
'---\nstatus: gaps_found\naudit_acknowledged:\n milestone: v1.0\n at: "2026-08-15"\n gap_snapshot: "gaps_found::scenarios=0"\n---\n# UAT\n\n## Gaps\n\n- truth: "something broke"\n status: open\n',
);
const after = audit(tmpDir);
assert.equal(after.counts.uat_gaps, 0, 'a pre-existing LF acknowledgment marker must still suppress the item after the mixed-frame fix');
assert.equal(after.acknowledged.uat_gaps, 1);
});
});
}