fix(state): derive total_phases from ROADMAP when phases lack directories
Phases listed in ROADMAP.md but not yet planned (no directory on disk) were excluded from total_phases, causing premature milestone completion. Now uses Math.max(diskDirs, roadmapCount) via filter.phaseCount. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
Lex Christopherson
parent
b863ed6de5
commit
e1b3277869
@@ -8,6 +8,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Fixed
|
||||
- Scope phase counting in `buildStateFrontmatter` and `cmdPhaseComplete` to current milestone — multi-milestone projects no longer report inflated total/completed phases
|
||||
- Use ROADMAP phase count for `total_phases` when phases lack directories — prevents premature milestone completion detection
|
||||
|
||||
## [1.22.1] - 2026-03-02
|
||||
|
||||
|
||||
@@ -425,17 +425,23 @@ function getMilestonePhaseFilter(cwd) {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (milestonePhaseNums.size === 0) return () => true;
|
||||
if (milestonePhaseNums.size === 0) {
|
||||
const passAll = () => true;
|
||||
passAll.phaseCount = 0;
|
||||
return passAll;
|
||||
}
|
||||
|
||||
const normalized = new Set(
|
||||
[...milestonePhaseNums].map(n => (n.replace(/^0+/, '') || '0').toLowerCase())
|
||||
);
|
||||
|
||||
return function isDirInMilestone(dirName) {
|
||||
function isDirInMilestone(dirName) {
|
||||
const m = dirName.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
|
||||
if (!m) return false;
|
||||
return normalized.has(m[1].toLowerCase());
|
||||
};
|
||||
}
|
||||
isDirInMilestone.phaseCount = milestonePhaseNums.size;
|
||||
return isDirInMilestone;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -565,7 +565,9 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
||||
diskTotalSummaries += summaries;
|
||||
if (plans > 0 && summaries >= plans) diskCompletedPhases++;
|
||||
}
|
||||
totalPhases = phaseDirs.length;
|
||||
totalPhases = isDirInMilestone.phaseCount > 0
|
||||
? Math.max(phaseDirs.length, isDirInMilestone.phaseCount)
|
||||
: phaseDirs.length;
|
||||
completedPhases = diskCompletedPhases;
|
||||
totalPlans = diskTotalPlans;
|
||||
completedPlans = diskTotalSummaries;
|
||||
|
||||
@@ -775,4 +775,29 @@ describe('getMilestonePhaseFilter', () => {
|
||||
assert.strictEqual(filter('not-a-phase'), false);
|
||||
assert.strictEqual(filter('.gitkeep'), false);
|
||||
});
|
||||
|
||||
test('phaseCount reflects ROADMAP phase count', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
'### Phase 5: Auth\n### Phase 6: Dashboard\n### Phase 7: Polish\n'
|
||||
);
|
||||
|
||||
const filter = getMilestonePhaseFilter(tmpDir);
|
||||
assert.strictEqual(filter.phaseCount, 3);
|
||||
});
|
||||
|
||||
test('phaseCount is 0 when ROADMAP is missing', () => {
|
||||
const filter = getMilestonePhaseFilter(tmpDir);
|
||||
assert.strictEqual(filter.phaseCount, 0);
|
||||
});
|
||||
|
||||
test('phaseCount is 0 when ROADMAP has no phase headings', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
'# Roadmap\n\nSome content.\n'
|
||||
);
|
||||
|
||||
const filter = getMilestonePhaseFilter(tmpDir);
|
||||
assert.strictEqual(filter.phaseCount, 0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1307,6 +1307,47 @@ describe('milestone-scoped phase counting in frontmatter', () => {
|
||||
assert.strictEqual(Number(output.progress.completed_phases), 2, 'both milestone phases have summaries');
|
||||
});
|
||||
|
||||
test('total_phases includes ROADMAP phases without directories', () => {
|
||||
// ROADMAP lists 6 phases (5-10), but only 4 have directories on disk
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
[
|
||||
'## Roadmap v3.0',
|
||||
'',
|
||||
'### Phase 5: Auth',
|
||||
'### Phase 6: Dashboard',
|
||||
'### Phase 7: API',
|
||||
'### Phase 8: Notifications',
|
||||
'### Phase 9: Analytics',
|
||||
'### Phase 10: Polish',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
// Only phases 5-8 have directories (9 and 10 not yet planned)
|
||||
for (let i = 5; i <= 8; 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');
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
'# Project State\n\n**Current Phase:** 08\n**Status:** In progress\n'
|
||||
);
|
||||
|
||||
const result = runGsdTools('state update Status "Executing"', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const jsonResult = runGsdTools('state json', tmpDir);
|
||||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||||
|
||||
const output = JSON.parse(jsonResult.output);
|
||||
assert.strictEqual(Number(output.progress.total_phases), 6, 'should count all 6 ROADMAP phases, not just 4 with directories');
|
||||
assert.strictEqual(Number(output.progress.completed_phases), 4, 'only 4 phases have summaries');
|
||||
});
|
||||
|
||||
test('without ROADMAP counts all phases (pass-all filter)', () => {
|
||||
// No ROADMAP.md — all phases should be counted
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
|
||||
Reference in New Issue
Block a user