* 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:
@@ -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 ─────────────────
|
||||
|
||||
Reference in New Issue
Block a user