Merge pull request #784 from Tibsfox/fix/milestone-stats-scoping

fix(milestone): scope stats and archive to current milestone phases
This commit is contained in:
TÂCHES
2026-02-27 09:22:45 -06:00
committed by GitHub
2 changed files with 224 additions and 2 deletions

View File

@@ -92,7 +92,44 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
// Ensure archive directory exists
fs.mkdirSync(archiveDir, { recursive: true });
// Gather stats from phases
// Extract milestone phase numbers from ROADMAP.md to scope stats.
// Only phases listed in the current ROADMAP are counted — phases from
// prior milestones that remain on disk are excluded.
//
// Related upstream PRs (getMilestoneInfo, not milestone complete):
// #756 — fix(core): detect current milestone correctly in getMilestoneInfo
// #783 — fix: getMilestoneInfo() returns wrong version after completion
// Those PRs fix *which* milestone is detected; this fix scopes *stats*
// and *accomplishments* to only the phases belonging to that milestone.
const milestonePhaseNums = new Set();
if (fs.existsSync(roadmapPath)) {
try {
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi;
let phaseMatch;
while ((phaseMatch = phasePattern.exec(roadmapContent)) !== null) {
milestonePhaseNums.add(phaseMatch[1]);
}
} catch {}
}
// Pre-normalize phase numbers for O(1) lookup — strip leading zeros
// and lowercase for case-insensitive matching of letter suffixes (e.g. 3A/3a).
const normalizedPhaseNums = new Set(
[...milestonePhaseNums].map(num => (num.replace(/^0+/, '') || '0').toLowerCase())
);
// Match a phase directory name to the milestone's phase set.
// Handles: "01-foo" → "1", "3A-bar" → "3a", "3.1-baz" → "3.1"
// Returns false for non-phase directories (no leading digit).
function isDirInMilestone(dirName) {
if (normalizedPhaseNums.size === 0) return true; // no scoping
const m = dirName.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
if (!m) return false; // not a phase directory
return normalizedPhaseNums.has(m[1].toLowerCase());
}
// Gather stats from phases (scoped to current milestone only)
let phaseCount = 0;
let totalPlans = 0;
let totalTasks = 0;
@@ -103,6 +140,8 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
for (const dir of dirs) {
if (!isDirInMilestone(dir)) continue;
phaseCount++;
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
@@ -182,10 +221,13 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
const phaseEntries = fs.readdirSync(phasesDir, { withFileTypes: true });
const phaseDirNames = phaseEntries.filter(e => e.isDirectory()).map(e => e.name);
let archivedCount = 0;
for (const dir of phaseDirNames) {
if (!isDirInMilestone(dir)) continue;
fs.renameSync(path.join(phasesDir, dir), path.join(phaseArchiveDir, dir));
archivedCount++;
}
phasesArchived = phaseDirNames.length > 0;
phasesArchived = archivedCount > 0;
} catch {}
}

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'),