fix(#557): milestone erased when version is in <summary> tag (#563)

* fix(#557): Milestone marked complete while ROADMAP lists unstarted phases

- Fix 1 — Broaden extractCurrentMilestone() (core.cjs):
  (a) Extend sectionPattern to also match <summary> tag content: when a
      milestone version appears only inside a <summary> tag, locate the
      enclosing <details> block and return its content directly instead of
      falling through to stripShippedMilestones().
  (b) Extend activeMarkerPattern to include 🔄: change
      /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progress|🚧/i to also match 🔄.
  (c) Extend the Step 2 fallback regex from /🚧\s*\*\*v(\d+\.\d+)\s/ to
      /(?:🚧|🔄)\s*\*\*v(\d+\.\d+)\s/ so the emoji-only fallback also
      catches 🔄.

- Fix 2 — Add completion guard (milestone.cjs):
  Before writing "milestone complete" to STATE.md, check whether any phase
  in the current milestone has no directory on disk (disk_status:
  no_directory). When STATE.md milestone version matches the version being
  completed and unstarted phases are found, emit an error. Re-run with
  --force to override.

- Fix 3 — Add W021 health check (verify.cjs):
  Check 14 (W021): if STATE.md status contains "milestone complete" or
  "archived", scan the current milestone section of ROADMAP.md; if any
  phase has no directory on disk, emit W021 warning: "STATE says milestone
  complete but ROADMAP lists N unstarted phase(s)".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#557): replace hand-written changeset with generated fragment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#557): address Codex review findings

- core.cjs: add (?!Phase\s+\S) to sectionPattern so phase headings that
  mention the milestone version (e.g. ### Phase 1: v1.3 migration) cannot
  bypass the <summary> fallback path
- milestone.cjs: replace loose numeric-prefix matching with phaseTokenMatches
  + normalizePhaseName so decimal (2.1) and letter-suffix (12A) phase IDs
  are handled correctly in the completion guard
- verify.cjs: same correction in W021 check; also add phaseTokenMatches to
  core.cjs import

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#557): fix getMilestonePhaseFilter version-heading false match

getMilestonePhaseFilter's sectionPattern also lacked the (?!Phase\s+\S)
exclusion that extractCurrentMilestone received in the previous commit.
A phase title like "### Phase 4: v1.3 migration" could match as the
milestone section start, mis-scoping the phase set used for completion
stats and archive.

Also handle the case where the version lives only in a <summary> tag:
when there is no heading match but a <summary> match exists, skip
setting missingExplicitVersion so milestone.complete does not incorrectly
error with "no phases found".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-31 22:05:45 -04:00
committed by GitHub
parent c976a9c858
commit 03c0663cf5
6 changed files with 439 additions and 13 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 557
---
**Milestone marked complete while ROADMAP lists unstarted phases** — `extractCurrentMilestone()` had two miss paths that caused it to fall through to `stripShippedMilestones()`: (1) `sectionPattern` only matched `##`/`###` headings, so when a milestone version appeared exclusively inside a `<summary>` tag the section was not found; (2) `activeMarkerPattern` and the Step 2 fallback regex did not include `🔄`, so milestones using that emoji as the in-progress marker were not recognised. Both misses caused `stripShippedMilestones()` to erase the active `<details open>` block, leaving `roadmap.analyze` with `phase_count: 0`. Fixes: (a) `<summary>` tag content is now searched when heading matches are absent; (b) `🔄` is added to `activeMarkerPattern` and the fallback emoji regex; (c) `cmdMilestoneComplete` now guards against completing when STATE confirms the current milestone still has phases with no directory on disk; (d) `validate health` emits W021 when STATE says milestone complete but ROADMAP lists unstarted phases. (#557)

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 557
---
**Milestone marked complete while ROADMAP lists unstarted phases** — extractCurrentMilestone() had two miss paths that caused it to fall through to stripShippedMilestones(): (1) sectionPattern only matched ##/### headings so when a milestone version appeared only inside a <summary> tag the section was not found; (2) activeMarkerPattern and the Step 2 fallback regex did not include the 🔄 emoji so milestones using it as the in-progress marker were not recognised. Both misses caused stripShippedMilestones() to erase the active <details open> block, leaving roadmap.analyze with phase_count: 0. Fixes: (a) <summary> tag content is now searched when heading matches are absent; (b) 🔄 is added to activeMarkerPattern and the fallback emoji regex; (c) cmdMilestoneComplete now guards against completing when STATE confirms the current milestone still has phases with no directory on disk; (d) validate health emits W021 when STATE says milestone complete but ROADMAP lists unstarted phases. (#557)

View File

@@ -973,8 +973,8 @@ function extractCurrentMilestone(content, cwd) {
// 2. Fallback: derive version from getMilestoneInfo pattern in ROADMAP.md itself
if (!version) {
// Check for 🚧 in-progress marker
const inProgressMatch = content.match(/🚧\s*\*\*v(\d+\.\d+)\s/);
// Check for 🚧 or 🔄 in-progress marker in bold version line
const inProgressMatch = content.match(/(?:🚧|🔄)\s*\*\*v(\d+\.\d+)\s/);
if (inProgressMatch) {
version = 'v' + inProgressMatch[1];
}
@@ -984,19 +984,57 @@ function extractCurrentMilestone(content, cwd) {
// 3. Find the section matching this version
// Match headings like: ## Roadmap v3.0: Name, ## v3.0 Name, etc.
// Also match <summary> tags that contain the version (milestone in <details open>).
// Exclude phase headings (e.g. "### Phase 1: v1.3 migration") so that a phase title
// that mentions the milestone version does not bypass the <summary> fallback.
const escapedVersion = escapeRegex(version);
const sectionPattern = new RegExp(
`(^#{1,3}\\s+.*${escapedVersion}\\b[^\\n]*)`,
`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}\\b[^\\n]*)`,
'gmi'
);
const allMatches = [...content.matchAll(sectionPattern)];
const summaryPattern = new RegExp(
`<summary[^>]*>([^<]*${escapedVersion}[^<]*)<\\/summary>`,
'i'
);
const headingMatches = [...content.matchAll(sectionPattern)];
if (allMatches.length === 0) return stripShippedMilestones(content);
// If the version appears only inside a <summary> tag (not in a heading),
// locate the enclosing <details open> block and treat its content as the
// current milestone section instead of falling through to stripShippedMilestones().
if (headingMatches.length === 0) {
const summaryMatch = content.match(summaryPattern);
if (summaryMatch) {
// Find the <details ...> opening tag that precedes this <summary>
const summaryIdx = content.indexOf(summaryMatch[0]);
const beforeSummary = content.slice(0, summaryIdx);
const detailsOpenIdx = beforeSummary.lastIndexOf('<details');
if (detailsOpenIdx !== -1) {
// Find the matching </details> closing tag
const afterDetails = content.slice(detailsOpenIdx);
const closingMatch = afterDetails.match(/<\/details>/i);
const detailsEnd = closingMatch
? detailsOpenIdx + closingMatch.index + '</details>'.length
: content.length;
// Preamble: everything before the first milestone-level section
const anyMilestoneOrDetails = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧|🔄)|<details/im;
const firstMilestoneMatch = content.match(anyMilestoneOrDetails);
const preambleCutoff = firstMilestoneMatch ? firstMilestoneMatch.index : detailsOpenIdx;
const preamble = content.slice(0, preambleCutoff)
.replace(/<details>[\s\S]*?<\/details>/gi, '')
.replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
.replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
return preamble + content.slice(detailsOpenIdx, detailsEnd);
}
}
return stripShippedMilestones(content);
}
const allMatches = headingMatches;
// Select the first non-closed heading; fall back to first match if all are closed.
// A heading is "closed" only if it carries a closed marker AND no active marker.
const closedMarkerPattern = /\b(?:CLOSED|ARCHIVED|ABANDONED|SHIPPED|FAILED)\b|✅|🗄/i;
const activeMarkerPattern = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progress|🚧/i;
const activeMarkerPattern = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progress|🚧|🔄/i;
const isClosed = (h) => closedMarkerPattern.test(h) && !activeMarkerPattern.test(h);
const firstMatch = allMatches[0];
const selected = allMatches.find((m) => !isClosed(m[1])) || firstMatch;
@@ -1890,14 +1928,42 @@ function getMilestonePhaseFilter(cwd, versionOverride) {
if (versionOverride) {
const escapedVersion = escapeRegex(versionOverride);
const sectionPattern = new RegExp(`(^#{1,3}\\s+.*${escapedVersion}[^\\n]*)`, 'mi');
const sectionMatch = roadmapContent.match(sectionPattern);
// Exclude phase headings (e.g. "### Phase 1: v1.3 migration") that mention
// the version but are not milestone-level headings. Same guard as sectionPattern
// in extractCurrentMilestone().
const sectionPattern = new RegExp(`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}[^\\n]*)`, 'mi');
let sectionMatch = roadmapContent.match(sectionPattern);
// If no heading match, check whether the version lives in a <summary> tag
// (milestone wrapped in <details open>). If so, extract that block's heading
// so the phase-filter can scope correctly.
if (!sectionMatch) {
// Only treat this as an error case when the roadmap is milestone-versioned.
const summaryPat = new RegExp(`<summary[^>]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i');
const summaryHit = roadmapContent.match(summaryPat);
if (summaryHit) {
// Treat the <summary> content as a synthetic heading match so the code
// below can extract the section boundaries from it.
// We fabricate a match object pointing at the <details> block start.
const beforeSummary = roadmapContent.slice(0, summaryHit.index);
const detailsIdx = beforeSummary.lastIndexOf('<details');
if (detailsIdx !== -1) {
// Use extractCurrentMilestone (already called above) which now handles
// <summary> correctly — roadmap is already scoped. Skip the override
// re-scoping: roadmap is already the current milestone content.
// Do nothing: keep roadmap as-is from extractCurrentMilestone.
sectionMatch = null; // fall through without setting missingExplicitVersion
}
}
}
if (!sectionMatch) {
// Only treat this as an error case when the roadmap is milestone-versioned
// AND the version is genuinely absent (not in a <summary> tag handled above).
// Older/flat roadmap formats without vX.Y milestone headings should keep
// legacy pass-through behavior for milestone.complete.
const hasVersionedMilestones = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent);
if (hasVersionedMilestones) {
const hasVersionedMilestones = /^#{1,3}\s+(?!Phase\s+\S).*v\d+\.\d+/mi.test(roadmapContent);
const versionInSummary = new RegExp(`<summary[^>]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i').test(roadmapContent);
if (hasVersionedMilestones && !versionInSummary) {
roadmap = '';
missingExplicitVersion = true;
}

View File

@@ -4,7 +4,7 @@
const fs = require('fs');
const path = require('path');
const { escapeRegex, getMilestonePhaseFilter, extractOneLinerFromBody, output, error } = require('./core.cjs');
const { escapeRegex, getMilestonePhaseFilter, extractOneLinerFromBody, normalizePhaseName, phaseTokenMatches, output, error } = require('./core.cjs');
const { platformWriteSync, platformEnsureDir } = require('./shell-command-projection.cjs');
const { planningPaths } = require('./planning-workspace.cjs');
const { extractFrontmatter } = require('./frontmatter.cjs');
@@ -114,6 +114,64 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
error(`no phases found for milestone ${version} in ROADMAP.md`);
}
// Guard: prevent marking complete when ROADMAP still lists phases that have
// no directory on disk (disk_status: no_directory). This catches the case
// where the active milestone was erroneously marked complete before phases
// were even started. Only fires when STATE.md confirms the current milestone
// version matches what is being completed — no false positives on fresh
// projects where phases haven't been scaffolded yet.
// Pass --force to override this guard.
if (!options.force) {
try {
// Only guard when STATE.md's milestone field matches the version being completed.
let stateVersion = null;
try {
const stateRaw = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : null;
if (stateRaw) {
const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
if (milestoneMatch) stateVersion = milestoneMatch[1].trim();
}
} catch { /* skip */ }
if (stateVersion && stateVersion === version) {
const { extractCurrentMilestone } = require('./core.cjs');
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
const scopedContent = extractCurrentMilestone(roadmapContent, cwd);
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
const noDirectoryPhases = [];
let pm;
const phaseDirEntries = (() => {
try {
return fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory()).map(e => e.name);
} catch { return []; }
})();
while ((pm = phasePattern.exec(scopedContent)) !== null) {
const phaseNum = pm[1];
const normalized = normalizePhaseName(phaseNum);
// A phase has disk_status: 'no_directory' when no phase directory
// with a matching token exists on disk. Use the same phaseTokenMatches
// helper that roadmap.analyze uses to avoid false positives on decimal
// (2.1) and letter-suffix (12A) phase IDs.
const hasDirectory = phaseDirEntries.some(d => phaseTokenMatches(d, normalized));
if (!hasDirectory) {
noDirectoryPhases.push(phaseNum);
}
}
if (noDirectoryPhases.length > 0) {
error(
`Cannot mark milestone complete: ROADMAP lists ${noDirectoryPhases.length} unstarted phase(s) ` +
`(e.g. Phase ${noDirectoryPhases[0]}). Re-run with --force to override.`
);
}
}
} catch (e) {
// If the error came from our guard, re-throw it; otherwise skip silently.
if (e.message && e.message.startsWith('Cannot mark milestone complete:')) throw e;
// Phase scan failed or STATE version mismatch — allow completion to proceed.
}
}
// Gather stats from phases (scoped to current milestone only)
let phaseCount = 0;
let totalPlans = 0;

View File

@@ -12,7 +12,7 @@ const {
const fs = require('fs');
const path = require('path');
const os = require('os');
const { loadConfig, normalizePhaseName, escapeRegex, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, output, error, checkAgentsInstalled, CONFIG_DEFAULTS, inspectWorktreeHealth } = require('./core.cjs');
const { loadConfig, normalizePhaseName, phaseTokenMatches, escapeRegex, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, output, error, checkAgentsInstalled, CONFIG_DEFAULTS, inspectWorktreeHealth } = require('./core.cjs');
const { execGit, platformReadSync: safeReadFile, platformWriteSync } = require('./shell-command-projection.cjs');
const { PACKAGE_NAME } = require('./package-identity.cjs');
const { planningDir } = require('./planning-workspace.cjs');
@@ -1107,6 +1107,50 @@ function cmdValidateHealth(cwd, options, raw) {
}
} catch { /* artifact check is advisory — skip on error */ }
// ─── Check 14: milestone-status vs. roadmap-progress incoherence (W021) ───
try {
if (fs.existsSync(statePath) && fs.existsSync(roadmapPath)) {
const stateRaw = fs.readFileSync(statePath, 'utf-8');
const statusMatch = stateRaw.match(/^status:\s*(.+)/im);
const stateStatus = statusMatch ? statusMatch[1].trim().toLowerCase() : '';
const isMarkedComplete = /milestone complete|archived/.test(stateStatus);
if (isMarkedComplete) {
// Inline phase scan: read roadmap, extract current milestone section,
// then check for phases with no directory on disk (unstarted).
const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8');
const scopedContent = extractCurrentMilestone(roadmapRaw, cwd);
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
const unstarted = [];
let pm;
const planningWorkspace = require('./planning-workspace.cjs');
const phasesDir2 = planningWorkspace.planningPaths(cwd).phases;
const phaseDirNames2 = (() => {
try {
return fs.readdirSync(phasesDir2, { withFileTypes: true })
.filter(e => e.isDirectory()).map(e => e.name);
} catch { return []; }
})();
while ((pm = phasePattern.exec(scopedContent)) !== null) {
const phaseNum = pm[1];
const normalized = normalizePhaseName(phaseNum);
// Phase is unstarted (disk_status: no_directory) if no directory
// with a matching token exists on disk. Use phaseTokenMatches (same
// helper as roadmap.analyze) to correctly handle decimal (2.1) and
// letter-suffix (12A) phase IDs without false positives.
const hasDirectory = phaseDirNames2.some(d => phaseTokenMatches(d, normalized));
if (!hasDirectory) {
unstarted.push(phaseNum);
}
}
if (unstarted.length > 0) {
addIssue('warning', 'W021',
`STATE says milestone complete but ROADMAP lists ${unstarted.length} unstarted phase(s) (e.g. Phase ${unstarted[0]})`,
'Run validate consistency or re-run complete-milestone after verifying all phases are done');
}
}
}
} catch { /* W021 check is advisory — skip on error */ }
// ─── Perform repairs if requested ─────────────────────────────────────────
const repairActions = [];
if (options.repair && repairs.length > 0) {

View File

@@ -0,0 +1,248 @@
/**
* Bug #557: Active milestone wrapped in <details open> with version only in
* <summary> tag + 🔄 emoji causes extractCurrentMilestone() to fall through
* to stripShippedMilestones(), erasing the active block and making
* roadmap.analyze return phase_count: 0 — which then triggers a premature
* milestone_complete STATE write.
*
* Root cause (two miss paths in extractCurrentMilestone, core.cjs):
* 1. sectionPattern only matches ##/### headings; version in <summary> not found.
* 2. activeMarkerPattern does not include 🔄; only 🚧 is recognised.
* Both misses → stripShippedMilestones() deletes the active <details open> block.
*
* This test will FAIL before the fix (phase_count returns 0) and PASS after.
*/
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');
// ─── Fixtures ────────────────────────────────────────────────────────────────
// ROADMAP where the active milestone's version ("v1.3") appears ONLY inside
// a <summary> tag, and the in-progress marker is 🔄 (not 🚧).
// Shipped milestone v1.2 is correctly collapsed in a <details> block.
const ROADMAP_DETAILS_SUMMARY = `# Roadmap
<details>
<summary>✅ v1.2: Foundation (shipped)</summary>
### Phase 1: Bootstrap
**Goal:** Set up infrastructure
### Phase 2: Core API
**Goal:** Build REST API
</details>
<details open>
<summary>🔄 v1.3: Active Sprint</summary>
### Phase 3: Auth
**Goal:** Add authentication
### Phase 4: Dashboard
**Goal:** Build dashboard UI
</details>
`;
// STATE.md with milestone: v1.3 — version matches the <summary> tag above
const STATE_V13 = `---
gsd_state_version: 1.0
milestone: v1.3
milestone_name: Active Sprint
status: in_progress
progress:
total_phases: 2
completed_phases: 0
total_plans: 0
completed_plans: 0
percent: 0
---
# Project State
## Current Position
Phase: 3 (Auth)
`;
// Second variant: active milestone uses the 🔄 emoji in a heading (not just
// <summary>) to confirm the activeMarkerPattern gap is also covered.
const ROADMAP_ROTATE_HEADING = `# Roadmap
<details>
<summary>✅ v2.0: Shipped (shipped)</summary>
### Phase 1: Old Phase
**Goal:** Done
</details>
## 🔄 v2.1: Active Milestone
### Phase 2: New Feature
**Goal:** Build the new feature
### Phase 3: Integration
**Goal:** Wire it all together
`;
const STATE_V21 = `---
gsd_state_version: 1.0
milestone: v2.1
milestone_name: Active Milestone
status: in_progress
---
# Project State
## Current Position
Phase: 2 (New Feature)
`;
// ─── Test suite ───────────────────────────────────────────────────────────────
describe('bug #557 — <details>/<summary> active milestone strip', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// ── Core repro: version only in <summary> tag ─────────────────────────────
test('roadmap.analyze returns correct phase_count when active milestone uses <summary> + 🔄', () => {
const planning = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8');
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V13, 'utf-8');
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
const result = runGsdTools(['roadmap', 'analyze'], tmpDir);
assert.ok(result.success, `roadmap.analyze failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.phase_count >= 2,
`Expected phase_count >= 2 (phases 3 and 4 of v1.3); got phase_count=${output.phase_count}. ` +
`Bug: extractCurrentMilestone() stripped the active <details open> block because ` +
`the version "v1.3" only appears in a <summary> tag and the emoji is 🔄, not 🚧.`
);
});
test('roadmap.analyze does NOT return phase_count: 0 when active milestone is in <details open>', () => {
const planning = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8');
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V13, 'utf-8');
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
const result = runGsdTools(['roadmap', 'analyze'], tmpDir);
assert.ok(result.success, `roadmap.analyze failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.notStrictEqual(
output.phase_count,
0,
'phase_count must not be 0 — a zero count caused by stripping the active block ' +
'is the direct trigger for the premature milestone_complete write.'
);
});
test('roadmap get-phase returns found:true for phase in active <details open> block', () => {
const planning = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8');
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V13, 'utf-8');
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
const result = runGsdTools(['roadmap', 'get-phase', '3'], tmpDir);
assert.ok(result.success, `roadmap get-phase failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.found,
true,
`Phase 3 must be found in the active v1.3 milestone block. ` +
`Bug: stripShippedMilestones() erased the <details open> block so the phase section was lost.`
);
});
test('shipped phases in collapsed <details> are NOT visible to roadmap.analyze (strip preserved for non-active)', () => {
const planning = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8');
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V13, 'utf-8');
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
const result = runGsdTools(['roadmap', 'analyze'], tmpDir);
assert.ok(result.success, `roadmap.analyze failed: ${result.error}`);
const output = JSON.parse(result.output);
const phaseNums = (output.phases || []).map(p => p.number);
assert.ok(
!phaseNums.includes('1') && !phaseNums.includes('2'),
`Shipped phases 1 and 2 (from collapsed <details>) must not appear in the analyze output. ` +
`Got phases: ${JSON.stringify(phaseNums)}`
);
});
// ── 🔄 in heading (not <summary>) also recognised ────────────────────────
test('extractCurrentMilestone recognises 🔄 in milestone heading as in-progress marker', () => {
const planning = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_ROTATE_HEADING, 'utf-8');
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V21, 'utf-8');
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
const result = runGsdTools(['roadmap', 'analyze'], tmpDir);
assert.ok(result.success, `roadmap.analyze failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.phase_count >= 2,
`Expected phase_count >= 2 for v2.1 with 🔄 heading; got ${output.phase_count}. ` +
`activeMarkerPattern must include 🔄, not just 🚧.`
);
});
// ── Health check W021: milestone_complete vs unstarted phases ─────────────
test('validate health emits W021 when STATE says milestone complete but ROADMAP has unstarted phases', () => {
const planning = path.join(tmpDir, '.planning');
// ROADMAP still has active phases in it
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8');
// STATE falsely says milestone complete
fs.writeFileSync(path.join(planning, 'STATE.md'), `---
gsd_state_version: 1.0
milestone: v1.3
milestone_name: Active Sprint
status: v1.3 milestone complete
---
# Project State
## Current Position
Phase: Milestone v1.3 complete
`, 'utf-8');
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
const result = runGsdTools(['validate', 'health'], tmpDir);
assert.ok(result.success, `validate health failed: ${result.error}`);
const output = JSON.parse(result.output);
const warnings = output.warnings || [];
const w021 = warnings.find(w => w.code === 'W021');
assert.ok(
w021 !== undefined,
`Expected W021 warning (milestone-status vs. roadmap-progress incoherence). ` +
`Got warnings: ${JSON.stringify(warnings.map(w => w.code))}`
);
});
});