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:
@@ -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 {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user