fix(#2788): recover Gaps Found rows; mark-complete no longer false-succeeds on a rejected row (#2902)

* fix(#2788): recover Gaps Found rows; mark-complete no longer false-succeeds on a rejected row

Two coupled defects in the requirement traceability state machine:

Defect 1 (terminal state): requirements revert-phase (the gaps_found response)
left a row at 'Gaps Found' with no inverse — neither mark-complete's /^pending$/i
guard nor the phase-complete reconcile's /^(?:pending|in progress)$/i accepted it,
so a single failed verification stranded every requirement permanently and blocked
the milestone. Widen both guards to accept 'gaps found' so a genuinely-satisfied
stranded row reaches Complete again.

Defect 2 (false success): mark-complete ORed checkboxHit || tableHit for 'updated',
so on a Gaps Found row it flipped the checkbox but could not move the row, yet
reported updated:true. When a traceability table has a row for an ID, gate 'updated'
on the row moving (tableHit) — a checkbox-only flip on a table-bearing file no
longer lies. The #2140 table_unmatched path (no row for the ID) is preserved.

* chore(#2788): backfill changeset PR 2902

---------

Co-authored-by: Test <test@example.com>
(cherry picked from commit 9f567a1627)
This commit is contained in:
Tom Boucher
2026-07-31 00:04:04 -04:00
committed by sim
parent 14cfbdaad0
commit a38e4d080d
4 changed files with 151 additions and 5 deletions

View File

@@ -971,6 +971,117 @@ describe('requirements mark-complete command', () => {
assert.strictEqual(output.updated, false);
assert.strictEqual(output.reason, 'REQUIREMENTS.md not found');
});
// #2788: a requirement row stranded at `Gaps Found` (by revert-phase, the
// gaps_found response) must be recoverable — mark-complete moves it to Complete.
// Pre-fix the `/^pending$/i` guard rejected `Gaps Found`, stranding the row
// permanently (no inverse transition existed) AND mark-complete reported
// `updated: true` while the row stayed `Gaps Found` (defect 2, the lie).
const GAPS_FOUND_REQUIREMENTS = `# Requirements
## Coverage
- [ ] **REQ-01**: feature one
- [ ] **REQ-02**: feature two
## Traceability
| Requirement | Phase | Status |
|-------------|-------|--------|
| REQ-01 | Phase 1 | Gaps Found |
| REQ-02 | Phase 1 | Complete |
`;
test('#2788 defect 1: a Gaps Found row moves to Complete via mark-complete (no longer terminal)', () => {
writeRequirements(tmpDir, GAPS_FOUND_REQUIREMENTS);
const result = runGsdTools('requirements mark-complete REQ-01', tmpDir);
assert.ok(result.success);
const out = JSON.parse(result.output);
// The row EXISTS and moved to Complete, so updated/marked_complete are truthful.
assert.ok(out.updated, 'the stranded Gaps Found row must be recoverable');
assert.ok(out.marked_complete.includes('REQ-01'));
const content = readRequirements(tmpDir);
assert.ok(/REQ-01 \| Phase 1 \| Complete/.test(content),
'the row must read Complete after mark-complete; got:\n' + content);
assert.ok(content.includes('- [x] **REQ-01**'), 'the checkbox must be checked');
});
test('#2788 defect 2: when a row EXISTS but the write is rejected, updated is FALSE (no false success)', () => {
// A row at `Blocked` (a status mark-complete does not accept) EXISTS for REQ-01.
// The checkbox flips, but the row does not move — `updated` must be false so the
// operator is not told it worked while the audit row still reads Blocked.
const blockedRequirements = `# Requirements
## Coverage
- [ ] **REQ-01**: feature one
## Traceability
| Requirement | Phase | Status |
|-------------|-------|--------|
| REQ-01 | Phase 1 | Blocked |
`;
writeRequirements(tmpDir, blockedRequirements);
const result = runGsdTools('requirements mark-complete REQ-01', tmpDir);
assert.ok(result.success);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, false,
'a checkbox flip on a table-bearing file whose row EXISTS but did not move must NOT report updated:true');
assert.ok(!out.marked_complete.includes('REQ-01'),
'REQ-01 must not be in marked_complete when the row write was rejected');
const content = readRequirements(tmpDir);
assert.ok(/REQ-01 \| Phase 1 \| Blocked/.test(content),
'the row must still read Blocked (write rejected); got:\n' + content);
// #2788 defect 2: the checkbox must NOT flip when the row write is rejected —
// the checkbox and the row are two representations of the same fact, so they
// must not silently diverge. The checkbox stays unchecked on disk.
assert.ok(content.includes('- [ ] **REQ-01**'),
'the checkbox must stay unchecked when the row write was rejected; got:\n' + content);
// write_set carries the truth: NEITHER surface applied (checkbox rolled back,
// traceability rejected).
const checkboxEntry = out.write_set.find(
(e) => e.requirement === 'REQ-01' && e.surface === 'checkbox');
assert.ok(checkboxEntry && checkboxEntry.applied === false,
'write_set must record the checkbox surface as not applied (rolled back)');
const traceabilityEntry = out.write_set.find(
(e) => e.requirement === 'REQ-01' && e.surface === 'traceability');
assert.ok(traceabilityEntry && traceabilityEntry.applied === false,
'write_set must record the traceability surface as not applied');
});
test('#2788 end-to-end: revert-phase (Complete → Gaps Found) then mark-complete (Gaps Found → Complete) round-trips', () => {
const completeRequirements = `# Requirements
## Coverage
- [x] **REQ-01**: feature one
## Traceability
| Requirement | Phase | Status |
|-------------|-------|--------|
| REQ-01 | Phase 1 | Complete |
`;
writeRequirements(tmpDir, completeRequirements);
// revert-phase strands the row at Gaps Found (the gaps_found response).
const reverted = JSON.parse(runGsdTools('requirements revert-phase REQ-01', tmpDir).output);
assert.ok(reverted.reverted.includes('REQ-01'));
let content = readRequirements(tmpDir);
assert.ok(/REQ-01 \| Phase 1 \| Gaps Found/.test(content), 'revert should strand at Gaps Found');
// Now the requirement is genuinely satisfied again — mark-complete must recover it.
const marked = JSON.parse(runGsdTools('requirements mark-complete REQ-01', tmpDir).output);
assert.ok(marked.updated, 'the stranded row must be recoverable via mark-complete');
content = readRequirements(tmpDir);
assert.ok(/REQ-01 \| Phase 1 \| Complete/.test(content),
'after mark-complete the row must read Complete again');
});
test('#2788 negative-space: the normal Pending → Complete path is unchanged', () => {
writeRequirements(tmpDir, STANDARD_REQUIREMENTS);
const out = JSON.parse(runGsdTools('requirements mark-complete TEST-01', tmpDir).output);
assert.ok(out.updated);
assert.ok(out.marked_complete.includes('TEST-01'));
const content = readRequirements(tmpDir);
assert.ok(/TEST-01 \| Phase 1 \| Complete/.test(content), 'Pending row still moves to Complete');
});
});
// ─────────────────────────────────────────────────────────────────────────────