fix: gap-analysis parses mixed requirement prefixes and skips table headers (#2902)

* fix: parse non-REQ IDs in gap-analysis and ignore table headers

* fix: parse requirement IDs from first traceability column only

---------

Co-authored-by: Tom Boucher <thomas.boucher@sas.com>
This commit is contained in:
Tom Boucher
2026-04-30 12:13:55 -04:00
committed by GitHub
parent abb2cb63f6
commit 6dce1de4a7
3 changed files with 72 additions and 5 deletions

View File

@@ -39,6 +39,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
posting a comment that points to the contribution guide. (#2872)
### Fixed — 1.40.0-rc.1
- **`gap-analysis` now parses non-`REQ-` requirement IDs and ignores traceability table headers** — `parseRequirements()` no longer hard-codes the `REQ-` prefix and now accepts uppercase prefixed IDs such as `TST-01`, `BACK-07`, and `INSP-04`; markdown table header rows (for example `| REQ-ID | ... |`) are excluded so header tokens are not reported as phantom uncovered requirements. Added regression coverage for mixed-prefix REQUIREMENTS files with traceability tables. (#2897)
- **Gemini slash commands namespaced as `/gsd:<cmd>` instead of `/gsd-<cmd>`** —
Gemini CLI namespaces commands under `gsd:`, so `/gsd-plan-phase` was unexecutable.
Body-text references in commands, agents, banners, and patch-reapply hints are now

View File

@@ -31,7 +31,10 @@ function parseRequirements(reqMd) {
const out = [];
const seen = new Set();
const checkboxRe = /^\s*-\s*\[[x ]\]\s*\*\*(REQ-[A-Za-z0-9_-]+)\*\*\s*(.*)$/gm;
// Prefix-agnostic ID format: REQ-01, TST-01, BACK-07, INSP-04, etc.
const ID_PATTERN = '[A-Z][A-Z0-9]*-[A-Za-z0-9_-]+';
const checkboxRe = new RegExp(`^\\s*-\\s*\\[[x ]\\]\\s*\\*\\*(${ID_PATTERN})\\*\\*\\s*(.*)$`, 'gm');
let cm = checkboxRe.exec(reqMd);
while (cm !== null) {
const id = cm[1];
@@ -42,15 +45,25 @@ function parseRequirements(reqMd) {
cm = checkboxRe.exec(reqMd);
}
const tableRe = /\|\s*(REQ-[A-Za-z0-9_-]+)\s*\|/g;
let tm = tableRe.exec(reqMd);
while (tm !== null) {
const tableFirstCellRe = new RegExp(`^\\s*\\|\\s*(${ID_PATTERN})\\s*\\|`);
const separatorRowRe = /^\s*\|[\s:|-]+\|\s*$/;
const lines = reqMd.split(/\r?\n/);
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
if (!line.includes('|')) continue;
// Skip markdown table separator rows and header rows immediately preceding them.
if (separatorRowRe.test(line)) continue;
if (i + 1 < lines.length && separatorRowRe.test(lines[i + 1])) continue;
const tm = tableFirstCellRe.exec(line);
if (!tm) continue;
const id = tm[1];
if (!seen.has(id)) {
seen.add(id);
out.push({ id, text: '' });
}
tm = tableRe.exec(reqMd);
}
return out;

View File

@@ -240,6 +240,59 @@ describe('gap-analysis CLI (#2493)', () => {
assert.deepStrictEqual(reqRows, ['REQ-01', 'REQ-02', 'REQ-10']);
});
test('parses non-REQ prefixes and ignores traceability header tokens', () => {
const requirementsMd = [
'# Requirements',
'',
'| REQ-ID | Phase | Plan(s) |',
'|--------|-------|---------|',
'| TST-01 | Phase 01 | TBD |',
'| BACK-07 | Phase 01 | TBD |',
'',
'- [ ] **INSP-04** Inspector requirement.',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), `${requirementsMd}\n`);
writePlan('01', '# Plan\n\nCovers TST-01, BACK-07, and INSP-04.\n');
const r = runGsdTools(['gap-analysis', '--phase-dir', phaseDir], tmpDir);
assert.ok(r.success, r.error);
const out = JSON.parse(r.output);
const reqRows = out.rows.filter(x => x.source === 'REQUIREMENTS.md');
const ids = reqRows.map(x => x.item);
assert.deepStrictEqual(ids, ['BACK-07', 'INSP-04', 'TST-01']);
assert.ok(!ids.includes('REQ-ID'), 'traceability header token must not be parsed as a requirement ID');
assert.ok(reqRows.every(x => x.status === 'Covered'));
});
test('does not parse requirement-like IDs from non-first table columns', () => {
const requirementsMd = [
'# Requirements',
'',
'| REQ-ID | Phase | Plan(s) |',
'|--------|-------|---------|',
'| TST-01 | Phase 01 | PLAN-01 |',
'| BACK-07 | Phase 01 | PLAN-02 |',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), `${requirementsMd}\n`);
writePlan('01', '# Plan\n\nCovers TST-01 and BACK-07 only.\n');
const r = runGsdTools(['gap-analysis', '--phase-dir', phaseDir], tmpDir);
assert.ok(r.success, r.error);
const out = JSON.parse(r.output);
const ids = out.rows
.filter(x => x.source === 'REQUIREMENTS.md')
.map(x => x.item);
assert.deepStrictEqual(ids, ['BACK-07', 'TST-01']);
assert.ok(!ids.includes('PLAN-01'));
assert.ok(!ids.includes('PLAN-02'));
});
test('REQUIREMENTS.md missing → CONTEXT-only run still works', () => {
writeContext([{ id: 'D-01', text: 'foo' }]);
writePlan('01', '# Plan mentioning D-01\n');