fix(sdk): stripShippedMilestones handles inline SHIPPED headings; getMilestoneInfo prefers STATE.md (#2508)

* fix(sdk): stripShippedMilestones handles inline SHIPPED headings; getMilestoneInfo prefers STATE.md

Fixes two compounding bugs:

- #2496: stripShippedMilestones only stripped <details> blocks, ignoring
  '## Heading — ✅ SHIPPED ...' inline markers. Shipped milestone sections
  were leaking into downstream parsers.

- #2495: getMilestoneInfo checked STATE.md frontmatter only as a last-resort
  fallback, so it returned the first heading match (often a leaked shipped
  milestone) rather than the current milestone. Moved STATE.md check to
  priority 1, consistent with extractCurrentMilestone.

Closes #2495
Closes #2496

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

* fix(roadmap): handle ### SHIPPED headings and STATE.md version-only case

Two follow-up fixes from CodeRabbit review of #2508:

1. stripShippedMilestones only split on ## boundaries; ### headings marked
   ✅ SHIPPED were not stripped, leaking into fallback parsers. Expanded
   the split/filter regex to #{2,3} to align with extractCurrentMilestone.

2. getMilestoneInfo's early-return on parseMilestoneFromState discarded the
   real milestone name from ROADMAP.md when STATE.md had only `milestone:`
   (no `milestone_name:`), returning the placeholder name 'milestone'.
   Now only short-circuits when STATE.md provides a real name; otherwise
   falls through to ROADMAP for the name while using stateVersion to
   override the version in every ROADMAP-derived return path.

Tests: +2 new cases (### SHIPPED heading, version-only STATE.md).

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-04-21 10:41:35 -04:00
committed by GitHub
parent 8789211038
commit 2980f0ec48
2 changed files with 140 additions and 12 deletions

View File

@@ -50,7 +50,13 @@ interface PhaseSection {
* Port of stripShippedMilestones from core.cjs line 1082-1084.
*/
export function stripShippedMilestones(content: string): string {
return content.replace(/<details>[\s\S]*?<\/details>/gi, '');
// Pattern 1: <details>...</details> blocks (explicit collapse)
let result = content.replace(/<details>[\s\S]*?<\/details>/gi, '');
// Pattern 2: inline milestone headings marked as shipped.
// Keep aligned with heading levels accepted by extractCurrentMilestone() (## and ###).
const sections = result.split(/(?=^#{2,3}\s)/m);
result = sections.filter(s => !/^#{2,3}\s[^\n]*✅\s*SHIPPED\b/im.test(s)).join('');
return result;
}
/**
@@ -84,25 +90,35 @@ async function parseMilestoneFromState(projectDir: string): Promise<{ version: s
*/
export async function getMilestoneInfo(projectDir: string): Promise<{ version: string; name: string }> {
try {
// Priority 1: STATE.md frontmatter (authoritative for version; name only when real)
const fromState = await parseMilestoneFromState(projectDir);
const stateVersion = fromState?.version ?? null;
const stateName = fromState && fromState.name !== 'milestone' ? fromState.name : null;
if (stateVersion && stateName) {
return { version: stateVersion, name: stateName };
}
// STATE.md has a version but no real name — fall through to ROADMAP for the name,
// then override the version with the authoritative STATE.md value.
const roadmap = await readFile(planningPaths(projectDir).roadmap, 'utf-8');
// List-format: construction / blocked (legacy emoji)
const barricadeMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
if (barricadeMatch) {
return { version: 'v' + barricadeMatch[1], name: barricadeMatch[2].trim() };
return { version: stateVersion ?? 'v' + barricadeMatch[1], name: barricadeMatch[2].trim() };
}
// List-format: in flight / active (GSD ROADMAP template uses 🟡 for current milestone)
const inFlightMatch = roadmap.match(/🟡\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
if (inFlightMatch) {
return { version: 'v' + inFlightMatch[1], name: inFlightMatch[2].trim() };
return { version: stateVersion ?? 'v' + inFlightMatch[1], name: inFlightMatch[2].trim() };
}
// Heading-format — strip shipped <details> blocks first
const cleaned = stripShippedMilestones(roadmap);
const headingMatch = cleaned.match(/##\s+.*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/);
if (headingMatch) {
return { version: 'v' + headingMatch[1], name: headingMatch[2].trim() };
return { version: stateVersion ?? 'v' + headingMatch[1], name: headingMatch[2].trim() };
}
// Milestone bullet list (## Milestones … ## Phases): use last **vX.Y Title** — typically the current row
@@ -110,21 +126,16 @@ export async function getMilestoneInfo(projectDir: string): Promise<{ version: s
const boldMatches = [...beforePhases.matchAll(/\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/g)];
if (boldMatches.length > 0) {
const last = boldMatches[boldMatches.length - 1];
return { version: 'v' + last[1], name: last[2].trim() };
}
const fromState = await parseMilestoneFromState(projectDir);
if (fromState) {
return fromState;
return { version: stateVersion ?? 'v' + last[1], name: last[2].trim() };
}
const allBare = [...cleaned.matchAll(/\bv(\d+(?:\.\d+)+)\b/g)];
if (allBare.length > 0) {
const lastBare = allBare[allBare.length - 1];
return { version: lastBare[0], name: 'milestone' };
return { version: stateVersion ?? lastBare[0], name: 'milestone' };
}
return { version: 'v1.0', name: 'milestone' };
return { version: stateVersion ?? 'v1.0', name: 'milestone' };
} catch {
const fromState = await parseMilestoneFromState(projectDir);
if (fromState) return fromState;