Files
msd-core/tests/phase.test.cjs
Tom Boucher 4f6fda852e fix(#1591): phase.complete recognizes checkbox-list phases in isLastPhase fallback (#1819)
* fix(#1591): phase.complete recognizes checkbox-list phases in the isLastPhase fallback

When the active milestone's phase checklist is written as `- [ ] Phase N:`
checkbox items inside a <details> block (the @Azd325 structure) and the next
phase has no directory yet, the disk-based next-phase resolver finds nothing
and phase.complete falls back to the roadmap-enumeration guard at the
isLastPhase site. That guard's phasePattern was heading-only
(/#{2,4}\s*Phase…/), so it never matched checklist items → is_last_phase=true
and next_phase=null on a mid-milestone phase, and STATE.md was wrongly marked
'Milestone complete' with total_phases decremented.

Broaden the marker alternation to match BOTH heading-style (### Phase N:) and
checkbox-list items (- [ ] Phase N: / - [x] Phase N:); the number/name
captures are unchanged. extractCurrentMilestone already surfaces the
<details>-wrapped checklist correctly, so no parser change is needed. The
heading-only sibling patterns elsewhere in phase.cts are left untouched
(scope discipline — only the reproduced isLastPhase fallback is changed).

Regression: a phase complete 36 on a <details>-wrapped v2.0 checklist
(Phases 36-38, only 36 has a dir) returns is_last_phase=false, next_phase=37,
and does NOT flip STATE.md to 'Milestone complete'.

* docs(#1591): add changeset fragment for phase.complete checkbox-list fix

* test(#1752): add total_phases-preservation regression for the #1591 follow-up

#1752 is the scoped follow-up to #1591 — same <details>-wrapped-checkbox
defect, with the additional emphasis on the total_phases decrement cascade.
The #1591 fix (is_last_phase=false) already resolves it: with all 8 phase
dirs on disk, phase.complete 36 on a v2.0 <details> checklist leaves
total_phases at 8 (not decremented to 7) and does not flip STATE.md to
'Milestone complete'. Verified manually before adding the test.

Add the #1752 regression case (8 phase dirs, curated total_phases: 8) to the
phase complete command block in tests/phase.test.cjs, and update the changeset
to reference both issues (#1591, #1752) since this is one user-facing change
resolving both.
2026-06-28 22:41:57 -04:00

5682 lines
218 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.
// allow-test-rule: state-md-is-the-runtime-contract — regression tests for
// bug #3517 assert the exact STATE.md fields written by phase.complete;
// STATE.md IS the product surface being verified, not source code.
// Migration to typed-IR parser tracked in #2974.
/**
* GSD Tools Tests - Phase
*
* Consolidated from 20 → 4 test files (issue #3740). This file covers the
* CJS phase CLI layer (phase.cjs + gsd-tools.cjs): add, remove, complete,
* list, insert, and all regression tests for bugs in that layer.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('node:os');
const { execFileSync, spawnSync } = require('node:child_process');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const GSD_TOOLS_BIN = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
function normalizePhaseToken(token) {
return String(token).replace(/\d+/g, (digits) => String(Number(digits)));
}
function phaseTokenFromDirName(name) {
const match = name.match(/^(?:[A-Z][A-Z0-9]*-)?(\d+[A-Z]?(?:\.\d+)*)/i);
return match ? match[1] : null;
}
function writePassedVerificationForPhase(tmpDir, phase) {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const wanted = normalizePhaseToken(phase);
const phaseDirName = fs.readdirSync(phasesDir)
.find((name) => normalizePhaseToken(phaseTokenFromDirName(name) || '') === wanted);
assert.ok(phaseDirName, `expected phase directory for Phase ${phase}`);
const phaseDir = path.join(phasesDir, phaseDirName);
fs.writeFileSync(
path.join(phaseDir, `${phase}-VERIFICATION.md`),
['---', 'status: passed', '---', '', '# Verification', ''].join('\n'),
);
}
function runVerifiedPhaseComplete(args, tmpDir, env) {
const argv = Array.isArray(args)
? args
: (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [])
.map((t) => t.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1'));
const completeIdx = argv.findIndex((token, index) => token === 'complete' && argv[index - 1] === 'phase');
assert.notEqual(completeIdx, -1, `expected phase complete command, got ${argv.join(' ')}`);
const phase = argv[completeIdx + 1];
assert.ok(phase, `expected phase number in command ${argv.join(' ')}`);
writePassedVerificationForPhase(tmpDir, phase);
return runGsdTools(args, tmpDir, env);
}
function writePhaseCompleteVerificationGateFixture(tmpDir, verificationStatus) {
const planningDir = path.join(tmpDir, '.planning');
const phase1Dir = path.join(planningDir, 'phases', '01-foundation');
const phase2Dir = path.join(planningDir, 'phases', '02-api');
fs.mkdirSync(phase1Dir, { recursive: true });
fs.mkdirSync(phase2Dir, { recursive: true });
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [ ] Phase 1: Foundation',
'- [ ] Phase 2: API',
'',
'### Phase 1: Foundation',
'**Goal:** Setup',
'**Plans:** 1 plans',
'',
'### Phase 2: API',
'**Goal:** Build API',
'',
'## Progress',
'',
'| Phase | Plans Complete | Status | Completed |',
'|-------|----------------|--------|-----------|',
'| 01. Foundation | 0/1 | Not started | - |',
'| 02. API | 0/1 | Not started | - |',
'',
].join('\n'),
);
fs.writeFileSync(
path.join(planningDir, 'STATE.md'),
[
'# State',
'',
'**Current Phase:** 01',
'**Current Phase Name:** Foundation',
'**Status:** In progress',
'**Current Plan:** 01-01',
'**Last Activity:** 2025-01-01',
'**Last Activity Description:** Working on phase 1',
'',
].join('\n'),
);
fs.writeFileSync(path.join(phase1Dir, '01-01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phase1Dir, '01-01-SUMMARY.md'), '# Summary\n');
if (verificationStatus !== null) {
fs.writeFileSync(
path.join(phase1Dir, '01-VERIFICATION.md'),
[
'---',
`status: ${verificationStatus}`,
'---',
'',
'# Verification',
'',
].join('\n'),
);
}
}
describe('phases list command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('empty phases directory returns empty array', () => {
const result = runGsdTools('phases list', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(output.directories, [], 'directories should be empty');
assert.strictEqual(output.count, 0, 'count should be 0');
});
test('lists phase directories sorted numerically', () => {
// Create out-of-order directories
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '10-final'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
const result = runGsdTools('phases list', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.count, 3, 'should have 3 directories');
assert.deepStrictEqual(
output.directories,
['01-foundation', '02-api', '10-final'],
'should be sorted numerically'
);
});
test('handles decimal phases in sort order', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02.1-hotfix'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02.2-patch'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-ui'), { recursive: true });
const result = runGsdTools('phases list', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(
output.directories,
['02-api', '02.1-hotfix', '02.2-patch', '03-ui'],
'decimal phases should sort correctly between whole numbers'
);
});
test('--type plans lists only PLAN.md files', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan 1');
fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan 2');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary');
fs.writeFileSync(path.join(phaseDir, 'RESEARCH.md'), '# Research');
const result = runGsdTools('phases list --type plans', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(
output.files.sort(),
['01-01-PLAN.md', '01-02-PLAN.md'],
'should list only PLAN files'
);
});
test('--type summaries lists only SUMMARY.md files', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary 1');
fs.writeFileSync(path.join(phaseDir, '01-02-SUMMARY.md'), '# Summary 2');
const result = runGsdTools('phases list --type summaries', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(
output.files.sort(),
['01-01-SUMMARY.md', '01-02-SUMMARY.md'],
'should list only SUMMARY files'
);
});
test('--phase filters to specific phase directory', () => {
const phase01 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
const phase02 = path.join(tmpDir, '.planning', 'phases', '02-api');
fs.mkdirSync(phase01, { recursive: true });
fs.mkdirSync(phase02, { recursive: true });
fs.writeFileSync(path.join(phase01, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phase02, '02-01-PLAN.md'), '# Plan');
const result = runGsdTools('phases list --type plans --phase 01', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(output.files, ['01-01-PLAN.md'], 'should only list phase 01 plans');
assert.strictEqual(output.phase_dir, 'foundation', 'should report phase name without number prefix');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// roadmap get-phase command
// ─────────────────────────────────────────────────────────────────────────────
describe('phase next-decimal command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('returns X.1 when no decimal phases exist', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-feature'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '07-next'), { recursive: true });
const result = runGsdTools('phase next-decimal 06', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.next, '06.1', 'should return 06.1');
assert.deepStrictEqual(output.existing, [], 'no existing decimals');
});
test('increments from existing decimal phases', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-feature'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.1-hotfix'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.2-patch'), { recursive: true });
const result = runGsdTools('phase next-decimal 06', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.next, '06.3', 'should return 06.3');
assert.deepStrictEqual(output.existing, ['06.1', '06.2'], 'lists existing decimals');
});
test('handles gaps in decimal sequence', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-feature'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.1-first'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.3-third'), { recursive: true });
const result = runGsdTools('phase next-decimal 06', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// Should take next after highest, not fill gap
assert.strictEqual(output.next, '06.4', 'should return 06.4, not fill gap at 06.2');
});
test('handles single-digit phase input', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-feature'), { recursive: true });
const result = runGsdTools('phase next-decimal 6', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.next, '06.1', 'should normalize to 06.1');
assert.strictEqual(output.base_phase, '06', 'base phase should be padded');
});
test('returns error if base phase does not exist', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-start'), { recursive: true });
const result = runGsdTools('phase next-decimal 06', tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, false, 'base phase not found');
assert.strictEqual(output.next, '06.1', 'should still suggest 06.1');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phase-plan-index command
// ─────────────────────────────────────────────────────────────────────────────
describe('phase-plan-index command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('empty phase directory returns empty plans array', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
const result = runGsdTools('phase-plan-index 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase, '03', 'phase number correct');
assert.deepStrictEqual(output.plans, [], 'plans should be empty');
assert.deepStrictEqual(output.waves, {}, 'waves should be empty');
assert.deepStrictEqual(output.incomplete, [], 'incomplete should be empty');
assert.strictEqual(output.has_checkpoints, false, 'no checkpoints');
assert.ok(output.warning === undefined, 'truly empty dir must not emit a warning');
});
// #2893 — when the planner produces filenames that don't match the canonical
// `{padded_phase}-{NN}-PLAN.md` contract, the executor used to silently see
// plan_count: 0 with no signal. Now the response must include a `warning`
// field naming every offender, so the user gets an actionable error instead
// of "execute-phase blocked, no clue why".
test('non-canonical plan filenames surface a warning naming each offender (#2893)', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });
// The reporter's exact symptom: planner wrote `{phase-id}-PLAN-{N}-{slug}.md`.
fs.writeFileSync(path.join(phaseDir, '01-PLAN-01-foundation.md'), '---\n---\n');
fs.writeFileSync(path.join(phaseDir, '01-PLAN-02-api.md'), '---\n---\n');
const result = runGsdTools('phase-plan-index 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.plans.length, 0, 'non-canonical files are not silently accepted');
assert.ok(typeof output.warning === 'string', 'warning field must be present');
assert.ok(output.warning.includes('01-PLAN-01-foundation.md'), 'warning names the first offender');
assert.ok(output.warning.includes('01-PLAN-02-api.md'), 'warning names the second offender');
assert.ok(
output.warning.includes('{padded_phase}-{NN}-PLAN.md'),
'warning cites the canonical pattern so user knows what to rename to',
);
});
test('canonical plans suppress the warning even alongside derivative files (#2893)', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });
// Canonical plan + the legitimate derivative artifacts the planner emits.
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '---\nwave: 1\n---\n');
fs.writeFileSync(path.join(phaseDir, '03-PLAN-OUTLINE.md'), '# outline\n');
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.pre-bounce.md'), '---\n---\n');
const result = runGsdTools('phase-plan-index 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.plans.length, 1, 'canonical plan detected');
assert.ok(
output.warning === undefined,
`outline and pre-bounce files must not trigger the warning, got: ${output.warning}`,
);
});
// #2893 parity — find-phase reads the same phase directory and applies the
// same canonical filter, so it must emit the same warning shape. Without
// these tests the two code paths could silently diverge.
test('find-phase: non-canonical plan filenames surface the same warning (#2893 parity)', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-PLAN-01-foundation.md'), '---\n---\n');
fs.writeFileSync(path.join(phaseDir, '01-PLAN-02-api.md'), '---\n---\n');
const result = runGsdTools('find-phase 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, true, 'phase directory found');
assert.deepStrictEqual(output.plans, [], 'non-canonical files are not silently accepted');
assert.ok(typeof output.warning === 'string', 'warning field must be present');
assert.ok(output.warning.includes('01-PLAN-01-foundation.md'), 'warning names the first offender');
assert.ok(output.warning.includes('01-PLAN-02-api.md'), 'warning names the second offender');
assert.ok(
output.warning.includes('{padded_phase}-{NN}-PLAN.md'),
'warning cites the canonical pattern',
);
});
test('find-phase: canonical plans + derivatives suppress the warning (#2893 parity)', () => {
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');
fs.writeFileSync(path.join(phaseDir, '03-PLAN-OUTLINE.md'), '# outline\n');
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.pre-bounce.md'), '---\n---\n');
const result = runGsdTools('find-phase 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(output.plans, ['03-01-PLAN.md'], 'canonical plan detected');
assert.ok(
output.warning === undefined,
`outline and pre-bounce files must not trigger the warning, got: ${output.warning}`,
);
});
// #2893 parity — `phases list --type plans` aggregates across phase dirs
// and prefixes each warning with `${dir}: ` so the user can locate the
// offending phase. Test mirrors the find-phase pair but accounts for that
// prefix in the assertion.
test('phases list --type plans: non-canonical filenames surface a per-dir warning (#2893 parity)', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-PLAN-01-foundation.md'), '---\n---\n');
fs.writeFileSync(path.join(phaseDir, '01-PLAN-02-api.md'), '---\n---\n');
const result = runGsdTools('phases list --type plans --phase 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(output.files, [], 'non-canonical files are not silently accepted');
assert.ok(typeof output.warning === 'string', 'warning field must be present');
assert.ok(output.warning.includes('03-api:'), 'warning is prefixed with the offending phase dir');
assert.ok(output.warning.includes('01-PLAN-01-foundation.md'), 'warning names the first offender');
assert.ok(output.warning.includes('01-PLAN-02-api.md'), 'warning names the second offender');
assert.ok(
output.warning.includes('{padded_phase}-{NN}-PLAN.md'),
'warning cites the canonical pattern',
);
});
test('phases list --type plans: canonical plans suppress the warning (#2893 parity)', () => {
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');
fs.writeFileSync(path.join(phaseDir, '03-PLAN-OUTLINE.md'), '# outline\n');
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.pre-bounce.md'), '---\n---\n');
const result = runGsdTools('phases list --type plans --phase 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(output.files, ['03-01-PLAN.md'], 'canonical plan detected');
assert.ok(
output.warning === undefined,
`outline and pre-bounce files must not trigger the warning, got: ${output.warning}`,
);
});
test('extracts single plan with frontmatter', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '03-01-PLAN.md'),
`---
wave: 1
autonomous: true
objective: Set up database schema
files-modified: [prisma/schema.prisma, src/lib/db.ts]
---
## Task 1: Create schema
## Task 2: Generate client
`
);
const result = runGsdTools('phase-plan-index 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.plans.length, 1, 'should have 1 plan');
assert.strictEqual(output.plans[0].id, '03-01', 'plan id correct');
assert.strictEqual(output.plans[0].wave, 1, 'wave extracted');
assert.strictEqual(output.plans[0].autonomous, true, 'autonomous extracted');
assert.strictEqual(output.plans[0].objective, 'Set up database schema', 'objective extracted');
assert.deepStrictEqual(output.plans[0].files_modified, ['prisma/schema.prisma', 'src/lib/db.ts'], 'files extracted');
assert.strictEqual(output.plans[0].task_count, 2, 'task count correct');
assert.strictEqual(output.plans[0].has_summary, false, 'no summary yet');
});
test('groups multiple plans by wave (DAG-bucketing: 03-03 depends on 03-01 and 03-02)', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '03-01-PLAN.md'),
[
'---',
'wave: 1',
'autonomous: true',
'objective: Database setup',
'depends_on: []',
'---',
'',
'## Task 1: Schema',
].join('\n')
);
fs.writeFileSync(
path.join(phaseDir, '03-02-PLAN.md'),
[
'---',
'wave: 1',
'autonomous: true',
'objective: Auth setup',
'depends_on: []',
'---',
'',
'## Task 1: JWT',
].join('\n')
);
fs.writeFileSync(
path.join(phaseDir, '03-03-PLAN.md'),
[
'---',
'wave: 2',
'autonomous: false',
'objective: API routes',
'depends_on:',
' - 03-01',
' - 03-02',
'---',
'',
'## Task 1: Routes',
].join('\n')
);
const result = runGsdTools('phase-plan-index 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.plans.length, 3, 'should have 3 plans');
assert.deepStrictEqual(output.waves['1'], ['03-01', '03-02'], 'wave 1 has 2 plans');
assert.deepStrictEqual(output.waves['2'], ['03-03'], 'wave 2 has 1 plan');
// No mismatch warning: declared wave 2 matches topo level 2
assert.strictEqual(output.warnings, undefined, 'no warnings when declared wave matches DAG');
});
test('detects incomplete plans (no matching summary)', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });
// Plan with summary
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), `---\nwave: 1\n---\n## Task 1`);
fs.writeFileSync(path.join(phaseDir, '03-01-SUMMARY.md'), `# Summary`);
// Plan without summary
fs.writeFileSync(path.join(phaseDir, '03-02-PLAN.md'), `---\nwave: 2\n---\n## Task 1`);
const result = runGsdTools('phase-plan-index 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.plans[0].has_summary, true, 'first plan has summary');
assert.strictEqual(output.plans[1].has_summary, false, 'second plan has no summary');
assert.deepStrictEqual(output.incomplete, ['03-02'], 'incomplete list correct');
});
test('phase-plan-index matches descriptive plan with prefix summary (#3101)', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '03-01-auth-hardening-PLAN.md'), `---\nwave: 1\n---\n## Task 1`);
fs.writeFileSync(path.join(phaseDir, '03-01-SUMMARY.md'), `# Summary`);
const result = runGsdTools('phase-plan-index 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.plans[0].has_summary, true, 'descriptive plan should match prefix summary');
assert.deepStrictEqual(output.incomplete, [], 'plan should not be marked incomplete');
});
// #3266 CR — depends_on canonical-id mismatch: a plan named
// '03-01-auth-hardening-PLAN.md' is stored with id '03-01-auth-hardening',
// but a dependency declared as '03-01' was never resolving to it, silently
// putting the dependent plan in the same wave as its prerequisite.
test('depends_on short canonical prefix resolves against descriptive plan filename (#3266)', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });
// Plan 01: descriptive filename — id becomes '03-01-auth-hardening'
fs.writeFileSync(
path.join(phaseDir, '03-01-auth-hardening-PLAN.md'),
`---\nwave: 1\n---\n## Task 1\n`,
);
// Plan 02: depends on the canonical prefix '03-01' (not the full stem)
fs.writeFileSync(
path.join(phaseDir, '03-02-followup-PLAN.md'),
`---\ndepends_on:\n - '03-01'\n---\n## Task 1\n`,
);
const result = runGsdTools('phase-plan-index 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const waves = output.waves;
// Plan 01 must be in an earlier wave than plan 02
const wave01 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('03-01')));
const wave02 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('03-02')));
assert.ok(wave01 !== undefined, 'plan 03-01-auth-hardening should appear in waves');
assert.ok(wave02 !== undefined, 'plan 03-02-followup should appear in waves');
assert.ok(
Number(wave01) < Number(wave02),
`03-02 must be in a later wave than 03-01 (got wave01=${wave01}, wave02=${wave02})`,
);
});
test('detects checkpoints (autonomous: false)', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '03-01-PLAN.md'),
`---
wave: 1
autonomous: false
objective: Manual review needed
---
## Task 1: Review
`
);
const result = runGsdTools('phase-plan-index 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.has_checkpoints, true, 'should detect checkpoint');
assert.strictEqual(output.plans[0].autonomous, false, 'plan marked non-autonomous');
});
test('phase not found returns error', () => {
const result = runGsdTools('phase-plan-index 99', tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.error, 'Phase not found', 'should report phase not found');
});
// #3785 — case-insensitive depends_on resolution
test('#3785: depends_on reference with different case resolves to correct plan', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '20-case-insensitive');
fs.mkdirSync(phaseDir, { recursive: true });
// Plan A: filename uses uppercase suffix — plan ID becomes '20-01-Auth'
fs.writeFileSync(
path.join(phaseDir, '20-01-Auth-PLAN.md'),
`---\nwave: 1\nautonomous: true\ndepends_on: []\n---\n<objective>Plan A.</objective>\n`,
);
// Plan B: depends_on uses lowercase — must still resolve to Plan A
fs.writeFileSync(
path.join(phaseDir, '20-02-PLAN.md'),
`---\nwave: 2\nautonomous: true\ndepends_on:\n - 20-01-auth\n---\n<objective>Plan B.</objective>\n`,
);
const result = runGsdTools('phase-plan-index 20', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const waves = output.waves;
const wave01 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('20-01')));
const wave02 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('20-02')));
assert.ok(wave01 !== undefined, 'plan 20-01-Auth should appear in waves');
assert.ok(wave02 !== undefined, 'plan 20-02 should appear in waves');
assert.ok(
Number(wave01) < Number(wave02),
`20-02 must be in a later wave than 20-01 (got wave01=${wave01}, wave02=${wave02}) — DAG edge dropped (case mismatch)`,
);
// Lock canonical casing: plan ID must be preserved as-is from the filename, not lowercased.
const planA = output.plans.find(p => p.id.startsWith('20-01'));
assert.strictEqual(planA.id, '20-01-Auth', 'canonical casing must be preserved in plan ID');
// depends_on output must use canonical plan ID, not the user-typed casing.
const planB = output.plans.find(p => p.id === '20-02');
assert.deepStrictEqual(planB.depends_on, ['20-01-Auth'], 'depends_on output must resolve to canonical ID casing');
// No unresolved-dep warning
const warnings = output.warnings ?? [];
assert.ok(
!warnings.some(w => /unresolved/i.test(w)),
`Unexpected unresolved-dep warning: ${JSON.stringify(warnings)}`,
);
});
// #3785 adversarial: two plan IDs that are identical when case-folded must
// fail fast with a clear error instead of silently routing edges to the wrong plan.
// This test can only run on Linux where the filesystem is case-sensitive.
// On macOS/Windows (case-insensitive FS), writing both files silently collapses
// them to one file, so the collision scenario cannot be triggered via disk.
test('#3785 adversarial: two plan IDs differing only by case produce a collision error', {
skip: process.platform !== 'linux' ? 'case-insensitive filesystem — collision test requires Linux' : false,
}, () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '21-collision');
fs.mkdirSync(phaseDir, { recursive: true });
// '21-01-auth-PLAN.md' → id '21-01-auth'
// '21-01-Auth-PLAN.md' → id '21-01-Auth'
// Both lowercase to '21-01-auth' — collision.
fs.writeFileSync(
path.join(phaseDir, '21-01-auth-PLAN.md'),
`---\nautonomous: true\ndepends_on: []\n---\n<objective>lowercase.</objective>\n`,
);
fs.writeFileSync(
path.join(phaseDir, '21-01-Auth-PLAN.md'),
`---\nautonomous: true\ndepends_on: []\n---\n<objective>uppercase.</objective>\n`,
);
const result = runGsdTools('phase-plan-index 21', tmpDir);
// The command must exit with an error (non-success) naming the collision.
assert.ok(!result.success, 'phase-plan-index must fail when two plan IDs collide under case-folding');
assert.ok(
/collision/i.test(result.error ?? result.output ?? ''),
`Error output must mention 'collision', got: ${result.error ?? result.output}`,
);
});
// #3785 — all-uppercase depends_on value resolves to an all-lowercase plan ID
test('#3785: all-uppercase depends_on ref resolves to lowercase plan ID', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '22-uppercase-dep');
fs.mkdirSync(phaseDir, { recursive: true });
// Plan A: all-lowercase plan ID
fs.writeFileSync(
path.join(phaseDir, '22-01-setup-PLAN.md'),
`---\nwave: 1\nautonomous: true\ndepends_on: []\n---\n<objective>Plan A.</objective>\n`,
);
// Plan B: depends_on uses ALL-UPPERCASE — must still route the DAG edge to Plan A
fs.writeFileSync(
path.join(phaseDir, '22-02-PLAN.md'),
`---\nwave: 2\nautonomous: true\ndepends_on:\n - 22-01-SETUP\n---\n<objective>Plan B.</objective>\n`,
);
const result = runGsdTools('phase-plan-index 22', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const waves = output.waves;
const wave01 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('22-01')));
const wave02 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('22-02')));
assert.ok(wave01 !== undefined, 'plan 22-01-setup should appear in waves');
assert.ok(wave02 !== undefined, 'plan 22-02 should appear in waves');
assert.ok(
Number(wave01) < Number(wave02),
`22-02 must be in a later wave than 22-01 — all-uppercase dep should route correctly (got wave01=${wave01}, wave02=${wave02})`,
);
// depends_on output must use canonical plan ID (lowercase as-on-disk), not the uppercase ref
const planB = output.plans.find(p => p.id === '22-02');
assert.deepStrictEqual(planB.depends_on, ['22-01-setup'], 'depends_on output must resolve to canonical lowercase ID');
});
// #3785 — external (cross-phase) depends_on reference is kept as-is in output
// The Pass 3 mapping must return the original dep string when planMap has no entry for it.
test('#3785: external cross-phase depends_on ref is preserved as-is in output', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '23-external-dep');
fs.mkdirSync(phaseDir, { recursive: true });
// Plan A: references a plan in a different phase (01-some-other-phase) — planMap won't have it
fs.writeFileSync(
path.join(phaseDir, '23-01-PLAN.md'),
`---\nwave: 1\nautonomous: true\ndepends_on:\n - 01-01-prereq\n---\n<objective>Plan A.</objective>\n`,
);
const result = runGsdTools('phase-plan-index 23', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const planA = output.plans.find(p => p.id.startsWith('23-01'));
assert.ok(planA !== undefined, 'plan 23-01 should appear in output');
// External dep must be preserved verbatim — not dropped, not resolved
assert.deepStrictEqual(planA.depends_on, ['01-01-prereq'], 'external cross-phase dep must be kept as-is in output');
});
// #3785 — mixed-case canonical prefix in depends_on resolves via canonicalToId lookup
// e.g. depends_on: '22-01-SETUP' where extractCanonicalPlanId gives '22-01' keyed lowercase
test('#3785: mixed-case short canonical prefix in depends_on resolves via canonicalToId', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '24-canon-case');
fs.mkdirSync(phaseDir, { recursive: true });
// Plan A: descriptive filename — id becomes '24-01-auth-hardening'
fs.writeFileSync(
path.join(phaseDir, '24-01-auth-hardening-PLAN.md'),
`---\nwave: 1\nautonomous: true\ndepends_on: []\n---\n<objective>Plan A.</objective>\n`,
);
// Plan B: depends_on uses an uppercase short prefix '24-01' — canonicalToId maps '24-01' → '24-01-auth-hardening'
fs.writeFileSync(
path.join(phaseDir, '24-02-followup-PLAN.md'),
`---\nwave: 2\nautonomous: true\ndepends_on:\n - '24-01'\n---\n<objective>Plan B.</objective>\n`,
);
const result = runGsdTools('phase-plan-index 24', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const waves = output.waves;
const wave01 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('24-01')));
const wave02 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('24-02')));
assert.ok(wave01 !== undefined, '24-01-auth-hardening should appear in waves');
assert.ok(wave02 !== undefined, '24-02-followup should appear in waves');
assert.ok(
Number(wave01) < Number(wave02),
`24-02 must be in a later wave than 24-01 via canonicalToId lookup (got wave01=${wave01}, wave02=${wave02})`,
);
// depends_on output: '24-01' is the canonical prefix, not in planMap directly, so falls back to dep as-is
const planB = output.plans.find(p => p.id === '24-02-followup');
assert.ok(planB !== undefined, '24-02-followup plan must be in output');
// The dep '24-01' is not a planMap key (full id is '24-01-auth-hardening'), so output keeps '24-01'
assert.deepStrictEqual(planB.depends_on, ['24-01'], 'short canonical prefix dep falls through to as-is in Pass 3 output');
});
// #3785 — plans with no depends_on (empty array) still emit correct output without errors
test('#3785: plans with undefined/empty depends_on emit empty array without errors', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '25-no-deps');
fs.mkdirSync(phaseDir, { recursive: true });
// Plan with no depends_on key at all
fs.writeFileSync(
path.join(phaseDir, '25-01-PLAN.md'),
`---\nwave: 1\nautonomous: true\n---\n<objective>Plan A, no deps.</objective>\n`,
);
// Plan with explicit empty depends_on array
fs.writeFileSync(
path.join(phaseDir, '25-02-PLAN.md'),
`---\nwave: 1\nautonomous: true\ndepends_on: []\n---\n<objective>Plan B, explicit empty deps.</objective>\n`,
);
const result = runGsdTools('phase-plan-index 25', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const plan01 = output.plans.find(p => p.id === '25-01');
const plan02 = output.plans.find(p => p.id === '25-02');
assert.ok(plan01 !== undefined, 'plan 25-01 should appear in output');
assert.ok(plan02 !== undefined, 'plan 25-02 should appear in output');
assert.deepStrictEqual(plan01.depends_on, [], 'plan with no depends_on key must emit empty array');
assert.deepStrictEqual(plan02.depends_on, [], 'plan with explicit empty depends_on must emit empty array');
// Both independent plans land in the same wave
assert.deepStrictEqual(output.waves['1'], ['25-01', '25-02'], 'both no-dep plans should be in wave 1');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phase-plan-index — canonical XML format (template-aligned)
// ─────────────────────────────────────────────────────────────────────────────
describe('phase-plan-index canonical format', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('files_modified: underscore key is parsed correctly', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-ui');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '04-01-PLAN.md'),
`---
wave: 1
autonomous: true
files_modified: [src/App.tsx, src/index.ts]
---
<objective>
Build main application shell
Purpose: Entry point
Output: App component
</objective>
<tasks>
<task type="auto">
<name>Task 1: Create App component</name>
<files>src/App.tsx</files>
<action>Create component</action>
<verify>npm run build</verify>
<done>Component renders</done>
</task>
</tasks>
`
);
const result = runGsdTools('phase-plan-index 04', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(
output.plans[0].files_modified,
['src/App.tsx', 'src/index.ts'],
'files_modified with underscore should be parsed'
);
});
test('objective: extracted from <objective> XML tag, not frontmatter', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-ui');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '04-01-PLAN.md'),
`---
wave: 1
autonomous: true
files_modified: []
---
<objective>
Build main application shell
Purpose: Entry point for the SPA
Output: App.tsx with routing
</objective>
<tasks>
<task type="auto">
<name>Task 1: Scaffold</name>
<files>src/App.tsx</files>
<action>Create shell</action>
<verify>build passes</verify>
<done>App renders</done>
</task>
</tasks>
`
);
const result = runGsdTools('phase-plan-index 04', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.plans[0].objective,
'Build main application shell',
'objective should come from <objective> XML tag first line'
);
});
test('task_count: counts <task> XML tags', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-ui');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '04-01-PLAN.md'),
`---
wave: 1
autonomous: true
files_modified: []
---
<objective>
Create UI components
</objective>
<tasks>
<task type="auto">
<name>Task 1: Header</name>
<files>src/Header.tsx</files>
<action>Create header</action>
<verify>build</verify>
<done>Header renders</done>
</task>
<task type="auto">
<name>Task 2: Footer</name>
<files>src/Footer.tsx</files>
<action>Create footer</action>
<verify>build</verify>
<done>Footer renders</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>UI components</what-built>
<how-to-verify>Visit localhost:3000</how-to-verify>
<resume-signal>Type approved</resume-signal>
</task>
</tasks>
`
);
const result = runGsdTools('phase-plan-index 04', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.plans[0].task_count,
3,
'should count all 3 <task> XML tags'
);
});
test('all three fields work together in canonical plan format', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-ui');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '04-01-PLAN.md'),
`---
phase: 04-ui
plan: 01
type: execute
wave: 1
depends_on: []
files_modified: [src/components/Chat.tsx, src/app/api/chat/route.ts]
autonomous: true
requirements: [R1, R2]
---
<objective>
Implement complete Chat feature as vertical slice.
Purpose: Self-contained chat that can run parallel to other features.
Output: Chat component, API endpoints.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Create Chat component</name>
<files>src/components/Chat.tsx</files>
<action>Build chat UI with message list and input</action>
<verify>npm run build</verify>
<done>Chat component renders messages</done>
</task>
<task type="auto">
<name>Task 2: Create Chat API</name>
<files>src/app/api/chat/route.ts</files>
<action>GET /api/chat and POST /api/chat endpoints</action>
<verify>curl tests pass</verify>
<done>CRUD operations work</done>
</task>
</tasks>
<verification>
- [ ] npm run build succeeds
- [ ] API endpoints respond correctly
</verification>
`
);
const result = runGsdTools('phase-plan-index 04', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const plan = output.plans[0];
assert.strictEqual(plan.objective, 'Implement complete Chat feature as vertical slice.', 'objective from XML tag');
assert.deepStrictEqual(plan.files_modified, ['src/components/Chat.tsx', 'src/app/api/chat/route.ts'], 'files_modified with underscore');
assert.strictEqual(plan.task_count, 2, 'task_count from <task> XML tags');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// state-snapshot command
// ─────────────────────────────────────────────────────────────────────────────
describe('phase add command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('adds phase after highest existing', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0
### Phase 1: Foundation
**Goal:** Setup
### Phase 2: API
**Goal:** Build API
---
`
);
const result = runGsdTools('phase add User Dashboard', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, 3, 'should be phase 3');
assert.strictEqual(output.slug, 'user-dashboard');
// Verify directory created
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-user-dashboard')),
'directory should be created'
);
// Verify ROADMAP updated
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('### Phase 3: User Dashboard'), 'roadmap should include new phase');
assert.ok(roadmap.includes('**Depends on:** Phase 2'), 'should depend on previous');
});
test('handles empty roadmap', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n`
);
const result = runGsdTools('phase add Initial Setup', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, 1, 'should be phase 1');
});
test('phase add includes **Requirements**: TBD in new ROADMAP entry', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n`
);
const result = runGsdTools('phase add User Dashboard', 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('**Requirements**: TBD'), 'new phase entry should include Requirements TBD');
});
test('phase add ignores --raw instead of persisting it in the description', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n`
);
const result = runGsdTools(['phase', 'add', '--raw', 'User', 'Dashboard'], 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('### Phase 2: User Dashboard'), 'description should exclude --raw');
assert.ok(!roadmap.includes('--raw'), 'raw flag must not be persisted into ROADMAP.md');
});
test('phase add rejects unsupported flags and dangling --id', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n`
);
const unsupported = runGsdTools(['phase', 'add', '--unknown', 'Dashboard'], tmpDir);
assert.ok(!unsupported.success, 'unsupported flags should fail');
assert.match(unsupported.error, /phase add does not support --unknown/);
const dangling = runGsdTools(['phase', 'add', 'Dashboard', '--id'], tmpDir);
assert.ok(!dangling.success, 'dangling --id should fail');
assert.match(dangling.error, /--id requires a value/);
});
test('skips 999.x backlog phases when calculating next phase number', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0
### Phase 1: Foundation
**Goal:** Setup
### Phase 2: API
**Goal:** Build API
### Phase 3: UI
**Goal:** Build UI
### Phase 999.1: Future Idea A
**Goal:** Backlog item
### Phase 999.2: Future Idea B
**Goal:** Backlog item
---
`
);
const result = runGsdTools('phase add Dashboard', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, 4, 'should be phase 4, not 1000');
assert.strictEqual(output.slug, 'dashboard');
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '04-dashboard')),
'directory should be 04-dashboard, not 1000-dashboard'
);
});
test('CJS scanner [999, 1000] fixture: skips exactly 999 and returns 1001 (regression #3774)', () => {
// Locks the BLOCKER fix in phase.cjs: guards at :610, :624, :688, :698 must
// use === 999 (not >= 999). With >= 999, phase 1000 is excluded from the
// max-scan and the result collapses back toward 1 instead of 1001.
//
// GSD_WORKSTREAM=ws1 forces the CJS fallback in phase-command-router.cjs.
// When GSD_WORKSTREAM is set, planningDir resolves to
// .planning/workstreams/<ws>/ — so ROADMAP.md and phases/ live there.
const ws = 'ws1';
const planningBase = path.join(tmpDir, '.planning', 'workstreams', ws);
fs.mkdirSync(path.join(planningBase, 'phases'), { recursive: true });
fs.writeFileSync(
path.join(planningBase, 'ROADMAP.md'),
[
'# Roadmap',
'',
'## Current Milestone: v1.0',
'',
'### Phase 999: Backlog',
'',
'**Goal:** Backlog sentinel',
'**Plans:** 0 plans',
'',
'### Phase 1000: First Four-Digit Phase',
'',
'**Goal:** First canonical phase above backlog sentinel',
'**Requirements**: TBD',
'**Plans:** 1 plans',
'',
'Plans:',
'- [x] 1000-01 (initial work)',
'',
'---',
'*Last updated: 2026-05-21*',
'',
].join('\n')
);
// Create matching phase directories on disk (inside the workstream planning dir)
fs.mkdirSync(path.join(planningBase, 'phases', '999-backlog'), { recursive: true });
fs.mkdirSync(path.join(planningBase, 'phases', '1000-first-four-digit'), { recursive: true });
const result = runGsdTools('phase add After One Thousand', tmpDir, { GSD_WORKSTREAM: ws });
assert.ok(result.success, `CJS phase add failed: ${result.error}`);
const output = JSON.parse(result.output);
// Must be 1001: skips 999 (backlog sentinel), keeps 1000, adds 1.
// With the old >= 999 guard: phase 1000 is excluded → max stays 0 → result = 1.
assert.strictEqual(output.phase_number, 1001, 'CJS scanner must return 1001, not 1 (regression #3774)');
assert.ok(
fs.existsSync(path.join(planningBase, 'phases', '1001-after-one-thousand')),
'directory should be 1001-after-one-thousand'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phase add — orphan directory collision prevention (#2026)
// ─────────────────────────────────────────────────────────────────────────────
describe('phase add — orphan directory collision prevention (#2026)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('orphan directory with higher number than ROADMAP pushes maxPhase up', () => {
// Orphan directory 05-orphan exists on disk but is NOT in ROADMAP.md
const orphanDir = path.join(tmpDir, '.planning', 'phases', '05-orphan');
fs.mkdirSync(orphanDir, { recursive: true });
fs.writeFileSync(path.join(orphanDir, 'SUMMARY.md'), 'existing work');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'## Milestone v1',
'### Phase 1: First phase',
'**Plans:** 0 plans',
'---',
].join('\n')
);
const result = runGsdTools('phase add dashboard', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// ROADMAP max is 1, but orphan 05-orphan means disk max is 5 → new phase = 6
assert.strictEqual(output.phase_number, 6, 'should be phase 6 (orphan 05 pushes max to 5)');
// The new directory must be 06-dashboard, not 02-dashboard
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '06-dashboard')),
'new phase directory must be 06-dashboard, not collide with orphan 05-orphan'
);
// The orphan directory must be untouched
assert.ok(
fs.existsSync(path.join(orphanDir, 'SUMMARY.md')),
'orphan directory content must be preserved (not overwritten)'
);
});
test('orphan directories with 999.x prefix are skipped when calculating disk max', () => {
// 999.x backlog orphans must not inflate the next sequential phase number
const backlogOrphan = path.join(tmpDir, '.planning', 'phases', '999-backlog-stuff');
fs.mkdirSync(backlogOrphan, { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'### Phase 1: Foundation',
'**Plans:** 0 plans',
'---',
].join('\n')
);
const result = runGsdTools('phase add new-feature', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// ROADMAP max is 1, disk orphan is 999 (backlog) → should be ignored → new phase = 2
assert.strictEqual(output.phase_number, 2, 'backlog 999.x orphan must not inflate phase count');
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-new-feature')),
'new phase directory should be 02-new-feature'
);
});
test('project_code prefix in orphan directory name is stripped before comparing', () => {
// Orphan directory has project_code prefix e.g. CK-05-orphan
const orphanDir = path.join(tmpDir, '.planning', 'phases', 'CK-05-old-feature');
fs.mkdirSync(orphanDir, { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ project_code: 'CK' })
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'### Phase 1: Foundation',
'**Plans:** 0 plans',
'---',
].join('\n')
);
const result = runGsdTools('phase add new-feature', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// ROADMAP max is 1, disk has CK-05-old-feature → strip prefix → disk max is 5 → new phase = 6
assert.strictEqual(output.phase_number, 6, 'project_code prefix must be stripped before disk max calculation');
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', 'CK-06-new-feature')),
'new phase directory must be CK-06-new-feature'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phase add with project_code prefix
// ─────────────────────────────────────────────────────────────────────────────
describe('phase add with project_code', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('prefixes phase directory with project_code', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ project_code: 'CK' })
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n'
);
const result = runGsdTools('phase add User Dashboard', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, 2, 'should be phase 2');
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', 'CK-02-user-dashboard')),
'directory should have CK- prefix'
);
});
test('no prefix when project_code is null', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ project_code: null })
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n'
);
const result = runGsdTools('phase add User Dashboard', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Command failed: ${result.error}`);
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-user-dashboard')),
'directory should have no prefix'
);
});
test('find-phase resolves prefixed directories', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', 'CK-01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan');
const result = runGsdTools('find-phase 01', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, true, 'should find prefixed phase');
assert.strictEqual(output.phase_number, '01', 'should extract numeric phase number');
});
test('phases list sorts prefixed directories correctly', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'CK-02-api'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'CK-01-foundation'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'CK-03-ui'), { recursive: true });
const result = runGsdTools('phases list', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(
output.directories,
['CK-01-foundation', 'CK-02-api', 'CK-03-ui'],
'prefixed phases should sort numerically'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phase add-batch command (#2165)
// ─────────────────────────────────────────────────────────────────────────────
describe('phase add-batch command (#2165)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap v1.0',
'',
'### Phase 1: Foundation',
'**Goal:** Setup',
'',
'---',
'',
].join('\n')
);
});
afterEach(() => {
cleanup(tmpDir);
});
test('adds multiple phases with sequential numbers in a single call', () => {
// Use array form to avoid shell quoting issues with JSON args
const result = runGsdTools(['phase', 'add-batch', '--descriptions', '["Alpha","Beta","Gamma"]'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.count, 3, 'should report 3 phases added');
assert.strictEqual(output.phases[0].phase_number, 2);
assert.strictEqual(output.phases[1].phase_number, 3);
assert.strictEqual(output.phases[2].phase_number, 4);
assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-alpha')), '02-alpha dir must exist');
assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-beta')), '03-beta dir must exist');
assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'phases', '04-gamma')), '04-gamma dir must exist');
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('### Phase 2: Alpha'), 'roadmap should include Phase 2');
assert.ok(roadmap.includes('### Phase 3: Beta'), 'roadmap should include Phase 3');
assert.ok(roadmap.includes('### Phase 4: Gamma'), 'roadmap should include Phase 4');
});
test('no duplicate phase numbers when multiple add-batch calls are made sequentially', () => {
// Regression for #2165: parallel `phase add` invocations produced duplicates
// because each read disk state before any write landed. add-batch serializes
// the entire batch under a single lock so the next call sees the updated state.
const r1 = runGsdTools(['phase', 'add-batch', '--descriptions', '["Wave-One-A","Wave-One-B"]'], tmpDir);
assert.ok(r1.success, `First batch failed: ${r1.error}`);
const r2 = runGsdTools(['phase', 'add-batch', '--descriptions', '["Wave-Two-A","Wave-Two-B"]'], tmpDir);
assert.ok(r2.success, `Second batch failed: ${r2.error}`);
const out1 = JSON.parse(r1.output);
const out2 = JSON.parse(r2.output);
const allNums = [...out1.phases, ...out2.phases].map(p => p.phase_number);
const unique = new Set(allNums);
assert.strictEqual(unique.size, allNums.length, `Duplicate phase numbers detected: ${allNums}`);
// Directories must all exist and be unique
const dirs = fs.readdirSync(path.join(tmpDir, '.planning', 'phases'));
assert.strictEqual(dirs.length, 4, `Expected 4 phase dirs, got: ${dirs}`);
});
test('each phase directory contains a .gitkeep file', () => {
const result = runGsdTools(['phase', 'add-batch', '--descriptions', '["Setup","Build"]'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-setup', '.gitkeep')),
'.gitkeep must exist in 02-setup'
);
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-build', '.gitkeep')),
'.gitkeep must exist in 03-build'
);
});
test('returns error for empty descriptions array', () => {
const result = runGsdTools(['phase', 'add-batch', '--descriptions', '[]'], tmpDir);
assert.ok(!result.success, 'should fail on empty array');
});
test('returns error when --descriptions JSON is not an array', () => {
const result = runGsdTools(['phase', 'add-batch', '--descriptions', '{"one":"Alpha"}'], tmpDir);
assert.ok(!result.success, 'should fail on non-array JSON');
assert.match(result.error, /--descriptions must be a JSON array/);
});
test('returns error when --descriptions is missing its JSON value', () => {
const missing = runGsdTools(['phase', 'add-batch', '--descriptions'], tmpDir);
assert.ok(!missing.success, 'should fail on dangling --descriptions');
assert.match(missing.error, /--descriptions must be a JSON array/);
const flagValue = runGsdTools(['phase', 'add-batch', '--descriptions', '--raw'], tmpDir);
assert.ok(!flagValue.success, 'should fail when --descriptions value is another flag');
assert.match(flagValue.error, /--descriptions must be a JSON array/);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phase insert command
// ─────────────────────────────────────────────────────────────────────────────
describe('phase insert command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('inserts decimal phase after target', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Foundation
**Goal:** Setup
### Phase 2: API
**Goal:** Build API
`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
const result = runGsdTools('phase insert 1 Fix Critical Bug', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, '01.1', 'should be 01.1');
assert.strictEqual(output.after_phase, '1');
// Verify directory
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '01.1-fix-critical-bug')),
'decimal phase directory should be created'
);
// Verify ROADMAP
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('Phase 01.1: Fix Critical Bug (INSERTED)'), 'roadmap should include inserted phase');
});
test('increments decimal when siblings exist', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Foundation
**Goal:** Setup
### Phase 2: API
**Goal:** Build API
`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01.1-hotfix'), { recursive: true });
const result = runGsdTools('phase insert 1 Another Fix', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, '01.2', 'should be 01.2');
});
test('rejects missing phase', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n### Phase 1: Test\n**Goal:** Test\n`
);
const result = runGsdTools('phase insert 99 Fix Something', tmpDir);
assert.ok(!result.success, 'should fail for missing phase');
assert.ok(result.error.includes('not found'), 'error mentions not found');
});
test('handles padding mismatch between input and roadmap', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
## Phase 09.05: Existing Decimal Phase
**Goal:** Test padding
## Phase 09.1: Next Phase
**Goal:** Test
`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '09.05-existing'), { recursive: true });
// Pass unpadded "9.05" but roadmap has "09.05"
const result = runGsdTools('phase insert 9.05 Padding Test', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.after_phase, '9.05');
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('(INSERTED)'), 'roadmap should include inserted phase');
});
test('phase insert includes **Requirements**: TBD in new ROADMAP entry', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n### Phase 2: API\n**Goal:** Build API\n`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
const result = runGsdTools('phase insert 1 Fix Critical Bug', 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('**Requirements**: TBD'), 'inserted phase entry should include Requirements TBD');
});
test('reports actionable error for summary-only placeholder phase without detail section (#3098)', () => {
// #3098: a hybrid ROADMAP that has heading-style phases for some phases
// but only a bullet summary entry for phase 5 (the detail section is
// missing). Insert must fail with "missing a detail section" rather than
// silently inserting in bullet-style — because the surrounding ROADMAP
// uses headings, so the absent `### Phase 5:` is a genuine omission.
// (Compare with the #3815 case below: a purely bullet-style ROADMAP that
// has NO heading-style phases at all is valid and insert should succeed.)
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n### Phase 4: Foundation\n**Goal:** Setup\n\n- [ ] **Phase 5: Placeholder**\n`
);
const result = runGsdTools('phase insert 5 Hotfix', tmpDir);
assert.ok(!result.success, 'should fail when phase is summary-only placeholder in a heading-style ROADMAP');
assert.ok(result.error.includes('missing a detail section'));
});
test('phase insert rejects unsupported --dry-run flag explicitly (#3098)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Setup\n`
);
const result = runGsdTools('phase insert 1 Hotfix --dry-run', tmpDir);
assert.ok(!result.success, 'phase insert should reject unsupported --dry-run');
assert.ok(result.error.includes('does not support --dry-run'));
});
test('handles #### heading depth from multi-milestone roadmaps', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### v1.1 Milestone
#### Phase 5: Feature Work
**Goal:** Build features
#### Phase 6: Polish
**Goal:** Polish
`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '05-feature-work'), { recursive: true });
const result = runGsdTools('phase insert 5 Hotfix', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, '05.1');
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('Phase 05.1: Hotfix (INSERTED)'), 'roadmap should include inserted phase');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phase remove command
// ─────────────────────────────────────────────────────────────────────────────
describe('phase remove command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('removes phase directory and renumbers subsequent', () => {
// Setup 3 phases
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Foundation
**Goal:** Setup
**Depends on:** Nothing
### Phase 2: Auth
**Goal:** Authentication
**Depends on:** Phase 1
### Phase 3: Features
**Goal:** Core features
**Depends on:** Phase 2
`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
const p2 = path.join(tmpDir, '.planning', 'phases', '02-auth');
fs.mkdirSync(p2, { recursive: true });
fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan');
const p3 = path.join(tmpDir, '.planning', 'phases', '03-features');
fs.mkdirSync(p3, { recursive: true });
fs.writeFileSync(path.join(p3, '03-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p3, '03-02-PLAN.md'), '# Plan 2');
// Remove phase 2
const result = runGsdTools('phase remove 2', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.removed, '2');
assert.strictEqual(output.directory_deleted, '02-auth');
// Phase 3 should be renumbered to 02
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-features')),
'phase 3 should be renumbered to 02-features'
);
assert.ok(
!fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-features')),
'old 03-features should not exist'
);
// Files inside should be renamed
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-features', '02-01-PLAN.md')),
'plan file should be renumbered to 02-01'
);
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-features', '02-02-PLAN.md')),
'plan 2 should be renumbered to 02-02'
);
// ROADMAP should be updated
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(!roadmap.includes('Phase 2: Auth'), 'removed phase should not be in roadmap');
assert.ok(roadmap.includes('Phase 2: Features'), 'phase 3 should be renumbered to 2');
});
test('rejects removal of phase with summaries unless --force', () => {
const p1 = path.join(tmpDir, '.planning', 'phases', '01-test');
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');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n### Phase 1: Test\n**Goal:** Test\n`
);
// Should fail without --force
const result = runGsdTools('phase remove 1', tmpDir);
assert.ok(!result.success, 'should fail without --force');
assert.ok(result.error.includes('executed plan'), 'error mentions executed plans');
// Should succeed with --force
const forceResult = runGsdTools('phase remove 1 --force', tmpDir);
assert.ok(forceResult.success, `Force remove failed: ${forceResult.error}`);
});
test('bug-3409: supports --force before phase id', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n### Phase 1: A\n**Goal:** A\n### Phase 2: B\n**Goal:** B\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 1\n**Total Phases:** 2\n`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-b'), { recursive: true });
const result = runGsdTools('phase remove --force 2', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.removed, '2');
assert.strictEqual(output.directory_deleted, '02-b');
assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-b')));
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(state.includes('**Total Phases:** 1'), 'total phases should be decremented after real removal');
});
test('removes decimal phase and renumbers siblings', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n### Phase 6: Main\n**Goal:** Main\n### Phase 6.1: Fix A\n**Goal:** Fix A\n### Phase 6.2: Fix B\n**Goal:** Fix B\n### Phase 6.3: Fix C\n**Goal:** Fix C\n`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-main'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.1-fix-a'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.2-fix-b'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.3-fix-c'), { recursive: true });
const result = runGsdTools('phase remove 6.2', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
// 06.3 should become 06.2
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '06.2-fix-c')),
'06.3 should be renumbered to 06.2'
);
assert.ok(
!fs.existsSync(path.join(tmpDir, '.planning', 'phases', '06.3-fix-c')),
'old 06.3 should not exist'
);
});
test('updates STATE.md phase count', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n### Phase 1: A\n**Goal:** A\n### Phase 2: B\n**Goal:** B\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 1\n**Total Phases:** 2\n`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-b'), { recursive: true });
runGsdTools('phase remove 2', tmpDir);
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(state.includes('**Total Phases:** 1'), 'total phases should be decremented');
});
test('bug-2434: integer phase remove does not rename 999.x backlog directory', () => {
// Setup: an active integer phase 4 and a backlog phase 999.1
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Foundation
**Goal:** Setup
### Phase 2: Auth
**Goal:** Authentication
### Phase 3: Features
**Goal:** Core features
### Phase 4: Extras
**Goal:** Extra stuff
### Phase 999.1: Backlog item
**Goal:** Parked backlog task
`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-features'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '04-extras'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-item'), { recursive: true });
const result = runGsdTools('phase remove 4', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
// Backlog directory must remain at 999.1, not be decremented to 998.1
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-item')),
'backlog directory 999.1-backlog-item must not be renamed'
);
assert.ok(
!fs.existsSync(path.join(tmpDir, '.planning', 'phases', '998.1-backlog-item')),
'backlog directory must not be incorrectly renamed to 998.1'
);
});
test('bug-16: integer phase remove renumbers canonical phases above 999 while preserving 999.x backlog', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1199: Baseline
**Goal:** Before removal
Plans:
- [x] 1199-01-PLAN.md
### Phase 1200: Remove Me
**Goal:** Target phase
Plans:
- [ ] 1200-01-PLAN.md
### Phase 1201: Follow Up A
**Goal:** First phase after target
**Depends on:** Phase 1200
Plans:
- [ ] 1201-01-PLAN.md
### Phase 1202: Follow Up B
**Goal:** Second phase after target
**Depends on:** Phase 1201
Plans:
- [ ] 1202-01-PLAN.md
### Phase 999.1: Backlog Item
**Goal:** Parked backlog item
`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '1199-baseline'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '1200-remove-me'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '1201-follow-up-a'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '1202-follow-up-b'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-item'), { recursive: true });
const result = runGsdTools('phase remove 1200', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
// On-disk phase directories should be decremented by one above removedInt.
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '1200-follow-up-a')),
'1201-follow-up-a should be renamed to 1200-follow-up-a',
);
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '1201-follow-up-b')),
'1202-follow-up-b should be renamed to 1201-follow-up-b',
);
assert.ok(
!fs.existsSync(path.join(tmpDir, '.planning', 'phases', '1201-follow-up-a')),
'old 1201-follow-up-a directory should not remain',
);
assert.ok(
!fs.existsSync(path.join(tmpDir, '.planning', 'phases', '1202-follow-up-b')),
'old 1202-follow-up-b directory should not remain',
);
// Backlog 999.x must remain untouched.
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-item')),
'backlog directory 999.1-backlog-item must not be renamed',
);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(!roadmap.includes('### Phase 1200: Remove Me'), 'removed phase 1200 section must be gone');
assert.ok(roadmap.includes('### Phase 1200: Follow Up A'), 'phase 1201 should be renumbered to 1200');
assert.ok(roadmap.includes('### Phase 1201: Follow Up B'), 'phase 1202 should be renumbered to 1201');
assert.ok(!roadmap.includes('### Phase 1202: Follow Up B'), 'old phase 1202 heading must not remain');
assert.ok(roadmap.includes('**Depends on:** Phase 1200'), 'depends-on reference above removed phase should be decremented');
assert.ok(roadmap.includes('### Phase 999.1: Backlog Item'), 'backlog phase 999.1 heading must not be renumbered');
});
test('bug-2435: integer phase remove does not corrupt YYYY-MM-DD dates in ROADMAP.md', () => {
// Setup: removing phase 4 from a roadmap containing 2026-04-14 date strings
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Foundation
**Goal:** Setup
**Completed:** 2026-01-15
### Phase 2: Auth
**Goal:** Authentication
**Completed:** 2026-02-20
### Phase 3: Features
**Goal:** Core features
**Completed:** 2026-04-14
### Phase 4: Extras
**Goal:** Extra stuff
### Phase 5: Final
**Goal:** Final phase
`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-features'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '04-extras'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '05-final'), { recursive: true });
const result = runGsdTools('phase remove 4', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
// Dates must be preserved exactly
assert.ok(roadmap.includes('2026-01-15'), 'date 2026-01-15 must not be corrupted');
assert.ok(roadmap.includes('2026-02-20'), 'date 2026-02-20 must not be corrupted');
assert.ok(roadmap.includes('2026-04-14'), 'date 2026-04-14 must not be corrupted');
// Phase 5 should be renumbered to 4
assert.ok(roadmap.includes('Phase 4: Final'), 'Phase 5 should be renumbered to Phase 4');
});
test('bug-2435: integer phase remove does not corrupt date whose month matches removed phase number', () => {
// Setup: removing phase 4 from a roadmap containing 2026-05-14
// When renumbering phase 5→4, the regex must not replace "05-14" in the date "2026-05-14"
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Foundation
**Goal:** Setup
**Completed:** 2026-01-15
### Phase 2: Auth
**Goal:** Authentication
**Completed:** 2026-02-20
### Phase 3: Features
**Goal:** Core features
**Completed:** 2026-03-10
### Phase 4: Extras
**Goal:** Extra stuff
### Phase 5: Final
**Goal:** Final phase
**Due:** 2026-05-14
`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-features'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '04-extras'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '05-final'), { recursive: true });
const result = runGsdTools('phase remove 4', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
// Date "2026-05-14" must not be corrupted to "2026-04-14" when phase 5 is renumbered to 4
assert.ok(roadmap.includes('2026-05-14'), 'date 2026-05-14 must not be corrupted when renumbering phase 5→4');
assert.ok(!roadmap.includes('2026-04-14'), 'date must not be incorrectly mutated to 2026-04-14');
// Phase 5 should be renumbered to 4
assert.ok(roadmap.includes('Phase 4: Final'), 'Phase 5 should be renumbered to Phase 4');
});
test('bug-3355: integer phase remove renumbers roadmap once without collapsing later phases', () => {
const lines = ['# Roadmap', '', '## Progress', '', '| Phase | Plans | Status | Notes |', '|---|---:|---|---|'];
for (let n = 26; n <= 35; n++) {
lines.push(`| ${n}. Phase ${n} | 0/1 | Planned | - |`);
}
lines.push('');
for (let n = 26; n <= 35; n++) {
lines.push(`### Phase ${n}: Phase ${n}`);
lines.push(`#### Phase ${n}.1: Phase ${n}.1 follow-up`);
lines.push(`**Goal:** Build phase ${n}`);
lines.push(n % 2 === 0 ? `**Depends on**: Phase ${n - 1}` : `**Depends on:** Phase ${n - 1}`);
lines.push(`Plans: ${String(n).padStart(2, '0')}-01-PLAN.md`);
lines.push('');
const phaseDir = path.join(tmpDir, '.planning', 'phases', `${String(n).padStart(2, '0')}-phase-${n}`);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, `${String(n).padStart(2, '0')}-01-PLAN.md`), '# Plan');
}
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), lines.join('\n'));
const result = runGsdTools('phase remove 27 --force', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.equal((roadmap.match(/\|\s*27\.\s/g) || []).length, 1, 'progress row 27 appears once');
assert.equal((roadmap.match(/\|\s*28\.\s/g) || []).length, 1, 'progress row 28 appears once');
assert.equal((roadmap.match(/\|\s*34\.\s/g) || []).length, 1, 'progress row 34 appears once');
assert.equal((roadmap.match(/\|\s*35\.\s/g) || []).length, 0, 'old progress row 35 removed by renumber');
assert.equal((roadmap.match(/^### Phase 27:/gm) || []).length, 1, 'heading 27 appears once');
assert.equal((roadmap.match(/^### Phase 34:/gm) || []).length, 1, 'heading 34 appears once');
assert.equal((roadmap.match(/^### Phase 35:/gm) || []).length, 0, 'old heading 35 removed by renumber');
assert.equal((roadmap.match(/^#### Phase 27\.1:/gm) || []).length, 1, 'decimal heading 27.1 appears once');
assert.equal((roadmap.match(/^#### Phase 34\.1:/gm) || []).length, 1, 'decimal heading 34.1 appears once');
assert.equal((roadmap.match(/^#### Phase 35\.1:/gm) || []).length, 0, 'old decimal heading 35.1 removed by renumber');
assert.equal((roadmap.match(/\*\*Depends on\*\*:\s*Phase\s+28\b/g) || []).length, 1, 'bold depends-on with outside colon is decremented');
assert.equal((roadmap.match(/\*\*Depends on:\*\*\s*Phase\s+29\b/g) || []).length, 1, 'legacy bold depends-on with inside colon is decremented');
assert.equal((roadmap.match(/\*\*Depends on:\*\*\s*Phase\s+35\b/g) || []).length, 0, 'old depends-on 35 removed by renumber');
assert.equal((roadmap.match(/\b27-01-PLAN\.md\b/g) || []).length, 1, 'plan id 27-01 appears once');
assert.equal((roadmap.match(/\b34-01-PLAN\.md\b/g) || []).length, 1, 'plan id 34-01 appears once');
assert.equal((roadmap.match(/\b35-01-PLAN\.md\b/g) || []).length, 0, 'old plan id 35-01 removed by renumber');
for (let n = 27; n <= 34; n++) {
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', `${String(n).padStart(2, '0')}-phase-${n + 1}`)),
`phase directory ${n} should preserve original phase slug ${n + 1}`,
);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phase complete command
// ─────────────────────────────────────────────────────────────────────────────
describe('phase complete canonical verification gate (#1522)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
for (const [name, verificationStatus, expectedMessage] of [
['missing verification report', null, /No verification report found/i],
['unknown verification status', 'unexpected_value', /Unexpected verification status/i],
['human-needed verification status', 'human_needed', /Human verification required/i],
['gap-bearing verification status', 'gaps_found', /Gaps found/i],
]) {
test(`blocks ${name} before mutating ROADMAP or STATE`, () => {
writePhaseCompleteVerificationGateFixture(tmpDir, verificationStatus);
const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md');
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const beforeRoadmap = fs.readFileSync(roadmapPath, 'utf-8');
const beforeState = fs.readFileSync(statePath, 'utf-8');
const result = runGsdTools(['--json-errors', 'phase', 'complete', '1'], tmpDir);
assert.equal(result.success, false, 'phase complete must fail when verification has not passed');
const errorPayload = JSON.parse(result.error);
assert.equal(errorPayload.reason, 'phase_verification_incomplete');
assert.match(errorPayload.message, expectedMessage);
assert.equal(fs.readFileSync(roadmapPath, 'utf-8'), beforeRoadmap);
assert.equal(fs.readFileSync(statePath, 'utf-8'), beforeState);
});
}
test('allows passed verification to complete and advance the phase', () => {
writePhaseCompleteVerificationGateFixture(tmpDir, 'passed');
const result = runGsdTools(['phase', 'complete', '1'], tmpDir);
assert.equal(result.success, true, `phase complete failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.equal(output.completed_phase, '1');
assert.equal(output.next_phase, '02');
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.match(roadmap, /- \[x\] Phase 1: Foundation/);
assert.match(state, /\*\*Current Phase:\*\* 02/);
});
test('blocks stale passed verification when summaries changed later', () => {
writePhaseCompleteVerificationGateFixture(tmpDir, 'passed');
const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md');
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const summaryPath = path.join(tmpDir, '.planning', 'phases', '01-foundation', '01-01-SUMMARY.md');
const verificationPath = path.join(tmpDir, '.planning', 'phases', '01-foundation', '01-VERIFICATION.md');
const beforeRoadmap = fs.readFileSync(roadmapPath, 'utf-8');
const beforeState = fs.readFileSync(statePath, 'utf-8');
const older = new Date('2025-01-01T00:00:00.000Z');
const newer = new Date('2025-01-01T00:01:00.000Z');
fs.utimesSync(verificationPath, older, older);
fs.utimesSync(summaryPath, newer, newer);
const result = runGsdTools(['--json-errors', 'phase', 'complete', '1'], tmpDir);
assert.equal(result.success, false, 'phase complete must fail when verification is stale');
const errorPayload = JSON.parse(result.error);
assert.equal(errorPayload.reason, 'phase_verification_incomplete');
assert.match(errorPayload.message, /stale/i);
assert.match(errorPayload.message, /\/gsd:verify-work 0?1/);
assert.equal(fs.readFileSync(roadmapPath, 'utf-8'), beforeRoadmap);
assert.equal(fs.readFileSync(statePath, 'utf-8'), beforeState);
});
});
describe('phase complete command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('marks phase complete and transitions to next', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] Phase 1: Foundation
- [ ] Phase 2: API
### Phase 1: Foundation
**Goal:** Setup
**Plans:** 1 plans
### Phase 2: API
**Goal:** Build API
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Current Phase Name:** Foundation\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working on phase 1\n`
);
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');
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.completed_phase, '1');
assert.strictEqual(output.plans_executed, '1/1');
assert.strictEqual(output.next_phase, '02');
assert.strictEqual(output.is_last_phase, false);
// Verify STATE.md updated
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(state.includes('**Current Phase:** 02'), 'should advance to phase 02');
assert.ok(state.includes('**Status:** Ready to plan'), 'status should be ready to plan');
assert.ok(state.includes('**Current Plan:** Not started'), 'plan should be reset');
// Verify ROADMAP checkbox
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('[x]'), 'phase should be checked off');
assert.ok(roadmap.includes('completed'), 'completion date should be added');
});
test('detects last phase in milestone', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n### Phase 1: Only Phase\n**Goal:** Everything\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-only-phase');
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');
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.is_last_phase, true, 'should detect last phase');
assert.strictEqual(output.next_phase, null, 'no next phase');
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(state.includes('Milestone complete'), 'status should be milestone complete');
});
// #1591: when the active milestone's phase checklist is wrapped in a
// <details> block AND phases are written as `- [ ] Phase N:` checkbox list
// items (not `### Phase N:` headings), phase.complete's next-phase enumerator
// saw no further phases → is_last_phase=true, next_phase=null on a mid-
// milestone phase, and STATE.md was wrongly marked "Milestone complete" with
// total_phases decremented. extractCurrentMilestone correctly surfaces the
// <details>-wrapped checklist; the defect was the heading-only phasePattern
// at the isLastPhase enumerator not recognizing checkbox-list phase items.
test('#1591: <details>-wrapped checkbox checklist — mid-milestone phase is NOT last', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# ROADMAP',
'',
'## Phases',
'',
'<details>',
'<summary>✅ v1.0 First (Phases 1–3) — SHIPPED</summary>',
'',
'- [x] Phase 1: a',
'- [x] Phase 2: b',
'- [x] Phase 3: c',
'',
'</details>',
'',
'<details>',
'<summary>🚀 v2.0 Second (Phases 36–38) — IN PLANNING</summary>',
'',
'- [x] Phase 36: first (completed)',
'- [ ] Phase 37: second',
'- [ ] Phase 38: third',
'',
'</details>',
'',
'## Backlog',
'',
'### Phase 999.1: future (BACKLOG)',
'',
].join('\n')
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'---',
'gsd_state_version: 1.0',
'milestone: v2.0',
'milestone_name: Second',
'current_phase: "36"',
'status: executing',
'---',
'',
'# GSD State',
'',
'**Current Phase:** 36',
'**Status:** Executing Phase 36',
'',
].join('\n')
);
// Only the COMPLETING phase (36) has a directory. Phases 37/38 exist only
// as `- [ ]` checklist items in the ROADMAP — they are not yet started, so
// they have no phase dirs. This is the @Azd325 scenario: the disk-based
// next-phase resolver finds nothing, and the roadmap-enumeration fallback
// (the heading-only phasePattern) is the only path that can find Phase 37.
const d36 = path.join(tmpDir, '.planning', 'phases', '36-first');
fs.mkdirSync(d36, { recursive: true });
fs.writeFileSync(path.join(d36, '36-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(d36, '36-SUMMARY.md'), '# Summary\n');
const result = runVerifiedPhaseComplete('phase complete 36', tmpDir);
assert.ok(result.success, `phase complete failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.is_last_phase,
false,
'Phase 36 of 36–38 must NOT be last — Phases 37/38 are still open `- [ ]` (#1591)',
);
assert.strictEqual(
output.next_phase,
'37',
'next_phase must resolve to 37 from the <details>-wrapped checkbox checklist (#1591)',
);
// Cascade check: a wrong is_last_phase=true previously wrote "Milestone
// complete" + decremented total_phases. With the fix, the milestone is
// still in progress.
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(
!/Milestone complete/i.test(state),
'a mid-milestone phase must not flip STATE.md to "Milestone complete" (#1591)',
);
});
// #1752: the #1591 follow-up — when phase.complete wrongly returned
// is_last_phase=true on a <details>-wrapped mid-milestone checklist, the
// milestone-complete cascade also DECREMENTED progress.total_phases (e.g.
// 8 -> 7) and flipped status. Same root cause, distinct symptom. With the
// #1591 fix (is_last_phase=false), the decrement must not occur: with all 8
// phase dirs on disk, total_phases stays 8 and status does not flip.
test('#1752: <details>-wrapped checklist — total_phases is NOT decremented on a mid-milestone phase', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# ROADMAP',
'',
'## Phases',
'',
'<details>',
'<summary>✅ v1.0 First (Phases 1–3) — SHIPPED</summary>',
'',
'- [x] Phase 1: a',
'- [x] Phase 2: b',
'- [x] Phase 3: c',
'',
'</details>',
'',
'<details>',
'<summary>🚀 v2.0 Second (Phases 36–43) — IN PLANNING</summary>',
'',
'- [x] Phase 36: first (completed)',
'- [ ] Phase 37: second',
'- [ ] Phase 38: third',
'- [ ] Phase 39: fourth',
'',
'</details>',
'',
].join('\n')
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'---',
'gsd_state_version: 1.0',
'milestone: v2.0',
'milestone_name: Second',
'current_phase: "36"',
'status: executing',
'progress:',
' total_phases: 8',
' completed_phases: 5',
' percent: 62',
'---',
'',
'# GSD State',
'',
'**Current Phase:** 36',
'**Status:** Executing Phase 36',
'',
].join('\n')
);
// All 8 phase dirs on disk (Phases 36–43) so the disk count is 8 — the
// reporter's real state. Before the #1591 fix, phase.complete 36 returned
// is_last_phase=true (no Phase 37+ heading match) and the milestone-complete
// path DECREMENTED total_phases 8 -> 7.
const names = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth'];
for (let i = 0; i < 8; i++) {
const num = String(36 + i);
const d = path.join(tmpDir, '.planning', 'phases', `${num}-${names[i]}`);
fs.mkdirSync(d, { recursive: true });
fs.writeFileSync(path.join(d, `${num}-PLAN.md`), '# Plan\n');
}
const result = runVerifiedPhaseComplete('phase complete 36', tmpDir);
assert.ok(result.success, `phase complete failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.is_last_phase, false, 'is_last_phase must be false (#1752 cascade root)');
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(
!/Milestone complete/i.test(state),
'a mid-milestone phase must not flip STATE.md to "Milestone complete" (#1752)',
);
const tpMatch = state.match(/total_phases:\s*(\d+)/);
assert.ok(tpMatch, 'STATE.md must carry a total_phases value after phase.complete');
assert.notStrictEqual(
parseInt(tpMatch[1], 10),
7,
'total_phases must NOT be decremented to 7 — the #1752 cascade of the false is_last_phase',
);
});
test('updates REQUIREMENTS.md traceability when phase completes', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] Phase 1: Auth
### Phase 1: Auth
**Goal:** User authentication
**Requirements:** AUTH-01, AUTH-02
**Plans:** 1 plans
### Phase 2: API
**Goal:** Build API
**Requirements:** API-01
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
`# Requirements
## v1 Requirements
### Authentication
- [ ] **AUTH-01**: User can sign up with email
- [ ] **AUTH-02**: User can log in
- [ ] **AUTH-03**: User can reset password
### API
- [ ] **API-01**: REST endpoints
## Traceability
| Requirement | Phase | Status |
|-------------|-------|--------|
| AUTH-01 | Phase 1 | Pending |
| AUTH-02 | Phase 1 | Pending |
| AUTH-03 | Phase 2 | Pending |
| API-01 | Phase 2 | Pending |
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
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');
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
// Checkboxes updated for phase 1 requirements
assert.ok(req.includes('- [x] **AUTH-01**'), 'AUTH-01 checkbox should be checked');
assert.ok(req.includes('- [x] **AUTH-02**'), 'AUTH-02 checkbox should be checked');
// Other requirements unchanged
assert.ok(req.includes('- [ ] **AUTH-03**'), 'AUTH-03 should remain unchecked');
assert.ok(req.includes('- [ ] **API-01**'), 'API-01 should remain unchecked');
// Traceability table updated
assert.ok(req.includes('| AUTH-01 | Phase 1 | Complete |'), 'AUTH-01 status should be Complete');
assert.ok(req.includes('| AUTH-02 | Phase 1 | Complete |'), 'AUTH-02 status should be Complete');
assert.ok(req.includes('| AUTH-03 | Phase 2 | Pending |'), 'AUTH-03 should remain Pending');
assert.ok(req.includes('| API-01 | Phase 2 | Pending |'), 'API-01 should remain Pending');
});
test('handles requirements with bracket format [REQ-01, REQ-02]', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] Phase 1: Auth
### Phase 1: Auth
**Goal:** User authentication
**Requirements:** [AUTH-01, AUTH-02]
**Plans:** 1 plans
### Phase 2: API
**Goal:** Build API
**Requirements:** [API-01]
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
`# Requirements
## v1 Requirements
### Authentication
- [ ] **AUTH-01**: User can sign up with email
- [ ] **AUTH-02**: User can log in
- [ ] **AUTH-03**: User can reset password
### API
- [ ] **API-01**: REST endpoints
## Traceability
| Requirement | Phase | Status |
|-------------|-------|--------|
| AUTH-01 | Phase 1 | Pending |
| AUTH-02 | Phase 1 | Pending |
| AUTH-03 | Phase 2 | Pending |
| API-01 | Phase 2 | Pending |
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
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');
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
// Checkboxes updated for phase 1 requirements (brackets stripped)
assert.ok(req.includes('- [x] **AUTH-01**'), 'AUTH-01 checkbox should be checked');
assert.ok(req.includes('- [x] **AUTH-02**'), 'AUTH-02 checkbox should be checked');
// Other requirements unchanged
assert.ok(req.includes('- [ ] **AUTH-03**'), 'AUTH-03 should remain unchecked');
assert.ok(req.includes('- [ ] **API-01**'), 'API-01 should remain unchecked');
// Traceability table updated
assert.ok(req.includes('| AUTH-01 | Phase 1 | Complete |'), 'AUTH-01 status should be Complete');
assert.ok(req.includes('| AUTH-02 | Phase 1 | Complete |'), 'AUTH-02 status should be Complete');
assert.ok(req.includes('| AUTH-03 | Phase 2 | Pending |'), 'AUTH-03 should remain Pending');
assert.ok(req.includes('| API-01 | Phase 2 | Pending |'), 'API-01 should remain Pending');
});
test('handles phase with no requirements mapping', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] Phase 1: Setup
### Phase 1: Setup
**Goal:** Project setup (no requirements)
**Plans:** 1 plans
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
`# Requirements
## v1 Requirements
- [ ] **REQ-01**: Some requirement
## Traceability
| Requirement | Phase | Status |
|-------------|-------|--------|
| REQ-01 | Phase 2 | Pending |
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
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');
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
// REQUIREMENTS.md should be unchanged
const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
assert.ok(req.includes('- [ ] **REQ-01**'), 'REQ-01 should remain unchecked');
assert.ok(req.includes('| REQ-01 | Phase 2 | Pending |'), 'REQ-01 should remain Pending');
});
test('handles missing REQUIREMENTS.md gracefully', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] Phase 1: Foundation
**Requirements:** REQ-01
### Phase 1: Foundation
**Goal:** Setup
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
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');
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
assert.ok(result.success, `Command should succeed even without REQUIREMENTS.md: ${result.error}`);
});
test('returns requirements_updated field in result', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] Phase 1: Auth
### Phase 1: Auth
**Goal:** User authentication
**Requirements:** AUTH-01
**Plans:** 1 plans
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
`# Requirements
## v1 Requirements
- [ ] **AUTH-01**: User can sign up
## Traceability
| Requirement | Phase | Status |
|-------------|-------|--------|
| AUTH-01 | Phase 1 | Pending |
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
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');
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.requirements_updated, true, 'requirements_updated should be true');
});
test('handles In Progress status in traceability table', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] Phase 1: Auth
### Phase 1: Auth
**Goal:** User authentication
**Requirements:** AUTH-01, AUTH-02
**Plans:** 1 plans
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
`# Requirements
## v1 Requirements
- [ ] **AUTH-01**: User can sign up
- [ ] **AUTH-02**: User can log in
## Traceability
| Requirement | Phase | Status |
|-------------|-------|--------|
| AUTH-01 | Phase 1 | In Progress |
| AUTH-02 | Phase 1 | Pending |
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
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');
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
assert.ok(req.includes('| AUTH-01 | Phase 1 | Complete |'), 'In Progress should become Complete');
assert.ok(req.includes('| AUTH-02 | Phase 1 | Complete |'), 'Pending should become Complete');
});
test('scoped regex does not cross phase boundaries', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] Phase 1: Setup
- [ ] Phase 2: Auth
### Phase 1: Setup
**Goal:** Project setup
**Plans:** 1 plans
### Phase 2: Auth
**Goal:** User authentication
**Requirements:** AUTH-01
**Plans:** 0 plans
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
`# Requirements
## v1 Requirements
- [ ] **AUTH-01**: User can sign up
## Traceability
| Requirement | Phase | Status |
|-------------|-------|--------|
| AUTH-01 | Phase 2 | Pending |
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Current Phase Name:** Setup\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
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');
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true });
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
// Phase 1 has no Requirements field, so Phase 2's AUTH-01 should NOT be updated
const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
assert.ok(req.includes('- [ ] **AUTH-01**'), 'AUTH-01 should remain unchecked (belongs to Phase 2)');
assert.ok(req.includes('| AUTH-01 | Phase 2 | Pending |'), 'AUTH-01 should remain Pending (belongs to Phase 2)');
});
test('handles multi-level decimal phase without regex crash', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [x] Phase 3: Lorem
- [x] Phase 3.2: Ipsum
- [ ] Phase 3.2.1: Dolor Sit
- [ ] Phase 4: Amet
### Phase 3: Lorem
**Goal:** Setup
**Plans:** 1/1 plans complete
**Requirements:** LOR-01
### Phase 3.2: Ipsum
**Goal:** Build
**Plans:** 1/1 plans complete
**Requirements:** IPS-01
### Phase 03.2.1: Dolor Sit Polish (INSERTED)
**Goal:** Polish
**Plans:** 1/1 plans complete
### Phase 4: Amet
**Goal:** Deliver
**Requirements:** AMT-01: Filter items by category with AND logic (items matching ALL selected categories)
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
`# Requirements
- [ ] **LOR-01**: Lorem database schema
- [ ] **IPS-01**: Ipsum rendering engine
- [ ] **AMT-01**: Filter items by category
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State
**Current Phase:** 03.2.1
**Current Phase Name:** Dolor Sit Polish
**Status:** Execution complete
**Current Plan:** 03.2.1-01
**Last Activity:** 2025-01-01
**Last Activity Description:** Working
`
);
const p32 = path.join(tmpDir, '.planning', 'phases', '03.2-ipsum');
const p321 = path.join(tmpDir, '.planning', 'phases', '03.2.1-dolor-sit');
const p4 = path.join(tmpDir, '.planning', 'phases', '04-amet');
fs.mkdirSync(p32, { recursive: true });
fs.mkdirSync(p321, { recursive: true });
fs.mkdirSync(p4, { recursive: true });
fs.writeFileSync(path.join(p321, '03.2.1-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p321, '03.2.1-01-SUMMARY.md'), '# Summary');
const result = runVerifiedPhaseComplete('phase complete 03.2.1', tmpDir);
assert.ok(result.success, `Command should not crash on regex metacharacters: ${result.error}`);
const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
assert.ok(req.includes('- [ ] **AMT-01**'), 'AMT-01 should remain unchanged');
});
test('preserves Milestone column in 5-column progress table', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] Phase 1: Foundation
### Phase 1: Foundation
**Goal:** Setup
**Plans:** 1 plans
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
|-------|-----------|----------------|--------|-----------|
| 1. Foundation | v1.0 | 0/1 | Planned | |
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
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');
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
const rowMatch = roadmap.match(/^\|[^\r\n]*1\. Foundation[^\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], 'v1.0', 'Milestone column should be preserved');
assert.ok(cells[3].includes('Complete'), 'Status column should be Complete');
});
test('updates STATE.md with plain format fields (no bold)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n### Phase 1: Only\n**Goal:** Test\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\nPhase: 1 of 1 (Only)\nStatus: In progress\nPlan: 01-01\nLast Activity: 2025-01-01\nLast Activity Description: Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-only');
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');
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(state.includes('Milestone complete'), 'plain Status field should be updated');
assert.ok(state.includes('Not started'), 'plain Plan field should be updated');
// Verify compound format preserved
assert.ok(state.match(/Phase:.*of\s+1/), 'should preserve "of N" in compound Phase format');
});
test('updates Plans Complete column in 4-column progress table', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] Phase 1: Foundation
- [ ] Phase 2: API
### Phase 1: Foundation
**Goal:** Setup
**Plans:** 1 plans
### Phase 2: API
**Goal:** Build API
## Progress
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Foundation | 0/1 | Not started | - |
| 2. API | 0/1 | Not started | - |
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
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');
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
const rowMatch = roadmap.match(/^\|[^\r\n]*1\. Foundation[^\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, 4, 'should have 4 columns');
assert.strictEqual(cells[1], '1/1', 'Plans Complete column should be updated to 1/1');
assert.ok(cells[2].includes('Complete'), 'Status column should be Complete');
});
test('updates Plans Complete column in 5-column progress table', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] Phase 1: Foundation
### Phase 1: Foundation
**Goal:** Setup
**Plans:** 1 plans
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
|-------|-----------|----------------|--------|-----------|
| 1. Foundation | v1.0 | 0/1 | Planned | |
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
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');
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
const rowMatch = roadmap.match(/^\|[^\r\n]*1\. Foundation[^\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[2], '1/1', 'Plans Complete column should be updated to 1/1');
assert.ok(cells[3].includes('Complete'), 'Status column should be Complete');
});
test('marks plan-level checkboxes on phase complete', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] Phase 1: Foundation
### Phase 1: Foundation
**Goal:** Setup
**Plans:** 2 plans
Plans:
- [ ] 01-01-PLAN.md \u2014 Schema migration
- [ ] 01-02-PLAN.md \u2014 Auth setup
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-02\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
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');
fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Summary');
const result = runVerifiedPhaseComplete('phase complete 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('[x] 01-01-PLAN.md'), 'plan 01-01 checkbox should be checked');
assert.ok(roadmap.includes('[x] 01-02-PLAN.md'), 'plan 01-02 checkbox should be checked');
assert.ok(!roadmap.includes('[ ] 01-01-PLAN.md'), 'plan 01-01 should not remain unchecked');
assert.ok(!roadmap.includes('[ ] 01-02-PLAN.md'), 'plan 01-02 should not remain unchecked');
});
test('marks bold-wrapped plan-level checkboxes on phase complete', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] Phase 1: Foundation
### Phase 1: Foundation
**Goal:** Setup
**Plans:** 2 plans
Plans:
- [ ] **01-01**: Schema migration
- [ ] **01-02**: Auth setup
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-02\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
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');
fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Summary');
const result = runVerifiedPhaseComplete('phase complete 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('[x] **01-01**'), 'bold plan 01-01 checkbox should be checked');
assert.ok(roadmap.includes('[x] **01-02**'), 'bold plan 01-02 checkbox should be checked');
assert.ok(!roadmap.includes('[ ] **01-01**'), 'bold plan 01-01 should not remain unchecked');
assert.ok(!roadmap.includes('[ ] **01-02**'), 'bold plan 01-02 should not remain unchecked');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// comparePhaseNum and normalizePhaseName (imported directly)
// ─────────────────────────────────────────────────────────────────────────────
const { comparePhaseNum, normalizePhaseName } = require('../gsd-core/bin/lib/phase-id.cjs');
describe('comparePhaseNum', () => {
test('sorts integer phases numerically', () => {
assert.ok(comparePhaseNum('2', '10') < 0);
assert.ok(comparePhaseNum('10', '2') > 0);
assert.strictEqual(comparePhaseNum('5', '5'), 0);
});
test('sorts decimal phases correctly', () => {
assert.ok(comparePhaseNum('12', '12.1') < 0);
assert.ok(comparePhaseNum('12.1', '12.2') < 0);
assert.ok(comparePhaseNum('12.2', '13') < 0);
});
test('sorts letter-suffix phases correctly', () => {
assert.ok(comparePhaseNum('12', '12A') < 0);
assert.ok(comparePhaseNum('12A', '12B') < 0);
assert.ok(comparePhaseNum('12B', '13') < 0);
});
test('sorts hybrid phases correctly', () => {
assert.ok(comparePhaseNum('12A', '12A.1') < 0);
assert.ok(comparePhaseNum('12A.1', '12A.2') < 0);
assert.ok(comparePhaseNum('12A.2', '12B') < 0);
});
test('handles full sort order', () => {
const phases = ['13', '12B', '12A.2', '12', '12.1', '12A', '12A.1', '12.2'];
phases.sort(comparePhaseNum);
assert.deepStrictEqual(phases, ['12', '12.1', '12.2', '12A', '12A.1', '12A.2', '12B', '13']);
});
test('handles directory names with slugs', () => {
const dirs = ['13-deploy', '12B-hotfix', '12A.1-bugfix', '12-foundation', '12.1-inserted', '12A-split'];
dirs.sort(comparePhaseNum);
assert.deepStrictEqual(dirs, [
'12-foundation', '12.1-inserted', '12A-split', '12A.1-bugfix', '12B-hotfix', '13-deploy'
]);
});
test('case insensitive letter matching', () => {
assert.ok(comparePhaseNum('12a', '12B') < 0);
assert.ok(comparePhaseNum('12A', '12b') < 0);
assert.strictEqual(comparePhaseNum('12a', '12A'), 0);
});
test('sorts multi-level decimal phases correctly', () => {
assert.ok(comparePhaseNum('3.2', '3.2.1') < 0);
assert.ok(comparePhaseNum('3.2.1', '3.2.2') < 0);
assert.ok(comparePhaseNum('3.2.1', '3.3') < 0);
assert.ok(comparePhaseNum('3.2.1', '4') < 0);
assert.strictEqual(comparePhaseNum('3.2.1', '3.2.1'), 0);
});
test('falls back to localeCompare for non-phase strings', () => {
const result = comparePhaseNum('abc', 'def');
assert.strictEqual(typeof result, 'number');
});
});
describe('normalizePhaseName', () => {
test('pads single-digit integers', () => {
assert.strictEqual(normalizePhaseName('3'), '03');
assert.strictEqual(normalizePhaseName('12'), '12');
});
test('handles decimal phases', () => {
assert.strictEqual(normalizePhaseName('3.1'), '03.1');
assert.strictEqual(normalizePhaseName('12.2'), '12.2');
});
test('handles letter-suffix phases', () => {
assert.strictEqual(normalizePhaseName('3A'), '03A');
assert.strictEqual(normalizePhaseName('12B'), '12B');
});
test('handles hybrid phases', () => {
assert.strictEqual(normalizePhaseName('3A.1'), '03A.1');
assert.strictEqual(normalizePhaseName('12A.2'), '12A.2');
});
test('preserves letter case', () => {
assert.strictEqual(normalizePhaseName('3a'), '03a');
assert.strictEqual(normalizePhaseName('12b.1'), '12b.1');
});
test('handles multi-level decimal phases', () => {
assert.strictEqual(normalizePhaseName('3.2.1'), '03.2.1');
assert.strictEqual(normalizePhaseName('12.3.4'), '12.3.4');
});
test('returns non-matching input unchanged', () => {
assert.strictEqual(normalizePhaseName('abc'), 'abc');
});
});
describe('letter-suffix phase sorting', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('lists letter-suffix phases in correct order', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12-foundation'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12.1-inserted'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12A-split'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12A.1-bugfix'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12B-hotfix'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '13-deploy'), { recursive: true });
const result = runGsdTools('phases list', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(
output.directories,
['12-foundation', '12.1-inserted', '12A-split', '12A.1-bugfix', '12B-hotfix', '13-deploy'],
'letter-suffix phases should sort correctly'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// milestone-scoped next-phase in phase complete
// ─────────────────────────────────────────────────────────────────────────────
describe('phase complete milestone-scoped next-phase', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('finds next phase within milestone, ignoring prior milestone dirs', () => {
// ROADMAP lists phases 5-6 (current milestone v2.0)
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'## Roadmap v2.0: Release',
'',
'- [ ] Phase 5: Auth',
'- [ ] Phase 6: Dashboard',
'',
'### Phase 5: Auth',
'**Goal:** Add authentication',
'**Plans:** 1 plans',
'',
'### Phase 6: Dashboard',
'**Goal:** Build dashboard',
].join('\n')
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# State\n\n**Current Phase:** 05\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 05-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n'
);
// Disk has dirs 01-06 (01-04 completed from prior milestone)
for (let i = 1; i <= 4; i++) {
const padded = String(i).padStart(2, '0');
const phaseDir = path.join(tmpDir, '.planning', 'phases', `${padded}-old-phase`);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, `${padded}-01-PLAN.md`), '# Plan');
fs.writeFileSync(path.join(phaseDir, `${padded}-01-SUMMARY.md`), '# Summary');
}
// Phase 5 — completing this one
const p5 = path.join(tmpDir, '.planning', 'phases', '05-auth');
fs.mkdirSync(p5, { recursive: true });
fs.writeFileSync(path.join(p5, '05-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p5, '05-01-SUMMARY.md'), '# Summary');
// Phase 6 — next phase in milestone
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-dashboard'), { recursive: true });
const result = runVerifiedPhaseComplete('phase complete 5', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.is_last_phase, false, 'should NOT be last phase — phase 6 is in milestone');
assert.strictEqual(output.next_phase, '06', 'next phase should be 06');
});
test('detects last phase when only milestone phases are considered', () => {
// ROADMAP lists only phase 5 (current milestone)
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'## Roadmap v2.0: Release',
'',
'### Phase 5: Auth',
'**Goal:** Add authentication',
'**Plans:** 1 plans',
].join('\n')
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# State\n\n**Current Phase:** 05\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 05-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n'
);
// Disk has dirs 01-06 but only 5 is in ROADMAP
for (let i = 1; i <= 6; i++) {
const padded = String(i).padStart(2, '0');
const phaseDir = path.join(tmpDir, '.planning', 'phases', `${padded}-phase-${i}`);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, `${padded}-01-PLAN.md`), '# Plan');
fs.writeFileSync(path.join(phaseDir, `${padded}-01-SUMMARY.md`), '# Summary');
}
const result = runVerifiedPhaseComplete('phase complete 5', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// Without the fix, dirs 06 on disk would make is_last_phase=false
// With the fix, only phase 5 is in milestone, so it IS the last phase
assert.strictEqual(output.is_last_phase, true, 'should be last phase — only phase 5 is in milestone');
assert.strictEqual(output.next_phase, null, 'no next phase in milestone');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// exact token matching (no prefix collisions)
// ─────────────────────────────────────────────────────────────────────────────
describe('phase resolution uses exact token matching', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('1009 must NOT match 1009A-feature-consistency when 1009 dir is absent', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
fs.mkdirSync(path.join(phasesDir, '1009A-feature-consistency'));
fs.writeFileSync(path.join(phasesDir, '1009A-feature-consistency', 'PLAN.md'), '# Plan');
const result = runGsdTools('find-phase 1009', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, false, 'should NOT find phase 1009 when only 1009A exists');
});
test('1009 matches 1009-pipeline-accuracy-fix when both exist', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
fs.mkdirSync(path.join(phasesDir, '1009-pipeline-accuracy-fix'));
fs.mkdirSync(path.join(phasesDir, '1009A-feature-consistency'));
fs.writeFileSync(path.join(phasesDir, '1009-pipeline-accuracy-fix', 'PLAN.md'), '# Plan');
const result = runGsdTools('find-phase 1009', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, true, 'should find phase 1009');
assert.ok(
output.directory.includes('1009-pipeline-accuracy-fix'),
`should match 1009-pipeline-accuracy-fix, got: ${output.directory}`
);
});
test('999.6 must NOT match 999.60-episode-processing when 999.6 dir is absent', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
fs.mkdirSync(path.join(phasesDir, '999.60-episode-processing'));
fs.writeFileSync(path.join(phasesDir, '999.60-episode-processing', 'PLAN.md'), '# Plan');
const result = runGsdTools('find-phase 999.6', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, false, 'should NOT find phase 999.6 when only 999.60 exists');
});
test('999.6 matches 999.6-ground-truth-dataset when both exist', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
fs.mkdirSync(path.join(phasesDir, '999.6-ground-truth-dataset'));
fs.mkdirSync(path.join(phasesDir, '999.60-episode-processing'));
fs.writeFileSync(path.join(phasesDir, '999.6-ground-truth-dataset', 'PLAN.md'), '# Plan');
const result = runGsdTools('find-phase 999.6', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, true, 'should find phase 999.6');
assert.ok(
output.directory.includes('999.6-ground-truth-dataset'),
`should match 999.6-ground-truth-dataset, got: ${output.directory}`
);
});
test('normal non-colliding phases still resolve', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
fs.mkdirSync(path.join(phasesDir, '01-foundation'));
fs.mkdirSync(path.join(phasesDir, '02-implementation'));
fs.writeFileSync(path.join(phasesDir, '01-foundation', 'PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phasesDir, '02-implementation', 'PLAN.md'), '# Plan');
const r1 = runGsdTools('find-phase 1', tmpDir);
assert.ok(r1.success, `Command failed for phase 1: ${r1.error}`);
const o1 = JSON.parse(r1.output);
assert.strictEqual(o1.found, true, 'should find phase 1');
assert.ok(o1.directory.includes('01-foundation'), `should match 01-foundation, got: ${o1.directory}`);
const r2 = runGsdTools('find-phase 2', tmpDir);
assert.ok(r2.success, `Command failed for phase 2: ${r2.error}`);
const o2 = JSON.parse(r2.output);
assert.strictEqual(o2.found, true, 'should find phase 2');
assert.ok(o2.directory.includes('02-implementation'), `should match 02-implementation, got: ${o2.directory}`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phase complete — Performance Metrics gate (Step 2 — Gate 4)
// ─────────────────────────────────────────────────────────────────────────────
describe('phase complete updates Performance Metrics', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('after cmdPhaseComplete: Performance Metrics has updated total plans count', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 2\n**Status:** Executing Phase 2\n**Total Plans in Phase:** 3\n**Current Plan:** 3\n**Completed Phases:** 0\n**Total Phases:** 3\n**Progress:** 0%\n\n## Performance Metrics\n\n**Velocity:**\n- Total plans completed: 0\n- Average duration: N/A\n- Total execution time: 0 hours\n\n**By Phase:**\n\n| Phase | Plans | Total | Avg/Plan |\n|-------|-------|-------|----------|\n\n## Accumulated Context\n`
);
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-core');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '02-01-PLAN.md'), '# Plan 1\n');
fs.writeFileSync(path.join(phaseDir, '02-02-PLAN.md'), '# Plan 2\n');
fs.writeFileSync(path.join(phaseDir, '02-03-PLAN.md'), '# Plan 3\n');
fs.writeFileSync(path.join(phaseDir, '02-01-SUMMARY.md'), '# Summary\n');
fs.writeFileSync(path.join(phaseDir, '02-02-SUMMARY.md'), '# Summary\n');
fs.writeFileSync(path.join(phaseDir, '02-03-SUMMARY.md'), '# Summary\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n## Phase 2: Core\n\n- [ ] Phase 2: Core Systems\n`
);
const result = runVerifiedPhaseComplete('phase complete 2', tmpDir);
assert.ok(result.success, `phase complete failed: ${result.error}`);
const stateAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(stateAfter.match(/Total plans completed:\s*3/), 'Total plans completed should be 3');
});
test('after cmdPhaseComplete: By Phase table has row for completed phase', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 1\n**Status:** Executing Phase 1\n**Total Plans in Phase:** 2\n**Current Plan:** 2\n**Completed Phases:** 0\n**Total Phases:** 2\n**Progress:** 0%\n\n## Performance Metrics\n\n**Velocity:**\n- Total plans completed: 0\n- Average duration: N/A\n- Total execution time: 0 hours\n\n**By Phase:**\n\n| Phase | Plans | Total | Avg/Plan |\n|-------|-------|-------|----------|\n\n## Accumulated Context\n`
);
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
fs.writeFileSync(path.join(phaseDir, '01-02-SUMMARY.md'), '# Summary\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n## Phase 1: Setup\n\n- [ ] Phase 1: Setup\n`
);
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
assert.ok(result.success, `phase complete failed: ${result.error}`);
const stateAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(stateAfter.match(/\|\s*1\s*\|\s*2\s*\|/), 'By Phase table should have row for phase 1 with 2 plans');
// Row must appear BEFORE the next section, not after it (regression: empty table body regex)
const rowIdx = stateAfter.indexOf('| 1 |');
const accIdx = stateAfter.indexOf('## Accumulated Context');
if (accIdx !== -1) {
assert.ok(rowIdx < accIdx, 'By Phase row must appear before ## Accumulated Context section');
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phase complete — backlog phase (999.x) exclusion (#2129)
// ─────────────────────────────────────────────────────────────────────────────
describe('phase complete excludes 999.x backlog from next-phase (#2129)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('next phase skips 999.x backlog dirs and falls back to roadmap', () => {
// ROADMAP defines phases 1, 2, 3 and a backlog 999.1
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [ ] Phase 1: Setup',
'- [ ] Phase 2: Core',
'- [ ] Phase 3: Polish',
'- [ ] Phase 999.1: Backlog idea',
'',
'### Phase 1: Setup',
'**Goal:** Initial setup',
'',
'### Phase 2: Core',
'**Goal:** Build core',
'',
'### Phase 3: Polish',
'**Goal:** Polish everything',
'',
'### Phase 999.1: Backlog idea',
'**Goal:** Parked idea',
].join('\n')
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'# State',
'',
'**Current Phase:** 02',
'**Status:** In progress',
'**Current Plan:** 02-01',
'**Last Activity:** 2025-01-01',
'**Last Activity Description:** Working',
].join('\n')
);
// Phase 1 and 2 exist on disk, phase 3 does NOT exist yet, 999.1 DOES exist
const p1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
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');
const p2 = path.join(tmpDir, '.planning', 'phases', '02-core');
fs.mkdirSync(p2, { recursive: true });
fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p2, '02-01-SUMMARY.md'), '# Summary');
// Backlog stub on disk — this is what triggers the bug
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-idea'), { recursive: true });
const result = runVerifiedPhaseComplete('phase complete 2', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// Should find phase 3 from roadmap, NOT 999.1 from filesystem
assert.strictEqual(output.next_phase, '3', 'next_phase should be 3, not 999.1');
assert.strictEqual(output.is_last_phase, false, 'should not be last phase');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// regression: bug #1962 — normalizePhaseName preserves letter suffix case
// (consolidated from tests/bug-1962-phase-suffix-case.test.cjs)
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #1962: normalizePhaseName preserves letter suffix case', () => {
test('lowercase suffix preserved: 16c → 16c', () => {
assert.equal(normalizePhaseName('16c'), '16c');
});
test('uppercase suffix preserved: 16C → 16C', () => {
assert.equal(normalizePhaseName('16C'), '16C');
});
test('single digit padded with lowercase suffix: 1a → 01a', () => {
assert.equal(normalizePhaseName('1a'), '01a');
});
test('single digit padded with uppercase suffix: 1A → 01A', () => {
assert.equal(normalizePhaseName('1A'), '01A');
});
test('no suffix unchanged: 16 → 16', () => {
assert.equal(normalizePhaseName('16'), '16');
});
test('decimal suffix preserved: 16.1 → 16.1', () => {
assert.equal(normalizePhaseName('16.1'), '16.1');
});
test('letter + decimal preserved: 16c.2 → 16c.2', () => {
assert.equal(normalizePhaseName('16c.2'), '16c.2');
});
test('project code prefix stripped, suffix case preserved: CK-01a → 01a', () => {
assert.equal(normalizePhaseName('CK-01a'), '01a');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// regression: bug #1998 — phase complete updates overview checkbox
// (consolidated from tests/bug-1998-phase-complete-checkbox.test.cjs)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Run `gsd-tools phase complete <phase>` for the phase-complete regression
* suites and return its stdout.
*
* `phase complete` writes ROADMAP.md as its LAST step, after a read-heavy
* parse/lock sequence (ROADMAP read, two extractCurrentMilestone STATE.md
* parses, REQUIREMENTS read, phase-dir scan, STATE read — all before the single
* writePlanningFileSet flush). Under the high-concurrency docker run (~672 test
* files in parallel), a tight 10s timeout could fire mid-parse and SIGTERM the
* subprocess BEFORE that write landed, leaving ROADMAP.md untouched. Call sites
* that used a bare `catch {}` then silently proceeded to assert on the pristine
* file — an intermittent "checkbox not checked" failure (bug #1998 flake).
*
* Two-part fix, no retry loop:
* 1. A generous timeout so the test's own timer never kills the subprocess
* under load (10s cold-node startup × 672-way CPU/IO contention was the
* real culprit — all I/O is scoped to tmpDir, so there is no cross-process
* race to blame).
* 2. Never silently swallow a signal/timeout kill: it means the process was
* terminated before completing its writes, so we surface it loudly with
* context instead of letting it masquerade as an assertion failure. A
* *clean* non-zero exit is still tolerated when `tolerateExit` is set,
* because the ROADMAP write has already landed before any post-write step
* that may exit non-zero in these minimal fixtures.
*/
function runPhaseComplete(tmpDir, { phase = '1', tolerateExit = false } = {}) {
writePassedVerificationForPhase(tmpDir, phase);
try {
return execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', phase], {
cwd: tmpDir,
timeout: 60000,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch (err) {
// A signal/timeout kill terminated the process before it finished writing —
// never tolerate it; surface it with whatever output was captured.
if (err.killed || err.signal != null || err.code === 'ETIMEDOUT') {
throw new Error(
`gsd-tools phase complete ${phase} was killed before completion ` +
`(signal=${err.signal}, code=${err.code}). ` +
`stdout=${err.stdout || ''} stderr=${err.stderr || ''}`
);
}
if (tolerateExit) {
return `${err.stdout || ''}${err.stderr || ''}`;
}
throw err;
}
}
describe('bug #1998: phase complete updates overview checkbox', () => {
let tmpDir;
let planningDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1998-'));
planningDir = path.join(tmpDir, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
// Minimal config
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ project_code: 'TEST' })
);
// Minimal STATE.md
fs.writeFileSync(
path.join(planningDir, 'STATE.md'),
'---\ncurrent_phase: 1\nstatus: executing\n---\n# State\n'
);
});
afterEach(() => {
cleanup(tmpDir);
});
test('checkbox updated when no archived milestones exist', () => {
const phasesDir = path.join(planningDir, 'phases', '01-foundation');
fs.mkdirSync(phasesDir, { recursive: true });
fs.writeFileSync(
path.join(phasesDir, '01-1-SUMMARY.md'),
'---\nstatus: complete\n---\n# Summary\nDone.'
);
fs.writeFileSync(
path.join(phasesDir, '01-1-PLAN.md'),
'---\nphase: 1\nplan: 1\n---\n# Plan 1\n'
);
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
fs.writeFileSync(roadmapPath, [
'# Roadmap',
'',
'## Phases',
'',
'- [ ] **Phase 1: Foundation** - core setup',
'- [ ] **Phase 2: Features** - add features',
'',
'## Progress',
'',
'| Phase | Plans | Status | Completed |',
'|-------|-------|--------|-----------|',
'| 1. Foundation | 0/1 | Pending | - |',
'| 2. Features | 0/1 | Pending | - |',
].join('\n'));
runPhaseComplete(tmpDir, { tolerateExit: true });
const result = fs.readFileSync(roadmapPath, 'utf-8');
assert.match(result, /- \[x\] \*\*Phase 1: Foundation\*\*/, 'overview checkbox should be checked');
assert.match(result, /- \[ \] \*\*Phase 2: Features\*\*/, 'phase 2 checkbox should remain unchecked');
});
test('checkbox updated when archived milestones exist in <details>', () => {
const phasesDir = path.join(planningDir, 'phases', '01-setup');
fs.mkdirSync(phasesDir, { recursive: true });
fs.writeFileSync(
path.join(phasesDir, '01-1-SUMMARY.md'),
'---\nstatus: complete\n---\n# Summary\nDone.'
);
fs.writeFileSync(
path.join(phasesDir, '01-1-PLAN.md'),
'---\nphase: 1\nplan: 1\n---\n# Plan 1\n'
);
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
fs.writeFileSync(roadmapPath, [
'# Roadmap v2.0',
'',
'## Phases',
'',
'- [ ] **Phase 1: Setup** - initial setup',
'- [ ] **Phase 2: Build** - build features',
'',
'## Progress',
'',
'| Phase | Plans | Status | Completed |',
'|-------|-------|--------|-----------|',
'| 1. Setup | 0/1 | Pending | - |',
'| 2. Build | 0/1 | Pending | - |',
'',
'<details>',
'<summary>v1.0 (Archived)</summary>',
'',
'## v1.0 Phases',
'- [x] **Phase 1: Init** - initialization',
'- [x] **Phase 2: Deploy** - deployment',
'',
'</details>',
].join('\n'));
runPhaseComplete(tmpDir, { tolerateExit: true });
const result = fs.readFileSync(roadmapPath, 'utf-8');
assert.match(result, /- \[x\] \*\*Phase 1: Setup\*\*/, 'current milestone checkbox should be checked');
assert.match(result, /- \[ \] \*\*Phase 2: Build\*\*/, 'phase 2 checkbox should remain unchecked');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// regression: bug #2005 — phase complete inside <details> updates plan count
// (consolidated from tests/bug-2005-phase-complete-details.test.cjs)
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #2005: phase complete updates plan count when milestone is inside <details>', () => {
let tmpDir;
let planningDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2005-'));
planningDir = path.join(tmpDir, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ project_code: 'TEST' })
);
fs.writeFileSync(
path.join(planningDir, 'STATE.md'),
'---\ncurrent_phase: 1\nstatus: executing\nmilestone: v2.0\n---\n# State\n'
);
});
afterEach(() => {
cleanup(tmpDir);
});
test('plan count is updated when current milestone is wrapped in <details>', () => {
const phasesDir = path.join(planningDir, 'phases', '01-setup');
fs.mkdirSync(phasesDir, { recursive: true });
fs.writeFileSync(
path.join(phasesDir, '01-1-SUMMARY.md'),
'---\nstatus: complete\n---\n# Summary\nDone.\n'
);
fs.writeFileSync(
path.join(phasesDir, '01-1-PLAN.md'),
'---\nphase: 1\nplan: 1\n---\n# Plan 1\n'
);
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
fs.writeFileSync(roadmapPath, [
'# Roadmap',
'',
'<details>',
'<summary>v1.0 (shipped)</summary>',
'',
'## v1.0 Phases',
'- [x] **Phase 0: Bootstrap** - shipped',
'',
'</details>',
'',
'<details open>',
'<summary>v2.0 (in progress)</summary>',
'',
'## v2.0 Phases',
'',
'- [ ] **Phase 1: Setup** - initial setup',
'- [ ] **Phase 2: Build** - build features',
'',
'## Progress',
'',
'| Phase | Plans | Status | Completed |',
'|-------|-------|--------|-----------|',
'| 1. Setup | 0/1 | Pending | - |',
'| 2. Build | 0/1 | Pending | - |',
'',
'### Phase 1: Setup',
'',
'**Plans:** 0/1 plans complete',
'',
'### Phase 2: Build',
'',
'**Plans:** 0/1 plans complete',
'',
'</details>',
].join('\n'));
runPhaseComplete(tmpDir, { tolerateExit: true });
const result = fs.readFileSync(roadmapPath, 'utf-8');
assert.match(
result,
/\*\*Plans:\*\*\s*1\/1 plans complete/,
'plan count in Phase 1 section must be updated to 1/1 plans complete'
);
assert.match(
result,
/- \[x\] \*\*Phase 1: Setup\*\*/,
'Phase 1 checkbox must be checked after completion'
);
assert.match(
result,
/- \[ \] \*\*Phase 2: Build\*\*/,
'Phase 2 checkbox must remain unchecked'
);
});
test('phase complete with all milestones in <details> does not corrupt phase 2 plan count', () => {
const phasesDir = path.join(planningDir, 'phases', '01-setup');
fs.mkdirSync(phasesDir, { recursive: true });
fs.writeFileSync(
path.join(phasesDir, '01-1-SUMMARY.md'),
'---\nstatus: complete\n---\n# Summary\nDone.\n'
);
fs.writeFileSync(
path.join(phasesDir, '01-1-PLAN.md'),
'---\nphase: 1\nplan: 1\n---\n# Plan 1\n'
);
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
fs.writeFileSync(roadmapPath, [
'# Roadmap',
'',
'<details open>',
'<summary>v2.0 (in progress)</summary>',
'',
'## v2.0 Phases',
'',
'- [ ] **Phase 1: Setup** - initial setup',
'- [ ] **Phase 2: Build** - build features',
'',
'### Phase 1: Setup',
'',
'**Plans:** 0/1 plans complete',
'',
'### Phase 2: Build',
'',
'**Plans:** 0/2 plans complete',
'',
'</details>',
].join('\n'));
runPhaseComplete(tmpDir, { tolerateExit: true });
const result = fs.readFileSync(roadmapPath, 'utf-8');
assert.match(
result,
/Phase 2: Build[\s\S]*?\*\*Plans:\*\*\s*0\/2 plans complete/,
'Phase 2 plan count must remain 0/2 (untouched)'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// regression: bug #2526 — phase complete warns about unregistered REQ-IDs
// (consolidated from tests/bug-2526-phase-complete-req-discovery.test.cjs)
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #2526: phase complete warns about unregistered REQ-IDs', () => {
let tmpDir;
let planningDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2526-'));
planningDir = path.join(tmpDir, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ project_code: '' })
);
fs.writeFileSync(
path.join(planningDir, 'STATE.md'),
'---\ncurrent_phase: 1\nstatus: executing\n---\n# State\n'
);
});
afterEach(() => {
cleanup(tmpDir);
});
test('emits warning for REQ-IDs in body but missing from Traceability table', () => {
const phasesDir = path.join(planningDir, 'phases', '01-foundation');
fs.mkdirSync(phasesDir, { recursive: true });
fs.writeFileSync(
path.join(phasesDir, '01-1-PLAN.md'),
'---\nphase: 1\nplan: 1\n---\n# Plan 1\n'
);
fs.writeFileSync(
path.join(phasesDir, '01-1-SUMMARY.md'),
'---\nstatus: complete\n---\n# Summary\nDone.'
);
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
fs.writeFileSync(roadmapPath, [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'**Goal:** Build core',
'**Requirements:** REQ-001',
'**Plans:** 1 plans',
'',
'Plans:',
'- [x] 01-1-PLAN.md',
'',
'| Phase | Plans | Status | Completed |',
'|-------|-------|--------|-----------|',
'| 1. Foundation | 0/1 | Pending | - |',
].join('\n'));
const reqPath = path.join(planningDir, 'REQUIREMENTS.md');
fs.writeFileSync(reqPath, [
'# Requirements',
'',
'## Functional Requirements',
'',
'- [x] **REQ-001**: Core data model',
'- [ ] **REQ-002**: User authentication',
'- [ ] **REQ-003**: API endpoints',
'',
'## Traceability',
'',
'| REQ-ID | Phase | Status |',
'|--------|-------|--------|',
'| REQ-001 | 1 | Pending |',
].join('\n'));
const combined = runPhaseComplete(tmpDir);
assert.match(combined, /REQ-002/, 'output should mention REQ-002 as missing from Traceability table');
assert.match(combined, /REQ-003/, 'output should mention REQ-003 as missing from Traceability table');
});
test('no warning when all body REQ-IDs are present in Traceability table', () => {
const phasesDir = path.join(planningDir, 'phases', '01-foundation');
fs.mkdirSync(phasesDir, { recursive: true });
fs.writeFileSync(
path.join(phasesDir, '01-1-PLAN.md'),
'---\nphase: 1\nplan: 1\n---\n# Plan 1\n'
);
fs.writeFileSync(
path.join(phasesDir, '01-1-SUMMARY.md'),
'---\nstatus: complete\n---\n# Summary\nDone.'
);
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
fs.writeFileSync(roadmapPath, [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'**Goal:** Build core',
'**Requirements:** REQ-001, REQ-002',
'**Plans:** 1 plans',
'',
'Plans:',
'- [x] 01-1-PLAN.md',
'',
'| Phase | Plans | Status | Completed |',
'|-------|-------|--------|-----------|',
'| 1. Foundation | 0/1 | Pending | - |',
].join('\n'));
const reqPath = path.join(planningDir, 'REQUIREMENTS.md');
fs.writeFileSync(reqPath, [
'# Requirements',
'',
'## Functional Requirements',
'',
'- [x] **REQ-001**: Core data model',
'- [x] **REQ-002**: User authentication',
'',
'## Traceability',
'',
'| REQ-ID | Phase | Status |',
'|--------|-------|--------|',
'| REQ-001 | 1 | Pending |',
'| REQ-002 | 1 | Pending |',
].join('\n'));
const combined = runPhaseComplete(tmpDir);
assert.doesNotMatch(
combined,
/unregistered|missing.*traceability|not in.*traceability/i,
'no warning should appear when all REQ-IDs are in the table'
);
});
test('warning includes all missing REQ-IDs, not just the first', () => {
const phasesDir = path.join(planningDir, 'phases', '01-foundation');
fs.mkdirSync(phasesDir, { recursive: true });
fs.writeFileSync(
path.join(phasesDir, '01-1-PLAN.md'),
'---\nphase: 1\nplan: 1\n---\n# Plan 1\n'
);
fs.writeFileSync(
path.join(phasesDir, '01-1-SUMMARY.md'),
'---\nstatus: complete\n---\n# Summary\nDone.'
);
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
fs.writeFileSync(roadmapPath, [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'**Goal:** Build core',
'**Requirements:** REQ-001',
'**Plans:** 1 plans',
'',
'Plans:',
'- [x] 01-1-PLAN.md',
'',
'| Phase | Plans | Status | Completed |',
'|-------|-------|--------|-----------|',
'| 1. Foundation | 0/1 | Pending | - |',
].join('\n'));
const reqPath = path.join(planningDir, 'REQUIREMENTS.md');
fs.writeFileSync(reqPath, [
'# Requirements',
'',
'- [x] **REQ-001**: Core data model',
'- [ ] **REQ-002**: User auth',
'- [ ] **REQ-003**: API',
'- [ ] **REQ-004**: Reports',
'',
'## Traceability',
'',
'| REQ-ID | Phase | Status |',
'|--------|-------|--------|',
'| REQ-001 | 1 | Pending |',
].join('\n'));
const combined = runPhaseComplete(tmpDir);
assert.match(combined, /REQ-002/, 'should warn about REQ-002');
assert.match(combined, /REQ-003/, 'should warn about REQ-003');
assert.match(combined, /REQ-004/, 'should warn about REQ-004');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// regression: bug #3287 — phase-dir prefix parity across creation paths
// (consolidated from tests/bug-3287-phase-dir-prefix-parity.test.cjs)
// ─────────────────────────────────────────────────────────────────────────────
// ─── shared fixture for bug-3287 ─────────────────────────────────────────────
function makeXRProject(tmpDir) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ project_code: 'XR' }),
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap v1.0',
'',
'### Phase 1: Foundation',
'**Goal:** Setup project',
'**Plans:** 0 plans',
'',
'---',
'',
].join('\n'),
);
}
describe('bug-3287 — phase.add emits project_code prefix (sanity)', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
test('phase.add creates XR-02-<slug> when project_code is XR', () => {
makeXRProject(tmpDir);
const result = runGsdTools('phase add auth service', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `phase.add failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, 2, 'phase number should be 2');
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const dirs = fs.readdirSync(phasesDir);
const prefixedDirs = dirs.filter(d => d.startsWith('XR-'));
assert.ok(
prefixedDirs.length > 0,
`Expected at least one XR- prefixed dir, got: ${JSON.stringify(dirs)}`,
);
assert.ok(
dirs.some(d => d === 'XR-02-auth-service'),
`Expected XR-02-auth-service, got: ${JSON.stringify(dirs)}`,
);
});
});
describe('bug-3287 — init phase-op exposes expected_phase_dir with project_code prefix', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
test('returns expected_phase_dir with XR- prefix when phase directory does not exist', () => {
makeXRProject(tmpDir);
const result = runGsdTools('init phase-op 1', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `init phase-op failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true, 'phase should be found in roadmap');
assert.strictEqual(output.phase_dir, null, 'phase_dir should be null (no dir yet)');
assert.ok(
typeof output.expected_phase_dir === 'string',
`expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`,
);
assert.ok(
output.expected_phase_dir.includes('XR-'),
`expected_phase_dir should contain XR- prefix, got: "${output.expected_phase_dir}"`,
);
assert.ok(
output.expected_phase_dir.includes('foundation'),
`expected_phase_dir should contain the phase slug, got: "${output.expected_phase_dir}"`,
);
});
test('expected_phase_dir is null when no project_code is set', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({}),
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n',
);
const result = runGsdTools('init phase-op 1', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `init phase-op failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_dir, null);
assert.ok(
typeof output.expected_phase_dir === 'string',
`expected_phase_dir should be a string even without project_code, got: ${JSON.stringify(output.expected_phase_dir)}`,
);
assert.ok(
!output.expected_phase_dir.match(/^[A-Z][A-Z0-9]*-/),
`expected_phase_dir should have NO prefix without project_code, got: "${output.expected_phase_dir}"`,
);
});
});
describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_code prefix', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
test('returns expected_phase_dir with XR- prefix when phase directory does not exist', () => {
makeXRProject(tmpDir);
const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true, 'phase should be found in roadmap');
assert.strictEqual(output.phase_dir, null, 'phase_dir should be null (no dir yet)');
assert.ok(
typeof output.expected_phase_dir === 'string',
`expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`,
);
assert.ok(
output.expected_phase_dir.includes('XR-'),
`expected_phase_dir should contain XR- prefix, got: "${output.expected_phase_dir}"`,
);
assert.ok(
output.expected_phase_dir.includes('foundation'),
`expected_phase_dir should contain the phase slug, got: "${output.expected_phase_dir}"`,
);
});
test('expected_phase_dir omits prefix when project_code is not set', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({}),
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n',
);
const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_dir, null);
assert.ok(
typeof output.expected_phase_dir === 'string',
`expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`,
);
assert.ok(
!output.expected_phase_dir.match(/^[A-Z][A-Z0-9]*-/),
`expected_phase_dir should have NO prefix without project_code, got: "${output.expected_phase_dir}"`,
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// regression: bug #3298 — phase-dir prefix drift in workflows
// (consolidated from tests/bug-3298-phase-dir-prefix-drift-in-workflows.test.cjs)
// ─────────────────────────────────────────────────────────────────────────────
{
const PMG_WF = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-milestone-gaps.md');
const IMPORT_WF = path.join(__dirname, '..', 'gsd-core', 'workflows', 'import.md');
const BACKLOG_WF = path.join(__dirname, '..', 'gsd-core', 'workflows', 'add-backlog.md');
function readWorkflow(filePath) {
try {
return fs.readFileSync(filePath, 'utf8');
} catch (err) {
throw new Error(`Cannot read workflow file ${filePath}: ${err.message}`);
}
}
function containsBareTemplateMkdir(content) {
return /mkdir[^`\r\n]*\.planning\/phases\/\{[A-Z0-9]+\}-\{/.test(content);
}
function containsBareShellVarMkdir(content) {
return /mkdir[^`\r\n]*\.planning\/phases\/"\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content)
|| /mkdir[^`\r\n]*\.planning\/phases\/\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content);
}
describe('bug-3298 — plan-milestone-gaps.md must not construct bare {NN}-{name} phase dirs', () => {
test('workflow file exists', () => {
assert.ok(fs.existsSync(PMG_WF), `plan-milestone-gaps.md must exist at ${PMG_WF}`);
});
test('step 8 must not use bare {NN}-{name} mkdir pattern', () => {
const content = readWorkflow(PMG_WF);
assert.ok(
!containsBareTemplateMkdir(content),
'plan-milestone-gaps.md must not contain bare mkdir .planning/phases/{NN}-{name} pattern — use phase.add or expected_phase_dir',
);
});
test('step 8 must use expected_phase_dir or phase.add for directory creation', () => {
const content = readWorkflow(PMG_WF);
const usesExpectedPhaseDir = content.includes('expected_phase_dir');
const usesPhaseAdd = content.includes('phase.add');
assert.ok(
usesExpectedPhaseDir || usesPhaseAdd,
'plan-milestone-gaps.md must use expected_phase_dir (from init.phase-op) or phase.add to create phase directories with project_code prefix',
);
});
});
describe('bug-3298 — import.md must not construct bare {NN}-{slug} phase dirs', () => {
test('workflow file exists', () => {
assert.ok(fs.existsSync(IMPORT_WF), `import.md must exist at ${IMPORT_WF}`);
});
test('plan_convert step must not use bare {NN}-{slug} mkdir pattern', () => {
const content = readWorkflow(IMPORT_WF);
assert.ok(
!containsBareTemplateMkdir(content),
'import.md must not contain bare mkdir .planning/phases/{NN}-{slug} pattern — use expected_phase_dir from init.phase-op',
);
});
test('plan_convert step must use expected_phase_dir for directory creation', () => {
const content = readWorkflow(IMPORT_WF);
assert.ok(
content.includes('expected_phase_dir'),
'import.md must use expected_phase_dir (from init.phase-op) to create phase directory with project_code prefix',
);
});
test('plan_convert step must call init.phase-op to resolve the prefixed dir', () => {
const content = readWorkflow(IMPORT_WF);
assert.ok(
content.includes('init.phase-op') || content.includes('init phase-op'),
'import.md must call gsd-sdk query init.phase-op to get expected_phase_dir with project_code prefix',
);
});
});
describe('bug-3298 — add-backlog.md must apply project_code prefix when creating 999.x dirs', () => {
test('workflow file exists', () => {
assert.ok(fs.existsSync(BACKLOG_WF), `add-backlog.md must exist at ${BACKLOG_WF}`);
});
test('step 4 must not use bare ${NEXT}-${SLUG} mkdir without project_code prefix', () => {
const content = readWorkflow(BACKLOG_WF);
assert.ok(
!containsBareShellVarMkdir(content),
'add-backlog.md must not create .planning/phases/${NEXT}-${SLUG} without a project_code prefix variable — apply ${PREFIX} (or equivalent) before ${NEXT}',
);
});
test('step 4 must reference project_code or a prefix variable before the phase number', () => {
const content = readWorkflow(BACKLOG_WF);
const hasProjectCodeRef = content.includes('project_code') || content.includes('PROJECT_CODE');
const hasPrefixVar = content.includes('${PREFIX}') || content.includes('${PHASE_PREFIX}') || content.includes('${CODE}');
assert.ok(
hasProjectCodeRef || hasPrefixVar,
'add-backlog.md must read project_code (or use a PREFIX variable) to apply the project_code prefix to the 999.x phase directory name',
);
});
});
}
// ─────────────────────────────────────────────────────────────────────────────
// regression: bug #3517 — phase.complete leaves STATE.md with stale fields
// (consolidated from tests/bug-3517-phase-complete-state-md-staleness.test.cjs)
// ─────────────────────────────────────────────────────────────────────────────
{
function runSdkQuery(args, cwd) {
if (Array.isArray(args) && args[0] === 'phase.complete') {
writePassedVerificationForPhase(cwd, args[1]);
}
const result = runGsdTools(args, cwd);
if (!result.success) return { success: false, error: result.error };
try {
const parsed = JSON.parse(result.output || '{}');
return { success: true, data: parsed };
} catch (err) {
return { success: false, error: err.message };
}
}
function setupPhase3517Project(tmpDir) {
const planningDir = path.join(tmpDir, '.planning');
const phasesDir = path.join(planningDir, 'phases');
fs.mkdirSync(planningDir, { recursive: true });
fs.mkdirSync(phasesDir, { recursive: true });
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ project_code: 'TEST' }),
);
const roadmap = [
'# Roadmap',
'',
'## Current Milestone: v3.0',
'',
'| Phase | Plans | Status | Completed |',
'|-------|-------|--------|-----------|',
'| 4. | 3/3 | Complete | 2026-04-01 |',
'| 5. | 7/7 | In Progress | |',
'| 6. | 0/5 | Not Started | |',
'',
'- [x] Phase 4: Foundation (completed 2026-04-01)',
'- [ ] Phase 5: Core API',
'- [ ] Phase 6: Integration',
'',
'### Phase 4: Foundation',
'',
'**Goal:** Foundation work',
'**Plans:** 3/3 plans complete',
'',
'### Phase 5: Core API',
'',
'**Goal:** Build core API layer',
'**Plans:** 7 plans',
'',
'Plans:',
'- [ ] 05-01 plan',
'- [ ] 05-02 plan',
'- [ ] 05-03 plan',
'- [ ] 05-04 plan',
'- [ ] 05-05 plan',
'- [ ] 05-06 plan',
'- [ ] 05-07 plan',
'',
'### Phase 6: Integration',
'',
'**Goal:** Integration work',
'**Plans:** 5 plans',
'',
'---',
'*Last updated: 2026-05-14*',
].join('\n');
fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmap);
const state = [
'---',
'gsd_state_version: 1.0',
'milestone: v3.0',
'milestone_name: Core Platform',
'status: executing',
'stopped_at: Completed 05-03-PLAN.md',
'last_updated: 2026-05-10T08:00:00.000Z',
'progress:',
' total_phases: 3',
' completed_phases: 1',
' total_plans: 15',
' completed_plans: 6',
' percent: 33',
'---',
'',
'# Project State',
'',
'## Current Position',
'',
'**Current focus:** Phase 5 — Core API',
'Phase: 5 of 3 (Core API) — EXECUTING',
'Plan: 7 of 7',
'Status: Executing Phase 5',
'Last activity: 2026-05-10',
'',
'## Progress',
'',
'Progress: [████████████░░░░] 40% (1/3 phases complete before Phase 5 closeout)',
'',
'## Performance Metrics',
'',
'**Velocity:**',
'',
'- Total plans completed: 6',
'- Average duration: 2h',
'- Total execution time: 14 hours',
'- Window: 2026-04-01 to 2026-05-10',
'',
'**By Phase:**',
'',
'| Phase | Plans | Total | Avg/Plan |',
'|-------|-------|-------|----------|',
'| 4 | 3 | - | - |',
'',
'## Session Continuity',
'',
'Last session: 2026-05-10T08:00:00.000Z',
'Stopped at: Completed 05-07-PLAN.md',
].join('\n');
fs.writeFileSync(path.join(planningDir, 'STATE.md'), state);
const phase5Dir = path.join(phasesDir, '05-core-api');
fs.mkdirSync(phase5Dir, { recursive: true });
for (let i = 1; i <= 7; i++) {
const padded = String(i).padStart(2, '0');
fs.writeFileSync(path.join(phase5Dir, `05-${padded}-PLAN.md`), `plan ${i}`, 'utf8');
fs.writeFileSync(path.join(phase5Dir, `05-${padded}-SUMMARY.md`), `summary ${i}`, 'utf8');
}
const phase4Dir = path.join(phasesDir, '04-foundation');
fs.mkdirSync(phase4Dir, { recursive: true });
for (let i = 1; i <= 3; i++) {
const padded = String(i).padStart(2, '0');
fs.writeFileSync(path.join(phase4Dir, `04-${padded}-PLAN.md`), `plan ${i}`, 'utf8');
fs.writeFileSync(path.join(phase4Dir, `04-${padded}-SUMMARY.md`), `summary ${i}`, 'utf8');
}
const phase6Dir = path.join(phasesDir, '06-integration');
fs.mkdirSync(phase6Dir, { recursive: true });
return { planningDir, phase5Dir };
}
function setupPhase1316Project(tmpDir) {
const planningDir = path.join(tmpDir, '.planning');
const phasesDir = path.join(planningDir, 'phases');
fs.mkdirSync(planningDir, { recursive: true });
fs.mkdirSync(phasesDir, { recursive: true });
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
[
'# Roadmap',
'',
'## Current Milestone: v3.0',
'',
'- [ ] Phase 32: Backlog-Closeout Lib Extraction',
'- [ ] Phase 33: Follow Up Implementation',
'',
'### Phase 32: Backlog-Closeout Lib Extraction',
'**Goal:** Complete closeout extraction',
'**Plans:** 1 plans',
'',
'### Phase 33: Follow Up Implementation',
'**Goal:** Continue implementation',
].join('\n'),
);
fs.writeFileSync(
path.join(planningDir, 'STATE.md'),
[
'---',
'gsd_state_version: 1.0',
'status: executing',
'current_phase: "32"',
'last_activity: "2026-06-14"',
'progress:',
' total_phases: 2',
' completed_phases: 0',
' total_plans: 1',
' completed_plans: 0',
' percent: 0',
'---',
'',
'# Project State',
'',
'## Current Position',
'',
'Phase: 32 — Backlog-Closeout Lib Extraction',
'Plan: 1 of 1',
'Status: Executing Phase 32',
'Last activity: 2026-06-14 — recorded planning complete',
'',
'## Session',
'',
'Last session: 2026-06-14T00:00:00.000Z',
].join('\n'),
);
const phase32Dir = path.join(phasesDir, '32-backlog-closeout-lib-extraction');
fs.mkdirSync(phase32Dir, { recursive: true });
fs.writeFileSync(path.join(phase32Dir, '32-01-PLAN.md'), '# Plan', 'utf8');
fs.writeFileSync(path.join(phase32Dir, '32-01-SUMMARY.md'), '# Summary', 'utf8');
fs.mkdirSync(path.join(phasesDir, '33-follow-up-implementation'), { recursive: true });
return { planningDir };
}
describe('bug #3517: phase.complete leaves STATE.md with stale fields', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3517-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('completed_phases is derived from ROADMAP, not blindly incremented (idempotency)', () => {
setupPhase3517Project(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const r1 = runSdkQuery(['phase.complete', '5'], tmpDir);
assert.ok(r1.success, `first call failed: ${r1.error}`);
const stateAfter1 = fs.readFileSync(statePath, 'utf8');
const match1 = stateAfter1.match(/completed_phases:\s*(\d+)/);
assert.ok(match1, 'completed_phases not found in frontmatter after first call');
assert.equal(
Number(match1[1]),
2,
`After first call: completed_phases should be 2 (derived from ROADMAP: phases 4 and 5 complete), got ${match1[1]}`,
);
const r2 = runSdkQuery(['phase.complete', '5'], tmpDir);
assert.ok(r2.success, `second call failed: ${r2.error}`);
const stateAfter2 = fs.readFileSync(statePath, 'utf8');
const match2 = stateAfter2.match(/completed_phases:\s*(\d+)/);
assert.ok(match2, 'completed_phases not found in frontmatter after second call');
assert.equal(
Number(match2[1]),
2,
`After second call (same phase): completed_phases must remain 2 (idempotent), got ${match2[1]}`,
);
});
test('frontmatter stopped_at is updated after phase.complete', () => {
setupPhase3517Project(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
assert.ok(r.success, `call failed: ${r.error}`);
const state = fs.readFileSync(statePath, 'utf8');
const stoppedMatch = state.match(/stopped_at:\s*(.+)/);
assert.ok(stoppedMatch, 'stopped_at not found in frontmatter');
assert.ok(
!stoppedMatch[1].includes('05-03-PLAN.md'),
`stopped_at should not still say "Completed 05-03-PLAN.md" — got: ${stoppedMatch[1]}`,
);
assert.ok(
stoppedMatch[1].toLowerCase().includes('phase 5') ||
stoppedMatch[1].toLowerCase().includes('complete'),
`stopped_at should reference phase 5 completion, got: ${stoppedMatch[1]}`,
);
});
test('frontmatter last_updated is refreshed to today after phase.complete', () => {
setupPhase3517Project(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
assert.ok(r.success, `call failed: ${r.error}`);
const state = fs.readFileSync(statePath, 'utf8');
const lastUpdatedMatch = state.match(/last_updated:\s*(.+)/);
assert.ok(lastUpdatedMatch, 'last_updated not found in frontmatter');
const raw = lastUpdatedMatch[1].trim().replace(/^"(.*)"$/, '$1');
// Must have been refreshed — not the stale seed value from setupPhase3517Project
assert.notEqual(
raw,
'2026-05-10T08:00:00.000Z',
`last_updated must be refreshed, but it is still the stale seed value: ${raw}`,
);
// Must parse as a valid ISO timestamp
const updatedAt = new Date(raw);
assert.ok(
!isNaN(updatedAt.getTime()),
`last_updated must be a valid ISO timestamp, got: ${raw}`,
);
// Date portion must equal today's UTC date — avoids any wall-clock window comparison
const todayUtc = new Date().toISOString().slice(0, 10);
const updatedDateUtc = updatedAt.toISOString().slice(0, 10);
assert.equal(
updatedDateUtc,
todayUtc,
`last_updated date portion must equal today's UTC date (${todayUtc}), got: ${updatedDateUtc}`,
);
});
test('frontmatter total_plans is updated from ROADMAP plan counts after phase.complete', () => {
setupPhase3517Project(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
assert.ok(r.success, `call failed: ${r.error}`);
const state = fs.readFileSync(statePath, 'utf8');
const match = state.match(/total_plans:\s*(\d+)/);
assert.ok(match, 'total_plans not found in frontmatter');
const totalPlans = Number(match[1]);
assert.ok(Number.isFinite(totalPlans) && totalPlans > 0, `total_plans must be a positive number, got: ${match[1]}`);
});
test('frontmatter completed_plans is updated from SUMMARY file count after phase.complete', () => {
setupPhase3517Project(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
assert.ok(r.success, `call failed: ${r.error}`);
const state = fs.readFileSync(statePath, 'utf8');
const match = state.match(/completed_plans:\s*(\d+)/);
assert.ok(match, 'completed_plans not found in frontmatter');
const completedPlans = Number(match[1]);
assert.equal(
completedPlans,
10,
`completed_plans should be 10 (3 phase-4 summaries + 7 phase-5 summaries), got: ${completedPlans}`,
);
});
test('frontmatter percent is recomputed from fresh derived counts', () => {
setupPhase3517Project(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
assert.ok(r.success, `call failed: ${r.error}`);
const state = fs.readFileSync(statePath, 'utf8');
const match = state.match(/percent:\s*(\d+)/);
assert.ok(match, 'percent not found in frontmatter');
assert.equal(Number(match[1]), 67, `percent should be 67 (2/3 phases), got: ${match[1]}`);
});
test('state frontmatter and numeric phase line reflect next phase after phase.complete', () => {
setupPhase3517Project(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
assert.ok(r.success, `call failed: ${r.error}`);
const state = fs.readFileSync(statePath, 'utf8');
assert.match(state, /completed_phases:\s*2/, 'completed_phases must be updated in frontmatter');
assert.match(state, /Phase:\s*0?6\b/, 'numeric Phase line should advance to phase 6');
});
test('prose-block STATE keeps next phase name without field-miss warnings (#1316)', () => {
const { planningDir } = setupPhase1316Project(tmpDir);
writePassedVerificationForPhase(tmpDir, '32');
const result = spawnSync(process.execPath, [GSD_TOOLS_BIN, 'phase', 'complete', '32'], {
cwd: tmpDir,
encoding: 'utf8',
env: process.env,
});
assert.strictEqual(result.status, 0, `phase complete failed: ${result.stderr || result.stdout}`);
assert.ok(
!result.stderr.includes('Current Phase Name'),
`phase.complete must not warn about missing Current Phase Name on prose-block STATE.md; stderr:\n${result.stderr}`,
);
assert.ok(
!result.stderr.includes('Last Activity Description'),
`phase.complete must not warn about missing Last Activity Description on prose-block STATE.md; stderr:\n${result.stderr}`,
);
const state = fs.readFileSync(path.join(planningDir, 'STATE.md'), 'utf8');
assert.match(state, /current_phase:\s*"?33"?/, 'current_phase frontmatter must advance to 33');
assert.match(
state,
/^Phase:\s*33\s+—\s+Follow Up Implementation\b/m,
`Current Position Phase line must keep the next phase name; state:\n${state}`,
);
assert.match(
state,
/^Last activity:\s*\d{4}-\d{2}-\d{2}\s+—\s+Phase 32 complete/m,
`Last activity line must use the template em-dash delimiter with narrative; state:\n${state}`,
);
});
test('body By Phase table row for completed phase shows correct plan count', () => {
setupPhase3517Project(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
assert.ok(r.success, `call failed: ${r.error}`);
const state = fs.readFileSync(statePath, 'utf8');
assert.match(
state,
/\|\s*5\s*\|\s*7\s*\|/,
`By Phase table should have a row for phase 5 with 7 summaries.\nState:\n${state}`,
);
});
test('full consistency check: all STATE.md fields are coherent after phase.complete', () => {
setupPhase3517Project(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
assert.ok(r.success, `call failed: ${r.error}`);
assert.equal(r.data?.state_updated, true, 'state_updated must be true');
const state = fs.readFileSync(statePath, 'utf8');
assert.match(state, /completed_phases:\s*2/, 'completed_phases must be 2 (4 and 5 complete)');
assert.match(state, /percent:\s*67/, 'percent must be 67%');
const hasPhase6 = /Phase:\s*0?6/.test(state) || /current_phase:\s*0?6/.test(state);
assert.ok(hasPhase6, `STATE.md must reference Phase 6 as current after completing Phase 5.\nState:\n${state}`);
});
});
}
// ─────────────────────────────────────────────────────────────────────────────
// regression: bug #3601 — phase remove preserves peer-depth decimal sections
// (consolidated from tests/bug-3601-phase-remove-preserves-decimal-sections.test.cjs)
// ─────────────────────────────────────────────────────────────────────────────
{
process.env.GSD_TEST_MODE = '1';
function writeRoadmapForRemove(tmpDir, body) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), body);
}
function writeStateForRemove(tmpDir, version) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`---\nmilestone: ${version}\n---\n`,
);
}
function ensurePhaseDir(tmpDir, name) {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', name), { recursive: true });
}
function getPhase(tmpDir, phaseNum) {
const r = runGsdTools(['roadmap', 'get-phase', phaseNum, '--json'], tmpDir);
if (!r.success) return { found: false, error: r.error };
return JSON.parse(r.output);
}
describe('bug #3601: phase remove preserves peer-depth decimal sections', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('bug-3601-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('removing Phase 2 preserves peer-depth Phase 2.1 and renumbers Phase 3 → 2', () => {
writeStateForRemove(tmpDir, 'v1.0.0');
writeRoadmapForRemove(
tmpDir,
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0 - Test',
'',
'### Phase 2: Parent',
'**Goal:** RemoveMeGoal',
'',
'### Phase 2.1: Follow-up',
'**Goal:** PreserveDecimalGoal',
'',
'### Phase 3: Trailing',
'**Goal:** PreserveTrailingGoal',
'',
].join('\n'),
);
ensurePhaseDir(tmpDir, '02-parent');
ensurePhaseDir(tmpDir, '02.1-follow-up');
ensurePhaseDir(tmpDir, '03-trailing');
const r = runGsdTools(['phase', 'remove', '2'], tmpDir);
assert.ok(r.success, `phase remove failed: ${r.error || r.output}`);
const decimal = getPhase(tmpDir, '2.1');
assert.strictEqual(decimal.found, true, 'Phase 2.1 deleted alongside Phase 2');
assert.strictEqual(decimal.phase_name, 'Follow-up');
assert.strictEqual(decimal.goal, 'PreserveDecimalGoal');
const renumbered = getPhase(tmpDir, '2');
assert.strictEqual(renumbered.found, true);
assert.strictEqual(renumbered.phase_name, 'Trailing');
assert.strictEqual(
renumbered.goal,
'PreserveTrailingGoal',
'Phase 3 → Phase 2 renumber did not carry the right section content',
);
const parentLookup = getPhase(tmpDir, '3');
assert.notStrictEqual(
parentLookup.goal,
'RemoveMeGoal',
'removed parent goal reappeared under a phase header',
);
});
test('removing Phase 5 preserves Phase 5.1 and Phase 5.2 (multiple peer decimals)', () => {
writeStateForRemove(tmpDir, 'v1.0.0');
writeRoadmapForRemove(
tmpDir,
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0 - Test',
'',
'### Phase 5: Parent',
'**Goal:** RemoveParent',
'',
'### Phase 5.1: First child',
'**Goal:** ChildAGoal',
'',
'### Phase 5.2: Second child',
'**Goal:** ChildBGoal',
'',
'### Phase 6: Tail',
'**Goal:** TailGoal',
'',
].join('\n'),
);
ensurePhaseDir(tmpDir, '05-parent');
ensurePhaseDir(tmpDir, '05.1-first-child');
ensurePhaseDir(tmpDir, '05.2-second-child');
ensurePhaseDir(tmpDir, '06-tail');
const r = runGsdTools(['phase', 'remove', '5'], tmpDir);
assert.ok(r.success);
const decimalA = getPhase(tmpDir, '5.1');
assert.strictEqual(decimalA.found, true, 'Phase 5.1 deleted');
assert.strictEqual(decimalA.goal, 'ChildAGoal');
const decimalB = getPhase(tmpDir, '5.2');
assert.strictEqual(decimalB.found, true, 'Phase 5.2 deleted');
assert.strictEqual(decimalB.goal, 'ChildBGoal');
const tail = getPhase(tmpDir, '5');
assert.strictEqual(tail.found, true);
assert.strictEqual(tail.goal, 'TailGoal', 'Phase 6 → Phase 5 renumber misfired');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// regression: bug #3602 — phase remove renumbers slugged plan references
// (consolidated from tests/bug-3602-phase-remove-renumbers-slugged-plan-refs.test.cjs)
// ─────────────────────────────────────────────────────────────────────────────
function ensurePlanFile(tmpDir, phaseDirName, planName) {
const p = path.join(tmpDir, '.planning', 'phases', phaseDirName, planName);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, '# Plan\n');
}
describe('bug #3602: phase remove renumbers slugged plan references in ROADMAP', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('bug-3602-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('slugged PLAN reference (07-01-cherry-pick-foundation-PLAN.md) is renumbered to 06-01-…', () => {
writeStateForRemove(tmpDir, 'v1.0.0');
writeRoadmapForRemove(
tmpDir,
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0',
'',
'### Phase 6: Old Work',
'**Goal:** RemoveThisGoal',
'',
'### Phase 7: New Work',
'**Goal:** Plans: 07-01-cherry-pick-foundation-PLAN.md and 07-02-finish-it-SUMMARY.md',
'',
].join('\n'),
);
ensurePhaseDir(tmpDir, '06-old');
ensurePhaseDir(tmpDir, '07-new');
ensurePlanFile(tmpDir, '07-new', '07-01-cherry-pick-foundation-PLAN.md');
ensurePlanFile(tmpDir, '07-new', '07-02-finish-it-SUMMARY.md');
const r = runGsdTools(['phase', 'remove', '6'], tmpDir);
assert.ok(r.success, `phase remove failed: ${r.error || r.output}`);
const phase6 = getPhase(tmpDir, '6');
assert.strictEqual(phase6.found, true);
assert.strictEqual(phase6.phase_name, 'New Work');
assert.ok(
phase6.goal.includes('06-01-cherry-pick-foundation-PLAN.md'),
`Plans reference for slugged PLAN was not renumbered. Goal: ${phase6.goal}`,
);
assert.ok(
phase6.goal.includes('06-02-finish-it-SUMMARY.md'),
`Plans reference for slugged SUMMARY was not renumbered. Goal: ${phase6.goal}`,
);
assert.ok(
!phase6.goal.includes('07-01'),
`stale 07-01 prefix remains in ROADMAP. Goal: ${phase6.goal}`,
);
assert.ok(
!phase6.goal.includes('07-02'),
`stale 07-02 prefix remains in ROADMAP. Goal: ${phase6.goal}`,
);
});
test('compact PLAN/SUMMARY reference (07-01-PLAN.md) still renumbers (#3601 contract preserved)', () => {
writeStateForRemove(tmpDir, 'v1.0.0');
writeRoadmapForRemove(
tmpDir,
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0',
'',
'### Phase 6: Old',
'**Goal:** RemoveGoal',
'',
'### Phase 7: New',
'**Goal:** Plans: 07-01-PLAN.md and 07-02-SUMMARY.md',
'',
].join('\n'),
);
ensurePhaseDir(tmpDir, '06-old');
ensurePhaseDir(tmpDir, '07-new');
ensurePlanFile(tmpDir, '07-new', '07-01-PLAN.md');
ensurePlanFile(tmpDir, '07-new', '07-02-SUMMARY.md');
const r = runGsdTools(['phase', 'remove', '6'], tmpDir);
assert.ok(r.success);
const phase6 = getPhase(tmpDir, '6');
assert.strictEqual(phase6.found, true);
assert.ok(phase6.goal.includes('06-01-PLAN.md'));
assert.ok(phase6.goal.includes('06-02-SUMMARY.md'));
assert.ok(!phase6.goal.includes('07-01-PLAN.md'));
assert.ok(!phase6.goal.includes('07-02-SUMMARY.md'));
});
test('does NOT renumber values that look like phase-plan tokens but are not (e.g. 2026-01-01 dates)', () => {
writeStateForRemove(tmpDir, 'v1.0.0');
writeRoadmapForRemove(
tmpDir,
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0',
'',
'### Phase 6: Old',
'**Goal:** RemoveGoal',
'',
'### Phase 7: Date safety',
'**Goal:** Created 2026-01-01 and tagged v1-2-3 — must not renumber',
'',
].join('\n'),
);
ensurePhaseDir(tmpDir, '06-old');
ensurePhaseDir(tmpDir, '07-new');
const r = runGsdTools(['phase', 'remove', '6'], tmpDir);
assert.ok(r.success);
const phase6 = getPhase(tmpDir, '6');
assert.strictEqual(phase6.found, true);
assert.ok(
phase6.goal.includes('2026-01-01'),
`ISO date 2026-01-01 was wrongly modified. Goal: ${phase6.goal}`,
);
assert.ok(
phase6.goal.includes('v1-2-3'),
`version-tag v1-2-3 was wrongly modified. Goal: ${phase6.goal}`,
);
});
});
}
// ─────────────────────────────────────────────────────────────────────────────
// feature: phase complete auto-prune (#2087)
// (consolidated from tests/phase-complete-auto-prune.test.cjs)
// ─────────────────────────────────────────────────────────────────────────────
{
function writeConfigForAutoPrune(tmpDir, config) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify(config, null, 2));
}
function writeStateMdForAutoPrune(tmpDir, content) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
}
function readStateMdForAutoPrune(tmpDir) {
return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
}
function writeRoadmapForAutoPrune(tmpDir, content) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), content);
}
function setupPhaseForAutoPrune(tmpDir, phaseNum, planCount) {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const phaseDir = path.join(phasesDir, `${String(phaseNum).padStart(2, '0')}-test-phase`);
fs.mkdirSync(phaseDir, { recursive: true });
for (let i = 1; i <= planCount; i++) {
const planId = `${String(phaseNum).padStart(2, '0')}-${String(i).padStart(2, '0')}`;
fs.writeFileSync(path.join(phaseDir, `${planId}-PLAN.md`), `# Plan ${planId}\n`);
fs.writeFileSync(path.join(phaseDir, `${planId}-SUMMARY.md`), `# Summary ${planId}\n`);
}
}
describe('phase complete auto-prune (#2087)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('prunes STATE.md automatically when auto_prune_state is true', () => {
writeConfigForAutoPrune(tmpDir, {
workflow: { auto_prune_state: true },
});
writeStateMdForAutoPrune(tmpDir, [
'# Session State',
'',
'**Current Phase:** 6',
'**Status:** Executing',
'',
'## Decisions',
'',
'- [Phase 1]: Old decision from phase 1',
'- [Phase 2]: Old decision from phase 2',
'- [Phase 5]: Recent decision',
'- [Phase 6]: Current decision',
'',
].join('\n'));
writeRoadmapForAutoPrune(tmpDir, [
'# Roadmap',
'',
'## Phase 6: Test Phase',
'',
'**Plans:** 0/2',
'',
].join('\n'));
setupPhaseForAutoPrune(tmpDir, 6, 2);
const result = runVerifiedPhaseComplete('phase complete 6', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const newState = readStateMdForAutoPrune(tmpDir);
assert.doesNotMatch(newState, /\[Phase 1\]: Old decision/);
assert.doesNotMatch(newState, /\[Phase 2\]: Old decision/);
assert.match(newState, /\[Phase 5\]: Recent decision/);
assert.match(newState, /\[Phase 6\]: Current decision/);
});
test('does NOT prune when auto_prune_state is false (default)', () => {
writeConfigForAutoPrune(tmpDir, {
workflow: { auto_prune_state: false },
});
writeStateMdForAutoPrune(tmpDir, [
'# Session State',
'',
'**Current Phase:** 6',
'**Status:** Executing',
'',
'## Decisions',
'',
'- [Phase 1]: Old decision from phase 1',
'- [Phase 5]: Recent decision',
'- [Phase 6]: Current decision',
'',
].join('\n'));
writeRoadmapForAutoPrune(tmpDir, [
'# Roadmap',
'',
'## Phase 6: Test Phase',
'',
'**Plans:** 0/2',
'',
].join('\n'));
setupPhaseForAutoPrune(tmpDir, 6, 2);
const result = runVerifiedPhaseComplete('phase complete 6', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const newState = readStateMdForAutoPrune(tmpDir);
assert.match(newState, /\[Phase 1\]: Old decision/);
});
test('does NOT prune when auto_prune_state is absent from config', () => {
writeConfigForAutoPrune(tmpDir, {
workflow: {},
});
writeStateMdForAutoPrune(tmpDir, [
'# Session State',
'',
'**Current Phase:** 6',
'**Status:** Executing',
'',
'## Decisions',
'',
'- [Phase 1]: Old decision from phase 1',
'- [Phase 6]: Current decision',
'',
].join('\n'));
writeRoadmapForAutoPrune(tmpDir, [
'# Roadmap',
'',
'## Phase 6: Test Phase',
'',
'**Plans:** 0/2',
'',
].join('\n'));
setupPhaseForAutoPrune(tmpDir, 6, 2);
const result = runVerifiedPhaseComplete('phase complete 6', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const newState = readStateMdForAutoPrune(tmpDir);
assert.match(newState, /\[Phase 1\]: Old decision/);
});
});
}
// ─────────────────────────────────────────────────────────────────────────────
// bug #1229: phase.add bullet-only phase collision
// ─────────────────────────────────────────────────────────────────────────────
// Count canonical Phase N entries in roadmap content (header or bullet form).
// Only counts "### Phase N:" headers and "- [ ] **Phase N:" bullet entries.
// Does NOT count references like "**Depends on:** Phase N".
function countBug1229PhaseNumber(roadmapContent, n) {
let count = 0;
const headerRe = new RegExp('^#{2,4}\\s*Phase\\s+' + n + '[A-Z]?(?:\\.\\d+)*:', 'gim');
const bulletRe = new RegExp(
'^[ \\t]*-[ \\t]*\\[[^\\]]*\\][ \\t]*\\*{0,2}Phase[ \\t]+' + n + '(?=[:.\\ \\t*]|$)',
'gim',
);
const headerMatches = roadmapContent.match(headerRe);
const bulletMatches = roadmapContent.match(bulletRe);
if (headerMatches) count += headerMatches.length;
if (bulletMatches) count += bulletMatches.length;
return count;
}
describe('bug #1229: phase.add must count bullet-only phases to avoid number collision', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('bullet-only Phase 11 is counted: next add gets Phase 12, not 11', () => {
// ROADMAP has Phases 1-3 as full sections and Phase 11 as bullet-only.
// Before the fix, maxPhase resolved to 3 (header scan) and phase.add
// silently produced Phase 4, then on a second add would produce Phase 11
// — or if headers went to 10, it would produce Phase 11 colliding with
// the existing bullet.
const roadmap = [
'# Roadmap v1.0',
'',
'## Phases',
'',
'- [ ] **Phase 1: Foundation**',
'- [ ] **Phase 2: Core**',
'- [x] **Phase 3: Done**',
'- [ ] **Phase 11: Communications / Zoho Sync**',
'',
'### Phase 1: Foundation',
'',
'**Goal:** Build foundations',
'',
'### Phase 2: Core',
'',
'**Goal:** Core work',
'',
'### Phase 3: Done',
'',
'**Goal:** Completed work',
'',
'---',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
// Create disk dirs for phases 1, 2, 3 (not 11 -- that is bullet-only)
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-core'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-done'), { recursive: true });
const result = runGsdTools('phase add New Feature', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.phase_number,
12,
`Expected phase 12 (bullet-only Phase 11 must be counted), got ${output.phase_number}`,
);
// Verify no duplicate Phase 11 written
const updatedRoadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
const phase11Count = countBug1229PhaseNumber(updatedRoadmap, 11);
assert.ok(
phase11Count === 1,
`ROADMAP must have exactly 1 occurrence of Phase 11 (no duplicate), found ${phase11Count}`,
);
// Verify Phase 12 was written
assert.ok(
updatedRoadmap.includes('### Phase 12:'),
'ROADMAP must contain new ### Phase 12: entry',
);
// Verify directory was created at 12, not 11
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '12-new-feature')),
'phases/12-new-feature directory must be created',
);
assert.ok(
!fs.existsSync(path.join(tmpDir, '.planning', 'phases', '11-new-feature')),
'phases/11-new-feature must NOT be created (collision guard)',
);
});
test('[x] checkbox variant bullet phase is counted', () => {
// Phase 5 exists only as a [x] bullet (completed, no dir, no header)
const roadmap = [
'# Roadmap v1.0',
'',
'### Phase 1: Foundation',
'',
'**Goal:** Setup',
'',
'### Phase 2: API',
'',
'**Goal:** Build',
'',
'### Phase 3: UI',
'',
'**Goal:** Interfaces',
'',
'### Phase 4: Deploy',
'',
'**Goal:** Ship it',
'',
'- [x] **Phase 5: Post-launch Cleanup**',
'',
'---',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
const result = runGsdTools('phase add Follow-up Work', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.phase_number,
6,
`Expected phase 6 ([x] bullet-only Phase 5 must be counted), got ${output.phase_number}`,
);
});
test('[~] checkbox variant bullet phase is counted', () => {
// Phase 7 exists only as a [~] bullet (in-progress, no dir, no header)
const roadmap = [
'# Roadmap v1.0',
'',
'### Phase 1: Foundation',
'',
'**Goal:** Setup',
'',
'- [~] **Phase 7: Partial Work**',
'',
'---',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
const result = runGsdTools('phase add Next Phase', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.phase_number,
8,
`Expected phase 8 ([~] bullet-only Phase 7 must be counted), got ${output.phase_number}`,
);
});
test('baseline: no bullet-only phases -- existing behavior preserved', () => {
const roadmap = [
'# Roadmap v1.0',
'',
'### Phase 1: Foundation',
'',
'**Goal:** Setup',
'',
'### Phase 2: API',
'',
'**Goal:** Build',
'',
'---',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
const result = runGsdTools('phase add Third Phase', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.phase_number,
3,
`Expected phase 3 (normal sequential add), got ${output.phase_number}`,
);
});
test('bullet without ** bold markers is counted', () => {
// Phase 6 as plain bullet without ** markdown bold
const roadmap = [
'# Roadmap v1.0',
'',
'### Phase 1: Foundation',
'',
'**Goal:** Setup',
'',
'### Phase 2: Core',
'',
'**Goal:** Core',
'',
'- [ ] Phase 6: Plain bullet no bold',
'',
'---',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
const result = runGsdTools('phase add Another Phase', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.phase_number,
7,
`Expected phase 7 (plain-bullet Phase 6 must be counted), got ${output.phase_number}`,
);
});
test('titleless bold bullet "- [ ] **Phase 11**" is counted: next add gets Phase 12', () => {
// Regression for the adversarial-review finding: the original bulletPattern
// required a colon or whitespace after the digits, so "- [ ] **Phase 11**"
// (bold-close immediately after the number) was silently skipped and phase.add
// would assign Phase 11 again — the exact collision class bug #1229 fixes.
const roadmap = [
'# Roadmap v1.0',
'',
'### Phase 1: Foundation',
'',
'**Goal:** Setup',
'',
'### Phase 2: Core',
'',
'**Goal:** Core',
'',
'- [ ] **Phase 11**',
'',
'---',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
const result = runGsdTools('phase add Titleless Bold Follow-up', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.phase_number,
12,
`Expected phase 12 (titleless bold bullet "**Phase 11**" must be counted), got ${output.phase_number}`,
);
const updatedRoadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(
updatedRoadmap.includes('### Phase 12:'),
'ROADMAP must contain new ### Phase 12: entry',
);
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '12-titleless-bold-follow-up')),
'phases/12-titleless-bold-follow-up directory must be created',
);
assert.ok(
!fs.existsSync(path.join(tmpDir, '.planning', 'phases', '11-titleless-bold-follow-up')),
'phases/11-titleless-bold-follow-up must NOT be created (collision guard)',
);
});
test('EOL bullet "- [ ] Phase 11" (no title, no bold) is counted: next add gets Phase 12', () => {
// Regression: "- [ ] Phase 11" at end-of-line was not matched by the original
// pattern whose trailing [:\s] requires at least one character after the digits.
const roadmap = [
'# Roadmap v1.0',
'',
'### Phase 1: Foundation',
'',
'**Goal:** Setup',
'',
'### Phase 2: Core',
'',
'**Goal:** Core',
'',
'- [ ] Phase 11',
'',
'---',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
const result = runGsdTools('phase add EOL Follow-up', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.phase_number,
12,
`Expected phase 12 (EOL bullet "Phase 11" must be counted), got ${output.phase_number}`,
);
const updatedRoadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(
updatedRoadmap.includes('### Phase 12:'),
'ROADMAP must contain new ### Phase 12: entry',
);
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '12-eol-follow-up')),
'phases/12-eol-follow-up directory must be created',
);
assert.ok(
!fs.existsSync(path.join(tmpDir, '.planning', 'phases', '11-eol-follow-up')),
'phases/11-eol-follow-up must NOT be created (collision guard)',
);
});
});