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 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-30 16:43:33 -04:00 committed by GitHub
parent 8b680179e2
commit e78dca41d4
2 changed files with 102 additions and 4 deletions

View file

@ -219,13 +219,14 @@ export function parseRoadmapSlices(content: string): RoadmapSliceEntry[] {
function parseProseSliceHeaders(content: string): RoadmapSliceEntry[] {
const slices: RoadmapSliceEntry[] = [];
// Match H1-H4 headers containing S<digits> 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);

View file

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