Merge pull request #2740 from gsd-build/fix/2641-details-summary-milestone-detection

fix(phase-lifecycle): skip <details>-wrapped sections in milestone detection
This commit is contained in:
Tom Boucher
2026-04-26 11:49:14 -04:00
committed by GitHub
2 changed files with 116 additions and 1 deletions

View File

@@ -150,6 +150,82 @@ describe('replaceInCurrentMilestone', () => {
expect(before).toContain('3 plans'); // old milestone untouched
expect(after).toContain('4 plans'); // current milestone updated
});
it('replaces only in current milestone when older milestones are wrapped in <details>', async () => {
const { replaceInCurrentMilestone } = await import('./phase-lifecycle.js');
const content = [
'# Roadmap',
'',
'<details>',
'<summary>✅ v1.18 (shipped)</summary>',
'',
'### Phase 1: Old Phase',
'',
'- [ ] Phase 1: Old Phase',
'',
'</details>',
'',
'<details>',
'<summary>✅ v1.19 (shipped)</summary>',
'',
'### Phase 2: Another Old Phase',
'',
'</details>',
'',
'## Current Milestone: v1.20',
'',
'- [ ] Phase 3: Current work',
'',
'### Phase 3: Current work',
'',
'**Plans:** 0/2 plans',
'',
].join('\n');
const pattern = /\*\*Plans:\*\* [^\n]+/;
const result = replaceInCurrentMilestone(content, pattern, '**Plans:** 2/2 plans complete');
// Should update Phase 3's Plans line (current milestone)
expect(result).toContain('**Plans:** 2/2 plans complete');
// Should NOT touch v1.18 or v1.19 sections
expect(result).toContain('✅ v1.18');
expect(result).toContain('✅ v1.19');
});
it('replaces inside active milestone when it is wrapped in a <details> block', async () => {
const { replaceInCurrentMilestone } = await import('./phase-lifecycle.js');
// Scenario: active milestone is collapsed in <details> (e.g. user collapsed it)
const content = [
'# Roadmap',
'',
'<details>',
'<summary>✅ v1.18 (shipped)</summary>',
'',
'### Phase 1: Old Phase',
'',
'**Plans:** 1/1 plans',
'',
'</details>',
'',
'<details>',
'<summary>🚧 v1.19 in-progress</summary>',
'',
'### Phase 2: Current Work',
'',
'**Plans:** 1/2 plans',
'',
'</details>',
'',
].join('\n');
const pattern = /\*\*Plans:\*\* [^\n]+/g;
const result = replaceInCurrentMilestone(content, pattern, '**Plans:** 2/2 plans complete');
// The replacement should happen somewhere in the content (not silently dropped)
expect(result).toContain('**Plans:** 2/2 plans complete');
// v1.18 old plans line should remain untouched
expect(result).toContain('**Plans:** 1/1 plans');
});
});
// ─── readModifyWriteRoadmapMd ───────────────────────────────────────────

View File

@@ -85,6 +85,12 @@ function generateSlugInternal(text: string): string {
* If no `</details>` blocks exist, replaces in the entire content.
* Otherwise, only replaces in content after the last `</details>` close tag.
*
* Edge case: when the active milestone is itself wrapped in a `<details>` block
* (e.g. collapsed before it is fully shipped), the last `</details>` belongs to
* the active milestone and the `after` slice is empty. In that case the function
* falls back to searching the full content with all complete `<details>` blocks
* stripped, so archived milestones are never touched.
*
* @param content - Full ROADMAP.md content
* @param pattern - Regex or string pattern to match
* @param replacement - Replacement string
@@ -102,7 +108,40 @@ export function replaceInCurrentMilestone(
const offset = lastDetailsClose + '</details>'.length;
const before = content.slice(0, offset);
const after = content.slice(offset);
return before + after.replace(pattern, replacement);
// Fast path: the current milestone is not inside a <details> block — the
// pattern lives in the plain text after the last </details>.
if (after.trim().length > 0) {
return before + after.replace(pattern, replacement);
}
// Slow path: the active milestone is inside the last <details> block.
// Strip every complete <details>…</details> block except the last one, then
// apply the replacement inside that last block while leaving the stripped
// (archived) blocks untouched.
//
// Strategy:
// 1. Collect all complete <details>…</details> spans.
// 2. Replace only inside the LAST span; leave earlier spans unchanged.
const detailsBlockRe = /<details>[\s\S]*?<\/details>/gi;
const spans: { start: number; end: number; text: string }[] = [];
let m: RegExpExecArray | null;
while ((m = detailsBlockRe.exec(content)) !== null) {
spans.push({ start: m.index, end: m.index + m[0].length, text: m[0] });
}
if (spans.length === 0) {
// No complete blocks found — fall back to full-content replace.
return content.replace(pattern, replacement);
}
const lastSpan = spans[spans.length - 1];
const updatedLastBlock = lastSpan.text.replace(pattern, replacement);
return (
content.slice(0, lastSpan.start) +
updatedLastBlock +
content.slice(lastSpan.end)
);
}
// ─── readModifyWriteRoadmapMd ───────────────────────────────────────────