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:
parent
fde6af9f38
commit
2a5570efd2
3 changed files with 324 additions and 2 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue