diff --git a/src/resources/extensions/gsd/roadmap-slices.ts b/src/resources/extensions/gsd/roadmap-slices.ts index 5b3b09fec..43ac53b92 100644 --- a/src/resources/extensions/gsd/roadmap-slices.ts +++ b/src/resources/extensions/gsd/roadmap-slices.ts @@ -41,7 +41,8 @@ export function expandDependencies(deps: string[]): string[] { } function extractSlicesSection(content: string): string { - const headingMatch = /^## Slices\b.*$/m.exec(content); + // Match "## Slices", "## Slice Overview", "## Slice Table", etc. + const headingMatch = /^## Slice(?:s| Overview| Table| Summary| Status)\b.*$/m.exec(content); if (!headingMatch || headingMatch.index == null) return ""; const start = headingMatch.index + headingMatch[0].length; @@ -50,9 +51,92 @@ function extractSlicesSection(content: string): string { return (nextHeading ? rest.slice(0, nextHeading.index) : rest).trimEnd(); } +/** + * Parse markdown table format for slices. + * + * Handles LLM-generated table variants: + * | S01 | Title | High | [x] Done | + * | S01 | Title | High | Done | [x] | + * | S01 | Title | High | Complete | + * | S01 | Title | [x] | High | S01,S02 | + * + * Returns parsed slices if a table with slice IDs is found, otherwise empty array. + */ +function parseTableSlices(section: string): RoadmapSliceEntry[] { + const lines = section.split("\n"); + const slices: RoadmapSliceEntry[] = []; + + for (const line of lines) { + // Skip non-table lines, separator lines (|---|---|), and header rows + if (!line.includes("|")) continue; + if (/^\s*\|[\s:-]+\|/.test(line) && !/S\d+/.test(line)) continue; + + // Extract a slice ID from the row + const idMatch = line.match(/\b(S\d+)\b/); + if (!idMatch) continue; + + const id = idMatch[1]!; + const cells = line.split("|").map(c => c.trim()).filter(Boolean); + + // Determine completion status from any cell containing [x], "Done", or "Complete" + const fullRow = line.toLowerCase(); + const done = + /\[x\]/i.test(line) || + /\bdone\b/.test(fullRow) || + /\bcomplete(?:d)?\b/.test(fullRow); + + // Extract risk from any cell containing risk keywords + let risk: RiskLevel = "medium"; + for (const cell of cells) { + const cellLower = cell.toLowerCase(); + if (/\bhigh\b/.test(cellLower)) { risk = "high"; break; } + if (/\blow\b/.test(cellLower)) { risk = "low"; break; } + if (/\bmedium\b/.test(cellLower) || /\bmed\b/.test(cellLower)) { risk = "medium"; break; } + } + + // Extract dependencies from cells containing S-prefixed IDs (excluding the slice's own ID) + let depends: string[] = []; + for (const cell of cells) { + if (/depends|deps/i.test(cell) || (cell.match(/S\d+/g)?.length ?? 0) > 0) { + const depIds = (cell.match(/S\d+/g) ?? []).filter(d => d !== id); + if (depIds.length > 0 || /none|—|-/i.test(cell)) { + depends = expandDependencies(depIds); + break; + } + } + } + + // Extract title: use the cell after the ID cell, excluding cells that look like + // status, risk, dependency, or checkbox fields + let title = ""; + const idCellIndex = cells.findIndex(c => c.includes(id)); + for (let i = 0; i < cells.length; i++) { + if (i === idCellIndex) continue; + const cellLower = cells[i]!.toLowerCase(); + // Skip cells that are clearly metadata + if (/^\[[ x]\]/.test(cells[i]!) || /\[x\]/i.test(cells[i]!)) continue; + if (/^(high|medium|med|low)$/i.test(cells[i]!.trim())) continue; + if (/^(done|complete[d]?|pending|in.?progress|not started|todo)$/i.test(cells[i]!.trim())) continue; + if (/^(none|—|-)$/.test(cells[i]!.trim())) continue; + if (/^S\d+/.test(cells[i]!.trim()) && i !== idCellIndex) continue; + if (/depends|deps/i.test(cellLower)) continue; + // First remaining cell is likely the title + if (!title && cells[i]!.trim()) { + title = cells[i]!.trim().replace(/^\*+|\*+$/g, ""); + break; + } + } + + if (!title) title = id; + + slices.push({ id, title, risk, depends, done, demo: "" }); + } + + return slices; +} + export function parseRoadmapSlices(content: string): RoadmapSliceEntry[] { const slicesSection = extractSlicesSection(content); - const slices: RoadmapSliceEntry[] = []; if (!slicesSection) { // Fallback: detect prose-style slice headers (## Slice S01: Title) // when the LLM writes freeform prose instead of the ## Slices checklist. @@ -60,6 +144,15 @@ export function parseRoadmapSlices(content: string): RoadmapSliceEntry[] { return parseProseSliceHeaders(content); } + // Try table format first — if the section contains pipe-delimited rows with + // slice IDs, parse them as a table (#1736). + const tableSlices = parseTableSlices(slicesSection); + if (tableSlices.length > 0) { + return tableSlices; + } + + // Standard checkbox format + const slices: RoadmapSliceEntry[] = []; const checkboxItems = slicesSection.split("\n"); let currentSlice: RoadmapSliceEntry | null = null; diff --git a/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts b/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts index e2d70a75b..f6530049a 100644 --- a/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +++ b/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts @@ -349,6 +349,114 @@ async function main(): Promise { assertEq(slices[0].id, 'S001', 'three-digit: S001'); } + // ═══════════════════════════════════════════════════════════════════════ + // Q. Regression #1736: Table format under ## Slices + // ═══════════════════════════════════════════════════════════════════════ + + console.log('\n=== Q. #1736: Table format under ## Slices ==='); + + { + const content = [ + '# M001: Test', + '', + '## Slices', + '', + '| Slice | Title | Risk | Status |', + '| --- | --- | --- | --- |', + '| S01 | Setup Foundation | Low | [x] Done |', + '| S02 | Core Features | High | [ ] Pending |', + '| S03 | Polish | Medium | [x] Done |', + '', + '## Boundary Map', + ].join('\n'); + + const slices = parseRoadmapSlices(content); + assertEq(slices.length, 3, '#1736 table: 3 slices'); + assertEq(slices[0].id, 'S01', '#1736 table: S01 id'); + assertEq(slices[0].title, 'Setup Foundation', '#1736 table: S01 title'); + assertEq(slices[0].done, true, '#1736 table: S01 done'); + assertEq(slices[0].risk, 'low', '#1736 table: S01 risk'); + assertEq(slices[1].done, false, '#1736 table: S02 not done'); + assertEq(slices[2].done, true, '#1736 table: S03 done'); + } + + // ═══════════════════════════════════════════════════════════════════════ + // R. Regression #1736: Table format under ## Slice Overview + // ═══════════════════════════════════════════════════════════════════════ + + console.log('\n=== R. #1736: Table format under ## Slice Overview ==='); + + { + const content = [ + '# M002: Overview Heading', + '', + '## Slice Overview', + '', + '| ID | Description | Risk | Done |', + '|---|---|---|---|', + '| S01 | Foundation | High | [x] |', + '| S02 | API Layer | Medium | [ ] |', + '', + ].join('\n'); + + const slices = parseRoadmapSlices(content); + assertEq(slices.length, 2, '#1736 overview: 2 slices'); + assertEq(slices[0].done, true, '#1736 overview: S01 done'); + assertEq(slices[1].done, false, '#1736 overview: S02 not done'); + } + + // ═══════════════════════════════════════════════════════════════════════ + // S. Regression #1736: Table with Done/Complete text status + // ═══════════════════════════════════════════════════════════════════════ + + console.log('\n=== S. #1736: Table with text status ==='); + + { + const content = [ + '# M003: Status Text', + '', + '## Slices', + '', + '| Slice | Title | Risk | Status |', + '|---|---|---|---|', + '| S01 | First | Low | Done |', + '| S02 | Second | High | Pending |', + '| S03 | Third | Medium | Completed |', + '', + ].join('\n'); + + const slices = parseRoadmapSlices(content); + assertEq(slices.length, 3, '#1736 text status: 3 slices'); + assertTrue(slices[0].done, '#1736 text status: Done = true'); + assertTrue(!slices[1].done, '#1736 text status: Pending = false'); + assertTrue(slices[2].done, '#1736 text status: Completed = true'); + } + + // ═══════════════════════════════════════════════════════════════════════ + // T. Regression #1736: Checkbox format still works after table support + // ═══════════════════════════════════════════════════════════════════════ + + console.log('\n=== T. #1736: Checkbox format unchanged ==='); + + { + const content = [ + '# M005: Unchanged', + '', + '## Slices', + '', + '- [x] **S01: First** `risk:low` `depends:[]`', + ' > After this: demo works.', + '- [ ] **S02: Second** `risk:medium` `depends:[S01]`', + '', + ].join('\n'); + + const slices = parseRoadmapSlices(content); + assertEq(slices.length, 2, '#1736 checkbox compat: 2 slices'); + assertEq(slices[0].done, true, '#1736 checkbox compat: S01 done'); + assertEq(slices[0].demo, 'demo works.', '#1736 checkbox compat: demo'); + assertEq(slices[1].done, false, '#1736 checkbox compat: S02 not done'); + } + report(); } diff --git a/src/resources/extensions/gsd/tests/roadmap-slices.test.ts b/src/resources/extensions/gsd/tests/roadmap-slices.test.ts index 3734380ac..b51d98dca 100644 --- a/src/resources/extensions/gsd/tests/roadmap-slices.test.ts +++ b/src/resources/extensions/gsd/tests/roadmap-slices.test.ts @@ -64,3 +64,124 @@ test("parseRoadmapSlices: comma-separated depends still works", () => { const slices = parseRoadmapSlices(commaContent); assert.deepEqual(slices[0]?.depends, ["S01", "S02", "S03", "S04"]); }); + +// ═══════════════════════════════════════════════════════════════════════════ +// Regression #1736: Table format parsing +// ═══════════════════════════════════════════════════════════════════════════ + +test("parseRoadmapSlices: table format under ## Slices heading (#1736)", () => { + const tableContent = [ + "# M001: Test Project", + "", + "## Slices", + "", + "| Slice | Title | Risk | Status |", + "| --- | --- | --- | --- |", + "| S01 | Setup Foundation | Low | [x] Done |", + "| S02 | Core Features | High | [ ] Pending |", + "| S03 | Polish | Medium | [x] Done |", + "", + "## Boundary Map", + ].join("\n"); + + const slices = parseRoadmapSlices(tableContent); + assert.equal(slices.length, 3, "should parse 3 slices from table"); + assert.equal(slices[0]?.id, "S01"); + assert.equal(slices[0]?.title, "Setup Foundation"); + assert.equal(slices[0]?.done, true); + assert.equal(slices[0]?.risk, "low"); + assert.equal(slices[1]?.id, "S02"); + assert.equal(slices[1]?.done, false); + assert.equal(slices[1]?.risk, "high"); + assert.equal(slices[2]?.id, "S03"); + assert.equal(slices[2]?.done, true); + assert.equal(slices[2]?.risk, "medium"); +}); + +test("parseRoadmapSlices: table format under ## Slice Overview heading (#1736)", () => { + const tableContent = [ + "# M002: Another Project", + "", + "## Slice Overview", + "", + "| ID | Description | Risk | Done |", + "|---|---|---|---|", + "| S01 | Foundation Work | High | [x] |", + "| S02 | API Layer | Medium | [ ] |", + "", + ].join("\n"); + + const slices = parseRoadmapSlices(tableContent); + assert.equal(slices.length, 2, "should parse slices from Slice Overview table"); + assert.equal(slices[0]?.id, "S01"); + assert.equal(slices[0]?.title, "Foundation Work"); + assert.equal(slices[0]?.done, true); + assert.equal(slices[0]?.risk, "high"); + assert.equal(slices[1]?.id, "S02"); + assert.equal(slices[1]?.done, false); +}); + +test("parseRoadmapSlices: table with Status Done/Complete text (#1736)", () => { + const tableContent = [ + "# M003: Status Text", + "", + "## Slices", + "", + "| Slice | Title | Risk | Status |", + "|---|---|---|---|", + "| S01 | First | Low | Done |", + "| S02 | Second | High | Pending |", + "| S03 | Third | Medium | Completed |", + "", + ].join("\n"); + + const slices = parseRoadmapSlices(tableContent); + assert.equal(slices.length, 3); + assert.equal(slices[0]?.done, true, "Done text marks slice as done"); + assert.equal(slices[1]?.done, false, "Pending text marks slice as not done"); + assert.equal(slices[2]?.done, true, "Completed text marks slice as done"); +}); + +test("parseRoadmapSlices: table with dependencies column (#1736)", () => { + const tableContent = [ + "# M004: Deps", + "", + "## Slices", + "", + "| Slice | Title | Risk | Depends | Status |", + "|---|---|---|---|---|", + "| S01 | First | Low | None | Done |", + "| S02 | Second | High | S01 | Pending |", + "| S03 | Third | Medium | S01, S02 | [ ] |", + "", + ].join("\n"); + + const slices = parseRoadmapSlices(tableContent); + assert.equal(slices.length, 3); + assert.deepEqual(slices[0]?.depends, [], "None deps parsed as empty"); + assert.deepEqual(slices[1]?.depends, ["S01"], "Single dep parsed"); + assert.deepEqual(slices[2]?.depends, ["S01", "S02"], "Multiple deps parsed"); +}); + +test("parseRoadmapSlices: standard checkbox format still works after table support (#1736)", () => { + // Verify the existing checkbox format is not broken by the table parsing addition + const checkboxContent = [ + "# M005: Unchanged", + "", + "## Slices", + "", + "- [x] **S01: First Slice** `risk:low` `depends:[]`", + " > After this: First demo works.", + "- [ ] **S02: Second Slice** `risk:medium` `depends:[S01]`", + "", + ].join("\n"); + + const slices = parseRoadmapSlices(checkboxContent); + assert.equal(slices.length, 2); + assert.equal(slices[0]?.id, "S01"); + assert.equal(slices[0]?.done, true); + assert.equal(slices[0]?.demo, "First demo works."); + assert.equal(slices[1]?.id, "S02"); + assert.equal(slices[1]?.done, false); + assert.deepEqual(slices[1]?.depends, ["S01"]); +});