'use strict'; /** * Property-based tests for normalizePhaseReqIds range expansion (#1269) and * post-expand ID-shape filtering (#3189). * * Module: gsd-core/bin/lib/gap-checker.cjs * Exported: normalizePhaseReqIds(rawVal) * * Range form (#1269): a `--phase-req-ids` list element of the shape * `-NN..-MM` (identical prefix both sides, identical bound digit * width, ascending numeric NN ≤ MM) expands in place to the individual IDs, * preserving the bounds' zero-pad width; ambiguous/invalid ranges stay literal * (fail-closed) — and are then dropped by the post-expand shape filter (#3189), * because they contain `.`/`..` and so cannot be requirement IDs. * * ID-shape filter (#3189): STRICTLY AFTER range expansion, every token is * filtered through `PHASE_REQ_ID_SHAPE_RE` (`/^(?=.*\d)[A-Z][A-Z0-9]*(?:[-_][A-Za-z0-9]+)*$/`). * Tokens that cannot be requirement IDs (prose, punctuation, dates, invalid * range tokens left literal by expandPhaseReqIdToken) are dropped; an input * whose every token is dropped collapses to `null`. * * Properties tested: * (a) valid ascending same-prefix, same-width range → length == MM-NN+1, all * elements share the prefix, suffixes are strictly monotonic NN..MM, * width preserved — and every expanded ID survives the shape filter * (b) NN == MM → single-element expansion equal to the (re-padded) bound * (c) literal preservation: a non-range token round-trips unchanged * (d) descending range → dropped by the shape filter (returns null) * (d2) mismatched-prefix range → dropped by the shape filter (returns null) * (d3) differing-width bounds → dropped by the shape filter (returns null) * (d4) non-numeric bounds → dropped by the shape filter (returns null) * (d5) missing left/right bound → dropped by the shape filter (returns null) * (d6) multi-dot tokens → dropped by the shape filter (returns null) * (e) never throws on arbitrary string input * * Lives in a sibling *.property.test.cjs file (the established property-test * convention). Its effective prefix `gap-checker.property` does not match the * `gap-checker` production prefix, so it does not count against the per-module * test-file cap; the unit/integration fixtures are folded into * bug-447-gap-analysis-phase-req-ids.test.cjs instead. */ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fc = require('./helpers/fast-check-setup.cjs'); const { normalizePhaseReqIds } = require('../gsd-core/bin/lib/gap-checker.cjs'); // A safe prefix that always ends in '-', contains no whitespace, commas, // brackets, quotes, parens, or dots (those are stripped/split by the // normalizer), and never collides with the null/TBD/none sentinels. // // #3189: leading char MUST be uppercase and the rest uppercase-or-digit, to // match `PHASE_REQ_ID_SHAPE_RE`'s `[A-Z][A-Z0-9]*` prefix portion. A // lowercase-leading prefix (e.g. `a-`) would produce IDs the shape filter // rejects, so properties (a)/(b)/(c) — which assert the expanded IDs survive — // would fail for the wrong reason. The realistic requirement-ID format is // uppercase-leading anyway (`REQ-`, `SEL-`, `R1`). const prefixArb = fc .stringMatching(/^[A-Z][A-Z0-9]{0,5}$/) .filter(s => !/^(null|tbd|none)$/i.test(s)) .map(s => `${s}-`); const widthArb = fc.integer({ min: 1, max: 4 }); function pad(n, width) { return String(n).padStart(width, '0'); } describe('#1269 normalizePhaseReqIds — range expansion properties', () => { test('(a) valid ascending same-prefix, same-width range expands to MM-NN+1 monotonic same-prefix IDs', () => { fc.assert(fc.property( prefixArb, fc.integer({ min: 0, max: 50 }), fc.integer({ min: 0, max: 50 }), widthArb, (prefix, a, b, w) => { const lo = Math.min(a, b); const hi = Math.max(a, b); // Both bounds share width w; choose w wide enough to hold hi so neither // bound is truncated and both render at the SAME digit width. const width = Math.max(w, String(hi).length); const loStr = pad(lo, width); const hiStr = pad(hi, width); const token = `${prefix}${loStr}..${prefix}${hiStr}`; const result = normalizePhaseReqIds(token); // length == MM - NN + 1 assert.strictEqual(result.length, hi - lo + 1, `length for ${token}`); // all elements share the prefix for (const id of result) { assert.ok(id.startsWith(prefix), `${id} must start with ${prefix}`); } // suffixes are strictly monotonic NN..MM, each padded to the shared width result.forEach((id, i) => { const expectedNum = lo + i; assert.strictEqual(id, `${prefix}${pad(expectedNum, width)}`, `element ${i} of ${token}`); }); }, )); }); test('(d3) differing-width bounds are dropped by the shape filter (#3189 — expandPhaseReqIdToken still fails closed, then the literal token is filtered)', () => { fc.assert(fc.property( prefixArb, fc.integer({ min: 0, max: 50 }), fc.integer({ min: 0, max: 50 }), widthArb, widthArb, (prefix, a, b, wA, wB) => { const lo = Math.min(a, b); const hi = Math.max(a, b); const loStr = pad(lo, wA); const hiStr = pad(hi, wB); // Only exercise the differing-width case here. fc.pre(loStr.length !== hiStr.length); const token = `${prefix}${loStr}..${prefix}${hiStr}`; // expandPhaseReqIdToken returns [token] (fail-closed on differing // width); the token contains `..`, so #3189's shape filter drops it // and normalizePhaseReqIds collapses to null. assert.strictEqual(normalizePhaseReqIds(token), null); }, )); }); test('(d4) non-numeric bounds are dropped by the shape filter (#3189)', () => { fc.assert(fc.property( prefixArb, // A suffix containing at least one non-digit so the bound is non-numeric. fc.stringMatching(/^[0-9]*[A-Za-z][0-9A-Za-z]*$/), fc.stringMatching(/^[0-9]*[A-Za-z][0-9A-Za-z]*$/), (prefix, sLo, sHi) => { const token = `${prefix}${sLo}..${prefix}${sHi}`; // expandPhaseReqIdToken fails closed (returns [token]); #3189's filter // then drops the literal token (contains `..`). assert.strictEqual(normalizePhaseReqIds(token), null); }, )); }); test('(d5) missing left or right bound is dropped by the shape filter (#3189)', () => { fc.assert(fc.property( prefixArb, fc.integer({ min: 0, max: 99 }), widthArb, fc.boolean(), (prefix, n, w, dropLeft) => { const bound = `${prefix}${pad(n, w)}`; const token = dropLeft ? `..${bound}` : `${bound}..`; // expandPhaseReqIdToken fails closed (returns [token]); #3189's filter // then drops the literal token (contains `..`). assert.strictEqual(normalizePhaseReqIds(token), null); }, )); }); test('(d6) multi-dot tokens are dropped by the shape filter (#3189)', () => { fc.assert(fc.property( prefixArb, fc.integer({ min: 0, max: 50 }), fc.integer({ min: 0, max: 50 }), fc.integer({ min: 0, max: 50 }), widthArb, (prefix, a, b, c, w) => { const token = `${prefix}${pad(a, w)}..${prefix}${pad(b, w)}..${prefix}${pad(c, w)}`; // expandPhaseReqIdToken fails closed (returns [token]); #3189's filter // then drops the literal token (contains `..`). assert.strictEqual(normalizePhaseReqIds(token), null); }, )); }); test('(b) NN == MM expands to a single re-padded bound', () => { fc.assert(fc.property( prefixArb, fc.integer({ min: 0, max: 99 }), widthArb, (prefix, n, w) => { const nStr = pad(n, w); const token = `${prefix}${nStr}..${prefix}${nStr}`; const result = normalizePhaseReqIds(token); // Expected width is nStr.length, not w: when n has more digits than w // (e.g. n=99, w=1), pad() returns the un-truncated "99", so the emitted // ID preserves the bound's actual width — which is what the range parser does. assert.deepStrictEqual(result, [`${prefix}${pad(n, nStr.length)}`]); }, )); }); test('(c) a non-range single token round-trips unchanged (literal preservation)', () => { fc.assert(fc.property( prefixArb, fc.integer({ min: 0, max: 999 }), widthArb, (prefix, n, w) => { const id = `${prefix}${pad(n, w)}`; // a plain ID, no '..' assert.deepStrictEqual(normalizePhaseReqIds(id), [id]); }, )); }); test('(d) descending range is dropped by the shape filter (#3189 — expandPhaseReqIdToken still fails closed, then the literal token is filtered)', () => { fc.assert(fc.property( prefixArb, fc.integer({ min: 1, max: 50 }), fc.integer({ min: 1, max: 50 }), widthArb, (prefix, a, b, w) => { fc.pre(a !== b); const hi = Math.max(a, b); const lo = Math.min(a, b); // Deliberately put the larger bound first → descending → expandPhaseReqIdToken // returns [token] (fail-closed); the token contains `..`, so #3189's // shape filter drops it and normalizePhaseReqIds collapses to null. const token = `${prefix}${pad(hi, w)}..${prefix}${pad(lo, w)}`; assert.strictEqual(normalizePhaseReqIds(token), null); }, )); }); test('(d2) mismatched-prefix range is dropped by the shape filter (#3189)', () => { fc.assert(fc.property( prefixArb, prefixArb, fc.integer({ min: 0, max: 50 }), fc.integer({ min: 0, max: 50 }), widthArb, (p1, p2, a, b, w) => { fc.pre(p1 !== p2); const lo = Math.min(a, b); const hi = Math.max(a, b); const token = `${p1}${pad(lo, w)}..${p2}${pad(hi, w)}`; // expandPhaseReqIdToken fails closed (returns [token]); #3189's filter // then drops the literal token (contains `..`). assert.strictEqual(normalizePhaseReqIds(token), null); }, )); }); test('(e) never throws on arbitrary string input', () => { fc.assert(fc.property(fc.string(), (s) => { // Either a valid normalized value or null — but never an exception. assert.doesNotThrow(() => normalizePhaseReqIds(s)); })); }); }); // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-447-gap-analysis-phase-req-ids.test.cjs — consolidation epic #1969 (B3 #1972) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-447-gap-analysis-phase-req-ids (consolidation epic #1969 B3 #1972)", () => { 'use strict'; /** * Bug #447: plan-phase §13e gap-analysis ignores phase_req_ids → false-positive * coverage gaps. * * Root cause: runGapAnalysis() diffs the ENTIRE REQUIREMENTS.md against the * phase's plans, with no awareness of phase_req_ids. §13 (Requirements Coverage * Gate) skips when phase_req_ids is null/TBD, but §13e never inherited that * scoping contract — so a phase that maps no requirements reports every * unrelated project REQ-ID as "Not covered". * * Fix: teach the gap-analysis CLI a --phase-req-ids option (the durable home for * the scoping contract), mirroring §13: * - null / TBD / empty → skip the REQUIREMENTS.md comparison entirely * (CONTEXT.md decisions are still reported). * - explicit ID list → restrict the comparison to those IDs. * - flag absent → backward-compatible (compare the whole file). */ const { describe, test, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); const { normalizePhaseReqIds } = require('../gsd-core/bin/lib/gap-checker.cjs'); describe('gap-analysis --phase-req-ids scoping (#447)', () => { let tmpDir; let phaseDir; function writeRequirements(ids) { const lines = ids.map((id, i) => `- [ ] **${id}** Requirement ${i + 1} description`); fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), `# Requirements\n\n${lines.join('\n')}\n`); } function writeContext(decisions) { const dLines = decisions.map(d => `- **${d.id}:** ${d.text}`).join('\n'); fs.writeFileSync(path.join(phaseDir, 'CONTEXT.md'), `# Phase Context\n\n\n## Implementation Decisions\n\n${dLines}\n\n`); } function writePlan(name, body) { fs.writeFileSync(path.join(phaseDir, `${name}-PLAN.md`), body); } function reqRows(out) { return out.rows.filter(r => r.source === 'REQUIREMENTS.md').map(r => r.item); } beforeEach(() => { tmpDir = createTempProject(); phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(phaseDir, { recursive: true }); const r = runGsdTools('config-ensure-section', tmpDir); assert.ok(r.success, `config-ensure-section failed: ${r.error}`); }); afterEach(() => cleanup(tmpDir)); // ── The core bug ───────────────────────────────────────────────────────────── test('phase mapping no REQs (--phase-req-ids TBD) reports zero REQUIREMENTS.md rows', () => { // A REQUIREMENTS.md full of IDs that belong to OTHER phases/milestones. writeRequirements(['BACK-01', 'WEB-03', 'API-07', 'DATA-02']); writePlan('01', '# Plan 1\n\nStandalone phase, maps no project requirements.\n'); const r = runGsdTools( ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', 'TBD'], tmpDir); assert.ok(r.success, `gap-analysis failed: ${r.error}`); const out = JSON.parse(r.output); assert.deepStrictEqual(reqRows(out), [], 'a phase that maps no REQ-IDs must not report unrelated project requirements as gaps'); assert.strictEqual(out.counts.uncovered, 0, 'no false-positive "not covered" rows for an unmapped phase'); }); test('--phase-req-ids null behaves the same as TBD (skip requirements)', () => { writeRequirements(['BACK-01', 'WEB-03']); writePlan('01', '# Plan\n\nNo mapped reqs.\n'); const r = runGsdTools( ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', 'null'], tmpDir); assert.ok(r.success, r.error); const out = JSON.parse(r.output); assert.deepStrictEqual(reqRows(out), []); }); // ── Scoped to a mapped subset ──────────────────────────────────────────────── test('explicit ID list restricts the comparison to those REQ-IDs', () => { writeRequirements(['REQ-01', 'REQ-02', 'REQ-03']); // Plan covers REQ-01 only; REQ-02 is mapped to the phase but not yet addressed. writePlan('01', '# Plan\n\nImplements REQ-01 only.\n'); const r = runGsdTools( ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', 'REQ-01,REQ-02'], tmpDir); assert.ok(r.success, r.error); const out = JSON.parse(r.output); assert.deepStrictEqual(reqRows(out).sort(), ['REQ-01', 'REQ-02'], 'only the phase-mapped REQ-IDs are considered; REQ-03 (another phase) is excluded'); const req01 = out.rows.find(x => x.item === 'REQ-01'); const req02 = out.rows.find(x => x.item === 'REQ-02'); assert.strictEqual(req01.status, 'Covered'); assert.strictEqual(req02.status, 'Not covered'); }); test('JSON-array-ish value (["REQ-01"]) is tolerated and scoped', () => { writeRequirements(['REQ-01', 'REQ-02']); writePlan('01', '# Plan\n\nImplements REQ-01.\n'); const r = runGsdTools( ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', '["REQ-01"]'], tmpDir); assert.ok(r.success, r.error); const out = JSON.parse(r.output); assert.deepStrictEqual(reqRows(out), ['REQ-01']); }); // ── CONTEXT.md decisions are unaffected by req scoping ─────────────────────── test('CONTEXT.md decisions are still reported when requirements are skipped', () => { writeRequirements(['BACK-01', 'WEB-03']); writeContext([{ id: 'D-01', text: 'Use a local notification daemon' }]); writePlan('01', '# Plan\n\nUnrelated work, no decisions addressed.\n'); const r = runGsdTools( ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', 'TBD'], tmpDir); assert.ok(r.success, r.error); const out = JSON.parse(r.output); assert.deepStrictEqual(reqRows(out), [], 'requirements skipped'); const d01 = out.rows.find(x => x.item === 'D-01'); assert.ok(d01, 'CONTEXT.md decision D-01 must still be reported'); assert.strictEqual(d01.source, 'CONTEXT.md'); assert.strictEqual(d01.status, 'Not covered'); }); // ── Parser robustness (workflow passes the roadmap value verbatim) ─────────── test('whitespace/newline-separated IDs are tolerated and scoped', () => { writeRequirements(['REQ-01', 'REQ-02', 'REQ-03']); writePlan('01', '# Plan\n\nImplements the first one.\n'); const r = runGsdTools( ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', 'REQ-01 REQ-02\nREQ-03'], tmpDir); assert.ok(r.success, r.error); const out = JSON.parse(r.output); assert.deepStrictEqual(reqRows(out).sort(), ['REQ-01', 'REQ-02', 'REQ-03']); }); // ── Backward compatibility ─────────────────────────────────────────────────── test('flag absent → whole REQUIREMENTS.md is compared (unchanged behavior)', () => { writeRequirements(['REQ-01', 'REQ-02']); writePlan('01', '# Plan\n\nImplements REQ-01 only.\n'); const r = runGsdTools(['gap-analysis', '--phase-dir', phaseDir], tmpDir); assert.ok(r.success, r.error); const out = JSON.parse(r.output); assert.deepStrictEqual(reqRows(out).sort(), ['REQ-01', 'REQ-02'], 'with no --phase-req-ids, all requirements are still reported (back-compat)'); }); // ── §13e wiring: init.plan-phase --pick phase_req_ids → gap-analysis ───────── // Guards the exact query the workflow uses. `roadmap.get-phase` returns raw // phase TEXT (not JSON), so --pick yields nothing there; the scoping value // must come from `init.plan-phase`. This test would have caught using the // wrong query (which silently skips requirements for every phase). function writeRoadmap(reqLine) { fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n## Phase 1: Test Phase\n**Goal:** Do the thing\n${reqLine}**Success Criteria:**\n- It works\n`); } test('init.plan-phase --pick phase_req_ids exposes the mapped IDs, and gap-analysis scopes to them', () => { writeRoadmap('**Requirements:** REQ-01, REQ-02\n'); writeRequirements(['REQ-01', 'REQ-02', 'REQ-03']); writePlan('01', '# Plan\n\nImplements the first requirement only.\n'); const q = runGsdTools(['query', 'init.plan-phase', '1', '--pick', 'phase_req_ids'], tmpDir); assert.ok(q.success, `init.plan-phase query failed: ${q.error}`); const ids = q.output.trim(); assert.match(ids, /REQ-01/, 'init.plan-phase must expose phase_req_ids (roadmap.get-phase does NOT)'); const r = runGsdTools(['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', ids], tmpDir); assert.ok(r.success, r.error); const out = JSON.parse(r.output); assert.deepStrictEqual(reqRows(out).sort(), ['REQ-01', 'REQ-02'], 'gap report is scoped to the phase-mapped IDs; REQ-03 (another phase) is excluded'); }); test('mapped REQ-ID absent from REQUIREMENTS.md appears as "Missing" row, not silently dropped', () => { writeRequirements(['REQ-01']); writePlan('01', '# Plan\n\nImplements REQ-01.\n'); const r = runGsdTools( ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', 'REQ-01,REQ-99'], tmpDir); assert.ok(r.success, r.error); const out = JSON.parse(r.output); assert.deepStrictEqual(reqRows(out).sort(), ['REQ-01', 'REQ-99'], 'REQ-99 (absent from REQUIREMENTS.md) must be present in the report, not silently dropped'); const req99 = out.rows.find(x => x.item === 'REQ-99'); assert.ok(req99, 'missing mapped ID must have an output row'); assert.strictEqual(req99.status, 'Missing from REQUIREMENTS.md'); assert.ok(out.counts.uncovered > 0, 'uncovered count must reflect the missing mapped ID'); }); test('phase with no Requirements line → init.plan-phase yields empty → gap-analysis skips requirements', () => { writeRoadmap(''); // no **Requirements:** line writeRequirements(['REQ-01', 'REQ-02']); writePlan('01', '# Plan\n\nStandalone phase.\n'); const q = runGsdTools(['query', 'init.plan-phase', '1', '--pick', 'phase_req_ids'], tmpDir); assert.ok(q.success, q.error); const ids = q.output.trim(); // expected empty // The workflow passes the (possibly empty) value through verbatim. const r = runGsdTools(['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', ids], tmpDir); assert.ok(r.success, r.error); const out = JSON.parse(r.output); assert.deepStrictEqual(reqRows(out), [], 'an unmapped phase reports no requirement gaps (the original #447 bug)'); }); }); /** * #1269: `--phase-req-ids` range syntax (`-NN..-MM`) was treated * as a literal ID, so a mapped range was reported as a coverage gap even when the * individual IDs existed. normalizePhaseReqIds now expands a valid ascending * same-prefix numeric range in place (preserving zero-pad width), and leaves any * ambiguous/invalid range literal (fail-closed). #3189 subsequently added a * post-expand shape filter that DROPS those left-literal invalid-range tokens * (they contain `..` and so cannot be requirement IDs) — the AC4 fixtures below * were updated to assert the dropped behaviour. expandPhaseReqIdToken's internal * fail-closed branch is unchanged. These unit fixtures are folded here (the * owning home for --phase-req-ids behavior) rather than a new bug-NNNN-* file, * per the regression-test-placement policy. */ describe('#1269 — normalizePhaseReqIds range expansion', () => { // ── The core bug: a range token must expand, not stay literal ──────────────── test('AC1: range + single ID expands in input order (was the literal-token bug)', () => { // Pre-fix this returned ['SEL-01..SEL-03','TEST-01'] — the unexpanded range. assert.deepStrictEqual( normalizePhaseReqIds('SEL-01..SEL-03,TEST-01'), ['SEL-01', 'SEL-02', 'SEL-03', 'TEST-01'], 'a same-prefix ascending range must expand in place, preserving list order'); }); test('AC2: zero-pad width is preserved across the expansion', () => { assert.deepStrictEqual( normalizePhaseReqIds('PREFIX-001..PREFIX-003'), ['PREFIX-001', 'PREFIX-002', 'PREFIX-003']); }); // ── AC3: existing behavior is unchanged ────────────────────────────────────── test('AC3: single-ID, comma/space/newline, and JSON-array-ish inputs unchanged', () => { assert.deepStrictEqual(normalizePhaseReqIds('REQ-01'), ['REQ-01']); assert.deepStrictEqual(normalizePhaseReqIds('REQ-01,REQ-02'), ['REQ-01', 'REQ-02']); assert.deepStrictEqual(normalizePhaseReqIds('REQ-01 REQ-02'), ['REQ-01', 'REQ-02']); assert.deepStrictEqual(normalizePhaseReqIds('REQ-01\nREQ-02'), ['REQ-01', 'REQ-02']); assert.deepStrictEqual(normalizePhaseReqIds(['REQ-01', 'REQ-02']), ['REQ-01', 'REQ-02']); assert.strictEqual(normalizePhaseReqIds(undefined), undefined); assert.strictEqual(normalizePhaseReqIds(null), null); assert.strictEqual(normalizePhaseReqIds('TBD'), null); assert.strictEqual(normalizePhaseReqIds(''), null); }); // ── AC4: invalid/ambiguous ranges — expandPhaseReqIdToken still fails closed // (returns the token literal), but #3189's post-expand shape filter then // DROPS that literal token (it contains `.`/`..`, so it cannot be a // requirement ID). The token is no longer surfaced as a fake missing // requirement — it is silently dropped, and an all-invalid input collapses // to null (skip semantics). expandPhaseReqIdToken's internal fail-closed // branch is unchanged; this assertion verifies the composition. test('AC4: mismatched-prefix range is dropped (#3189 — was: stayed literal)', () => { assert.strictEqual(normalizePhaseReqIds('SEL-01..TEST-03'), null); }); test('AC4: descending range is dropped (#3189 — was: stayed literal)', () => { assert.strictEqual(normalizePhaseReqIds('SEL-03..SEL-01'), null); }); test('AC4: non-numeric bound is dropped (#3189 — was: stayed literal)', () => { assert.strictEqual(normalizePhaseReqIds('SEL-0A..SEL-0C'), null); }); test('AC4: missing bound is dropped (#3189 — was: stayed literal)', () => { assert.strictEqual(normalizePhaseReqIds('SEL-01..'), null); assert.strictEqual(normalizePhaseReqIds('..SEL-03'), null); }); test('AC4: an invalid range inside a mixed list is dropped while valid ones expand (#3189)', () => { // SEL-01..SEL-03 expands (valid); BAD-3..BAD-1 fails-closed literal then is // dropped by the shape filter. The valid expansions are preserved in order. assert.deepStrictEqual( normalizePhaseReqIds('SEL-01..SEL-03,BAD-3..BAD-1'), ['SEL-01', 'SEL-02', 'SEL-03']); }); // ── Boundary fixtures ──────────────────────────────────────────────────────── test('boundary: single-element range (NN == MM)', () => { assert.deepStrictEqual(normalizePhaseReqIds('SEL-02..SEL-02'), ['SEL-02']); }); test('boundary: two-element range (NN == MM-1)', () => { assert.deepStrictEqual(normalizePhaseReqIds('SEL-01..SEL-02'), ['SEL-01', 'SEL-02']); }); test('boundary: differing zero-pad widths are dropped (#3189 — was: stayed literal)', () => { // Bounds of differing digit width are ambiguous: padding 'SEL-9' to width 2 // would invent 'SEL-09', which may never appear unpadded in REQUIREMENTS. // expandPhaseReqIdToken fails closed (returns the token literal); #3189's // shape filter then drops the literal token (contains `..`). assert.strictEqual(normalizePhaseReqIds('SEL-9..SEL-11'), null); }); test('AC4: a range exceeding MAX_PHASE_REQ_RANGE is dropped (#3189 — DoS guard still fires, then shape filter drops the literal)', () => { // Same-width bounds (both 4 digits) so the differing-width guard does NOT fire // first; span = 1001 - 1 + 1 = 1001 > MAX_PHASE_REQ_RANGE (1000) → the DoS cap // is what keeps this literal. Isolates the cap branch from the width check. // #3189's shape filter then drops the literal token (contains `..`). assert.strictEqual(normalizePhaseReqIds('REQ-0001..REQ-1001'), null); }); test('multi-segment prefix with digits is handled (prefix compared verbatim)', () => { assert.deepStrictEqual( normalizePhaseReqIds('REQ2-01..REQ2-03'), ['REQ2-01', 'REQ2-02', 'REQ2-03']); }); }); /** * #1269 integration (AC5): the gap-analysis CLI must not flag a mapped range — * or the IDs it expands to — as missing when those IDs exist in REQUIREMENTS.md. */ describe('#1269 — gap-analysis --phase-req-ids range (integration)', () => { let tmpDir; let phaseDir; function writeRequirements(ids) { const lines = ids.map((id, i) => `- [ ] **${id}** Requirement ${i + 1} description`); fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), `# Requirements\n\n${lines.join('\n')}\n`); } function writePlan(name, body) { fs.writeFileSync(path.join(phaseDir, `${name}-PLAN.md`), body); } function reqRows(out) { return out.rows.filter(r => r.source === 'REQUIREMENTS.md').map(r => r.item); } beforeEach(() => { tmpDir = createTempProject(); phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(phaseDir, { recursive: true }); const r = runGsdTools('config-ensure-section', tmpDir); assert.ok(r.success, `config-ensure-section failed: ${r.error}`); }); afterEach(() => cleanup(tmpDir)); test('AC5: a mapped range is expanded and not flagged as missing when the IDs exist', () => { writeRequirements(['SEL-01', 'SEL-02', 'SEL-03', 'TEST-01', 'OTHER-09']); // The plan addresses each expanded SEL id and TEST-01. writePlan('01', '# Plan\n\nImplements SEL-01, SEL-02, SEL-03, and TEST-01.\n'); const r = runGsdTools( ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', 'SEL-01..SEL-03,TEST-01'], tmpDir); assert.ok(r.success, r.error); const out = JSON.parse(r.output); assert.deepStrictEqual(reqRows(out).sort(), ['SEL-01', 'SEL-02', 'SEL-03', 'TEST-01'], 'the range expands to individual SEL IDs; the literal range token must NOT appear, and OTHER-09 (unmapped) is excluded'); // The literal range token must never surface as a missing row. assert.ok(!out.rows.some(x => x.item.includes('..')), 'no range-literal row (e.g. "SEL-01..SEL-03") may be reported'); assert.strictEqual(out.counts.uncovered, 0, 'all expanded IDs exist in REQUIREMENTS.md and are covered — zero gaps'); }); }); }); } // ─── #3189: normalizePhaseReqIds ID-shape filter (post-expand) ──────────────── // // ROADMAP `**Requirements:**` lines routinely carry prose trailing the real ID // list (locked-decision annotations, ambiguity scores, prohibitions, dates). // The function's own contract says callers may pass that roadmap value through // verbatim, but the whitespace split + range expansion alone let every prose // word through as a "missing requirement", drowning the real coverage signal // (8 real gaps became 21 reported in the issue's reproduction). // // #3189 adds a shape filter (`PHASE_REQ_ID_SHAPE_RE`) STRICTLY AFTER range // expansion so: // - real IDs survive (both hyphen-less `R1` and prefix-hyphen `SEL-01`); // - prose / punctuation / dates / invalid range tokens are dropped; // - an all-dropped input collapses to `null` (skip semantics); // - valid range expansion still works (filter is post-expand, so // `SEL-01..SEL-03` expands first, then each expanded ID passes the filter). // // These are the unit-level fixtures; the end-to-end CLI reproduction lives in // tests/check-gap-analysis-plan-post-e2e.test.cjs. describe('#3189 — normalizePhaseReqIds drops non-ID prose after range expansion', () => { // ── AC1: prose trailing a real ID list — only ID-shaped tokens survive ────── test('AC1 (issue repro): prose-annotated Requirements value yields only the ID-shaped tokens', () => { // The exact reproduction string from the issue. After bracket/paren strip, // whitespace split, range expansion, and the #3189 shape filter, only the // ID-shaped tokens survive: R1..R8 plus `P1-P3`. Every prose fragment // (locked, , —, canonical, source, `NN-SPEC.md, ##, Requirements;, // ambiguity, 0.12;, +, prohibitions) is dropped. // // `P1-P3` (from "prohibitions P1-P3") SURVIVES because it is syntactically // ID-shaped: it matches `PHASE_REQ_ID_SHAPE_RE` exactly the way `SEL-01` // does, and the issue's own note 1 lists `P1-P3` alongside `R1` and // `SEL-01` as an example of a real requirement ID that carries a digit. It // is consistent with the codebase's `ID_PATTERN` (`[A-Z][A-Z0-9]*-[A-Za-z0-9_-]+`), // which ALSO accepts `P1-P3` — so a `P1-P3` requirement parsed from // REQUIREMENTS.md would be a valid ID, and dropping the same token here // would create a false mismatch. The 8 R-IDs are the "actual" requirement // IDs in the issue's REQUIREMENTS.md; `P1-P3` is an additional ID-shaped // token that the filter (correctly) cannot distinguish from a real ID // without semantic context. const raw = 'R1, R2, R3, R4, R5, R6, R7, R8 (locked — canonical source `NN-SPEC.md ## Requirements`; ambiguity 0.12; + prohibitions P1-P3)'; assert.deepStrictEqual( normalizePhaseReqIds(raw), ['R1', 'R2', 'R3', 'R4', 'R5', 'R6', 'R7', 'R8', 'P1-P3'], 'only ID-shaped tokens survive; every prose fragment is dropped'); }); test('AC1: every prose fragment named in the issue is dropped (no prose reaches the comparison)', () => { const raw = 'R1, R2, R3, R4, R5, R6, R7, R8 (locked — canonical source `NN-SPEC.md ## Requirements`; ambiguity 0.12; + prohibitions P1-P3)'; const result = normalizePhaseReqIds(raw); // These are the exact fragments the issue reported as fake "missing // requirements" — none may survive the shape filter. const droppedProse = [ 'locked', '', '—', 'canonical', 'source', '`NN-SPEC.md', '##', 'Requirements;', 'ambiguity', '0.12;', '+', 'prohibitions', ]; for (const frag of droppedProse) { assert.ok(!result.includes(frag), `prose fragment ${JSON.stringify(frag)} must be dropped, but it survived in ${JSON.stringify(result)}`); } }); test('AC1: em-dash, markdown heading marker, bare +, version-like 0.12; and are all dropped', () => { assert.deepStrictEqual(normalizePhaseReqIds('REQ-01 — ## + 0.12; '), ['REQ-01']); }); test('AC1: backtick filename fragment and trailing prose are dropped', () => { assert.deepStrictEqual(normalizePhaseReqIds('REQ-01 `NN-SPEC.md` canonical source'), ['REQ-01']); }); // ── AC2: a pure-prose caps token with no digit is dropped ─────────────────── // // The digit lookahead `(?=.*\d)` in PHASE_REQ_ID_SHAPE_RE is load-bearing: // without it ALL-CAPS prose tokens that appear in annotations (LOCKED, NONE, // TBD-as-prose) would pass the shape and still reach the report as fake IDs. test('AC2: a pure-prose caps token with no digit is dropped (digit lookahead is load-bearing)', () => { assert.strictEqual(normalizePhaseReqIds('LOCKED'), null); assert.strictEqual(normalizePhaseReqIds('PROSE'), null); }); test('AC2: a pure-prose caps token mixed with real IDs is dropped, real IDs survive', () => { assert.deepStrictEqual(normalizePhaseReqIds('REQ-01 LOCKED REQ-02'), ['REQ-01', 'REQ-02']); }); // ── Edge case: ID-shaped prose tokens survive (filter is syntactic, not semantic) ─ // // `P1-P3` (from "prohibitions P1-P3") is syntactically indistinguishable from // a real requirement ID — it matches `PHASE_REQ_ID_SHAPE_RE` exactly as // `SEL-01` does, and the codebase's own `ID_PATTERN` (`[A-Z][A-Z0-9]*-[A-Za-z0-9_-]+`) // accepts it too. The filter is purely syntactic: it cannot know from shape // alone that this particular `P1-P3` was prose describing a prohibition range // rather than a real requirement ID. The issue's note 1 explicitly groups // `P1-P3` with `R1` and `SEL-01` as a real-ID shape. Keeping it is consistent // with how REQUIREMENTS.md parsing treats the same token; dropping it would // create a false mismatch for any project that legitimately uses `P1-P3`-shaped // IDs. Tightening the filter to reject this shape is a separate decision // (would need a semantic signal the filter does not have). test('edge: an ID-shaped prose token (P1-P3) survives the syntactic filter, consistent with ID_PATTERN', () => { assert.deepStrictEqual(normalizePhaseReqIds('P1-P3'), ['P1-P3']); // And it does NOT collapse a mixed list to null — real IDs alongside it survive. assert.deepStrictEqual(normalizePhaseReqIds('R1 P1-P3 R8'), ['R1', 'P1-P3', 'R8']); }); // ── AC3: range + trailing prose — expand THEN filter ──────────────────────── // // Filter ordering is load-bearing: filtering BEFORE expandPhaseReqIdToken // would discard the range token itself (`SEL-01..SEL-03` matches no single-ID // shape), silently undoing #1269 / #1419. test('AC3: range followed by trailing prose still expands, then the prose is dropped', () => { assert.deepStrictEqual( normalizePhaseReqIds('SEL-01..SEL-03 (locked )'), ['SEL-01', 'SEL-02', 'SEL-03']); }); test('AC3: range + single ID + trailing prose preserves order, drops prose', () => { assert.deepStrictEqual( normalizePhaseReqIds('SEL-01..SEL-03, TEST-01 (notes: locked)'), ['SEL-01', 'SEL-02', 'SEL-03', 'TEST-01']); }); // ── AC4: empty/null/tbd/none sentinel skip behavior is unchanged ──────────── test('AC4: undefined / null / empty / tbd / none sentinel skip behavior is unchanged', () => { assert.strictEqual(normalizePhaseReqIds(undefined), undefined); assert.strictEqual(normalizePhaseReqIds(null), null); assert.strictEqual(normalizePhaseReqIds(''), null); assert.strictEqual(normalizePhaseReqIds('TBD'), null); assert.strictEqual(normalizePhaseReqIds('tbd'), null); assert.strictEqual(normalizePhaseReqIds('none'), null); assert.strictEqual(normalizePhaseReqIds('NONE'), null); }); test('AC4: an all-prose input (every token dropped) collapses to null (skip semantics)', () => { assert.strictEqual(normalizePhaseReqIds('locked — ambiguity 0.12; prohibitions'), null); }); // ── AC5: real coverage detection is NOT narrowed ──────────────────────────── // // A genuinely-missing requirement ID (no prose involved) must still be // returned so the caller can report it as "Missing from REQUIREMENTS.md". test('AC5: a genuinely-missing requirement ID is still returned (no narrowing)', () => { assert.deepStrictEqual(normalizePhaseReqIds('REQ-01,REQ-99'), ['REQ-01', 'REQ-99']); }); test('AC5 (backstop): hyphen-less digit-bearing IDs (R-family) are preserved', () => { assert.deepStrictEqual(normalizePhaseReqIds('R1,R8'), ['R1', 'R8']); }); test('AC5 (backstop): prefix-hyphen IDs and multi-segment-prefix IDs are preserved', () => { assert.deepStrictEqual(normalizePhaseReqIds('SEL-01,REQ-02'), ['SEL-01', 'REQ-02']); assert.deepStrictEqual( normalizePhaseReqIds('REQ2-01..REQ2-03'), ['REQ2-01', 'REQ2-02', 'REQ2-03']); }); // ── AC5 (backstop): existing input shapes that already worked are unchanged ─ test('AC5 (backstop): comma/space/newline/JSON-array-ish inputs unchanged for real IDs', () => { assert.deepStrictEqual(normalizePhaseReqIds('REQ-01,REQ-02'), ['REQ-01', 'REQ-02']); assert.deepStrictEqual(normalizePhaseReqIds('REQ-01 REQ-02'), ['REQ-01', 'REQ-02']); assert.deepStrictEqual(normalizePhaseReqIds('REQ-01\nREQ-02'), ['REQ-01', 'REQ-02']); assert.deepStrictEqual(normalizePhaseReqIds(['REQ-01', 'REQ-02']), ['REQ-01', 'REQ-02']); }); });