Files
msd-core/tests/bug-501-flat-phase-details-milestone-leak.test.cjs
Tom Boucher 5589f4f817 fix(#501): stop flat "## Phase Details" leaking phases into active milestone (#513)
* fix(#501): stop flat "## Phase Details" leaking phases into active milestone

extractCurrentMilestone returned `preamble + currentSection`, where the
preamble (everything before the first milestone heading, only <details>
stripped) could carry a flat "## Phase Details" section listing `### Phase N:`
entries for ALL milestones. Those leaked into the active-milestone scope, so
getMilestonePhaseFilter / buildStateFrontmatter counted the whole project
(e.g. total_phases: 18) instead of the active milestone (14-18).

Fix (maintainer direction: code fix, count + validate-aware):
1. core.cjs extractCurrentMilestone — strip flat phase-detail blocks
   (`### Phase N:` heading + body, and a "## Phase Details" heading) from the
   preamble. The active milestone's own phases live in currentSection, so this
   is safe. Fixes the count with no ROADMAP edits.
2. verify.cjs cmdValidateConsistency + cmdValidateHealth — the "phases on disk
   but not in ROADMAP" / W007 checks now compare disk dirs against the FULL
   roadmap (every milestone), not the active-milestone scope. Without this,
   narrowing the scope would flag every shipped phase dir as a spurious orphan
   (the documented side effect of the <details> workaround).

Tests reproduce the real layout (flat Phase Details before milestones) and
assert: state json total_phases counts only active phases; validate
consistency and validate health (W007) do not flag shipped phase dirs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#501): add changeset for flat Phase Details milestone leak fix

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 12:00:58 -04:00

148 lines
5.1 KiB
JavaScript

/**
* Bug #501: extractCurrentMilestone leaks prior-milestone phases when the
* ROADMAP uses a flat shared "## Phase Details" section.
*
* extractCurrentMilestone returns `preamble + currentSection`, where the
* preamble is everything before the first milestone heading (only <details>
* blocks stripped). A flat "## Phase Details" section listing every phase
* across all milestones therefore leaks its `### Phase N:` headings into the
* active-milestone scope, so getMilestonePhaseFilter / buildStateFrontmatter
* count the whole project instead of just the active milestone.
*
* Maintainer direction (triage of #501): fix in code AND make
* `validate consistency` milestone-aware so it does not flag shipped phase
* dirs as orphans once the scope is correctly narrowed.
*
* Layout under test (mirrors the real repro):
* # Roadmap
* ## Phase Details <- flat, BEFORE the first milestone heading
* ### Phase 1..3 <- shipped phases
* ## ✅ v2.0 <- shipped milestone
* ## 🚧 v3.0 (active)
* ### Phase 4..5 <- active-milestone phases
* STATE.md milestone: v3.0 → state json must report total_phases: 2.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const ROADMAP = `# Roadmap
Project overview prose that legitimately lives before the milestones.
## Phase Details
### Phase 1: Shipped One
Did a thing.
### Phase 2: Shipped Two
Did another thing.
### Phase 3: Shipped Three
Did a third thing.
## ✅ v2.0: Foundation (shipped)
Summary of the shipped milestone.
## 🚧 v3.0: Active Milestone
### Phase 4: Active One
Doing a thing.
### Phase 5: Active Two
Doing another thing.
`;
const STATE = `---
gsd_state_version: 1.0
milestone: v3.0
milestone_name: Active Milestone
status: in_progress
progress:
total_phases: 2
completed_phases: 0
total_plans: 0
completed_plans: 0
percent: 0
---
# Project State
## Current Position
Phase: 4 (Active One)
`;
describe('flat "## Phase Details" milestone leak (#501)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
const planning = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP, 'utf-8');
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE, 'utf-8');
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
// All five phase dirs exist on disk (the flat layout retains shipped dirs).
const phaseDirs = ['01-shipped-one', '02-shipped-two', '03-shipped-three', '04-active-one', '05-active-two'];
for (const d of phaseDirs) {
const dir = path.join(planning, 'phases', d);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, '01-PLAN.md'), '# Plan\n', 'utf-8');
}
});
afterEach(() => {
cleanup(tmpDir);
});
test('state json counts only the active milestone phases, not the flat Phase Details list', () => {
const result = runGsdTools(['state', 'json'], tmpDir);
assert.equal(result.success, true, result.error || result.output);
const state = JSON.parse(result.output);
assert.equal(
state.progress.total_phases,
2,
`active milestone v3.0 has 2 phases (4,5); flat Phase Details (1-3) must not leak. Got total_phases=${state.progress.total_phases}`
);
});
test('validate consistency does not flag shipped phase dirs as not-in-ROADMAP', () => {
// Once milestone scope is correctly narrowed (Test A), the shipped phase
// dirs (1-3) are no longer in the SCOPED roadmap. They are, however, real
// phases listed in the FULL roadmap, so they must NOT be reported as
// "exists on disk but not in ROADMAP" orphans. (#501 — validate must be
// milestone-aware.)
const result = runGsdTools(['validate', 'consistency'], tmpDir);
const payload = JSON.parse(result.output);
const warnings = payload.warnings || [];
const orphanWarnings = warnings.filter((w) => /exists on disk but not in ROADMAP/i.test(w));
assert.deepEqual(
orphanWarnings,
[],
`shipped phase dirs (1-3) are in the full ROADMAP and must not be flagged as orphans. Got: ${JSON.stringify(orphanWarnings)}`
);
});
test('validate health (W007) does not flag shipped phase dirs as not-in-ROADMAP', () => {
// cmdValidateHealth's Check 8 has the same coupling: its W007 membership
// check compared active disk phases against the active-milestone scope.
// Shipped phase dirs in the active phases/ dir must be checked against the
// FULL roadmap so they are not false W007 orphans. (#501)
const result = runGsdTools(['validate', 'health'], tmpDir);
const payload = JSON.parse(result.output);
const warnings = payload.warnings || [];
const w007Orphans = warnings.filter(
(w) => w.code === 'W007' && /exists on disk but not in ROADMAP/i.test(w.message)
);
assert.deepEqual(
w007Orphans,
[],
`shipped phase dirs (1-3) must not produce W007. Got: ${JSON.stringify(w007Orphans.map((w) => w.message))}`
);
});
});