fix(#3189): drop non-id prose from phase_req_ids before gap check (#3438)

* fix(#3189): drop non-id prose from phase_req_ids before gap check

* chore(#3189): pin changeset fragment to pr 3438

* fix(#3189): use hyphenated IDs in e2e fixtures (parseRequirements requires hyphen)

* fix(#3189): assert e2e item set via table, not rows (check query has no rows)

* fix(#3189): check prose fragments against table rows, not the table heading

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-14 01:10:48 -04:00
committed by GitHub
parent dbbcb8f736
commit 8ecbac0350
4 changed files with 412 additions and 46 deletions

View File

@@ -337,6 +337,109 @@ describe('check gap-analysis.plan-post — gate content E2E', () => {
// REQ-01 must still be covered
assert.ok(out.table.includes('✓ Covered'), 'REQ-01 must show as covered');
});
// ── #3189: prose trailing a real ID list must not be reported as missing ──
//
// ROADMAP `**Requirements:**` lines carry prose after the ID list (locked-
// decision annotations, ambiguity scores, prohibitions, dates). The workflow
// passes that value verbatim into --phase-req-ids. Pre-fix, every prose word
// was reported as an individually-missing requirement (8 real IDs became 21
// reported uncovered in the issue). Post-fix, only ID-shaped tokens reach the
// comparison.
//
// NOTE: the issue's exact reproduction uses hyphen-less `R1`..`R8`, but the
// codebase's REQUIREMENTS.md parser (`parseRequirements`, `ID_PATTERN =
// [A-Z][A-Z0-9]*-[A-Za-z0-9_-]+`) REQUIRES a hyphen, so `R1` is not a valid
// REQUIREMENTS.md ID in this codebase. The `R1`..`R8` shape is covered
// directly against `normalizePhaseReqIds` in tests/gap-checker.property.test.cjs
// (the filter accepts hyphen-less digit-bearing IDs per the issue spec). These
// E2E fixtures use the hyphenated `REQ-01`..`REQ-08` family so the full
// pipeline (parseRequirements → normalizePhaseReqIds → coverage compare) is
// exercised end-to-end; the prose annotations are the issue's verbatim.
test('[#3189] check gap-analysis.plan-post with prose-annotated phase-req-ids drops every prose fragment, keeps only ID-shaped tokens', () => {
// REQUIREMENTS.md defines exactly REQ-01..REQ-08 — the real requirement set.
writeRequirements(path.join(tmpDir, '.planning'),
['REQ-01', 'REQ-02', 'REQ-03', 'REQ-04', 'REQ-05', 'REQ-06', 'REQ-07', 'REQ-08']);
// The plan addresses all eight real IDs.
writePlan(phaseDir, '01',
'# Plan\n\nImplements REQ-01, REQ-02, REQ-03, REQ-04, REQ-05, REQ-06, REQ-07, and REQ-08.\n');
// The issue's prose-annotated shape, with REQ-01..REQ-08 as the ID list.
// The trailing clause is the issue's verbatim prose (locked-decision
// annotation, ambiguity score, prohibition range).
const rawReqIds = 'REQ-01, REQ-02, REQ-03, REQ-04, REQ-05, REQ-06, REQ-07, REQ-08 (locked <date> — canonical source `NN-SPEC.md ## Requirements`; ambiguity 0.12; + prohibitions P1-P3)';
const r = runGapCheck([phaseDir, rawReqIds], tmpDir);
assert.ok(r.success, `check failed: ${r.error}`);
const out = JSON.parse(r.output);
// After the #3189 shape filter, the ID-shaped tokens that survive are
// REQ-01..REQ-08 PLUS `P1-P3` (from "prohibitions P1-P3"). `P1-P3` is
// syntactically ID-shaped — it matches `PHASE_REQ_ID_SHAPE_RE` exactly as
// `SEL-01` does, and the codebase's `ID_PATTERN` accepts it too, so dropping
// it would create a false mismatch for projects using `P1-P3`-shape IDs. It
// is NOT in REQUIREMENTS.md, so it correctly surfaces as a single "Missing
// from REQUIREMENTS.md" ghost row.
//
// Pre-fix, this same input produced a report where every prose word was a
// separate "Missing from REQUIREMENTS.md" row. Post-fix the prose noise is
// gone: 8 covered REQ-IDs + 1 ID-shaped ghost (P1-P3).
assert.strictEqual(out.counts.total, 9,
`total must be 9 (REQ-01..REQ-08 + the ID-shaped P1-P3); got ${out.counts.total}. Full table:\n${out.table}`);
assert.strictEqual(out.counts.covered, 8, 'all 8 REQ-IDs are covered by the plan');
assert.strictEqual(out.counts.uncovered, 1, 'the one uncovered item is P1-P3 (ID-shaped ghost, not prose)');
// Every surviving ID-shaped token must appear in the table. (The check
// query exposes `table`/`counts` but not the raw `rows` array, so assert
// via the rendered table.)
for (const id of ['REQ-01', 'REQ-02', 'REQ-03', 'REQ-04', 'REQ-05', 'REQ-06', 'REQ-07', 'REQ-08', 'P1-P3']) {
assert.ok(out.table.includes(id),
`${id} must appear in the table. Full table:\n${out.table}`);
}
// P1-P3 (the ID-shaped ghost) must be flagged Missing from REQUIREMENTS.md.
assert.ok(out.table.includes('Missing from REQUIREMENTS.md'),
`P1-P3 must be flagged Missing from REQUIREMENTS.md. Full table:\n${out.table}`);
// No prose fragment may appear as a table row — these are the exact
// fragments the issue reported as fake missing requirements. Only table
// ROW lines (starting with `|`) are checked: the table string itself begins
// with the `## Post-Planning Gap Analysis` markdown heading, so a raw
// `out.table.includes('##')` would trivially be true.
const tableRows = out.table.split('\n').filter(line => line.startsWith('|'));
const proseFragments = ['—', '##', '`NN-SPEC.md', '+', '0.12;', '<date>',
'ambiguity', 'locked', 'prohibitions', 'Requirements;', 'canonical', 'source;'];
for (const frag of proseFragments) {
assert.ok(!tableRows.some(line => line.includes(frag)),
`prose fragment ${JSON.stringify(frag)} must NOT appear in any table row. Full table:\n${out.table}`);
}
});
test('[#3189] a genuinely-missing real requirement ID is still reported (no narrowing) — prose-annotated mixed list', () => {
// REQUIREMENTS.md defines REQ-01..REQ-03 only; REQ-99 is cited in
// phase-req-ids but absent (a real missing-requirement signal). The trailing
// prose must be dropped, but REQ-99 (a real ID shape) must still be reported
// as Missing — the fix must not narrow real coverage detection.
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01', 'REQ-02', 'REQ-03']);
writePlan(phaseDir, '01', '# Plan\n\nImplements REQ-01, REQ-02, REQ-03.\n');
const rawReqIds = 'REQ-01, REQ-02, REQ-03, REQ-99 (locked 2026-08-07 — ambiguity 0.12)';
const r = runGapCheck([phaseDir, rawReqIds], tmpDir);
assert.ok(r.success, `check failed: ${r.error}`);
const out = JSON.parse(r.output);
// GENUINE: total must be 4 — REQ-01..REQ-03 (covered) + REQ-99 (ghost). The
// prose is dropped, but the real missing ID REQ-99 is preserved.
assert.strictEqual(out.counts.total, 4,
`total must be 4 (REQ-01..REQ-03 + REQ-99; prose dropped, REQ-99 preserved); got ${out.counts.total}`);
assert.strictEqual(out.counts.uncovered, 1, 'REQ-99 is the one uncovered item');
assert.ok(out.table.includes('REQ-99'), 'REQ-99 (real missing ID) must appear in the table');
assert.ok(out.table.includes('Missing from REQUIREMENTS.md'),
'REQ-99 must be flagged Missing from REQUIREMENTS.md');
// Prose fragments still dropped.
assert.ok(!out.table.includes('locked'), 'prose "locked" must NOT appear');
assert.ok(!out.table.includes('ambiguity'), 'prose "ambiguity" must NOT appear');
});
});
// ─── Section 3: Full pipeline — render-hooks → check dispatch ─────────────────