From 03c0663cf5e3b1d57d6530087ab01290e33396d6 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 31 May 2026 22:05:45 -0400 Subject: [PATCH] fix(#557): milestone erased when version is in tag (#563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#557): Milestone marked complete while ROADMAP lists unstarted phases - Fix 1 — Broaden extractCurrentMilestone() (core.cjs): (a) Extend sectionPattern to also match tag content: when a milestone version appears only inside a tag, locate the enclosing
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 * fix(#557): replace hand-written changeset with generated fragment Co-Authored-By: Claude Sonnet 4.6 * 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 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 * 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 tag: when there is no heading match but a match exists, skip setting missingExplicitVersion so milestone.complete does not incorrectly error with "no phases found". Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../557-details-summary-milestone-strip.md | 5 + .changeset/wise-pumas-march.md | 5 + get-shit-done/bin/lib/core.cjs | 88 ++++++- get-shit-done/bin/lib/milestone.cjs | 60 ++++- get-shit-done/bin/lib/verify.cjs | 46 +++- ...7-details-summary-milestone-strip.test.cjs | 248 ++++++++++++++++++ 6 files changed, 439 insertions(+), 13 deletions(-) create mode 100644 .changeset/557-details-summary-milestone-strip.md create mode 100644 .changeset/wise-pumas-march.md create mode 100644 tests/bug-557-details-summary-milestone-strip.test.cjs diff --git a/.changeset/557-details-summary-milestone-strip.md b/.changeset/557-details-summary-milestone-strip.md new file mode 100644 index 000000000..c694cf161 --- /dev/null +++ b/.changeset/557-details-summary-milestone-strip.md @@ -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 `` 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 `
` block, leaving `roadmap.analyze` with `phase_count: 0`. Fixes: (a) `` 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) diff --git a/.changeset/wise-pumas-march.md b/.changeset/wise-pumas-march.md new file mode 100644 index 000000000..65ca18c97 --- /dev/null +++ b/.changeset/wise-pumas-march.md @@ -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 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
block, leaving roadmap.analyze with phase_count: 0. Fixes: (a) 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) diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 549a0db2c..53efd9843 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -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 tags that contain the version (milestone in
). + // Exclude phase headings (e.g. "### Phase 1: v1.3 migration") so that a phase title + // that mentions the milestone version does not bypass the 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( + `]*>([^<]*${escapedVersion}[^<]*)<\\/summary>`, + 'i' + ); + const headingMatches = [...content.matchAll(sectionPattern)]; - if (allMatches.length === 0) return stripShippedMilestones(content); + // If the version appears only inside a tag (not in a heading), + // locate the enclosing
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
opening tag that precedes this + const summaryIdx = content.indexOf(summaryMatch[0]); + const beforeSummary = content.slice(0, summaryIdx); + const detailsOpenIdx = beforeSummary.lastIndexOf(' closing tag + const afterDetails = content.slice(detailsOpenIdx); + const closingMatch = afterDetails.match(/<\/details>/i); + const detailsEnd = closingMatch + ? detailsOpenIdx + closingMatch.index + '
'.length + : content.length; + // Preamble: everything before the first milestone-level section + const anyMilestoneOrDetails = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧|🔄)|
[\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 tag + // (milestone wrapped in
). 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(`]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i'); + const summaryHit = roadmapContent.match(summaryPat); + if (summaryHit) { + // Treat the 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
block start. + const beforeSummary = roadmapContent.slice(0, summaryHit.index); + const detailsIdx = beforeSummary.lastIndexOf(' 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 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(`]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i').test(roadmapContent); + if (hasVersionedMilestones && !versionInSummary) { roadmap = ''; missingExplicitVersion = true; } diff --git a/get-shit-done/bin/lib/milestone.cjs b/get-shit-done/bin/lib/milestone.cjs index 00d72aaab..54a882fc2 100644 --- a/get-shit-done/bin/lib/milestone.cjs +++ b/get-shit-done/bin/lib/milestone.cjs @@ -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; diff --git a/get-shit-done/bin/lib/verify.cjs b/get-shit-done/bin/lib/verify.cjs index b54e17725..5d5f0f925 100644 --- a/get-shit-done/bin/lib/verify.cjs +++ b/get-shit-done/bin/lib/verify.cjs @@ -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) { diff --git a/tests/bug-557-details-summary-milestone-strip.test.cjs b/tests/bug-557-details-summary-milestone-strip.test.cjs new file mode 100644 index 000000000..3bc75ee5a --- /dev/null +++ b/tests/bug-557-details-summary-milestone-strip.test.cjs @@ -0,0 +1,248 @@ +/** + * Bug #557: Active milestone wrapped in
with version only in + * 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 not found. + * 2. activeMarkerPattern does not include 🔄; only 🚧 is recognised. + * Both misses → stripShippedMilestones() deletes the active
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 tag, and the in-progress marker is 🔄 (not 🚧). +// Shipped milestone v1.2 is correctly collapsed in a
block. +const ROADMAP_DETAILS_SUMMARY = `# Roadmap + +
+✅ v1.2: Foundation (shipped) + +### Phase 1: Bootstrap +**Goal:** Set up infrastructure + +### Phase 2: Core API +**Goal:** Build REST API + +
+ +
+🔄 v1.3: Active Sprint + +### Phase 3: Auth +**Goal:** Add authentication + +### Phase 4: Dashboard +**Goal:** Build dashboard UI + +
+`; + +// STATE.md with milestone: v1.3 — version matches the 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 +// ) to confirm the activeMarkerPattern gap is also covered. +const ROADMAP_ROTATE_HEADING = `# Roadmap + +
+✅ v2.0: Shipped (shipped) + +### Phase 1: Old Phase +**Goal:** Done + +
+ +## 🔄 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 —
/ active milestone strip', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // ── Core repro: version only in tag ───────────────────────────── + + test('roadmap.analyze returns correct phase_count when active milestone uses + 🔄', () => { + 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
block because ` + + `the version "v1.3" only appears in a tag and the emoji is 🔄, not 🚧.` + ); + }); + + test('roadmap.analyze does NOT return phase_count: 0 when active milestone is in
', () => { + 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
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
block so the phase section was lost.` + ); + }); + + test('shipped phases in collapsed
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
) must not appear in the analyze output. ` + + `Got phases: ${JSON.stringify(phaseNums)}` + ); + }); + + // ── 🔄 in heading (not ) 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))}` + ); + }); +});