* 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:
5
.changeset/557-details-summary-milestone-strip.md
Normal file
5
.changeset/557-details-summary-milestone-strip.md
Normal 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)
|
||||
5
.changeset/wise-pumas-march.md
Normal file
5
.changeset/wise-pumas-march.md
Normal 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)
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
248
tests/bug-557-details-summary-milestone-strip.test.cjs
Normal file
248
tests/bug-557-details-summary-milestone-strip.test.cjs
Normal 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))}`
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user