* test(#2316): fail-first tests for ghost REQ-IDs, v-heading over-match, all-orphan gap check
Red phase: 4 of 10 fail against current source (#2316-1 ghost-ID warning,
-3 requirements_updated honesty, -4a v1-heading suppression, -6b all-orphan gap
rows). The other 6 are controls/boundaries that must keep passing — including the
#1159 deferred-heading guard and the literal "TBD" placeholder boundary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA
* fix(#2316): stop phase complete silently dropping ghost requirement IDs
phase complete parses a phase's `**Requirements**:` line from ROADMAP.md and
reconciles it into REQUIREMENTS.md. When a cited ID was registered nowhere, every
branch degraded to a no-op and the report was indistinguishable from a run that
applied every update: `requirements_updated: true, warnings: [], has_warnings:
false`, file byte-for-byte unchanged.
Four defects on that path, all long-standing (traced to 2bc295b32d, v1.2.0):
1. The only cross-check compared REQUIREMENTS.md's own body against its own
Traceability table, so an ID cited by ROADMAP but defined in neither was
invisible to it. `citedReqIds` is now hoisted out of the `if (reqMatch)` block
and cross-checked; ghost IDs raise a warning through the existing
warnings/has_warnings surface that execute-phase.md already prints.
2. `if (reqUpdate.ok)` had no `else`, so a Traceability-row write matching
nothing was discarded silently. Misses are now recorded.
3. `requirements_updated` was set unconditionally inside `if (existsSync)`,
reporting "the file was in the transaction" rather than "a write landed". It
now reflects whether the content actually changed.
4. DEFERRED_HEADING_RE's bare `v\d+` alternative treated an active `## v1
Requirements` heading as deferred, zeroing the body scan for that section.
Dropped; `deferred|backlog|future` still match, so #1159's suite still holds.
Also fixes the same class in gap-checker: the `items.length === 0` early return
fired before ghost rows were folded in, so an all-unregistered phase reported
LESS than a partially-unregistered one. It is now gated on ghostReqIds too.
Follows the #2140 precedent in milestone.cts, which hardened this exact class for
`requirements mark-complete`; adapted to this function's single `warnings` array
rather than adding fields execute-phase.md never reads.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA
* fix(#2316): address review — milestone-aware deferred headings, probe-based ghost check
Independent review found the first pass green but wrong — the tests were built to
miss the shape that breaks.
BLOCKER: dropping the `v\d+` alternative from DEFERRED_HEADING_RE regressed #1159
against GSD's OWN shipped template. gsd-core/templates/requirements.md:35 ships
`## v2 Requirements` with the deferred-ness in the BODY PROSE ("Deferred to future
release"), not the heading — so `v\d+` was the only alternative matching it.
Deleting it made every scaffolded project emit a false traceability warning on
every phase complete, forever. The real problem is that `v\d+` matched BOTH the
active v1 and the deferred v2: deleting it swings from suppress-everything to
suppress-nothing. The heading check is now milestone-aware — a `## v<N>` heading
is deferred only when <N> is not the current milestone's major, resolved through
the existing state.cjs seam. Unresolvable milestone fails safe to the old
always-deferred behavior, since suppressing a warning beats spamming every project.
HIGH: the ghost check re-derived membership from two lossy index sets that
disagreed with the case-insensitive write paths, so it warned "not registered
anywhere" about an ID whose checkbox it had just ticked, and about a
case-mismatched ID whose write landed. It now probes the same two write surfaces
the writes use, mirroring milestone.cts's #2140 precedent (probe the write, don't
re-derive it) — which is what the original brief asked for and the first pass
didn't do.
HIGH: the cited-ID tokenizer split the rest of the line on whitespace, so every
trailing word became a "cited REQ-ID". The shipped roadmap.md template puts an
inline HTML comment on that exact line, so a fully correct run told users to
register `<!--` and `-->` as requirements. Cited IDs are now filtered to the
REQ-ID shape, matching what bodyReqIds/tableReqIds already require.
HIGH: gap-checker had the identical early-return defect 34 lines above the one
fixed — the could-not-parse branch gated ghost rows behind the same unguarded
items.length. One malformed <decisions> line made all ghost rows vanish.
Tests: the #2316-5 guard only covered `## Deferred`/`## Backlog`/`## Future` —
headings that trivially still match — omitting `## v2 Requirements`, the only
shape the change altered. Added the shipped-template fixture plus guards for each
finding above.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA
* docs(#2316): backfill PR number 2339 into changeset
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -434,6 +434,108 @@ describe('Full pipeline: render-hooks plan:post discovers gate, then check dispa
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Section 3b: issue #2316 (Secondary B) — all-unregistered ghost REQ-IDs ──
|
||||
//
|
||||
// runGapAnalysis's `items.length === 0` short-circuit fires BEFORE ghostReqIds
|
||||
// (phaseReqIds cited by ROADMAP but absent from REQUIREMENTS.md) is folded
|
||||
// into `rows`. When EVERY cited REQ-ID is a ghost, reqItems is filtered down
|
||||
// to [] and there is no CONTEXT.md, so items.length === 0 and the ghost rows
|
||||
// never appear — the phase reports "No requirements or decisions to check."
|
||||
// instead of the ⚠ Missing from REQUIREMENTS.md rows. A phase with ONE
|
||||
// unregistered ID mixed with a registered one already surfaces correctly
|
||||
// (see the mapped-REQ-ID-absent test in Section 2 above) — only the
|
||||
// ALL-unregistered case is broken.
|
||||
|
||||
describe('issue #2316 (Secondary B): all-unregistered phaseReqIds must still emit ghost rows, not the empty-state message', () => {
|
||||
let tmpDir;
|
||||
let phaseDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
const init = runGsdTools('config-ensure-section', tmpDir);
|
||||
assert.ok(init.success, `config-ensure-section failed: ${init.error}`);
|
||||
writeRequirements(path.join(tmpDir, '.planning'), ['KNOWN-01']);
|
||||
writePlan(phaseDir, '01', '# Plan\n\nImplements KNOWN-01.\n');
|
||||
});
|
||||
|
||||
afterEach(() => cleanup(tmpDir));
|
||||
|
||||
test(
|
||||
'#2316-6a (control, mixed known+ghost): a phase mapping one registered and one ghost REQ-ID already surfaces the ghost row',
|
||||
() => {
|
||||
const r = runGapCheck([phaseDir, 'KNOWN-01,ORPHAN-01'], tmpDir);
|
||||
assert.ok(r.success, `check failed: ${r.error}`);
|
||||
const out = JSON.parse(r.output);
|
||||
assert.ok(
|
||||
out.table.includes('ORPHAN-01') && out.table.includes('Missing from REQUIREMENTS.md'),
|
||||
`#2316-6a FAILED: mixed control must still list ORPHAN-01 as Missing, got table: ${out.table}`,
|
||||
);
|
||||
assert.strictEqual(out.counts.total, 2, '#2316-6a FAILED: total must count both REQ-IDs');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'#2316-6b: a phase whose EVERY cited REQ-ID is unregistered must still return the ghost rows, not "No requirements or decisions to check."',
|
||||
() => {
|
||||
const r = runGapCheck([phaseDir, 'ORPHAN-01,ORPHAN-02'], tmpDir);
|
||||
assert.ok(r.success, `check failed: ${r.error}`);
|
||||
const out = JSON.parse(r.output);
|
||||
assert.ok(
|
||||
!/No requirements or decisions to check/.test(out.table),
|
||||
`#2316-6b FAILED: all-unregistered REQ-IDs must not collapse to the empty-state message.\nFull table: ${out.table}`,
|
||||
);
|
||||
assert.ok(
|
||||
out.table.includes('ORPHAN-01') && out.table.includes('ORPHAN-02'),
|
||||
`#2316-6b FAILED: table must list both ghost REQ-IDs, got: ${out.table}`,
|
||||
);
|
||||
assert.ok(
|
||||
out.table.includes('Missing from REQUIREMENTS.md'),
|
||||
`#2316-6b FAILED: table must show "Missing from REQUIREMENTS.md" status for the ghost IDs, got: ${out.table}`,
|
||||
);
|
||||
assert.strictEqual(out.counts.total, 2, '#2316-6b FAILED: total must count both ghost REQ-IDs, not 0');
|
||||
assert.strictEqual(out.counts.uncovered, 2, '#2316-6b FAILED: uncovered must count both ghost REQ-IDs, not 0');
|
||||
assert.strictEqual(out.enabled, true, '#2316-6b FAILED: enabled must still be true');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'#2334 HIGH 1: all-ghost REQ-IDs + a malformed CONTEXT.md <decisions> line (could-not-parse) must still return the ghost rows, not silently drop to the mismatch-only message',
|
||||
() => {
|
||||
// Same "items.length === 0" defect #2316-6b fixed ~34 lines below in
|
||||
// runGapAnalysis, but in the sibling `ctxExtraction.outcome ===
|
||||
// 'could-not-parse'` branch a few lines ABOVE it: that branch gated its
|
||||
// own ghost-row inclusion on the unguarded `items.length > 0`, so a
|
||||
// malformed <decisions> line made both ghost rows vanish and `total`
|
||||
// drop 2 -> 0 for the exact same all-ghost phase #2316-6b already
|
||||
// covers on the happy (non-malformed) CONTEXT.md path.
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, 'CONTEXT.md'),
|
||||
'# Phase Context\n\n<decisions>\n## Implementation Decisions\n\n' +
|
||||
'- **D-01 this line is malformed and has no closing bold/colon\n</decisions>\n',
|
||||
);
|
||||
const r = runGapCheck([phaseDir, 'ORPHAN-01,ORPHAN-02'], tmpDir);
|
||||
assert.ok(r.success, `check failed: ${r.error}`);
|
||||
const out = JSON.parse(r.output);
|
||||
assert.ok(
|
||||
!/No requirements or decisions to check/.test(out.table),
|
||||
`#2334 HIGH 1 FAILED: all-ghost + could-not-parse must not collapse to the empty-state message.\nFull table: ${out.table}`,
|
||||
);
|
||||
assert.ok(
|
||||
out.table.includes('ORPHAN-01') && out.table.includes('ORPHAN-02'),
|
||||
`#2334 HIGH 1 FAILED: table must still list both ghost REQ-IDs despite the malformed decisions line, got: ${out.table}`,
|
||||
);
|
||||
assert.ok(
|
||||
/format mismatch/i.test(out.table),
|
||||
`#2334 HIGH 1 FAILED: the could-not-parse signal must still be present in the table, got: ${out.table}`,
|
||||
);
|
||||
assert.strictEqual(out.counts.total, 2, '#2334 HIGH 1 FAILED: total must count both ghost REQ-IDs, not 0');
|
||||
assert.strictEqual(out.counts.uncovered, 2, '#2334 HIGH 1 FAILED: uncovered must count both ghost REQ-IDs, not 0');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Section 4: Pure resolver tests against real registry ────────────────────
|
||||
|
||||
describe('resolveLoopHooks plan:post — pure function against real registry', () => {
|
||||
|
||||
Reference in New Issue
Block a user