* 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>
148 lines
5.1 KiB
JavaScript
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))}`
|
|
);
|
|
});
|
|
});
|