* 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>
8292 lines
354 KiB
JavaScript
8292 lines
354 KiB
JavaScript
/**
|
||
* GSD Tools Tests - UAT Audit
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const fc = require('./helpers/fast-check-setup.cjs');
|
||
const { runGsdTools, createTempProject, createTempDir, cleanup } = require('./helpers.cjs');
|
||
const {
|
||
buildCheckpoint,
|
||
CHECKPOINT_FRAMES,
|
||
CHECKPOINT_LANGUAGE_ALIASES,
|
||
resolveCheckpointFrame,
|
||
parseDeferredItems,
|
||
parseDeferredItemsWithStatus,
|
||
acknowledgeDeferredItem,
|
||
parseUatItems,
|
||
parseUatItemsWithStats,
|
||
DEFERRED_MARKER_ALT,
|
||
DEFERRED_BULLET_MARKERS,
|
||
} = require('../gsd-core/bin/lib/uat.cjs');
|
||
const { iterateBullets } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs');
|
||
|
||
describe('audit-uat command', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('returns empty results when no UAT files exist', () => {
|
||
// Create a phase directory with no UAT files
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '01-foundation', '.gitkeep'), '');
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.deepStrictEqual(output.results, []);
|
||
assert.strictEqual(output.summary.total_items, 0);
|
||
assert.strictEqual(output.summary.total_files, 0);
|
||
});
|
||
|
||
test('detects UAT with pending items', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, '01-UAT.md'), `---
|
||
status: testing
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
## Tests
|
||
|
||
### 1. Login Form
|
||
expected: Form displays with email and password fields
|
||
result: pass
|
||
|
||
### 2. Submit Button
|
||
expected: Submitting shows loading state
|
||
result: pending
|
||
`);
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 1);
|
||
assert.strictEqual(output.results[0].phase, '01');
|
||
assert.strictEqual(output.results[0].items[0].result, 'pending');
|
||
assert.strictEqual(output.results[0].items[0].category, 'pending');
|
||
assert.strictEqual(output.results[0].items[0].name, 'Submit Button');
|
||
});
|
||
|
||
// Regression: #2273 — bracketed result values [pending], [blocked], [skipped]
|
||
test('detects UAT items with bracketed result values (#2273)', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, '01-UAT.md'), [
|
||
'---',
|
||
'status: testing',
|
||
'phase: 01-foundation',
|
||
'started: 2025-01-01T00:00:00Z',
|
||
'updated: 2025-01-01T00:00:00Z',
|
||
'---',
|
||
'',
|
||
'## Tests',
|
||
'',
|
||
'### 1. Login Form',
|
||
'expected: Form displays correctly',
|
||
'result: [pending]',
|
||
'',
|
||
'### 2. Submit Button',
|
||
'expected: Shows loading state',
|
||
'result: [blocked]',
|
||
'blocked_by: #123',
|
||
'',
|
||
'### 3. Error Message',
|
||
'expected: Shows validation error',
|
||
'result: [skipped]',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 3, 'all 3 bracketed items should be detected');
|
||
assert.strictEqual(output.results[0].items[0].result, 'pending', '[pending] should parse as pending');
|
||
assert.strictEqual(output.results[0].items[1].result, 'blocked', '[blocked] should parse as blocked');
|
||
assert.strictEqual(output.results[0].items[2].result, 'skipped', '[skipped] should parse as skipped');
|
||
});
|
||
|
||
test('detects UAT with blocked items and categorizes blocked_by', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-api');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, '02-UAT.md'), `---
|
||
status: partial
|
||
phase: 02-api
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
## Tests
|
||
|
||
### 1. API Health Check
|
||
expected: Returns 200 OK
|
||
result: blocked
|
||
blocked_by: server
|
||
reason: Server not running locally
|
||
`);
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 1);
|
||
assert.strictEqual(output.results[0].items[0].result, 'blocked');
|
||
assert.strictEqual(output.results[0].items[0].category, 'server_blocked');
|
||
assert.strictEqual(output.results[0].items[0].blocked_by, 'server');
|
||
});
|
||
|
||
test('detects false completion (complete status with pending items)', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-ui');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, '03-UAT.md'), `---
|
||
status: complete
|
||
phase: 03-ui
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
## Tests
|
||
|
||
### 1. Dashboard Layout
|
||
expected: Cards render in grid
|
||
result: pass
|
||
|
||
### 2. Mobile Responsive
|
||
expected: Grid collapses to single column on mobile
|
||
result: pending
|
||
`);
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 1);
|
||
assert.strictEqual(output.results[0].status, 'complete');
|
||
assert.strictEqual(output.results[0].items[0].result, 'pending');
|
||
});
|
||
|
||
test('extracts human_needed items from VERIFICATION files', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-auth');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, '04-VERIFICATION.md'), `---
|
||
status: human_needed
|
||
phase: 04-auth
|
||
---
|
||
|
||
## Automated Checks
|
||
|
||
All passed.
|
||
|
||
## Human Verification
|
||
|
||
1. Test SSO login with Google account
|
||
2. Test password reset flow end-to-end
|
||
3. Verify MFA enrollment on new device
|
||
`);
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 3);
|
||
assert.strictEqual(output.results[0].type, 'verification');
|
||
assert.strictEqual(output.results[0].status, 'human_needed');
|
||
assert.strictEqual(output.results[0].items[0].category, 'human_uat');
|
||
assert.strictEqual(output.results[0].items[0].name, 'Test SSO login with Google account');
|
||
});
|
||
|
||
test('scans and aggregates across multiple phases', () => {
|
||
// Phase 1 with pending
|
||
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phase1, { recursive: true });
|
||
fs.writeFileSync(path.join(phase1, '01-UAT.md'), `---
|
||
status: partial
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
## Tests
|
||
|
||
### 1. Test A
|
||
expected: Works
|
||
result: pending
|
||
`);
|
||
|
||
// Phase 2 with blocked
|
||
const phase2 = path.join(tmpDir, '.planning', 'phases', '02-api');
|
||
fs.mkdirSync(phase2, { recursive: true });
|
||
fs.writeFileSync(path.join(phase2, '02-UAT.md'), `---
|
||
status: partial
|
||
phase: 02-api
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
## Tests
|
||
|
||
### 1. Test B
|
||
expected: Responds
|
||
result: blocked
|
||
blocked_by: server
|
||
|
||
### 2. Test C
|
||
expected: Returns data
|
||
result: skipped
|
||
reason: device not available
|
||
`);
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_files, 2);
|
||
assert.strictEqual(output.summary.total_items, 3);
|
||
assert.strictEqual(output.summary.by_phase['01'], 1);
|
||
assert.strictEqual(output.summary.by_phase['02'], 2);
|
||
});
|
||
|
||
test('milestone scoping filters phases to current milestone', () => {
|
||
// Create a ROADMAP.md that only references Phase 2
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap
|
||
|
||
### Phase 2: API Layer
|
||
**Goal:** Build API
|
||
`);
|
||
|
||
// Phase 1 (not in current milestone) with pending
|
||
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phase1, { recursive: true });
|
||
fs.writeFileSync(path.join(phase1, '01-UAT.md'), `---
|
||
status: partial
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
## Tests
|
||
|
||
### 1. Old Test
|
||
expected: Old behavior
|
||
result: pending
|
||
`);
|
||
|
||
// Phase 2 (in current milestone) with pending
|
||
const phase2 = path.join(tmpDir, '.planning', 'phases', '02-api');
|
||
fs.mkdirSync(phase2, { recursive: true });
|
||
fs.writeFileSync(path.join(phase2, '02-UAT.md'), `---
|
||
status: partial
|
||
phase: 02-api
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
## Tests
|
||
|
||
### 1. New Test
|
||
expected: New behavior
|
||
result: pending
|
||
`);
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
// Only Phase 2 should be included (Phase 1 not in ROADMAP)
|
||
assert.strictEqual(output.summary.total_files, 1);
|
||
assert.strictEqual(output.results[0].phase, '02');
|
||
});
|
||
|
||
test('summary by_category counts are correct', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '05-billing');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, '05-UAT.md'), `---
|
||
status: partial
|
||
phase: 05-billing
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
## Tests
|
||
|
||
### 1. Payment Form
|
||
expected: Stripe elements load
|
||
result: pending
|
||
|
||
### 2. Webhook Handler
|
||
expected: Processes payment events
|
||
result: blocked
|
||
blocked_by: third-party Stripe
|
||
|
||
### 3. Invoice PDF
|
||
expected: Generates downloadable PDF
|
||
result: skipped
|
||
reason: needs release build
|
||
|
||
### 4. Refund Flow
|
||
expected: Processes refund
|
||
result: pending
|
||
`);
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 4);
|
||
assert.strictEqual(output.summary.by_category.pending, 2);
|
||
assert.strictEqual(output.summary.by_category.third_party, 1);
|
||
assert.strictEqual(output.summary.by_category.build_needed, 1);
|
||
});
|
||
|
||
test('ignores VERIFICATION files without human_needed or gaps_found status', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, '01-VERIFICATION.md'), `---
|
||
status: passed
|
||
phase: 01-foundation
|
||
---
|
||
|
||
## Results
|
||
|
||
All checks passed.
|
||
`);
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 0);
|
||
assert.strictEqual(output.summary.total_files, 0);
|
||
});
|
||
|
||
// Regression: #2383 — human_needed items with result: PASS are still reported
|
||
test('ignores human_verification items with result PASS (regression #2383)', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '31-auth');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
// This file has status: human_needed in frontmatter but all individual items
|
||
// have result: "PASS" — they should not be reported as outstanding
|
||
fs.writeFileSync(path.join(phaseDir, '31-VERIFICATION.md'), [
|
||
'---',
|
||
'status: human_needed',
|
||
'phase: 31-auth',
|
||
'gaps_remaining: []',
|
||
'---',
|
||
'',
|
||
'## Human Verification',
|
||
'',
|
||
'| # | Item | Result | Evidence |',
|
||
'|---|------|--------|----------|',
|
||
'| 1 | Test SSO login with Google | PASS | Verified 2025-01-15 |',
|
||
'| 2 | Test password reset flow | PASS | Verified 2025-01-15 |',
|
||
'| 3 | Verify MFA enrollment | PASS | Verified 2025-01-15 |',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 0,
|
||
`Expected 0 outstanding items but got ${output.summary.total_items} — resolved PASS items should not be counted`);
|
||
assert.strictEqual(output.summary.total_files, 0);
|
||
});
|
||
|
||
test('ignores human_needed VERIFICATION file when file-level status is passed (regression #2383)', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '31-auth');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
// When the frontmatter status is "passed", skip entirely regardless of section content
|
||
fs.writeFileSync(path.join(phaseDir, '31-VERIFICATION.md'), [
|
||
'---',
|
||
'status: passed',
|
||
'phase: 31-auth',
|
||
'gaps_remaining: []',
|
||
'---',
|
||
'',
|
||
'## Human Verification',
|
||
'',
|
||
'1. Test SSO login with Google account',
|
||
'2. Test password reset flow end-to-end',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 0,
|
||
`status: passed file should produce 0 outstanding items, got ${output.summary.total_items}`);
|
||
assert.strictEqual(output.summary.total_files, 0);
|
||
});
|
||
|
||
// #3511: a cross-phase, stray, or ad-hoc UAT/VERIFICATION file sitting in
|
||
// this phase's directory must not surface under this phase's audit-uat
|
||
// entry; this phase's own UAT/VERIFICATION artifacts must keep reporting
|
||
// exactly as before (non-stray case unchanged).
|
||
test('#3511: cross-phase stray UAT/VERIFICATION files in the same dir do not surface; own artifacts still do', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-foo');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
// This phase's own UAT — must still report its pending item.
|
||
fs.writeFileSync(path.join(phaseDir, '03-UAT.md'), [
|
||
'---', 'status: partial', '---', '',
|
||
'## Tests', '',
|
||
'### 1. Own Test', 'expected: Works', 'result: pending', '',
|
||
].join('\n'));
|
||
// This phase's own VERIFICATION — must still report its human-needed item.
|
||
fs.writeFileSync(path.join(phaseDir, '03-VERIFICATION.md'), [
|
||
'---', 'status: human_needed', 'phase: 03-foo', '---', '',
|
||
'## Human Verification', '',
|
||
'1. Own human check',
|
||
].join('\n'));
|
||
|
||
// Cross-phase strays sitting in the SAME directory — token "04", not "03".
|
||
fs.writeFileSync(path.join(phaseDir, '04-UAT.md'), [
|
||
'---', 'status: partial', '---', '',
|
||
'## Tests', '',
|
||
'### 1. Stray Test', 'expected: Works', 'result: pending', '',
|
||
].join('\n'));
|
||
fs.writeFileSync(path.join(phaseDir, '04-VERIFICATION.md'), [
|
||
'---', 'status: human_needed', 'phase: 04-bar', '---', '',
|
||
'## Human Verification', '',
|
||
'1. Stray human check',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
|
||
assert.strictEqual(output.summary.total_files, 2,
|
||
`only this phase's own 2 files must be scanned; got: ${JSON.stringify(output.results.map(r => r.file))}`);
|
||
assert.strictEqual(output.summary.total_items, 2,
|
||
`1 own UAT item + 1 own VERIFICATION item, strays excluded; got: ${output.summary.total_items}`);
|
||
assert.strictEqual(output.summary.by_phase['03'], 2, 'own phase must be credited both items');
|
||
assert.ok(!('04' in output.summary.by_phase), 'the cross-phase stray must not appear in by_phase at all');
|
||
assert.ok(!result.output.includes('04-UAT.md'), 'stray UAT filename must never surface in the output');
|
||
assert.ok(!result.output.includes('04-VERIFICATION.md'), 'stray VERIFICATION filename must never surface in the output');
|
||
assert.ok(output.results.some(r => r.file === '03-UAT.md' && r.items.some(i => i.name === 'Own Test')));
|
||
assert.ok(output.results.some(r => r.file === '03-VERIFICATION.md' && r.items.some(i => i.name === 'Own human check')));
|
||
});
|
||
|
||
// #3511 follow-up: over-exclusion check on the #2528 digit-leading-slug
|
||
// family. "05-80-20-cleanup" tokenizes to "05-80-20" (mis-absorbed past
|
||
// the digit run scaffold actually writes into), so a literal token compare
|
||
// excluded the phase's own report — audit-uat reported total_files: 0.
|
||
test('#3511 follow-up: own UAT file still surfaces from the digit-leading-slug dir "05-80-20-cleanup" (over-exclusion check)', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '05-80-20-cleanup');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, '05-UAT.md'), [
|
||
'---', 'status: partial', '---', '',
|
||
'## Tests', '',
|
||
'### 1. Own Test', 'expected: Works', 'result: pending', '',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
|
||
assert.strictEqual(output.summary.total_files, 1,
|
||
`own UAT file in a digit-leading-slug dir must still surface; got: ${JSON.stringify(output)}`);
|
||
assert.strictEqual(output.summary.by_phase['05'], 1);
|
||
});
|
||
|
||
// Regression: #2286 — parseUatItems never scanned a `## Gaps` section, so a
|
||
// *-UAT.md file recording its only outstanding findings there returned
|
||
// total_items: 0 (false-clean). Boundary: 0 / 1 / 2+ unresolved entries.
|
||
describe('Gaps separator lines are not items (#3898)', () => {
|
||
// The reporter's exact measurement table: every separator shape must
|
||
// yield ONLY the real entry. A spaced hyphen break matched the item
|
||
// opener regex (/^(\s*)-\s/) and fabricated a gap named '- -' with
|
||
// result 'unknown' — unfixable by editing any entry, because there is
|
||
// no entry, only the separator the author put there deliberately.
|
||
const mkDoc = (sep) => [
|
||
'---', 'status: partial', 'phase: 01-x', '---', '',
|
||
'## Gaps', '',
|
||
sep,
|
||
'- truth: real', ' status: open', '',
|
||
].join('\n');
|
||
|
||
const SEPARATORS = [
|
||
'- - -',
|
||
'- -',
|
||
'- - -',
|
||
'- - - -',
|
||
' - - -',
|
||
// unaffected forms stay unaffected (accidentally today, by handling after the fix)
|
||
'---',
|
||
'----',
|
||
'* * *',
|
||
'___',
|
||
];
|
||
for (const sep of SEPARATORS) {
|
||
test(`separator ${JSON.stringify(sep)} yields only the real entry`, () => {
|
||
const items = parseUatItems(mkDoc(sep));
|
||
assert.deepStrictEqual(
|
||
items.map((i) => i.name),
|
||
['real'],
|
||
`a thematic break must be a separator, not an entry (#3898); got ${JSON.stringify(items.map((i) => i.name))}`,
|
||
);
|
||
});
|
||
}
|
||
|
||
test('property: any bullet line whose remainder is only hyphens/spaces (>=2 hyphens) yields no item', () => {
|
||
// CLAUDE.md's parser-contract convention: table coverage above, property
|
||
// coverage here — arbitrary spacings and counts, not just the table's nine.
|
||
fc.assert(fc.property(
|
||
fc.integer({ min: 2, max: 6 }), // extra hyphens
|
||
fc.integer({ min: 0, max: 3 }), // leading indent
|
||
fc.integer({ min: 1, max: 3 }), // spaces between hyphens
|
||
(hyphens, indent, gap) => {
|
||
const pad = ' '.repeat(indent);
|
||
const sep = pad + Array(hyphens + 1).fill('-').join(' '.repeat(gap));
|
||
const items = parseUatItems(mkDoc(sep));
|
||
return items.length === 1 && items[0].name === 'real';
|
||
},
|
||
), { seed: 20260829, numRuns: 60 });
|
||
});
|
||
|
||
test('#3898 review: a separator inside a live entry keeps its span contiguous (ack-able)', () => {
|
||
// Disposition (a): a separator deeper than baseIndent folds back as a
|
||
// continuation line, so entry lines and the entry's byte span agree —
|
||
// the ack writer's identity re-verification still matches.
|
||
const items = parseUatItems([
|
||
'---', 'status: partial', 'phase: 01-x', '---', '',
|
||
'## Gaps', '',
|
||
'- truth: real', ' - - -', ' status: open', '',
|
||
].join('\n'));
|
||
assert.deepStrictEqual(items.map((i) => i.name), ['real']);
|
||
});
|
||
|
||
test('a real entry whose text starts with a hyphen is still an entry (no over-skip)', () => {
|
||
const items = parseUatItems([
|
||
'---', 'status: partial', 'phase: 01-x', '---', '',
|
||
'## Gaps', '',
|
||
'- truth: "-5 error budget remaining"', ' status: open', '',
|
||
].join('\n'));
|
||
assert.deepStrictEqual(items.map((i) => i.name), ['-5 error budget remaining']);
|
||
});
|
||
});
|
||
|
||
describe('Gaps section scanning (#2286)', () => {
|
||
test('a Gaps-only UAT file with 0 unresolved entries (all resolved) yields no items', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, '01-UAT.md'), [
|
||
'---',
|
||
'status: partial',
|
||
'phase: 01-foundation',
|
||
'---',
|
||
'',
|
||
'## Gaps',
|
||
'',
|
||
'<!-- YAML format for plan-phase --gaps consumption -->',
|
||
'- truth: "SC1: Widget renders with data"',
|
||
' status: resolved',
|
||
' reason: "Fixed in follow-up commit"',
|
||
'',
|
||
'- truth: "SC2: Second finding also fixed"',
|
||
' status: resolved',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 0,
|
||
'resolved Gaps entries must not be counted as outstanding items');
|
||
assert.strictEqual(output.summary.total_files, 0);
|
||
});
|
||
|
||
test('a Gaps-only UAT file with exactly 1 unresolved entry and zero ### N. test blocks yields 1 item', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, '01-UAT.md'), [
|
||
'---',
|
||
'status: partial',
|
||
'phase: 01-foundation',
|
||
'---',
|
||
'',
|
||
'## Gaps',
|
||
'',
|
||
'<!-- YAML format for plan-phase --gaps consumption -->',
|
||
'- truth: "SC1: Widget renders with data"',
|
||
' status: open',
|
||
' reason: "Missing data binding"',
|
||
' severity: major',
|
||
' test: 2',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 1, 'total_items must be > 0, not the false-clean 0');
|
||
assert.strictEqual(output.results[0].type, 'uat');
|
||
assert.strictEqual(output.results[0].items[0].name, 'SC1: Widget renders with data');
|
||
assert.strictEqual(output.results[0].items[0].result, 'open');
|
||
assert.strictEqual(output.results[0].items[0].reason, 'Missing data binding');
|
||
assert.strictEqual(output.results[0].items[0].test, 2);
|
||
});
|
||
|
||
test('a Gaps section with 2+ unresolved entries surfaces all of them and skips the resolved one', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-api');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, '02-UAT.md'), [
|
||
'---',
|
||
'status: partial',
|
||
'phase: 02-api',
|
||
'---',
|
||
'',
|
||
'## Gaps',
|
||
'',
|
||
'<!-- YAML format for plan-phase --gaps consumption -->',
|
||
'- truth: "SC1: First outstanding gap"',
|
||
' status: failed',
|
||
' reason: "Endpoint returns 500"',
|
||
'',
|
||
'- truth: "SC2: Second outstanding gap"',
|
||
' status: open',
|
||
'',
|
||
'- truth: "SC3: Already fixed gap"',
|
||
' status: resolved',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 2,
|
||
'exactly the 2 unresolved gaps should be counted, resolved gap excluded');
|
||
const names = output.results[0].items.map((item) => item.name).sort();
|
||
assert.deepStrictEqual(names, ['SC1: First outstanding gap', 'SC2: Second outstanding gap']);
|
||
});
|
||
|
||
// Regression: #2286 review HIGH finding — a naive whole-string `key:`
|
||
// scan over a Gaps entry's flattened text matches the FIRST `key:`-shaped
|
||
// substring anywhere, including one embedded inside an EARLIER field's
|
||
// own quoted free-text value. A `truth`/`reason` value that itself
|
||
// contains the literal text "status: resolved" (or "reason:"/"test:")
|
||
// must never hijack the real, later `status:`/`reason:`/`test:` field —
|
||
// the fix parses each field anchored to the START of its own line.
|
||
test('a truth value containing the literal substring "status: resolved" does not suppress the real open status', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, '01-UAT.md'), [
|
||
'---',
|
||
'status: partial',
|
||
'phase: 01-foundation',
|
||
'---',
|
||
'',
|
||
'## Gaps',
|
||
'',
|
||
'<!-- YAML format for plan-phase --gaps consumption -->',
|
||
'- truth: "The status: resolved workflow should trigger a banner"',
|
||
' status: failed',
|
||
' reason: "Contains a reason: field embedded phrase, and test: 9 too"',
|
||
' test: 3',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 1,
|
||
'the genuinely open gap must be surfaced, not dropped because its truth text contains "status: resolved"');
|
||
const item = output.results[0].items[0];
|
||
assert.strictEqual(item.name, 'The status: resolved workflow should trigger a banner');
|
||
assert.strictEqual(item.result, 'failed', 'the REAL status: field must win, not the embedded phrase inside truth');
|
||
assert.strictEqual(item.reason, 'Contains a reason: field embedded phrase, and test: 9 too',
|
||
'the reason value is taken verbatim, including its own embedded colon-bearing phrases');
|
||
assert.strictEqual(item.test, 3, 'the REAL test: field (3) must win, not the "test: 9" phrase embedded in reason');
|
||
});
|
||
|
||
// Regression: #2286 review LOW finding — a nested `artifacts:` sub-list
|
||
// (per templates/UAT.md's `## Gaps` schema) must be folded into its
|
||
// parent entry, not mis-split into spurious standalone items.
|
||
test('a Gaps entry with a nested artifacts sub-list parses as exactly one item', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, '01-UAT.md'), [
|
||
'---',
|
||
'status: partial',
|
||
'phase: 01-foundation',
|
||
'---',
|
||
'',
|
||
'## Gaps',
|
||
'',
|
||
'<!-- YAML format for plan-phase --gaps consumption -->',
|
||
'- truth: "SC1: Some behavior"',
|
||
' status: failed',
|
||
' reason: "reason text"',
|
||
' severity: major',
|
||
' test: 1',
|
||
' root_cause: ""',
|
||
' artifacts:',
|
||
' - src/foo.ts',
|
||
' - src/bar.ts',
|
||
' missing: []',
|
||
' debug_session: ""',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 1,
|
||
'the nested artifacts sub-list items must not spawn spurious extra Gaps items');
|
||
assert.strictEqual(output.results[0].items[0].name, 'SC1: Some behavior');
|
||
assert.strictEqual(output.results[0].items[0].category, 'unknown',
|
||
'a Gaps item with no dedicated category mapping falls back to unknown');
|
||
});
|
||
|
||
// Regression: #2286 review item 5 (fail-safe direction) — #2286 is a
|
||
// false-NEGATIVE bug, so a Gaps entry with no parseable `status:` field
|
||
// is surfaced (as result: 'unknown') rather than silently dropped.
|
||
test('a Gaps entry with no status field is surfaced as an unknown-status item (fail-safe)', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, '01-UAT.md'), [
|
||
'---',
|
||
'status: partial',
|
||
'phase: 01-foundation',
|
||
'---',
|
||
'',
|
||
'## Gaps',
|
||
'',
|
||
'- truth: "SC1: Missing status field entirely"',
|
||
' reason: "why it is open"',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 1,
|
||
'a garbled/missing status must SURFACE the entry, not silently drop it');
|
||
assert.strictEqual(output.results[0].items[0].result, 'unknown');
|
||
assert.strictEqual(output.results[0].items[0].name, 'SC1: Missing status field entirely');
|
||
});
|
||
|
||
test('an empty Gaps section (heading present, no bullets) yields 0 items without throwing', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, '01-UAT.md'), [
|
||
'---',
|
||
'status: partial',
|
||
'phase: 01-foundation',
|
||
'---',
|
||
'',
|
||
'## Gaps',
|
||
'',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 0);
|
||
assert.strictEqual(output.summary.total_files, 0);
|
||
});
|
||
});
|
||
|
||
// Regression: #2286 — parseVerificationItems never read the frontmatter's
|
||
// structured `human_verification:` YAML array, and never recognized the
|
||
// `### N. <label>` + bold-paragraph body shape shipped by
|
||
// templates/verification-report.md. Boundary: array length 0 / 1 / 2+.
|
||
describe('human_verification frontmatter array + heading shape (#2286)', () => {
|
||
test('an empty human_verification array (length 0) falls back to the body scan', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-auth');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, '04-VERIFICATION.md'), [
|
||
'---',
|
||
'status: human_needed',
|
||
'phase: 04-auth',
|
||
'human_verification: []',
|
||
'---',
|
||
'',
|
||
'## Human Verification',
|
||
'',
|
||
'1. Test SSO login with Google account',
|
||
'2. Test password reset flow end-to-end',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 2,
|
||
'an empty structured array must fall back to the existing body scan, not report 0');
|
||
assert.strictEqual(output.results[0].items[0].name, 'Test SSO login with Google account');
|
||
});
|
||
|
||
test('a populated human_verification array of length 1 is sourced from frontmatter as primary', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-auth');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, '04-VERIFICATION.md'), [
|
||
'---',
|
||
'status: human_needed',
|
||
'phase: 04-auth',
|
||
'human_verification:',
|
||
' - test: "Confirm the widget renders correctly"',
|
||
'---',
|
||
'',
|
||
'## Human Verification',
|
||
'',
|
||
'None — see frontmatter human_verification array.',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 1,
|
||
'total_items must reflect the frontmatter array, not the unstructured body prose');
|
||
// ADR-3473 §8.1 (#3881): pre-migration, extractFrontmatter's hand-rolled array-item
|
||
// scanner had no notion of nested key/value objects — a `- test: "..."` entry was
|
||
// flattened via a REGEX quote-strip (`.replace(/^["']|["']$/g, '')`) that only ever
|
||
// matches a quote at the very start or very end of the WHOLE post-"- " string. Since
|
||
// this string starts with `test:` (not a quote), only the regex's END anchor matched,
|
||
// stripping the trailing `"` but leaving the opening one embedded mid-string — a
|
||
// documented but genuinely ugly artifact (`test: "Confirm the widget renders correctly`,
|
||
// unbalanced quote and all).
|
||
//
|
||
// Under js-yaml (ADR-3473 §8.1), `- test: "..."` is parsed as real YAML — a proper
|
||
// mapping `{test: "Confirm the widget renders correctly"}` — and `flattenObjectListItem`
|
||
// re-joins it as `key: value` with the value's OWN quoting already resolved by the real
|
||
// parser, not re-derived by a second regex. The embedded quote is gone because it was
|
||
// never data to begin with; it was YAML's own value-delimiter syntax. This is strictly
|
||
// more correct (no unbalanced-quote artifact) and the `normalizeHumanVerificationEntry`
|
||
// consumer is unaffected — it still receives a `name` string of the same shape (still not
|
||
// lossy of the `test:` label prefix, which is a deliberate, documented, and unrelated
|
||
// decision — see normalizeHumanVerificationEntry's doc comment).
|
||
assert.strictEqual(output.results[0].items[0].name, 'test: Confirm the widget renders correctly');
|
||
assert.strictEqual(output.results[0].items[0].category, 'human_uat');
|
||
});
|
||
|
||
// Regression: #2286 review LOW finding — a plain-string human_verification
|
||
// entry that itself starts with "Word: " must be preserved verbatim, not
|
||
// truncated by a (removed) leading-key-prefix strip.
|
||
test('a plain-string human_verification entry beginning with "Word: " is preserved verbatim', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-auth');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, '04-VERIFICATION.md'), [
|
||
'---',
|
||
'status: human_needed',
|
||
'phase: 04-auth',
|
||
'human_verification:',
|
||
' - "Confirm: the button responds"',
|
||
'---',
|
||
'',
|
||
'## Human Verification',
|
||
'',
|
||
'None.',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 1);
|
||
assert.strictEqual(output.results[0].items[0].name, 'Confirm: the button responds',
|
||
'a plain string beginning with a word and a colon must not be truncated');
|
||
});
|
||
|
||
test('a populated human_verification array of length 2+ takes priority over a differently-shaped body', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-auth');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, '04-VERIFICATION.md'), [
|
||
'---',
|
||
'status: human_needed',
|
||
'phase: 04-auth',
|
||
'human_verification:',
|
||
' - "Confirm SSO login works end to end"',
|
||
' - "Confirm MFA enrollment banner appears"',
|
||
'---',
|
||
'',
|
||
'## Human Verification',
|
||
'',
|
||
'1. A body-scan item that must NOT be double-counted',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 2,
|
||
'the structured array is the PRIMARY source and must not union with the body scan');
|
||
const names = output.results[0].items.map((item) => item.name).sort();
|
||
assert.deepStrictEqual(names, ['Confirm MFA enrollment banner appears', 'Confirm SSO login works end to end']);
|
||
});
|
||
|
||
test('recognizes the ### N. <label> + bold-paragraph Human Verification body shape', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '05-widgets');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, '05-VERIFICATION.md'), [
|
||
'---',
|
||
'status: human_needed',
|
||
'phase: 05-widgets',
|
||
'---',
|
||
'',
|
||
'## Human Verification Required',
|
||
'',
|
||
'### 1. Widget render check',
|
||
'**Test:** Confirm the widget appears as expected on the dashboard.',
|
||
'**Expected:** Widget renders with live data within 2 seconds.',
|
||
'**Why human:** Visual rendering cannot be verified by static analysis.',
|
||
'',
|
||
'### 2. Notification banner check',
|
||
'**Test:** Trigger a new notification and confirm the banner appears.',
|
||
'**Expected:** Banner appears within 1 second and auto-dismisses after 5 seconds.',
|
||
'**Why human:** Timing-based UI behavior requires visual confirmation.',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 2,
|
||
'the ### N. + bold-paragraph shape must be recognized instead of returning 0 items');
|
||
assert.strictEqual(output.results[0].items[0].test, 1);
|
||
assert.strictEqual(output.results[0].items[0].name, 'Widget render check');
|
||
assert.strictEqual(output.results[0].items[1].test, 2);
|
||
assert.strictEqual(output.results[0].items[1].name, 'Notification banner check');
|
||
assert.strictEqual(output.results[0].items[0].category, 'human_uat');
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('uat render-checkpoint', () => {
|
||
let tmpDir;
|
||
let uatPath;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test-phase');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
uatPath = path.join(phaseDir, '01-UAT.md');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('buildCheckpoint: unset/unrecognized language falls back to English default (#2402)', () => {
|
||
const currentTest = { number: 1, name: 'Sample', expected: 'Something happens.' };
|
||
const defaultOutput = buildCheckpoint(currentTest);
|
||
const explicitEnglish = buildCheckpoint(currentTest, 'English');
|
||
const unrecognized = buildCheckpoint(currentTest, 'Klingon');
|
||
|
||
assert.strictEqual(defaultOutput, explicitEnglish, 'unset language should equal the English frame');
|
||
assert.strictEqual(defaultOutput, unrecognized, 'unrecognized language should fall back to the English frame');
|
||
assert.ok(defaultOutput.includes('CHECKPOINT: Verification Required'));
|
||
});
|
||
|
||
test('buildCheckpoint: recognized language swaps only the two frame strings (#2402)', () => {
|
||
const currentTest = { number: 1, name: 'Sample', expected: 'Something happens.' };
|
||
const english = buildCheckpoint(currentTest);
|
||
const japanese = buildCheckpoint(currentTest, 'Japanese');
|
||
|
||
assert.ok(japanese.includes('チェックポイント'));
|
||
assert.ok(japanese.includes('`pass`'));
|
||
// Structural lines (heading marker, separator, Test N heading, expected content) are untouched.
|
||
assert.ok(japanese.includes('### チェックポイント: 検証が必要です'));
|
||
assert.ok(japanese.includes('---'));
|
||
assert.ok(japanese.includes('**Test 1: Sample**'));
|
||
assert.ok(japanese.includes('Something happens.'));
|
||
assert.ok(!/[╔╗╚╝║]/.test(japanese), 'the box border must be gone (#3028)');
|
||
assert.notStrictEqual(japanese, english);
|
||
});
|
||
|
||
test('resolveCheckpointFrame: every extended-pack alias resolves its localized frame', () => {
|
||
// Exercise canonical names, ISO codes, endonyms, and transliterations so a
|
||
// typo or duplicate alias cannot silently route a supported language back
|
||
// to the English fallback.
|
||
const cases = [
|
||
{
|
||
aliases: ['Dutch', 'nl', 'nederlands', 'flemish', 'vlaams'],
|
||
frame: {
|
||
banner: 'CONTROLEPUNT: Verificatie vereist',
|
||
instruction: 'Typ `pass` of beschrijf wat er mis is.',
|
||
},
|
||
},
|
||
{
|
||
aliases: ['Polish', 'pl', 'polski'],
|
||
frame: {
|
||
banner: 'PUNKT KONTROLNY: Wymagana weryfikacja',
|
||
instruction: 'Wpisz `pass` lub opisz, co jest nie tak.',
|
||
},
|
||
},
|
||
{
|
||
aliases: ['Russian', 'ru', 'ru-ru', 'русский'],
|
||
frame: {
|
||
banner: 'КОНТРОЛЬНАЯ ТОЧКА: требуется проверка',
|
||
instruction: 'Введите `pass` или опишите, что не так.',
|
||
},
|
||
},
|
||
{
|
||
aliases: ['Ukrainian', 'uk', 'ua', 'українська'],
|
||
frame: {
|
||
banner: 'КОНТРОЛЬНА ТОЧКА: потрібна перевірка',
|
||
instruction: 'Введіть `pass` або опишіть, що не так.',
|
||
},
|
||
},
|
||
{
|
||
aliases: ['Turkish', 'tr', 'türkçe', 'turkce'],
|
||
frame: {
|
||
banner: 'KONTROL NOKTASI: Doğrulama gerekli',
|
||
instruction: '`pass` yazın veya sorunu açıklayın.',
|
||
},
|
||
},
|
||
{
|
||
aliases: ['Hindi', 'hi', 'हिन्दी', 'हिंदी'],
|
||
frame: {
|
||
banner: 'चेकपॉइंट: सत्यापन आवश्यक',
|
||
instruction: '`pass` लिखें या बताएं कि क्या गलत है।',
|
||
},
|
||
},
|
||
{
|
||
aliases: ['Arabic', 'ar', 'العربية'],
|
||
frame: {
|
||
banner: 'نقطة تحقق: المراجعة مطلوبة',
|
||
instruction: 'اكتب `pass` أو صف المشكلة.',
|
||
direction: 'rtl',
|
||
},
|
||
},
|
||
{
|
||
aliases: ['Vietnamese', 'vi', 'tiếng việt', 'tieng viet'],
|
||
frame: {
|
||
banner: 'ĐIỂM KIỂM TRA: Cần xác minh',
|
||
instruction: 'Nhập `pass` hoặc mô tả vấn đề.',
|
||
},
|
||
},
|
||
{
|
||
aliases: ['Indonesian', 'id', 'bahasa indonesia'],
|
||
frame: {
|
||
banner: 'TITIK PEMERIKSAAN: Verifikasi diperlukan',
|
||
instruction: 'Ketik `pass` atau jelaskan apa yang salah.',
|
||
},
|
||
},
|
||
];
|
||
for (const { aliases, frame } of cases) {
|
||
for (const alias of aliases) {
|
||
assert.deepStrictEqual(
|
||
resolveCheckpointFrame(alias),
|
||
frame,
|
||
`${alias} resolved to the wrong checkpoint frame`,
|
||
);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('checkpoint frame and alias catalogs remain structurally complete', () => {
|
||
const english = CHECKPOINT_FRAMES.english;
|
||
assert.ok(english, 'English fallback frame must exist');
|
||
|
||
for (const [language, frame] of Object.entries(CHECKPOINT_FRAMES)) {
|
||
const expectedKeys = frame.direction
|
||
? ['banner', 'direction', 'instruction']
|
||
: ['banner', 'instruction'];
|
||
assert.deepStrictEqual(
|
||
Object.keys(frame).sort(),
|
||
expectedKeys,
|
||
`${language} has an unexpected checkpoint-frame shape`,
|
||
);
|
||
assert.ok(frame.banner.trim(), `${language} banner must be non-empty`);
|
||
assert.ok(frame.instruction.trim(), `${language} instruction must be non-empty`);
|
||
if (frame.direction !== undefined) {
|
||
assert.strictEqual(frame.direction, 'rtl', `${language} has an unsupported direction`);
|
||
}
|
||
assert.strictEqual(
|
||
CHECKPOINT_LANGUAGE_ALIASES[language],
|
||
language,
|
||
`${language} must self-alias to its canonical frame`,
|
||
);
|
||
if (language !== 'english') {
|
||
assert.notDeepStrictEqual(frame, english, `${language} must not duplicate the English frame`);
|
||
}
|
||
}
|
||
|
||
for (const [alias, language] of Object.entries(CHECKPOINT_LANGUAGE_ALIASES)) {
|
||
const frame = CHECKPOINT_FRAMES[language];
|
||
assert.ok(frame, `${alias} targets missing checkpoint frame ${language}`);
|
||
assert.strictEqual(
|
||
resolveCheckpointFrame(alias),
|
||
frame,
|
||
`${alias} must resolve to its declared checkpoint frame`,
|
||
);
|
||
if (language !== 'english') {
|
||
assert.notDeepStrictEqual(
|
||
frame,
|
||
english,
|
||
`${alias} must not resolve to the English fallback`,
|
||
);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Two alias keys that differ only by case or Unicode normalization form are
|
||
// distinct object keys — every assertion above still passes. But resolution
|
||
// lowercases and NFC-normalizes before the lookup, so the two collapse to one
|
||
// lookup key at runtime and whichever was written first becomes unreachable:
|
||
// the losing language silently renders the English fallback.
|
||
//
|
||
// Both defects survive compilation and both are observable on the catalog
|
||
// itself, precisely because the keys stay distinct. The remaining case — two
|
||
// byte-identical keys, where the object genuinely no longer records what was
|
||
// written — is rejected by tsc as TS1117 before this suite can run, since the
|
||
// tests execute against `gsd-core/bin/lib/uat.cjs` built from this source.
|
||
test('checkpoint alias catalog declares no colliding or unreachable alias keys', () => {
|
||
const declared = Object.keys(CHECKPOINT_LANGUAGE_ALIASES);
|
||
|
||
const seen = new Set();
|
||
const collisions = declared.filter(
|
||
(alias) => seen.size === seen.add(alias.normalize('NFC').toLowerCase()).size,
|
||
);
|
||
assert.deepStrictEqual(
|
||
collisions,
|
||
[],
|
||
`alias key(s) collapse onto an earlier alias once normalized for lookup, so one language silently loses its alias: ${collisions.join(', ')}`,
|
||
);
|
||
|
||
// An alias not already in lookup form is the mirror defect: it collides with
|
||
// nothing, and resolveCheckpointFrame() — which normalizes its argument
|
||
// before indexing — can never produce it, so the entry is simply dead.
|
||
const unreachable = declared.filter(
|
||
(alias) => alias !== alias.normalize('NFC').toLowerCase(),
|
||
);
|
||
assert.deepStrictEqual(
|
||
unreachable,
|
||
[],
|
||
`alias key(s) are not in NFC-lowercase lookup form and can never resolve: ${unreachable.join(', ')}`,
|
||
);
|
||
});
|
||
|
||
test('resolveCheckpointFrame: canonically equivalent aliases resolve after NFC normalization', () => {
|
||
assert.deepStrictEqual(
|
||
resolveCheckpointFrame('türkçe'.normalize('NFD')),
|
||
resolveCheckpointFrame('türkçe'),
|
||
);
|
||
assert.deepStrictEqual(
|
||
resolveCheckpointFrame('tiếng việt'.normalize('NFD')),
|
||
resolveCheckpointFrame('tiếng việt'),
|
||
);
|
||
});
|
||
|
||
// Regression: #3028 — the checkpoint renderer no longer draws a 64-column
|
||
// double-line box (checkpointBoxLine/displayWidth/isWideCodePoint/
|
||
// ZERO_WIDTH_MARK_RE/CHECKPOINT_BOX_WIDTH were removed from src/uat.cts).
|
||
// These cases now pin the heading form (`### {banner}`) directly instead of
|
||
// a padded box interior; the localized-language coverage that used to prove
|
||
// display-width-correct padding now proves the banner text is emitted
|
||
// intact, unpadded, and box-free.
|
||
describe('checkpoint banner renders as a heading, not a box (#2402, #2530, #3028)', () => {
|
||
test('exact rendered banner heading for Japanese/Chinese/Korean (regression pin)', () => {
|
||
const currentTest = { number: 1, name: 'Sample', expected: 'Something happens.' };
|
||
const japanese = buildCheckpoint(currentTest, 'Japanese');
|
||
const chinese = buildCheckpoint(currentTest, 'Chinese');
|
||
const korean = buildCheckpoint(currentTest, 'Korean');
|
||
|
||
assert.strictEqual(japanese.split('\n')[0], '### チェックポイント: 検証が必要です');
|
||
assert.strictEqual(chinese.split('\n')[0], '### 检查点:需要验证');
|
||
assert.strictEqual(korean.split('\n')[0], '### 체크포인트: 검증 필요');
|
||
|
||
for (const output of [japanese, chinese, korean]) {
|
||
assert.ok(!/[╔╗╚╝║]/.test(output), 'the box border must be gone (#3028)');
|
||
}
|
||
});
|
||
|
||
test('exact rendered Hindi banner heading ignores combining-mark cell width (regression pin)', () => {
|
||
const currentTest = { number: 1, name: 'Sample', expected: 'Something happens.' };
|
||
const hindi = buildCheckpoint(currentTest, 'Hindi');
|
||
assert.strictEqual(hindi.split('\n')[0], '### चेकपॉइंट: सत्यापन आवश्यक');
|
||
assert.ok(!/[╔╗╚╝║]/.test(hindi), 'the box border must be gone (#3028)');
|
||
});
|
||
|
||
test('exact rendered Arabic frame is isolated inside the LTR checkpoint layout', () => {
|
||
const currentTest = { number: 1, name: 'Sample', expected: 'Something happens.' };
|
||
const arabic = buildCheckpoint(currentTest, 'Arabic');
|
||
// The one behavior the box removal must not disturb: the RTL banner and
|
||
// instruction text stay wrapped in directional isolates.
|
||
assert.strictEqual(
|
||
arabic.split('\n')[0],
|
||
`### نقطة تحقق: المراجعة مطلوبة`,
|
||
);
|
||
assert.ok(arabic.includes('اكتب `pass` أو صف المشكلة.'));
|
||
assert.ok(!/[╔╗╚╝║]/.test(arabic), 'the box border must be gone (#3028)');
|
||
});
|
||
|
||
test('emits an over-long banner intact (no box to overflow)', (t) => {
|
||
// Previously a banner exceeding the 64-column inner width produced a
|
||
// ragged, unpadded border. Now there is no border to overflow — the
|
||
// full heading text is emitted intact regardless of length. None of the
|
||
// shipped frames are long enough to exercise this, so a synthetic frame
|
||
// is registered on the exported (mutable) lookup tables for the
|
||
// duration of the test.
|
||
const longBanner = `${'X'.repeat(80)}: Verification required well beyond the old 64-column box width`;
|
||
const frameKey = '__test_overlong_frame__3028__';
|
||
const aliasKey = '__test_overlong_alias__3028__';
|
||
CHECKPOINT_FRAMES[frameKey] = {
|
||
banner: longBanner,
|
||
instruction: 'Type `pass` or describe what\'s wrong.',
|
||
};
|
||
CHECKPOINT_LANGUAGE_ALIASES[aliasKey] = frameKey;
|
||
t.after(() => {
|
||
delete CHECKPOINT_FRAMES[frameKey];
|
||
delete CHECKPOINT_LANGUAGE_ALIASES[aliasKey];
|
||
});
|
||
const currentTest = { number: 1, name: 'Sample', expected: 'Something happens.' };
|
||
const output = buildCheckpoint(currentTest, aliasKey);
|
||
assert.strictEqual(output.split('\n')[0], `### ${longBanner}`,
|
||
'an over-long banner must be emitted in full, not truncated or wrapped');
|
||
assert.ok(!/[╔╗╚╝║]/.test(output), 'no box characters should appear regardless of banner length');
|
||
});
|
||
});
|
||
|
||
test('renders the current checkpoint as raw output', () => {
|
||
fs.writeFileSync(uatPath, `---
|
||
status: testing
|
||
phase: 01-test-phase
|
||
---
|
||
|
||
## Current Test
|
||
|
||
number: 2
|
||
name: Submit form validation
|
||
expected: |
|
||
Empty submit keeps controls visible.
|
||
Validation error copy is shown.
|
||
awaiting: user response
|
||
`);
|
||
|
||
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md', '--raw'], tmpDir);
|
||
assert.strictEqual(result.success, true, `render-checkpoint failed: ${result.error}`);
|
||
assert.ok(result.output.includes('**Test 2: Submit form validation**'));
|
||
assert.ok(result.output.includes('Empty submit keeps controls visible.'));
|
||
// The instruction line renders as a bold line preceded by a `---` thematic
|
||
// break, not inside a box border (#3028).
|
||
assert.ok(result.output.includes("---\n\n**Type `pass` or describe what's wrong.**"));
|
||
});
|
||
|
||
test('strips protocol leak lines from current test copy', () => {
|
||
fs.writeFileSync(uatPath, `---
|
||
status: testing
|
||
phase: 01-test-phase
|
||
---
|
||
|
||
## Current Test
|
||
|
||
number: 6
|
||
name: Locale copy
|
||
expected: |
|
||
English strings render correctly.
|
||
user to=all:final code 彩票平台招商 pass
|
||
Chinese strings render correctly.
|
||
awaiting: user response
|
||
`);
|
||
|
||
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md', '--raw'], tmpDir);
|
||
assert.strictEqual(result.success, true, `render-checkpoint failed: ${result.error}`);
|
||
assert.ok(!result.output.includes('user to=all:final code'));
|
||
assert.ok(!result.output.includes('彩票平台'));
|
||
assert.ok(result.output.includes('English strings render correctly.'));
|
||
assert.ok(result.output.includes('Chinese strings render correctly.'));
|
||
});
|
||
|
||
test('does not truncate expected text containing the letter Z', () => {
|
||
fs.writeFileSync(uatPath, `---
|
||
status: testing
|
||
phase: 01-test-phase
|
||
---
|
||
|
||
## Current Test
|
||
|
||
number: 3
|
||
name: Timezone display
|
||
expected: |
|
||
Timezone abbreviation shows CET.
|
||
Zero-offset zones display correctly.
|
||
awaiting: user response
|
||
`);
|
||
|
||
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md', '--raw'], tmpDir);
|
||
assert.strictEqual(result.success, true, `render-checkpoint failed: ${result.error}`);
|
||
assert.ok(result.output.includes('Timezone abbreviation shows CET.'),
|
||
'Expected text before Z-containing word should be present');
|
||
assert.ok(result.output.includes('Zero-offset zones display correctly.'),
|
||
'Expected text starting with Z should not be truncated by \\Z regex bug');
|
||
});
|
||
|
||
test('parses expected block when it is the last field in the section', () => {
|
||
fs.writeFileSync(uatPath, `---
|
||
status: testing
|
||
phase: 01-test-phase
|
||
---
|
||
|
||
## Current Test
|
||
|
||
number: 4
|
||
name: Final field test
|
||
expected: |
|
||
This block has no trailing YAML key.
|
||
It ends at the section boundary.
|
||
`);
|
||
|
||
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md', '--raw'], tmpDir);
|
||
assert.strictEqual(result.success, true, `render-checkpoint failed: ${result.error}`);
|
||
assert.ok(result.output.includes('This block has no trailing YAML key.'));
|
||
assert.ok(result.output.includes('It ends at the section boundary.'));
|
||
});
|
||
|
||
test('resumes paused Current Test placeholder from first pending test (#1300)', () => {
|
||
fs.writeFileSync(uatPath, [
|
||
'---',
|
||
'status: partial',
|
||
'phase: 01-test-phase',
|
||
'started: 2026-06-15T00:00:00Z',
|
||
'updated: 2026-06-15T00:00:00Z',
|
||
'---',
|
||
'',
|
||
'## Current Test',
|
||
'',
|
||
'[testing paused — 2 items outstanding]',
|
||
'',
|
||
'## Tests',
|
||
'',
|
||
'### 1. First test',
|
||
'expected: something observable',
|
||
'result: pass',
|
||
'',
|
||
'### 2. Second test',
|
||
'expected: another observable thing',
|
||
'result: [pending]',
|
||
'',
|
||
'## Summary',
|
||
'',
|
||
'total: 2',
|
||
'passed: 1',
|
||
'issues: 0',
|
||
'pending: 1',
|
||
'skipped: 0',
|
||
'blocked: 0',
|
||
'',
|
||
'## Gaps',
|
||
'',
|
||
'[none yet]',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md'], tmpDir);
|
||
assert.strictEqual(result.success, true, `render-checkpoint failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.test_number, 2);
|
||
assert.strictEqual(output.test_name, 'Second test');
|
||
assert.strictEqual(output.file_path, '.planning/phases/01-test-phase/01-UAT.md');
|
||
});
|
||
|
||
test('raw checkpoint mode accepts paused Current Test placeholder (#1300)', () => {
|
||
fs.writeFileSync(uatPath, [
|
||
'---',
|
||
'status: partial',
|
||
'phase: 01-test-phase',
|
||
'---',
|
||
'',
|
||
'## Current Test',
|
||
'',
|
||
'[testing paused — 1 item outstanding]',
|
||
'',
|
||
'## Tests',
|
||
'',
|
||
'### 1. First pending test',
|
||
'expected: raw mode checkpoint is available',
|
||
'result: [pending]',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md', '--raw'], tmpDir);
|
||
assert.strictEqual(result.success, true, `render-checkpoint failed: ${result.error}`);
|
||
assert.ok(result.output.length > 0, 'raw mode must emit a checkpoint');
|
||
});
|
||
|
||
test('non-structured Current Test with no pending tests reports actionable resume error (#1300)', () => {
|
||
fs.writeFileSync(uatPath, [
|
||
'---',
|
||
'status: partial',
|
||
'phase: 01-test-phase',
|
||
'---',
|
||
'',
|
||
'## Current Test',
|
||
'',
|
||
'[testing paused — 0 items outstanding]',
|
||
'',
|
||
'## Tests',
|
||
'',
|
||
'### 1. Already handled test',
|
||
'expected: completed behavior',
|
||
'result: pass',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md'], tmpDir);
|
||
assert.strictEqual(result.success, false, 'Should fail when a paused placeholder has no pending test to resume');
|
||
assert.ok(result.error.includes('no pending UAT test remains'));
|
||
assert.ok(!result.error.includes('Current Test section is malformed'));
|
||
});
|
||
|
||
test('fails when testing is already complete', () => {
|
||
fs.writeFileSync(uatPath, `---
|
||
status: complete
|
||
phase: 01-test-phase
|
||
---
|
||
|
||
## Current Test
|
||
|
||
[testing complete]
|
||
`);
|
||
|
||
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md'], tmpDir);
|
||
assert.strictEqual(result.success, false, 'Should fail when no current test exists');
|
||
assert.ok(result.error.includes('already complete'));
|
||
});
|
||
|
||
// #2402: response_language must reach the checkpoint frame itself — verify-work.md
|
||
// requires the model to reprint the checkpoint byte-for-byte, so translation can't
|
||
// happen after the fact. The renderer has to already emit localized frame strings.
|
||
test('localizes the checkpoint frame when response_language is configured (#2402)', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({ response_language: 'Spanish' })
|
||
);
|
||
fs.writeFileSync(uatPath, `---
|
||
status: testing
|
||
phase: 01-test-phase
|
||
---
|
||
|
||
## Current Test
|
||
|
||
number: 2
|
||
name: Submit form validation
|
||
expected: |
|
||
Empty submit keeps controls visible.
|
||
Validation error copy is shown.
|
||
awaiting: user response
|
||
`);
|
||
|
||
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md', '--raw'], tmpDir);
|
||
assert.strictEqual(result.success, true, `render-checkpoint failed: ${result.error}`);
|
||
|
||
// Frame strings must be localized, not English.
|
||
assert.ok(!result.output.includes('CHECKPOINT: Verification Required'),
|
||
'banner should be localized, not the English default');
|
||
assert.ok(!result.output.includes("Type `pass` or describe what's wrong."),
|
||
'instruction line should be localized, not the English default');
|
||
assert.ok(result.output.includes('Verificación requerida'), 'banner should be in Spanish');
|
||
assert.ok(result.output.includes('Escribe `pass`'), 'instruction line should be in Spanish');
|
||
|
||
// Structure/IDs stay untranslated: the heading marker, the `---` separator,
|
||
// the Test N: name line, and the expected content are preserved verbatim.
|
||
assert.ok(result.output.includes('### PUNTO DE CONTROL: Verificación requerida'));
|
||
assert.ok(result.output.includes('---'));
|
||
assert.ok(result.output.includes('**Test 2: Submit form validation**'));
|
||
assert.ok(result.output.includes('Empty submit keeps controls visible.'));
|
||
assert.ok(result.output.includes('Validation error copy is shown.'));
|
||
assert.ok(!/[╔╗╚╝║]/.test(result.output), 'the box border must be gone (#3028)');
|
||
});
|
||
|
||
// Regression guard for the "unset ⇒ byte-identical English" acceptance criterion.
|
||
test('renders byte-identical English checkpoint when response_language is unset (#2402)', () => {
|
||
fs.writeFileSync(uatPath, `---
|
||
status: testing
|
||
phase: 01-test-phase
|
||
---
|
||
|
||
## Current Test
|
||
|
||
number: 2
|
||
name: Submit form validation
|
||
expected: |
|
||
Empty submit keeps controls visible.
|
||
Validation error copy is shown.
|
||
awaiting: user response
|
||
`);
|
||
|
||
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md', '--raw'], tmpDir);
|
||
assert.strictEqual(result.success, true, `render-checkpoint failed: ${result.error}`);
|
||
|
||
const expected = [
|
||
'### CHECKPOINT: Verification Required',
|
||
'',
|
||
'**Test 2: Submit form validation**',
|
||
'',
|
||
'Empty submit keeps controls visible.\nValidation error copy is shown.',
|
||
'',
|
||
'---',
|
||
'',
|
||
'**Type `pass` or describe what\'s wrong.**',
|
||
].join('\n');
|
||
|
||
assert.strictEqual(result.output, expected);
|
||
});
|
||
});
|
||
|
||
// ─── cmdAuditUat behavioral coverage (#2287 deferred-items.md) ─────────────
|
||
|
||
describe('#2287 cmdAuditUat: deferred-items.md awareness', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('no deferred-items.md present (0 entries) → no results, no false positive', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '.gitkeep'), '');
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.deepStrictEqual(output.results, []);
|
||
assert.strictEqual(output.summary.total_items, 0);
|
||
assert.strictEqual(output.summary.total_files, 0);
|
||
});
|
||
|
||
test('deferred-items.md with only a resolved entry (0 unresolved) → no result surfaced', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, 'deferred-items.md'), [
|
||
'## Deferred Items',
|
||
'',
|
||
'- Already handled unrelated lint warning.',
|
||
' status: resolved',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.deepStrictEqual(output.results, [],
|
||
'a fully-resolved deferred-items.md must not surface any result');
|
||
assert.strictEqual(output.summary.total_items, 0);
|
||
});
|
||
|
||
test('deferred-items.md with 1 unresolved entry → surfaced in structured JSON output', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, 'deferred-items.md'), [
|
||
'## Deferred Items',
|
||
'',
|
||
'- Found an unrelated pre-existing test failure in `some-other-module` while working on',
|
||
' this phase\'s task. Out of scope for this task — logged here per SCOPE BOUNDARY.',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 1);
|
||
assert.strictEqual(output.summary.total_files, 1);
|
||
assert.strictEqual(output.summary.by_category.deferred, 1);
|
||
assert.strictEqual(output.summary.by_phase['01'], 1);
|
||
|
||
const deferredResult = output.results.find(r => r.type === 'deferred');
|
||
assert.ok(deferredResult, 'a deferred-typed result must be present');
|
||
assert.strictEqual(deferredResult.phase, '01');
|
||
assert.strictEqual(deferredResult.file, 'deferred-items.md');
|
||
assert.strictEqual(
|
||
deferredResult.file_path,
|
||
'.planning/phases/01-foundation/deferred-items.md',
|
||
);
|
||
assert.strictEqual(deferredResult.items.length, 1);
|
||
assert.match(deferredResult.items[0].name, /unrelated pre-existing test failure/);
|
||
assert.strictEqual(deferredResult.items[0].result, 'unresolved');
|
||
assert.strictEqual(deferredResult.items[0].category, 'deferred');
|
||
});
|
||
|
||
test('deferred-items.md with 2+ entries (mixed resolved/unresolved) → only unresolved surfaced', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, 'deferred-items.md'), [
|
||
'## Deferred Items',
|
||
'',
|
||
'- First unrelated finding, still open.',
|
||
'- Second unrelated finding, also still open.',
|
||
'- Third finding, already fixed separately.',
|
||
' status: resolved',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
const deferredResult = output.results.find(r => r.type === 'deferred');
|
||
assert.ok(deferredResult);
|
||
assert.strictEqual(deferredResult.items.length, 2,
|
||
'exactly the 2 unresolved entries must surface; the resolved 3rd must not');
|
||
const names = deferredResult.items.map(i => i.name);
|
||
assert.ok(names.some(n => n.includes('First unrelated finding')));
|
||
assert.ok(names.some(n => n.includes('Second unrelated finding')));
|
||
assert.ok(!names.some(n => n.includes('Third finding')));
|
||
});
|
||
|
||
test('deferred entries surface across multiple phase directories', () => {
|
||
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
const phase2 = path.join(tmpDir, '.planning', 'phases', '02-auth');
|
||
fs.mkdirSync(phase1, { recursive: true });
|
||
fs.mkdirSync(phase2, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phase1, 'deferred-items.md'), [
|
||
'## Deferred Items',
|
||
'',
|
||
'- Phase 1 unrelated finding.',
|
||
].join('\n'));
|
||
fs.writeFileSync(path.join(phase2, 'deferred-items.md'), [
|
||
'## Deferred Items',
|
||
'',
|
||
'- Phase 2 unrelated finding.',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
const deferredResults = output.results.filter(r => r.type === 'deferred');
|
||
assert.strictEqual(deferredResults.length, 2);
|
||
assert.strictEqual(output.summary.total_items, 2);
|
||
assert.strictEqual(output.summary.by_phase['01'], 1);
|
||
assert.strictEqual(output.summary.by_phase['02'], 1);
|
||
});
|
||
|
||
test('an entry with a garbled/missing status fails safe and is surfaced (not silently dropped)', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, 'deferred-items.md'), [
|
||
'## Deferred Items',
|
||
'',
|
||
'- An entry with no status field at all.',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 1,
|
||
'missing status must SURFACE the entry, not silently drop it');
|
||
});
|
||
|
||
test('existing UAT/VERIFICATION scanning is unchanged when a deferred-items.md is also present', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, '01-UAT.md'), [
|
||
'---',
|
||
'status: testing',
|
||
'phase: 01-foundation',
|
||
'started: 2025-01-01T00:00:00Z',
|
||
'updated: 2025-01-01T00:00:00Z',
|
||
'---',
|
||
'',
|
||
'## Tests',
|
||
'',
|
||
'### 1. Login Form',
|
||
'expected: Form displays with email and password fields',
|
||
'result: pending',
|
||
].join('\n'));
|
||
|
||
fs.writeFileSync(path.join(phaseDir, 'deferred-items.md'), [
|
||
'## Deferred Items',
|
||
'',
|
||
'- An unrelated out-of-scope finding.',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.results.length, 2, 'both the UAT file and deferred-items.md must surface as separate results');
|
||
const uatResult = output.results.find(r => r.type === 'uat');
|
||
const deferredResult = output.results.find(r => r.type === 'deferred');
|
||
assert.ok(uatResult, 'existing uat-type result must still be present');
|
||
assert.strictEqual(uatResult.items.length, 1);
|
||
assert.strictEqual(uatResult.items[0].result, 'pending');
|
||
assert.ok(deferredResult, 'new deferred-type result must be present');
|
||
assert.strictEqual(deferredResult.items.length, 1);
|
||
});
|
||
});
|
||
|
||
// ─── forensic_audit workflow-prose source-contract guard (#2287) ──────────
|
||
|
||
// #2994 fragmentization moved the --forensic-gated forensic_audit step out of
|
||
// progress.md into gsd-core/workflows/progress/steps/forensic-audit.md behind
|
||
// a section marker. Read that step file directly — it is the sole remaining
|
||
// source of the forensic_audit step body these guards assert on.
|
||
const PROGRESS_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'progress', 'steps', 'forensic-audit.md');
|
||
|
||
describe('#2287 progress.md forensic_audit: deferred-items.md contract', () => {
|
||
const content = fs.readFileSync(PROGRESS_MD, 'utf-8');
|
||
const stepStart = content.indexOf('<step name="forensic_audit">');
|
||
const stepEnd = content.indexOf('</step>', stepStart);
|
||
const section = stepStart !== -1 && stepEnd !== -1 ? content.slice(stepStart, stepEnd) : '';
|
||
|
||
test('forensic_audit step exists', () => {
|
||
assert.notEqual(stepStart, -1, 'progress.md (or its extracted progress/steps/forensic-audit.md) must contain the forensic_audit step');
|
||
});
|
||
|
||
test('forensic_audit now runs 7 checks (was 6) and globs deferred-items.md', () => {
|
||
assert.ok(/running 7 deep checks/i.test(section),
|
||
'forensic_audit must advertise 7 deep checks (was 6) now that deferred-items.md is read');
|
||
assert.ok(/\.planning\/phases\/\*\/deferred-items\.md/.test(section),
|
||
'forensic_audit must glob .planning/phases/*/deferred-items.md');
|
||
});
|
||
|
||
test('the new check reports unresolved deferred items with the same ✓/⚠ semantics as the other checks', () => {
|
||
assert.ok(/check\s*7/i.test(section),
|
||
'a 7th check must be present');
|
||
assert.ok(/unresolved deferred items/i.test(section),
|
||
'the check must be framed around unresolved deferred items');
|
||
assert.ok(/✓[^\n]*no unresolved deferred items/i.test(section),
|
||
'the check must emit a ✓ pass line when no unresolved deferred items exist');
|
||
assert.ok(/⚠[^\n]*unresolved deferred items found/i.test(section),
|
||
'the check must emit a ⚠ warning line when unresolved deferred items exist');
|
||
});
|
||
|
||
test('an entry is resolved only via an explicit status: resolved field (fail-safe otherwise)', () => {
|
||
assert.ok(/status:\s*resolved/i.test(section),
|
||
'the resolved/unresolved parsing rule must be documented in the step prose');
|
||
});
|
||
|
||
test('the verdict summary now gates on 7 checks (was 6)', () => {
|
||
assert.ok(/after all 7 checks/i.test(section),
|
||
'the verdict section must say "after all 7 checks"');
|
||
assert.ok(/if all 7 checks passed/i.test(section),
|
||
'the verdict section must say "if all 7 checks passed"');
|
||
assert.ok(!/after all 6 checks/i.test(section) && !/if all 6 checks passed/i.test(section),
|
||
'stale "6 checks" phrasing must not remain in the step');
|
||
});
|
||
});
|
||
|
||
// ─── parseDeferredItems property test (#2287, widened by #3702 round 2) ─────
|
||
|
||
describe('#2287 parseDeferredItems: property (status: resolved fail-safe) × marker × shape × line ending', () => {
|
||
// Single-line entry text: no newlines (would break bullet-entry splitting),
|
||
// non-empty after trim, and never itself SHAPED like a `status:` field line
|
||
// (that would be indistinguishable from a real field regardless of intent).
|
||
const plainText = fc.string({ minLength: 1, maxLength: 40 })
|
||
.map((s) => s.replace(/[\r\n]/g, ' ').trim())
|
||
.filter((s) => s.length > 0 && !/^status:/i.test(s));
|
||
|
||
// Decoy: entry text that CONTAINS a `status: resolved`-shaped substring
|
||
// mid-line (not at line start) — must never be misread as a resolved
|
||
// marker, since extractGapEntryFields only recognises a field anchored to
|
||
// the START of its own trimmed line (see parseDeferredItems' doc comment).
|
||
const decoyText = plainText.map((s) => `${s} status: resolved trailing note`);
|
||
|
||
const textArb = fc.oneof(plainText, decoyText);
|
||
// `statusFirst` matters on the heading shape: round 1's only CRLF test put
|
||
// `**Status:**` LAST, the one line `collectSection`'s `.trimEnd()` had
|
||
// already de-CR'd, and the B1 regression hid behind it.
|
||
// `decoy` adds a prose line that BEGINS with a non-1 ordinal (`7. …`): under
|
||
// the start-at-1 rule (B2) it is never an item, never evidence and never a
|
||
// field — round 1 read it as an opener. Placed where it cannot end a run:
|
||
// before the first headless entry, and first in a heading body.
|
||
const entryArb = fc.record({ text: textArb, resolved: fc.boolean(), statusFirst: fc.boolean(), decoy: fc.boolean() });
|
||
const decoyOrdinal = fc.integer({ min: 2, max: 999999999 });
|
||
|
||
// #3702 round 2 (B3): the marker set is an enumerated domain — exactly what a
|
||
// property is for. Ordered markers are numbered from 1 (the ordered-run
|
||
// rule, B2); the two shapes exercise both splitters; CRLF exercises the
|
||
// heading path's CR handling (B1).
|
||
const markerArb = fc.constantFrom('-', '*', '+', 'ordered');
|
||
const shapeArb = fc.constantFrom('headless', 'heading');
|
||
const eolArb = fc.constantFrom('\n', '\r\n');
|
||
const mk = (marker, i) => (marker === 'ordered' ? `${i + 1}.` : marker);
|
||
|
||
const render = (entries, marker, shape, eol, ordinal = 7) => {
|
||
const lines = ['## Deferred Items', ''];
|
||
if (shape === 'headless' && entries.some((e) => e.decoy)) {
|
||
// Pre-first-entry prose: a rejected ordinal is discarded, an accepted
|
||
// one (round 1) opens a phantom entry and breaks the count.
|
||
lines.push(`${ordinal}. ${entries.find((e) => e.decoy).text} status: resolved`, '');
|
||
}
|
||
entries.forEach((e, i) => {
|
||
if (shape === 'headless') {
|
||
lines.push(`${mk(marker, i)} ${e.text}`);
|
||
if (e.resolved) lines.push(' status: resolved');
|
||
} else {
|
||
lines.push(`### ${e.text}`, '');
|
||
// A decoy ordinal line FIRST in the body: never stripped, so the
|
||
// `status: resolved` after it can never become a field.
|
||
if (e.decoy) lines.push(`${ordinal}. ${e.text} status: resolved`);
|
||
const what = (n) => `${mk(marker, n)} **What:** ${e.text}`;
|
||
const status = (n) => `${mk(marker, n)} **Status:** resolved`;
|
||
if (!e.resolved) lines.push(what(0));
|
||
else if (e.statusFirst) lines.push(status(0), what(1));
|
||
else lines.push(what(0), status(1));
|
||
lines.push('');
|
||
}
|
||
});
|
||
return lines.join(eol);
|
||
};
|
||
const idOf = (name) => { const m = /E(\d+)_/.exec(name); return m ? Number(m[1]) : -1; };
|
||
|
||
test('property: an entry is surfaced iff it is NOT marked status: resolved; surfaced count == non-resolved count', () => {
|
||
fc.assert(
|
||
fc.property(
|
||
fc.array(entryArb, { maxLength: 20 }), markerArb, shapeArb, eolArb, decoyOrdinal,
|
||
(rawEntries, marker, shape, eol, ordinal) => {
|
||
// Index-prefix for uniqueness so surfaced items can be mapped back
|
||
// to their source entry unambiguously even with colliding random text.
|
||
const entries = rawEntries.map((e, i) => ({ ...e, text: `E${i}_${e.text}` }));
|
||
const content = render(entries, marker, shape, eol, ordinal);
|
||
const where = `${shape} ${JSON.stringify(marker)} ${JSON.stringify(eol)} decoy=${ordinal}`;
|
||
|
||
const items = parseDeferredItems(content);
|
||
const surfacedIds = new Set(items.map((it) => idOf(it.name)));
|
||
|
||
const expectedUnresolved = entries.filter((e) => !e.resolved);
|
||
|
||
// Total surfaced count equals the count of non-resolved entries.
|
||
assert.strictEqual(items.length, expectedUnresolved.length, where);
|
||
|
||
// Every non-resolved entry IS surfaced (including status:-shaped
|
||
// decoy substrings embedded mid-line — those must not flip the
|
||
// outcome).
|
||
for (const [i, e] of entries.entries()) {
|
||
assert.strictEqual(surfacedIds.has(i), !e.resolved, `${where}: ${e.resolved ? 'resolved entry must never surface' : 'unresolved entry must surface'}: ${e.text}`);
|
||
}
|
||
|
||
// Headless: the surfaced NAME is the entry text with the marker gone,
|
||
// whichever marker it was (the name is what acknowledge matches on).
|
||
if (shape === 'headless') {
|
||
for (const it of items) assert.strictEqual(it.name, entries[idOf(it.name)].text, where);
|
||
}
|
||
// A heading body carrying ONLY a decoy ordinal line is prose, not an entry.
|
||
if (shape === 'heading' && entries.length > 0) {
|
||
const decoyOnly = `## Deferred Items${eol}${eol}### only-decoy${eol}${eol}${ordinal}. ${entries[0].text} status: resolved${eol}`;
|
||
assert.deepStrictEqual(parseDeferredItems(decoyOnly), [], `${where}: decoy-only body`);
|
||
}
|
||
|
||
// Every returned item carries the fixed deferred category/result shape.
|
||
for (const item of items) {
|
||
assert.strictEqual(item.result, 'unresolved');
|
||
assert.strictEqual(item.category, 'deferred');
|
||
}
|
||
}
|
||
)
|
||
);
|
||
});
|
||
|
||
test('property: acknowledge reaches and rewrites every unresolved headless entry, whichever marker or line ending', () => {
|
||
// The writer refuses the heading shape by design (`unsupported_heading_shape`),
|
||
// so this ranges over the headless shape only. It is the property that
|
||
// reaches M4 (CRLF rewrite reported ok and wrote nothing) and m2 (indent).
|
||
fc.assert(
|
||
fc.property(
|
||
fc.array(entryArb, { minLength: 1, maxLength: 12 }), markerArb, eolArb,
|
||
(rawEntries, marker, eol) => {
|
||
const entries = rawEntries.map((e, i) => ({ ...e, text: `E${i}_${e.text}` }));
|
||
let content = render(entries, marker, 'headless', eol);
|
||
const where = `${JSON.stringify(marker)} ${JSON.stringify(eol)}`;
|
||
|
||
for (const e of entries.filter((x) => !x.resolved)) {
|
||
const got = acknowledgeDeferredItem(content, e.text);
|
||
assert.strictEqual(got.status, 'ok', `${where}: ${e.text}`);
|
||
assert.notStrictEqual(got.content, content, `${where}: an ok must have written: ${e.text}`);
|
||
content = got.content;
|
||
}
|
||
const after = parseDeferredItemsWithStatus(content);
|
||
assert.strictEqual(after.length, entries.length, where);
|
||
for (const it of after) {
|
||
const e = entries[idOf(it.name)];
|
||
assert.strictEqual(it.status, e.resolved ? 'resolved' : 'acknowledged', `${where}: ${it.name}`);
|
||
}
|
||
// `acknowledged` is suppressed at the AUDIT layer, not the parser's:
|
||
// only `resolved` leaves the outstanding list here, so the count is
|
||
// unchanged by the writes above.
|
||
assert.strictEqual(parseDeferredItems(content).length, entries.filter((x) => !x.resolved).length, where);
|
||
}
|
||
)
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── #2766: archived phase dirs, and GFM-table-shaped deferred/gaps ────────
|
||
|
||
const UAT_ONE_PENDING = [
|
||
'---',
|
||
'status: partial',
|
||
'phase: 01-foundation',
|
||
'---',
|
||
'',
|
||
'## Current Test',
|
||
'',
|
||
'[awaiting human testing]',
|
||
'',
|
||
'## Tests',
|
||
'',
|
||
'### 1. A scenario nobody ever ran',
|
||
'expected: something observable happens',
|
||
'result: [pending]',
|
||
'',
|
||
'## Summary',
|
||
'',
|
||
'total: 1',
|
||
'pending: 1',
|
||
'',
|
||
'## Gaps',
|
||
'',
|
||
].join('\n');
|
||
|
||
/** Write a UAT file whose `## Gaps` section holds `gapsBody`. */
|
||
function uatWithGaps(gapsBody) {
|
||
return [
|
||
'---',
|
||
'status: complete',
|
||
'phase: 50-gaps',
|
||
'---',
|
||
'',
|
||
'## Current Test',
|
||
'',
|
||
'[testing complete]',
|
||
'',
|
||
'## Tests',
|
||
'',
|
||
'### 1. A passing scenario',
|
||
'expected: this one is fine',
|
||
'result: pass',
|
||
'',
|
||
'## Summary',
|
||
'',
|
||
'total: 1',
|
||
'passed: 1',
|
||
'',
|
||
'## Gaps',
|
||
'',
|
||
gapsBody,
|
||
'',
|
||
].join('\n');
|
||
}
|
||
|
||
// ─── Bug 1: archived phase dirs ───────────────────────────────────────────────
|
||
|
||
describe('#2766 cmdAuditUat: archived phase directories', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('phases ONLY in the archive → items surfaced, not a hard error', () => {
|
||
const archiveDir = path.join(
|
||
tmpDir, '.planning', 'milestones', 'v1.0-phases', '01-foundation',
|
||
);
|
||
fs.mkdirSync(archiveDir, { recursive: true });
|
||
fs.writeFileSync(path.join(archiveDir, '01-UAT.md'), UAT_ONE_PENDING);
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 1);
|
||
assert.strictEqual(output.results.length, 1);
|
||
assert.strictEqual(output.results[0].phase, '01');
|
||
assert.strictEqual(output.results[0].archived_milestone, 'v1.0');
|
||
assert.match(output.results[0].file_path, /milestones\/v1\.0-phases\//);
|
||
});
|
||
|
||
test('active and archived trees are both scanned', () => {
|
||
const activeDir = path.join(tmpDir, '.planning', 'phases', '40-current');
|
||
fs.mkdirSync(activeDir, { recursive: true });
|
||
fs.writeFileSync(path.join(activeDir, '40-UAT.md'), UAT_ONE_PENDING);
|
||
|
||
const archiveDir = path.join(
|
||
tmpDir, '.planning', 'milestones', 'v1.0-phases', '01-foundation',
|
||
);
|
||
fs.mkdirSync(archiveDir, { recursive: true });
|
||
fs.writeFileSync(path.join(archiveDir, '01-UAT.md'), UAT_ONE_PENDING);
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
const byPhase = new Map(output.results.map(r => [r.phase, r]));
|
||
assert.ok(byPhase.has('01'), `archived phase missing: ${JSON.stringify([...byPhase.keys()])}`);
|
||
assert.ok(byPhase.has('40'), `active phase missing: ${JSON.stringify([...byPhase.keys()])}`);
|
||
assert.strictEqual(byPhase.get('01').archived_milestone, 'v1.0');
|
||
assert.strictEqual(byPhase.get('40').archived_milestone, undefined);
|
||
});
|
||
|
||
test('multiple archived milestones are all scanned', () => {
|
||
for (const [version, phase] of [['v1.0', '01-foundation'], ['v2.0', '07-later']]) {
|
||
const dir = path.join(tmpDir, '.planning', 'milestones', `${version}-phases`, phase);
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
fs.writeFileSync(path.join(dir, `${phase.slice(0, 2)}-UAT.md`), UAT_ONE_PENDING);
|
||
}
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.summary.total_items, 2);
|
||
assert.deepStrictEqual(
|
||
output.results.map(r => r.archived_milestone).sort(),
|
||
['v1.0', 'v2.0'],
|
||
);
|
||
});
|
||
|
||
test('an empty active phases dir still succeeds with no items (pre-existing behavior)', () => {
|
||
// createTempProject() ships an empty `.planning/phases/`, so this is the
|
||
// shape the existing uat.test.cjs "no UAT files" case covers — the archive
|
||
// change must not turn it into an error.
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.deepStrictEqual(output.results, []);
|
||
assert.strictEqual(output.summary.total_items, 0);
|
||
});
|
||
|
||
test('no phases dir AND no archive still errors — no false all-clear', (t) => {
|
||
// A bare temp dir with a .planning/ that has NO phases subdir and no
|
||
// milestones archive — built from createTempDir rather than by deleting
|
||
// createTempProject's phases dir, so nothing is torn down mid-test.
|
||
const bare = createTempDir();
|
||
t.after(() => cleanup(bare));
|
||
|
||
fs.mkdirSync(path.join(bare, '.planning'), { recursive: true });
|
||
|
||
const result = runGsdTools('audit-uat --raw', bare);
|
||
assert.strictEqual(result.success, false, 'expected a failure when no phases exist at all');
|
||
});
|
||
});
|
||
|
||
// ─── Bug 2: table-shaped deferred-items.md ────────────────────────────────────
|
||
|
||
describe('#2766 parseDeferredItems: GFM table shape', () => {
|
||
const names = (md) => parseDeferredItems(md).map(i => i.name);
|
||
|
||
test('header + delimiter → header dropped, data rows surfaced', () => {
|
||
assert.deepStrictEqual(
|
||
names([
|
||
'## Discovered during 01-03',
|
||
'',
|
||
'| Test | Failing seeds |',
|
||
'|------|---------------|',
|
||
'| test_a | 0, 1 |',
|
||
'| test_b | 424242 |',
|
||
].join('\n')),
|
||
['test_a — 0, 1', 'test_b — 424242'],
|
||
);
|
||
});
|
||
|
||
test('later columns are preserved, not truncated to the first cell', () => {
|
||
const [name] = names('| T | seeds |\n|---|---|\n| test_a | 0, 1, 424242 |');
|
||
assert.match(name, /0, 1, 424242/);
|
||
});
|
||
|
||
test('headerless table → every row surfaced', () => {
|
||
assert.deepStrictEqual(
|
||
names('| test_a | 0 |\n| test_b | 1 |'),
|
||
['test_a — 0', 'test_b — 1'],
|
||
);
|
||
});
|
||
|
||
test('row marked resolved/done/pass is suppressed', () => {
|
||
assert.deepStrictEqual(
|
||
names([
|
||
'| Test | Seeds | Status |',
|
||
'|---|---|---|',
|
||
'| test_open | 0 | open |',
|
||
'| test_fixed | 1 | resolved |',
|
||
'| test_done | 2 | DONE |',
|
||
].join('\n')),
|
||
['test_open — 0 — open'],
|
||
);
|
||
});
|
||
|
||
test('two prose-separated tables → each drops its own header', () => {
|
||
assert.deepStrictEqual(
|
||
names([
|
||
'| T1 | x |', '|---|---|', '| one | 1 |',
|
||
'',
|
||
'some prose in between',
|
||
'',
|
||
'| T2 | y |', '|---|---|', '| two | 2 |',
|
||
].join('\n')),
|
||
['one — 1', 'two — 2'],
|
||
);
|
||
});
|
||
|
||
test('bullets and a table in one file → union, no double-counting', () => {
|
||
const got = names([
|
||
'## Deferred Items',
|
||
'',
|
||
'- a bullet-shaped deferred entry',
|
||
'',
|
||
'| Test | Seeds |',
|
||
'|---|---|',
|
||
'| test_a | 0 |',
|
||
].join('\n'));
|
||
assert.strictEqual(got.length, 2, JSON.stringify(got));
|
||
assert.ok(got.some(n => n.includes('bullet-shaped')));
|
||
assert.ok(got.some(n => n.startsWith('test_a')));
|
||
});
|
||
|
||
test('bullet-only file unchanged (no regression on #2287)', () => {
|
||
assert.deepStrictEqual(
|
||
names('## Deferred Items\n\n- entry one\n- entry two\n'),
|
||
['entry one', 'entry two'],
|
||
);
|
||
});
|
||
|
||
test('explicit status: resolved bullet still suppressed (no regression on #2287)', () => {
|
||
const got = names(
|
||
'## Deferred Items\n\n- truth: "closed thing"\n status: resolved\n- truth: "open thing"\n',
|
||
);
|
||
assert.strictEqual(got.length, 1, JSON.stringify(got));
|
||
assert.match(got[0], /open thing/);
|
||
});
|
||
|
||
test('no table and no bullets → zero items, no throw', () => {
|
||
assert.deepStrictEqual(names('# Notes\n\njust prose, nothing actionable.\n'), []);
|
||
});
|
||
});
|
||
|
||
// ─── #3457: heading-delimited deferred entries ────────────────────────────────
|
||
|
||
describe('#3457 parseDeferredItems: heading-delimited entries', () => {
|
||
const items = (md) => parseDeferredItems(md);
|
||
const names = (md) => items(md).map(i => i.name);
|
||
|
||
test('issue minimal repro: heading + sibling field bullets = ONE item', () => {
|
||
const got = items([
|
||
'# Deferred Items',
|
||
'',
|
||
'## Deferred Items',
|
||
'',
|
||
'### Widget layout suite — 3 failing assertions',
|
||
'',
|
||
'- **What:** three assertions fail on widget alignment.',
|
||
'- **Cause:** a pre-existing uncommitted edit in the working tree.',
|
||
'- **Scope:** out of this plan\'s scope.',
|
||
'- **Disposition:** NOT fixed here; left for a follow-up plan.',
|
||
].join('\n'));
|
||
|
||
assert.strictEqual(got.length, 1, JSON.stringify(got.map(i => i.name)));
|
||
assert.match(got[0].name, /Widget layout suite — 3 failing assertions/);
|
||
assert.match(got[0].name, /three assertions fail/);
|
||
assert.strictEqual(got[0].result, 'unresolved');
|
||
assert.strictEqual(got[0].category, 'deferred');
|
||
});
|
||
|
||
test('flat shape: `#` title + `##` entries — title is not an item', () => {
|
||
const got = names([
|
||
'# Deferred Items',
|
||
'',
|
||
'## DEF-01 renderer fix',
|
||
'',
|
||
'- **What:** a.',
|
||
'',
|
||
'## DEF-02 seed drift',
|
||
'',
|
||
'- **What:** b.',
|
||
].join('\n'));
|
||
|
||
assert.strictEqual(got.length, 2, JSON.stringify(got));
|
||
assert.match(got[0], /^DEF-01 renderer fix/);
|
||
assert.match(got[1], /^DEF-02 seed drift/);
|
||
});
|
||
|
||
test('container shape: `##` group label + `###` entries — group is not an item, entries not collapsed', () => {
|
||
// The shape both shallow-boundary rules get wrong: "count all headings"
|
||
// counts the group; "shallowest level" collapses both entries into one.
|
||
const got = names([
|
||
'# Deferred Items',
|
||
'',
|
||
'## Plan 28-02 provenance',
|
||
'',
|
||
'### Entry A — flaky seed',
|
||
'',
|
||
'- **What:** a.',
|
||
'',
|
||
'### Entry B — slow build',
|
||
'',
|
||
'- **What:** b.',
|
||
].join('\n'));
|
||
|
||
assert.strictEqual(got.length, 2, JSON.stringify(got));
|
||
assert.match(got[0], /^Entry A — flaky seed/);
|
||
assert.match(got[1], /^Entry B — slow build/);
|
||
// A following entry's heading must not be swallowed into the previous
|
||
// entry's name (the pre-fix bullet-split folded it in).
|
||
assert.ok(!got[0].includes('Entry B'), got[0]);
|
||
});
|
||
|
||
test('mixed shape: loose preamble bullets before a later heading group stay one-per-bullet', () => {
|
||
const got = names([
|
||
'# Deferred Items',
|
||
'',
|
||
'- loose preamble item one',
|
||
'- loose preamble item two',
|
||
'',
|
||
'## Group under here',
|
||
'',
|
||
'### Entry C',
|
||
'- **What:** c.',
|
||
].join('\n'));
|
||
|
||
assert.deepStrictEqual(
|
||
got.map(n => n.replace(/\s+- \*\*What:\*\*.*$/, '')),
|
||
['loose preamble item one', 'loose preamble item two', 'Entry C'],
|
||
JSON.stringify(got),
|
||
);
|
||
});
|
||
|
||
test('mixed depths: childless `##` entry alongside a `##` group with `###` children — all counted', () => {
|
||
// The case "deepest heading level present" rules miss: the childless ##
|
||
// is shallower than the deepest level in the file but is still an entry.
|
||
const got = names([
|
||
'# Deferred Items',
|
||
'',
|
||
'## Group with children',
|
||
'',
|
||
'### Entry A',
|
||
'- **What:** a.',
|
||
'',
|
||
'### Entry B',
|
||
'- **What:** b.',
|
||
'',
|
||
'## Standalone entry',
|
||
'',
|
||
'- **What:** standalone.',
|
||
].join('\n'));
|
||
|
||
assert.strictEqual(got.length, 3, JSON.stringify(got));
|
||
assert.ok(got.some(n => /^Standalone entry/.test(n)), JSON.stringify(got));
|
||
});
|
||
|
||
test('no headings at all → one-bullet-per-item, unchanged names (no regression)', () => {
|
||
assert.deepStrictEqual(
|
||
names('## Deferred Items\n\n- entry one\n- entry two\n'),
|
||
['entry one', 'entry two'],
|
||
);
|
||
});
|
||
|
||
test('bolded `- **Status:** resolved` under a leaf heading resolves the entry', () => {
|
||
const got = names([
|
||
'## Deferred Items',
|
||
'',
|
||
'### Item resolved inline',
|
||
'',
|
||
'- **What:** x.',
|
||
'- **Status:** resolved',
|
||
].join('\n'));
|
||
|
||
assert.deepStrictEqual(got, [], JSON.stringify(got));
|
||
});
|
||
|
||
test('bolded `- **Status:** resolved` with no headings: resolves itself, never surfaces as its own item', () => {
|
||
// The issue's negative control: previously count = 2 with a literal
|
||
// `**Status:** resolved` pseudo-entry; must match the bare form's count = 1.
|
||
const got = names('## Deferred Items\n\n- **What:** one deferred item.\n- **Status:** resolved\n');
|
||
|
||
assert.strictEqual(got.length, 1, JSON.stringify(got));
|
||
assert.match(got[0], /\*\*What:\*\* one deferred item\./);
|
||
assert.ok(!got.some(n => /Status/.test(n)), JSON.stringify(got));
|
||
});
|
||
|
||
test('bare `status: resolved` controls keep working (no regression on #2287)', () => {
|
||
// Headless continuation form.
|
||
assert.strictEqual(names('## Deferred Items\n\n- a\n status: resolved\n- b\n').length, 1);
|
||
// Bare status as a sibling bullet under a leaf heading.
|
||
assert.strictEqual(names([
|
||
'## Deferred Items',
|
||
'',
|
||
'### Item resolved bare',
|
||
'',
|
||
'- **What:** x.',
|
||
' status: resolved',
|
||
].join('\n')).length, 0);
|
||
});
|
||
|
||
test('leaf heading over a table-only body → table rows only, no double-count', () => {
|
||
// parseDeferredTableItems owns the rows; the heading must not add an item.
|
||
const got = names([
|
||
'## Discovered during 01-03',
|
||
'',
|
||
'| Test | Failing seeds |',
|
||
'|------|---------------|',
|
||
'| test_a | 0, 1 |',
|
||
].join('\n'));
|
||
|
||
assert.deepStrictEqual(got, ['test_a — 0, 1'], JSON.stringify(got));
|
||
});
|
||
|
||
test('prose-only or bare headings contribute no items', () => {
|
||
// "Prose is not an item" is this parser's pre-existing contract (#2766
|
||
// `# Notes` case) — heading mode must not start counting prose sections.
|
||
assert.deepStrictEqual(names('## Deferred Items\n\n### Musings\n\njust prose here.\n'), []);
|
||
assert.deepStrictEqual(names('## Deferred Items\n\n### A bare heading with no body\n'), []);
|
||
});
|
||
|
||
test('CRLF files: heading entries still split and resolve', () => {
|
||
const got = names('## Deferred Items\r\n\r\n### Entry\r\n\r\n- **What:** x.\r\n- **Status:** resolved\r\n');
|
||
|
||
assert.deepStrictEqual(got, [], JSON.stringify(got));
|
||
});
|
||
|
||
test('mid-line `status: resolved` decoy under a heading must not resolve the entry', () => {
|
||
// The #2287 decoy invariant, ported to the heading shape: a status-shaped
|
||
// phrase inside entry prose is never a field.
|
||
const got = items([
|
||
'## Deferred Items',
|
||
'',
|
||
'### Entry with decoy prose',
|
||
'',
|
||
'- note: saw a status: resolved message in the log',
|
||
].join('\n'));
|
||
|
||
assert.strictEqual(got.length, 1, JSON.stringify(got.map(i => i.name)));
|
||
assert.strictEqual(got[0].result, 'unresolved');
|
||
});
|
||
});
|
||
|
||
describe('#3702 parseDeferredItems: list-marker grammar', () => {
|
||
// `deferred-items.md` has no template and no mandated shape, but the parser
|
||
// recognised only the hyphen marker — so `*`, `+` and ordered lists (all
|
||
// lists in CommonMark and GFM) contributed ZERO entries on both the headless
|
||
// and the heading-delimited path. A mixed file dropped its non-hyphen entries
|
||
// while keeping their hyphenated siblings, under-reporting without ever
|
||
// looking empty.
|
||
const SECTION = '## Deferred Items\n\n';
|
||
const names = (md) => parseDeferredItems(SECTION + md).map((i) => i.name);
|
||
const count = (md) => names(md).length;
|
||
|
||
// The marker set the ruling widened to. `1)` is deliberately absent — the
|
||
// paren-terminated ordered form is out of scope for this fix, and the
|
||
// `1) still yields zero` case below pins that as intended, not as an oversight.
|
||
const MARKERS = ['-', '*', '+', '1.'];
|
||
|
||
test('AC1 headless: every marker yields the same count as the hyphen form', () => {
|
||
const shape = (m) => `${m} alpha\n${m} beta\n`;
|
||
const hyphen = count(shape('-'));
|
||
|
||
assert.strictEqual(hyphen, 2, 'baseline: the hyphen form must yield 2');
|
||
for (const m of MARKERS) {
|
||
assert.strictEqual(count(shape(m)), hyphen, `marker ${JSON.stringify(m)}: ${JSON.stringify(names(shape(m)))}`);
|
||
}
|
||
});
|
||
|
||
test('AC1 headless: the entry NAME drops the marker, whichever marker it is', () => {
|
||
// rawGapEntryText renders the name acknowledgeDeferredItem later matches on,
|
||
// so a marker left in the rendered name would make the entry unreachable.
|
||
for (const m of MARKERS) {
|
||
assert.deepStrictEqual(names(`${m} alpha\n`), ['alpha'], `marker ${JSON.stringify(m)}`);
|
||
}
|
||
});
|
||
|
||
test('AC2 heading-delimited: a body carrying any marker is KEPT (was dropped)', () => {
|
||
const shape = (m) => `### Entry\n\n${m} **What:** x.\n`;
|
||
|
||
for (const m of MARKERS) {
|
||
assert.strictEqual(count(shape(m)), 1, `marker ${JSON.stringify(m)}: ${JSON.stringify(names(shape(m)))}`);
|
||
}
|
||
});
|
||
|
||
test('AC2 heading-delimited: a mixed file no longer drops its non-hyphen entry', () => {
|
||
// The row that bites hardest in the wild: the file never looks empty, it
|
||
// just silently under-reports.
|
||
const got = names('### Hyphen entry\n\n- x.\n\n### Asterisk entry\n\n* y.\n');
|
||
|
||
assert.strictEqual(got.length, 2, JSON.stringify(got));
|
||
assert.match(got[0], /^Hyphen entry/);
|
||
assert.match(got[1], /^Asterisk entry/);
|
||
});
|
||
|
||
test('AC3: a resolved-status field under any marker resolves its entry', () => {
|
||
// The lockstep property: widening what OPENS an entry without widening the
|
||
// marker STRIP feeding field extraction would surface the entry and then
|
||
// never resolve it — permanently unresolved, which is worse than dropped.
|
||
//
|
||
// Asserted through parseDeferredItemsWithStatus, NOT through an empty
|
||
// parseDeferredItems: "no outstanding item" is also what a DROPPED entry
|
||
// looks like, so the weaker form passes against the unfixed parser for
|
||
// precisely the reason under test. The entry must exist AND read resolved.
|
||
for (const m of MARKERS) {
|
||
for (const [shape, md] of [
|
||
['heading', `${SECTION}### Entry\n\n${m} **What:** x.\n${m} **Status:** resolved\n`],
|
||
['headless', `${SECTION}${m} alpha\n status: resolved\n`],
|
||
]) {
|
||
const where = `${shape} shape, marker ${JSON.stringify(m)}`;
|
||
const withStatus = parseDeferredItemsWithStatus(md);
|
||
|
||
assert.strictEqual(withStatus.length, 1, `${where}: entry must be parsed at all — ${JSON.stringify(withStatus)}`);
|
||
assert.strictEqual(withStatus[0].status, 'resolved', `${where}: ${JSON.stringify(withStatus)}`);
|
||
assert.deepStrictEqual(parseDeferredItems(md), [], `${where}: resolved entries are not outstanding`);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('AC3: the acknowledge writer reaches an entry written under any marker', () => {
|
||
for (const m of MARKERS) {
|
||
const content = `${SECTION}${m} alpha\n`;
|
||
const got = acknowledgeDeferredItem(content, 'alpha');
|
||
|
||
assert.strictEqual(got.status, 'ok', `marker ${JSON.stringify(m)}`);
|
||
assert.match(got.content, /status: acknowledged/, `marker ${JSON.stringify(m)}`);
|
||
assert.strictEqual(
|
||
parseDeferredItemsWithStatus(got.content)[0].status,
|
||
'acknowledged',
|
||
`marker ${JSON.stringify(m)}: the written marker must parse back`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('AC3: an already-acknowledged entry under any marker is not double-written', () => {
|
||
for (const m of MARKERS) {
|
||
const original = `${SECTION}${m} alpha\n`;
|
||
const once = acknowledgeDeferredItem(original, 'alpha').content;
|
||
const twice = acknowledgeDeferredItem(once, 'alpha').content;
|
||
|
||
// Anti-vacuity: an unreachable entry is also idempotent, so pin that the
|
||
// first call actually wrote before pinning that the second did not.
|
||
assert.notStrictEqual(once, original, `marker ${JSON.stringify(m)}: first acknowledge must write`);
|
||
assert.strictEqual(twice, once, `marker ${JSON.stringify(m)}`);
|
||
}
|
||
});
|
||
|
||
test('AC4: prose-only and bare headings still contribute nothing', () => {
|
||
// The "prose is not an item" contract is untouched: an asterisk bullet is
|
||
// not prose, so widening the marker set cannot start counting prose.
|
||
assert.deepStrictEqual(names('### Musings\n\njust prose here.\n'), []);
|
||
assert.deepStrictEqual(names('### A bare heading with no body\n'), []);
|
||
assert.deepStrictEqual(names('### Notes\n\nwe considered * and + as options.\n'), []);
|
||
});
|
||
|
||
test('AC4: a bolded field key is not mistaken for an asterisk bullet', () => {
|
||
// `**Status:**` opens with `*` but supplies no whitespace after it, so the
|
||
// widened marker declines and the bolded-key path still owns the line.
|
||
const got = parseDeferredItemsWithStatus(`${SECTION}### Entry\n\n- **What:** x.\n**Status:** resolved\n`);
|
||
|
||
assert.strictEqual(got.length, 1, JSON.stringify(got));
|
||
assert.strictEqual(got[0].status, 'resolved', JSON.stringify(got));
|
||
});
|
||
|
||
test('AC5: a table under a leaf heading still yields exactly its rows', () => {
|
||
// The anti-double-count property (#2766): table lines are skipped before
|
||
// the body-marker flag can be set, and a `|` row is not a list marker, so
|
||
// the heading still contributes no phantom entry.
|
||
const oneRow = names('### Discovered\n\n| Test | Seeds |\n|---|---|\n| test_a | 0, 1 |\n');
|
||
assert.deepStrictEqual(oneRow, ['test_a — 0, 1'], JSON.stringify(oneRow));
|
||
|
||
const twoRows = names('### Discovered\n\n| Test | Seeds |\n|---|---|\n| test_a | 0 |\n| test_b | 1 |\n');
|
||
assert.strictEqual(twoRows.length, 2, JSON.stringify(twoRows));
|
||
});
|
||
|
||
test('the paren-terminated ordered marker `1)` remains out of scope', () => {
|
||
// Pinned so a later reader sees this as the ruling's scope, not a miss.
|
||
assert.deepStrictEqual(names('1) alpha\n2) beta\n'), []);
|
||
});
|
||
|
||
test('CRLF files: widened markers split and resolve identically', () => {
|
||
for (const m of MARKERS) {
|
||
const crlf = `## Deferred Items\r\n\r\n### Entry\r\n\r\n${m} **What:** x.\r\n${m} **Status:** resolved\r\n`;
|
||
const withStatus = parseDeferredItemsWithStatus(crlf);
|
||
|
||
// Same anti-vacuity as AC3: an empty outstanding list would also be
|
||
// satisfied by the entry never being parsed.
|
||
assert.strictEqual(withStatus.length, 1, `marker ${JSON.stringify(m)}: ${JSON.stringify(withStatus)}`);
|
||
assert.strictEqual(withStatus[0].status, 'resolved', `marker ${JSON.stringify(m)}`);
|
||
assert.deepStrictEqual(parseDeferredItems(crlf), [], `marker ${JSON.stringify(m)}`);
|
||
}
|
||
});
|
||
|
||
test('nested sub-lists under any marker stay folded into their parent entry', () => {
|
||
// splitGapsEntries' indent rule (#2286) is marker-agnostic: only a marker at
|
||
// or shallower than the first one seen opens a new entry.
|
||
for (const m of MARKERS) {
|
||
const got = names(`${m} alpha\n ${m} nested one\n ${m} nested two\n${m} beta\n`);
|
||
assert.strictEqual(got.length, 2, `marker ${JSON.stringify(m)}: ${JSON.stringify(got)}`);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('#3702 round 2: CRLF on the heading path and in the acknowledge writer', () => {
|
||
const MARKERS = ['-', '*', '+', '1.'];
|
||
const CRLF_SECTION = '## Deferred Items\r\n\r\n';
|
||
|
||
test('B1: a CRLF heading entry resolves when **Status:** is NOT the last line', () => {
|
||
// Round-1's CRLF test put `**Status:**` on the fixture's LAST line, where
|
||
// `collectSection`'s `.trimEnd()` had already removed the one `\r` that
|
||
// mattered — a false green. Every other line of a CRLF body still carries
|
||
// its `\r`, and a `$`-anchored marker strip fails on it, so the marker
|
||
// survived into field extraction and the field was silently lost.
|
||
for (const m of MARKERS) {
|
||
const statusFirst = `${CRLF_SECTION}### Entry\r\n\r\n${m} **Status:** resolved\r\n${m} **What:** x.\r\n`;
|
||
const withStatus = parseDeferredItemsWithStatus(statusFirst);
|
||
|
||
assert.strictEqual(withStatus.length, 1, `marker ${JSON.stringify(m)}: ${JSON.stringify(withStatus)}`);
|
||
assert.strictEqual(withStatus[0].status, 'resolved', `marker ${JSON.stringify(m)}: status-first CRLF must resolve`);
|
||
assert.deepStrictEqual(parseDeferredItems(statusFirst), [], `marker ${JSON.stringify(m)}`);
|
||
|
||
// And the mirror: a field ABOVE a trailing status line is not lost either.
|
||
const whatFirst = `${CRLF_SECTION}### Entry\r\n\r\n${m} **What:** x.\r\n${m} **Status:** resolved\r\n${m} **Why:** y.\r\n`;
|
||
assert.strictEqual(parseDeferredItemsWithStatus(whatFirst)[0].status, 'resolved', `marker ${JSON.stringify(m)}: mid-body status`);
|
||
}
|
||
});
|
||
|
||
test('B1: a CRLF heading entry parses byte-for-byte like its LF twin', () => {
|
||
for (const m of MARKERS) {
|
||
const body = `### Entry\n\n${m} **Status:** resolved\n${m} **What:** x.\n`;
|
||
const lf = parseDeferredItemsWithStatus(`## Deferred Items\n\n${body}`);
|
||
const crlf = parseDeferredItemsWithStatus(`${CRLF_SECTION}${body.replace(/\n/g, '\r\n')}`);
|
||
assert.deepStrictEqual(crlf, lf, `marker ${JSON.stringify(m)}`);
|
||
}
|
||
});
|
||
|
||
test('M4: acknowledge REWRITES an existing status line on a CRLF file (was: ok + no write)', () => {
|
||
// Pre-existing on `next`: the finder tested a CR-stripped copy, the rewrite
|
||
// ran on the raw `\r`-terminated line with a `$`-anchored regex, `replace`
|
||
// returned the input unchanged, and the writer reported `ok` over content
|
||
// that was byte-identical — the item then resurfaced on every audit.
|
||
for (const m of MARKERS) {
|
||
const content = `${CRLF_SECTION}${m} alpha\r\n status: pending\r\n${m} beta\r\n`;
|
||
const target = parseDeferredItemsWithStatus(content)[0].name;
|
||
const got = acknowledgeDeferredItem(content, target);
|
||
|
||
assert.strictEqual(got.status, 'ok', `marker ${JSON.stringify(m)}`);
|
||
assert.notStrictEqual(got.content, content, `marker ${JSON.stringify(m)}: an ok must have written`);
|
||
assert.strictEqual(parseDeferredItemsWithStatus(got.content)[0].status, 'acknowledged', `marker ${JSON.stringify(m)}`);
|
||
}
|
||
});
|
||
|
||
test('m2: the inserted status line takes the entry indent on a CRLF file', () => {
|
||
for (const m of MARKERS) {
|
||
const content = `${CRLF_SECTION} ${m} alpha\r\n ${m} beta\r\n`;
|
||
const got = acknowledgeDeferredItem(content, 'alpha');
|
||
|
||
assert.strictEqual(got.status, 'ok', `marker ${JSON.stringify(m)}`);
|
||
assert.match(got.content, /\n {6}status: acknowledged/, `marker ${JSON.stringify(m)}: indent 4 + 2, not the indent-0 fallback`);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('#3702 round 3: detect/strip symmetry on the acknowledge path (B1, B2)', () => {
|
||
// The round-3 blocker. Round 2 widened the WRITER's status-line finder to
|
||
// the deferred marker set while the reader still de-bulleted line 0 only, so
|
||
// a nested ` * status:` was selectable by the writer and invisible to the
|
||
// reader: acknowledge rewrote it, returned `ok`, and the item stayed
|
||
// outstanding on every later audit. Measured against a `next` build, `*`,
|
||
// `+` and `1.` all resolved on base and stopped resolving at round 2's head
|
||
// — a regression, not a gap in new behaviour.
|
||
for (const marker of ['-', '*', '+', '1.']) {
|
||
test(`a nested "${marker} status:" line acknowledges and READS BACK`, () => {
|
||
const doc = `## Deferred Items\n\n- alpha thing\n ${marker} status: pending\n`;
|
||
const items = parseDeferredItemsWithStatus(doc);
|
||
assert.strictEqual(items.length, 1);
|
||
const got = acknowledgeDeferredItem(doc, items[0].name);
|
||
assert.strictEqual(got.status, 'ok', `${marker}: acknowledge reported`);
|
||
assert.notStrictEqual(got.content, doc, `${marker}: content actually changed`);
|
||
const after = parseDeferredItemsWithStatus(got.content);
|
||
assert.strictEqual(
|
||
after[0].status, 'acknowledged',
|
||
`${marker}: the entry must read back as acknowledged — reporting ok over a line the reader skips is the defect`,
|
||
);
|
||
});
|
||
}
|
||
|
||
// The hyphen row above is NOT a widened marker: it was already broken on
|
||
// `next`, for the same reason. One classifier cannot be right for three
|
||
// markers and wrong for the fourth, so it is fixed here rather than left as
|
||
// a pre-existing defect found while working.
|
||
test('a bare capitalised "Status:" resolves rather than reporting a write nothing reads', () => {
|
||
// The reader stores a bare key case-sensitively, so `Status:` is not
|
||
// `status:`. The writer must therefore NOT select it — it falls to the
|
||
// insert branch, which writes a line the reader does read.
|
||
const doc = '## Deferred Items\n\n- alpha\n Status: pending\n';
|
||
const items = parseDeferredItemsWithStatus(doc);
|
||
const got = acknowledgeDeferredItem(doc, items[0].name);
|
||
assert.strictEqual(got.status, 'ok');
|
||
assert.strictEqual(parseDeferredItemsWithStatus(got.content)[0].status, 'acknowledged');
|
||
});
|
||
|
||
test('a fenced "status:" line does not make the entry un-acknowledgeable', () => {
|
||
// Found by the pre-push review, and a regression THIS round introduced:
|
||
// the reader applied the fence gate before classifying and the writer did
|
||
// not, so the writer selected a fenced `status:` line the reader skips.
|
||
// The read-back guard then refused the write and the entry could not be
|
||
// acknowledged at all — `audit acknowledge` raised an internal error and
|
||
// `complete-milestone` halted. It acknowledged cleanly on `next`.
|
||
const F = '`'.repeat(3);
|
||
const doc = `## Deferred Items\n\n- alpha\n ${F}\n status: pending\n ${F}\n`;
|
||
const items = parseDeferredItemsWithStatus(doc);
|
||
assert.strictEqual(items.length, 1);
|
||
const got = acknowledgeDeferredItem(doc, items[0].name);
|
||
assert.strictEqual(got.status, 'ok', 'a fenced status line must not refuse the write');
|
||
assert.strictEqual(
|
||
parseDeferredItemsWithStatus(got.content)[0].status, 'acknowledged',
|
||
'the insert branch must write a line the reader reads, rather than rewriting one it skips',
|
||
);
|
||
});
|
||
|
||
test('CONTROL: a bolded line-0 key keeps its ** wrapper and spelling through the rewrite', () => {
|
||
// Held before this round too — it is a control on the NEW offset-based
|
||
// rewrite, not a regression test for a reported defect. The rewrite
|
||
// replaces the VALUE at the offset the classifier reported, so the key is
|
||
// untouched by construction; a key-matching regex would have to reproduce
|
||
// the wrapper to preserve it, which is the thing that could regress.
|
||
const doc = '## Deferred Items\n\n- **Status:** pending alpha\n';
|
||
const items = parseDeferredItemsWithStatus(doc);
|
||
const got = acknowledgeDeferredItem(doc, items[0].name);
|
||
assert.strictEqual(got.status, 'ok');
|
||
assert.match(got.content, /- \*\*Status:\*\* acknowledged/);
|
||
});
|
||
|
||
test('acknowledge is idempotent across a re-read', () => {
|
||
// The failure this guards is the one the defect actually produced: the
|
||
// item resurfaces, gets acknowledged again, and never settles.
|
||
const doc = '## Deferred Items\n\n- alpha\n * status: pending\n';
|
||
const first = acknowledgeDeferredItem(doc, parseDeferredItemsWithStatus(doc)[0].name);
|
||
const reread = parseDeferredItemsWithStatus(first.content);
|
||
assert.strictEqual(reread[0].status, 'acknowledged');
|
||
const second = acknowledgeDeferredItem(first.content, reread[0].name);
|
||
assert.strictEqual(second.status, 'ok');
|
||
assert.strictEqual(parseDeferredItemsWithStatus(second.content)[0].status, 'acknowledged');
|
||
});
|
||
});
|
||
|
||
describe('#3702 round 4: ONE end-of-file CRLF algorithm, adopted from #3773 with its B4 closed (M1)', () => {
|
||
// Two open PRs shipped two different answers to "what line ending does an
|
||
// entry that ENDS THE FILE get?", and the maintainer's round-4 ruling was
|
||
// that the disagreement "needs one answer, not two". Neither shipped answer
|
||
// was that one. Measured, on builds of both heads, over the fixtures below:
|
||
//
|
||
// 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
|
||
//
|
||
// #3739's `content.endsWith('\r\n', matchIndexInContent)` reads the
|
||
// terminator of the PREVIOUS line, so it propagated an isolated CRLF into an
|
||
// LF-dominant list. #3773's `crlfAtEof` asks the right question but over a
|
||
// scope that goes EMPTY for an undelimited single-entry list, so it inserted
|
||
// a bare `\n` into a CRLF document — its own B4, and a violation of the
|
||
// uniform-CRLF invariant the fix exists to hold. What lands here is
|
||
// `crlfAtEof`'s semantics over a scope that widens instead of going empty.
|
||
//
|
||
// Every fixture below is a counterexample that killed a simpler algorithm,
|
||
// four of them ported from #3773 along with the function. They are not
|
||
// decoration: drop any one and a refuted algorithm passes again.
|
||
const endings = (text) => (text.match(/\r?\n/g) || []).map((b) => (b === '\r\n' ? 'CRLF' : 'LF'));
|
||
const assertUniformCrlf = (text) => {
|
||
assert.ok(!/[^\r]\n/.test(text) && !/^\n/.test(text), `mixed line endings: ${JSON.stringify(text)}`);
|
||
};
|
||
|
||
test('B4: an UNDELIMITED single-entry list at EOF inserts CRLF, not a bare LF', () => {
|
||
// #3773's B4, the defect this PR must not inherit while absorbing that PR.
|
||
// With no `## Deferred Items` heading and exactly ONE entry, the entry-list
|
||
// region runs from the first entry's start to the insertion point — and
|
||
// those are the same offset, so the region is empty and `crlfAtEof('')` is
|
||
// `false` by its own `before.length > 0` guard. The scope widens to
|
||
// everything before the insertion point rather than asserting LF from no
|
||
// evidence at all.
|
||
const content = 'preamble\r\n\r\n- alpha';
|
||
const ack = acknowledgeDeferredItem(content, 'alpha');
|
||
assert.strictEqual(ack.status, 'ok');
|
||
assert.strictEqual(ack.content, 'preamble\r\n\r\n- alpha\r\n status: acknowledged');
|
||
assert.strictEqual(parseDeferredItemsWithStatus(ack.content)[0].status, 'acknowledged');
|
||
assertUniformCrlf(ack.content);
|
||
});
|
||
|
||
test('B4 control: the same shape under LF stays LF', () => {
|
||
const content = 'preamble\n\n- alpha';
|
||
const ack = acknowledgeDeferredItem(content, 'alpha');
|
||
assert.strictEqual(ack.status, 'ok');
|
||
assert.strictEqual(ack.content, 'preamble\n\n- alpha\n status: acknowledged');
|
||
});
|
||
|
||
test('an LF-dominant document with ONE stray CRLF: the EOF entry does not inherit it', () => {
|
||
// Ported from #3773, and the fixture that refutes THIS PR's round-3
|
||
// algorithm. `delta`'s CRLF terminates DELTA, not the unterminated `beta`
|
||
// after it, so copying the preceding separator propagates an isolated CRLF
|
||
// into an otherwise-LF file — strictly worse than the bare `\n` it
|
||
// replaced. Only a scope with no contradicting bare `\n` may assert CRLF.
|
||
const content = '## Deferred Items\n\n- alpha\n- gamma\n- delta\r\n- beta';
|
||
const ack = acknowledgeDeferredItem(content, 'beta');
|
||
assert.strictEqual(ack.status, 'ok');
|
||
assert.strictEqual(parseDeferredItemsWithStatus(ack.content)[3].status, 'acknowledged');
|
||
assert.strictEqual(ack.content, '## Deferred Items\n\n- alpha\n- gamma\n- delta\r\n- beta\n status: acknowledged');
|
||
assert.deepStrictEqual(endings(ack.content), ['LF', 'LF', 'LF', 'LF', 'CRLF', 'LF'],
|
||
'the inserted break must not duplicate the unrelated CRLF above it');
|
||
});
|
||
|
||
test('a DELIMITED single-entry list still has evidence: the section preamble is in scope', () => {
|
||
// Ported from #3773. The scope must not shrink to the entry list alone; a
|
||
// delimited section's own preamble belongs to that section and counts.
|
||
const content = '## Deferred Items\r\n\r\n- alpha thing';
|
||
const ack = acknowledgeDeferredItem(content, 'alpha thing');
|
||
assert.strictEqual(ack.status, 'ok');
|
||
assert.strictEqual(ack.content, '## Deferred Items\r\n\r\n- alpha thing\r\n status: acknowledged');
|
||
assertUniformCrlf(ack.content);
|
||
const lf = '## Deferred Items\n\n- alpha thing';
|
||
assert.strictEqual(
|
||
acknowledgeDeferredItem(lf, 'alpha thing').content,
|
||
'## Deferred Items\n\n- alpha thing\n status: acknowledged',
|
||
);
|
||
});
|
||
|
||
test('a bare LF OUTSIDE the deferred section does not veto the CRLF insert', () => {
|
||
// Ported from #3773, and the fixture that rules out scanning the whole
|
||
// DOCUMENT: an unrelated bare LF inside a fenced block in another section
|
||
// would reject CRLF and drop an isolated LF into an otherwise-CRLF list.
|
||
const content = '# Notes\r\n\r\n```text\r\nfirst\nsecond\r\n```\r\n\r\n'
|
||
+ '## Deferred Items\r\n\r\n- alpha\r\n- beta';
|
||
const ack = acknowledgeDeferredItem(content, 'beta');
|
||
assert.strictEqual(ack.status, 'ok');
|
||
assert.match(ack.content, /- beta\r\n {2}status: acknowledged$/);
|
||
const section = ack.content.slice(ack.content.indexOf('## Deferred Items'));
|
||
assert.ok(!/(^|[^\r])\n/.test(section), `bare LF in the deferred section: ${JSON.stringify(section)}`);
|
||
assert.ok(ack.content.includes('first\nsecond'), 'the unrelated bare LF must not be rewritten');
|
||
});
|
||
|
||
test('an UNDELIMITED list with entries reads only the entry list, not the preamble', () => {
|
||
// Ported from #3773, and the fixture that rules out the SECTION as the
|
||
// scope: with no heading the section body IS the whole document, so a
|
||
// section-scoped scan silently becomes the whole-document scan the case
|
||
// above already refuted. The preamble is consulted ONLY when the entry
|
||
// list is empty (the B4 case) — here it is not, so the fence's bare LF is
|
||
// out of scope and must not veto.
|
||
const content = '```text\r\nfirst\nsecond\r\n```\r\n\r\n- alpha\r\n- beta';
|
||
const ack = acknowledgeDeferredItem(content, 'beta');
|
||
assert.strictEqual(ack.status, 'ok');
|
||
assert.match(ack.content, /- beta\r\n {2}status: acknowledged$/);
|
||
assert.ok(ack.content.includes('first\nsecond'), 'the unrelated bare LF must not be rewritten');
|
||
});
|
||
|
||
test('no evidence under EITHER scope: an entry at offset 0 stays LF', () => {
|
||
// The widened scope terminates rather than recursing outward forever. A
|
||
// document that is exactly one unterminated entry has no line ending
|
||
// anywhere; LF is the floor, not an invented CRLF.
|
||
const ack = acknowledgeDeferredItem('- alpha', 'alpha');
|
||
assert.strictEqual(ack.status, 'ok');
|
||
assert.strictEqual(ack.content, '- alpha\n status: acknowledged');
|
||
});
|
||
|
||
test('mixed-ending document: the insert branch reads the ENTRY\'s ending, not the file\'s', () => {
|
||
// Ported from #3773. M1 asks for the mixed-ending fixture this PR lacked:
|
||
// every CRLF test it shipped used UNIFORM CRLF, so the motivating case
|
||
// could not fail. A CRLF heading over an LF entry — the opener's own `\n`
|
||
// must survive, and a document-wide sniff would rewrite it.
|
||
const content = '## Deferred Items\r\n\r\n- alpha\n reason: x\n';
|
||
const ack = acknowledgeDeferredItem(content, parseDeferredItemsWithStatus(content)[0].name);
|
||
assert.strictEqual(ack.status, 'ok');
|
||
assert.strictEqual(ack.content, '## Deferred Items\r\n\r\n- alpha\n status: acknowledged\n reason: x\n');
|
||
// The single-line twin: a CRLF entry whose only evidence is the separator
|
||
// that FOLLOWS it, inside an otherwise-LF document.
|
||
const single = '## Deferred Items\n\n- alpha\r\n';
|
||
assert.strictEqual(
|
||
acknowledgeDeferredItem(single, parseDeferredItemsWithStatus(single)[0].name).content,
|
||
'## Deferred Items\n\n- alpha\r\n status: acknowledged\r\n',
|
||
);
|
||
});
|
||
|
||
test('the widened EOF grammar carries the CRLF rule for every marker', () => {
|
||
// The EOF fixtures above are all hyphen-shaped because they were inherited
|
||
// from a hyphen-only PR. This PR's whole subject is the widened marker set,
|
||
// so the EOF rule has to hold across it or the two changes are only
|
||
// accidentally compatible.
|
||
for (const marker of ['-', '*', '+', '1.']) {
|
||
const content = `preamble\r\n\r\n${marker} alpha`;
|
||
const ack = acknowledgeDeferredItem(content, 'alpha');
|
||
assert.strictEqual(ack.status, 'ok', `marker ${JSON.stringify(marker)}`);
|
||
assert.strictEqual(ack.content, `preamble\r\n\r\n${marker} alpha\r\n status: acknowledged`,
|
||
`marker ${JSON.stringify(marker)}: EOF insert must be CRLF`);
|
||
assertUniformCrlf(ack.content);
|
||
}
|
||
});
|
||
|
||
test('acknowledging twice at EOF is idempotent', () => {
|
||
const first = acknowledgeDeferredItem('preamble\r\n\r\n- alpha', 'alpha');
|
||
const again = acknowledgeDeferredItem(first.content, parseDeferredItemsWithStatus(first.content)[0].name);
|
||
assert.strictEqual(again.status, 'ok');
|
||
assert.strictEqual(again.content, first.content, 'a second acknowledge must not append a second line ending');
|
||
assert.deepStrictEqual(endings(again.content), ['CRLF', 'CRLF', 'CRLF']);
|
||
});
|
||
});
|
||
|
||
describe('#3702 round 4: the fence gate is indent-unbounded, like the rest of the grammar (M2)', () => {
|
||
// `scanFencedBlocks` is CommonMark, which caps a fence delimiter's indent at
|
||
// three spaces. This grammar opted out of that cliff for entry openers
|
||
// (`[ \t]*`) and thematic breaks (`^[ \t]*`) but not for fences, so a fence
|
||
// at four spaces was not a fence to the gate and a `status: resolved` inside
|
||
// it RESOLVED the entry containing it. That is reached by ordinary
|
||
// documents, not exotic ones: a fenced block written under a nested bullet
|
||
// sits at four spaces. Driven before the fix at indents 4, 5, 8 and a
|
||
// leading tab — all four silently resolved.
|
||
//
|
||
// `gsd-core/references/executor-examples.md` states flatly that "nothing
|
||
// inside a fenced code block is an entry or a field". These pin that claim
|
||
// instead of quietly bounding it.
|
||
const H = '## Deferred Items\n\n';
|
||
const fencedStatusDoc = (pad, delim = '```') =>
|
||
`${H}- alpha\n${pad}${delim}text\n${pad}status: resolved\n${pad}${delim}\n`;
|
||
|
||
for (const [label, pad] of [
|
||
['0 spaces', ''], ['1 space', ' '], ['2 spaces', ' '], ['3 spaces (CommonMark cap)', ' '],
|
||
['4 spaces (past the cap)', ' '], ['5 spaces', ' '], ['8 spaces', ' '],
|
||
['a leading tab', '\t'],
|
||
]) {
|
||
test(`a fenced "status: resolved" at ${label} does not resolve the entry`, () => {
|
||
const items = parseDeferredItemsWithStatus(fencedStatusDoc(pad));
|
||
assert.strictEqual(items.length, 1, `${label}: expected exactly one entry`);
|
||
assert.notStrictEqual(String(items[0].status || '').toLowerCase(), 'resolved',
|
||
`${label}: the fenced status line must not resolve the entry`);
|
||
});
|
||
}
|
||
|
||
test('the same rule holds for tilde fences past the cap', () => {
|
||
const items = parseDeferredItemsWithStatus(fencedStatusDoc(' ', '~~~'));
|
||
assert.strictEqual(items.length, 1);
|
||
assert.notStrictEqual(String(items[0].status || '').toLowerCase(), 'resolved');
|
||
});
|
||
|
||
test('an entry-shaped line inside a deep fence is content, not an entry', () => {
|
||
// The other half of the doc's claim: not an entry EITHER. #3702's wild
|
||
// records carry reproduction blocks whose `- ` and `1. ` lines are prose.
|
||
const doc = `${H}- alpha\n \`\`\`diff\n - not an entry\n 1. also not an entry\n \`\`\`\n- beta\n`;
|
||
const names = parseDeferredItems(doc).map((i) => i.name);
|
||
assert.ok(names.some((n) => n.startsWith('alpha')), `alpha missing: ${JSON.stringify(names)}`);
|
||
assert.ok(names.includes('beta'), `beta missing: ${JSON.stringify(names)}`);
|
||
assert.ok(!names.includes('not an entry'), `fenced content became an entry: ${JSON.stringify(names)}`);
|
||
assert.ok(!names.includes('also not an entry'), `fenced content became an entry: ${JSON.stringify(names)}`);
|
||
});
|
||
|
||
test('an UNTERMINATED deep fence runs to the end of its ENTRY (round 5, B1) — the status inside it stays gated, the next entry does not', () => {
|
||
// Round 4 titled this "runs to end-of-file, exactly as CommonMark says";
|
||
// CommonMark bounds a fence by its container, and the review's B1 showed
|
||
// the end-of-file reading swallowing the entries after a stray delimiter.
|
||
// The engine still classifies; the walk bounds it at the entry.
|
||
const doc = `${H}- alpha\n \`\`\`text\n status: resolved\n`;
|
||
const items = parseDeferredItemsWithStatus(doc);
|
||
assert.strictEqual(items.length, 1);
|
||
assert.notStrictEqual(String(items[0].status || '').toLowerCase(), 'resolved');
|
||
const two = parseDeferredItemsWithStatus(`${doc}- beta\n status: resolved\n`);
|
||
assert.deepStrictEqual(two.map((i) => [i.name, i.status]), [['alpha ```text status: resolved', ''], ['beta status: resolved', 'resolved']]);
|
||
});
|
||
|
||
test('a deep fence still CLOSES: fields after it are read again', () => {
|
||
// The gate must not swallow the rest of the entry. If the closer at the
|
||
// same deep indent were not recognised, everything after it would stay
|
||
// fenced to EOF and the real status line would be invisible.
|
||
const doc = `${H}- alpha\n \`\`\`text\n status: resolved\n \`\`\`\n status: acknowledged\n`;
|
||
const items = parseDeferredItemsWithStatus(doc);
|
||
assert.strictEqual(items.length, 1);
|
||
assert.strictEqual(String(items[0].status).toLowerCase(), 'acknowledged',
|
||
'the post-fence status line must still be read');
|
||
});
|
||
|
||
test('acknowledge agrees with the reader about a deep fence', () => {
|
||
// Reader and writer share one classifier; a fence the reader honours must
|
||
// be one the writer refuses to rewrite into. Otherwise acknowledge writes
|
||
// inside the code block and reports ok.
|
||
const doc = `${H}- alpha\n \`\`\`text\n status: pending\n \`\`\`\n`;
|
||
const ack = acknowledgeDeferredItem(doc, parseDeferredItemsWithStatus(doc)[0].name);
|
||
assert.strictEqual(ack.status, 'ok');
|
||
assert.ok(ack.content.includes(' status: pending'),
|
||
'the fenced line must be left exactly as written');
|
||
assert.strictEqual(String(parseDeferredItemsWithStatus(ack.content)[0].status).toLowerCase(), 'acknowledged',
|
||
'the entry must read back acknowledged through an inserted line, not a rewritten fenced one');
|
||
});
|
||
|
||
test('GUARD: `## Gaps` is untouched — it opts out of block structure entirely', () => {
|
||
// Both marker-parameterised call sites gate on `markers.blockStructure`,
|
||
// which the Gaps set does not set, so Gaps reaches an EMPTY fenced set and
|
||
// this change cannot reach it. Pinned rather than asserted: a future
|
||
// "consistency" edit that dropped that gate would silently move Gaps, and
|
||
// this PR's central claim is that Gaps keeps its `next` behaviour.
|
||
//
|
||
// The observable form of "no fence gate": Gaps folds the fenced lines into
|
||
// the entry's NAME rather than hiding them, and does so identically at an
|
||
// indent inside CommonMark's cap and one past it. If the deferred gate
|
||
// ever leaked into Gaps, the deep form would start differing from the
|
||
// shallow one.
|
||
const deep = '## Gaps\n\n- alpha\n ```text\n status: open\n ```\n';
|
||
const shallow = '## Gaps\n\n- alpha\n ```text\n status: open\n ```\n';
|
||
assert.deepStrictEqual(parseUatItems(deep), parseUatItems(shallow),
|
||
'Gaps must read a fence at 4 spaces exactly as it reads one at 2');
|
||
assert.deepStrictEqual(parseUatItems(deep), [
|
||
{ name: 'alpha ```text status: open ```', result: 'open', category: 'unknown' },
|
||
], 'Gaps folds fenced lines into the entry name — no fence gate at any indent');
|
||
|
||
// And the entry-shaped twin: a `- ` line inside a deep fence is still not
|
||
// a separate Gaps entry, because Gaps never split on it to begin with.
|
||
const entryShaped = '## Gaps\n\n- alpha\n ```diff\n - fenced line\n ```\n- beta\n';
|
||
assert.deepStrictEqual(parseUatItems(entryShaped).map((i) => i.name),
|
||
['alpha ```diff - fenced line ```', 'beta']);
|
||
});
|
||
});
|
||
|
||
describe('#3702 round 4: the minors (m1, m2, m3, m5)', () => {
|
||
const H = '## Deferred Items\n\n';
|
||
const names = (doc) => parseDeferredItems(doc).map((i) => i.name);
|
||
|
||
// ── m1: the ordinal digit cap needs the limit-1 case ──────────────────────
|
||
// RULESET.TESTS.boundary-coverage asks for N ∈ {limit-1, limit, limit+1}.
|
||
// The limit (`999999999.`) and limit+1 (`1234567890.`) were already pinned;
|
||
// limit-1 was the missing third. It is not a formality: an off-by-one in the
|
||
// `\d{1,9}` bound shows up at eight digits, not at nine.
|
||
test('m1: an EIGHT-digit ordinal (limit-1) is a marker', () => {
|
||
assert.deepStrictEqual(names(`${H}1. a\n12345678. b\n`), ['a', 'b']);
|
||
assert.strictEqual(DEFERRED_BULLET_MARKERS.open.test('12345678. x'), true);
|
||
});
|
||
|
||
test('m1: the three boundary points read consistently through the marker set', () => {
|
||
assert.strictEqual(DEFERRED_BULLET_MARKERS.open.test('12345678. x'), true, 'limit-1');
|
||
assert.strictEqual(DEFERRED_BULLET_MARKERS.open.test('999999999. x'), true, 'limit');
|
||
assert.strictEqual(DEFERRED_BULLET_MARKERS.open.test('1234567890. x'), false, 'limit+1');
|
||
});
|
||
|
||
// ── m2: the hand-rolled CommonMark copies, checked against CommonMark ─────
|
||
// `THEMATIC_BREAK_RE` and the tab-expanding indent counter are fresh
|
||
// implementations of rules CommonMark already specifies, and this repo has
|
||
// no sectionizer helper for either to be compared against. So the parity
|
||
// assertion is against the SPEC, with the two deliberate divergences named
|
||
// rather than left for a later reader to discover and "fix" back.
|
||
test('m2: THEMATIC_BREAK_RE agrees with CommonMark on `-`, `*` and `_`', () => {
|
||
const isBreak = (line) => {
|
||
const doc = `${H}- alpha\n${line}\n- beta\n`;
|
||
// A separator closes the list, so `beta` opens a NEW list rather than
|
||
// continuing alpha's. If the line is not a break, it is swallowed as
|
||
// alpha's continuation text.
|
||
return names(doc).length === 2 && names(doc)[0] === 'alpha';
|
||
};
|
||
// `-- -` belongs in the YES list: CommonMark asks for three or more
|
||
// matching characters "each followed optionally by any number of spaces or
|
||
// tabs", so the spacing between them is free. It was in the NO list on the
|
||
// first cut of this test and the parser was right, not the fixture.
|
||
for (const yes of ['---', '***', '___', '- - -', '* * *', '_ _ _', '-----', ' ---', '-- -']) {
|
||
assert.strictEqual(isBreak(yes), true, `CommonMark thematic break not recognised: ${JSON.stringify(yes)}`);
|
||
}
|
||
for (const no of ['--', '**', '__', '- -', 'a---']) {
|
||
assert.strictEqual(isBreak(no), false, `not a CommonMark thematic break, but treated as one: ${JSON.stringify(no)}`);
|
||
}
|
||
});
|
||
|
||
test('m2: the two DELIBERATE divergences from CommonMark are pinned, not accidental', () => {
|
||
const isBreak = (line) => {
|
||
const doc = `${H}- alpha\n${line}\n- beta\n`;
|
||
return names(doc).length === 2 && names(doc)[0] === 'alpha';
|
||
};
|
||
// (1) `+` is NOT a CommonMark thematic-break character. It is one here,
|
||
// because `+` IS a list marker in this grammar, so `+ + +` would otherwise
|
||
// open a phantom entry named `+ +` — the same reason `* * *` is a break
|
||
// rather than an entry named `* *`.
|
||
assert.strictEqual(isBreak('+ + +'), true,
|
||
'`+ + +` must be a separator here even though CommonMark says otherwise');
|
||
// (2) Indent is UNBOUNDED. CommonMark stops recognising a thematic break at
|
||
// four spaces (it becomes indented code); this grammar reads entries and
|
||
// breaks at any indent, so the break must follow the entries.
|
||
assert.strictEqual(isBreak(' ---'), true, 'a 7-space break must still close the list');
|
||
assert.strictEqual(isBreak('\t---'), true, 'a tab-indented break must still close the list');
|
||
});
|
||
|
||
test('m2: the indent counter expands tabs to CommonMark 4-column stops', () => {
|
||
// Not directly exported, so it is measured through its observable effect:
|
||
// a continuation line must land at or past its opener's indent column. A
|
||
// tab counted as ONE column instead of expanding to the next stop of four
|
||
// puts a 3-space continuation "outside" a tab-indented bullet.
|
||
const tabOpener = `${H}\t- alpha\n\t status: acknowledged\n`;
|
||
assert.deepStrictEqual(
|
||
parseDeferredItemsWithStatus(tabOpener).map((i) => i.status),
|
||
['acknowledged'],
|
||
'a tab-indented entry must read its own tab-indented field',
|
||
);
|
||
// A 4-space opener and a tab opener occupy the same column, so the same
|
||
// continuation depth works for both.
|
||
const spaceOpener = `${H} - alpha\n status: acknowledged\n`;
|
||
assert.deepStrictEqual(
|
||
parseDeferredItemsWithStatus(spaceOpener).map((i) => i.status),
|
||
['acknowledged'],
|
||
);
|
||
});
|
||
|
||
// ── m3: the result union is declared twice; check it BEHAVIOURALLY ────────
|
||
// `src/audit.cts` carries a hand-written structural view of `uat.cjs`,
|
||
// including its own copy of the result union. That decoupling is deliberate
|
||
// (the CLI does not take a type dependency on the module it `require()`s),
|
||
// so the fix is not to delete one copy but to make drift observable: every
|
||
// status the writer can actually PRODUCE is driven here, so a status added
|
||
// to one union and not the other shows up as a fixture with no counterpart
|
||
// rather than as a silent fall-through to the write.
|
||
//
|
||
// Four of these had no assertion anywhere in the suite before this test.
|
||
test('m3: every reachable acknowledge status is driven from a fixture', () => {
|
||
const reached = new Map();
|
||
const drive = (label, doc, target) => {
|
||
const r = acknowledgeDeferredItem(doc, target);
|
||
reached.set(r.status, label);
|
||
return r;
|
||
};
|
||
|
||
drive('ok', `${H}- alpha\n`, 'alpha');
|
||
drive('not_found', `${H}- alpha\n`, 'no such entry');
|
||
drive('ambiguous', `${H}- alpha\n- alpha\n`, 'alpha');
|
||
const resolvedDoc = `${H}- alpha\n status: resolved\n`;
|
||
drive('already_resolved', resolvedDoc, parseDeferredItemsWithStatus(resolvedDoc)[0].name);
|
||
// #3781 (merged round 5): the heading shape acks; the refusal is reached
|
||
// only by a span with a GFM table row INSIDE it, which no write can anchor.
|
||
const headingDoc = `${H}### Entry one\n\n- **Status:** open\n| a | b |\n- more\n`;
|
||
drive('unsupported_heading_shape', headingDoc, parseDeferredItemsWithStatus(headingDoc)[0].name);
|
||
|
||
assert.deepStrictEqual(
|
||
[...reached.keys()].sort(),
|
||
['already_resolved', 'ambiguous', 'not_found', 'ok', 'unsupported_heading_shape'],
|
||
'a reachable status stopped being reachable, or a new one appeared',
|
||
);
|
||
|
||
// `match_verification_failed` is the sixth member and is NOT driven here.
|
||
// It is a defensive re-verify of the matched span against the target text,
|
||
// computed by an independent code path, and no fixture reaches it — the
|
||
// same position `rewrite_not_readable` was in before round 4 removed it.
|
||
// Stated rather than quietly omitted: if this union is ever pruned to what
|
||
// tests reach, that member is the one to weigh, and the answer is the same
|
||
// one round 4 gave for the other — an assertion nothing can drive is a
|
||
// specification nothing holds.
|
||
});
|
||
|
||
test('m3: each reachable status produces a DISTINCT outcome, so a merge of two would show', () => {
|
||
const doc = `${H}- alpha\n`;
|
||
const ok = acknowledgeDeferredItem(doc, 'alpha');
|
||
const notFound = acknowledgeDeferredItem(doc, 'nope');
|
||
assert.notStrictEqual(ok.content, doc, 'ok must change the content');
|
||
assert.strictEqual(notFound.content, doc, 'a refusal must return the content untouched');
|
||
// Every non-ok status returns the ORIGINAL content: that is the property
|
||
// the CLI relies on when it refuses rather than writes.
|
||
for (const [label, d, t] of [
|
||
['ambiguous', `${H}- alpha\n- alpha\n`, 'alpha'],
|
||
['not_found', doc, 'nope'],
|
||
]) {
|
||
const r = acknowledgeDeferredItem(d, t);
|
||
assert.strictEqual(r.content, d, `${label}: refusal must not mutate content`);
|
||
}
|
||
});
|
||
|
||
// ── m5: DECLINED, with the measurement that refutes it ───────────────────
|
||
// The review is right that `markers.open`'s `(\s*)` indent group and the
|
||
// `/^[ \t]*/` reader disagree about `\f`, `\v` and NBSP. Its prescribed fix
|
||
// — "narrow to `([ \t]*)`" — was implemented, measured, and REVERTED,
|
||
// because the disagreement is not currently doing any harm and the narrowing
|
||
// is.
|
||
//
|
||
// Driven, both directions:
|
||
// indent as shipped narrowed to [ \t]*
|
||
// \f entry, status open, ack ok, reads back NO ENTRY
|
||
// \v entry, status open, ack ok, reads back NO ENTRY
|
||
// NBSP entry, status open, ack ok, reads back NO ENTRY
|
||
//
|
||
// The whole round-trip already works for these: the entry surfaces, its
|
||
// field parses, `acknowledgeDeferredItem` returns ok and the result reads
|
||
// back acknowledged. Narrowing turns three working shapes into three
|
||
// SILENTLY DROPPED ones — which is the #3702 defect class itself, and the
|
||
// opposite of the fail-safe rule this file states ("never silently drop a
|
||
// possibly-open item"). A latent inconsistency in the safe direction is not
|
||
// worth trading for a live regression in the unsafe one.
|
||
//
|
||
// Pinned here so the prescription cannot be re-applied without failing a
|
||
// test that explains why. If the inconsistency is ever to be closed, the
|
||
// direction is to make the READERS agree with the opener — `indentOf` counts
|
||
// non-tab whitespace as a column instead of terminating on it — not to make
|
||
// the opener reject lines it currently accepts.
|
||
for (const [label, indent] of [['a form feed', '\f'], ['a vertical tab', '\v'], ['an NBSP', '\u00a0']]) {
|
||
test(`m5: an entry indented with ${label} still round-trips (narrowing would drop it)`, () => {
|
||
const doc = `${H}${indent}- alpha\n status: open\n`;
|
||
const items = parseDeferredItemsWithStatus(doc);
|
||
assert.strictEqual(items.length, 1, `${label}: the entry must surface`);
|
||
assert.strictEqual(items[0].status, 'open', `${label}: its field must parse`);
|
||
const ack = acknowledgeDeferredItem(doc, items[0].name);
|
||
assert.strictEqual(ack.status, 'ok', `${label}: it must be acknowledgeable`);
|
||
assert.strictEqual(
|
||
String(parseDeferredItemsWithStatus(ack.content)[0].status).toLowerCase(),
|
||
'acknowledged',
|
||
`${label}: and the acknowledgement must read back`,
|
||
);
|
||
});
|
||
}
|
||
|
||
test('m5 GUARD: `## Gaps` and the deferred set read exotic indent IDENTICALLY today', () => {
|
||
// The two sets differing here is what a narrowing would introduce. Both
|
||
// use `\s*` now, so a form-feed-indented bullet is an item on both paths.
|
||
// If a later edit narrows only one, this fails.
|
||
const gaps = parseUatItems('## Gaps\n\n\f- alpha\n').map((i) => i.name);
|
||
const deferred = parseDeferredItems('## Deferred Items\n\n\f- alpha\n').map((i) => i.name);
|
||
assert.deepStrictEqual(gaps, ['alpha'], 'Gaps must read a form-feed-indented bullet');
|
||
assert.deepStrictEqual(deferred, ['alpha'], 'the deferred set must read it too');
|
||
});
|
||
});
|
||
|
||
describe('#3702 round 3: heading-path reader/namer inputs (m7, m8)', () => {
|
||
test('a bullet whose CONTENT is a fence opener does not suppress the entry\'s fields', () => {
|
||
// m7: the fence re-scan used to run on already-marker-stripped lines, so
|
||
// `- ```sh` — an ordinary bullet to the splitter — stripped to a fence
|
||
// opener that existed in no other pass, and the `**Status:** resolved`
|
||
// after it was suppressed as fence content. A RESOLVED entry resurfaced.
|
||
const doc = '## Deferred Items\n\n### Entry\n\n- ```sh\n- **Status:** resolved\n- ```\n';
|
||
// The status field must be READ (the round-2 fence suppression is for a
|
||
// fence the SPLITTER saw, which this is not) ...
|
||
assert.strictEqual(parseDeferredItemsWithStatus(doc)[0].status, 'resolved');
|
||
// ... and a resolved entry must therefore not surface as outstanding.
|
||
assert.deepStrictEqual(parseDeferredItems(doc), []);
|
||
});
|
||
|
||
test('a heading that begins with a list marker keeps it in the entry name', () => {
|
||
// m8: line 0 of a heading entry is the heading TEXT, not a bullet.
|
||
// Stripping a marker off it silently renamed the entry — and the name is
|
||
// the key acknowledge matches on.
|
||
for (const [heading, expected] of [
|
||
['### 1. Race in the writer', '1. Race in the writer'],
|
||
['### * starred title', '* starred title'],
|
||
['### Race in the writer', 'Race in the writer'],
|
||
]) {
|
||
const doc = `## Deferred Items\n\n${heading}\n\n- **What:** x\n`;
|
||
const items = parseDeferredItemsWithStatus(doc);
|
||
assert.strictEqual(items.length, 1, heading);
|
||
assert.ok(items[0].name.startsWith(expected), `${heading} -> ${items[0].name}`);
|
||
}
|
||
});
|
||
|
||
test('CONTROL: a body bullet keeps its marker in the entry name', () => {
|
||
// Held before this round too — a control on the opener-flag threading, not
|
||
// a reported defect. The flags say which lines CARRY a marker, but only
|
||
// line 0's is part of the entry's identity; wiring the flags into the namer
|
||
// wholesale strips the body lines too, which is a rename of the key
|
||
// acknowledge matches on. This pins that it did not happen.
|
||
const doc = '## Deferred Items\n\n### Entry\n\n- **What:** x\n';
|
||
assert.strictEqual(parseDeferredItemsWithStatus(doc)[0].name, 'Entry - **What:** x');
|
||
});
|
||
});
|
||
|
||
describe('#3702 round 2: marker-grammar parity (M3, N1, N2)', () => {
|
||
test('every deferred-items marker regex derives from the one alternation source', () => {
|
||
// Structural, not behavioural: both splitter regexes embed the SAME
|
||
// source string, so a marker added to one cannot be absent from the other.
|
||
// Round 2 ran this over four regexes; the two writer-side ones are gone.
|
||
for (const [name, re] of [
|
||
['open', DEFERRED_BULLET_MARKERS.open],
|
||
['strip', DEFERRED_BULLET_MARKERS.strip],
|
||
]) {
|
||
assert.ok(re.source.includes(DEFERRED_MARKER_ALT), `${name}: ${re.source}`);
|
||
}
|
||
});
|
||
|
||
// #3702 round 3 (M6): the structural assertion above is kept for the two
|
||
// SPLITTER regexes, which really are two copies of one alternation. It is no
|
||
// longer asked to stand in for the writer/reader agreement — it never could.
|
||
// Sharing a source string says nothing about whether the line the writer
|
||
// selects is a line the reader reads, which is precisely the asymmetry that
|
||
// shipped. The replacement is BEHAVIOURAL and drives the real seam.
|
||
test('every marker that OPENS an entry also resolves it through acknowledge', () => {
|
||
for (const m of ['-', '*', '+', '1.']) {
|
||
assert.ok(DEFERRED_BULLET_MARKERS.open.test(`${m} x`), `open: ${m}`);
|
||
// The marker goes on BOTH the opener and the nested status line. Round
|
||
// 2's structural test could not reach B1; a replacement that only marks
|
||
// the opener cannot either — it is green on the defective build.
|
||
const doc = `## Deferred Items\n\n${m} alpha\n ${m} status: pending\n`;
|
||
const items = parseDeferredItemsWithStatus(doc);
|
||
assert.strictEqual(items.length, 1, `entry surfaced: ${m}`);
|
||
const got = acknowledgeDeferredItem(doc, items[0].name);
|
||
assert.strictEqual(got.status, 'ok', `ack ok: ${m}`);
|
||
const after = parseDeferredItemsWithStatus(got.content);
|
||
assert.strictEqual(
|
||
after[0].status, 'acknowledged',
|
||
`${m}: acknowledge must be READ BACK, not merely written — a status line the reader skips leaves the item outstanding forever`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('parity with markdown-sectionizer iterateBullets on the shared vocabulary', () => {
|
||
// `iterateBullets` is the repo's other list-marker grammar. On everything
|
||
// both grammars are meant to agree on, they do — including the negatives.
|
||
const opens = (line) => DEFERRED_BULLET_MARKERS.open.test(line);
|
||
const sectionizerOpens = (line) => iterateBullets(line).length === 1;
|
||
const shared = [
|
||
['- x', true], ['* x', true], ['+ x', true], ['1. x', true], ['12. x', true], ['01. x', true],
|
||
[' - x', true], ['- [ ] x', true], ['- [x] x', true],
|
||
['**Status:** x', false], ['1) x', false], ['-x', false], ['*x', false], ['1.x', false],
|
||
['prose', false], ['| a | b |', false], ['2026 was a year', false],
|
||
];
|
||
for (const [line, expected] of shared) {
|
||
assert.strictEqual(opens(line), expected, `deferred: ${JSON.stringify(line)}`);
|
||
assert.strictEqual(sectionizerOpens(line), expected, `sectionizer: ${JSON.stringify(line)}`);
|
||
}
|
||
});
|
||
|
||
test('the two deliberate divergences from iterateBullets are exactly these', () => {
|
||
// N2 — a tab after the marker is CommonMark-legal; `iterateBullets`
|
||
// requires a literal space. Kept, and pinned so the difference is visible.
|
||
assert.strictEqual(DEFERRED_BULLET_MARKERS.open.test('-\tx'), true);
|
||
assert.strictEqual(iterateBullets('-\tx').length, 0);
|
||
// N1 — CommonMark caps an ordered start at 9 digits; `iterateBullets` is
|
||
// `\d+`. Ten digits is not a list marker here.
|
||
assert.strictEqual(DEFERRED_BULLET_MARKERS.open.test('1234567890. x'), false);
|
||
assert.strictEqual(iterateBullets('1234567890. x').length, 1);
|
||
// And `\r` is no longer whitespace after a marker (round 1's `\s` was) —
|
||
// nor are NBSP, form-feed or vertical-tab, which `\s` also accepted and
|
||
// CommonMark does not: only a space or a tab follows a marker. This is
|
||
// the assertion that fails on a `[ \t]` → `\s` revert on its own.
|
||
assert.strictEqual(DEFERRED_BULLET_MARKERS.open.test('-\r'), false);
|
||
for (const ws of ['\u00a0', '\f', '\v']) {
|
||
assert.strictEqual(DEFERRED_BULLET_MARKERS.open.test(`-${ws}x`), false, JSON.stringify(ws));
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('#3702 round 2: the ordered marker and the prose contract (B2, m1)', () => {
|
||
const SECTION = '## Deferred Items\n\n';
|
||
const names = (md) => parseDeferredItems(SECTION + md).map((i) => i.name);
|
||
|
||
test('B2: a sentence that happens to open with `<number>.` is prose, not an item', () => {
|
||
// Both were items on round 1 — `\d+\.` accepted any digit run. CommonMark
|
||
// §5.3's own prose/list discriminator is that an ordered list interrupting
|
||
// a paragraph must START AT 1; this parser applies that rule everywhere
|
||
// an ordered marker is seen (see `matchListOpener`).
|
||
assert.deepStrictEqual(names('2026. was a bad year for this module\n'), []);
|
||
assert.deepStrictEqual(names('### Notes\n\n3. is the number of retries we settled on.\n'), []);
|
||
// And the mixed heading case: prose under one heading, a list under another.
|
||
const got = names('### Notes\n\n3. is the number of retries.\n\n### Steps\n\n1. do this\n2. then this\n');
|
||
assert.strictEqual(got.length, 1, JSON.stringify(got));
|
||
assert.match(got[0], /^Steps/);
|
||
});
|
||
|
||
test('B2: an ordered list that starts at 1. counts, at any later number in the run', () => {
|
||
assert.deepStrictEqual(names('1. alpha\n2. beta\n3. gamma\n'), ['alpha', 'beta', 'gamma']);
|
||
// CommonMark ignores the numbers after the first — so does the run.
|
||
assert.deepStrictEqual(names('1. alpha\n3. gamma\n7. delta\n'), ['alpha', 'gamma', 'delta']);
|
||
assert.deepStrictEqual(names('01. alpha\n02. beta\n'), ['alpha', 'beta']);
|
||
// Heading shape: the run is per entry body.
|
||
assert.strictEqual(names('### Steps\n\n1. do\n2. then\n').length, 1);
|
||
// Status fields under an ordered run still resolve their entry.
|
||
assert.deepStrictEqual(names('1. alpha\n status: resolved\n2. beta\n'), ['beta']);
|
||
});
|
||
|
||
test('B2: the rule\'s stated cost — a run that does not start at 1 is prose', () => {
|
||
// Pinned so the trade is visible: a hand-numbered list starting at 2 is
|
||
// read as prose, the same way CommonMark refuses it as a paragraph
|
||
// interruption. The wild records (#3702) all start at 1.
|
||
assert.deepStrictEqual(names('2. alpha\n3. beta\n'), []);
|
||
// A bullet does NOT end the list for this purpose (round 5, M2): a list is
|
||
// open at the level, so the following non-1 ordinal is an item — CommonMark
|
||
// reads `2. gamma` there as a fresh ordered list (start=2), not as text.
|
||
assert.deepStrictEqual(names('1. alpha\n- beta\n2. gamma\n'), ['alpha', 'beta', 'gamma']);
|
||
});
|
||
|
||
test('round 5 (M1): the ordered-start threshold — `0.` and `1.` open a list, `2.` does not', () => {
|
||
// CommonMark §5.2 permits any 1-9-digit start; a `0.`-numbered list is
|
||
// ordinary. Refusing `0.` dropped ONLY the first item, because the run
|
||
// then started at `1.` — the mixed under-report that looks like a clean
|
||
// parse. Boundary: limit-1 / limit / limit+1 of the threshold itself.
|
||
assert.deepStrictEqual(names('0. alpha\n1. beta\n2. gamma\n'), ['alpha', 'beta', 'gamma']);
|
||
assert.deepStrictEqual(names('1. alpha\n2. beta\n'), ['alpha', 'beta']);
|
||
assert.deepStrictEqual(names('2. alpha\n3. beta\n'), []);
|
||
assert.deepStrictEqual(names('0. only\n'), ['only']);
|
||
assert.deepStrictEqual(names('00. alpha\n01. beta\n'), ['alpha', 'beta']);
|
||
// The cost, stated accurately: a list starting at 2 or more reads as
|
||
// prose UNTIL its first `0.`/`1.` line — the loss is the prefix.
|
||
assert.deepStrictEqual(names('2. alpha\n3. beta\n1. gamma\n'), ['gamma']);
|
||
});
|
||
|
||
test('round 5 (M2): a non-1 ordinal is an item wherever a list is already open at its level', () => {
|
||
// `1. a` / `- b` / `5. c` folded `5. c` into `b` on round 4 — the
|
||
// ordered-run memory was cleared by the bullet. In CommonMark `5. c` there
|
||
// is a fresh ordered list (start=5): a non-1 start is refused only where
|
||
// it would interrupt a PARAGRAPH, and after a list item it interrupts none.
|
||
assert.deepStrictEqual(names('1. a\n- b\n5. c\n'), ['a', 'b', 'c']);
|
||
assert.deepStrictEqual(names('- a\n5. c\n'), ['a', 'c']);
|
||
// Across a blank line the list is still open (CommonMark: a loose list).
|
||
assert.deepStrictEqual(names('- a\n\n5. c\n'), ['a', 'c']);
|
||
// A paragraph after the blank ENDS the list; the ordinal after it is prose
|
||
// — the round-2 B2 contract, now placed where CommonMark places it.
|
||
assert.deepStrictEqual(names('- a\n\nprose here\n5. c\n'), ['a prose here 5. c']);
|
||
// Doc start and after a heading are paragraph positions: the B2 pins hold.
|
||
assert.deepStrictEqual(names('5. c\n'), []);
|
||
assert.deepStrictEqual(names('### Notes\n\n5. c\n'), []);
|
||
});
|
||
|
||
test('m1: the 9-digit boundary of an ordered start', () => {
|
||
// `999999999.` is a legal CommonMark ordered marker; ten digits is not.
|
||
assert.deepStrictEqual(names('1. a\n999999999. b\n'), ['a', 'b']);
|
||
// Ten digits: not a marker at all — a lazy continuation of the open item.
|
||
assert.deepStrictEqual(names('1. a\n1234567890. b\n'), ['a 1234567890. b']);
|
||
// And a ten-digit line cannot open a run on its own.
|
||
assert.deepStrictEqual(names('1234567890. b\n'), []);
|
||
});
|
||
|
||
test('m1: the indentation cliff is deliberately NOT applied — indent-lenient by design', () => {
|
||
// CommonMark reads a 4-space-indented line outside a list as indented
|
||
// code. This parser does not: `deferred-items.md` is hand-written with no
|
||
// mandated shape, and surfacing a questionable entry beats dropping a real
|
||
// one (the #2766 stance). Pinned as a decision, with the 3-space twin that
|
||
// both readings agree on.
|
||
assert.deepStrictEqual(names(' - x\n'), ['x']);
|
||
assert.deepStrictEqual(names(' - x\n'), ['x']);
|
||
// Nesting still folds by the indent rule at the 2-space depth executors
|
||
// actually write, not only at the 4-space depth round 1 tested.
|
||
assert.deepStrictEqual(names('- alpha\n - nested\n- beta\n'), ['alpha - nested', 'beta']);
|
||
});
|
||
});
|
||
|
||
describe('#3702 round 2: thematic breaks and fenced code are not list items (M1, M2)', () => {
|
||
const SECTION = '## Deferred Items\n\n';
|
||
const names = (md) => parseDeferredItems(SECTION + md).map((i) => i.name);
|
||
|
||
test('M1: a thematic break opens no entry, whichever character it is drawn with', () => {
|
||
// `- - -` was a phantom `"- -"` entry on base already; round 1 added
|
||
// `* * *` and `+ + +` to the class — and `* * *` is the separator an
|
||
// author writing in the `*` style is most likely to use.
|
||
for (const hr of ['* * *', '+ + +', '- - -', '***', '---', '___', ' * * *', '* * * ', '- - - - -']) {
|
||
assert.deepStrictEqual(names(`${hr}\n`), [], JSON.stringify(hr));
|
||
assert.deepStrictEqual(names(`### Entry\n\n${hr}\n`), [], `heading: ${JSON.stringify(hr)}`);
|
||
}
|
||
});
|
||
|
||
test('M1: a thematic break ENDS the open entry rather than joining it', () => {
|
||
// CommonMark: a thematic break closes the list. The separator is neither a
|
||
// phantom item nor a continuation line of the item above it.
|
||
assert.deepStrictEqual(names('- alpha\n\n* * *\n\n- beta\n'), ['alpha', 'beta']);
|
||
assert.deepStrictEqual(names('* alpha\n* * *\n* beta\n'), ['alpha', 'beta']);
|
||
// Under a heading the break stays a BODY line (round 5, m3): the entry's
|
||
// name is what `next` reports for that body, and its span stays contiguous
|
||
// for the writer. It is still not evidence — a break alone is no entry.
|
||
assert.deepStrictEqual(names('### Entry\n\n- **What:** x.\n\n* * *\n'), ['Entry - **What:** x. * * *']);
|
||
assert.deepStrictEqual(names('### Entry\n\n* * *\n'), []);
|
||
// `- - - x` is NOT a break (trailing text); it is a `- ` item whose text is `- - x`.
|
||
assert.deepStrictEqual(names('- - - x\n'), ['- - x']);
|
||
});
|
||
|
||
test('M2: lines inside a fenced code block never open an entry', () => {
|
||
// #3702's wild records carry reproduction blocks — `+`-prefixed diff lines
|
||
// and `1.`-numbered repro steps are the NORMAL content of such a file.
|
||
assert.deepStrictEqual(names('### Entry\n\n```sh\n1. run this\n2. then this\n```\n'), []);
|
||
assert.deepStrictEqual(names('```diff\n+ added\n- removed\n```\n'), []);
|
||
assert.deepStrictEqual(names('~~~\n* not an item\n~~~\n'), []);
|
||
// An unterminated fence runs to the end of its ENTRY, never past it
|
||
// (round 5, B1): a stray delimiter before the first item hides nothing.
|
||
assert.deepStrictEqual(names('```\n- still fenced\n'), ['still fenced']);
|
||
});
|
||
|
||
test('B1 (round 5): an unterminated fence runs to the end of its entry — a stray delimiter cannot swallow later entries', () => {
|
||
// The exact review reproduction, at indent 0 and at 4: `next` reports two
|
||
// entries and round 4 reported ONE, with `- b` swallowed into `a`'s name.
|
||
assert.deepStrictEqual(names('- a\n\n```\n\n- b\n'), ['a ```', 'b']);
|
||
assert.deepStrictEqual(names('- a\n\n ```\n\n- b\n'), ['a ```', 'b']);
|
||
// A TERMINATED deep fence still gates what it encloses (round 4, M2 holds).
|
||
assert.deepStrictEqual(names('- a\n ```\n - not b\n ```\n- b\n'), ['a ``` - not b ```', 'b']);
|
||
// Inside its own entry the stray fence still gates: a `status:` under it
|
||
// does not resolve the entry, and the NEXT entry is read on its own terms.
|
||
const statusesOf = (md) => parseDeferredItemsWithStatus(SECTION + md).map((i) => i.status);
|
||
assert.deepStrictEqual(statusesOf('- alpha\n ```\n status: resolved\n- beta\n status: resolved\n'), ['', 'resolved']);
|
||
// A delimiter after the bound is a fence in its own right: without the
|
||
// rescan the `~~~` pair below would be invisible and beta would resolve.
|
||
assert.deepStrictEqual(statusesOf('- a\n```\n- b\n ~~~\n status: resolved\n ~~~\n- c\n'), ['', '', '']);
|
||
assert.deepStrictEqual(names('- a\n```\n- b\n ~~~\n status: resolved\n ~~~\n- c\n'), ['a ```', 'b ~~~ status: resolved ~~~', 'c']);
|
||
// Heading shape: a heading ends the entry, and the fence with it. (At
|
||
// indent 0 the heading TOKENIZER applies CommonMark's own fence rule, so
|
||
// `### Next` after a stray delimiter is body text there, exactly as on
|
||
// `next`; at four spaces the tokenizer sees no fence and the heading holds.)
|
||
assert.deepStrictEqual(names('### Entry\n\n- **What:** x\n\n```\n\n### Next\n\n- y\n'), ['Entry - **What:** x ``` ### Next - y']);
|
||
assert.deepStrictEqual(names('### Entry\n\n- **What:** x\n\n ```\n\n### Next\n\n- y\n'), ['Entry - **What:** x ```', 'Next - y']);
|
||
// And in the headless region before the first heading (four spaces, for
|
||
// the tokenizer reason above — at indent 0 the section has no heading).
|
||
assert.deepStrictEqual(names(' ```\n- a\n\n### Entry\n\n- b\n'), ['a', 'Entry - b']);
|
||
assert.deepStrictEqual(names('```\n- a\n\n### Entry\n\n- b\n'), ['a ### Entry', 'b']);
|
||
});
|
||
|
||
test('M2: a fence inside an entry is continuation, and the entry still parses around it', () => {
|
||
const md = '- alpha\n ```sh\n 1. step\n + diff\n ```\n status: resolved\n- beta\n';
|
||
const withStatus = parseDeferredItemsWithStatus(SECTION + md);
|
||
assert.strictEqual(withStatus.length, 2, JSON.stringify(withStatus));
|
||
assert.strictEqual(withStatus[0].status, 'resolved');
|
||
assert.deepStrictEqual(names(md), ['beta']);
|
||
// Heading shape: the fenced lines are body text, not evidence — the `-`
|
||
// line outside the fence is what keeps the entry.
|
||
assert.strictEqual(names('### Entry\n\n- **What:** x.\n\n```\n1. repro\n```\n').length, 1);
|
||
// And the acknowledge writer's span survives a fenced continuation.
|
||
const ack = acknowledgeDeferredItem(SECTION + '- alpha\n ```\n + diff\n ```\n- beta\n', 'alpha ``` + diff ```');
|
||
assert.strictEqual(ack.status, 'ok');
|
||
assert.strictEqual(parseDeferredItemsWithStatus(ack.content)[0].status, 'acknowledged');
|
||
});
|
||
});
|
||
|
||
describe('#3702 round 2: indent measure is grammar-scoped (review round 6)', () => {
|
||
// The deferred grammar measures CommonMark columns (a tab is a jump to the
|
||
// next multiple of 4); the Gaps grammar keeps `next`'s raw character count.
|
||
// Sharing one measure silently changed Gaps entry boundaries in BOTH
|
||
// directions on tab-indented input, breaking the `blockStructure: false`
|
||
// opt-out's byte-for-byte promise.
|
||
const gapsNames = (body) => parseUatItems(['# UAT', '', '## Gaps', '', body, ''].join('\n')).map((i) => i.name);
|
||
const deferredNames = (body) => parseDeferredItems('## Deferred Items\n\n' + body + '\n').map((i) => i.name);
|
||
|
||
test('Gaps: a tab-indented item followed by a two-space one stays ONE entry, as on `next`', () => {
|
||
assert.deepEqual(gapsNames('\t- first item\n - second item'), ['first item - second item']);
|
||
});
|
||
|
||
test('Gaps: a two-space item followed by a tab-indented one stays TWO entries, as on `next`', () => {
|
||
assert.deepEqual(gapsNames(' - first item\n\t- second item'), ['first item', 'second item']);
|
||
});
|
||
|
||
test('Gaps: four spaces then two spaces splits, and a tab pair splits — unchanged either way', () => {
|
||
assert.deepEqual(gapsNames(' - first item\n - second item'), ['first item', 'second item']);
|
||
assert.deepEqual(gapsNames('\t- first item\n\t- second item'), ['first item', 'second item']);
|
||
});
|
||
|
||
test('deferred: the SAME tab/space pairs measure in columns — the opposite verdict, by design', () => {
|
||
assert.deepEqual(deferredNames('\t- first\n - second'), ['first', 'second']);
|
||
assert.deepEqual(deferredNames(' - first\n\t- second'), ['first - second']);
|
||
});
|
||
});
|
||
|
||
describe('#3702 round 2: round-review refinements (ordered run, rejected ordinals, breaks, fenced fields, Gaps scope)', () => {
|
||
const SECTION = '## Deferred Items\n\n';
|
||
const names = (md) => parseDeferredItems(SECTION + md).map((i) => i.name);
|
||
const statuses = (md) => parseDeferredItemsWithStatus(SECTION + md).map((i) => i.status);
|
||
|
||
test('an ordered run ENDS at a paragraph that follows a blank line (CommonMark §5.3), but survives lazy continuation', () => {
|
||
// A blank line then a non-indented, non-list line is a paragraph: the list
|
||
// is over, and `5. x` after it is prose folded into the open entry.
|
||
const got = names('1. a\n\nparagraph\n\n5. x\n');
|
||
assert.strictEqual(got.length, 1, JSON.stringify(got));
|
||
assert.match(got[0], /^a/);
|
||
// No blank line → lazy continuation → the list is still open and `2. b` is an item.
|
||
assert.deepStrictEqual(names('1. a\nlazy continuation\n2. b\n'), ['a lazy continuation', 'b']);
|
||
// Heading shape carries the same rule per body.
|
||
assert.strictEqual(names('### Steps\n\n1. do\n\nsome prose.\n\n4. not an item\n').length, 1);
|
||
});
|
||
|
||
test('an accepted opener clears the blank-line memory — lazy continuation right after it keeps the run', () => {
|
||
// Round-review continuation: `blankSeen` survived the opener branch, so
|
||
// `2. b` + a lazy line ended the run and `3. c` folded into `b`.
|
||
assert.deepStrictEqual(names('1. a\n\n2. b\nlazy continuation\n3. c\n'), ['a', 'b lazy continuation', 'c']);
|
||
});
|
||
|
||
test('a headless region of a heading-shaped file applies the SAME paragraph reset — opener flags come from the splitter, not a re-derivation', () => {
|
||
// Round-review continuation: a re-derived flag set re-accepted `3.` under a
|
||
// stale run after the paragraph had ended it, and stripped it into a field.
|
||
const md = '1. alpha\n\nparagraph\n\n3. status: resolved\n\n### Entry\n\n- **What:** x\n';
|
||
assert.deepStrictEqual(statuses(md), ['', '']);
|
||
assert.strictEqual(names(md).length, 2);
|
||
});
|
||
|
||
test('an ordered run is per INDENT: a nested `1. / 2.` run resolves (round-1 parity), and a nested ordinal after a nested bullet continues that level\'s list', () => {
|
||
// Round-review continuation 2: nested openers read the top-level run and
|
||
// never wrote their own.
|
||
for (const eol of ['\n', '\r\n']) {
|
||
const nestedRun = '- alpha\n 1. what: detail\n 2. status: resolved\n\n### Entry\n\n- **What:** x\n'.replace(/\n/g, eol);
|
||
assert.deepStrictEqual(statuses(nestedRun), ['resolved', ''], JSON.stringify(eol));
|
||
// Round 5 (M2): a list is open at the nested level, so `3.` is an item
|
||
// there — a fresh ordered list in CommonMark — and a nested item that
|
||
// reads `status: resolved` is a field line, as `- status: resolved` is.
|
||
const nestedAfterBullet = '1. alpha\n - nested item\n 3. status: resolved\n\n### Entry\n\n- **What:** x\n'.replace(/\n/g, eol);
|
||
assert.deepStrictEqual(statuses(nestedAfterBullet), ['resolved', ''], JSON.stringify(eol));
|
||
// With NO list open at the nested level the ordinal is prose (round 2).
|
||
const leak = '1. alpha\n nested prose\n 3. status: resolved\n\n### Entry\n\n- **What:** x\n'.replace(/\n/g, eol);
|
||
assert.deepStrictEqual(statuses(leak), ['', ''], JSON.stringify(eol));
|
||
}
|
||
// A new top-level item resets the nested levels: `2.` under beta does not continue alpha's nested run.
|
||
assert.deepStrictEqual(statuses('- alpha\n 1. a\n- beta\n 2. status: resolved\n'), ['', '']);
|
||
// Under a heading the same per-indent rule applies.
|
||
assert.deepStrictEqual(statuses('### Entry\n\n- **What:** x\n 1. step\n 2. **Status:** resolved\n'), ['resolved']);
|
||
});
|
||
|
||
test('a DEDENTING top-level list keeps its entry boundaries — every indent at or above the base is one level', () => {
|
||
// Round-review continuation 3: the exact-indent run lookup rejected the
|
||
// shallower ordinals, collapsing three entries into one.
|
||
assert.deepStrictEqual(names(' 1. alpha\n 2. beta\n3. gamma\n'), ['alpha', 'beta', 'gamma']);
|
||
assert.deepStrictEqual(names(' - alpha\n- beta\n - gamma\n'), ['alpha', 'beta - gamma']);
|
||
});
|
||
|
||
test('indent is measured in CommonMark COLUMNS (a tab advances to the next multiple of 4), so a tab and a space are different levels', () => {
|
||
// Round-review continuation 3: character counting aliased `\t` and ` `.
|
||
// Heading shape, where every ACCEPTED nested opener is marker-stripped
|
||
// before field extraction (the headless path strips line 0 only — #3740).
|
||
for (const eol of ['\n', '\r\n']) {
|
||
const body = (nested) => `### Entry\n\n- **What:** x\n${nested}`.replace(/\n/g, eol);
|
||
assert.deepStrictEqual(statuses(body('\t1. nested\n 2. **Status:** resolved\n')), [''], JSON.stringify(eol));
|
||
assert.deepStrictEqual(statuses(body('\t1. nested\n\t2. **Status:** resolved\n')), ['resolved'], JSON.stringify(eol));
|
||
assert.deepStrictEqual(statuses(body(' 1. nested\n\t2. **Status:** resolved\n')), ['resolved'], JSON.stringify(eol));
|
||
}
|
||
});
|
||
|
||
test('a fenced block ends the runs at its indent and deeper, like a paragraph does', () => {
|
||
// Round-review continuation 3: a nested run stayed open across a fence,
|
||
// so a post-fence `2. status: resolved` resolved the entry. Heading shape,
|
||
// for the reason the columns test states.
|
||
const body = (nested) => `### Entry\n\n- **What:** x\n${nested}`;
|
||
assert.deepStrictEqual(statuses(body(' 1. a\n ```\n code\n ```\n 2. **Status:** resolved\n')), ['']);
|
||
// A deeper fence (3 spaces — the sectionizer's CommonMark `{0,3}` limit) leaves the shallower run alone.
|
||
assert.deepStrictEqual(statuses(body(' 1. a\n ```\n code\n ```\n 2. **Status:** resolved\n')), ['resolved']);
|
||
// Control: without the fence the run continues and resolves.
|
||
assert.deepStrictEqual(statuses(body(' 1. a\n 2. **Status:** resolved\n')), ['resolved']);
|
||
});
|
||
|
||
test('a REJECTED ordinal line under a heading is not marker-stripped, so it cannot manufacture a field', () => {
|
||
// `3. status: resolved` at a PARAGRAPH position is prose by the
|
||
// ordered-start rule; before this fix the heading path stripped its marker
|
||
// anyway and read a resolved field off it. (Round 5, M2: directly after a
|
||
// list item it is an item instead — a list is open there.)
|
||
assert.deepStrictEqual(statuses('### Entry\n\n- **What:** x\n\nSome prose.\n3. status: resolved\n'), ['']);
|
||
assert.strictEqual(names('### Entry\n\n- **What:** x\n\nSome prose.\n3. status: resolved\n').length, 1);
|
||
assert.deepStrictEqual(statuses('### Entry\n\n- **What:** x\n3. status: resolved\n'), ['resolved']);
|
||
// An ACCEPTED ordered status line still resolves, as `- status: resolved` does.
|
||
assert.deepStrictEqual(statuses('### Entry\n\n1. **What:** x\n2. **Status:** resolved\n'), ['resolved']);
|
||
// Same rule in a headless region of a heading-shaped file.
|
||
assert.deepStrictEqual(statuses('- alpha\n 3. status: resolved\n\n### Entry\n\n- **What:** x\n'), ['', '']);
|
||
});
|
||
|
||
test('a thematic break is recognised at any indent — the parser is indent-lenient for breaks as it is for items', () => {
|
||
assert.deepStrictEqual(names(' * * *\n'), []);
|
||
assert.deepStrictEqual(names('- alpha\n\n - - -\n\n- beta\n'), ['alpha', 'beta']);
|
||
});
|
||
|
||
test('a status line orphaned after a break leaves its entry OPEN — the fail-safe polarity, pinned', () => {
|
||
// `- alpha\n---` is a list then a thematic break in CommonMark; the indented
|
||
// line after it belongs to nothing. Surfacing alpha is the safe direction.
|
||
assert.deepStrictEqual(names('- alpha\n---\n status: resolved\n'), ['alpha']);
|
||
});
|
||
|
||
test('fenced lines carry no FIELDS either — a fenced `status: resolved` does not resolve the entry', () => {
|
||
assert.deepStrictEqual(statuses('- alpha\n```\nstatus: resolved\n```\n'), ['']);
|
||
assert.deepStrictEqual(statuses('### Entry\n\n- **What:** x\n```\n- **Status:** resolved\n```\n'), ['']);
|
||
assert.deepStrictEqual(statuses('- alpha\n ```yaml\n status: resolved\n ```\n status: acknowledged\n'), ['acknowledged']);
|
||
});
|
||
|
||
test('`## Gaps` keeps its round-1 grammar byte-for-byte: no fence or break awareness there', () => {
|
||
// Block structure (M1/M2) is scoped to the deferred grammar via
|
||
// `BulletMarkers.blockStructure`; the Gaps section is template-mandated and
|
||
// out of #3702's blast radius, so a fenced hyphen line still counts there,
|
||
// and a fenced field is still read — exactly as on `next`.
|
||
//
|
||
// THE SECOND ASSERTION tracks `next`'s #3898 fix, which landed in the
|
||
// base range this branch merged (b431ae9f0): a spaced hyphen thematic
|
||
// break in `## Gaps` is a SEPARATOR — skipped between entries — so
|
||
// `- - -` no longer surfaces a phantom open gap named `- -`. Until that
|
||
// fix this line pinned the phantom on purpose (round 4, m4), because it
|
||
// was the only assertion that would notice the Gaps path moving; it still
|
||
// is, and it now pins the fixed reading. The full separator table lives in
|
||
// the #3898 describe block above; this one keeps the Gaps opt-out honest.
|
||
const uat = ['---', 'status: partial', 'phase: 01-x', '---', '', '## Gaps', '', '```', '- truth: phantom', ' status: open', '```', ''].join('\n');
|
||
const got = parseUatItems(uat);
|
||
assert.deepStrictEqual(got.map((i) => i.name), ['phantom'], JSON.stringify(got));
|
||
const withBreak = ['---', 'status: partial', 'phase: 01-x', '---', '', '## Gaps', '', '- - -', '- truth: real', ' status: open', ''].join('\n');
|
||
assert.deepStrictEqual(parseUatItems(withBreak).map((i) => i.name), ['real']);
|
||
});
|
||
});
|
||
|
||
// ─── Bug 3: table-shaped ## Gaps section ──────────────────────────────────────
|
||
|
||
describe('#2766 parseGapsItems: GFM table shape', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
/** Run audit-uat over a phase whose UAT file has `gapsBody` as its Gaps section. */
|
||
function gapsItems(gapsBody) {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '50-gaps');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '50-UAT.md'), uatWithGaps(gapsBody));
|
||
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
const uat = output.results.find(r => r.type === 'uat');
|
||
return uat ? uat.items : [];
|
||
}
|
||
|
||
test('header-mapped table → truth/status/reason/test extracted', () => {
|
||
const items = gapsItems([
|
||
'| Truth | Status | Reason | Test |',
|
||
'|-------|--------|--------|------|',
|
||
'| Login should redirect | failed | User reported a 500 | 1 |',
|
||
].join('\n'));
|
||
|
||
assert.strictEqual(items.length, 1, JSON.stringify(items));
|
||
assert.strictEqual(items[0].name, 'Login should redirect');
|
||
assert.strictEqual(items[0].result, 'failed');
|
||
assert.strictEqual(items[0].reason, 'User reported a 500');
|
||
assert.strictEqual(items[0].test, 1);
|
||
});
|
||
|
||
test('status: resolved row suppressed, open row kept', () => {
|
||
const items = gapsItems([
|
||
'| Truth | Status |',
|
||
'|-------|--------|',
|
||
'| closed thing | resolved |',
|
||
'| open thing | failed |',
|
||
].join('\n'));
|
||
|
||
assert.strictEqual(items.length, 1, JSON.stringify(items.map(i => i.name)));
|
||
assert.strictEqual(items[0].name, 'open thing');
|
||
});
|
||
|
||
test('no status column → surfaced as unknown, not dropped', () => {
|
||
const items = gapsItems('| Truth | Note |\n|---|---|\n| something is off | see logs |');
|
||
|
||
assert.strictEqual(items.length, 1, JSON.stringify(items));
|
||
assert.strictEqual(items[0].result, 'unknown');
|
||
assert.strictEqual(items[0].name, 'something is off');
|
||
});
|
||
|
||
test('unrecognizable header → joined cells + unknown status', () => {
|
||
const items = gapsItems('| Alpha | Beta |\n|---|---|\n| xxx | yyy |');
|
||
|
||
assert.strictEqual(items.length, 1, JSON.stringify(items));
|
||
assert.strictEqual(items[0].result, 'unknown');
|
||
assert.match(items[0].name, /xxx/);
|
||
assert.match(items[0].name, /yyy/);
|
||
});
|
||
|
||
test('headerless table → explicit resolved cell still suppressed', () => {
|
||
const items = gapsItems('| open thing | failed |\n| closed thing | resolved |');
|
||
|
||
assert.strictEqual(items.length, 1, JSON.stringify(items.map(i => i.name)));
|
||
assert.match(items[0].name, /open thing/);
|
||
});
|
||
|
||
test('bullets and a table in one Gaps section → union, no double-counting', () => {
|
||
const items = gapsItems([
|
||
'- truth: "a bullet gap"',
|
||
' status: failed',
|
||
'',
|
||
'| Truth | Status |',
|
||
'|---|---|',
|
||
'| a table gap | failed |',
|
||
].join('\n'));
|
||
|
||
assert.strictEqual(items.length, 2, JSON.stringify(items.map(i => i.name)));
|
||
assert.ok(items.some(i => i.name === 'a bullet gap'));
|
||
assert.ok(items.some(i => i.name === 'a table gap'));
|
||
});
|
||
|
||
test('bullet-only Gaps unchanged (no regression on #2286)', () => {
|
||
const items = gapsItems('- truth: "only a bullet"\n status: failed\n reason: "because"\n');
|
||
|
||
assert.strictEqual(items.length, 1, JSON.stringify(items));
|
||
assert.strictEqual(items[0].name, 'only a bullet');
|
||
assert.strictEqual(items[0].reason, 'because');
|
||
});
|
||
});
|
||
|
||
describe("#3707: audit-uat must not silently drop outstanding UAT rows", () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
// Shared harness: write one UAT file into a phase directory using the same
|
||
// naming convention (`.planning/phases/01-foundation/01-UAT.md`) the
|
||
// existing `audit-uat command` tests above use. Phase dirs are
|
||
// milestone-window filtered from ROADMAP.md, so an unlisted/invented phase
|
||
// name risks being excluded for an unrelated reason — reusing the
|
||
// established name avoids that entirely.
|
||
function writeUat(content, phaseDirName = "01-foundation", fileName = "01-UAT.md") {
|
||
const phaseDir = path.join(tmpDir, ".planning", "phases", phaseDirName);
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, fileName), content);
|
||
}
|
||
|
||
function runAudit() {
|
||
const result = runGsdTools("audit-uat --raw", tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
return JSON.parse(result.output);
|
||
}
|
||
|
||
const ISSUE_ROW = `---
|
||
status: testing
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
## Tests
|
||
|
||
### 1. Login Form
|
||
expected: Form displays with email and password fields
|
||
result: issue
|
||
reported: "Button color is wrong"
|
||
severity: major
|
||
`;
|
||
|
||
const BLOCK_SCALAR_ROW = `---
|
||
status: testing
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
## Tests
|
||
|
||
### 1. Wrapped Expected
|
||
expected: |
|
||
Line one of the expected behavior.
|
||
Line two of the expected behavior.
|
||
result: pending
|
||
`;
|
||
|
||
const WRAPPED_INLINE_ROW = `---
|
||
status: testing
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
## Tests
|
||
|
||
### 1. Wrapped Inline
|
||
expected: Some behavior that wraps onto
|
||
a second indented line
|
||
result: blocked
|
||
blocked_by: physical-device
|
||
`;
|
||
|
||
// Post-FIX-1 both block-scalar and wrapped-inline `expected:` rows PARSE
|
||
// (defect 2 is fixed), so a fixture built from them would go green via
|
||
// FIX 1 alone and never exercise FIX 4 (#3707 review note). This fixture
|
||
// instead carries rows with NO `result:` line at all — genuinely
|
||
// unparseable as test rows under every fix — so the file still parses to
|
||
// ZERO items and FIX 4's parse_gap path is the thing actually exercised.
|
||
const ALL_UNPARSEABLE_ROWS = `---
|
||
status: partial
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
## Tests
|
||
|
||
### 1. Still Being Written
|
||
expected: Something should happen
|
||
notes: result not yet recorded
|
||
|
||
### 2. Also Still Being Written
|
||
expected: Another thing should happen
|
||
notes: result not yet recorded
|
||
`;
|
||
|
||
// #3078 security review: a terminal `complete` file that still contains
|
||
// `### N.` blocks the parser could not read. The author's claim of
|
||
// completeness cannot be verified against rows nobody could read, so this
|
||
// IS surfaced, with parse_gap.
|
||
const ALL_UNPARSEABLE_ROWS_COMPLETE = `---
|
||
status: complete
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
## Tests
|
||
|
||
### 1. Still Being Written
|
||
expected: Something should happen
|
||
notes: result not yet recorded
|
||
`;
|
||
|
||
// The genuinely-terminal case the old `status !== 'complete'` guard was
|
||
// really protecting: `complete` with NO `### N.` test blocks at all, so
|
||
// `headingsSeen === 0` and there is nothing unread to contradict the
|
||
// author's claim. Must stay omitted entirely.
|
||
const NO_BLOCKS_COMPLETE = `---
|
||
status: complete
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
## Tests
|
||
|
||
All scenarios were exercised manually and signed off; nothing left to record.
|
||
|
||
## Notes
|
||
|
||
Closed out at the milestone review.
|
||
`;
|
||
|
||
// The other must-not-regress terminal case: `complete` where every row DID
|
||
// parse and every row passed. `headingsSeen` is never set for a parsed row,
|
||
// so this file stays quiet — a genuinely finished file must not become
|
||
// noise just because the status guard was removed.
|
||
const ALL_PASS_ROWS_COMPLETE = `---
|
||
status: complete
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
## Tests
|
||
|
||
### 1. First Scenario
|
||
expected: Works fine
|
||
result: pass
|
||
|
||
### 2. Second Scenario
|
||
expected: Also works fine
|
||
result: pass
|
||
`;
|
||
|
||
// The security reviewer's verbatim repro: a `status: complete` file with a
|
||
// column-0 fence that straddles a `### 2. ... result: blocked` row. The
|
||
// straddled row is hidden from the tokenizer, so it yields no item — under
|
||
// the old guard the terminal status ALSO suppressed the parse-gap entry and
|
||
// the audit reported the file totally clean with a `blocked` sitting in it.
|
||
const FENCE_STRADDLED_BLOCKED_COMPLETE = `---
|
||
status: complete
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
## Tests
|
||
|
||
### 1. Alpha
|
||
expected: Alpha works
|
||
result: pass
|
||
|
||
\`\`\`
|
||
### 2. Straddled Blocked
|
||
expected: Beta works
|
||
result: blocked
|
||
blocked_by: server team
|
||
`;
|
||
|
||
const UNRECOGNISED_RESULT_ROW = `---
|
||
status: testing
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
## Tests
|
||
|
||
### 1. Odd Result
|
||
expected: Something happens
|
||
result: wibble
|
||
`;
|
||
|
||
const PASS_AND_PENDING_ROWS = `---
|
||
status: testing
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
## Tests
|
||
|
||
### 1. Passing Test
|
||
expected: Works fine
|
||
result: pass
|
||
|
||
### 2. Pending Test
|
||
expected: Still pending
|
||
result: pending
|
||
`;
|
||
|
||
const ALL_PASS_ROWS = `---
|
||
status: testing
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
## Tests
|
||
|
||
### 1. Passing Test
|
||
expected: Works fine
|
||
result: pass
|
||
|
||
### 2. Also Passing
|
||
expected: Also works fine
|
||
result: pass
|
||
`;
|
||
|
||
const CLASSIC_PENDING_ROW = `---
|
||
status: testing
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
## Tests
|
||
|
||
### 1. Classic Row
|
||
expected: Displays correctly
|
||
result: pending
|
||
`;
|
||
|
||
// Defect 1: parseUatItems's result filter is a DROP-list
|
||
// (`pending|skipped|blocked`) that never recognises the template-sanctioned
|
||
// `result: issue` token, so a genuinely outstanding issue row is matched by
|
||
// the regex and then thrown away by the filter. Pre-fix: total_items is 0.
|
||
test("a template-sanctioned `result: issue` row is surfaced", () => {
|
||
writeUat(ISSUE_ROW);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.summary.total_items, 1);
|
||
assert.strictEqual(output.results[0].items[0].result, "issue");
|
||
});
|
||
|
||
// Defect 1 (categorization half): categorizeItem has no branch for
|
||
// `result === 'issue'` and falls through to the catch-all 'unknown'.
|
||
// Pre-fix: this never even runs the assertion path because the row above
|
||
// is dropped before categorizeItem sees it — so this too is red pre-fix.
|
||
test("an issue row categorizes as issue, not unknown", () => {
|
||
writeUat(ISSUE_ROW);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.results[0].items[0].category, "issue");
|
||
});
|
||
|
||
// Defect 2: the `testPattern` regex requires `expected:` and `result:` to
|
||
// be ADJACENT single lines (`expected:\s*([^\n]+)\nresult:\s*...`), so a
|
||
// `expected: |` block-scalar row — whose continuation lines sit BETWEEN
|
||
// `expected:` and `result:` — never matches the pattern at all. Pre-fix:
|
||
// total_items is 0, the row is invisible with no trace.
|
||
test("a block-scalar `expected: |` row is surfaced", () => {
|
||
writeUat(BLOCK_SCALAR_ROW);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.summary.total_items, 1);
|
||
});
|
||
|
||
// Defect 2 again, inline-wrap variant: an `expected:` value that wraps onto
|
||
// a second indented line also breaks the adjacency the regex requires.
|
||
// Pre-fix: total_items is 0.
|
||
test("a wrapped inline `expected:` row is surfaced", () => {
|
||
writeUat(WRAPPED_INLINE_ROW);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.summary.total_items, 1);
|
||
});
|
||
|
||
// Same defect 2 fixture — pins that once surfaced, the row's own
|
||
// `blocked_by` field (which the regex's failure to match currently
|
||
// discards entirely) is preserved. Pre-fix: there is no item to read
|
||
// `blocked_by` off of.
|
||
test("a wrapped row keeps its blocked_by", () => {
|
||
writeUat(WRAPPED_INLINE_ROW);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.results[0].items[0].blocked_by, "physical-device");
|
||
});
|
||
|
||
// Same defect 2 fixture — pins categorizeItem's existing
|
||
// `/device|physical/i` mapping on `blocked_by` still applies once the row
|
||
// is actually surfaced. Pre-fix: there is no item to categorize.
|
||
test("a wrapped blocked row categorizes by its blocked_by", () => {
|
||
writeUat(WRAPPED_INLINE_ROW);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.results[0].items[0].category, "device_needed");
|
||
});
|
||
|
||
// Defect 3: cmdAuditUat only pushes a file's result entry when
|
||
// `items.length > 0`. A file whose every row happens to be unparseable
|
||
// (both defect-2 shapes above) parses to zero items and the WHOLE FILE
|
||
// vanishes from the audit — taking its phase and frontmatter `status:`
|
||
// with it. Pre-fix: `by_phase` has no '01' key and `results` is empty.
|
||
test("a file whose rows are all unparseable still reports its phase", () => {
|
||
writeUat(ALL_UNPARSEABLE_ROWS);
|
||
const output = runAudit();
|
||
assert.ok(
|
||
Object.prototype.hasOwnProperty.call(output.summary.by_phase, "01"),
|
||
`expected phase '01' in by_phase, got ${JSON.stringify(output.summary.by_phase)}`,
|
||
);
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.ok(entry, `expected a results entry for 01-UAT.md, got ${JSON.stringify(output.results)}`);
|
||
});
|
||
|
||
// Defect 3, frontmatter half: the same vanished-file entry would have
|
||
// carried the file's own `status: partial` frontmatter. Pre-fix: there is
|
||
// no entry to read `status` off of.
|
||
test("that entry carries the frontmatter status", () => {
|
||
writeUat(ALL_UNPARSEABLE_ROWS);
|
||
const output = runAudit();
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.ok(entry, `expected a results entry for 01-UAT.md, got ${JSON.stringify(output.results)}`);
|
||
assert.strictEqual(entry.status, "partial");
|
||
assert.strictEqual(entry.parse_gap, true);
|
||
});
|
||
|
||
// #3078 security review — REPLACES "a zero-item file with a complete status
|
||
// is still omitted". That test used ALL_UNPARSEABLE_ROWS_COMPLETE, whose
|
||
// `### 1.` block carries no `result:` line, so it is NOT a zero-item file in
|
||
// the sense the name claimed: it is a file with one block the parser could
|
||
// not read (`headingsSeen === 1`). Under the old `status !== 'complete'`
|
||
// guard the terminal status suppressed the entry anyway, which is the
|
||
// self-declared kill switch this change closes. The intent the old test
|
||
// meant to protect — a genuinely finished file stays quiet — is preserved
|
||
// and made STRICTER below, split across the two cases that actually differ:
|
||
// `headingsSeen === 0` (nothing unread) still omits, `headingsSeen > 0`
|
||
// (rows the tool could not read) now surfaces.
|
||
//
|
||
// Round-3 review MAJOR 2 still applies to all of these: the fixture must be
|
||
// written as `01-UAT.md` into `01-foundation`, because under #3511 phase
|
||
// scoping `selectPhaseUatFiles` filters files against the phase dir's own
|
||
// token, and a mismatched pair is never opened at all — making any
|
||
// "expected undefined" assertion vacuously green. `writeUat`'s defaults
|
||
// give the matching pairing.
|
||
|
||
// REPLACEMENT 1 — preserves the original intent, unweakened: terminal
|
||
// `complete` with no test blocks at all is omitted entirely.
|
||
test("a complete file with no test blocks at all is still omitted", () => {
|
||
writeUat(NO_BLOCKS_COMPLETE);
|
||
const output = runAudit();
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.strictEqual(entry, undefined, `expected no results entry for 01-UAT.md, got ${JSON.stringify(entry)}`);
|
||
assert.strictEqual(output.summary.total_items, 0);
|
||
});
|
||
|
||
// REPLACEMENT 1b — the same intent for the other genuinely-finished shape:
|
||
// every row parsed and every row passed. This is the important
|
||
// non-regression for removing the status guard.
|
||
test("a complete file whose rows all parse and all pass is still omitted", () => {
|
||
writeUat(ALL_PASS_ROWS_COMPLETE);
|
||
const output = runAudit();
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.strictEqual(entry, undefined, `expected no results entry for 01-UAT.md, got ${JSON.stringify(entry)}`);
|
||
assert.strictEqual(output.summary.total_items, 0);
|
||
});
|
||
|
||
// REPLACEMENT 2 — the new distinction: terminal `complete` with `### N.`
|
||
// blocks the parser could not read IS surfaced. The author's assertion of
|
||
// completeness cannot be verified against rows nobody could read.
|
||
test("a complete file with unparseable test blocks is surfaced with parse_gap", () => {
|
||
writeUat(ALL_UNPARSEABLE_ROWS_COMPLETE);
|
||
const output = runAudit();
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.ok(entry, `expected a results entry for 01-UAT.md, got ${JSON.stringify(output.results)}`);
|
||
assert.strictEqual(entry.status, "complete");
|
||
assert.strictEqual(entry.parse_gap, true);
|
||
assert.strictEqual(entry.unparsed_blocks, 1);
|
||
assert.deepStrictEqual(entry.items, []);
|
||
});
|
||
|
||
// The security reviewer's actual repro. A column-0 fence straddles
|
||
// `### 2. ... result: blocked`, hiding it from the tokenizer; the terminal
|
||
// status used to suppress the resulting parse-gap entry too, so the audit
|
||
// reported nothing at all for a file with a `blocked` row in it.
|
||
test("a complete file with a fence-straddled blocked row is flagged, not silent", () => {
|
||
writeUat(FENCE_STRADDLED_BLOCKED_COMPLETE);
|
||
const output = runAudit();
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.ok(entry, `expected a results entry for 01-UAT.md, got ${JSON.stringify(output.results)}`);
|
||
assert.strictEqual(entry.status, "complete");
|
||
assert.strictEqual(entry.parse_gap, true);
|
||
// Both `### N.` headings are counted by the whole-document shortfall
|
||
// comparison: row 1 parsed but passed (so yields no item) and row 2 is
|
||
// hidden by the unterminated fence entirely.
|
||
assert.strictEqual(entry.unparsed_blocks, 2);
|
||
// The gap counter is the straddled row's only trace. Assert by identity
|
||
// that the blocked row did NOT sneak through as an item.
|
||
assert.strictEqual(
|
||
entry.items.find((i) => i.test === 2),
|
||
undefined,
|
||
`the straddled row must not appear as an item, got ${JSON.stringify(entry.items)}`,
|
||
);
|
||
});
|
||
|
||
// Defect 1, design-decision case: the fix inverts the DROP-list filter to a
|
||
// PASS set, so an unrecognised token like `result: wibble` — neither a
|
||
// known passing nor a known non-passing token — is surfaced rather than
|
||
// silently dropped. Pre-fix: total_items is 0.
|
||
test("an unrecognised result token is surfaced rather than dropped", () => {
|
||
writeUat(UNRECOGNISED_RESULT_ROW);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.summary.total_items, 1);
|
||
});
|
||
|
||
// CONTROL: `result: pass` must never be surfaced, today or after the fix.
|
||
// This constrains the defect-1 fix — inverting the filter to a PASS set
|
||
// must not swing so far that passing rows become "outstanding".
|
||
test("`result: pass` rows are never surfaced", () => {
|
||
writeUat(PASS_AND_PENDING_ROWS);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.summary.total_items, 1);
|
||
assert.strictEqual(output.results[0].items[0].result, "pending");
|
||
});
|
||
|
||
// CONTROL: a file where every row passes must contribute zero items (and
|
||
// therefore, pre-fix, is exactly the case defect 3's `items.length > 0`
|
||
// guard is legitimately protecting — a fully-passing file SHOULD vanish).
|
||
// Strengthened (regression review): the weak `total_items === 0` form
|
||
// stayed green even while a bogus `parse_gap` entry was being emitted for
|
||
// this exact fixture, so also assert `results` carries NO entry at all.
|
||
test("a fully passing file contributes no items", () => {
|
||
writeUat(ALL_PASS_ROWS);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.summary.total_items, 0);
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.strictEqual(entry, undefined, `expected no results entry at all, got ${JSON.stringify(entry)}`);
|
||
});
|
||
|
||
// CONTROL: the plain, pre-existing single-line `expected:` + `result:
|
||
// pending` shape must keep working unchanged.
|
||
test("an existing single-line expected + result: pending file is unchanged", () => {
|
||
writeUat(CLASSIC_PENDING_ROW);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.summary.total_items, 1);
|
||
assert.strictEqual(output.results[0].items[0].category, "pending");
|
||
});
|
||
});
|
||
|
||
describe("#3707 review: end-anchored result matcher regressed trailing-text rows", () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
function writeUat(content, phaseDirName = "01-foundation", fileName = "01-UAT.md") {
|
||
const phaseDir = path.join(tmpDir, ".planning", "phases", phaseDirName);
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, fileName), content);
|
||
}
|
||
|
||
function runAudit() {
|
||
const result = runGsdTools("audit-uat --raw", tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
return JSON.parse(result.output);
|
||
}
|
||
|
||
const HEADER = `---
|
||
status: testing
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
## Tests
|
||
|
||
`;
|
||
|
||
// BLOCKER 1: the end-anchored `^result:\s*\[?(\w+)\]?\s*$` matcher yielded
|
||
// NO match — and so silently dropped the row — for any `result:` line
|
||
// carrying trailing text. Each of these three shapes returned an item on
|
||
// origin/next and [] on the regressed commit.
|
||
test("a result: line with a trailing parenthetical is surfaced", () => {
|
||
writeUat(`${HEADER}### 1. Trailing Paren\nexpected: x\nresult: pending (blocked on staging)\n`);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.summary.total_items, 1);
|
||
assert.strictEqual(output.results[0].items[0].result, "pending");
|
||
});
|
||
|
||
test("a bracketed result: with a trailing comment is surfaced", () => {
|
||
writeUat(`${HEADER}### 1. Bracket Comment\nexpected: x\nresult: [skipped] # no device\n`);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.summary.total_items, 1);
|
||
assert.strictEqual(output.results[0].items[0].result, "skipped");
|
||
});
|
||
|
||
test("a result: line with a trailing dash-clause is surfaced", () => {
|
||
writeUat(`${HEADER}### 1. Dash Clause\nexpected: x\nresult: blocked - waiting\n`);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.summary.total_items, 1);
|
||
assert.strictEqual(output.results[0].items[0].result, "blocked");
|
||
});
|
||
|
||
// MAJOR: case-handling split — categorizeItem compared case-sensitively
|
||
// while the PASS check lowercased, so `result: PENDING` / `result: Blocked`
|
||
// fell through to 'unknown' instead of their real category.
|
||
test("result: PENDING categorizes as pending, not unknown", () => {
|
||
writeUat(`${HEADER}### 1. Casing\nexpected: x\nresult: PENDING\n`);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.results[0].items[0].category, "pending");
|
||
});
|
||
|
||
test("result: Blocked categorizes as blocked, not unknown", () => {
|
||
writeUat(`${HEADER}### 1. Casing\nexpected: x\nresult: Blocked\n`);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.results[0].items[0].category, "blocked");
|
||
});
|
||
|
||
// MINOR: the block previously ended only at the next NUMBERED level-3
|
||
// heading, so a trailing `## Gaps` section was absorbed into the
|
||
// preceding test's block and its unanchored `reason:` scan bled a Gaps
|
||
// entry's own reason onto the last test row.
|
||
test("a trailing ## Gaps section's reason does not bleed onto the prior test", () => {
|
||
writeUat(`${HEADER}### 1. Prior Test\nexpected: x\nresult: pending\n\n## Gaps\n\n- truth: "unrelated finding"\n status: open\n reason: GAPS-REASON\n`);
|
||
const output = runAudit();
|
||
const testItem = output.results[0].items.find((i) => i.name === "Prior Test");
|
||
assert.ok(testItem, `expected an item for 'Prior Test', got ${JSON.stringify(output.results[0].items)}`);
|
||
assert.strictEqual(testItem.reason, undefined, `expected no bled reason, got ${JSON.stringify(testItem)}`);
|
||
});
|
||
});
|
||
|
||
describe("#3707 review: parse_gap must reflect headings seen vs. items yielded", () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
function writeUat(content, phaseDirName = "01-foundation", fileName = "01-UAT.md") {
|
||
const phaseDir = path.join(tmpDir, ".planning", "phases", phaseDirName);
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, fileName), content);
|
||
}
|
||
|
||
function runAudit() {
|
||
const result = runGsdTools("audit-uat --raw", tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
return JSON.parse(result.output);
|
||
}
|
||
|
||
const FRONTMATTER = `---
|
||
status: testing
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
`;
|
||
|
||
// A Gaps-only UAT file whose sole entry is already resolved must yield no
|
||
// items AND no parse_gap entry — the old `items.length === 0 && status !==
|
||
// 'complete'` signal fired on this fixture even though nothing is
|
||
// outstanding and nothing failed to parse.
|
||
test("a Gaps-only file with 0 unresolved entries yields no items and no parse_gap", () => {
|
||
writeUat(`${FRONTMATTER}## Gaps\n\n- truth: "already handled"\n status: resolved\n`);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.summary.total_items, 0);
|
||
assert.strictEqual(output.summary.parse_gap_files, 0);
|
||
assert.strictEqual(output.results.find((r) => r.file === "01-UAT.md"), undefined);
|
||
});
|
||
|
||
// An empty `## Gaps` section (heading present, zero bullets) must not
|
||
// throw and must not register as a parse gap.
|
||
test("an empty Gaps section yields 0 items without throwing and no parse_gap", () => {
|
||
writeUat(`${FRONTMATTER}## Gaps\n`);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.summary.total_items, 0);
|
||
assert.strictEqual(output.summary.parse_gap_files, 0);
|
||
assert.strictEqual(output.results.find((r) => r.file === "01-UAT.md"), undefined);
|
||
});
|
||
|
||
// A file whose `### N.` blocks have no `result:` line at all is a genuine
|
||
// parse gap (headings were seen, no item was yielded for any of them, and
|
||
// it was not because they passed).
|
||
test("a file whose test blocks have no result: line at all is a parse_gap", () => {
|
||
writeUat(`${FRONTMATTER}## Tests\n\n### 1. Undrafted\nexpected: something\nnotes: result not yet recorded\n`);
|
||
const output = runAudit();
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.ok(entry, `expected a results entry, got ${JSON.stringify(output.results)}`);
|
||
assert.strictEqual(entry.parse_gap, true);
|
||
assert.strictEqual(output.summary.parse_gap_files, 1);
|
||
});
|
||
});
|
||
|
||
describe("#3707 follow-up BLOCKER: a MIXED file must not drop its unparseable rows", () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
function writeUat(content, phaseDirName = "01-foundation", fileName = "01-UAT.md") {
|
||
const phaseDir = path.join(tmpDir, ".planning", "phases", phaseDirName);
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, fileName), content);
|
||
}
|
||
|
||
function runAudit() {
|
||
const result = runGsdTools("audit-uat --raw", tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
return JSON.parse(result.output);
|
||
}
|
||
|
||
const FRONTMATTER = `---
|
||
status: testing
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
`;
|
||
|
||
// The old `else if (items.length > 0)` branch discarded `headingsSeen`
|
||
// entirely the instant ANY item existed anywhere in the file — a file with
|
||
// one parseable row plus two unparseable blocks reported total_items: 1,
|
||
// parse_gap_files: 0, parse_gap: undefined, silently losing the two
|
||
// outstanding rows with zero trace.
|
||
test("a mixed file reports the real item AND parse_gap true AND unparsed_blocks 2", () => {
|
||
writeUat(`${FRONTMATTER}## Tests
|
||
|
||
### 1. Real Row
|
||
expected: x
|
||
result: pending
|
||
|
||
### 2. Missing Result
|
||
expected: y
|
||
notes: none
|
||
|
||
### 3. Missing Result Too
|
||
expected: z
|
||
notes: none
|
||
`);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.summary.total_items, 1);
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.ok(entry, `expected a results entry, got ${JSON.stringify(output.results)}`);
|
||
assert.strictEqual(entry.items.length, 1);
|
||
assert.strictEqual(entry.items[0].name, "Real Row");
|
||
assert.strictEqual(entry.parse_gap, true);
|
||
assert.strictEqual(entry.unparsed_blocks, 2);
|
||
assert.strictEqual(output.summary.parse_gap_files, 1);
|
||
});
|
||
|
||
// Same hole reachable via the Gaps union: all `### N.` test blocks are
|
||
// unparseable but the file also has one open `## Gaps` entry, so
|
||
// `items.length` is 1 via the Gaps path alone — the old guard's
|
||
// `items.length > 0` check never distinguished the SOURCE of the items,
|
||
// so the flag never set even though two test blocks are still unaccounted
|
||
// for.
|
||
test("all test blocks unparseable plus one open Gaps entry also flags parse_gap", () => {
|
||
writeUat(`${FRONTMATTER}## Tests
|
||
|
||
### 1. Missing Result
|
||
expected: y
|
||
notes: none
|
||
|
||
### 2. Missing Result Too
|
||
expected: z
|
||
notes: none
|
||
|
||
## Gaps
|
||
|
||
- truth: "something open"
|
||
status: open
|
||
`);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.summary.total_items, 1);
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.ok(entry, `expected a results entry, got ${JSON.stringify(output.results)}`);
|
||
assert.strictEqual(entry.items[0].name, "something open");
|
||
assert.strictEqual(entry.parse_gap, true);
|
||
assert.strictEqual(entry.unparsed_blocks, 2);
|
||
assert.strictEqual(output.summary.parse_gap_files, 1);
|
||
});
|
||
});
|
||
|
||
describe("#3707 follow-up MAJOR: a result: inside a fenced code block must not be read as real", () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
function writeUat(content, phaseDirName = "01-foundation", fileName = "01-UAT.md") {
|
||
const phaseDir = path.join(tmpDir, ".planning", "phases", phaseDirName);
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, fileName), content);
|
||
}
|
||
|
||
function runAudit() {
|
||
const result = runGsdTools("audit-uat --raw", tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
return JSON.parse(result.output);
|
||
}
|
||
|
||
const FRONTMATTER = `---
|
||
status: testing
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
`;
|
||
|
||
// `block` was raw slice text and only HEADINGS were fence-stripped, so a
|
||
// fenced code sample's own `result: pending` line was read as the test's
|
||
// real outcome, hiding a genuinely PASSING test behind an outstanding row
|
||
// sourced from an example. origin/next returned null here (a regression).
|
||
test("a fenced result: pending followed by a real result: pass is NOT surfaced", () => {
|
||
writeUat(`${FRONTMATTER}## Tests
|
||
|
||
### 1. Fenced Then Real
|
||
expected: x
|
||
\`\`\`
|
||
result: pending
|
||
\`\`\`
|
||
result: pass
|
||
`);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.summary.total_items, 0);
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.strictEqual(entry, undefined, `expected no results entry at all, got ${JSON.stringify(entry)}`);
|
||
});
|
||
|
||
// A fenced-only `result:` with no real one must fabricate no item AND
|
||
// still count as an unparsed block (headingsSeen), not silently vanish.
|
||
test("a fenced-only result: with no real result: yields no item and counts as an unparsed block", () => {
|
||
writeUat(`${FRONTMATTER}## Tests
|
||
|
||
### 1. Fenced Only
|
||
expected: x
|
||
\`\`\`
|
||
result: pending
|
||
\`\`\`
|
||
`);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.summary.total_items, 0);
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.ok(entry, `expected a results entry, got ${JSON.stringify(output.results)}`);
|
||
assert.strictEqual(entry.items.length, 0);
|
||
assert.strictEqual(entry.parse_gap, true);
|
||
assert.strictEqual(entry.unparsed_blocks, 1);
|
||
});
|
||
});
|
||
|
||
describe("#3707 follow-up MINOR: headings without a name are still surfaced", () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
function writeUat(content, phaseDirName = "01-foundation", fileName = "01-UAT.md") {
|
||
const phaseDir = path.join(tmpDir, ".planning", "phases", phaseDirName);
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, fileName), content);
|
||
}
|
||
|
||
function runAudit() {
|
||
const result = runGsdTools("audit-uat --raw", tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
return JSON.parse(result.output);
|
||
}
|
||
|
||
const FRONTMATTER = `---
|
||
status: testing
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
`;
|
||
|
||
// `^\d+\.\s+` excluded `### 3.` (no name at all) — the heading contributed
|
||
// neither an item nor headingsSeen, so a file made only of these vanished
|
||
// entirely with no trace, the original symptom still reachable.
|
||
test("a file of nameless `### N.` headings with real result: lines is surfaced", () => {
|
||
writeUat(`${FRONTMATTER}## Tests
|
||
|
||
### 3.
|
||
expected: x
|
||
result: pending
|
||
`);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.summary.total_items, 1);
|
||
assert.strictEqual(output.results[0].items[0].test, 3);
|
||
});
|
||
|
||
// Same exclusion for `### 3.Foo` (no space between the number and name).
|
||
test("a file of no-space `### N.Name` headings with real result: lines is surfaced", () => {
|
||
writeUat(`${FRONTMATTER}## Tests
|
||
|
||
### 3.Foo
|
||
expected: x
|
||
result: pending
|
||
`);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.summary.total_items, 1);
|
||
assert.strictEqual(output.results[0].items[0].test, 3);
|
||
assert.strictEqual(output.results[0].items[0].name, "Foo");
|
||
});
|
||
});
|
||
|
||
describe("#3707 follow-up MINOR: trailing-text-to-reason synthesis is removed", () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
function writeUat(content, phaseDirName = "01-foundation", fileName = "01-UAT.md") {
|
||
const phaseDir = path.join(tmpDir, ".planning", "phases", phaseDirName);
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, fileName), content);
|
||
}
|
||
|
||
function runAudit() {
|
||
const result = runGsdTools("audit-uat --raw", tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
return JSON.parse(result.output);
|
||
}
|
||
|
||
const FRONTMATTER = `---
|
||
status: testing
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
`;
|
||
|
||
// `extractTrailingReason` stripped only `#-:`, so `result: [skipped] # no
|
||
// device` yielded reason ", needs device"-shaped text and categorized as
|
||
// device_needed on the regressed commit, where origin/next gave
|
||
// skipped_unresolved. The row must still be surfaced (that's what the
|
||
// blocker required) but with NO synthesized reason, restoring the
|
||
// origin/next categorization.
|
||
test("result: [skipped] # no device is surfaced, has no reason, and categorizes as skipped_unresolved", () => {
|
||
writeUat(`${FRONTMATTER}## Tests
|
||
|
||
### 1. Bracket Comment
|
||
expected: x
|
||
result: [skipped] # no device
|
||
`);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.summary.total_items, 1);
|
||
const item = output.results[0].items[0];
|
||
assert.strictEqual(item.result, "skipped");
|
||
assert.strictEqual(item.reason, undefined);
|
||
assert.strictEqual(item.category, "skipped_unresolved");
|
||
});
|
||
});
|
||
|
||
describe("#3707 follow-up: unparsed_blocks and by_phase 0-valued keys", () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
function writeUat(content, phaseDirName = "01-foundation", fileName = "01-UAT.md") {
|
||
const phaseDir = path.join(tmpDir, ".planning", "phases", phaseDirName);
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, fileName), content);
|
||
}
|
||
|
||
function runAudit() {
|
||
const result = runGsdTools("audit-uat --raw", tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
return JSON.parse(result.output);
|
||
}
|
||
|
||
const FRONTMATTER = `---
|
||
status: testing
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
`;
|
||
|
||
// A parse-gap-only phase (zero real items) still gains a `by_phase` key
|
||
// with value 0 — deliberate (see the doc comment at the accumulation
|
||
// site): it distinguishes "scanned, nothing countable" from "never
|
||
// scanned at all".
|
||
test("a parse-gap-only phase gains a by_phase key with value 0", () => {
|
||
writeUat(`${FRONTMATTER}## Tests\n\n### 1. Undrafted\nexpected: something\nnotes: result not yet recorded\n`);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.summary.total_items, 0);
|
||
assert.ok(
|
||
Object.prototype.hasOwnProperty.call(output.summary.by_phase, "01"),
|
||
`expected phase '01' in by_phase, got ${JSON.stringify(output.summary.by_phase)}`,
|
||
);
|
||
assert.strictEqual(output.summary.by_phase["01"], 0);
|
||
});
|
||
});
|
||
|
||
describe("#3707 round-3 review: unterminated fence and decimal sub-headings", () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
function writeUat(content, phaseDirName = "01-foundation", fileName = "01-UAT.md") {
|
||
const phaseDir = path.join(tmpDir, ".planning", "phases", phaseDirName);
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, fileName), content);
|
||
}
|
||
|
||
function runAudit() {
|
||
const result = runGsdTools("audit-uat --raw", tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
return JSON.parse(result.output);
|
||
}
|
||
|
||
const FRONTMATTER = `---
|
||
status: testing
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
`;
|
||
|
||
// MINOR 1: an UNTERMINATED fence (opened, never closed, so it runs to the
|
||
// end of this test's own block) must not swallow the real `result:` line
|
||
// that follows it into a false parse_gap. Pre-fix: `stripFencedCode`
|
||
// drops everything from the opener to EOF (including `result: pending`),
|
||
// the field-line scan finds nothing, and the row is silently downgraded to
|
||
// a `headingsSeen`-only parse gap instead of a surfaced item.
|
||
test("an unterminated fence does not swallow a real result: line", () => {
|
||
writeUat(`${FRONTMATTER}## Tests
|
||
|
||
### 1. Broken Fence
|
||
expected: sample
|
||
\`\`\`
|
||
sample code
|
||
result: pending
|
||
`);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.summary.total_items, 1, `expected 1 item, got ${JSON.stringify(output)}`);
|
||
assert.strictEqual(output.results[0].items[0].test, 1);
|
||
assert.strictEqual(output.results[0].items[0].name, "Broken Fence");
|
||
assert.strictEqual(output.results[0].items[0].result, "pending");
|
||
// #3078 DEFECT D amends this expectation: the ROW is still surfaced (that
|
||
// is this test's original point — the unterminated fence must not swallow
|
||
// the real `result:` line), but the FILE is now additionally flagged as a
|
||
// parse gap. An unterminated fence swallows everything after it for every
|
||
// downstream markdown consumer — later `### N.` rows and any trailing
|
||
// `## Gaps` section alike — so "some rows parsed" is not evidence the file
|
||
// was read completely. `uat-predicate.cts` already refuses such a file
|
||
// (src/uat-predicate.cts:278-281); the audit now agrees rather than
|
||
// reporting a partially-read file as clean.
|
||
assert.strictEqual(output.results[0].parse_gap, true, JSON.stringify(output.results[0]));
|
||
assert.ok(output.results[0].unparsed_blocks >= 1);
|
||
});
|
||
|
||
// MINOR 2: `### 1.2.3 Rollback` is a decimal-numbered level-3 sub-heading,
|
||
// not a `### N.` test heading — the widened `^\d+\.` filter (without the
|
||
// negative lookahead) matched its leading `1.` and parsed it as test 1
|
||
// named "2.3 Rollback", a phantom row. Requiring `^\d+\.(?!\d)` excludes
|
||
// it: the "1." is followed by another digit, so it is not a bare
|
||
// integer-dot heading at all.
|
||
test("a decimal-numbered sub-heading like `### 1.2.3 Rollback` is not parsed as a test row", () => {
|
||
writeUat(`${FRONTMATTER}## Tests
|
||
|
||
### 1.2.3 Rollback
|
||
result: pending
|
||
`);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.summary.total_items, 0, `expected 0 items, got ${JSON.stringify(output)}`);
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.strictEqual(entry, undefined, `expected no results entry, got ${JSON.stringify(entry)}`);
|
||
});
|
||
|
||
// Regression guard: `### 3.` (no name) and `### 3.Foo` (no space) must
|
||
// still be recognised as test headings after the `(?!\d)` tightening —
|
||
// neither has a digit immediately after the `N.`, so the lookahead does
|
||
// not exclude them.
|
||
test("`### 3.` and `### 3.Foo` are still parsed as test rows", () => {
|
||
writeUat(`${FRONTMATTER}## Tests
|
||
|
||
### 3.
|
||
expected: x
|
||
result: pending
|
||
|
||
### 3.Foo
|
||
expected: y
|
||
result: blocked
|
||
`);
|
||
const output = runAudit();
|
||
assert.strictEqual(output.summary.total_items, 2, `expected 2 items, got ${JSON.stringify(output)}`);
|
||
});
|
||
});
|
||
|
||
// ─── #3078 security review: fence/scalar boundary defects ─────────────────────
|
||
//
|
||
// Four correctness defects in `parseUatItemsWithStats` (src/uat.cts), all of
|
||
// which end the same way: a genuinely outstanding UAT row disappears with NO
|
||
// trace and the file reports CLEAN.
|
||
//
|
||
// Every assertion below checks the row's IDENTITY (test number AND name) and
|
||
// not merely the item COUNT plus a result token — a phantom row that STEALS a
|
||
// real row's `result:` satisfies count-and-token assertions exactly, which is
|
||
// how blocker B was previously (wrongly) cleared.
|
||
describe("#3078: fenced/scalar boundaries must never silently drop a UAT row", () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
function writeUat(content, phaseDirName = "01-foundation", fileName = "01-UAT.md") {
|
||
const phaseDir = path.join(tmpDir, ".planning", "phases", phaseDirName);
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, fileName), content);
|
||
}
|
||
|
||
function runAudit() {
|
||
const result = runGsdTools("audit-uat --raw", tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
return JSON.parse(result.output);
|
||
}
|
||
|
||
const FRONTMATTER = `---
|
||
status: testing
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
`;
|
||
|
||
const FENCE = "```";
|
||
|
||
// Renders the FULL item list so a misattribution (a row publishing another
|
||
// row's field) is visible in the failure message rather than hidden behind a
|
||
// bare count mismatch.
|
||
function describeItems(entry) {
|
||
return JSON.stringify(entry ? entry.items : null, null, 2);
|
||
}
|
||
|
||
// BLOCKER A. `tokenizeHeadings` is fence-aware, so a BALANCED fence pair
|
||
// opened after test 1 and closed before test 3 makes `### 2.` invisible AS A
|
||
// HEADING — it never enters the token stream, so the per-block loop cannot
|
||
// count it either. Pre-fix: items for 1 and 3 only, headingsSeen 0, no
|
||
// parse_gap, no unparsed_blocks — test 2's `result: blocked` vanishes with
|
||
// ZERO trace, which origin/next's whole-file regex DID surface (a
|
||
// regression, not merely a gap).
|
||
test("a balanced fence straddling `### 2.` flags the file instead of silently dropping the row", () => {
|
||
writeUat(`${FRONTMATTER}## Tests
|
||
|
||
### 1. Alpha
|
||
result: pending
|
||
|
||
${FENCE}
|
||
### 2. Beta
|
||
result: blocked
|
||
${FENCE}
|
||
|
||
### 3. Gamma
|
||
result: pending
|
||
`);
|
||
const output = runAudit();
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.ok(entry, `expected a results entry, got ${JSON.stringify(output.results)}`);
|
||
|
||
// The suppressed row is accounted for, not silently absent.
|
||
assert.strictEqual(entry.parse_gap, true, `expected parse_gap, items were ${describeItems(entry)}`);
|
||
assert.ok(
|
||
entry.unparsed_blocks >= 1,
|
||
`expected unparsed_blocks >= 1, got ${entry.unparsed_blocks}; items ${describeItems(entry)}`,
|
||
);
|
||
assert.strictEqual(output.summary.parse_gap_files, 1);
|
||
|
||
// The two VISIBLE rows keep their own identities.
|
||
const byNumber = new Map(entry.items.filter((i) => i.test !== undefined).map((i) => [i.test, i]));
|
||
assert.strictEqual(byNumber.get(1).name, "Alpha", describeItems(entry));
|
||
assert.strictEqual(byNumber.get(1).result, "pending", describeItems(entry));
|
||
assert.strictEqual(byNumber.get(3).name, "Gamma", describeItems(entry));
|
||
});
|
||
|
||
// BLOCKER B. A `### N.` line indented 2 spaces inside an `expected: |`
|
||
// block scalar IS a valid ATX heading to markdown (<= 3 leading spaces), so
|
||
// the tokenizer emitted a PHANTOM row that consumed the REAL row's
|
||
// `result:` line. Pre-fix this file yielded exactly
|
||
// `{"test":3,"name":"Fake Row","result":"pending"}` with headingsSeen 0 —
|
||
// one item, result "pending", so a count-and-token assertion passed while
|
||
// test 1 had disappeared entirely. Hence the identity assertions here.
|
||
test("a `### 3.` indented inside an `expected: |` scalar does not steal the real row", () => {
|
||
writeUat(`${FRONTMATTER}## Tests
|
||
|
||
### 1. Alpha
|
||
expected: |
|
||
### 3. Fake Row
|
||
result: pending
|
||
`);
|
||
const output = runAudit();
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.ok(entry, `expected a results entry, got ${JSON.stringify(output.results)}`);
|
||
|
||
assert.strictEqual(entry.items.length, 1, describeItems(entry));
|
||
assert.strictEqual(entry.items[0].test, 1, describeItems(entry));
|
||
assert.strictEqual(entry.items[0].name, "Alpha", describeItems(entry));
|
||
assert.strictEqual(entry.items[0].result, "pending", describeItems(entry));
|
||
// The scalar's contents are the row's VALUE, read verbatim.
|
||
assert.strictEqual(entry.items[0].expected, "### 3. Fake Row", describeItems(entry));
|
||
|
||
// No phantom row numbered 3 anywhere.
|
||
assert.deepStrictEqual(
|
||
entry.items.filter((i) => i.test === 3),
|
||
[],
|
||
`phantom row 3 present: ${describeItems(entry)}`,
|
||
);
|
||
// Scalar body is VALUE text, never a suppressed heading, so it must not
|
||
// inflate the parse-gap tally either.
|
||
assert.strictEqual(entry.unparsed_blocks, undefined, describeItems(entry));
|
||
assert.strictEqual(entry.parse_gap, undefined, describeItems(entry));
|
||
assert.strictEqual(output.summary.parse_gap_files, 0);
|
||
});
|
||
|
||
// BLOCKER C (same root as B, opposite direction). `parseExpectedFromTestBlock`
|
||
// received the RAW slice while the field scans used a fence-STRIPPED copy, so
|
||
// row 1's raw block ran straight into fence-hidden row 2 and published row
|
||
// 2's `expected:` as its own. Pre-fix: row 1 carried
|
||
// `expected: "SECRET-FROM-ROW-2"` and row 2 was silently absent.
|
||
test("a fence-hidden later row's `expected:` is not published by the preceding row", () => {
|
||
writeUat(`${FRONTMATTER}## Tests
|
||
|
||
### 1. Alpha
|
||
result: pending
|
||
${FENCE}
|
||
### 2. Beta
|
||
expected: SECRET-FROM-ROW-2
|
||
result: blocked
|
||
${FENCE}
|
||
`);
|
||
const output = runAudit();
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.ok(entry, `expected a results entry, got ${JSON.stringify(output.results)}`);
|
||
|
||
const row1 = entry.items.find((i) => i.test === 1);
|
||
assert.ok(row1, `row 1 missing: ${describeItems(entry)}`);
|
||
assert.strictEqual(row1.name, "Alpha", describeItems(entry));
|
||
assert.notStrictEqual(row1.expected, "SECRET-FROM-ROW-2", `row 1 stole row 2's expected: ${describeItems(entry)}`);
|
||
assert.strictEqual(row1.expected, undefined, describeItems(entry));
|
||
|
||
// Row 2 is either surfaced or counted — never silently absent.
|
||
const row2 = entry.items.find((i) => i.test === 2);
|
||
if (!row2) {
|
||
assert.strictEqual(entry.parse_gap, true, `row 2 dropped with no parse gap: ${describeItems(entry)}`);
|
||
assert.ok(entry.unparsed_blocks >= 1, `unparsed_blocks was ${entry.unparsed_blocks}`);
|
||
} else {
|
||
assert.strictEqual(row2.name, "Beta", describeItems(entry));
|
||
}
|
||
});
|
||
|
||
// DEFECT D. An unterminated fence swallows the remainder of the document —
|
||
// every later row AND a trailing `## Gaps` section — so pre-fix the file
|
||
// yielded `{items: [], headingsSeen: 0}`, never entered `results` at all, and
|
||
// `parse_gap_files` stayed 0: a WHOLE-FILE false clean. `uat-predicate.cts`
|
||
// already refuses such a file via `analyzeMarkdown(raw).unterminatedFence`
|
||
// (src/uat-predicate.cts:278-281); the audit must agree.
|
||
test("an unterminated fence makes the file a parse gap, not a clean file", () => {
|
||
writeUat(`${FRONTMATTER}## Tests
|
||
|
||
### 1. Alpha
|
||
result: pass
|
||
|
||
${FENCE}
|
||
oops, never closed
|
||
|
||
## Gaps
|
||
|
||
- truth: "the export silently truncates"
|
||
status: open
|
||
`);
|
||
const output = runAudit();
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.ok(entry, `unterminated-fence file vanished entirely: ${JSON.stringify(output)}`);
|
||
assert.strictEqual(entry.parse_gap, true, describeItems(entry));
|
||
assert.ok(entry.unparsed_blocks >= 1, `unparsed_blocks was ${entry.unparsed_blocks}`);
|
||
assert.strictEqual(output.summary.parse_gap_files, 1, JSON.stringify(output.summary));
|
||
});
|
||
|
||
// DEFECT D, scalar variant. When the unterminated fence opens INSIDE an
|
||
// `expected:` scalar the pre-fix parser reported `unparsed_blocks: 1` while
|
||
// TWO rows had been lost — an undercount that reads as "one minor gap".
|
||
// The unterminated-fence signal is deliberately measured on the RAW document
|
||
// (a masked copy would hide the opener), so this file is flagged AND both
|
||
// rows keep their identities.
|
||
test("an unterminated fence inside an `expected:` scalar still flags the file", () => {
|
||
writeUat(`${FRONTMATTER}## Tests
|
||
|
||
### 1. Alpha
|
||
expected: |
|
||
${FENCE}
|
||
sample
|
||
result: pending
|
||
|
||
### 2. Beta
|
||
result: blocked
|
||
`);
|
||
const output = runAudit();
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.ok(entry, `expected a results entry, got ${JSON.stringify(output.results)}`);
|
||
assert.strictEqual(entry.parse_gap, true, describeItems(entry));
|
||
assert.ok(entry.unparsed_blocks >= 1, `unparsed_blocks was ${entry.unparsed_blocks}`);
|
||
assert.strictEqual(output.summary.parse_gap_files, 1);
|
||
|
||
const byNumber = new Map(entry.items.filter((i) => i.test !== undefined).map((i) => [i.test, i]));
|
||
assert.strictEqual(byNumber.get(1).name, "Alpha", describeItems(entry));
|
||
assert.strictEqual(byNumber.get(1).result, "pending", describeItems(entry));
|
||
assert.strictEqual(byNumber.get(2).name, "Beta", describeItems(entry));
|
||
assert.strictEqual(byNumber.get(2).result, "blocked", describeItems(entry));
|
||
});
|
||
|
||
// Regression guard for the fixes above: a LEGITIMATE `expected: |` scalar
|
||
// carrying indented prose AND a fenced code sample must still yield its own
|
||
// full value. This is the case that forbids either (a) fence-STRIPPING the
|
||
// block before reading `expected:`, or (b) clipping it at ANY fence opener —
|
||
// the clipper only recognises a COLUMN-0 fence precisely so a nested,
|
||
// necessarily-indented sample cannot truncate the field.
|
||
// Note the sample's own `result: pending` line must NOT become the row's
|
||
// outcome; the real `result: blocked` does.
|
||
test("a legitimate `expected: |` scalar with prose and a fenced sample is read in full", () => {
|
||
writeUat(`${FRONTMATTER}## Tests
|
||
|
||
### 1. Alpha
|
||
expected: |
|
||
The banner renders.
|
||
Then:
|
||
${FENCE}
|
||
result: pending
|
||
${FENCE}
|
||
Done.
|
||
result: blocked
|
||
|
||
### 2. Beta
|
||
expected: plain inline
|
||
result: pending
|
||
`);
|
||
const output = runAudit();
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.ok(entry, `expected a results entry, got ${JSON.stringify(output.results)}`);
|
||
assert.strictEqual(entry.parse_gap, undefined, describeItems(entry));
|
||
|
||
const byNumber = new Map(entry.items.filter((i) => i.test !== undefined).map((i) => [i.test, i]));
|
||
assert.strictEqual(byNumber.get(1).name, "Alpha", describeItems(entry));
|
||
assert.strictEqual(byNumber.get(1).result, "blocked", describeItems(entry));
|
||
assert.strictEqual(
|
||
byNumber.get(1).expected,
|
||
["The banner renders.", "Then:", FENCE, "result: pending", FENCE, "Done."].join("\n"),
|
||
describeItems(entry),
|
||
);
|
||
assert.strictEqual(byNumber.get(2).name, "Beta", describeItems(entry));
|
||
assert.strictEqual(byNumber.get(2).expected, "plain inline", describeItems(entry));
|
||
});
|
||
|
||
// Found while proving the fixes above (#3078): `parseExpectedFromTestBlock`'s
|
||
// block-scalar opener demanded a BARE `\n` after the `|`, so on a CRLF
|
||
// document `expected: |\r\n` never matched it and control fell through to the
|
||
// INLINE arm, which captured the pipe character itself — the row published
|
||
// `expected: "|"` and the whole multi-line value was discarded silently.
|
||
test("a CRLF `expected: |` scalar is read as its value, not as the literal `|`", () => {
|
||
writeUat(
|
||
`${FRONTMATTER}## Tests\n\n### 1. Alpha\nexpected: |\n first line\n second line\nresult: pending\n`
|
||
.replace(/\n/g, "\r\n"),
|
||
);
|
||
const output = runAudit();
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.ok(entry, `expected a results entry, got ${JSON.stringify(output.results)}`);
|
||
assert.strictEqual(entry.items.length, 1, describeItems(entry));
|
||
assert.strictEqual(entry.items[0].test, 1, describeItems(entry));
|
||
assert.strictEqual(entry.items[0].name, "Alpha", describeItems(entry));
|
||
assert.strictEqual(entry.items[0].result, "pending", describeItems(entry));
|
||
assert.strictEqual(entry.items[0].expected, "first line\nsecond line", describeItems(entry));
|
||
});
|
||
|
||
// Sibling of the CRLF case: `|-` / `|+` chomping indicators. The READER's
|
||
// opener grammar must admit them — otherwise the field falls through to the
|
||
// inline arm and publishes the literal `"|-"` instead of the value.
|
||
test("an `expected: |-` chomped scalar is read as its value, not as the literal `|-`", () => {
|
||
writeUat(`${FRONTMATTER}## Tests
|
||
|
||
### 1. Alpha
|
||
expected: |-
|
||
chomped value
|
||
result: pending
|
||
`);
|
||
const output = runAudit();
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.ok(entry, `expected a results entry, got ${JSON.stringify(output.results)}`);
|
||
assert.strictEqual(entry.items[0].name, "Alpha", describeItems(entry));
|
||
assert.strictEqual(entry.items[0].expected, "chomped value", describeItems(entry));
|
||
});
|
||
|
||
// #3078 follow-up (security review): the reader's opener grammar admitted
|
||
// the chomping indicator (`|-`, `|+`) but not YAML's explicit INDENTATION
|
||
// indicator (`1`-`9`), which may appear before OR after the chomping
|
||
// indicator (`|2`, `|2-`, `|-2`, `>2`, `>2+`, ...). A body under an
|
||
// unrecognised opener was read as the literal opener string, and its
|
||
// `### N.`-shaped lines fed the row-theft class this row-identity assertion
|
||
// guards. Row IDENTITY (number AND name) is asserted for every variant,
|
||
// never just a count, per the finding.
|
||
for (const opener of ["|2", "|2-", "|-2", ">2"]) {
|
||
test(`an \`expected: ${opener}\` scalar with an explicit indentation indicator does not let its body steal row identity`, () => {
|
||
writeUat(`${FRONTMATTER}## Tests
|
||
|
||
### 1. Alpha
|
||
expected: ${opener}
|
||
### 2. Phantom
|
||
result: pending
|
||
result: blocked
|
||
`);
|
||
const output = runAudit();
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.ok(entry, `expected a results entry, got ${JSON.stringify(output.results)}`);
|
||
|
||
assert.strictEqual(entry.items.length, 1, describeItems(entry));
|
||
assert.strictEqual(entry.items[0].test, 1, describeItems(entry));
|
||
assert.strictEqual(entry.items[0].name, "Alpha", describeItems(entry));
|
||
assert.strictEqual(entry.items[0].result, "blocked", describeItems(entry));
|
||
|
||
// No phantom row numbered 2, and specifically none named "Phantom".
|
||
assert.deepStrictEqual(
|
||
entry.items.filter((i) => i.test === 2),
|
||
[],
|
||
`phantom row 2 present: ${describeItems(entry)}`,
|
||
);
|
||
assert.ok(
|
||
!entry.items.some((i) => i.name === "Phantom"),
|
||
`a row named Phantom was surfaced: ${describeItems(entry)}`,
|
||
);
|
||
});
|
||
}
|
||
|
||
// Regression guard: the plain (no indentation indicator) openers this
|
||
// module already handled must behave exactly as before the fix above.
|
||
for (const opener of ["|", "|-", "|+", ">"]) {
|
||
test(`regression: an \`expected: ${opener}\` scalar (no indentation indicator) keeps its body inert`, () => {
|
||
writeUat(`${FRONTMATTER}## Tests
|
||
|
||
### 1. Alpha
|
||
expected: ${opener}
|
||
### 2. Phantom
|
||
result: pending
|
||
result: blocked
|
||
`);
|
||
const output = runAudit();
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.ok(entry, `expected a results entry, got ${JSON.stringify(output.results)}`);
|
||
|
||
assert.strictEqual(entry.items.length, 1, describeItems(entry));
|
||
assert.strictEqual(entry.items[0].test, 1, describeItems(entry));
|
||
assert.strictEqual(entry.items[0].name, "Alpha", describeItems(entry));
|
||
assert.strictEqual(entry.items[0].result, "blocked", describeItems(entry));
|
||
assert.deepStrictEqual(
|
||
entry.items.filter((i) => i.test === 2),
|
||
[],
|
||
`phantom row 2 present: ${describeItems(entry)}`,
|
||
);
|
||
});
|
||
}
|
||
|
||
// #3078 follow-up: the `>` FOLDED-scalar family hit the same
|
||
// silent-field-loss class already fixed twice in this file for `|` — the
|
||
// opener grammar only ever matched `|`, so `expected: >` fell through to
|
||
// the INLINE arm and published the literal `">"` as the value. Assert the
|
||
// EXACT extracted value (not merely non-empty) for every reproduced
|
||
// opener, in both LF and CRLF form.
|
||
for (const [opener, crlf] of [
|
||
["|", false], ["|-", false], ["|+", false], ["|2", false], ["|2-", false], ["|-2", false],
|
||
[">", false], [">-", false], [">+", false], [">2", false], [">2+", false],
|
||
["|", true], [">", true],
|
||
]) {
|
||
test(`\`expected: ${opener}\`${crlf ? " (CRLF)" : ""} extracts the exact value, not the literal opener`, () => {
|
||
let doc = `${FRONTMATTER}## Tests
|
||
|
||
### 1. Alpha
|
||
expected: ${opener}
|
||
first line
|
||
second line
|
||
result: pending
|
||
`;
|
||
if (crlf) doc = doc.replace(/\n/g, "\r\n");
|
||
writeUat(doc);
|
||
const output = runAudit();
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.ok(entry, `expected a results entry, got ${JSON.stringify(output.results)}`);
|
||
assert.strictEqual(entry.items.length, 1, describeItems(entry));
|
||
assert.strictEqual(entry.items[0].test, 1, describeItems(entry));
|
||
assert.strictEqual(entry.items[0].name, "Alpha", describeItems(entry));
|
||
assert.notStrictEqual(entry.items[0].expected, opener, describeItems(entry));
|
||
|
||
const expectedValue = opener.startsWith(">")
|
||
? "first line second line"
|
||
: "first line\nsecond line";
|
||
assert.strictEqual(entry.items[0].expected, expectedValue, describeItems(entry));
|
||
});
|
||
}
|
||
|
||
// #3078 follow-up: `>` folding — a blank line in the body becomes a literal
|
||
// `\n` in the folded output, per YAML's fold semantics (distinct from `|`,
|
||
// which preserves every newline).
|
||
test("an `expected: >` scalar folds a blank line in its body to a newline", () => {
|
||
writeUat(`${FRONTMATTER}## Tests
|
||
|
||
### 1. Alpha
|
||
expected: >
|
||
first paragraph
|
||
still first
|
||
|
||
second paragraph
|
||
result: pending
|
||
`);
|
||
const output = runAudit();
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.ok(entry, `expected a results entry, got ${JSON.stringify(output.results)}`);
|
||
assert.strictEqual(
|
||
entry.items[0].expected,
|
||
"first paragraph still first\nsecond paragraph",
|
||
describeItems(entry),
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── #3078 follow-up: parseFirstPendingTest shares the same exposure ──────────
|
||
//
|
||
// `parseFirstPendingTest` (src/uat.cts) feeds `cmdRenderCheckpoint` — the same
|
||
// tokenizeHeadings-on-raw + raw-block `parseExpectedFromTestBlock` shape as
|
||
// `parseUatItemsWithStats`, on the render-checkpoint path a human is shown and
|
||
// answers. A phantom row here means the user is asked to confirm a test that
|
||
// does not exist, or the real pending test is skipped.
|
||
describe("#3078 follow-up: parseFirstPendingTest must not steal rows on the render-checkpoint path", () => {
|
||
let tmpDir;
|
||
let uatPath;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
const phaseDir = path.join(tmpDir, ".planning", "phases", "01-test-phase");
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
uatPath = path.join(phaseDir, "01-UAT.md");
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
const FENCE = "```";
|
||
const FRONTMATTER = `---
|
||
status: partial
|
||
phase: 01-test-phase
|
||
---
|
||
|
||
## Current Test
|
||
|
||
[testing paused — 1 item outstanding]
|
||
|
||
`;
|
||
|
||
function renderCheckpoint() {
|
||
const result = runGsdTools(["uat", "render-checkpoint", "--file", ".planning/phases/01-test-phase/01-UAT.md"], tmpDir);
|
||
assert.strictEqual(result.success, true, `render-checkpoint failed: ${result.error}`);
|
||
return JSON.parse(result.output);
|
||
}
|
||
|
||
// BLOCKER B, render-checkpoint surface. A `### 3.` line indented inside an
|
||
// `expected: |` scalar is a valid ATX heading to markdown, so the tokenizer
|
||
// emits a PHANTOM row that steals the real row's `result:` line and the
|
||
// checkpoint would resume/confirm test 3 "Fake Row" instead of test 1
|
||
// "Alpha" — which never disappeared, it just never got a chance to render.
|
||
test("a `### 3.` indented inside an `expected: |` scalar does not become the rendered checkpoint", () => {
|
||
fs.writeFileSync(uatPath, `${FRONTMATTER}## Tests
|
||
|
||
### 1. Alpha
|
||
expected: |
|
||
### 3. Fake Row
|
||
result: pending
|
||
`);
|
||
const output = renderCheckpoint();
|
||
// Row identity: the checkpoint is for test 1 "Alpha" — never test 3 /
|
||
// "Fake Row" as the subject under test. `### 3. Fake Row` legitimately
|
||
// appears as row 1's own `expected:` scalar body, so its presence in the
|
||
// checkpoint text is correct and is NOT asserted against here.
|
||
assert.strictEqual(output.test_number, 1, JSON.stringify(output));
|
||
assert.strictEqual(output.test_name, "Alpha", JSON.stringify(output));
|
||
assert.ok(
|
||
output.checkpoint.includes("**Test 1: Alpha**"),
|
||
`checkpoint subject header missing/wrong: ${output.checkpoint}`,
|
||
);
|
||
assert.ok(
|
||
!/\*\*Test 3: Fake Row\*\*/.test(output.checkpoint),
|
||
`checkpoint rendered test 3 "Fake Row" as the subject: ${output.checkpoint}`,
|
||
);
|
||
});
|
||
|
||
// BLOCKER C, render-checkpoint surface. `parseExpectedFromTestBlock` reading
|
||
// the RAW block let row 1 run into fence-hidden row 2 and publish row 2's
|
||
// `expected:` as its own in the rendered checkpoint.
|
||
test("a fence-hidden later row's `expected:` is not published in the rendered checkpoint", () => {
|
||
// Row 1 carries its OWN `expected:`. Row 2's `expected:` lives inside a
|
||
// fence and must never leak into row 1's rendered checkpoint.
|
||
fs.writeFileSync(uatPath, `${FRONTMATTER}## Tests
|
||
|
||
### 1. Alpha
|
||
expected: ALPHA-OWN-VALUE
|
||
result: pending
|
||
${FENCE}
|
||
### 2. Beta
|
||
expected: SECRET-FROM-ROW-2
|
||
result: blocked
|
||
${FENCE}
|
||
`);
|
||
const output = renderCheckpoint();
|
||
assert.strictEqual(output.test_number, 1, JSON.stringify(output));
|
||
assert.strictEqual(output.test_name, "Alpha", JSON.stringify(output));
|
||
assert.ok(output.checkpoint.includes("ALPHA-OWN-VALUE"), `row 1's own expected missing: ${output.checkpoint}`);
|
||
assert.ok(!/SECRET-FROM-ROW-2/.test(output.checkpoint), `row 1 stole row 2's expected: ${output.checkpoint}`);
|
||
});
|
||
|
||
// Honest error path: when row 1 has NO `expected:` of its own and the only
|
||
// reachable `expected:` line lives inside a fence (originally row 2's),
|
||
// render-checkpoint must fail cleanly rather than leak the hidden row's text.
|
||
test("a row whose only reachable expected is fence-hidden fails cleanly instead of leaking it", () => {
|
||
fs.writeFileSync(uatPath, `${FRONTMATTER}## Tests
|
||
|
||
### 1. Alpha
|
||
result: pending
|
||
${FENCE}
|
||
### 2. Beta
|
||
expected: SECRET-FROM-ROW-2
|
||
result: pending
|
||
${FENCE}
|
||
`);
|
||
const result = runGsdTools(["uat", "render-checkpoint", "--file", ".planning/phases/01-test-phase/01-UAT.md"], tmpDir);
|
||
assert.strictEqual(result.success, false, `expected failure, got: ${result.output}`);
|
||
assert.ok(/missing an expected field/.test(result.error), `unexpected error: ${result.error}`);
|
||
assert.ok(!/SECRET-FROM-ROW-2/.test(result.error), `hidden row leaked into error: ${result.error}`);
|
||
});
|
||
|
||
// Regression guard: a LEGITIMATE `expected: |` scalar carrying indented
|
||
// prose and a fenced code sample must still render its own full value.
|
||
test("a legitimate `expected: |` scalar with prose and a fenced sample renders in full", () => {
|
||
fs.writeFileSync(uatPath, `${FRONTMATTER}## Tests
|
||
|
||
### 1. Alpha
|
||
expected: |
|
||
The banner renders.
|
||
Then:
|
||
${FENCE}
|
||
result: pending
|
||
${FENCE}
|
||
Done.
|
||
result: pending
|
||
`);
|
||
const output = renderCheckpoint();
|
||
assert.strictEqual(output.test_number, 1, JSON.stringify(output));
|
||
assert.strictEqual(output.test_name, "Alpha", JSON.stringify(output));
|
||
assert.ok(output.checkpoint.includes("The banner renders."), output.checkpoint);
|
||
assert.ok(output.checkpoint.includes(`${FENCE}\nresult: pending\n${FENCE}`), output.checkpoint);
|
||
assert.ok(output.checkpoint.includes("Done."), output.checkpoint);
|
||
});
|
||
|
||
// #3078 follow-up (security review), render-checkpoint surface: an
|
||
// `expected: |2` scalar (explicit indentation indicator) must mask its body
|
||
// on this path too — otherwise the rendered checkpoint would confirm a
|
||
// phantom test 2 "Phantom" instead of the real, pending test 1 "Alpha".
|
||
test("an `expected: |2` scalar with an explicit indentation indicator does not become the rendered checkpoint", () => {
|
||
fs.writeFileSync(uatPath, `${FRONTMATTER}## Tests
|
||
|
||
### 1. Alpha
|
||
expected: |2
|
||
### 2. Phantom
|
||
result: pending
|
||
result: pending
|
||
`);
|
||
const output = renderCheckpoint();
|
||
assert.strictEqual(output.test_number, 1, JSON.stringify(output));
|
||
assert.strictEqual(output.test_name, "Alpha", JSON.stringify(output));
|
||
assert.ok(
|
||
output.checkpoint.includes("**Test 1: Alpha**"),
|
||
`checkpoint subject header missing/wrong: ${output.checkpoint}`,
|
||
);
|
||
assert.ok(
|
||
!/\*\*Test 2: Phantom\*\*/.test(output.checkpoint),
|
||
`checkpoint rendered a phantom test 2 "Phantom": ${output.checkpoint}`,
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── #3078 review follow-up: astral (surrogate-pair) characters in a row name ──
|
||
//
|
||
// A test NAME carrying emoji plus an `expected: |` scalar body containing a
|
||
// `### 3.` line and a `result:` line once published a phantom row 3 and stole
|
||
// the real row's fields: the scalar masker measured offsets in UTF-16 units but
|
||
// spliced into a CODE POINT array, so every astral character earlier in the
|
||
// document shifted a later mask write one slot right. The masker is gone — the
|
||
// `### 3.` line is inert because it is INDENTED, and no character-splicing
|
||
// happens anywhere — so this class is now structurally unreachable. These
|
||
// fixtures stay as the behavioural pin.
|
||
describe("#3078 review: an astral (emoji) row name never yields a phantom row", () => {
|
||
function fixtureWithEmoji(emojiCount) {
|
||
const emoji = "\u{1F600}".repeat(emojiCount);
|
||
return `## Tests
|
||
|
||
### 1. Row ${emoji}
|
||
expected: |
|
||
### 3. Phantom
|
||
result: blocked
|
||
`;
|
||
}
|
||
|
||
for (const emojiCount of [1, 3, 6, 10]) {
|
||
test(`a name with ${emojiCount} emoji keeps row 1 intact with no phantom row 3`, () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(fixtureWithEmoji(emojiCount));
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.strictEqual(items[0].test, 1, describeAll());
|
||
assert.strictEqual(items[0].name, `Row ${"\u{1F600}".repeat(emojiCount)}`, describeAll());
|
||
assert.strictEqual(items[0].result, "blocked", describeAll());
|
||
assert.strictEqual(items[0].expected, "### 3. Phantom", describeAll());
|
||
|
||
assert.deepStrictEqual(
|
||
items.filter((i) => i.test === 3),
|
||
[],
|
||
`phantom row 3 present: ${describeAll()}`,
|
||
);
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
}
|
||
|
||
// Same fixture through the render-checkpoint path (`parseFirstPendingTest`
|
||
// shares the column-0 heading rule) must resume test 1, never a phantom.
|
||
describe("render-checkpoint path", () => {
|
||
let tmpDir;
|
||
let uatPath;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
const phaseDir = path.join(tmpDir, ".planning", "phases", "01-test-phase");
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
uatPath = path.join(phaseDir, "01-UAT.md");
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
const FRONTMATTER = `---
|
||
status: partial
|
||
phase: 01-test-phase
|
||
---
|
||
|
||
## Current Test
|
||
|
||
[testing paused — 1 item outstanding]
|
||
|
||
`;
|
||
|
||
test("renders test 1 with its emoji name intact, not a phantom row 3", () => {
|
||
const emoji = "\u{1F600}\u{1F601}\u{1F602}";
|
||
fs.writeFileSync(
|
||
uatPath,
|
||
`${FRONTMATTER}## Tests
|
||
|
||
### 1. Row ${emoji}
|
||
expected: |
|
||
### 3. Phantom
|
||
result: pending
|
||
`,
|
||
);
|
||
const result = runGsdTools(
|
||
["uat", "render-checkpoint", "--file", ".planning/phases/01-test-phase/01-UAT.md"],
|
||
tmpDir,
|
||
);
|
||
assert.strictEqual(result.success, true, `render-checkpoint failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.test_number, 1, JSON.stringify(output));
|
||
assert.strictEqual(output.test_name, `Row ${emoji}`, JSON.stringify(output));
|
||
assert.ok(
|
||
output.checkpoint.includes(`**Test 1: Row ${emoji}**`),
|
||
`checkpoint subject header missing/wrong: ${output.checkpoint}`,
|
||
);
|
||
assert.ok(
|
||
!/\*\*Test 3: Phantom\*\*/.test(output.checkpoint),
|
||
`checkpoint rendered a phantom test 3 "Phantom": ${output.checkpoint}`,
|
||
);
|
||
});
|
||
});
|
||
|
||
});
|
||
|
||
// ─── #3078 round 7 HIGH: the shortfall scan is symmetric, and over-reports ────
|
||
//
|
||
// The `## Tests`-section scoping that used to keep a documentation sample quiet
|
||
// (review MINOR 1) is RETIRED. Scoping the raw line scan while the PARSE side
|
||
// stayed whole-document produced two separate HIGH-severity SILENT FALSE
|
||
// CLEANS (see the round-7 describe block at the end of this file). Both sides
|
||
// of the comparison are now whole-document, and the documented consequence —
|
||
// pinned here so it is visible rather than surprising — is that a closed-fence
|
||
// row-format sample in `## Notes` raises a parse gap on a file with nothing
|
||
// missing. That is an OVER-report: noisy, visible, fail-safe. It is the
|
||
// deliberate trade; do not "optimise" it back into a scope.
|
||
describe("#3078 round 7: a documented row-format sample inside a closed fence is an ACCEPTED over-report", () => {
|
||
test("a clean pending row plus a `## Notes` fence containing `### 9. Example row` raises a parse gap (accepted over-report, not a false clean)", () => {
|
||
const content = `## Tests
|
||
|
||
### 1. Alpha
|
||
result: pending
|
||
|
||
## Notes
|
||
|
||
\`\`\`
|
||
### 9. Example row
|
||
result: pending
|
||
\`\`\`
|
||
`;
|
||
const { items, headingsSeen } = parseUatItemsWithStats(content);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.strictEqual(items[0].test, 1, describeAll());
|
||
assert.strictEqual(items[0].name, "Alpha", describeAll());
|
||
// The `### 9.` sample line is heading-SHAPED at column 0 but hidden from
|
||
// the tokenizer by its fence, so it registers as a shortfall of exactly 1.
|
||
assert.strictEqual(headingsSeen, 1, describeAll());
|
||
});
|
||
|
||
// Regression guard: the fence-straddle BLOCKER this same counter exists to
|
||
// catch is ALSO a "properly closed" fence — closedness alone cannot
|
||
// distinguish documentation from a genuinely hidden row, so this must still
|
||
// flag. What differs is section: the straddle lives INSIDE `## Tests`.
|
||
test("a fence straddling two real rows INSIDE `## Tests` still flags a parse_gap", () => {
|
||
const FENCE = "```";
|
||
const content = `## Tests
|
||
|
||
### 1. Alpha
|
||
result: pending
|
||
|
||
${FENCE}
|
||
### 2. Beta
|
||
result: blocked
|
||
${FENCE}
|
||
|
||
### 3. Gamma
|
||
result: pending
|
||
`;
|
||
const { items, headingsSeen } = parseUatItemsWithStats(content);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
|
||
assert.ok(headingsSeen >= 1, `expected a flagged shortfall, got ${describeAll()}`);
|
||
const byNumber = new Map(items.filter((i) => i.test !== undefined).map((i) => [i.test, i]));
|
||
assert.strictEqual(byNumber.get(1).name, "Alpha", describeAll());
|
||
assert.strictEqual(byNumber.get(3).name, "Gamma", describeAll());
|
||
});
|
||
});
|
||
|
||
// ─── #3078 review follow-up: clipBlockAtFirstFence must not drop a late expected: ──
|
||
//
|
||
// MINOR 2: clipping was unconditional on field order, so an `expected:`
|
||
// appearing AFTER a fenced sample in the same block was discarded even when it
|
||
// sat entirely outside the fence. Fix distinguishes "expected after the fence
|
||
// closes" from "expected inside the fence" by tracking fence open/close state
|
||
// (on the scalar-masked copy) and reconstructing the block with every
|
||
// TOP-LEVEL fenced region dropped: an `expected:` that survives that
|
||
// reconstruction was outside every fence (i.e. after one closed); an
|
||
// `expected:` that never survives it was inside one and stays unreachable —
|
||
// the anti-theft property is unchanged.
|
||
describe("#3078 review MINOR 2: an expected: after a closed fence must survive; one inside a fence must not", () => {
|
||
const FENCE = "```";
|
||
|
||
test("an `expected:` after a closed fence is preserved", () => {
|
||
const content = `## Tests
|
||
|
||
### 1. Alpha
|
||
result: pending
|
||
${FENCE}
|
||
sample
|
||
${FENCE}
|
||
expected: THE REAL VALUE
|
||
`;
|
||
const { items } = parseUatItemsWithStats(content);
|
||
const describeAll = () => JSON.stringify(items, null, 2);
|
||
const row1 = items.find((i) => i.test === 1);
|
||
assert.ok(row1, describeAll());
|
||
assert.strictEqual(row1.expected, "THE REAL VALUE", describeAll());
|
||
});
|
||
|
||
test("an `expected:` living strictly inside a fence is still not stolen", () => {
|
||
const content = `## Tests
|
||
|
||
### 1. Alpha
|
||
result: pending
|
||
${FENCE}
|
||
expected: SECRET-INSIDE
|
||
${FENCE}
|
||
`;
|
||
const { items } = parseUatItemsWithStats(content);
|
||
const describeAll = () => JSON.stringify(items, null, 2);
|
||
const row1 = items.find((i) => i.test === 1);
|
||
assert.ok(row1, describeAll());
|
||
assert.notStrictEqual(row1.expected, "SECRET-INSIDE", `fence-hidden expected was stolen: ${describeAll()}`);
|
||
assert.strictEqual(row1.expected, undefined, describeAll());
|
||
});
|
||
});
|
||
|
||
// ─── #3078 follow-up: indented fence delimiter must not reach tokenizeHeadings ──
|
||
//
|
||
// Escalated design call, answered as option (b): dropping the scalar masker
|
||
// for the column-0 heading filter fixed the phantom-heading theft, but
|
||
// exposed a SECOND thing masking used to do — hide an indented fence
|
||
// delimiter from `tokenizeHeadings` itself. `tokenizeHeadings` is a
|
||
// CommonMark scanner with its own {0,3}-space fence tolerance, so a 2-space
|
||
// fence opener inside an `expected: |` scalar body still opens a real fence
|
||
// AS FAR AS THE TOKENIZER IS CONCERNED — the fence's matching closer (also
|
||
// indented, also inside a LATER row's own scalar body, so the pair reads as
|
||
// TERMINATED at the whole-document level) sits past `### 2. Beta`'s heading
|
||
// line, which is never returned as a token at all. This is a DIFFERENT
|
||
// failure mode from every other #3078 fixture above: it does not trip the
|
||
// document-wide `unterminatedFence` DEFECT-D signal (a real closer exists),
|
||
// so the row is not merely a counted parse-gap shortfall — pre-fix it is
|
||
// swallowed cleanly, with `parse_gap` never even set. Option (a) — asserting
|
||
// row 2 as "counted, not surfaced" — would have shipped exactly this
|
||
// silent-drop as permanent, intended behaviour; asserting row IDENTITY here
|
||
// (number AND name, not merely a count) is what forbids that.
|
||
describe("#3078 follow-up: an indented fence delimiter must not reach tokenizeHeadings", () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
const FRONTMATTER = `---
|
||
status: testing
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
`;
|
||
const FENCE = "```";
|
||
|
||
function writeUat(content, phaseDirName = "01-foundation", fileName = "01-UAT.md") {
|
||
const phaseDir = path.join(tmpDir, ".planning", "phases", phaseDirName);
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, fileName), content);
|
||
}
|
||
|
||
function runAudit() {
|
||
const result = runGsdTools("audit-uat --raw", tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
return JSON.parse(result.output);
|
||
}
|
||
|
||
test("an indented fence opener inside an `expected:` scalar, closed inside a LATER row's own scalar, still surfaces the row between them by identity", () => {
|
||
writeUat(`${FRONTMATTER}## Tests
|
||
|
||
### 1. Alpha
|
||
expected: |
|
||
${FENCE}
|
||
sample
|
||
result: pending
|
||
|
||
### 2. Beta
|
||
result: blocked
|
||
|
||
### 3. Gamma
|
||
expected: |
|
||
${FENCE}
|
||
result: pending
|
||
`);
|
||
const output = runAudit();
|
||
const entry = output.results.find((r) => r.file === "01-UAT.md");
|
||
assert.ok(entry, `expected a results entry, got ${JSON.stringify(output.results)}`);
|
||
assert.strictEqual(entry.parse_gap, undefined, JSON.stringify(entry, null, 2));
|
||
|
||
const byNumber = new Map(entry.items.filter((i) => i.test !== undefined).map((i) => [i.test, i]));
|
||
assert.strictEqual(byNumber.get(1).name, "Alpha", JSON.stringify(entry, null, 2));
|
||
assert.strictEqual(byNumber.get(2).name, "Beta", JSON.stringify(entry, null, 2));
|
||
assert.strictEqual(byNumber.get(2).result, "blocked", JSON.stringify(entry, null, 2));
|
||
assert.strictEqual(byNumber.get(3).name, "Gamma", JSON.stringify(entry, null, 2));
|
||
});
|
||
});
|
||
|
||
// ─── #3078 round 4 MAJOR 1: an indented delimiter may be a COLUMN-0 fence's closer ──
|
||
//
|
||
// `blankIndentedFenceDelimiters` blanked every indented delimiter LINE on
|
||
// sight, with no notion of open/closed state. CommonMark lets a column-0 fence
|
||
// be CLOSED by a delimiter indented up to three spaces, so the pass perturbed
|
||
// fence pairing in BOTH directions and swallowed a genuinely outstanding row:
|
||
// - column-0 opener + indented closer: the closer was blanked, the fence
|
||
// never closed for `tokenizeHeadings`, and the row after it vanished —
|
||
// `{items: [], headingsSeen: 1}` pre-fix, where the identical document with
|
||
// a column-0 closer returned the row;
|
||
// - indented opener + column-0 closer: the opener was blanked, PROMOTING the
|
||
// closer into an opener, same vanishing row, same `{items: [],
|
||
// headingsSeen: 1}`.
|
||
// Both documents are legal CommonMark that renders correctly, so the row's
|
||
// CONTENT — not merely a count — must survive. Every assertion below is on row
|
||
// IDENTITY (number AND name) plus its result/blocked_by, never on a count:
|
||
// pre-fix these files reported a parse-gap shortfall of 1, so a count-only
|
||
// assertion would have shipped the content loss as intended behaviour.
|
||
describe('#3078 round 4 MAJOR 1: an indented fence delimiter must not perturb column-0 fence pairing', () => {
|
||
const BACKTICK = '```';
|
||
const TILDE = '~~~';
|
||
const LONG_BACKTICK = '`````';
|
||
|
||
function documentWith(opener, closer) {
|
||
return `## Tests
|
||
|
||
### 1. Alpha
|
||
result: pass
|
||
|
||
${opener}
|
||
hidden sample text
|
||
${closer}
|
||
### 2. Outstanding Row
|
||
result: blocked
|
||
blocked_by: server
|
||
`;
|
||
}
|
||
|
||
function assertOutstandingRowSurfaced(content) {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(content);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
|
||
const row = items.find((i) => i.test === 2);
|
||
assert.ok(row, `outstanding row 2 absent: ${describeAll()}`);
|
||
assert.strictEqual(row.name, 'Outstanding Row', describeAll());
|
||
assert.strictEqual(row.result, 'blocked', describeAll());
|
||
assert.strictEqual(row.blocked_by, 'server', describeAll());
|
||
assert.strictEqual(row.category, 'server_blocked', describeAll());
|
||
return { items, headingsSeen, describeAll };
|
||
}
|
||
|
||
test('a column-0 opener closed by a 3-space-indented closer still surfaces the row after it', () => {
|
||
const { headingsSeen, describeAll } = assertOutstandingRowSurfaced(
|
||
documentWith(BACKTICK, ` ${BACKTICK}`),
|
||
);
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
|
||
test('the same document with a column-0 closer is unchanged (control)', () => {
|
||
const { headingsSeen, describeAll } = assertOutstandingRowSurfaced(
|
||
documentWith(BACKTICK, BACKTICK),
|
||
);
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
|
||
test('a 2-space-indented opener closed by a column-0 closer still surfaces the row after it', () => {
|
||
const { headingsSeen, describeAll } = assertOutstandingRowSurfaced(
|
||
documentWith(` ${BACKTICK}`, BACKTICK),
|
||
);
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
|
||
// Same two pairings on the OTHER delimiter character and at a run length
|
||
// longer than 3, so the fix cannot be a backtick-and-exactly-3 special case:
|
||
// the delimiter rules come from the shared `scanFencedBlocks` engine.
|
||
test('a column-0 `~~~` opener closed by a 2-space-indented `~~~` closer still surfaces the row after it', () => {
|
||
assertOutstandingRowSurfaced(documentWith(TILDE, ` ${TILDE}`));
|
||
});
|
||
|
||
test('a 3-space-indented `~~~` opener closed by a column-0 `~~~` closer still surfaces the row after it', () => {
|
||
assertOutstandingRowSurfaced(documentWith(` ${TILDE}`, TILDE));
|
||
});
|
||
|
||
test('a column-0 5-backtick opener closed by a 3-space-indented 5-backtick closer still surfaces the row after it', () => {
|
||
assertOutstandingRowSurfaced(documentWith(LONG_BACKTICK, ` ${LONG_BACKTICK}`));
|
||
});
|
||
|
||
test('a 2-space-indented opener closed by a column-0 LONGER-run closer still surfaces the row after it', () => {
|
||
assertOutstandingRowSurfaced(documentWith(` ${BACKTICK}`, LONG_BACKTICK));
|
||
});
|
||
|
||
// The case the helper EXISTS for must not regress: an indented fence PAIR
|
||
// living wholly inside an `expected: |` value is neutralised, so a later
|
||
// column-0 row is still tokenised — and the scalar itself is still published
|
||
// verbatim, fences and all.
|
||
test('an indented fence PAIR wholly inside an `expected: |` body still leaves a later row visible', () => {
|
||
const content = `## Tests
|
||
|
||
### 1. Alpha
|
||
expected: |
|
||
${BACKTICK}
|
||
sample
|
||
${BACKTICK}
|
||
result: pending
|
||
|
||
### 2. Beta
|
||
result: blocked
|
||
`;
|
||
const { items, headingsSeen } = parseUatItemsWithStats(content);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
|
||
const alpha = items.find((i) => i.test === 1);
|
||
const beta = items.find((i) => i.test === 2);
|
||
assert.ok(alpha, `row 1 absent: ${describeAll()}`);
|
||
assert.strictEqual(alpha.name, 'Alpha', describeAll());
|
||
assert.strictEqual(alpha.expected, `${BACKTICK}\nsample\n${BACKTICK}`, describeAll());
|
||
assert.ok(beta, `row 2 absent: ${describeAll()}`);
|
||
assert.strictEqual(beta.name, 'Beta', describeAll());
|
||
assert.strictEqual(beta.result, 'blocked', describeAll());
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
|
||
test('an indented `~~~` PAIR wholly inside an `expected: |` body still leaves a later row visible', () => {
|
||
const content = `## Tests
|
||
|
||
### 1. Alpha
|
||
expected: |
|
||
${TILDE}
|
||
sample
|
||
${TILDE}
|
||
result: pending
|
||
|
||
### 2. Beta
|
||
result: blocked
|
||
`;
|
||
const { items, headingsSeen } = parseUatItemsWithStats(content);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
|
||
const beta = items.find((i) => i.test === 2);
|
||
assert.ok(beta, `row 2 absent: ${describeAll()}`);
|
||
assert.strictEqual(beta.name, 'Beta', describeAll());
|
||
assert.strictEqual(beta.result, 'blocked', describeAll());
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
});
|
||
|
||
// ─── #3078 round 4 MAJOR 2: an indented row must be COUNTED, not dropped ──
|
||
//
|
||
// `isColumnZeroHeading` refusing to PARSE an indented `### N.` row is
|
||
// deliberate. But `TEST_HEADING_LINE_RE` — the shortfall scan that exists to
|
||
// count rows the parser could not read — inherited the same column-0 anchor,
|
||
// so a heading the parse gate rejected could never reach `headingsSeen`
|
||
// either. origin/next's unanchored `###\s*(\d+)\.` surfaced
|
||
// ` ### 1. Indented Row` with its `result: pending`; pre-fix this HEAD
|
||
// returned `{items: [], headingsSeen: 0}` — no item, no gap, no count, no
|
||
// trace. Refusing to parse is defensible; vanishing silently is the defect
|
||
// class this issue exists to close.
|
||
describe('#3078 round 4 MAJOR 2: an indented test row surfaces as a parse gap instead of vanishing', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
const FRONTMATTER = `---
|
||
status: testing
|
||
phase: 01-foundation
|
||
started: 2025-01-01T00:00:00Z
|
||
updated: 2025-01-01T00:00:00Z
|
||
---
|
||
|
||
`;
|
||
|
||
function writeUat(content) {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '01-UAT.md'), content);
|
||
}
|
||
|
||
test('a file whose only test row is indented yields no item but IS flagged as a parse gap', () => {
|
||
writeUat(`${FRONTMATTER}## Tests
|
||
|
||
### 1. Indented Row
|
||
result: pending
|
||
`);
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
const entry = output.results.find((r) => r.file === '01-UAT.md');
|
||
const describeAll = () => JSON.stringify(output, null, 2);
|
||
|
||
assert.ok(entry, `expected a results entry, got ${describeAll()}`);
|
||
assert.deepStrictEqual(entry.items, [], describeAll());
|
||
assert.strictEqual(entry.parse_gap, true, describeAll());
|
||
assert.ok(entry.unparsed_blocks >= 1, describeAll());
|
||
assert.strictEqual(output.summary.parse_gap_files, 1, describeAll());
|
||
});
|
||
|
||
test('the indented row is counted at the parser seam too', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Indented Row
|
||
result: pending
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
assert.deepStrictEqual(items, [], describeAll());
|
||
assert.strictEqual(headingsSeen, 1, describeAll());
|
||
});
|
||
|
||
// Boundary coverage on the counter's indent tolerance: 1, 2 and 3 spaces are
|
||
// heading-shaped and must be counted. 4+ spaces and a leading TAB are ALSO
|
||
// counted since #3078 round 7 — see the replacement test below for why the
|
||
// old "indented code block, not a row" carve-out was retired.
|
||
for (const spaces of [1, 2, 3]) {
|
||
test(`a row indented ${spaces} space(s) is counted as an unparsed block`, () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
${' '.repeat(spaces)}### 1. Indented Row
|
||
result: pending
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
assert.deepStrictEqual(items, [], describeAll());
|
||
assert.strictEqual(headingsSeen, 1, describeAll());
|
||
});
|
||
}
|
||
|
||
// REPLACES the retired 'a line indented 4 spaces is an indented code block,
|
||
// not a row, and is not counted' pin (#3078 round 7). That carve-out read
|
||
// the line as CommonMark would and therefore dropped it with no trace at
|
||
// all — items=[], headingsSeen=0 — which is exactly the vanishing-row class
|
||
// this counter exists to close, and which origin/next's unanchored
|
||
// `###\s*(\d+)\.` did surface. The counter now takes `^[ \t]+`, so a row
|
||
// indented 4+ spaces or with a leading TAB is still refused by the PARSE
|
||
// gate (`isColumnZeroHeading` is unchanged) but is visible as a parse gap.
|
||
test('a line indented 4 spaces is still refused by the parse gate but IS counted, not dropped silently', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
result: blocked
|
||
|
||
### 2. Code Block Line
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.strictEqual(items[0].name, 'Alpha', describeAll());
|
||
assert.strictEqual(headingsSeen, 1, describeAll());
|
||
});
|
||
|
||
// The counting loosening must NOT reach an indented `### N.` that is the
|
||
// VALUE of an `expected:` block scalar — that line is already published,
|
||
// verbatim, as the row's own `expected` field, so counting it would flag a
|
||
// parse gap against a document with nothing missing.
|
||
test('an indented `### N.` that is `expected:` scalar VALUE text is still not counted', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
expected: |
|
||
### 3. Fake Row
|
||
result: pending
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.strictEqual(items[0].test, 1, describeAll());
|
||
assert.strictEqual(items[0].name, 'Alpha', describeAll());
|
||
assert.strictEqual(items[0].expected, '### 3. Fake Row', describeAll());
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
|
||
// Same exclusion across the whole opener family the reader accepts — the
|
||
// line-level header grammar is derived from the reader's own source, so a
|
||
// new opener shape cannot be admitted by one and refused by the other.
|
||
for (const opener of ['|', '|-', '|+', '|2', '|2-', '|-2', '>', '>-', '>+', '>2', '>2+']) {
|
||
test(`an indented \`### N.\` under an \`expected: ${opener}\` scalar is not counted`, () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
expected: ${opener}
|
||
### 3. Fake Row
|
||
result: pending
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.strictEqual(items[0].name, 'Alpha', describeAll());
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
}
|
||
|
||
// An indented row following a CLOSED scalar body (the preceding column-0
|
||
// line is an ordinary field, not a scalar header) is a row, not value text.
|
||
test('an indented row after a completed row is still counted', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
result: pass
|
||
|
||
### 2. Indented Row
|
||
result: pending
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
assert.deepStrictEqual(items, [], describeAll());
|
||
assert.strictEqual(headingsSeen, 1, describeAll());
|
||
});
|
||
});
|
||
|
||
// ─── #3078 round 5: COLUMN 0 IS STRUCTURE, INDENTATION IS CONTENT (PINNED) ────
|
||
//
|
||
// THE RULE, stated once so it stops being re-litigated: a delimiter at COLUMN 0
|
||
// is document structure; anything indented is content. `blankIndentedFenceDelimiters`
|
||
// therefore blanks the DELIMITER LINES of a wholly-indented fenced block and
|
||
// NOTHING ELSE — never the body.
|
||
//
|
||
// The consequence, which LOOKS like a bug and is not: a column-0 `### N.` line
|
||
// sitting BETWEEN two indented delimiters genuinely IS a heading, and a
|
||
// `result:` line after it genuinely belongs to it. There is no fence for that
|
||
// line to be "inside" of, because by the very rule that neutralised the block,
|
||
// an indented delimiter is not a fence. The document below is malformed; the
|
||
// parser reading it this way is CONSISTENT, not thieving.
|
||
//
|
||
// This was once "fixed" by blanking the whole block open-to-close. That was
|
||
// REVERTED: it destroys content legitimately living between the delimiters, and
|
||
// on an UNTERMINATED indented opener it blanks to EOF, dropping every later row
|
||
// in the file. The tests below therefore PIN the consistent reading by ROW
|
||
// IDENTITY so the next person does not flip it back — if you are here because
|
||
// this "looks wrong", read `blankIndentedFenceDelimiters`'s comment first.
|
||
//
|
||
// Nothing vanishes silently either way: the row that loses its `result:` is
|
||
// counted in `headingsSeen`, i.e. it surfaces as a parse gap.
|
||
describe('#3078 round 5: column 0 is structure, so a column-0 heading between indented delimiters is a heading', () => {
|
||
const BACKTICK = '```';
|
||
const TILDE = '~~~';
|
||
const LONG_BACKTICK = '`````';
|
||
|
||
function documentWith(opener, closer) {
|
||
return `## Tests
|
||
|
||
### 1. Alpha
|
||
expected: x
|
||
${opener}
|
||
### 9. Phantom
|
||
${closer}
|
||
result: pending
|
||
`;
|
||
}
|
||
|
||
// Measured, not assumed: row 9 is real and owns the `result: pending` that
|
||
// follows it, row 1 has no `result:` of its own left and is reported as an
|
||
// unparsed block rather than dropped.
|
||
function assertColumnZeroHeadingWon(content) {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(content);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.strictEqual(items[0].test, 9, describeAll());
|
||
assert.strictEqual(items[0].name, 'Phantom', describeAll());
|
||
assert.strictEqual(items[0].result, 'pending', describeAll());
|
||
assert.strictEqual(items[0].category, 'pending', describeAll());
|
||
assert.strictEqual(items[0].expected, undefined, describeAll());
|
||
assert.strictEqual(
|
||
items.find((i) => i.test === 1),
|
||
undefined,
|
||
`row 1 lost its result: line to the column-0 heading and must not yield an item: ${describeAll()}`,
|
||
);
|
||
// The pin that keeps this from being a silent drop.
|
||
assert.strictEqual(headingsSeen, 1, describeAll());
|
||
return { items, headingsSeen, describeAll };
|
||
}
|
||
|
||
test('a column-0 `### 9.` inside a 2-space-indented backtick pair IS a heading', () => {
|
||
assertColumnZeroHeadingWon(documentWith(` ${BACKTICK}`, ` ${BACKTICK}`));
|
||
});
|
||
|
||
test('the same document with an indented `~~~` pair behaves identically', () => {
|
||
assertColumnZeroHeadingWon(documentWith(` ${TILDE}`, ` ${TILDE}`));
|
||
});
|
||
|
||
test('the same document with an indented 5-backtick pair behaves identically', () => {
|
||
assertColumnZeroHeadingWon(documentWith(` ${LONG_BACKTICK}`, ` ${LONG_BACKTICK}`));
|
||
});
|
||
|
||
// The UNTERMINATED indented opener — the case that makes whole-block blanking
|
||
// untenable, because its "body" is the whole rest of the file. Row 1 keeps its
|
||
// own `result:` (which precedes the opener) and the later column-0 row keeps
|
||
// ITS `result:`; blanking to EOF would have deleted both rows below the opener.
|
||
test('an unterminated indented opener neutralises only itself: both later rows survive', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
expected: x
|
||
result: pending
|
||
${BACKTICK}
|
||
### 9. Phantom
|
||
result: blocked
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
const alpha = items.find((i) => i.test === 1);
|
||
const phantom = items.find((i) => i.test === 9);
|
||
assert.ok(alpha, `row 1 "Alpha" absent: ${describeAll()}`);
|
||
assert.strictEqual(alpha.name, 'Alpha', describeAll());
|
||
assert.strictEqual(alpha.result, 'pending', describeAll());
|
||
assert.strictEqual(alpha.expected, 'x', describeAll());
|
||
assert.ok(phantom, `the column-0 row after the opener was swallowed: ${describeAll()}`);
|
||
assert.strictEqual(phantom.name, 'Phantom', describeAll());
|
||
assert.strictEqual(phantom.result, 'blocked', describeAll());
|
||
assert.strictEqual(items.length, 2, describeAll());
|
||
// The unterminated opener is still reported on the RAW document.
|
||
assert.strictEqual(headingsSeen, 1, describeAll());
|
||
});
|
||
|
||
// The case the helper EXISTS for, unchanged: blanking is done on a
|
||
// tokenizer-only COPY, so `parseExpectedFromTestBlock` still reads the RAW
|
||
// block and a legitimate `expected: |` scalar containing an indented fenced
|
||
// sample still publishes that sample verbatim.
|
||
test('a legitimate `expected: |` scalar with an indented fenced sample still yields its full text', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
expected: |
|
||
intro line
|
||
${BACKTICK}js
|
||
const x = 1;
|
||
${BACKTICK}
|
||
outro line
|
||
result: pending
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
const alpha = items.find((i) => i.test === 1);
|
||
assert.ok(alpha, `row 1 absent: ${describeAll()}`);
|
||
assert.strictEqual(alpha.name, 'Alpha', describeAll());
|
||
assert.strictEqual(
|
||
alpha.expected,
|
||
`intro line\n${BACKTICK}js\nconst x = 1;\n${BACKTICK}\noutro line`,
|
||
describeAll(),
|
||
);
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
});
|
||
|
||
// ─── #3078 round 5 MAJOR: ONE test-row grammar, shared by BOTH parse paths ────
|
||
//
|
||
// `parseFirstPendingTest` (render-checkpoint) filtered level-3 headings with
|
||
// `/^\d+\.\s+/` while `parseUatItemsWithStats` (audit) used `/^\d+\.(?!\d)/`.
|
||
// So `### 3.Foo` WAS a test row to the audit and was NOT one to the checkpoint:
|
||
// two paths in one module disagreeing about the same grammar. Both now go
|
||
// through `isTestRowHeadingText` / `parseTestRowHeadingText`, and the AUDIT rule
|
||
// won — it admits `### 3.` and `### 3.Foo` and excludes the dotted-outline
|
||
// `### 1.2.3`.
|
||
describe('#3078 round 5 MAJOR: the audit and render-checkpoint paths agree on what a test row is', () => {
|
||
let tmpDir;
|
||
let uatPath;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test-phase');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
uatPath = path.join(phaseDir, '01-UAT.md');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
// A NON-STRUCTURED `## Current Test` section is what routes `parseCurrentTest`
|
||
// into `parseFirstPendingTest` — the path under test here.
|
||
const FRONTMATTER = `---
|
||
status: partial
|
||
phase: 01-test-phase
|
||
---
|
||
|
||
## Current Test
|
||
|
||
[testing paused — 1 item outstanding]
|
||
|
||
`;
|
||
|
||
function documentFor(headingText) {
|
||
return `## Tests
|
||
|
||
### ${headingText}
|
||
expected: EXPECTED-VALUE
|
||
result: pending
|
||
`;
|
||
}
|
||
|
||
function renderCheckpoint(headingText) {
|
||
fs.writeFileSync(uatPath, `${FRONTMATTER}${documentFor(headingText)}`);
|
||
return runGsdTools(
|
||
['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md'],
|
||
tmpDir,
|
||
);
|
||
}
|
||
|
||
function assertBothPathsYieldRow(headingText, expectedNumber, expectedName) {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(documentFor(headingText));
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
assert.strictEqual(items.length, 1, `audit path: ${describeAll()}`);
|
||
assert.strictEqual(items[0].test, expectedNumber, `audit path: ${describeAll()}`);
|
||
assert.strictEqual(items[0].name, expectedName, `audit path: ${describeAll()}`);
|
||
|
||
const result = renderCheckpoint(headingText);
|
||
assert.strictEqual(
|
||
result.success,
|
||
true,
|
||
`render-checkpoint path did not treat "### ${headingText}" as a row: ${result.error}`,
|
||
);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.test_number, expectedNumber, JSON.stringify(output));
|
||
assert.strictEqual(output.test_name, expectedName, JSON.stringify(output));
|
||
}
|
||
|
||
function assertNeitherPathYieldsRow(headingText) {
|
||
const { items } = parseUatItemsWithStats(documentFor(headingText));
|
||
assert.deepStrictEqual(
|
||
items,
|
||
[],
|
||
`audit path parsed "### ${headingText}" as a row: ${JSON.stringify(items)}`,
|
||
);
|
||
|
||
const result = renderCheckpoint(headingText);
|
||
assert.strictEqual(
|
||
result.success,
|
||
false,
|
||
`render-checkpoint path parsed "### ${headingText}" as a row: ${result.output}`,
|
||
);
|
||
}
|
||
|
||
// `### 3.Foo` — the name squished against the dot. This is the shape that
|
||
// actually diverged: a row to the audit, silently NOT a row to the checkpoint.
|
||
test('`### 3.Foo` is a row with the same identity on BOTH paths', () => {
|
||
assertBothPathsYieldRow('3.Foo', 3, 'Foo');
|
||
});
|
||
|
||
// `### 3.` — no name at all; both paths fall back to the heading's own text.
|
||
test('`### 3.` is a row with the same identity on BOTH paths', () => {
|
||
assertBothPathsYieldRow('3.', 3, '3.');
|
||
});
|
||
|
||
// The control that keeps the shared rule from being "anything starting with a
|
||
// digit": a dotted OUTLINE number is a document heading, not test row 1.
|
||
test('`### 1.2.3` is excluded on BOTH paths', () => {
|
||
assertNeitherPathYieldsRow('1.2.3');
|
||
});
|
||
|
||
test('`### 1.2.3 Overview` is excluded on BOTH paths', () => {
|
||
assertNeitherPathYieldsRow('1.2.3 Overview');
|
||
});
|
||
|
||
// The conventional shape neither path ever disagreed about — proves the
|
||
// shared predicate did not narrow the grammar while closing the divergence.
|
||
test('`### 3. Foo` is a row with the same identity on BOTH paths (control)', () => {
|
||
assertBothPathsYieldRow('3. Foo', 3, 'Foo');
|
||
});
|
||
});
|
||
|
||
// ─── #3078 round 5 MINOR: parseUatItems is a documented, TESTED wrapper ───────
|
||
//
|
||
// `parseUatItems` has no in-tree caller left (`cmdAuditUat` and
|
||
// `src/planning-inspect.cts` both use `parseUatItemsWithStats`) but is a public
|
||
// export of a shipped module, so it stays — removing an exported symbol is a
|
||
// contract change. It must not therefore be untested dead weight: this pins the
|
||
// one thing it promises, that it is the items-only projection of the stats form.
|
||
describe('#3078 round 5 MINOR: parseUatItems is the items-only form of parseUatItemsWithStats', () => {
|
||
test('it returns exactly the `items` the stats form returns, and drops `headingsSeen`', () => {
|
||
// A document exercising both surfaces: a surfaced `### N.` row, a passing
|
||
// row that yields nothing, a `## Gaps` entry, and a heading with no
|
||
// `result:` line at all (which contributes to headingsSeen only).
|
||
const content = `## Tests
|
||
|
||
### 1. Alpha
|
||
expected: A
|
||
result: pending
|
||
|
||
### 2. Beta
|
||
result: pass
|
||
|
||
### 3. Gamma
|
||
expected: G
|
||
|
||
## Gaps
|
||
|
||
- truth: "an open finding"
|
||
status: open
|
||
`;
|
||
const stats = parseUatItemsWithStats(content);
|
||
const items = parseUatItems(content);
|
||
const describeAll = () => JSON.stringify({ items, stats }, null, 2);
|
||
|
||
assert.deepStrictEqual(items, stats.items, describeAll());
|
||
assert.strictEqual(
|
||
stats.headingsSeen,
|
||
1,
|
||
`row 3 has no result: line and should be the only unparsed block: ${describeAll()}`,
|
||
);
|
||
// Identity, not just count — the wrapper must not reorder or re-shape.
|
||
assert.strictEqual(items.length, 2, describeAll());
|
||
assert.strictEqual(items[0].test, 1, describeAll());
|
||
assert.strictEqual(items[0].name, 'Alpha', describeAll());
|
||
assert.strictEqual(items[0].result, 'pending', describeAll());
|
||
assert.strictEqual(items[1].name, 'an open finding', describeAll());
|
||
assert.strictEqual(items[1].result, 'open', describeAll());
|
||
});
|
||
});
|
||
|
||
// ─── #3078 round 5 MINOR: `result:` is lower-cased once, at extraction ────────
|
||
//
|
||
// The PASS-token check re-lowercased an already-lower-cased value. Removing the
|
||
// redundant call must leave mixed-case tokens behaving exactly as before: a
|
||
// mixed-case PASS is still suppressed, and a mixed-case non-pass token is still
|
||
// surfaced with a lower-cased `result` and a matching `category`.
|
||
describe('#3078 round 5 MINOR: mixed-case result tokens normalize at a single point', () => {
|
||
test('`result: PASS` and `result: Passed` are suppressed', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
result: PASS
|
||
|
||
### 2. Beta
|
||
result: Passed
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
assert.deepStrictEqual(items, [], describeAll());
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
|
||
test('`result: PENDING` surfaces lower-cased with a matching category', () => {
|
||
const { items } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
result: PENDING
|
||
|
||
### 2. Beta
|
||
result: Blocked
|
||
blocked_by: Server team
|
||
`);
|
||
const describeAll = () => JSON.stringify(items, null, 2);
|
||
assert.strictEqual(items.length, 2, describeAll());
|
||
assert.strictEqual(items[0].result, 'pending', describeAll());
|
||
assert.strictEqual(items[0].category, 'pending', describeAll());
|
||
assert.strictEqual(items[1].result, 'blocked', describeAll());
|
||
assert.strictEqual(items[1].category, 'server_blocked', describeAll());
|
||
});
|
||
});
|
||
|
||
// ─── #3078 MINOR 1: the indented-row counter is not `expected:`-ONLY ──────────
|
||
//
|
||
// `countUnattributedIndentedRows` walked back from an indented `### N.`-shaped
|
||
// line to the nearest preceding column-0 line and tested it against an
|
||
// `expected:`-ONLY grammar. A `reported: |` or `reason: |` block scalar
|
||
// (both template-sanctioned — `reported:` ships in gsd-core/templates/UAT.md)
|
||
// holding free-form prose that happens to contain an indented `### N.`-shaped
|
||
// line was therefore miscounted as a lost row on a file with nothing missing.
|
||
describe('#3078 MINOR 1: indented-row counter recognizes ANY key\'s block scalar, not only expected:', () => {
|
||
test('an indented `### N.`-shaped line inside `reported: |` is not counted', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
result: human_needed
|
||
reported: |
|
||
The user said:
|
||
### 9. Section Nine
|
||
looked wrong.
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.strictEqual(items[0].test, 1, describeAll());
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
|
||
test('an indented `### N.`-shaped line inside `reason: |` is not counted', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
result: skipped
|
||
reason: |
|
||
See report:
|
||
### 9. Section Nine
|
||
for details.
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.strictEqual(items[0].test, 1, describeAll());
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
|
||
// The walk-back logic itself must stay sound: a genuinely indented row that
|
||
// is NOT the value of any preceding block scalar is still counted — this
|
||
// fix must not just switch the counter off entirely.
|
||
test('a genuinely indented row OUTSIDE any scalar is still counted', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
result: pass
|
||
|
||
### 2. Indented Row
|
||
result: pending
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
assert.deepStrictEqual(items, [], describeAll());
|
||
assert.ok(headingsSeen >= 1, describeAll());
|
||
});
|
||
});
|
||
|
||
// ─── #3078 MINOR 2: `reason:`/`blocked_by:` gain block-scalar grammar ─────────
|
||
//
|
||
// `reason:` and `blocked_by:` had no block-scalar grammar at all: `reason: |`
|
||
// published the literal string `"|"`, discarding the entire multi-line value;
|
||
// `reason: >` gave `">"`; `blocked_by: |` gave `"|"`. Not a regression
|
||
// (origin/next captures the same `"|"` on this fixture) but fixed anyway
|
||
// because `categorizeItem` reads exactly this field, so a discarded `reason`
|
||
// can silently change an item's category.
|
||
describe('#3078 MINOR 2: reason: and blocked_by: support block-scalar (|, >) values', () => {
|
||
test('`reason: |` with a two-line body yields the exact multi-line value', () => {
|
||
const { items } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
result: skipped
|
||
reason: |
|
||
Line one.
|
||
Line two.
|
||
`);
|
||
const describeAll = () => JSON.stringify(items, null, 2);
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.strictEqual(items[0].reason, 'Line one.\nLine two.', describeAll());
|
||
});
|
||
|
||
test('`reason: >` folds a two-line body into one space-joined line', () => {
|
||
const { items } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
result: skipped
|
||
reason: >
|
||
Line one
|
||
continues here.
|
||
`);
|
||
const describeAll = () => JSON.stringify(items, null, 2);
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.strictEqual(items[0].reason, 'Line one continues here.', describeAll());
|
||
});
|
||
|
||
test('`blocked_by: |` with a multi-line body yields the exact value', () => {
|
||
const { items } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
result: blocked
|
||
blocked_by: |
|
||
Waiting on the
|
||
staging server team.
|
||
`);
|
||
const describeAll = () => JSON.stringify(items, null, 2);
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.strictEqual(items[0].blocked_by, 'Waiting on the\nstaging server team.', describeAll());
|
||
});
|
||
|
||
test('a plain inline `reason: text` is unchanged', () => {
|
||
const { items } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
result: skipped
|
||
reason: not running locally
|
||
`);
|
||
const describeAll = () => JSON.stringify(items, null, 2);
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.strictEqual(items[0].reason, 'not running locally', describeAll());
|
||
});
|
||
|
||
test('a plain inline `blocked_by: text` is unchanged', () => {
|
||
const { items } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
result: blocked
|
||
blocked_by: #123
|
||
`);
|
||
const describeAll = () => JSON.stringify(items, null, 2);
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.strictEqual(items[0].blocked_by, '#123', describeAll());
|
||
});
|
||
|
||
// Categorization consequence: a `reason:` block scalar whose text mentions
|
||
// "server" must categorize as `server_blocked` — impossible before this fix
|
||
// because the value was thrown away and replaced with the literal `"|"`.
|
||
test('a reason: block scalar mentioning "server" categorizes as server_blocked', () => {
|
||
const { items } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
result: skipped
|
||
reason: |
|
||
The staging
|
||
server is down.
|
||
`);
|
||
const describeAll = () => JSON.stringify(items, null, 2);
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.strictEqual(items[0].reason, 'The staging\nserver is down.', describeAll());
|
||
assert.strictEqual(items[0].category, 'server_blocked', describeAll());
|
||
});
|
||
});
|
||
|
||
// ─── #3078 round 6 HIGH: the shortfall scan must compare ONE surface ──────────
|
||
//
|
||
// `countUnattributedIndentedRows` / the shortfall logic compared a
|
||
// SECTION-SCOPED raw line count (the `## Tests` body) against a DOCUMENT-WIDE
|
||
// token count (`subHeadings`, built from `allHeadings` over the whole file).
|
||
// Any legal `### N.` row living OUTSIDE `## Tests` therefore decremented the
|
||
// shortfall by one and SILENTLY DISABLED the fence-straddle detector: two
|
||
// byte-identical `## Tests` bodies audited differently purely because of a row
|
||
// somewhere else in the document. Same unit-mismatch class as the earlier
|
||
// UTF-16-vs-code-point defect, relocated from code units to scope.
|
||
describe('#3078 round 6 HIGH: the fence-straddle detector survives legal rows outside ## Tests', () => {
|
||
const BACKTICK = '```';
|
||
|
||
// The `## Tests` body is byte-identical in every document below. A plain
|
||
// column-0 fence straddle hides `### 2. Blocked` with an outstanding result.
|
||
const TESTS_BODY = `## Tests
|
||
|
||
### 1. Alpha
|
||
result: pass
|
||
|
||
${BACKTICK}
|
||
### 2. Blocked
|
||
result: blocked
|
||
${BACKTICK}
|
||
`;
|
||
|
||
const PRIOR = `## Prior
|
||
|
||
### 9. Old
|
||
result: pass
|
||
|
||
`;
|
||
|
||
const NOTES = `
|
||
## Notes
|
||
|
||
### 7. Note Row
|
||
result: pass
|
||
|
||
### 6. Note Row Two
|
||
result: pass
|
||
`;
|
||
|
||
function assertStraddleReported(label, content) {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(content);
|
||
const describeAll = () => `${label}: ${JSON.stringify({ items, headingsSeen }, null, 2)}`;
|
||
assert.ok(
|
||
headingsSeen >= 1,
|
||
`the fence-straddled row was not counted, so the file audits as clean: ${describeAll()}`,
|
||
);
|
||
// parse_gap is exactly `headingsSeen > 0` at the caller (#3078: the
|
||
// `status !== 'complete'` term was removed — a self-declared terminal
|
||
// status may not switch off this detector).
|
||
assert.strictEqual(headingsSeen > 0, true, describeAll());
|
||
// The straddled row is HIDDEN from the tokenizer by construction, so it
|
||
// must not appear as an item — the gap counter is the only trace it has.
|
||
assert.strictEqual(items.find((i) => i.test === 2), undefined, describeAll());
|
||
return { items, headingsSeen, describeAll };
|
||
}
|
||
|
||
test('D1: the straddle alone is reported', () => {
|
||
const { headingsSeen, describeAll } = assertStraddleReported('D1', TESTS_BODY);
|
||
assert.strictEqual(headingsSeen, 1, describeAll());
|
||
});
|
||
|
||
test('D2: one legal row in a PRECEDING ## Prior section does not disable the detector', () => {
|
||
const { items, headingsSeen, describeAll } = assertStraddleReported('D2', PRIOR + TESTS_BODY);
|
||
assert.strictEqual(headingsSeen, 1, describeAll());
|
||
// The out-of-section row legitimately passed, so it yields no item — but it
|
||
// must not have been spent cancelling the in-section shortfall either.
|
||
assert.strictEqual(items.length, 0, describeAll());
|
||
});
|
||
|
||
test('D3: rows in ## Prior, ## Tests AND ## Notes — the scoping is not merely off-by-one', () => {
|
||
const { headingsSeen, describeAll } = assertStraddleReported(
|
||
'D3',
|
||
`${PRIOR}### 8. Older
|
||
result: pass
|
||
|
||
${TESTS_BODY}${NOTES}`,
|
||
);
|
||
assert.strictEqual(headingsSeen, 1, describeAll());
|
||
});
|
||
|
||
// #3078 round 7: this pin is INVERTED, deliberately. It used to assert that
|
||
// a fenced row sample outside `## Tests` contributed nothing — which is only
|
||
// achievable by scoping the raw scan, and scoping the raw scan is precisely
|
||
// what produced the round-7 silent false cleans. A fence-hidden `### N.`
|
||
// line outside `## Tests` is indistinguishable, by any fence- or
|
||
// closedness-based rule, from a genuinely suppressed row living there (see
|
||
// `## Regression Tests` in the round-7 block below), so it is now counted.
|
||
test('a fenced row sample living only in ## Notes DOES inflate the tally — the accepted over-report', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
result: pass
|
||
|
||
## Notes
|
||
|
||
${BACKTICK}
|
||
### 4. Sample Row
|
||
result: blocked
|
||
${BACKTICK}
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
assert.strictEqual(headingsSeen, 1, describeAll());
|
||
assert.strictEqual(items.length, 0, describeAll());
|
||
});
|
||
});
|
||
|
||
// ─── #3078 round 6 MAJOR: the two fence engines must agree on the text ────────
|
||
//
|
||
// `scanFencedBlocks` classifies the ORIGINAL lines; `tokenizeHeadings` re-runs
|
||
// its own state machine over the MUTATED (neutralised) copy. A COLUMN-0
|
||
// delimiter run that was fence CONTENT in the original — a ```-run inside an
|
||
// indented ````-pair — was PROMOTED to an opener once the enclosing indented
|
||
// delimiters were blanked, hiding every later heading to EOF.
|
||
describe('#3078 round 6 MAJOR: neutralising a block must not promote its own content into a fence', () => {
|
||
const BACKTICK = '```';
|
||
const QUAD = '````';
|
||
|
||
// The run must be ODD inside the pair — an even number of column-0 runs
|
||
// pairs up with itself once promoted and hides nothing, so this repro would
|
||
// vacuously pass against the unfixed code. Measured: unfixed, this document
|
||
// yields items=[] with headingsSeen=2; rows 2 and 3 are absent from the
|
||
// token stream entirely.
|
||
test('a column-0 ``` run inside an indented ```` pair does not swallow the later rows', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
expected: |
|
||
${QUAD}
|
||
${BACKTICK}
|
||
${QUAD}
|
||
result: pass
|
||
|
||
### 2. Bravo
|
||
result: blocked
|
||
reason: server down
|
||
|
||
### 3. Charlie
|
||
result: pending
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
const bravo = items.find((i) => i.test === 2);
|
||
const charlie = items.find((i) => i.test === 3);
|
||
assert.ok(bravo, `row 2 was swallowed by a promoted fence: ${describeAll()}`);
|
||
assert.strictEqual(bravo.name, 'Bravo', describeAll());
|
||
assert.strictEqual(bravo.result, 'blocked', describeAll());
|
||
assert.strictEqual(bravo.reason, 'server down', describeAll());
|
||
assert.ok(charlie, `row 3 was swallowed by a promoted fence: ${describeAll()}`);
|
||
assert.strictEqual(charlie.name, 'Charlie', describeAll());
|
||
assert.strictEqual(charlie.result, 'pending', describeAll());
|
||
assert.strictEqual(items.length, 2, describeAll());
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
|
||
// COMPOUNDED WITH THE HIGH ABOVE, the promoted fence used to go completely
|
||
// SILENT: the two legal `## Prior` rows cancelled the in-section shortfall,
|
||
// so the document audited as totally clean while hiding two outstanding
|
||
// rows. Measured against the unfixed code: items=[], headingsSeen=0.
|
||
test('compounded with an out-of-section row, the promoted fence is still not silent', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Prior
|
||
|
||
### 8. Older
|
||
result: pass
|
||
|
||
### 9. Old
|
||
result: pass
|
||
|
||
## Tests
|
||
|
||
### 1. Alpha
|
||
expected: |
|
||
${QUAD}
|
||
${BACKTICK}
|
||
${QUAD}
|
||
result: pass
|
||
|
||
### 2. Bravo
|
||
result: blocked
|
||
reason: server down
|
||
|
||
### 3. Charlie
|
||
result: pending
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
assert.deepStrictEqual(items.map((i) => i.test), [2, 3], describeAll());
|
||
assert.strictEqual(items[0].result, 'blocked', describeAll());
|
||
assert.strictEqual(items[1].result, 'pending', describeAll());
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
|
||
// The blanking widening is DELIMITER-SHAPED LINES ONLY. These two pins are
|
||
// the constraints it must not trade away — both are also covered by the
|
||
// round-4/round-5 suites above; asserted here against the new shape so the
|
||
// widening cannot regress them silently.
|
||
test('a column-0 `### N.` between neutralised delimiters is STILL a heading', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
expected: x
|
||
${BACKTICK}
|
||
### 9. Phantom
|
||
${BACKTICK}
|
||
result: pending
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.strictEqual(items[0].test, 9, describeAll());
|
||
assert.strictEqual(items[0].result, 'pending', describeAll());
|
||
assert.strictEqual(headingsSeen, 1, describeAll());
|
||
});
|
||
|
||
test('field lines between two neutralised scalars still reach their own row', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
expected: |
|
||
${BACKTICK}
|
||
sample one
|
||
${BACKTICK}
|
||
result: blocked
|
||
blocked_by: Server team
|
||
|
||
### 2. Bravo
|
||
expected: |
|
||
${BACKTICK}
|
||
sample two
|
||
${BACKTICK}
|
||
result: pending
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
const alpha = items.find((i) => i.test === 1);
|
||
const bravo = items.find((i) => i.test === 2);
|
||
assert.ok(alpha, describeAll());
|
||
assert.strictEqual(alpha.result, 'blocked', describeAll());
|
||
assert.strictEqual(alpha.blocked_by, 'Server team', describeAll());
|
||
assert.strictEqual(alpha.expected, `${BACKTICK}\nsample one\n${BACKTICK}`, describeAll());
|
||
assert.ok(bravo, describeAll());
|
||
assert.strictEqual(bravo.result, 'pending', describeAll());
|
||
assert.strictEqual(bravo.expected, `${BACKTICK}\nsample two\n${BACKTICK}`, describeAll());
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
});
|
||
|
||
// ─── #3078 round 6 MINOR: a scalar header may carry a trailing comment ────────
|
||
//
|
||
// `expected: | # sample` and `expected: >- # note` are legal YAML block-scalar
|
||
// headers. The shared header grammar was `$`-anchored right after the
|
||
// indicator, so those headers matched neither the reader's opener (the value
|
||
// fell through to the INLINE arm and published the literal `"|"`) nor the
|
||
// indented-row counter's walk-back test (so the scalar's own indented body
|
||
// heading was counted as a lost row — a FALSE parse gap).
|
||
describe('#3078 round 6 MINOR: a block-scalar header with a trailing comment still opens a scalar', () => {
|
||
test('`expected: | # sample` extracts the body and raises no false parse gap', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
expected: | # sample
|
||
first line
|
||
second line
|
||
result: blocked
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.strictEqual(items[0].expected, 'first line\nsecond line', describeAll());
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
|
||
test('`expected: >- # note` folds its body exactly as the bare `>-` form does', () => {
|
||
const commented = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
expected: >- # note
|
||
folded one
|
||
folded two
|
||
result: blocked
|
||
`);
|
||
const bare = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
expected: >-
|
||
folded one
|
||
folded two
|
||
result: blocked
|
||
`);
|
||
const describeAll = () => JSON.stringify({ commented, bare }, null, 2);
|
||
assert.strictEqual(commented.items[0].expected, 'folded one folded two', describeAll());
|
||
assert.deepStrictEqual(commented.items, bare.items, describeAll());
|
||
assert.strictEqual(commented.headingsSeen, 0, describeAll());
|
||
});
|
||
|
||
test('an indented `### N.`-shaped line inside a COMMENTED header\'s body is not a lost row', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
expected: | # sample
|
||
### 9. Section Nine
|
||
result: blocked
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.strictEqual(items[0].expected, '### 9. Section Nine', describeAll());
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
|
||
test('`reason:` and `blocked_by:` share the same commented-header grammar', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
result: blocked
|
||
reason: | # why
|
||
staging is down
|
||
blocked_by: >- # who
|
||
Server
|
||
team
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
assert.strictEqual(items[0].reason, 'staging is down', describeAll());
|
||
assert.strictEqual(items[0].blocked_by, 'Server team', describeAll());
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
|
||
// A `#` that is NOT after a scalar indicator must stay an ordinary inline
|
||
// value — the comment allowance must not swallow a plain `key: value`.
|
||
test('an inline value containing a `#` is untouched by the comment allowance', () => {
|
||
const { items } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
expected: build #42 succeeds
|
||
result: blocked
|
||
`);
|
||
assert.strictEqual(items[0].expected, 'build #42 succeeds', JSON.stringify(items, null, 2));
|
||
});
|
||
});
|
||
|
||
// ─── #3078 round 7 MAJOR: the inner delimiter sweep tolerates indented shapes ──
|
||
//
|
||
// The sweep that blanks delimiter-shaped lines strictly BETWEEN a neutralised
|
||
// block's own delimiters tested `FENCE_OPENER_RE` — column-0-anchored — so an
|
||
// INDENTED delimiter-shaped line inside that block (mere content in the
|
||
// original, since CommonMark tolerates 1-3 spaces on an opener) was NOT
|
||
// blanked, and got promoted to a real fence opener the instant the enclosing
|
||
// pair was blanked, swallowing every later row to EOF.
|
||
describe('#3078 round 7 MAJOR: the inner delimiter sweep tolerates indented delimiter shapes', () => {
|
||
const BACKTICK = '```';
|
||
const TILDE = '~~~';
|
||
const QUAD = '````';
|
||
const LONG_BACKTICK = '`````';
|
||
|
||
// Reproduced against the unfixed code: items=[], headingsSeen=1 — row 2's
|
||
// `result: blocked` / `blocked_by: server team` vanish entirely.
|
||
test('an indented ``` run inside an indented ````` pair does not swallow the later row', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
expected: |
|
||
${LONG_BACKTICK}
|
||
${BACKTICK}
|
||
${LONG_BACKTICK}
|
||
result: pass
|
||
|
||
### 2. Outstanding
|
||
result: blocked
|
||
blocked_by: server team
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
const outstanding = items.find((i) => i.test === 2);
|
||
assert.ok(outstanding, `row 2 was swallowed by a promoted indented fence: ${describeAll()}`);
|
||
assert.strictEqual(outstanding.name, 'Outstanding', describeAll());
|
||
assert.strictEqual(outstanding.result, 'blocked', describeAll());
|
||
assert.strictEqual(outstanding.blocked_by, 'server team', describeAll());
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
|
||
// Byte-identical document except the inner run sits at column 0 — this
|
||
// already worked pre-fix, which isolates the defect to the ANCHOR, not to
|
||
// the sweep's existence.
|
||
test('control: the byte-equivalent column-0 inner run already surfaces the row', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
expected: |
|
||
${LONG_BACKTICK}
|
||
${BACKTICK}
|
||
${LONG_BACKTICK}
|
||
result: pass
|
||
|
||
### 2. Outstanding
|
||
result: blocked
|
||
blocked_by: server team
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
const outstanding = items.find((i) => i.test === 2);
|
||
assert.ok(outstanding, describeAll());
|
||
assert.strictEqual(outstanding.result, 'blocked', describeAll());
|
||
assert.strictEqual(outstanding.blocked_by, 'server team', describeAll());
|
||
});
|
||
|
||
// Same defect, mixed delimiter characters — an indented `~~~` run inside an
|
||
// indented ```` pair — so the fix cannot be a backtick-only special case.
|
||
test('an indented ~~~ run inside an indented ```` pair reproduces identically', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
expected: |
|
||
${QUAD}
|
||
${TILDE}
|
||
${QUAD}
|
||
result: pass
|
||
|
||
### 2. Outstanding
|
||
result: blocked
|
||
blocked_by: server team
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
const outstanding = items.find((i) => i.test === 2);
|
||
assert.ok(outstanding, describeAll());
|
||
assert.strictEqual(outstanding.result, 'blocked', describeAll());
|
||
assert.strictEqual(outstanding.blocked_by, 'server team', describeAll());
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
|
||
// GUARD: the pinned "column 0 is structure" behaviour is unaffected — a
|
||
// column-0 `### N.` between neutralised delimiters is STILL a heading.
|
||
test('GUARD: a column-0 `### N.` between neutralised delimiters is still a heading', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
expected: x
|
||
${BACKTICK}
|
||
### 9. Phantom
|
||
${BACKTICK}
|
||
result: pending
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.strictEqual(items[0].test, 9, describeAll());
|
||
assert.strictEqual(items[0].result, 'pending', describeAll());
|
||
assert.strictEqual(headingsSeen, 1, describeAll());
|
||
});
|
||
|
||
// GUARD: field lines between two neutralised scalars still reach their own
|
||
// row — the widened sweep must add ONLY delimiter-shaped lines.
|
||
test('GUARD: field lines between two neutralised scalars still reach their own row', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
expected: |
|
||
${BACKTICK}
|
||
sample one
|
||
${BACKTICK}
|
||
result: blocked
|
||
blocked_by: Server team
|
||
|
||
### 2. Bravo
|
||
expected: |
|
||
${BACKTICK}
|
||
sample two
|
||
${BACKTICK}
|
||
result: pending
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
const alpha = items.find((i) => i.test === 1);
|
||
const bravo = items.find((i) => i.test === 2);
|
||
assert.ok(alpha, describeAll());
|
||
assert.strictEqual(alpha.result, 'blocked', describeAll());
|
||
assert.strictEqual(alpha.blocked_by, 'Server team', describeAll());
|
||
assert.strictEqual(alpha.expected, `${BACKTICK}\nsample one\n${BACKTICK}`, describeAll());
|
||
assert.ok(bravo, describeAll());
|
||
assert.strictEqual(bravo.result, 'pending', describeAll());
|
||
assert.strictEqual(bravo.expected, `${BACKTICK}\nsample two\n${BACKTICK}`, describeAll());
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
});
|
||
|
||
// ─── #3078 round 7 MINOR: the indented-row counter is not limited to 1-3 spaces ──
|
||
//
|
||
// `INDENTED_TEST_HEADING_LINE_RE` matched `^ {1,3}`, so a test row indented 4+
|
||
// spaces, or with a leading TAB, was neither parsed nor counted — origin/next's
|
||
// unanchored `###\s*(\d+)\.` surfaced every indent width; this HEAD silently
|
||
// dropped anything past 3 spaces with no trace at all (items=[], headingsSeen=0).
|
||
// The parse gate (`isColumnZeroHeading`) is unchanged: refusing to PARSE an
|
||
// indented row stays correct; only the COUNTER is widened.
|
||
describe('#3078 round 7 MINOR: the indented-row counter surfaces every indent width, not just 1-3 spaces', () => {
|
||
const cases = [
|
||
['1 space', ' '],
|
||
['3 spaces', ' '],
|
||
['4 spaces', ' '],
|
||
['8 spaces', ' '],
|
||
['a leading tab', '\t'],
|
||
];
|
||
|
||
for (const [label, indent] of cases) {
|
||
test(`a row indented with ${label} is counted, not parsed`, () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
result: pass
|
||
|
||
${indent}### 2. Deep Indented Row
|
||
result: pending
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
assert.deepStrictEqual(items, [], describeAll());
|
||
assert.ok(headingsSeen >= 1, describeAll());
|
||
});
|
||
}
|
||
|
||
// GUARD: a column-0 row is unaffected — still parsed normally, not counted
|
||
// as a gap.
|
||
test('GUARD: a column-0 row is still parsed normally', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(`## Tests
|
||
|
||
### 1. Alpha
|
||
result: pass
|
||
|
||
### 2. Normal
|
||
result: pending
|
||
`);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.strictEqual(items[0].test, 2, describeAll());
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
});
|
||
|
||
// ─── #3078 round 7 HIGH: the shortfall scan is whole-document on BOTH sides ───
|
||
//
|
||
// Round 6 scoped the RAW LINE SCAN to the first `## Tests` section body while
|
||
// the token count stayed whole-document. Round 7's first attempt "equalized"
|
||
// that by ALSO scoping the token side to the section's offset span — which made
|
||
// the two counters agree with each other but left the PARSE side
|
||
// whole-document. A `### N.` row living OUTSIDE the first `## Tests` section is
|
||
// parsed and surfaced normally when visible, yet vanished with NO item AND NO
|
||
// parse_gap the moment a fence straddled it: neither side of the comparison
|
||
// covered it. Both sides are now whole-document. Symmetry is the property that
|
||
// matters; every attempt to be clever about scope has produced a silent false
|
||
// clean.
|
||
//
|
||
// `parse_gap` at the caller (`cmdAuditUat`) is exactly `headingsSeen > 0`,
|
||
// independent of frontmatter status, so these documents flag iff
|
||
// `headingsSeen > 0`.
|
||
describe('#3078 round 7 HIGH: a fence-straddled row outside the first ## Tests section still flags', () => {
|
||
const BACKTICK = '```';
|
||
|
||
const parseGapOf = (headingsSeen) => headingsSeen > 0;
|
||
|
||
function report(label, content) {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(content);
|
||
const parseGap = parseGapOf(headingsSeen);
|
||
const describeAll = () =>
|
||
`${label}: ${JSON.stringify({ items, headingsSeen, parse_gap: parseGap }, null, 2)}`;
|
||
return { items, headingsSeen, parseGap, describeAll };
|
||
}
|
||
|
||
// CASE 1 — the reported repro. Before the fix this yielded items: [],
|
||
// headingsSeen: 0, so the file never entered `results` and the audit
|
||
// reported totally clean with a `result: blocked` sitting in it.
|
||
const REGRESSION_SECTION_DOC = `## Tests
|
||
|
||
### 1. Alpha
|
||
result: pass
|
||
|
||
## Regression Tests
|
||
|
||
${BACKTICK}
|
||
### 2. Straddled Blocked
|
||
result: blocked
|
||
blocked_by: server team
|
||
|
||
### 3. Straddled Pending
|
||
result: pending
|
||
${BACKTICK}
|
||
`;
|
||
|
||
test('CASE 1: a straddle inside a `## Regression Tests` section is FLAGGED, not silently clean', () => {
|
||
const { items, headingsSeen, parseGap, describeAll } = report('C1', REGRESSION_SECTION_DOC);
|
||
// Both straddled rows are hidden from the tokenizer by construction, so the
|
||
// gap counter is the only trace they have — but it MUST exist.
|
||
assert.deepStrictEqual(items, [], describeAll());
|
||
assert.strictEqual(headingsSeen, 2, describeAll());
|
||
assert.ok(headingsSeen >= 1, describeAll());
|
||
assert.strictEqual(parseGap, true, describeAll());
|
||
});
|
||
|
||
// The same document WITHOUT the fence parses both rows normally, with full
|
||
// identity — which is what makes the fenced form a REGRESSION (origin/next's
|
||
// whole-file regex surfaced them) rather than an intentional exclusion.
|
||
test('CASE 1 twin: the identical document without the fence surfaces both rows with full identity', () => {
|
||
const { items, headingsSeen, parseGap, describeAll } = report(
|
||
'C1-twin',
|
||
REGRESSION_SECTION_DOC.split('\n').filter((l) => l !== BACKTICK).join('\n'),
|
||
);
|
||
assert.strictEqual(items.length, 2, describeAll());
|
||
assert.strictEqual(items[0].test, 2, describeAll());
|
||
assert.strictEqual(items[0].name, 'Straddled Blocked', describeAll());
|
||
assert.strictEqual(items[0].result, 'blocked', describeAll());
|
||
assert.strictEqual(items[0].blocked_by, 'server team', describeAll());
|
||
assert.strictEqual(items[0].category, 'server_blocked', describeAll());
|
||
assert.strictEqual(items[1].test, 3, describeAll());
|
||
assert.strictEqual(items[1].name, 'Straddled Pending', describeAll());
|
||
assert.strictEqual(items[1].result, 'pending', describeAll());
|
||
assert.strictEqual(items[1].category, 'pending', describeAll());
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
assert.strictEqual(parseGap, false, describeAll());
|
||
});
|
||
|
||
// CASE 2 — `collectSection` takes the FIRST match only, so a SECOND
|
||
// `## Tests` section was outside the scan span for the same reason.
|
||
test('CASE 2: a straddle in a SECOND `## Tests` section is FLAGGED', () => {
|
||
const { items, headingsSeen, parseGap, describeAll } = report(
|
||
'C2',
|
||
`## Tests
|
||
|
||
### 1. Alpha
|
||
result: pass
|
||
|
||
## Other
|
||
|
||
prose.
|
||
|
||
## Tests
|
||
|
||
${BACKTICK}
|
||
### 2. Straddled Blocked
|
||
result: blocked
|
||
${BACKTICK}
|
||
`,
|
||
);
|
||
assert.deepStrictEqual(items, [], describeAll());
|
||
assert.strictEqual(headingsSeen, 1, describeAll());
|
||
assert.strictEqual(parseGap, true, describeAll());
|
||
});
|
||
|
||
// CASE 3 — the control. This one reported correctly even before the fix
|
||
// (no `## Tests` heading meant the scan fell back to the whole document),
|
||
// which is what isolated the defect to the SCOPING rather than to the
|
||
// straddle detector itself. It must keep reporting.
|
||
test('CASE 3 (control): the identical straddle with NO `## Tests` heading at all still flags', () => {
|
||
const { items, headingsSeen, parseGap, describeAll } = report(
|
||
'C3',
|
||
`# Phase 1 UAT
|
||
|
||
${BACKTICK}
|
||
### 2. Straddled Blocked
|
||
result: blocked
|
||
${BACKTICK}
|
||
`,
|
||
);
|
||
assert.deepStrictEqual(items, [], describeAll());
|
||
assert.strictEqual(headingsSeen, 1, describeAll());
|
||
assert.strictEqual(parseGap, true, describeAll());
|
||
});
|
||
|
||
// CASE 4 — THE ACCEPTED OVER-REPORT, pinned so the trade is visible rather
|
||
// than surprising. A `### N.`-shaped line inside a properly CLOSED fence in
|
||
// a `## Notes` section is a DOCUMENTATION SAMPLE of the row format, and
|
||
// nothing is missing from this file — yet it raises a parse gap, because no
|
||
// fence- or closedness-based rule can tell it apart from CASE 1's genuinely
|
||
// suppressed row, and the only rule that could (scope) is what produced two
|
||
// HIGH-severity silent false cleans. Noisy-but-visible beats invisible: this
|
||
// whole issue exists to eliminate false cleans, so the trade goes this way
|
||
// deliberately. If this test starts failing, the scoping has been
|
||
// reintroduced and CASES 1 and 2 have silently regressed with it.
|
||
test('CASE 4: a `## Notes` closed-fence documentation sample raises a parse gap — the ACCEPTED over-report', () => {
|
||
const { items, headingsSeen, parseGap, describeAll } = report(
|
||
'C4',
|
||
`## Tests
|
||
|
||
### 1. Alpha
|
||
result: pending
|
||
|
||
## Notes
|
||
|
||
Write each row like this:
|
||
|
||
${BACKTICK}
|
||
### 9. Example Row
|
||
result: pending
|
||
${BACKTICK}
|
||
`,
|
||
);
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.strictEqual(items[0].test, 1, describeAll());
|
||
assert.strictEqual(items[0].name, 'Alpha', describeAll());
|
||
assert.strictEqual(items[0].result, 'pending', describeAll());
|
||
assert.strictEqual(items[0].category, 'pending', describeAll());
|
||
assert.strictEqual(headingsSeen, 1, describeAll());
|
||
assert.strictEqual(parseGap, true, describeAll());
|
||
});
|
||
|
||
// CASE 5 — regression guard: the ordinary single-`## Tests` straddle, the
|
||
// case the counter was built for, is unchanged by the widening.
|
||
test('CASE 5 (regression): a normal single-`## Tests` straddle still flags exactly as before', () => {
|
||
const { items, headingsSeen, parseGap, describeAll } = report(
|
||
'C5',
|
||
`## Tests
|
||
|
||
### 1. Alpha
|
||
result: pass
|
||
|
||
${BACKTICK}
|
||
### 2. Straddled Blocked
|
||
result: blocked
|
||
${BACKTICK}
|
||
|
||
### 3. Gamma
|
||
result: pending
|
||
`,
|
||
);
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.strictEqual(items[0].test, 3, describeAll());
|
||
assert.strictEqual(items[0].name, 'Gamma', describeAll());
|
||
assert.strictEqual(items[0].result, 'pending', describeAll());
|
||
assert.strictEqual(items.find((i) => i.test === 2), undefined, describeAll());
|
||
assert.strictEqual(headingsSeen, 1, describeAll());
|
||
assert.strictEqual(parseGap, true, describeAll());
|
||
});
|
||
});
|
||
|
||
// ─── #3707 fix: parse_gap_files is ONE counter, archived or not ────────────────
|
||
//
|
||
// A live/archived split on `parse_gap_files` was tried in this branch and
|
||
// reverted (#3707 follow-up, #2766's own rationale): "Outstanding UAT items do
|
||
// not stop mattering when a milestone closes: a deferred human-UAT scenario or
|
||
// a `skipped` live-stack test is exactly what gets archived still-open." So
|
||
// "archived UAT files are complete by definition" — the split's premise — is
|
||
// false, and `total_items` (`summary.total_items`) has never had such a split
|
||
// either. Two regressions were executed under the split:
|
||
// (a) a phase belonging to the CURRENT milestone, but filed under
|
||
// `.planning/milestones/<version>-phases/` (getArchivedPhaseDirs scans
|
||
// that whole tree regardless of which milestone ROADMAP.md currently
|
||
// names), was classified "archived" and demoted out of the gate —
|
||
// live in-progress work reported as closed history.
|
||
// (b) an archived outstanding row that PARSES gives `total_items: 1` and
|
||
// trips Verification Debt; the SAME row made unparseable gave
|
||
// `parse_gap_files: 0` under the split — the parse failure buried the
|
||
// debt, the exact bug class #3707 exists to fix.
|
||
// `parse_gap_files` now counts EVERY entry with `parse_gap: true`, unfiltered
|
||
// by `archived_milestone` — mirroring `total_items` exactly.
|
||
describe('#3707 fix: parse_gap_files counts every parse gap, archived or not', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
// A `### N.` block with NO `result:` line at all — the parser counts the
|
||
// heading (headingsSeen) but yields no item, which is exactly a parse gap.
|
||
const UNPARSEABLE_BLOCK = `---
|
||
status: complete
|
||
---
|
||
|
||
## Tests
|
||
|
||
### 1. Unreadable Row
|
||
expected: something observable
|
||
`;
|
||
|
||
const LIVE_PENDING = `---
|
||
status: testing
|
||
---
|
||
|
||
## Tests
|
||
|
||
### 1. Real Outstanding Row
|
||
expected: something observable
|
||
result: pending
|
||
`;
|
||
|
||
function writeFile(...parts) {
|
||
const body = parts.pop();
|
||
const target = path.join(tmpDir, ...parts);
|
||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||
fs.writeFileSync(target, body);
|
||
}
|
||
|
||
function audit() {
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
return JSON.parse(result.output);
|
||
}
|
||
|
||
// The merged-semantics replacement for the old "stays out of the live gate"
|
||
// assertion: four archived milestones with unparseable blocks ALL count.
|
||
test('archived milestones with unparseable blocks are counted in parse_gap_files, not hidden from it', () => {
|
||
writeFile('.planning', 'phases', '01-live', '01-UAT.md', LIVE_PENDING);
|
||
const archived = [
|
||
['v0.1.0', '01-alpha'],
|
||
['v0.2.0', '02-bravo'],
|
||
['v0.3.0', '03-charlie'],
|
||
['v0.4.0', '04-delta'],
|
||
];
|
||
for (const [milestone, phaseDir] of archived) {
|
||
writeFile('.planning', 'milestones', `${milestone}-phases`, phaseDir,
|
||
`${phaseDir.slice(0, 2)}-UAT.md`, UNPARSEABLE_BLOCK);
|
||
}
|
||
|
||
const output = audit();
|
||
const describeAll = () => JSON.stringify(output, null, 2);
|
||
|
||
// ONE counter: all four archived gaps plus none from the live pending row.
|
||
assert.strictEqual(output.summary.parse_gap_files, 4, describeAll());
|
||
assert.strictEqual(output.summary.archived_parse_gap_files, undefined, describeAll());
|
||
assert.strictEqual(output.summary.total_items, 1, describeAll());
|
||
assert.strictEqual(output.summary.total_files, 5, describeAll());
|
||
|
||
// NOTHING IS HIDDEN: all four archived entries are still in `results`,
|
||
// each still flagged `parse_gap: true` and still carrying its
|
||
// `archived_milestone`, per file identity.
|
||
const archivedEntries = output.results.filter((r) => r.archived_milestone !== undefined);
|
||
assert.strictEqual(archivedEntries.length, 4, describeAll());
|
||
for (const entry of archivedEntries) {
|
||
assert.strictEqual(entry.parse_gap, true, describeAll());
|
||
assert.strictEqual(entry.unparsed_blocks, 1, describeAll());
|
||
}
|
||
assert.deepStrictEqual(
|
||
archivedEntries.map((r) => r.archived_milestone).sort(),
|
||
['v0.1.0', 'v0.2.0', 'v0.3.0', 'v0.4.0'],
|
||
describeAll(),
|
||
);
|
||
});
|
||
|
||
// Baseline: a live phase whose block cannot be read is still a real,
|
||
// actionable gap and must still trip the gate the workflows read.
|
||
test('a live phase with an unparseable block is counted in parse_gap_files', () => {
|
||
writeFile('.planning', 'phases', '01-live', '01-UAT.md', UNPARSEABLE_BLOCK);
|
||
|
||
const output = audit();
|
||
const describeAll = () => JSON.stringify(output, null, 2);
|
||
|
||
assert.strictEqual(output.summary.parse_gap_files, 1, describeAll());
|
||
assert.strictEqual(output.summary.total_items, 0, describeAll());
|
||
assert.strictEqual(output.summary.total_files, 1, describeAll());
|
||
|
||
const [entry] = output.results;
|
||
assert.strictEqual(entry.parse_gap, true, describeAll());
|
||
assert.strictEqual(entry.unparsed_blocks, 1, describeAll());
|
||
assert.strictEqual(entry.archived_milestone, undefined, describeAll());
|
||
// Status-independence is preserved: this file declares `complete` and is
|
||
// STILL reported. A self-declared terminal status must not switch off the
|
||
// detector that would contradict it.
|
||
assert.strictEqual(entry.status, 'complete', describeAll());
|
||
});
|
||
|
||
test('a mixed project sums live and archived parse gaps into one counter', () => {
|
||
writeFile('.planning', 'phases', '01-live', '01-UAT.md', UNPARSEABLE_BLOCK);
|
||
writeFile('.planning', 'phases', '02-also-live', '02-UAT.md', LIVE_PENDING);
|
||
writeFile('.planning', 'milestones', 'v0.9.0-phases', '09-old', '09-UAT.md', UNPARSEABLE_BLOCK);
|
||
|
||
const output = audit();
|
||
const describeAll = () => JSON.stringify(output, null, 2);
|
||
|
||
assert.strictEqual(output.summary.parse_gap_files, 2, describeAll());
|
||
assert.strictEqual(output.summary.total_items, 1, describeAll());
|
||
assert.strictEqual(output.summary.total_files, 3, describeAll());
|
||
|
||
const liveGap = output.results.find(
|
||
(r) => r.parse_gap && r.archived_milestone === undefined,
|
||
);
|
||
const archivedGap = output.results.find(
|
||
(r) => r.parse_gap && r.archived_milestone !== undefined,
|
||
);
|
||
assert.ok(liveGap, describeAll());
|
||
assert.strictEqual(liveGap.phase, '01', describeAll());
|
||
assert.ok(archivedGap, describeAll());
|
||
assert.strictEqual(archivedGap.phase, '09', describeAll());
|
||
assert.strictEqual(archivedGap.archived_milestone, 'v0.9.0', describeAll());
|
||
|
||
// The phase carrying a genuine outstanding item is a parse gap on
|
||
// NEITHER side.
|
||
const pendingEntry = output.results.find((r) => r.phase === '02');
|
||
assert.ok(pendingEntry, describeAll());
|
||
assert.strictEqual(pendingEntry.parse_gap, undefined, describeAll());
|
||
assert.strictEqual(pendingEntry.items.length, 1, describeAll());
|
||
});
|
||
|
||
// REGRESSION (a): a phase under `.planning/milestones/v<ver>-phases/` whose
|
||
// milestone IS the ROADMAP's "## Current Milestone" must still count in
|
||
// `parse_gap_files` — live in-progress work must never be demoted to
|
||
// closed history merely because of which directory it happens to live
|
||
// under. Asserts IDENTITY: exact count, plus the entry's own phase/file.
|
||
test('a phase under the CURRENT milestone but filed in the milestones/ archive tree still counts', () => {
|
||
writeFile('.planning', 'ROADMAP.md', `# Roadmap
|
||
|
||
## Current Milestone
|
||
|
||
v1.1 - Example Milestone
|
||
|
||
### Phase 1: Alpha
|
||
`);
|
||
writeFile('.planning', 'milestones', 'v1.1-phases', '01-alpha', '01-UAT.md', UNPARSEABLE_BLOCK);
|
||
|
||
const output = audit();
|
||
const describeAll = () => JSON.stringify(output, null, 2);
|
||
|
||
assert.strictEqual(output.summary.parse_gap_files, 1, describeAll());
|
||
assert.strictEqual(output.summary.total_files, 1, describeAll());
|
||
|
||
const [entry] = output.results;
|
||
assert.strictEqual(entry.phase, '01', describeAll());
|
||
assert.strictEqual(entry.file, '01-UAT.md', describeAll());
|
||
assert.strictEqual(entry.parse_gap, true, describeAll());
|
||
assert.strictEqual(entry.archived_milestone, 'v1.1', describeAll());
|
||
});
|
||
|
||
// REGRESSION (b): an archived outstanding row that fails to parse must not
|
||
// be buried relative to the identical row when it happens to parse — the
|
||
// same file, parseable, gives `total_items: 1`; unparseable, it must give
|
||
// `parse_gap_files: 1`, not 0. Asserts IDENTITY: exact counts, plus the
|
||
// entry's own phase/file.
|
||
test('an archived row that fails to parse counts exactly like the identical row that parses', () => {
|
||
writeFile('.planning', 'milestones', 'v0.5.0-phases', '05-old', '05-UAT.md', LIVE_PENDING);
|
||
const parseableOutput = audit();
|
||
assert.strictEqual(parseableOutput.summary.total_items, 1, JSON.stringify(parseableOutput, null, 2));
|
||
assert.strictEqual(parseableOutput.summary.parse_gap_files, 0, JSON.stringify(parseableOutput, null, 2));
|
||
|
||
writeFile('.planning', 'milestones', 'v0.5.0-phases', '05-old', '05-UAT.md', UNPARSEABLE_BLOCK);
|
||
const gapOutput = audit();
|
||
const describeAll = () => JSON.stringify(gapOutput, null, 2);
|
||
|
||
assert.strictEqual(gapOutput.summary.total_items, 0, describeAll());
|
||
assert.strictEqual(gapOutput.summary.parse_gap_files, 1, describeAll());
|
||
assert.strictEqual(gapOutput.summary.total_files, 1, describeAll());
|
||
|
||
const [entry] = gapOutput.results;
|
||
assert.strictEqual(entry.phase, '05', describeAll());
|
||
assert.strictEqual(entry.file, '05-UAT.md', describeAll());
|
||
assert.strictEqual(entry.archived_milestone, 'v0.5.0', describeAll());
|
||
});
|
||
});
|
||
|
||
// ─── #3707-CR security review MEDIUM: a lone CR is not a line boundary anywhere ──
|
||
//
|
||
// [FAILING-FIRST, DO NOT "FIX" src/ TO MAKE THIS PASS — see dispatch brief]
|
||
//
|
||
// CommonMark treats a lone CR (no paired LF) as a line ending — a document
|
||
// using it RENDERS as separate lines to a human reader. This parser's row
|
||
// scan (`content.split('\n')` feeding both `tokenizeHeadings` and the
|
||
// column-0 `TEST_HEADING_LINE_RE` shortfall scan, uat.cjs:1193/869) and the
|
||
// #3078 round-7 symmetry invariant (both sides of the shortfall comparison
|
||
// are whole-document, uat.cjs:1137-1166) both key on `\n` alone. A lone CR
|
||
// never becomes a boundary on EITHER side, so a `### N.` row separated from
|
||
// its predecessor only by CR is invisible to `tokenizeHeadings` (no token),
|
||
// to the raw-line shortfall scan (`TEST_HEADING_LINE_RE.test(line)` only
|
||
// matches `^`, and the whole multi-row chunk is now ONE unsplit "line" whose
|
||
// `^` sits before earlier content, not before the buried heading), AND to
|
||
// `parseGapsItems`'s own `content.split('\n')` walk. No item, no shortfall,
|
||
// no headingsSeen: a TOTAL false-clean, not merely a missed row.
|
||
const CR = String.fromCharCode(13);
|
||
const LF = String.fromCharCode(10);
|
||
const CRLF = CR + LF;
|
||
|
||
describe('#3707-CR: a lone CR line ending must not hide an outstanding UAT row', () => {
|
||
/**
|
||
* `join(sep)` on lines already containing an embedded body — used so the
|
||
* fixture text itself stays free of literal CR characters that an editor
|
||
* or a diff viewer could silently rewrite (CLAUDE.md IO-injection rule).
|
||
*/
|
||
function bodyWith(sep) {
|
||
return [
|
||
'---',
|
||
'status: partial',
|
||
'phase: 01-a',
|
||
'---',
|
||
'',
|
||
'## Tests',
|
||
'',
|
||
'### 1. Alpha',
|
||
'expected: ok',
|
||
'result: pass',
|
||
'',
|
||
'Notes.',
|
||
'### 2. Beta',
|
||
'expected: the export works',
|
||
'result: blocked',
|
||
'',
|
||
].join(sep);
|
||
}
|
||
|
||
test('[RED] a lone-CR document should surface the hidden `### 2. Beta` row by full identity', () => {
|
||
const { items, headingsSeen, shortfallBlocks } = parseUatItemsWithStats(bodyWith(CR));
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen, shortfallBlocks }, null, 2);
|
||
|
||
const beta = items.find((i) => i.name === 'Beta');
|
||
assert.ok(beta, `hidden row 2 "Beta" absent from items: ${describeAll()}`);
|
||
assert.strictEqual(beta.test, 2, describeAll());
|
||
assert.strictEqual(beta.name, 'Beta', describeAll());
|
||
assert.strictEqual(beta.result, 'blocked', describeAll());
|
||
});
|
||
|
||
test('[GREEN] CONTROL: the LF equivalent of the same body surfaces the identical row identity', () => {
|
||
const { items } = parseUatItemsWithStats(bodyWith(LF));
|
||
const describeAll = () => JSON.stringify(items, null, 2);
|
||
|
||
const beta = items.find((i) => i.name === 'Beta');
|
||
assert.ok(beta, `row 2 "Beta" absent from LF control: ${describeAll()}`);
|
||
assert.strictEqual(beta.test, 2, describeAll());
|
||
assert.strictEqual(beta.name, 'Beta', describeAll());
|
||
assert.strictEqual(beta.result, 'blocked', describeAll());
|
||
});
|
||
|
||
test('[GREEN] CONTROL: CRLF still parses exactly as today — no double-count, no strip', () => {
|
||
const { items, headingsSeen, shortfallBlocks } = parseUatItemsWithStats(bodyWith(CRLF));
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen, shortfallBlocks }, null, 2);
|
||
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
const [beta] = items;
|
||
assert.strictEqual(beta.test, 2, describeAll());
|
||
assert.strictEqual(beta.name, 'Beta', describeAll());
|
||
assert.strictEqual(beta.result, 'blocked', describeAll());
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
assert.strictEqual(shortfallBlocks, 0, describeAll());
|
||
});
|
||
|
||
test('[GREEN] CONTROL: a literal CR inside a fenced block is not torn into extra rows', () => {
|
||
const content = [
|
||
'## Tests',
|
||
'',
|
||
'### 1. Alpha',
|
||
'expected: |',
|
||
'```',
|
||
`sample${CR}line`,
|
||
'```',
|
||
'result: pass',
|
||
'',
|
||
'### 2. Beta',
|
||
'result: blocked',
|
||
'',
|
||
].join(LF);
|
||
const { items, headingsSeen } = parseUatItemsWithStats(content);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
|
||
// Row 1 ("Alpha") carries `result: pass`, which is deliberately excluded
|
||
// from `items` by design (a PASS token is the one case a heading yields
|
||
// no item without being a parse gap) — so exactly ONE item is expected
|
||
// here, not two. Asserting `headingsSeen === 0` is what proves Alpha's
|
||
// heading was still correctly SEEN and attributed, not silently dropped
|
||
// by the embedded CR splitting its fence/scalar content into extra rows.
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
const beta = items.find((i) => i.test === 2);
|
||
assert.ok(beta, `row 2 absent: ${describeAll()}`);
|
||
assert.strictEqual(beta.name, 'Beta', describeAll());
|
||
assert.strictEqual(beta.result, 'blocked', describeAll());
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
|
||
test('[GREEN] CONTROL: a literal CR inside an `expected: |` block-scalar body is not torn into extra rows', () => {
|
||
const content = [
|
||
'## Tests',
|
||
'',
|
||
'### 1. Alpha',
|
||
'expected: |',
|
||
` line one${CR}still the scalar`,
|
||
' line two',
|
||
'result: pass',
|
||
'',
|
||
'### 2. Beta',
|
||
'result: blocked',
|
||
'',
|
||
].join(LF);
|
||
const { items, headingsSeen } = parseUatItemsWithStats(content);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
|
||
// Same PASS-exclusion rule as the fenced-CR control above: Alpha's
|
||
// `result: pass` yields no item by design, so exactly ONE item (Beta) is
|
||
// expected, and `headingsSeen === 0` proves Alpha was still attributed.
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
const beta = items.find((i) => i.test === 2);
|
||
assert.ok(beta, `row 2 absent: ${describeAll()}`);
|
||
assert.strictEqual(beta.name, 'Beta', describeAll());
|
||
assert.strictEqual(beta.result, 'blocked', describeAll());
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
|
||
test('[RED] boundary: a lone CR at the very start of the document also hides the very first row', () => {
|
||
// A second manifestation of the same defect, not a distinct one: the
|
||
// leading CR is not a `\n`, so `content.split('\n')` yields a single
|
||
// first "line" of `"\r### 1. Alpha"` — the heading text no longer sits
|
||
// at column 0 of that split unit, so `TEST_HEADING_LINE_RE`'s `^#{3}`
|
||
// anchor and the tokenizer's own column-0 check both refuse it.
|
||
const content = CR + [
|
||
'### 1. Alpha',
|
||
'result: blocked',
|
||
'',
|
||
].join(LF);
|
||
const { items } = parseUatItemsWithStats(content);
|
||
const describeAll = () => JSON.stringify(items, null, 2);
|
||
|
||
const alpha = items.find((i) => i.name === 'Alpha');
|
||
assert.ok(alpha, `row "Alpha" absent: ${describeAll()}`);
|
||
assert.strictEqual(alpha.test, 1, describeAll());
|
||
assert.strictEqual(alpha.result, 'blocked', describeAll());
|
||
});
|
||
|
||
test('[GREEN] boundary: a lone CR at the very end of the document is harmless', () => {
|
||
const content = [
|
||
'### 1. Alpha',
|
||
'result: blocked',
|
||
].join(LF) + CR;
|
||
const { items } = parseUatItemsWithStats(content);
|
||
const describeAll = () => JSON.stringify(items, null, 2);
|
||
|
||
const alpha = items.find((i) => i.name === 'Alpha');
|
||
assert.ok(alpha, `row "Alpha" absent: ${describeAll()}`);
|
||
assert.strictEqual(alpha.test, 1, describeAll());
|
||
assert.strictEqual(alpha.result, 'blocked', describeAll());
|
||
});
|
||
|
||
test('[RED] boundary: two consecutive lone CRs between rows still hides the row, though the whole-document shortfall scan happens to flag it', () => {
|
||
// With no `\n` anywhere in this fixture, `content.split('\n')` returns
|
||
// ONE line: the entire document text. That single line legitimately
|
||
// starts with `### 1. Alpha` (true string start, column 0), so the raw
|
||
// `TEST_HEADING_LINE_RE` shortfall scan (uat.cjs:1193) counts exactly one
|
||
// shaped heading line for the WHOLE document, while the tokenizer-backed
|
||
// `subHeadings` side finds none it can attribute — `headingsSeen`/
|
||
// `shortfallBlocks` land at 1, so this shape is not a TOTAL silent
|
||
// false-clean like the primary repro. But `items` is still empty: the
|
||
// "Beta" row's own identity (number, name, result) is not recovered by
|
||
// that shortfall count, which is why this assertion is on identity, not
|
||
// presence of a nonzero counter.
|
||
const content = [
|
||
'### 1. Alpha',
|
||
'result: pass',
|
||
'',
|
||
'### 2. Beta',
|
||
'result: blocked',
|
||
'',
|
||
].join(CR + CR);
|
||
const { items } = parseUatItemsWithStats(content);
|
||
const describeAll = () => JSON.stringify(items, null, 2);
|
||
|
||
const beta = items.find((i) => i.name === 'Beta');
|
||
assert.ok(beta, `row 2 "Beta" absent: ${describeAll()}`);
|
||
assert.strictEqual(beta.test, 2, describeAll());
|
||
assert.strictEqual(beta.result, 'blocked', describeAll());
|
||
});
|
||
});
|
||
|
||
// ─── #3707-CR follow-up MAJOR: the two OTHER cmdAuditUat ingresses ─────────────
|
||
//
|
||
// The original #3707-CR fix normalized line endings inside two of
|
||
// `cmdAuditUat`'s FOUR parsers (`parseUatItemsWithStats`, `parseCurrentTest`)
|
||
// and declared the class closed. It was not: `parseVerificationItems`
|
||
// (VERIFICATION.md) and `parseDeferredItems` (deferred-items.md) are reached
|
||
// from the SAME function via their own, separately unnormalized
|
||
// `fs.readFileSync` calls, so a lone-CR VERIFICATION.md or deferred-items.md
|
||
// hit the identical total false-clean this issue exists to close. The fix
|
||
// this time is at the READ BOUNDARY (`readNormalizedDocument` in
|
||
// src/uat.cts), not per-parser — these tests drive the full CLI end-to-end so
|
||
// they exercise that boundary, not a parser function directly.
|
||
describe('#3707-CR follow-up MAJOR: VERIFICATION.md and deferred-items.md ingresses normalize at the read boundary', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
function audit() {
|
||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
return JSON.parse(result.output);
|
||
}
|
||
|
||
function deferredBody(eol) {
|
||
return [
|
||
'## Deferred Items',
|
||
'',
|
||
'- First deferred item, still open.',
|
||
'- Second deferred item, still open.',
|
||
].join(eol);
|
||
}
|
||
|
||
test('[RED] a lone-CR deferred-items.md surfaces both items by identity', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, 'deferred-items.md'), deferredBody(CR));
|
||
|
||
const output = audit();
|
||
const describeAll = () => JSON.stringify(output, null, 2);
|
||
|
||
const entry = output.results.find((r) => r.file === 'deferred-items.md');
|
||
assert.ok(entry, `deferred-items.md entry absent: ${describeAll()}`);
|
||
const names = entry.items.map((i) => ({ name: i.name, result: i.result }));
|
||
assert.deepStrictEqual(names, [
|
||
{ name: 'First deferred item, still open.', result: 'unresolved' },
|
||
{ name: 'Second deferred item, still open.', result: 'unresolved' },
|
||
], describeAll());
|
||
assert.strictEqual(output.summary.total_items, 2, describeAll());
|
||
});
|
||
|
||
test('[GREEN] CONTROL: the LF twin of the same deferred-items.md surfaces the identical items', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, 'deferred-items.md'), deferredBody(LF));
|
||
|
||
const output = audit();
|
||
const describeAll = () => JSON.stringify(output, null, 2);
|
||
|
||
const entry = output.results.find((r) => r.file === 'deferred-items.md');
|
||
assert.ok(entry, `deferred-items.md entry absent: ${describeAll()}`);
|
||
const names = entry.items.map((i) => ({ name: i.name, result: i.result }));
|
||
assert.deepStrictEqual(names, [
|
||
{ name: 'First deferred item, still open.', result: 'unresolved' },
|
||
{ name: 'Second deferred item, still open.', result: 'unresolved' },
|
||
], describeAll());
|
||
assert.strictEqual(output.summary.total_items, 2, describeAll());
|
||
});
|
||
|
||
test('[GREEN] CONTROL: a CRLF deferred-items.md is unchanged (no double-count, no strip)', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, 'deferred-items.md'), deferredBody(CRLF));
|
||
|
||
const output = audit();
|
||
const describeAll = () => JSON.stringify(output, null, 2);
|
||
|
||
const entry = output.results.find((r) => r.file === 'deferred-items.md');
|
||
assert.ok(entry, `deferred-items.md entry absent: ${describeAll()}`);
|
||
const names = entry.items.map((i) => ({ name: i.name, result: i.result }));
|
||
assert.deepStrictEqual(names, [
|
||
{ name: 'First deferred item, still open.', result: 'unresolved' },
|
||
{ name: 'Second deferred item, still open.', result: 'unresolved' },
|
||
], describeAll());
|
||
assert.strictEqual(output.summary.total_items, 2, describeAll());
|
||
});
|
||
|
||
function verificationBody(eol) {
|
||
return [
|
||
'---',
|
||
'status: human_needed',
|
||
'phase: 02-auth',
|
||
'---',
|
||
'',
|
||
'## Human Verification',
|
||
'',
|
||
'1. Test SSO login with Google account',
|
||
'2. Test password reset flow end-to-end',
|
||
'',
|
||
].join(eol);
|
||
}
|
||
|
||
test('[RED] a lone-CR VERIFICATION.md (status: human_needed) surfaces both human-verification items by identity', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-auth');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '02-VERIFICATION.md'), verificationBody(CR));
|
||
|
||
const output = audit();
|
||
const describeAll = () => JSON.stringify(output, null, 2);
|
||
|
||
const entry = output.results.find((r) => r.type === 'verification');
|
||
assert.ok(entry, `VERIFICATION entry absent: ${describeAll()}`);
|
||
const names = entry.items.map((i) => i.name);
|
||
assert.deepStrictEqual(names, [
|
||
'Test SSO login with Google account',
|
||
'Test password reset flow end-to-end',
|
||
], describeAll());
|
||
assert.strictEqual(output.summary.total_items, 2, describeAll());
|
||
});
|
||
|
||
test('[GREEN] CONTROL: the LF twin of the same VERIFICATION.md surfaces the identical items', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-auth');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '02-VERIFICATION.md'), verificationBody(LF));
|
||
|
||
const output = audit();
|
||
const describeAll = () => JSON.stringify(output, null, 2);
|
||
|
||
const entry = output.results.find((r) => r.type === 'verification');
|
||
assert.ok(entry, `VERIFICATION entry absent: ${describeAll()}`);
|
||
const names = entry.items.map((i) => i.name);
|
||
assert.deepStrictEqual(names, [
|
||
'Test SSO login with Google account',
|
||
'Test password reset flow end-to-end',
|
||
], describeAll());
|
||
assert.strictEqual(output.summary.total_items, 2, describeAll());
|
||
});
|
||
|
||
test('[GREEN] CONTROL: a CRLF VERIFICATION.md is unchanged (no double-count, no strip)', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-auth');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '02-VERIFICATION.md'), verificationBody(CRLF));
|
||
|
||
const output = audit();
|
||
const describeAll = () => JSON.stringify(output, null, 2);
|
||
|
||
const entry = output.results.find((r) => r.type === 'verification');
|
||
assert.ok(entry, `VERIFICATION entry absent: ${describeAll()}`);
|
||
const names = entry.items.map((i) => i.name);
|
||
assert.deepStrictEqual(names, [
|
||
'Test SSO login with Google account',
|
||
'Test password reset flow end-to-end',
|
||
], describeAll());
|
||
assert.strictEqual(output.summary.total_items, 2, describeAll());
|
||
});
|
||
|
||
// The end-to-end phase carrying BOTH a VERIFICATION.md and a
|
||
// deferred-items.md, written twice from one source (LF and lone-CR),
|
||
// exercising ALL FOUR ingresses in one audit-uat run at once.
|
||
test('[RED] a phase with both VERIFICATION.md and deferred-items.md: lone-CR and LF produce identical audit output', () => {
|
||
function build(eol) {
|
||
const dir = createTempProject();
|
||
const phaseDir = path.join(dir, '.planning', 'phases', '03-combo');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '03-VERIFICATION.md'), verificationBody(eol));
|
||
fs.writeFileSync(path.join(phaseDir, 'deferred-items.md'), deferredBody(eol));
|
||
return dir;
|
||
}
|
||
|
||
const lfDir = build(LF);
|
||
const crDir = build(CR);
|
||
try {
|
||
const lfResult = runGsdTools('audit-uat --raw', lfDir);
|
||
const crResult = runGsdTools('audit-uat --raw', crDir);
|
||
assert.ok(lfResult.success, `LF run failed: ${lfResult.error}`);
|
||
assert.ok(crResult.success, `CR run failed: ${crResult.error}`);
|
||
const lfOutput = JSON.parse(lfResult.output);
|
||
const crOutput = JSON.parse(crResult.output);
|
||
const describeAll = () => JSON.stringify({ lf: lfOutput, cr: crOutput }, null, 2);
|
||
|
||
assert.strictEqual(lfOutput.summary.total_files, 2, describeAll());
|
||
assert.strictEqual(lfOutput.summary.total_items, 4, describeAll());
|
||
assert.strictEqual(crOutput.summary.total_files, lfOutput.summary.total_files, describeAll());
|
||
assert.strictEqual(crOutput.summary.total_items, lfOutput.summary.total_items, describeAll());
|
||
assert.strictEqual(crOutput.summary.parse_gap_files, lfOutput.summary.parse_gap_files, describeAll());
|
||
|
||
const rowIdentity = (r) => ({
|
||
type: r.type,
|
||
items: r.items.map((i) => ({ name: i.name, result: i.result })).sort((a, b) => a.name.localeCompare(b.name)),
|
||
});
|
||
assert.deepStrictEqual(
|
||
crOutput.results.map(rowIdentity).sort((a, b) => a.type.localeCompare(b.type)),
|
||
lfOutput.results.map(rowIdentity).sort((a, b) => a.type.localeCompare(b.type)),
|
||
describeAll(),
|
||
);
|
||
} finally {
|
||
cleanup(lfDir);
|
||
cleanup(crDir);
|
||
}
|
||
});
|
||
});
|
||
|
||
// #3078-CR: a column-0 line-terminator boundary defect in
|
||
// `parseUatItemsWithStats`'s `result:` scan, reproduced directly against the
|
||
// LIVE built copy (`../gsd-core/bin/lib/uat.cjs`, imported at the top of this
|
||
// file) rather than through a rebuilt fixture, so these rows fail against the
|
||
// ACTUAL shipped parser, not a stale mental model of it.
|
||
//
|
||
// DEFECT A (fixed): `/^result:.../im` treats U+2028 LINE SEPARATOR and U+2029
|
||
// PARAGRAPH SEPARATOR as line-start boundaries (native JS `/m` behaviour),
|
||
// but `normalizeLineEndings` (core-utils.cjs) folds only `\r`/`\r\n` and never
|
||
// touches U+2028/U+2029, and neither does `split('\n')` or the heading
|
||
// tokenizer. A `result:`-shaped line living INSIDE an `expected: |` scalar
|
||
// body, immediately after one of these separators, was therefore read as a
|
||
// real line start by the regex engine and — because `.match()` without `/g`
|
||
// returns the LEFTMOST match in the whole string — won over a genuine
|
||
// column-0 `result:` line appearing later, discarding it with no gap raised.
|
||
// Fixed by testing each `split('\n')`-produced line individually instead of
|
||
// running an `/m`-anchored regex over the whole block: `split('\n')` never
|
||
// treats U+2028/U+2029 as a delimiter, so neither can manufacture a line
|
||
// start.
|
||
//
|
||
// A "defect B" (more than one column-0 `result:` line reported as an
|
||
// ambiguous parse gap rather than resolving to the first) was attempted and
|
||
// REVERTED: its boundary-truncation heuristic mistook an indented `### N.`
|
||
// living inside a legitimate block scalar for a heading boundary, which
|
||
// broke every scalar/indent guard this module has (see the `#3078` scalar
|
||
// guard tests elsewhere in this file). Two column-0 `result:` lines resolve
|
||
// to the FIRST one — the pre-existing, pinned behaviour — see controls B3/B4
|
||
// below.
|
||
describe('parseUatItemsWithStats — result: line-scan boundary defects (#3078-CR)', () => {
|
||
const LINE_SEPARATOR = String.fromCharCode(0x2028);
|
||
const PARAGRAPH_SEPARATOR = String.fromCharCode(0x2029);
|
||
|
||
// Row 1 ("### 1. Alpha") by IDENTITY: test number AND name AND result.
|
||
// A bare count or a bare `result` check both pass for the wrong reason —
|
||
// e.g. a phantom row from the scalar's OWN `result: pass` clause matching
|
||
// `items.length === 1` just as readily as the real blocked row would.
|
||
function findAlphaBlocked(items) {
|
||
return items.find((i) => i.test === 1 && i.name === 'Alpha' && i.result === 'blocked');
|
||
}
|
||
function findAnyPassItem(items) {
|
||
return items.find((i) => i.result === 'pass' || i.result === 'passed');
|
||
}
|
||
|
||
function defectADoc(marker) {
|
||
// An `expected: |` scalar body whose text ends in "...x<marker>result:
|
||
// pass", followed by the block's REAL column-0 "result: blocked" line.
|
||
return '## Tests\n\n### 1. Alpha\nexpected: |\n x' + marker + 'result: pass\nresult: blocked\n';
|
||
}
|
||
|
||
test('[RED] A1: U+2028 inside an expected scalar must not swallow the column-0 blocked row', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(defectADoc(LINE_SEPARATOR));
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
|
||
assert.ok(findAlphaBlocked(items), `expected outstanding row 1/Alpha/blocked absent: ${describeAll()}`);
|
||
assert.strictEqual(findAnyPassItem(items), undefined, `a phantom pass item must not be emitted: ${describeAll()}`);
|
||
});
|
||
|
||
test('[RED] A2: U+2029 inside an expected scalar must not swallow the column-0 blocked row', () => {
|
||
const { items, headingsSeen } = parseUatItemsWithStats(defectADoc(PARAGRAPH_SEPARATOR));
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
|
||
assert.ok(findAlphaBlocked(items), `expected outstanding row 1/Alpha/blocked absent: ${describeAll()}`);
|
||
assert.strictEqual(findAnyPassItem(items), undefined, `a phantom pass item must not be emitted: ${describeAll()}`);
|
||
});
|
||
|
||
test('[CONTROL] A3: an ordinary (non-line-terminator) marker in the same position still yields the blocked row', () => {
|
||
// Proves A1/A2 are a SEPARATOR defect, not a content defect: swap the
|
||
// exotic separator for two literal "@@" characters, which JS never
|
||
// treats as a line terminator under any regex flag.
|
||
const { items, headingsSeen } = parseUatItemsWithStats(defectADoc('@@'));
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
|
||
assert.ok(findAlphaBlocked(items), `control document must still surface 1/Alpha/blocked: ${describeAll()}`);
|
||
assert.strictEqual(findAnyPassItem(items), undefined, describeAll());
|
||
});
|
||
|
||
test('[CONTROL] A4: U+2028 living in ordinary prose (not faking a line start) parses unaffected', () => {
|
||
// The separator sits between two prose words, never immediately before a
|
||
// "result:"-shaped token, so it cannot fake a line start that matters —
|
||
// this must parse exactly as it does today, both before and after any
|
||
// future fix to the boundary handling.
|
||
const doc = '## Tests\n\n### 1. Alpha\nexpected: |\n some prose' + LINE_SEPARATOR + 'continues here\nresult: blocked\n';
|
||
const { items, headingsSeen } = parseUatItemsWithStats(doc);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
|
||
assert.ok(findAlphaBlocked(items), `legitimate U+2028 content must not perturb parsing: ${describeAll()}`);
|
||
assert.strictEqual(items.length, 1, `no extra/phantom item may appear: ${describeAll()}`);
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
|
||
test('[CONTROL] B3: a block with exactly one result: line is unchanged', () => {
|
||
const doc = '### 1. Alpha\nexpected: ok\nresult: blocked\n';
|
||
const { items, headingsSeen } = parseUatItemsWithStats(doc);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.ok(findAlphaBlocked(items), `unambiguous single-result block must still surface 1/Alpha/blocked: ${describeAll()}`);
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
|
||
test('[CONTROL] B4: a result: line inside a fenced code sample does not count as a second column-0 occurrence', () => {
|
||
// The fenced "result: pass" sample line is document content (a fenced
|
||
// code block is stripped before the result-line scan runs), not a
|
||
// second real result declaration — only the genuine column-0
|
||
// "result: blocked" line below the fence is the row's outcome.
|
||
const doc = '### 1. Alpha\nexpected: ok\n```\nresult: pass\n```\nresult: blocked\n';
|
||
const { items, headingsSeen } = parseUatItemsWithStats(doc);
|
||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||
|
||
assert.strictEqual(items.length, 1, describeAll());
|
||
assert.ok(findAlphaBlocked(items), `fenced sample result: line must not compete with the real row: ${describeAll()}`);
|
||
assert.strictEqual(findAnyPassItem(items), undefined, describeAll());
|
||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||
});
|
||
|
||
test('[REGRESSION] a column-0 result: line whose trailing text contains U+2028 parses identically to its plain-LF twin', () => {
|
||
// Final review MINOR 1: the per-line pattern kept `.*$` after the fix
|
||
// above dropped `/m`, and `.` never matches U+2028/U+2029, so `$` was
|
||
// unreachable on a line whose TRAILING text (after the token) contained
|
||
// one of these separators — the line failed to match at all. Compare by
|
||
// IDENTITY (test number AND name AND result) against the plain-LF
|
||
// equivalent, not just presence/count, per this suite's own convention.
|
||
const withSeparator = '### 1. Alpha\nexpected: ok\nresult: blocked' + LINE_SEPARATOR + 'trailing note\n';
|
||
const plainLf = '### 1. Alpha\nexpected: ok\nresult: blocked trailing note\n';
|
||
|
||
const withSeparatorResult = parseUatItemsWithStats(withSeparator);
|
||
const plainLfResult = parseUatItemsWithStats(plainLf);
|
||
const describeAll = () => JSON.stringify({ withSeparatorResult, plainLfResult }, null, 2);
|
||
|
||
assert.ok(findAlphaBlocked(withSeparatorResult.items), `expected outstanding row 1/Alpha/blocked absent: ${describeAll()}`);
|
||
assert.deepStrictEqual(withSeparatorResult.items, plainLfResult.items, `must match the plain-LF twin by identity: ${describeAll()}`);
|
||
assert.strictEqual(withSeparatorResult.headingsSeen, plainLfResult.headingsSeen, describeAll());
|
||
});
|
||
});
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// #3740: acknowledgeDeferredItem must agree with the field extractor on
|
||
// what a status-field line is. The writer's search regex was marker-optional
|
||
// while the reader (extractGapEntryFields) deliberately strips a bullet
|
||
// marker on line 0 ONLY — a later `- ` line is a nested sub-list, not a
|
||
// field. Pre-fix, a nested ` - status: open` line was rewritten and `ok`
|
||
// returned, but no reader ever saw the rewrite: the entry stayed outstanding
|
||
// after a "successful" acknowledgement.
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
describe('#3740: acknowledge round-trips through the reader (parse → acknowledge → parse)', () => {
|
||
function roundTrip(body) {
|
||
const content = '## Deferred Items\n\n' + body + '\n';
|
||
const before = parseDeferredItemsWithStatus(content);
|
||
assert.equal(before.length, 1, `fixture must parse to exactly one entry, got ${before.length}`);
|
||
const ack = acknowledgeDeferredItem(content, before[0].name);
|
||
const after = parseDeferredItemsWithStatus(ack.content);
|
||
assert.equal(after.length, 1, 'acknowledged file must still parse to one entry');
|
||
return { ack, before: before[0], after: after[0], content: ack.content };
|
||
}
|
||
|
||
test('nested-marker status line: ack returns ok and the entry is no longer outstanding', () => {
|
||
const r = roundTrip('- alpha\n - status: open');
|
||
assert.equal(r.ack.status, 'ok');
|
||
assert.equal(r.after.status, 'acknowledged',
|
||
'#3740: a nested `- status:` line is not a field line to the reader; the ack must take the insert branch the reader parses');
|
||
});
|
||
|
||
test('nested-marker status line, CRLF variant: same outcome', () => {
|
||
const r = roundTrip('- alpha\r\n - status: open');
|
||
assert.equal(r.ack.status, 'ok');
|
||
assert.equal(r.after.status, 'acknowledged');
|
||
});
|
||
|
||
test('line-0 entry-line status (`- status: open`) is still rewritten in place', () => {
|
||
// The reader de-bullets line 0, so a status field on the entry line IS a
|
||
// real field there — and first-wins means the insert branch could never
|
||
// outrank it. The search must keep matching this shape (#3740 review).
|
||
const r = roundTrip('- status: open\n reason: flaky');
|
||
assert.equal(r.ack.status, 'ok');
|
||
assert.equal(r.after.status, 'acknowledged');
|
||
const statusLines = r.content.split('\n').filter((l) => /status:\s*/.test(l));
|
||
assert.equal(statusLines.length, 1, `exactly one status line must remain, got ${JSON.stringify(statusLines)}`);
|
||
});
|
||
|
||
test('control: marker-free status line is still rewritten in place, not duplicated', () => {
|
||
const r = roundTrip('- alpha\n status: open');
|
||
assert.equal(r.ack.status, 'ok');
|
||
assert.equal(r.after.status, 'acknowledged');
|
||
const statusLines = r.content.split('\n').filter((l) => /status:\s*/.test(l));
|
||
assert.equal(statusLines.length, 1, `exactly one status line must remain, got ${JSON.stringify(statusLines)}`);
|
||
assert.match(statusLines[0], /status:\s*acknowledged/);
|
||
});
|
||
|
||
test('control: entry with no status line keeps the insert branch', () => {
|
||
const r = roundTrip('- alpha');
|
||
assert.equal(r.ack.status, 'ok');
|
||
assert.equal(r.after.status, 'acknowledged');
|
||
});
|
||
});
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// #3781: acknowledge on the heading-delimited (#3457) entry shape. The
|
||
// writer refused EVERY entry in any heading-shaped deferred-items.md
|
||
// (`unsupported_heading_shape`) because the reader carried no character
|
||
// spans to anchor a write. The fix adds a span-carrying sibling of the
|
||
// heading walk; leaves rewrite in place / insert after their last
|
||
// non-blank line, and pending (preamble / container-direct) bullets reuse
|
||
// the headless span machinery with a baseOffset translation. Entries with
|
||
// an embedded GFM table row still refuse — a table line makes the span
|
||
// non-contiguous.
|
||
describe('#3781: acknowledge supports the heading-delimited entry shape', () => {
|
||
const leafDoc = [
|
||
'## Deferred Items',
|
||
'',
|
||
'### Finding one',
|
||
'- did a thing',
|
||
'- evidence gathered',
|
||
'',
|
||
].join('\n');
|
||
|
||
test('leaf entry without status acks via the insert branch the reader reads', () => {
|
||
const before = parseDeferredItemsWithStatus(leafDoc);
|
||
assert.equal(before.length, 1);
|
||
assert.equal(before[0].status, '');
|
||
|
||
const ack = acknowledgeDeferredItem(leafDoc, before[0].name);
|
||
assert.equal(ack.status, 'ok', `pre-fix this refused unsupported_heading_shape; got ${ack.status}`);
|
||
|
||
const after = parseDeferredItemsWithStatus(ack.content);
|
||
assert.equal(after.length, 1);
|
||
assert.equal(after[0].status, 'acknowledged', 'the entry must read acknowledged afterward');
|
||
|
||
// AC7 sentence trap: the marker goes after the LAST non-blank line, never
|
||
// spliced mid-entry after the heading line.
|
||
const lines = ack.content.split('\n');
|
||
const markerIdx = lines.findIndex((l) => /status: acknowledged/.test(l));
|
||
const lastBodyIdx = lines.findIndex((l) => l === '- evidence gathered');
|
||
assert.ok(markerIdx > lastBodyIdx, 'marker must follow the entry\'s last non-blank line');
|
||
assert.ok(lines.includes('- did a thing') && lines.includes('- evidence gathered'),
|
||
'the soft-wrapped body must remain intact');
|
||
});
|
||
|
||
test('leaf entry with an existing Status field is replaced in place', () => {
|
||
const doc = leafDoc.replace('- did a thing', '- **Status:** open\n- did a thing');
|
||
const before = parseDeferredItemsWithStatus(doc);
|
||
assert.equal(before[0].status, 'open');
|
||
|
||
const ack = acknowledgeDeferredItem(doc, before[0].name);
|
||
assert.equal(ack.status, 'ok');
|
||
|
||
const statusLines = ack.content.split('\n').filter((l) => /status:/i.test(l) && l.trim() !== '');
|
||
assert.equal(statusLines.length, 1, `exactly one status line, got ${JSON.stringify(statusLines)}`);
|
||
assert.match(statusLines[0], /\*\*Status:\*\*\s*acknowledged/);
|
||
assert.equal(parseDeferredItemsWithStatus(ack.content)[0].status, 'acknowledged');
|
||
});
|
||
|
||
test('pending bullets alongside heading entries ack without disturbing siblings', () => {
|
||
// Inner headings must be H3+ — a sibling H2 would end the level-bounded
|
||
// '## Deferred Items' section (collectSection), so the realistic shapes
|
||
// are H3 containers with H4 leaves.
|
||
const doc = [
|
||
'## Deferred Items',
|
||
'',
|
||
'- loose preamble item',
|
||
'',
|
||
'### Findings group',
|
||
'',
|
||
'- container-direct item',
|
||
'',
|
||
'#### Finding one',
|
||
'- did a thing',
|
||
'',
|
||
].join('\n');
|
||
const before = parseDeferredItemsWithStatus(doc);
|
||
assert.equal(before.length, 3, `fixture must parse to three entries, got ${JSON.stringify(before)}`);
|
||
|
||
const ackPreamble = acknowledgeDeferredItem(doc, 'loose preamble item');
|
||
assert.equal(ackPreamble.status, 'ok');
|
||
let items = parseDeferredItemsWithStatus(ackPreamble.content);
|
||
assert.equal(items[0].status, 'acknowledged');
|
||
assert.equal(items[1].status, '', 'container-direct sibling untouched');
|
||
assert.equal(items[2].status, '', 'leaf sibling untouched');
|
||
|
||
const ackContainerDirect = acknowledgeDeferredItem(ackPreamble.content, 'container-direct item');
|
||
assert.equal(ackContainerDirect.status, 'ok');
|
||
items = parseDeferredItemsWithStatus(ackContainerDirect.content);
|
||
assert.deepEqual(items.map((e) => e.status), ['acknowledged', 'acknowledged', ''],
|
||
'each pending bullet acks independently');
|
||
assert.ok(ackContainerDirect.content.includes('- did a thing'), 'leaf entry text untouched');
|
||
});
|
||
|
||
test('already_resolved / ambiguous / not_found semantics match the headless shape', () => {
|
||
const resolvedDoc = leafDoc.replace('- did a thing', '- **Status:** resolved\n- did a thing');
|
||
const resolved = acknowledgeDeferredItem(resolvedDoc, parseDeferredItemsWithStatus(resolvedDoc)[0].name);
|
||
assert.equal(resolved.status, 'already_resolved');
|
||
assert.equal(resolved.content, resolvedDoc, 'file unchanged');
|
||
|
||
const dupDoc = [
|
||
'## Deferred Items',
|
||
'',
|
||
'### Finding one',
|
||
'- did a thing',
|
||
'',
|
||
'### Finding two',
|
||
'- did a thing',
|
||
'',
|
||
].join('\n');
|
||
// Both leaves carry bullet `- did a thing`; identity text differs by heading,
|
||
// so force ambiguity with identical heading+body.
|
||
const dupDoc2 = [
|
||
'## Deferred Items',
|
||
'',
|
||
'### Same',
|
||
'- did a thing',
|
||
'',
|
||
'### Same',
|
||
'- did a thing',
|
||
'',
|
||
].join('\n');
|
||
const dup = acknowledgeDeferredItem(dupDoc2, parseDeferredItemsWithStatus(dupDoc2)[0].name);
|
||
assert.equal(dup.status, 'ambiguous');
|
||
assert.equal(dup.content, dupDoc2, 'file unchanged');
|
||
|
||
const missing = acknowledgeDeferredItem(leafDoc, 'no such entry');
|
||
assert.equal(missing.status, 'not_found');
|
||
assert.equal(missing.content, leafDoc);
|
||
void dupDoc;
|
||
});
|
||
|
||
test('entries with embedded GFM table rows still refuse', () => {
|
||
const doc = [
|
||
'## Deferred Items',
|
||
'',
|
||
'### Finding one',
|
||
'- did a thing',
|
||
'| a | b |',
|
||
'- more evidence',
|
||
'',
|
||
].join('\n');
|
||
const before = parseDeferredItemsWithStatus(doc);
|
||
// The leaf entry AND the table row itself (parseDeferredTableItems unions
|
||
// over the same section) both surface.
|
||
assert.equal(before.length, 2, 'fixture self-check: leaf entry + table row');
|
||
const ack = acknowledgeDeferredItem(doc, before[0].name);
|
||
assert.equal(ack.status, 'unsupported_heading_shape',
|
||
'a table line inside the entry body makes its span non-contiguous — refuse');
|
||
assert.equal(ack.content, doc, 'file unchanged');
|
||
});
|
||
|
||
test('a table row AFTER the entry\'s last line is outside its span — the write lands above it', () => {
|
||
// #3781 refused any leaf whose heading-to-next-heading range held a table
|
||
// row. The refusal exists because a row INSIDE the span makes it
|
||
// non-contiguous; a row after the last entry line is not inside anything,
|
||
// and refusing it halts `complete-milestone` over a write that is safe.
|
||
const doc = '## Deferred Items\n\n### Finding one\n- did a thing\n| x | y |\n';
|
||
const before = parseDeferredItemsWithStatus(doc);
|
||
assert.equal(before.length, 2, 'fixture self-check: leaf entry + table row');
|
||
const ack = acknowledgeDeferredItem(doc, before[0].name);
|
||
assert.equal(ack.status, 'ok');
|
||
assert.equal(ack.content, '## Deferred Items\n\n### Finding one\n- did a thing\n status: acknowledged\n| x | y |\n');
|
||
assert.equal(parseDeferredItemsWithStatus(ack.content)[0].status, 'acknowledged');
|
||
});
|
||
|
||
test('an entry whose body ENDS in a fence acks readably — the marker lands before the fence, never inside it (round 5, RV6.5)', () => {
|
||
// Unclosed fence: it runs to the entry's end, so "after the last
|
||
// non-blank line" is fence content — the reader never sees the marker.
|
||
const unclosed = '## Deferred Items\n\n### E\n- **What:** x\n```\ncode\n';
|
||
let ack = acknowledgeDeferredItem(unclosed, parseDeferredItemsWithStatus(unclosed)[0].name);
|
||
assert.equal(ack.status, 'ok');
|
||
assert.equal(ack.content, '## Deferred Items\n\n### E\n- **What:** x\n status: acknowledged\n```\ncode\n');
|
||
assert.equal(parseDeferredItemsWithStatus(ack.content)[0].status, 'acknowledged', 'the marker must be read back');
|
||
// Closed fence at the end of the body: same placement, for the same reason.
|
||
const closed = '## Deferred Items\n\n### E\n- **What:** x\n```\ncode\n```\n';
|
||
ack = acknowledgeDeferredItem(closed, parseDeferredItemsWithStatus(closed)[0].name);
|
||
assert.equal(ack.status, 'ok');
|
||
assert.equal(parseDeferredItemsWithStatus(ack.content)[0].status, 'acknowledged');
|
||
assert.ok(ack.content.includes('- **What:** x\n status: acknowledged\n```'), ack.content);
|
||
// A pending (preamble) entry ending in an unclosed fence before a heading.
|
||
const pending = '## Deferred Items\n\n- a\n```\ncode\n\n### E\n- b\n';
|
||
const items = parseDeferredItemsWithStatus(pending);
|
||
assert.equal(items.length, 2, JSON.stringify(items));
|
||
ack = acknowledgeDeferredItem(pending, items[0].name);
|
||
assert.equal(ack.status, 'ok');
|
||
assert.deepStrictEqual(parseDeferredItemsWithStatus(ack.content).map((i) => i.status), ['acknowledged', '']);
|
||
assert.ok(ack.content.startsWith('## Deferred Items\n\n- a\n status: acknowledged\n```\ncode\n'), ack.content);
|
||
});
|
||
|
||
test('a heading whose TEXT is a fence delimiter is a heading, not a fence (round 5, RV6.5)', () => {
|
||
// The entry-level fence scan saw line 0 (`\`\`\``, the heading text) as an
|
||
// opener: every body line was fenced, the reader read no field, and the
|
||
// writer's marker landed on a line nothing reads.
|
||
for (const delim of ['```', '~~~']) {
|
||
const doc = `## Deferred Items\n\n### ${delim}\n- x\n status: resolved\n`;
|
||
assert.deepStrictEqual(parseDeferredItemsWithStatus(doc).map((i) => i.status), ['resolved'], delim);
|
||
const open = `## Deferred Items\n\n### ${delim}\n- x\n`;
|
||
const ack = acknowledgeDeferredItem(open, parseDeferredItemsWithStatus(open)[0].name);
|
||
assert.equal(ack.status, 'ok', delim);
|
||
assert.equal(parseDeferredItemsWithStatus(ack.content)[0].status, 'acknowledged', delim);
|
||
}
|
||
});
|
||
|
||
test('leaf line-0 rewrite keeps a closing `#` sequence (round 5, RV6.5)', () => {
|
||
const doc = '## Deferred Items\n\n### status: open ###\n- did a thing\n';
|
||
const ack = acknowledgeDeferredItem(doc, parseDeferredItemsWithStatus(doc)[0].name);
|
||
assert.equal(ack.status, 'ok');
|
||
assert.ok(ack.content.includes('### status: acknowledged ###'), ack.content);
|
||
assert.equal(parseDeferredItemsWithStatus(ack.content)[0].status, 'acknowledged');
|
||
});
|
||
|
||
test('leaf line-0 corner: a heading whose text parses as a status field', () => {
|
||
const doc = '## Deferred Items\n\n### status: open\n- did a thing\n';
|
||
const before = parseDeferredItemsWithStatus(doc);
|
||
assert.equal(before.length, 1);
|
||
assert.equal(before[0].status, 'open', 'fixture self-check: the reader reads the heading text itself as the field');
|
||
|
||
const ack = acknowledgeDeferredItem(doc, before[0].name);
|
||
assert.equal(ack.status, 'ok');
|
||
assert.ok(ack.content.includes('### status: acknowledged'),
|
||
'the ATX prefix must be preserved on the rewritten heading line');
|
||
const after = parseDeferredItemsWithStatus(ack.content);
|
||
assert.equal(after[0].status, 'acknowledged', 'first-wins must read the rewritten heading text');
|
||
});
|
||
|
||
test('CRLF pending entry verifies and acks (review finding)', () => {
|
||
const doc = '## Deferred Items\r\n\r\n- alpha\r\n continuation line\r\n\r\n### Finding one\r\n- did a thing\r\n';
|
||
const before = parseDeferredItemsWithStatus(doc);
|
||
assert.equal(before.length, 2, 'fixture self-check: preamble pending + leaf');
|
||
|
||
const ack = acknowledgeDeferredItem(doc, before[0].name);
|
||
assert.equal(ack.status, 'ok', 'a CRLF pending entry must not false-refuse match_verification_failed');
|
||
const after = parseDeferredItemsWithStatus(ack.content);
|
||
assert.equal(after[0].status, 'acknowledged');
|
||
});
|
||
|
||
test('flat and mixed heading-depth files ack their leaf entries', () => {
|
||
const flatSection = '## Deferred Items\n\n### Notes\n\n#### Finding A\n- item one\n\n#### Finding B\n- item two\n';
|
||
let items = parseDeferredItemsWithStatus(flatSection);
|
||
assert.equal(items.length, 2, 'container group + two leaves');
|
||
const ackA = acknowledgeDeferredItem(flatSection, 'Finding A - item one');
|
||
assert.equal(ackA.status, 'ok');
|
||
assert.equal(parseDeferredItemsWithStatus(ackA.content).map((e) => e.status).filter(Boolean).length, 1,
|
||
'exactly one entry acknowledged');
|
||
|
||
const mixed = '## Deferred Items\n\n### Childless group\n- solo item\n\n### Parent group\n#### Child one\n- child item\n';
|
||
items = parseDeferredItemsWithStatus(mixed);
|
||
assert.ok(items.length >= 2, 'fixture self-check: mixed depths parse');
|
||
const ackSolo = acknowledgeDeferredItem(mixed, 'Childless group - solo item');
|
||
assert.equal(ackSolo.status, 'ok', 'a childless ## leaf alongside a ## container acks');
|
||
});
|
||
|
||
test('leaf body status line keeps its bullet marker and indent on rewrite', () => {
|
||
const doc = '## Deferred Items\n\n### Finding one\n- did a thing\n - **Status:** open\n';
|
||
const before = parseDeferredItemsWithStatus(doc);
|
||
assert.equal(before[0].status, 'open');
|
||
|
||
const ack = acknowledgeDeferredItem(doc, before[0].name);
|
||
assert.equal(ack.status, 'ok');
|
||
assert.ok(ack.content.includes(' - **Status:** acknowledged'),
|
||
`the raw line's bullet marker and indent must survive the rewrite; got:\n${ack.content}`);
|
||
});
|
||
|
||
test('fully-headless file is byte-for-byte unchanged by this feature', () => {
|
||
const doc = '## Deferred Items\n\n- alpha\n status: open\n';
|
||
const before = parseDeferredItemsWithStatus(doc);
|
||
const ack = acknowledgeDeferredItem(doc, before[0].name);
|
||
assert.equal(ack.status, 'ok');
|
||
assert.equal(ack.content, '## Deferred Items\n\n- alpha\n status: acknowledged\n',
|
||
'the pre-existing headless splice shape must be untouched');
|
||
});
|
||
});
|