// allow-test-rule: source-text-is-the-product // Reads .md/.json/.yml product files whose deployed text IS what the // runtime loads — testing text content tests the deployed contract. /** * GSD Tools Tests - Roadmap */ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const { runGsdTools, createTempProject, cleanup, captureFdSync } = require('./helpers.cjs'); const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); describe('roadmap get-phase command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('extracts phase section from ROADMAP.md', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0 ## Phases ### Phase 1: Foundation **Goal:** Set up project infrastructure **Plans:** 2 plans Some description here. ### Phase 2: API **Goal:** Build REST API **Plans:** 3 plans ` ); const result = runGsdTools('roadmap get-phase 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, true, 'phase should be found'); assert.strictEqual(output.phase_number, '1', 'phase number correct'); assert.strictEqual(output.phase_name, 'Foundation', 'phase name extracted'); assert.strictEqual(output.goal, 'Set up project infrastructure', 'goal extracted'); }); test('returns not found for missing phase', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0 ### Phase 1: Foundation **Goal:** Set up project ` ); const result = runGsdTools('roadmap get-phase 5', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, false, 'phase should not be found'); }); test('handles decimal phase numbers', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 2: Main **Goal:** Main work ### Phase 2.1: Hotfix **Goal:** Emergency fix ` ); const result = runGsdTools('roadmap get-phase 2.1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, true, 'decimal phase should be found'); assert.strictEqual(output.phase_name, 'Hotfix', 'phase name correct'); assert.strictEqual(output.goal, 'Emergency fix', 'goal extracted'); }); test('extracts full section content', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Setup **Goal:** Initialize everything This phase covers: - Database setup - Auth configuration - CI/CD pipeline ### Phase 2: Build **Goal:** Build features ` ); const result = runGsdTools('roadmap get-phase 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.ok(output.section.includes('Database setup'), 'section includes description'); assert.ok(output.section.includes('CI/CD pipeline'), 'section includes all bullets'); assert.ok(!output.section.includes('Phase 2'), 'section does not include next phase'); }); test('handles missing ROADMAP.md gracefully', () => { const result = runGsdTools('roadmap get-phase 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, false, 'should return not found'); assert.strictEqual(output.error, 'ROADMAP.md not found', 'should explain why'); }); test('accepts ## phase headers (two hashes)', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0 ## Phase 1: Foundation **Goal:** Set up project infrastructure **Plans:** 2 plans ## Phase 2: API **Goal:** Build REST API ` ); const result = runGsdTools('roadmap get-phase 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, true, 'phase with ## header should be found'); assert.strictEqual(output.phase_name, 'Foundation', 'phase name extracted'); assert.strictEqual(output.goal, 'Set up project infrastructure', 'goal extracted'); }); test('extracts goal when colon is outside bold (**Goal**: format)', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.24 ### Phase 5: Skill Scaffolding **Goal**: The autonomous skill files exist following project conventions **Plans:** 2 plans ### Phase 6: Smart Discuss **Goal**: Grey area resolution works with proposals ` ); const result = runGsdTools('roadmap get-phase 5', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, true, 'phase should be found'); assert.strictEqual(output.goal, 'The autonomous skill files exist following project conventions', 'goal extracted with colon outside bold'); }); test('extracts goal for both colon-inside and colon-outside bold formats', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Alpha **Goal:** Colon inside bold format ### Phase 2: Beta **Goal**: Colon outside bold format ` ); const result1 = runGsdTools('roadmap get-phase 1', tmpDir); const output1 = JSON.parse(result1.output); assert.strictEqual(output1.goal, 'Colon inside bold format', 'colon-inside-bold goal extracted'); const result2 = runGsdTools('roadmap get-phase 2', tmpDir); const output2 = JSON.parse(result2.output); assert.strictEqual(output2.goal, 'Colon outside bold format', 'colon-outside-bold goal extracted'); }); test('detects malformed ROADMAP with summary list but no detail sections', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0 ## Phases - [ ] **Phase 1: Foundation** - Set up project - [ ] **Phase 2: API** - Build REST API ` ); const result = runGsdTools('roadmap get-phase 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, false, 'phase should not be found'); assert.strictEqual(output.error, 'malformed_roadmap', 'should identify malformed roadmap'); assert.ok(output.message.includes('missing'), 'should explain the issue'); }); }); // ───────────────────────────────────────────────────────────────────────────── // phase next-decimal command // ───────────────────────────────────────────────────────────────────────────── describe('roadmap analyze command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('missing ROADMAP.md returns error', () => { const result = runGsdTools('roadmap analyze', tmpDir); assert.ok(result.success, `Command should succeed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.error, 'ROADMAP.md not found'); }); test('parses phases with goals and disk status', () => { // #3217 (ADR-3180 §7.6 rule 4): no version token — no STATE.md exists to // resolve which milestone "v1.0" names, so a version-bearing title here // would window as UNSCOPED (§7.1 row 4), not the free-form COMPLETE // window this test's disk-status counting depends on. fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Foundation **Goal:** Set up infrastructure ### Phase 2: Authentication **Goal:** Add user auth ### Phase 3: Features **Goal:** Build core features ` ); // Create phase dirs with varying completion const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); // Disk-strict completion (ADR-3180 §7.4, #3186): a passing // *-VERIFICATION.md is what makes phase 1 count as complete now. fs.writeFileSync(path.join(p1, '01-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n'); const p2 = path.join(tmpDir, '.planning', 'phases', '02-authentication'); fs.mkdirSync(p2, { recursive: true }); fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan'); const result = runGsdTools('roadmap analyze', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase_count, 3, 'should find 3 phases'); assert.strictEqual(output.phases[0].disk_status, 'complete', 'phase 1 complete'); assert.strictEqual(output.phases[1].disk_status, 'planned', 'phase 2 planned'); assert.strictEqual(output.phases[2].disk_status, 'no_directory', 'phase 3 no directory'); assert.strictEqual(output.completed_phases, 1, '1 phase complete'); assert.strictEqual(output.total_plans, 2, '2 total plans'); assert.strictEqual(output.total_summaries, 1, '1 total summary'); assert.strictEqual(output.progress_percent, 50, '50% complete'); assert.strictEqual(output.current_phase, '2', 'current phase is 2'); }); test('extracts goals and dependencies', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Setup **Goal:** Initialize project **Depends on:** Nothing ### Phase 2: Build **Goal:** Build features **Depends on:** Phase 1 ` ); const result = runGsdTools('roadmap analyze', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phases[0].goal, 'Initialize project'); assert.strictEqual(output.phases[0].depends_on, 'Nothing'); assert.strictEqual(output.phases[1].goal, 'Build features'); assert.strictEqual(output.phases[1].depends_on, 'Phase 1'); }); test('extracts goals and depends_on with colon outside bold (**Goal**: format)', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.24 ### Phase 5: Skill Scaffolding **Goal**: The autonomous skill files exist following project conventions **Depends on**: Phase 4 (v1.23 complete) ### Phase 6: Smart Discuss **Goal**: Grey area resolution works with proposals **Depends on**: Phase 5 ` ); const result = runGsdTools('roadmap analyze', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phases[0].goal, 'The autonomous skill files exist following project conventions', 'goal extracted with colon outside bold'); assert.strictEqual(output.phases[0].depends_on, 'Phase 4 (v1.23 complete)', 'depends_on extracted with colon outside bold'); assert.strictEqual(output.phases[1].goal, 'Grey area resolution works with proposals', 'second phase goal extracted'); assert.strictEqual(output.phases[1].depends_on, 'Phase 5', 'second phase depends_on extracted'); }); test('handles mixed colon-inside and colon-outside bold formats in analyze', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Alpha **Goal:** Colon inside bold **Depends on:** Nothing ### Phase 2: Beta **Goal**: Colon outside bold **Depends on**: Phase 1 ` ); const result = runGsdTools('roadmap analyze', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phases[0].goal, 'Colon inside bold', 'colon-inside goal works'); assert.strictEqual(output.phases[0].depends_on, 'Nothing', 'colon-inside depends_on works'); assert.strictEqual(output.phases[1].goal, 'Colon outside bold', 'colon-outside goal works'); assert.strictEqual(output.phases[1].depends_on, 'Phase 1', 'colon-outside depends_on works'); }); // #4478: collectAnalyzePhases's phase-heading regex had no line anchor — // #{2,4} could match a `### Phase N:`-shaped mention ANYWHERE the global // regex scan reached: mid-sentence prose, inside a blockquote, inside an // inline code span (backtick-quoted on the same line, not a fenced code // block tokenizeHeadings would exclude). Any such line minted a phantom // phase entry. Rows mirror the issue's own reproduction table exactly. const PHANTOM_PHASE_LOOKALIKE_ROWS = [ ['blockquote + inline code span', '> A note mentioning `### Phase 2:` in a blockquote.'], ['blockquote, no code span', '> A note mentioning ### Phase 2: in a blockquote.'], ['plain prose + inline code span', 'A note mentioning `### Phase 2:` in plain prose.'], ['plain prose, no code span', 'A note mentioning ### Phase 2: in plain prose.'], ['list item + inline code span', '- list item mentioning `### Phase 2:` here.'], ]; for (const [label, lookalikeLine] of PHANTOM_PHASE_LOOKALIKE_ROWS) { test(`#4478: does not mint a phantom phase — ${label}`, () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ## Milestone v1.0 — Example ${lookalikeLine} ### Phase 1: Real First Phase **Goal**: Real. ` ); const result = runGsdTools('roadmap analyze', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase_count, 1, `expected only the real phase, got: ${JSON.stringify(output.phases)}`); assert.deepStrictEqual(output.phases.map((p) => p.name), ['Real First Phase']); }); } test('#4478: a phantom does not shadow or collide with a real heading sharing its number', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ## Milestone v1.0 — Example ### Phase 1: Real First Phase **Goal**: Real. A note mentioning ### Phase 2: in plain prose. ### Phase 2: Real Second Phase **Goal**: Also real. ` ); const result = runGsdTools('roadmap analyze', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase_count, 2, `expected exactly the two real phases, got: ${JSON.stringify(output.phases)}`); assert.deepStrictEqual( output.phases.map((p) => p.name).sort(), ['Real First Phase', 'Real Second Phase'].sort(), ); }); test('#4478: a legitimately-indented (1-3 space) real heading is still counted', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ## Milestone v1.0 — Example ### Phase 1: Indented but real **Goal**: Real. ` ); const result = runGsdTools('roadmap analyze', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase_count, 1, `expected the indented heading to still count, got: ${JSON.stringify(output.phases)}`); assert.deepStrictEqual(output.phases.map((p) => p.name), ['Indented but real']); }); // Follow-up (found by this fix's own independent code review): the "next // heading" section-boundary lookup a few lines below phasePattern lacked // the SAME {0,3} leading-space tolerance, so an indented NEXT phase heading // was invisible to it, letting the prior phase's field extraction bleed // across the section boundary. Uses **Depends on:** specifically because // it is a field only the SECOND phase has — Goal alone can't demonstrate // the bleed since both phases have one and the first (non-global) match // still finds phase 1's own occurrence first either way. test('#4478: an indented NEXT phase heading still bounds the prior phase\'s field extraction', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ## Milestone v1.0 — Example ### Phase 1: Real First Phase **Goal**: First goal. ### Phase 2: Indented Next Phase **Goal**: Second goal. **Depends on:** Phase 1 ` ); const result = runGsdTools('roadmap analyze', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); const byNumber = Object.fromEntries(output.phases.map((p) => [p.number, p])); assert.strictEqual( byNumber['1'].depends_on, null, `phase 1 has no Depends on field of its own — it must not inherit phase 2's, got: ${JSON.stringify(byNumber['1'])}`, ); assert.strictEqual(byNumber['2'].depends_on, 'Phase 1'); }); }); // ───────────────────────────────────────────────────────────────────────────── // roadmap analyze disk status variants // ───────────────────────────────────────────────────────────────────────────── describe('roadmap analyze disk status variants', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('returns researched status for phase dir with only RESEARCH.md', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Exploration **Goal:** Research the domain ` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-exploration'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-RESEARCH.md'), '# Research notes'); const result = runGsdTools('roadmap analyze', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phases[0].disk_status, 'researched', 'disk_status should be researched'); assert.strictEqual(output.phases[0].has_research, true, 'has_research should be true'); }); test('#3511-class: roadmap analyze has_research/has_context ignore another phase\'s misplaced artifact', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Setup **Goal:** Build the foundation ### Phase 2: API **Goal:** Build the API ` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-setup'); fs.mkdirSync(p1, { recursive: true }); // Phase 02's directory holds ONLY a stray artifact whose filename token // ("01-") belongs to phase 01, not to this directory's own phase (02). const p2 = path.join(tmpDir, '.planning', 'phases', '02-api'); fs.mkdirSync(p2, { recursive: true }); fs.writeFileSync(path.join(p2, '01-RESEARCH.md'), '# Research for phase 01'); fs.writeFileSync(path.join(p2, '01-CONTEXT.md'), '# Context for phase 01'); const result = runGsdTools('roadmap analyze', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); const phase2 = output.phases.find((p) => p.number === '2'); assert.strictEqual(phase2.has_research, false, 'phase 2 must not report has_research from a file that belongs to phase 1'); assert.strictEqual(phase2.has_context, false, 'phase 2 must not report has_context from a file that belongs to phase 1'); }); test('returns discussed status for phase dir with only CONTEXT.md', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Discussion **Goal:** Gather context ` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-discussion'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-CONTEXT.md'), '# Context notes'); const result = runGsdTools('roadmap analyze', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phases[0].disk_status, 'discussed', 'disk_status should be discussed'); assert.strictEqual(output.phases[0].has_context, true, 'has_context should be true'); }); test('returns empty status for phase dir with no recognized files', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Empty **Goal:** Nothing yet ` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-empty'); fs.mkdirSync(p1, { recursive: true }); const result = runGsdTools('roadmap analyze', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phases[0].disk_status, 'empty', 'disk_status should be empty'); }); }); // ───────────────────────────────────────────────────────────────────────────── // roadmap analyze milestone extraction // ───────────────────────────────────────────────────────────────────────────── describe('roadmap analyze milestone extraction', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('extracts milestone headings and version numbers', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ## v1.0 Test Infrastructure ### Phase 1: Foundation **Goal:** Set up base ## v1.1 Coverage Hardening ### Phase 2: Coverage **Goal:** Add coverage ` ); const result = runGsdTools('roadmap analyze', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.ok(Array.isArray(output.milestones), 'milestones should be an array'); assert.strictEqual(output.milestones.length, 2, 'should find 2 milestones'); assert.strictEqual(output.milestones[0].version, 'v1.0', 'first milestone version'); assert.ok(output.milestones[0].heading.includes('v1.0'), 'first milestone heading contains v1.0'); assert.strictEqual(output.milestones[1].version, 'v1.1', 'second milestone version'); assert.ok(output.milestones[1].heading.includes('v1.1'), 'second milestone heading contains v1.1'); }); }); // ───────────────────────────────────────────────────────────────────────────── // roadmap analyze missing phase details // ───────────────────────────────────────────────────────────────────────────── describe('roadmap analyze missing phase details', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('detects checklist-only phases missing detail sections', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] **Phase 1: Foundation** - Set up project - [ ] **Phase 2: API** - Build REST API ### Phase 2: API **Goal:** Build REST API ` ); const result = runGsdTools('roadmap analyze', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.ok(Array.isArray(output.missing_phase_details), 'missing_phase_details should be an array'); assert.ok(output.missing_phase_details.includes('1'), 'phase 1 should be in missing details'); assert.ok(!output.missing_phase_details.includes('2'), 'phase 2 should not be in missing details'); }); test('returns null when all checklist phases have detail sections', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] **Phase 1: Foundation** - Set up project - [ ] **Phase 2: API** - Build REST API ### Phase 1: Foundation **Goal:** Set up project ### Phase 2: API **Goal:** Build REST API ` ); const result = runGsdTools('roadmap analyze', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.missing_phase_details, null, 'missing_phase_details should be null'); }); test('does not report phantom missing details for milestone-prefixed (M-NN) phase IDs', () => { // The checklist scanner truncated dash-separated IDs at the dash (1-01 -> 1) // while the detail-heading scanner kept the full ID, so every milestone-prefixed // ROADMAP spuriously reported the truncated major as a missing detail section. fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] **Phase 1-01: Foundation** - Set up project - [ ] **Phase 1-02: API** - Build REST API - [ ] **Phase 2-01: Ship** - Release ### Phase 1-01: Foundation **Goal:** Set up project ### Phase 1-02: API **Goal:** Build REST API ### Phase 2-01: Ship **Goal:** Release ` ); const result = runGsdTools('roadmap analyze', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.missing_phase_details, null, 'milestone-prefixed phases with matching detail sections should report no missing details' ); }); }); // ───────────────────────────────────────────────────────────────────────────── // roadmap get-phase success criteria // ───────────────────────────────────────────────────────────────────────────── describe('roadmap get-phase success criteria', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('extracts success_criteria array from phase section', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Test **Goal:** Test goal **Success Criteria** (what must be TRUE): 1. First criterion 2. Second criterion 3. Third criterion ### Phase 2: Other **Goal:** Other goal ` ); const result = runGsdTools('roadmap get-phase 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, true, 'phase should be found'); assert.ok(Array.isArray(output.success_criteria), 'success_criteria should be an array'); assert.strictEqual(output.success_criteria.length, 3, 'should have 3 criteria'); assert.ok(output.success_criteria[0].includes('First criterion'), 'first criterion matches'); assert.ok(output.success_criteria[1].includes('Second criterion'), 'second criterion matches'); assert.ok(output.success_criteria[2].includes('Third criterion'), 'third criterion matches'); }); test('#2522: wrapped success-criteria lines fold into their criterion (not dropped/truncated)', () => { // A criterion that wraps onto an indented continuation line must fold INTO its // criterion (retaining its trailing [REQ-ID]), and every criterion below the wrap // must still be parsed. The old regex broke the run at the wrap, truncating // criterion 2 mid-sentence (losing [A-02]) and silently dropping criterion 3. fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# Roadmap', '', '### Phase 9: T', '**Goal**: g', '**Success Criteria** (what must be TRUE):', ' 1. alpha. [A-01]', ' 2. beta which is long and', ' wraps onto a second line. [A-02]', ' 3. gamma. [A-03]', '**Plans**: TBD', '', ].join('\n') ); const result = runGsdTools('roadmap get-phase 9', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, true, 'phase should be found'); assert.strictEqual(output.success_criteria.length, 3, 'all 3 criteria must parse — wrapped continuations must not drop/truncate (#2522)'); assert.ok(output.success_criteria[1].includes('wraps onto a second line'), 'the wrapped continuation must fold into criterion 2'); assert.ok(output.success_criteria[1].includes('[A-02]'), 'the wrapped criterion must retain its trailing [REQ-ID] tag (#2522)'); assert.ok(output.success_criteria[2].includes('[A-03]'), 'criterion 3 (below the wrap) must not be dropped (#2522)'); // Boundary: the parse must not bleed past the Success Criteria block. assert.ok(!output.success_criteria.some(c => c.includes('TBD')), 'parse must not consume the **Plans** line'); }); test('#2522: blank-line-separated success criteria still parse (the continuation fix must not regress blank lines)', () => { // Guard against the naive-form trap the issue flags: narrowing the leading // whitespace to [ \t]* and splitting on \n would trade wrapped-line truncation // for blank-line truncation. Blank lines between criteria must keep working. fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# Roadmap', '', '### Phase 1: T', '**Goal**: g', '**Success Criteria** (what must be TRUE):', ' 1. alpha. [A-01]', '', ' 2. beta. [A-02]', '', ' 3. gamma. [A-03]', '**Plans**: TBD', '', ].join('\n') ); const result = runGsdTools('roadmap get-phase 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.success_criteria.length, 3, 'blank-line-separated criteria must all parse (#2522 guard)'); assert.ok(output.success_criteria[0].includes('[A-01]')); assert.ok(output.success_criteria[1].includes('[A-02]')); assert.ok(output.success_criteria[2].includes('[A-03]')); }); test('returns empty array when no success criteria present', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Simple **Goal:** No criteria here ` ); const result = runGsdTools('roadmap get-phase 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, true, 'phase should be found'); assert.ok(Array.isArray(output.success_criteria), 'success_criteria should be an array'); assert.strictEqual(output.success_criteria.length, 0, 'should have empty criteria'); }); }); // ───────────────────────────────────────────────────────────────────────────── // roadmap update-plan-progress command // ───────────────────────────────────────────────────────────────────────────── describe('roadmap update-plan-progress command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('missing phase number returns error', () => { const result = runGsdTools('roadmap update-plan-progress', tmpDir); assert.strictEqual(result.success, false, 'should fail without phase number'); assert.ok(result.error.includes('phase number required'), 'error should mention phase number required'); }); test('nonexistent phase returns error', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Test **Goal:** Test goal ` ); const result = runGsdTools('roadmap update-plan-progress 99', tmpDir); assert.strictEqual(result.success, false, 'should fail for nonexistent phase'); assert.ok(result.error.includes('not found'), 'error should mention not found'); }); test('no plans found returns updated false', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Test **Goal:** Test goal ` ); // Create phase dir with only a context file (no plans) const p1 = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-CONTEXT.md'), '# Context'); const result = runGsdTools('roadmap update-plan-progress 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.updated, false, 'should not update'); assert.ok(output.reason.includes('No plans'), 'reason should mention no plans'); assert.strictEqual(output.plan_count, 0, 'plan_count should be 0'); }); test('updates progress for partial completion', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Test **Goal:** Test goal **Plans:** TBD ## Progress | Phase | Milestone | Plans Complete | Status | Completed | |-------|-----------|----------------|--------|-----------| | 1. Test | v1.0 | 0/2 | Planned | - | ` ); // Create phase dir with 2 plans, 1 summary const p1 = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan 1'); fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan 2'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary 1'); const result = runGsdTools('roadmap update-plan-progress 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.updated, true, 'should update'); assert.strictEqual(output.plan_count, 2, 'plan_count should be 2'); assert.strictEqual(output.summary_count, 1, 'summary_count should be 1'); assert.strictEqual(output.status, 'In Progress', 'status should be In Progress'); assert.strictEqual(output.complete, false, 'should not be complete'); // Verify file was actually modified const roadmapContent = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmapContent.includes('1/2'), 'roadmap should contain updated plan count'); }); test('#4725: leaves surrounding prose byte-identical (bold paragraph above a tight list)', () => { const fixture = [ '# Roadmap', '', '### Phase 654: Stream Consumer', '', '**Goal:** bind the evidence HMAC key in production', '', '**Scope narrowed 2026-09-14, round `663-DISPOSITION` Q7 (3/3)** (`.planning/decisions/663-disposition.md`):', '- The "for retry" javadoc correction moved to Phase 663', '- Per Q6 (3/3), crash and failed-XACK residue stay this phase\'s population', '', '**Plans:** 2 plans', '', 'Plans:', '', '- [ ] 654-01-PLAN.md — (wave 1) the evidence HMAC key binds in production', '- [ ] 654-02-PLAN.md — (wave 2) consumer hardening', '', ].join('\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), fixture); const phaseDir = path.join(tmpDir, '.planning', 'phases', '654-stream'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '654-01-PLAN.md'), '# Plan 1'); fs.writeFileSync(path.join(phaseDir, '654-02-PLAN.md'), '# Plan 2'); fs.writeFileSync(path.join(phaseDir, '654-01-SUMMARY.md'), '# Summary 1'); const result = runGsdTools('roadmap update-plan-progress 654', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.updated, true, 'should update'); // Whole-file assertion per the issue's Expected: ONLY the **Plans:** // count line and the matching checkbox row change; every other byte — // the bold paragraph and its tight list included — is untouched. const expected = fixture .replace('**Plans:** 2 plans', '**Plans:** 1/2 plans executed') .replace('- [ ] 654-01-PLAN.md', '- [x] 654-01-PLAN.md'); const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.strictEqual( written, expected, `the file must differ from the input by exactly the two intended edits; written:\n${written}` ); }); test('counts plans and summaries from plans/ subdirectory layout (#3053)', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Test **Goal:** Test goal ` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-test', 'plans'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, 'PLAN-01.md'), '# Plan 1'); fs.writeFileSync(path.join(p1, 'PLAN-02.md'), '# Plan 2'); fs.writeFileSync(path.join(p1, 'SUMMARY-01.md'), '# Summary 1'); const result = runGsdTools('roadmap analyze', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phases[0].plan_count, 2); assert.strictEqual(output.phases[0].summary_count, 1); assert.strictEqual(output.phases[0].disk_status, 'partial'); }); test('#1988 — stray non-plan *-SUMMARY.md files do not inflate summary_count or flip phase to Complete', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] **Phase 30: Big** - description ### Phase 30: Big **Goal:** big goal **Plans:** TBD ## Progress | Phase | Milestone | Plans Complete | Status | Completed | |-------|-----------|----------------|--------|-----------| | 30. Big | v3.0 | 0/4 | Planned | - | ` ); // 4 plans, only 1 has a real summary; plus 3 stray non-plan summaries // (the exact names from the #1988 report: FIX-CR02, FIX-WR02-04, GAPCLOSURE). const p = path.join(tmpDir, '.planning', 'phases', '30-big'); fs.mkdirSync(p, { recursive: true }); fs.writeFileSync(path.join(p, '30-01-PLAN.md'), '# Plan 1'); fs.writeFileSync(path.join(p, '30-02-PLAN.md'), '# Plan 2'); fs.writeFileSync(path.join(p, '30-03-PLAN.md'), '# Plan 3'); fs.writeFileSync(path.join(p, '30-04-PLAN.md'), '# Plan 4'); fs.writeFileSync(path.join(p, '30-01-SUMMARY.md'), '# Summary 1'); // Strays — these must NOT count: fs.writeFileSync(path.join(p, '30-FIX-CR02-SUMMARY.md'), '# fix summary'); fs.writeFileSync(path.join(p, '30-FIX-WR02-04-SUMMARY.md'), '# fix summary'); fs.writeFileSync(path.join(p, '30-GAPCLOSURE-SUMMARY.md'), '# gap closure summary'); const result = runGsdTools('roadmap update-plan-progress 30', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.plan_count, 4, 'plan_count is the 4 real plans'); assert.strictEqual(output.summary_count, 1, 'stray summaries must not inflate the count'); assert.strictEqual(output.status, 'In Progress', 'must not flip to Complete'); assert.strictEqual(output.complete, false, 'must not be complete'); // The ROADMAP row must NOT show 4/4 Complete. const roadmapContent = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmapContent.includes('1/4'), 'roadmap row must show the real 1/4 progress'); assert.ok(!/4\/4\s*\|\s*Complete/.test(roadmapContent), 'must not stamp 4/4 Complete'); assert.ok(!/\[x\] \*\*Phase 30/.test(roadmapContent), 'phase checkbox must not be checked'); }); test('#2022 — all summaries present but verification NOT passed → checkbox NOT checked', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] **Phase 1: Test** - description ### Phase 1: Test **Goal:** Test goal **Plans:** TBD ## Progress | Phase | Milestone | Plans Complete | Status | Completed | |-------|-----------|----------------|--------|-----------| | 1. Test | v1.0 | 0/1 | Planned | - | ` ); // 1 plan + 1 summary (all summaries present) but NO VERIFICATION.md → the // verification gate (#2022) must prevent the checkbox from being checked. const p1 = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan 1'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary 1'); const result = runGsdTools('roadmap update-plan-progress 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.complete, false, 'must NOT be complete without verification'); assert.strictEqual(output.status, 'In Progress', 'status should be In Progress (not Complete)'); const roadmapContent = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmapContent.includes('[ ] **Phase 1'), 'phase checkbox must remain unchecked'); assert.ok(!/\[x\] \*\*Phase 1/.test(roadmapContent), 'phase checkbox must NOT be checked without verification'); assert.ok(roadmapContent.includes('1/1'), 'plan count should still be updated'); }); test('#2022 — verification status NOT passed → checkbox NOT checked', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] **Phase 1: Test** ### Phase 1: Test **Goal:** Test goal ## Progress | Phase | Milestone | Plans Complete | Status | Completed | |-------|-----------|----------------|--------|-----------| | 1. Test | v1.0 | 0/1 | Planned | - | ` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan 1'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary 1'); // Verification exists but status is gaps_found (not passed) fs.writeFileSync(path.join(p1, '01-VERIFICATION.md'), '---\nstatus: gaps_found\n---\n# Verification\n'); const result = runGsdTools('roadmap update-plan-progress 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.complete, false, 'must NOT be complete (verification gaps_found)'); assert.strictEqual(output.status, 'In Progress'); const roadmapContent = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(!roadmapContent.includes('[x]'), 'checkbox must NOT be checked (verification not passed)'); }); test('updates progress and checks checkbox on completion', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] **Phase 1: Test** - description ### Phase 1: Test **Goal:** Test goal **Plans:** TBD ## Progress | Phase | Milestone | Plans Complete | Status | Completed | |-------|-----------|----------------|--------|-----------| | 1. Test | v1.0 | 0/1 | Planned | - | ` ); // Create phase dir with 1 plan, 1 summary (complete) + verification passed (#2022 gate) const p1 = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan 1'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary 1'); fs.writeFileSync(path.join(p1, '01-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n'); const result = runGsdTools('roadmap update-plan-progress 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.updated, true, 'should update'); assert.strictEqual(output.complete, true, 'should be complete'); assert.strictEqual(output.status, 'Complete', 'status should be Complete'); // Verify file was actually modified const roadmapContent = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmapContent.includes('[x]'), 'checkbox should be checked'); assert.ok(roadmapContent.includes('completed'), 'should contain completion date text'); assert.ok(roadmapContent.includes('1/1'), 'roadmap should contain updated plan count'); }); test('updates unpadded ROADMAP phase entries when called with padded phase argument', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] **Phase 3: Build** - description ### Phase 3: Build **Goal:** Test goal **Plans:** 0 plans - [ ] 03-01-PLAN.md ## Progress | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| | 3. Build | 0/1 | Planned | | ` ); const p3 = path.join(tmpDir, '.planning', 'phases', '03-build'); fs.mkdirSync(p3, { recursive: true }); fs.writeFileSync(path.join(p3, '03-01-PLAN.md'), '# Plan 1'); fs.writeFileSync(path.join(p3, '03-01-SUMMARY.md'), '# Summary 1'); fs.writeFileSync(path.join(p3, '03-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n'); const result = runGsdTools('roadmap update-plan-progress 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmapContent = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.match(roadmapContent, /- \[x\] \*\*Phase 3: Build\*\* - description \(completed \d{4}-\d{2}-\d{2}\)/); assert.ok(roadmapContent.includes('**Plans:** 1/1 plans complete'), 'phase detail plan count should be updated'); assert.match(roadmapContent, /\| 3\. Build \| 1\/1 \| Complete\s+\| \d{4}-\d{2}-\d{2} \|/); assert.ok(roadmapContent.includes('- [x] 03-01-PLAN.md'), 'completed plan checkbox should still be marked'); }); test('missing ROADMAP.md returns updated false', () => { // Create phase dir with plans and summaries but NO ROADMAP.md const p1 = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan 1'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary 1'); const result = runGsdTools('roadmap update-plan-progress 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.updated, false, 'should not update'); assert.ok(output.reason.includes('ROADMAP.md not found'), 'reason should mention missing ROADMAP.md'); }); test('marks completed plan checkboxes', () => { const roadmapContent = `# Roadmap - [ ] Phase 50: Build - [ ] 50-01-PLAN.md - [ ] 50-02-PLAN.md ### Phase 50: Build **Goal:** Build stuff **Plans:** 2 plans ## Progress | Phase | Plans Complete | Status | Completed | |-------|---------------|--------|-----------| | 50. Build | 0/2 | Planned | | `; fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmapContent); const p50 = path.join(tmpDir, '.planning', 'phases', '50-build'); fs.mkdirSync(p50, { recursive: true }); fs.writeFileSync(path.join(p50, '50-01-PLAN.md'), '# Plan 1'); fs.writeFileSync(path.join(p50, '50-02-PLAN.md'), '# Plan 2'); // Only plan 1 has a summary (completed) fs.writeFileSync(path.join(p50, '50-01-SUMMARY.md'), '# Summary 1'); const result = runGsdTools('roadmap update-plan-progress 50', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('[x] 50-01-PLAN.md') || roadmap.includes('[x] 50-01'), 'completed plan checkbox should be marked'); assert.ok(roadmap.includes('[ ] 50-02-PLAN.md') || roadmap.includes('[ ] 50-02'), 'incomplete plan checkbox should remain unchecked'); }); test('preserves Milestone column in 5-column progress table', () => { const roadmapContent = `# Roadmap ### Phase 50: Build **Goal:** Build stuff **Plans:** 1 plans ## Progress | Phase | Milestone | Plans Complete | Status | Completed | |-------|-----------|----------------|--------|-----------| | 50. Build | v2.0 | 0/1 | Planned | | `; fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmapContent); const p50 = path.join(tmpDir, '.planning', 'phases', '50-build'); fs.mkdirSync(p50, { recursive: true }); fs.writeFileSync(path.join(p50, '50-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p50, '50-01-SUMMARY.md'), '# Summary'); fs.writeFileSync(path.join(p50, '50-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n'); const result = runGsdTools('roadmap update-plan-progress 50', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); // eslint-disable-next-line local/no-unbounded-quantifier -- parses ROADMAP.md the test itself wrote via a fixed fixture string, bounded, not adversarial input const rowMatch = roadmap.match(/^\|[^\r\n]*50\. Build[^\r\n]*$/m); assert.ok(rowMatch, 'table row should exist'); const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim()); assert.strictEqual(cells.length, 5, 'should have 5 columns'); assert.strictEqual(cells[1], 'v2.0', 'Milestone column should be preserved'); assert.ok(cells[3].includes('Complete'), 'Status column should show Complete'); }); }); // ───────────────────────────────────────────────────────────────────────────── // #3057 B3: cmdRoadmapUpdatePlanProgress surfaces an indeterminate staleness check // // readVerificationStatus's internal staleness check can fail (fs / // scanPhasePlans / clock error). Pre-#3057 B3 wiring, that failure was // dropped here — `verificationPassed` used only `.status`, never // `.staleCheckIndeterminate` — so this command's JSON output could never // distinguish "checked; nothing is stale" from "could not check". These // tests require roadmap.cjs directly (in-process) so an `fs.statSync` fault // can be injected via the same deterministic path-scoped seam #3057 B4 uses // for git-base-branch.cts (see tests/git-base-branch.test.cjs). // ───────────────────────────────────────────────────────────────────────────── describe('#3057 B3: roadmap update-plan-progress — verification staleness-check indeterminate is surfaced', () => { const roadmapMod = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'roadmap.cjs')); let tmpDir; beforeEach(() => { tmpDir = createTempProject('gsd-3057-b3-roadmap-'); }); afterEach(() => { cleanup(tmpDir); }); /** * In-process capture of cmdRoadmapUpdatePlanProgress's stdout JSON, stderr * discarded. * * io.cts's `output()` writes via `writeAllSync` → `fs.writeSync(1, ...)` * directly (bug #1008's non-blocking-pipe fix), NOT `process.stdout.write` * — so mocking `process.stdout.write` here silently captured nothing and * every assertion below saw `JSON.parse('')` ("Unexpected end of JSON * input") regardless of what the command actually produced. The fix is the * fd-level seam tests/io.test.cjs already established for exactly this * function (bug #1008's `t.mock.method(fs, 'writeSync', ...)` pattern): * intercept fd 1, discard fd 2, and pass every OTHER fd through to the * real writeSync — this command takes the planning lock, which writes its * own JSON via a separate fd that must not be swallowed as if it were * stdout (confirmed: without the pass-through, its lock-acquire JSON * corrupts the captured payload into two concatenated JSON objects). */ function captureUpdatePlanProgress(t, cwd, phaseNum) { const captured = captureFdSync(1, () => roadmapMod.cmdRoadmapUpdatePlanProgress(cwd, phaseNum, false)); assert.ok(captured.length > 0, 'cmdRoadmapUpdatePlanProgress produced no stdout output'); return captured; } function seedVerifiedPhase() { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Test **Goal:** Test goal **Plans:** TBD ## Progress | Phase | Milestone | Plans Complete | Status | Completed | |-------|-----------|----------------|--------|-----------| | 1. Test | v1.0 | 0/1 | Planned | - | ` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan 1'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary 1'); fs.writeFileSync(path.join(p1, '01-VERIFICATION.md'), '---\nstatus: passed\n---\n\n# Verification\n'); const summaryPath = path.join(p1, '01-01-SUMMARY.md'); const verificationPath = path.join(p1, '01-VERIFICATION.md'); // Deterministic mtime ordering — never rely on write-order clock ties. const older = new Date('2026-01-01T00:00:00.000Z'); const newer = new Date('2026-01-01T00:01:00.000Z'); fs.utimesSync(summaryPath, older, older); fs.utimesSync(verificationPath, newer, newer); return { summaryPath, verificationPath }; } test('an fs failure inside the staleness check sets verification_stale_check_indeterminate:true; routing unchanged', (t) => { const { summaryPath, verificationPath } = seedVerifiedPhase(); const origStatSync = fs.statSync; t.mock.method(fs, 'statSync', function injectedStaleCheckFault(target, ...args) { const targetPath = String(target); if (targetPath === verificationPath || targetPath === summaryPath) { throw new Error('injected stat failure (#3057 B3)'); } return origStatSync.call(fs, target, ...args); }); const output = JSON.parse(captureUpdatePlanProgress(t, tmpDir, '1')); // Pre-existing no-throw fail-open routing is UNCHANGED: the phase still // resolves complete, exactly as it would without the injected fault. assert.strictEqual(output.complete, true, 'routing unchanged — phase still marked complete'); assert.strictEqual(output.verification_stale_check_indeterminate, true); }); test('a completed staleness check that finds nothing stale reports verification_stale_check_indeterminate:false', (t) => { seedVerifiedPhase(); const output = JSON.parse(captureUpdatePlanProgress(t, tmpDir, '1')); assert.strictEqual(output.complete, true); assert.strictEqual(output.verification_stale_check_indeterminate, false); }); }); // ───────────────────────────────────────────────────────────────────────────── // regressions: #4247 — checklist-form ROADMAP.md must not produce a false // `updated:true` (untouched phase row) nor blank-line corruption elsewhere // ───────────────────────────────────────────────────────────────────────────── /** * The checklist house style the bundled roadmapper emits for the summary * checklist (`agents/gsd-roadmapper.md` §"Summary Checklist"): long * `- [ ] **Phase N: Title** - description` entries with column-0 continuation * sentences (the shape issue #4247 was filed against), plus a Progress table. * * `tableCell` controls the Progress table's Phase cell for the target phase. */ function buildChecklistRoadmap4247(tableCell) { return [ '# Roadmap: Mango Tree', '', '## Phases', '', '- [ ] **Phase 65: Orchard Layout** - goal: design the canopy grid. Progress: 4/4 plans executed, verified 2026-08-30;', 'grid survey closed the two-centimeter tolerance, terracing passed inspection, and the irrigation', 'channels were flushed before the storm; soil probes re-zeroed afterwards.', '- [ ] **Phase 68: Scheduler** - goal: ship the picking scheduler. Progress: 1/5 plans executed;', '68-01 calendar model in review; 68-02 crew assignment summarized; 68-03 weather windows', 'not started; 68-04 crate logistics blocked on warehouse slot; 68-05 billing hook pending.', '- [ ] **Phase 69: Packhouse** - goal: automate the line. Progress: 0/2 executed.', '', '## Execution Waves', '', '- Wave 1: Phases 65-66 — complete.', '- Wave 2: Phase 68 — in flight.', '', '## Progress', '', '| Phase | Plans Complete | Status | Completed |', '|-------|----------------|--------|-----------|', '| 65 | 4/4 | Complete | 2026-08-30 |', `| ${tableCell} | 0/5 | Planned | - |`, '', ].join('\n'); } /** Phase 68 on disk: 5 plans, 1 summary → In Progress, 1/5. */ function seedPhase68WithPlans(tmpDir, { roadmap } = {}) { fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); const p68 = path.join(tmpDir, '.planning', 'phases', '68-scheduler'); fs.mkdirSync(p68, { recursive: true }); for (const n of ['01', '02', '03', '04', '05']) { fs.writeFileSync(path.join(p68, `68-${n}-PLAN.md`), `# Plan ${n}\n`); } fs.writeFileSync(path.join(p68, '68-02-SUMMARY.md'), '# Summary\n'); return p68; } describe('#4247: roadmap update-plan-progress — checklist-form ROADMAP refuses instead of false-green', () => { let tmpDir; let roadmapPath; beforeEach(() => { tmpDir = createTempProject('gsd-4247-roadmap-'); roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); }); afterEach(() => { cleanup(tmpDir); }); // Row 1 — the issue's exact repro shape. FAILING FIRST. test('checklist form with non-matching table row and a stray plan row refuses with missing_phase_details and writes nothing', () => { const roadmap = buildChecklistRoadmap4247('Phase 68').replace( '- [ ] **Phase 69: Packhouse**', ' - [ ] 68-01: calendar model\n - [ ] 68-02: crew assignment\n- [ ] **Phase 69: Packhouse**', ); seedPhase68WithPlans(tmpDir, { roadmap }); const result = runGsdTools('roadmap update-plan-progress 68', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.updated, false, 'a phase with no writable roadmap representation must not claim updated'); assert.strictEqual(output.reason, 'missing_phase_details', 'refusal carries the typed parse diagnostic'); assert.strictEqual(output.plan_count, 5, 'computed counts are still reported'); assert.strictEqual(output.summary_count, 1); // The issue's round-trip requirement: the file must be byte-identical — // no plan-checkbox marks, no blank lines splitting other phases' sentences. const written = fs.readFileSync(roadmapPath, 'utf-8'); assert.strictEqual(written, roadmap, 'ROADMAP.md must be byte-identical on refusal'); }); // Row 2 — no table at all. test('checklist form with no Progress table refuses with missing_phase_details and writes nothing', () => { const roadmap = [ '# Roadmap: T', '', '## Phases', '', '- [ ] **Phase 68: Scheduler** - goal: ship the picking scheduler.', '', ].join('\n'); seedPhase68WithPlans(tmpDir, { roadmap }); const result = runGsdTools('roadmap update-plan-progress 68', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.updated, false); assert.strictEqual(output.reason, 'missing_phase_details'); assert.strictEqual( fs.readFileSync(roadmapPath, 'utf-8'), roadmap, 'ROADMAP.md must be byte-identical on refusal', ); }); // Row 3 — isolates the non-matching cell (word-prefixed) from the plan-row trigger. test('checklist form whose table row does not match the phase-cell grammar refuses without writing', () => { const roadmap = buildChecklistRoadmap4247('Phase 68'); seedPhase68WithPlans(tmpDir, { roadmap }); const result = runGsdTools('roadmap update-plan-progress 68', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.updated, false); assert.strictEqual(output.reason, 'missing_phase_details'); assert.strictEqual( fs.readFileSync(roadmapPath, 'utf-8'), roadmap, 'ROADMAP.md must be byte-identical on refusal', ); }); // Row 4 — table-form MUST keep updating exactly as today (full-file byte assertion). test('table form with a bare-number cell still updates the row byte-exactly and touches nothing else', () => { // Normal-form fixture (template spacing, single-line entries): the write // seam's markdown normalization is a no-op here, so the byte-exact splice // is observable. const roadmap = [ '# Roadmap: Mango Tree', '', '## Phases', '', '- [ ] **Phase 65: Orchard Layout** - goal: design the canopy grid. Progress: 4/4 plans executed, verified 2026-08-30.', '', '- [ ] **Phase 68: Scheduler** - goal: ship the picking scheduler. Progress: 1/5 plans executed.', '', '- [ ] **Phase 69: Packhouse** - goal: automate the line. Progress: 0/2 executed.', '', '## Progress', '', '| Phase | Plans Complete | Status | Completed |', '|-------|----------------|--------|-----------|', '| 65 | 4/4 | Complete | 2026-08-30 |', '| 68 | 0/5 | Planned | - |', '', ].join('\n'); seedPhase68WithPlans(tmpDir, { roadmap }); const result = runGsdTools('roadmap update-plan-progress 68', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.updated, true, 'a matching table row is a writable target'); // Byte-exact expectation: only the Phase 68 row's three cells change // (` 1/5 ` splice, ` In Progress` padEnd(11), Completed cleared to ` `), // every other byte — including Phase 65/69 prose and the waves section — // is untouched (no blank-line insertion anywhere outside the row). const expected = roadmap.replace( '| 68 | 0/5 | Planned | - |', '| 68 | 1/5 | In Progress| |', ); assert.strictEqual(fs.readFileSync(roadmapPath, 'utf-8'), expected); }); // Row 5 — 5-column milestone-grouped table, `68. [Scheduler]` cell form. test('milestone-grouped table form with template cell still updates by column name', () => { const roadmap = [ '# Roadmap: T', '', '## Progress', '', '| Phase | Milestone | Plans Complete | Status | Completed |', '|-------|-----------|----------------|--------|-----------|', '| 67. [Waves] | v1.0 | 0/2 | Planned | - |', '| 68. [Scheduler] | v1.0 | 0/5 | Planned | - |', '| 69. [Packhouse] | v1.0 | 0/2 | Planned | - |', '', ].join('\n'); seedPhase68WithPlans(tmpDir, { roadmap }); const result = runGsdTools('roadmap update-plan-progress 68', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.updated, true); const expected = roadmap.replace( '| 68. [Scheduler] | v1.0 | 0/5 | Planned | - |', '| 68. [Scheduler] | v1.0 | 1/5 | In Progress| |', ); assert.strictEqual(fs.readFileSync(roadmapPath, 'utf-8'), expected); }); // Row 6/7 — heading-form targets keep every existing behavior. test('heading-form detail section still updates counts, inserts rows, and marks checkboxes', () => { const roadmap = [ '# Roadmap: T', '', '## Phases', '', '- [ ] **Phase 68: Scheduler** - goal: ship scheduler.', '', '### Phase 68: Scheduler', '**Goal**: ship it', '**Plans**: 5 plans', '', 'Plans:', '- [ ] 68-02: crew assignment', '', '## Progress', '', '| Phase | Plans Complete | Status | Completed |', '|-------|----------------|--------|-----------|', '| Phase 68 | 0/5 | Planned | - |', '', ].join('\n'); seedPhase68WithPlans(tmpDir, { roadmap }); const result = runGsdTools('roadmap update-plan-progress 68', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.updated, true, 'a heading target is writable — no refusal'); const written = fs.readFileSync(roadmapPath, 'utf-8'); assert.ok(written.includes('**Plans**: 1/5 plans executed'), 'count token rewritten'); assert.ok(written.includes('- [x] 68-02: crew assignment'), 'summarized plan checkbox marked'); assert.ok(written.includes('- [ ] 68-01-PLAN.md'), 'missing plan rows inserted'); }); // Row 9/10/11 — boundaries inside a table must not trip the refusal. test('first and last table rows still update; adjacent rows stay untouched', () => { for (const cell of ['68', 'Phase 68']) { const roadmap = [ '# Roadmap: T', '', '## Progress', '', '| Phase | Plans Complete | Status | Completed |', '|-------|----------------|--------|-----------|', '| 67 | 0/2 | Planned | - |', `| ${cell === '68' ? '68' : 'Phase 68'} | 0/5 | Planned | - |`, '| 69 | 0/2 | Planned | - |', '', ].join('\n'); seedPhase68WithPlans(tmpDir, { roadmap }); const result = runGsdTools('roadmap update-plan-progress 68', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); const written = fs.readFileSync(roadmapPath, 'utf-8'); assert.ok(written.includes('| 67 | 0/2 | Planned | - |'), 'adjacent row untouched'); assert.ok(written.includes('| 69 | 0/2 | Planned | - |'), 'adjacent row untouched'); if (cell === '68') { assert.strictEqual(output.updated, true, 'bare cell 68 between siblings updates'); assert.ok(written.includes('| 68 | 1/5 |'), 'middle row updated'); } else { assert.strictEqual(output.updated, false, 'word-prefixed cell is not a writable target'); assert.strictEqual(written, roadmap, 'nothing written on refusal'); } cleanup(tmpDir); tmpDir = createTempProject('gsd-4247-roadmap-'); roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); } }); // Row 12 — phase exists only on disk; roadmap has no representation at all. test('phase absent from the roadmap entirely refuses with missing_phase_details', () => { const roadmap = [ '# Roadmap: T', '', '## Phases', '', '- [ ] **Phase 65: Layout** - goal design.', '', '## Progress', '', '| Phase | Plans Complete | Status | Completed |', '|-------|----------------|--------|-----------|', '| 65 | 4/4 | Complete | 2026-08-30 |', '', ].join('\n'); seedPhase68WithPlans(tmpDir, { roadmap }); const result = runGsdTools('roadmap update-plan-progress 68', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.updated, false); assert.strictEqual(output.reason, 'missing_phase_details'); assert.strictEqual(fs.readFileSync(roadmapPath, 'utf-8'), roadmap); }); // Row 13 — the #3957 honest no-op decline must survive for target-found reruns. test('idempotent re-run on an up-to-date table row keeps the honest no-changes decline', () => { const roadmap = [ '# Roadmap: T', '', '## Progress', '', '| Phase | Plans Complete | Status | Completed |', '|-------|----------------|--------|-----------|', '| 68 | 1/5 | In Progress| |', '', ].join('\n'); seedPhase68WithPlans(tmpDir, { roadmap }); const result = runGsdTools('roadmap update-plan-progress 68', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.updated, false); assert.notStrictEqual(output.reason, 'missing_phase_details', 'target found — this is a genuine no-op'); assert.ok(String(output.reason).includes('no changes were needed'), 'preserves the #3957 decline vocabulary'); assert.strictEqual(fs.readFileSync(roadmapPath, 'utf-8'), roadmap); }); // Row 8 — the completion arm: the checklist entry's own checkbox IS the // writable phase row for a complete phase. test('checklist form completing the phase still checks the phase bullet and marks its plan rows', () => { const roadmap = buildChecklistRoadmap4247('Phase 68').replace( '- [ ] **Phase 69: Packhouse**', ' - [ ] 68-01: calendar model\n - [ ] 68-02: crew assignment\n- [ ] **Phase 69: Packhouse**', ); const p68 = seedPhase68WithPlans(tmpDir, { roadmap }); for (const n of ['01', '03', '04', '05']) { fs.writeFileSync(path.join(p68, `68-${n}-SUMMARY.md`), '# Summary\n'); } fs.writeFileSync(path.join(p68, '68-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n'); const result = runGsdTools('roadmap update-plan-progress 68', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.complete, true); assert.strictEqual(output.updated, true, 'the phase bullet is a writable target when completing'); const written = fs.readFileSync(roadmapPath, 'utf-8'); assert.match(written, /- \[x\] \*\*Phase 68: Scheduler\*\* - goal: ship the picking scheduler\. Progress: 1\/5 plans executed; \(completed \d{4}-\d{2}-\d{2}\)/, 'phase bullet checked with completion date'); // Other phases' checklist bullets are not checked or annotated. assert.match(written, /- \[ \] \*\*Phase 65: Orchard Layout\*\*/); assert.match(written, /- \[ \] \*\*Phase 69: Packhouse\*\*/); assert.ok(written.includes('- [x] 68-02: crew assignment'), 'summarized plan row marked'); }); }); // ───────────────────────────────────────────────────────────────────────────── // phase add command // ───────────────────────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────────────────────── // regressions: insert missing plan rows (#1163) // ───────────────────────────────────────────────────────────────────────────── // Phase numbers are zero-padded by normalizePhaseName so the .planning/phases/ // directory must use the padded form (e.g. "05-test-phase" for phase 5). const PHASE_NUM_1163 = '5'; // what we pass on the command line const PHASE_DIR_SLUG_1163 = '05'; // what ends up on disk after normalization /** * ROADMAP.md with a phase-5 detail section that uses bold **Plans:** and has * NO per-plan checkbox rows yet. */ function buildRoadmapBoldPlans(phaseNum = PHASE_NUM_1163, planCount = '0/3 plans executed') { return [ '# ROADMAP', '', '## Milestone v1.0', '', '| Phase | Plans | Status | Completed |', '| --- | --- | --- | --- |', `| Phase ${phaseNum}: test phase | 0/3 | Planned | |`, '', `### Phase ${phaseNum}: test phase`, '', 'Goal: build something', '', `**Plans:** ${planCount}`, '', '(No individual plan rows yet — template freshly generated)', '', ].join('\n'); } /** * ROADMAP.md using the CANONICAL template shape: * **Plans**: N plans ← bold summary metadata line * (blank line) * Plans: ← checklist header * - [ ] NN-XX-PLAN.md ← per-plan checkboxes * * The template (gsd-core/templates/roadmap.md) always uses this two-line form. * `**Plans**:` has the colon OUTSIDE the bold markers. */ function buildRoadmapCanonicalTemplate(phaseNum = PHASE_NUM_1163, existingRows = []) { const rowLines = existingRows.length > 0 ? ['Plans:', ...existingRows.map(r => `- [ ] ${r}`), ''] : ['Plans:', '']; return [ '# ROADMAP', '', '## Milestone v1.0', '', '| Phase | Plans | Status | Completed |', '| --- | --- | --- | --- |', `| Phase ${phaseNum}: test phase | 0/3 | Planned | |`, '', `### Phase ${phaseNum}: test phase`, '', '**Goal**: build something', `**Plans**: 0/3 plans`, '', ...rowLines, ].join('\n'); } /** * ROADMAP.md with a duplicate phase heading in an archived
section * plus the same phase as the ACTIVE milestone section. */ function buildRoadmapWithArchivedDuplicate(phaseNum = PHASE_NUM_1163) { return [ '# ROADMAP', '', '
', 'v0.9 — shipped', '', `### Phase ${phaseNum}: test phase`, '', '**Plans**: 2/2 plans complete', '', 'Plans:', `- [x] ${phaseNum}-01-PLAN.md`, `- [x] ${phaseNum}-02-PLAN.md`, '', '
', '', '## Milestone v1.0', '', `### Phase ${phaseNum}: test phase`, '', '**Plans**: 0/3 plans', '', 'Plans:', '', ].join('\n'); } /** * ROADMAP.md with a phase-5 detail section that uses plain `Plans:` (not bold) * and NO per-plan checkbox rows yet. */ function buildRoadmapPlainPlans(phaseNum = PHASE_NUM_1163) { return [ '# ROADMAP', '', '## Milestone v1.0', '', '| Phase | Plans | Status | Completed |', '| --- | --- | --- | --- |', `| Phase ${phaseNum}: test phase | 0/3 | Planned | |`, '', `### Phase ${phaseNum}: test phase`, '', 'Goal: build something', '', `Plans: 0/3 plans executed`, '', '(No individual plan rows yet)', '', ].join('\n'); } /** * Create plan files for a given phase in the .planning/phases tree. * Uses the normalized (zero-padded) directory name so findPhaseInternal can * locate the phase. Returns the phase directory path. */ function createPhaseWithPlans(tmpDir, phaseNum, planNames) { // normalizePhaseName('5') → '05', so use zero-padded slug on disk const paddedNum = String(phaseNum).padStart(2, '0'); const phaseDir = path.join(tmpDir, '.planning', 'phases', `${paddedNum}-test-phase`); fs.mkdirSync(phaseDir, { recursive: true }); for (const name of planNames) { fs.writeFileSync(path.join(phaseDir, name), `# ${name}\n`); } return phaseDir; } describe('regressions: insert missing plan rows (#1163)', () => { let tmpDir; let roadmapPath; beforeEach(() => { tmpDir = createTempProject('gsd-1163-'); roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); }); afterEach(() => { cleanup(tmpDir); }); // ── Bold **Plans:** — insert missing rows ──────────────────────────────── test('inserts plan checkbox rows under bold **Plans:** when none exist', () => { fs.writeFileSync(roadmapPath, buildRoadmapBoldPlans('5')); createPhaseWithPlans(tmpDir, '5', [ '5-01-PLAN.md', '5-02-PLAN.md', '5-03-PLAN.md', ]); const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const parsed = JSON.parse(result.output); assert.equal(parsed.updated, true, 'expected updated:true'); const written = fs.readFileSync(roadmapPath, 'utf-8'); assert.ok(written.includes('- [ ] 5-01-PLAN.md'), '5-01-PLAN.md row not inserted'); assert.ok(written.includes('- [ ] 5-02-PLAN.md'), '5-02-PLAN.md row not inserted'); assert.ok(written.includes('- [ ] 5-03-PLAN.md'), '5-03-PLAN.md row not inserted'); }); // ── Plain Plans: — insert missing rows ────────────────────────────────── test('inserts plan checkbox rows under plain Plans: when none exist', () => { fs.writeFileSync(roadmapPath, buildRoadmapPlainPlans('5')); createPhaseWithPlans(tmpDir, '5', [ '5-01-PLAN.md', '5-02-PLAN.md', ]); const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const parsed = JSON.parse(result.output); assert.equal(parsed.updated, true, 'expected updated:true'); const written = fs.readFileSync(roadmapPath, 'utf-8'); assert.ok(written.includes('- [ ] 5-01-PLAN.md'), '5-01-PLAN.md row not inserted (plain Plans:)'); assert.ok(written.includes('- [ ] 5-02-PLAN.md'), '5-02-PLAN.md row not inserted (plain Plans:)'); }); // ── Plan count update with plain Plans: ───────────────────────────────── test('plan count is updated when section uses plain Plans: (not bold)', () => { fs.writeFileSync(roadmapPath, buildRoadmapPlainPlans('5')); // createPhaseWithPlans returns the padded dir path (05-test-phase) const phaseDir = createPhaseWithPlans(tmpDir, '5', [ '5-01-PLAN.md', '5-02-PLAN.md', ]); // Create a summary for plan 1 so it's not 0/2 fs.writeFileSync(path.join(phaseDir, '5-01-SUMMARY.md'), '# Summary\n'); const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const written = fs.readFileSync(roadmapPath, 'utf-8'); // Plan count should reflect 1 completed out of 2 assert.ok( written.match(/Plans:\s*1\/2 plans executed/), 'Plain Plans: count not updated. ROADMAP.md:\n' + written, ); }); // ── Bold **Plans:** count update ───────────────────────────────────────── test('plan count is updated when section uses bold **Plans:**', () => { fs.writeFileSync(roadmapPath, buildRoadmapBoldPlans('5', '0/3 plans executed')); const phaseDir = createPhaseWithPlans(tmpDir, '5', [ '5-01-PLAN.md', '5-02-PLAN.md', '5-03-PLAN.md', ]); // Complete 1 plan fs.writeFileSync(path.join(phaseDir, '5-01-SUMMARY.md'), '# Summary\n'); const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const written = fs.readFileSync(roadmapPath, 'utf-8'); assert.ok( written.includes('**Plans:** 1/3 plans executed'), '**Plans:** count not updated. ROADMAP.md:\n' + written, ); }); // ── Existing rows are checked off, not duplicated ─────────────────────── test('existing plan rows are marked complete, not duplicated when SUMMARY exists', () => { const roadmapWithRows = [ '# ROADMAP', '', '## Milestone v1.0', '', '### Phase 5: test phase', '', '**Plans:** 0/2 plans executed', '', '- [ ] 5-01-PLAN.md', '- [ ] 5-02-PLAN.md', '', ].join('\n'); fs.writeFileSync(roadmapPath, roadmapWithRows); const phaseDir = createPhaseWithPlans(tmpDir, '5', [ '5-01-PLAN.md', '5-02-PLAN.md', ]); fs.writeFileSync(path.join(phaseDir, '5-01-SUMMARY.md'), '# Summary\n'); const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const written = fs.readFileSync(roadmapPath, 'utf-8'); // 5-01 should be checked, 5-02 should remain unchecked assert.ok(written.includes('- [x] 5-01-PLAN.md'), '5-01-PLAN.md not marked complete'); assert.ok(written.includes('- [ ] 5-02-PLAN.md'), '5-02-PLAN.md incorrectly marked complete'); // Only two rows — no duplicates const matches = (written.match(/- \[.\] 5-\d+-PLAN\.md/g) || []); assert.equal(matches.length, 2, `Expected 2 checkbox rows, got ${matches.length}:\n${written}`); }); // ── Inserted rows are sorted ───────────────────────────────────────────── test('inserted plan rows are sorted in ascending order', () => { fs.writeFileSync(roadmapPath, buildRoadmapBoldPlans('5')); createPhaseWithPlans(tmpDir, '5', [ '5-03-PLAN.md', '5-01-PLAN.md', '5-02-PLAN.md', ]); const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const written = fs.readFileSync(roadmapPath, 'utf-8'); const rowPositions = [ written.indexOf('5-01-PLAN.md'), written.indexOf('5-02-PLAN.md'), written.indexOf('5-03-PLAN.md'), ]; assert.ok( rowPositions[0] < rowPositions[1] && rowPositions[1] < rowPositions[2], 'Inserted plan rows are not in ascending order. ROADMAP.md:\n' + written, ); }); // ── No plans found — command returns updated:false without inserting ───── test('returns updated:false gracefully when phase has no plan files', () => { fs.writeFileSync(roadmapPath, buildRoadmapBoldPlans('5')); // Create phase dir (padded slug) but no plan files const phaseDir = path.join(tmpDir, '.planning', 'phases', `${PHASE_DIR_SLUG_1163}-test-phase`); fs.mkdirSync(phaseDir, { recursive: true }); const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const parsed = JSON.parse(result.output); assert.equal(parsed.updated, false, 'Expected updated:false when no plans exist'); }); // ── Adversarial: CRLF in ROADMAP.md ────────────────────────────────────── test('CRLF line endings in ROADMAP.md are handled without corruption', () => { const content = buildRoadmapBoldPlans('5').replace(/\r?\n/g, '\r\n'); fs.writeFileSync(roadmapPath, content); createPhaseWithPlans(tmpDir, '5', ['5-01-PLAN.md', '5-02-PLAN.md']); const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const parsed = JSON.parse(result.output); assert.equal(parsed.updated, true, 'CRLF ROADMAP not handled'); }); }); describe('regressions: insert missing plan rows (#1163) — adversarial: partial gaps, canonical template, scoped insertion', () => { let tmpDir; let roadmapPath; beforeEach(() => { tmpDir = createTempProject('gsd-1163-adv-'); roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); }); afterEach(() => { cleanup(tmpDir); }); // ── Finding 1: partial-row gaps ────────────────────────────────────────── test('(Finding 1) inserts missing rows when SOME plan rows already exist', () => { // Phase has 5-01, 5-02, 5-03 on disk. // ROADMAP already has a row for 5-01 only. // Expected: 5-02 and 5-03 rows are inserted; 5-01 is NOT duplicated. const roadmapContent = [ '# ROADMAP', '', '## Milestone v1.0', '', `### Phase 5: test phase`, '', '**Plans:** 0/3 plans executed', '', 'Plans:', '- [ ] 5-01-PLAN.md', '', ].join('\n'); fs.writeFileSync(roadmapPath, roadmapContent); createPhaseWithPlans(tmpDir, '5', [ '5-01-PLAN.md', '5-02-PLAN.md', '5-03-PLAN.md', ]); const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const written = fs.readFileSync(roadmapPath, 'utf-8'); // All three plans must have rows assert.ok(written.includes('5-01-PLAN.md'), '5-01-PLAN.md row missing'); assert.ok(written.includes('5-02-PLAN.md'), '5-02-PLAN.md row was not inserted for partial gap'); assert.ok(written.includes('5-03-PLAN.md'), '5-03-PLAN.md row was not inserted for partial gap'); // No duplicates: exactly one checkbox row per plan const rows01 = (written.match(/- \[.\] 5-01-PLAN\.md/g) || []); const rows02 = (written.match(/- \[.\] 5-02-PLAN\.md/g) || []); const rows03 = (written.match(/- \[.\] 5-03-PLAN\.md/g) || []); assert.equal(rows01.length, 1, `5-01-PLAN.md duplicated (${rows01.length} times)`); assert.equal(rows02.length, 1, `5-02-PLAN.md duplicated (${rows02.length} times)`); assert.equal(rows03.length, 1, `5-03-PLAN.md duplicated (${rows03.length} times)`); }); test('(Finding 1) running twice (idempotent) does not duplicate partially-inserted rows', () => { const roadmapContent = [ '# ROADMAP', '', '## Milestone v1.0', '', `### Phase 5: test phase`, '', '**Plans:** 0/2 plans executed', '', 'Plans:', '- [ ] 5-01-PLAN.md', '', ].join('\n'); fs.writeFileSync(roadmapPath, roadmapContent); createPhaseWithPlans(tmpDir, '5', ['5-01-PLAN.md', '5-02-PLAN.md']); // Run once to insert 5-02 runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir); // Run again — should be a no-op runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir); const written = fs.readFileSync(roadmapPath, 'utf-8'); const rows01 = (written.match(/- \[.\] 5-01-PLAN\.md/g) || []); const rows02 = (written.match(/- \[.\] 5-02-PLAN\.md/g) || []); assert.equal(rows01.length, 1, `5-01-PLAN.md duplicated after two runs (${rows01.length} times)`); assert.equal(rows02.length, 1, `5-02-PLAN.md duplicated after two runs (${rows02.length} times)`); }); // ── Finding 2: canonical template shape ───────────────────────────────── // The canonical template (gsd-core/templates/roadmap.md) uses: // **Plans**: N plans ← summary line (bold word, outer colon) // (blank line) // Plans: ← checklist header // - [ ] NN-XX rows // // NOTE: `**Plans**:` differs from `**Plans:**` (colon placement): // **Plans**: → bold "Plans" + outer colon (CANONICAL) // **Plans:** → bold "Plans:" (previously assumed form) // Rows must be inserted under the `Plans:` checklist header, not after the // `**Plans**:` summary line. test('(Finding 2) canonical template: inserts rows under Plans: checklist header, not after **Plans**: summary', () => { // Canonical form: **Plans**: summary + blank + Plans: checklist header + no rows yet fs.writeFileSync(roadmapPath, buildRoadmapCanonicalTemplate('5', [])); createPhaseWithPlans(tmpDir, '5', ['5-01-PLAN.md', '5-02-PLAN.md']); const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const written = fs.readFileSync(roadmapPath, 'utf-8'); assert.ok(written.includes('- [ ] 5-01-PLAN.md'), '5-01-PLAN.md not inserted under Plans:'); assert.ok(written.includes('- [ ] 5-02-PLAN.md'), '5-02-PLAN.md not inserted under Plans:'); // Rows must appear AFTER the `Plans:` line, not between `**Plans**:` and `Plans:` const plansHeaderIdx = written.indexOf('\nPlans:\n'); const boldPlansIdx = written.indexOf('**Plans**:'); const row01Idx = written.indexOf('- [ ] 5-01-PLAN.md'); assert.ok(boldPlansIdx !== -1, '**Plans**: summary line is missing from output'); assert.ok(plansHeaderIdx !== -1, 'Plans: checklist header is missing from output'); assert.ok(row01Idx > plansHeaderIdx, 'row 5-01 appears before Plans: checklist header'); }); test('(Finding 2) canonical template: plan count updated on **Plans**: summary line', () => { // **Plans**: uses bold word + outer colon — the count update regex must handle it fs.writeFileSync(roadmapPath, buildRoadmapCanonicalTemplate('5', [])); const phaseDir = createPhaseWithPlans(tmpDir, '5', ['5-01-PLAN.md', '5-02-PLAN.md']); fs.writeFileSync(path.join(phaseDir, '5-01-SUMMARY.md'), '# Summary\n'); const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const written = fs.readFileSync(roadmapPath, 'utf-8'); // The **Plans**: summary line should be updated to reflect 1/2 assert.ok( written.match(/\*\*Plans\*\*:\s*1\/2 plans executed/), '**Plans**: count not updated for canonical template shape.\nROADMAP.md:\n' + written, ); }); // ── Finding 3: insertion scoped to active milestone ────────────────────── test('(Finding 3) rows are inserted only in the active milestone, not in archived
', () => { // ROADMAP has duplicate phase heading: one inside
(archived) and // one in the active section. Rows must land ONLY in the active section. fs.writeFileSync(roadmapPath, buildRoadmapWithArchivedDuplicate('5')); createPhaseWithPlans(tmpDir, '5', [ '5-01-PLAN.md', '5-02-PLAN.md', '5-03-PLAN.md', ]); const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const written = fs.readFileSync(roadmapPath, 'utf-8'); // The archived section already has rows for 5-01 and 5-02 only (checked off). // The active section should get 5-03 row (and ideally 5-01/5-02 too if they // were missing from the active section — the active section's Plans: was empty). // Key assertion: no NEW rows were inserted into the archived
block. const detailsStart = written.indexOf('
'); const detailsEnd = written.indexOf('
'); const archivedSection = written.slice(detailsStart, detailsEnd + '
'.length); // The archived section should still have exactly 2 rows (5-01 and 5-02) const archivedRows = (archivedSection.match(/- \[.\] 5-\d+-PLAN\.md/g) || []); assert.equal(archivedRows.length, 2, `Archived section row count changed — rows were inserted into archived section:\n${archivedSection}`); // The active milestone section (after
) should have the new rows const activeSection = written.slice(detailsEnd + '
'.length); assert.ok(activeSection.includes('5-03-PLAN.md'), '5-03 row not inserted in active milestone section'); }); // ── Finding 1 (code-review round 2): detection scoped to active region ─── // When an archived
block contains checkbox rows for the SAME plan // files as the current phase, the missingPlans filter must detect those plans // as MISSING from the ACTIVE section and insert them there — not skip them // because they appear anywhere in the full file content. test('(Finding 1 code-review) inserts ALL missing rows in active section even when archived section has same plans', () => { // Archived section has 5-01 and 5-02 (checked off for v0.9). // Active section has NO checkbox rows yet (empty Plans: block). // Phase 5 on disk has 5-01, 5-02, 5-03. // Expected: active section gets rows for ALL THREE plans (5-01, 5-02, 5-03). // Bug (pre-fix): detection runs against full content → 5-01 and 5-02 found in // archived block → missingPlans = [5-03 only] → only 5-03 inserted. fs.writeFileSync(roadmapPath, buildRoadmapWithArchivedDuplicate('5')); createPhaseWithPlans(tmpDir, '5', [ '5-01-PLAN.md', '5-02-PLAN.md', '5-03-PLAN.md', ]); const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const written = fs.readFileSync(roadmapPath, 'utf-8'); const detailsEnd = written.indexOf('
'); const activeSection = written.slice(detailsEnd + '
'.length); // All three plans must be present in the active section assert.ok(activeSection.includes('5-01-PLAN.md'), '5-01-PLAN.md not inserted in active section (detection not scoped to active region)'); assert.ok(activeSection.includes('5-02-PLAN.md'), '5-02-PLAN.md not inserted in active section (detection not scoped to active region)'); assert.ok(activeSection.includes('5-03-PLAN.md'), '5-03-PLAN.md not inserted in active section'); // Archived section must remain untouched (still exactly 2 rows) const archivedSection = written.slice(written.indexOf('
'), detailsEnd + '
'.length); const archivedRows = (archivedSection.match(/- \[.\] 5-\d+-PLAN\.md/g) || []); assert.equal(archivedRows.length, 2, `Archived section row count changed:\n${archivedSection}`); }); }); // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-2661-roadmap-sync-parallel.test.cjs — consolidation epic #1969 (B2 #1971) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-2661-roadmap-sync-parallel (consolidation epic #1969 B2 #1971)", () => { // allow-test-rule: source-text-is-the-product (see #2661) // Reads .md/.json/.yml product files whose deployed text IS what the // runtime loads — testing text content tests the deployed contract. /** * Regression tests for bug #2661: * `/gsd-execute-phase N --auto` with parallelization: true, use_worktrees: false * left ROADMAP plan checkboxes unchecked until a manual * `roadmap update-plan-progress` was run. * * Root cause (workflow-level): execute-plan.md `update_roadmap` step was * gated on a worktree-detection branch that incorrectly conflated * "parallel mode" with "worktree mode". When `parallelization: true, * use_worktrees: false` was configured, the step was still gated by the * worktree-only check (which is true: the executing tree IS the main repo, * not a worktree, so the gate happened to fire correctly there) — the * actual reproducer was a different code path. The original PR #2682 fix * made the sync unconditional, which violated the single-writer contract * for shared ROADMAP.md established by #1486 / dcb50396 in worktree mode. * * Minimal fix (this PR): restore the worktree guard and document its * intent explicitly. The `IS_WORKTREE != "true"` branch IS the * `use_worktrees: false` mode: only that mode runs the in-handler sync. * Worktree mode relies on the orchestrator's post-merge sync at * execute-phase.md §5.7 (lines 815-834) — the single writer for shared * tracking files. * * These tests: * (1) assert the workflow gates the sync call on `use_worktrees: false` * (i.e. the IS_WORKTREE != "true" branch is present and gates the call); * (2) assert the handler itself behaves correctly under the * use_worktrees: false reproducer (the original #2661 case); * (3) assert the handler is idempotent and lock-safe (lockfile is the * in-handler defense; the workflow gate is the cross-handler one). */ const { test, describe, 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 WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-plan.md'); function writeRoadmap(tmpDir, content) { fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), content); } function readRoadmap(tmpDir) { return fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); } function seedPhase(tmpDir, phaseNum, planIds, summaryIds) { const phaseDir = path.join(tmpDir, '.planning', 'phases', `${String(phaseNum).padStart(2, '0')}-test`); fs.mkdirSync(phaseDir, { recursive: true }); for (const id of planIds) { fs.writeFileSync(path.join(phaseDir, `${id}-PLAN.md`), `# Plan ${id}`); } for (const id of summaryIds) { fs.writeFileSync(path.join(phaseDir, `${id}-SUMMARY.md`), `# Summary ${id}`); } } const THREE_PLAN_ROADMAP = `# Roadmap - [ ] Phase 1: Test phase with three parallel plans - [ ] 01-01-PLAN.md - [ ] 01-02-PLAN.md - [ ] 01-03-PLAN.md ### Phase 1: Test **Goal:** Parallel execution regression test **Plans:** 3 plans ## Progress | Phase | Milestone | Plans Complete | Status | Completed | |-------|-----------|----------------|--------|-----------| | 1. Test | v1.0 | 0/3 | Planned | | `; // ─── Structural: workflow gates sync on use_worktrees=false ────────────────── describe('bug #2661: execute-plan.md update_roadmap gating', () => { const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); const stepMatch = content.match( // eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own workflow .md content, fixed-size author-controlled content /([\s\S]*?)<\/step>/ ); const step = stepMatch && stepMatch[1]; test('update_roadmap step exists and invokes roadmap.update-plan-progress', () => { assert.ok(stepMatch, 'update_roadmap step must exist'); // After #3797 architectural fix, callsites use gsd_run assert.ok( /gsd_run query roadmap\.update-plan-progress/.test(step), 'update_roadmap must still invoke roadmap.update-plan-progress' ); }); test('use_worktrees: false mode — sync call is gated to fire (the #2661 reproducer)', () => { // The non-worktree branch must contain the sync call. // After #3797 architectural fix, callsites use gsd_run assert.ok( /IS_WORKTREE.*!=.*"true"[\s\S]*?gsd_run query roadmap\.update-plan-progress/.test(step), 'sync call must execute on the IS_WORKTREE != "true" branch (use_worktrees: false)' ); }); test('use_worktrees: true mode — sync call does NOT fire (single-writer contract)', () => { // The sync call must be inside an `if [ "$IS_WORKTREE" != "true" ]` block, // i.e. it must NOT be unconditional and it must NOT appear on the worktree branch. // We verify by extracting the bash block and checking the call sits under the gate. const stepLines = step.split(/\r?\n/); const bashFence = scanFencedBlocks(stepLines).find( (b) => b.closeLineIdx !== -1 && (b.infoString || '').trim() === 'bash', ); assert.ok(bashFence, 'update_roadmap must contain a bash block'); const bash = stepLines.slice(bashFence.openLineIdx + 1, bashFence.closeLineIdx).join('\n'); assert.ok( /IS_WORKTREE/.test(bash), 'bash block must include the IS_WORKTREE worktree-detection check' ); // Sync call must appear after the guard check, not before. // After #3797 architectural fix, callsites use gsd_run const guardIdx = bash.search(/if \[ "\$IS_WORKTREE" != "true" \]/); const callIdx = bash.search(/gsd_run query roadmap\.update-plan-progress/); assert.ok(guardIdx >= 0, 'guard must be present'); assert.ok(callIdx > guardIdx, 'sync call must appear inside the use_worktrees: false guard, not before/outside it'); }); test('intent doc references single-writer contract / orchestrator-owns-write', () => { // The prose must justify why worktree mode is excluded so future readers // do not regress this back to unconditional. assert.ok( /worktree|orchestrator|single-writer|#1486|#2661/i.test(step), 'update_roadmap must document the contract that justifies the gate' ); }); }); // ─── Handler-level: idempotence + multi-plan sync (use_worktrees: false case) ─ describe('bug #2661: roadmap update-plan-progress handler (use_worktrees: false)', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject('gsd-2661-'); }); afterEach(() => { cleanup(tmpDir); }); test('three parallel SUMMARY.md files produce three [x] plan checkboxes', () => { writeRoadmap(tmpDir, THREE_PLAN_ROADMAP); seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-01', '01-02', '01-03']); const result = runGsdTools('roadmap update-plan-progress 1', tmpDir); assert.ok(result.success, `handler failed: ${result.error}`); const roadmap = readRoadmap(tmpDir); assert.ok(roadmap.includes('[x] 01-01-PLAN.md'), 'plan 01-01 should be checked'); assert.ok(roadmap.includes('[x] 01-02-PLAN.md'), 'plan 01-02 should be checked'); assert.ok(roadmap.includes('[x] 01-03-PLAN.md'), 'plan 01-03 should be checked'); assert.ok(roadmap.includes('3/3'), 'progress row should reflect 3/3'); }); test('handler is idempotent — second call produces identical content', () => { writeRoadmap(tmpDir, THREE_PLAN_ROADMAP); seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-01', '01-02', '01-03']); const first = runGsdTools('roadmap update-plan-progress 1', tmpDir); assert.ok(first.success, first.error); const afterFirst = readRoadmap(tmpDir); const second = runGsdTools('roadmap update-plan-progress 1', tmpDir); assert.ok(second.success, second.error); const afterSecond = readRoadmap(tmpDir); assert.strictEqual(afterSecond, afterFirst, 'repeated invocation must not mutate ROADMAP.md further (idempotent)'); }); test('partial completion: only plans with SUMMARY.md get [x]', () => { writeRoadmap(tmpDir, THREE_PLAN_ROADMAP); // Only plan 01-02 has a SUMMARY.md seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-02']); const result = runGsdTools('roadmap update-plan-progress 1', tmpDir); assert.ok(result.success, result.error); const roadmap = readRoadmap(tmpDir); assert.ok(roadmap.includes('[ ] 01-01-PLAN.md'), 'plan 01-01 should remain unchecked'); assert.ok(roadmap.includes('[x] 01-02-PLAN.md'), 'plan 01-02 should be checked'); assert.ok(roadmap.includes('[ ] 01-03-PLAN.md'), 'plan 01-03 should remain unchecked'); assert.ok(roadmap.includes('1/3'), 'progress row should reflect 1/3'); }); test('lockfile contention: concurrent handler invocations within a single tree do not corrupt ROADMAP.md', async () => { // Scope: lockfile only serializes within a single working tree. Cross-worktree // serialization is enforced by the workflow gate (worktree mode never calls // this handler from execute-plan.md), not by the lockfile. writeRoadmap(tmpDir, THREE_PLAN_ROADMAP); seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-01', '01-02', '01-03']); const invocations = Array.from({ length: 3 }, () => new Promise((resolve) => { const r = runGsdTools('roadmap update-plan-progress 1', tmpDir); resolve(r); }) ); const results = await Promise.all(invocations); for (const r of results) { assert.ok(r.success, `concurrent handler invocation failed: ${r.error}`); } const roadmap = readRoadmap(tmpDir); // Structural integrity: each checkbox appears exactly once, progress row intact. for (const id of ['01-01', '01-02', '01-03']) { const occurrences = roadmap.split(`[x] ${id}-PLAN.md`).length - 1; assert.strictEqual(occurrences, 1, `plan ${id} checkbox should appear exactly once (got ${occurrences})`); } assert.ok(roadmap.includes('3/3'), 'progress row should reflect 3/3 after concurrent runs'); // Lockfile should have been cleaned up after the final release. assert.ok( !fs.existsSync(path.join(tmpDir, '.planning', 'ROADMAP.md.lock')), 'ROADMAP.md.lock should be released after concurrent invocations settle' ); }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-3599-roadmap-get-phase-project-code-prefix.test.cjs — consolidation epic #1969 (B2 #1971) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-3599-roadmap-get-phase-project-code-prefix (consolidation epic #1969 B2 #1971)", () => { /** * Bug #3599: roadmap.get-phase no longer matches custom phase IDs with * project-code prefixes like `PROJ-42`. * * `phaseMarkdownRegexSource(phaseNum)` in gsd-core/bin/lib/core.cjs * (and its SDK twin in sdk/src/query/roadmap-update-plan-progress.ts) strips * the `PROJ-` prefix before building the padding-tolerant numeric regex. * Result: `roadmap get-phase PROJ-42` produces a regex of `0*42`, which * matches `### Phase 42:` instead of (or in addition to) the intended * `### Phase PROJ-42:`. The function's own docstring promises a fallback to * `escapeRegex(phaseNum)` for non-numeric custom IDs, but that branch is * unreachable for project-code-prefixed numeric IDs. * * Fix: the emitted regex must match BOTH the stripped numeric form (so * `CK-01-name` directory inputs still resolve to `Phase 1:` in prose, the * #3537 contract) AND the full prefixed form (so `PROJ-42` resolves to * `Phase PROJ-42:`). */ '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 { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); function writeRoadmap(tmpDir, body) { fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), body); } function writeState(tmpDir, version) { fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `---\nmilestone: ${version}\n---\n`, ); } describe('bug #3599: roadmap get-phase preserves project-code prefix in lookup', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject('bug-3599-'); }); afterEach(() => { cleanup(tmpDir); }); test('finds ### Phase PROJ-42: when queried as PROJ-42', () => { writeState(tmpDir, 'v1.0.0'); writeRoadmap( tmpDir, [ '# Roadmap', '', '## Current Milestone: v1.0.0 - Test', '', '### Phase PROJ-42: Custom phase', '**Goal:** Verify project-code-prefixed lookup', '', ].join('\n'), ); const result = runGsdTools('roadmap get-phase PROJ-42 --json', tmpDir); assert.ok(result.success, `command failed: ${result.error || result.output}`); const payload = JSON.parse(result.output); assert.strictEqual(payload.found, true, `expected found=true, got: ${result.output}`); assert.strictEqual(payload.phase_name, 'Custom phase'); assert.strictEqual(payload.goal, 'Verify project-code-prefixed lookup'); }); test('bare numeric prefers a bare sibling over a prefixed one (#3599 anti-steal, updated for #2114)', () => { // #3599's real guard is anti-STEALING: when BOTH a bare `Phase 42:` and a // distinct prefixed `Phase PROJ-42:` exist, a bare `42` query must resolve // the BARE one — the numeric source is tried before the prefix-tolerant // fallback, so a bare query never steals a distinct prefixed sibling. // (Since #2114/#2121, a bare query DOES resolve a *drifted-only* prefixed // heading when no bare sibling exists — see the bug #2114 block below.) writeState(tmpDir, 'v1.0.0'); writeRoadmap( tmpDir, [ '# Roadmap', '', '## Current Milestone: v1.0.0 - Test', '', '### Phase 42: Bare', '**Goal:** Canonical bare heading', '', '### Phase PROJ-42: Prefixed', '**Goal:** Distinct prefixed sibling', '', ].join('\n'), ); const result = runGsdTools('roadmap get-phase 42 --json', tmpDir); assert.ok(result.success); const payload = JSON.parse(result.output); assert.strictEqual(payload.found, true, `expected found=true, got: ${result.output}`); assert.strictEqual( payload.phase_name, 'Bare', `bare '42' must resolve the bare 'Phase 42:', not steal 'Phase PROJ-42:'; got ${result.output}`, ); }); test('preserves #3537 contract: CK-01 directory form resolves to Phase 1 prose', () => { // Existing contract: phase directory names like `CK-01-name` carry the // project_code prefix and a zero-padded number, but ROADMAP prose is // typically un-padded (`### Phase 1:`). The padding-tolerant lookup must // still bridge those two surfaces. writeState(tmpDir, 'v1.0.0'); writeRoadmap( tmpDir, [ '# Roadmap', '', '## Current Milestone: v1.0.0 - Test', '', '### Phase 1: Numeric prose', '**Goal:** #3537 contract — CK-01 dir → Phase 1 prose', '', ].join('\n'), ); const result = runGsdTools('roadmap get-phase CK-01 --json', tmpDir); assert.ok(result.success); const payload = JSON.parse(result.output); assert.strictEqual( payload.found, true, `CK-01 must still resolve to 'Phase 1:' prose (#3537 contract); got ${result.output}`, ); assert.strictEqual(payload.phase_name, 'Numeric prose'); }); test('finds the right phase when both prefixed and bare forms coexist', () => { // Disambiguation test: a roadmap that contains BOTH `### Phase 42:` and // `### Phase PROJ-42:` must resolve each query to its specific match. writeState(tmpDir, 'v1.0.0'); writeRoadmap( tmpDir, [ '# Roadmap', '', '## Current Milestone: v1.0.0 - Test', '', '### Phase 42: Bare numeric', '**Goal:** Bare', '', '### Phase PROJ-42: Prefixed', '**Goal:** Prefixed', '', ].join('\n'), ); const r42 = runGsdTools('roadmap get-phase 42 --json', tmpDir); const rProj = runGsdTools('roadmap get-phase PROJ-42 --json', tmpDir); const p42 = JSON.parse(r42.output); const pProj = JSON.parse(rProj.output); assert.strictEqual(p42.found, true); assert.strictEqual(p42.phase_name, 'Bare numeric'); assert.strictEqual(p42.goal, 'Bare'); assert.strictEqual(pProj.found, true); assert.strictEqual(pProj.phase_name, 'Prefixed'); assert.strictEqual(pProj.goal, 'Prefixed'); }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/enh-2447-roadmap-wave-deps.test.cjs — consolidation epic #1969 (B2 #1971) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:enh-2447-roadmap-wave-deps (consolidation epic #1969 B2 #1971)", () => { 'use strict'; // allow-test-rule: source-text-is-the-product (see #2447) // Reads .md/.json/.yml product files whose deployed text IS what the // runtime loads — testing text content tests the deployed contract. /** * Tests for ROADMAP wave dependency surfacing (#2447). */ const { test, describe, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); const PLAN_TEMPLATE = (wave, truths = []) => `--- phase: "1" plan: "01-0${wave}" type: standard wave: ${wave} depends_on: [] files_modified: [] autonomous: true requirements: [] must_haves: truths: ${truths.map(t => ` - ${t}`).join('\n') || ' - (none)'} artifacts: [] key_links: [] --- Plan ${wave} objective `; function makePlanProject(files = {}) { const dir = createTempProject(); fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), ''); fs.mkdirSync(path.join(dir, '.planning', 'phases', '01-foundation'), { recursive: true }); for (const [rel, content] of Object.entries(files)) { const abs = path.join(dir, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, content, 'utf-8'); } return dir; } describe('bug #2114: roadmap get-phase resolves drifted prefixed headings by bare number', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject('bug-2114-'); }); afterEach(() => { cleanup(tmpDir); }); test('bare-number query resolves a drifted project-code-prefixed heading', () => { // Before the fix, bare `29` did NOT match `### Phase AB-29:` from the CLI // (2-source lookup), even though getRoadmapPhaseInternal (init.phase-op) did // — the #2114 divergence. Now all three resolvers share the 3-source list. fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), '---\nmilestone: v1.0.0\n---\n# State\n\n**Status:** In progress\n', ); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# Roadmap', '', '## Current Milestone: v1.0.0 - Test', '', '### Phase 30: Plain', '**Goal:** Canonical bare heading', '', '### Phase AB-29: Prefixed', '**Goal:** Drifted prefixed heading', '', ].join('\n'), ); const resultAB29 = runGsdTools('roadmap get-phase 29 --json', tmpDir); assert.ok(resultAB29.success, `command failed: ${resultAB29.error || resultAB29.output}`); const payloadAB29 = JSON.parse(resultAB29.output); assert.strictEqual(payloadAB29.found, true, `expected found=true for drifted AB-29, got: ${resultAB29.output}`); assert.strictEqual(payloadAB29.phase_name, 'Prefixed'); assert.strictEqual(payloadAB29.goal, 'Drifted prefixed heading'); // The canonical bare heading still resolves. const result30 = runGsdTools('roadmap get-phase 30 --json', tmpDir); assert.ok(result30.success); const payload30 = JSON.parse(result30.output); assert.strictEqual(payload30.found, true); assert.strictEqual(payload30.phase_name, 'Plain'); }); test('project-code-prefixed checklist-only entry surfaces malformed_roadmap for both query forms', () => { // #2121/#2114 route all three resolvers through the shared 3-source lookup. A // `**Phase PROJ-42:**` summary line with no matching `### Phase PROJ-42:` detail heading // is a malformed ROADMAP. Before the consolidation this project-code-prefixed checklist // was reported as a silent `{found:false}` for BOTH query forms — the prefixed pass // discarded its malformed candidate, and the bare pass could not match the `PROJ-` prefix // at all. The unified lookup newly surfaces the malformed_roadmap diagnostic for both, so // this test fails on the prior silent-empty behavior for the prefixed AND the bare form. fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# Roadmap v1.0', '', '## Phases', '', '- [ ] **Phase PROJ-42: Checklist only, no header**', '', ].join('\n'), ); const prefixed = runGsdTools('roadmap get-phase PROJ-42 --json', tmpDir); assert.ok(prefixed.success, `command failed: ${prefixed.error || prefixed.output}`); const pPayload = JSON.parse(prefixed.output); assert.strictEqual(pPayload.found, false, 'malformed roadmap: phase must not be found'); assert.strictEqual(pPayload.error, 'malformed_roadmap', 'prefixed query must surface malformed_roadmap'); assert.ok(pPayload.message.includes('missing'), 'message must explain the missing detail section'); // Parity: the bare numeric form yields the same diagnostic against the same fixture. const bare = runGsdTools('roadmap get-phase 42 --json', tmpDir); assert.ok(bare.success, `command failed: ${bare.error || bare.output}`); assert.strictEqual(JSON.parse(bare.output).error, 'malformed_roadmap', 'bare query surfaces the same diagnostic'); }); }); describe('roadmap annotate-dependencies', () => { let tmpDir; afterEach(() => cleanup(tmpDir)); test('inserts wave headers for multi-wave plan set', () => { tmpDir = makePlanProject({ '.planning/ROADMAP.md': `# Roadmap ### Phase 1: Foundation **Goal:** Set up project **Plans:** 2 plans Plans: - [ ] 01-01-PLAN.md — Set up DB - [ ] 01-02-PLAN.md — Build API `, '.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, ['DB schema is correct']), '.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2, ['API returns 200']), }); const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.updated, true); assert.strictEqual(out.waves, 2); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('**Wave 1**'), 'Wave 1 header present'); assert.ok(roadmap.includes('**Wave 2**'), 'Wave 2 header present'); assert.ok(roadmap.includes('blocked on Wave 1'), 'Wave 2 blocked-on note present'); }); test('does not insert wave headers for single-wave plan set', () => { tmpDir = makePlanProject({ '.planning/ROADMAP.md': `# Roadmap ### Phase 1: Foundation **Goal:** Set up project **Plans:** 2 plans Plans: - [ ] 01-01-PLAN.md — Set up DB - [ ] 01-02-PLAN.md — Build API `, '.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, ['DB schema is correct']), '.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(1, ['API returns 200']), }); const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(!roadmap.includes('**Wave 1**'), 'no Wave header for single-wave set'); assert.ok(!roadmap.includes('blocked on'), 'no blocked-on note for single wave'); }); test('surfaces cross-cutting constraints when truths appear in 2+ plans', () => { const sharedTruth = 'All endpoints require auth'; tmpDir = makePlanProject({ '.planning/ROADMAP.md': `# Roadmap ### Phase 1: Foundation **Goal:** Set up project **Plans:** 2 plans Plans: - [ ] 01-01-PLAN.md — Set up DB - [ ] 01-02-PLAN.md — Build API `, '.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, [sharedTruth, 'DB schema is correct']), '.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2, [sharedTruth, 'API returns 200']), }); const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.cross_cutting_constraints, 1); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('Cross-cutting constraints:'), 'constraints subsection present'); assert.ok(roadmap.includes(sharedTruth), 'shared truth listed'); }); test('#1154: surfaces a cross-cutting backstop (object-form) truth by its statement, not dropped', () => { // An object-form backstop truth `{ statement, verification: backstop }` (the #1154 non-inferable // marker on must_haves.truths) shared across 2 plans must be coerced by its `statement` — the // Hyrum backward-compat guard: a truth-reader must tolerate the new object form, never drop it. const backstopTruth = 'statement: Adjacent touching intervals merge\n verification: backstop'; tmpDir = makePlanProject({ '.planning/ROADMAP.md': `# Roadmap ### Phase 1: Foundation **Goal:** Set up project **Plans:** 2 plans Plans: - [ ] 01-01-PLAN.md — Set up DB - [ ] 01-02-PLAN.md — Build API `, '.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, [backstopTruth, 'DB schema is correct']), '.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2, [backstopTruth, 'API returns 200']), }); const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.cross_cutting_constraints, 1, 'the shared backstop truth is surfaced, not dropped'); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('Adjacent touching intervals merge'), 'surfaced by its statement text, not [object Object]'); }); test('does not surface constraints that appear in only one plan', () => { tmpDir = makePlanProject({ '.planning/ROADMAP.md': `# Roadmap ### Phase 1: Foundation **Goal:** Set up project **Plans:** 2 plans Plans: - [ ] 01-01-PLAN.md — Set up DB - [ ] 01-02-PLAN.md — Build API `, '.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, ['Only in plan 1']), '.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2, ['Only in plan 2']), }); const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.cross_cutting_constraints, 0); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(!roadmap.includes('Cross-cutting constraints:'), 'no constraints section when none are cross-cutting'); }); test('is idempotent — running twice does not double-insert wave headers', () => { tmpDir = makePlanProject({ '.planning/ROADMAP.md': `# Roadmap ### Phase 1: Foundation **Goal:** Set up project **Plans:** 2 plans Plans: - [ ] 01-01-PLAN.md — Set up DB - [ ] 01-02-PLAN.md — Build API `, '.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1), '.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2), }); runGsdTools('roadmap annotate-dependencies 1', tmpDir); const secondResult = runGsdTools('roadmap annotate-dependencies 1', tmpDir); assert.ok(secondResult.success); const out = JSON.parse(secondResult.output); assert.strictEqual(out.updated, false, 'second run should be no-op'); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); const waveMatches = roadmap.match(/\*\*Wave \d+\*\*/g) || []; assert.strictEqual(waveMatches.length, 2, 'exactly 2 wave headers (not doubled)'); }); test('returns no-op when phase has no plans', () => { tmpDir = makePlanProject({ '.planning/ROADMAP.md': `# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Set up project\n`, }); const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir); assert.ok(result.success); const out = JSON.parse(result.output); assert.strictEqual(out.updated, false); }); test('#2757: truths containing colons do not crash annotate-dependencies', () => { // Unquoted truths with colons (Rails idioms: db:seed, /foo/:id, Class::Method) // caused parseMustHavesBlock to return {} instead of a string, then t.trim() threw. const colonTruths = [ 'GET /foo/:id resolves to controller#show', 'Class::Method is idempotent', '"Quoted truth with colon: inside"', ]; tmpDir = makePlanProject({ '.planning/ROADMAP.md': `# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Set up project\n**Plans:** 1 plan\n\nPlans:\n- [ ] 01-01-PLAN.md — Repro plan\n`, '.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, colonTruths), }); const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir); assert.ok(result.success, `Command threw on colon-containing truths: ${result.error}`); const out = JSON.parse(result.output); assert.ok(typeof out.updated === 'boolean', 'should return a valid result object'); }); test('#314 map-lookup: found-path uses plan wave, miss-path defaults to wave 1', () => { // Behavior lock for the O(1) Map swap: asserts BOTH branches of the lookup. // - 01-01-PLAN.md is in planData (wave 2) → checklist line must land under Wave 2. // - 01-99-PLAN.md is NOT in planData → null-on-miss → defaults to wave 1. tmpDir = makePlanProject({ '.planning/ROADMAP.md': `# Roadmap ### Phase 1: Foundation **Goal:** Set up project **Plans:** 2 plans Plans: - [ ] 01-01-PLAN.md — Known plan - [ ] 01-99-PLAN.md — Unknown plan (no PLAN.md) `, '.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(2), // 01-99-PLAN.md intentionally absent — simulates a checklist entry with no backing plan file }); const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); // Both waves must be present (wave 1 from the miss, wave 2 from the found entry) assert.ok(roadmap.includes('**Wave 1**'), 'Wave 1 header present (miss-path default)'); assert.ok(roadmap.includes('**Wave 2**'), 'Wave 2 header present (found-path)'); // Known plan (wave 2) must appear AFTER Wave 2 header const wave2Idx = roadmap.indexOf('**Wave 2**'); const knownLineIdx = roadmap.indexOf('01-01-PLAN.md'); assert.ok(knownLineIdx > wave2Idx, 'known plan line grouped under Wave 2'); // Unknown plan (wave 1 default) must appear AFTER Wave 1 header and BEFORE Wave 2 header const wave1Idx = roadmap.indexOf('**Wave 1**'); const unknownLineIdx = roadmap.indexOf('01-99-PLAN.md'); assert.ok(unknownLineIdx > wave1Idx, 'unknown plan line grouped under Wave 1'); assert.ok(unknownLineIdx < wave2Idx, 'unknown plan line appears before Wave 2 section'); }); test('plan-phase.md documents annotate-dependencies step', () => { const planPhase = fs.readFileSync( path.join(__dirname, '../gsd-core/workflows/plan-phase.md'), 'utf-8' ); assert.ok(planPhase.includes('annotate-dependencies'), 'plan-phase.md references annotate-dependencies command'); assert.ok(planPhase.includes('13d'), 'plan-phase.md has step 13d'); assert.ok(planPhase.includes('Cross-cutting constraints'), 'plan-phase.md documents cross-cutting constraints'); }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-557-details-summary-milestone-strip.test.cjs — consolidation epic #1969 (B2 #1971) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-557-details-summary-milestone-strip (consolidation epic #1969 B2 #1971)", () => { /** * Bug #557: Active milestone wrapped in
with version only in * tag + 🔄 emoji causes extractCurrentMilestone() to fall through * to stripShippedMilestones(), erasing the active block and making * roadmap.analyze return phase_count: 0 — which then triggers a premature * milestone_complete STATE write. * * Root cause (two miss paths in extractCurrentMilestone, core.cjs): * 1. sectionPattern only matches ##/### headings; version in not found. * 2. activeMarkerPattern does not include 🔄; only 🚧 is recognised. * Both misses → stripShippedMilestones() deletes the active
block. * * This test will FAIL before the fix (phase_count returns 0) and PASS after. */ const { test, describe, 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'); // ─── Fixtures ──────────────────────────────────────────────────────────────── // ROADMAP where the active milestone's version ("v1.3") appears ONLY inside // a tag, and the in-progress marker is 🔄 (not 🚧). // Shipped milestone v1.2 is correctly collapsed in a
block. const ROADMAP_DETAILS_SUMMARY = `# Roadmap
✅ v1.2: Foundation (shipped) ### Phase 1: Bootstrap **Goal:** Set up infrastructure ### Phase 2: Core API **Goal:** Build REST API
🔄 v1.3: Active Sprint ### Phase 3: Auth **Goal:** Add authentication ### Phase 4: Dashboard **Goal:** Build dashboard UI
`; // STATE.md with milestone: v1.3 — version matches the tag above const STATE_V13 = `--- gsd_state_version: 1.0 milestone: v1.3 milestone_name: Active Sprint status: in_progress progress: total_phases: 2 completed_phases: 0 total_plans: 0 completed_plans: 0 percent: 0 --- # Project State ## Current Position Phase: 3 (Auth) `; // Second variant: active milestone uses the 🔄 emoji in a heading (not just // ) to confirm the activeMarkerPattern gap is also covered. const ROADMAP_ROTATE_HEADING = `# Roadmap
✅ v2.0: Shipped (shipped) ### Phase 1: Old Phase **Goal:** Done
## 🔄 v2.1: Active Milestone ### Phase 2: New Feature **Goal:** Build the new feature ### Phase 3: Integration **Goal:** Wire it all together `; const STATE_V21 = `--- gsd_state_version: 1.0 milestone: v2.1 milestone_name: Active Milestone status: in_progress --- # Project State ## Current Position Phase: 2 (New Feature) `; // ─── Test suite ─────────────────────────────────────────────────────────────── describe('bug #557 —
/ active milestone strip', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); // ── Core repro: version only in tag ───────────────────────────── test('roadmap.analyze returns correct phase_count when active milestone uses + 🔄', () => { const planning = path.join(tmpDir, '.planning'); fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8'); fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V13, 'utf-8'); fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8'); const result = runGsdTools(['roadmap', 'analyze'], tmpDir); assert.ok(result.success, `roadmap.analyze failed: ${result.error}`); const output = JSON.parse(result.output); assert.ok( output.phase_count >= 2, `Expected phase_count >= 2 (phases 3 and 4 of v1.3); got phase_count=${output.phase_count}. ` + `Bug: extractCurrentMilestone() stripped the active
block because ` + `the version "v1.3" only appears in a tag and the emoji is 🔄, not 🚧.` ); }); test('roadmap.analyze does NOT return phase_count: 0 when active milestone is in
', () => { const planning = path.join(tmpDir, '.planning'); fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8'); fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V13, 'utf-8'); fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8'); const result = runGsdTools(['roadmap', 'analyze'], tmpDir); assert.ok(result.success, `roadmap.analyze failed: ${result.error}`); const output = JSON.parse(result.output); assert.notStrictEqual( output.phase_count, 0, 'phase_count must not be 0 — a zero count caused by stripping the active block ' + 'is the direct trigger for the premature milestone_complete write.' ); }); test('roadmap get-phase returns found:true for phase in active
block', () => { const planning = path.join(tmpDir, '.planning'); fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8'); fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V13, 'utf-8'); fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8'); const result = runGsdTools(['roadmap', 'get-phase', '3'], tmpDir); assert.ok(result.success, `roadmap get-phase failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.found, true, `Phase 3 must be found in the active v1.3 milestone block. ` + `Bug: stripShippedMilestones() erased the
block so the phase section was lost.` ); }); test('shipped phases in collapsed
are NOT visible to roadmap.analyze (strip preserved for non-active)', () => { const planning = path.join(tmpDir, '.planning'); fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8'); fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V13, 'utf-8'); fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8'); const result = runGsdTools(['roadmap', 'analyze'], tmpDir); assert.ok(result.success, `roadmap.analyze failed: ${result.error}`); const output = JSON.parse(result.output); const phaseNums = (output.phases || []).map(p => p.number); assert.ok( !phaseNums.includes('1') && !phaseNums.includes('2'), `Shipped phases 1 and 2 (from collapsed
) must not appear in the analyze output. ` + `Got phases: ${JSON.stringify(phaseNums)}` ); }); // ── 🔄 in heading (not ) also recognised ──────────────────────── test('extractCurrentMilestone recognises 🔄 in milestone heading as in-progress marker', () => { const planning = path.join(tmpDir, '.planning'); fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_ROTATE_HEADING, 'utf-8'); fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V21, 'utf-8'); fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8'); const result = runGsdTools(['roadmap', 'analyze'], tmpDir); assert.ok(result.success, `roadmap.analyze failed: ${result.error}`); const output = JSON.parse(result.output); assert.ok( output.phase_count >= 2, `Expected phase_count >= 2 for v2.1 with 🔄 heading; got ${output.phase_count}. ` + `activeMarkerPattern must include 🔄, not just 🚧.` ); }); // ── Health check W026: milestone_complete vs unstarted phases ───────────── // Phase 11 (#3309): this subject moved off the pre-migration 'W021' code // onto the new 'W026' code (the split-off half of the two-subject // conflation the design doc's "New codes for the two split subjects" // section documents) — the OTHER W021 subject, phase_id_convention // mismatch, kept its code. test('validate health emits W026 when STATE says milestone complete but ROADMAP has unstarted phases', () => { const planning = path.join(tmpDir, '.planning'); // ROADMAP still has active phases in it fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8'); // STATE falsely says milestone complete fs.writeFileSync(path.join(planning, 'STATE.md'), `--- gsd_state_version: 1.0 milestone: v1.3 milestone_name: Active Sprint status: v1.3 milestone complete --- # Project State ## Current Position Phase: Milestone v1.3 complete `, 'utf-8'); fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8'); const result = runGsdTools(['validate', 'health'], tmpDir); assert.ok(result.success, `validate health failed: ${result.error}`); const output = JSON.parse(result.output); const warnings = output.warnings || []; const w026 = warnings.find(w => w.code === 'W026'); assert.ok( w026 !== undefined, `Expected W026 warning (milestone-status vs. roadmap-progress incoherence). ` + `Got warnings: ${JSON.stringify(warnings.map(w => w.code))}` ); // W021/W026 independence (Phase 11, #3309 split): this fixture's subject // is the W026 one (milestone-complete vs. unstarted phases) — it must // NOT also produce a W021 (phase_id_convention mismatch, an unrelated // subject this config.json-less fixture never triggers). assert.ok( warnings.every(w => w.code !== 'W021'), `W026 fixture must not also fire W021: ${JSON.stringify(warnings.map(w => w.code))}` ); }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-549-total-phases-overcounts-with-phase-section-heading.test.cjs — consolidation epic #1969 (B2 #1971) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-549-total-phases-overcounts-with-phase-section-heading (consolidation epic #1969 B2 #1971)", () => { 'use strict'; /** * Regression test for bug #549: * STATE.md progress.total_phases is over-counted by 1 when the ROADMAP contains * a non-phase section heading that happens to match the broader pattern used by * getMilestonePhaseFilter (e.g. `## Phase Overview:`, `## Phase Details:`). * * Root cause: * buildStateFrontmatter sources total_phases from getMilestonePhaseFilter.phaseCount, * which uses the looser regex `#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:` to build its * milestonePhaseNums set. That pattern matches section headings like * `## Phase Overview:` and `## Phase Details:`, adding non-numeric tokens * ("Overview", "Details") to the set and inflating phaseCount by 1 per heading. * * roadmap.analyze uses the stricter pattern * `#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)` which requires a * leading digit, so it only counts real phase headings. * * Fix: buildStateFrontmatter (and cmdStateSync) must source total_phases from * the same digit-anchored phase-heading parser as roadmap.analyze — single * source of truth. * * Scenario under test: * ROADMAP with 6 integer phases (01-06) + 1 inserted decimal phase (05.1) = 7 * phases, plus a `## Phase Overview:` section header. * * BEFORE fix: state json / state sync report total_phases: 8 (7 + 1 spurious * "Overview" token from the getMilestonePhaseFilter pattern). * AFTER fix: state json / state sync report total_phases: 7, matching * roadmap.analyze.phase_count. */ const { test, describe, 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'); // ROADMAP: 6 integer phases + 1 inserted decimal + `## Phase Overview:` section header. // The section header must include a trailing `:` so it matches the getMilestonePhaseFilter // broader pattern (the bug trigger). const ROADMAP = `## Milestone v1.0: Test Milestone ## Phase Overview: High-level narrative about the phases. ### Phase 01: Alpha **Goal:** alpha ### Phase 02: Beta **Goal:** beta ### Phase 03: Gamma **Goal:** gamma ### Phase 04: Delta **Goal:** delta ### Phase 05: Epsilon **Goal:** epsilon ### Phase 05.1: Inserted Hotfix (INSERTED) **Goal:** inserted hotfix ### Phase 06: Zeta **Goal:** zeta `; describe('bug #549 — total_phases over-counted by non-phase section headings', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject('bug-549-'); const planning = path.join(tmpDir, '.planning'); fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP, 'utf-8'); fs.writeFileSync( path.join(planning, 'STATE.md'), [ '---', 'gsd_state_version: 1.0', 'milestone: v1.0', 'milestone_name: Test Milestone', 'status: executing', '---', '', '# Project State', '', '## Configuration', 'Current Phase: 1', 'Status: Executing Phase 1', 'Last Activity: 2026-01-01', '', ].join('\n'), 'utf-8', ); fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8'); // Create 7 phase dirs (6 integer + 1 decimal). const phaseDirs = [ '01-alpha', '02-beta', '03-gamma', '04-delta', '05-epsilon', '05.1-inserted-hotfix', '06-zeta', ]; for (const d of phaseDirs) { const dir = path.join(planning, 'phases', d); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8'); } }); afterEach(() => { cleanup(tmpDir); }); test('state json total_phases matches roadmap.analyze phase_count (7, not 8)', () => { // Authoritative count from roadmap.analyze — uses the digit-anchored pattern. const analyzeResult = runGsdTools(['roadmap', 'analyze'], tmpDir); assert.ok(analyzeResult.success, `roadmap analyze failed: ${analyzeResult.error}`); const analyzed = JSON.parse(analyzeResult.output); assert.equal( analyzed.phase_count, 7, `roadmap.analyze should count 7 phases (01-06 + 05.1), got ${analyzed.phase_count}`, ); // State frontmatter must equal the authoritative count. const stateResult = runGsdTools(['state', 'json'], tmpDir); assert.ok(stateResult.success, `state json failed: ${stateResult.error}`); const state = JSON.parse(stateResult.output); assert.ok(state.progress, 'state json must return a progress block'); assert.equal( state.progress.total_phases, 7, `progress.total_phases must be 7 (not 8) — ## Phase Overview: section must not be counted as a phase. Got ${state.progress.total_phases}`, ); assert.equal( state.progress.total_phases, analyzed.phase_count, `progress.total_phases (${state.progress.total_phases}) must equal roadmap.analyze.phase_count (${analyzed.phase_count})`, ); }); test('state sync total_phases matches roadmap.analyze phase_count', () => { // Add a Progress field to the body so cmdStateSync has something to update. const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const before = fs.readFileSync(statePath, 'utf-8'); fs.writeFileSync(statePath, before.replace('Last Activity: 2026-01-01', 'Last Activity: 2026-01-01\nProgress: [░░░░░░░░░░] 0%'), 'utf-8'); const syncResult = runGsdTools(['state', 'sync'], tmpDir); assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`); // Read frontmatter via state json (authoritative JSON path). const stateResult = runGsdTools(['state', 'json'], tmpDir); assert.ok(stateResult.success, `state json after sync failed: ${stateResult.error}`); const state = JSON.parse(stateResult.output); assert.ok(state.progress, 'state json must return a progress block after sync'); assert.equal( state.progress.total_phases, 7, `state sync must write total_phases: 7, not 8. ## Phase Overview: must not inflate the count. Got ${state.progress.total_phases}`, ); }); test('integer-only project without decimal phase also counts correctly', () => { // Regression guard: the fix must not break projects with no decimal phases. const tmpDir2 = createTempProject('bug-549-integer-'); try { const planning2 = path.join(tmpDir2, '.planning'); // ROADMAP: 4 integer phases only + non-phase section heading. fs.writeFileSync( path.join(planning2, 'ROADMAP.md'), [ '## Milestone v1.0: Simple', '', '## Phase Overview:', '', '### Phase 01: One', '**Goal:** one', '', '### Phase 02: Two', '**Goal:** two', '', '### Phase 03: Three', '**Goal:** three', '', '### Phase 04: Four', '**Goal:** four', ].join('\n'), 'utf-8', ); fs.writeFileSync( path.join(planning2, 'STATE.md'), '---\ngsd_state_version: 1.0\nmilestone: v1.0\nstatus: executing\n---\n\n# State\n\nStatus: Executing Phase 1\nLast Activity: 2026-01-01\n', 'utf-8', ); fs.writeFileSync(path.join(planning2, 'config.json'), '{}', 'utf-8'); for (const d of ['01-one', '02-two', '03-three', '04-four']) { const dir = path.join(planning2, '.planning', 'phases', d); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8'); } const analyzeResult = runGsdTools(['roadmap', 'analyze'], tmpDir2); assert.ok(analyzeResult.success, `roadmap analyze failed: ${analyzeResult.error}`); const analyzed = JSON.parse(analyzeResult.output); assert.equal(analyzed.phase_count, 4, `expected 4 phases, got ${analyzed.phase_count}`); const stateResult = runGsdTools(['state', 'json'], tmpDir2); assert.ok(stateResult.success, `state json failed: ${stateResult.error}`); const state = JSON.parse(stateResult.output); assert.ok(state.progress, 'state json must return a progress block'); assert.equal( state.progress.total_phases, 4, `integer-only project: total_phases must be 4, not 5. Got ${state.progress.total_phases}`, ); } finally { cleanup(tmpDir2); } }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-3691-annotate-deps-plans-block-variants.test.cjs — consolidation epic #1969 (B2 #1971) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-3691-annotate-deps-plans-block-variants (consolidation epic #1969 B2 #1971)", () => { 'use strict'; // allow-test-rule: source-text-is-the-product (see #3691) // Reads .md/.json/.yml product files whose deployed text IS what the // runtime loads — testing text content tests the deployed contract. /** * Regression — issue #3691 * * Two regex defects fixed in `roadmap.cjs` function `cmdRoadmapAnnotateDependencies`, * plus two review-cycle additions (F3 defensive guard, F4 adversarial gaps): * * Bug 1 (line ~553) — Plans-block detection regex `/(Plans:\s*\n)/i` requires no text * after the colon. Headers like `Plans: 3 plans across 2 waves\n` or * `**Plans:** 3 plans\n` are silently skipped and the function early-returns. * Fix: `(?:^|\n)(\*{0,2}Plans\*{0,2}:[^\n]*\n)` anchors to start-of-line and * accepts optional bold wrappers and any trailing text on the header line. * * Bug 3 (line ~566) — Plan-ID extraction regex `/([\w-]+?)/` excludes `.`, so * decimal plan IDs like `02.3-01` are captured as `02` only, never match * the planData entry, and every plan defaults to wave 1. * Fix: `[\w.-]+?` includes `.` so decimal IDs are captured in full. * * Note: "Bug 2" (phase-section boundary `\d` → `\d[\d.]*`) was confirmed empirically * to be a no-op: any phase heading starts with a digit, so `\d` already matches. * The no-op change was dropped from the PR; this file has no Bug 2 describe block. * * Review additions: * F3 — Leading-dot plan ID guard: malformed IDs like `.invalid` are rejected * before planData.find() rather than silently defaulting to wave 1. * F4 — Adversarial gaps: multi-decimal leading-zero IDs (001.10-PLAN.md) and * bare-bold `**Plans:**` (no trailing text) are explicitly covered. */ const { test, describe, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); // --------------------------------------------------------------------------- // Shared test helpers // --------------------------------------------------------------------------- function makePlanProject(files = {}) { const dir = createTempProject(); fs.mkdirSync(path.join(dir, '.planning', 'phases'), { recursive: true }); for (const [rel, content] of Object.entries(files)) { const abs = path.join(dir, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, content, 'utf-8'); } return dir; } /** Build a minimal PLAN.md frontmatter string */ function makePlan({ phase, plan, wave, dependsOn = [] }) { return [ '---', `phase: "${phase}"`, `plan: "${plan}"`, 'type: standard', `wave: ${wave}`, `depends_on: [${dependsOn.map(d => `"${d}"`).join(', ')}]`, 'files_modified: []', 'autonomous: true', 'must_haves:', ' truths: []', ' artifacts: []', ' key_links: []', '---', '', `Plan ${plan}`, '', ].join('\n'); } // --------------------------------------------------------------------------- // Bug 1 — Plans-block detection: inline summary text after the colon // --------------------------------------------------------------------------- describe('bug #3691 — Bug 1: Plans-block detection with inline summary', () => { let tmpDir; afterEach(() => cleanup(tmpDir)); test('Plans: N plans (inline count after colon) is detected as a Plans-block', (_t) => { // Pre-fix: `Plans:\s*\n` requires bare newline — fails for "Plans: 2 plans\n" // Post-fix: `Plans:[^\n]*\n` accepts any text after the colon const roadmap = [ '# Roadmap', '', '### Phase 1: Foundation', '', '**Goal:** Set up project', '', 'Plans: 2 plans', '- [ ] 01-01-PLAN.md — Task A', '- [ ] 01-02-PLAN.md — Task B', '', ].join('\n'); tmpDir = makePlanProject({ '.planning/ROADMAP.md': roadmap, '.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }), '.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2, dependsOn: ['01-01'] }), }); const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.updated, true, 'Plans-block with inline summary must be detected and written'); assert.ok(out.waves >= 1, 'at least one wave must be written'); const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(written.includes('Wave'), 'wave annotation must appear in ROADMAP.md'); }); test('Plans: N plans across N waves (longer inline text) is detected', (_t) => { const roadmap = [ '# Roadmap', '', '### Phase 1: Foundation', '', 'Plans: 3 plans across 2 waves', '- [ ] 01-01-PLAN.md — Task A', '- [ ] 01-02-PLAN.md — Task B', '- [ ] 01-03-PLAN.md — Task C', '', ].join('\n'); tmpDir = makePlanProject({ '.planning/ROADMAP.md': roadmap, '.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }), '.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 1 }), '.planning/phases/01-foundation/01-03-PLAN.md': makePlan({ phase: '1', plan: '01-03', wave: 2, dependsOn: ['01-01'] }), }); const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.updated, true, 'Plans-block with "N plans across N waves" inline text must be detected'); }); test('**Plans:** (bold markdown wrapper) is detected as a Plans-block', (_t) => { // Bold wrapper: `**Plans:** 3 plans across 2 waves` const roadmap = [ '# Roadmap', '', '### Phase 1: Foundation', '', '**Plans:** 2 plans', '- [ ] 01-01-PLAN.md — Task A', '- [ ] 01-02-PLAN.md — Task B', '', ].join('\n'); tmpDir = makePlanProject({ '.planning/ROADMAP.md': roadmap, '.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }), '.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2 }), }); const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.updated, true, '**Plans:** bold-wrapped header must be detected as a Plans-block'); }); test('bare Plans: (no inline text, legacy format) still works after fix', (_t) => { // Regression guard: the fix must not break the working case const roadmap = [ '# Roadmap', '', '### Phase 1: Foundation', '', 'Plans:', '- [ ] 01-01-PLAN.md — Task A', '- [ ] 01-02-PLAN.md — Task B', '', ].join('\n'); tmpDir = makePlanProject({ '.planning/ROADMAP.md': roadmap, '.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }), '.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2 }), }); const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.updated, true, 'bare Plans: (legacy format) must still be detected after the fix'); }); }); // --------------------------------------------------------------------------- // Bug 3 — Plan-ID extraction: decimal phase IDs like 02.3-01 // --------------------------------------------------------------------------- describe('bug #3691 — Bug 3: decimal plan IDs (e.g. 02.3-01-PLAN.md) parse correctly', () => { let tmpDir; afterEach(() => cleanup(tmpDir)); test('decimal plan ID 02.3-01 is captured fully and matched to the correct wave', (_t) => { // Pre-fix: `[\w-]+?` stops at `.` → captures `02` only → planData.find misses → wave = 1 for all // Post-fix: `[\w.-]+?` captures `02.3-01` → planData.find resolves → correct wave written const roadmap = [ '# Roadmap', '', '### Phase 02.3: Surgical edit ops', '', 'Plans: 2 plans across 2 waves', '- [ ] 02.3-01-PLAN.md — Path resolver', '- [ ] 02.3-02-PLAN.md — Op handlers', '', ].join('\n'); tmpDir = makePlanProject({ '.planning/ROADMAP.md': roadmap, '.planning/phases/02.3-surgical-edit-ops/02.3-01-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-01', wave: 1 }), '.planning/phases/02.3-surgical-edit-ops/02.3-02-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-02', wave: 2, dependsOn: ['02.3-01'] }), }); const result = runGsdTools('roadmap annotate-dependencies 02.3', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.updated, true, 'decimal-phase ROADMAP with inline Plans: summary must be annotated'); assert.strictEqual(out.waves, 2, 'two distinct waves must be identified (02.3-01→wave 1, 02.3-02→wave 2)'); const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(/Wave 1/.test(written), 'Wave 1 header must appear in output'); assert.ok(/Wave 2/.test(written), 'Wave 2 header must appear in output'); }); test('combined fixture: decimal phase + bold Plans: header (both bugs together)', (_t) => { // Exercises Bug 1 (bold **Plans:** header) AND Bug 3 (decimal IDs) simultaneously. // This is the exact ROADMAP fragment from the issue report. const roadmap = [ '# Roadmap', '', '## Milestone v1.2', '', '### Phase 02.3: Surgical edit ops', '', '**Plans:** 3 plans across 2 waves', '- [ ] 02.3-01-PLAN.md — Path resolver', '- [ ] 02.3-02-PLAN.md — Op handlers', '- [ ] 02.3-03-PLAN.md — Tests', '', ].join('\n'); tmpDir = makePlanProject({ '.planning/ROADMAP.md': roadmap, '.planning/phases/02.3-surgical-edit-ops/02.3-01-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-01', wave: 1 }), '.planning/phases/02.3-surgical-edit-ops/02.3-02-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-02', wave: 1 }), '.planning/phases/02.3-surgical-edit-ops/02.3-03-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-03', wave: 2, dependsOn: ['02.3-01', '02.3-02'] }), }); const result = runGsdTools('roadmap annotate-dependencies 02.3', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.updated, true, 'combined fixture (bold Plans: + decimal IDs) must produce updated: true'); assert.strictEqual(out.waves, 2, 'wave 2 dependency must be detected from decimal plan IDs'); }); }); // --------------------------------------------------------------------------- // F3 review fix — Leading-dot ID validation guard (defensive, malformed ROADMAP) // --------------------------------------------------------------------------- describe('review fix F3 — leading-dot plan ID is rejected (defensive guard)', () => { let tmpDir; afterEach(() => cleanup(tmpDir)); test('checklist line with leading-dot plan ID is skipped and does not silently default to wave 1', (_t) => { // Guards: `.invalid-PLAN.md` would be captured as `.invalid` by the `[\w.-]+?` regex // (since `.` is now included), which starts with a dot — an invalid ID. // Without the guard, planData.find() misses it and wave defaults to 1, silently // polluting the output. With the guard, the line is skipped entirely. // We verify this by having TWO real plans with known waves (1 and 2), plus one // malformed line. The malformed line must not appear in the wave-annotated output. const roadmap = [ '# Roadmap', '', '### Phase 1: Foundation', '', 'Plans: 3 items', '- [ ] 01-01-PLAN.md — Task A', '- [ ] .invalid-PLAN.md — Corrupted entry', '- [ ] 01-02-PLAN.md — Task B', '', ].join('\n'); tmpDir = makePlanProject({ '.planning/ROADMAP.md': roadmap, '.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }), '.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2, dependsOn: ['01-01'] }), }); const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); // Two valid plans resolve to 2 waves — annotation must still proceed assert.strictEqual(out.updated, true, 'annotation must proceed despite malformed line'); assert.strictEqual(out.waves, 2, 'two distinct waves from the two valid plans'); const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); // The malformed line should not appear in the written output (it was skipped) assert.ok(!written.includes('.invalid-PLAN.md'), 'malformed leading-dot entry must be dropped from the annotated output'); }); }); // --------------------------------------------------------------------------- // F4 review additions — adversarial test gaps // --------------------------------------------------------------------------- describe('review fix F4 — adversarial test gaps', () => { let tmpDir; afterEach(() => cleanup(tmpDir)); test('001.10-PLAN.md multi-decimal leading-zero ID is captured fully and wave-assigned correctly', (_t) => { // Guards regression of Bug 3: `[\w-]+?` would stop at the first `.` and // capture `001` instead of `001.10`, which never matches any planData entry. // Post-fix `[\w.-]+?` must capture `001.10` in full. const roadmap = [ '# Roadmap', '', '### Phase 001.10: Extended decimal phase', '', 'Plans: 2 plans across 2 waves', '- [ ] 001.10-01-PLAN.md — First task', '- [ ] 001.10-02-PLAN.md — Second task', '', ].join('\n'); tmpDir = makePlanProject({ '.planning/ROADMAP.md': roadmap, '.planning/phases/001.10-extended/001.10-01-PLAN.md': makePlan({ phase: '001.10', plan: '001.10-01', wave: 1 }), '.planning/phases/001.10-extended/001.10-02-PLAN.md': makePlan({ phase: '001.10', plan: '001.10-02', wave: 2, dependsOn: ['001.10-01'] }), }); const result = runGsdTools('roadmap annotate-dependencies 001.10', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.updated, true, 'multi-decimal leading-zero plan ID must be captured fully and annotated'); assert.strictEqual(out.waves, 2, 'wave 2 dependency must be resolved from full 001.10-02 ID (not truncated to 001)'); }); test('**Plans:** (bold, no trailing text) is matched and checklist is processed', (_t) => { // Guards the bare-bold variant: `**Plans:**` with nothing after the colon. // The `[^\n]*` quantifier accepts zero chars so this should already work, // but this test would fail if `\*{0,2}Plans\*{0,2}` regressed to require no stars. const roadmap = [ '# Roadmap', '', '### Phase 1: Foundation', '', '**Plans:**', '- [ ] 01-01-PLAN.md — Task A', '- [ ] 01-02-PLAN.md — Task B', '', ].join('\n'); tmpDir = makePlanProject({ '.planning/ROADMAP.md': roadmap, '.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }), '.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2, dependsOn: ['01-01'] }), }); const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.updated, true, '**Plans:** bare-bold variant (no trailing text) must be detected and annotated'); assert.strictEqual(out.waves, 2, 'wave assignment must resolve correctly from planData for bare-bold variant'); }); }); // --------------------------------------------------------------------------- // Bug #1103 — leading newline dropped in replace, fusing adjacent lines // // On the SUCCESSFUL mutation path, the block matcher anchors with `(?:^|\n)`. // On a mid-string match (a `**Plans:** N plans` bold summary line directly above // a bare `Plans:` block) `plansBlockMatch[0]` begins with the consumed `\n`. The // replacement did not re-emit it, fusing the summary line onto the header → // `**Plans:** 3 plansPlans:`. Distinct from #3691's silent-no-op detection bugs; // #3691's Bug 1 fix is what let this two-line layout be matched and exposed it. // --------------------------------------------------------------------------- describe('bug #1103 — annotate-dependencies preserves newline before Plans: header', () => { let tmpDir; afterEach(() => cleanup(tmpDir)); test('bold **Plans:** summary line followed by bare Plans: header are not fused', (_t) => { const roadmap = [ '# Roadmap', '', '### Phase 1: Foundation', '', '**Goal:** Set up project', '', '**Plans:** 3 plans', 'Plans:', '- [ ] 01-01-PLAN.md — Task A', '- [ ] 01-02-PLAN.md — Task B', '- [ ] 01-03-PLAN.md — Task C', '', ].join('\n'); tmpDir = makePlanProject({ '.planning/ROADMAP.md': roadmap, '.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }), '.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 1 }), '.planning/phases/01-foundation/01-03-PLAN.md': makePlan({ phase: '1', plan: '01-03', wave: 2, dependsOn: ['01-01'] }), }); const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.updated, true, 'annotation must succeed and write back'); const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); // Core assertion: the malformed fusion string must NOT appear. assert.ok( !written.includes('plansPlans:'), `ROADMAP must not contain the fused string "plansPlans:"; got:\n${written}` ); // The bold summary line must keep its line boundary before Plans:. assert.ok( written.includes('**Plans:** 3 plans\nPlans:'), `**Plans:** 3 plans must be followed by a newline before Plans:; got:\n${written}` ); assert.ok(written.includes('Wave'), 'wave annotation must appear in ROADMAP.md'); }); test('inline Plans: header (no bold summary prefix) still annotates after fix', (_t) => { const roadmap = [ '# Roadmap', '', '### Phase 1: Foundation', '', 'Plans: 2 plans', '- [ ] 01-01-PLAN.md — Task A', '- [ ] 01-02-PLAN.md — Task B', '', ].join('\n'); tmpDir = makePlanProject({ '.planning/ROADMAP.md': roadmap, '.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }), '.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2, dependsOn: ['01-01'] }), }); const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.updated, true, 'inline Plans: header must still be annotated after fix'); }); test('mid-string Plans: header keeps exact heading→header spacing (no extra newline)', (_t) => { // `phaseSection` always begins with the `### Phase` heading, so the `Plans:` // match is always mid-string and `(?:^|\n)` matches a `\n` (the start-of-string // `^` branch is unreachable through the handler). This guards the inverse of the // bug: the re-emitted newline must restore EXACTLY the one `\n` the regex // consumed — not a doubled blank line and not a fusion. const roadmap = [ '### Phase 1: Foundation', '', 'Plans:', '- [ ] 01-01-PLAN.md — Task A', '- [ ] 01-02-PLAN.md — Task B', '', ].join('\n'); tmpDir = makePlanProject({ '.planning/ROADMAP.md': roadmap, '.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }), '.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2, dependsOn: ['01-01'] }), }); const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.updated, true, 'Plans: must still be detected and annotated'); const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok( written.includes('### Phase 1: Foundation\n\nPlans:'), `heading→Plans spacing must be preserved exactly; got:\n${written}` ); assert.ok(!written.includes('FoundationPlans:'), 'heading must not fuse onto Plans:'); assert.ok(!written.includes('Foundation\n\n\nPlans:'), 'no doubled blank line before Plans:'); }); }); }); } // ───────────────────────────────────────────────────────────────────────────── // bug #2978: roadmap validate returns {"warnings":[]} for every input // ───────────────────────────────────────────────────────────────────────────── describe('bug #2978: roadmap validate performs structural validation', () => { test('empty (zero-byte) ROADMAP.md → non-empty warnings, non-zero exit', () => { const tmpDir = createTempProject('gsd-2978-empty-'); try { fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), ''); const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); assert.strictEqual(result.success, false, 'empty file must exit non-zero'); const payload = JSON.parse(result.output); assert.ok(Array.isArray(payload.warnings) && payload.warnings.length > 0, `empty file must produce non-empty warnings; got: ${JSON.stringify(payload)}`); } finally { cleanup(tmpDir); } }); test('garbage/non-roadmap text → non-empty warnings, non-zero exit', () => { const tmpDir = createTempProject('gsd-2978-garbage-'); try { fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'not a roadmap at all'); const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); assert.strictEqual(result.success, false, 'garbage must exit non-zero'); const payload = JSON.parse(result.output); assert.ok(payload.warnings.length > 0, 'garbage must produce warnings'); } finally { cleanup(tmpDir); } }); test('missing ROADMAP.md → non-empty warnings, non-zero exit', () => { const tmpDir = createTempProject('gsd-2978-missing-'); try { // createTempProject creates .planning/phases but no ROADMAP.md — don't write one const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); assert.strictEqual(result.success, false, 'missing file must exit non-zero'); const payload = JSON.parse(result.output); assert.ok(payload.warnings.length > 0, 'missing file must produce warnings'); } finally { cleanup(tmpDir); } }); test('truncated frontmatter (unterminated ---) → non-empty warnings', () => { const tmpDir = createTempProject('gsd-2978-trunc-'); try { fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '---\nmilestone: v1.0\n### Phase 1: Setup\n'); const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); assert.strictEqual(result.success, false, 'truncated frontmatter must exit non-zero'); const payload = JSON.parse(result.output); assert.ok(payload.warnings.length > 0, 'truncated frontmatter must produce warnings'); } finally { cleanup(tmpDir); } }); test('well-formed roadmap → warnings: [], exit 0 (no false positive)', () => { const tmpDir = createTempProject('gsd-2978-good-'); try { fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n## v1.0\n\n### Phase 1: Foundation\n**Goal:** setup\n'); const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); assert.ok(result.success, `well-formed roadmap must exit 0; got: ${result.error}`); const payload = JSON.parse(result.output); assert.deepStrictEqual(payload.warnings, [], 'well-formed roadmap must have no warnings'); } finally { cleanup(tmpDir); } }); test('BOM-prefixed well-formed roadmap → warnings: [], exit 0 (not corruption)', () => { const tmpDir = createTempProject('gsd-2978-bom-'); try { fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '\uFEFF# Roadmap\n\n### Phase 1: Foundation\n**Goal:** setup\n'); const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); assert.ok(result.success, `BOM-prefixed roadmap must exit 0; got: ${result.error}`); const payload = JSON.parse(result.output); assert.deepStrictEqual(payload.warnings, [], 'BOM is not corruption'); } finally { cleanup(tmpDir); } }); }); // ───────────────────────────────────────────────────────────────────────────── // bug #3263: roadmap validate is silent when the active milestone window is // truncated (phase entries exist in the document but are excluded from the // resolved window). The scope discriminator (#3184) already classifies this // as SCOPE.TRUNCATED; validate must surface it as V005 + non-zero exit. // Matrix: .gsd/bug/fix-3263-milestone-window-truncation-retest/50-test-matrix.md // ───────────────────────────────────────────────────────────────────────────── describe('bug #3263: roadmap validate warns on a truncated milestone window', () => { function writeFixture(tmpDir, roadmapContent, stateFields) { fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmapContent); if (stateFields) { const lines = ['---']; for (const [k, v] of Object.entries(stateFields)) lines.push(`${k}: ${v}`); lines.push('---', ''); fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), lines.join('\n')); } } test('window closed before its phase entries → V005 warning, non-zero exit', () => { const tmpDir = createTempProject('gsd-3263-truncated-'); try { // v3.0's window closes at the intervening `## v4.0 Next` heading, // before the phase entries — which exist in the document, under v4.0. writeFixture(tmpDir, [ '# Roadmap', '', '## v3.0 In Progress 🚧', '', 'Some preamble notes. No phase headings here.', '', '## v4.0 Next', '', '### Phase 1: Foo', '', '### Phase 2: Bar', ].join('\n'), { milestone: 'v3.0' }); const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); assert.strictEqual(result.success, false, 'truncated window must exit non-zero'); const payload = JSON.parse(result.output); const v005 = payload.warnings.find((w) => w.code === 'V005'); assert.ok(v005, `truncated window must produce a V005 warning; got: ${JSON.stringify(payload)}`); assert.ok(v005.message.length > 0, 'V005 carries a message'); } finally { cleanup(tmpDir); } }); test('phases inside the window → no V005, exit 0 (no false positive)', () => { const tmpDir = createTempProject('gsd-3263-normal-'); try { writeFixture(tmpDir, [ '# Roadmap', '', '## v3.0 In Progress 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar', '', '## v4.0 Next', '', 'Later plans.', ].join('\n'), { milestone: 'v3.0' }); const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); assert.ok(result.success, `normal roadmap must exit 0; got: ${result.error}`); const payload = JSON.parse(result.output); assert.deepStrictEqual(payload.warnings, [], 'normal roadmap must have no warnings'); } finally { cleanup(tmpDir); } }); test('genuinely phase-less milestone → V004 only, no V005', () => { const tmpDir = createTempProject('gsd-3263-empty-'); try { writeFixture(tmpDir, [ '# Roadmap', '', '## v1.0 Current 🚧', '', 'Nothing planned yet.', ].join('\n'), { milestone: 'v1.0' }); const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); assert.strictEqual(result.success, false, 'phase-less roadmap must exit non-zero'); const payload = JSON.parse(result.output); assert.ok(payload.warnings.some((w) => w.code === 'V004'), `V004 still owns the no-phase-entries case; got: ${JSON.stringify(payload)}`); assert.ok(!payload.warnings.some((w) => w.code === 'V005'), `a genuinely phase-less milestone must not produce V005; got: ${JSON.stringify(payload)}`); } finally { cleanup(tmpDir); } }); test('unscoped versioned roadmap (no STATE.md milestone) → no V005 over-fire', () => { const tmpDir = createTempProject('gsd-3263-unscoped-'); try { // No STATE.md: scope is UNSCOPED, not TRUNCATED — must not fire V005. writeFixture(tmpDir, [ '# Roadmap', '', '## v1.0 Old ✅ SHIPPED', '', '### Phase 1: Foo', ].join('\n')); const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); assert.ok(result.success, `unscoped roadmap must exit 0; got: ${result.error}`); const payload = JSON.parse(result.output); assert.deepStrictEqual(payload.warnings, [], 'unscoped roadmap must have no warnings'); } finally { cleanup(tmpDir); } }); }); // ───────────────────────────────────────────────────────────────────────────── // #3641: hasPhaseEntries is convention-blind — V005 (and V004) cannot see // bracket-convention phase entries (`### [GSD.04] 01: Name`), so a genuinely // truncated bracket window classifies COMPLETE and validate stays silent // (while V004 falsely reports "no recognizable phase entries"). The fix // threads the resolved `phase_id_convention` into hasPhaseEntries and routes // V004 through the same owner. Mirrors the #3263 harness directly above. // Matrix: .gsd/bug/fix-3641-hasphaseentries-convention-v005/50-test-matrix.md // ───────────────────────────────────────────────────────────────────────────── describe('bug #3641: bracket-convention windows are visible to validate (V005/V004)', () => { const ROADMAP_PARSER_LIB = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'roadmap-parser.cjs'); function writeFixture3641(tmpDir, roadmapContent, stateFields, conventionSource) { fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmapContent); if (stateFields) { const lines = ['---']; for (const [k, v] of Object.entries(stateFields)) lines.push(`${k}: ${v}`); lines.push('---', ''); fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), lines.join('\n')); } if (conventionSource && conventionSource.kind === 'config') { fs.writeFileSync( path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ phase_id_convention: 'bracket' }, null, 2), ); } // kind === 'frontmatter': the roadmap content itself carries the frontmatter. } const BRACKET_TRUNCATED_ROADMAP = [ '# Roadmap', '', '## v3.0 In Progress 🚧', '', 'Some preamble notes. No phase headings here.', '', '## v4.0 Next', '', '### [GSD.04] 01: Foo', '', '### [GSD.04] 02: Bar', ].join('\n'); test('bracket truncated window (config convention) → V005 fires, no false V004', (t) => { const tmpDir = createTempProject('gsd-3641-bracket-truncated-'); t.after(() => cleanup(tmpDir)); writeFixture3641(tmpDir, BRACKET_TRUNCATED_ROADMAP, { milestone: 'v3.0' }, { kind: 'config' }); const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); assert.strictEqual(result.success, false, 'truncated bracket window must exit non-zero'); const payload = JSON.parse(result.output); assert.ok(payload.warnings.some((w) => w.code === 'V005'), `truncated bracket window must produce V005; got: ${JSON.stringify(payload)}`); assert.ok(!payload.warnings.some((w) => w.code === 'V004'), `the document HAS bracket phase entries — V004 must not fire; got: ${JSON.stringify(payload)}`); }); test('bracket complete window → no warnings, exit 0', (t) => { const tmpDir = createTempProject('gsd-3641-bracket-complete-'); t.after(() => cleanup(tmpDir)); writeFixture3641(tmpDir, [ '# Roadmap', '', '## v3.0 In Progress 🚧', '', '### [GSD.03] 01: Foo', '', '### [GSD.03] 02: Bar', '', '## v4.0 Next', '', 'Later plans.', ].join('\n'), { milestone: 'v3.0' }, { kind: 'config' }); const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); assert.ok(result.success, `complete bracket window must exit 0; got: ${result.error}`); const payload = JSON.parse(result.output); assert.deepStrictEqual(payload.warnings, [], `complete bracket window must have no warnings; got: ${JSON.stringify(payload)}`); }); test('bracket truncated window (frontmatter convention) → V005 fires', (t) => { const tmpDir = createTempProject('gsd-3641-bracket-fm-'); t.after(() => cleanup(tmpDir)); const withFrontmatter = [ '---', 'phase_id_convention: bracket', '---', '', BRACKET_TRUNCATED_ROADMAP, ].join('\n'); writeFixture3641(tmpDir, withFrontmatter, { milestone: 'v3.0' }, { kind: 'frontmatter' }); const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); assert.strictEqual(result.success, false, 'frontmatter-convention truncated window must exit non-zero'); const payload = JSON.parse(result.output); assert.ok(payload.warnings.some((w) => w.code === 'V005'), `frontmatter convention must resolve for V005; got: ${JSON.stringify(payload)}`); }); test('legacy truncated window without convention → V005 still fires (parity)', (t) => { const tmpDir = createTempProject('gsd-3641-legacy-parity-'); t.after(() => cleanup(tmpDir)); writeFixture3641(tmpDir, [ '# Roadmap', '', '## v3.0 In Progress 🚧', '', 'Some preamble notes.', '', '## v4.0 Next', '', '### Phase 1: Foo', '', '### Phase 2: Bar', ].join('\n'), { milestone: 'v3.0' }, null); const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); assert.strictEqual(result.success, false, 'legacy truncated window must still exit non-zero'); const payload = JSON.parse(result.output); assert.ok(payload.warnings.some((w) => w.code === 'V005'), `legacy V005 behavior must be unchanged; got: ${JSON.stringify(payload)}`); }); test('bracket-spelling roadmap without convention → not widened (V004 only)', (t) => { const tmpDir = createTempProject('gsd-3641-no-convention-'); t.after(() => cleanup(tmpDir)); writeFixture3641(tmpDir, BRACKET_TRUNCATED_ROADMAP, { milestone: 'v3.0' }, null); const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); assert.strictEqual(result.success, false, 'unrecognized entries must still exit non-zero'); const payload = JSON.parse(result.output); assert.ok(payload.warnings.some((w) => w.code === 'V004'), `a project that never opted in keeps the legacy reading (V004); got: ${JSON.stringify(payload)}`); assert.ok(!payload.warnings.some((w) => w.code === 'V005'), `no convention declared — the widened grammar must not engage; got: ${JSON.stringify(payload)}`); }); test('bracket phase-less roadmap with bracket milestone name headings → V004 only, no V005', (t) => { const tmpDir = createTempProject('gsd-3641-bracket-phaseless-'); t.after(() => cleanup(tmpDir)); writeFixture3641(tmpDir, [ '# Roadmap', '', '## [GSD.02] Foundation', '', 'Nothing planned yet. A bracket MILESTONE heading is a bracket plus a', 'name — not a phase entry (no digit-then-colon tail).', ].join('\n'), { milestone: 'v3.0' }, { kind: 'config' }); const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); assert.strictEqual(result.success, false, 'genuinely phase-less bracket roadmap must exit non-zero'); const payload = JSON.parse(result.output); assert.ok(payload.warnings.some((w) => w.code === 'V004'), `V004 owns the phase-less case under bracket too; got: ${JSON.stringify(payload)}`); assert.ok(!payload.warnings.some((w) => w.code === 'V005'), `a phase-less window must never produce V005; got: ${JSON.stringify(payload)}`); }); test('fenced bracket heading example does not count as a phase entry', (t) => { const tmpDir = createTempProject('gsd-3641-bracket-fence-'); t.after(() => cleanup(tmpDir)); writeFixture3641(tmpDir, [ '# Roadmap', '', '## v3.0 In Progress 🚧', '', 'Documentation example of the syntax:', '', '```markdown', '### [GSD.03] 01: Documented example', '```', '', 'No real phase headings anywhere.', '', '## v4.0 Next', '', 'Later plans.', ].join('\n'), { milestone: 'v3.0' }, { kind: 'config' }); const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); assert.strictEqual(result.success, false, 'fenced-example-only roadmap must exit non-zero'); const payload = JSON.parse(result.output); assert.ok(payload.warnings.some((w) => w.code === 'V004'), `a fenced example is not a real entry; got: ${JSON.stringify(payload)}`); assert.ok(!payload.warnings.some((w) => w.code === 'V005'), `fenced examples must not flip the scope axis; got: ${JSON.stringify(payload)}`); }); test('bracket+label mixed spelling still recognized under bracket convention', (t) => { const tmpDir = createTempProject('gsd-3641-bracket-mixed-'); t.after(() => cleanup(tmpDir)); writeFixture3641(tmpDir, [ '# Roadmap', '', '## v3.0 In Progress 🚧', '', '### [GSD.03] Phase 01: Foo', '', '## v4.0 Next', '', 'Later plans.', ].join('\n'), { milestone: 'v3.0' }, { kind: 'config' }); const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); assert.ok(result.success, `mixed spelling is already-recognized — must exit 0; got: ${result.error}`); const payload = JSON.parse(result.output); assert.deepStrictEqual(payload.warnings, [], `mixed spelling must stay recognized; got: ${JSON.stringify(payload)}`); }); test('colon-bearing bracket milestone heading in-window does not read as a phase entry (#3641 review HIGH)', (t) => { const tmpDir = createTempProject('gsd-3641-bracket-colon-milestone-'); t.after(() => cleanup(tmpDir)); writeFixture3641(tmpDir, [ '# Roadmap', '', '## [GSD.03] v3.0: Current', '', 'No phases under the active milestone — and its own heading carries a', 'colon, which must NOT make the window read as having phase entries.', '', '## [GSD.04] v4.0: Next', '', '### [GSD.04] 01: Foo', ].join('\n'), { milestone: 'v3.0' }, { kind: 'config' }); const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); assert.strictEqual(result.success, false, 'truncated bracket window (colon-bearing milestone heading) must exit non-zero'); const payload = JSON.parse(result.output); assert.ok(payload.warnings.some((w) => w.code === 'V005'), `ADR-612 discriminator: a bracket + NAME(:version) heading is a milestone, not an entry — the window is still truncated; got: ${JSON.stringify(payload)}`); assert.ok(!payload.warnings.some((w) => w.code === 'V004'), `real bracket entries exist; got: ${JSON.stringify(payload)}`); }); test('decoy bracket notes heading outside the window does not manufacture V005 (#3641 review MEDIUM)', (t) => { const tmpDir = createTempProject('gsd-3641-bracket-decoy-'); t.after(() => cleanup(tmpDir)); writeFixture3641(tmpDir, [ '# Roadmap', '', '## v3.0 Current 🚧', '', 'Nothing planned yet — a genuinely phase-less milestone.', '', '## v4.0 Next', '', '### [GSD.04] Notes: follow-ups', '', 'Prose under a later milestone, not a phase entry.', ].join('\n'), { milestone: 'v3.0' }, { kind: 'config' }); const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); assert.strictEqual(result.success, false, 'phase-less bracket roadmap must exit non-zero'); const payload = JSON.parse(result.output); assert.ok(payload.warnings.some((w) => w.code === 'V004'), `no real entries — V004 owns the verdict; got: ${JSON.stringify(payload)}`); assert.ok(!payload.warnings.some((w) => w.code === 'V005'), `a decoy colon heading must never flip the scope axis to TRUNCATED; got: ${JSON.stringify(payload)}`); }); test('milestone-scope probe agrees with validate on a truncated bracket window (#3641 review MEDIUM)', (t) => { const tmpDir = createTempProject('gsd-3641-bracket-probe-'); t.after(() => cleanup(tmpDir)); writeFixture3641(tmpDir, BRACKET_TRUNCATED_ROADMAP, { milestone: 'v3.0' }, { kind: 'config' }); const result = runGsdTools(['roadmap', 'milestone-scope', '--raw'], tmpDir); assert.ok(result.success, `probe must exit 0; got: ${result.error}`); const payload = JSON.parse(result.output); assert.strictEqual(payload.scope, 'truncated', `the #3262 capture/compare signal must agree with validate's V005 classifier; got: ${JSON.stringify(payload)}`); }); test('colon-less legacy phase heading alone still yields V004 (owner-entry definition pin)', (t) => { const tmpDir = createTempProject('gsd-3641-colonless-'); t.after(() => cleanup(tmpDir)); writeFixture3641(tmpDir, [ '# Roadmap', '', '## v3.0 Current 🚧', '', '### Phase 1 — Foo', ].join('\n'), { milestone: 'v3.0' }, null); const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); assert.strictEqual(result.success, false, 'colon-less-only roadmap must exit non-zero'); const payload = JSON.parse(result.output); assert.ok(payload.warnings.some((w) => w.code === 'V004'), `the canonical entry grammar is colon-terminated — a colon-less heading is not an entry for ANY reader; got: ${JSON.stringify(payload)}`); }); test('hasPhaseEntries gates the widened grammar on the bracket convention (unit seam)', () => { // Direct rows on the exported predicate: the widened heading grammar // engages ONLY when the resolved convention is 'bracket'. const roadmapParser = require(ROADMAP_PARSER_LIB); assert.strictEqual(typeof roadmapParser.hasPhaseEntries, 'function', 'hasPhaseEntries must be exported for the validate seam (#3641)'); const bracketHeading = '### [GSD.04] 01: Foo'; const legacyHeading = '### Phase 01: Foo'; assert.strictEqual(roadmapParser.hasPhaseEntries(bracketHeading, 'bracket'), true, "bracket convention: '[GSD.04] 01:' is a phase entry"); assert.strictEqual(roadmapParser.hasPhaseEntries(bracketHeading, null), false, 'no convention: the bracket+bare-number spelling is not an entry'); assert.strictEqual(roadmapParser.hasPhaseEntries(legacyHeading, 'bracket'), true, 'bracket convention is a superset — the legacy label stays recognized'); }); }); // ───────────────────────────────────────────────────────────────────────────── // #3885 (ADR-3473 §8.5) / item 5 — `countPhasePlansAndSummaries` swallows an // unreadable phase directory into "absent", indistinguishable from a phase // that genuinely has no CONTEXT.md. // // Mechanism (src/roadmap.cts, countPhasePlansAndSummaries): // try { phaseFiles = fs.readdirSync(phaseDir); } catch { /* empty */ } // An EACCES/EIO collapses `phaseFiles` to `[]`, which makes // `findContextMdIn(scopedFiles)` return null — identical to a phase dir that // was successfully read and genuinely has no CONTEXT.md. `cmdRoadmapAnalyze` // (the only exported consumer) surfaces this as `has_context: false` on the // phase's entry in `phases[]`, with nothing distinguishing "could not read" // from "nothing there". // // DESIGN DECISION (chosen by this test file, not yet implemented): each // `AnalyzePhase` gains a `context_read_error: string | null` field — null on // success (including a genuinely missing/ENOENT directory), and a message // string naming the phase directory when the readdirSync call fails with any // non-ENOENT error (EACCES, EIO, ...). Mirrors the SCOPE.UNREADABLE // discriminator `src/core-utils.cts`'s `getPhaseFileStats` already uses to // keep "unreadable" separate from "absent" on the sibling phase-stats path. // // `countPhasePlansAndSummaries` itself is not exported from roadmap.cjs, so // these tests drive the ONLY exported consumer, `cmdRoadmapAnalyze`, in // process — injecting the fs failure by monkeypatching `fs.readdirSync` // (restored in `finally`) and capturing `output()`'s raw fd-1 write by // monkeypatching `fs.writeSync` (io.cjs writes via `fs.writeSync(1, ...)`, // bypassing console.log, so `captureConsole()` cannot see it). NEVER // `chmod 0o000` — root bypasses mode bits, so that trick passes with zero // coverage in root Docker/CI. describe('#3885 (ADR-3473 §8.5): countPhasePlansAndSummaries distinguishes unreadable from absent (roadmap.cts caller)', () => { let tmpDir; let roadmapLib; beforeEach(() => { tmpDir = createTempProject(); roadmapLib = require('../gsd-core/bin/lib/roadmap.cjs'); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n### Phase 3: API\n**Goal:** Build API\n', ); }); afterEach(() => { cleanup(tmpDir); }); // Runs `roadmapLib.cmdRoadmapAnalyze(cwd, false)` while capturing the raw // fd-1 bytes `output()` writes via `fs.writeSync`, and returns the parsed // JSON result. Restores `fs.writeSync` in `finally` even if analyze throws. function runAnalyzeCapturingStdout(cwd) { const chunks = []; const origWriteSync = fs.writeSync; fs.writeSync = function patchedWriteSync(fd, buffer, offset, length) { if (fd !== 1) return origWriteSync.apply(fs, arguments); const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer); const start = offset ?? 0; const len = length ?? (buf.length - start); chunks.push(Buffer.from(buf.subarray(start, start + len))); return len; }; try { roadmapLib.cmdRoadmapAnalyze(cwd, false); } finally { fs.writeSync = origWriteSync; } return JSON.parse(Buffer.concat(chunks).toString('utf8')); } // Monkeypatches `fs.readdirSync` so a call whose FIRST argument resolves to // `targetPath` throws an error shaped like `code`; every other path is // delegated to the real implementation. Returns a restorer — callers MUST // invoke it in `finally`. function injectReaddirFailure(targetPath, code) { const resolved = path.resolve(targetPath); const origReaddirSync = fs.readdirSync; fs.readdirSync = function patchedReaddirSync(p, ...rest) { if (path.resolve(String(p)) === resolved) { const err = new Error(`${code}: simulated failure, scandir '${p}'`); err.code = code; throw err; } return origReaddirSync.call(fs, p, ...rest); }; return () => { fs.readdirSync = origReaddirSync; }; } function findPhase3(analyzeOutput) { const phase = analyzeOutput.phases.find((p) => p.number === '3'); assert.ok(phase, `phase 3 must appear in analyze output; got: ${JSON.stringify(analyzeOutput.phases)}`); return phase; } // T61 — MUST STAY GREEN: a readable phase directory with no CONTEXT.md // reports has_context:false, and (using `?? null` so this passes both // before and after the fix) no read-error signal. test('T61: readableDirWithoutContextReportsFalse', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '---\nwave: 1\n---\n## Task 1\n'); const output = runAnalyzeCapturingStdout(tmpDir); const phase = findPhase3(output); assert.strictEqual(phase.has_context, false, 'no CONTEXT.md on disk — has_context must be false'); assert.strictEqual(phase.context_read_error ?? null, null, 'a readable, genuinely context-less dir must report no read error'); }); // T62 — RED today: measured on this tree, an EACCES on the phase // directory's readdirSync collapses to has_context:false / // disk_status:"empty" with nothing distinguishing it from a phase that was // successfully read and genuinely has no CONTEXT.md. Required: the failure // must be reported, naming the phase directory. test('T62: unreadablePhaseDirIsNotReportedAsAbsent', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '---\nwave: 1\n---\n## Task 1\n'); const restore = injectReaddirFailure(phaseDir, 'EACCES'); let output; try { output = runAnalyzeCapturingStdout(tmpDir); } finally { restore(); } const phase = findPhase3(output); assert.strictEqual( typeof (phase.context_read_error ?? null), 'string', `an unreadable phase directory must be reported as an error, not silently absent; got context_read_error=${JSON.stringify(phase.context_read_error)}`, ); assert.ok( (phase.context_read_error || '').includes('03-api'), `the reported error must name the discarded input (the phase directory); got: ${phase.context_read_error}`, ); }); // T64 — MUST STAY GREEN: a genuinely missing directory (ENOENT) is absent, // not an error — this is the row that keeps T62's fix from over-firing on // every ordinary "no directory yet" phase. Injected the same way as T62/T63 // (readdirSync throws ENOENT for the exact phase-dir path) so the assertion // exercises the discriminator itself, not merely "no error was ever // thrown". test('T64: missingDirIsGenuinelyAbsent', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '---\nwave: 1\n---\n## Task 1\n'); const restore = injectReaddirFailure(phaseDir, 'ENOENT'); let output; try { output = runAnalyzeCapturingStdout(tmpDir); } finally { restore(); } const phase = findPhase3(output); assert.strictEqual( phase.context_read_error ?? null, null, `ENOENT must be treated as genuinely absent, not reported as an error; got: ${phase.context_read_error}`, ); }); // ─── #4014 (epic #3473 B4-unreadable) matrix rows 7-9 ─────────────────── // // `countPhasePlansAndSummaries` (not exported — driven through // cmdRoadmapAnalyze, the same style as T61/T62/T64 above) gains a // `context_scope` field on its result, surfaced on `AnalyzePhase` as // `context_scope` — the typed SCOPE-enum sibling of the existing // `context_read_error` string field. test('#4014 matrix row 7: readable phase dir with CONTEXT.md reports has_context:true, context_scope:complete', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, 'CONTEXT.md'), '# context\n'); const output = runAnalyzeCapturingStdout(tmpDir); const phase = findPhase3(output); assert.strictEqual(phase.has_context, true); assert.strictEqual(phase.context_scope, 'complete'); assert.strictEqual(phase.context_read_error ?? null, null); }); test('#4014 matrix row 8: unreadable phase dir reports context_scope:unreadable, distinct from a genuinely empty phase dir', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '---\nwave: 1\n---\n## Task 1\n'); const restore = injectReaddirFailure(phaseDir, 'EACCES'); let output; try { output = runAnalyzeCapturingStdout(tmpDir); } finally { restore(); } const phase = findPhase3(output); assert.strictEqual(phase.has_context, false); assert.strictEqual(phase.context_scope, 'unreadable', `an unreadable phase directory must report context_scope 'unreadable', distinct from a genuinely empty one; got: ${phase.context_scope}`); }); test('#4014 matrix row 9: genuinely absent phase dir reports context_scope:complete (boundary — not unreadable)', () => { // No phase directory created at all — matches T64's ENOENT-shaped absence. const output = runAnalyzeCapturingStdout(tmpDir); const phase = findPhase3(output); assert.strictEqual(phase.has_context, false); assert.strictEqual(phase.context_scope, 'complete', `a genuinely absent phase directory must report context_scope 'complete', not 'unreadable'; got: ${phase.context_scope}`); }); }); // ═══════════════════════════════════════════════════════════════════════════ // #3957 (epic #3473 B9) — a no-op decline reports the real condition. // .gsd/phase/enhance-3957-noop-real-condition/{40-design,50-test-matrix}.md // Rows 11-18 of the test matrix. In-process (not runGsdTools's subprocess): // a subprocess's legacy result shape drops stderr on a clean (exit 0) run // (tests/helpers.cjs toLegacyShape), and a no-op decline is exactly that. // ═══════════════════════════════════════════════════════════════════════════ describe('#3957 (epic #3473 B9): no-op decline reports the real condition', () => { const roadmapLib = require('../gsd-core/bin/lib/roadmap.cjs'); // Mirrors state.test.cjs's captureCliIO — see that file's doc comment. function captureCliIO(fn) { const originalStderrWrite = process.stderr.write.bind(process.stderr); let stderr = ''; process.stderr.write = (chunk) => { stderr += String(chunk); return true; }; let stdout; try { stdout = captureFdSync(1, fn); } finally { process.stderr.write = originalStderrWrite; } return { stdout, stderr }; } describe('cmdRoadmapUpdatePlanProgress', () => { let tmpDir; afterEach(() => { if (tmpDir) cleanup(tmpDir); }); // Row 11 (signature D — false success, B9.4). Run once to produce a real // change, then re-run against the now-up-to-date ROADMAP.md. test('update-plan-progress: idempotent re-run reports updated:false, does not rewrite', () => { tmpDir = createTempProject(); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# Roadmap', '', '- [ ] **Phase 1: Test** - description', '', '### Phase 1: Test', '**Goal:** Test goal', '**Plans:** TBD', '', '## Progress', '', '| Phase | Milestone | Plans Complete | Status | Completed |', '|-------|-----------|----------------|--------|-----------|', '| 1. Test | v1.0 | 0/1 | Planned | - |', '', ].join('\n'), ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan 1'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary 1'); fs.writeFileSync(path.join(p1, '01-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n'); const first = captureCliIO(() => { roadmapLib.cmdRoadmapUpdatePlanProgress(tmpDir, '1', false); }); const firstOut = JSON.parse(first.stdout); assert.strictEqual(firstOut.updated, true, 'setup: first run must be a real change'); const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); const beforeSecond = fs.readFileSync(roadmapPath, 'utf-8'); const beforeMtime = fs.statSync(roadmapPath).mtimeMs; const second = captureCliIO(() => { roadmapLib.cmdRoadmapUpdatePlanProgress(tmpDir, '1', false); }); const secondOut = JSON.parse(second.stdout); assert.strictEqual(secondOut.updated, false, 'idempotent re-run must not report updated:true'); assert.strictEqual( secondOut.reason, "no changes were needed — ROADMAP.md already reflects this phase's plan/summary counts and status", ); assert.strictEqual(fs.readFileSync(roadmapPath, 'utf-8'), beforeSecond, 'ROADMAP.md bytes must not change'); assert.strictEqual(fs.statSync(roadmapPath).mtimeMs, beforeMtime, 'ROADMAP.md must not be rewritten (no write call)'); assert.match( second.stderr, /^\[gsd-tools\] WARNING: roadmap update-plan-progress skipped — no changes were needed;/, ); }); // Row 12 (boundary — must not regress; pre-existing coverage exists // above under "updates progress and checks checkbox on completion"). test('update-plan-progress: real change still reports updated:true', () => { tmpDir = createTempProject(); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# Roadmap', '', '### Phase 1: Test', '**Goal:** Test goal', '**Plans:** TBD', '', '## Progress', '', '| Phase | Milestone | Plans Complete | Status | Completed |', '|-------|-----------|----------------|--------|-----------|', '| 1. Test | v1.0 | 0/2 | Planned | - |', '', ].join('\n'), ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan 1'); fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan 2'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary 1'); const { stdout, stderr } = captureCliIO(() => { roadmapLib.cmdRoadmapUpdatePlanProgress(tmpDir, '1', false); }); const out = JSON.parse(stdout); assert.strictEqual(out.updated, true); assert.strictEqual(stderr, '', 'a real change must not emit a decline disclosure'); const roadmapContent = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmapContent.includes('1/2'), 'roadmap should contain updated plan count'); }); // Row 13 (helper adoption — same reason/computed values, stderr now emitted). test('update-plan-progress: missing roadmap still discloses via stderr', () => { // createTempProject() never writes a ROADMAP.md itself — no ROADMAP.md // is created at all, which is the fixture this row needs. tmpDir = createTempProject(); const p1 = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan 1'); const { stdout, stderr } = captureCliIO(() => { roadmapLib.cmdRoadmapUpdatePlanProgress(tmpDir, '1', false); }); const out = JSON.parse(stdout); assert.strictEqual(out.updated, false); assert.strictEqual(out.reason, 'ROADMAP.md not found'); assert.strictEqual(out.plan_count, 1); assert.strictEqual(out.summary_count, 0); assert.match(stderr, /^\[gsd-tools\] WARNING: roadmap update-plan-progress skipped — ROADMAP\.md not found\./); }); // Row 14 (helper adoption — the issue's own cited "correct" example). test('update-plan-progress: zero plans still discloses via stderr', () => { tmpDir = createTempProject(); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), ['# Roadmap', '', '### Phase 1: Test', '**Goal:** Test goal', ''].join('\n'), ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-CONTEXT.md'), '# Context'); const { stdout, stderr } = captureCliIO(() => { roadmapLib.cmdRoadmapUpdatePlanProgress(tmpDir, '1', false); }); const out = JSON.parse(stdout); assert.strictEqual(out.updated, false); assert.strictEqual(out.reason, 'No plans found'); assert.strictEqual(out.plan_count, 0); assert.strictEqual(out.summary_count, 0); assert.match(stderr, /^\[gsd-tools\] WARNING: roadmap update-plan-progress skipped — no plans found for this phase\./); }); }); describe('cmdRoadmapAnnotateDependencies', () => { let tmpDir; afterEach(() => { if (tmpDir) cleanup(tmpDir); }); // Row 15 (signature A) — the phase number does not resolve to any phase // directory at all (distinct from "resolves with zero plans", row 16). test('annotate-dependencies: unresolvable phase reports phase-not-found', () => { tmpDir = createTempProject(); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), ['# Roadmap', '', '### Phase 1: Foundation', '**Goal:** Set up project', ''].join('\n'), ); // No .planning/phases/* directory for phase 2 anywhere on disk. const { stdout, stderr } = captureCliIO(() => { roadmapLib.cmdRoadmapAnnotateDependencies(tmpDir, '2', false); }); const out = JSON.parse(stdout); assert.strictEqual(out.updated, false); assert.strictEqual(out.reason, 'phase 2 not found'); assert.match(stderr, /^\[gsd-tools\] WARNING: roadmap annotate-dependencies skipped — phase "2" not found\./); }); // Row 16 (boundary vs #15) — the phase resolves to a real directory, but // that directory has zero plan files in it. test('annotate-dependencies: phase with no plans reports no-plans, distinct from phase-not-found', () => { tmpDir = createTempProject(); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), ['# Roadmap', '', '### Phase 1: Foundation', '**Goal:** Set up project', ''].join('\n'), ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); const { stdout, stderr } = captureCliIO(() => { roadmapLib.cmdRoadmapAnnotateDependencies(tmpDir, '1', false); }); const out = JSON.parse(stdout); assert.strictEqual(out.updated, false); assert.strictEqual(out.reason, 'phase 1 has no plans'); assert.notStrictEqual(out.reason, 'phase 1 not found', 'must be a distinct string from the not-found case'); assert.match(stderr, /^\[gsd-tools\] WARNING: roadmap annotate-dependencies skipped — phase "1" has no plans\./); }); // Row 17 (helper adoption). test('annotate-dependencies: missing roadmap still discloses via stderr', () => { // createTempProject() never writes a ROADMAP.md itself — no ROADMAP.md // is created at all, which is the fixture this row needs. tmpDir = createTempProject(); const { stdout, stderr } = captureCliIO(() => { roadmapLib.cmdRoadmapAnnotateDependencies(tmpDir, '1', false); }); const out = JSON.parse(stdout); assert.strictEqual(out.updated, false); assert.strictEqual(out.reason, 'ROADMAP.md not found'); assert.match(stderr, /^\[gsd-tools\] WARNING: roadmap annotate-dependencies skipped — ROADMAP\.md not found\./); }); // Row 18 (helper adoption) — every plan file in the phase is unreadable. // Method-monkeypatch fault injection (CONTRIBUTING's cross-platform IO // rule) rather than chmod: a real PLAN.md exists and is discoverable by // findPhaseInternal, but fs.readFileSync throws for that exact path, // deterministically on every platform/CI user (root Docker included). test('annotate-dependencies: unreadable plans still discloses via stderr', () => { tmpDir = createTempProject(); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# Roadmap', '', '### Phase 1: Foundation', '**Goal:** Set up project', '**Plans:** 1 plan', '', 'Plans:', '- [ ] 01-01-PLAN.md — Set up DB', '', ].join('\n'), ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); const planPath = path.join(p1, '01-01-PLAN.md'); // A minimal, well-formed PLAN.md — content is irrelevant since the // injected fs.readFileSync failure below fires before it is ever read. fs.writeFileSync(planPath, '---\nphase: "1"\nplan: "01-01"\nwave: 1\n---\n\n\nPlan 1\n\n'); const originalReadFileSync = fs.readFileSync; fs.readFileSync = (filePath, ...rest) => { if (filePath === planPath) throw new Error('simulated unreadable plan file (#3957 row 18)'); return originalReadFileSync(filePath, ...rest); }; let stdout, stderr; try { ({ stdout, stderr } = captureCliIO(() => { roadmapLib.cmdRoadmapAnnotateDependencies(tmpDir, '1', false); })); } finally { fs.readFileSync = originalReadFileSync; } const out = JSON.parse(stdout); assert.strictEqual(out.updated, false); assert.strictEqual(out.reason, 'could not read plan frontmatter'); assert.match(stderr, /^\[gsd-tools\] WARNING: roadmap annotate-dependencies skipped — could not read plan frontmatter/); }); }); }); // ─── #4741: a superseded plan must not be ticked from its SUMMARY ──────────── describe('roadmap update-plan-progress — superseded plans (#4741)', () => { // The issue's self-contained fixture: phase 1 with an active plan (01-01) // and a superseded plan (01-02, `status: superseded` in the PLAN // frontmatter), each with a SUMMARY file. The count excludes the superseded // plan (#2349) while the checkbox tick iterated the raw summaries — so the // ROADMAP read "1/1 plans executed" above two checked rows. function write4741World(tmpDir, opts = {}) { const { supersededPlanStatus = 'superseded', supersededSummaryStatus = 'halted', includeSupersededSummary = true, activeSummaryStatus = 'complete', roadmap = null, } = opts; fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-demo'), { recursive: true }); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap ?? [ '# Roadmap', '', '### Phase 1: Demo', '', '**Goal:** demo', '', '**Plans:** 2 plans', '', 'Plans:', '', '- [ ] 01-01-PLAN.md — first', '- [ ] 01-02-PLAN.md — second', '', ].join('\n'), ); const plan = (num, extra) => `---\nphase: 01-demo\nplan: ${num}\ntype: execute\nwave: 1\ndepends_on: []\nfiles_modified: []\nautonomous: true${extra ? `\n${extra}` : ''}\n---\n`; fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '01-demo', '01-01-PLAN.md'), plan('01')); fs.writeFileSync( path.join(tmpDir, '.planning', 'phases', '01-demo', '01-02-PLAN.md'), plan('02', `status: ${supersededPlanStatus}`), ); fs.writeFileSync( path.join(tmpDir, '.planning', 'phases', '01-demo', '01-01-SUMMARY.md'), `---\nphase: 01-demo\nplan: 01\nstatus: ${activeSummaryStatus}\n---\n`, ); if (includeSupersededSummary) { fs.writeFileSync( path.join(tmpDir, '.planning', 'phases', '01-demo', '01-02-SUMMARY.md'), `---\nphase: 01-demo\nplan: 02\nstatus: ${supersededSummaryStatus}\n---\n`, ); } } function runUpdate(tmpDir) { const result = runGsdTools('roadmap update-plan-progress 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); return JSON.parse(result.output); } test('#4741: a superseded plan is not ticked even when its SUMMARY exists', (t) => { const tmpDir = createTempProject(); t.after(() => cleanup(tmpDir)); write4741World(tmpDir); const output = runUpdate(tmpDir); assert.strictEqual(output.plan_count, 1, 'the superseded plan is excluded from the count'); assert.strictEqual(output.summary_count, 1); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('- [x] 01-01-PLAN.md'), 'the active executed plan ticks'); assert.ok( roadmap.includes('- [ ] 01-02-PLAN.md'), 'the superseded plan must stay unchecked — a plan the tool does not count must not read as executed', ); assert.ok(roadmap.includes('1/1 plans executed'), 'the count line agrees with the checkboxes'); }); test('#4741: the superseded filter ignores the SUMMARY\'s own status', (t) => { const tmpDir = createTempProject(); t.after(() => cleanup(tmpDir)); write4741World(tmpDir, { supersededSummaryStatus: 'complete' }); runUpdate(tmpDir); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok( roadmap.includes('- [ ] 01-02-PLAN.md'), 'a superseded plan with a COMPLETE summary still must not read as executed', ); }); test('#4741: the inserted-rows tick path respects the superseded filter too', (t) => { // Loop 2 fires when a countable plan's row is MISSING (insertion path): // pre-existing superseded row + missing countable row. Before the fix the // insertion path's tick loop iterated the raw summaries and ticked the // pre-existing superseded row as well. const tmpDir = createTempProject(); t.after(() => cleanup(tmpDir)); write4741World(tmpDir, { roadmap: [ '# Roadmap', '', '### Phase 1: Demo', '', '**Goal:** demo', '', '**Plans:** 2 plans', '', 'Plans:', '', '- [ ] 01-02-PLAN.md — second', '', ].join('\n'), }); const output = runUpdate(tmpDir); assert.strictEqual(output.plan_count, 1); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('- [x] 01-01-PLAN.md'), 'the inserted countable row is ticked (it has a summary)'); assert.ok( roadmap.includes('- [ ] 01-02-PLAN.md'), 'the pre-existing superseded row must stay unchecked even on the insertion path', ); }); test('#4741 negative space: a superseded plan without a summary stays unchecked', (t) => { const tmpDir = createTempProject(); t.after(() => cleanup(tmpDir)); write4741World(tmpDir, { includeSupersededSummary: false }); const output = runUpdate(tmpDir); assert.strictEqual(output.plan_count, 1); assert.strictEqual(output.summary_count, 1); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('- [ ] 01-02-PLAN.md'), 'no summary → no tick (existing behavior preserved)'); }); test('#4741 negative space: a halted summary on an ACTIVE plan still ticks (#2830)', (t) => { const tmpDir = createTempProject(); t.after(() => cleanup(tmpDir)); // #2830: `status: halted` on a plan that still COUNTS is executed-by-design. // The active plan's SUMMARY carries `halted` here; the superseded plan's // row must still stay unchecked in the same document. write4741World(tmpDir, { activeSummaryStatus: 'halted' }); const output = runUpdate(tmpDir); assert.strictEqual(output.plan_count, 1, 'only the superseded plan is excluded'); assert.strictEqual(output.summary_count, 1); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('- [x] 01-01-PLAN.md'), 'a halted summary on an ACTIVE plan ticks (#2830)'); assert.ok( roadmap.includes('- [ ] 01-02-PLAN.md'), 'and the superseded plan stays unchecked in the same document', ); assert.ok(roadmap.includes('1/1 plans executed'), 'numbers and checkboxes agree'); }); }); // ─── #4786: suffix-less hand-written plan lists tick in place, never duplicated ── describe('#4786: suffix-less hand-written plan lists are recognized, not duplicated', () => { let tmpDir; let roadmapPath; beforeEach(() => { tmpDir = createTempProject('gsd-4786-'); roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); }); afterEach(() => { cleanup(tmpDir); }); function writtenAfter() { return fs.readFileSync(roadmapPath, 'utf-8'); } test('#4786: a suffix-less hand-written plan list is ticked in place, never duplicated', () => { // The issue's measured shape: a hand-written list WITHOUT the -PLAN.md // suffix, carrying em-dash descriptions. The old detection keyed on the // full plan filename, counted every plan "missing", and inserted a // canonical list above the hand-written one (32 checkbox lines for 16 // plans, exit 0). fs.writeFileSync(roadmapPath, [ '# ROADMAP', '', '### Phase 5: test phase', '', '**Plans:** 2/3 plans executed', '', 'Plans:', '', '- [x] 5-01 — first: does the thing with a long hand-written description', '- [x] 5-02 — second: does the other thing through submit(true)', '- [ ] 5-03 — union run, redeploy and demo rebuild, live measurement', '', ].join('\n')); createPhaseWithPlans(tmpDir, '5', [ '5-01-PLAN.md', '5-02-PLAN.md', '5-03-PLAN.md', ]); // All three plans have summaries (the issue's shape: the call lands the // last summary, 15/16 → 16/16). #4741: only plans the count counts are // tickable — a summary-less row is correctly left alone. for (const n of ['5-01', '5-02', '5-03']) { fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '05-test-phase', `${n}-SUMMARY.md`), '# Summary\n'); } const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const parsed = JSON.parse(result.output); assert.equal(parsed.updated, true, 'the unchecked plan must be ticked'); assert.ok( writtenAfter(result).includes('**Plans:** 3/3 plans executed'), 'the count line must update to 3/3', ); const written = fs.readFileSync(roadmapPath, 'utf-8'); // The damage was the INSERTION — none may appear. assert.ok(!written.includes('5-01-PLAN.md'), 'no canonical row may be inserted beside a recognized suffix-less list'); assert.ok(!written.includes('5-02-PLAN.md'), 'no canonical row may be inserted beside a recognized suffix-less list'); assert.ok(!written.includes('5-03-PLAN.md'), 'no canonical row may be inserted beside a recognized suffix-less list'); // The tick happened IN PLACE, with the hand-written description byte-identical. assert.ok( written.includes('- [x] 5-03 — union run, redeploy and demo rebuild, live measurement'), `the unchecked suffix-less row must be ticked in place with its description intact; ROADMAP:\n${written}`, ); assert.ok( written.includes('- [x] 5-01 — first: does the thing with a long hand-written description'), 'already-ticked rows must be byte-identical', ); assert.equal( (written.match(/- \[.\] 5-0/g) || []).length, 3, `exactly three checkbox rows must remain (no duplication); ROADMAP:\n${written}`, ); }); test('#4786: a stem does not match a longer plan id', () => { // Boundary: the new stem recognition must not let `- [x] 5-011` satisfy // plan 5-01 (5-011 is a different plan). 5-01 carries no recognizable row, // so the #1163 fresh-template insertion still fires for it — that is the // documented contract; the pin is that 5-011 is not treated as 5-01. fs.writeFileSync(roadmapPath, [ '# ROADMAP', '', '### Phase 5: test phase', '', 'Plans:', '', '- [x] 5-011 — bogus longer id, not plan 5-01', '', ].join('\n')); createPhaseWithPlans(tmpDir, '5', [ '5-01-PLAN.md', '5-02-PLAN.md', '5-03-PLAN.md', ]); const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const written = fs.readFileSync(roadmapPath, 'utf-8'); assert.ok(written.includes('- [ ] 5-01-PLAN.md'), '5-01 must still count as missing (5-011 is a different plan)'); assert.ok(written.includes('- [ ] 5-02-PLAN.md') && written.includes('- [ ] 5-03-PLAN.md'), 'all genuinely absent plans still insert'); assert.equal( (written.match(/- \[.\] 5-0/g) || []).length, 4, `3 inserted rows + the hand-written 5-011 row; ROADMAP:\n${written}`, ); }); }); // ─── #4801: archived phase directories resolve in init.manager ────────────── describe('#4801: init.manager resolves archived phase directories', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject('gsd-4801-'); }); afterEach(() => { cleanup(tmpDir); }); function writeState4801() { fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nstatus: active\n---\n# State\n'); } function writeRoadmap4801() { fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# Roadmap', '', '### Phase 3: shipped', '', 'Goal: shipped before archive', '', '### Phase 5: live', '', 'Goal: in progress', '', '### Phase 7: never started', '', 'Goal: not started', '', ].join('\n')); } function seedArchivedPhase() { // An archived, shipped phase: PLAN + SUMMARY + a NEWER passing VERIFICATION // (mtime discipline per the #3057 fixture — the verification must postdate // the summary for the completion projection to read it as fresh). const arch = path.join(tmpDir, '.planning', 'milestones', 'v0.1-phases', '03-shipped'); fs.mkdirSync(arch, { recursive: true }); fs.writeFileSync(path.join(arch, '03-01-PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(arch, '03-01-SUMMARY.md'), '# Summary\n'); fs.writeFileSync(path.join(arch, '03-VERIFICATION.md'), '---\nstatus: passed\n---\n\n# Verification\n'); const older = new Date('2026-01-01T00:00:00.000Z'); const newer = new Date('2026-01-01T00:01:00.000Z'); fs.utimesSync(path.join(arch, '03-01-SUMMARY.md'), older, older); fs.utimesSync(path.join(arch, '03-VERIFICATION.md'), newer, newer); } test('#4801: an archived phase directory resolves and reports complete', () => { writeState4801(); writeRoadmap4801(); seedArchivedPhase(); // A live in-progress phase and a never-started phase for contrast. const live = path.join(tmpDir, '.planning', 'phases', '05-live'); fs.mkdirSync(live, { recursive: true }); fs.writeFileSync(path.join(live, '05-01-PLAN.md'), '# Plan\n'); const output = JSON.parse(runGsdTools(['query', 'init.manager'], tmpDir).output); const rows = new Map(output.phases.map((p) => [String(p.number), p])); const archived = rows.get('3'); assert.ok(archived, 'the archived phase must appear in the enumeration'); assert.notStrictEqual(archived.disk_status, 'no_directory', 'an archived phase directory must resolve — no_directory means never started'); assert.strictEqual(archived.phase_complete, true, 'an archived phase with a passed verification reports complete'); const neverStarted = rows.get('7'); assert.strictEqual(neverStarted.disk_status, 'no_directory', 'a never-started phase still reports no_directory'); }); test('#4801: a live in-progress phase is unchanged by the archived-resolution swap', () => { writeState4801(); writeRoadmap4801(); seedArchivedPhase(); const live = path.join(tmpDir, '.planning', 'phases', '05-live'); fs.mkdirSync(live, { recursive: true }); fs.writeFileSync(path.join(live, '05-01-PLAN.md'), '# Plan\n'); const output = JSON.parse(runGsdTools(['query', 'init.manager'], tmpDir).output); const row = output.phases.find((p) => String(p.number) === '5'); assert.strictEqual(row.phase_complete, false); assert.ok(['planned', 'empty', 'no_directory'].includes(row.disk_status), `live plan-less phase stays incomplete; got ${row.disk_status}`); }); }); // ───────────────────────────────────────────────────────────────────────────── // #4906 Phase 2 (.gsd/phase/feat-4906-phase2-field-writes/50-test-matrix.md): // cmdRoadmapUpdatePlanProgress's `**Plans:**` write migrated onto the // PlanningDoc parse -> setFieldValue -> serialize seam (src/planning-document. // cjs). Row numbers refer to the locked matrix. // // Row 6 (a bracketed HUMAN annotation, e.g. `[Deferred pending re-scope]`, // must not be mistaken for the fresh-template placeholder) is #3584 Finding // A's own regression fixture and is already covered — see tests/phase.test. // cjs's "case 11 — a bracketed human annotation is preserved verbatim, not // mistaken for the template placeholder" in the `bug #3584` describe block — // not duplicated here per the fixture-provenance rule. // // Row 13 (parity between phase.cts and roadmap.cts for identical numeric // inputs) is asserted once, at tests/phase.test.cjs's "row 13" test above; // not duplicated here. // ───────────────────────────────────────────────────────────────────────────── describe('#4906 Phase 2: cmdRoadmapUpdatePlanProgress Plans-line seam migration (site 2)', () => { let tmpDir; let roadmapPath; beforeEach(() => { tmpDir = createTempProject('gsd-4906-roadmap-'); roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); }); afterEach(() => { cleanup(tmpDir); }); function seedPhase10(plansLine, { verified = true } = {}) { fs.writeFileSync( roadmapPath, [ '# Roadmap', '', '- [ ] **Phase 10: Test Phase**', '', '### Phase 10: Test Phase', '**Goal:** goal', plansLine, '', '## Progress', '', '| Phase | Plans Complete | Status | Completed |', '|-------|----------------|--------|-----------|', '| 10 Test Phase | 0/1 | Not started | - |', '', ].join('\n'), ); const phaseDir = path.join(tmpDir, '.planning', 'phases', '10-test-phase'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '10-01-PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(phaseDir, '10-01-SUMMARY.md'), '# Summary\n'); if (verified) { fs.writeFileSync(path.join(phaseDir, '10-VERIFICATION.md'), '---\nstatus: passed\n---\n\n# Verification\n'); } return phaseDir; } function plansLineIn(content) { // Recognizes both BOLD_FIELD_RE spellings (colon inside `**Plans:**` and // colon outside `**Plans**:`, the canonical templates/roadmap.md shape) — // rows 4/5 below seed the colon-outside spelling, and a finder anchored // to only one spelling silently reports "field not found" (`undefined`) // rather than a real assertion failure, masking whichever bug is actually // present. Caught by gsd-test (#4906 Phase 2 verification run). return content.split(/\r?\n/).find((l) => /^\*\*Plans(?::\*\*|\*\*:)/.test(l.trim())); } test('row 2 (non-regression of #2853/#3584): trailing prose survives migration', () => { const annotation = '— replanned per review'; seedPhase10(`**Plans:** 1 plans ${annotation}`); const result = runGsdTools('roadmap update-plan-progress 10', tmpDir); assert.ok(result.success, `command failed: ${result.error}`); const line = plansLineIn(fs.readFileSync(roadmapPath, 'utf-8')); assert.equal(line, `**Plans:** 1/1 plans complete ${annotation}`, 'annotation must survive byte-for-byte after the count bump'); }); test('row 3: a Plans line with a real count and no trailing content updates cleanly', () => { seedPhase10('**Plans:** 1 plans'); const result = runGsdTools('roadmap update-plan-progress 10', tmpDir); assert.ok(result.success, `command failed: ${result.error}`); const line = plansLineIn(fs.readFileSync(roadmapPath, 'utf-8')); assert.equal(line, '**Plans:** 1/1 plans complete'); }); test('row 4: the fresh-template placeholder is replaced with the real count', () => { seedPhase10('**Plans**: [Number of plans, e.g., "3 plans" or "TBD"]'); const result = runGsdTools('roadmap update-plan-progress 10', tmpDir); assert.ok(result.success, `command failed: ${result.error}`); const line = plansLineIn(fs.readFileSync(roadmapPath, 'utf-8')); assert.equal(line, '**Plans**: 1/1 plans complete'); }); test('row 5: freeform prose (not the template placeholder, not a real count) is left untouched', () => { seedPhase10('**Plans**: TBD — annotation'); const result = runGsdTools('roadmap update-plan-progress 10', tmpDir); assert.ok(result.success, `command failed: ${result.error}`); const line = plansLineIn(fs.readFileSync(roadmapPath, 'utf-8')); assert.equal(line, '**Plans**: TBD — annotation', 'freeform prose must never be classified as a writable count'); }); test('row 7: a missing Plans field does not crash roadmap.update-plan-progress', () => { fs.writeFileSync( roadmapPath, [ '# Roadmap', '', '- [ ] **Phase 10: Test Phase**', '', '### Phase 10: Test Phase', '**Goal:** goal', '', '## Progress', '', '| Phase | Plans Complete | Status | Completed |', '|-------|----------------|--------|-----------|', '| 10 Test Phase | 0/1 | Not started | - |', '', ].join('\n'), ); const phaseDir = path.join(tmpDir, '.planning', 'phases', '10-test-phase'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '10-01-PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(phaseDir, '10-01-SUMMARY.md'), '# Summary\n'); fs.writeFileSync(path.join(phaseDir, '10-VERIFICATION.md'), '---\nstatus: passed\n---\n\n# Verification\n'); const result = runGsdTools('roadmap update-plan-progress 10', tmpDir); assert.ok(result.success, `must not crash on a missing Plans field: ${result.error}`); }); test('row 9: the write does not escape its confinement window into a sibling phase', () => { const annotation = '— do not touch'; fs.writeFileSync( roadmapPath, [ '# Roadmap', '', '- [ ] **Phase 10: Test Phase**', '- [ ] **Phase 11: Sibling Phase**', '', '### Phase 10: Test Phase', '**Goal:** goal', '**Plans:** 1 plans', '', '### Phase 11: Sibling Phase', '**Goal:** sibling goal', `**Plans:** 0/1 plans ${annotation}`, '', '## Progress', '', '| Phase | Plans Complete | Status | Completed |', '|-------|----------------|--------|-----------|', '| 10 Test Phase | 0/1 | Not started | - |', '| 11 Sibling Phase | 0/1 | Not started | - |', '', ].join('\n'), ); const phaseDir = path.join(tmpDir, '.planning', 'phases', '10-test-phase'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '10-01-PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(phaseDir, '10-01-SUMMARY.md'), '# Summary\n'); fs.writeFileSync(path.join(phaseDir, '10-VERIFICATION.md'), '---\nstatus: passed\n---\n\n# Verification\n'); const result = runGsdTools('roadmap update-plan-progress 10', tmpDir); assert.ok(result.success, `command failed: ${result.error}`); const lines = fs.readFileSync(roadmapPath, 'utf-8') .split(/\r?\n/) .filter((l) => l.trim().startsWith('**Plans:**')); assert.equal(lines[0], '**Plans:** 1/1 plans complete', 'phase 10 (the target) is rewritten'); assert.equal(lines[1], `**Plans:** 0/1 plans ${annotation}`, "phase 11's own Plans line must stay untouched by phase 10's write"); }); test('row 10: a migrated Plans write round-trips identically through the seam\'s own read path', () => { seedPhase10('**Plans:** 1 plans'); const result = runGsdTools('roadmap update-plan-progress 10', tmpDir); assert.ok(result.success, `command failed: ${result.error}`); const written = fs.readFileSync(roadmapPath, 'utf-8'); const { parsePlanningDoc, findField, readNode } = require('../gsd-core/bin/lib/planning-document.cjs'); const parsed = parsePlanningDoc(written, 'ROADMAP.md'); assert.ok(parsed.ok, 'the written ROADMAP.md must remain parseable by the same seam'); const fieldId = findField(parsed.value, 'Plans'); assert.ok(fieldId, 'the Plans field must still be locatable'); const read = readNode(parsed.value, fieldId); assert.ok(read.ok, 'the Plans field must remain readable'); assert.equal(read.value, '1/1 plans complete', 'round-tripped value matches exactly what was computed'); }); test('row 12: the Plans-line migration is CRLF-safe', () => { const { splitLines } = require('../gsd-core/bin/lib/text-lines.cjs'); const annotation = '— crlf annotation'; seedPhase10(`**Plans:** 1 plans ${annotation}`); const lf = fs.readFileSync(roadmapPath, 'utf-8'); fs.writeFileSync(roadmapPath, splitLines(lf).join('\r\n')); const result = runGsdTools('roadmap update-plan-progress 10', tmpDir); assert.ok(result.success, `command failed: ${result.error}`); const written = fs.readFileSync(roadmapPath, 'utf-8'); const line = splitLines(written).find((l) => l.trim().startsWith('**Plans:**')); assert.equal(line, `**Plans:** 1/1 plans complete ${annotation}`, 'CRLF source must not corrupt the offset or strand/duplicate the annotation'); }); });