fix(milestone): scope stats, accomplishments, and archive to current milestone phases

cmdMilestoneComplete previously iterated ALL phase directories in
.planning/phases/, including phases from prior milestones that remain on
disk. This produced inflated stats, stale accomplishments from old
summaries, and --archive-phases moving directories that belong to
earlier milestones.

This fix parses ROADMAP.md to extract phase numbers for the current
milestone, builds a normalized Set for O(1) lookup, and filters all
phase directory operations through an isDirInMilestone() helper.

The helper handles edge cases: leading zeros (01 -> 1), letter suffixes
(3A), decimal phases (3.1), large numbers (456), and excludes
non-numeric directories (notes/, misc/).

Adds 5 tests covering scoped stats, scoped archive, prefix collision
guard, non-numeric directory exclusion, and large phase numbers.

Complements upstream PRs #756 and #783 which fix getMilestoneInfo()
milestone detection — this fix addresses milestone completion scoping.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tibsfox
2026-02-27 03:45:42 -08:00
parent f9fc2a3f33
commit 367149d0b2
2 changed files with 224 additions and 2 deletions

View File

@@ -200,6 +200,186 @@ describe('milestone complete command', () => {
);
});
test('scopes stats to current milestone phases only', () => {
// Set up ROADMAP.md that only references Phase 3 and Phase 4
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.1\n\n### Phase 3: New Feature\n**Goal:** Build it\n\n### Phase 4: Polish\n**Goal:** Ship it\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
// Create phases from PREVIOUS milestone (should be excluded)
const p1 = path.join(tmpDir, '.planning', 'phases', '01-old-setup');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '---\none-liner: Old setup work\n---\n# Summary\n');
const p2 = path.join(tmpDir, '.planning', 'phases', '02-old-core');
fs.mkdirSync(p2, { recursive: true });
fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(p2, '02-01-SUMMARY.md'), '---\none-liner: Old core work\n---\n# Summary\n');
// Create phases for CURRENT milestone (should be included)
const p3 = path.join(tmpDir, '.planning', 'phases', '03-new-feature');
fs.mkdirSync(p3, { recursive: true });
fs.writeFileSync(path.join(p3, '03-01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(p3, '03-01-SUMMARY.md'), '---\none-liner: Built new feature\n---\n# Summary\n');
const p4 = path.join(tmpDir, '.planning', 'phases', '04-polish');
fs.mkdirSync(p4, { recursive: true });
fs.writeFileSync(path.join(p4, '04-01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(p4, '04-02-PLAN.md'), '# Plan 2\n');
fs.writeFileSync(path.join(p4, '04-01-SUMMARY.md'), '---\none-liner: Polished UI\n---\n# Summary\n');
const result = runGsdTools('milestone complete v1.1 --name "Second Release"', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// Should only count phases 3 and 4, not 1 and 2
assert.strictEqual(output.phases, 2, 'should count only milestone phases (3, 4)');
assert.strictEqual(output.plans, 3, 'should count only plans from phases 3 and 4');
// Accomplishments should only be from phases 3 and 4
assert.ok(output.accomplishments.includes('Built new feature'), 'should include current milestone accomplishment');
assert.ok(output.accomplishments.includes('Polished UI'), 'should include current milestone accomplishment');
assert.ok(!output.accomplishments.includes('Old setup work'), 'should NOT include previous milestone accomplishment');
assert.ok(!output.accomplishments.includes('Old core work'), 'should NOT include previous milestone accomplishment');
});
test('archive-phases only archives current milestone phases', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.1\n\n### Phase 2: Current Work\n**Goal:** Do it\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
// Phase from previous milestone
const p1 = path.join(tmpDir, '.planning', 'phases', '01-old');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan\n');
// Phase from current milestone
const p2 = path.join(tmpDir, '.planning', 'phases', '02-current');
fs.mkdirSync(p2, { recursive: true });
fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan\n');
const result = runGsdTools('milestone complete v1.1 --name Test --archive-phases', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
// Phase 2 should be archived
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.1-phases', '02-current')),
'current milestone phase should be archived'
);
// Phase 1 should still be in place (not archived)
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '01-old')),
'previous milestone phase should NOT be archived'
);
});
test('phase 1 in roadmap does NOT match directory 10-something (no prefix collision)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Status:** In progress\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\n');
fs.writeFileSync(
path.join(p1, '01-01-SUMMARY.md'),
'---\none-liner: Foundation work\n---\n'
);
const p10 = path.join(tmpDir, '.planning', 'phases', '10-scaling');
fs.mkdirSync(p10, { recursive: true });
fs.writeFileSync(path.join(p10, '10-01-PLAN.md'), '# Plan\n');
fs.writeFileSync(
path.join(p10, '10-01-SUMMARY.md'),
'---\none-liner: Scaling work\n---\n'
);
const result = runGsdTools('milestone complete v1.0 --name MVP', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases, 1, 'should count only phase 1, not phase 10');
assert.strictEqual(output.plans, 1, 'should count only plans from phase 1');
assert.ok(
output.accomplishments.includes('Foundation work'),
'should include phase 1 accomplishment'
);
assert.ok(
!output.accomplishments.includes('Scaling work'),
'should NOT include phase 10 accomplishment'
);
});
test('non-numeric directory is excluded when milestone scoping is active', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n\n### Phase 1: Core\n**Goal:** Build core\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-core');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan\n');
// Non-phase directory — should be excluded
const misc = path.join(tmpDir, '.planning', 'phases', 'notes');
fs.mkdirSync(misc, { recursive: true });
fs.writeFileSync(path.join(misc, 'PLAN.md'), '# Not a phase\n');
const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases, 1, 'non-numeric dir should not be counted as a phase');
assert.strictEqual(output.plans, 1, 'plans from non-numeric dir should not be counted');
});
test('large phase numbers (456, 457) scope correctly', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.49\n\n### Phase 456: DACP\n**Goal:** Ship DACP\n\n### Phase 457: Integration\n**Goal:** Integrate\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p456 = path.join(tmpDir, '.planning', 'phases', '456-dacp');
fs.mkdirSync(p456, { recursive: true });
fs.writeFileSync(path.join(p456, '456-01-PLAN.md'), '# Plan\n');
const p457 = path.join(tmpDir, '.planning', 'phases', '457-integration');
fs.mkdirSync(p457, { recursive: true });
fs.writeFileSync(path.join(p457, '457-01-PLAN.md'), '# Plan\n');
// Phase 45 from prior milestone — should not match
const p45 = path.join(tmpDir, '.planning', 'phases', '45-old');
fs.mkdirSync(p45, { recursive: true });
fs.writeFileSync(path.join(p45, 'PLAN.md'), '# Plan\n');
const result = runGsdTools('milestone complete v1.49 --name DACP', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases, 2, 'should count only phases 456 and 457');
});
test('handles empty phases directory', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),