From 98d92d75709734f363b8d6c2ae25a935588beaf7 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 21 Apr 2026 21:18:58 -0400 Subject: [PATCH] fix(#2526): warn about REQ-IDs in body missing from Traceability table (#2539) Scan REQUIREMENTS.md body for all **REQ-ID** patterns during phase complete and emit a warning for any IDs absent from the Traceability table, regardless of whether the roadmap has a Requirements: line. Closes #2526 --- get-shit-done/bin/lib/phase.cjs | 39 ++- ...2526-phase-complete-req-discovery.test.cjs | 271 ++++++++++++++++++ 2 files changed, 306 insertions(+), 4 deletions(-) create mode 100644 tests/bug-2526-phase-complete-req-discovery.test.cjs diff --git a/get-shit-done/bin/lib/phase.cjs b/get-shit-done/bin/lib/phase.cjs index 9e829f065..5853ea5e7 100644 --- a/get-shit-done/bin/lib/phase.cjs +++ b/get-shit-done/bin/lib/phase.cjs @@ -870,9 +870,10 @@ function cmdPhaseComplete(cwd, phaseNum, raw) { const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : ''; const reqMatch = sectionText.match(/\*\*Requirements:\*\*\s*([^\n]+)/i); + let reqContent = fs.readFileSync(reqPath, 'utf-8'); + if (reqMatch) { const reqIds = reqMatch[1].replace(/[\[\]]/g, '').split(/[,\s]+/).map(r => r.trim()).filter(Boolean); - let reqContent = fs.readFileSync(reqPath, 'utf-8'); for (const reqId of reqIds) { const reqEscaped = escapeRegex(reqId); @@ -887,10 +888,40 @@ function cmdPhaseComplete(cwd, phaseNum, raw) { '$1 Complete $2' ); } - - atomicWriteFileSync(reqPath, reqContent); - requirementsUpdated = true; } + + // Scan body for all **REQ-ID** patterns, warn about any missing from the Traceability table. + // Always runs regardless of whether the roadmap has a Requirements: line. + const bodyReqIds = []; + const bodyReqPattern = /\*\*([A-Z][A-Z0-9]*-\d+)\*\*/g; + let bodyMatch; + while ((bodyMatch = bodyReqPattern.exec(reqContent)) !== null) { + const id = bodyMatch[1]; + if (!bodyReqIds.includes(id)) bodyReqIds.push(id); + } + + // Collect REQ-IDs present in the Traceability section only, to avoid + // picking up IDs from other tables in the document. + const traceabilityHeadingMatch = reqContent.match(/^#{1,6}\s+Traceability\b/im); + const traceabilitySection = traceabilityHeadingMatch + ? reqContent.slice(traceabilityHeadingMatch.index) + : ''; + const tableReqIds = new Set(); + const tableRowPattern = /^\|\s*([A-Z][A-Z0-9]*-\d+)\s*\|/gm; + let tableMatch; + while ((tableMatch = tableRowPattern.exec(traceabilitySection)) !== null) { + tableReqIds.add(tableMatch[1]); + } + + const unregistered = bodyReqIds.filter(id => !tableReqIds.has(id)); + if (unregistered.length > 0) { + warnings.push( + `REQUIREMENTS.md: ${unregistered.length} REQ-ID(s) found in body but missing from Traceability table: ${unregistered.join(', ')} — add them manually to keep traceability in sync` + ); + } + + atomicWriteFileSync(reqPath, reqContent); + requirementsUpdated = true; } }); } diff --git a/tests/bug-2526-phase-complete-req-discovery.test.cjs b/tests/bug-2526-phase-complete-req-discovery.test.cjs new file mode 100644 index 000000000..39c2a8452 --- /dev/null +++ b/tests/bug-2526-phase-complete-req-discovery.test.cjs @@ -0,0 +1,271 @@ +/** + * Regression tests for bug #2526 + * + * phase complete must warn about REQ-IDs that appear in the REQUIREMENTS.md + * body but are missing from the Traceability table. + * + * Root cause: cmdPhaseComplete() only flips status for REQ-IDs already in + * the Traceability table (from the roadmap **Requirements:** line). REQ-IDs + * added to the REQUIREMENTS.md body after roadmap creation are never + * discovered or reflected in the table. + * + * Fix (Option A — warning only): scan the REQUIREMENTS.md body for all + * REQ-IDs, check which are absent from the Traceability table, and emit + * a warning listing the missing IDs. + */ + +'use strict'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync } = require('node:child_process'); + +const gsdTools = path.resolve(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs'); + +describe('bug #2526: phase complete warns about unregistered REQ-IDs', () => { + let tmpDir; + let planningDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2526-')); + planningDir = path.join(tmpDir, '.planning'); + fs.mkdirSync(planningDir, { recursive: true }); + + // Minimal config + fs.writeFileSync( + path.join(planningDir, 'config.json'), + JSON.stringify({ project_code: '' }) + ); + + // Minimal STATE.md + fs.writeFileSync( + path.join(planningDir, 'STATE.md'), + '---\ncurrent_phase: 1\nstatus: executing\n---\n# State\n' + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('emits warning for REQ-IDs in body but missing from Traceability table', () => { + // Set up phase directory with a plan and summary + const phasesDir = path.join(planningDir, 'phases', '01-foundation'); + fs.mkdirSync(phasesDir, { recursive: true }); + fs.writeFileSync( + path.join(phasesDir, '01-1-PLAN.md'), + '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' + ); + fs.writeFileSync( + path.join(phasesDir, '01-1-SUMMARY.md'), + '---\nstatus: complete\n---\n# Summary\nDone.' + ); + + // ROADMAP.md — phase 1 lists only REQ-001 in its Requirements line + const roadmapPath = path.join(planningDir, 'ROADMAP.md'); + fs.writeFileSync(roadmapPath, [ + '# Roadmap', + '', + '### Phase 1: Foundation', + '', + '**Goal:** Build core', + '**Requirements:** REQ-001', + '**Plans:** 1 plans', + '', + 'Plans:', + '- [x] 01-1-PLAN.md', + '', + '| Phase | Plans | Status | Completed |', + '|-------|-------|--------|-----------|', + '| 1. Foundation | 0/1 | Pending | - |', + ].join('\n')); + + // REQUIREMENTS.md — body has REQ-001 (in table) and REQ-002, REQ-003 (missing from table) + const reqPath = path.join(planningDir, 'REQUIREMENTS.md'); + fs.writeFileSync(reqPath, [ + '# Requirements', + '', + '## Functional Requirements', + '', + '- [x] **REQ-001**: Core data model', + '- [ ] **REQ-002**: User authentication', + '- [ ] **REQ-003**: API endpoints', + '', + '## Traceability', + '', + '| REQ-ID | Phase | Status |', + '|--------|-------|--------|', + '| REQ-001 | 1 | Pending |', + ].join('\n')); + + let stdout = ''; + let stderr = ''; + try { + const result = execFileSync('node', [gsdTools, 'phase', 'complete', '1'], { + cwd: tmpDir, + timeout: 10000, + encoding: 'utf-8', + }); + stdout = result; + } catch (err) { + stdout = err.stdout || ''; + stderr = err.stderr || ''; + throw err; + } + + const combined = stdout + stderr; + assert.match( + combined, + /REQ-002/, + 'output should mention REQ-002 as missing from Traceability table' + ); + assert.match( + combined, + /REQ-003/, + 'output should mention REQ-003 as missing from Traceability table' + ); + }); + + test('no warning when all body REQ-IDs are present in Traceability table', () => { + // Set up phase directory + const phasesDir = path.join(planningDir, 'phases', '01-foundation'); + fs.mkdirSync(phasesDir, { recursive: true }); + fs.writeFileSync( + path.join(phasesDir, '01-1-PLAN.md'), + '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' + ); + fs.writeFileSync( + path.join(phasesDir, '01-1-SUMMARY.md'), + '---\nstatus: complete\n---\n# Summary\nDone.' + ); + + const roadmapPath = path.join(planningDir, 'ROADMAP.md'); + fs.writeFileSync(roadmapPath, [ + '# Roadmap', + '', + '### Phase 1: Foundation', + '', + '**Goal:** Build core', + '**Requirements:** REQ-001, REQ-002', + '**Plans:** 1 plans', + '', + 'Plans:', + '- [x] 01-1-PLAN.md', + '', + '| Phase | Plans | Status | Completed |', + '|-------|-------|--------|-----------|', + '| 1. Foundation | 0/1 | Pending | - |', + ].join('\n')); + + // All body REQ-IDs are present in the Traceability table + const reqPath = path.join(planningDir, 'REQUIREMENTS.md'); + fs.writeFileSync(reqPath, [ + '# Requirements', + '', + '## Functional Requirements', + '', + '- [x] **REQ-001**: Core data model', + '- [x] **REQ-002**: User authentication', + '', + '## Traceability', + '', + '| REQ-ID | Phase | Status |', + '|--------|-------|--------|', + '| REQ-001 | 1 | Pending |', + '| REQ-002 | 1 | Pending |', + ].join('\n')); + + let stdout = ''; + let stderr = ''; + try { + const result = execFileSync('node', [gsdTools, 'phase', 'complete', '1'], { + cwd: tmpDir, + timeout: 10000, + encoding: 'utf-8', + }); + stdout = result; + } catch (err) { + stdout = err.stdout || ''; + stderr = err.stderr || ''; + throw err; + } + + const combined = stdout + stderr; + assert.doesNotMatch( + combined, + /unregistered|missing.*traceability|not in.*traceability/i, + 'no warning should appear when all REQ-IDs are in the table' + ); + }); + + test('warning includes all missing REQ-IDs, not just the first', () => { + const phasesDir = path.join(planningDir, 'phases', '01-foundation'); + fs.mkdirSync(phasesDir, { recursive: true }); + fs.writeFileSync( + path.join(phasesDir, '01-1-PLAN.md'), + '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' + ); + fs.writeFileSync( + path.join(phasesDir, '01-1-SUMMARY.md'), + '---\nstatus: complete\n---\n# Summary\nDone.' + ); + + const roadmapPath = path.join(planningDir, 'ROADMAP.md'); + fs.writeFileSync(roadmapPath, [ + '# Roadmap', + '', + '### Phase 1: Foundation', + '', + '**Goal:** Build core', + '**Requirements:** REQ-001', + '**Plans:** 1 plans', + '', + 'Plans:', + '- [x] 01-1-PLAN.md', + '', + '| Phase | Plans | Status | Completed |', + '|-------|-------|--------|-----------|', + '| 1. Foundation | 0/1 | Pending | - |', + ].join('\n')); + + // Body has 4 REQ-IDs; table only has 1 + const reqPath = path.join(planningDir, 'REQUIREMENTS.md'); + fs.writeFileSync(reqPath, [ + '# Requirements', + '', + '- [x] **REQ-001**: Core data model', + '- [ ] **REQ-002**: User auth', + '- [ ] **REQ-003**: API', + '- [ ] **REQ-004**: Reports', + '', + '## Traceability', + '', + '| REQ-ID | Phase | Status |', + '|--------|-------|--------|', + '| REQ-001 | 1 | Pending |', + ].join('\n')); + + let stdout = ''; + let stderr = ''; + try { + const result = execFileSync('node', [gsdTools, 'phase', 'complete', '1'], { + cwd: tmpDir, + timeout: 10000, + encoding: 'utf-8', + }); + stdout = result; + } catch (err) { + stdout = err.stdout || ''; + stderr = err.stderr || ''; + throw err; + } + + const combined = stdout + stderr; + assert.match(combined, /REQ-002/, 'should warn about REQ-002'); + assert.match(combined, /REQ-003/, 'should warn about REQ-003'); + assert.match(combined, /REQ-004/, 'should warn about REQ-004'); + }); +});