Files
msd-core/tests/roadmap.test.cjs
Tom Boucher 9ee6d54cc3 fix(#4306): extend fault-injection fd-swallow fix across the whole suite (#4308)
* fix(#4306): forward real bytes through io.test.cjs's fault-injection mocks

The bug #1008 fault-injection tests mock fs.writeSync scoped only by file
descriptor. On their "success" arms (the retry-after-EAGAIN/EINTR call, and
the short-write simulation) they fabricated a return byte count without ever
calling the real writeSync -- the bytes went into a local array and nowhere
else.

node:test's process-isolation runner (default on Node >= 22) reads each test
file's own stdout to parse its child-to-parent result protocol. If the
runner's own reporter write for an adjacent test lands on fd 1 while one of
these mocks is installed, that write was silently swallowed instead of
reaching the real pipe -- observed in CI as "Unable to deserialize cloned
data" (a corrupted/truncated byte stream on the parent's read side), not a
thrown exception.

Every "success" arm now forwards the real bytes to orig()/restore() instead
of fabricating a return value, so anything else sharing the fd during the
mocked window still gets its bytes delivered for real. writeAllSync (the
only production caller reaching this mock) always passes a Buffer, so the
forwarded calls use the buffer-form fs.writeSync overload unambiguously.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4306): extend fault-injection fd-swallow fix across the whole suite

The originally-fixed instance (tests/io.test.cjs) was one occurrence of a
copy-pasted defect: mocked fs.writeSync arms fabricated a return byte count
without ever forwarding the call to the real fs.writeSync, silently
discarding bytes. Under node:test's process-isolated runner, the parent
reads the child's real stdout to parse v8-serialized report frames
interleaved with plain output (confirmed against node's own
lib/internal/test_runner/runner.js and a matching upstream issue,
nodejs/node#64061) — a swallowed write on that fd corrupts the parent's
parse ("Unable to deserialize cloned data").

Adds a shared, safe capture helper to tests/helpers.cjs, captureFdSync(fd,
fn): it always forwards every write to the real fs.writeSync first, then
records only the observed fd's bytes, sliced by the real return count (not
the requested length), decoded once via Buffer.concat so a short write
can't split a multi-byte codepoint across two decodes.

17 test files migrate their local copy of the unsafe mock to this shared
helper. tests/worktree-base-ref.test.cjs keeps a narrower in-place fix
instead (it needs to record every fd a write touched, which the shared
helper doesn't expose).

tests/io.test.cjs gets two follow-up correctness fixes on top of the
already-committed forwarding fix: the EAGAIN/EINTR/short-write arms now
derive their recorded chunk from the real return count everywhere
(including the string-form overload), and the short-write test no longer
forces a Buffer-shaped truncation call onto a string-form write that could
land on the same fd.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 23:54:02 -04:00

4593 lines
191 KiB
JavaScript

// 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');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 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('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);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 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 <details> section
* plus the same phase as the ACTIVE milestone section.
*/
function buildRoadmapWithArchivedDuplicate(phaseNum = PHASE_NUM_1163) {
return [
'# ROADMAP',
'',
'<details>',
'<summary>v0.9 — shipped</summary>',
'',
`### Phase ${phaseNum}: test phase`,
'',
'**Plans**: 2/2 plans complete',
'',
'Plans:',
`- [x] ${phaseNum}-01-PLAN.md`,
`- [x] ${phaseNum}-02-PLAN.md`,
'',
'</details>',
'',
'## 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 <details>', () => {
// ROADMAP has duplicate phase heading: one inside <details> (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 <details> block.
const detailsStart = written.indexOf('<details>');
const detailsEnd = written.indexOf('</details>');
const archivedSection = written.slice(detailsStart, detailsEnd + '</details>'.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 </details>) should have the new rows
const activeSection = written.slice(detailsEnd + '</details>'.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 <details> 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('</details>');
const activeSection = written.slice(detailsEnd + '</details>'.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('<details>'), detailsEnd + '</details>'.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
/<step name="update_roadmap">([\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: []
---
<objective>
Plan ${wave} objective
</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 <details open> with version only in
* <summary> 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 <summary> not found.
* 2. activeMarkerPattern does not include 🔄; only 🚧 is recognised.
* Both misses → stripShippedMilestones() deletes the active <details open> 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 <summary> tag, and the in-progress marker is 🔄 (not 🚧).
// Shipped milestone v1.2 is correctly collapsed in a <details> block.
const ROADMAP_DETAILS_SUMMARY = `# Roadmap
<details>
<summary>✅ v1.2: Foundation (shipped)</summary>
### Phase 1: Bootstrap
**Goal:** Set up infrastructure
### Phase 2: Core API
**Goal:** Build REST API
</details>
<details open>
<summary>🔄 v1.3: Active Sprint</summary>
### Phase 3: Auth
**Goal:** Add authentication
### Phase 4: Dashboard
**Goal:** Build dashboard UI
</details>
`;
// STATE.md with milestone: v1.3 — version matches the <summary> 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
// <summary>) to confirm the activeMarkerPattern gap is also covered.
const ROADMAP_ROTATE_HEADING = `# Roadmap
<details>
<summary>✅ v2.0: Shipped (shipped)</summary>
### Phase 1: Old Phase
**Goal:** Done
</details>
## 🔄 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 — <details>/<summary> active milestone strip', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// ── Core repro: version only in <summary> tag ─────────────────────────────
test('roadmap.analyze returns correct phase_count when active milestone uses <summary> + 🔄', () => {
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 <details open> block because ` +
`the version "v1.3" only appears in a <summary> tag and the emoji is 🔄, not 🚧.`
);
});
test('roadmap.analyze does NOT return phase_count: 0 when active milestone is in <details open>', () => {
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 <details open> 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 <details open> block so the phase section was lost.`
);
});
test('shipped phases in collapsed <details> 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 <details>) must not appear in the analyze output. ` +
`Got phases: ${JSON.stringify(phaseNums)}`
);
});
// ── 🔄 in heading (not <summary>) 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: []',
'---',
'',
`<objective>Plan ${plan}</objective>`,
'',
].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<objective>\nPlan 1\n</objective>\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/);
});
});
});