fix: fall through to prose slice parser when checkbox parser yields empty under ## Slices (#1744)

When the ## Slices section exists but contains H3 prose headers instead of
checkboxes, parseRoadmapSlices returned an empty array because the prose
fallback was only invoked when the ## Slices heading was entirely absent.
Now, when the checkbox parser finds zero slices, it falls through to
parseProseSliceHeaders as a second-chance fallback.

Also adds a missing_slice_dir diagnostic in doctor.ts when resolveSlicePath
returns null, with auto-fix via mkdir.

Fixes #1711

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-21 15:24:05 -04:00 committed by GitHub
parent a7ad0caf9f
commit 897237ab0a
4 changed files with 84 additions and 1 deletions

View file

@ -57,6 +57,7 @@ export type DoctorIssueCode =
// GSD state structural checks
| "circular_slice_dependency"
| "orphaned_slice_directory"
| "missing_slice_dir"
| "duplicate_task_id"
| "task_file_not_in_plan"
| "stale_replan_file"

View file

@ -706,7 +706,26 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
}
const slicePath = resolveSlicePath(basePath, milestoneId, slice.id);
if (!slicePath) continue;
if (!slicePath) {
const expectedPath = relSlicePath(basePath, milestoneId, slice.id);
issues.push({
severity: slice.done ? "warning" : "error",
code: "missing_slice_dir",
scope: "slice",
unitId,
message: slice.done
? `Missing slice directory for ${unitId} (slice is complete — cosmetic only)`
: `Missing slice directory for ${unitId}`,
file: expectedPath,
fixable: true,
});
if (fix) {
const absoluteSliceDir = join(milestonePath, "slices", slice.id);
mkdirSync(absoluteSliceDir, { recursive: true });
fixesApplied.push(`created ${absoluteSliceDir}`);
}
continue;
}
const tasksDir = resolveTasksDir(basePath, milestoneId, slice.id);
if (!tasksDir) {

View file

@ -184,6 +184,14 @@ export function parseRoadmapSlices(content: string): RoadmapSliceEntry[] {
}
if (currentSlice) slices.push(currentSlice);
// When the ## Slices section exists but the checkbox parser found nothing
// (e.g. the LLM used H3 prose headers instead of checkboxes), fall through
// to the prose-header parser as a second-chance fallback.
if (slices.length === 0) {
return parseProseSliceHeaders(content);
}
return slices;
}

View file

@ -198,3 +198,58 @@ Not done.
assert.equal(slices[0]?.title, "Done Slice");
assert.equal(slices[1]?.done, false);
});
// ── Regression tests for #1711 ─────────────────────────────────────────────
test("parseRoadmapSlices: H3 prose headers under ## Slices section triggers prose fallback (#1711)", () => {
const proseUnderSlices = `# M010: My Milestone
**Vision:** Ship it.
## Slices
### S01 Setup Environment
Set up the dev environment and tooling.
### S02 Build Core
Implement the core logic.
**Depends on:** S01
### S03 Polish UI
Final polish and theming.
**Depends on:** S01, S02
`;
const slices = parseRoadmapSlices(proseUnderSlices);
assert.equal(slices.length, 3, "should find 3 slices from H3 prose headers under ## Slices");
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: ## Slices with valid checkboxes does NOT invoke prose fallback", () => {
const slices = parseRoadmapSlices(content);
assert.equal(slices.length, 3);
assert.equal(slices[0]?.id, "S01");
assert.equal(slices[0]?.done, true);
});
test("parseRoadmapSlices: ## Slices with only non-matching lines returns prose fallback results", () => {
const weirdContent = `# M020: Odd
## Slices
Some introductory text that is not a checkbox or a slice header.
### S01: First Thing
Do the first thing.
### S02: Second Thing
Do the second thing.
`;
const slices = parseRoadmapSlices(weirdContent);
assert.equal(slices.length, 2, "should fall through to prose parser");
assert.equal(slices[0]?.id, "S01");
assert.equal(slices[1]?.id, "S02");
});