From e78dca41d4869a938a7a96ad9c1e238fdf848256 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 30 Mar 2026 16:43:33 -0400 Subject: [PATCH] fix(roadmap): handle numbered, bracketed, and indented prose H3 headers in slice parser (#3063) The prose slice header fallback parser failed to extract slices when LLMs generated common formatting variants: numbered prefixes (### 1. S01), parenthetical numbering (### (1) S01), bracketed IDs (### [S01]), or indented headings ( ### S01). This caused auto-mode to permanently block with "No slice eligible" when the plan-milestone prompt produced these formats inside a ## Slices section. Broadened the parseProseSliceHeaders regex to accept optional leading whitespace, numeric prefixes, parenthetical numbering, and square brackets around slice IDs. Closes #2567 Co-authored-by: Claude Opus 4.6 --- .../extensions/gsd/roadmap-slices.ts | 9 +- .../gsd/tests/roadmap-slices.test.ts | 97 +++++++++++++++++++ 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/src/resources/extensions/gsd/roadmap-slices.ts b/src/resources/extensions/gsd/roadmap-slices.ts index 5031f004f..93fb05038 100644 --- a/src/resources/extensions/gsd/roadmap-slices.ts +++ b/src/resources/extensions/gsd/roadmap-slices.ts @@ -219,13 +219,14 @@ export function parseRoadmapSlices(content: string): RoadmapSliceEntry[] { function parseProseSliceHeaders(content: string): RoadmapSliceEntry[] { const slices: RoadmapSliceEntry[] = []; // Match H1-H4 headers containing S with optional "Slice" prefix, bold markers, - // and optional checkmark completion marker before the slice ID. + // numeric prefixes (e.g., "1.", "(1)"), bracketed IDs (e.g., "[S01]"), + // optional checkmark completion marker, and optional leading indentation. // Separator after the ID is flexible: colon, dash, em/en dash, dot, or just whitespace. - const headerPattern = /^#{1,4}\s+\*{0,2}(?:\u2713\s+)?(?:Slice\s+)?(S\d+)\*{0,2}[:\s.\u2014\u2013-]*\s*(.+)/gm; + const headerPattern = /^\s*#{1,4}\s+\*{0,2}(?:\u2713\s+)?(?:\d+[.)]\s+)?(?:\(\d+\)\s+)?(?:Slice\s+)?\[?(S\d+)\]?\*{0,2}[:\s.\u2014\u2013-]*\s*(.+)/gm; let match: RegExpExecArray | null; // Check for checkmark before the slice ID (e.g., "## checkmark S01: Title") - const prefixCheckPattern = /^#{1,4}\s+\*{0,2}\u2713\s+/; + const prefixCheckPattern = /^\s*#{1,4}\s+\*{0,2}\u2713\s+/; while ((match = headerPattern.exec(content)) !== null) { const id = match[1]!; @@ -251,7 +252,7 @@ function parseProseSliceHeaders(content: string): RoadmapSliceEntry[] { // Try to extract depends from prose: "Depends on: S01" or "**Depends on:** S01, S02" const afterHeader = content.slice(match.index + match[0].length); - const nextHeader = afterHeader.search(/^#{1,4}\s/m); + const nextHeader = afterHeader.search(/^\s*#{1,4}\s/m); const section = nextHeader !== -1 ? afterHeader.slice(0, nextHeader) : afterHeader.slice(0, 500); const depsMatch = section.match(/\*{0,2}Depends\s+on:?\*{0,2}\s*(.+)/i); diff --git a/src/resources/extensions/gsd/tests/roadmap-slices.test.ts b/src/resources/extensions/gsd/tests/roadmap-slices.test.ts index 63f607683..56364a653 100644 --- a/src/resources/extensions/gsd/tests/roadmap-slices.test.ts +++ b/src/resources/extensions/gsd/tests/roadmap-slices.test.ts @@ -296,3 +296,100 @@ Do the second thing. assert.equal(slices[0]?.id, "S01"); assert.equal(slices[1]?.id, "S02"); }); + +// ── Regression tests for #2567 ───────────────────────────────────────────── +// Prose H3 parser fails on common LLM-generated patterns: numbered prefixes, +// parenthetical numbering, bracketed IDs, and indented headings. + +test("parseRoadmapSlices: numbered H3 headers under ## Slices (#2567)", () => { + const numberedContent = `# M002: My Milestone + +**Vision:** Ship the product. + +## Slices + +### 1. S01: Setup Environment +Set up the dev environment and tooling. + +### 2. S02: Build Core +Implement the core logic. +**Depends on:** S01 + +### 3. S03: Polish UI +Final polish and theming. +**Depends on:** S01, S02 +`; + const slices = parseRoadmapSlices(numberedContent); + assert.equal(slices.length, 3, "should parse 3 slices from numbered H3 headers"); + assert.equal(slices[0]?.id, "S01"); + assert.equal(slices[0]?.title, "Setup Environment"); + assert.equal(slices[1]?.id, "S02"); + assert.deepEqual(slices[1]?.depends, ["S01"]); + assert.equal(slices[2]?.id, "S03"); + assert.deepEqual(slices[2]?.depends, ["S01", "S02"]); +}); + +test("parseRoadmapSlices: parenthetical-numbered H3 headers (#2567)", () => { + const parenContent = `# M002: Milestone + +**Vision:** Ship. + +## Slices + +### (1) S01: Setup +Setup work. + +### (2) S02: Build +Build work. +**Depends on:** S01 +`; + const slices = parseRoadmapSlices(parenContent); + assert.equal(slices.length, 2, "should parse slices with parenthetical numbering"); + assert.equal(slices[0]?.id, "S01"); + assert.equal(slices[0]?.title, "Setup"); + assert.equal(slices[1]?.id, "S02"); + assert.deepEqual(slices[1]?.depends, ["S01"]); +}); + +test("parseRoadmapSlices: bracketed slice IDs in H3 headers (#2567)", () => { + const bracketContent = `# M002: Milestone + +**Vision:** Ship. + +## Slices + +### [S01] Setup Environment +Setup work. + +### [S02] Build Core +Build work. +**Depends on:** S01 +`; + const slices = parseRoadmapSlices(bracketContent); + assert.equal(slices.length, 2, "should parse slices with bracketed IDs"); + assert.equal(slices[0]?.id, "S01"); + assert.equal(slices[0]?.title, "Setup Environment"); + assert.equal(slices[1]?.id, "S02"); + assert.deepEqual(slices[1]?.depends, ["S01"]); +}); + +test("parseRoadmapSlices: indented H3 headers under ## Slices (#2567)", () => { + const indentedContent = `# M002: Milestone + +**Vision:** Ship. + +## Slices + + ### S01: Setup + Setup work. + + ### S02: Build + Build work. +`; + const slices = parseRoadmapSlices(indentedContent); + assert.equal(slices.length, 2, "should parse slices from indented H3 headers"); + assert.equal(slices[0]?.id, "S01"); + assert.equal(slices[0]?.title, "Setup"); + assert.equal(slices[1]?.id, "S02"); + assert.equal(slices[1]?.title, "Build"); +});