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:
@@ -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 ───────────────────────────────────────────
|
||||
|
||||
@@ -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 ───────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user