fix(roadmap): parse table-format slices in roadmap files (#1741)

parseRoadmapSlices() only understood checkbox format. When LLMs generated
markdown tables (## Slice Overview with pipe-delimited rows), the parser
returned empty results causing all_tasks_done_roadmap_not_checked errors
and auto-mode loops.

Add parseTableSlices() to detect and parse table format including slice
IDs, titles, risk levels, completion status, and dependencies. Broaden
heading matcher to accept alternate slice section headings.

Fixes #1736

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-21 10:50:08 -04:00 committed by GitHub
parent fde6af9f38
commit 2a5570efd2
3 changed files with 324 additions and 2 deletions

View file

@ -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;

View file

@ -349,6 +349,114 @@ async function main(): Promise<void> {
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();
}

View file

@ -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"]);
});