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

@@ -100,6 +100,63 @@ describe('stripShippedMilestones', () => {
it('returns content unchanged when no details blocks', () => {
expect(stripShippedMilestones('no details here')).toBe('no details here');
});
// Bug #2496: inline ✅ SHIPPED heading sections must be stripped
it('strips ## heading sections marked ✅ SHIPPED', () => {
const content = [
'## Milestone v1.0: MVP — ✅ SHIPPED 2026-01-15',
'',
'Phase 1, Phase 2',
'',
'## Milestone v2.0: Current',
'',
'Phase 3',
].join('\n');
const stripped = stripShippedMilestones(content);
expect(stripped).not.toContain('MVP');
expect(stripped).not.toContain('v1.0');
expect(stripped).toContain('v2.0');
expect(stripped).toContain('Current');
});
it('strips multiple inline SHIPPED sections and leaves non-shipped content', () => {
const content = [
'## Milestone v1.0: Alpha — ✅ SHIPPED 2026-01-01',
'',
'Old content',
'',
'## Milestone v1.5: Beta — ✅ SHIPPED 2026-02-01',
'',
'More old content',
'',
'## Milestone v2.0: Gamma',
'',
'Current content',
].join('\n');
const stripped = stripShippedMilestones(content);
expect(stripped).not.toContain('Alpha');
expect(stripped).not.toContain('Beta');
expect(stripped).toContain('Gamma');
expect(stripped).toContain('Current content');
});
// Bug #2508 follow-up: ### headings must be stripped too
it('strips ### heading sections marked ✅ SHIPPED', () => {
const content = [
'### Milestone v1.0: MVP — ✅ SHIPPED 2026-01-15',
'',
'Phase 1, Phase 2',
'',
'### Milestone v2.0: Current',
'',
'Phase 3',
].join('\n');
const stripped = stripShippedMilestones(content);
expect(stripped).not.toContain('MVP');
expect(stripped).not.toContain('v1.0');
expect(stripped).toContain('v2.0');
expect(stripped).toContain('Current');
});
});
// ─── getMilestoneInfo ─────────────────────────────────────────────────────
@@ -158,6 +215,66 @@ describe('getMilestoneInfo', () => {
expect(info.version).toBe('v1.0');
expect(info.name).toBe('milestone');
});
// Bug #2495: STATE.md must take priority over ROADMAP heading matching
it('prefers STATE.md milestone over ROADMAP heading match', async () => {
const roadmap = [
'## Milestone v1.0: Shipped — ✅ SHIPPED 2026-01-01',
'',
'Phase 1',
'',
'## Milestone v2.0: Current Active',
'',
'Phase 2',
].join('\n');
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
await writeFile(
join(tmpDir, '.planning', 'STATE.md'),
'---\nmilestone: v2.0\nmilestone_name: Current Active\n---\n',
);
const info = await getMilestoneInfo(tmpDir);
expect(info.version).toBe('v2.0');
expect(info.name).toBe('Current Active');
});
// Bug #2508 follow-up: STATE.md has milestone version but no milestone_name —
// should use ROADMAP for the real name, still prefer STATE.md for version.
it('uses ROADMAP name when STATE.md has milestone version but no milestone_name', async () => {
const roadmap = [
'## Milestone v2.0: Real Name From Roadmap',
'',
'Phase 2',
].join('\n');
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
await writeFile(
join(tmpDir, '.planning', 'STATE.md'),
'---\nmilestone: v2.0\n---\n', // no milestone_name
);
const info = await getMilestoneInfo(tmpDir);
expect(info.version).toBe('v2.0');
expect(info.name).toBe('Real Name From Roadmap');
});
it('returns correct milestone from STATE.md even when ROADMAP inline-SHIPPED stripping would fix it', async () => {
// ROADMAP with an unstripped shipped milestone heading (pre-fix state)
const roadmap = [
'## Milestone v1.0: Old — ✅ SHIPPED 2026-01-01',
'',
'Old phases',
'',
'## Milestone v2.0: New',
'',
'New phases',
].join('\n');
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
await writeFile(
join(tmpDir, '.planning', 'STATE.md'),
'---\nmilestone: v2.0\nmilestone_name: New\n---\n',
);
const info = await getMilestoneInfo(tmpDir);
expect(info.version).toBe('v2.0');
expect(info.name).toBe('New');
});
});
// ─── extractCurrentMilestone ──────────────────────────────────────────────