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:
j2h4u
2026-03-02 22:27:41 +05:00
committed by Lex Christopherson
parent b863ed6de5
commit e1b3277869
5 changed files with 79 additions and 4 deletions

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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++) {