* enhance(#3829): report code review severity counts and record a per-finding disposition `code_review_gate` extracted `status:` from REVIEW.md's frontmatter and discarded the `critical`/`warning`/`info`/`total` values sitting in the same range, so its output was byte-identical for a review with one `info` finding and a review with a Critical. Nothing anywhere recorded what happened to a finding: no file under `gsd-core/workflows/` branches on `issues_found`, and `gsd-verifier.md` has zero references to REVIEW.md. A phase therefore reached `phase.complete` with Criticals standing and no trace they had been seen. Both halves were approved on the issue; the gate stays advisory. A — severity surfacing, in `execute-phase.md`. The gate states the breakdown it already parsed, accepting `blocker:` as the documented tier-equivalent of `critical:`. The breakdown is shown only when all four counts are numeric (`REVIEW_COUNTS_OK`); otherwise the countless message stands, because gating on the total alone still emits `6 findings — critical` for a review carrying a total and nothing else. Frontmatter is extracted by an `awk` that emits only when it saw the CLOSING delimiter, after stripping CR. A `sed` range re-opens on a body `---` and runs to EOF: first-match protects a key the frontmatter always carries, but not an optional one, so a review with no `findings:` block and a body `total:` line would have reported the body's number. An unterminated block would leak the whole body the same way. Every read is guarded and `|| true`-terminated. This step is advisory, and under `set -e`/`pipefail` a non-matching `grep` exits 1 — an assignment whose command substitution fails would take the step down with it. A REVIEW.md that is missing, a directory, or unreadable now leaves the counts empty and execution continues. B — per-finding disposition, in a new lazily-read step file, `gsd-core/workflows/execute-phase/steps/code-review-disposition.md`, referenced from the gate in the established plain read-and-execute form. One row per finding ID, defaulting to `open`, and: - `fixed`/`skipped` are reconciled from REVIEW-FIX.md, whose section headings are matched WHOLE — a prefix match let `## Fixed Issues Verification` classify every finding beneath it as fixed — and only when the fix report names the SAME finding. Finding ids are reused across re-reviews, so matching on the id alone let a stale fix report declare a brand-new CR-01 already fixed. - headings inside fenced blocks are ignored; a quoted example is not a finding. - an id listed under both sections resolves by first occurrence, not row order. - a recorded disposition is preserved together with the reason in its Source cell, escaped pipes included, and a hand-mangled row missing its trailing pipe still keeps its decision. - a decided finding the current review no longer reports is CARRIED and marked; `--auto` rewrites REVIEW.md each iteration, so this is routine, and dropping the row would erase the record that it was seen. An untriaged `open` row for a vanished finding is not carried. A review reporting nothing still reconciles an existing ledger rather than freezing it. - a run that changes no disposition rewrites nothing, so a re-executed phase does not produce a docs commit whose only delta is a timestamp. The record is a sibling artifact, not a section inside REVIEW.md: `--auto`'s re-review loop rewrites REVIEW.md every iteration, so a ledger kept inside it would not survive the next pass, and REVIEW.md has a single writer that this step is not. B lives in an extracted step file because `execute-phase.md` was 91,493 bytes against a 98,304 hard cap the size-budget test calls a red line, and because `scanWiredKinds` caps a call site's dispatch-coverage region at 6000 characters — an inline version pushed the `kind == "gate"` paragraph out of that window, which silently drops `gate` from the covered set and fails `gen-capability-registry --check` while pointing at the capability rather than at the prose that displaced it. Extraction is what that size test's own message prescribes, and it leaves the file at 93,854 bytes. The tests execute the shipped script rather than modelling it. Three adversarial review rounds each refuted "the mirror is faithful", and mutation testing agreed: with a hand-written model, deleting the carried-row logic from the shipped file turned nothing red. The suite now extracts the embedded script — undoing exactly the four shell double-quote escapes — and runs it, so all ten mutations of its behaviour are caught. * chore(#3829): set changeset fragment pr to 3861 * fix(#3829): keep execute-phase.md under both size ceilings and propagate the launcher probe The first push failed `full test (macos-latest, 24, shard 3/3)`. Two things it caught that the CI-selected scope for this diff does not run, and that I therefore did not run either: 1. `execute-phase.md` is governed by TWO ceilings, not one. The XL hard cap in `tests/workflow-size-budget.test.cjs` (98304) was satisfied at 95179, but the frozen ADR-857 pre-phase-6 ceiling in `tests/claude-orchestration.test.cjs` (93600) was not. The whole budget from base is 2107 bytes, which the inline reporting half alone did not fit. That half now lives in the extracted step file alongside the disposition half, and the parent carries only the paragraph that reads and executes it — 91529 bytes, 36 over base. 2. The step file calls `gsd_run`, so it owes the hermes runtime-home probe that `tests/runtime-launcher-parity.test.cjs` (E) requires of every workflow file that does. Propagated with `node scripts/sync-runtime-launcher.cjs`, the remedy that test names. Verified with the FULL unit suite this time rather than the scoped selection — 14 shards, 0 failures — plus `npm run lint:ci`, and a re-run of the ten mutations of the shipped disposition script, all still caught. * fix(#3829): stop the disposition step instructing the agent to execute itself Blocker 1 and Minor 7 of the round-1 review are one defect. The step file carried a copy of execute-phase.md's pointer paragraph, so it named its own path as something to "read and execute" — unbounded self-recursion at runtime — and that copy is also the duplicated paragraph, sitting immediately above the full instruction it duplicates. Removing the copy resolves both. execute-phase.md remains the only surface that points here, which is what it always intended. Two structural tests guard it. Both are red against the pre-fix file: no behavioural test could see either defect, because they execute the node script through the process seam and so never read the prose that tells the agent what to load. * fix(#3829): re-derive the ledger paths in the block that uses them Blocker 2. The disposition block reads REVIEW_FILE, DISPOSITION_FILE and PADDED, all derived in the step's FIRST shell block. Each fenced block is dispatched as its own shell, so all three are empty by the time the second block runs: the ledger write lands on a bare `-REVIEW-DISPOSITION.md` path and the review read finds nothing. The step then reports success having produced no artifact — the feature's central acceptance criterion, silently unmet, with no error to notice. The tell was already in the file: the gsd_run shim preamble is re-emitted in the second block for exactly this reason. These three paths belong beside it, and now are. The guard test asserts the general property rather than the instance — every block derives what it reads, inheriting only the step's declared inputs (PHASE_DIR, PHASE_NUMBER) — so a third block added later cannot reintroduce it. Red against the pre-fix file. * test(#3829): assert the counts mirror against the shipped shell, and execute its guards Major 4, with Minor 6 and part of Minor 9. The disposition builder stopped being a mirror three rounds ago, and the reason given then was that a hand model of a shell-embedded script drifts while the tests stay green. parseGateCounts kept its mirror anyway. That argument does not stop applying at the boundary between the step's two shell blocks, so the mirror now loses its authority: it is asserted against the shipped awk and greps, run under `set -euo pipefail` in a real shell, across every fixture it is exercised on. Negative-controlled in both directions. Dropping `blocker:` from the mirror alone fails the parity test; replacing the shipped awk with the leaky `sed -n '/^---$/,/^---$/p'` range fails it on the unterminated-frontmatter fixture. Divergence in either half is now red, which is what the finding asks for. Skipped on win32, where there is no bash to compare against. Minor 6: the zero-count edge is covered — `0` is numeric, so a zero-finding review reports `0 findings — 0 critical, …` rather than falling back to the countless form. A guard written against truthiness would have failed here silently, and now cannot. Minor 9, partially: running the block makes its advisory guards behavioural, so the four `src.includes()` assertions that stood in for them are retired — a missing and an unreadable REVIEW.md are now proven not to abort under `set -e`, rather than asserted to contain a string. The remaining docs-parity assertions are kept deliberately; see the PR discussion. * test(#3829): add the render/re-parse fixed-point property for the ledger Major 3. RULESET.TESTS.property-based-testing asks for at least one fc property on a parsing/transformation contract, and the ledger is one with a fixed point stated in its own prose: re-running the gate preserves every disposition except `open`, and rewrites nothing when nothing changed. Two properties, both driving the SHIPPED script rather than a model of it: idempotency — a second run reports `unchanged` and leaves the file byte-identical. Without it, the timestamp alone dirties the tree on every phase re-run. round-trip — a hand-recorded decision AND the reason beside it survive render -> re-parse -> render, escaped pipes included. The Source cell is where a human writes why something was deferred, so losing it loses the only thing that instruction asks for. Negative-controlled per property: disabling the unchanged-check fails the first and only the first; discarding the carried source cell fails the second and only the second. numRuns is 40 rather than the shared 200 because each case spawns the shipped script twice through the process seam. The seed stays pinned, so a failure still reproduces; the deviation is stated in the file header rather than made silently. * fix(#3829): state a stale fix-report match instead of dropping it silently Minor 5, plus the finding-id census this round owes. Exact-title coupling stays — ids are reused across re-reviews, so a stale REVIEW-FIX.md must not mark a brand-new CR-01 as already fixed. What changes is the silence. A row that stays `open` because the report named a different finding under the same id is indistinguishable, to any reader, from a row that stays open because no report mentioned it. The gate now names the ids it could not reconcile, on both report paths, and stays advisory throughout. The census (RV4, self-found — the review did not ask for this). The script enumerates finding-id prefixes in three places: the heading matcher, the ledger re-parser, and the severity map's keys. The DOMAIN those enumerate is owned elsewhere — gsd-code-reviewer.md's body template and its Label-equivalence paragraph — so it can acquire a member without this script changing. Reached: CR, BL, WR, IN — 4 of 4, all present. Not reached: none today. What follows if that changes is the payload: an unlisted prefix is not mis-tiered, it is INVISIBLE — the finding never enters the order list and gets no row at all, so the artifact silently under-reports the review it is meant to record. Adding a prefix to two of the three copies fails the same way, and additionally drops carried rows on the next run. Two guards rather than a rewrite: hoisting the alternation into one constant means rebuilding three regexes inside a double-quoted shell string, which is the exact class of edit that produced both of this round's blockers. The guards make the drift loud instead, and are negative-controlled against each of the two ways it can happen. * docs(#3829): keep the feature reference descriptive, not instructional Minor 8 — a Diataxis mode mix. "Set `deferred` by hand and put the reason in the Source cell" is a how-to instruction sitting in a reference doc. The information belongs there (a reader needs to know the field exists and what preserves it); the imperative does not. Rewritten to describe the field instead: `deferred` is the one disposition the gate never writes, and the reason recorded beside it survives re-runs. The same pass records Minor 5's new behaviour, since the reference described the title coupling but not what happens when it misses. The imperative form is kept where it belongs — inside the ledger the gate renders, which is where a reader meets the field and the only place an instruction has an audience. docs/FEATURES.md regenerated from it; `gen-features.cjs --check` is green. * fix(#3829): close six defects found by reviewing this round's own fixes None of these came from the maintainer's review. They came from adversarially reviewing the five commits above before pushing them, and two are worse than anything the round was opened to fix. 1. A foreign fence marker swapped an example for a finding. The heading scanner toggled fenced/not-fenced on ANY fence marker, so a ~~~ line inside a ``` example closed the fence and the example's real close reopened one. Driven: a review quoting ~~~ inside a fenced example produced a ledger recording CR-77, the illustration, and omitting CR-01, the actual finding. A confidently-written artifact wrong in both directions at once. The open marker's character and length are now remembered, and a fence closes only on the same character at least as long, per CommonMark. 2. The disposition block had no status gate at all. The prose above it says it runs only when the review reports issues — but block 1 computes REVIEW_STATUS, emits nothing, and its shell is discarded, so no later block could act on that condition even in principle. A prose gate on a value nothing downstream can see is not a gate, and a clean re-review would rewrite a ledger it was never meant to touch. Re-derived in block 2's own shell. 3. A numeric breakdown could still be internally false. `total: 0` beside `critical: 1` is four valid numbers rendering `0 findings — 1 critical, …`. Numeric was necessary and not sufficient; an inconsistent breakdown is now withheld for the same reason a partial one is. 4. The carried-marker strip ate hand-written prose. It removed a trailing `(not in the current review)` unboundedly and unconditionally, so a deferral reason that merely ENDED in that phrase lost it — the one field a human writes into this artifact. Now bounded to one occurrence, and only on rows the marker can legitimately be on. The no-growth property it exists for is re-pinned. 5. parseGateCounts diverged from the shipped pipeline in two ways no fixture reached. The shipped reads are `cut -d: -f2 | tr -d ' '`: `tr` removes INTERNAL spaces (`1 0` -> `10`) where `.trim()` keeps them, and `cut` takes only the second colon-field where a tail capture keeps the rest. The mirror models the pipeline now, and both counterexamples are fixtures — a parity assertion that agrees only on well-formed input asserts very little. 6. The prefix census guards were both partly vacuous. The drift guard read the two regex alternations and not the severity map, so a set could agree in both regexes while mis-tiering in the map. The domain guard scanned only `### XX-01:` headings — and BL appears in no heading at all, only in the Label-equivalence prose, so the guard passed purely because BL happened to be hard-coded and would have missed the next prose-defined prefix exactly as it missed BL. Both widened; the domain the guard now sees is BL, CR, IN, WR. Each fix fails a named test on reversion and none fires on the ordinary path. The property generator now deliberately produces the reserved suffix from (4), which a generator drawn only from innocuous characters could never reach. Also corrected: the previous commit's account of the empty-path failure. The script did not write a bare `-REVIEW-DISPOSITION.md`; it threw on reading the empty review path and the trailing `|| echo` swallowed it as a non-blocking skip. Same silent outcome, different mechanism, and the comment said the wrong one. * fix(#3829): the tests now run what bash runs — and six fixes to the fixes A second adversarial pass over the previous commit. It found a regression that commit introduced, and the reason it slipped through is the finding worth keeping. THE FIDELITY GAP. Every test here extracts the embedded script as TEXT and runs it. Bash does not: it expands the double-quoted `node -e "..."` argument first, so a backtick inside it is COMMAND SUBSTITUTION. The previous commit put one in a code comment. Bash duly ran it, failed with `+: command not found`, and handed Node a script two bytes shorter than the one 122 green tests were exercising. No behavioural test could see this, because none of them ever asked bash what it would actually pass. One now does, and it is the general guard: it catches an unescaped backtick, an unescaped $, and any other expansion the extractor cannot model. Then, in the shipped step: - A padded count silently disabled the sum check. `$((08 + …))` fails on base inference; it does not abort — the expansion sits in an `if` condition, where set -e does not fire — so the check simply never ran and an inconsistent breakdown passed with a stray diagnostic as its only trace. `10#` on every operand. - The status guard made the script's own reconciliation unreachable. A clean review with an EXISTING ledger must still be reconciled — decided rows carried, stale `open` rows dropped — or the ledger freezes showing findings as open that the review no longer reports. The guard now skips only when there is nothing to reconcile. - The carried marker is no longer stripped at parse time at all. Bounding the strip still ate a carried row's human-written reason. No-growth is a property of the RENDER, so it is enforced there: a marker already present is not appended again. Nothing is stripped, nothing doubles. - Fence openers are bounded to three leading spaces, per CommonMark. - parseGateCounts matched `[ \t]` where the shipped grep uses `[[:space:]]`, which covers form feed and vertical tab. Third counterexample of the same class, and a fixture. - The census drift guard checked only one direction, so a tier for a prefix the regexes never admit stayed green as dead code that reads as coverage. TWO OF MY OWN TESTS WERE VACUOUS, and the controls are what said so. The leading-zero test asserted exit 0 and a consistent verdict — both true before the fix. The clean-review test drove the node script directly, which never executes the shell guard at all: it passed unchanged with the guard made unconditional. Both are rewritten to test the layer the defect lives on, and both now fail when their fix is reverted. Every fix in this commit fails a named test on reversion, each mutation verified to have applied before its verdict was read. * fix(#3829): the carried marker can no longer outlive the carry A third adversarial pass. Its most important finding is a defect the SECOND pass talked me into, which is worth recording as plainly as the fix. THE MARKER BECAME A LIE. Pass 2 objected that bounding the carried-marker strip still altered a human-written reason, and proposed storing the cell verbatim instead. That objection was a preference, not a defect — its own driven output showed exactly one marker, which is correct — and adopting it created a real one: once the generated marker is stored it can never leave, so a carried finding that REAPPEARS in a later review still renders "not in the current review". The ledger then contradicts its own contents. Driven both runs. The strip is back, bounded to one occurrence and unconditional. The residual ambiguity is irreducible — a reason ending in exactly that phrase is indistinguishable from the marker — and it costs nothing real: on a carried row the render puts the phrase straight back, and on a current row the phrase was self-contradictory to begin with. The unbounded quantifier is what had to go, not the strip. The property now states that contract rather than asserting a verbatim survival the code deliberately does not provide. Also: - An ABSENT REVIEW.md abandoned the ledger it was meant to reconcile. The guard proceeds when a ledger exists, then the script read the review unconditionally, threw, and the trailing fallback swallowed it — the freeze the reconciliation path exists to prevent, reached through the door the guard opened. - Counts are length-bounded as well as digit-only. Bash integers wrap at 2^64, so a 20-digit count arrived at the sum as 0 and an inconsistent breakdown passed. - A closing fence must carry only whitespace after its marker; a line with an info string is an opener's shape and ended the fence early. - parseGateCounts matched [ \t\n\v\f\r] where the shipped grep uses [[:space:]], which under this UTF-8 locale matches EM SPACE. `\s` is the faithful model. Fourth counterexample of that class, and a fixture. - The agent-domain scan required [A-Z]{2,}, so a one-letter prefix like `C-01` — explicit and parseable, not prose — was invisible to it. AND THE FIDELITY GUARD PAID FOR ITSELF INSIDE ONE SESSION: writing this round's first draft I put backticks around a token in a code comment again, in the very commit whose subject is that mistake. The probe failed, named it, and no test of behaviour could have. Two of my own tests also had to be rewritten: one asserted things true before its fix, and one drove the node script directly where the defect lived in the shell. 383 pass across the touched files and the two size ceilings; ten lint gates green; every fix fails a named test on reversion, each mutation verified to have applied before its verdict was read. * docs(#3829): the Source reason is preserved, but not verbatim — say so Found by claim-auditing the response comment before posting it, which is the one place this would have been caught: the doc and the code were written in different commits and only a reader holding both notices they disagree. The feature reference said the hand-written reason is "preserved verbatim across re-runs". It is not, and deliberately so — a reason ending in the literal phrase "(not in the current review)" loses that trailing phrase, because it is indistinguishable from the carried marker the gate appends. The exception is stated rather than dropped, with the reason it is the better trade: storing the marker instead means it never leaves, and a carried finding that later reappears goes on claiming it is absent from the very review that reports it. A ledger wrong about its own contents beats losing a duplicated phrase, but only if the doc admits which one it chose. FEATURES.md regenerated; gen-features --check and lint:docs green. * fix(#3829): the gate now emits the counts it computes (B1a/B1b) Block 1 computed REVIEW_STATUS and the four counts and printed none of them, then the prose below asked the agent to display four of them. The shell exits at the closing fence and the agent sees only stdout, so those values were unobtainable: REQ-REVIEW-08 was unreachable in every shipped path and the fence was decorative. The rule was already stated one block down -- "a prose-only gate on a value no later block can see is not a gate" -- and applied only to block 2. It now governs the block that is this step's primary deliverable. Both arms emit, and the status gate is mechanical rather than prose: a clean/skipped/absent review prints nothing, an inconsistent or partial breakdown prints the countless form, and the full breakdown prints otherwise. Driven against the review's own case (critical: 1, warning: 9, info: 8, total: 18) with no appended emitter: Code review: 18 findings - 1 critical, 9 warning, 8 info. Consider running: /gsd:code-review 1 --fix * test(#3829): the counts harness stops manufacturing the output it asserts on (B2) runShippedGateCounts extracted the shipped fence and then APPENDED its own printf of the six internal variables before running it. Every counts assertion was green against a script that existed only inside the test process: the shipped fence emitted nothing, the tested fence emitted six lines because the test added them. That is why B1a shipped past a suite that looks like it covers exactly that surface -- the green was structurally incapable of turning red for it. The emitter now lives in the fence, so the harness reads the fence's own stdout and synthesizes nothing. Parity with the mirror moved up a level with it: renderGateMessage() renders both arms from the mirror's parsed counts and the assertion compares the WHOLE emitted message, so a drift in any parsed value changes the string or the arm it selects. Asserting on the observable is strictly stronger than asserting on five intermediates, and it can express what the old probe could not -- an absent review now reports NOTHING, which is a different fact from reporting a countless review. A fifth src.includes() assertion converted with it (round 1 retired four). It pinned the PROSE stating the countless condition, so it went red when the emitter moved into the fence while the behaviour it named was untouched -- the pin arguing for its own conversion. Negative control: reverting the shipped echo now turns 16 tests red. Before this commit the same reversion turned zero red, which is the finding. * fix(#3829): the disposition column is an enum, not any lowercase token (B3) ADR-227 requires a trust boundary to validate semantic SHAPE and to coerce a failure to the contract's safe default. The ledger is a trust boundary by construction -- the rendered instruction tells a human to hand-edit it -- and the prior-row parser captured column 3 as ([a-z]+), checked against nothing. One transposed character was enough. `| CR-01 | critical | opne | - |` is not the literal 'open', so it beat the default, was excluded from the `open:` headline count, and was carried forward forever. The ledger then reported the phase fully triaged off a typo. The asymmetry is what made this a correctness bug rather than a style point: a typo OUTSIDE [a-z] ('Deferred') already failed to match, lost the decision and reset the row to open -- safe. A typo INSIDE [a-z] was unsafe. The parser failed open in the one direction that matters. A row that fails the enum now yields no prior entry and the row falls back to 'open', by the same path the capital-D case already took. The property test could not have caught this: DECIDED is drawn from the vocabulary, so no property built on it can present an out-of-vocabulary token. Added JUNK, the arbitrary for the complement, deliberately lowercase so it stays inside the old capture's own character set -- the unsafe half is the token that LOOKS like a decision and is not. The new property also asserts the headline count agrees with the row it renders, which is the half the defect actually reported wrongly. Negative control: the new property fails against the ([a-z]+) capture and passes against the enum. * fix(#3829): a finding the heading parser cannot match is surfaced, not dropped (B4) Two independent parsers produce two numbers one paragraph apart -- the counts from REVIEW.md's frontmatter, the rows from `### <ID>:` heading matches against a closed CR|BL|WR|IN alternation -- and nothing reconciled them. A finding the alternation could not reach contributed no row, no note and no diagnostic, and the ledger then declared `open: 3 of 3` over a set strictly smaller than the console line had reported one paragraph earlier. Two findings recorded nowhere, and neither artifact said so. The PR's own argument for the closed alternation -- that an unlisted prefix produces no row rather than a MIS-CLASSIFIED one -- is the wrong trade under this repo's fail-safe rule. A dropped finding is demoted below every finding that parsed, and an unparseable finding is precisely the one a human most needs to see. Block 2 now derives the frontmatter total (anchored inside the findings: mapping, digit-and-length-bounded like block 1's) and hands it to the script, which reconciles it against the CURRENT review's matched findings -- order.length, never rows.length, which also counts carried rows and would either understate the shortfall or invent one. Surfaced exactly as the stale fix-report case already is: a non-blocking `unparsed: N` key plus the console line, both naming the two numbers so the claim is checkable. Code review disposition recorded: 3 of 3 finding(s) open (2 finding(s) recorded NOWHERE: the review reports 5, but only 3 matched the expected heading shape `### <CR|BL|WR|IN>-NN: <title>`) The key is emitted only when there IS a shortfall, so an ordinary ledger gains no noise key and the unchanged-run check is unaffected. Four tests, including three negative controls the round owed itself: a clean review gains no key, an absent/non-numeric total reconciles nothing rather than fabricating a shortfall, and a total SMALLER than the row count cannot render `unparsed: -1`. Reversion control: dropping the key turns the first red. * fix(#3829): pass --raw to the commit_docs config-get (#3763) Not from the review -- from a gate the base range added after it. #3763 lands `tests/config-get-raw-guard.test.cjs`, and this branch was its sole offender: a config-get command substitution without --raw feeds JSON.stringify output into a bash string comparison, where it silently never matches for string values. The consumer here is exactly that: if [ "$COMMIT_DOCS" = "true" ] Every other shipped call site in the tree already passes --raw (spike.md, fast.md, new-milestone.md, sketch-wrap-up.md, ...), so this is sibling convention, not a new posture. Worth recording because the two readings are both correct and they disagree: round 2's review cleared this exact line under ADR-3409 as "the safe member of that family", since `query config-get <key>` with no --pick exits 1 on absence and the fallback arm is reachable. That is still true -- --raw does not change it. The base then moved and added a gate that reads the same line for a different property. * fix(#3829): scope the count reads to the findings: mapping, not just the frontmatter (m1) `^[[:space:]]*total:` matches any indented key anywhere in the block, so a top-level key later named `total:`, `info:` or `critical:` was picked up ahead of the nested one. The block's own extensive comment is about scoping the FRONTMATTER, and the scoping stopped one level short of the mapping the values actually belong to. `status:` was never exposed -- it is anchored to column 0 because it IS top-level. The reads now run over the `findings:` block alone, selected by awk and cut at the next column-0 key. Block 2's REVIEW_TOTAL derivation (added with B4) already used that filter; this brings block 1 to it, so the two agree by construction rather than by coincidence. The mirror models the same scoping, and two fixtures drive it: a top-level `total: 999` ahead of a nested `total: 1`, and top-level `critical:`/`info:` ahead of theirs. Reversion control: unanchoring the shipped reads turns them red. * fix(#3829): severity comes from the section heading, not just the id prefix (M3) gsd-code-reviewer.md emits findings under '## Critical Issues' / '## Warnings' / '## Info', and that heading is the reviewer's own statement of a finding's severity. The walker already visits every line -- the fix-report path tracks '## ' sections -- so the signal was in hand and discarded in favour of the id prefix alone. A reviewer who mis-numbers a Critical as WR-04 while filing it under '## Critical Issues' produced a row reading 'warning'. The ledger's Severity column is the whole basis for triaging it, and it then disagreed both with the review it summarizes and with the frontmatter count line block 1 prints from findings.critical. Section first, prefix as fallback: a finding under no recognized section -- a review that does not use the documented headings, and every row carried from an earlier review -- keeps the prefix mapping, BL- included. Sections are matched WHOLE, exactly as the fix-report sections are, so '## Critical Issues Verification' does not re-tier what sits under it, and a heading inside a fenced example does not govern. Five tests: both mis-numbering directions, the prefix fallback across all four prefixes, the lookalike heading, and the fenced-example case. Reversion control: prefix-only turns the first two red. Sixth src.includes() assertion converted with it -- it pinned the exact source LINE of the enumeration loop, so it went red when that loop was reformatted while the property it names was strictly widened. It now asserts the property: every finding id, in order, once each. * fix(#3829): an untriaged row is carried too, not silently deleted (M1) The carry-forward kept a prior row only when its disposition was not 'open', so an untriaged row for a finding the current review no longer reports was dropped entirely. Combined with the reconciliation gap that left EVERY row open, a re-review deleted the whole ledger. The re-review loop rewrites REVIEW.md on every iteration, so REVIEW.md does not retain it either: run 1 records CR-01 open, the re-review renumbers it to CR-02, run 2's ledger contains neither. That is #3829's complaint verbatim -- "no trace of what happened to them" -- reproduced by the artifact built to prevent it. The old justification, "nothing was decided about it", is exactly the state #3829 says must leave a trace. Every prior row is now carried, and the carried marker is what keeps it honest: the row does not claim the finding is live, it records that it was seen and never triaged. Two costs, stated rather than discovered: a renumbered finding shows twice until the old row is triaged, and a carried untriaged row persists until decided. Both are bounded by the phase's own findings, both are legible from the marker, and both beat a silent delete. Five tests updated -- they encoded the dropped-untriaged behaviour as the contract -- plus one new test for the renumbering case M1 names. Reversion control: restoring the guard turns six red. Two self-inflicted defects caught while writing this, both by probes round 1 built: - Four unescaped backticks in a comment inside the double-quoted node -e argument, which bash ran as command substitution. The extractor-parity probe fired ("--auto: command not found"). Third time that trap has been sprung in this PR, third time the probe caught it. - The reworded ledger footer contained the literal carried-marker phrase, and the marker-accumulation assertion counts it across the whole file, so a doc line read as a second marker. The assertion was right. * test(#3829): cover the count-length threshold at limit-1, limit and limit+1 (M2) The guard is `?????????*` -- nine or more characters -- so the limit is 8 digits accepted, 9 rejected. The only cases were 'x', single digits and a 20-digit value, none of which pins the boundary. RULESET.TESTS boundary-coverage is a hard rule here and it was unmet. All three points asserted, with the sum kept consistent at each so the LENGTH rule is what decides the verdict rather than the sum check incidentally agreeing. Reversion control is the off-by-one M2 names: dropping one `?` moves the limit to 7 digits, which no test could previously notice, and now turns this one red. * feat(#3829): wire the disposition ledger into the fix path (B1c/B1d) REQ-REVIEW-09 was unreachable in every shipped path. execute-phase.md's code_review_gate invokes review with neither --fix nor --auto, so <NN>-REVIEW-FIX.md cannot exist when the gate runs and every row it writes is `open` by construction. The operator then runs /gsd:code-review N --fix by hand -- the very suggestion the step prints -- which writes REVIEW-FIX.md and never touched the ledger. A phase with 23 findings, all fixed, ended at `open: 23 / total: 23`: the artifact that exists to distinguish a triaged finding from a forgotten one asserted that 23 triaged findings were forgotten. Worse than recording nothing, because it looks authoritative and is inverted. Taking remedy (i), not (ii). Narrowing the docs to say the ledger reflects the previous phase execution is a legitimate choice, but it ships a feature whose central artifact is inert and then documents the inertness. ONE ADAPTATION, because the prescribed site does not exist. The review says to wire code-review.md's --fix/--auto path. code-review.md is not the writer (gsd-code-fixer writes the report, code-review-fix.md commits it), and more decisively it has no point that is AFTER the report exists: it delegates through code-review/steps/dispatch-fix.md, which calls Workflow(code-review-fix.md) and then exits the workflow. There is nothing downstream of that call to wire to. The site is code-review-fix.md, immediately after commit_fix_report. That is where the report is on disk and committed, it is the canonical implementation for all fix logic by dispatch-fix.md's own statement, and it additionally covers a direct invocation of that workflow -- which a wiring in code-review.md would have missed. The same step, not a second copy: it consumes PHASE_DIR and PHASE_NUMBER, both already parsed from the init JSON, and it is idempotent, so a phase that reaches the gate and then a fix run ends with one ledger reflecting both rather than two competing ones. Driven end to end: the gate writes `open: 2 of 2`, the fix path reconciles to fixed/skipped and `open: 0`. Two tests -- one pins the wiring and its ordering relative to commit_fix_report and present_results, one drives the two call sites in sequence. Reversion control: removing the step turns the first red; the second covers the reconciliation the wiring makes reachable rather than the wiring itself. * fix(#3829): a reflowed fix-report title is the same title (m2) The stale-fix-report guard compared titles with trim() equality. The strict instinct is right -- ids are reused across re-reviews, so a stale REVIEW-FIX.md must not mark a brand-new CR-01 as already fixed -- but gsd-code-fixer.md writes '### {finding_id}: {title}' under no contract that the title is copied byte-for-byte from REVIEW.md. A fixer that reflows a long title produced a spurious mismatch note, left a genuinely-fixed row 'open', and told the reader the report named a different finding. That false-positive mode was acknowledged nowhere. Whitespace is normalized, and only whitespace: a wrapped title is the same title, and it is the one divergence that carries no information. Case changes and truncation stay strict on purpose -- they are the shapes a genuinely DIFFERENT finding takes, and widening to them would trade a visible false positive for the silent false negative the strict match exists to prevent. The residual is now stated in the step rather than left to be rediscovered. The note's wording changed with it. It asserted the report "names a different finding"; both causes reach that branch and the step cannot tell them apart, so it now reports the observation -- "titles its finding differently from the review ... a stale report, or a re-titled one" -- rather than a conclusion it has not earned. Three tests: the reflow case reconciles cleanly, the re-cased case still reports, and the stale case still reports with the new wording. Reversion control: restoring the strict comparison turns the reflow test red. Seventh src.includes() converted -- it pinned the comparison EXPRESSION, so it went red when the comparison gained normalization while the property it names was unchanged. * docs(#3829): describe the flow that ships, not the one implied (m3) Both reference pages said "/gsd-code-review <N> --fix records fixed and skipped, which the gate reconciles from REVIEW-FIX.md" -- true in the abstract, materially misleading in practice, because no shipped path performed that reconciliation. With B1c/B1d wired it is now real, and the pages say WHERE it happens rather than leaving a reader to assume the in-phase gate does it: the gate runs before any fix report exists and writes all-open, and --fix is what records what happened. The round's other behaviour changes land here too, since a reference page that lags the artifact is worse than none: - the disposition column is a closed vocabulary, and a value outside it falls back to open rather than being treated as a decision - severity comes from the section heading when the review uses one, and from the ID prefix otherwise - an unparsed shortfall is stated rather than dropped - titles are compared ignoring whitespace, so a reflowed title still reconciles, and a mismatch is reported as an observation rather than as a claim that the report is stale - EVERY row is carried now, triaged or not, with the cost of the renumbered-finding double-entry stated rather than left to be found docs/FEATURES.md regenerated from the fragment; lint:generated-sync and lint:docs both exit 0. * chore(#3829): migrate the emitted-drift ack from a fragment to commit trailers ADR-3942 landed on next in #3954: the acknowledgment is a git commit trailer now, and tests/emitted-drift-acks/ no longer exists. Worth noting for anyone reading the rebase: this did NOT surface as the modify/delete conflict the migration guidance predicts. This branch ADDED its fragment rather than modifying an existing one, and the base deleted only the files that were already there, so the replay was clean and the fragment survived silently into a directory that no longer exists. Quieter than a conflict, and worse -- the gate is what catches it, not git. Two Growth keys rather than the fragment's one: round 2 wired the ledger into code-review-fix.md, so that file grew too. Both key on the bare filename, per the Growth namespace. Emitted-Drift-Ack-Growth: code-review-fix.md — #3829 review round 2, blocker 1c/1d: REQ-REVIEW-09 was unreachable in every shipped path because the in-phase gate runs before any REVIEW-FIX.md exists, so every ledger row it wrote was open and nothing ever reconciled them. This file gains one step, record_disposition, that reads and executes the same lazily-read step after commit_fix_report. It is the only point in the fix flow that is after the report is on disk: code-review.md delegates here through steps/dispatch-fix.md and exits, so it has no such point at all. Growth is one step of prose, no logic is duplicated, and the step is idempotent so the two call sites converge on one ledger. * chore(#3829): the changeset describes the round's behaviour, not round 1's It renders into CHANGELOG, so it carries the same misleading implication minor 3 was about: "the gate ... reconciling fixed/skipped from REVIEW-FIX.md" reads as though the in-phase gate does it, when the gate runs before any fix report exists. Says where it happens, and picks up the round's other user-visible changes -- carried untriaged rows, section-based severity, the disposition vocabulary, and the unparsed shortfall. * fix(#3829): a dotted phase number no longer aborts the step Found by this round's own adversarial review, in its MISSED section: no finding asked about it, and it is the most serious thing in the round after the two blockers. Both callers explicitly accept a dotted phase -- code-review.md:60 and code-review-fix.md:36 both validate ^[0-9]+(\.[0-9]+)?$ and name "03.1" in their own error text -- and both fences reconstructed the path with `printf "%02d" "${PHASE_NUMBER}"`, which cannot format one. Driven with PHASE_NUMBER=3.1: bash prints `invalid number` and exits 1, and under `set -euo pipefail` that aborts the step on its FIRST line. An advisory gate that promises never to block took the phase's entire review report down with it, and the newly wired fix-path call site inherited the same defect. Pad the integer part and carry the sub-number verbatim, so 3.1 -> 03.1 and 3 -> 03, with both arms falling back to the raw value rather than aborting. Driven: 3.1 now reads 03.1-REVIEW.md and writes 03.1-REVIEW-DISPOSITION.md; the integer path is unchanged. Two other findings from the same review, both about claims rather than code: MINOR 2's TEST WAS MIS-NAMED, and the reviewer was right to refute the claim. It called itself the "reflowed" case while substituting triple spaces, which is not a reflow. Driven: a genuinely WRAPPED heading is still not reconciled, because a `###` heading is one line by definition and the continuation is a separate paragraph. Not widened -- absorbing whatever follows a heading into the title would swallow arbitrary prose and make the stale-report check meaningless, and the kept failure mode is the safe one (a visible mismatch note, never a wrong "fixed"). The test is renamed to what it covers and the bound is now pinned by its own test. THE SHELL-SHARING GUARD DID NOT GUARD. Negative-controlling it -- rather than reading it -- showed that deleting block 2's real REVIEW_FILE derivation left it GREEN, on the exact defect it was written for. Block 2 prefixes its `node -e` with `REVIEW_FILE="${REVIEW_FILE}" ...` to put the values in the child's environment, and the detector counted that self-referential pass-through as a derivation. Pass-throughs are now excluded, and the control fires. Pre-existing, not introduced here: the original column-0 anchor matched that same line. Also worth recording: my first attempt at that control silently patched nothing and reported clean. Same lesson this PR already learned once. * fix(#3829): validate the phase number before formatting it, and make the shell guard executable Three findings from the round review's continuation pass, all confirmed by driving them. 1. MY OWN DOTTED-PHASE FIX WAS WRONG on the fallback path. `printf "%02d" abc` writes `00` to stdout BEFORE it fails, so `$(printf ... || printf %s ...)` CONCATENATES the two: `abc` became `00abc`, empty became `00`, and a legitimate `08.1` became `0008.1` because bash reads the leading zero as octal. An unset PHASE_NUMBER also aborted under `set -u` -- in the step that promises never to abort. Validate, then format: never format and fall back on failure. Driven across every edge the review named -- 3.1 -> 03.1, 3 -> 03, 08.1 -> 08.1, 09 -> 09, 1.2.3 -> 01.2.3, and abc / empty / -1 / unset carried verbatim with exit 0. 2. THE SHELL-SHARING GUARD STILL DID NOT GUARD. Excluding pass-throughs was not enough: a structural predicate recognises assignment TOKENS, never assignments that derive a usable value, so `REVIEW_FILE=`, `REVIEW_FILE=$REVIEW_FILE` and a commented-out assignment all evaded it. No regex closes that class. The authority moves to execution -- the third time this PR has learned that lesson. The real second fence now runs in a fresh shell with nothing but the step's two declared inputs and must write the ledger at the correct derived path. All four mutations are caught: empty assignment, self-reference, commented-out, and deletion. The textual check stays as a cheap fast-fail and is labelled as one. 3. THE TITLE-BOUND CORRECTION HAD NOT REACHED THE DOCS. The step comment and both docs pages still said a reflowed title reconciles, contradicting the bound pinned one commit earlier. Superseded prose left standing reads as current to anyone arriving cold, so all three surfaces are rewritten rather than annotated, and FEATURES.md regenerated. Also hoisted `HAS_BASH` to the file's other top-level constants. `const` is in the temporal dead zone until its declaration runs, and a `{ skip: !HAS_BASH }` option object is evaluated eagerly, so a bash-gated test added above the old mid-file declaration threw a ReferenceError that aborted its whole describe and CANCELLED its siblings -- while the summary line still read `fail 0`. It caught three separate additions in this round before I stopped moving tests and moved the constant. * fix(#3829): refuse an out-of-shape phase number instead of carrying it into a path Self-found while writing the prompt for the next review pass, which is the honest provenance: I asked the reviewer whether a path traversal was reachable through PHASE_NUMBER, then checked before dispatching. It was, and I had introduced it. The previous commit's fallback carried an unusable phase number VERBATIM, and PHASE_NUMBER is interpolated into a file path: PHASE_NUMBER='../../etc/passwd' -> REVIEW_FILE=/tmp/phase/../../etc/passwd-REVIEW.md The `printf "%02d"` it replaced had at least mangled that to `00`. A fix that makes a path more reachable than the bug it replaced is a regression, whatever it does for the case it was written for. Both callers already validate ^[0-9]+(\.[0-9]+)?$ (code-review.md:60, code-review-fix.md:36), so this is defense in depth rather than a live exploit -- but the step has two call sites now and should not take either caller's word for its own inputs. It validates the WHOLE value and, on failure, builds no path at all: PADDED is empty and each fence refuses by name rather than coercing. Block 1 declines to report counts read from a path made out of the bad value; block 2 declines to write, which also keeps it clear of the bare-name ledger defect round 1 closed. Driven across the shape boundary: 3.1 / 3 / 08.1 / 09 accepted; abc, empty, unset, 1.2.3, -1, 3., .1, +1, "3 1" and ../../etc/passwd all refused with exit 0 and a named diagnostic. Reversion control: restoring carry-verbatim turns the traversal test red. * fix(#3829): bound the phase number's length, and make the shell guard prove derivation Third adversarial pass. Two of its three refutations were already closed by the previous commit (the ../escape and 1/../../escape traversals, and the unset-input abort); these two were not. 1. A 54-DIGIT PHASE NUMBER WRAPPED SILENTLY. The validator accepted any all-digit value, so `$((10#$_int))` overflowed 64 bits and PADDED became `-7908320945662590977`. Length-bounded now at 8 digits, exactly as the counts already are and for the identical reason -- and the counts guard sitting twenty lines away is why this one is embarrassing rather than subtle. Driven at the boundary: 8 digits accepted, 9 rejected. The bare `${PHASE_NUMBER}` in the suggestion line is hardened to `${PHASE_NUMBER:-}` while here. The empty-PADDED guard makes it unreachable today, but it is one refactor away from an unbound-variable abort under `set -u`, in the step that promises not to abort. 2. THE EXECUTED SHELL GUARD PROVED THE FENCE WORKS, NOT THAT IT DERIVES. A single-phase probe is satisfied by a hardcode, and the review demonstrated exactly that: replacing the derivation with `case ... in 1) PADDED=01 ;; 7) PADDED=07 ;; *) PADDED=07 ;; esac` breaks every real phase and passed the entire suite. It now runs two distinct phases, 7 and 3.1 -- a hardcode cannot satisfy both, and the dotted one additionally pins the integer-part split. The claim "given only the declared inputs" was also overstated: the test spreads `...process.env` (it needs PATH and HOME). The DERIVED names are now explicitly deleted from that environment, so the claim is true rather than merely intended. Also rewrote a comment that had become false: it pinned a describe to the end of the file because of the HAS_BASH temporal-dead-zone constraint, which the hoist removed. Superseded prose left standing reads as current to anyone arriving cold. The changeset's "the gate stays advisory and never blocks" is now verified rather than asserted: both fences exit 0 under an unset PHASE_NUMBER and a traversal-shaped one. * fix(#3829): validate both inputs, refuse before building a path, and never write through a symlink Fourth adversarial pass. Four findings, all confirmed by driving them. 1. PHASE_DIR WAS NOT VALIDATED AT ALL. Unset, both fences died with `PHASE_DIR: unbound variable` under `set -u` -- the same class as PHASE_NUMBER, which I had just spent two commits fixing while its sibling input sat one line away. The step declares two inputs; it now validates two. 2. THE LENGTH BOUND WAS ON THE WRONG THING. The nine-character glob applied to the WHOLE value rather than the integer part, so it falsely rejected `12345678.1` (a legal 8-digit phase) while accepting `1.123456`. Each component is bounded on its own now; the sub-number is bounded too, since it is likewise interpolated into a filename. 3. REJECTED VALUES STILL HAD PATHS BUILT FROM THEM. The refusal guard sat AFTER the assignments, so an unusable input still assembled `${PHASE_DIR}/-REVIEW.md` and stat'ed it before refusing. The guard is now the first thing after validation, and both fences construct paths from validated locals rather than from the raw environment. 4. THE LEDGER WRITE FOLLOWED SYMLINKS. From the review's MISSED section, and the sharpest thing in it: `fs.writeFileSync` follows a symlink, so a pre-existing symlink at the ledger path replaced the contents of whatever it pointed at -- outside the phase directory, with the link left intact so nothing looked wrong. Driven, and the target's contents were gone. This PR introduces the artifact, so it owns the check: an existing ledger that is not a regular file is not a ledger, and the advisory gate says so and steps over. The executed shell guard now draws its phases AT RUN TIME. Fixed fixtures cannot establish derivation -- the review defeated the one-phase version with a hardcode, then defeated the two-phase version by adding one more arm to the same case. Any finite sample loses that race. A phase picked per run cannot be enumerated in advance; the drawn values print in every assertion message so a failure stays reproducible. Control: the review's three-value hardcode now fails on three consecutive runs. Eighth src.includes() converted -- it pinned the literal `${PHASE_DIR}` interpolation and went red when construction moved to a validated local, while "writes a REVIEW-DISPOSITION sibling" was untouched. It now asserts that property, and that REVIEW.md is not written. The changeset's "stays advisory and never blocks" is verified rather than asserted: 8 of 8 hostile-input cases across both fences exit 0 -- both inputs unset, PHASE_DIR unset, a traversal-shaped phase, and a missing phase directory. * fix(#3829): check the ledger path before reading it, and pin the write-safety behaviour Fifth adversarial pass, and the last one this round. Three fixes, three disclosed residuals. FIXED 1. A FIFO AT THE LEDGER PATH BLOCKED FOREVER. readFileSync on a FIFO never returns, so the step documented as "advisory, never blocks" blocked indefinitely -- the literal counterexample to its own headline claim. The non-regular-file check ran after that read. 2. THE UNCHANGED-RUN FAST PATH BYPASSED THE CHECK. A symlink whose target already matched the rendered ledger read through the link, reported `unchanged`, and never reached the refusal. Both fixed by the same move: the check is now the FIRST thing the script does, before any read or write of that path. Ordering was the defect, not the predicate. 3. THE COMMIT TEST FOLLOWED THE LINK the script had just refused. `[ -f ]` resolves symlinks, so the guard and its consumer disagreed about the same path and the helper could still be handed one. `[ ! -L ]` added. And the behaviour shipped with NO regression control -- I hand-drove it last commit and did not pin it, which the review caught by grepping for the words. Five tests now: symlink, symlink-with-matching-target, FIFO, directory, and an ordinary ledger as the negative control so the refusal is not a blanket one. mkfifo goes through the process seam like every other spawn here. DISCLOSED, NOT FIXED -- these are stated in the step rather than carried silently: - TOCTOU between the lstat and the write. Node exposes no portable O_NOFOLLOW write, and an attacker who can write into the phase directory mid-run already has what the check would protect. It narrows a real accident; it is not a security boundary and the docs claim none. - A hard link passes isFile() by construction. - The REVIEW.md and REVIEW-FIX.md reads still resolve symlinks. They are reads of files the operator owns, in their own phase directory. Also narrowed a comment that overclaimed. The randomized guard's domain is FINITE -- 88 integer and 792 dotted values -- so a mutation enumerating all 880 passes forever, and Math.random() is unseeded, so "reproducible" means only that the drawn values are printed on failure. Raising the bar is what it buys; proving derivation is not, and nothing short of reading the fence is. The previous comment claimed otherwise and was refuted. * test(#3829): make the write-safety controls portable to the Windows lane CI caught what neither the local suite nor five adversarial review passes could: every one of those ran on Linux. The FIFO test gated on `mkfifo`'s exit code. On the Windows lane mkfifo EXISTS and exits 0 while producing something that is not a FIFO, so the guard passed, the test ran against an ordinary path, the ledger wrote normally, and the assertion failed for a reason unrelated to the behaviour under test. It now gates on `lstatSync().isFIFO()` -- what was actually created, not what the command claimed. Control: with the shipped guard disabled the test still goes red on Linux, where the FIFO is real. The two symlink tests are skipped on win32, following this repo's existing convention for symlink-planting tests (tests/settings-jsonc.test.cjs:389 skips the same class; tests/unreachable-guard-drift.test.cjs:726 records the reason -- symlink creation requires elevated privileges on Windows CI). The privilege happened to be available on the lane this round, which is exactly why the convention is not "try it and see". * fix(#3829): a bare `|` in a deferral reason is prose, not a parse failure Review round 3, the one blocker. The Source cell is the one field this ledger asks a human to hand-edit, and "waiting on team A | team B to align" is an ordinary thing to type there. The prior-row capture admitted a pipe only when escaped, so a bare one failed the WHOLE line: prior.get() was undefined, the row fell through to `open` with an empty Source, and the console line read "1 of 1 finding(s) open" — a Critical a human explicitly deferred, with a documented reason, rendered indistinguishable from one never triaged, and the reason gone. The exact ambiguity #3829 exists to remove, reachable by one missing backslash. The Source cell is the LAST column, so it is now captured through to the end of the line, less an optional trailing pipe; a bare `|` inside it is prose. The render escapes a bare pipe on the next write so the table stays a table, and the escaped form re-parses to itself, so the second run reports `unchanged` — the fixed point holds. The ledger's own instruction line says so instead of asking the human to escape. Why the property never caught it: SOURCE_CELL only ever appended a PRE-ESCAPED pipe, so the arbitrary built to stress this cell could not reach the one input that broke it. It now also emits a bare pipe, and the round-trip expectation is the escaped form of what the human wrote. A fixed regression case drives the reviewer's exact input through two runs and asserts the decision, the reason, the headline count and convergence. Negative-controlled: both new tests fail against the previous capture. The src.includes() pin on the old capture text is retired for the behavioural case — it was pinning the defect. * fix(#3829): escape every bare pipe in one write, whatever precedes it Round 3, found by the adversarial pass over the round's own fix rather than by the review. The first escape used /(^|[^\\])\|/g, which CONSUMES the character before the pipe: adjacent bare pipes were escaped one per run (A||B -> A\||B -> A\|\|B, a third run to converge, breaking the advertised second-run fixed point), and an escaped backslash before a pipe (A\\|B) hid the pipe behind the wrong parity and left it bare in the rendered table. The property generator emits at most one bare pipe, which is the one case the old form got right, so no property reached either. Scan as pairs instead: an escaped pair (backslash + anything) is kept verbatim and only a pipe outside one is escaped. One write, then a fixed point. Regression case drives `A||B and C\\|D` through two runs; it fails against the previous escape. * fix(#3829): the script leaves by return, so an explicit exit cannot drop its verdict line Round 3, from the adversarial pass over the round's own fix. The embedded node script printed its verdict and then called process.exit(0) -- on the 'unchanged' branch only; the 'recorded' branch fell off the end. Node's "A note on process I/O" documents process.stdout writes to pipes and sockets as asynchronous on POSIX, and process.exit() as forcing exit before pending asynchronous stdout writes complete -- so on a POSIX lane the caller can see exit 0 with no verdict line. This is a hardening against that documented hazard, not a reproduced defect: the reviewer's empty-second-run stdout, which first pointed here, turned out to be its own sandbox -- a bare console.log child printed nothing there either -- and that attribution is withdrawn. The script now runs inside main() and leaves by return on all four early-exit paths, so the event loop drains stdout before the process ends. Same exit status either way, and the || echo fallback is unaffected. A structural test pins the absence of the call (comment-stripped; dotted, bracketed and whitespace-split spellings). The empty-review docs-parity pin that asserted the literal process.exit(0) line is retired -- the round-2 describe drives that property behaviourally. Also widens the property generator: SOURCE_CELL now reaches adjacent pipes and a backslash of either parity before a pipe, the two shapes the first render escape got wrong while passing every input the generator could then produce -- checked against an independent parity-walk oracle rather than a copy of the render's own scan. * fix(#3829): record what an --auto iteration fixed, instead of reporting it open Round 5's major. `record_disposition` runs once, after the whole capped-at-3 `--auto` loop converges — but this workflow keeps ONE final version of REVIEW.md and REVIEW-FIX.md rather than per-iteration copies, and deletes the .iterN.md backups on convergence. A finding fixed in iteration 1 was therefore absent from the final review (it was fixed, so the re-review stopped reporting it) AND from the final fix report (overwritten by the last iteration), so the row fell back to the gate's `open` and rendered `open ... (not in the current review)` — the same bytes a finding that vanished for an unrelated reason produces. That is the one distinction #3829 exists to make, undone by the artifact built to make it. The precise site was the two-arm `applied` construction: for an id the current review does not report, `sameTitle(undefined, h.title)` is false and `title.has(id)` is false too, so the entry entered NEITHER `applied` NOR `staleFix`. It was dropped in silence. Four changes, one defect: - A third arm. When the review does not report an id at all there is no title to disagree with, so this is not the stale-report case — it is what a finding looks like once it has been acted on. Record it. The id-reuse hazard stays closed by the arm below it: when the review DOES report the id, a title mismatch still goes to `staleFix` and is never applied, so a renumbered finding cannot inherit an earlier iteration's `fixed`. - Rows for decided ids the review no longer reports, carried and marked. A decision the ledger cannot render is a decision lost — the same silent drop the carry-forward loop already refuses for prior rows, one source over. - The .iterN.md fix-report backups are read alongside the final report, newest first, so the most recent statement about an id wins — the precedence a duplicate id already gets within one report. - The shell guard proceeds on a fix report, not only on an existing ledger. A direct `/gsd-code-review N --auto` writes no gate ledger, and a converged loop leaves `status: clean`, so a fully successful multi-iteration run recorded nothing at all. And the backups now go in `cleanup_iteration_backups`, after the ledger has read them. #3190's rule is untouched — spent scratch on convergence, retained on degradation — only the timing moved; deleting them inside the loop erased every early fix before anything read it. `CONVERGED` does not survive the loop's shell and is re-derived from the final review's status, which is exactly how the loop sets it; anything but a proven-clean review retains. Seven new regression tests plus an ordering test, all eight reversion-controlled against pre-fix code — every one fires. One is the negative control that matters: a reused id whose title differs must stay `open`, never inherit `fixed`. Residual, stated: an id appearing only in an iteration fix report takes its severity from the id prefix rather than a section heading, because `sectionSev` is built from the current review. That is the documented fallback for carried rows, not a new gap. * test(#3829): pin the two PADDED derivations against a silent desync Round 5's minor 1. Each fenced block runs in a fresh shell and must derive what it reads, so the PADDED derivation — the traversal fence between an attacker-influenceable phase number and a file path, plus the per-component length bound — is duplicated verbatim. Both copies were independently tested and nothing asserted they stay in step, which is the shared-parallel-surface shape CLAUDE.md requires a parity test for, on security-relevant validation logic rather than incidental repetition. Compared line by line rather than through a normalizing rewrite: a normalizer has to be told what may differ, and whatever it is told to tolerate stops being asserted. Exactly one line may differ — each block refuses by its own name — and the test names both forms. It also asserts the slice is substantial, since a parity test over an empty slice passes vacuously. Control: dropping one `?` from block 2's length bound, which moves that copy's limit to 7 digits while block 1 keeps 8, turns it red. That is the exact silent divergence the finding describes. One correction to the finding's own statement, since it is worth recording: the cited lines are :324 and ~:480, which are node-script lines; the derivations are at :48-83 and :211-246. And they are 35-of-36 identical rather than byte-identical — the refusal message differs, deliberately. * docs(#3829): state the PHASE_DIR trust boundary instead of carrying it Round 5's minor 2 asked that the assumption behind PHASE_DIR's validation be confirmed rather than silently carried forward at the two new call sites. It is confirmed, and the comment that stood here was wrong about it: "PHASE_DIR is the step's other declared input and gets the same treatment" describes something the code does not do. Both inputs have the SAME provenance — each caller binds them from `gsd_run query init.phase-op` (code-review-fix.md:7,17; execute-phase.md the same) — so neither is raw user input and neither is more trusted. The asymmetry is not about trust. It is that only one of them has a shape: PHASE_NUMBER carries a documented contract, `^[0-9]+(\.[0-9]+)?$`, asserted by both callers, so a value outside it is provably wrong and is refused. PHASE_DIR's contract is "a filesystem path", which admits `..`, absolute and relative forms and symlinked parents alike; no predicate separates a legitimate planning directory from an illegitimate one, so a shape check would reject working setups while proving nothing. So the emptiness check is adopted as what it actually is — the guard against `PHASE_DIR: unbound variable` aborting a step that promises never to block — and the shape check is declined, with the reason written where the next reader meets it rather than left to be re-derived. The residual is restated in place rather than left in a PR comment: PHASE_DIR may itself be a symlink and the ledger is then written through it, outside the phase directory, deterministically. Left alone deliberately — the write goes where the caller pointed. Not a security boundary, and nothing here claims one. * docs(#3829): record why HAS_BASH is a platform assumption, not a probe Round 5's minor 3 is DECLINED, and the reason is the repo's own contract rather than a judgement call — written at the constant so the next reader does not "fix" it and re-enable what the rule exists to prevent. The gap is real and confirmed: 22 tests carry `{ skip: !HAS_BASH }`, so block 1's bash severity-reporting path has no Windows-lane coverage. But `local/no-unguarded-nonportable-exec` (eslint-rules/no-unguarded-nonportable-exec.cjs, DEFECT.WINDOWS-TEST-PORTABILITY) REQUIRES this guard around `sh -c` / `bash -c` in tests, and its own remedy text names `if (process.platform !== 'win32')` as the sanctioned form, because these constructs fail under Windows Git Bash. So the constant is the repo's answer to this question, not an oversight in this PR. Swapping it for a runtime `bash` probe would light 22 tests up on a lane the rule has already determined they cannot pass — trading a legible, rule-encoded skip for a red matrix. Reversing that is the rule's decision; a change here belongs with a change there. * docs(#3829): describe how --auto's iterations reach the disposition ledger The reconciliation section described the `--fix` path accurately and said nothing about `--auto`, which is where round 5's major lived. It now states that the loop overwrites its fix report each pass, that the re-review drops a finding once it is fixed, that the gate therefore reads the per-iteration backups newest-first, and that the backups are removed after the ledger has read them rather than before. It also states the converged-with-no-ledger case: a fix report on disk is reason enough to record. FEATURES.md regenerated (176 features / 21 groups). Changeset extended to name the shipped behaviour rather than only the `--fix` half. * fix(#3829): clear lint-workflow-shellcheck, a gate the base range added Not from the review. The rebase onto `next` brought in `lint-workflow-shellcheck` (#4109), whose baseline was generated before this PR's new step file existed — so that file's findings are new by construction and `lint:ci` exited 1 on the rebased head before this round touched anything. The last green CI run predates the gate. Caught locally rather than by a red push. Three fixes and one baseline entry, split by whether the finding is real: - STRUCTURAL (not ShellCheck, not baselineable): the guard's `for _f in "…${PADDED}-REVIEW-FIX.iter"*.md` is the bare `for x in $VAR` shape that word-splits differently under bash and zsh. Wrapped in `$(printf '%s' "$PADDED")`, the linter's own prescribed remedy. - SC2097/SC2098, and this one was a genuine latent bug rather than a lint nit: `FIX_REPORT_FILE="${_pd}/${PADDED}-REVIEW-FIX.md"` sat in the same env-prefix list that sets `PADDED`, so its `${PADDED}` expanded the OUTER variable, not the one two entries earlier. Both happen to hold the same value here, which is exactly why it would have kept being wrong quietly. Built before the command now. - SC2317 ×3 is baselined, not fixed. It fires on `return 0 2>/dev/null || exit 0` — the deliberate idiom that lets a fence refuse whether it is sourced or executed — and the verdict is a false positive: the `exit 0` is reached precisely in the executed case. Rewriting a dual-mode refusal to satisfy a wrong unreachability claim trades a real behaviour for a clean report. Baseline 207 -> 210. `lint:ci` exits 0. 173 tests pass across the two touched files. * fix(#3829): a reused finding id no longer inherits the old finding's decision Found by this round's own adversarial review, which drove it rather than reasoned about it — and it refuted the arm I had named as my strongest suspicion, so it is recorded as a correction, not a discovery. Finding ids are reused across re-reviews: the --auto loop renumbers. `row()` inherited a prior decision on an id MATCH ALONE, with nothing checking it was the same finding. Driven: a prior `CR-01 fixed` row against a review reporting a brand-new CR-01 rendered the NEW finding `fixed`. A false decision in the artifact whose entire purpose is telling triaged from forgotten — the same failure mode round 4's blocker was, reached by the other door. I had argued this was closed by the stale-report arm. It is not: that arm guards the FIX-REPORT path only. The PRIOR-LEDGER path had no title check at all. - The ledger now records each finding's title, in the FRONTMATTER rather than a fifth table column: the Source cell is the field a human hand-edits and the one that must escape pipes, and a second free-text column doubles that surface for no reader benefit. - A prior decision is inherited only when the recorded title still matches. An ABSENT prior title inherits, deliberately — a ledger written before titles were recorded carries none, and refusing there would reset every decision in it, which is the loss this guard exists to prevent, caused by the guard. - A decision whose id has been reused is PRESERVED under a `superseded:` key rather than dropped. The review's driven refutation was precisely that the mismatch was surfaced while the decision was lost. It cannot keep a row — the id is taken, and two rows under one id is an ambiguity, not a record — so it is carried in the frontmatter, re-emitted every run, deduped by id+title, and named on the console. - And an iteration-derived decision now cites the report it actually came from. The Source cell hard-coded the unsuffixed `<NN>-REVIEW-FIX.md`, so a decision read out of an iteration backup cited a file that may not exist. A citation the reader cannot follow is worse than none. Also the review's finding. Five new tests. Four fail against the pre-fix step; the fifth — that a ledger with no recorded title still inherits — is a BACK-COMPAT guard and passes both ways by construction. It is not a reversion control and is not counted as one. * fix(#3829): follow the cleanup move through, and stop miscalling a converged run Three loose ends the earlier cleanup relocation left, two of them found by the round's own review and one by the suite. **#3190's own test still pinned the old placement.** T6 asserted the `.iterN.md` removal lives inside `auto_iteration_loop` — exactly what moving it broke. Its SEMANTICS are unchanged and still asserted: removed on convergence, retained on degradation, creation intact. What it now pins additionally is the ordering that forced the move — the ledger reads the backups BEFORE they are removed — and that the loop no longer removes what it just wrote. Rewritten rather than deleted: the assertion was superseded, the guarantee was not. **`CONVERGED` had become a decoy.** With the removal gone from the loop, the flag was set in two places and read in none. Deleted, and the prose that still said "the loop sets it" rewritten to what is true: the loop breaks on exactly one condition, a clean re-review, which leaves REVIEW.md at `status: clean` — and that is what `cleanup_iteration_backups` re-derives from. **A converged final iteration reported the opposite of what happened.** The post-loop message keyed on the iteration COUNTER alone, so a run that converged ON iteration 3 exited with `ITERATION == MAX_ITERATIONS` and printed "Reached maximum iterations. Remaining issues documented in REVIEW-FIX.md" over a run in which every finding was fixed. Convergence is re-derived from the review the loop left behind — the same signal the cleanup step reads, so the two cannot disagree. * docs(#3829): retract two claims this round made and could not support Both were caught by the round's own adversarial review, both were driven, and both would have reached the maintainer. Recording the retraction where the claim was made, rather than only in a PR comment. **The env-prefix "latent bug" does not exist.** An earlier commit in this round claimed that `FIX_REPORT_FILE="${_pd}/${PADDED}-REVIEW-FIX.md"`, sitting in the same `node -e` env-prefix list that sets `PADDED`, expanded the OUTER variable rather than the one two entries earlier — reading ShellCheck's SC2097/SC2098 as a defect report. Driven in bash and in dash: assignments in one prefix list take effect left to right, and the later entry DOES see the earlier one. The warning is a false positive here. The split is kept, but for readability only; the comment no longer describes it as a fix. **The HAS_BASH decline rested on a rule that does not govern these call sites.** It cited `local/no-unguarded-nonportable-exec` as REQUIRING the `process.platform !== 'win32'` guard. Checked, and wrong on both halves: the rule fires only on a file that also chmods an exec bit with an octal literal, and this file has none — so it never runs here — while `eslint-rules/lib/platform-guard.cjs` accepts four guard shapes plus `os.platform()`, not one. A constraint that exists is not a constraint that applies, and I did not check which. The decline stands on narrower and honest grounds: whether these fences PASS on the Windows lane is UNVERIFIED. What evidence there is points at divergence rather than absence — the rule's subject line is that `bash -c` constructs "fail on Windows Git Bash", and this PR already measured `mkfifo` existing on that runner, exiting 0, and creating no FIFO. So a probe would not be a clean win; it would light 22 tests on a lane whose shell semantics are known to differ and unknown in detail. That is a measurement to make deliberately, not a change to make in passing. The gap is real and is now stated as a gap. * fix(#3829): close four defects the review drove out of the first title fix The round's own adversarial review re-ran against the reworked tree and refuted two more claims. Every item below is its finding, verified before acting. **An iteration-only decision recorded no title, so the reuse guard leaked.** `applied` stored `{d, src}` and the row took its title from the current review — which does not report the finding at all. The row shipped with no title, and the next review reusing that id hit the title-ABSENT back-compat exception and inherited the old `fixed`. The exact defect the title machinery exists to close, surviving through the hole opened for legacy ledgers. `applied` now carries the title it was decided under. **A changed decision was dropped in favour of the obsolete one.** The dedupe was a has()-guard, so re-superseding a finding whose decision had since changed left the older record standing. It now replaces. **Re-spaced titles double-recorded.** The dedupe keyed on the raw title while `sameTitle()` collapses whitespace; the key now agrees with the comparison. **And the frontmatter was not valid YAML.** `title: Parser: loses data` is rejected outright by a real reader, and the `superseded:` line format was not YAML at all. Values are emitted as JSON scalars — YAML 1.2 is a JSON superset — and superseded records are properly nested. Round-tripped through js-yaml in the tests. One more, self-inflicted while fixing the above: the parse registered each carried superseded record TWICE, once at `- id:` under an empty-title key and again at `title:`. Records doubled on every run. They are collected during the walk and registered once, complete. **T6 was vacuous.** The review flipped `= "clean"` to `!=` in the cleanup and the rewritten T6 still passed — it greps for `FINAL_STATUS`, `rm` and "retained" occurring somewhere, never wiring them to a branch. T6b now EXECUTES the fence in both directions against real files. It fails on that exact mutation. **And a converged final iteration printed two success messages** — the loop's break already reported it. This branch now stays silent and exists only to withhold the degradation warning. Three CI gates the base range brought in, all tripped by this round's own text: - `/gsd-code-review` in a comment — runtime workflow artifacts take the colon form. Now `/gsd:code-review`. - The preamble-ordering parity test: my PHASE_DIR comment wrote the literal `gsd_run` before the shim preamble. Reworded. - Prompt-stuffing: the file passed 50K. I trimmed 5.8K of my own commentary first; even removing every added comment leaves the added CODE over the line, and the file entered this round at 44,523 — 89% of the budget. Added to SIZE_ONLY_WORKFLOWS with the same reasoning the two existing entries carry, and the same acknowledgement: splitting is the real fix. * test(#3829): extract the cleanup fence without an ad-hoc markdown regex T6b's helper used `/```bash\n([\s\S]*?)\n```/`, which trips two of the repo's own rules: `local/no-adhoc-markdown-parsing` (use the sectionizer, not a hand-rolled fence regex) and `local/no-crlf-fragile-split` (a bare `\n` against readFileSync content is wrong under Windows autocrlf). Line-scanned now, CRLF-normalized first — the same shape `bashFences()` in tests/code-review-pipeline-regression.test.cjs already uses, which solved this first. `npm run lint` is clean and T6b still fails on the inverted-branch mutation it exists to catch. * fix(#3829): withdraw the superseded-decision store; keep the identity guard Three adversarial passes over this round each found real defects, and passes 2 and 3 were entirely inside the `superseded:` block added in pass 1 — a second identity scheme, keyed on (id, title), living beside the row store keyed on id. Pass 3 refuted it on three separate counts: a legacy title that merely looked like JSON lost its quotes and fabricated a record; a finding that was deferred, superseded, then returned and fixed left an active row and an obsolete superseded record standing together, reporting `unchanged` forever; and my own test for the replacement path never passed the earlier ledger in, so it guarded nothing. The construct had no terminal state. It is withdrawn. **What survives is the safety property.** The ledger records each finding's title, and a recorded decision is carried forward only while the id still names the same finding. That is what stops a renumbered `CR-01` inheriting an earlier `CR-01`'s `fixed` — a false decision in the artifact whose purpose is telling triaged from forgotten, and the same class as round 4's blocker. **What is given up, and it is disclosed rather than hidden.** On a detected reuse the earlier decision loses its row. The drop is reported on the console naming the id and what had been decided, the previous ledger is committed so the row remains in git, and docs/features/code-review-pipeline.md states the limitation. Two defects from pass 3 are fixed rather than deleted, because they are in the guard and not the store: - **Known-empty and NOT-KNOWN were conflated.** `### CR-01:` yields an empty title; that is a title. While it emitted no `title:` key it read back as a pre-format ledger and inherited across a reused id — the same leak, three passes running. Emitted whenever the title is known, empty included; a carried row no source knows stays absent, which is the legacy-compatible read. Underneath it was a falsy fallback: `(act && act.t) || priorTitle.get(id)` discards `''`. Now a typeof check. - **JSON.parse ran on legacy values.** A pre-format ledger whose bare title was written `"quoted"` was parsed and lost its quotes, so the decision stopped matching. The frontmatter now declares `titles: json` and the parse is gated on it; a ledger without the marker keeps its scalars. One defect from pass 3 is NOT mine and is not fixed here: a converged run prints a success message from the loop break AND another from `present_results`. Both predate this round. My earlier claim that "the duplicate is gone" was true only of the pair I introduced; the pre-existing pair stands, and widening this round into `present_results` is not warranted. 188 tests pass. The three new tests fire against the pre-simplification step. `lint:ci` exits 0. The step file is 55,590 chars, down from a 62,220 peak. * docs(#3829): stop the ledger promising a preservation it no longer makes Fourth review pass. No machinery defects this time — both findings are claims in text this step SHIPS, which is the class this whole stack exists to prevent. **The rendered ledger still said "Re-running the gate preserves every row and every disposition."** That was true until the same round gave the step an intentional drop for a reused finding id, and then it was false in the artifact's own user-facing footer. It now states what the step does, including the one exception, where a reader actually meets it. **And the console asserted "the previous ledger is in git."** Committing the ledger is gated on `commit_docs`, and a failed commit is swallowed — so under `commit_docs=false` the overwritten decision may exist nowhere. The note reports the drop and stops there; asserting a recovery path that may not be there is the same overclaim in a smaller font. Two residuals from the same pass are DECLINED and documented rather than fixed, because both would need the second identity scheme just withdrawn: - A pre-titles ledger carries no titles, so its decisions inherit on the id alone. Refusing there resets every decision in every existing ledger, which is the loss the guard exists to prevent. - Two genuinely distinct findings sharing both an id and a title are indistinguishable to an (id, title) key. The pass also refuted the `titles: json` marker on a ledger written by `b86ea6065^`, which emitted JSON titles before the marker existed. Declined: that revision is an intermediate commit on this unpushed branch and has never been released. The PR's published head writes no titles at all, so a real ledger is either pre-titles (unmarked, bare — handled) or written by the shipped version (marked). The unmarked-JSON state cannot reach a user. Test pinned, and it fails against the pre-correction step. * docs(#3829): fix four wrong citations and one false size justification All four came out of a claim-audit of this round's own response comment — an audit of the text, not the code, which is where the remaining errors were. - **The caller citation was wrong.** The in-code note said both inputs bind from `gsd_run query init.phase-op`. `execute-phase.md:85` uses `init.execute-phase`; only `code-review-fix.md:22` uses `init.phase-op`. The substantive point is unchanged — both are orchestrator-derived, neither is raw user input — but the citation was not checked. - **A leftover "the prior row is in git."** Removed from the console note last commit, left standing in the comment two lines above it. - **The docs still carried the promise the ledger had just dropped.** The rendered footer was corrected; the same sentence in `docs/features/code-review-pipeline.md` was not. - **The SIZE_ONLY_WORKFLOWS justification was false.** It claimed the added CODE alone exceeded the threshold. Removing every round-added comment leaves 47,148 chars against a 50,000 limit, so the file CAN fit — the claim was wrong, and an exemption defended on a wrong premise is worse than no exemption. So the entry is re-justified on what is actually true, and earned first: another **10,188 chars** of this round's own commentary are cut (62,220 → 52,032, from a 44,523 baseline that was already 89% of the budget). Fitting under is possible only by stripping essentially all remaining explanation from logic three review passes found defects in. That is the wrong trade in a file whose house style is heavy in-fence documentation, and the entry says so rather than implying the file had no choice. One measurement corrected while checking: the Windows-lane skip count is **37**, not the 22 the review cited nor the 26 I first counted. Twenty-two and 26 count `{ skip: !HAS_BASH }` CALL SITES; a skip on a `describe` cancels its subtests. Forced the constant false and counted what actually skips. 236 tests pass. `lint:ci` exits 0. * fix(#3829): the drop report is conditional, and two published claims were not A fifth adversarial pass, run against the two commits that went out AFTER the fourth pass and were never reviewed, refuted three claims this round published. 1. The drop is NOT reported unconditionally. `row()` reports only a RECORDED decision (`was.d !== 'open'`); a prior row still at `open` is replaced in silence. The behaviour is right — `open` records no decision to lose — but the shipped ledger legend and BOTH feature docs asserted the report happens every time. Text corrected in all three places, which is the same defect class this round already corrected once for the preservation promise. 2. The test guarding that console wording was VACUOUS: it ran with no prior ledger, so no reuse occurred and its `is in git` assertion could not have failed however the console was worded. Driven through a real drop now, with the drop asserted as a precondition. A new test covers the `open` arm and fails on the pre-fix legend. 3. The HAS_BASH gap is now MEASURED rather than assumed, on native Windows with Git Bash 5.2.37 / MINGW64 first on PATH, node v25.2.1: HAS_BASH left alone: 179 tests, 127 pass, 0 fail, 52 skipped HAS_BASH forced true: 179 tests, 140 pass, 24 fail, 15 skipped So 37 of the skips are this guard's, confirming the count the round published — and unskipping is NOT a clean win: 24 fail, clustered on `bash -c` quoting and spawn failures, exactly the divergence the eslint rule's subject line names. The guard stays; it now documents a measured gap. The stale "the count is 22" comment is gone. 4. The size-exemption justification was wrong a second time. The overshoot is ~2.3K normalized chars, not "essentially all remaining explanation": the round's committed peak was 59,246 chars (not 62,220, which was never committed), and it entered at 44,466 chars, not 44,523 — both earlier figures mixed bytes into a character measurement. Rewritten to the numbers the scanner actually produces. Also: the shipped comment said both callers validate the phase shape without naming that they validate PADDED_PHASE, not the raw PHASE_NUMBER this step is handed. * fix(#3829): renumber this PR's two REQs, which #3661 took while the branch sat The rebase onto current `next` surfaced a REQ-number collision, not a text conflict. #3661 landed `REQ-REVIEW-08` (`workflow.code_review_point`) on `docs/features/code-review-pipeline.md` while this branch also claimed 08 and 09 for severity surfacing and the per-finding disposition. Two different requirements under one identifier is the kind of thing that reads as correct in both diffs and is wrong in the merged tree. Base numbering wins, because it shipped: `REQ-REVIEW-08` stays #3661's. This PR's two become **REQ-REVIEW-09** (severity surfacing) and **REQ-REVIEW-10** (per-finding disposition). Swept the whole tree rather than the conflict hunk — two references sat in files git merged cleanly and never flagged: - `gsd-core/workflows/code-review-fix.md:450`, the prose stating why `record_disposition` is the step's only reachable call site. - `tests/code-review-pipeline-regression.test.cjs:1782`, the comment on the test that pins that call site. `docs/FEATURES.md` is regenerated from the fragment rather than hand-edited; `node scripts/gen-features.cjs --check` is green (178 features, 21 groups) and `lint:generated-sync` exits 0. Two things stated rather than quietly carried. The `Emitted-Drift-Ack-Growth` trailer on the round-2 commit still reads `REQ-REVIEW-09` for what is now REQ-REVIEW-10 — it is a historical acknowledgment of that commit's growth, and its purpose is unaffected, so it is left rather than rewritten across 52 replayed commits. And `docs/INVENTORY-MANIFEST.json` appeared stale immediately after the replay, reporting two missing `cli_modules/` entries; that was the lane's pre-rebase build output, not manifest drift. Rebuilding in the replayed lane and re-checking shows it in sync and unmodified. Regenerating before the build would have committed the deletion of two base-added entries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mgqVNP3rqTVMgBNnBMnGH * test(#3829): reach the title round-trip with a generator that can break it Round 6's only finding. The round-5 title tracking introduced a fresh parser (the `titles: json` / ` - id:` / ` title:` frontmatter walk) and a fresh bijective contract (`JSON.stringify(oneLine(t))` out, `/^ title: (.*)$/` plus `JSON.parse` back in), and `tests/code-review-disposition.property.test.cjs` was untouched since round 4 with no reference to `title` at all. Every heading the generator built was `'### <id>: finding number <i>'` — never a colon, a quote, a backslash, or the empty string. You were right that this is the round-3 shape again, and I would rather demonstrate that than assert it. Two mutations to the shipped step, each a plausible edit rather than a contrived one: A. render `titles: raw` instead of `titles: json`, so the re-parser never JSON.parses and stores the quoted scalar as the title; B. `yv = (t) => oneLine(t)` — the bare scalar, no JSON at all. mutation A — new generator: FAIL old generator: pass (3/3) mutation B — new generator: FAIL old generator: pass (3/3) Both ship past the pre-round suite. The gap was reachable, not theoretical. What changed: - `TITLE`, a new arbitrary drawn from the class the render's own comments say the escaping is for — `:` (why `yv()` exists), `"` and `\` (what stringify/parse must round-trip), the empty string (the known-empty vs not-known distinction the render draws explicitly) — plus scalars that MIMIC the ledger's own frontmatter grammar (`findings:`, `titles: json`, a nested ` title: ` line, ` - id: CR-99`), unicode, surrounding whitespace, and one title long enough to outrun a scanner assuming short scalars. - `FINDINGS` now carries a title per id, so all four properties run the cycle over the title contract instead of over a constant. `IDS` keeps the old id-only shape it is built from. - A fourth property asserting the round trip in the two places it is observable: the stored scalar must `JSON.parse` back to the trimmed heading title, and a hand-recorded decision must survive the next run. The second half is the one that matters, and its construction is the point. The decision is made by EDITING THE RENDERED LEDGER IN PLACE, never by writing a bare row the way the existing properties do. A bare row carries no frontmatter, so `priorTitle` is empty, `sameFinding()` returns true through its `!priorTitle.has(id)` back-compat arm, and the title contract is never consulted — the property would pass over a completely broken round-trip. Both mutations above go green against the bare-row form. That collapse is why the property is written this way, and the comment says so in place. So the assertion is the consequence, not the JSON: a lossy round-trip does not corrupt a title, it makes `sameFinding()` false and resets a human's `deferred` to `open` with the reason gone — this PR's own founding failure mode, reached through the field the round-5 work added. BOUND, stated rather than quietly omitted: the generator emits no CR or LF. A `###` heading is one line by definition, so a newline is not an input the heading parser can be handed; `oneLine()` guards the value's other producers, not this one. Two things found while writing it, both corrected here rather than left: - `runOnce` now returns stdout. The reuse report is a CONSOLE note, not a ledger key, so my first draft's `assert.doesNotMatch(ledger, /^reused:/m)` was vacuously true forever — a test that cannot fail. - `expectedTitle` is a TRIM, not a `\s+` collapse. Collapsing is `sameTitle`'s COMPARISON rule; `oneLine()` is the STORAGE rule and preserves internal whitespace. The collapse form fails on an internal tab against entirely correct code, which is how a test gets weakened instead of believed the first time it goes red. The file header claimed "two properties" while three were running; it now states four, one line each. 239 tests pass across the four pipeline files, 0 skipped. `lint:ci` exits 0 (`lint-workflow-shellcheck`: 203 baseline findings, 0 new). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mgqVNP3rqTVMgBNnBMnGH * test(#3829): the prefix census guard now says four sites, because round 5 added one Self-found, from re-deriving the round-1 finding-id census this round rather than carrying the round-1 verdict forward. The census guard's comment says the prefix set is "written out three times — the heading matcher, the ledger re-parser, and (by its keys) the severity map". That was true when it was written. Round 5's title tracking added a fourth copy: the frontmatter `- id: ((?:CR|BL|WR|IN)-\d+)` matcher that rebuilds `priorTitle`. The guard itself did not fall behind, and the reason is worth keeping visible: `idAlternations()` scans the extracted script by PATTERN rather than walking a fixed list of sites, so the new alternation was absorbed with no edit. Verified by running the extractor at this head — three alternations found, one distinct set, severity map keys `CR,BL,WR` with `IN` on the documented `info` default, 0 domain members not reached. Only the prose fell behind. Corrected, with the pattern-scan rationale stated in place so the next reader does not helpfully convert it into the hand-listed enumeration it deliberately is not — which would be exactly the defect this guard exists to catch, in the guard. Census discharge for this round: re-derived at the rebased head over the extracted shipped script, 3 enumeration sites reached, 0 not reached; the domain (the prefixes `gsd-code-reviewer.md` can emit, walked across both its heading template and its prose Label-equivalence paragraph) is unchanged since round 1 at 4 of 4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mgqVNP3rqTVMgBNnBMnGH * test(#3829): catch a duplicate REQ id in a fragment, since nothing did Not from your review — this is the test the round owed itself, and I would rather say why than let it look like scope creep. The renumber commit earlier in this round has no reversion control without it. I reverted that fix to check, and the first attempt LOOKED controlled: reverting only the fragment turned `gen-features --check` red. That is the generated-sync gate noticing the projection went stale, not anything noticing the collision. Reverting CONSISTENTLY — fragment plus a regenerated `docs/FEATURES.md` — is silent: gen-features --check rc=0 lint:ci rc=0 pipeline suite rc=0 with two `REQ-REVIEW-08` entries standing in one requirement list. Nothing in the repo reads REQ ids at all, so there was no second place for it to be caught. The failure this guards is a MERGE, not an edit, which is why review does not see it: two PRs open at once each append "the next" REQ number to the same list, and whichever lands second is rebased onto a list that already used it. git merges them as different lines of one file and reports nothing. Neither PR's diff shows a collision — each is correct against the tree it was written on. That is exactly how #3661 and this PR both ended up claiming REQ-REVIEW-08. Scope, stated because it is the part that could be wrong: the check is WITHIN a fragment, never across the corpus. Two different features legitimately both carry `REQ-REVIEW-01..07` — the cross-AI review feature and the code-review pipeline — so corpus-wide uniqueness would be false on the committed tree and would have to be weakened the day it first ran. A requirement list belongs to its feature; that is the scope of the identifier. It lives in `describe('the committed docs/features/ corpus')` because it is an invariant over the committed corpus, which is that block's stated job, and it pins no count — the file's own header rules out counts as shared mutable cells that every feature PR would have to edit. Control: green on the committed tree (no fragment carries a duplicate today); red on the restored collision, naming the file and the id. 85 tests pass in this file. Happy to drop this if you would rather the round stayed inside the review's four corners — but then the renumber ships uncontrolled, and I would rather put that choice in front of you than make it quietly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mgqVNP3rqTVMgBNnBMnGH * test(#3829): finish the census comment correction, which stopped one line short Found by this round's own pre-push adversarial review, which refuted the claim the previous commit made about itself. `7f019d985` said the census comment correction was complete. It corrected one site and left two, both in the helper block twelve lines above the test it belongs to: - `severityMapKeys`' header still read "The THIRD copy: the severity map's keys". With three alternations the map is the FOURTH copy, and has been since round 5. - `idAlternations`' header said "adding a prefix to only two of them is silent", written when there were two alternations and never updated to three. This is the defect the original correction was ABOUT, committed inside the correction: a fragment of prose carries no supersession marker, so a reader landing on line 2810 gets the dead count stated as current fact, and the fixed comment eighty lines down does not reach them. Fixing one surface and leaving its neighbour is not a partial fix, it is the same fix not done. The region is now consistent end to end, and both headers say the thing that actually matters — the scan is by PATTERN, not a fixed list of sites, which is why round 5's new matcher needed no edit here and why converting it to an enumeration would reintroduce exactly the drift it guards. WHILE HERE, a disclosure that was narrower than the truth. `7a6680e8f` said the `Emitted-Drift-Ack-Growth` trailer still names REQ-REVIEW-09 for what is now REQ-REVIEW-10, and left it deliberately rather than rewrite 52 replayed commits. That is right, but it is not the whole set: the message BODIES of `c94106568` ("wire the disposition ledger into the fix path") and `06282f668` ("migrate the emitted-drift ack") both state "REQ-REVIEW-09 was unreachable in every shipped path", meaning the disposition requirement, which is now REQ-REVIEW-10. Same decision, stated at its real size: three historical references, not one. They are commit history rather than living documentation — git is the record of what was believed when — and rewriting the branch to correct a number in a message would cost every review round its correspondence to the commits it reviewed. The TREE carries no stale reference; `docs/`, the workflows and the tests all read REQ-REVIEW-09 for severity surfacing and REQ-REVIEW-10 for the disposition. Regression file: 181 tests pass, 0 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mgqVNP3rqTVMgBNnBMnGH * test(#3829): the third stale count, and a disclosure that over-counted itself Both found by re-running this round's pre-push review after the last fix. It refuted the commit that claimed the region was consistent — for the second time in a row — and it was right again. **The third site.** `:2917` said "And the third copy, which is not an alternation" and `:2919` said "Without this, both regexes can gain a prefix". Written when there were two alternations; there are three, so the map is the fourth copy and it is three regexes that can drift. Worth saying how it survived two passes, because the mechanism is the point and it is the same one this PR keeps re-learning. Both earlier passes VERIFIED with a grep built from the strings I had just fixed — `THIRD copy`, case-sensitive, plus a handful of phrasings I expected. `the third copy` in lowercase matched none of them, and `both regexes` was not a phrasing I thought to look for. A grep returns what you already thought of; that is not a verification of prose, it is a re-statement of your own assumption. The region is now checked by reading it end to end, and all four count statements agree: three alternations (heading matcher, ledger row re-parser, frontmatter `- id:` matcher), with the severity map as the fourth copy. **And the disclosure over-counted.** The previous commit widened the historical REQ-REVIEW-09 references from one to three. Three is wrong. There are TWO underlying statements: - `c94106568`'s message body, and - the `Emitted-Drift-Ack-Growth` trailer on `06282f668`. I counted `06282f668` twice — once as "the trailer" and once as "a body" — when its only mention IS that trailer (`git show -s --format=%B 06282f668 | grep -c REQ-REVIEW-09` outside the trailer line: 0). Over-counting is the safe direction and it is still a wrong number in a message, which is the thing this round has been correcting all along. The decision is unchanged: both are commit history rather than living documentation, and rewriting the branch to fix a number in a message would cost every review round its correspondence to the commits it reviewed. The TREE carries no stale reference — 08 is #3661's `workflow.code_review_point`, 09 is severity surfacing, 10 is the per-finding disposition. Comment-only in one test file; no assertion, regex or extracted-script expectation moved. Regression file: 181 tests pass, 0 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mgqVNP3rqTVMgBNnBMnGH * fix(#3829): join the disposition-step dispatch so REQ-LANG-04 inheritance is provable `lint-response-language-coverage` (#2529, which landed on `next` after this PR was approved) reported `execute-phase/steps/code-review-disposition.md` as having no response-language coverage. The step does inherit it: `execute-phase.md` imports `references/execute-phase-response-language.md` and dispatches the step with `Read and execute`. The dispatch stub wrapped, leaving the verb at the end of one line and the path at the start of the next, and `namesFragmentAsEntryPoint` matches within a single line — so a genuine inheritance was unprovable to the linter. Rejoining the verb and the path restores it: `namesFragmentAsEntryPoint` goes false -> true and the lint reports `OK (165 workflows covered)`. Only line breaks move — the word stream is identical to the previous revision, and the file is unchanged at 93,390 bytes, so no growth acknowledgment is owed. This takes the third coverage form the lint documents — inheritance — rather than the inline directive the CI message names first. Where inheritance is provable the lint's own comments say a second copy "buys no coverage and adds a sentence that can drift", and the step file already sits over the prompt-stuffing threshold. Swept all 76 fragments in the catalog: this is the only one whose parent's previous line ends with a dispatch verb. The 17 others that are mentioned without a provable entry point are table-routed or bare prose references carrying no dispatch verb at all, and correctly hold the pinned inline directive instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgX6QQmygeZnQqbc3o8RNC * chore(#3829): regenerate derived artifacts after rebase onto next The rebase onto current `next` conflicted on the 19 install-tree goldens and `docs/FEATURES.md`. Those are generated, so the conflicts were resolved arbitrarily and the generators re-run (`npm run regen:derived`) rather than hand-merged — a clean textual merge of a generated file attests the merge, never the content. Reconciled per artifact against the base's own committed copy rather than against the pre-regen tree, because the pre-regen tree is the arbitrary resolution: - all 19 `tests/fixtures/install-tree/*.json` now differ from `upstream/next` by exactly one key, `gsd-core/workflows/execute-phase/steps/code-review-disposition.md`; - `docs/FEATURES.md` differs by exactly REQ-REVIEW-09/10 and this PR's own reference section; - `docs/INVENTORY-MANIFEST.json` differs by exactly the same one step file, and needed no regeneration to get there. Nothing the base added was dropped by the arbitrary resolution: the restored entries (the `gsd-core/agents/` and `gsd-core/commands/gsd/` families, the compact templates, the `detail/elaboration.md` files, `gsd-secret-read-guard.js`) are all base-owned and came back through the generator, which is what the resolve-arbitrarily-then-regenerate discipline is for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBjyHFtRTtHDD2tM6V6aUV * fix(#3829): repair the rebase's conflict resolution in the regression suite The rebase onto current `next` hit one add/add conflict in this file: #4209's external-reviewer-evidence describe and this PR's #3829 block were added at the same insertion point. Resolving it by keeping both sides was correct in substance and wrong in mechanics — the conflict boundary cuts through two open blocks that the SHARED trailing ` });\n});` closes, so each side carries +2 unbalanced braces on its own and concatenating them left the file with 683 `{` against 680 `}`. `node --check` fails outright, so the whole file deregistered rather than failing a test — 188 tests silently stopped existing. Rebuilt the region as a real three-way merge (ancestora262ad6b6, ours upstream/next, theirs 77ee739c3) and closed the first side explicitly before the second begins. Both feature blocks are present exactly once, braces balance 683/683, and the file runs 188/188 locally. The sibling markdown file resolved the same way is unaffected and was checked rather than assumed: prose has no block structure to unbalance, and it differs from the base by 112 added lines with zero removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBjyHFtRTtHDD2tM6V6aUV * test(#3829): inject the unreadable-review failure in a way root cannot bypass Round 9 finding 1. `runShippedGateCounts({ mode: 0o000 })` does not simulate an unreadable review under root: root bypasses POSIX read permission bits, so the fence's `[ -f "$REVIEW_FILE" ] && [ -r "$REVIEW_FILE" ]` guard stays true, the fixture is read, and the assertion sees a real breakdown where it expects silence. Reproduced as reported — `node:24-slim`, euid 0: not ok 18 - an unreadable REVIEW.md leaves the counts empty and does not abort actual: 'Code review: 4 findings — 1 critical, 2 warning, 1 info.\n...' **The prescribed remedy does not reach this site, so this adapts it rather than applying it.** Stubbing `fs.readFileSync` to throw EACCES is the right fix where the read happens in-process; here the read is performed by a spawned `bash`, so node's `fs` is not on the code path and the stub would change nothing. What the guard actually has is two legs, and only `-r` is defeated by root: - the `-r` leg keeps the mode-bit fixture and declares the lanes it cannot bind on (`win32`, `euid 0`), which is exactly what tests/plan-review-convergence.test.cjs:2326 does for its own shell-side `-r` arm — the repo's existing precedent for this shape; - the `-f` leg is new and root-immune: a DIRECTORY at the review path fails `-f` for every euid, reaching the same non-reporting arm with the same observable. It binds on the bench lane where the first test is skipped. Skipping the first without adding the second would have traded a false failure for lost coverage on the only lane that found this. Reversion control, run as root: reverting this commit fails exactly `an unreadable REVIEW.md leaves the counts empty and does not abort` and its enclosing describe `#3861 round 1 — the counts mirror is asserted against the shipped shell`, with no other change to the failure set. **That answers the round's open question** — the review flagged the describe as possibly a second root cause; it is the first one's rollup, and there is no second. Local (euid 1000): 189/189, both tests run. Root: 179 pass / 1 skip, the skip naming its reason, the directory test running. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBjyHFtRTtHDD2tM6V6aUV * chore(#3829): refresh the compact-content benchmark baseline for the gate's edit Self-found in this round; not raised in review. The base range landed #4139's compact-content benchmark, whose committed baseline records per-workflow token counts. This PR replaces a 12-line bash block in `execute-phase.md`'s code_review_gate with a 5-line dispatch paragraph, which moves that workflow's measured counts by 17 tokens — so the baseline the base just added drifts against a tree it was measured before. DRIFT: split "execute-phase": off 25631 -> 25614 (-17), on 23380 -> 23363 (-17) DRIFT: aggregate: off 106923 -> 106906, on 90275 -> 90258 Refreshed with the remedy the script itself names (`node scripts/benchmark-compact-content.cjs --write`). The regenerated diff touches only the `execute-phase` entry and the aggregate — every other workflow's numbers are byte-identical, which is the reconcile this PR's edit predicts. Attributed rather than assumed: `tests/benchmark-compact-content.test.cjs` is 27/27 at `upstream/next` with no PR content, and was 26/27 on this head. So the drift is this PR's, not base noise — and it is invisible to a diff-scoped sweep, because the PR never touches the baseline file and the base range is what created it. The sibling `benchmark:compact-content-variants` was checked in the same pass and reports up to date, so this is the only one of the pair affected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBjyHFtRTtHDD2tM6V6aUV * test(#3829): cover an EMPTY REVIEW.md, a case the body claimed and no test reached Found by this round's own adversarial audit of the PR body, not by review. The body has said since round 1 that the suite covers "a REVIEW.md that is missing, empty, or a directory". Two of those three were true. The empty one was not. Every `reviewText: ''` call in this file also passes `writeReview: false`, which makes the file MISSING, not empty — so the arm the body named had no test at all. They are genuinely different paths through the shipped fence: a missing file never gets past `[ -f ]`, while an empty one passes both `[ -f ]` and `[ -r ]` and is actually opened and read. Probed the shipped fence directly against a real empty file before asserting anything: exit 0, empty stdout. So the behaviour was already correct and only the coverage claim was false — which is the same "documented as covered, not covered" shape this PR exists to make visible in the review gate, found in its own body. The explanatory comment is deliberately precise about WHY the scan yields nothing, because the plausible reading is wrong and a later reader would inherit it: it is not the `NR==1{if($0!="---") exit}` guard. A zero-byte file gives awk no record, so that action never runs (NR stays 0); the output is empty because `closed` is never set. The comment also states what the test does not prove on its own — its observable is identical to the missing-file case, so "the file was read" rests on the harness and the fence, not on the assertions. 189 -> 190 tests in this file, all passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBjyHFtRTtHDD2tM6V6aUV * chore(#3829): regenerate derived artifacts after rebase onto next The rebase onto current `next` conflicted in the 19 install-tree goldens, `docs/INVENTORY-MANIFEST.json` and the compact-content benchmark baseline. Those are generated, so they were resolved arbitrarily and regenerated with their own producers rather than hand-merged: every golden now differs from `next`'s committed copy by exactly the one PR-owned entry (`gsd-core/workflows/execute-phase/steps/code-review-disposition.md`), the manifest by the same entry, and the benchmark baseline by the `execute-phase` split plus the aggregate. * chore(#3829): regenerate the platform-conformance tier for the added property test `next` gained the conformance-tier classifier (#4591) and its CI gate after this branch was cut. The branch adds `tests/code-review-disposition.property.test.cjs`, so the generated tier list was one file short (546 != 547). Regenerated with `node scripts/gen-platform-conformance-tier.cjs --write`; the macOS tier (`--target macos --check`) already matched. * chore(#3829): regenerate the two conflicted generated artifacts after the rebase `next` moved 6 commits past the previous base and conflicted in exactly two files, both generated: - `tests/fixtures/compact-content-benchmark-baseline.json` — #4208 (`4cc2a466b`) and #4619 (`db4d8a9ba`) both moved the measured token counts, and this branch moves the `execute-phase` split too. - `scripts/lib/platform-conformance-tier.generated.cjs` — #4641 (`4d65c248e`) made test-conformance the sole Windows selector and narrowed the tier to 28.5%, and #4568/#4619 re-ran it after. Both were resolved arbitrarily during the replay and then regenerated with their own producers rather than hand-merged, per the generated-artifact rule: `node scripts/benchmark-compact-content.cjs --write` and `npm run regen:derived` (which runs `gen-platform-conformance-tier.cjs --write` for both the default and the macOS target). Reconciled against `next`'s own committed copies rather than the pre-regen tree: - the benchmark baseline differs from `next` by exactly the `execute-phase` split (`offTokens` 25827 -> 25810, `onTokens` 23576 -> 23559 — the 17-token delta this PR's step-file extraction has carried since round 5) plus the `aggregate` that sums it; - the conformance tier differs from `next` by exactly one added entry, `tests/code-review-fix-pipeline-regression.test.cjs`. Under the narrowed 28.5% selector that is the file the classifier now picks from this PR's test set. The arbitrary resolution had carried 282 stale lines computed under the pre-#4641 selector (`--numstat` on this commit: 3 insertions, 282 deletions), and regeneration collapsed them. The full derived sweep was run, not just the two named producers: all 19 install-tree goldens, `docs/INVENTORY-MANIFEST.json`, `docs/FEATURES.md`, the macOS conformance tier and the exit-code registries regenerated byte-identical, so nothing else drifted under the new base. * fix(#3829): accept N-segment phase ids, and bound length per component The base range added `scanMarkdownSingleSegmentPhaseRegex` (#4568, `a2331c01f`), which refuses the single-optional-segment phase regex on phase-carrying markdown lines under three roots — `gsd-core/workflows/`, `gsd-core/references/` and `agents/` (`lint-phase-id-drift.cjs:301`, `:373-387`). It flagged two lines in this step file. Chasing the flag turned up two real defects behind it, so this commit is those rather than the comment edit the flag literally asked for. ## Defect 1 — the step refused ids both its callers accept The comments asserted that both callers validate `^[0-9]+(\.[0-9]+)?$`. #4568 had widened those two call sites to `^[0-9]+(\.[0-9]+)*$`, so the prose was stale. Correcting only the prose would have shipped a comment promising N-segment support over code that refused it, because the step carried a third `case` arm: *.*.*) _ok=0 ;; # more than one dot: not the documented shape `23.1.2` took the refusal arm, `PADDED` came back empty, and the step printed `Code review reporting skipped (unusable phase number ...)` and wrote **no ledger** — for a phase id both of its callers accept. It degraded loudly rather than silently; there is a diagnostic on stdout. The traversal-fence test asserted that refusal as *correct*, listing `1.2.3` among the values that must be rejected, so an arity bound and a shape bound sat folded into one `case` arm with a test pinning the pair. The arity arm is gone. Deleting it alone would have left the step **wider** than its callers in one direction — `1..2` has an empty segment, which `^[0-9]+(\.[0-9]+)*$` refuses and the retired arm had been masking — so a third arm replaces it: *..*) _ok=0 ;; # EMPTY SEGMENT ## Defect 2 — the length bound was not per-component, though its comment said so Removing the arity arm made a second defect reachable. The bound read: case "$_pn" in *.*) case "${_pn#*.}" in ?????????*) _ok=0 ;; esac ;; esac `${_pn#*.}` is the whole tail after the first dot — one component only while an id has at most two. With N-segment ids accepted, that form rejects `1.1234567.1`, whose every component is a legal 7 digits, purely because the tail measures 9 characters. The comment directly above it has read **"LENGTH-BOUND EACH COMPONENT SEPARATELY"** since before this PR, and had itself named the composite bound as "too strict" — the same mistake, surviving one level up. Both fences now walk the segments and bound each: _rest="$_pn" while [ -n "$_rest" ]; do case "$_rest" in *.*) _seg="${_rest%%.*}"; _rest="${_rest#*.}" ;; *) _seg="$_rest"; _rest="" ;; esac case "$_seg" in ?????????*) _ok=0 ;; esac done The `$((10#...))` overflow guard is preserved, per component: bash integers wrap at 2^64, so an unbounded integer segment would silently become a negative padded phase. **The remaining divergence from the callers is a CLASS, not a list:** any id carrying a component of nine or more characters is caller-accepted and fence-refused — `123456789`, `1.999999999`, `1.123456789.1`, `123456789.1`, `1.1.123456789` and so on. That narrowing is deliberate and is the overflow guard. An earlier draft named two examples as though they were exhaustive; that wording is withdrawn. ## What is NOT claimed - The canonical grammar is `PHASE_NUMBER_TOKEN_SOURCE` in `src/phase-id.cts:65`, `\d+[A-Z]?(?:\.\d+)*`, added by **#2128** (`09be501eb`, 2026-07-10). An earlier draft dated it to #865 (2026-06-08); that was the first commit to touch the *file*, not the one that added the constant, and it is withdrawn. - #4568 gave the six shell sites **segment-count** parity with that grammar, not textual parity: the canonical source permits an optional `[A-Z]`, and the shell literals remain digit-only. Driven: this step and both callers all refuse `23A.1`, so they agree with each other and are jointly narrower than `src/phase-id.cts`. That is a question about the six sites rather than about this step, and it is not touched here. - **#4619 does not produce N-segment ids.** It only transforms an already-supplied `{phase_number}` so `$((10#...))` does not abort on one. An earlier draft cited it as the producer; that is withdrawn, and is stated rather than silently swapped so a reader can see it was corrected. - That the folded `case` arm is *why* nothing caught this is an observation about the test's shape, not an established cause. ## Tests - `an N-SEGMENT phase number reports counts, exactly as its callers accept it` — drives `23.1.2` **and** `1.2.3.4`: the retired guard was arity-shaped, so a bound merely moved from two dots to three would pass a three-segment-only test. - `the length bound is PER COMPONENT, not over the whole tail after the first dot` — drives an 8-char and a 9-char **middle** segment, the position the old form got wrong. - `the fence agrees with its callers across a probed set spanning both boundaries` — example-based, and says so: a finite probe cannot prove congruence over an infinite language, and one review pass demonstrated that by injecting a `2) _ok=0` arm this test still passed. It is a regression pin over the values that actually broke. - The traversal list loses `1.2.3` (legal at this base) and gains `1..2` and `1.2.` — the malformed-dot cases the arity guard had masked. **Negative controls, re-measured against reconstructed fences:** fence state N-seg probed per-comp fully pre-fix FAIL FAIL FAIL shape-fix only PASS FAIL FAIL this tree PASS PASS PASS Two tests, not one, catch Defect 2: the caller-agreement probe includes `1.1234567.1`, so the whole-tail bound breaks it too. An earlier draft claimed the per-component test failed alone — that table was written before `1.1234567.1` was added to the probe and was not re-measured afterwards. It is corrected here from a fresh run. The N-segment test correctly does not fire on Defect 2; it predates the bound work and is insensitive to it. Found by this round's own adversarial review passes. * docs(#3829): name this step's two dispatchers correctly, in code as well as in the PR body `code-review.md` is not a call site of this step. It carries an identical `^[0-9]+(\.[0-9]+)*$` validator, which is why it kept getting cited as one, but it never dispatches `code-review-disposition.md`. The two dispatchers are `execute-phase.md` (`code_review_gate`) and `code-review-fix.md` (`record_disposition`) -- and only the second validates anything. This round corrected that in the PR body and simultaneously wrote the old conflation into the shipped comments, so the file asserted at line 24 what the body denied in public, and contradicted its own line 48. Four false assertions, each duplicated because the fenced block is emitted twice: - "Both callers explicitly accept ... (code-review.md:63, code-review-fix.md:39)" - "#4568 widened both of this step's callers" -- it widened the one that validates; the other has no validator to widen - "Both callers already validate ... (code-review.md:63, code-review-fix.md:39)" - "a SHAPE (..., asserted by both callers)" -- asserted by one The same conflation had propagated into three comments in `code-review-pipeline-regression.test.cjs`; corrected there too. Adds the one fact that follows from naming the dispatchers correctly and that nothing else in the tree records: `execute-phase.md` applies NO shape gate, so this fence is not mirroring an upstream guarantee -- it IS the guarantee. A later reader who believes the caller validates will "simplify" it away. Comments only. The executable shell is byte-identical to 03adf9474 (verified by stripping comment lines and diffing). 200/200 regression + property, 47/47 prompt-injection security scan, eslint and lint:generated-sync clean. The wider [A-Z]-axis divergence between these sites and the canonical `PHASE_NUMBER_TOKEN_SOURCE` is tracked separately as #4660 and deliberately not restated here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xdw628PpLYveWfJu7kDyvZ * chore(#3829): regenerate the two conflicted generated artifacts after the rebase onto next Both conflicted during the replay onto `eb49ff98d` and were resolved arbitrarily, then regenerated with their own producers (`npm run regen:derived`, `node scripts/benchmark-compact-content.cjs --write`) rather than hand-merged. Reconciled against next's committed copies: the conformance tier differs by the one entry this PR adds, the benchmark baseline by the `execute-phase` split (the same 17-token delta this PR's step-file extraction has carried since round 5) plus the aggregate that sums it. The rest of the derived sweep — 19 install-tree goldens, INVENTORY-MANIFEST, FEATURES, the macOS tier, the exit-code registries — regenerated byte-identical. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U9FANs6AXuXNa7fCSahTQe * fix(#3829): a carried row keeps the severity the ledger recorded, instead of re-inferring it from the prefix The ledger always wrote a severity for every row (table cell and frontmatter key) and nothing read either back: the prior-row regex skipped the cell as `[^|]*`, the frontmatter walk collected only titles, and a carried row was rebuilt through sev() from the id prefix, because sectionSev holds only the findings the CURRENT review reports. So a WR-04 the reviewer filed under `## Critical Issues` was recorded critical, a human deferred it, and the next run -- the review no longer reporting it -- silently re-recorded it warning. The one artifact whose purpose is remembering a finding's severity lost it on the second run, in the unsafe direction (round 11, reproduced by executing the shipped script twice). Both persisted copies are now read back, enum-validated (ADR-227, as the disposition column already is): the table cell first, the frontmatter `severity:` as the fallback for a hand-mangled cell. Severity precedence is the current review's SECTION, then the RECORDED value, then the id PREFIX, and the recorded value is inherited only while the id still names the same finding -- the identity rule the disposition already obeys -- so a reused id starts from its own review. sev() moves below sameFinding() because it now depends on it. Tests: a new describe drives the reviewer's exact case (WR-04 under `## Critical Issues`, deferred by hand, dropped by the next review -> stays critical) plus five controls: recorded outranks prefix under no recognized section; the current section still outranks recorded; a REUSED id does not inherit; a mangled cell falls back to the frontmatter and a mangled pair to the prefix; a bare pre-severity row still infers. A fast-check property assigns each finding a section independent of its prefix, carries every row through an empty review, and asserts the section severity survives and the third run reports unchanged. Negative control, measured against the pre-fix step: the two carry tests, the mangled-cell test and the property fail; the three precedence/back-compat controls pass at both ends, as they pin behaviour that predates the fix. Every prior carried-row test used CR-01/IN-01, whose prefix already matched, so the lossy path had returned the right answer by coincidence. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U9FANs6AXuXNa7fCSahTQe * fix(#3829): a malformed REVIEW.md is reported as unparsed, not passed over as clean A REVIEW.md with three criticals and an unterminated frontmatter yielded REVIEW_STATUS='', and the counting arm then printed nothing -- byte-identical to a clean review. The guard that scopes the frontmatter scan was right to yield no values from an unterminated block; the reporting arm was wrong to treat 'no status' as 'no review'. Block 2 said `status: none` rather than `clean`, which is why a careful reader could still separate them (round 11, Minor). Both fences now record whether the file was actually READ, separately from what it yielded. A read file with no parseable status -- unterminated frontmatter, no frontmatter, no `status:` key, a zero-byte file -- prints `Code review status unparsed: ...` with no breakdown (there is none to trust) and no --fix suggestion (nothing proves there are findings). Absent, directory and unreadable stay silent: nothing was read, so nothing is described. Block 2's skip line names the same distinction, `status: unparsed` vs `none`. The counts mirror follows the shell: a mirror is always handed a text, so its empty-status arm is the unparsed one, and the existing 'unterminated frontmatter' and 'no frontmatter at all' parity fixtures now bind the new message on both sides. The EMPTY-file test from round 9 changes its assertion deliberately: its observable is no longer identical to the missing-file case, which is the point. Five new tests drive the arm, its three shapes, the three shapes that stay silent, and block 2's wording. Negative control, against the previous step: the unterminated, no-status, no-frontmatter and empty-file tests fail, both parity fixtures fail (the mirror moved and the shell had not), and block 2's `unparsed` assertion fails; the stays-silent controls pass at both ends. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U9FANs6AXuXNa7fCSahTQe * docs(#3829): name the unlocked read-modify-write and reference #3780 rather than solving it The ledger is rendered whole from a prior read with nothing serializing two writers, and this step has two dispatchers plus an invited hand-edit, so the window is real. It is the shape #3780 reported for WINDOWS.md under parallel executors, which #4681 closed with a cross-process lock in src/broken-windows.cts. Not taken here, deliberately: the step is a shell-embedded script with no dependency on the compiled tree, and adopting the lock module is its own change. Stated at the write site and as a residual in the feature doc; no lost update has been reproduced (round 11, Minor). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U9FANs6AXuXNa7fCSahTQe * docs(#3829): cross-reference the two "review disposition" ledgers in both directions ADR-3806 canonizes a `## Review Dispositions Ledger` section inside PLAN.md for reviews-mode planning: append-only per round, over REVIEWS.md findings. This PR's `<NN>-REVIEW-DISPOSITION.md` is a sibling file beside REVIEW.md for the code-review pipeline, rewritten idempotently with rows carried. Adjacent names, opposite durability rules, and neither document mentioned the other -- the round-11 review checked the ADR gate against 3806, cleared it, and flagged exactly that mis-read hazard. An in-place dated amendment section on ADR-3806 (contributor-standards "Amending an accepted ADR", pattern 1) and a paragraph in the pipeline feature doc, each naming the other and the axis on which they differ. docs/FEATURES.md regenerated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U9FANs6AXuXNa7fCSahTQe * chore(#3829): regenerate the compact-content benchmark baseline after the rebase onto next `next` moved three commits while the round was in flight and the baseline conflicted again; resolved arbitrarily during the replay and regenerated with its own producer. It differs from next's copy by the `execute-phase` split this PR has carried since round 5, plus the aggregate. The rest of the derived sweep regenerated byte-identical. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U9FANs6AXuXNa7fCSahTQe * fix(#3829): accept letter-variant phase ids, matching the canonical grammar #4744 widened the dispatchers to #4744 (#4660) landed on `next` while this round was in flight: it widened the six shell/markdown phase-number mirrors -- `code-review-fix.md:39`, this step's validating dispatcher, among them -- to the canonical grammar's letter axis (`12A`, `3A`, `23A.1.2`), and added a `lint-phase-id-drift` ratchet that flags any digit-only mirror left in the workflow tree. Rebased onto that base, this step was the one it flagged (two fences, two sites): `12A` was refused by name and wrote no ledger, for a phase id its own dispatcher now accepts -- the round-10 class ("the step refused phase ids its validating dispatcher accepts") re-opened by the base. Found by running the base range's modified gates against the rebased tree, not by the review. Both fences now admit an uppercase letter in the character class and pin WHERE it may sit -- only as the last character of the integer part, at most once -- so `23a`, `A23`, `2A3`, `23AB` and `23.1A` stay refused. The per-component length bound is on the DIGITS (the letter is one character the `$((10#...))` overflow guard has no stake in, so `12345678A` is within it exactly as `12345678` is), and the letter is carried verbatim after the padded digits, `3A` -> `03A`, as `src/phase-id.cts` pads it. The two fences stay line-identical except for their refusal message (the parity test holds), and every comment literal of the old shape reads the canonical one. Tests: a new fixture drives `12A`, `3A`, `23A.1.2` and `12345678A` through the shipped fence to the padded path; the traversal-fence list gains the five wrong placements; the caller-agreement probe's regex gains the letter axis with both-direction cases, and `123456789A` joins the deliberate over-bound narrowing. Negative control against the pre-widening step: the letter-variant test, the caller-agreement probe and the base's `scanMarkdownLetterlessPhaseMirror` gate all fail; the traversal-fence test passes at both ends (the five new placements were already refused, by the narrower class). Also re-anchors the fence and test comments' `code-review-fix.md` / `code-review.md` citations by content (the validator, not a line number): the line numbers had drifted by one against the rebased base, and drift again on every rebase. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U9FANs6AXuXNa7fCSahTQe * chore(#3829): regenerate the two conflicted generated artifacts after the rebase onto next Both files conflicted during the replay and were resolved arbitrarily rather than hand-merged, then regenerated with their own producers — `npm run regen:derived` and `node scripts/benchmark-compact-content.cjs --write`. Reconciled against next's own committed copies rather than against the pre-regen tree, because a clean textual merge of a pinned-number file attests the merge and not the numbers: - `tests/fixtures/compact-content-benchmark-baseline.json` differs from next by exactly the `execute-phase` split (offTokens 26264 -> 26200, onTokens 24013 -> 23949) and the `aggregate` that sums it. That is the step-file extraction this PR has carried since round 5, re-measured against the new base; no other entry moved. - `scripts/lib/platform-conformance-tier.generated.cjs` differs from next by exactly one added entry, `tests/code-review-fix-pipeline-regression.test.cjs` — the file the classifier picks from this PR's test set. The full derived sweep was run, not just the two named producers: all 19 install-tree goldens, docs/FEATURES.md, docs/INVENTORY-MANIFEST.json, the macOS tier and the exit-code registries regenerate byte-identical under the new base, so nothing else drifted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks * fix(#3829): stop block 2 computing an unparsed shortfall from a self-contradicting findings block Round 12, Minor. Confirmed, and the premise is slightly stronger than stated: block 2 does not merely skip block 1's `critical + warning + info == total` cross-check — it never derived the three severity counts at all, so the check's inputs were absent. It bounded `total` for digits and length only and handed it to the `unparsed:` reconciliation. So a REVIEW.md whose `findings:` block disagrees with itself (`total: 10` beside `critical: 1, warning: 1, info: 1`) made block 1 print the countless form — breakdown suppressed as untrustworthy — while block 2 still computed a shortfall from that same untrusted number. Two trust models for one field, one fence apart, with the weaker one downstream. It fails in the safe direction, which is why the review did not raise it as a blocker; it is still a real inconsistency. Block 2 now derives `critical`/`blocker`, `warning` and `info` through the same `findings:`-anchored filter block 1 uses, with the same digit-and-length bound and the same `10#` on every operand, and blanks `total` when the three disagree with it. **Adapted, not applied verbatim — and the divergence is the point.** The finding says to re-apply block 1's cross-check. Block 1's gate is `REVIEW_COUNTS_OK`, which demands all four counts be numeric, because block 1 DISPLAYS all four and `6 findings — critical` is the half-filled line that rule exists to prevent. Block 2 displays none of them; it uses `total` alone, against the number of headings the row parser matched. Applied verbatim, the all-four rule blanks a perfectly usable `total: 5` on a review carrying no severity keys and SILENTLY DROPS an `unparsed:` shortfall this step reports correctly today — trading a safe-direction over-report for a silent under-report, which is the wrong way round and is the exact failure class the `unparsed:` key was added to close. Only the CONTRADICTION ports: absent counts are not a disagreement, because there is nothing to disagree with. Driven against the shipped fence, not a mirror: consistent 1+1+1=3 -> total 3 CONTRADICTION total:10 -> withheld blocker: alternation -> total 2 counts absent -> total 5 (kept) leading zeros 01+01+01=03 -> total 03 one count absent -> total 5 (kept) no findings block -> withheld non-numeric count -> total 5 (kept) Six regression tests drive the second markdown fence end to end through `runHook` under bash, asserting on the rendered ledger. Negative controls fire in OPPOSITE directions, which is what pins the narrowing rather than only the fix: - revert the fence fix -> `a contradicting findings block yields no shortfall` and the `blocker:` twin go red - apply the VERBATIM all-four -> `a total with NO severity keys still reconciles` prescription instead and the partial/non-numeric case go red Restored tree: 214/214. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks * docs(#3829): state the one input that produces no unparsed shortfall The reference page said the shortfall "is stated" whenever `total:` exceeds the parsed headings. After the round-12 fix that is conditional, and a doc asserting the unconditional form describes behaviour the step no longer has. Names the boundary in both directions, because the narrowing is the part a reader would otherwise get wrong: a `findings:` block whose three severities are all present, numeric and do not sum to `total` produces no key — the same input on which the console line already withholds the breakdown — while counts that are merely absent, partial or non-numeric are not a disagreement and still reconcile from `total` alone. `docs/FEATURES.md` regenerated; `gen-features --check` green (182 features, 21 groups). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks * fix(#3829): close two holes the round's own adversarial pass found in its first attempt Neither is from the maintainer's review. Both were found by the pre-push adversarial pass over this round's own claims, which refuted them by execution. **1. A malformed severity could suppress a real shortfall.** The sibling frontmatter reads use `cut -d: -f2 | tr -d ' '`, and `tr -d` deletes INTERNAL spaces, so `critical: 1 0` arrives as the perfectly numeric `10`. That is long-standing in those reads — its mirror is pinned as a fixture from round 1 — and it was INERT in block 2 until this round made that block read the severities at all. At that point a repaired number could satisfy the new sum test and suppress an `unparsed:` shortfall that is genuinely owed. Driven, pre-fix: `critical: 1 0 / warning: 0 / info: 0 / total: 5` against three parsed headings emitted no `unparsed:` key where `unparsed: 2` was correct. The three severity reads now trim the ends only, so an internal space survives into the digit check and fails it — `_sum_ok=0`, nothing is suppressed. Fail-safe in the only direction that matters: when the frontmatter is malformed the step declines to suppress rather than trusting a repaired number. **Scope, stated:** only the SUPPRESSION inputs are strict. `REVIEW_TOTAL`'s own read still uses `tr -d ' '`, unchanged and identical to block 1's — narrowing it would change the `unparsed:` computation itself, which is pre-existing behaviour and wider than this round. So `total: 1 0` is still read as `10` by both blocks, as before. **2. The repointed #4748 gate could not see a later rebinding.** Its derivation slices stop AT the first anchored assignment, so inserting the canonical lookup and then overriding it with `REVIEW_FILE="${_pd}/WRONG-REVIEW.md"` left every assertion green — the slice pins a line, not the path the fence actually consumes. A new test pins the whole file instead: the only `REVIEW_FILE=` bindings permitted are the canonical lookup (exactly twice, once per fence, each being a fresh shell) and the identity pass-through that hands it to the embedded node script as an env prefix. Negative controls, both the adversarial pass's own mutations, against the restored tree at 215/215 and 164/164: - restore `tr -d ' '` on the severity reads -> the internal-space test reds - insert the WRONG-REVIEW override after the lookup -> the rebinding test reds Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks * fix(#3829): one parser for every count block 2 reads, and widen the rebinding guard A second adversarial pass, run against the first pass's own fixes, refuted three of them by execution. Fixes to review findings are the class most likely to carry a new defect, which is why that pass exists; all three were real. **1. `cut -d: -f2` takes the SECOND FIELD, not the scalar.** So `critical: 1: junk` arrived as the perfectly numeric `1`, and `1+0+0 != 5` was read as a contradiction that SUPPRESSED a shortfall genuinely owed. The previous fix trimmed the ends but still cut at the wrong place, so it closed the internal-space shape and left this one open. `-f2-` keeps everything after the first colon; the malformed scalar stays malformed and `total: 5` still reconciles. **2. The deliberate asymmetry was wrong, and it FABRICATED.** The previous fix parsed the severities strictly and left `total` lenient, on the reasoning that narrowing `total` was out of scope. Driven: `critical: 5 0` with `total: 1 0` repaired only the total to `10`, rejected the severity, skipped the contradiction check, and invented `unparsed: 7` against three parsed headings. Both uniform policies behave sanely — strict rejects the malformed total, lenient detects `50 != 10`. A field is either trustworthy or it is not; parsing one leniently and its sibling strictly is the shape that fabricates. Every count this block reads now goes through one parser. **Scope, restated because it moved:** the previous commit said `REVIEW_TOTAL`'s read was deliberately unchanged. That is no longer true and the reasoning behind it did not survive contact — the asymmetry it protected is what produced the fabrication. Block 1's reads are still untouched; its own all-four gate runs over consistently-parsed values, so it has no equivalent split. **3. The rebinding guard missed an indented or exported assignment.** `^REVIEW_FILE=` let both ` REVIEW_FILE=...` and `export REVIEW_FILE=...` through, and each executes exactly like a bare one. The predicate now absorbs leading whitespace and an optional `export` before the accept-list decides. Driven after the fix, against three parsed headings: critical: '1: junk' total: 5 -> total 5 kept, unparsed: 2 reported critical: '5 0' total: '1 0' -> total rejected, no unparsed key critical: '1 0' total: 5 -> total 5 kept (unchanged) consistent / contradiction / blocker / leading-zero / absent — all unchanged Negative controls, each the adversarial pass's own mutation, against 381/381: - `-f2-` back to `-f2` -> the second-colon test reds - `total` back to lenient `tr -d` -> the fabricated-shortfall test reds - an INDENTED rebinding -> the rebinding guard reds - an `export` rebinding -> the rebinding guard reds Residual, disclosed: a duplicate `critical:`/`blocker:` key is still resolved by `grep -m1` taking the first match. Duplicate keys are invalid YAML and the same first-match rule is long-standing in the sibling reads; detecting them is a wider change than this round. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks * fix(#3829): one parser for the whole step, and prove a contradiction from a partial sum A third adversarial pass, run against the second pass's fixes. Three findings, plus one this round's own negative control caught afterwards. **1. An absent severity still bounds the sum from below.** Counts are non-negative, so a missing one can only ADD: when the severities that ARE present already sum to MORE than `total`, the block disagrees with itself whatever the absent value is. Requiring all three before comparing missed that — driven: `critical: 4`, `warning: 4`, no `info:`, `total: 5` reconciled against a total the present counts had already refuted. The comparison is two-armed now: EQUALITY when all three are known, a LOWER BOUND when they are not. An UNDERshoot stays reconcilable, because that is exactly what the absent count explains. **2. Block 1 now uses the same parser, so the console and the ledger cannot contradict each other.** Tightening block 2 first left the two fences disagreeing about the same bytes. Driven: `critical: 1 0` with `total: 1 0` repairs to 10 and 10, which SUM — so block 1 reported `10 findings — 10 critical, 0 warning, 0 info.` from a `findings:` block containing no such numbers, while the ledger recorded three rows and no shortfall. Block 1's reads move to `cut -d: -f2-` plus an end-trim; both fences now take the countless arm on that input. The counts mirror moves with them — its whole job is modelling the shipped pipeline, and it modelled the retired one. This is wider than the review's finding and I want that visible: the finding was about block 2 alone. But a disclosed divergence between a console line and a ledger is the confusion this PR exists to remove, so it is fixed rather than documented. **3. `REVIEW_FILE+=-wrong` executes and was missed.** The rebinding guard matched only `=`; `+=` appends (driven: `REVIEW_FILE=good; REVIEW_FILE+=-wrong` prints `good-wrong`). **4. My first negative control for (2) was VACUOUS, and that is the reason for the new `BLOCK 1 withholds a breakdown built from REPAIRED counts` test.** Reverting block 1's parser left the suite green: on every fixture that existed both parsers landed on the same arm, so parity could not see the difference. A SELF-CONSISTENT repaired breakdown separates them, and the test pins it directly rather than through parity. **Correction to the previous commit's claim.** It said moving `total` to a strict read "changes nothing for a well-formed review". That is false: `total:\t5\t` is valid YAML (`yaml.parse` returns 5) which the old `tr -d ' '` rejected and the new trim accepts. The change is an improvement, not a no-op, and the claim was the wrong shape. Also from the third pass's MISSED: the fixtures exercised malformed `critical` and `total` only, so they did not pin the four-field symmetry the fix claims. Every field now gets every malformed shape. Negative controls, against the restored tree at 383/383 (220 in the pipeline file): - revert block 1's parser -> the repaired-counts test reds (was vacuous; now fires) - revert the overshoot arm -> the absent-severity contradiction test reds - a `+=` rebinding -> the rebinding guard reds Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks * fix(#3829): finish the mirror update, and give status the same parser as the counts A fourth adversarial pass. The important finding is that the PREVIOUS commit's mirror update was HALF APPLIED, and the suite could not see it. **1. The counts mirror was still on the retired parser.** That file carries TWO helpers: `first`, used for `status:`, and `firstIn`, used for ALL FOUR counts. The previous commit updated `first` and the comment above it, and left `firstIn` on `split(':')[1].replace(/ /g,'')` — so the shipped block had moved to `-f2-` + end-trim and the mirror had not, while the parity assertion stayed green. It stayed green for the same reason this round's earlier negative control was vacuous: on every fixture that existed, both parsers reach the COUNTLESS arm, so the rendered message is identical and parity cannot see the divergence. Two fixtures now separate them — `a self-consistent repaired breakdown` (10 == 10+0+0, so the retired parser renders a full breakdown from a `findings:` block containing no such numbers) and `tab-separated counts` (valid YAML the retired `tr -d ' '` made non-numeric). Reverting `firstIn` reds both. This is the same shape this PR's round-3 reply already recorded about itself: a fix verified with a grep built from the strings just fixed. The region is checked by reading it end to end now. **2. `status:` kept the retired parser after the counts moved off it, and it is the read where truncation costs most.** `cut -d: -f2` turned the valid YAML scalar `status: clean:junk` into the bare `clean`, so an unusable status took the CLEAN arm and suppressed BOTH the console report and the ledger. Driven, both parsers side by side. The whole scalar matches no arm now, so the step reports. One parser for every scalar this step reads, in both fences. **Two claim corrections, no code change:** - The previous commit implied block 1's console output was preserved for every well-formed review. It is not: `critical:\t1` is valid YAML that the retired `tr -d ' '` left non-numeric (countless form) and the trim now reads (full breakdown). That is an improvement, and the claim was the wrong shape. The `tab-separated counts` fixture pins it. - "Block 1 and block 2 can no longer contradict each other about counts" was overstated. It is true of the PARSER, which is what changed. They can still differ when the body carries MORE findings than `total:` declares: the reconciliation reports a shortfall only, and the excess direction is deliberately clamped so a review under-declaring its own total cannot render `unparsed: -1` — pinned by the round-2 test `a total SMALLER than the rows is not reported as a negative shortfall`. That is pre-existing and out of this round's scope; stating it rather than widening scope again. Negative controls, against the restored tree at 223/223 (387 across both files): - revert the mirror's `firstIn` -> both new parity fixtures red - revert the `status:` parser -> the clean-arm suppression test reds `npm run lint:ci` exits 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks * fix(#3829): pin the reads to LC_ALL=C, so the parser cannot depend on the machine A fifth adversarial pass. It refuted the claim that the shipped reads and their JS mirror are equivalent, and the counterexample is a locale. **The POSIX character classes are locale-defined, and glibc's C.UTF-8 disagrees with both C and en_US.UTF-8.** Driven, same sed, same input, three locales: LC_ALL=C clean<U+2003> -> clean<U+2003> (kept) LC_ALL=C.UTF-8 clean<U+2003> -> clean (trimmed) LC_ALL=en_US.UTF-8 clean<U+2003> -> clean<U+2003> (kept) C.UTF-8 classifies U+2003 — and U+1680, U+2000-U+200A, U+205F, U+3000 — as BOTH [[:space:]] and [[:blank:]]. So `status: clean<U+2003>` trimmed to the bare `clean`, took the CLEAN arm, and silently suppressed both the console report and the ledger — but only on machines whose locale said so. That is the same suppression the previous commit fixed for `clean:junk`, with a machine-dependent trigger instead of a parse one. `[[:blank:]]` is NOT the fix — it is locale-defined too, and C.UTF-8 puts U+2003 in it as well. Nor is `[ \t]`: POSIX bracket expressions provide no escape at all, so `\t` there is a backslash and a `t`. GNU sed's default reading of it as TAB is an extension, and the same GNU sed asked for conformance shows the other reading on this host: sed -E 's/[ \t]+$//' draft -> draft sed --posix -E 's/[ \t]+$//' draft -> draf A POSIX-conforming sed is therefore expected to truncate `status: draft` to `draf`. That expectation is derived from POSIX plus the `--posix` demonstration above; it was NOT driven against a BSD/macOS sed, because this host has none. The portable fix is to pin the locale: under C the class is exactly {space, tab, NL, VT, FF, CR}, which is precisely what the mirror already spells out literally. The two now agree by construction rather than by coincidence of the machine. All 20 read sites (10 `grep`, 10 `sed`, both fences) are pinned. The mirror's four anchors move from JS `\s` to the same literal class, closing the divergence in the other direction — `\s` matches a U+2003 indent that the pinned `grep` does not. **A second gap, found by this round's own control rather than by the reviewer.** The mirror has two helpers, and the previous commit proved `firstIn` (the counts) was pinned by a fixture. `first` (the status) was NOT: reverting it left the suite green. Every pre-existing status fixture left both parsers on the SAME arm — `issues:found` truncates to `issues`, which is no more `clean` than `issues:found` is — so the status mirror could drift unseen, exactly as `firstIn` had. The fixture that separates them is one where truncation FLIPS the arm: `status: clean:junk`. That is the third time this round a mirror edit was invisible to the fixtures that existed, and the question that finds it every time is: what input actually separates the two versions? **Two prose corrections in the step**, which had gone stale rather than wrong-headed: the block-2 comment still said "the sibling reads use `tr -d`" after they had all been moved off it, and the mirror's class comment claimed an equivalence it did not yet have. Negative controls, each driven against the committed tree: - drop LC_ALL=C from the shipped seds -> 5 red, incl. both locale-invariance tests - revert the `first` status mirror -> `a status whose truncation would flip the arm` reds - revert the mirror anchors to `\s` -> `locale-invariant on a unicode-space indented count key` reds The locale-invariance tests are the durable guard: the parity fixtures only run under whatever locale the suite inherits, so they can catch this only on a machine that already has the bug. These drive the same input under both locales and assert the shipped fence does not care. 238/238 across the two pipeline files. `npm run lint:ci` exits 0, with lint-workflow-shellcheck reporting 212 pre-existing findings and 0 new. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks * fix(#3829): pin the awk selectors too, and assert the invariant instead of claiming it A sixth adversarial pass, and it refuted the previous commit's central claim. That commit pinned all 10 `grep` and all 10 `sed` reads to LC_ALL=C and then said the parser no longer depends on the machine. It does: the `findings:` MAPPING SELECTOR is an `awk`, and both copies of it were left unpinned. `awk '/^findings:[[:space:]]*$/{f=1; next} f&&/^[^[:space:]]/{exit} f'` resolves its two character classes through the ambient locale exactly as grep's and sed's did. Driven, on `findings:<U+2003>`: fence 1, LC_ALL=C Code review found issues. fence 1, LC_ALL=C.UTF-8 Code review: 1 findings — 1 critical, 0 warning, 0 info. fence 2, LC_ALL=C TOTAL='' fence 2, LC_ALL=C.UTF-8 TOTAL=1 Under C.UTF-8 the opener matched and the mapping opened; under C it did not. The same review rendered a breakdown on one machine and the countless message on another, with every grep and sed already pinned. **Why it was missed is the more useful part.** The previous commit's census counted `grep` and `sed` sites and reported zero unpinned — because it SEARCHED FOR THE TOOLS IT HAD JUST EDITED rather than for the tools that were there. That is the same shape as this round's other three misses: a check built from the thing just changed cannot see what the change forgot. So this commit does not just add the fourth and fifth pins; it replaces the claim with an assertion the next edit cannot fool: `every locale-sensitive tool in the step is pinned to LC_ALL=C` walks the step file and fails on ANY unpinned `grep`/`sed`/`awk`, naming line and call. `cut -d: -f2-` and `tr -d '\r'` stay exempt, and the exemption is principled rather than residual: neither resolves a character class or a collation — one splits on a single ASCII byte, the other deletes one literal byte. The mirror's block boundary moves to the same literal classes, for the same reason the anchors did last commit — `/^findings:\s*$/` and `/^\S/` model neither pinned side. **A correction to the previous commit's message, made in place.** It asserted that BSD sed reads `[ \t]` as a literal backslash and `t`, stated as driven fact. It was not driven — this host has no BSD sed. The claim is now stated as what it is: POSIX bracket expressions provide no escape, GNU's TAB reading is an extension, and GNU sed asked for conformance demonstrates the other reading here (`sed --posix -E 's/[ \t]+$//'` turns `draft` into `draf`). The conclusion is unchanged; the evidence class was overstated. Also measured while establishing that LC_ALL=C is safe for non-ASCII, and worth recording because it makes the pin a strict improvement rather than a wash: on a REVIEW.md carrying a single invalid UTF-8 byte, GNU grep under C.UTF-8 reports `binary file matches` and emits nothing, blanking EVERY read; under C the value parses and is rejected on its merits. Valid UTF-8 is untouched either way — the C space class is entirely bytes < 0x80, which no UTF-8 multibyte sequence contains, so the trim cannot split a character. Negative controls, each driven and restored: - unpin the four `awk` selectors -> 3 red, incl. the new invariant test naming both lines - revert the mirror block boundary to `\s`/`\S` -> both `findings:` opener tests red 241/241 across the two pipeline files. `npm run lint:ci` exits 0 — after it caught a real defect in the new test itself: `split('\n')` on readFileSync content is banned here (DEFECT.WINDOWS-CRLF-TEST-PORTABILITY), and it now uses `splitLines()`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks * test(#3829): make the pin invariant see calls that are not piped The invariant added in the previous commit keyed on `| grep|sed|awk`, which is true of every call in the step today and is exactly the wrong thing to rely on. A guard written around the shapes that happen to exist cannot see the shape a later edit introduces — `awk '...' < "$f"` or `$(grep ...)` would have walked straight past it, which is the same property that let the two awk selectors sit unpinned through a commit claiming the parser was locale-independent. It now blanks the PINNED calls and treats anything still naming one of the three tools on a non-comment line as an offender, so the check is "every call is pinned" rather than "every piped call is pinned". Comment lines stay exempt: the step's prose names unpinned forms while explaining why they were retired. Driven both ways against the invariant alone: - inject a NON-PIPED unpinned `awk '...' < "$REVIEW_FILE"` -> reds (the old form did not) - unpin the four piped `awk` selectors -> still reds (no regression) - unmodified tree -> green 241/241 across the two pipeline files, `npm run lint:ci` exits 0. No shipped behaviour changes in this commit; it only widens what the test can see. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks * test(#3829): close the invariant's path-qualified hole, and state the limit it keeps A seventh adversarial pass. It confirmed the shipped fix — census clean at 24 pinned calls, whole-fence output identical under C and C.UTF-8 on every input built to separate them, and both fences byte-identical on a well-formed review carrying `café 東京` and `naïve résumé` — and then refuted the claim I made about the GUARD, not the feature. `/usr/bin/awk '/[[:space:]]/{exit}' < "$REVIEW_FILE"` passed the invariant. The preceding- character class shielded any match preceded by `/` or `.`, so a path-qualified call was invisible. A path-qualified call is still a call; the class no longer shields either. The substrings that motivated the exclusion are unaffected — `parsed`, `passed` and `awkward` have a word character on one side or the other, so the boundary still rejects them. **The rest of that finding is disclosed rather than fixed, deliberately.** `$AWK "$f"` and a command name computed inside the embedded `node -e` block also evade the check, and they are not closable by this mechanism: it scans text, not shell or JavaScript command structure. The same pass that found them showed that widening the regex further only trades those false negatives for false positives on quoted strings and awk program text. So the test now STATES its boundary instead of implying it has none — the previous comment claimed a census "a future edit cannot fool", which was exactly the kind of overclaim this round has been correcting. What it catches is enumerated there, driven, along with which direction its false answers go. Driven against the invariant alone, each mutation reporting its own substitution count: - `/usr/bin/awk ... < "$f"` (the exact evasion) -> reds; before this commit it did NOT - `env awk ... < "$f"` -> reds - `LC_ALL=C.UTF-8 awk ... < "$f"` (wrong pin) -> reds - unmodified tree -> green 241/241 across the two pipeline files, `npm run lint:ci` exits 0. No shipped behaviour changes in this commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks * test(#3829): stop the guard's comment promising a closed list of what it misses An eighth adversarial pass. It confirmed the delta was test-only and that both fences are byte-identical across it — exit, stdout, stderr and rendered ledger — and then refuted the comment again, with two more evasions: a command name fragmented in shell (`a''wk`), and an executable command substitution on a physical line starting with `#` inside a multiline quoted argument, which the comment exemption skips. Both are real. Neither is the point. Three passes running have each found one more evasion of a TEXT scan, which is the actual finding: **the list cannot be closed.** A comment that enumerates residuals is false the moment someone is cleverer than the enumeration, and fixing it by appending the newest example just resets the clock. So the comment no longer claims an inventory. It says what this is — a regression guard against the accident that has now happened twice in this round, a read added or edited without its pin in a file where every other read has one — and what it is not: a proof. The examples are marked as illustrations. The operative instruction is the one that survives any future evasion: treat anything it reports as real, and never treat its silence as proof a new read is pinned. No logic changed; the guard catches exactly what it caught before. 241/241 across the two pipeline files. `npm run lint:ci` exits 0 — after it caught this commit twice over, which is worth recording because both were in prose I had just written to be careful: - the previous message's example path `docs/grep.md` read as a genuine docs reference from this file, and lint-docs-guard-registration demanded a baseline entry for a path that does not exist. The illustration is now `bin/grep-wrapper`, and the reason is stated inline. - an earlier commit's `split('\n')` on readFileSync content tripped the CRLF-portability rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks * test(#3829): the guard's two directions are not symmetric, so stop saying they are A ninth adversarial pass. It confirmed the delta before it was comment-only with the guard's logic byte-identical, and that all six advertised forms are still caught — and then found the one absolute the rewrite left behind. The comment said "treat anything this guard reports as real" three lines above admitting the guard produces loud false positives. Driven: `echo ok # grep is discussed` is reported, and labelled `(unpinned)`, though it is prose and no unpinned read exists. Both sentences were mine, in the same comment, written in the same edit that was supposed to remove overclaiming. The instruction is now the accurate one, which is that the two directions are NOT symmetric: a REPORT is cheap to adjudicate — read the line, a trailing comment or a path is obvious — while SILENCE proves nothing, because the known evasions are silent and so is any evasion nobody has thought of yet. Investigate every report; never read silence as proof a new read is pinned. Comment-only. Guard logic untouched, `gsd-core/` byte-identical to cac648aab — three consecutive passes have now confirmed the shipped behaviour unchanged. 241/241 across the two pipeline files, `npm run lint:ci` exits 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks * test(#3829): a report needs its context to adjudicate, not just its own line A tenth adversarial pass, and the last one this round runs. It confirmed `gsd-core/` is the SAME TREE OBJECT as at cac648aab (105b5ed9b) with the guard's caught and silent sets unchanged, and refuted one more sentence of the same comment. "A report is cheap to adjudicate (read the line)" is false, driven: the identical reported physical line ` grep` is a COMMAND after `:` and an ARGUMENT after `printf '%s\n' \`. The guard reports both, and the reported line alone does not distinguish them — the preceding line is what settles it. The comment now says so, with that counterexample in it. This is the fourth consecutive pass to find a defect in this one comment and none in the shipped code, which is itself the result worth recording: the shipped fix has been frozen since cac648aab and confirmed byte-identical by four passes, while the prose describing a best-effort text scanner took four attempts to stop overclaiming. Writing an accurate description of what a heuristic does NOT do turns out to be harder than the heuristic. Comment-only; guard logic untouched. 241/241 across the two pipeline files, `npm run lint:ci` exits 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks * test(#3829): re-home the lookup's phase-id gate beside the step, not in a block upstream removed #4781 (#4628) removed #4748's letter-axis work from `tests/nsegment-phase-grammar.test.cjs`, including the `#4748 — the REVIEW.md lookup` describe block. This PR had four tests living in that block, because that is where the gate was when #3829 moved the lookup out of `execute-phase.md` and into the lazily-read step file. Those four tests assert properties of THIS PR's step file, not of #4748's sites. Rebasing onto the removal would have deleted them silently — the branch would still be green, with its own coverage quietly gone. They move here instead, unchanged in substance, beside the step they guard: an unrelated upstream revert can no longer take this PR's coverage with it. One assertion did NOT come along. The old block also checked that `execute-phase.md`'s init parse list names `padded_phase`; #4781 removed that field from the list, and the assertion is a property of #4748's site rather than of this step. Carrying it here would only have pinned someone else's revert to this PR. The step never depended on that field in the first place — it computes PADDED itself, validating PHASE_NUMBER for shape and traversal and padding the digit run through `10#` while carrying an optional letter verbatim. That self-containment is why the removal costs this PR nothing but the tests' address. 5 pass, 0 fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks * chore(#3829): regenerate the derived artifacts the round-12 rebase invalidated The base moved from092d9256bto003d982c8(six merges) while this round was in flight, so the branch was rebased and the derived state had to be re-derived rather than hand-merged. `platform-conformance-tier.generated.cjs` regains `tests/configured-entrypoint-validation.test.cjs`, added upstream by #4249. Resolving the conflict hunk-by-hunk in favour of this branch had dropped that entry; `lint:generated-sync` caught it, which is what that check is for. `compact-content-benchmark-baseline.json` carries the measured values at the new base rather than this branch's stale pair: split "execute-phase" off 26200 -> 26242, on 23949 -> 23952 (8.59% -> 8.73%); aggregate off 108243 -> 108285, on 91595 -> 91598 (15.38% -> 15.41%). The benchmark reports drift and exits 0 either way, so a stale baseline does not announce itself here — it announces itself in CI. **The growth acknowledgment is back, and the reason is worth stating.** Against the previous base this PR left `execute-phase.md` a net -103 bytes: the extraction removed more than the dispatch paragraph added, so the file ended up smaller than the base's copy and the `Emitted-Drift-Ack-Growth` trailer became false and was dropped. #4781 then rewrote that file upstream, and against the new base the same extraction nets +36 bytes (93421 -> 93457) — which is the figure this PR originally reported at round 2. The size delta was never a property of this change alone; it is a property of this change against whichever base it sits on, and it has now been both signs in one round. The trailer was restored then. Round 14 rebased onto `029acd915`, where #4830's re-land moved `execute-phase.md` again and the same extraction is -103 once more (93564 -> 93461), so the trailer is false a second time and this commit no longer carries it. Third sign flip, same reason each time. `lint:generated-sync` and `benchmark-compact-content --check` both clean afterwards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks * chore(#3829): regenerate derived artifacts after rebase onto next The rebase onto `c9a5cc3e1` conflicted in the 19 install-tree goldens. Those are generated, so they were resolved arbitrarily and regenerated with their own producers (`npm run regen:derived`, then `benchmark-compact-content.cjs --write`) rather than hand-merged — a clean textual merge of a generated file attests the merge, never the content. Reconciled per artifact against the base's own committed copy rather than against the pre-regen tree, because the pre-regen tree is the arbitrary resolution: - every install-tree golden now differs from `c9a5cc3e1`'s copy by exactly one entry, `gsd-core/workflows/execute-phase/steps/code-review-disposition.md`, which is this PR's own new step file; - the compact-content benchmark baseline by the `execute-phase` entry (offTokens 26184 -> 26167) and the aggregate that sums it. `docs/INVENTORY-MANIFEST.json` was in the at-risk set but regenerated byte-identical, so it carries no change here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RJPv9LNaPV3Cd3NCpfFKGb * chore(#3829): regenerate derived artifacts after rebase onto029acd915The rebase onto current `next` conflicted in two generated files — the compact-content benchmark baseline and the platform conformance tier. Both were resolved arbitrarily and regenerated with their own producers (`npm run regen:derived`, then `benchmark-compact-content.cjs --write`) rather than hand-merged: a clean textual merge of a generated file attests the merge, never the content. Reconciled per artifact against the base's own committed copy rather than against the pre-regen tree, because the pre-regen tree is the arbitrary resolution. Every differing key belongs to a file this PR actually touches: - `tests/fixtures/install-tree/claude.json` differs from `029acd915`'s copy by exactly one entry, `gsd-core/workflows/execute-phase/steps/code-review-disposition.md`, this PR's own new step file; - `scripts/lib/platform-conformance-tier.generated.cjs` by exactly one entry, `tests/code-review-fix-pipeline-regression.test.cjs`, a test this PR adds; - `tests/fixtures/compact-content-benchmark-baseline.json` by the `execute-phase` entry (offTokens 26344 -> 26280) and the aggregate that sums it, which this PR moves by editing `execute-phase.md`. The other 18 install-tree goldens, `docs/FEATURES.md` and `docs/INVENTORY-MANIFEST.json` were regenerated too and came back byte-identical, so they carry no change here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nAJFVZcCZHtSgYmWM5WNP * test(#3829): follow #4748's REVIEW.md-lookup gate to the step that now owns the lookup Self-found while rebasing onto `029acd915`, not raised in review. #4830 (`8a5166598c`) re-landed #4768's letter-suffix work on `next`, restoring the `#4748 — execute-phase.md resolves the REVIEW.md path from init's padded_phase` block in `tests/nsegment-phase-grammar.test.cjs`. That block had been removed by #4781, which is why round 13 re-homed this PR's own four tests out of it. The restored block anchors on a line this PR deletes: expected 1 line(s) containing "REVIEW_FILE=\"${PHASE_DIR}/${PADDED}-REVIEW.md\"", found 0 It fails at the describe level, so all four of its tests go with it. It was green before this rebase only because the base did not carry the block yet. Putting the line back is not available. #3829 moved the lookup into the lazily-read step file because `execute-phase.md` did not fit under ADR-857's frozen pre-phase-6 ceiling (93600); the parent is at 93461, and the three lines this gate anchors on cost 183 (measured, not computed: `git show 029acd915:... | sed -n '1168,1170p' | wc -c`). The block would also be dead code — the step performs the lookup. So the gate follows the lookup. Two of its four assertions are properties of the lookup and are re-pointed at the step file: that no fence hands PHASE_NUMBER to `printf "%02d"`, and that both lookups are preceded by a PADDED binding that pads the digit run through `10#` and carries the letter verbatim. The regression control on the lookup line itself comes along, now over both fences. The init-parse-list assertion stays on `execute-phase.md`, which still names `padded_phase`. Two assertions do NOT come along, and they are the two that were properties of the INLINE site rather than of the lookup: the `PADDED="{padded_phase}"` literal binding (the step derives PADDED itself, validating PHASE_NUMBER for shape and traversal first), and the composition run over the three live lines. The step's executable coverage — a composition run plus a padding-agrees-with-the-canonical-normalizer matrix over letter ids — already exists in `tests/code-review-pipeline-regression.test.cjs` under "#3829 — the step's REVIEW.md lookup resolves a letter-suffixed phase without a shell re-pad". Mirroring it here would be a second implementation of one grammar. That coverage is NOT equivalent, and the difference is worth stating rather than glossing. At its original site #4748's gate was a DATAFLOW pin: `execute-phase.md` bound init's own `{padded_phase}`, so the lookup could not disagree with the canonical normalizer because it never computed anything. The step reconstructs the value in shell, so that pin is not available at this address and AGREEMENT with the normalizer is what replaces it. A follow-on commit adds a fast-check property asserting that agreement over generated ids, because the existing 13-shape matrix samples 2 of 26 letters and cannot see a divergence outside its own points. One consequence is disclosed rather than absorbed: `padded_phase` is now parsed but unused in `execute-phase.md` (`:95`). Removing it from that parse list is #4830's call on its own site, not this PR's, so the assertion that it is still named stays. #4748's property is unchanged: a letter-suffixed phase resolves its own REVIEW.md, and an already-padded `08` does not read as octal. Negative-controlled rather than asserted. Against the shipped step: 162 pass, 0 fail. Dropping `$_let` from both PADDED bindings reds "every lookup is preceded by a PADDED binding that carries the letter run"; dropping `10#` reds it too. The step file was restored byte-identical after each control. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nAJFVZcCZHtSgYmWM5WNP * test(#3829): assert the step's padding against the canonical normalizer by property, not by 13 points From this round's own pre-push adversarial review, not from the maintainer's. The prior commit re-points #4748's REVIEW.md-lookup gate at the step that now owns the lookup. The review's finding was that this is not coverage-equivalent, and it is right: at the original site the gate was a DATAFLOW pin — `execute-phase.md` bound init's own `{padded_phase}`, so the lookup could not disagree with the canonical normalizer because it never computed anything. The step reconstructs the value in shell, so agreement with the normalizer is what has to replace the pin. That agreement was already asserted, but over a 13-shape matrix. Its words: "future canonical grammar changes could therefore diverge without this gate detecting them." Correct — the matrix samples 2 of 26 letters and a bounded set of segment shapes, and this PR has twice been told that a generator which cannot reach the interesting input is a fixture with extra steps (round 3's SOURCE_CELL, round 5's title generator). Same defect, third address. So the agreement is now a property over generated ids: a digit run inside the step's own 8-digit bound, an optional single A-Z, and up to two dot segments, asserted equal to `normalizePhaseName(id)` through the shipped shell derivation of BOTH fences. Milestone `N-N` forms are outside the step's accepted domain and are asserted nowhere here rather than silently passed. Negative-controlled on two mutations, and the second is the one that justifies the property rather than the matrix: %02d -> %03d matrix RED, property RED drop the letter when outside {A,B} matrix GREEN, property RED The second is the added coverage, demonstrated rather than argued: the matrix is structurally unable to reach a letter it does not enumerate. The step file was restored byte-identical after each control. numRuns is 25 — each case spawns bash twice through the process seam, once per fence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nAJFVZcCZHtSgYmWM5WNP * fix(#3829): pad the phase id as a string, so the step agrees with the canonical normalizer Found by the property the previous commit added, on its second adversarial pass. This is a real divergence in shipped behaviour, not a test-only correction. `normalizePhaseName` (src/init.cts, via gsd-core/bin/lib/phase-id.cjs) left-pads a phase id's digit run to a MINIMUM of two and otherwise PRESERVES it — `padStart(2, '0')`. The step re-derived the same value arithmetically, `printf "%02d" "$((10#$_dig))"`, which does not preserve: it collapses every leading-zero run longer than two. id normalizePhaseName the step (before) 8 08 08 08 08 08 008 008 08 <-- diverges 0008 0008 08 <-- diverges 00000008 00000008 08 <-- diverges 0008A 0008A 08A <-- diverges Consequence: for such an id the gate resolves `08-REVIEW.md` while init emits `008-REVIEW.md`, so it finds no review and says so — advisory, and therefore silent. That is the failure class #4748 exists to close, reached by a different road: not a letter this time, but a leading-zero run. The fix is a string pad that implements `padStart(2, '0')` exactly, at both fences: case "${#_dig}" in 1) PADDED="0${_dig}${_let}${_sub}" ;; *) PADDED="${_dig}${_let}${_sub}" ;; esac It is strictly less machinery than what it replaces. `10#` existed only to stop bash reading a leading zero as octal inside `$(( ))`; with no arithmetic there is no octal hazard to guard, so the remedy is retired rather than kept. `scripts/lint-phase-id-drift.cjs` — which exists to flag unsanctioned `printf "%02d"` re-pads of phase-carrying variables — is green, and now has one less re-pad to tolerate. Two dependent assertions move with it, and both are now stated as the PROPERTY rather than as one spelling of the remedy: the round-14 gate in `tests/nsegment-phase-grammar.test.cjs` asserts the binding does no arithmetic and carries both `${_dig}` and `${_let}`, and the regression pin in `tests/code-review-pipeline-regression.test.cjs` follows the new form. The property's generator is widened in the same commit, because its first cut could not have found this: it built the digit run with `String(fc.integer(...))`, which can never produce a leading zero, so it had silently LOST the `08`/`09` coverage the 13-shape matrix beside it already had. The run is now generated as a digit string, and segment depth goes to four (the repo exercises `1.2.3.4`). The step's grammar is unbounded in depth; four is a stated bound, and it is this property's residual. Negative-controlled, and the matrix is the control's control — it stays GREEN on both: revert to the arithmetic pad matrix GREEN, property RED drop the letter when outside {A,B} matrix GREEN, property RED The step file was restored byte-identical after each. 407 pass / 0 fail across both test files; lint:ci, gen-features --check, gen-platform-conformance-tier --check, benchmark-compact-content --check and gen-install-tree-fixtures all clean, with no regenerated artifact moving. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nAJFVZcCZHtSgYmWM5WNP * test(#3829): hold #4748's gate by execution, and retire the comments the string pad made false Third adversarial pass on this round. Two findings, both fair, neither a correctness defect. FIRST — the gate pinned a SPELLING, not the property. Its objection was concrete: an equivalent multi-line string pad would have failed a regex that matches one `case ... esac` line. That is a false-positive generator, and a guard that false-fires is a guard that gets deleted. The split is now honest about what each layer can hold. A STATIC gate can hold the DEFECT SHAPE — no arithmetic in the binding above each lookup — and that is all it asserts. Correctness is held by EXECUTION: the base gate regains a composition test that runs the shipped derivation slice of both fences against `normalizePhaseName`, over `3A 8 9 08 008 0008A 23A.1.2`. That composition test is the one I removed two commits ago, and removing it was the weaker call. At #4748's ORIGINAL site the gate could be static because the property was a literal binding of init's own `{padded_phase}`; nothing could disagree, because nothing computed. At this site the step derives the value, so the property is behavioural and only execution holds it. `008` is in the list because it is the case the arithmetic pad got wrong and no prior fixture covered. Controlled three ways, and the middle one is the finding being answered: arithmetic pad restored 4 fail caught EQUIVALENT multi-line string pad 0 fail no false positive drop the letter outside {A,B} 1 fail semantic drift caught SECOND — the step carried comments the fix had made false, in four places. Two explained `10#` as part of the live phase derivation; two justified the eight-digit bound by bash integer overflow. Neither described the code any more. They are rewritten to current truth rather than annotated, because a fragment carries no supersession marker and overturned prose reads as canon: - the octal rationale now says the pad performs no arithmetic and needs no `10#`, and notes that `10#` survives in this step only on the severity COUNTS, which really are numbers being added; - the length bound now states that overflow is unreachable since the pad stopped converting, and that the bound stays for the reason it always also had — every component is interpolated into a filename, and filesystem components are finite. 408 pass / 0 fail across both files. lint:ci, lint-phase-id-drift, gen-features --check, gen-platform-conformance-tier --check, benchmark-compact-content --check and the prompt-injection-scan security suite are all clean, and no regenerated artifact moved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nAJFVZcCZHtSgYmWM5WNP * docs(#3829): correct four comments, including one this round's own rewrite got wrong Fourth adversarial pass. Comment-only; no code, no test logic, no regenerated artifact moves. ONE OF THESE IS MY OWN ERROR, introduced two commits ago. Rewriting the length-bound rationale, I replaced the dead integer-overflow justification with "the bound stays because every component is interpolated into a filename and filesystem components are finite". That is false, and it was driven false: the bound is PER SEGMENT, every segment is joined into ONE filename component, and depth is unbounded. Thirty 8-digit segments yield a 278-character PADDED and a 294-character name against a NAME_MAX of 255. Replacing a dead rationale with a wrong one is worse than leaving the dead one, so the comment now states what the bound actually does and names the composite-length gap as a residual of this validator that predates the pad change. It is not fixed here; it is stated. The other three are stale rather than wrong: - `overflow guard` named the length check in two places. Nothing overflows any more -- the pad does no arithmetic -- so it is the digit bound, and is called that. - The `#4748` block header in `tests/nsegment-phase-grammar.test.cjs` still said the step pads through `10#`, still said the composition run did not survive the move, and still said the executable coverage was "cited rather than copied" -- while the composition test sat twenty lines below it. All three were true when written and none survived this round. The header now records why a STATIC assertion cannot hold a BEHAVIOURAL property, and that the PR's fast-check property is a different instrument over the same contract rather than the same test twice. - The severity-count comment said a base-inference failure "takes the whole advisory step down under `set -e`". It does not: the arithmetic sits inside an `if` condition, a TESTED context, where `set -e` is inert, and the consistency check is SKIPPED instead -- which the regression suite already records. `10#` stays; only the account of what it prevents is corrected. This one predates the round and is corrected because it is adjacent and factually wrong, not because it blocked anything. 408 pass / 0 fail across both files; lint:ci, lint-phase-id-drift, gen-features --check, benchmark-compact-content --check and the prompt-injection-scan security suite all clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nAJFVZcCZHtSgYmWM5WNP * chore(#3829): regenerate the compact-content benchmark baseline after the rebase ontofac0e9de8The rebase onto current `next` conflicted on this generated fixture, as it has in every recent round: the base regenerates it for its own token deltas and this branch regenerates it for `execute-phase.md`'s, so both sides rewrite the same keys. Resolved arbitrarily during the replay and regenerated with its own producer (`scripts/benchmark-compact-content.cjs --write`), never hand-merged. Key-level drift against the base's committed copy is exactly two entries and both are this PR's own: `splits.execute-phase` (the workflow this PR edits) and `aggregate`, which is the sum over the splits and therefore moves whenever any split does. Zero foreign keys moved. The full generator sweep was re-run after the replay -- gen-features, gen-inventory-manifest, gen-platform-conformance-tier (both targets), gen-install-tree-fixtures and the benchmark -- and this fixture is the only artifact that moved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V5xnzzipRHPHq8Z3doQvn3 * chore(#3829): regenerate the compact-content benchmark baseline after the rebase ontob956bb7c6`next` moved again while this round was running -- #4902 landed at 06:27Z and touches this same generated fixture -- so the branch went back to CONFLICTING within minutes of the previous push. This is the second rebase of the round, not a correction of the first. Resolved arbitrarily during the replay and regenerated with its own producer (`scripts/benchmark-compact-content.cjs --write`), never hand-merged. Key-level drift against the new base's committed copy is again exactly `splits.execute-phase` and `aggregate` (the sum over splits) -- zero foreign keys. The full generator sweep was re-run after this replay as well; this fixture is the only artifact that moved. Build inputs were untouched by the base range, so the lane's existing build stands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V5xnzzipRHPHq8Z3doQvn3 * fix(#3829): refresh the launcher preamble in place, without hoisting it above the guard Found by this round's own post-rebase validity gate, not by review: a reader flip-test over the 85 tests that read the two at-risk workflow files was clean before the replay and failed after it, on `runtime-launcher-parity (#373)` invariant (B). The cause is a real interaction. #4902 landed on `next` mid-round and rewrote the canonical launcher preamble; invariant (B) counts occurrences of that exact snippet, so this step's older copy matched zero times even though it sat in the right place. The remedy the invariant names -- `node scripts/sync-runtime-launcher.cjs` -- fixes the count but also HOISTS the preamble to the top of the block, and that is wrong here: it moved the shim ahead of the status guard, and six of this PR's own tests exist to pin that ordering (`runDispositionGuard` asserts the block opens with its guard, then the shim). Running the tool verbatim turned one red into seven. So the preamble text is refreshed to the current canonical snippet IN PLACE, at the offset it already occupied. Both constraints hold at once, verified by execution rather than by reading: invariant (B) sees exactly one canonical occurrence and it precedes the first `gsd_run` call, while the guard still opens the block (shim at offset 19897 of the second fence, and the test wants > 0). `runtime-launcher-parity` + `code-review-pipeline-regression` together: 282 tests, 281 pass. Not fixed here, and not ours: `(K2) end-to-end: the resolved local tool honors git.allow_default_branch_commits (#4834)` -- the one remaining failure -- fails identically on a detached worktree at pristine `b956bb7c6` carrying none of this PR's content (37 tests, 36 pass, same single failure). Reported rather than chased. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V5xnzzipRHPHq8Z3doQvn3 * fix(#3829): record the shortfall when NO finding in a review parses Found by this round's own pre-push adversarial review, not by the maintainer -- and it is the same defect class round 2 raised as Blocker 4, surviving in the one corner that round's fix did not reach. The unparsed reconciliation exists so a finding the CR|BL|WR|IN heading parser cannot match is SURFACED rather than dropped. It reported faithfully whenever SOME findings parsed. It reported nothing at all when NONE did, on a phase with no prior ledger and no fix report: a REVIEW.md declaring two Criticals, both written under a prefix the alternation does not carry, produced no ledger, no console line and no diagnostic. That is precisely the silent drop this reconciliation was added to close, reachable exactly where the evidence is weakest -- the run in which not one finding was understood. Two exits discarded it, and the first one is the one that actually fired. The shortfall was derived beside the render, while the exit that stands down for "nothing to record" keys on order.length and sits ~200 lines earlier; it returned before the value existed. The later rows.length exit had the same hole but was unreachable for this input. So the derivation moves above the earlier exit -- order is final from the heading walk and never grows again, so the value is unchanged -- and both exits now decline to fire while a shortfall is outstanding. The result is a zero-row ledger carrying an unparsed key: an honest record that the review declared findings and none of them were understood, which is strictly better than the file not existing. Scoped, not removed. A genuinely clean review is untouched: a declared total of 0 is not greater than order.length, so unparsed is 0 and both returns still fire exactly as before. The new test's companion pins that, and it is why the exit was relaxed conditionally rather than deleted. Driven at every step rather than reasoned about. Before the fix, three cases through the shipped script: two unmatched findings on a first run wrote NOTHING; two unmatched plus one matched reported unparsed: 1; two unmatched against an existing ledger reported unparsed: 2. Only the first was silent, which is why the mechanism read as covered. After the fix the first renders a ledger with unparsed: 2 and names it on the console, and the other two are byte-unchanged. The new regression test was negative-controlled against the pre-fix step file and goes red there (1 pass / 1 fail over the pair); post-fix both pass. Its companion clean-review control is green on both sides, so the pair is not passing by accident. The PR's six test files: 554 tests, 554 pass, 0 fail, 0 skipped. lint:ci exits 0. The generator sweep still produces no drift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfaJkgoq87837ZqWtTxqqn * test(#3829): pin the SECOND exit that discarded the shortfall, and prove the pair kills it Self-found by the round review of the previous commit, not by the maintainer: that commit added the !unparsed conjunct to BOTH exits but tested only one of them. Deleting the later one left every new test green -- a surviving mutant, which is coverage in name only. The two exits are reached by different inputs, which is why one fixture cannot pin both. The earlier exit stands down the moment a fix report exists, so an input carrying one sails past it and lands on the later rows.length return. The fixture therefore needs a fix report that contributes NO row: an id the alternation CAN match becomes a carried row, rows.length is 1, and the later guard never decides. The first draft of this test used CR-99 and was vacuous for exactly that reason -- it passed with the guard deleted. It names SEC-03 now. Mutation-controlled in all three directions, since a test that kills no mutant pins nothing: earlier exit loses !unparsed -> test 1 RED, test 2 green, test 3 green later exit loses !unparsed -> test 1 RED, test 2 RED, test 3 green both exits neutered (over-fire)-> test 1 green,test 2 green, test 3 RED unmutated -> all three green Every test kills at least one mutant and no mutant survives all three, so the pair covers both roads to the drop and the clean-review control covers the over-fire the relaxation could have introduced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfaJkgoq87837ZqWtTxqqn * test(#3829): close the fourth cell — the later exit's own over-fire Self-found again by the round review, which drove the mutant set rather than trusting the matrix the previous commit asserted: neutering ONLY the later exit survived all three tests. The previous commit's matrix was accurate and incomplete, which is the more dangerous shape -- it reads as a closed argument. The guards form a 2x2 and only three cells were pinned. T1 pins the earlier exit's under-fire, T2 the later exit's under-fire, T3 the earlier exit's over-fire. Nothing pinned the LATER exit's own over-fire, and it is reachable: a fix report -- even one naming no matchable id -- makes the earlier exit stand down, so control arrives at the later exit carrying a genuinely clean review and no shortfall. Neuter that exit and a phase with nothing to report grows a zero-row ledger reading "0 of 0 finding(s) open", with all three earlier tests green through it. T4 is that case. Five mutants driven over the four tests: earlier exit loses !unparsed -> T1 RED later exit loses !unparsed -> T1 RED, T2 RED both exits -> if(false) -> T3 RED, T4 RED ONLY later exit -> if(false) -> T4 RED (the survivor this commit kills) ONLY earlier exit -> if(false) -> all four green The last one is reported as an EQUIVALENT mutant rather than an open cell, and the distinction is the point of stating it: the earlier exit's over-fire condition is a strict subset of the later exit's -- it adds only fixReports.length === 0 -- so removing it is subsumed and changes no observable behaviour. A test cannot kill a mutant that does not alter output, and pretending otherwise would mean writing one that asserts on internals. The PR's six test files: 556 tests, 556 pass, 0 fail, 0 skipped. lint:ci exits 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfaJkgoq87837ZqWtTxqqn * fix(#3829): keep the embedded record-builder inside a Windows command line Block 2 runs the disposition record-builder as `node -e "<script>"`, so the entire script is a single argv entry. Windows caps a command line at 32767 characters (CreateProcess) and Node surfaces the overflow as ENAMETOOLONG from spawn -- the process never starts. Linux's ~2 MB ARG_MAX cannot see that cliff at all. The previous fix (bd83bc88e, hoisting the shortfall derivation above the earlier exit) grew the extracted script from 31941 to 33353 characters. There were 826 characters of headroom; it spent 1412. On the next CI run ubuntu and macOS stayed green and `conformance test (windows-latest, 24, shard 1/3)` went red with 83 failures -- 79 reporting `spawn_failed` out of runShippedDisposition, the other 4 asserting on a ledger that was never written. That same shard was SUCCESS at 596968aa0, the head before that commit. Measured on native Windows (node v25.2.1), bisected: the largest `-e` argument that still spawns is 32728 characters; 32729 fails. Both payloads driven directly: pre-fix 33353 chars -> SPAWN-FAIL ENAMETOOLONG post-fix 15916 chars -> SPAWN-OK Note the shape of it: the fix that makes this gate report a silently-dropped finding was itself silently dropped on Windows, because the whole script stopped launching. What changes here is placement -- not content, not behaviour. 17 long rationale comment blocks move out of the quoted payload into a new "Design notes for the embedded record-builder" section in this file's prose, each anchored to the code line it preceded so the pairing survives the move. Comment runs shorter than five lines stay inline, where adjacency is cheap. Nothing is deleted. extracted script 33353 -> 15916 chars (16812 under the measured limit) executable code byte-identical at 10853 bytes, verified by diffing the payload with all comment lines stripped from both sides this file 78542 -> 79538 bytes -- the prose moved, it did not grow Verified green: this PR's own test file at 250 tests across all 36 suites, 0 fail; changeset-lint, docs-lint, default-flip-documentation, lint:ci, gen-emitted-baseline, workflow-size-budget (133 tests), and lint-workflow-shellcheck (204 pre-existing findings, 0 new). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfaJkgoq87837ZqWtTxqqn * test(#3829): pin the embedded script under the Windows command-line budget Nothing guarded the size of the `node -e` payload, so the regression the previous commit fixes was invisible to every Linux gate and surfaced only as 83 Windows failures that said `spawn_failed` and never mentioned length. Without a guard the next addition to the script re-breaks Windows exactly the same way, and finds out the same expensive way. The test asserts the extracted script stays under a 24 KiB budget -- the 32767 CreateProcess cap less roughly 8 KiB of deliberate headroom, so the script has somewhere to grow before this fires. It is bounded from BELOW as well, and that half is the point: a pure length assertion passes when the extractor returns '', which is exactly what a moved fence or a renamed delimiter would produce. A guard that reports a comfortable 0 bytes is the vacuous-oracle shape. The lower bound makes a broken extractor fail loudly here instead of reporting success. Negative-controlled rather than assumed. Against the PRE-fix step file the test goes red on the real payload (33353 > 24576); against the fixed file it passes at 15916. A new test that has only ever been run against fixed code can be green because it hit a branch the bug never lived on. This PR's own test file: 250 tests, 250 pass, 0 fail, 0 skipped, across all 36 suites. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfaJkgoq87837ZqWtTxqqn * test(#3829): say what the command-line guard does not prove The round review's MISSED, adopted. The guard counts the extracted JavaScript, but the 32767 cap applies to the whole serialized command line -- executable path, quoting and backslash escaping included -- so a quote-heavy payload expands on the way out, and the 32728 figure it cites is one host's measured threshold rather than the CI runner's. The budget is unchanged and still correct; only the claim around it moves. 24576 leaves roughly 8 KiB for both effects, which is a practical margin, not a proof that everything the guard admits will spawn. Stating that in the test is cheaper than having a future reader infer a guarantee the assertion cannot make. Comment-only. No assertion, no budget and no behaviour changes. This PR's own test file: 221 tests, 0 fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfaJkgoq87837ZqWtTxqqn --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
4997 lines
284 KiB
JavaScript
4997 lines
284 KiB
JavaScript
// docs-guard-exempt: 'docs/DEVELOPMENT.md' is a synthetic files-list fixture entry, not read as content.
|
|
// allow-test-rule: source-text-is-the-product
|
|
// The workflow and agent .md files ARE the product: their text is loaded and
|
|
// executed/interpreted at runtime by the agent host. Testing that specific
|
|
// strings exist within these files tests the deployed contract, not an
|
|
// implementation detail. No runtime API exists to enumerate the label accept-
|
|
// list or filter-set definitions — the text IS the specification.
|
|
//
|
|
// Bug 1 (compute_file_scope) — The inline Node.js script embedded in the
|
|
// workflow .md is the parser. The test implements the identical parse logic as
|
|
// a pure JS function (mirroring lines 172-184 of code-review.md exactly) and
|
|
// asserts on its structured output. A separate docs-parity assertion checks
|
|
// that the workflow .md contains the hyphen-aware boundary regex and the
|
|
// em-dash/parenthetical stripping — both of which are the deployed contract.
|
|
//
|
|
// Bug 2 (present_results) — Tested both behaviourally (pure JS helper that
|
|
// mimics the grep|cut pipeline) and via docs-parity on the workflow .md text.
|
|
//
|
|
// Bugs 3 and reviewer contract — docs-parity only on agents/*.md: the filter-
|
|
// set definition and label-equivalence contract exist only as text in those
|
|
// files; there is no runtime enumeration API.
|
|
|
|
'use strict';
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const fc = require('fast-check');
|
|
const { runHook, runNode, OUTCOME } = require('./helpers/process-seam.cjs');
|
|
const { toLegacyResult, gitOrThrow } = require('./helpers/git-fixture.cjs');
|
|
const { PROBE_TIMEOUT_MS, GIT_TIMEOUT_MS, HOOK_FANOUT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
const { createTempDir, createTempGitProject, cleanup, readFileNormalized } = require('./helpers.cjs');
|
|
const {
|
|
foldShellContinuations,
|
|
findShellFencedMatches,
|
|
} = require('./helpers/shell-doc-scan.cjs');
|
|
const os = require('node:os');
|
|
|
|
/**
|
|
* A single invocation of the external `fallow` binary's `audit`
|
|
* subcommand against the REAL current repo tree (CI-only) -- a heavier
|
|
* third-party tool operation distinct from any existing shared constant.
|
|
*/
|
|
const FALLOW_AUDIT_TIMEOUT_MS = 120000;
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
// HOISTED. `const` is in the temporal dead zone until its declaration executes, and a
|
|
// `{ skip: !HAS_BASH }` option object is evaluated EAGERLY when its describe body runs --
|
|
// so a bash-gated test added ABOVE the old mid-file declaration threw a ReferenceError
|
|
// that aborted the whole describe and CANCELLED its siblings. It caught three separate
|
|
// additions in this round alone, and the cancellation reads as a passing run in the
|
|
// summary line. Declared with the other file-level constants so placement stops mattering.
|
|
// AND NOT A PROBE — but the honest reason is narrower than the first draft of this comment claimed,
|
|
// and the correction is worth keeping. Round 5 flagged that every test exercising the shipped bash
|
|
// fences is `{ skip: !HAS_BASH }`, so block 1's severity-reporting path has no Windows-lane coverage.
|
|
// The gap is real and the count is 37 — 22 was the number of `{ skip: !HAS_BASH }` CALL SITES, and a
|
|
// skip on a `describe` cancels its subtests.
|
|
//
|
|
// This comment first justified the skip by citing `local/no-unguarded-nonportable-exec` as REQUIRING
|
|
// this exact guard. That was checked and is wrong on both halves: the rule only fires on a file that
|
|
// also chmods an exec bit with an octal literal (this file has none, so it never fires here), and
|
|
// `eslint-rules/lib/platform-guard.cjs` accepts four guard shapes plus `os.platform()`, not one. A
|
|
// constraint that exists is not a constraint that applies.
|
|
//
|
|
// What holds — and this is now MEASURED, not assumed. The measurement the previous version of this
|
|
// comment deferred has been made, on native Windows (not WSL) with Git Bash 5.2.37 / MINGW64 first on
|
|
// PATH, node v25.2.1 — the same shell family the repo's `windows-latest` lane runs:
|
|
//
|
|
// HAS_BASH left alone: 179 tests, 127 pass, 0 fail, 52 skipped
|
|
// HAS_BASH forced true: 179 tests, 140 pass, 24 fail, 15 skipped
|
|
//
|
|
// So 37 of the skips are this guard's (52 - 15; the other 15 skip for unrelated reasons), and
|
|
// unskipping them does NOT reveal a clean win: 13 pass and 24 fail. The failures cluster on exactly
|
|
// the divergence the eslint rule's subject line names — `bash -c` quoting (one surfaces as
|
|
// `unexpected EOF while looking for matching '"'`), empty captured output, and two outright
|
|
// `spawn_failed`. Flipping this constant to a runtime probe today would red the Windows lane with 24
|
|
// failures, so the guard STAYS; what changes is that it now documents a measured gap instead of an
|
|
// assumed one. Closing it means porting the fences themselves, which is a change of its own, not a
|
|
// line in a review round.
|
|
//
|
|
// NOTE for anyone re-running this: on a WSL host `bash` on the Windows PATH resolves to
|
|
// C:\Windows\system32\bash.exe, the WSL bridge — measuring through that runs real Linux bash and
|
|
// reports a false clean. Put `C:\Program Files\Git\bin` first.
|
|
const HAS_BASH = process.platform !== 'win32';
|
|
const WORKFLOW_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'code-review.md');
|
|
const PRE_PASS_STEP_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'code-review', 'steps', 'structural-pre-pass.md');
|
|
const FIXER_PATH = path.join(ROOT, 'agents', 'gsd-code-fixer.md');
|
|
const REVIEWER_PATH = path.join(ROOT, 'agents', 'gsd-code-reviewer.md');
|
|
const EXECUTE_PHASE_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase.md');
|
|
const DISPOSITION_STEP_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase', 'steps', 'code-review-disposition.md');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #4259: the T6 docs-parity site scan, hoisted out of the assertion so it can
|
|
// be driven directly by the negative controls below.
|
|
//
|
|
// The scan is `$`-anchored with `[^\n]*` on both sides of `--grep=`, so it only
|
|
// ever matched when `git log` and `--grep=` sat on the SAME physical line. A
|
|
// shell line-continuation made a semantically identical derivation invisible:
|
|
// zero hits, and T6 passed. Both generations of the assertion were defeated by
|
|
// it — the current anti-revert ban let a wrapped site through outright, and at
|
|
// v1.12.0 a wrapped site was silently exempted from the pattern-conformance
|
|
// checks written to catch the macOS `\b`-no-op class, so it could have carried
|
|
// exactly the malformed pattern T6 exists to reject. That is not a hypothetical
|
|
// shape: wrapping is the natural way to write a `git log` carrying a long ERE,
|
|
// and a real candidate implementation for #3926 did it, passed T6, and was
|
|
// caught only by later manual review.
|
|
//
|
|
// Folding continuations BEFORE matching is the repair, rather than widening the
|
|
// regex in place: the assertion's message and its `PHASE_SCOPE_NUM` filter both
|
|
// assume one site is one string, and a `[\s\S]*?` would happily run the scan
|
|
// across unrelated statements. The fold corrects the input, so everything built
|
|
// on the scan is fixed at once.
|
|
//
|
|
const GREP_SITE_RE = /^\s*[A-Z_]+=\$\(git log[^\n]*--grep=[^\n]*$/gm;
|
|
|
|
const findGrepSites = (src) => findShellFencedMatches(src, GREP_SITE_RE);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pure-function implementation of the compute_file_scope Node script body.
|
|
// This mirrors the logic in code-review.md lines 172-184 exactly.
|
|
// If those lines change, this function must be updated in tandem (and the
|
|
// docs-parity assertions below will catch a mismatch at the regex level).
|
|
//
|
|
// #2666: the acceptance predicate accepts root-level paths (no `/`) and known
|
|
// extensionless build files (Dockerfile/Makefile/etc.), not only nested paths
|
|
// with a trailing extension. Prose bullets are rejected by the known-filename /
|
|
// has-extension distinction (plus the post-processing existence check backstop
|
|
// in the shipped workflow).
|
|
const KNOWN_EXTENSIONLESS_BUILD_FILES = new Set([
|
|
'dockerfile', 'containerfile', 'makefile', 'justfile', 'procfile',
|
|
]);
|
|
function isAcceptablePath(raw) {
|
|
// A trailing `.`+alphanumerics extension qualifies (root-level OR nested):
|
|
// package.json, renovate.json, .gitlab-ci.yml, AGENTS.md, app/foo.tsx
|
|
if (/\.[A-Za-z0-9]+$/.test(raw)) return true;
|
|
// Known extensionless build filename (basename, case-insensitive): Dockerfile, Makefile, …
|
|
const base = raw.split('/').pop();
|
|
if (KNOWN_EXTENSIONLESS_BUILD_FILES.has(base.toLowerCase())) return true;
|
|
return false;
|
|
}
|
|
function parseKeyFiles(yaml) {
|
|
const files = [];
|
|
let inSection = null;
|
|
for (const line of yaml.split('\n')) {
|
|
if (/^\s+created:/.test(line)) { inSection = 'created'; continue; }
|
|
if (/^\s+modified:/.test(line)) { inSection = 'modified'; continue; }
|
|
// Hyphen-aware boundary: reset inSection for ANY key: line (including key-decisions:, etc.)
|
|
if (/^\s*[\w-]+:/.test(line) && !/^\s*-/.test(line)) { inSection = null; continue; }
|
|
if (inSection && /^\s+-\s+(.+)/.test(line)) {
|
|
let raw = line.match(/^\s+-\s+(.+)/)[1].trim();
|
|
raw = raw.replace(/^['"]|['"]$/g, '');
|
|
// Order matters: parens BEFORE em-dash because em-dashes can appear inside parens
|
|
raw = raw.replace(/\s+\([^)]*\)\s*$/, '');
|
|
raw = raw.split(/\s+—\s/)[0].trim();
|
|
if (isAcceptablePath(raw)) {
|
|
files.push(raw);
|
|
}
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pure-function implementation of the present_results severity-label parser.
|
|
// Mirrors the grep -E "^\s*(critical|blocker):" | head -1 | cut -d: -f2 | xargs
|
|
// pipeline from code-review.md.
|
|
// ---------------------------------------------------------------------------
|
|
function parseFrontmatterCritical(frontmatter) {
|
|
const lines = frontmatter.split('\n');
|
|
const match = lines.find((l) => /^\s*(critical|blocker):/.test(l));
|
|
if (!match) return { critical: 0 };
|
|
const value = match.split(':').slice(1).join(':').trim();
|
|
return { critical: parseInt(value, 10) || 0 };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BUG 1 — SUMMARY parser: compute_file_scope must not bleed prose from
|
|
// hyphenated sections (key-decisions:, patterns-established:, etc.) into the
|
|
// file list, and must strip em-dash descriptions and parentheticals.
|
|
// ---------------------------------------------------------------------------
|
|
describe('Bug 1 — compute_file_scope SUMMARY parser', () => {
|
|
test('#4461: the shipped compute_file_scope bash fence parses verbatim', () => {
|
|
const src = readFileNormalized(WORKFLOW_PATH);
|
|
const stepStart = src.indexOf('<step name="compute_file_scope">');
|
|
assert.notStrictEqual(stepStart, -1, 'compute_file_scope step must exist');
|
|
const marker = src.indexOf('EXTRACTED=$(', stepStart);
|
|
assert.notStrictEqual(marker, -1, 'Tier-2 SUMMARY extractor must exist');
|
|
const fenceStart = src.lastIndexOf('```bash\n', marker);
|
|
const fenceEnd = src.indexOf('\n```', marker);
|
|
assert.ok(fenceStart !== -1 && fenceEnd !== -1, 'Tier-2 SUMMARY bash fence must be complete');
|
|
const script = src.slice(fenceStart + '```bash\n'.length, fenceEnd);
|
|
|
|
const result = runHook('-n', ['-c', script], {
|
|
interpreter: 'bash',
|
|
timeoutMs: PROBE_TIMEOUT_MS,
|
|
});
|
|
|
|
assert.equal(result.exitCode, 0, `bash rejected the shipped fence:\n${result.stderr}`);
|
|
});
|
|
|
|
test('#4461: the shipped extractor helper treats adversarial SUMMARY text and argv as inert data', () => {
|
|
const src = readFileNormalized(WORKFLOW_PATH);
|
|
const helperStart = src.indexOf(' extract_summary_files() {');
|
|
// Anchor on the real loop gate, not the whitespace-only separator above it:
|
|
// editors are entitled to trim trailing spaces without changing behavior.
|
|
const helperEnd = src.indexOf('\n if [ -n "$SUMMARIES" ]; then', helperStart);
|
|
assert.ok(helperStart !== -1 && helperEnd !== -1, 'extract_summary_files helper must be extractable');
|
|
const helper = src.slice(helperStart, helperEnd);
|
|
|
|
const dir = createTempDir('gsd-4461-adversarial-');
|
|
try {
|
|
const sentinel = path.join(dir, 'MUST-NOT-EXIST');
|
|
// This directly executes the shipped helper's argv boundary. It does
|
|
// not claim the workflow's outer SUMMARY-list iteration preserves
|
|
// whitespace; that pre-existing shell-word-splitting behavior remains
|
|
// tracked separately by #4109.
|
|
// Double quotes are not legal in Windows filenames. Keep the argv
|
|
// adversarial with shell syntax, whitespace, and a quote that is valid
|
|
// on every supported filesystem; the SUMMARY payload below exercises
|
|
// a literal double quote independently.
|
|
const summary = path.join(dir, "SUMMARY $(not-a-command) 'quoted'.md");
|
|
// Git Bash launches the native Windows Node binary in CI. Forward-slash
|
|
// absolute paths survive that argv boundary on both platforms, while a
|
|
// raw drive path's backslashes are MSYS quoting syntax rather than data.
|
|
const shellSentinel = sentinel.replace(/\\/g, '/');
|
|
const shellSummary = summary.replace(/\\/g, '/');
|
|
const dollarPath = `src/$(touch ${shellSentinel}).js`;
|
|
const backtickPath = `src/\`touch ${shellSentinel}\`.js`;
|
|
const quotePath = 'src/"quoted".js';
|
|
fs.writeFileSync(summary, [
|
|
'---',
|
|
'key-files:',
|
|
' created:',
|
|
` - ${dollarPath}`,
|
|
' modified:',
|
|
` - ${backtickPath}`,
|
|
` - ${quotePath}`,
|
|
'---',
|
|
'',
|
|
].join('\n'));
|
|
|
|
const script = ['set -eu', helper, 'extract_summary_files "$SUMMARY_PATH"'].join('\n');
|
|
const result = toLegacyResult(runHook('-c', [script, 'bash'], {
|
|
interpreter: 'bash',
|
|
env: { ...process.env, SUMMARY_PATH: shellSummary },
|
|
timeoutMs: PROBE_TIMEOUT_MS,
|
|
}));
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.deepStrictEqual(result.stdout.trim().split('\n'), [dollarPath, backtickPath, quotePath]);
|
|
assert.ok(!fs.existsSync(sentinel), 'SUMMARY payload must never execute command substitutions');
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('extracts only key-files.created and key-files.modified entries', () => {
|
|
const yaml = [
|
|
'key-files:',
|
|
' created:',
|
|
' - app/foo.tsx',
|
|
' modified:',
|
|
' - lib/bar.ts',
|
|
'key-decisions:',
|
|
' - We chose RSC for performance reasons',
|
|
'patterns-established:',
|
|
' - Always validate at the boundary',
|
|
'requirements-completed:',
|
|
' - REQ-01 done',
|
|
].join('\n');
|
|
|
|
const files = parseKeyFiles(yaml);
|
|
assert.deepStrictEqual(files.sort(), ['app/foo.tsx', 'lib/bar.ts'].sort());
|
|
});
|
|
|
|
test('strips em-dash narrative from bullet: "app/foo.tsx — RSC catalogue with filters"', () => {
|
|
const yaml = [
|
|
'key-files:',
|
|
' created:',
|
|
' - app/foo.tsx — RSC catalogue with topic/mode/date filters',
|
|
].join('\n');
|
|
|
|
const files = parseKeyFiles(yaml);
|
|
assert.deepStrictEqual(files, ['app/foo.tsx']);
|
|
});
|
|
|
|
test('strips parenthetical from bullet: "tests/bar.test.ts (122 lines — 17 assertions)"', () => {
|
|
const yaml = [
|
|
'key-files:',
|
|
' created:',
|
|
' - tests/bar.test.ts (122 lines — 17 assertions)',
|
|
].join('\n');
|
|
|
|
const files = parseKeyFiles(yaml);
|
|
assert.deepStrictEqual(files, ['tests/bar.test.ts']);
|
|
});
|
|
|
|
test('hyphenated sections in any order produce identical results', () => {
|
|
const yamlA = [
|
|
'key-decisions:',
|
|
' - Some decision',
|
|
'key-files:',
|
|
' created:',
|
|
' - src/index.ts',
|
|
'patterns-established:',
|
|
' - Some pattern',
|
|
].join('\n');
|
|
|
|
const yamlB = [
|
|
'patterns-established:',
|
|
' - Some pattern',
|
|
'key-files:',
|
|
' created:',
|
|
' - src/index.ts',
|
|
'key-decisions:',
|
|
' - Some decision',
|
|
].join('\n');
|
|
|
|
assert.deepStrictEqual(parseKeyFiles(yamlA), parseKeyFiles(yamlB));
|
|
assert.deepStrictEqual(parseKeyFiles(yamlA), ['src/index.ts']);
|
|
});
|
|
|
|
test('prose-only bullets from key-decisions are never included in file list', () => {
|
|
const yaml = [
|
|
'key-decisions:',
|
|
' - We chose RSC for performance reasons',
|
|
' - Deferred auth to Phase 3',
|
|
'key-files:',
|
|
' created:',
|
|
' - app/page.tsx',
|
|
].join('\n');
|
|
|
|
const files = parseKeyFiles(yaml);
|
|
assert.deepStrictEqual(files, ['app/page.tsx']);
|
|
});
|
|
|
|
// #2666 — the Tier-2 extractor must NOT drop repository-root files (no `/`)
|
|
// or known extensionless build files. Pre-fix the buggy predicate
|
|
// `/\//.test(raw) && /\.[A-Za-z0-9]+$/.test(raw)` dropped every root-level
|
|
// path and every extensionless build file anywhere in the tree.
|
|
test('#2666 RED: root-level files with extensions are accepted (package.json, renovate.json, .gitlab-ci.yml, AGENTS.md)', () => {
|
|
const yaml = [
|
|
'key-files:',
|
|
' modified:',
|
|
' - package.json',
|
|
' - renovate.json',
|
|
' - .gitlab-ci.yml',
|
|
' - AGENTS.md',
|
|
' - CLAUDE.md',
|
|
].join('\n');
|
|
const files = parseKeyFiles(yaml);
|
|
assert.deepStrictEqual(
|
|
files.sort(),
|
|
['.gitlab-ci.yml', 'AGENTS.md', 'CLAUDE.md', 'package.json', 'renovate.json'],
|
|
'root-level files with extensions must not be dropped for lacking a directory separator',
|
|
);
|
|
});
|
|
|
|
test('#2666: nested extensionless build files are accepted (docker/Dockerfile, web/Makefile)', () => {
|
|
const yaml = [
|
|
'key-files:',
|
|
' modified:',
|
|
' - docker/Dockerfile',
|
|
' - web/Makefile',
|
|
].join('\n');
|
|
const files = parseKeyFiles(yaml);
|
|
assert.deepStrictEqual(files.sort(), ['docker/Dockerfile', 'web/Makefile']);
|
|
});
|
|
|
|
test('#2666: root-level extensionless build files are accepted (Dockerfile, Makefile, Justfile, Containerfile, Procfile)', () => {
|
|
const yaml = [
|
|
'key-files:',
|
|
' created:',
|
|
' - Dockerfile',
|
|
' - Makefile',
|
|
' - Justfile',
|
|
' - Containerfile',
|
|
' - Procfile',
|
|
].join('\n');
|
|
const files = parseKeyFiles(yaml);
|
|
assert.deepStrictEqual(
|
|
files.sort(),
|
|
['Containerfile', 'Dockerfile', 'Justfile', 'Makefile', 'Procfile'],
|
|
);
|
|
});
|
|
|
|
test('#2666 acceptance #1: the reporter 10-file Docker+CI phase yields all 10 paths', () => {
|
|
const yaml = [
|
|
'key-files:',
|
|
' created:',
|
|
' - Dockerfile',
|
|
' - .gitlab-ci.yml',
|
|
' - renovate.json',
|
|
' - AGENTS.md',
|
|
' - CLAUDE.md',
|
|
' - docs/DEVELOPMENT.md',
|
|
' - scripts/version-consistency-gate.mjs',
|
|
' - web/package.json',
|
|
' - web/version_management.md',
|
|
' - web/update-version.cjs',
|
|
].join('\n');
|
|
const files = parseKeyFiles(yaml);
|
|
assert.deepStrictEqual(
|
|
files.sort(),
|
|
[
|
|
'.gitlab-ci.yml', 'AGENTS.md', 'CLAUDE.md', 'Dockerfile',
|
|
'docs/DEVELOPMENT.md', 'renovate.json', 'scripts/version-consistency-gate.mjs',
|
|
'web/package.json', 'web/update-version.cjs', 'web/version_management.md',
|
|
],
|
|
'the full reporter phase must scope all 10 files, including Dockerfile + root files',
|
|
);
|
|
});
|
|
|
|
test('#2666 negative-space: a path-like prose bullet with no extension and unknown basename is rejected', () => {
|
|
// `topic/mode/date filters` has a `/` but no extension and an unknown basename —
|
|
// the pre-fix predicate dropped it (good), the relaxed predicate must STILL drop it.
|
|
const yaml = [
|
|
'key-decisions:',
|
|
' - topic/mode/date filters',
|
|
'key-files:',
|
|
' created:',
|
|
' - app/page.tsx',
|
|
].join('\n');
|
|
const files = parseKeyFiles(yaml);
|
|
assert.deepStrictEqual(files, ['app/page.tsx']);
|
|
});
|
|
|
|
test('#2666 negative-space: em-dash/parenthetical stripping still works on an accepted root file', () => {
|
|
const yaml = [
|
|
'key-files:',
|
|
' modified:',
|
|
' - Dockerfile — multi-stage build',
|
|
].join('\n');
|
|
const files = parseKeyFiles(yaml);
|
|
assert.deepStrictEqual(files, ['Dockerfile']);
|
|
});
|
|
|
|
// Docs-parity: the workflow .md must contain the hyphen-aware boundary regex
|
|
// so what we tested above is actually what is deployed.
|
|
test('code-review.md contains hyphen-aware boundary regex [\\w-]+', () => {
|
|
const src = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
|
// Locate the Node script block in the compute_file_scope step
|
|
const scriptStart = src.indexOf('const files = [];');
|
|
assert.ok(scriptStart !== -1, 'compute_file_scope script must contain "const files = [];"');
|
|
const scriptEnd = src.indexOf('if (files.length)', scriptStart);
|
|
const scriptSection = src.slice(scriptStart, scriptEnd);
|
|
// Must use [\\w-]+ (hyphen-aware) not \\w+ only
|
|
const hasHyphenAwareRegex = scriptSection.includes('[\\\\w-]') || scriptSection.includes('[\\w-]');
|
|
assert.ok(
|
|
hasHyphenAwareRegex,
|
|
'compute_file_scope boundary regex must be hyphen-aware ([\\w-]+), found section:\n' + scriptSection
|
|
);
|
|
});
|
|
|
|
// Docs-parity: the workflow .md must contain the em-dash and parenthetical stripping.
|
|
test('code-review.md contains em-dash split and parenthetical strip in script body', () => {
|
|
const src = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
|
const scriptStart = src.indexOf('const files = [];');
|
|
const scriptEnd = src.indexOf('if (files.length)', scriptStart);
|
|
const scriptSection = src.slice(scriptStart, scriptEnd);
|
|
assert.ok(
|
|
scriptSection.includes('replace(/\\s+\\([^)]*\\)\\s*$/, \'\')'),
|
|
'Script must strip parentheticals with replace(/\\s+\\([^)]*\\)\\s*$/, \'\')'
|
|
);
|
|
assert.ok(
|
|
scriptSection.includes('split(/\\s+—\\s'),
|
|
'Script must split on em-dash to strip narrative'
|
|
);
|
|
});
|
|
|
|
// #2666 docs-parity: the shipped workflow must NOT still carry the buggy
|
|
// AND-joined predicate that required BOTH a `/` and a trailing extension —
|
|
// that predicate dropped every root-level file and every extensionless build
|
|
// file. Catches a revert of the #2666 fix.
|
|
test('#2666 docs-parity: compute_file_scope does not contain the buggy slash-and-extension predicate', () => {
|
|
const src = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
|
const scriptStart = src.indexOf('const files = [];');
|
|
const scriptEnd = src.indexOf('if (files.length)', scriptStart);
|
|
const scriptSection = src.slice(scriptStart, scriptEnd);
|
|
assert.ok(
|
|
!scriptSection.includes('/\\//.test(raw) && /\\.[A-Za-z0-9]+$/.test(raw)'),
|
|
'compute_file_scope must not use the buggy AND-joined /\\//.test(raw) && /\\.[A-Za-z0-9]+$/.test(raw) ' +
|
|
'predicate (#2666) — it drops every root-level and extensionless build file. Found section:\n' +
|
|
scriptSection
|
|
);
|
|
});
|
|
|
|
// #2666 docs-parity: the shipped workflow must reference the known
|
|
// extensionless build filenames so Dockerfile/Makefile/etc. are accepted.
|
|
test('#2666 docs-parity: compute_file_scope accepts known extensionless build files (Dockerfile)', () => {
|
|
const src = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
|
const scriptStart = src.indexOf('const files = [];');
|
|
const scriptEnd = src.indexOf('if (files.length)', scriptStart);
|
|
const scriptSection = src.slice(scriptStart, scriptEnd);
|
|
assert.ok(
|
|
/dockerfile/i.test(scriptSection),
|
|
'compute_file_scope must reference known extensionless build filenames (e.g. Dockerfile) ' +
|
|
'so they are not dropped (#2666). Found section:\n' + scriptSection
|
|
);
|
|
});
|
|
|
|
// #2666 docs-parity: the Tier-3 git-diff fallback must intersect with the
|
|
// SUMMARY scope and warn on dropped files (not only fire on zero Tier-2 hits).
|
|
test('#2666 docs-parity: Tier-3 intersects/warns against git diff --name-only', () => {
|
|
const src = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
|
// The shipped workflow must compute git diff --name-only AND emit a warning
|
|
// when the diff contains files the SUMMARY extractor did not surface.
|
|
assert.ok(
|
|
src.includes('git diff --name-only'),
|
|
'code-review.md must run `git diff --name-only` to cross-check the SUMMARY scope (#2666)'
|
|
);
|
|
assert.ok(
|
|
/warn|missing|not surfaced|did not|not in/i.test(src),
|
|
'code-review.md must warn when git diff contains files the SUMMARY extractor dropped (#2666)'
|
|
);
|
|
});
|
|
|
|
// #2666 docs-parity: the membership test must be EXACT whole-line matching
|
|
// (grep -Fxq), not an unanchored `case` substring match — otherwise a short
|
|
// basename in the diff (root `Dockerfile`) substring-matches a longer scoped
|
|
// path (`docker/Dockerfile`) and is silently skipped, reintroducing the bug.
|
|
test('#2666 docs-parity: Tier-3 cross-check uses exact whole-line matching (grep -Fxq), not substring case', () => {
|
|
const src = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
|
assert.ok(
|
|
src.includes('grep -Fxq'),
|
|
'code-review.md Tier-3 cross-check must use grep -Fxq (exact whole-line match) for membership ' +
|
|
'testing, not an unanchored `case` substring match that would skip a root `Dockerfile` ' +
|
|
'whose name appears as a suffix of an already-scoped `docker/Dockerfile` (#2666)'
|
|
);
|
|
// The unanchored substring `case "$IN_SCOPE" in` membership test must NOT be
|
|
// present — it would false-match a basename suffix. Plain substring check (no
|
|
// regex, so no CRLF-fragility): the grep -Fxq positive guard above proves the
|
|
// correct mechanism; this negative guard catches a revert to the `case` form.
|
|
assert.ok(
|
|
!src.includes('case "$IN_SCOPE"'),
|
|
'code-review.md Tier-3 must not use the unanchored `case "$IN_SCOPE"` substring membership ' +
|
|
'test (#2666) — use grep -Fxq for exact whole-line matching'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BUG 2 — severity-label parser: present_results must accept both `critical:`
|
|
// and `blocker:` as Critical-tier frontmatter keys.
|
|
// ---------------------------------------------------------------------------
|
|
describe('Bug 2 — present_results severity-label parser', () => {
|
|
test('frontmatter with blocker: 8 is parsed as critical: 8', () => {
|
|
const frontmatter = [
|
|
'phase: 03-courses',
|
|
'reviewed: 2025-01-01T00:00:00Z',
|
|
'findings:',
|
|
' blocker: 8',
|
|
' warning: 2',
|
|
' info: 0',
|
|
' total: 10',
|
|
'status: issues_found',
|
|
].join('\n');
|
|
|
|
const result = parseFrontmatterCritical(frontmatter);
|
|
assert.strictEqual(result.critical, 8);
|
|
});
|
|
|
|
test('frontmatter with critical: 5 is parsed as critical: 5', () => {
|
|
const frontmatter = [
|
|
'phase: 03-courses',
|
|
'reviewed: 2025-01-01T00:00:00Z',
|
|
'findings:',
|
|
' critical: 5',
|
|
' warning: 1',
|
|
' info: 0',
|
|
' total: 6',
|
|
'status: issues_found',
|
|
].join('\n');
|
|
|
|
const result = parseFrontmatterCritical(frontmatter);
|
|
assert.strictEqual(result.critical, 5);
|
|
});
|
|
|
|
test('frontmatter with neither critical nor blocker returns 0', () => {
|
|
const frontmatter = [
|
|
'phase: 03-courses',
|
|
'findings:',
|
|
' warning: 3',
|
|
' info: 1',
|
|
' total: 4',
|
|
'status: issues_found',
|
|
].join('\n');
|
|
|
|
const result = parseFrontmatterCritical(frontmatter);
|
|
assert.strictEqual(result.critical, 0);
|
|
});
|
|
|
|
// Docs-parity: the workflow .md must contain the updated grep pattern.
|
|
test('code-review.md present_results grep accepts both critical and blocker labels', () => {
|
|
const src = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
|
assert.ok(
|
|
src.includes('grep -E "^[[:space:]]*(critical|blocker):"'),
|
|
'code-review.md present_results must grep for both critical: and blocker: labels'
|
|
);
|
|
});
|
|
|
|
// Docs-parity: the workflow .md must contain the updated grep for BL- headings.
|
|
test('code-review.md present_results grep includes BL- headings alongside CR- and WR-', () => {
|
|
const src = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
|
assert.ok(
|
|
src.includes('### BL-') && src.includes('### CR-') && src.includes('### WR-'),
|
|
'code-review.md present_results must grep for BL- alongside CR- and WR- headings'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BUG 3 — fixer agent ID alphabet and filter sets must include BL-* alongside CR-*.
|
|
// ---------------------------------------------------------------------------
|
|
describe('Bug 3 — gsd-code-fixer BL-* inclusion in filter sets', () => {
|
|
test('finding_parser documents BL-\\d+ as Critical-tier-equivalent', () => {
|
|
const src = fs.readFileSync(FIXER_PATH, 'utf8');
|
|
const parserStart = src.indexOf('<finding_parser>');
|
|
const parserEnd = src.indexOf('</finding_parser>');
|
|
assert.ok(parserStart !== -1, 'gsd-code-fixer.md must have a <finding_parser> block');
|
|
const parserSection = src.slice(parserStart, parserEnd);
|
|
assert.ok(
|
|
parserSection.includes('BL-'),
|
|
'finding_parser block must document BL-* as a Critical-tier-equivalent ID prefix'
|
|
);
|
|
});
|
|
|
|
test('parse_findings step documents severity as "Critical (CR-* or BL-*)"', () => {
|
|
const src = fs.readFileSync(FIXER_PATH, 'utf8');
|
|
const stepStart = src.indexOf('<step name="parse_findings">');
|
|
const stepEnd = src.indexOf('</step>', stepStart);
|
|
assert.ok(stepStart !== -1, 'gsd-code-fixer.md must have a parse_findings step');
|
|
const stepSection = src.slice(stepStart, stepEnd);
|
|
assert.ok(
|
|
stepSection.includes('CR-* or BL-*') || stepSection.includes('CR-* and BL-*'),
|
|
'parse_findings step must describe Critical severity as "CR-* or BL-*"'
|
|
);
|
|
});
|
|
|
|
test('critical_warning filter set includes BL-* alongside CR-* and WR-*', () => {
|
|
const src = fs.readFileSync(FIXER_PATH, 'utf8');
|
|
const stepStart = src.indexOf('<step name="parse_findings">');
|
|
const stepEnd = src.indexOf('</step>', stepStart);
|
|
const stepSection = src.slice(stepStart, stepEnd);
|
|
|
|
const critWarningIdx = stepSection.indexOf('critical_warning');
|
|
assert.ok(critWarningIdx !== -1, 'parse_findings must define critical_warning filter');
|
|
const lineStart = stepSection.lastIndexOf('\n', critWarningIdx);
|
|
const lineEnd = stepSection.indexOf('\n', critWarningIdx);
|
|
const filterLine = stepSection.slice(lineStart, lineEnd);
|
|
assert.ok(
|
|
filterLine.includes('BL-'),
|
|
'critical_warning filter line must include BL-*: ' + filterLine.trim()
|
|
);
|
|
});
|
|
|
|
test('sort order description mentions both CR-* and BL-* for Critical tier', () => {
|
|
const src = fs.readFileSync(FIXER_PATH, 'utf8');
|
|
const stepStart = src.indexOf('<step name="parse_findings">');
|
|
const stepEnd = src.indexOf('</step>', stepStart);
|
|
const stepSection = src.slice(stepStart, stepEnd);
|
|
assert.ok(
|
|
stepSection.includes('BL-'),
|
|
'parse_findings sort-order description must mention BL-* as Critical-tier alongside CR-*'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// REVIEWER CONTRACT — gsd-code-reviewer.md must acknowledge BL-/blocker: as
|
|
// an accepted alternative to CR-/critical: (tier-equivalent).
|
|
// ---------------------------------------------------------------------------
|
|
describe('Reviewer contract — gsd-code-reviewer.md label-equivalence', () => {
|
|
test('write_review step documents blocker: as accepted alternative to critical:', () => {
|
|
const src = fs.readFileSync(REVIEWER_PATH, 'utf8');
|
|
const stepStart = src.indexOf('<step name="write_review">');
|
|
const stepEnd = src.indexOf('</step>', stepStart);
|
|
assert.ok(stepStart !== -1, 'gsd-code-reviewer.md must have a write_review step');
|
|
const stepSection = src.slice(stepStart, stepEnd);
|
|
assert.ok(
|
|
stepSection.includes('blocker'),
|
|
'write_review step must acknowledge blocker: as a tier-equivalent alternative to critical:'
|
|
);
|
|
});
|
|
|
|
test('write_review step acknowledges BL- finding ID prefix as Critical-tier-equivalent', () => {
|
|
const src = fs.readFileSync(REVIEWER_PATH, 'utf8');
|
|
const stepStart = src.indexOf('<step name="write_review">');
|
|
const stepEnd = src.indexOf('</step>', stepStart);
|
|
const stepSection = src.slice(stepStart, stepEnd);
|
|
assert.ok(
|
|
stepSection.includes('BL-'),
|
|
'write_review step must acknowledge BL- as a Critical-tier-equivalent finding ID prefix'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BUG 4 (#2352) — compute_file_scope must tilde-expand `~/...`-prefixed
|
|
// SUMMARY.md key-files entries BEFORE the "Filter deleted files" existence
|
|
// check. Bash only tilde-expands a literal `~` written in source text, never
|
|
// one arriving as the value of an already-expanded variable — so a real file
|
|
// recorded as `~/.claude/gsd-core/workflows/verify-phase.md` was silently
|
|
// misclassified as deleted and dropped from REVIEW_FILES, and a phase whose
|
|
// every recorded file used a `~/...` path hit the empty-scope skip
|
|
// ("No source files changed ... Skipping review.") as a false negative.
|
|
//
|
|
// Tested both ways: a docs-parity assertion (cross-platform, pure fs read)
|
|
// that the normalization block exists in the deployed workflow text, and a
|
|
// behavioral test that extracts the actual "Expand tilde paths" +
|
|
// "Filter deleted files" bash blocks from code-review.md and executes them
|
|
// via a real bash subprocess against planted files under a fresh HOME.
|
|
// ---------------------------------------------------------------------------
|
|
describe('Bug 4 (#2352) — compute_file_scope tilde-path expansion', () => {
|
|
// Docs-parity: the workflow .md must contain the tilde-normalization block
|
|
// as step 1 of "Post-processing (all tiers)", ahead of the deleted-file
|
|
// filter, so what we behaviorally test below is what is actually deployed.
|
|
test('code-review.md contains a tilde-expansion block ahead of the deleted-file filter', () => {
|
|
const src = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
|
const postProcessingIdx = src.indexOf('**Post-processing (all tiers):**');
|
|
assert.ok(postProcessingIdx !== -1, 'code-review.md must have a "Post-processing (all tiers)" section');
|
|
|
|
const expandIdx = src.indexOf('EXPANDED_FILES=()', postProcessingIdx);
|
|
assert.ok(expandIdx !== -1, 'Post-processing must contain an EXPANDED_FILES=() tilde-expansion loop');
|
|
|
|
const caseIdx = src.indexOf('case "$file" in', postProcessingIdx);
|
|
assert.ok(caseIdx !== -1 && caseIdx < expandIdx + 400, 'tilde-expansion loop must use a case "$file" in match');
|
|
assert.ok(
|
|
src.slice(caseIdx, caseIdx + 200).includes('"~/"*)') &&
|
|
src.slice(caseIdx, caseIdx + 200).includes('${HOME}${file#\\~}'),
|
|
'tilde-expansion loop must rewrite a leading ~/ to ${HOME}/... via ${file#\\~}'
|
|
);
|
|
|
|
const deletedFilterIdx = src.indexOf('DELETED_COUNT=0', postProcessingIdx);
|
|
assert.ok(deletedFilterIdx !== -1, 'Post-processing must still contain the deleted-file filter');
|
|
assert.ok(
|
|
expandIdx < deletedFilterIdx,
|
|
'tilde-expansion loop must run BEFORE the deleted-file filter, not after'
|
|
);
|
|
});
|
|
|
|
// Extract the tilde-expansion fence and the (non-adjacent — the exclusions
|
|
// filter sits between them) deleted-file-filter fence from the
|
|
// "Post-processing (all tiers)" section of code-review.md — the exact
|
|
// snippets the runtime executes, located by content anchor rather than
|
|
// position so an intervening step doesn't silently swap in the wrong
|
|
// block — and glue them behind a synthetic REVIEW_FILES=("$@") seed for
|
|
// direct execution. The exclusions filter itself is intentionally skipped
|
|
// here: it only matches relative planning-artifact paths and is orthogonal
|
|
// to tilde expansion (see code-review.md step 2, "Apply exclusions").
|
|
function extractPostProcessingScript() {
|
|
// readFileNormalized() strips \r\n -> \n before either fence below is
|
|
// sliced out and later spawned via spawnSync('bash', ...) in
|
|
// runPostProcessing() — an un-normalized read on a Windows checkout would
|
|
// break bash mid-script (DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE, #2650).
|
|
const src = readFileNormalized(WORKFLOW_PATH);
|
|
const postProcessingIdx = src.indexOf('**Post-processing (all tiers):**');
|
|
assert.ok(postProcessingIdx !== -1, 'code-review.md must have a "Post-processing (all tiers)" section');
|
|
|
|
function fenceContaining(marker) {
|
|
const markerIdx = src.indexOf(marker, postProcessingIdx);
|
|
assert.ok(markerIdx !== -1, `expected to find "${marker}" in the Post-processing section`);
|
|
const fenceStart = src.lastIndexOf('```bash', markerIdx);
|
|
assert.ok(fenceStart !== -1 && fenceStart > postProcessingIdx, `no \`\`\`bash fence before "${marker}"`);
|
|
const bodyStart = src.indexOf('\n', fenceStart) + 1;
|
|
const fenceEnd = src.indexOf('\n```', bodyStart);
|
|
assert.ok(fenceEnd !== -1, `unterminated \`\`\`bash fence containing "${marker}"`);
|
|
return src.slice(bodyStart, fenceEnd);
|
|
}
|
|
|
|
const tildeBlock = fenceContaining('EXPANDED_FILES=()');
|
|
const deletedBlock = fenceContaining('DELETED_COUNT=0');
|
|
|
|
return [
|
|
'REVIEW_FILES=("$@")',
|
|
tildeBlock,
|
|
deletedBlock,
|
|
'printf "%s\\n" "${REVIEW_FILES[@]}"',
|
|
'echo "REVIEW_FILES_COUNT=${#REVIEW_FILES[@]}"',
|
|
'echo "DELETED_COUNT=$DELETED_COUNT"',
|
|
].join('\n');
|
|
}
|
|
|
|
function runPostProcessing(homeDir, files) {
|
|
const script = extractPostProcessingScript();
|
|
// "bash" as $0 so the real REVIEW_FILES entries land in "$@" from $1.
|
|
return toLegacyResult(
|
|
runHook('-c', [script, 'bash', ...files], {
|
|
interpreter: 'bash',
|
|
env: { ...process.env, HOME: homeDir },
|
|
timeoutMs: PROBE_TIMEOUT_MS,
|
|
})
|
|
);
|
|
}
|
|
|
|
let tmpHome;
|
|
|
|
test('setup: plant a fresh HOME with a real file', { skip: process.platform === 'win32' }, () => {
|
|
tmpHome = createTempDir('gsd-2352-home-');
|
|
fs.mkdirSync(path.join(tmpHome, '.claude', 'gsd-core', 'workflows'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpHome, '.claude', 'gsd-core', 'workflows', 'verify-phase.md'),
|
|
'# real file\n',
|
|
'utf8'
|
|
);
|
|
});
|
|
|
|
test(
|
|
'AC1: a ~/-prefixed path to a real file survives and is not counted deleted',
|
|
{ skip: process.platform === 'win32' },
|
|
() => {
|
|
const result = runPostProcessing(tmpHome, ['~/.claude/gsd-core/workflows/verify-phase.md']);
|
|
assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
|
|
assert.match(
|
|
result.stdout,
|
|
new RegExp(path.join(tmpHome, '.claude', 'gsd-core', 'workflows', 'verify-phase.md').replace(/[/\\.]/g, '\\$&')),
|
|
`expected expanded absolute path in surviving REVIEW_FILES; got: ${JSON.stringify(result.stdout)}`
|
|
);
|
|
assert.match(result.stdout, /DELETED_COUNT=0/, `expected DELETED_COUNT=0; got: ${JSON.stringify(result.stdout)}`);
|
|
assert.match(
|
|
result.stdout,
|
|
/REVIEW_FILES_COUNT=1/,
|
|
`expected the tilde path to survive into REVIEW_FILES; got: ${JSON.stringify(result.stdout)}`
|
|
);
|
|
}
|
|
);
|
|
|
|
test(
|
|
'AC2: a ~/-prefixed path to a non-existent file is still correctly excluded as deleted',
|
|
{ skip: process.platform === 'win32' },
|
|
() => {
|
|
const result = runPostProcessing(tmpHome, ['~/.claude/gsd-core/workflows/does-not-exist.md']);
|
|
assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
|
|
assert.match(result.stdout, /DELETED_COUNT=1/, `expected DELETED_COUNT=1; got: ${JSON.stringify(result.stdout)}`);
|
|
assert.match(
|
|
result.stdout,
|
|
/REVIEW_FILES_COUNT=0/,
|
|
`expected the missing tilde path to be dropped; got: ${JSON.stringify(result.stdout)}`
|
|
);
|
|
}
|
|
);
|
|
|
|
test(
|
|
'AC3: a phase where every recorded file is a real ~/-prefixed path does not empty the scope',
|
|
{ skip: process.platform === 'win32' },
|
|
() => {
|
|
const result = runPostProcessing(tmpHome, ['~/.claude/gsd-core/workflows/verify-phase.md']);
|
|
assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
|
|
const countMatch = result.stdout.match(/REVIEW_FILES_COUNT=(\d+)/);
|
|
assert.ok(countMatch, `expected a REVIEW_FILES_COUNT line; got: ${JSON.stringify(result.stdout)}`);
|
|
assert.ok(
|
|
Number(countMatch[1]) > 0,
|
|
'an all-tilde real-file scope must not reduce to zero (would trigger the empty-scope skip)'
|
|
);
|
|
}
|
|
);
|
|
|
|
test(
|
|
'AC4: mixed tilde + missing ordinary relative path resolve independently',
|
|
{ skip: process.platform === 'win32' },
|
|
() => {
|
|
const result = runPostProcessing(tmpHome, [
|
|
'~/.claude/gsd-core/workflows/verify-phase.md',
|
|
'this/relative/path/does-not-exist.md',
|
|
]);
|
|
assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
|
|
assert.match(result.stdout, /DELETED_COUNT=1/, `expected exactly 1 deleted; got: ${JSON.stringify(result.stdout)}`);
|
|
assert.match(
|
|
result.stdout,
|
|
/REVIEW_FILES_COUNT=1/,
|
|
`expected only the tilde path to survive; got: ${JSON.stringify(result.stdout)}`
|
|
);
|
|
assert.doesNotMatch(
|
|
result.stdout,
|
|
/this\/relative\/path\/does-not-exist\.md/,
|
|
'the missing ordinary relative path must not survive into REVIEW_FILES'
|
|
);
|
|
}
|
|
);
|
|
|
|
test('teardown: remove the temp HOME', { skip: process.platform === 'win32' }, () => {
|
|
cleanup(tmpHome);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared diff-base extraction/execution helpers (Bug 5 #3191, Bug 6 #3503).
|
|
//
|
|
// The workflow computes "the phase's base commit" in three independent bash
|
|
// invocations (each <step> is its own shell): the Tier-3 file-scope fallback
|
|
// (compute_file_scope), the agent-context DIFF_BASE (spawn_reviewer), and the
|
|
// fallow pre-pass's --changed-since base (structural-pre-pass.md).
|
|
//
|
|
// Behavioral style follows Bug 4: extract the SHIPPED bash from the workflow
|
|
// .md files by content anchor and execute it via a real bash subprocess
|
|
// against a git fixture — so the assertion binds the deployed text, not a
|
|
// JS reimplementation. Running the real `git log` (not a regex shim) is what
|
|
// makes platform-level regex holes (the #3191 macOS `\b` no-op) visible.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// The ```bash fence containing `marker`, located after `fromIdx`.
|
|
function fenceContaining(src, marker, fromIdx = 0) {
|
|
const markerIdx = src.indexOf(marker, fromIdx);
|
|
assert.ok(markerIdx !== -1, `expected to find "${marker}" in workflow source`);
|
|
const fenceStart = src.lastIndexOf('```bash', markerIdx);
|
|
assert.ok(fenceStart !== -1, `no \`\`\`bash fence before "${marker}"`);
|
|
const bodyStart = src.indexOf('\n', fenceStart) + 1;
|
|
const fenceEnd = src.indexOf('\n```', bodyStart);
|
|
assert.ok(fenceEnd !== -1, `unterminated \`\`\`bash fence containing "${marker}"`);
|
|
return src.slice(bodyStart, fenceEnd);
|
|
}
|
|
|
|
// The Tier-3 derivation prefix: fence start up to the REVIEW_FILES branch.
|
|
function extractTier3Derivation() {
|
|
const src = readFileNormalized(WORKFLOW_PATH);
|
|
const fence = fenceContaining(src, '# Compute diff base from phase commits');
|
|
const cut = fence.indexOf('if [ ${#REVIEW_FILES[@]} -eq 0 ]');
|
|
assert.ok(cut !== -1, 'Tier-3 fence must contain the REVIEW_FILES empty-scope branch');
|
|
return fence.slice(0, cut);
|
|
}
|
|
|
|
// spawn_reviewer no longer derives its own DIFF_BASE (#4209 B3 fix: a second,
|
|
// divergent recomputation there made the external reviewer lane and the
|
|
// internal reviewer diff against different base SHAs on any re-review). It
|
|
// now reuses the value compute_file_scope's Tier-3 derivation already
|
|
// computed, so this is the SAME snippet as extractTier3Derivation() — kept
|
|
// as a distinct name so T2/T5 below still read as testing spawn_reviewer's
|
|
// contract, not just Tier 3's.
|
|
function extractSpawnReviewerDerivation() {
|
|
return extractTier3Derivation();
|
|
}
|
|
|
|
// The fallow phase-scope derivation, from the step fragment. The fragment
|
|
// carries markdown-escaped quotes (\") in this fence — an authoring
|
|
// artifact that survived #2994 fragmentization verbatim; the runtime agent
|
|
// normalizes them when transcribing, so the test does the same before
|
|
// executing. Sliced from FALLOW_SCOPE_ARGS=() (skipping the gsd-tools
|
|
// runtime resolver line above it, which exits 1 on machines without an
|
|
// installed gsd-tools and is orthogonal to the base-derivation under test)
|
|
// to just before the gsd_run invocation (which needs the real binary).
|
|
function extractFallowDerivation() {
|
|
const src = readFileNormalized(PRE_PASS_STEP_PATH);
|
|
const fence = fenceContaining(src, 'FALLOW_PHASE_START=$(git log');
|
|
const scopeStart = fence.indexOf('FALLOW_SCOPE_ARGS=()');
|
|
assert.ok(scopeStart !== -1, 'fallow fence must define FALLOW_SCOPE_ARGS=()');
|
|
const cut = fence.indexOf('gsd_run run-with-timeout');
|
|
assert.ok(cut !== -1, 'fallow fence must contain the gsd_run run-with-timeout call');
|
|
assert.ok(scopeStart < cut, 'FALLOW_SCOPE_ARGS must precede the gsd_run invocation');
|
|
return fence.slice(scopeStart, cut).replace(/\\"/g, '"');
|
|
}
|
|
|
|
// Execute a derivation snippet with PADDED_PHASE (and the fallow scope gate)
|
|
// set, echoing the values it computes between sentinels so multi-line
|
|
// PHASE_COMMITS parse cleanly.
|
|
function runDerivation(repo, snippet, phase) {
|
|
const script = [
|
|
`PADDED_PHASE=${phase}`,
|
|
// #3995: the derivations anchor on the phase's own directory, not a
|
|
// commit-subject grep — the fixture commits each phase's directory at
|
|
// its first scope commit.
|
|
`PHASE_DIR=${repo}/.planning/phases/${phase}-ctx`,
|
|
'FALLOW_SCOPE=phase',
|
|
snippet,
|
|
'echo "===PHASE_START==="',
|
|
'printf \'%s\\n\' "$PHASE_START"',
|
|
'echo "===DIFF_BASE==="',
|
|
'printf \'%s\\n\' "$DIFF_BASE"',
|
|
'echo "===FALLOW_BASE==="',
|
|
'printf \'%s\\n\' "$FALLOW_BASE"',
|
|
'echo "===END==="',
|
|
].join('\n');
|
|
// Bash FAN-OUT: the extracted snippet runs `git log` plus an `echo | tail`
|
|
// pipe — the wrong class for `PROBE_TIMEOUT_MS` (a single short CLI
|
|
// probe). Same class as the observed CI failures in
|
|
// tests/quick-branching.test.cjs (PR #3787 run 32668773524) and
|
|
// tests/worktree-safety.test.cjs (`next` run 32608945654). See
|
|
// HOOK_FANOUT_TIMEOUT_MS in ./helpers/timeouts.cjs for the class
|
|
// rationale.
|
|
return toLegacyResult(
|
|
runHook('-c', [script, 'bash'], {
|
|
interpreter: 'bash',
|
|
cwd: repo,
|
|
timeoutMs: HOOK_FANOUT_TIMEOUT_MS,
|
|
})
|
|
);
|
|
}
|
|
|
|
function parseSentinel(stdout, name) {
|
|
const m = stdout.match(new RegExp(`===${name}===\\n([\\s\\S]*?)\\n===`));
|
|
if (!m) return null;
|
|
return m[1].split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bug 5 (#3191) — EVERY diff-base derivation must use the same anchored,
|
|
// portable derivation.
|
|
//
|
|
// The workflow computes "the phase's base commit" in three independent bash
|
|
// invocations (each <step> is its own shell): the Tier-3 file-scope fallback
|
|
// (compute_file_scope), the agent-context DIFF_BASE (spawn_reviewer), and the
|
|
// fallow pre-pass's --changed-since base (structural-pre-pass.md). #2989
|
|
// anchored only the Tier-3 copy — and did so with `\b`, which is not a POSIX
|
|
// ERE token, so on macOS (regex(3)) that grep matches NOTHING and Tier 3
|
|
// always fails closed. The other two sites kept the original unanchored
|
|
// `--grep="${PADDED_PHASE}"`, whose oldest substring match is routinely a
|
|
// version-string/date commit from months before the phase existed.
|
|
// (#3503 later replaced the anchor itself — a subject-line conventional-
|
|
// commit scope match instead of the "[Pp]hase N" prose phrase, which GSD's
|
|
// own commits never contain; see Bug 6. The lockstep + portability +
|
|
// fail-closed contract THIS block verifies is unchanged.)
|
|
//
|
|
// Behavioral style follows Bug 4: extract the SHIPPED bash from the workflow
|
|
// .md files by content anchor and execute it via a real bash subprocess
|
|
// against a git fixture — so the assertion binds the deployed text, not a
|
|
// JS reimplementation. Running the real `git log` (not a regex shim) is what
|
|
// keeps platform-level regex holes (the #3191 macOS `\b` no-op) visible.
|
|
// ---------------------------------------------------------------------------
|
|
// Shared: the fixture phase directory every derivation anchors on (#3995).
|
|
const PHASE06_PLAN_REL = path.join('.planning', 'phases', '06-ctx', '06-PLAN.md');
|
|
|
|
// Shared history builder (was local to the #3503 describe; the #3995 rows
|
|
// reuse it). Each entry is [relPath, subject, body?]; parent dirs are created.
|
|
function buildHistory(prefix, commits) {
|
|
const repo = createTempGitProject(prefix);
|
|
const hashes = {};
|
|
for (const [file, message, body] of commits) {
|
|
fs.mkdirSync(path.dirname(path.join(repo, file)), { recursive: true });
|
|
fs.writeFileSync(path.join(repo, file), `${message}\n`);
|
|
gitOrThrow(['add', file], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['commit', '-m', message, ...(body ? ['-m', body] : [])], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
|
|
hashes[file] = gitOrThrow(['rev-parse', 'HEAD'], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS }).trim();
|
|
}
|
|
return { repo, hashes };
|
|
}
|
|
|
|
describe('Bug 5 (#3191) — same anchored, portable phase-scope grep at all three diff-base sites', () => {
|
|
const SKIP_WIN32 = { skip: process.platform === 'win32' };
|
|
|
|
// Fixture: five commits whose messages exercise every false-match class
|
|
// from the issue — version string + date, another phase's plan whose scope
|
|
// number is a digit-superset, a prose "Phase N" mention in another phase's
|
|
// subject — plus the phase's real first scope commit and an unrelated HEAD.
|
|
function buildFixture(prefix, phaseCommitMessage, opts = {}) {
|
|
// opts.skipPhaseDir: the fail-closed row (T5) commits NO phase directory,
|
|
// so the directory anchor must resolve nothing.
|
|
const commitPhaseDir = opts.skipPhaseDir !== true;
|
|
const repo = createTempGitProject(prefix);
|
|
const phaseDir = path.join(repo, '.planning', 'phases', '06-ctx');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
const commits = [
|
|
['c1.txt', 'chore: bump to v2.06.0 on 2026-01-05'],
|
|
['c2.txt', 'docs(60-01): unrelated phase-plan work'],
|
|
[commitPhaseDir ? PHASE06_PLAN_REL : 'c3.txt', phaseCommitMessage],
|
|
['c4.txt', 'chore: Phase 60 cleanup'],
|
|
['c5.txt', 'docs: touch README'],
|
|
];
|
|
const hashes = {};
|
|
for (const [file, message] of commits) {
|
|
fs.mkdirSync(path.dirname(path.join(repo, file)), { recursive: true });
|
|
fs.writeFileSync(path.join(repo, file), `${message}\n`);
|
|
gitOrThrow(['add', file], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['commit', '-m', message], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
|
|
hashes[file] = gitOrThrow(['rev-parse', 'HEAD'], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS }).trim();
|
|
}
|
|
return { repo, hashes };
|
|
}
|
|
|
|
test(
|
|
'T1 + T4: Tier-3 derivation matches ONLY the phase\'s real scope commit — never a digit-substring or superset hit',
|
|
SKIP_WIN32,
|
|
() => {
|
|
const { repo, hashes } = buildFixture('gsd-3191-tier3-', 'docs(06): capture phase context');
|
|
try {
|
|
const result = runDerivation(repo, extractTier3Derivation(), '06');
|
|
assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
|
|
const phaseStart = parseSentinel(result.stdout, 'PHASE_START');
|
|
const diffBase = parseSentinel(result.stdout, 'DIFF_BASE');
|
|
// AC: the phase's real commits are a small minority of digit-containing
|
|
// commits; the derivation must resolve to an ancestor near the phase's
|
|
// actual first commit (c3^) — never the older v2.06.0/docs(06-01) hits.
|
|
assert.deepStrictEqual(
|
|
phaseStart,
|
|
[hashes[PHASE06_PLAN_REL]],
|
|
`Tier-3 anchor must resolve to the phase dir's first commit; got: ${JSON.stringify(phaseStart)}`
|
|
);
|
|
assert.deepStrictEqual(
|
|
diffBase,
|
|
[`${hashes[PHASE06_PLAN_REL]}^`],
|
|
'Tier-3 DIFF_BASE must be the phase first-commit parent'
|
|
);
|
|
} finally {
|
|
cleanup(repo);
|
|
}
|
|
}
|
|
);
|
|
|
|
test(
|
|
'T2: spawn_reviewer DIFF_BASE derivation uses the same anchored grep (not the bare digit)',
|
|
SKIP_WIN32,
|
|
() => {
|
|
const { repo, hashes } = buildFixture('gsd-3191-spawn-', 'docs(06): capture phase context');
|
|
try {
|
|
const result = runDerivation(repo, extractSpawnReviewerDerivation(), '06');
|
|
assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
|
|
const phaseStart = parseSentinel(result.stdout, 'PHASE_START');
|
|
const diffBase = parseSentinel(result.stdout, 'DIFF_BASE');
|
|
// Pre-fix this matches c1 and c2 as well and tail -1 picks c1 — the
|
|
// oldest unrelated match — feeding a bogus diff_base to the reviewer
|
|
// agent exactly when files: is empty (the fail-closed scenario).
|
|
assert.deepStrictEqual(
|
|
phaseStart,
|
|
[hashes[PHASE06_PLAN_REL]],
|
|
`spawn_reviewer anchor must resolve to the phase dir's first commit; got: ${JSON.stringify(phaseStart)}`
|
|
);
|
|
assert.deepStrictEqual(
|
|
diffBase,
|
|
[`${hashes[PHASE06_PLAN_REL]}^`],
|
|
'spawn_reviewer DIFF_BASE must be the phase first-commit parent'
|
|
);
|
|
} finally {
|
|
cleanup(repo);
|
|
}
|
|
}
|
|
);
|
|
|
|
test(
|
|
'T3: fallow phase scope derives --changed-since from the anchored grep, never an old substring match',
|
|
SKIP_WIN32,
|
|
() => {
|
|
const { repo, hashes } = buildFixture('gsd-3191-fallow-', 'docs(06): capture phase context');
|
|
try {
|
|
const result = runDerivation(repo, extractFallowDerivation(), '06');
|
|
assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
|
|
const fallowBase = parseSentinel(result.stdout, 'FALLOW_BASE');
|
|
// Pre-fix the unanchored grep's oldest match is the v2.06.0 commit, so
|
|
// FALLOW_SCOPE_ARGS resolves to --changed-since <old-unrelated-commit>
|
|
// and widens the structural pre-pass far beyond the phase.
|
|
assert.deepStrictEqual(
|
|
fallowBase,
|
|
[`${hashes[PHASE06_PLAN_REL]}^`],
|
|
`FALLOW_BASE must be the phase first-commit parent, got: ${JSON.stringify(fallowBase)}`
|
|
);
|
|
} finally {
|
|
cleanup(repo);
|
|
}
|
|
}
|
|
);
|
|
|
|
test(
|
|
'root commit: fallow phase scope uses a resolvable root SHA',
|
|
SKIP_WIN32,
|
|
() => {
|
|
const repo = createTempDir('gsd-4183-fallow-root-');
|
|
try {
|
|
gitOrThrow(['init', '-b', 'main'], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['config', 'user.email', 'test@test.com'], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['config', 'user.name', 'Test'], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['config', 'commit.gpgsign', 'false'], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
|
|
|
|
const phaseFile = path.join(repo, '.planning', 'phases', '06-ctx', 'PLAN.md');
|
|
fs.mkdirSync(path.dirname(phaseFile), { recursive: true });
|
|
fs.writeFileSync(phaseFile, '# phase context\n');
|
|
gitOrThrow(['add', '.planning'], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['commit', '-m', 'docs(06): initial phase context'], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
|
|
const rootSha = gitOrThrow(['rev-parse', 'HEAD'], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS }).trim();
|
|
|
|
fs.writeFileSync(path.join(repo, 'index.js'), 'module.exports = 1;\n');
|
|
gitOrThrow(['add', 'index.js'], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['commit', '-m', 'feat: add source'], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
|
|
|
|
const result = runDerivation(repo, extractFallowDerivation(), '06');
|
|
assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
|
|
const fallowBase = parseSentinel(result.stdout, 'FALLOW_BASE');
|
|
assert.deepStrictEqual(
|
|
fallowBase,
|
|
[rootSha],
|
|
`root-parent FALLOW_BASE regression: expected ${rootSha}, got ${JSON.stringify(fallowBase)}`,
|
|
);
|
|
assert.equal(
|
|
gitOrThrow(['rev-parse', '--verify', `${fallowBase[0]}^{commit}`], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS }).trim(),
|
|
rootSha,
|
|
'FALLOW_BASE must resolve to the root commit',
|
|
);
|
|
|
|
if (process.env.CI) {
|
|
const { requireFallowBinary } = require('../gsd-core/bin/lib/fallow-runner.cjs');
|
|
const { execTool } = require('../gsd-core/bin/lib/shell-command-projection.cjs');
|
|
const audit = execTool(
|
|
requireFallowBinary({ cwd: ROOT, envPath: '' }),
|
|
['audit', '--changed-since', fallowBase[0], '--format', 'json'],
|
|
{ cwd: repo, timeout: FALLOW_AUDIT_TIMEOUT_MS },
|
|
);
|
|
assert.ok([0, 1].includes(audit.exitCode), `fallow root audit exit=${audit.exitCode}; stderr=${audit.stderr}`);
|
|
console.log(`fallow-root-audit normal-exit=${audit.exitCode}`);
|
|
}
|
|
} finally {
|
|
cleanup(repo);
|
|
}
|
|
},
|
|
);
|
|
|
|
test(
|
|
'T5: with no genuine phase scope commit, every derivation yields NO base (fail-closed preserved)',
|
|
SKIP_WIN32,
|
|
() => {
|
|
const { repo } = buildFixture('gsd-3191-closed-', 'feat: scanner core', { skipPhaseDir: true }); // no committed phase dir anywhere
|
|
try {
|
|
for (const [label, snippet] of [
|
|
['tier3', extractTier3Derivation()],
|
|
['spawn_reviewer', extractSpawnReviewerDerivation()],
|
|
['fallow', extractFallowDerivation()],
|
|
]) {
|
|
const result = runDerivation(repo, snippet, '06');
|
|
assert.equal(result.status, 0, `${label} exited ${result.status}; stderr=${result.stderr}`);
|
|
const phaseStart = parseSentinel(result.stdout, 'PHASE_START');
|
|
const diffBase = parseSentinel(result.stdout, 'DIFF_BASE');
|
|
const fallowBase = parseSentinel(result.stdout, 'FALLOW_BASE');
|
|
assert.deepStrictEqual(phaseStart, [], `${label}: no phase dir committed — anchor must stay empty`);
|
|
assert.deepStrictEqual(diffBase, [], `${label}: DIFF_BASE must stay empty (no bogus base)`);
|
|
assert.deepStrictEqual(fallowBase, [], `${label}: FALLOW_BASE must stay unset`);
|
|
}
|
|
} finally {
|
|
cleanup(repo);
|
|
}
|
|
}
|
|
);
|
|
|
|
// T6 docs-parity anti-revert (#3191/#3995): every diff-base derivation in
|
|
// both files must use the SAME phase-directory anchor — and no message-grep
|
|
// derivation may return (a subject carries no milestone bound; that class
|
|
// failed five times: #2989/#3191/#3503/#3995).
|
|
// #4259: the T6 scan drives itself off the live workflow files, which are
|
|
// clean — so its matching branch is exercised only by whatever those files
|
|
// happen to contain, and the hole it had was invisible for exactly that
|
|
// reason. These fixtures drive findGrepSites directly, in both directions.
|
|
test('#4259 T6 site scan sees a backslash-continued derivation, and still ignores what it should', () => {
|
|
const sameLine = [
|
|
'```bash',
|
|
'PHASE_START=$(git log --extended-regexp --grep="^(feat|fix)\\(phase-${PHASE_SCOPE_NUM}" --format="%H")',
|
|
'```',
|
|
].join('\n');
|
|
|
|
// Semantically identical to the row above. The only difference is two
|
|
// continued physical lines, and that used to be enough to vanish.
|
|
const continued = [
|
|
'```bash',
|
|
'PHASE_START=$(git log \\',
|
|
' --extended-regexp \\',
|
|
' --grep="^(feat|fix)\\(phase-${PHASE_SCOPE_NUM}" --format="%H")',
|
|
'```',
|
|
].join('\n');
|
|
|
|
assert.equal(findGrepSites(sameLine).length, 1, 'the same-line form must stay caught');
|
|
assert.equal(findGrepSites(continued).length, 1, 'the continued form must now be caught (#4259)');
|
|
|
|
// The filter T6 actually asserts on has to see the marker too. Before the
|
|
// fold this failed twice over: the scan returned nothing, AND
|
|
// PHASE_SCOPE_NUM sat on a different physical line from the one the scan
|
|
// would have captured, so even a matching scan would have filtered it out.
|
|
for (const src of [sameLine, continued]) {
|
|
assert.equal(
|
|
findGrepSites(src).filter((l) => l.includes('PHASE_SCOPE_NUM')).length,
|
|
1,
|
|
'the captured site must carry the marker T6 filters on',
|
|
);
|
|
}
|
|
|
|
// Negative control that DOES exercise the fold: a continued, non-phase
|
|
// grep site remains a site, but must not become a phase-scope finding.
|
|
const benign = [
|
|
'```bash',
|
|
'RELEASE_NOTES=$(git log \\',
|
|
' --grep="^chore" --format="%s")',
|
|
'```',
|
|
].join('\n');
|
|
assert.equal(
|
|
findGrepSites(benign).filter((l) => l.includes('PHASE_SCOPE_NUM') || /phase-\)?\(/.test(l)).length,
|
|
0,
|
|
'a non-phase-scope --grep must stay clean',
|
|
);
|
|
|
|
// A wrapped git-log assignment that merely sits near a --grep string must
|
|
// not be glued into one logical line with it. This exercises the fold and
|
|
// still contains every keyword the site regex looks for.
|
|
const unrelated = [
|
|
'```bash',
|
|
'SOME_VAR=$(git log \\',
|
|
' --format="%H")',
|
|
'echo "--grep=$SOME_VAR"',
|
|
'```',
|
|
].join('\n');
|
|
assert.deepStrictEqual(findGrepSites(unrelated), [], 'a wrapped unrelated assignment must not glue into a hit');
|
|
|
|
// A continuation must not reach across a blank line — the reason this
|
|
// folds [ \t]* rather than \s* after the newline.
|
|
const acrossBlank = [
|
|
'```bash',
|
|
'SOME_VAR=$(git log \\',
|
|
'',
|
|
'FOO=--grep=x',
|
|
'```',
|
|
].join('\n');
|
|
assert.deepStrictEqual(findGrepSites(acrossBlank), [], 'the fold must stop at a blank line');
|
|
|
|
// Two trailing backslashes represent one literal backslash followed by a
|
|
// real newline. Folding this would invent a site the shell does not have.
|
|
const evenBackslashes = [
|
|
'```bash',
|
|
'PHASE_START=$(git log \\\\',
|
|
' --grep="phase-${PHASE_SCOPE_NUM}")',
|
|
'```',
|
|
].join('\n');
|
|
assert.deepStrictEqual(
|
|
findGrepSites(evenBackslashes),
|
|
[],
|
|
'an even trailing-backslash run is not a shell continuation',
|
|
);
|
|
|
|
// Three trailing backslashes retain one literal pair while the final
|
|
// backslash continues the command. This pins the non-trivial odd boundary.
|
|
const oddBackslashes = [
|
|
'```bash',
|
|
`PHASE_START=$(git log ${'\\'.repeat(3)}`,
|
|
' --grep="phase-${PHASE_SCOPE_NUM}")',
|
|
'```',
|
|
].join('\n');
|
|
assert.deepStrictEqual(
|
|
findGrepSites(oddBackslashes),
|
|
[`PHASE_START=$(git log ${'\\'.repeat(2)} --grep="phase-\${PHASE_SCOPE_NUM}")`],
|
|
'an odd trailing-backslash run keeps its literal pairs and continues the line',
|
|
);
|
|
});
|
|
|
|
test('#4259 continuation folding preserves every odd/even backslash-run boundary', () => {
|
|
fc.assert(fc.property(
|
|
fc.integer({ min: 0, max: 31 }),
|
|
fc.array(fc.constantFrom(' ', '\t'), { maxLength: 8 }).map((chars) => chars.join('')),
|
|
(runLength, indentation) => {
|
|
const slashes = '\\'.repeat(runLength);
|
|
const source = `cmd ${slashes}\n${indentation}tail`;
|
|
const expected = runLength % 2 === 1
|
|
? `cmd ${'\\'.repeat(runLength - 1)} tail`
|
|
: source;
|
|
assert.strictEqual(foldShellContinuations(source), expected);
|
|
},
|
|
), { numRuns: 200 });
|
|
});
|
|
|
|
test('#4259 T6 site scan reports nothing on the live workflow files', () => {
|
|
// The adoption check: only shell code fences are scanned, so markdown hard
|
|
// breaks and examples in other languages cannot be folded into fake shell
|
|
// sites. Distinct from T6 itself, this asserts the live scan is quiet.
|
|
for (const src of [
|
|
readFileNormalized(WORKFLOW_PATH),
|
|
readFileNormalized(PRE_PASS_STEP_PATH).replace(/\\"/g, '"'),
|
|
]) {
|
|
assert.deepStrictEqual(findGrepSites(src), []);
|
|
}
|
|
});
|
|
|
|
test('T6 docs-parity: all diff-base derivations use the identical phase-directory anchor; no --grep site remains', () => {
|
|
const sources = [
|
|
readFileNormalized(WORKFLOW_PATH),
|
|
readFileNormalized(PRE_PASS_STEP_PATH).replace(/\\"/g, '"'),
|
|
];
|
|
for (const src of sources) {
|
|
assert.ok(
|
|
src.includes('PHASE_START=$(git log --format="%H" --diff-filter=A -- "${PHASE_DIR}"'),
|
|
'each file must derive the base from the phase directory\'s first commit (#3995)'
|
|
);
|
|
}
|
|
const grepSites = [];
|
|
for (const src of sources) {
|
|
// #4259: findGrepSites folds backslash continuations first, so a wrapped
|
|
// assignment presents as one logical line and cannot slip the scan.
|
|
grepSites.push(...findGrepSites(src));
|
|
}
|
|
assert.deepStrictEqual(
|
|
grepSites.filter((l) => l.includes('PHASE_SCOPE_NUM') || /phase-\)?\(/.test(l)),
|
|
[],
|
|
'no phase-scope message-grep derivation may remain — subjects carry no milestone bound (#3995)'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Bug 6 (#3503/#3995) — diff base keys on the phase directory, not commit subjects', () => {
|
|
const SKIP_WIN32 = { skip: process.platform === 'win32' };
|
|
|
|
const REPRO_HISTORY = [
|
|
['c1.txt', 'chore: bump to v2.06.0 on 2026-01-05'],
|
|
['c2.txt', 'feat(60-01): probe wiring', 'The EF path still uses it, fenced to Phase 06 per D-09.'],
|
|
['c3.txt', 'docs: commit message format', 'Phase headers use the form:\n\n### Phase 06 (Cluster B): Title\n\nin ROADMAP detail sections.'],
|
|
[PHASE06_PLAN_REL, 'docs(06): capture phase context'],
|
|
['c5.txt', 'feat(06-01): implement scanner core'],
|
|
['c6.txt', 'docs(phase-6): update tracking after wave 1'],
|
|
['c7.txt', 'docs: touch README'],
|
|
];
|
|
|
|
test(
|
|
'T1: prose forward-references and doc-format examples never capture the base — it resolves to the phase dir first commit at all three sites',
|
|
SKIP_WIN32,
|
|
() => {
|
|
const { repo, hashes } = buildHistory('gsd-3503-scope-', REPRO_HISTORY);
|
|
try {
|
|
const sites = [
|
|
['tier3', extractTier3Derivation()],
|
|
['spawn_reviewer', extractSpawnReviewerDerivation()],
|
|
['fallow', extractFallowDerivation()],
|
|
];
|
|
for (const [label, snippet] of sites) {
|
|
const result = runDerivation(repo, snippet, '06');
|
|
assert.equal(result.status, 0, `${label} exited ${result.status}; stderr=${result.stderr}`);
|
|
const phaseStart = parseSentinel(result.stdout, 'PHASE_START');
|
|
const diffBase = parseSentinel(result.stdout, 'DIFF_BASE');
|
|
const fallowBase = parseSentinel(result.stdout, 'FALLOW_BASE');
|
|
const dirFirst = hashes[PHASE06_PLAN_REL];
|
|
if (label !== 'fallow') {
|
|
assert.deepStrictEqual(
|
|
phaseStart,
|
|
[dirFirst],
|
|
`${label}: anchor must resolve to the phase dir's first commit; got: ${JSON.stringify(phaseStart)}`
|
|
);
|
|
}
|
|
const expected = [`${dirFirst}^`];
|
|
if (label === 'fallow') {
|
|
assert.deepStrictEqual(fallowBase, expected, `${label}: base must be the phase dir first commit's parent`);
|
|
} else {
|
|
assert.deepStrictEqual(diffBase, expected, `${label}: base must be the phase dir first commit's parent`);
|
|
}
|
|
}
|
|
} finally {
|
|
cleanup(repo);
|
|
}
|
|
}
|
|
);
|
|
|
|
test(
|
|
'T2: subject spellings are irrelevant to the directory anchor — unpadded and padded histories resolve identically',
|
|
SKIP_WIN32,
|
|
() => {
|
|
const { repo, hashes } = buildHistory('gsd-3503-unpadded-', [
|
|
['c1.txt', 'feat(60-01): probe wiring', 'Deferred to Phase 06 per D-09.'],
|
|
[PHASE06_PLAN_REL, 'docs(phase-6): capture phase context'],
|
|
['c3.txt', 'feat(6-01): implement scanner core'],
|
|
['c4.txt', 'test(6): persist human verification items as UAT'],
|
|
['c5.txt', 'docs: touch README'],
|
|
]);
|
|
try {
|
|
for (const [label, snippet] of [
|
|
['tier3', extractTier3Derivation()],
|
|
['spawn_reviewer', extractSpawnReviewerDerivation()],
|
|
['fallow', extractFallowDerivation()],
|
|
]) {
|
|
const result = runDerivation(repo, snippet, '06');
|
|
assert.equal(result.status, 0, `${label} exited ${result.status}; stderr=${result.stderr}`);
|
|
const diffBase = parseSentinel(result.stdout, 'DIFF_BASE');
|
|
const fallowBase = parseSentinel(result.stdout, 'FALLOW_BASE');
|
|
const expected = [`${hashes[PHASE06_PLAN_REL]}^`];
|
|
if (label === 'fallow') {
|
|
assert.deepStrictEqual(fallowBase, expected, `${label}: base must be the phase dir first commit's parent`);
|
|
} else {
|
|
assert.deepStrictEqual(diffBase, expected, `${label}: base must be the phase dir first commit's parent`);
|
|
}
|
|
}
|
|
} finally {
|
|
cleanup(repo);
|
|
}
|
|
}
|
|
);
|
|
|
|
test(
|
|
'T3: no committed phase dir fails closed (no silent arbitrary base)',
|
|
SKIP_WIN32,
|
|
() => {
|
|
const { repo } = buildHistory('gsd-3503-closed-', [
|
|
['c1.txt', 'chore: bump to v2.06.0'],
|
|
['c2.txt', 'feat(60-01): probe wiring', 'Deferred to Phase 06 per D-09.'],
|
|
['c3.txt', 'docs(06): capture phase context'],
|
|
['c4.txt', 'docs: touch README'],
|
|
]);
|
|
try {
|
|
for (const [label, snippet] of [
|
|
['tier3', extractTier3Derivation()],
|
|
['spawn_reviewer', extractSpawnReviewerDerivation()],
|
|
['fallow', extractFallowDerivation()],
|
|
]) {
|
|
const result = runDerivation(repo, snippet, '06');
|
|
assert.equal(result.status, 0, `${label} exited ${result.status}; stderr=${result.stderr}`);
|
|
const diffBase = parseSentinel(result.stdout, 'DIFF_BASE');
|
|
const fallowBase = parseSentinel(result.stdout, 'FALLOW_BASE');
|
|
assert.deepStrictEqual(diffBase, [], `${label}: DIFF_BASE must stay empty without a committed phase dir`);
|
|
assert.deepStrictEqual(fallowBase, [], `${label}: FALLOW_BASE must stay unset`);
|
|
}
|
|
} finally {
|
|
cleanup(repo);
|
|
}
|
|
}
|
|
);
|
|
|
|
// #3995: the milestone-blind repro. A PREVIOUS milestone's phase-02 commit
|
|
// exists in history with a perfectly anchored subject; the current
|
|
// milestone's phase 02 has its own directory. The old derivation's
|
|
// unbounded grep + tail -1 selected the archived milestone's commit and
|
|
// took a 7-file phase to a 3388-file scope; the directory anchor cannot.
|
|
test(
|
|
"T4 (#3995): a previous milestone's same-numbered phase commit never captures the base",
|
|
SKIP_WIN32,
|
|
() => {
|
|
const oldMilestonePhase = path.join('.planning', 'milestones', 'v1.1-phases', '02-old', '02-PLAN.md');
|
|
const currentPhase = path.join('.planning', 'phases', '02-ctx', '02-PLAN.md');
|
|
const { repo, hashes } = buildHistory('gsd-3995-milestone-', [
|
|
[oldMilestonePhase, 'feat(02-01): research-project command, workflow, and template'],
|
|
['mid.txt', 'chore: close milestone v1.1'],
|
|
[currentPhase, 'feat(02-01): current milestone phase 02 plan 01'],
|
|
['c4.txt', 'docs: touch README'],
|
|
]);
|
|
try {
|
|
for (const [label, snippet] of [
|
|
['tier3', extractTier3Derivation()],
|
|
['spawn_reviewer', extractSpawnReviewerDerivation()],
|
|
['fallow', extractFallowDerivation()],
|
|
]) {
|
|
const result = runDerivation(repo, snippet, '02');
|
|
assert.equal(result.status, 0, `${label} exited ${result.status}; stderr=${result.stderr}`);
|
|
const diffBase = parseSentinel(result.stdout, 'DIFF_BASE');
|
|
const fallowBase = parseSentinel(result.stdout, 'FALLOW_BASE');
|
|
const expected = [`${hashes[currentPhase]}^`];
|
|
if (label === 'fallow') {
|
|
assert.deepStrictEqual(fallowBase, expected,
|
|
`${label}: base must be the CURRENT phase dir's first commit, never the archived milestone's (#3995)`);
|
|
} else {
|
|
assert.deepStrictEqual(diffBase, expected,
|
|
`${label}: base must be the CURRENT phase dir's first commit, never the archived milestone's (#3995)`);
|
|
}
|
|
}
|
|
} finally {
|
|
cleanup(repo);
|
|
}
|
|
}
|
|
);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #4209 Phase 1 Plan 3 (Task 2) — external reviewer evidence consolidation.
|
|
// gsd-code-reviewer.md must treat <external_reviewer_evidence> as untrusted
|
|
// input: independently re-verify every claim against the actual current
|
|
// source before it can appear in REVIEW.md, fold a verified claim into the
|
|
// SAME Narrative Findings section (no second schema), and never let text
|
|
// embedded inside an evidence file act as an instruction. code-review.md's
|
|
// EXTERNAL_EVIDENCE_BLOCK must keep restating the four fixed prohibitions.
|
|
// ---------------------------------------------------------------------------
|
|
describe('CONS-01..03 — external reviewer evidence consolidation (#4209)', () => {
|
|
function loadStep(src, stepName) {
|
|
const stepStart = src.indexOf(`<step name="${stepName}">`);
|
|
assert.ok(stepStart !== -1, `agent must have a ${stepName} step`);
|
|
const stepEnd = src.indexOf('</step>', stepStart);
|
|
return src.slice(stepStart, stepEnd);
|
|
}
|
|
|
|
test('load_context parses <external_reviewer_evidence> and marks it untrusted', () => {
|
|
const src = fs.readFileSync(REVIEWER_PATH, 'utf8');
|
|
const stepSection = loadStep(src, 'load_context');
|
|
assert.ok(stepSection.includes('external_reviewer_evidence'),
|
|
'load_context must parse the external_reviewer_evidence block');
|
|
assert.ok(/untrusted/i.test(stepSection),
|
|
'load_context must explicitly mark external reviewer evidence as untrusted data');
|
|
});
|
|
|
|
test('load_context requires independent re-verification against actual source before accepting a claim', () => {
|
|
const src = fs.readFileSync(REVIEWER_PATH, 'utf8');
|
|
const stepSection = loadStep(src, 'load_context');
|
|
assert.ok(/re-open|reopen/i.test(stepSection) && /re-read/i.test(stepSection),
|
|
'load_context must require re-opening and re-reading the actual cited source before accepting an external claim');
|
|
assert.ok(/REJECTED|reject/i.test(stepSection),
|
|
'load_context must state that an unverifiable external claim is rejected, not included');
|
|
});
|
|
|
|
test('load_context resists prompt injection embedded inside evidence text', () => {
|
|
const src = fs.readFileSync(REVIEWER_PATH, 'utf8');
|
|
const stepSection = loadStep(src, 'load_context');
|
|
assert.ok(/prompt-injection|prompt injection/i.test(stepSection),
|
|
'load_context must name prompt injection as a threat from evidence content');
|
|
assert.ok(/never a command|not a command/i.test(stepSection),
|
|
'load_context must state evidence text is data, never a command');
|
|
});
|
|
|
|
test('a verified external claim folds into Narrative Findings with no separate schema (CONS-03)', () => {
|
|
const src = fs.readFileSync(REVIEWER_PATH, 'utf8');
|
|
const stepSection = loadStep(src, 'load_context');
|
|
assert.ok(/Narrative Findings/.test(stepSection),
|
|
'load_context must route a verified external claim into the existing Narrative Findings section');
|
|
const writeReviewSection = loadStep(src, 'write_review');
|
|
assert.ok(/external:/.test(writeReviewSection),
|
|
'write_review must document the (external: {slug}) provenance tag for a verified external finding');
|
|
assert.ok(!/## External/i.test(writeReviewSection),
|
|
'write_review must not introduce a separate External Findings section — one REVIEW.md schema only');
|
|
});
|
|
|
|
test('critical_rules restates the untrusted-evidence contract', () => {
|
|
const src = fs.readFileSync(REVIEWER_PATH, 'utf8');
|
|
const rulesStart = src.indexOf('<critical_rules>');
|
|
const rulesEnd = src.indexOf('</critical_rules>');
|
|
assert.ok(rulesStart !== -1 && rulesEnd !== -1, 'gsd-code-reviewer.md must have a critical_rules section');
|
|
const rulesSection = src.slice(rulesStart, rulesEnd);
|
|
assert.ok(/external_reviewer_evidence|external reviewer/i.test(rulesSection),
|
|
'critical_rules must restate the external-evidence-is-untrusted contract');
|
|
});
|
|
|
|
test('code-review.md restates the four fixed source-review prohibitions when handing off evidence', () => {
|
|
const workflowSrc = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
|
const blockStart = workflowSrc.indexOf('EXTERNAL_EVIDENCE_BLOCK=$(printf');
|
|
assert.ok(blockStart !== -1, 'code-review.md must build an EXTERNAL_EVIDENCE_BLOCK');
|
|
const blockEnd = workflowSrc.indexOf('\n', workflowSrc.indexOf(')', blockStart));
|
|
const blockText = workflowSrc.slice(blockStart, blockEnd);
|
|
for (const prohibition of ['no source mutation', 'no test execution', 'no background processes', 'no active polling']) {
|
|
assert.ok(blockText.includes(prohibition),
|
|
`EXTERNAL_EVIDENCE_BLOCK must restate "${prohibition}" (SAFE-03..06)`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// #3829 — execute-phase `code_review_gate`: surface the severity counts it
|
|
// already parses, and record a per-finding disposition.
|
|
//
|
|
// Before this change the gate extracted `status:` from REVIEW.md's frontmatter,
|
|
// discarded the `critical`/`warning`/`info`/`total` values sitting in the same
|
|
// `sed` range, and printed a message that was byte-identical for a review with
|
|
// one `info` finding and a review with a Critical. Nothing recorded what
|
|
// happened to any finding, so a phase reached `phase.complete` with Criticals
|
|
// standing and no trace they had been seen.
|
|
//
|
|
// Tested the way this file tests every other embedded parser: a pure-JS mirror
|
|
// of the shipped `node -e` script (validated against the shipped block during
|
|
// implementation), plus docs-parity assertions on the workflow .md text, which
|
|
// is itself the deployed contract.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Mirror of the gate's frontmatter scalar reads. The shipped block strips CR, then takes ONLY
|
|
// the first `---` ... `---` block — it does NOT use a sed range, because a sed range re-opens on
|
|
// a body `---` and runs to EOF, which leaks body lines into an OPTIONAL key's read. This mirror
|
|
// must model that extraction, not a whole-document scan: a mirror that scans the document passes
|
|
// on a fixture the shipped code fails, which is the drift this comment exists to prevent.
|
|
function parseGateCounts(reviewText) {
|
|
const lines = String(reviewText).replace(/\r/g, '').split('\n');
|
|
// Only a CLOSED frontmatter block counts: an unterminated one must yield nothing rather than
|
|
// hand the whole review body to the reads below.
|
|
let fm = [];
|
|
if (lines[0] === '---') {
|
|
const buf = [];
|
|
let closed = false;
|
|
for (let i = 1; i < lines.length; i++) {
|
|
if (lines[i] === '---') { closed = true; break; }
|
|
buf.push(lines[i]);
|
|
}
|
|
if (closed) fm = buf;
|
|
}
|
|
// The shipped reads are `grep -m1 <key> | cut -d: -f2- | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//'`,
|
|
// and BOTH stages matter. This mirror previously modelled `cut -d: -f2 | tr -d ' '`, which REPAIRED a
|
|
// malformed scalar into a number twice over -- `tr -d` deleting INTERNAL spaces (`1 0` -> `10`) and
|
|
// `-f2` keeping only the second colon-field (`1: junk` -> `1`). Both were retired from the shipped
|
|
// reads when an adversarial pass showed a repaired number deciding whether a shortfall was reported.
|
|
// `-f2-` keeps the whole scalar and only the ends are trimmed. The class below is POSIX
|
|
// [[:space:]] AS THE C LOCALE DEFINES IT -- space, tab, newline, VT, FF, CR -- spelled out
|
|
// literally rather than as JS `\s`, which also matches the unicode spaces C does not. That is an
|
|
// equivalence, not an approximation, ONLY because the shipped reads pin LC_ALL=C: glibc's C.UTF-8
|
|
// classifies U+2003 as [[:space:]] and as [[:blank:]], so an unpinned shipped read trimmed a
|
|
// character this mirror keeps, and the divergence was invisible here because both sides still
|
|
// landed on the same arm for every fixture that existed. The anchors below are spelled with the
|
|
// same literal class, for the same reason -- `\s` there would match a U+2003 indent the pinned
|
|
// shipped grep does not.
|
|
const TRIM = /^[ \t\n\v\f\r]+|[ \t\n\v\f\r]+$/g;
|
|
const first = (re) => {
|
|
for (const line of fm) {
|
|
const m = line.match(re);
|
|
if (m) return line.slice(line.indexOf(':') + 1).replace(TRIM, '');
|
|
}
|
|
return '';
|
|
};
|
|
// The four counts belong to the `findings:` MAPPING, so the mirror scopes to it exactly as the
|
|
// shipped awk does: select the findings: block, stop at the next column-0 key. Without this a
|
|
// top-level key later named `total:` / `info:` / `critical:` is picked up ahead of the nested
|
|
// one. `status:` stays anchored at column 0 because it IS top-level.
|
|
const findingsBlock = [];
|
|
{
|
|
let inBlock = false;
|
|
for (const line of fm) {
|
|
// The BLOCK BOUNDARY takes the literal class too, and it is not decoration: the shipped
|
|
// selector is an `awk` whose `/^findings:[[:space:]]*$/` and `/^[^[:space:]]/` resolve
|
|
// through the ambient locale exactly as grep's and sed's did. Pass 6 found the awk had been
|
|
// left unpinned while grep and sed were fixed -- `findings:<U+2003>` opened the block under
|
|
// C.UTF-8 and did not under C, so the SAME review reported a breakdown on one machine and
|
|
// the countless message on another. `\s`/`\S` here would model neither pinned side.
|
|
if (/^findings:[ \t\n\v\f\r]*$/.test(line)) { inBlock = true; continue; }
|
|
if (inBlock && /^[^ \t\n\v\f\r]/.test(line)) break;
|
|
if (inBlock) findingsBlock.push(line);
|
|
}
|
|
}
|
|
// SAME pipeline as `first` above, and the duplication is the point: BOTH helpers model the shipped
|
|
// reads, and the counts go through THIS one. A previous edit updated `first` and the comment above
|
|
// it while leaving this body on the retired `split(':')[1].replace(/ /g,'')` — the shipped block had
|
|
// moved to `-f2-` + end-trim and the mirror had not, which the parity fixtures could not see because
|
|
// both parsers landed on the countless arm for every fixture that then existed. The
|
|
// `a self-consistent repaired breakdown` fixture below is what separates them.
|
|
const firstIn = (lines, re) => {
|
|
for (const line of lines) {
|
|
const m = line.match(re);
|
|
if (m) return line.slice(line.indexOf(':') + 1).replace(TRIM, '');
|
|
}
|
|
return '';
|
|
};
|
|
return {
|
|
status: first(/^status:(.*)$/),
|
|
critical: firstIn(findingsBlock, /^[ \t\n\v\f\r]*(?:critical|blocker):(.*)$/),
|
|
warning: firstIn(findingsBlock, /^[ \t\n\v\f\r]*warning:(.*)$/),
|
|
info: firstIn(findingsBlock, /^[ \t\n\v\f\r]*info:(.*)$/),
|
|
total: firstIn(findingsBlock, /^[ \t\n\v\f\r]*total:(.*)$/),
|
|
};
|
|
}
|
|
|
|
// ── The SHIPPED disposition builder, executed — not modelled ──────────────────
|
|
//
|
|
// Rounds 1 and 2 of the pre-filing review both refuted "the mirrors are faithful": a
|
|
// hand-written model of a shell-embedded script drifts, and when it drifts the behavioural
|
|
// tests pass while the shipped block is broken. Mutation-testing confirmed it — deleting the
|
|
// carried-row logic from the shipped file turned nothing red.
|
|
//
|
|
// So the disposition tests below run the ACTUAL script. It is pure Node (every input arrives
|
|
// through process.env), so extracting it and executing it needs no shell and works on Windows.
|
|
// The only transformation is undoing the two shell escapes the surrounding double-quoted
|
|
// `node -e "…"` string requires: \` -> ` and \$ -> $.
|
|
function shippedDispositionScript() {
|
|
const src = fs.readFileSync(DISPOSITION_STEP_PATH, 'utf8').replace(/\r\n/g, '\n');
|
|
const open = src.indexOf('node -e "');
|
|
assert.ok(open !== -1, 'the disposition step must still embed a node -e script');
|
|
const body = src.slice(open + 'node -e "'.length);
|
|
const end = body.indexOf('\n" || echo ');
|
|
assert.ok(end !== -1, 'the node -e script must still be closed by its || echo fallback');
|
|
// Undo exactly what the surrounding double-quoted shell string does, in ONE left-to-right
|
|
// pass: inside "..." a backslash is special only before $ ` " or \\, and everything else is
|
|
// literal. Doing these as separate passes (or missing \\\\ -> \\) silently hands the test a
|
|
// DIFFERENT regex from the one that ships — which is how an escaped-pipe case passed here
|
|
// while failing in the shell.
|
|
return body.slice(0, end).replace(/\\([\\$`"])/g, '$1');
|
|
}
|
|
|
|
// Run the shipped script against a temp phase dir and return the ledger it wrote (or null).
|
|
function runShippedDisposition({ reviewText, priorText, fixText, iterFixText, padded = '01', reviewTotal }) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3829-'));
|
|
try {
|
|
const reviewPath = path.join(dir, padded + '-REVIEW.md');
|
|
const dispPath = path.join(dir, padded + '-REVIEW-DISPOSITION.md');
|
|
const fixPath = path.join(dir, padded + '-REVIEW-FIX.md');
|
|
fs.writeFileSync(reviewPath, reviewText);
|
|
if (priorText !== undefined) fs.writeFileSync(dispPath, priorText);
|
|
if (fixText !== undefined) fs.writeFileSync(fixPath, fixText);
|
|
// The --auto loop's per-iteration backups (<NN>-REVIEW-FIX.iterN.md). Keyed by iteration
|
|
// number so a test can drive the ordering the script relies on (newest wins).
|
|
for (const [n, text] of Object.entries(iterFixText || {})) {
|
|
fs.writeFileSync(path.join(dir, padded + '-REVIEW-FIX.iter' + n + '.md'), text);
|
|
}
|
|
// Bounded by construction via the process seam — an unbounded spawn is an indefinite hang.
|
|
const res = runNode(['-e', shippedDispositionScript()], {
|
|
timeoutMs: PROBE_TIMEOUT_MS,
|
|
env: {
|
|
...process.env,
|
|
REVIEW_FILE: reviewPath,
|
|
DISPOSITION_FILE: dispPath,
|
|
FIX_REPORT_FILE: fixPath,
|
|
PADDED: padded,
|
|
// The frontmatter total block 2 derives and hands to the script, so the two parsers can
|
|
// be reconciled. Passed through here so a test can drive the shortfall path.
|
|
REVIEW_TOTAL: reviewTotal === undefined ? '' : String(reviewTotal),
|
|
},
|
|
});
|
|
assert.strictEqual(res.outcome, OUTCOME.EXITED, 'the shipped script must run to completion');
|
|
assert.strictEqual(res.exitCode, 0, 'the shipped script must exit 0: ' + res.stderr);
|
|
const ledger = fs.existsSync(dispPath) ? fs.readFileSync(dispPath, 'utf8') : null;
|
|
return { ledger, stdout: res.stdout, wroteNothing: /disposition unchanged/.test(res.stdout) };
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
}
|
|
|
|
// Parse the rows out of a rendered ledger, so assertions read against real output.
|
|
function ledgerRows(ledger) {
|
|
if (ledger === null) return null;
|
|
const rows = [];
|
|
for (const line of ledger.split('\n')) {
|
|
const m = line.match(/^\|\s*((?:CR|BL|WR|IN)-\d+)\s*\|\s*([a-z]+)\s*\|\s*([a-z]+)\s*\|\s*(.*?)\s*\|$/);
|
|
if (m) rows.push({ id: m[1], severity: m[2], disposition: m[3], source: m[4] });
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
// buildDisposition is NOT a mirror any more — it drives the SHIPPED script.
|
|
//
|
|
// Three pre-filing review rounds each refuted "the mirror is faithful", and mutation testing
|
|
// confirmed the cost: a hand-written model of a shell-embedded script drifts, and when it drifts
|
|
// the tests pass while the shipped block is broken. The model is gone; this adapter runs the real
|
|
// thing and returns the same shape the assertions below already expect.
|
|
function buildDisposition({ reviewText, priorText, fixText, padded = '01', reviewTotal }) {
|
|
const rows = ledgerRows(runShippedDisposition({ reviewText, priorText, fixText, padded, reviewTotal }).ledger);
|
|
if (rows === null) return null;
|
|
return {
|
|
rows: rows.map((r) => ({
|
|
...r,
|
|
carried: / \(not in the current review\)$/.test(r.source) || undefined,
|
|
source: r.source.replace(/ \(not in the current review\)$/, ''),
|
|
})),
|
|
open: rows.filter((r) => r.disposition === 'open').length,
|
|
total: rows.length,
|
|
};
|
|
}
|
|
|
|
const REVIEW_WITH_FINDINGS = [
|
|
'---',
|
|
'phase: 01',
|
|
'findings:',
|
|
' critical: 1',
|
|
' warning: 2',
|
|
' info: 1',
|
|
' total: 4',
|
|
'status: issues_found',
|
|
'---',
|
|
'',
|
|
'## Critical Issues',
|
|
'',
|
|
'### CR-01: SQL injection in auth',
|
|
'',
|
|
'## Warnings',
|
|
'',
|
|
'### WR-01: missing null check',
|
|
'',
|
|
'### WR-02: unused import',
|
|
'',
|
|
'## Info',
|
|
'',
|
|
'### IN-01: stale TODO',
|
|
].join('\n');
|
|
|
|
describe('#3829 — code_review_gate severity surfacing', () => {
|
|
test('the gate reads the counts that sit beside the status it already parsed', () => {
|
|
const parsed = parseGateCounts(REVIEW_WITH_FINDINGS);
|
|
assert.deepStrictEqual(parsed, {
|
|
status: 'issues_found', critical: '1', warning: '2', info: '1', total: '4',
|
|
});
|
|
});
|
|
|
|
test('blocker: is accepted as the Critical tier-equivalent of critical:', () => {
|
|
const parsed = parseGateCounts(REVIEW_WITH_FINDINGS.replace(' critical: 1', ' blocker: 1'));
|
|
assert.strictEqual(parsed.critical, '1');
|
|
});
|
|
|
|
test('a status:/total: line in the review BODY never displaces the frontmatter value', () => {
|
|
// The `sed -n '/^---$/,/^---$/p'` range re-opens on a body `---` and runs to
|
|
// EOF, so first-match (`grep -m1`) is what makes this correct — not the range.
|
|
const poisoned = REVIEW_WITH_FINDINGS + '\n\n---\n\nstatus: clean\ntotal: 999\n';
|
|
const parsed = parseGateCounts(poisoned);
|
|
assert.strictEqual(parsed.status, 'issues_found');
|
|
assert.strictEqual(parsed.total, '4');
|
|
});
|
|
|
|
test('a REVIEW.md with no findings: block yields no counts, so the gate can fall back', () => {
|
|
const legacy = ['---', 'phase: 02', 'status: issues_found', '---', '', '# Phase 02'].join('\n');
|
|
const parsed = parseGateCounts(legacy);
|
|
assert.strictEqual(parsed.status, 'issues_found');
|
|
assert.strictEqual(parsed.total, '');
|
|
assert.strictEqual(parsed.critical, '');
|
|
});
|
|
|
|
|
|
test('docs-parity: the gate states the counts rather than the countless message alone', () => {
|
|
const src = fs.readFileSync(DISPOSITION_STEP_PATH, 'utf8');
|
|
assert.ok(
|
|
src.includes('Code review: ${REVIEW_TOTAL} findings — ${REVIEW_CRITICAL} critical, ${REVIEW_WARNING} warning, ${REVIEW_INFO} info.'),
|
|
'code_review_gate must display the per-severity breakdown it parsed'
|
|
);
|
|
});
|
|
|
|
test('docs-parity: every frontmatter scalar read carries a first-match guard', () => {
|
|
const src = fs.readFileSync(DISPOSITION_STEP_PATH, 'utf8');
|
|
assert.ok(
|
|
src.includes('grep -m1 "^status:"'),
|
|
'the status: read must keep its single-match guard (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP)'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('#3829 — code_review_gate per-finding disposition record', () => {
|
|
test('every finding is recorded, defaulting to open', () => {
|
|
const d = buildDisposition({ reviewText: REVIEW_WITH_FINDINGS, padded: '01' });
|
|
assert.strictEqual(d.total, 4);
|
|
assert.strictEqual(d.open, 4);
|
|
assert.deepStrictEqual(d.rows.map((r) => r.id), ['CR-01', 'WR-01', 'WR-02', 'IN-01']);
|
|
assert.deepStrictEqual(d.rows.map((r) => r.severity), ['critical', 'warning', 'warning', 'info']);
|
|
});
|
|
|
|
test('BL- findings are recorded at the Critical tier alongside CR-', () => {
|
|
const d = buildDisposition({
|
|
reviewText: REVIEW_WITH_FINDINGS.replace('### CR-01:', '### BL-01:'),
|
|
padded: '01',
|
|
});
|
|
assert.strictEqual(d.rows[0].id, 'BL-01');
|
|
assert.strictEqual(d.rows[0].severity, 'critical');
|
|
});
|
|
|
|
test('--fix outcomes are reconciled from REVIEW-FIX.md with provenance', () => {
|
|
const fixText = [
|
|
'# Phase 01: Code Review Fix Report',
|
|
'',
|
|
'## Fixed Issues',
|
|
'',
|
|
'### WR-01: missing null check',
|
|
'',
|
|
'## Skipped Issues',
|
|
'',
|
|
'### WR-02: unused import',
|
|
].join('\n');
|
|
const d = buildDisposition({ reviewText: REVIEW_WITH_FINDINGS, fixText, padded: '01' });
|
|
const byId = Object.fromEntries(d.rows.map((r) => [r.id, r]));
|
|
assert.strictEqual(byId['WR-01'].disposition, 'fixed');
|
|
assert.strictEqual(byId['WR-01'].source, '01-REVIEW-FIX.md');
|
|
assert.strictEqual(byId['WR-02'].disposition, 'skipped');
|
|
assert.strictEqual(byId['CR-01'].disposition, 'open');
|
|
assert.strictEqual(d.open, 2);
|
|
});
|
|
|
|
test('a finding heading outside the Fixed/Skipped sections is not a disposition', () => {
|
|
const fixText = [
|
|
'## Fixed Issues',
|
|
'',
|
|
'### WR-01: missing null check',
|
|
'',
|
|
'## Verification',
|
|
'',
|
|
'### IN-01: mentioned while describing how the fix was verified',
|
|
].join('\n');
|
|
const d = buildDisposition({ reviewText: REVIEW_WITH_FINDINGS, fixText, padded: '01' });
|
|
const byId = Object.fromEntries(d.rows.map((r) => [r.id, r]));
|
|
assert.strictEqual(byId['WR-01'].disposition, 'fixed');
|
|
assert.strictEqual(byId['IN-01'].disposition, 'open');
|
|
});
|
|
|
|
test('a recorded decision survives a re-run — open never overwrites deferred', () => {
|
|
const priorText = [
|
|
'| Finding | Severity | Disposition | Source |',
|
|
'|---------|----------|-------------|--------|',
|
|
'| CR-01 | critical | deferred | ships next phase |',
|
|
'| WR-01 | warning | open | - |',
|
|
].join('\n');
|
|
const d = buildDisposition({ reviewText: REVIEW_WITH_FINDINGS, priorText, padded: '01' });
|
|
const byId = Object.fromEntries(d.rows.map((r) => [r.id, r]));
|
|
assert.strictEqual(byId['CR-01'].disposition, 'deferred');
|
|
// The source cell carries the human's stated reason and is preserved, not replaced.
|
|
assert.strictEqual(byId['CR-01'].source, 'ships next phase');
|
|
assert.strictEqual(d.open, 3);
|
|
});
|
|
|
|
test('an applied --fix outcome wins over an earlier deferral', () => {
|
|
const priorText = '| CR-01 | critical | deferred | later |';
|
|
const fixText = ['## Fixed Issues', '', '### CR-01: SQL injection in auth'].join('\n');
|
|
const d = buildDisposition({ reviewText: REVIEW_WITH_FINDINGS, priorText, fixText, padded: '01' });
|
|
assert.strictEqual(d.rows[0].disposition, 'fixed');
|
|
});
|
|
|
|
test('a review with no finding headings produces no record at all', () => {
|
|
const legacy = ['---', 'phase: 02', 'status: issues_found', '---', '', 'prose only'].join('\n');
|
|
assert.strictEqual(buildDisposition({ reviewText: legacy, padded: '02' }), null);
|
|
});
|
|
|
|
test('docs-parity: the gate writes a REVIEW-DISPOSITION sibling, not into REVIEW.md', () => {
|
|
// Eighth src.includes() converted. It pinned the literal `${PHASE_DIR}` interpolation, so it
|
|
// went red when path construction moved to a VALIDATED local -- while the property it names,
|
|
// "a REVIEW-DISPOSITION sibling", was untouched. Assert the property: the ledger lands beside
|
|
// the review, under the derived name, and REVIEW.md itself is not written.
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3861-sib-'));
|
|
try {
|
|
const reviewPath = path.join(dir, '02-REVIEW.md');
|
|
const reviewText = ['---', 'status: issues_found', '---', '', '### CR-01: a finding'].join('\n');
|
|
fs.writeFileSync(reviewPath, reviewText);
|
|
const res = runNode(['-e', shippedDispositionScript()], {
|
|
timeoutMs: PROBE_TIMEOUT_MS,
|
|
env: {
|
|
...process.env,
|
|
REVIEW_FILE: reviewPath,
|
|
DISPOSITION_FILE: path.join(dir, '02-REVIEW-DISPOSITION.md'),
|
|
FIX_REPORT_FILE: path.join(dir, '02-REVIEW-FIX.md'),
|
|
PADDED: '02',
|
|
},
|
|
});
|
|
assert.strictEqual(res.exitCode, 0, res.stderr);
|
|
assert.ok(fs.existsSync(path.join(dir, '02-REVIEW-DISPOSITION.md')),
|
|
'code_review_gate must write the disposition record to a REVIEW-DISPOSITION sibling');
|
|
assert.strictEqual(fs.readFileSync(reviewPath, 'utf8'), reviewText,
|
|
'and must not write into REVIEW.md — gsd-code-reviewer is its single writer');
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
// Sixth src.includes() converted. It pinned the exact SOURCE LINE of the enumeration loop, so
|
|
// it went red when M3 reformatted that loop to track the enclosing section -- while the
|
|
// property it names, "enumerate every finding ID", was strictly widened rather than broken.
|
|
// A pin on a code shape reports every refactor as a regression and no regression as one.
|
|
const review = ['---', 'phase: 01', 'status: issues_found', '---', '',
|
|
'### CR-01: a', '### WR-01: b', '### IN-01: c',
|
|
'### CR-01: a duplicate id, which must not produce a second row'].join('\n');
|
|
const rows = ledgerRows(runShippedDisposition({ reviewText: review }).ledger);
|
|
assert.deepStrictEqual(
|
|
rows.map((r) => r.id), ['CR-01', 'WR-01', 'IN-01'],
|
|
'code_review_gate must enumerate every finding ID from REVIEW.md, in order, once each'
|
|
);
|
|
});
|
|
|
|
test('docs-parity: code_review_gate actually reaches the extracted step', () => {
|
|
// The step is only reachable because the parent says to read and execute it. Without this,
|
|
// every other docs-parity assertion here could pass against a file nothing loads.
|
|
const parent = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf8');
|
|
assert.ok(
|
|
/Read and execute\s+`gsd-core\/workflows\/execute-phase\/steps\/code-review-disposition\.md`/.test(parent),
|
|
'code_review_gate must read and execute the disposition step'
|
|
);
|
|
assert.ok(
|
|
parent.indexOf('code-review-disposition.md') < parent.indexOf('**TDD review escalation'),
|
|
'and must do so before the TDD escalation can stop the phase'
|
|
);
|
|
});
|
|
|
|
test('docs-parity: the disposition write is non-blocking', () => {
|
|
const src = fs.readFileSync(DISPOSITION_STEP_PATH, 'utf8');
|
|
assert.ok(
|
|
src.includes('Code review disposition record skipped (non-blocking).'),
|
|
'the disposition write must never block execution flow'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #3829 review round — the seven defects a cross-AI adversarial pass on the fix
|
|
// diff refuted before filing. Each is pinned here so the round's work survives.
|
|
// ---------------------------------------------------------------------------
|
|
describe('#3829 review round — frontmatter scoping, section anchoring, ledger durability', () => {
|
|
test('an OPTIONAL count absent from frontmatter is not supplied by a leaked body value', () => {
|
|
// A sed range re-opens on a body `---` and runs to EOF. First-match protects a key the
|
|
// frontmatter DOES carry; it cannot protect one it does not. Extraction must stop at the
|
|
// closing delimiter, or a body `total:` becomes the reported finding count.
|
|
const leaky = [
|
|
'---', 'phase: 01', 'status: issues_found', '---',
|
|
'', '# Report', '', '---', '', 'total: 7', 'critical: 9',
|
|
].join('\n');
|
|
const parsed = parseGateCounts(leaky);
|
|
assert.strictEqual(parsed.status, 'issues_found');
|
|
assert.strictEqual(parsed.total, '', 'a body total: must not become the finding count');
|
|
assert.strictEqual(parsed.critical, '', 'a body critical: must not become the critical count');
|
|
});
|
|
|
|
test('a CRLF-authored review parses to bare values, with no carriage return riding along', () => {
|
|
const crlf = [
|
|
'---', 'phase: 01', 'findings:', ' critical: 1', ' warning: 0', ' info: 2',
|
|
' total: 3', 'status: issues_found', '---', '', '### CR-01: a',
|
|
].join('\r\n');
|
|
const parsed = parseGateCounts(crlf);
|
|
assert.deepStrictEqual(parsed, {
|
|
status: 'issues_found', critical: '1', warning: '0', info: '2', total: '3',
|
|
});
|
|
for (const v of Object.values(parsed)) assert.ok(!/\r/.test(v), 'no value may carry a CR');
|
|
});
|
|
|
|
test('docs-parity: the gate stops at the closing delimiter and strips CR before parsing', () => {
|
|
const src = fs.readFileSync(DISPOSITION_STEP_PATH, 'utf8');
|
|
assert.ok(
|
|
src.includes(`awk 'NR==1{if($0!="---") exit; next} /^---$/{closed=1; exit}`),
|
|
'the gate must extract only the FIRST frontmatter block, not a re-opening sed range'
|
|
);
|
|
assert.ok(
|
|
src.includes(`tr -d '\\r' < "$REVIEW_FILE"`),
|
|
'the gate must strip CR so a CRLF review cannot inject one into the message'
|
|
);
|
|
});
|
|
|
|
test('a section heading that merely STARTS with "Fixed Issues" does not classify findings', () => {
|
|
const fixText = [
|
|
'# Fix report', '', '## Fixed Issues Verification', '', '### IN-01: named while verifying',
|
|
].join('\n');
|
|
const d = buildDisposition({ reviewText: REVIEW_WITH_FINDINGS, fixText, padded: '01' });
|
|
const byId = Object.fromEntries(d.rows.map((r) => [r.id, r]));
|
|
assert.strictEqual(byId['IN-01'].disposition, 'open');
|
|
});
|
|
|
|
test('a deferral reason written into the Source cell survives a re-run verbatim', () => {
|
|
const priorText = '| CR-01 | critical | deferred | ships next phase, see ADR-99 |';
|
|
const d = buildDisposition({ reviewText: REVIEW_WITH_FINDINGS, priorText, padded: '01' });
|
|
const cr = d.rows.find((r) => r.id === 'CR-01');
|
|
assert.strictEqual(cr.disposition, 'deferred');
|
|
assert.strictEqual(cr.source, 'ships next phase, see ADR-99');
|
|
});
|
|
|
|
test('a decided finding the current review no longer reports is carried, not dropped', () => {
|
|
// --auto re-reviews and rewrites REVIEW.md; a fixed or deferred finding can vanish from it.
|
|
// Dropping the row would erase the record that it was seen — the exact failure #3829 is about.
|
|
const priorText = [
|
|
'| CR-01 | critical | deferred | ships next phase |',
|
|
'| IN-01 | info | open | - |',
|
|
].join('\n');
|
|
const shrunk = ['---', 'status: issues_found', '---', '', '### WR-01: still here'].join('\n');
|
|
const d = buildDisposition({ reviewText: shrunk, priorText, padded: '01' });
|
|
const byId = Object.fromEntries(d.rows.map((r) => [r.id, r]));
|
|
assert.ok(byId['CR-01'], 'a deferred finding absent from the review must still be recorded');
|
|
assert.strictEqual(byId['CR-01'].disposition, 'deferred');
|
|
assert.strictEqual(byId['CR-01'].carried, true);
|
|
assert.strictEqual(byId['CR-01'].source, 'ships next phase');
|
|
// M1: the untriaged row is carried TOO, and marked. Dropping it erased the record that the
|
|
// finding was ever seen -- which is #3829's complaint verbatim, reproduced by the artifact
|
|
// built to prevent it. 'Nothing was decided about it' is precisely the state that must leave
|
|
// a trace. The marker keeps it honest: the row does not claim the finding is live.
|
|
assert.ok(byId['IN-01'], 'an untriaged row for a vanished finding is carried, not deleted');
|
|
assert.strictEqual(byId['IN-01'].disposition, 'open');
|
|
assert.strictEqual(byId['IN-01'].carried, true);
|
|
});
|
|
|
|
test('the carried marker is rendered, never stored — it cannot accumulate across runs', () => {
|
|
// The marker lives in the Source cell, which is re-parsed on the next run. Storing it would
|
|
// re-append it every time: the cell grows without bound AND the file changes on every run,
|
|
// which silently defeats the unchanged-run check and restores the empty-docs-commit churn.
|
|
const shrunk = ['---', 'status: issues_found', '---', '', '### WR-01: b'].join('\n');
|
|
let priorText = '| CR-01 | critical | deferred | ships next phase |';
|
|
let cr;
|
|
for (let i = 0; i < 3; i++) {
|
|
const d = buildDisposition({ reviewText: shrunk, priorText, padded: '01' });
|
|
cr = d.rows.find((r) => r.id === 'CR-01');
|
|
// Re-render the row the way the shipped builder does, and feed it back in.
|
|
priorText = '| ' + cr.id + ' | ' + cr.severity + ' | ' + cr.disposition + ' | ' +
|
|
cr.source + (cr.carried ? ' (not in the current review)' : '') + ' |';
|
|
}
|
|
assert.strictEqual(cr.source, 'ships next phase', 'the stored source must stay canonical');
|
|
assert.strictEqual(
|
|
(priorText.match(/\(not in the current review\)/g) || []).length, 1,
|
|
'the marker must appear exactly once no matter how many times the gate re-runs'
|
|
);
|
|
});
|
|
|
|
test('docs-parity: the ledger is rewritten only on a real change, timestamp excluded', () => {
|
|
const src = fs.readFileSync(DISPOSITION_STEP_PATH, 'utf8');
|
|
assert.ok(
|
|
src.includes("const stripTs = (t) => t.replace(/^recorded:.*\\$/m, 'recorded:');"),
|
|
'the gate must compare ignoring the timestamp so an unchanged run writes nothing'
|
|
);
|
|
assert.ok(
|
|
src.includes('Code review disposition unchanged: '),
|
|
'an unchanged run must say so rather than producing an empty docs commit'
|
|
);
|
|
});
|
|
|
|
test('docs-parity: section headings are anchored whole and the prior source cell is captured', () => {
|
|
const src = fs.readFileSync(DISPOSITION_STEP_PATH, 'utf8');
|
|
assert.ok(
|
|
src.includes('/^##\\s+Fixed Issues\\s*\\$/') && src.includes('/^##\\s+Skipped Issues\\s*\\$/'),
|
|
'fix-report section headings must be matched whole, never by prefix'
|
|
);
|
|
// Eighth src.includes() converted -- it pinned the exact source-cell CAPTURE, and that
|
|
// capture was the round-3 defect (a bare | failed the whole match). The property it named
|
|
// is asserted behaviourally below: 'an escaped pipe in a deferral reason survives whole'
|
|
// and 'a bare pipe in a deferral reason is kept as prose'.
|
|
// Seventh src.includes() converted -- it pinned the exact comparison EXPRESSION, so it went
|
|
// red when the comparison gained whitespace normalization while the property it names was
|
|
// unchanged. Assert the property instead: a fix report naming a different finding under a
|
|
// reused id does not decide the row.
|
|
const review = ['---', 'status: issues_found', '---', '', '### CR-01: a new finding'].join('\n');
|
|
const stale = ['## Fixed Issues', '', '### CR-01: what this id used to mean'].join('\n');
|
|
const rows = ledgerRows(runShippedDisposition({ reviewText: review, fixText: stale }).ledger);
|
|
assert.strictEqual(rows[0].disposition, 'open',
|
|
'a fix report must name the SAME finding before its outcome is applied');
|
|
});
|
|
});
|
|
|
|
describe('#3861 round 2 — the disposition ledger is reachable in a shipped path (B1c/B1d)', () => {
|
|
const FIX_WORKFLOW = path.join(ROOT, 'gsd-core', 'workflows', 'code-review-fix.md');
|
|
|
|
test('code-review-fix.md reads and executes the disposition step after committing the fix report', () => {
|
|
// Without this the reconciliation logic -- the bulk of the step and nearly all of its test
|
|
// surface -- is reachable only on a RE-EXECUTION of the phase, and REQ-REVIEW-10 is unmet in
|
|
// every shipped path. execute-phase.md's gate invokes review with neither --fix nor --auto,
|
|
// so REVIEW-FIX.md cannot exist there and every row it writes is `open` by construction.
|
|
const src = fs.readFileSync(FIX_WORKFLOW, 'utf8');
|
|
assert.match(src, /<step name="record_disposition">/,
|
|
'the fix workflow must carry a step that records the disposition');
|
|
assert.match(src, /gsd-core\/workflows\/execute-phase\/steps\/code-review-disposition\.md/,
|
|
'and it must invoke the SAME step, not a second copy of the logic');
|
|
// Ordering is load-bearing: the fix report must be on disk before the ledger claims anything
|
|
// about it. Assert the step positions rather than merely their presence.
|
|
const commitAt = src.indexOf('<step name="commit_fix_report">');
|
|
const recordAt = src.indexOf('<step name="record_disposition">');
|
|
const presentAt = src.indexOf('<step name="present_results">');
|
|
assert.ok(commitAt > -1 && recordAt > -1 && presentAt > -1, 'all three steps must exist');
|
|
assert.ok(commitAt < recordAt, 'the ledger is reconciled AFTER the fix report is written');
|
|
assert.ok(recordAt < presentAt, 'and before results are presented');
|
|
});
|
|
|
|
test('the iteration backups are removed AFTER the ledger has read them, not inside the loop', () => {
|
|
// #3861 round 5. The .iterN.md backups are the only surviving record of what an earlier --auto
|
|
// iteration fixed -- this workflow keeps ONE final version of each artifact, and the re-review
|
|
// drops a finding once it is fixed, so neither final artifact carries it. Deleting them at the
|
|
// end of the loop meant record_disposition reached a CONVERGED run with every early fix already
|
|
// erased and recorded those findings as open.
|
|
const src = fs.readFileSync(FIX_WORKFLOW, 'utf8');
|
|
const recordAt = src.indexOf('<step name="record_disposition">');
|
|
const cleanupAt = src.indexOf('<step name="cleanup_iteration_backups">');
|
|
assert.ok(cleanupAt > -1, 'the backups must be removed by a named step, not inline in the loop');
|
|
assert.ok(recordAt < cleanupAt, 'the ledger reads the backups BEFORE they are removed');
|
|
// And the removal must not have been left behind in the loop as well.
|
|
const loopAt = src.indexOf('<step name="auto_iteration_loop">');
|
|
const loopBody = src.slice(loopAt, src.indexOf('<step name="commit_fix_report">'));
|
|
assert.ok(!/rm -f "\$\{REVIEW_PATH%\.md\}\.iter"/.test(loopBody),
|
|
'the loop must no longer delete the backups it just wrote');
|
|
});
|
|
|
|
test('the reconciliation is reachable: gate writes all-open, the fix path resolves it', () => {
|
|
// The two call sites driven in sequence, which is the shipped order. This is the review's own
|
|
// input -> wrong output case, inverted: a phase whose findings are all fixed by a subsequent
|
|
// --fix run used to end at `open: N / total: N`, asserting that every triaged finding was
|
|
// forgotten -- worse than recording nothing, because it looks authoritative and is inverted.
|
|
const review = ['---', 'phase: 01', 'status: issues_found', 'findings:',
|
|
' critical: 1', ' warning: 1', ' info: 0', ' total: 2', '---', '',
|
|
'## Critical Issues', '', '### CR-01: the critical one', '',
|
|
'## Warnings', '', '### WR-01: the warning one'].join('\n');
|
|
const fixReport = ['---', 'status: complete', '---', '',
|
|
'## Fixed Issues', '', '### CR-01: the critical one', '',
|
|
'## Skipped Issues', '', '### WR-01: the warning one'].join('\n');
|
|
|
|
// Call site 1 -- execute-phase.md's code_review_gate. No fix report exists yet.
|
|
const gate = runShippedDisposition({ reviewText: review, reviewTotal: 2 });
|
|
const gateRows = ledgerRows(gate.ledger);
|
|
assert.deepStrictEqual(gateRows.map((r) => r.disposition), ['open', 'open'],
|
|
'at the gate there is no fix report, so every row is open -- correctly');
|
|
|
|
// Call site 2 -- code-review-fix.md's record_disposition, with the report on disk and the
|
|
// gate's ledger carried in as prior state.
|
|
const after = runShippedDisposition({
|
|
reviewText: review, priorText: gate.ledger, fixText: fixReport, reviewTotal: 2,
|
|
});
|
|
const rows = ledgerRows(after.ledger);
|
|
assert.deepStrictEqual(
|
|
rows.map((r) => [r.id, r.disposition]),
|
|
[['CR-01', 'fixed'], ['WR-01', 'skipped']],
|
|
'the fix outcomes reach the ledger'
|
|
);
|
|
assert.match(after.ledger, /^open: 0$/m, 'and the headline agrees');
|
|
assert.match(after.ledger, /^total: 2$/m);
|
|
});
|
|
});
|
|
|
|
describe('#3861 round 2 — severity comes from the section, not just the id prefix (M3)', () => {
|
|
const sevOf = (reviewText, id) => {
|
|
const rows = ledgerRows(runShippedDisposition({ reviewText }).ledger);
|
|
const row = rows.find((r) => r.id === id);
|
|
assert.ok(row, id + ' must have a row');
|
|
return row.severity;
|
|
};
|
|
|
|
test("a Critical mis-numbered as WR- is recorded critical when it sits under '## Critical Issues'", () => {
|
|
// The section heading is the reviewer's OWN statement of severity, and the walker already
|
|
// visits it. Deriving from the prefix alone puts the ledger's Severity column -- the whole
|
|
// basis for triaging it -- in disagreement with the review it summarizes and with the
|
|
// frontmatter count line block 1 prints.
|
|
const review = ['---', 'phase: 01', 'status: issues_found', '---', '',
|
|
'## Critical Issues', '', '### CR-01: properly numbered', '',
|
|
'### WR-04: a critical the reviewer mis-numbered', '',
|
|
'## Info', '', '### IN-01: an info item'].join('\n');
|
|
assert.strictEqual(sevOf(review, 'WR-04'), 'critical', 'the section outranks the prefix');
|
|
assert.strictEqual(sevOf(review, 'CR-01'), 'critical');
|
|
assert.strictEqual(sevOf(review, 'IN-01'), 'info');
|
|
});
|
|
|
|
test('an IN- finding under ## Warnings is recorded warning', () => {
|
|
// The other direction, so the rule is not one-way: the section governs whichever way the
|
|
// prefix disagrees with it.
|
|
const review = ['---', 'phase: 01', 'status: issues_found', '---', '',
|
|
'## Warnings', '', '### IN-02: mis-numbered the other way'].join('\n');
|
|
assert.strictEqual(sevOf(review, 'IN-02'), 'warning');
|
|
});
|
|
|
|
test('the id prefix still governs when no recognized section encloses the finding', () => {
|
|
// Fallback control. A review that does not use the documented headings -- and every carried
|
|
// row from an earlier review -- must keep the prefix mapping, BL- included.
|
|
const review = ['---', 'phase: 01', 'status: issues_found', '---', '',
|
|
'### CR-01: a', '### BL-02: b', '### WR-03: c', '### IN-04: d'].join('\n');
|
|
assert.strictEqual(sevOf(review, 'CR-01'), 'critical');
|
|
assert.strictEqual(sevOf(review, 'BL-02'), 'critical', 'BL- stays Critical-tier-equivalent');
|
|
assert.strictEqual(sevOf(review, 'WR-03'), 'warning');
|
|
assert.strictEqual(sevOf(review, 'IN-04'), 'info');
|
|
});
|
|
|
|
test('a lookalike section heading does not re-tier the findings under it', () => {
|
|
// Matched WHOLE, exactly as the fix-report sections are. A prefix match would let
|
|
// '## Critical Issues Verification' promote everything beneath it.
|
|
const review = ['---', 'phase: 01', 'status: issues_found', '---', '',
|
|
'## Critical Issues Verification', '', '### IN-05: not actually critical'].join('\n');
|
|
assert.strictEqual(sevOf(review, 'IN-05'), 'info', 'an unrecognized section falls back to the prefix');
|
|
});
|
|
|
|
test('a section heading inside a fenced example does not govern', () => {
|
|
// The fence walker already skips fenced content; this pins that the new section tracking
|
|
// honours it rather than reading an illustration as document structure.
|
|
const review = ['---', 'phase: 01', 'status: issues_found', '---', '',
|
|
'```markdown', '## Critical Issues', '```', '', '### IN-06: outside the fence'].join('\n');
|
|
assert.strictEqual(sevOf(review, 'IN-06'), 'info');
|
|
});
|
|
});
|
|
|
|
describe('#3861 round 11 — a carried row keeps the severity the ledger recorded', () => {
|
|
// The ledger always WROTE a severity (table cell + frontmatter key) and nothing read it back:
|
|
// the row regex skipped the cell as [^|]*, the frontmatter walk collected only titles, and a
|
|
// carried row was rebuilt from the id prefix because sectionSev holds only the CURRENT review's
|
|
// findings. So the one case round 2's M3 fix exists for -- a Critical the reviewer mis-numbered
|
|
// WR-04 -- was recorded critical on run 1 and silently re-recorded warning on run 2, once the
|
|
// review stopped reporting it. Every carried-row test used CR-01/IN-01, whose prefix already
|
|
// matched, so the fallback returned the right answer by coincidence and no mutation could tell
|
|
// the two paths apart. Reproduced by the round-11 review by executing the shipped script twice.
|
|
const REVIEW_1 = ['---', 'phase: 01', 'status: issues_found', '---', '',
|
|
'## Critical Issues', '', '### CR-01: properly numbered', '',
|
|
'### WR-04: a critical the reviewer mis-numbered', ''].join('\n');
|
|
// Run 2: the review no longer reports WR-04 at all.
|
|
const REVIEW_2_DROPPED = ['---', 'phase: 01', 'status: issues_found', '---', '',
|
|
'## Critical Issues', '', '### CR-01: properly numbered', ''].join('\n');
|
|
const rowOf = (ledger, id) => {
|
|
const row = ledgerRows(ledger).find((r) => r.id === id);
|
|
assert.ok(row, id + ' must have a row');
|
|
return row;
|
|
};
|
|
// Run 1's ledger, with WR-04 deferred BY HAND, the way the ledger's own legend instructs. Edited
|
|
// in place so the frontmatter (and its title) is present -- a bare row would carry no title and
|
|
// sameFinding() would pass through its back-compat arm, never consulting identity at all.
|
|
const deferredLedger = () => {
|
|
const first = runShippedDisposition({ reviewText: REVIEW_1 }).ledger;
|
|
assert.strictEqual(rowOf(first, 'WR-04').severity, 'critical', 'run 1 records the section severity');
|
|
const edited = first.replace(/^\| WR-04 \| critical \| open \| - \|$/m, '| WR-04 | critical | deferred | waiting on team A |');
|
|
assert.notStrictEqual(edited, first, 'the hand edit must have landed');
|
|
return edited;
|
|
};
|
|
|
|
test("a deferred WR-* filed under '## Critical Issues' stays critical once the review stops reporting it", () => {
|
|
const after = runShippedDisposition({ reviewText: REVIEW_2_DROPPED, priorText: deferredLedger() });
|
|
const row = rowOf(after.ledger, 'WR-04');
|
|
assert.strictEqual(row.severity, 'critical', 'the recorded severity survives the carry -- the prefix would say warning');
|
|
assert.strictEqual(row.disposition, 'deferred', 'and so does the decision');
|
|
assert.match(row.source, /^waiting on team A/, 'and the reason');
|
|
assert.match(row.source, /\(not in the current review\)$/, 'and the row is marked carried');
|
|
assert.match(after.ledger, /^ {4}severity: critical$/m, 'the frontmatter agrees with the table');
|
|
});
|
|
|
|
test('the recorded severity also outranks the prefix while the review still reports the finding under no recognized section', () => {
|
|
// A re-review that dropped the documented headings. The current review gives no section, the
|
|
// ledger remembers what the last one said, and that is a better source than the id alone.
|
|
const plain = ['---', 'phase: 01', 'status: issues_found', '---', '',
|
|
'### CR-01: properly numbered', '### WR-04: a critical the reviewer mis-numbered'].join('\n');
|
|
const after = runShippedDisposition({ reviewText: plain, priorText: deferredLedger() });
|
|
assert.strictEqual(rowOf(after.ledger, 'WR-04').severity, 'critical');
|
|
});
|
|
|
|
test("the current review's section still wins over the recorded value", () => {
|
|
// Precedence control: a reviewer who re-tiers a finding on re-review is the newer statement.
|
|
const retiered = ['---', 'phase: 01', 'status: issues_found', '---', '',
|
|
'## Critical Issues', '', '### CR-01: properly numbered', '',
|
|
'## Warnings', '', '### WR-04: a critical the reviewer mis-numbered'].join('\n');
|
|
const after = runShippedDisposition({ reviewText: retiered, priorText: deferredLedger() });
|
|
assert.strictEqual(rowOf(after.ledger, 'WR-04').severity, 'warning', 'the section is the current statement');
|
|
});
|
|
|
|
test('a REUSED id does not inherit the old finding\'s severity', () => {
|
|
// Identity control, the same rule the disposition takes: ids are reused across re-reviews, so
|
|
// a brand-new WR-04 under no section starts from its own prefix, not from the finding it
|
|
// replaced. Inheriting here would put a Critical tier on an ordinary warning.
|
|
const reused = ['---', 'phase: 01', 'status: issues_found', '---', '',
|
|
'### CR-01: properly numbered', '### WR-04: an entirely different finding'].join('\n');
|
|
const after = runShippedDisposition({ reviewText: reused, priorText: deferredLedger() });
|
|
const row = rowOf(after.ledger, 'WR-04');
|
|
assert.strictEqual(row.severity, 'warning', 'a different finding under a reused id is inferred from its prefix');
|
|
assert.strictEqual(row.disposition, 'open', 'and, as before, does not inherit the decision either');
|
|
});
|
|
|
|
test('a hand-mangled Severity cell falls back to the frontmatter copy, and a mangled pair to the prefix', () => {
|
|
// Both sources are enum-validated (ADR-227): a value outside critical|warning|info is not a
|
|
// severity. The table is read first because it is the surface a human edits; the frontmatter
|
|
// is the copy a human is not invited to touch.
|
|
const base = deferredLedger();
|
|
const cellMangled = base.replace('| WR-04 | critical | deferred |', '| WR-04 | critcal | deferred |');
|
|
assert.notStrictEqual(cellMangled, base);
|
|
let after = runShippedDisposition({ reviewText: REVIEW_2_DROPPED, priorText: cellMangled });
|
|
assert.strictEqual(rowOf(after.ledger, 'WR-04').severity, 'critical', 'the frontmatter still knows');
|
|
assert.strictEqual(rowOf(after.ledger, 'WR-04').disposition, 'deferred', 'a bad severity cell does not cost the decision');
|
|
const bothMangled = cellMangled.replace(/^( {2}- id: WR-04\n {4}severity: )critical$/m, '$1critcal');
|
|
assert.notStrictEqual(bothMangled, cellMangled, 'the frontmatter mangling must have landed');
|
|
after = runShippedDisposition({ reviewText: REVIEW_2_DROPPED, priorText: bothMangled });
|
|
assert.strictEqual(rowOf(after.ledger, 'WR-04').severity, 'warning', 'nothing recorded is usable, so the prefix is all that is left');
|
|
});
|
|
|
|
test('a pre-severity ledger (bare rows, no frontmatter) still infers from the prefix', () => {
|
|
// Back-compat control. A ledger written by hand with no severity cell to speak of is not
|
|
// rejected; it just has nothing to carry, so the prefix governs as it always did.
|
|
const bare = '| WR-04 | | deferred | by hand |\n';
|
|
const after = runShippedDisposition({ reviewText: REVIEW_2_DROPPED, priorText: bare });
|
|
const row = rowOf(after.ledger, 'WR-04');
|
|
assert.strictEqual(row.severity, 'warning');
|
|
assert.strictEqual(row.disposition, 'deferred', 'an empty severity cell does not cost the decision');
|
|
});
|
|
});
|
|
|
|
describe('#3861 round 2 — a finding the heading parser cannot match is SURFACED, not dropped (B4)', () => {
|
|
// Two independent parsers produce two numbers one paragraph apart: the counts come from
|
|
// REVIEW.md's frontmatter, the rows from `### <ID>:` heading matches against a CLOSED
|
|
// CR|BL|WR|IN alternation. Nothing reconciled them, so a finding the alternation cannot reach
|
|
// contributed no row, no note and no diagnostic -- and the ledger asserted `open: N of N` over
|
|
// a set strictly smaller than the console line had just reported.
|
|
const REVIEW_5 = ['---', 'phase: 01', 'status: issues_found', 'findings:',
|
|
' critical: 1', ' warning: 2', ' info: 2', ' total: 5', '---', '',
|
|
'### CR-01: a conforming finding',
|
|
'### WR-01: another conforming one',
|
|
'### WR-02: a third',
|
|
'### SEC-01: a prefix the alternation does not carry',
|
|
'#### IN-09: a heading one level too deep'].join('\n');
|
|
|
|
test('the shortfall is stated in the ledger frontmatter and on the console', () => {
|
|
const out = runShippedDisposition({ reviewText: REVIEW_5, reviewTotal: 5 });
|
|
assert.match(out.ledger, /^unparsed: 2$/m,
|
|
'the ledger must record that two findings reached no row');
|
|
assert.match(out.ledger, /^total: 3$/m, 'and must still report the rows it does have');
|
|
assert.match(out.stdout, /2 finding\(s\) recorded NOWHERE/,
|
|
'the console line must say so too -- the ledger is not the only surface a human reads');
|
|
assert.match(out.stdout, /the review reports 5, but only 3 matched/,
|
|
'and must name both numbers, so the shortfall is checkable rather than asserted');
|
|
});
|
|
|
|
test('a review whose findings all parse gains no unparsed key at all', () => {
|
|
// Negative control for the key itself. An ordinary ledger must not grow a noise key, or the
|
|
// unchanged-run check starts rewriting the file on every phase.
|
|
const clean = ['---', 'phase: 01', 'status: issues_found', 'findings:',
|
|
' critical: 1', ' warning: 0', ' info: 0', ' total: 1', '---', '',
|
|
'### CR-01: the only finding'].join('\n');
|
|
const out = runShippedDisposition({ reviewText: clean, reviewTotal: 1 });
|
|
assert.doesNotMatch(out.ledger, /^unparsed:/m, 'nothing was dropped, so nothing is reported');
|
|
assert.doesNotMatch(out.stdout, /recorded NOWHERE/);
|
|
});
|
|
|
|
test('an absent or non-numeric total reconciles nothing rather than inventing a shortfall', () => {
|
|
// The reconciliation needs a number on BOTH sides. A legacy review with no findings: block
|
|
// has no total to compare against, and reporting `unparsed: 3` there would be a fabrication.
|
|
const legacy = ['---', 'phase: 01', 'status: issues_found', '---', '',
|
|
'### CR-01: a', '### WR-01: b'].join('\n');
|
|
const out = runShippedDisposition({ reviewText: legacy, reviewTotal: '' });
|
|
assert.doesNotMatch(out.ledger, /^unparsed:/m);
|
|
assert.match(out.ledger, /^total: 2$/m);
|
|
});
|
|
|
|
test('a total SMALLER than the rows is not reported as a negative shortfall', () => {
|
|
// Boundary in the other direction: the subtraction is clamped, so a review under-reporting
|
|
// its own total cannot produce `unparsed: -1`.
|
|
const out = runShippedDisposition({
|
|
reviewText: ['---', 'phase: 01', 'status: issues_found', 'findings:', ' total: 1', '---', '',
|
|
'### CR-01: a', '### WR-01: b'].join('\n'),
|
|
reviewTotal: 1,
|
|
});
|
|
assert.doesNotMatch(out.ledger, /^unparsed:/m, 'no shortfall when more parsed than declared');
|
|
assert.doesNotMatch(out.ledger, /unparsed: -/);
|
|
});
|
|
|
|
test('a review NONE of whose findings parse still records the shortfall on a first run', () => {
|
|
// The corner every case above misses, and the one carrying the LEAST evidence anywhere else:
|
|
// every finding in a heading shape the alternation cannot reach, on a phase with no prior
|
|
// ledger and no fix report. `order` is empty, so the early exit that stands down for "nothing
|
|
// to record" fired BEFORE the shortfall was computed -- no ledger, no console line, no
|
|
// diagnostic, for a review that declared two Criticals. A PARTIAL shortfall always reported,
|
|
// which is exactly why the total one read as covered. The shortfall is now derived above that
|
|
// exit and both exits decline to fire while one is outstanding.
|
|
const allUnmatched = ['---', 'phase: 01', 'status: issues_found', 'findings:',
|
|
' critical: 2', ' warning: 0', ' info: 0', ' total: 2', '---', '',
|
|
'## Critical Issues', '',
|
|
'### SEC-01: a prefix the alternation does not carry',
|
|
'### SEC-02: and a second one'].join('\n');
|
|
const out = runShippedDisposition({ reviewText: allUnmatched, reviewTotal: 2 });
|
|
assert.notStrictEqual(out.ledger, null,
|
|
'a review whose findings NONE parsed must still leave a record — silence here is the exact silent drop this reconciliation exists to close');
|
|
assert.match(out.ledger, /^unparsed: 2$/m, 'and must state how many findings reached no row');
|
|
assert.match(out.ledger, /^total: 0$/m, 'while reporting honestly that it carries no rows');
|
|
assert.match(out.stdout, /2 finding\(s\) recorded NOWHERE/,
|
|
'the console line must say so too — the ledger is not the only surface a human reads');
|
|
});
|
|
|
|
test('a fix report present, nothing parsed, no prior ledger — the SECOND exit records it too', () => {
|
|
// The two exits that discarded the shortfall are reached by DIFFERENT inputs, so one test
|
|
// cannot pin both. The earlier one stands down as soon as a fix report exists; this input
|
|
// therefore sails past it and lands on the later `rows.length === 0` return, which had the
|
|
// identical hole. Without this case, deleting the later guard's conjunct leaves the pair
|
|
// above green and the drop returns by the other road — a surviving mutant, not a covered one.
|
|
const allUnmatched = ['---', 'phase: 01', 'status: issues_found', 'findings:',
|
|
' critical: 2', ' warning: 0', ' info: 0', ' total: 2', '---', '',
|
|
'### SEC-01: a prefix the alternation does not carry',
|
|
'### SEC-02: and a second one'].join('\n');
|
|
// The fix report must contribute NO row, or it never reaches the later exit: an id the
|
|
// alternation CAN match becomes a carried row, rows.length is 1, and the guard under test is
|
|
// not the one that decides. Mutation-controlled — with a CR-NN id here the later guard's
|
|
// conjunct could be deleted and this test stayed green.
|
|
const fixReport = ['## Fixed Issues', '', '### SEC-03: an unmatched id in the fix report too'].join('\n');
|
|
const out = runShippedDisposition({ reviewText: allUnmatched, fixText: fixReport, reviewTotal: 2 });
|
|
assert.notStrictEqual(out.ledger, null,
|
|
'a fix report does not excuse the drop — the shortfall is still two findings recorded nowhere');
|
|
assert.match(out.ledger, /^unparsed: 2$/m, 'and the count must survive this path too');
|
|
});
|
|
|
|
test('a genuinely clean review still writes nothing — the relaxed exit is scoped to a shortfall', () => {
|
|
// Negative control for the fix itself, and the reason it is scoped rather than removed:
|
|
// relaxing that exit unconditionally would grow a zero-row ledger on every clean phase. With
|
|
// `total: 0` there is no shortfall to outstand, so both exits still fire exactly as before.
|
|
const clean = ['---', 'phase: 01', 'status: clean', 'findings:',
|
|
' critical: 0', ' warning: 0', ' info: 0', ' total: 0', '---', '', 'No issues.'].join('\n');
|
|
const out = runShippedDisposition({ reviewText: clean, reviewTotal: 0 });
|
|
assert.strictEqual(out.ledger, null, 'no findings declared and none parsed — nothing to record');
|
|
assert.strictEqual(out.stdout.trim(), '', 'and nothing to say');
|
|
});
|
|
|
|
test('a clean review with an unparseable fix report still writes nothing — the later exit must keep firing', () => {
|
|
// The fourth cell of the matrix, and the only one the three tests above leave open: they pin
|
|
// the LATER exit's under-fire (it must not swallow a shortfall) and the EARLIER exit's
|
|
// over-fire (a clean review must stay unrecorded), but nothing pins the later exit's OWN
|
|
// over-fire. It is reachable because a fix report — even one naming no matchable id — makes
|
|
// the earlier exit stand down, so control arrives at the later exit with a genuinely clean
|
|
// review and no shortfall. Neutering only that exit then writes a zero-row ledger reading
|
|
// "0 of 0 finding(s) open" for a phase that had nothing to report, and all three tests above
|
|
// stay green through it.
|
|
const clean = ['---', 'phase: 01', 'status: clean', 'findings:',
|
|
' critical: 0', ' warning: 0', ' info: 0', ' total: 0', '---', '', 'No issues.'].join('\n');
|
|
const unparseableFix = ['## Fixed Issues', '', '### SEC-03: an unmatched id in the fix report'].join('\n');
|
|
const out = runShippedDisposition({ reviewText: clean, fixText: unparseableFix, reviewTotal: 0 });
|
|
assert.strictEqual(out.ledger, null,
|
|
'a fix report that decides nothing is not a reason to record a ledger for a clean review');
|
|
assert.strictEqual(out.stdout.trim(), '', 'and nothing to say about it');
|
|
});
|
|
});
|
|
|
|
describe('#3829 review round 2 — a review that reports nothing still reconciles the ledger', () => {
|
|
const EMPTY_REVIEW = ['---', 'phase: 01', 'status: issues_found', '---', '', 'no findings'].join('\n');
|
|
|
|
test('a decided row is carried when the review reports no findings at all', () => {
|
|
// Exiting early on an empty review would freeze a stale ledger showing findings as open that
|
|
// the review no longer reports — the opposite of what this record exists to do.
|
|
const priorText = [
|
|
'| CR-01 | critical | deferred | ships next phase |',
|
|
'| WR-01 | warning | open | - |',
|
|
].join('\n');
|
|
const d = buildDisposition({ reviewText: EMPTY_REVIEW, priorText, padded: '01' });
|
|
// M1: BOTH rows survive. The decided one because it was triaged, the untriaged one because
|
|
// it was not -- and an untriaged finding vanishing without trace is the defect #3829 names.
|
|
assert.strictEqual(d.total, 2, 'both rows survive; neither is silently deleted');
|
|
assert.deepStrictEqual(d.rows.map((r) => r.id), ['CR-01', 'WR-01']);
|
|
assert.strictEqual(d.rows[0].disposition, 'deferred');
|
|
assert.strictEqual(d.rows[0].carried, true);
|
|
assert.strictEqual(d.rows[1].disposition, 'open');
|
|
assert.strictEqual(d.rows[1].carried, true);
|
|
assert.strictEqual(d.open, 1, 'the untriaged carried row is still open, and the count says so');
|
|
});
|
|
|
|
test('a review with no findings and no prior ledger produces nothing at all', () => {
|
|
assert.strictEqual(buildDisposition({ reviewText: EMPTY_REVIEW, padded: '01' }), null);
|
|
});
|
|
|
|
// Ninth src.includes() retired (round 3): it pinned the guard's exact line, including the
|
|
// process.exit(0) the script no longer calls, so it went red on a change that left the property
|
|
// untouched. The property -- an empty review still reconciles an EXISTING ledger -- is asserted
|
|
// behaviourally by the round-2 describe '#3829 review round 2 — a review that reports nothing
|
|
// still reconciles the ledger' below, which drives the shipped script against a prior ledger.
|
|
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #3829 — the SHIPPED disposition script, driven. These tests execute the real
|
|
// embedded script (see shippedDispositionScript), so a regression in the
|
|
// workflow file turns them red. The mirror-based tests above are kept for the
|
|
// pure parsing shapes; these are the ones that hold the contract.
|
|
// ---------------------------------------------------------------------------
|
|
describe('#3829 — shipped disposition script (executed, not mirrored)', () => {
|
|
const REVIEW = ['---', 'phase: 01', 'status: issues_found', '---', '',
|
|
'### CR-01: a', '', '### WR-01: b', '', '### IN-01: c'].join('\n');
|
|
|
|
test('every finding is recorded, defaulting to open, at the right severity', () => {
|
|
const rows = ledgerRows(runShippedDisposition({ reviewText: REVIEW }).ledger);
|
|
assert.deepStrictEqual(rows.map((r) => [r.id, r.severity, r.disposition]), [
|
|
['CR-01', 'critical', 'open'], ['WR-01', 'warning', 'open'], ['IN-01', 'info', 'open'],
|
|
]);
|
|
});
|
|
|
|
test('--fix outcomes are reconciled, and a lookalike section heading is not one', () => {
|
|
const fixText = ['## Fixed Issues', '', '### WR-01: b', '',
|
|
'## Fixed Issues Verification', '', '### IN-01: c'].join('\n');
|
|
const rows = ledgerRows(runShippedDisposition({ reviewText: REVIEW, fixText }).ledger);
|
|
const by = Object.fromEntries(rows.map((r) => [r.id, r]));
|
|
assert.strictEqual(by['WR-01'].disposition, 'fixed');
|
|
assert.strictEqual(by['WR-01'].source, '01-REVIEW-FIX.md');
|
|
assert.strictEqual(by['IN-01'].disposition, 'open', 'a lookalike heading must classify nothing');
|
|
});
|
|
|
|
test("a human's deferral reason survives, and the carried marker never accumulates", () => {
|
|
// Render -> re-parse -> render, four times, exactly as consecutive phase runs would.
|
|
const shrunk = ['---', 'status: issues_found', '---', '', '### WR-01: b'].join('\n');
|
|
let prior = ['| Finding | Severity | Disposition | Source |',
|
|
'|---------|----------|-------------|--------|',
|
|
'| CR-01 | critical | deferred | ships next phase, see ADR-99 |'].join('\n');
|
|
let ledger;
|
|
for (let i = 0; i < 4; i++) {
|
|
ledger = runShippedDisposition({ reviewText: shrunk, priorText: prior }).ledger;
|
|
prior = ledger;
|
|
}
|
|
const cr = ledgerRows(ledger).find((r) => r.id === 'CR-01');
|
|
assert.strictEqual(cr.disposition, 'deferred');
|
|
assert.strictEqual(cr.source, 'ships next phase, see ADR-99 (not in the current review)');
|
|
assert.strictEqual(
|
|
(ledger.match(/\(not in the current review\)/g) || []).length, 1,
|
|
'the carried marker must appear exactly once however many times the gate runs'
|
|
);
|
|
});
|
|
|
|
test('the ledger is a fixed point: a run that changes nothing rewrites nothing', () => {
|
|
const first = runShippedDisposition({ reviewText: REVIEW }).ledger;
|
|
const again = runShippedDisposition({ reviewText: REVIEW, priorText: first });
|
|
assert.strictEqual(again.wroteNothing, true, 'an unchanged run must report unchanged');
|
|
assert.strictEqual(again.ledger, first, 'and must leave the bytes alone');
|
|
});
|
|
|
|
test('a finding the review no longer reports is carried whether or not it was triaged', () => {
|
|
const prior = ['| CR-01 | critical | deferred | later |', '| IN-01 | info | open | - |'].join('\n');
|
|
const shrunk = ['---', 'status: issues_found', '---', '', '### WR-01: b'].join('\n');
|
|
const rows = ledgerRows(runShippedDisposition({ reviewText: shrunk, priorText: prior }).ledger);
|
|
const ids = rows.map((r) => r.id);
|
|
assert.ok(ids.includes('CR-01'), 'a deferred finding must survive leaving the review');
|
|
assert.ok(ids.includes('IN-01'), 'and so must an untriaged one -- that is the whole record');
|
|
});
|
|
|
|
test('a renumbered finding leaves its old row behind, marked, rather than vanishing', () => {
|
|
// The concrete case M1 names: run 1 records CR-01 open, the re-review renumbers it to CR-02,
|
|
// and run 2's ledger used to contain neither. The old row is now carried and marked, so the
|
|
// double-count is legible rather than a silent delete -- the stated cost of the fix.
|
|
const prior = '| CR-01 | critical | open | - |';
|
|
const renumbered = ['---', 'status: issues_found', '---', '', '### CR-02: the same finding, renumbered'].join('\n');
|
|
const ledger = runShippedDisposition({ reviewText: renumbered, priorText: prior }).ledger;
|
|
const rows = ledgerRows(ledger);
|
|
assert.deepStrictEqual(rows.map((r) => r.id), ['CR-02', 'CR-01']);
|
|
assert.match(ledger, /\| CR-01 \|.*\(not in the current review\) \|/,
|
|
'the carried row is MARKED, so it does not claim the finding is live');
|
|
});
|
|
|
|
test('a review reporting nothing still reconciles an existing ledger', () => {
|
|
const empty = ['---', 'status: issues_found', '---', '', 'no findings'].join('\n');
|
|
const prior = ['| CR-01 | critical | deferred | later |', '| WR-01 | warning | open | - |'].join('\n');
|
|
const rows = ledgerRows(runShippedDisposition({ reviewText: empty, priorText: prior }).ledger);
|
|
assert.deepStrictEqual(rows.map((r) => r.id), ['CR-01', 'WR-01'], 'reconciled, not truncated');
|
|
});
|
|
|
|
test('a review reporting nothing with no prior ledger writes no ledger at all', () => {
|
|
const empty = ['---', 'status: issues_found', '---', '', 'no findings'].join('\n');
|
|
assert.strictEqual(runShippedDisposition({ reviewText: empty }).ledger, null);
|
|
});
|
|
|
|
test('an applied outcome outranks an earlier deferral', () => {
|
|
const prior = '| CR-01 | critical | deferred | later |';
|
|
const fixText = ['## Fixed Issues', '', '### CR-01: a'].join('\n');
|
|
const rows = ledgerRows(runShippedDisposition({ reviewText: REVIEW, priorText: prior, fixText }).ledger);
|
|
assert.strictEqual(rows.find((r) => r.id === 'CR-01').disposition, 'fixed');
|
|
});
|
|
|
|
test('a deferral on a finding STILL in the review is not reset to open', () => {
|
|
// Distinct from the carried case: this finding is present in the current review, so it takes
|
|
// the ordinary path. 'open' must never overwrite a recorded decision on that path either.
|
|
const prior = '| CR-01 | critical | deferred | ships next phase |';
|
|
const rows = ledgerRows(runShippedDisposition({ reviewText: REVIEW, priorText: prior }).ledger);
|
|
const cr = rows.find((r) => r.id === 'CR-01');
|
|
assert.strictEqual(cr.disposition, 'deferred');
|
|
assert.strictEqual(cr.source, 'ships next phase');
|
|
assert.ok(!/not in the current review/.test(cr.source), 'it is present, so it is not carried');
|
|
});
|
|
|
|
test('BL- is recorded at the Critical tier, as the documented CR- equivalent', () => {
|
|
const withBlocker = ['---', 'status: issues_found', '---', '',
|
|
'### BL-01: a', '', '### WR-01: b'].join('\n');
|
|
const rows = ledgerRows(runShippedDisposition({ reviewText: withBlocker }).ledger);
|
|
assert.deepStrictEqual(rows.map((r) => [r.id, r.severity]), [
|
|
['BL-01', 'critical'], ['WR-01', 'warning'],
|
|
]);
|
|
});
|
|
|
|
test('a finding id whose prefix is a JS object property name produces no row', () => {
|
|
const hostile = ['---', 'status: issues_found', '---', '',
|
|
'### constructor-01: x', '', '### __proto__-02: y', '', '### CR-01: real'].join('\n');
|
|
const rows = ledgerRows(runShippedDisposition({ reviewText: hostile }).ledger);
|
|
assert.deepStrictEqual(rows.map((r) => r.id), ['CR-01']);
|
|
});
|
|
});
|
|
|
|
describe('#3829 review round 3 — hostile frontmatter and hand-edited ledgers', () => {
|
|
test('an UNTERMINATED frontmatter block yields no values, not the whole review body', () => {
|
|
// Stopping at "the next ---" is not enough: with no closing delimiter the scan would run to
|
|
// EOF and hand body text to every read, undoing the scoping fix entirely.
|
|
const unterminated = [
|
|
'---', 'phase: 01', 'status: issues_found', '',
|
|
'# body', '', 'total: 777', 'critical: 66',
|
|
].join('\n');
|
|
const parsed = parseGateCounts(unterminated);
|
|
assert.deepStrictEqual(parsed, { status: '', critical: '', warning: '', info: '', total: '' });
|
|
});
|
|
|
|
test('docs-parity: the frontmatter scan emits only when the closing delimiter was seen', () => {
|
|
const src = fs.readFileSync(DISPOSITION_STEP_PATH, 'utf8');
|
|
assert.ok(
|
|
src.includes('END{if (closed) printf "%s", buf}'),
|
|
'an unterminated frontmatter block must yield nothing'
|
|
);
|
|
});
|
|
|
|
|
|
test('a hand-edited row missing its trailing pipe still preserves the decision', () => {
|
|
// A mangled table is already broken; silently dropping the row would lose a deferral, which
|
|
// is the exact class of loss this record exists to prevent.
|
|
const review = ['---', 'status: issues_found', '---', '', '### CR-01: a'].join('\n');
|
|
const prior = '| CR-01 | critical | deferred | see issue 42';
|
|
const rows = ledgerRows(runShippedDisposition({ reviewText: review, priorText: prior }).ledger);
|
|
const cr = rows.find((r) => r.id === 'CR-01');
|
|
assert.strictEqual(cr.disposition, 'deferred');
|
|
assert.strictEqual(cr.source, 'see issue 42');
|
|
});
|
|
});
|
|
|
|
describe('#3829 review round 3 — stale fix reports, fenced examples, hostile REVIEW.md', () => {
|
|
test('a STALE fix report does not mark a new finding of the same id as fixed', () => {
|
|
// Finding ids are reused across re-reviews. Matching on the id alone would let a fix report
|
|
// from an earlier review declare a brand-new CR-01 already fixed — the worst possible lie
|
|
// for a record whose whole job is saying what happened to a finding.
|
|
const review = ['---', 'status: issues_found', '---', '', '### CR-01: NEW authentication bypass'].join('\n');
|
|
const fixText = ['## Fixed Issues', '', '### CR-01: OLD null dereference'].join('\n');
|
|
const rows = ledgerRows(runShippedDisposition({ reviewText: review, fixText }).ledger);
|
|
assert.strictEqual(rows[0].disposition, 'open');
|
|
});
|
|
|
|
test('a fix report naming the same finding still applies', () => {
|
|
const review = ['---', 'status: issues_found', '---', '', '### CR-01: same title'].join('\n');
|
|
const fixText = ['## Fixed Issues', '', '### CR-01: same title'].join('\n');
|
|
const rows = ledgerRows(runShippedDisposition({ reviewText: review, fixText }).ledger);
|
|
assert.strictEqual(rows[0].disposition, 'fixed');
|
|
});
|
|
|
|
test('a finding heading inside a fenced block is an example, not a finding', () => {
|
|
const review = ['---', 'status: issues_found', '---', '', '### CR-01: real',
|
|
'', '```', '### CR-77: an illustration', '```'].join('\n');
|
|
const rows = ledgerRows(runShippedDisposition({ reviewText: review }).ledger);
|
|
assert.deepStrictEqual(rows.map((r) => r.id), ['CR-01']);
|
|
});
|
|
|
|
test('an id listed under BOTH Fixed and Skipped is not decided by row order', () => {
|
|
const review = ['---', 'status: issues_found', '---', '', '### WR-01: dup'].join('\n');
|
|
const fixText = ['## Fixed Issues', '', '### WR-01: dup', '',
|
|
'## Skipped Issues', '', '### WR-01: dup'].join('\n');
|
|
const rows = ledgerRows(runShippedDisposition({ reviewText: review, fixText }).ledger);
|
|
assert.strictEqual(rows[0].disposition, 'fixed', 'first occurrence wins, deterministically');
|
|
});
|
|
|
|
test('an escaped pipe in a deferral reason survives whole', () => {
|
|
const review = ['---', 'status: issues_found', '---', '', '### CR-01: a'].join('\n');
|
|
const prior = '| CR-01 | critical | deferred | wait \\| see ADR-9 |';
|
|
const rows = ledgerRows(runShippedDisposition({ reviewText: review, priorText: prior }).ledger);
|
|
assert.strictEqual(rows[0].source, 'wait \\| see ADR-9');
|
|
});
|
|
|
|
// #3861 round 3. The Source cell is the one field this ledger asks a human to hand-edit, and
|
|
// "waiting on team A | team B to align" is an ordinary thing to type there. Under the previous
|
|
// capture a bare | failed the WHOLE prior-row match: prior.get() was undefined, the row fell
|
|
// through to open with an empty Source, and the console line read "1 of 1 finding(s) open" --
|
|
// a Critical a human explicitly deferred, with a documented reason, rendered indistinguishable
|
|
// from one never triaged, and the reason gone. Exactly the "was this ever seen" ambiguity
|
|
// #3829 exists to remove, reachable by one missing backslash. The cell is the LAST column, so
|
|
// it is now captured to the end of the line and a bare pipe is prose; the render escapes it
|
|
// so the table stays a table, and the second run converges.
|
|
test('a bare pipe in a deferral reason is kept as prose: the decision and the reason survive', () => {
|
|
const review = ['---', 'status: issues_found', '---', '', '### CR-01: a'].join('\n');
|
|
const prior = '| CR-01 | critical | deferred | waiting on team A | team B to align |';
|
|
const first = runShippedDisposition({ reviewText: review, priorText: prior });
|
|
const rows = ledgerRows(first.ledger);
|
|
assert.strictEqual(rows[0].disposition, 'deferred', 'a bare pipe must not revert the decision');
|
|
assert.strictEqual(rows[0].source, 'waiting on team A \\| team B to align', 'the reason survives, escaped');
|
|
assert.match(first.ledger, /^open: 0$/m, 'and the headline count agrees with the row');
|
|
// Fixed point: the escaped form re-parses to itself, so the second run rewrites nothing.
|
|
const second = runShippedDisposition({ reviewText: review, priorText: first.ledger });
|
|
assert.strictEqual(ledgerRows(second.ledger)[0].source, 'waiting on team A \\| team B to align');
|
|
assert.match(second.stdout, /disposition unchanged/, 'the second run must converge');
|
|
});
|
|
|
|
// Round-3 adversarial pass on the fix above. The first escape used /(^|[^\\])\|/g, which CONSUMES
|
|
// the character before the pipe: adjacent bare pipes were escaped one per run (A||B -> A\||B ->
|
|
// A\|\|B, a third run to converge, breaking the advertised second-run fixed point), and an escaped
|
|
// backslash before a pipe (A\\|B) hid the pipe behind the wrong parity and left it bare in the
|
|
// rendered table. The generator then emitted at most one bare pipe, so no property reached either; it does now.
|
|
// Round-3 adversarial pass. The script printed its verdict and then called process.exit(0) on the
|
|
// 'unchanged' branch only. Node documents process.stdout writes to pipes as ASYNCHRONOUS on
|
|
// POSIX, and an explicit exit can pre-empt a pending write, so on those lanes the caller can see
|
|
// exit 0 with no verdict line -- a hardening, not a reproduced defect: the reviewer's empty-stdout
|
|
// observation turned out to be its own sandbox (a bare console.log child printed nothing there
|
|
// either), which is stated so nobody re-reads this as evidence the drop was seen. The script now
|
|
// runs inside main() and leaves by return, so the loop drains stdout before exit.
|
|
// Shape-pinned deliberately: the property IS the absence of the call, and a behavioural test would
|
|
// have to race a pipe to fail. Comments are stripped first so a mention is not a match, and the
|
|
// match covers the dotted, bracketed and whitespace-split spellings; a call built by any other
|
|
// indirection is outside this pin and is what code review is for.
|
|
test('the shipped script never calls process.exit -- it returns, so its verdict line is never lost', () => {
|
|
const code = shippedDispositionScript().replace(/^\s*\/\/.*$/gm, '');
|
|
assert.doesNotMatch(code, /process\s*(?:\.\s*exit\b|\[\s*['"]exit['"]\s*\])/,
|
|
'leave main() by return; an explicit exit can drop the verdict line on a piped stdout');
|
|
});
|
|
|
|
test('adjacent bare pipes and a backslash-then-pipe are escaped in ONE write, then converge', () => {
|
|
const review = ['---', 'status: issues_found', '---', '', '### CR-01: a'].join('\n');
|
|
const prior = '| CR-01 | critical | deferred | A||B and C\\\\|D |';
|
|
const first = runShippedDisposition({ reviewText: review, priorText: prior });
|
|
assert.strictEqual(ledgerRows(first.ledger)[0].source, 'A\\|\\|B and C\\\\\\|D',
|
|
'every bare pipe is escaped on the first write, whatever precedes it');
|
|
const second = runShippedDisposition({ reviewText: review, priorText: first.ledger });
|
|
assert.strictEqual(second.ledger, first.ledger, 'and the escaped form is a fixed point');
|
|
assert.match(second.stdout, /disposition unchanged/);
|
|
});
|
|
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #4665 — `--fix` must be able to act on an existing REVIEW.md even when the
|
|
// incremental scope for a FRESH review is empty. check_empty_scope used to
|
|
// exit the entire workflow whenever REVIEW_FILES was empty — before
|
|
// dispatch-fix — so the documented contract ("after review completes (or if
|
|
// REVIEW.md already exists), auto-apply findings found") was unreachable for
|
|
// any phase whose post-review changes are planning artifacts only.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('#4665 — check_empty_scope --fix recovery onto an existing REVIEW.md', () => {
|
|
const readWorkflow = () => fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
|
|
|
test('check_empty_scope recovers --fix onto an existing REVIEW.md instead of exiting (#4665)', () => {
|
|
const src = readWorkflow();
|
|
const stepIdx = src.indexOf('<step name="check_empty_scope">');
|
|
const nextStepIdx = src.indexOf('<step name="structural_pre_pass">');
|
|
assert.ok(stepIdx !== -1, 'check_empty_scope step must exist');
|
|
assert.ok(nextStepIdx > stepIdx, 'structural_pre_pass must follow check_empty_scope');
|
|
const block = src.slice(stepIdx, nextStepIdx);
|
|
|
|
// The recovery computes the phase's REVIEW.md path with the same
|
|
// expression spawn_reviewer uses.
|
|
assert.ok(
|
|
block.includes('REVIEW_PATH="${PHASE_DIR}/${PADDED_PHASE}-REVIEW.md"'),
|
|
'check_empty_scope must compute REVIEW_PATH exactly as spawn_reviewer does'
|
|
);
|
|
// The skip is now guarded: it fires only when --fix is absent OR no
|
|
// REVIEW.md exists on disk.
|
|
assert.ok(
|
|
block.includes('FIX_FLAG') && block.includes('-f "${REVIEW_PATH}"'),
|
|
'the empty-scope skip must be guarded on FIX_FLAG and the existing REVIEW.md file check'
|
|
);
|
|
// The fence must be self-contained about its own precondition: an explicit
|
|
// emptiness check, so a literal-minded execution cannot read the recovery
|
|
// paragraph as skipping a needed fresh review on a non-empty scope.
|
|
assert.ok(
|
|
block.includes("\"${#REVIEW_FILES[@]}\" -ne 0"),
|
|
'check_empty_scope must assert REVIEW_FILES emptiness explicitly, not only in prose'
|
|
);
|
|
});
|
|
|
|
test('the fix-recovery path routes to dispatch-fix past the fresh-review steps (#4665)', () => {
|
|
const src = readWorkflow();
|
|
const stepIdx = src.indexOf('<step name="check_empty_scope">');
|
|
const nextStepIdx = src.indexOf('<step name="structural_pre_pass">');
|
|
const block = src.slice(stepIdx, nextStepIdx);
|
|
|
|
assert.match(block, /dispatch-fix/, 'the recovery must route to dispatch-fix');
|
|
assert.match(
|
|
block, /structural_pre_pass[\s\S]*dispatch_reviewer_lanes[\s\S]*spawn_reviewer[\s\S]*commit_review/,
|
|
'the recovery must name the fresh-review steps it skips (no reviewer spawn, nothing to commit)'
|
|
);
|
|
assert.doesNotMatch(
|
|
block, /spawn the (reviewer|agent)|gsd-code-reviewer/,
|
|
'the recovery must not contain an instruction to spawn a fresh reviewer'
|
|
);
|
|
});
|
|
|
|
test('the plain empty-scope skip survives for the non-fix path (#4665)', () => {
|
|
const src = readWorkflow();
|
|
const stepIdx = src.indexOf('<step name="check_empty_scope">');
|
|
const nextStepIdx = src.indexOf('<step name="structural_pre_pass">');
|
|
const block = src.slice(stepIdx, nextStepIdx);
|
|
|
|
assert.ok(
|
|
block.includes('No source files changed in phase ${PHASE_ARG}. Skipping review.'),
|
|
'the plain skip text must remain for invocations without --fix or without an existing REVIEW.md'
|
|
);
|
|
assert.match(block, /Exit workflow/i, 'the non-recovery path must still end the workflow');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #3861 round 1 — the two blockers, and the structural properties that catch them
|
|
//
|
|
// Both were invisible to every behavioural test above, for the same reason: those
|
|
// tests execute the node script through the process seam with the environment
|
|
// handed to it, so they never see the SHELL that is supposed to build that
|
|
// environment, nor the prose that tells the agent what to read.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Every ```bash fence in a step file, in order.
|
|
function bashFences(src) {
|
|
const out = [];
|
|
const lines = src.replace(/\r\n/g, '\n').split('\n');
|
|
let start = -1;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (start === -1 && /^```bash\s*$/.test(lines[i])) { start = i + 1; continue; }
|
|
if (start !== -1 && /^```\s*$/.test(lines[i])) { out.push(lines.slice(start, i).join('\n')); start = -1; }
|
|
}
|
|
return out;
|
|
}
|
|
|
|
describe('#3861 round 1 — step-file structural contract', () => {
|
|
test('the step file never instructs the agent to read and execute ITSELF', () => {
|
|
// A step file that names its own path as something to "read and execute" is
|
|
// unbounded self-recursion at runtime, and nothing downstream bounds it.
|
|
const src = fs.readFileSync(DISPOSITION_STEP_PATH, 'utf8');
|
|
const self = 'execute-phase/steps/' + path.basename(DISPOSITION_STEP_PATH);
|
|
assert.ok(
|
|
!src.includes(self),
|
|
'code-review-disposition.md must not reference its own path — execute-phase.md is what points here'
|
|
);
|
|
});
|
|
|
|
test('the disposition instruction appears exactly once', () => {
|
|
// The self-reference above arrived as a duplicated paragraph; the duplicate is
|
|
// the tell, and no linter scores markdown prose.
|
|
const src = fs.readFileSync(DISPOSITION_STEP_PATH, 'utf8');
|
|
const n = src.split(/\r?\n/).filter((l) => l.startsWith('**Record a per-finding disposition.**')).length;
|
|
assert.strictEqual(n, 1, 'exactly one disposition instruction, not a pointer plus a body');
|
|
});
|
|
|
|
test('every shell block derives the variables it reads — blocks do not share a shell', () => {
|
|
// Each fenced block is dispatched as its own Bash call, so a variable derived in
|
|
// block 1 is EMPTY in block 2 — and empty is silent: the write lands on a bare
|
|
// `-REVIEW-DISPOSITION.md` and the step still reports success. The step's own
|
|
// inputs (PHASE_DIR, PHASE_NUMBER) are the only values a block may inherit.
|
|
const INPUTS = new Set(['PHASE_DIR', 'PHASE_NUMBER']);
|
|
const DERIVED = ['PADDED', 'REVIEW_FILE', 'DISPOSITION_FILE'];
|
|
const fences = bashFences(fs.readFileSync(DISPOSITION_STEP_PATH, 'utf8'));
|
|
assert.ok(fences.length >= 2, 'the step must still carry more than one shell block');
|
|
for (const [i, fence] of fences.entries()) {
|
|
for (const v of DERIVED) {
|
|
if (INPUTS.has(v)) continue;
|
|
// The derivation may be INDENTED or sit inside a `case` arm -- PADDED is derived by a
|
|
// case split so a dotted phase number does not reach `printf %02d`. Anchoring the
|
|
// detector at column 0 made a real derivation invisible and the guard fired on it. The
|
|
// property being checked is unchanged: the block must ASSIGN what it reads.
|
|
// A SELF-REFERENTIAL assignment is a PASS-THROUGH, not a derivation. Block 2 prefixes its
|
|
// `node -e` with `REVIEW_FILE="${REVIEW_FILE}" DISPOSITION_FILE="${DISPOSITION_FILE}" ...`
|
|
// to put them in the child's environment, and the detector counted that as deriving them.
|
|
// So deleting block 2's REAL derivation left this guard GREEN -- on the exact defect it
|
|
// was written for. Found by negative-controlling the guard rather than by reading it.
|
|
// (Pre-existing: the original column-0 anchor matched that same line, which is at column 0.)
|
|
const fenceNoPassthrough = fence.replace(
|
|
new RegExp('(?:^|[\\s;])' + v + '="\\$\\{' + v + '\\}"', 'gm'), ' ');
|
|
// Non-empty RHS required. `REVIEW_FILE=` is an assignment TOKEN and not a derivation, and
|
|
// the reviewer evaded the earlier predicate with exactly that. This is a cheap fast-fail,
|
|
// NOT the guard's authority -- see the executed guard below, which is.
|
|
const assigned = new RegExp('(?:^|[\\s;])' + v + '=(?![\\s;#]|$)', 'm');
|
|
const reads = new RegExp('\\$\\{?' + v + '\\b').test(fence.replace(new RegExp('(?:^|[\\s;])' + v + '=', 'gm'), ''));
|
|
if (!reads) continue;
|
|
assert.ok(
|
|
assigned.test(fenceNoPassthrough),
|
|
'block ' + (i + 1) + ' reads ' + v + ' without deriving it — it is empty in a fresh shell'
|
|
);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
|
|
describe('#3861 round 16 — the embedded script fits a Windows command line', () => {
|
|
// Block 2 runs the record-builder as `node -e "<script>"`, so the whole script is ONE
|
|
// argv entry. Windows caps a command line at 32767 characters (CreateProcess), and Node
|
|
// surfaces the overflow as ENAMETOOLONG from spawn — the process never starts. Linux's
|
|
// ~2 MB ARG_MAX means the Linux lane cannot see this at all: when the script crossed the
|
|
// cap it stayed green on ubuntu and took out 83 tests on windows-latest in one push,
|
|
// every one of them reporting `spawn_failed` rather than anything about length.
|
|
//
|
|
// Measured on native Windows (node v25.2.1): the largest `-e` argument that still spawns
|
|
// is 32728 characters; 32729 fails. The budget below sits well under that so the next
|
|
// addition to the script has somewhere to go — long rationale belongs in the step file's
|
|
// prose, which costs the command line nothing.
|
|
//
|
|
// What this guard does NOT establish, stated so it is not read as more than it is. It counts
|
|
// the extracted JavaScript, not the command line Windows finally serializes: the 32767 cap
|
|
// applies to the whole line — executable path, quoting and backslash escaping included — so a
|
|
// quote-heavy payload expands on the way out, and the 32728 figure above is one host's
|
|
// threshold rather than the CI runner's. The budget is a practical margin with room for both
|
|
// effects, not a proof that everything it admits will spawn.
|
|
const WINDOWS_CMDLINE_CAP = 32767;
|
|
const CMDLINE_BUDGET = 24576; // 24 KiB — the cap less ~8 KiB of deliberate headroom
|
|
|
|
test('the extracted node -e script stays well under the Windows command-line cap', () => {
|
|
const script = shippedDispositionScript();
|
|
// Anti-vacuity: a length assertion alone passes when the extractor returns '' — which is
|
|
// exactly what a moved fence or a renamed delimiter would produce. Bound it from BELOW
|
|
// first, so a broken extractor fails here instead of reporting a comfortable 0 bytes.
|
|
assert.ok(
|
|
script.length > 4096,
|
|
'the extractor returned ' + script.length + ' bytes — it is not reading the shipped script'
|
|
);
|
|
assert.ok(
|
|
script.length <= CMDLINE_BUDGET,
|
|
'the embedded node -e script is ' + script.length + ' characters; the budget is ' +
|
|
CMDLINE_BUDGET + ' and the hard Windows limit is ' + WINDOWS_CMDLINE_CAP +
|
|
'. Move long rationale out of the script and into the step file prose — it reads the ' +
|
|
'same there and costs the command line nothing.'
|
|
);
|
|
});
|
|
});
|
|
describe('#3861 round 2 — the shell-sharing guard, EXECUTED', () => {
|
|
// The textual guard above is a fast-fail, not the authority. An adversarial pass evaded it
|
|
// three ways -- an empty `REVIEW_FILE=`, a self-referential `REVIEW_FILE=$REVIEW_FILE`, and a
|
|
// commented assignment -- because a structural predicate recognises assignment TOKENS, never
|
|
// assignments that derive a usable value. No amount of regex fixes that class.
|
|
//
|
|
// So the authority moves to execution, which is the lesson this PR has now learned three times:
|
|
// run the real second fence in a FRESH shell with nothing but the step's two declared inputs,
|
|
// and require it to write the ledger at the correct derived path. Every derivation in that
|
|
// fence is load-bearing for that outcome, so no textual dodge survives it.
|
|
// RANDOM PHASES, and the randomness is the mechanism rather than decoration. Fixed fixtures
|
|
// cannot establish derivation: an adversarial pass defeated the one-phase version with
|
|
// `case ... in 7) PADDED=07 ;; *) PADDED=07 ;; esac`, and then defeated the two-phase version
|
|
// by simply adding `3.1) PADDED=03.1` to the same case. Any finite sample loses that race --
|
|
// the enumeration just grows to cover whatever the test happens to name.
|
|
//
|
|
// A phase picked at RUN TIME raises the cost of a hardcode from two arms to the whole drawn
|
|
// domain, so an accidental loss of derivation fails on some run rather than never.
|
|
//
|
|
// STATED HONESTLY, because the first version of this comment overclaimed and was refuted: the
|
|
// domain is FINITE -- 88 integer values and 792 dotted ones -- so a mutation enumerating all
|
|
// 880 passes forever, and one covering 90 or 12345678.1 or 1.10 would still break real phases
|
|
// the draw cannot reach. This raises the bar; it does not prove derivation, and nothing short
|
|
// of reading the fence can. `Math.random()` is also unseeded, so a failure is reproducible only
|
|
// in the sense that the drawn values are printed in every assertion message below -- re-running
|
|
// draws different ones. That is the honest description of what this buys.
|
|
//
|
|
// One integer and one dotted phase per run: the dotted one additionally pins the integer-part
|
|
// split a naive `%02d` cannot express.
|
|
const rnd = (lo, hi) => lo + Math.floor(Math.random() * (hi - lo + 1));
|
|
const intPhase = String(rnd(2, 89));
|
|
const dotPhase = rnd(2, 89) + '.' + rnd(1, 9);
|
|
const pad = (v) => {
|
|
const [i, sub] = String(v).split('.');
|
|
return String(Number(i)).padStart(2, '0') + (sub === undefined ? '' : '.' + sub);
|
|
};
|
|
for (const [phaseNumber, padded] of [[intPhase, pad(intPhase)], [dotPhase, pad(dotPhase)]]) {
|
|
test('block 2 derives its own paths and writes the ledger for phase ' + phaseNumber,
|
|
{ skip: !HAS_BASH }, () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3861-blk2-'));
|
|
const drawn = ' (drawn phase ' + phaseNumber + ' -> ' + padded + ')';
|
|
try {
|
|
fs.writeFileSync(path.join(dir, padded + '-REVIEW.md'),
|
|
['---', 'phase: ' + padded, 'status: issues_found', '---', '',
|
|
'### CR-01: a finding'].join('\n'));
|
|
const fence = bashFences(fs.readFileSync(DISPOSITION_STEP_PATH, 'utf8'))[1];
|
|
assert.ok(fence && fence.includes('DISPOSITION_FILE'), 'the disposition block must be fence 2');
|
|
// Scrub the DERIVED names from the inherited environment, so the claim "given only the
|
|
// declared inputs" is true rather than merely intended. `...process.env` is still needed
|
|
// for PATH/HOME, and an adversarial pass was right to call out the earlier wording.
|
|
const env = { ...process.env, PHASE_DIR: dir, PHASE_NUMBER: phaseNumber, RUNTIME_DIR: ROOT };
|
|
for (const k of ['PADDED', 'REVIEW_FILE', 'DISPOSITION_FILE', 'FIX_REPORT_FILE']) delete env[k];
|
|
const res = runHook('-c', ['set -euo pipefail\n' + fence + '\n'], {
|
|
interpreter: 'bash', timeoutMs: PROBE_TIMEOUT_MS, env,
|
|
});
|
|
assert.strictEqual(res.outcome, OUTCOME.EXITED, 'the block must run to completion');
|
|
assert.strictEqual(res.exitCode, 0, 'advisory: it must not abort' + drawn + ': ' + res.stderr);
|
|
assert.ok(fs.existsSync(path.join(dir, padded + '-REVIEW-DISPOSITION.md')),
|
|
'the ledger must land at the DERIVED path — a lost derivation writes elsewhere or nowhere' + drawn);
|
|
const rows = ledgerRows(fs.readFileSync(path.join(dir, padded + '-REVIEW-DISPOSITION.md'), 'utf8'));
|
|
assert.deepStrictEqual(rows.map((r) => r.id), ['CR-01'], 'and it must have read the review' + drawn);
|
|
assert.ok(!fs.existsSync(path.join(dir, '-REVIEW-DISPOSITION.md')),
|
|
'an empty PADDED must never produce a bare-named ledger' + drawn);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #3861 round 1, Major 4 + Minor 9 — the frontmatter reads, EXECUTED
|
|
//
|
|
// The disposition builder stopped being a mirror three rounds ago, for a reason
|
|
// this file already states: a hand model of a shell-embedded script drifts, and
|
|
// when it drifts the tests pass while the shipped block is broken. The counts
|
|
// parser kept its mirror anyway, and the argument against mirrors does not stop
|
|
// applying at the boundary between the two blocks.
|
|
//
|
|
// So the mirror loses its authority: it is now asserted AGAINST the shipped awk
|
|
// and greps, run under `set -euo pipefail` in a real shell, over every fixture
|
|
// the mirror is tested on. Divergence in either direction fails.
|
|
//
|
|
// Running the block also makes its advisory guards behavioural rather than
|
|
// textual, which retires the two `src.includes()` docs-parity assertions that
|
|
// stood in for them — Minor 9's anti-pattern, reduced by executing the thing
|
|
// the assertions were describing.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Run the step's FIRST shell block — the frontmatter reads — and report WHAT IT PRINTS.
|
|
//
|
|
// This harness used to append its own `printf` of the six internal variables to the fence before
|
|
// running it, and every assertion below then read those six lines. That is a test manufacturing the
|
|
// observable it asserts on: the shipped fence emitted nothing, the tested fence emitted six lines
|
|
// because the test added them, and the whole group was green against a script that did not exist
|
|
// outside this process. It is why the "the gate reports the counts" defect shipped past a suite that
|
|
// looks like it covers exactly that surface — the green was structurally incapable of turning red
|
|
// for it. The emitter now lives in the fence (see the step file), so the harness reads the fence's
|
|
// own stdout and nothing is synthesized here.
|
|
// `plantDir` stands a DIRECTORY at the review path. It is the root-immune half of the
|
|
// unreadable-review contract: the fence's guard is `[ -f "$REVIEW_FILE" ] && [ -r ... ]`,
|
|
// and only the `-r` leg is defeated by running as root. A directory fails the `-f` leg on
|
|
// every lane and euid, so it reaches the same non-reporting arm without depending on
|
|
// permission bits at all. See the two tests at the end of this describe.
|
|
function runShippedGateCounts({ reviewText, padded = '01', writeReview = true, mode, phaseNumber, plantDir = false, extraEnv = {} }) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3861-'));
|
|
try {
|
|
const reviewPath = path.join(dir, padded + '-REVIEW.md');
|
|
if (plantDir) fs.mkdirSync(reviewPath);
|
|
else if (writeReview) fs.writeFileSync(reviewPath, reviewText);
|
|
if (mode !== undefined) fs.chmodSync(reviewPath, mode);
|
|
const fence = bashFences(fs.readFileSync(DISPOSITION_STEP_PATH, 'utf8'))[0];
|
|
assert.ok(fence && fence.includes('REVIEW_COUNTS_OK'), 'the counts block must still be the first fence');
|
|
// `set -euo pipefail` is the point, not decoration: the step is advisory, so a
|
|
// non-matching grep or an unreadable review must not take the block down.
|
|
const script = 'set -euo pipefail\n' + fence + '\n';
|
|
const res = runHook('-c', [script], {
|
|
interpreter: 'bash',
|
|
timeoutMs: PROBE_TIMEOUT_MS,
|
|
// `Number(padded)` is deliberate for the integer case (strips the leading zero the way a
|
|
// caller's parsed phase_number does) but must not mangle a DOTTED phase: Number('03.1') is
|
|
// 3.1, which is what we want, while a non-numeric padded value would become NaN.
|
|
// `phaseNumber` overrides the derivation so a test can drive a value `padded` cannot
|
|
// express -- an unusable one. Otherwise PHASE_NUMBER is derived from `padded` as a caller's
|
|
// parsed phase_number would be.
|
|
env: {
|
|
...process.env,
|
|
PHASE_DIR: dir,
|
|
PHASE_NUMBER: phaseNumber === undefined ? String(Number(padded)) : phaseNumber,
|
|
// `extraEnv` exists for ONE property: the reads pin LC_ALL=C, and a test that cannot set
|
|
// the ambient locale cannot prove the pin is doing anything.
|
|
...extraEnv,
|
|
},
|
|
});
|
|
assert.strictEqual(res.outcome, OUTCOME.EXITED, 'the counts block must run to completion');
|
|
return { exitCode: res.exitCode, stdout: res.stdout, stderr: res.stderr };
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
}
|
|
|
|
// Read the shipped message back into the facts it asserts. This parses the OBSERVABLE the operator
|
|
// sees — it does not reach into the fence — so a value the gate declines to report is `reported:
|
|
// false` here rather than a number this helper invented.
|
|
function readGateMessage(stdout) {
|
|
const full = /^Code review: (\d+) findings — (\d+) critical, (\d+) warning, (\d+) info\.$/m.exec(stdout);
|
|
if (full) {
|
|
return { reported: true, countsOk: '1', total: full[1], critical: full[2], warning: full[3], info: full[4] };
|
|
}
|
|
if (/^Code review found issues\.$/m.test(stdout)) return { reported: true, countsOk: '0' };
|
|
// The third arm (round 11): the file was READ and yielded no status. Reported -- the operator is
|
|
// told something -- but no counts and no verdict, which is what `unparsed` records.
|
|
if (/^Code review status unparsed: /m.test(stdout)) return { reported: true, countsOk: '0', unparsed: true };
|
|
return { reported: false };
|
|
}
|
|
|
|
// The mirror's other half: render the two arms exactly as the shipped fence does, from the mirror's
|
|
// own parsed counts. Parity is asserted over this WHOLE STRING rather than over five intermediate
|
|
// values, which is what makes the assertion bind to something a user can see. The countsOk gate is
|
|
// modelled here because the shipped fence gates on it — digit-only, at most 8 digits, and the three
|
|
// severities must sum to the total.
|
|
function renderGateMessage(counts, phaseNumber) {
|
|
const numeric = (v) => v !== '' && /^[0-9]+$/.test(v) && v.length <= 8;
|
|
const all = [counts.total, counts.critical, counts.warning, counts.info];
|
|
let ok = all.every(numeric);
|
|
if (ok) {
|
|
const n = (v) => parseInt(v, 10);
|
|
if (n(counts.critical) + n(counts.warning) + n(counts.info) !== n(counts.total)) ok = false;
|
|
}
|
|
// A mirror is always handed a TEXT, so the file was read by construction: an empty status here
|
|
// is the read-but-unparseable arm, never the absent one (round 11 Minor -- a malformed report
|
|
// must not read as clean). The absent arm is driven directly, not through the mirror.
|
|
if (counts.status === '') {
|
|
return 'Code review status unparsed: REVIEW.md is present but its frontmatter has no parseable status; severity counts unavailable.\n';
|
|
}
|
|
if (counts.status === 'clean' || counts.status === 'skipped') return '';
|
|
const head = ok
|
|
? `Code review: ${counts.total} findings — ${counts.critical} critical, ${counts.warning} warning, ${counts.info} info.`
|
|
: 'Code review found issues.';
|
|
return head + '\n' + `Consider running: /gsd:code-review ${phaseNumber} --fix` + '\n';
|
|
}
|
|
|
|
describe('#3861 round 2 — the ledger write refuses a non-regular file', () => {
|
|
// The write-safety behaviour shipped with NO regression control at all -- I hand-drove it and
|
|
// did not pin it, which the round review caught by grepping for the words. Three shapes, each
|
|
// a distinct failure mode, all driven before this test existed:
|
|
// symlink -> writeFileSync FOLLOWS it and replaced the target's contents, outside the
|
|
// phase directory, leaving the link intact so nothing looked wrong;
|
|
// FIFO -> readFileSync BLOCKED FOREVER, in a gate documented as never blocking;
|
|
// directory -> the write throws.
|
|
const mkPhase = () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3861-nrf-'));
|
|
fs.writeFileSync(path.join(dir, '03-REVIEW.md'),
|
|
['---', 'status: issues_found', '---', '', '### CR-01: a finding'].join('\n'));
|
|
return dir;
|
|
};
|
|
const runAt = (dir) => runNode(['-e', shippedDispositionScript()], {
|
|
timeoutMs: PROBE_TIMEOUT_MS,
|
|
env: {
|
|
...process.env,
|
|
REVIEW_FILE: path.join(dir, '03-REVIEW.md'),
|
|
DISPOSITION_FILE: path.join(dir, '03-REVIEW-DISPOSITION.md'),
|
|
FIX_REPORT_FILE: path.join(dir, '03-REVIEW-FIX.md'),
|
|
PADDED: '03',
|
|
},
|
|
});
|
|
|
|
// Skipped on win32 per the repo's existing convention for symlink-planting tests:
|
|
// tests/settings-jsonc.test.cjs:389 skips the same class, and
|
|
// tests/unreachable-guard-drift.test.cjs:726 records why -- symlink creation requires elevated
|
|
// privileges on Windows CI. It happened to be available on the lane this round; the convention
|
|
// exists because it is not guaranteed.
|
|
test('a symlink at the ledger path is refused, and its target is untouched',
|
|
{ skip: process.platform === 'win32' }, () => {
|
|
const dir = mkPhase();
|
|
try {
|
|
const outside = path.join(dir, 'outside.txt');
|
|
fs.writeFileSync(outside, 'ORIGINAL');
|
|
fs.symlinkSync(outside, path.join(dir, '03-REVIEW-DISPOSITION.md'));
|
|
const res = runAt(dir);
|
|
assert.strictEqual(res.exitCode, 0, 'advisory: it refuses, it does not fail');
|
|
assert.match(res.stdout, /not a regular file/, 'and says why');
|
|
assert.strictEqual(fs.readFileSync(outside, 'utf8'), 'ORIGINAL',
|
|
'the symlink target must not be overwritten');
|
|
assert.ok(fs.lstatSync(path.join(dir, '03-REVIEW-DISPOSITION.md')).isSymbolicLink(),
|
|
'and the link itself is left alone');
|
|
} finally { cleanup(dir); }
|
|
});
|
|
|
|
test('a symlink whose target ALREADY matches is refused too — the fast path does not bypass it',
|
|
{ skip: process.platform === 'win32' }, () => {
|
|
// The unchanged-run fast path read the ledger before the check, so a link whose target
|
|
// happened to match slipped through reporting `unchanged`. The check is first now.
|
|
const dir = mkPhase();
|
|
try {
|
|
const outside = path.join(dir, 'outside.txt');
|
|
fs.writeFileSync(outside, 'ORIGINAL');
|
|
fs.symlinkSync(outside, path.join(dir, '03-REVIEW-DISPOSITION.md'));
|
|
runAt(dir);
|
|
const res = runAt(dir);
|
|
assert.match(res.stdout, /not a regular file/);
|
|
assert.doesNotMatch(res.stdout, /unchanged/, 'the fast path must not run ahead of the check');
|
|
assert.strictEqual(fs.readFileSync(outside, 'utf8'), 'ORIGINAL');
|
|
} finally { cleanup(dir); }
|
|
});
|
|
|
|
test('a FIFO at the ledger path is refused rather than blocking the phase forever', () => {
|
|
const dir = mkPhase();
|
|
try {
|
|
// Through the process seam, like every other spawn in this file.
|
|
const fifoPath = path.join(dir, '03-REVIEW-DISPOSITION.md');
|
|
runHook('-c', ['mkfifo "$1"', '_', fifoPath], { interpreter: 'bash', timeoutMs: PROBE_TIMEOUT_MS });
|
|
// GATE ON WHAT WAS CREATED, never on mkfifo's exit code. On the Windows lane mkfifo EXISTS
|
|
// and exits 0 while producing something that is not a FIFO, so an exit-code guard let this
|
|
// test run against an ordinary path: the ledger wrote normally and the assertion below
|
|
// failed for a reason that had nothing to do with the behaviour under test. Caught by CI,
|
|
// not by the local suite or five review passes -- every one of which ran on Linux.
|
|
let isFifo = false;
|
|
try { isFifo = fs.lstatSync(fifoPath).isFIFO(); } catch { isFifo = false; }
|
|
if (!isFifo) return; // no real FIFO on this platform; nothing to assert
|
|
const res = runAt(dir);
|
|
assert.strictEqual(res.outcome, OUTCOME.EXITED,
|
|
'a FIFO must not hang the step — readFileSync blocks on one forever');
|
|
assert.strictEqual(res.exitCode, 0);
|
|
assert.match(res.stdout, /not a regular file/);
|
|
} finally { cleanup(dir); }
|
|
});
|
|
|
|
test('a directory at the ledger path is refused', () => {
|
|
const dir = mkPhase();
|
|
try {
|
|
fs.mkdirSync(path.join(dir, '03-REVIEW-DISPOSITION.md'));
|
|
const res = runAt(dir);
|
|
assert.strictEqual(res.exitCode, 0);
|
|
assert.match(res.stdout, /not a regular file/);
|
|
} finally { cleanup(dir); }
|
|
});
|
|
|
|
test('an ordinary ledger is still written — the refusal is not a blanket refusal', () => {
|
|
const dir = mkPhase();
|
|
try {
|
|
const res = runAt(dir);
|
|
assert.strictEqual(res.exitCode, 0);
|
|
assert.doesNotMatch(res.stdout, /not a regular file/);
|
|
assert.ok(fs.existsSync(path.join(dir, '03-REVIEW-DISPOSITION.md')));
|
|
} finally { cleanup(dir); }
|
|
});
|
|
});
|
|
|
|
describe('#3861 round 2 — a DOTTED phase number does not break the step', () => {
|
|
// Found by the round's own adversarial review, in its MISSED section -- no finding asked about
|
|
// it. Both of this step's call sites accept `03.1` -- `code-review-fix.md`'s PADDED_PHASE validator accepts
|
|
// ^[0-9]+(\.[0-9]+)*$ (widened from `?` to `*` by #4568; this comment named the pre-#4568 form
|
|
// until round 11) and `execute-phase.md` applies no shape gate at all. (`code-review.md`'s
|
|
// own PADDED_PHASE validator is identical but never dispatches this step; it was cited here as a caller
|
|
// for several rounds and is not one.) The step reconstructed the path with `printf "%02d"`, which cannot
|
|
// format one: bash prints `invalid number` and exits 1. Under `set -euo pipefail` that aborts
|
|
// the step on its FIRST line -- the loudest possible failure from a gate that promises never to
|
|
// block, and it takes the phase's whole review report with it.
|
|
// #3861 round 5, minor 1. The PADDED derivation -- the traversal fence between an
|
|
// attacker-influenceable phase number and a file path, plus the per-component length bound -- is
|
|
// duplicated verbatim across both fences, because each fenced block runs in a fresh shell and must
|
|
// derive what it reads. Each copy is independently tested, but nothing asserted they stay in step,
|
|
// and a future edit to one could silently desync the other with the suite still green. That is the
|
|
// shared-parallel-surface shape CLAUDE.md requires a parity test for, and it is security-relevant
|
|
// validation logic rather than incidental repetition.
|
|
//
|
|
// Compared LINE BY LINE rather than through a normalizing rewrite: a normalizer would have to be
|
|
// told what may differ, and anything it was told to tolerate would stop being asserted. Exactly one
|
|
// line may differ, and the test names both of its forms.
|
|
test('the two fences derive PADDED identically, and only the refusal message may differ', () => {
|
|
const fences = bashFences(fs.readFileSync(DISPOSITION_STEP_PATH, 'utf8'));
|
|
assert.strictEqual(fences.length, 2, 'the step must still carry exactly two bash fences');
|
|
const derivationOf = (fence, which) => {
|
|
const start = fence.indexOf('_pd="${PHASE_DIR:-}"');
|
|
const end = fence.indexOf('DISPOSITION_FILE="${_pd}/${PADDED}-REVIEW-DISPOSITION.md"');
|
|
assert.ok(start > -1, 'block ' + which + ' must still open the derivation with _pd');
|
|
assert.ok(end > start, 'block ' + which + ' must still close it by building the ledger path');
|
|
return fence.slice(start, end).split('\n');
|
|
};
|
|
const a = derivationOf(fences[0], 1);
|
|
const b = derivationOf(fences[1], 2);
|
|
// A parity test over an empty or trivial slice passes vacuously and pins nothing.
|
|
assert.ok(a.length > 20, 'the derivation must still be the substantial block this pins');
|
|
assert.strictEqual(a.length, b.length, 'the two derivations must have the same shape');
|
|
const differing = a.map((line, i) => [i, line, b[i]]).filter((e) => e[1] !== e[2]);
|
|
assert.strictEqual(differing.length, 1,
|
|
'exactly one line may differ between the two derivations; got ' + differing.length + ': ' +
|
|
JSON.stringify(differing.map((e) => [e[1], e[2]])));
|
|
assert.match(differing[0][1], /Code review reporting skipped/, 'block 1 refuses by its own name');
|
|
assert.match(differing[0][2], /Code review disposition skipped/, 'block 2 refuses by its own name');
|
|
});
|
|
|
|
test('block 1 reports a dotted phase instead of aborting', { skip: !HAS_BASH }, () => {
|
|
const review = ['---', 'phase: 03.1', 'status: issues_found', 'findings:',
|
|
' critical: 1', ' warning: 0', ' info: 0', ' total: 1', '---', '',
|
|
'### CR-01: a real finding'].join('\n');
|
|
const out = runShippedGateCounts({ reviewText: review, padded: '03.1' });
|
|
assert.strictEqual(out.exitCode, 0, 'advisory: a dotted phase must not abort the step');
|
|
assert.doesNotMatch(out.stderr, /invalid number/,
|
|
'the phase number must never reach printf %02d unsplit');
|
|
assert.match(out.stdout, /^Code review: 1 findings — 1 critical, 0 warning, 0 info\.$/m,
|
|
'and the review is actually found and reported');
|
|
});
|
|
|
|
test('a phase number outside the documented shape builds NO path — traversal fence', { skip: !HAS_BASH }, () => {
|
|
// PHASE_NUMBER is interpolated into a file path. The first draft of the dotted-phase fix
|
|
// carried an unusable value VERBATIM, which made `${PHASE_DIR}/../../etc/passwd-REVIEW.md`
|
|
// reachable where the old `printf "%02d"` had at least mangled it to `00` -- a regression
|
|
// introduced by the fix, found by adversarially reviewing it. Both call sites accept
|
|
// ^[0-9]+(\.[0-9]+)*$ -- an UNBOUNDED segment count since #4568, anchored at
|
|
// `code-review-fix.md`'s PADDED_PHASE validator and ungated at `execute-phase.md` -- and this step has two
|
|
// call sites and validates for itself.
|
|
// `1.2.3` LEFT THIS LIST in round 11. It is a legal N-segment id at the current base, and
|
|
// asserting its refusal here is precisely what held the step narrower than both of them;
|
|
// the positive case is its own test below. What remains here is SHAPE, not arity, so the
|
|
// two malformed-dot cases that the arity guard used to mask are added explicitly.
|
|
// The LETTER AXIS joined in round 12 (#4744 / #4660): a single uppercase letter is legal only as
|
|
// the last character of the integer part, so every other placement is shape, not arity.
|
|
for (const bad of ['../../etc/passwd', 'abc', '', '-1', '3.', '.1', '+1', '3 1', '1..2', '1.2.',
|
|
'23a', 'A23', '2A3', '23AB', '23.1A']) {
|
|
const out = runShippedGateCounts({ reviewText: '', writeReview: false, phaseNumber: bad });
|
|
assert.strictEqual(out.exitCode, 0, 'advisory: `' + bad + '` must not abort the step');
|
|
assert.match(out.stdout, /skipped \(unusable phase number/,
|
|
'`' + bad + '` must be refused by name, not silently coerced');
|
|
assert.doesNotMatch(out.stdout, /Code review: /,
|
|
'`' + bad + '` must not report counts read from a path built out of it');
|
|
}
|
|
});
|
|
|
|
test('an N-SEGMENT phase number reports counts, exactly as its callers accept it', { skip: !HAS_BASH }, () => {
|
|
// #3861 round 11. Found by this round's own adversarial review, not by the maintainer's.
|
|
// The base range widened `code-review-fix.md`'s PADDED_PHASE validator to `^[0-9]+(\\.[0-9]+)*$` (#4568), matching the
|
|
// segment-count freedom the canonical grammar in src/phase-id.cts has carried since
|
|
// #2128. This step still carried
|
|
// `*.*.*) _ok=0` -- "more than one dot: not the documented shape" -- so `23.1.2` took the
|
|
// refusal arm, printed `skipped (unusable phase number ...)` and wrote NO ledger, for a
|
|
// phase id its own dispatcher had just produced.
|
|
//
|
|
// It degraded LOUDLY, not silently, which is exactly why nothing caught it: the
|
|
// traversal-fence test above asserted that refusal as CORRECT. An arity bound and a shape
|
|
// bound had been folded into one arm, so the test that should have failed was the test
|
|
// that encoded the bug.
|
|
//
|
|
// THREE segments and FOUR, deliberately: the retired guard was arity-shaped, so a fix that
|
|
// merely moved the bound from two dots to three would pass a three-segment-only test.
|
|
for (const phase of ['23.1.2', '1.2.3.4']) {
|
|
const review = ['---', 'phase: ' + phase, 'status: issues_found', 'findings:',
|
|
' critical: 1', ' warning: 2', ' info: 0', ' total: 3', '---', '',
|
|
'### CR-01: a finding'].join('\n');
|
|
const padded = phase.replace(/^[0-9]+/, (m) => m.padStart(2, '0'));
|
|
const out = runShippedGateCounts({ reviewText: review, padded, phaseNumber: phase });
|
|
assert.strictEqual(out.exitCode, 0, phase + ': advisory -- must not abort the step');
|
|
assert.doesNotMatch(out.stdout, /skipped \(unusable phase number/,
|
|
phase + ': must NOT be refused -- both of this step\'s callers accept it');
|
|
assert.match(out.stdout, /^Code review: 3 findings — 1 critical, 2 warning, 0 info\.$/m,
|
|
phase + ': the review must be found at the N-segment path and reported');
|
|
assert.doesNotMatch(out.stderr, /invalid number/,
|
|
phase + ': the phase number must never reach printf %02d unsplit');
|
|
}
|
|
});
|
|
|
|
test('a LETTER-VARIANT phase number reports counts, padded as the canonical grammar pads it', { skip: !HAS_BASH }, () => {
|
|
// #3861 round 12. #4744 (#4660) widened the six shell/markdown phase mirrors to the canonical
|
|
// grammar's letter axis after this branch was cut, and its `lint-phase-id-drift` ratchet then
|
|
// flagged this step as the one digit-only mirror left -- found by running the base range's
|
|
// modified gates against the rebased tree, not by the review. `12A` and `23A.1.2` were refused
|
|
// by name; `3A` must pad to `03A`, the letter carried verbatim after the padded digits exactly as
|
|
// src/phase-id.cts pads it.
|
|
for (const [phase, padded] of [['12A', '12A'], ['3A', '03A'], ['23A.1.2', '23A.1.2'], ['12345678A', '12345678A']]) {
|
|
const review = ['---', 'phase: ' + phase, 'status: issues_found', 'findings:',
|
|
' critical: 1', ' warning: 0', ' info: 0', ' total: 1', '---', '',
|
|
'### CR-01: a finding'].join('\n');
|
|
const out = runShippedGateCounts({ reviewText: review, padded, phaseNumber: phase });
|
|
assert.strictEqual(out.exitCode, 0, phase + ': advisory -- must not abort the step');
|
|
assert.doesNotMatch(out.stdout, /skipped \(unusable phase number/,
|
|
phase + ': must NOT be refused -- its dispatcher accepts it since #4744');
|
|
assert.match(out.stdout, /^Code review: 1 findings — 1 critical, 0 warning, 0 info\.$/m,
|
|
phase + ': the review must be found at the ' + padded + ' path and reported');
|
|
}
|
|
});
|
|
|
|
test('the fence agrees with its callers across a probed set spanning both boundaries', { skip: !HAS_BASH }, () => {
|
|
// #3861 round 11. The first cut of this test was named "congruence, not merely wider" and
|
|
// probed 14 ids, none of them near the length bound. It passed, and the property it named
|
|
// was false: the shipped fence is deliberately NARROWER than the callers' regex, because
|
|
// the two `?????????*` checks bound the integer part and the suffix to 8 characters each.
|
|
// That overclaim was caught by this round's second adversarial review, which drove
|
|
// `123456789` and `1.1234567.1` — both caller-valid, both fence-refused.
|
|
//
|
|
// EXAMPLE-BASED, and the name says so rather than promising a language-level invariant.
|
|
// A finite probe set cannot prove congruence over an infinite language: the third review
|
|
// pass demonstrated this by injecting a `2) _ok=0` arm into the fence, which this test
|
|
// still passed because `2` is not in the list below. Read it as a regression pin over the
|
|
// values that actually broke, not as an exhaustive equivalence proof.
|
|
//
|
|
// It asserts two things, in two parts:
|
|
// (1) WITHIN the length bound, the fence and the callers agree exactly -- that is what
|
|
// round 11's shape fix bought, and the regression worth pinning.
|
|
// (2) BEYOND it, the fence refuses ids the callers accept. That divergence is
|
|
// PRE-EXISTING and untouched by this round (the bound predates the N-segment work
|
|
// and guards `$((10#...))` against bash's 2^64 wrap); it is pinned here so it stays
|
|
// a KNOWN narrowing rather than drifting back into an accidental one.
|
|
// The callers' regex since #4744: the letter axis is part of the agreement now.
|
|
const CALLER_RE = /^[0-9]+[A-Z]?(\.[0-9]+)*$/;
|
|
const refused = (v) => {
|
|
const out = runShippedGateCounts({ reviewText: '', writeReview: false, phaseNumber: v });
|
|
assert.strictEqual(out.exitCode, 0, v + ': advisory -- must not abort');
|
|
return /skipped \(unusable phase number/.test(out.stdout);
|
|
};
|
|
|
|
// (1) agreement, for every id whose components are each within the bound
|
|
for (const v of ['1', '03', '03.1', '23.1.2', '1.2.3.4', '12345678', '1.12345678',
|
|
'1.1234567.1', '12345678.12345678', '1.1.1.1.1.1.1.1.1.1',
|
|
'12A', '3A', '23A.1.2', '12345678A', '23a', 'A23', '2A3', '23AB', '23.1A',
|
|
'1..2', '1.2.', '.1', '3.', 'abc', '-1', '+1', '3 1', '../../etc/passwd']) {
|
|
assert.strictEqual(refused(v), !CALLER_RE.test(v),
|
|
v + ': with every component within the bound, step and callers must agree');
|
|
}
|
|
|
|
// (2) the remaining deliberate narrowing: a SINGLE component over 8 characters.
|
|
// This is the `$((10#...))` overflow guard and it is NOT a congruence defect -- bash
|
|
// integers wrap at 2^64, so an unbounded integer segment silently becomes a negative
|
|
// padded phase. Pinned so the narrowing stays known rather than drifting back.
|
|
for (const v of ['123456789', '1.999999999', '123456789A']) {
|
|
assert.ok(CALLER_RE.test(v), v + ': precondition -- the callers do accept this');
|
|
assert.ok(refused(v),
|
|
v + ': the per-component 8-char bound must keep refusing this; if this flips, the '
|
|
+ 'bound changed and `$((10#...))` overflow protection needs re-deriving');
|
|
}
|
|
});
|
|
|
|
test('the length bound is PER COMPONENT, not over the whole tail after the first dot', { skip: !HAS_BASH }, () => {
|
|
// #3861 round 11, C1. The bound used to read `${_pn#*.}` -- the entire suffix -- which is
|
|
// one component only while an id has at most two. The moment N-segment ids were accepted,
|
|
// that form rejected `1.1234567.1`: every component is a legal 7 digits, but the tail
|
|
// measures 9 characters. The comment above the check had promised per-component bounding
|
|
// since before this PR; the code only became untrue of it when the arity arm came out.
|
|
//
|
|
// Drives the boundary from both sides on a LATER segment, which is the part the old form
|
|
// got wrong -- an 8-char middle segment must pass and a 9-char one must fail, with the
|
|
// total length in both cases well past what the old whole-tail bound allowed.
|
|
const cases = [
|
|
['1.12345678.1', false, 'an 8-char middle segment is within the per-component bound'],
|
|
['1.123456789.1', true, 'a 9-char middle segment exceeds it'],
|
|
['1.1234567.1', false, 'the id the whole-tail bound rejected for its total length'],
|
|
['12345678.12345678', false, 'two 8-char components, 17 characters total'],
|
|
];
|
|
for (const [phase, mustRefuse, why] of cases) {
|
|
const out = runShippedGateCounts({ reviewText: '', writeReview: false, phaseNumber: phase });
|
|
assert.strictEqual(out.exitCode, 0, phase + ': advisory -- must not abort');
|
|
assert.strictEqual(/skipped \(unusable phase number/.test(out.stdout), mustRefuse,
|
|
phase + ': ' + why);
|
|
}
|
|
});
|
|
|
|
test('an integer phase is still zero-padded exactly as before', { skip: !HAS_BASH }, () => {
|
|
// Negative control for the split: the ordinary path must be untouched.
|
|
const review = ['---', 'phase: 01', 'status: issues_found', 'findings:',
|
|
' critical: 1', ' warning: 0', ' info: 0', ' total: 1', '---', '',
|
|
'### CR-01: a finding'].join('\n');
|
|
const out = runShippedGateCounts({ reviewText: review, padded: '01' });
|
|
assert.strictEqual(out.exitCode, 0);
|
|
assert.match(out.stdout, /^Code review: 1 findings/m);
|
|
});
|
|
});
|
|
|
|
describe('#3861 round 1 — the counts mirror is asserted against the shipped shell', () => {
|
|
// Every fixture the mirror is exercised on above, plus the count edges.
|
|
const FIXTURES = {
|
|
'the documented review': REVIEW_WITH_FINDINGS,
|
|
'blocker: as the critical tier-equivalent': REVIEW_WITH_FINDINGS.replace(' critical: 1', ' blocker: 1'),
|
|
'a body ---, status: and total: after the frontmatter':
|
|
REVIEW_WITH_FINDINGS + '\n\n---\n\nstatus: clean\ntotal: 999\n',
|
|
'a legacy review with no findings: block':
|
|
['---', 'phase: 02', 'status: issues_found', '---', '', '# Phase 02'].join('\n'),
|
|
'CRLF line endings': REVIEW_WITH_FINDINGS.replace(/\n/g, '\r\n'),
|
|
'unterminated frontmatter': ['---', 'status: issues_found', 'total: 4', '', '## Body'].join('\n'),
|
|
'no frontmatter at all': '# Phase 01\n\nnothing here\n',
|
|
'a zero-finding review': ['---', 'phase: 01', 'findings:', ' critical: 0', ' warning: 0',
|
|
' info: 0', ' total: 0', 'status: issues_found', '---'].join('\n'),
|
|
// Both from the round-1 adversarial pass: the shipped pipeline collapses `1 0` to `10` where
|
|
// a trim keeps `1 0`, and truncates at a second colon where a tail capture keeps it. Neither
|
|
// is reachable from the well-formed fixtures above, which is exactly why they are here.
|
|
'a count with an internal space': REVIEW_WITH_FINDINGS.replace(' critical: 1', ' critical: 1 0'),
|
|
// The fixture that can actually SEE a mirror/shipped parser divergence. The two above cannot:
|
|
// on them both parsers reach the countless arm, so the parity assertion holds either way. Here
|
|
// the repaired numbers are SELF-CONSISTENT (10 == 10 + 0 + 0), so the retired parser renders a
|
|
// full breakdown from a `findings:` block containing no such numbers while the shipped one
|
|
// withholds it.
|
|
'a self-consistent repaired breakdown':
|
|
['---', 'phase: 01', 'status: issues_found', 'findings:', ' critical: 1 0',
|
|
' warning: 0', ' info: 0', ' total: 1 0', '---', '', '### CR-01: a'].join('\n'),
|
|
// Tab-separated scalars are valid YAML. The retired `tr -d ' '` left the tab in place and made
|
|
// every count non-numeric; the shipped trim reads them, so this fixture also pins that change.
|
|
'tab-separated counts':
|
|
['---', 'phase: 01', 'status: issues_found', 'findings:', ' critical:\t1',
|
|
' warning:\t1', ' info:\t1', ' total:\t3', '---', '', '### CR-01: a'].join('\n'),
|
|
// The STATUS axis of the same class, and the fixture that pins the `first` helper. Every
|
|
// pre-existing status fixture left both parsers on the SAME arm -- `issues:found` truncates to
|
|
// `issues`, which is no more `clean` than `issues:found` is -- so the mirror's status read could
|
|
// drift from the shipped one unseen, exactly as `firstIn` did. Here truncation FLIPS the arm:
|
|
// `clean:junk` cut at the second colon is the silent `clean`, whole it reports.
|
|
'a status whose truncation would flip the arm':
|
|
REVIEW_WITH_FINDINGS.replace('status: issues_found', 'status: clean:junk'),
|
|
// Unicode whitespace is NOT trimmed, because the reads pin LC_ALL=C. Unpinned under glibc's
|
|
// C.UTF-8 the shipped sed trimmed U+2003 and this took the silent clean arm on some machines
|
|
// and not others; the mirror never trims it. Same class as the fixture above, locale axis.
|
|
'a status with a trailing unicode space':
|
|
REVIEW_WITH_FINDINGS.replace('status: issues_found', 'status: clean\u2003'),
|
|
'a count with a trailing unicode space':
|
|
REVIEW_WITH_FINDINGS.replace(' critical: 1', ' critical: 1\u2003'),
|
|
'a findings: opener with a trailing unicode space':
|
|
REVIEW_WITH_FINDINGS.replace('findings:', 'findings:\u2003'),
|
|
'a value containing a second colon': REVIEW_WITH_FINDINGS.replace('status: issues_found', 'status: issues:found'),
|
|
// POSIX [[:space:]] covers form feed and vertical tab; a [ \t] mirror does not, so the
|
|
// shipped grep matches a line the mirror rejects outright. Third counterexample, same class.
|
|
'a key indented with a form feed': REVIEW_WITH_FINDINGS.replace(' critical: 1', '\fcritical: 1'),
|
|
// Minor 1: a TOP-LEVEL key sharing a name with a nested count. The reads were scoped to the
|
|
// frontmatter but not to the `findings:` mapping the values belong to, so `^[[:space:]]*total:`
|
|
// matched this one first and the gate reported a number from outside the breakdown.
|
|
'a top-level total: ahead of the nested one':
|
|
['---', 'phase: 02', 'total: 999', 'status: issues_found', 'findings:',
|
|
' critical: 1', ' warning: 0', ' info: 0', ' total: 1', '---'].join('\n'),
|
|
'a top-level info: and critical: ahead of the nested ones':
|
|
['---', 'critical: 42', 'info: 7', 'status: issues_found', 'findings:',
|
|
' critical: 1', ' warning: 0', ' info: 0', ' total: 1', '---'].join('\n'),
|
|
};
|
|
|
|
for (const [name, reviewText] of Object.entries(FIXTURES)) {
|
|
test('shipped shell and mirror agree on ' + name, { skip: !HAS_BASH }, () => {
|
|
const shipped = runShippedGateCounts({ reviewText });
|
|
// Parity over the WHOLE emitted message, not over five intermediate variables the test
|
|
// used to print for itself. A drift in any parsed value changes this string or the arm
|
|
// it selects, so the assertion binds to what an operator actually sees.
|
|
assert.strictEqual(
|
|
shipped.stdout,
|
|
renderGateMessage(parseGateCounts(reviewText), 1),
|
|
'the mirror has drifted from the shipped awk/grep block'
|
|
);
|
|
});
|
|
}
|
|
|
|
// The parity fixtures above run under whatever locale the suite inherits, so they can only catch a
|
|
// locale bug on a machine that happens to have it. This drives the SAME input under both locales and
|
|
// asserts the shipped fence does not care -- which is the actual property LC_ALL=C buys. Found by the
|
|
// round's fifth adversarial pass: glibc's C.UTF-8 classifies U+2003 as [[:space:]] AND [[:blank:]]
|
|
// where C and en_US.UTF-8 classify it as neither, so before the pin `status: clean<U+2003>` trimmed
|
|
// to the silent `clean` on some machines and reported on others.
|
|
for (const [name, reviewText] of Object.entries({
|
|
'a status with a trailing unicode space':
|
|
REVIEW_WITH_FINDINGS.replace('status: issues_found', 'status: clean\u2003'),
|
|
'a count with a trailing unicode space':
|
|
REVIEW_WITH_FINDINGS.replace(' critical: 1', ' critical: 1\u2003'),
|
|
'a unicode-space indented count key':
|
|
REVIEW_WITH_FINDINGS.replace(' critical: 1', '\u2003critical: 1'),
|
|
// Pass 6's finding, and the one input that proves the awk was missed: the BLOCK OPENER. Under
|
|
// C.UTF-8 `findings:<U+2003>` matched `/^findings:[[:space:]]*$/` and opened the mapping, under
|
|
// C it did not -- so the same review rendered a full breakdown on one machine and the countless
|
|
// message on another, with every grep and sed already pinned.
|
|
'a findings: opener with a trailing unicode space':
|
|
REVIEW_WITH_FINDINGS.replace('findings:', 'findings:\u2003'),
|
|
'the documented review': REVIEW_WITH_FINDINGS,
|
|
})) {
|
|
test('the shipped reads are locale-invariant on ' + name, { skip: !HAS_BASH }, () => {
|
|
const c = runShippedGateCounts({ reviewText, extraEnv: { LC_ALL: 'C', LANG: 'C' } });
|
|
const utf8 = runShippedGateCounts({ reviewText, extraEnv: { LC_ALL: 'C.UTF-8', LANG: 'C.UTF-8' } });
|
|
assert.strictEqual(c.exitCode, 0, 'advisory: must not abort under any locale');
|
|
assert.strictEqual(utf8.exitCode, 0, 'advisory: must not abort under any locale');
|
|
assert.strictEqual(utf8.stdout, c.stdout,
|
|
'the shipped reads must not depend on the ambient locale; drop LC_ALL=C and this reds');
|
|
// And the mirror predicts that one locale-independent answer.
|
|
assert.strictEqual(c.stdout, renderGateMessage(parseGateCounts(reviewText), 1),
|
|
'the mirror must model the locale-pinned shipped read');
|
|
});
|
|
}
|
|
|
|
test('every locale-sensitive tool in the step is pinned to LC_ALL=C', () => {
|
|
// THE CHECK THAT WOULD HAVE CAUGHT THE LAST MISS. `grep`, `sed` and `awk` all resolve
|
|
// [[:space:]] / [[:blank:]] through the ambient locale, and glibc's C.UTF-8 classifies U+2003
|
|
// as both where C and en_US.UTF-8 classify it as neither. Pass 5 pinned the grep and sed reads
|
|
// and the round then CLAIMED the parser was locale-independent; pass 6 found the two `awk`
|
|
// mapping selectors still unpinned, because that census searched for the tools it expected
|
|
// rather than the tools that were there. Asserting the invariant over the file is that census
|
|
// in a form a future edit is far less likely to slip past.
|
|
//
|
|
// WHAT THIS IS, AND WHAT IT IS NOT. It is a REGRESSION GUARD against the accident that has now
|
|
// happened twice — a read added or edited without its pin, in a file where every existing read
|
|
// has one. It is NOT a proof, and it is deliberately not written as one. It scans TEXT, so it
|
|
// cannot see shell or JS command structure: a name that is not written literally (`$AWK "$f"`,
|
|
// a command composed as `a''wk`, a command name computed inside the embedded `node -e` block)
|
|
// is invisible to it, and so is an executable command substitution on a physical line that
|
|
// begins with `#` inside a multiline quoted argument, which the comment exemption below skips.
|
|
//
|
|
// Earlier versions of this comment tried to ENUMERATE those residuals. Three adversarial passes
|
|
// in a row then found one more each time, which is the actual lesson: the list cannot be closed,
|
|
// so a comment promising a closed list is false the moment someone is cleverer than it. The
|
|
// examples above are illustrations, not an inventory. Its two directions are NOT symmetric, and
|
|
// that asymmetry is the whole operating instruction: a report is ADJUDICABLE — read the reported
|
|
// line together with what precedes it, since the same text can be a command or an argument to one
|
|
// (` grep` is a call after `:` and a string after `printf '%s\n' \`), so the line alone does not
|
|
// always settle it — whereas SILENCE proves nothing at all,
|
|
// because the evasions above are silent and so is any evasion no one has thought of yet. So:
|
|
// investigate every report, and never read silence as proof that a new read is pinned.
|
|
//
|
|
// Driven, it does catch: unpinned, `env`-prefixed, wrongly-pinned (`LC_ALL=C.UTF-8`),
|
|
// path-qualified, line-initial, and literal-in-Node calls. Its false answers run loud rather
|
|
// than quiet — a trailing comment naming a tool, a tool name inside an awk program, or a path
|
|
// whose component starts with one (`bin/grep-wrapper`) would all trip it. That direction is the
|
|
// right one for a guard, and none of those shapes exists in the step today. (The illustration
|
|
// is deliberately not a `docs/`-prefixed path: lint-docs-guard-registration reads one of those
|
|
// as a real docs reference from this file and demands a baseline entry for it.)
|
|
// `splitLines`, not `split('\n')`: the repo's own lint bans the latter on readFileSync content
|
|
// (DEFECT.WINDOWS-CRLF-TEST-PORTABILITY), and a CRLF checkout would otherwise leave a stray
|
|
// `\r` on every line here.
|
|
const { splitLines } = require('../gsd-core/bin/lib/text-lines.cjs');
|
|
const step = fs.readFileSync(DISPOSITION_STEP_PATH, 'utf8');
|
|
const offenders = [];
|
|
splitLines(step).forEach((line, i) => {
|
|
if (/^\s*#/.test(line)) return; // prose may name an unpinned form while explaining it
|
|
// Blank out the PINNED calls first, then anything left naming one of these tools is unpinned.
|
|
// Deliberately NOT keyed on a leading `|`: every call is piped today, but a guard that only
|
|
// sees pipes would wave through `awk '...' < "$f"` or `$(grep ...)`, and "correct for the
|
|
// shapes that happen to exist right now" is the exact property that let the awk selectors sit
|
|
// unpinned through a whole commit that claimed otherwise.
|
|
const rest = line.replace(/LC_ALL=C\s+(?:grep|sed|awk)\b/g, '');
|
|
// The preceding-char class deliberately does NOT shield `/` or `.`: an earlier version did,
|
|
// and `/usr/bin/awk '...' < "$f"` sailed through it. A path-qualified call is still a call.
|
|
// `parsed`, `passed` and `awkward` stay unmatched, because their tool substring is preceded
|
|
// or followed by a word character.
|
|
const leftover = rest.match(/(?:^|[^A-Za-z0-9_-])(?:grep|sed|awk)\b/g) || [];
|
|
for (const c of leftover) offenders.push(`${i + 1}: ${c.trim()} (unpinned)`);
|
|
});
|
|
assert.deepStrictEqual(offenders, [],
|
|
'every grep/sed/awk in the step must be LC_ALL=C-pinned; an unpinned one makes the parse '
|
|
+ 'depend on the machine, which is what pass 5 and pass 6 each found');
|
|
// `cut -d: -f2-` and `tr -d '\r'` are deliberately NOT pinned, and the exemption is principled
|
|
// rather than an oversight: neither resolves a character class or a collation. One splits on a
|
|
// single ASCII byte, the other deletes one literal byte.
|
|
assert.ok(/\|\s*cut -d: -f2-/.test(step), 'the cut reads are still the class-free shape');
|
|
});
|
|
|
|
test('a zero-finding review reports a real breakdown, not the countless fallback', { skip: !HAS_BASH }, () => {
|
|
// Minor 6. `0` is a number, so the gate must state `0 findings — 0 critical, …`
|
|
// rather than fall back. The `case` guard rejects the empty string and non-digits;
|
|
// a guard written against truthiness would reject this and say nothing at all.
|
|
const shipped = runShippedGateCounts({ reviewText: FIXTURES['a zero-finding review'] });
|
|
assert.strictEqual(shipped.stdout,
|
|
'Code review: 0 findings \u2014 0 critical, 0 warning, 0 info.\n'
|
|
+ 'Consider running: /gsd:code-review 1 --fix\n',
|
|
'all four counts are numeric, so the breakdown is reported rather than withheld');
|
|
});
|
|
|
|
test('a partial findings: block makes the whole breakdown unavailable', { skip: !HAS_BASH }, () => {
|
|
const shipped = runShippedGateCounts({
|
|
reviewText: ['---', 'findings:', ' total: 4', 'status: issues_found', '---'].join('\n'),
|
|
});
|
|
assert.strictEqual(readGateMessage(shipped.stdout).countsOk, '0',
|
|
'a total without the three severities is not a breakdown');
|
|
assert.match(shipped.stdout, /^Code review found issues\.$/m,
|
|
'the countless form is what reaches the operator');
|
|
});
|
|
|
|
test('a missing REVIEW.md leaves the counts empty and does not abort', { skip: !HAS_BASH }, () => {
|
|
// Behavioural replacement for the `src.includes('if [ -f "$REVIEW_FILE" ] …')`
|
|
// assertion: under `set -e` an aborting block is what actually breaks the phase.
|
|
const shipped = runShippedGateCounts({ reviewText: '', writeReview: false });
|
|
assert.strictEqual(shipped.exitCode, 0, 'advisory: a missing review must not abort the step');
|
|
// An absent review yields an empty status, which is a NON-REPORTING arm: the gate says
|
|
// nothing at all rather than claiming a countless review. Asserting on the observable is
|
|
// what makes that distinction visible; the old six-line probe could not express it.
|
|
assert.strictEqual(shipped.stdout, '', 'no review, no message');
|
|
assert.strictEqual(readGateMessage(shipped.stdout).reported, false);
|
|
});
|
|
|
|
test('the countless fallback requires all four counts, not just the total', { skip: !HAS_BASH }, () => {
|
|
// Fifth `src.includes()` assertion converted to a behavioural one (round 1 retired four).
|
|
// It pinned the PROSE that stated the condition, so it went red the moment the emitter moved
|
|
// into the fence and the prose was rewritten — while the behaviour it named was untouched.
|
|
// That is the pin arguing for its own conversion: the arm is now executed and observable, so
|
|
// assert the arm. Contrast is the point — a bare total takes the countless arm, the full set
|
|
// takes the breakdown arm — which a one-sided assertion could not express.
|
|
const fm = (rows) => ['---', 'findings:', ...rows, 'status: issues_found', '---'].join('\n');
|
|
const totalOnly = runShippedGateCounts({ reviewText: fm([' total: 4']) });
|
|
assert.match(totalOnly.stdout, /^Code review found issues\.$/m,
|
|
'a numeric total with missing severities must not emit a half-filled breakdown');
|
|
assert.doesNotMatch(totalOnly.stdout, /findings —/,
|
|
'and must not emit the breakdown form at all');
|
|
const allFour = runShippedGateCounts({
|
|
reviewText: fm([' critical: 1', ' warning: 2', ' info: 1', ' total: 4']),
|
|
});
|
|
assert.match(allFour.stdout, /^Code review: 4 findings — 1 critical, 2 warning, 1 info\.$/m,
|
|
'all four present and consistent is what the breakdown arm requires');
|
|
});
|
|
|
|
// The guard this pair covers is `[ -f "$REVIEW_FILE" ] && [ -r "$REVIEW_FILE" ]`, and the two
|
|
// legs need different fixtures because only one of them survives root.
|
|
//
|
|
// `chmod 0o000` does NOT make a file unreadable to root: root bypasses POSIX read permission
|
|
// bits, so `[ -r ]` stays true, the fence reads the fixture, and the assertion below sees the
|
|
// real breakdown instead of silence. That is not hypothetical here — the bench runs this suite
|
|
// as root under Docker, where this test failed with
|
|
// actual: 'Code review: 4 findings — 1 critical, 2 warning, 1 info.\n...'
|
|
// against an expected ''. CLAUDE.md names the mode-bit trick as the wrong tool for injecting an
|
|
// IO failure, and the usual remedy — monkeypatch the read to throw EACCES — does not reach this
|
|
// site: the read is performed by a spawned `bash`, not by this process, so stubbing node's `fs`
|
|
// is not on the code path at all.
|
|
//
|
|
// So the `-r` leg keeps the mode-bit fixture and declares the lanes it cannot bind on, exactly
|
|
// as tests/plan-review-convergence.test.cjs does for its own shell-side `-r` arm, and the `-f`
|
|
// leg below carries the contract on every lane INCLUDING root. Skipping the first without
|
|
// adding the second would have traded a false failure for lost coverage.
|
|
const SKIP_MODE_BITS = !HAS_BASH
|
|
? 'POSIX-only bash fragment'
|
|
: typeof process.getuid === 'function' && process.getuid() === 0
|
|
? 'root bypasses the read permission bit'
|
|
: false;
|
|
|
|
test('an unreadable REVIEW.md leaves the counts empty and does not abort', { skip: SKIP_MODE_BITS }, () => {
|
|
const shipped = runShippedGateCounts({ reviewText: REVIEW_WITH_FINDINGS, mode: 0o000 });
|
|
assert.strictEqual(shipped.exitCode, 0, 'advisory: an unreadable review must not abort the step');
|
|
assert.strictEqual(shipped.stdout, '', 'an unreadable review reports nothing, and does not guess');
|
|
});
|
|
|
|
// Root-immune companion: a directory fails `-f` for every euid, so this binds on the bench lane
|
|
// where the test above is skipped. Same arm, same observable — silence and a zero exit.
|
|
test('a directory standing in for REVIEW.md leaves the counts empty and does not abort', { skip: !HAS_BASH }, () => {
|
|
const shipped = runShippedGateCounts({ reviewText: REVIEW_WITH_FINDINGS, plantDir: true });
|
|
assert.strictEqual(shipped.exitCode, 0, 'advisory: a non-regular review path must not abort the step');
|
|
assert.strictEqual(shipped.stdout, '', 'a review path that is not a regular file reports nothing, and does not guess');
|
|
assert.strictEqual(readGateMessage(shipped.stdout).reported, false);
|
|
});
|
|
|
|
// An EMPTY REGULAR FILE is a third arm, and it is NOT the missing case: `-f` and `-r` both pass,
|
|
// so the fence opens and reads the file where the missing case never gets past the guard. The
|
|
// missing-file test above cannot reach it — it passes `writeReview: false`, so no file exists at
|
|
// all — and until this test every `reviewText: ''` call in this file did the same. The PR body
|
|
// has claimed this case since round 1; it was documented as covered and was not covered.
|
|
//
|
|
// WHY the scan yields nothing, stated precisely because the obvious reading is wrong: it is NOT
|
|
// the `NR==1{if($0!="---") exit}` guard. A zero-byte file supplies awk no record at all, so that
|
|
// action never executes (NR stays 0). The output is empty because `closed` is never set and the
|
|
// END block therefore prints nothing.
|
|
//
|
|
// Since round 11 the observable is NOT the missing-file case's any more, and that is the point:
|
|
// a zero-byte REVIEW.md was read and has no parseable status, so the fence says so rather than
|
|
// staying silent -- silence is what a clean review looks like. The counts are still empty and
|
|
// nothing is guessed; what changed is that the operator is told the report could not be read.
|
|
test('an EMPTY REVIEW.md leaves the counts empty, reports the unparseable status, and does not abort', { skip: !HAS_BASH }, () => {
|
|
const shipped = runShippedGateCounts({ reviewText: '' }); // writeReview defaults true: a real, empty file
|
|
assert.strictEqual(shipped.exitCode, 0, 'advisory: an empty review must not abort the step');
|
|
assert.match(shipped.stdout, /^Code review status unparsed: /m, 'an empty file was read, and says so');
|
|
assert.doesNotMatch(shipped.stdout, /^Code review: \d+ findings/m, 'and no breakdown is invented');
|
|
assert.strictEqual(readGateMessage(shipped.stdout).unparsed, true);
|
|
});
|
|
});
|
|
|
|
describe('#3861 round 11 — a malformed report does not read as clean in the counting arm', () => {
|
|
// A REVIEW.md with three criticals and an UNTERMINATED frontmatter yielded REVIEW_STATUS='',
|
|
// and the counting arm then printed nothing -- byte-identical to a clean review. Block 2 still
|
|
// said `status: none` rather than `clean`, so a careful reader could separate them downstream,
|
|
// which is the only reason this was Minor. The counting arm now distinguishes 'no findings'
|
|
// from 'could not parse', and block 2 names the same distinction ('unparsed' vs 'none').
|
|
const UNTERMINATED = ['---', 'phase: 01', 'status: issues_found', 'findings:',
|
|
' critical: 3', ' warning: 0', ' info: 0', ' total: 3', '',
|
|
'## Critical Issues', '', '### CR-01: a', '### CR-02: b', '### CR-03: c'].join('\n');
|
|
|
|
test('an unterminated frontmatter is reported as unparsed, not passed over in silence', { skip: !HAS_BASH }, () => {
|
|
const shipped = runShippedGateCounts({ reviewText: UNTERMINATED });
|
|
assert.strictEqual(shipped.exitCode, 0, 'advisory: a malformed review must not abort the step');
|
|
assert.match(shipped.stdout, /^Code review status unparsed: REVIEW\.md is present but its frontmatter has no parseable status; severity counts unavailable\.$/m);
|
|
assert.doesNotMatch(shipped.stdout, /^Code review: \d+ findings/m, 'a breakdown read from an unterminated block would be the body leak this scan prevents');
|
|
assert.doesNotMatch(shipped.stdout, /Consider running/, 'nothing here proves there are findings to fix');
|
|
assert.strictEqual(readGateMessage(shipped.stdout).unparsed, true);
|
|
});
|
|
|
|
test('a closed frontmatter with no status: key is the same arm', { skip: !HAS_BASH }, () => {
|
|
const noStatus = ['---', 'phase: 01', 'findings:', ' critical: 3', ' warning: 0', ' info: 0',
|
|
' total: 3', '---', '', '### CR-01: a'].join('\n');
|
|
const shipped = runShippedGateCounts({ reviewText: noStatus });
|
|
assert.match(shipped.stdout, /^Code review status unparsed: /m);
|
|
});
|
|
|
|
test('a review with no frontmatter at all is the same arm', { skip: !HAS_BASH }, () => {
|
|
const shipped = runShippedGateCounts({ reviewText: '# Phase 01\n\n### CR-01: a finding\n' });
|
|
assert.match(shipped.stdout, /^Code review status unparsed: /m);
|
|
});
|
|
|
|
test('the absent, directory and unreadable cases stay silent — nothing was read, so nothing is described', { skip: !HAS_BASH }, () => {
|
|
// The distinction is READ-ness, not emptiness: the three guard-refused shapes have no file
|
|
// content to describe, and describing one would be the guess the guard exists to prevent.
|
|
// (The mode-bit case is pinned by its own root-aware test above.)
|
|
assert.strictEqual(runShippedGateCounts({ reviewText: '', writeReview: false }).stdout, '', 'absent');
|
|
assert.strictEqual(runShippedGateCounts({ reviewText: UNTERMINATED, plantDir: true }).stdout, '', 'directory');
|
|
});
|
|
|
|
test('block 2 names the same distinction: unparsed for a read file, none for an absent one', { skip: !HAS_BASH }, () => {
|
|
const script = bashFences(fs.readFileSync(DISPOSITION_STEP_PATH, 'utf8'))[1];
|
|
const runBlock2 = (dir, padded) => runHook('-c', ['set -euo pipefail\n' + script + '\n'], {
|
|
interpreter: 'bash', timeoutMs: PROBE_TIMEOUT_MS,
|
|
env: { ...process.env, PHASE_DIR: dir, PHASE_NUMBER: String(Number(padded)) },
|
|
});
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3861-unparsed-'));
|
|
try {
|
|
// No REVIEW.md, no ledger, no fix report -> skipped (status: none)
|
|
let res = runBlock2(dir, '01');
|
|
assert.strictEqual(res.exitCode, 0);
|
|
assert.match(res.stdout, /^Code review disposition skipped \(status: none\)$/m, 'absent reads as none');
|
|
fs.writeFileSync(path.join(dir, '01-REVIEW.md'), UNTERMINATED);
|
|
res = runBlock2(dir, '01');
|
|
assert.strictEqual(res.exitCode, 0);
|
|
assert.match(res.stdout, /^Code review disposition skipped \(status: unparsed\)$/m, 'a read-but-unparseable review reads as unparsed');
|
|
assert.doesNotMatch(res.stdout, /status: none/);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('#3861 round 12 — block 2 does not compute a shortfall from a self-contradicting findings block', () => {
|
|
// Round 12, Minor. Block 1 withholds the severity breakdown unless the four counts are numeric
|
|
// AND `critical + warning + info == total`; block 2 bounded `total` for digits and length only
|
|
// and then handed it to the `unparsed:` reconciliation. So a REVIEW.md whose `findings:` block
|
|
// disagrees with itself made block 1 print the countless form -- breakdown suppressed as
|
|
// untrustworthy -- while block 2 still computed a shortfall from that same untrusted number.
|
|
// Two trust models for one field, one fence apart, with the weaker one downstream.
|
|
//
|
|
// The fix is NARROWER than "re-apply block 1's check", deliberately: block 1 demands all four
|
|
// counts because it DISPLAYS all four. Block 2 uses `total` alone. Applying the all-four rule
|
|
// here would blank a perfectly usable `total: 5` on a review carrying no severity keys and
|
|
// SILENTLY DROP a shortfall the step reports correctly today -- trading a safe-direction
|
|
// over-report for a silent under-report. Only the CONTRADICTION ports.
|
|
const headings = ['', '### CR-01: a conforming finding', '### WR-01: another', '### WR-02: a third'];
|
|
const review = (fm) => ['---', 'phase: 01', 'status: issues_found', ...fm, '---', ...headings].join('\n');
|
|
|
|
const runBlock2 = (dir) => {
|
|
const script = bashFences(fs.readFileSync(DISPOSITION_STEP_PATH, 'utf8'))[1];
|
|
return runHook('-c', ['set -euo pipefail\n' + script + '\n'], {
|
|
interpreter: 'bash', timeoutMs: PROBE_TIMEOUT_MS,
|
|
env: { ...process.env, PHASE_DIR: dir, PHASE_NUMBER: '1' },
|
|
});
|
|
};
|
|
const drive = (fm, status) => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3861-r12-'));
|
|
try {
|
|
const body = status === undefined ? review(fm)
|
|
: review(fm).replace('status: issues_found', 'status: ' + status);
|
|
fs.writeFileSync(path.join(dir, '01-REVIEW.md'), body);
|
|
const res = runBlock2(dir);
|
|
assert.strictEqual(res.exitCode, 0, 'the fence is advisory and must never abort: ' + res.stderr);
|
|
const p = path.join(dir, '01-REVIEW-DISPOSITION.md');
|
|
return { stdout: res.stdout, ledger: fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null };
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
};
|
|
|
|
test('a contradicting findings block yields no shortfall (fails before the fix)', { skip: !HAS_BASH }, () => {
|
|
// total: 10 against 1 + 1 + 1. Three headings parse. Before the fix this rendered
|
|
// `unparsed: 7` from a number block 1 had already judged untrustworthy.
|
|
const out = drive(['findings:', ' critical: 1', ' warning: 1', ' info: 1', ' total: 10']);
|
|
assert.doesNotMatch(out.ledger, /^unparsed:/m, 'a self-contradicting total is not a number to reconcile against');
|
|
assert.doesNotMatch(out.stdout, /recorded NOWHERE/);
|
|
assert.match(out.ledger, /^total: 3$/m, 'and the rows it does have are still reported');
|
|
});
|
|
|
|
test('a CONSISTENT total still reconciles — the check narrows nothing it should not', { skip: !HAS_BASH }, () => {
|
|
// 1 + 2 + 2 == 5, three headings parse, so two are recorded nowhere and must be said.
|
|
const out = drive(['findings:', ' critical: 1', ' warning: 2', ' info: 2', ' total: 5']);
|
|
assert.match(out.ledger, /^unparsed: 2$/m);
|
|
assert.match(out.stdout, /2 finding\(s\) recorded NOWHERE/);
|
|
});
|
|
|
|
test('a total with NO severity keys still reconciles — absent is not a contradiction', { skip: !HAS_BASH }, () => {
|
|
// The over-reach control. There is nothing for `total: 5` to disagree WITH here, so the
|
|
// shortfall this step reports correctly today must survive the new check.
|
|
const out = drive(['findings:', ' total: 5']);
|
|
assert.match(out.ledger, /^unparsed: 2$/m, 'absent counts must not suppress a real shortfall');
|
|
});
|
|
|
|
test('a partial or non-numeric breakdown is likewise not a contradiction', { skip: !HAS_BASH }, () => {
|
|
for (const fm of [
|
|
['findings:', ' critical: 1', ' total: 5'],
|
|
['findings:', ' critical: x', ' warning: 1', ' info: 1', ' total: 5'],
|
|
]) {
|
|
assert.match(drive(fm).ledger, /^unparsed: 2$/m, JSON.stringify(fm));
|
|
}
|
|
});
|
|
|
|
test('`blocker:` is read as the critical tier, exactly as block 1 reads it', { skip: !HAS_BASH }, () => {
|
|
// A mirror that dropped the documented alternation would diverge from block 1 on precisely
|
|
// the reviews that use it: 1 + 1 + 1 == 3 here, so the sum agrees and nothing is suppressed.
|
|
const out = drive(['findings:', ' blocker: 1', ' warning: 1', ' info: 1', ' total: 3']);
|
|
assert.doesNotMatch(out.ledger, /^unparsed:/m, 'blocker counted as critical => the sum agrees');
|
|
// And the contradicting twin, to prove the alternation is load-bearing rather than inert.
|
|
const bad = drive(['findings:', ' blocker: 1', ' warning: 1', ' info: 1', ' total: 9']);
|
|
assert.doesNotMatch(bad.ledger, /^unparsed:/m);
|
|
});
|
|
|
|
test('a severity with an INTERNAL space is malformed, and does not suppress the shortfall', { skip: !HAS_BASH }, () => {
|
|
// Found by the round's own adversarial pass, against the first version of this fix. The sibling
|
|
// reads use `cut -d: -f2 | tr -d ' '`, which deletes INTERNAL spaces too, so `critical: 1 0`
|
|
// arrives as the perfectly numeric `10`. That is long-standing in those reads and was INERT here
|
|
// until this block started reading the severities -- at which point a repaired number could
|
|
// satisfy the sum test and SUPPRESS a real `unparsed:` shortfall. The severity reads now trim the
|
|
// ends only, so the space survives, the digit check rejects it, and nothing is suppressed.
|
|
const out = drive(['findings:', ' critical: 1 0', ' warning: 0', ' info: 0', ' total: 5']);
|
|
assert.match(out.ledger, /^unparsed: 2$/m, 'a malformed severity must not license suppression');
|
|
});
|
|
|
|
test('a scalar with a SECOND COLON is malformed, and the well-formed total still reconciles', { skip: !HAS_BASH }, () => {
|
|
// Second adversarial pass. `cut -d: -f2` takes only the SECOND FIELD, so `critical: 1: junk`
|
|
// arrived as the perfectly numeric `1` -- 1+0+0 != 5 read as a contradiction and SUPPRESSED a
|
|
// shortfall that was genuinely owed. `-f2-` keeps everything after the first colon, so the
|
|
// malformed scalar stays malformed, no contradiction is claimed, and `total: 5` still reconciles.
|
|
const out = drive(['findings:', ' critical: 1: junk', ' warning: 0', ' info: 0', ' total: 5']);
|
|
assert.match(out.ledger, /^unparsed: 2$/m, 'a malformed severity must not license suppression');
|
|
});
|
|
|
|
test('a malformed TOTAL is not repaired into a fabricated shortfall', { skip: !HAS_BASH }, () => {
|
|
// Second adversarial pass, and the sharper of the two. The first version of this fix parsed the
|
|
// severities strictly and left `total` lenient -- so `critical: 5 0` with `total: 1 0` repaired
|
|
// ONLY the total to `10`, rejected the severity, skipped the contradiction check, and INVENTED
|
|
// `unparsed: 7` against three parsed headings. A field is either trustworthy or it is not:
|
|
// parsing one leniently and its sibling strictly is the shape that fabricates. Every count this
|
|
// block reads now goes through one parser.
|
|
const out = drive(['findings:', ' critical: 5 0', ' warning: 0', ' info: 0', ' total: 1 0']);
|
|
assert.doesNotMatch(out.ledger, /^unparsed:/m, 'a malformed total is not a number to reconcile against');
|
|
assert.doesNotMatch(out.stdout, /recorded NOWHERE/);
|
|
});
|
|
|
|
test('BLOCK 1 withholds a breakdown built from REPAIRED counts', { skip: !HAS_BASH }, () => {
|
|
// The one input that distinguishes block 1's old parser from its new one, and the reason this
|
|
// test exists: the round's first negative control for the block-1 change was VACUOUS. Reverting
|
|
// block 1 to `cut -d: -f2 | tr -d ' '` left the whole suite green, because on every fixture that
|
|
// existed both parsers landed on the SAME arm -- the countless form -- so a parity assertion
|
|
// could not see the difference.
|
|
//
|
|
// A SELF-CONSISTENT repaired breakdown separates them. `critical: 1 0` / `total: 1 0` repairs to
|
|
// 10 and 10, which SUM, so the old parser reported `10 findings -- 10 critical, 0 warning,
|
|
// 0 info.` from a `findings:` block that contains no such numbers. The new parser rejects the
|
|
// scalars and takes the countless arm, which is also what block 2 does with the same bytes --
|
|
// a console line and a ledger can no longer contradict each other on this input.
|
|
const repaired = ['---', 'phase: 01', 'status: issues_found', 'findings:',
|
|
' critical: 1 0', ' warning: 0', ' info: 0', ' total: 1 0', '---', '',
|
|
'### CR-01: a', '### WR-01: b', '### WR-02: c'].join('\n');
|
|
const shipped = runShippedGateCounts({ reviewText: repaired });
|
|
assert.strictEqual(shipped.exitCode, 0, 'advisory: never abort');
|
|
assert.doesNotMatch(shipped.stdout, /10 findings/, 'a repaired number must not be reported as a count');
|
|
assert.doesNotMatch(shipped.stdout, /10 critical/);
|
|
assert.match(shipped.stdout, /^Code review found issues\./m, 'the countless arm is the honest one here');
|
|
});
|
|
|
|
test('a status scalar carrying a second colon does not silently take the clean arm', { skip: !HAS_BASH }, () => {
|
|
// Fourth adversarial pass, MISSED. `status:` kept the retired `cut -d: -f2` after the counts moved
|
|
// off it, and it is the read where truncation costs most: the valid YAML scalar `status: clean:junk`
|
|
// arrived as the bare `clean`, so an unusable status took the CLEAN arm and suppressed both the
|
|
// report and the ledger. The whole scalar matches no arm, so the step reports instead.
|
|
const out = drive(['findings:', ' critical: 1', ' warning: 0', ' info: 0', ' total: 1'], 'clean:junk');
|
|
assert.ok(out.ledger !== null, 'an unusable status must not suppress the ledger');
|
|
});
|
|
|
|
test('a status with trailing unicode whitespace does not silently take the clean arm', { skip: !HAS_BASH }, () => {
|
|
// Fifth adversarial pass. The ledger half of the locale finding: unpinned, glibc's C.UTF-8 trimmed
|
|
// U+2003 and `status: clean<U+2003>` suppressed the ledger on exactly the machines whose locale
|
|
// said so. Pinned to C the scalar stays unusable, matches no arm, and the ledger is written.
|
|
const out = drive(['findings:', ' critical: 1', ' warning: 0', ' info: 0', ' total: 1'], 'clean\u2003');
|
|
assert.ok(out.ledger !== null, 'an unusable status must not suppress the ledger under any locale');
|
|
});
|
|
|
|
test('the parser is symmetric across all four count fields', { skip: !HAS_BASH }, () => {
|
|
// Third adversarial pass, MISSED: the fixtures exercised malformed `critical` and `total` only,
|
|
// so they did not actually pin the four-field symmetry the fix claims. Each field in turn gets
|
|
// each malformed shape; none may license suppression, because a malformed severity is not a
|
|
// disagreement and `total: 5` stays usable.
|
|
for (const key of ['critical', 'blocker', 'warning', 'info']) {
|
|
for (const bad of ['1 0', '1: junk']) {
|
|
const fm = ['findings:', ' total: 5'];
|
|
for (const k of ['critical', 'warning', 'info']) {
|
|
if (k === key || (key === 'blocker' && k === 'critical')) continue;
|
|
fm.push(` ${k}: 0`);
|
|
}
|
|
fm.push(` ${key}: ${bad}`);
|
|
assert.match(drive(fm).ledger, /^unparsed: 2$/m, `${key}: '${bad}' must not license suppression`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('an absent severity still proves a contradiction when the known ones OVERSHOOT', { skip: !HAS_BASH }, () => {
|
|
// Third adversarial pass. Counts are non-negative, so a missing one can only ADD: when the
|
|
// present severities already sum to MORE than `total`, the block disagrees with itself whatever
|
|
// the absent value is. Requiring all three before comparing reconciled against a total the
|
|
// present counts had already refuted.
|
|
const over = drive(['findings:', ' critical: 4', ' warning: 4', ' total: 5']);
|
|
assert.doesNotMatch(over.ledger, /^unparsed:/m, '4 + 4 > 5 is a contradiction with or without info:');
|
|
// The other direction stays reconcilable: an UNDERshoot is exactly what the absent count explains.
|
|
const under = drive(['findings:', ' critical: 1', ' warning: 1', ' total: 5']);
|
|
assert.match(under.ledger, /^unparsed: 2$/m, 'an undershoot is the absent count\'s job, not a contradiction');
|
|
});
|
|
|
|
test('a zero-padded breakdown does not take the advisory step down', { skip: !HAS_BASH }, () => {
|
|
// `10#` on every operand: bash reads a leading zero as octal, so `critical: 08` would make
|
|
// $(( )) fail with "value too great for base" and, under `set -e`, abort a step that
|
|
// promises never to block. 8 + 1 + 1 == 10, so the sum agrees and the shortfall is reported.
|
|
const out = drive(['findings:', ' critical: 08', ' warning: 01', ' info: 01', ' total: 10']);
|
|
assert.match(out.ledger, /^unparsed: 7$/m, 'octal-looking counts are read as decimal, not as an abort');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #3861 round 1 — Minor 5, and the finding-id census the review did not ask for
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const REVIEWER_AGENT_PATH = path.join(ROOT, 'agents', 'gsd-code-reviewer.md');
|
|
|
|
// The alternations the shipped script uses to recognise a finding id. There are three —
|
|
// the heading matcher, the ledger row re-parser, and the frontmatter `- id:` matcher —
|
|
// and adding a prefix to only some of them is silent. The severity map below is a fourth
|
|
// copy of the same set; it is not an alternation, so it is extracted separately.
|
|
// This scan is by PATTERN, never a fixed list of sites, which is why round 5's new
|
|
// matcher was absorbed with no edit here. Do not convert it to an enumeration.
|
|
function idAlternations() {
|
|
const script = shippedDispositionScript();
|
|
return [...script.matchAll(/\(\?:((?:[A-Z]{2}\|)+[A-Z]{2})\)-/g)].map((m) => m[1].split('|').sort().join('|'));
|
|
}
|
|
|
|
// The FOURTH copy: the severity map's keys. It is not an alternation, so the extractor above cannot
|
|
// see it — and a set that agrees in the three regexes while mis-tiering in the map is the drift the
|
|
// guard would otherwise miss entirely.
|
|
// (Said THIRD until round 6. The extractor finds three alternations — round 5's frontmatter `- id:`
|
|
// matcher is the third — so the map has been the fourth copy since then. The count is prose only;
|
|
// nothing below reads it.)
|
|
function severityMapKeys() {
|
|
const script = shippedDispositionScript();
|
|
const m = script.match(/\{([^}]*?)\}\[id\.split/);
|
|
assert.ok(m, 'the severity map must still be an inline object literal indexed by the id prefix');
|
|
return [...m[1].matchAll(/([A-Z]{2}):/g)].map((x) => x[1]);
|
|
}
|
|
|
|
describe('#3861 round 1 — stale fix reports are stated, not silently ignored', () => {
|
|
test('a fix report naming a different finding under a reused id says so', () => {
|
|
// Exact-title coupling is deliberate — ids are reused across re-reviews, so a
|
|
// stale REVIEW-FIX.md must not mark a brand-new CR-01 fixed. But failing it
|
|
// silently leaves 'open' indistinguishable from 'the report never named it',
|
|
// which is the one thing the ledger exists to tell apart.
|
|
const review = ['---', 'status: issues_found', '---', '', '### CR-01: a genuinely new finding'].join('\n');
|
|
const fixText = ['## Fixed Issues', '', '### CR-01: the finding this id used to mean'].join('\n');
|
|
const out = runShippedDisposition({ reviewText: review, fixText });
|
|
const rows = ledgerRows(out.ledger);
|
|
assert.strictEqual(rows[0].disposition, 'open', 'a stale report must not decide the row');
|
|
assert.match(out.stdout, /titles its finding differently from the review/,
|
|
'and the mismatch must be reported, not swallowed');
|
|
assert.match(out.stdout, /a stale report, or a re-titled one/,
|
|
'stated as the observation it is -- the step cannot tell the two causes apart');
|
|
assert.match(out.stdout, /CR-01/, 'naming the finding it could not reconcile');
|
|
});
|
|
|
|
test('a title differing only in INTRA-LINE whitespace still reconciles (m2)', () => {
|
|
// gsd-code-fixer.md writes '### {finding_id}: {title}' under no contract that the title is
|
|
// copied byte-for-byte, so a fixer that re-spaces a title used to produce a spurious note and
|
|
// leave a genuinely-fixed row 'open'. Runs of spaces carry no information; they are collapsed.
|
|
// NAMED PRECISELY. An earlier version of this test called itself the "reflowed" case while
|
|
// substituting triple spaces, which is not a reflow -- see the bound pinned below.
|
|
const title = 'a long finding title a fixer might re-space';
|
|
const review = ['---', 'status: issues_found', '---', '', '### CR-01: ' + title].join('\n');
|
|
const respaced = ['## Fixed Issues', '', '### CR-01: ' + title.replace(/ /g, ' ')].join('\n');
|
|
const out = runShippedDisposition({ reviewText: review, fixText: respaced });
|
|
assert.strictEqual(ledgerRows(out.ledger)[0].disposition, 'fixed',
|
|
'a re-spaced title is the same title, and the fix outcome must reach the ledger');
|
|
assert.doesNotMatch(out.stdout, /titles its finding differently/,
|
|
'and no spurious mismatch is reported');
|
|
});
|
|
|
|
test('a title WRAPPED across lines is not reconciled — the bound, pinned deliberately', () => {
|
|
// The limit of the m2 fix, stated rather than left to be discovered. A `###` heading is ONE
|
|
// line by definition: if a fixer wraps a long title, the continuation is a separate paragraph
|
|
// and the heading parser -- correctly -- captures only the first line. Whitespace collapsing
|
|
// cannot reach across that boundary.
|
|
//
|
|
// NOT widened, and the reason is that widening is the worse defect: to reconcile a wrapped
|
|
// title the parser would have to absorb whatever follows a heading into the title, which
|
|
// silently swallows arbitrary prose and would make the stale-report check meaningless. The
|
|
// failure mode kept here is the SAFE one -- a visible mismatch note and a row left open,
|
|
// never a wrong 'fixed'.
|
|
const review = ['---', 'status: issues_found', '---', '',
|
|
'### CR-01: a long finding title that wraps'].join('\n');
|
|
const wrapped = ['## Fixed Issues', '', '### CR-01: a long', 'finding title that wraps'].join('\n');
|
|
const out = runShippedDisposition({ reviewText: review, fixText: wrapped });
|
|
assert.strictEqual(ledgerRows(out.ledger)[0].disposition, 'open',
|
|
'a wrapped heading does not reconcile -- and fails in the safe direction');
|
|
assert.match(out.stdout, /titles its finding differently/,
|
|
'the mismatch is reported rather than swallowed');
|
|
});
|
|
|
|
test('a RE-CASED or truncated title still reports a mismatch — the strict half is kept', () => {
|
|
// The deliberate residual. Case changes and truncation are the shapes a genuinely different
|
|
// finding takes, so widening to them would trade a visible false positive for a silent false
|
|
// negative -- a stale report marking a brand-new CR-01 fixed, which is the worse direction.
|
|
const review = ['---', 'status: issues_found', '---', '', '### CR-01: The Finding'].join('\n');
|
|
const recased = ['## Fixed Issues', '', '### CR-01: the finding'].join('\n');
|
|
const out = runShippedDisposition({ reviewText: review, fixText: recased });
|
|
assert.strictEqual(ledgerRows(out.ledger)[0].disposition, 'open');
|
|
assert.match(out.stdout, /titles its finding differently/);
|
|
});
|
|
|
|
test('a matching fix report reports no mismatch', () => {
|
|
// Negative control for the note itself: it must not fire on the ordinary path.
|
|
const review = ['---', 'status: issues_found', '---', '', '### CR-01: same title'].join('\n');
|
|
const fixText = ['## Fixed Issues', '', '### CR-01: same title'].join('\n');
|
|
const out = runShippedDisposition({ reviewText: review, fixText });
|
|
assert.strictEqual(ledgerRows(out.ledger)[0].disposition, 'fixed');
|
|
assert.doesNotMatch(out.stdout, /reused id/);
|
|
});
|
|
});
|
|
|
|
describe('#3861 round 1 — finding-id prefix census', () => {
|
|
test('every copy of the prefix set agrees with every other', () => {
|
|
// The set is written out FOUR times in one script — the heading matcher, the ledger
|
|
// row re-parser, the frontmatter `- id:` matcher the title tracking added, and (by its
|
|
// keys) the severity map. Adding a prefix to some of them does not error; it drops
|
|
// carried rows on the next run.
|
|
// The count is stated for the reader; nothing below depends on it. idAlternations()
|
|
// scans the script by PATTERN rather than walking a fixed site list, which is why the
|
|
// fourth site was absorbed without a change here — this comment is the only thing that
|
|
// fell behind, and a guard whose population is hand-listed is the defect it would have
|
|
// been. Do not convert this to an enumeration.
|
|
const alts = idAlternations();
|
|
assert.ok(alts.length >= 2, 'the script must still enumerate finding-id prefixes');
|
|
assert.strictEqual(new Set(alts).size, 1, 'the prefix enumerations have drifted apart: ' + alts.join(' vs '));
|
|
// And the fourth copy, which is not an alternation: every prefix the regexes admit must either
|
|
// carry an explicit tier in the severity map or fall to `info` by the documented default.
|
|
// Without this, the three regexes can gain a prefix while the map silently mis-tiers it.
|
|
const mapped = new Set(severityMapKeys());
|
|
const admitted = alts[0].split('|');
|
|
const unmapped = admitted.filter((p) => !mapped.has(p));
|
|
assert.deepStrictEqual(
|
|
unmapped, ['IN'],
|
|
'only IN may rely on the info default; every other admitted prefix needs an explicit tier'
|
|
);
|
|
// And the other direction, which a one-way check leaves open: a tier for a prefix the
|
|
// regexes never admit is dead code that reads as coverage.
|
|
const unadmitted = [...mapped].filter((p) => admitted.indexOf(p) === -1);
|
|
assert.deepStrictEqual(
|
|
unadmitted, [],
|
|
'the severity map tiers prefixes the id regexes do not admit: ' + unadmitted.join(',')
|
|
);
|
|
});
|
|
|
|
test('the prefix set covers every id shape the reviewer agent emits', () => {
|
|
// The DOMAIN is owned elsewhere — gsd-code-reviewer.md's body template and its
|
|
// Label-equivalence paragraph — so it can acquire a member without this script
|
|
// changing. An unlisted prefix is not mis-tiered, it is INVISIBLE: the finding
|
|
// never enters the order list and gets no row at all.
|
|
const agent = fs.readFileSync(REVIEWER_AGENT_PATH, 'utf8');
|
|
// BOTH surfaces. The body template writes `### CR-01:` headings; the Label-equivalence
|
|
// paragraph defines BL in PROSE and appears in no heading at all (`### BL-` occurs zero
|
|
// times). A heading-only scan therefore passes today purely because BL happens to be
|
|
// hard-coded, and would miss the next prose-defined prefix exactly as it would miss BL.
|
|
const emitted = new Set([
|
|
...[...agent.matchAll(/^###\s+([A-Z]+)-\d+:/gm)].map((m) => m[1]),
|
|
...[...agent.matchAll(/\b([A-Z]+)-\s*(?:IDs?|prefix)/g)].map((m) => m[1]),
|
|
...[...agent.matchAll(/IDs? beginning with\s+`?([A-Z]+)-/g)].map((m) => m[1]),
|
|
]);
|
|
assert.ok(emitted.size > 0, 'the reviewer agent must still declare its finding-id shapes');
|
|
const known = new Set(idAlternations()[0].split('|'));
|
|
for (const prefix of emitted) {
|
|
assert.ok(known.has(prefix), prefix + '- findings would get no disposition row at all');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #3861 round 1, second pass — defects found by adversarially reviewing the
|
|
// round's OWN fixes before pushing them. Every one of these was invisible to
|
|
// the maintainer's review and to the first pass above.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('#3861 round 1 — fence tracking, status gating, count consistency', () => {
|
|
test('a foreign fence marker inside a fenced example does not swap example for finding', () => {
|
|
// The worst shape this file has carried: a bare fenced/not-fenced toggle treats ``` and ~~~
|
|
// as interchangeable, so a ~~~ line inside a ``` example CLOSES the fence and the example's
|
|
// real close REOPENS one. The ledger then records the ILLUSTRATION and drops the finding —
|
|
// a confidently-written artifact that is wrong in both directions at once.
|
|
const review = ['---', 'status: issues_found', '---', '', '```', '~~~',
|
|
'### CR-77: an example inside a fence', '```', '', '### CR-01: a real finding'].join('\n');
|
|
const rows = ledgerRows(runShippedDisposition({ reviewText: review }).ledger);
|
|
assert.deepStrictEqual(rows.map((r) => r.id), ['CR-01'], 'the real finding, and only it');
|
|
});
|
|
|
|
test('a longer close does not require an exact-length match, per CommonMark', () => {
|
|
const review = ['---', 'status: issues_found', '---', '', '```',
|
|
'### CR-77: fenced', '````', '', '### CR-01: real'].join('\n');
|
|
const rows = ledgerRows(runShippedDisposition({ reviewText: review }).ledger);
|
|
assert.deepStrictEqual(rows.map((r) => r.id), ['CR-01']);
|
|
});
|
|
|
|
test('the disposition block gates on review status in shell, not in prose', { skip: !HAS_BASH }, () => {
|
|
// Block 1 computes REVIEW_STATUS and emits nothing, and its shell is discarded — so a
|
|
// condition stated only in the prose between the blocks is not available to anything. A
|
|
// clean re-review would otherwise rewrite a ledger it was never meant to touch.
|
|
const fences = bashFences(fs.readFileSync(DISPOSITION_STEP_PATH, 'utf8'));
|
|
assert.ok(fences.length >= 2, 'the step must still carry more than one shell block');
|
|
assert.match(fences[1], /REVIEW_STATUS/, 'block 2 must re-derive the status it is gated on');
|
|
assert.match(fences[1], /clean\|skipped/, 'and gate on the documented clean/skipped/empty set');
|
|
});
|
|
|
|
test('an internally inconsistent breakdown is withheld, not half-rendered', { skip: !HAS_BASH }, () => {
|
|
// `total: 0` beside `critical: 1` is four valid numbers producing a self-contradicting
|
|
// line. Numeric is necessary, not sufficient.
|
|
const shipped = runShippedGateCounts({
|
|
reviewText: ['---', 'findings:', ' critical: 1', ' warning: 0', ' info: 0',
|
|
' total: 0', 'status: issues_found', '---'].join('\n'),
|
|
});
|
|
assert.strictEqual(readGateMessage(shipped.stdout).countsOk, '0',
|
|
'the counts do not sum to the total, so no breakdown');
|
|
});
|
|
|
|
test('a consistent breakdown is still reported', { skip: !HAS_BASH }, () => {
|
|
// Negative control for the sum check — it must not withhold a correct breakdown.
|
|
const shipped = runShippedGateCounts({ reviewText: REVIEW_WITH_FINDINGS });
|
|
assert.strictEqual(readGateMessage(shipped.stdout).countsOk, '1');
|
|
});
|
|
|
|
test('a carried finding that REAPPEARS loses the carried marker', () => {
|
|
// The defect that storing the cell verbatim introduced, and the reason the strip is back.
|
|
// Run 1 carries CR-01 and marks it; run 2 reports CR-01 again. If the marker were permanent
|
|
// the ledger would state 'not in the current review' about a finding plainly in it —
|
|
// an artifact confidently wrong about its own contents.
|
|
const absent = ['---', 'status: issues_found', '---', '', '### WR-01: other'].join('\n');
|
|
const back = ['---', 'status: issues_found', '---', '', '### CR-01: it came back'].join('\n');
|
|
const prior = '| CR-01 | critical | deferred | waiting on ADR-9 |';
|
|
const run1 = runShippedDisposition({ reviewText: absent, priorText: prior });
|
|
assert.strictEqual(
|
|
ledgerRows(run1.ledger).find((r) => r.id === 'CR-01').source,
|
|
'waiting on ADR-9 (not in the current review)', 'carried, and marked as such'
|
|
);
|
|
const run2 = runShippedDisposition({ reviewText: back, priorText: run1.ledger });
|
|
const row = ledgerRows(run2.ledger).find((r) => r.id === 'CR-01');
|
|
assert.strictEqual(row.source, 'waiting on ADR-9', 'the marker goes when the finding returns');
|
|
assert.strictEqual(row.disposition, 'deferred', 'and the decision itself is still preserved');
|
|
});
|
|
|
|
test('a carried row still does not grow its marker across runs', () => {
|
|
// The property the strip exists for, re-pinned now that it is bounded.
|
|
const review = ['---', 'status: issues_found', '---', '', '### WR-01: unrelated'].join('\n');
|
|
const prior = '| CR-01 | critical | deferred | waiting on ADR-9 (not in the current review) |';
|
|
const first = runShippedDisposition({ reviewText: review, priorText: prior });
|
|
const carried = ledgerRows(first.ledger).find((r) => r.id === 'CR-01');
|
|
assert.strictEqual(carried.source, 'waiting on ADR-9 (not in the current review)');
|
|
const second = runShippedDisposition({ reviewText: review, priorText: first.ledger });
|
|
assert.strictEqual(
|
|
ledgerRows(second.ledger).find((r) => r.id === 'CR-01').source,
|
|
'waiting on ADR-9 (not in the current review)',
|
|
'exactly one marker, however many times the gate runs'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #3861 round 1, third pass — the fidelity gap that let the second pass ship a
|
|
// regression the whole suite was green over.
|
|
//
|
|
// Every test above extracts the embedded script as TEXT and runs it. Bash does
|
|
// not: it expands the double-quoted `node -e "..."` argument first, so an
|
|
// unescaped backtick is COMMAND SUBSTITUTION and the script Node receives is
|
|
// not the script the tests read. That is not a hypothetical — the previous
|
|
// commit shipped exactly that, in a code comment, and 122 green tests said
|
|
// nothing because none of them ever asked bash what it would actually pass.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('#3861 round 1 — the tests must run what BASH would run', () => {
|
|
test('bash expansion of the node -e argument matches what the tests extract', { skip: !HAS_BASH }, () => {
|
|
// Ask bash for the literal argument it would hand node, and compare. This is the general
|
|
// guard: it catches an unescaped backtick, an unescaped $, and any other expansion the
|
|
// extractor's two-escape undo cannot model — none of which the behavioural tests can see.
|
|
const src = fs.readFileSync(DISPOSITION_STEP_PATH, 'utf8').replace(/\r\n/g, '\n');
|
|
const open = src.indexOf('node -e "');
|
|
const body = src.slice(open + 'node -e "'.length);
|
|
const end = body.indexOf('\n" || echo ');
|
|
assert.ok(end !== -1, 'the node -e script must still be closed by its || echo fallback');
|
|
const quoted = body.slice(0, end);
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3861-fid-'));
|
|
try {
|
|
const out = path.join(dir, 'arg.txt');
|
|
// `printf %s` with the SAME double-quoted string the step uses: whatever bash does to it
|
|
// on the way to node, it does here too.
|
|
const probe = 'printf %s "' + quoted + '" > ' + JSON.stringify(out) + '\n';
|
|
const res = runHook('-c', [probe], { interpreter: 'bash', timeoutMs: PROBE_TIMEOUT_MS });
|
|
assert.strictEqual(res.outcome, OUTCOME.EXITED, 'the probe must run to completion');
|
|
assert.strictEqual(
|
|
res.stderr.trim(), '',
|
|
'bash emitted diagnostics expanding the node -e argument — an unescaped backtick or $: ' + res.stderr
|
|
);
|
|
assert.strictEqual(
|
|
fs.readFileSync(out, 'utf8'), shippedDispositionScript(),
|
|
'bash hands node a DIFFERENT script than the tests exercise'
|
|
);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
// Run ONLY the status guard at the head of the disposition block — everything up to the shim
|
|
// preamble. runShippedDisposition drives the node script directly and never sees this shell at
|
|
// all, so a test written against it says nothing about the guard: it passed unchanged with the
|
|
// guard made unconditional, which is exactly the vacuity this helper exists to remove.
|
|
function runDispositionGuard({ reviewText, withLedger, withFix, withIterFix }) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3861-grd-'));
|
|
try {
|
|
fs.writeFileSync(path.join(dir, '01-REVIEW.md'), reviewText);
|
|
if (withLedger) fs.writeFileSync(path.join(dir, '01-REVIEW-DISPOSITION.md'), '| CR-01 | critical | deferred | x |\n');
|
|
if (withFix) fs.writeFileSync(path.join(dir, '01-REVIEW-FIX.md'), '## Fixed Issues\n\n### CR-01: a thing\n');
|
|
if (withIterFix) fs.writeFileSync(path.join(dir, '01-REVIEW-FIX.iter2.md'), '## Fixed Issues\n\n### CR-01: a thing\n');
|
|
const fence = bashFences(fs.readFileSync(DISPOSITION_STEP_PATH, 'utf8'))[1];
|
|
const cut = fence.indexOf('_GSD_SHIM_NAME=');
|
|
assert.ok(cut > 0, 'the disposition block must still open with its guard, then the shim');
|
|
const script = 'set -euo pipefail\n' + fence.slice(0, cut) + '\nprintf "PROCEEDED\\n"\n';
|
|
const res = runHook('-c', [script], {
|
|
interpreter: 'bash',
|
|
timeoutMs: PROBE_TIMEOUT_MS,
|
|
env: { ...process.env, PHASE_DIR: dir, PHASE_NUMBER: '1' },
|
|
});
|
|
assert.strictEqual(res.outcome, OUTCOME.EXITED, 'the guard must run to completion');
|
|
return { proceeded: /PROCEEDED/.test(res.stdout), stdout: res.stdout, exitCode: res.exitCode };
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
}
|
|
|
|
test('a clean review with no ledger is skipped', { skip: !HAS_BASH }, () => {
|
|
const out = runDispositionGuard({ reviewText: ['---', 'status: clean', '---'].join('\n') });
|
|
assert.strictEqual(out.proceeded, false, 'nothing to record and nothing to reconcile');
|
|
assert.match(out.stdout, /skipped \(status: clean\)/);
|
|
});
|
|
|
|
test('a clean review with an EXISTING ledger still proceeds, to reconcile it', { skip: !HAS_BASH }, () => {
|
|
// Freezing the ledger here would leave findings showing as open that the review no longer
|
|
// reports — the case the embedded script's own reconciliation path is written for. A guard
|
|
// that skipped unconditionally would make that path unreachable on exactly the run needing it.
|
|
const out = runDispositionGuard({ reviewText: ['---', 'status: clean', '---'].join('\n'), withLedger: true });
|
|
assert.strictEqual(out.proceeded, true, 'an existing ledger must still be reconciled');
|
|
assert.match(out.stdout, /reconciling the fix report and any existing disposition ledger/);
|
|
});
|
|
|
|
test('a review reporting issues always proceeds', { skip: !HAS_BASH }, () => {
|
|
const out = runDispositionGuard({ reviewText: ['---', 'status: issues_found', '---'].join('\n') });
|
|
assert.strictEqual(out.proceeded, true);
|
|
});
|
|
|
|
// #3861 round 5 — a converged `--auto` run reaches the guard with a CLEAN review and, on a direct
|
|
// /gsd-code-review invocation, no gate-written ledger. Keying the skip on the ledger alone meant a
|
|
// fully successful multi-iteration run — every finding fixed and committed — recorded nothing at all.
|
|
test('a clean review with NO ledger but a fix report still proceeds', { skip: !HAS_BASH }, () => {
|
|
const out = runDispositionGuard({ reviewText: ['---', 'status: clean', '---'].join('\n'), withFix: true });
|
|
assert.strictEqual(out.proceeded, true, 'a fix report is a decision to record, ledger or not');
|
|
assert.match(out.stdout, /reconciling the fix report/);
|
|
});
|
|
|
|
test('a clean review with NO ledger but only an ITERATION fix report still proceeds', { skip: !HAS_BASH }, () => {
|
|
// The converged loop's earlier iterations survive only as <NN>-REVIEW-FIX.iterN.md, so the
|
|
// backups have to count toward the guard exactly as the final report does.
|
|
const out = runDispositionGuard({ reviewText: ['---', 'status: clean', '---'].join('\n'), withIterFix: true });
|
|
assert.strictEqual(out.proceeded, true, 'an iteration backup is a decision to record too');
|
|
});
|
|
|
|
test('a clean review with neither a ledger nor any fix report is still skipped', { skip: !HAS_BASH }, () => {
|
|
// The widening must not become "always proceed" — the original skip is still correct when
|
|
// there is genuinely nothing to record.
|
|
const out = runDispositionGuard({ reviewText: ['---', 'status: clean', '---'].join('\n') });
|
|
assert.strictEqual(out.proceeded, false);
|
|
assert.match(out.stdout, /skipped \(status: clean\)/);
|
|
});
|
|
|
|
// ── #3861 round 5 — the --auto multi-iteration reconciliation gap ──────────────────────────
|
|
//
|
|
// code-review-fix.md overwrites REVIEW-FIX.md on every iteration and DELETES the .iterN.md
|
|
// backups on convergence, so a finding fixed in iteration 1 was absent from the final fix report
|
|
// AND from the final review (it was fixed, so the re-review stopped reporting it). The step then
|
|
// fell back to the gate's `open` row and rendered `open ... (not in the current review)` — the
|
|
// same bytes a finding that vanished for an unrelated reason produces.
|
|
|
|
test('a fix report naming a finding the review no longer reports records it FIXED, not open', () => {
|
|
// The precise site: sameTitle(undefined, h.title) is false and title.has(id) is false too, so
|
|
// the entry entered NEITHER applied NOR staleFix and was dropped in silence.
|
|
const review = ['---', 'status: issues_found', '---', '', '### WR-09: something else'].join('\n');
|
|
const fix = ['## Fixed Issues', '', '### CR-01: the one fixed earlier'].join('\n');
|
|
const out = runShippedDisposition({ reviewText: review, fixText: fix });
|
|
const rows = ledgerRows(out.ledger);
|
|
const cr = rows.find((r) => r.id === 'CR-01');
|
|
assert.ok(cr, 'the fixed finding must have a row at all');
|
|
assert.strictEqual(cr.disposition, 'fixed', 'a committed fix must not render as open');
|
|
assert.match(cr.source, /not in the current review/, 'and it must be marked as no longer reported');
|
|
});
|
|
|
|
test('an ITERATION fix report is reconciled even when the final report has moved on', () => {
|
|
// The reviewer's scenario end to end: iteration 1 fixed CR-01, iteration 3 fixed WR-09, and the
|
|
// final REVIEW-FIX.md carries only the last iteration's scope.
|
|
const review = ['---', 'status: issues_found', '---', '', '### IN-07: still open'].join('\n');
|
|
const fix = ['## Fixed Issues', '', '### WR-09: fixed last'].join('\n');
|
|
const iter = { 2: ['## Fixed Issues', '', '### CR-01: fixed in iteration one'].join('\n') };
|
|
const out = runShippedDisposition({ reviewText: review, fixText: fix, iterFixText: iter });
|
|
const rows = ledgerRows(out.ledger);
|
|
assert.strictEqual(rows.find((r) => r.id === 'CR-01').disposition, 'fixed');
|
|
assert.strictEqual(rows.find((r) => r.id === 'WR-09').disposition, 'fixed');
|
|
assert.strictEqual(rows.find((r) => r.id === 'IN-07').disposition, 'open');
|
|
});
|
|
|
|
test('the NEWEST fix report wins when two iterations decide the same id differently', () => {
|
|
// Reports are read newest-first, so first-occurrence-wins gives the most recent statement —
|
|
// the same precedence a duplicate id already gets WITHIN one report.
|
|
const review = ['---', 'status: issues_found', '---', '', '### IN-07: unrelated'].join('\n');
|
|
const fix = ['## Skipped Issues', '', '### CR-01: contested'].join('\n');
|
|
const iter = { 2: ['## Fixed Issues', '', '### CR-01: contested'].join('\n') };
|
|
const out = runShippedDisposition({ reviewText: review, fixText: fix, iterFixText: iter });
|
|
assert.strictEqual(ledgerRows(out.ledger).find((r) => r.id === 'CR-01').disposition, 'skipped');
|
|
});
|
|
|
|
test('a REUSED id whose title differs is NOT inherited as fixed — the stale-report arm still rules', () => {
|
|
// The negative control for the arm added above. Re-review renumbers, so an earlier iteration's
|
|
// CR-01 and the current review's CR-01 can be different findings; carrying the decision across
|
|
// that boundary would render a false `fixed`, which is worse than the `open` it replaced.
|
|
const review = ['---', 'status: issues_found', '---', '', '### CR-01: a brand new finding'].join('\n');
|
|
const iter = { 2: ['## Fixed Issues', '', '### CR-01: an older, different finding'].join('\n') };
|
|
const out = runShippedDisposition({ reviewText: review, iterFixText: iter });
|
|
const cr = ledgerRows(out.ledger).find((r) => r.id === 'CR-01');
|
|
assert.strictEqual(cr.disposition, 'open', 'a different finding under a reused id must stay open');
|
|
assert.match(out.stdout, /title their finding differently|titles its finding differently/,
|
|
'and the mismatch must be stated, not swallowed');
|
|
});
|
|
|
|
// ── #3861 round 5 rework — a REUSED finding id must not inherit the old finding's decision ──
|
|
//
|
|
// Found by the round's own adversarial review, which drove it: ids are reused across re-reviews
|
|
// (the --auto loop renumbers), and row() inherited a prior decision on an id match alone. A prior
|
|
// 'CR-01 fixed' against a review reporting a brand-new CR-01 rendered the NEW finding `fixed` — a
|
|
// false decision in the artifact whose entire purpose is telling triaged from forgotten.
|
|
|
|
test('the ledger does not promise a preservation it no longer makes', () => {
|
|
// The rendered text said "preserves every row and every disposition" while the step had gained
|
|
// an intentional drop for a reused id — shipped, user-facing text asserting something false.
|
|
// And the console must not point at git: committing is gated on commit_docs and a failed commit
|
|
// is swallowed, so under commit_docs=false the overwritten decision may exist nowhere.
|
|
// Round 5 found this test VACUOUS: it ran with no prior ledger, so no reuse ever occurred and
|
|
// the `is in git` assertion could not have failed however the console was worded. Driven through
|
|
// a real drop now, so the negative assertion is made against a console line that actually exists.
|
|
const seed = runShippedDisposition({
|
|
reviewText: ['---', 'status: issues_found', '---', '', '### CR-01: the original finding'].join('\n'),
|
|
priorText: '| CR-01 | critical | deferred | waiting on the vendor |\n',
|
|
});
|
|
const out = runShippedDisposition({
|
|
reviewText: ['---', 'status: issues_found', '---', '', '### CR-01: a brand new finding'].join('\n'),
|
|
priorText: seed.ledger,
|
|
});
|
|
assert.match(out.stdout, /decision\(s\) DROPPED/, 'precondition: this run must actually drop a decision');
|
|
assert.doesNotMatch(out.ledger, /preserves every row and every disposition/,
|
|
'the unqualified preservation promise must not return');
|
|
assert.match(out.ledger, /id is REUSED/, 'and the one exception must be stated where a reader meets it');
|
|
assert.doesNotMatch(out.stdout, /is in git/, 'the console must not assert a recovery path that may not exist');
|
|
});
|
|
|
|
test('an `open` prior is replaced SILENTLY, and the shipped text says so', () => {
|
|
// Round 5: the legend and both feature docs claimed the drop is named on the console
|
|
// unconditionally. It is not — `row()` reports only a RECORDED decision (`was.d !== 'open'`).
|
|
// The behaviour is deliberate (an `open` row records no decision to lose); the text was wrong.
|
|
const seed = runShippedDisposition({
|
|
reviewText: ['---', 'status: issues_found', '---', '', '### CR-01: the original finding'].join('\n'),
|
|
});
|
|
assert.strictEqual(ledgerRows(seed.ledger).find((r) => r.id === 'CR-01').disposition, 'open');
|
|
const out = runShippedDisposition({
|
|
reviewText: ['---', 'status: issues_found', '---', '', '### CR-01: a brand new finding'].join('\n'),
|
|
priorText: seed.ledger,
|
|
});
|
|
assert.doesNotMatch(out.stdout, /DROPPED/, 'an untriaged prior row is replaced without a report');
|
|
assert.match(out.ledger, /a row still at `open` is replaced silently/,
|
|
'and the legend must state that exception rather than promising an unconditional report');
|
|
});
|
|
|
|
test('a dropped decision is REPORTED on the console, not lost quietly', () => {
|
|
// The `superseded:` block that preserved these was tried and withdrawn — it produced a fresh
|
|
// defect on each of three review passes. What survives is the guard (no false `fixed`) plus an
|
|
// explicit report; the prior ledger row remains in git.
|
|
const seed = runShippedDisposition({
|
|
reviewText: ['---', 'status: issues_found', '---', '', '### CR-01: the original finding'].join('\n'),
|
|
priorText: '| CR-01 | critical | deferred | waiting on the vendor |\n',
|
|
});
|
|
const out = runShippedDisposition({
|
|
reviewText: ['---', 'status: issues_found', '---', '', '### CR-01: a brand new finding'].join('\n'),
|
|
priorText: seed.ledger,
|
|
});
|
|
assert.strictEqual(ledgerRows(out.ledger).find((r) => r.id === 'CR-01').disposition, 'open');
|
|
assert.match(out.stdout, /decision\(s\) DROPPED/, 'the drop must be stated');
|
|
assert.match(out.stdout, /CR-01=deferred/, 'naming the id and what was decided');
|
|
assert.doesNotMatch(out.ledger, /^superseded:/m, 'and no preservation block is written');
|
|
});
|
|
|
|
test('a prior decision is NOT inherited when the id now names a different finding', () => {
|
|
const first = runShippedDisposition({
|
|
reviewText: ['---', 'status: issues_found', '---', '', '### CR-01: the original finding'].join('\n'),
|
|
fixText: ['## Fixed Issues', '', '### CR-01: the original finding'].join('\n'),
|
|
});
|
|
assert.strictEqual(ledgerRows(first.ledger).find((r) => r.id === 'CR-01').disposition, 'fixed');
|
|
assert.match(first.ledger, /title: "the original finding"/, 'the title must be recorded to make reuse detectable');
|
|
|
|
const second = runShippedDisposition({
|
|
reviewText: ['---', 'status: issues_found', '---', '', '### CR-01: a brand new finding'].join('\n'),
|
|
priorText: first.ledger,
|
|
});
|
|
const cr = ledgerRows(second.ledger).find((r) => r.id === 'CR-01');
|
|
assert.strictEqual(cr.disposition, 'open', 'a different finding under a reused id must be untriaged');
|
|
});
|
|
|
|
|
|
|
|
test('an EMPTY title is recorded, so it cannot read back as a pre-format ledger', () => {
|
|
// The leak that came back three passes running: while an empty title emitted no `title:` key,
|
|
// an empty-titled finding read back as legacy and inherited a decision across a reused id.
|
|
const first = runShippedDisposition({
|
|
reviewText: ['---', 'status: issues_found', '---', '', '### IN-07: unrelated'].join('\n'),
|
|
iterFixText: { 2: ['## Fixed Issues', '', '### CR-01:'].join('\n') },
|
|
});
|
|
assert.match(first.ledger, /title: ""/, 'an empty title is still recorded, explicitly');
|
|
const second = runShippedDisposition({
|
|
reviewText: ['---', 'status: issues_found', '---', '', '### CR-01: a brand new finding'].join('\n'),
|
|
priorText: first.ledger,
|
|
});
|
|
assert.strictEqual(ledgerRows(second.ledger).find((r) => r.id === 'CR-01').disposition, 'open',
|
|
'an empty recorded title is a title, not an absent one');
|
|
});
|
|
|
|
test('a pre-format ledger whose title merely LOOKS like JSON keeps its quotes', () => {
|
|
// Without the `titles: json` marker, JSON.parse ran on every value — so a legacy bare title
|
|
// written as "quoted whole title" lost its quotes, stopped matching, and flipped to open.
|
|
const out = runShippedDisposition({
|
|
reviewText: ['---', 'status: issues_found', '---', '', '### CR-01: "quoted whole title"'].join('\n'),
|
|
priorText: [
|
|
'---', 'phase: 01', 'review: 01-REVIEW.md', 'findings:',
|
|
' - id: CR-01', ' severity: critical', ' disposition: deferred',
|
|
' title: "quoted whole title"', 'open: 0', 'total: 1', 'recorded: x', '---', '',
|
|
'| CR-01 | critical | deferred | a reason |',
|
|
].join('\n'),
|
|
});
|
|
const cr = ledgerRows(out.ledger).find((r) => r.id === 'CR-01');
|
|
assert.strictEqual(cr.disposition, 'deferred', 'the legacy title must still identify the finding');
|
|
assert.doesNotMatch(out.stdout, /DROPPED/, 'and no drop may be reported');
|
|
});
|
|
|
|
test('a prior ledger with NO recorded title still inherits its decision (back-compat)', () => {
|
|
// A ledger written before titles were recorded carries none. Refusing to inherit there would
|
|
// reset every decision in it — the loss this guard exists to prevent, caused by the guard.
|
|
const out = runShippedDisposition({
|
|
reviewText: ['---', 'status: issues_found', '---', '', '### CR-01: whatever it is called now'].join('\n'),
|
|
priorText: '| CR-01 | critical | deferred | a reason from an older ledger |\n',
|
|
});
|
|
const cr = ledgerRows(out.ledger).find((r) => r.id === 'CR-01');
|
|
assert.strictEqual(cr.disposition, 'deferred', 'an absent prior title must inherit, not reset');
|
|
assert.strictEqual(cr.source, 'a reason from an older ledger');
|
|
});
|
|
|
|
test('an iteration-derived decision cites the report it actually came from', () => {
|
|
// The Source cell hard-coded the unsuffixed <NN>-REVIEW-FIX.md, so a decision read out of an
|
|
// iteration backup cited a file that may not exist. A citation the reader cannot follow is
|
|
// worse than none.
|
|
const out = runShippedDisposition({
|
|
reviewText: ['---', 'status: issues_found', '---', '', '### IN-07: unrelated'].join('\n'),
|
|
iterFixText: { 2: ['## Fixed Issues', '', '### CR-01: fixed in iteration one'].join('\n') },
|
|
});
|
|
const cr = ledgerRows(out.ledger).find((r) => r.id === 'CR-01');
|
|
assert.strictEqual(cr.disposition, 'fixed');
|
|
assert.match(cr.source, /01-REVIEW-FIX\.iter2\.md/, 'the cited report must be the one that decided it');
|
|
});
|
|
|
|
// ── #3861 round 5, second rework pass — four defects the review drove out of the FIRST fix ──
|
|
|
|
test('an iteration-only decision records the title it was decided under', () => {
|
|
// Without this the row was written with NO title -- the current review does not report the
|
|
// finding, so nothing else knows one -- and the next review reusing that id then hit the
|
|
// title-ABSENT back-compat exception and inherited the old `fixed`. The very defect the title
|
|
// machinery exists to close, surviving through the hole opened for legacy ledgers.
|
|
const first = runShippedDisposition({
|
|
reviewText: ['---', 'status: issues_found', '---', '', '### IN-07: unrelated'].join('\n'),
|
|
iterFixText: { 2: ['## Fixed Issues', '', '### CR-01: the original finding'].join('\n') },
|
|
});
|
|
assert.match(first.ledger, /title: "the original finding"/, 'the deciding title must be recorded');
|
|
|
|
const second = runShippedDisposition({
|
|
reviewText: ['---', 'status: issues_found', '---', '', '### CR-01: a brand new finding'].join('\n'),
|
|
priorText: first.ledger,
|
|
});
|
|
assert.strictEqual(ledgerRows(second.ledger).find((r) => r.id === 'CR-01').disposition, 'open',
|
|
'a reused id must not inherit a decision recorded for a different finding');
|
|
});
|
|
|
|
|
|
|
|
test('the ledger frontmatter is valid YAML even when a title contains a colon', () => {
|
|
// `title: Parser: loses data` is not YAML — a real reader returns 'bad indentation of a mapping
|
|
// entry'. The values are emitted as JSON scalars, which YAML 1.2 reads as double-quoted strings.
|
|
const yaml = require('js-yaml');
|
|
const out = runShippedDisposition({
|
|
reviewText: ['---', 'status: issues_found', '---', '', '### CR-01: Parser: loses data'].join('\n'),
|
|
});
|
|
const fm = yaml.load(out.ledger.split('---')[1]);
|
|
assert.strictEqual(fm.findings[0].title, 'Parser: loses data', 'the colon must survive the round trip');
|
|
// And the title still round-trips through the parser as an identity, so a re-run is unchanged.
|
|
const again = runShippedDisposition({
|
|
reviewText: ['---', 'status: issues_found', '---', '', '### CR-01: Parser: loses data'].join('\n'),
|
|
priorText: out.ledger,
|
|
});
|
|
assert.strictEqual(again.wroteNothing, true, 'a second run must still report unchanged');
|
|
});
|
|
|
|
test('a carried human reason ending in the marker is preserved, and not doubled', () => {
|
|
// Neither stripped (the previous fix's residue) nor appended twice.
|
|
const review = ['---', 'status: issues_found', '---', '', '### WR-01: unrelated'].join('\n');
|
|
const prior = '| CR-01 | critical | deferred | defer because (not in the current review) |';
|
|
const first = runShippedDisposition({ reviewText: review, priorText: prior });
|
|
const carried = ledgerRows(first.ledger).find((r) => r.id === 'CR-01');
|
|
assert.strictEqual(carried.source, 'defer because (not in the current review)');
|
|
const second = runShippedDisposition({ reviewText: review, priorText: first.ledger });
|
|
assert.strictEqual(
|
|
ledgerRows(second.ledger).find((r) => r.id === 'CR-01').source,
|
|
'defer because (not in the current review)',
|
|
'stable across runs — the render appends nothing it can already see'
|
|
);
|
|
});
|
|
|
|
test('a leading-zero count is evaluated, not silently skipped', { skip: !HAS_BASH }, () => {
|
|
// Bash infers the base from a leading zero, so `critical: 08` made $(( )) fail. It does NOT
|
|
// abort — the expansion sits in an `if` condition, where set -e does not fire — which is
|
|
// worse: the sum check silently does not run, an inconsistent breakdown passes, and the
|
|
// only trace is a stray diagnostic on stderr. Asserting exit 0 alone is vacuous here; it
|
|
// was true before the fix too. Assert the check's OUTCOME and the absent diagnostic.
|
|
const padded = (total) => ['---', 'findings:', ' critical: 08', ' warning: 0',
|
|
' info: 0', ' total: ' + total, 'status: issues_found', '---'].join('\n');
|
|
const inconsistent = runShippedGateCounts({ reviewText: padded('9') });
|
|
assert.strictEqual(readGateMessage(inconsistent.stdout).countsOk, '0',
|
|
'08 + 0 + 0 is 8, not 9 \u2014 the check must FIRE');
|
|
assert.doesNotMatch(inconsistent.stderr, /value too great for base/,
|
|
'the padded count must be read as decimal, not left to base inference');
|
|
const consistent = runShippedGateCounts({ reviewText: padded('8') });
|
|
assert.strictEqual(consistent.exitCode, 0, 'an advisory gate must not abort on a padded count');
|
|
assert.strictEqual(readGateMessage(consistent.stdout).countsOk, '1',
|
|
'08 + 0 + 0 is 8, so the breakdown is consistent');
|
|
});
|
|
|
|
test('a fence indented past three spaces is not a fence', { skip: !HAS_BASH }, () => {
|
|
// CommonMark: at most three leading spaces open a fence; four is an indented code block.
|
|
const src = fs.readFileSync(DISPOSITION_STEP_PATH, 'utf8');
|
|
assert.match(src, /\^ \{0,3\}\(/, 'the fence matcher must bound its leading whitespace');
|
|
});
|
|
});
|
|
|
|
// Placement here is now incidental. This block used to be pinned to the end of the file because
|
|
// HAS_BASH was declared mid-file and a `skip` option referencing it from an earlier block hit the
|
|
// temporal dead zone -- cancelling its neighbours rather than failing visibly. HAS_BASH is
|
|
// declared with the file's other top-level constants now, so that constraint is gone and this
|
|
// block may be moved beside its siblings whenever someone is tidying.
|
|
describe('#3861 round 1 — count validation, executed', () => {
|
|
test('all four counts are validated before the breakdown is shown', { skip: !HAS_BASH }, () => {
|
|
// Was a `src.includes()` assertion on the exact `case` line, which broke the moment that
|
|
// line grew a length bound. The behaviour is what matters and is now executed directly.
|
|
const counts = (c, w, i, t) => ['---', 'findings:', ' critical: ' + c, ' warning: ' + w,
|
|
' info: ' + i, ' total: ' + t, 'status: issues_found', '---'].join('\n');
|
|
const ok = (t) => readGateMessage(runShippedGateCounts({ reviewText: t }).stdout).countsOk;
|
|
assert.strictEqual(ok(counts('x', '0', '0', '0')), '0',
|
|
'a non-numeric count withholds the whole breakdown');
|
|
assert.strictEqual(ok(counts('1', '2', '1', '4')), '1');
|
|
// Bash integers wrap at 2^64, so a 20-digit count reaches the sum as 0 and an inconsistent
|
|
// breakdown passes. Length-bounded, because no review reports nine digits of findings.
|
|
assert.strictEqual(ok(counts('18446744073709551616', '0', '0', '0')), '0',
|
|
'a count long enough to wrap the sum is not a count');
|
|
});
|
|
|
|
test('the count-length threshold is covered at limit-1, limit and limit+1', { skip: !HAS_BASH }, () => {
|
|
// M2. The guard is `?????????*` -- nine or more characters -- so the limit is 8 digits
|
|
// ACCEPTED, 9 REJECTED. The only cases here were 'x', single digits and a 20-digit value,
|
|
// none of which pins the boundary: dropping one `?` moves the limit to 7 digits and no test
|
|
// would have noticed. All three points are asserted, and the sum is kept consistent at each
|
|
// so the length rule is what decides the verdict rather than the sum check.
|
|
const counts = (c, w, i, t) => ['---', 'findings:', ' critical: ' + c, ' warning: ' + w,
|
|
' info: ' + i, ' total: ' + t, 'status: issues_found', '---'].join('\n');
|
|
const ok = (t) => readGateMessage(runShippedGateCounts({ reviewText: t }).stdout).countsOk;
|
|
const d = (n) => '1'.padEnd(n, '0'); // n digits, leading 1 so the value is exact
|
|
assert.strictEqual(d(7).length, 7);
|
|
assert.strictEqual(d(8).length, 8);
|
|
assert.strictEqual(d(9).length, 9);
|
|
assert.strictEqual(ok(counts(d(7), '0', '0', d(7))), '1', 'limit-1: 7 digits is accepted');
|
|
assert.strictEqual(ok(counts(d(8), '0', '0', d(8))), '1', 'limit: 8 digits is accepted');
|
|
assert.strictEqual(ok(counts(d(9), '0', '0', d(9))), '0', 'limit+1: 9 digits is rejected');
|
|
});
|
|
});
|
|
|
|
describe('#3861 round 1 — an absent review still reconciles', () => {
|
|
test('a missing REVIEW.md does not abandon an existing ledger', () => {
|
|
// The status guard proceeds when a ledger exists, so the script must tolerate the review
|
|
// being gone: reading it unconditionally threw, the trailing fallback swallowed it, and the
|
|
// ledger was left frozen — the exact freeze the reconciliation path exists to prevent.
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3861-abs-'));
|
|
try {
|
|
const dispPath = path.join(dir, '01-REVIEW-DISPOSITION.md');
|
|
fs.writeFileSync(dispPath, ['| CR-01 | critical | deferred | waiting on ADR-9 |',
|
|
'| WR-09 | warning | open | - |'].join('\n'));
|
|
const res = runNode(['-e', shippedDispositionScript()], {
|
|
timeoutMs: PROBE_TIMEOUT_MS,
|
|
env: {
|
|
...process.env,
|
|
REVIEW_FILE: path.join(dir, '01-REVIEW.md'), // deliberately absent
|
|
DISPOSITION_FILE: dispPath,
|
|
FIX_REPORT_FILE: path.join(dir, '01-REVIEW-FIX.md'),
|
|
PADDED: '01',
|
|
},
|
|
});
|
|
assert.strictEqual(res.outcome, OUTCOME.EXITED);
|
|
assert.strictEqual(res.exitCode, 0, 'an absent review is not an error: ' + res.stderr);
|
|
const rows = ledgerRows(fs.readFileSync(dispPath, 'utf8'));
|
|
assert.deepStrictEqual(rows.map((r) => r.id), ['CR-01', 'WR-09'],
|
|
'an absent review reconciles the ledger; it does not delete rows from it');
|
|
assert.match(rows[0].source, /not in the current review/);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('#3861 round 1 — fence closers and one-letter prefixes', () => {
|
|
test('a line with an info string is an opener shape, never a closer', () => {
|
|
// CommonMark: a closing fence carries only whitespace after its marker. Treating an
|
|
// info-string line as a close ends the fence early and admits the example headings under it.
|
|
const review = ['---', 'status: issues_found', '---', '', '```',
|
|
'```js', '### CR-77: still inside the fence', '```', '', '### CR-01: real'].join('\n');
|
|
const rows = ledgerRows(runShippedDisposition({ reviewText: review }).ledger);
|
|
assert.deepStrictEqual(rows.map((r) => r.id), ['CR-01']);
|
|
});
|
|
|
|
test('a one-letter finding prefix in the agent template is not invisible', () => {
|
|
// The domain scan required [A-Z]{2,}, so a template heading like `### C-01:` — explicit and
|
|
// parseable, not prose — contributed nothing and the guard passed over a finding shape the
|
|
// ledger would drop entirely.
|
|
const agent = fs.readFileSync(REVIEWER_AGENT_PATH, 'utf8');
|
|
const emitted = [...agent.matchAll(/^###\s+([A-Z]+)-\d+:/gm)].map((m) => m[1]);
|
|
assert.ok(emitted.length > 0, 'the reviewer agent must still declare its finding-id shapes');
|
|
const oneLetter = [...'### C-01: x'.matchAll(/^###\s+([A-Z]+)-\d+:/gm)].map((m) => m[1]);
|
|
assert.deepStrictEqual(oneLetter, ['C'], 'the scan must admit a single-letter prefix');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The REVIEW.md lookup's phase-id handling, tested at the step file that owns it.
|
|
//
|
|
// These four tests used to live in `tests/nsegment-phase-grammar.test.cjs`, inside the
|
|
// `#4748` describe block, because that is where the lookup's gate was when #3829 moved the
|
|
// lookup out of `execute-phase.md`. #4781 (#4628) then removed #4748's letter-axis work from
|
|
// the grammar file, so the block that hosted them no longer exists. They are re-homed here
|
|
// unchanged in substance: they assert properties of THIS PR's step file, not of #4748's sites,
|
|
// and keeping them beside the step they guard is what stops an unrelated upstream revert from
|
|
// silently deleting this PR's own coverage.
|
|
//
|
|
// One test did NOT come along: the assertion that `execute-phase.md`'s init parse list names
|
|
// `padded_phase`. That is a property of #4748's site, not of this step — #4781 removed the
|
|
// field from that list, and carrying the assertion here would only pin someone else's revert.
|
|
//
|
|
// The MECHANISM differs from the pre-move one and the PROPERTY does not. `execute-phase.md`
|
|
// bound init's `{padded_phase}`; the step validates PHASE_NUMBER for shape and traversal and
|
|
// then pads the DIGIT RUN as a STRING, carrying an optional letter and any dot segments verbatim.
|
|
// Both refuse exactly the two shapes #4748 named: `printf "%02d"` cannot pad `03A` (prints `03`,
|
|
// exits 1) and reads an already-padded `08` as octal (prints `00`) -- and the string pad refuses
|
|
// them by not doing arithmetic at all, which also fixed a THIRD shape the arithmetic form got
|
|
// wrong (`008` -> `08`). The last two tests drive that equivalence against the canonical
|
|
// normalizer rather than asserting it.
|
|
// ---------------------------------------------------------------------------
|
|
describe('#3829 — the step\'s REVIEW.md lookup resolves a letter-suffixed phase without a shell re-pad', () => {
|
|
const { execFileSync } = require('node:child_process');
|
|
const { splitLines } = require('../gsd-core/bin/lib/text-lines.cjs');
|
|
const { normalizePhaseName } = require('../gsd-core/bin/lib/phase-id.cjs');
|
|
|
|
/**
|
|
* Pure: the indexes of every line containing `anchor`. Asserts the count so a site that is
|
|
* added, removed or renamed breaks this test loudly instead of silently narrowing what it
|
|
* covers (the step carries each anchor TWICE — one per markdown fence, each a fresh shell).
|
|
*/
|
|
function findAnchoredLineIndexes(lines, anchor, expectedCount) {
|
|
const idx = [];
|
|
lines.forEach((l, i) => { if (l.includes(anchor)) idx.push(i); });
|
|
assert.equal(
|
|
idx.length,
|
|
expectedCount,
|
|
`expected ${expectedCount} line(s) containing ${JSON.stringify(anchor)}, found ${idx.length}`,
|
|
);
|
|
return idx;
|
|
}
|
|
|
|
/** Run `script` in bash with `env` merged in; never throws — returns { status, stdout, stderr }. */
|
|
function runBash(script, env) {
|
|
try {
|
|
const stdout = execFileSync('bash', [], {
|
|
input: script,
|
|
encoding: 'utf8',
|
|
timeout: PROBE_TIMEOUT_MS,
|
|
env: { ...process.env, ...env },
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
});
|
|
return { status: 0, stdout: stdout.trim(), stderr: '' };
|
|
} catch (e) {
|
|
return { status: e.status, stdout: String(e.stdout || '').trim(), stderr: String(e.stderr || '').trim() };
|
|
}
|
|
}
|
|
|
|
const stepLines = splitLines(fs.readFileSync(DISPOSITION_STEP_PATH, 'utf8'));
|
|
// TWO fences — each markdown fence is a fresh shell, so each derives and looks up for itself.
|
|
const lookups = findAnchoredLineIndexes(stepLines, 'REVIEW_FILE="${_pd}/${PADDED}-REVIEW.md"', 2);
|
|
// The derivation slice each fence runs before its lookup, taken by CONTENT rather than by a
|
|
// line number that every edit to the file would drift.
|
|
const derivStarts = findAnchoredLineIndexes(stepLines, '_pd="${PHASE_DIR:-}"', 2);
|
|
const derivations = derivStarts.map((d, n) => stepLines.slice(d, lookups[n] + 1).join('\n'));
|
|
|
|
test('no fence re-pads the phase number with printf (fails before the fix)', () => {
|
|
// The defect shape, not the remedy: `printf "%0Nd"` applied to PHASE_NUMBER itself.
|
|
const offenders = stepLines
|
|
.map((l, n) => [n + 1, l])
|
|
.filter(([, l]) => !/^\s*#/.test(l) && /printf\s+"%0\d*d"\s+"?\$\{?PHASE_NUMBER/.test(l));
|
|
assert.deepEqual(offenders, [], `the step must not re-pad PHASE_NUMBER in shell: ${JSON.stringify(offenders)}`);
|
|
// And the pad it does perform is a STRING pad, not arithmetic: the canonical normalizer
|
|
// left-pads to a MINIMUM of two and otherwise preserves the run, so `$(( ))` is wrong by
|
|
// construction -- it collapsed `008` to `08` until round 14.
|
|
// Asserted as the PROPERTY, not as one spelling of it: an equivalent multi-line string pad must
|
|
// pass. What must not pass is arithmetic, or a pad that drops either carried part.
|
|
for (const d of derivations) {
|
|
const bound = d.split('\n').find((l) => /PADDED=/.test(l) && !/PADDED=""/.test(l));
|
|
assert.ok(bound, 'the derivation must bind PADDED');
|
|
assert.doesNotMatch(bound, /printf|\$\(\(/, `the pad must not be arithmetic: ${bound.trim()}`);
|
|
// What the pad must PRODUCE is asserted by the matrix and the property below, which execute
|
|
// it. Requiring a particular variable to appear HERE false-positives on an equivalent pad
|
|
// that routes the digit run through an intermediate -- driven, and it is why this stops at
|
|
// the defect shape.
|
|
}
|
|
});
|
|
|
|
test('regression control: both lookup lines are unchanged', () => {
|
|
for (const i of lookups) {
|
|
assert.equal(stepLines[i].trim(), 'REVIEW_FILE="${_pd}/${PADDED}-REVIEW.md"');
|
|
}
|
|
});
|
|
|
|
test('nothing REBINDS REVIEW_FILE after the lookup', () => {
|
|
// The slices above stop AT the first anchored assignment, so on their own they cannot see a
|
|
// later line overwriting the path the fence actually consumes. Driven by an adversarial pass
|
|
// on this very test: inserting the expected lookup and then overriding it with
|
|
// REVIEW_FILE="${_pd}/WRONG-REVIEW.md" left every other assertion here green. Pin the whole
|
|
// file rather than the slice -- the only REVIEW_FILE= bindings permitted are the canonical
|
|
// lookup (once per fence) and the identity pass-through that hands it to the embedded node
|
|
// script as an env prefix.
|
|
// The predicate is deliberately wider than `^REVIEW_FILE=`: a second adversarial pass drove an
|
|
// INDENTED assignment and an `export REVIEW_FILE=...` straight through that anchor, both of
|
|
// which execute exactly like a bare one. Leading whitespace and an optional `export` are
|
|
// absorbed here so the accept-list below is what decides, not the spelling of the line.
|
|
// `+=` too: `REVIEW_FILE+=-wrong` APPENDS and executes (driven: `REVIEW_FILE=good;
|
|
// REVIEW_FILE+=-wrong` prints `good-wrong`), so an assignment-operator match that only sees
|
|
// `=` lets a real rebinding through. Third spelling found by a third adversarial pass.
|
|
const BIND_RE = /^\s*(?:export\s+)?REVIEW_FILE\+?=/;
|
|
const binds = stepLines.filter((l) => BIND_RE.test(l)).map((l) => l.replace(/^\s*(?:export\s+)?/, ''));
|
|
assert.equal(binds.filter((l) => l.startsWith('REVIEW_FILE="${_pd}/${PADDED}-REVIEW.md"')).length, 2,
|
|
'each fence must bind the canonical lookup exactly once');
|
|
for (const b of binds) {
|
|
const ok = b.startsWith('REVIEW_FILE="${_pd}/${PADDED}-REVIEW.md"')
|
|
|| b.startsWith('REVIEW_FILE="${REVIEW_FILE}"');
|
|
assert.ok(ok, `REVIEW_FILE is rebound to something other than the canonical lookup: ${b}`);
|
|
}
|
|
});
|
|
|
|
test('composition: the live derivation and lookup resolve the letter phase\'s own REVIEW.md', (t) => {
|
|
// The executable half: run the SHIPPED lines against a fixture so the validation, the padding,
|
|
// the letter carry and the path construction are exercised together.
|
|
const dir = createTempDir();
|
|
t.after(() => cleanup(dir));
|
|
for (const [id, status] of [['3A', 'clean'], ['8', 'issues'], ['9', 'skipped']]) {
|
|
const emitted = normalizePhaseName(id);
|
|
fs.writeFileSync(path.join(dir, `${emitted}-REVIEW.md`), `---\nstatus: ${status}\n---\n# review\n`);
|
|
for (const deriv of derivations) {
|
|
const script = [
|
|
'set -e',
|
|
deriv,
|
|
'test -f "$REVIEW_FILE" || { echo "MISSING $REVIEW_FILE"; exit 3; }',
|
|
'printf \'%s %s\' "$PADDED" "$(grep -m1 "^status:" "$REVIEW_FILE" | cut -d: -f2 | tr -d " ")"',
|
|
].join('\n');
|
|
const r = runBash(script, { PHASE_DIR: dir, PHASE_NUMBER: id });
|
|
assert.equal(r.status, 0, `bash exited ${r.status}: ${r.stdout} ${r.stderr}`);
|
|
assert.equal(r.stdout, `${emitted} ${status}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('the step\'s padding agrees with the canonical normalizer across the letter matrix', (t) => {
|
|
// Driven, not argued: every shape #4748's own tests named, plus its stated regression controls.
|
|
const dir = createTempDir();
|
|
t.after(() => cleanup(dir));
|
|
for (const id of ['3A', '8', '9', '08', '09', '12A', '4B', '23.1.2', '03A.1.2', '1', '06', '36.14', '08.5']) {
|
|
for (const deriv of derivations) {
|
|
const r = runBash(`set -e\n${deriv}\nprintf '%s' "$PADDED"`, { PHASE_DIR: dir, PHASE_NUMBER: id });
|
|
assert.equal(r.status, 0, `bash exited ${r.status} on ${id}: ${r.stderr}`);
|
|
assert.equal(r.stdout, normalizePhaseName(id), `padding drifted from the canonical normalizer on ${id}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('property: the step\'s padding agrees with the canonical normalizer on any id it accepts', (t) => {
|
|
// The matrix above samples 13 shapes. #4748's guarantee at its ORIGINAL site was a DATAFLOW
|
|
// pin — `execute-phase.md` bound init's own `{padded_phase}`, so the lookup could not disagree
|
|
// with the canonical normalizer because it never computed anything. This step reconstructs the
|
|
// value in shell instead, so that pin is not available here and AGREEMENT is what replaces it.
|
|
// A 13-point sample cannot see a future canonical-grammar change land outside those 13 points;
|
|
// a generator can, and this is the exact blind spot rounds 3 and 5 of this PR were both about.
|
|
// Scoped to the ids the step's own guard ACCEPTS: a digit run within its 8-digit bound, an
|
|
// optional single A-Z, and optional dot segments. Milestone `N-N` forms are outside the step's
|
|
// domain, and are asserted nowhere here rather than silently passed.
|
|
const dir = createTempDir();
|
|
t.after(() => cleanup(dir));
|
|
// The digit run is generated as a STRING of digits, never as an integer: `String(fc.integer())`
|
|
// can never produce a LEADING ZERO, so an integer-sourced generator silently loses the `08`/`09`
|
|
// cases the finite matrix above already covered, and could never have reached `008`. That was
|
|
// this property's own first cut, and the review that caught it is why the shape is spelled out.
|
|
// Segment depth goes to four because the repo itself exercises `1.2.3.4`. The step's grammar is
|
|
// unbounded in depth; four is a bound, stated rather than implied, and it is the residual here.
|
|
const digitRun = fc.array(fc.integer({ min: 0, max: 9 }), { minLength: 1, maxLength: 8 })
|
|
.map((ds) => ds.join(''));
|
|
const PHASE_ID = fc.tuple(
|
|
digitRun,
|
|
fc.option(fc.constantFrom(...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'), { nil: '' }),
|
|
fc.array(digitRun, { maxLength: 4 }),
|
|
).map(([run, letter, segs]) => run + letter + segs.map((s) => '.' + s).join(''));
|
|
fc.assert(fc.property(PHASE_ID, (id) => {
|
|
for (const deriv of derivations) {
|
|
const r = runBash(`set -e\n${deriv}\nprintf '%s' "$PADDED"`, { PHASE_DIR: dir, PHASE_NUMBER: id });
|
|
assert.equal(r.status, 0, `bash exited ${r.status} on ${id}: ${r.stderr}`);
|
|
assert.equal(r.stdout, normalizePhaseName(id), `padding drifted from the canonical normalizer on ${id}`);
|
|
}
|
|
}), { numRuns: 25 });
|
|
});
|
|
});
|