fix(roadmap): detect ✓ completion marker in prose slice headers (#1816)
parseProseSliceHeaders() always set done:false regardless of ✓ or
(Complete) markers in the title, causing auto-mode to repeatedly
dispatch complete-slice for already-finished slices.
- Detect ✓ prefix before slice ID ("## ✓ S01: Title")
- Detect ✓ after separator ("## S01: ✓ Title")
- Detect (Complete) suffix ("## S01: Title (Complete)")
- Strip markers from title so downstream consumers get clean names
- Add prose format support to markSliceDoneInRoadmap
Fixes #1803
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
72f39b6e23
commit
ab2eab01d2
3 changed files with 113 additions and 66 deletions
|
|
@ -27,11 +27,24 @@ export function markSliceDoneInRoadmap(basePath: string, mid: string, sid: strin
|
|||
return false;
|
||||
}
|
||||
|
||||
const updated = content.replace(
|
||||
// Try checkbox format first: "- [ ] **S01: Title**"
|
||||
let updated = content.replace(
|
||||
new RegExp(`^(\\s*-\\s+)\\[ \\]\\s+\\*\\*${sid}:`, "m"),
|
||||
`$1[x] **${sid}:`,
|
||||
);
|
||||
|
||||
// If checkbox format didn't match, try prose format: "## S01: Title" -> "## S01: \u2713 Title"
|
||||
if (updated === content) {
|
||||
updated = content.replace(
|
||||
new RegExp(`^(#{1,4}\\s+(?:\\*{0,2})(?:Slice\\s+)?${sid}\\*{0,2}[:\\s.\\u2014\\u2013-]+\\s*)(.+)`, "m"),
|
||||
(match, prefix, title) => {
|
||||
// Already marked done — no-op
|
||||
if (/^\u2713/.test(title) || /\(Complete\)\s*$/i.test(title)) return match;
|
||||
return `${prefix}\u2713 ${title}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (updated === content) return false;
|
||||
|
||||
atomicWriteSync(roadmapFile, updated);
|
||||
|
|
|
|||
|
|
@ -209,16 +209,37 @@ 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 and bold markers.
|
||||
// Match H1-H4 headers containing S<digits> with optional "Slice" prefix, bold markers,
|
||||
// and optional checkmark completion marker before the slice ID.
|
||||
// Separator after the ID is flexible: colon, dash, em/en dash, dot, or just whitespace.
|
||||
const headerPattern = /^#{1,4}\s+\*{0,2}(?:Slice\s+)?(S\d+)\*{0,2}[:\s.—–-]*\s*(.+)/gm;
|
||||
const headerPattern = /^#{1,4}\s+\*{0,2}(?:\u2713\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+/;
|
||||
|
||||
while ((match = headerPattern.exec(content)) !== null) {
|
||||
const id = match[1]!;
|
||||
let title = match[2]!.trim().replace(/\*{1,2}$/g, "").trim(); // strip trailing bold markers
|
||||
if (!title) continue; // skip if we only matched the ID with no title
|
||||
|
||||
// Detect completion markers:
|
||||
// 1. Checkmark before the slice ID: "## checkmark S01: Title"
|
||||
// 2. Checkmark after separator: "## S01: checkmark Title"
|
||||
// 3. (Complete) suffix: "## S01: Title (Complete)"
|
||||
const line = match[0];
|
||||
let done = prefixCheckPattern.test(line);
|
||||
|
||||
if (!done && title.startsWith("\u2713")) {
|
||||
done = true;
|
||||
title = title.replace(/^\u2713\s*/, "");
|
||||
}
|
||||
|
||||
if (!done && /\(Complete\)\s*$/i.test(title)) {
|
||||
done = true;
|
||||
title = title.replace(/\s*\(Complete\)\s*$/i, "");
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
|
@ -235,7 +256,7 @@ function parseProseSliceHeaders(content: string): RoadmapSliceEntry[] {
|
|||
}
|
||||
}
|
||||
|
||||
slices.push({ id, title, risk: "medium" as RiskLevel, depends, done: false, demo: "" });
|
||||
slices.push({ id, title, risk: "medium" as RiskLevel, depends, done, demo: "" });
|
||||
}
|
||||
|
||||
return slices;
|
||||
|
|
|
|||
|
|
@ -71,117 +71,130 @@ test("parseRoadmapSlices: comma-separated depends still works", () => {
|
|||
|
||||
test("parseRoadmapSlices: table format under ## Slices heading (#1736)", () => {
|
||||
const tableContent = [
|
||||
"# M001: Test Project",
|
||||
"",
|
||||
"## Slices",
|
||||
"",
|
||||
"# 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",
|
||||
"", "## 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 |",
|
||||
"|---|---|---|---|",
|
||||
"# M002: Another Project", "", "## Slice Overview", "",
|
||||
"| ID | Description | Risk | Done |", "|---|---|---|---|",
|
||||
"| S01 | Foundation Work | High | [x] |",
|
||||
"| S02 | API Layer | Medium | [ ] |",
|
||||
"",
|
||||
"| 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.length, 2);
|
||||
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 |",
|
||||
"|---|---|---|---|",
|
||||
"# M003: Status Text", "", "## Slices", "",
|
||||
"| Slice | Title | Risk | Status |", "|---|---|---|---|",
|
||||
"| S01 | First | Low | Done |",
|
||||
"| S02 | Second | High | Pending |",
|
||||
"| S03 | Third | Medium | Completed |",
|
||||
"",
|
||||
"| 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");
|
||||
assert.equal(slices[0]?.done, true);
|
||||
assert.equal(slices[1]?.done, false);
|
||||
assert.equal(slices[2]?.done, true);
|
||||
});
|
||||
|
||||
test("parseRoadmapSlices: table with dependencies column (#1736)", () => {
|
||||
const tableContent = [
|
||||
"# M004: Deps",
|
||||
"",
|
||||
"## Slices",
|
||||
"",
|
||||
"| Slice | Title | Risk | Depends | Status |",
|
||||
"|---|---|---|---|---|",
|
||||
"# M004: Deps", "", "## Slices", "",
|
||||
"| Slice | Title | Risk | Depends | Status |", "|---|---|---|---|---|",
|
||||
"| S01 | First | Low | None | Done |",
|
||||
"| S02 | Second | High | S01 | Pending |",
|
||||
"| S03 | Third | Medium | S01, S02 | [ ] |",
|
||||
"",
|
||||
"| 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");
|
||||
assert.deepEqual(slices[0]?.depends, []);
|
||||
assert.deepEqual(slices[1]?.depends, ["S01"]);
|
||||
assert.deepEqual(slices[2]?.depends, ["S01", "S02"]);
|
||||
});
|
||||
|
||||
test("parseRoadmapSlices: standard checkbox format still works after table support (#1736)", () => {
|
||||
// Verify the existing checkbox format is not broken by the table parsing addition
|
||||
test("parseRoadmapSlices: standard checkbox format still works (#1736)", () => {
|
||||
const checkboxContent = [
|
||||
"# M005: Unchanged",
|
||||
"",
|
||||
"## Slices",
|
||||
"",
|
||||
"# M005: Unchanged", "", "## Slices", "",
|
||||
"- [x] **S01: First Slice** `risk:low` `depends:[]`",
|
||||
" > After this: First demo works.",
|
||||
"- [ ] **S02: Second Slice** `risk:medium` `depends:[S01]`",
|
||||
"",
|
||||
"- [ ] **S02: Second Slice** `risk:medium` `depends:[S01]`", "",
|
||||
].join("\n");
|
||||
|
||||
const slices = parseRoadmapSlices(checkboxContent);
|
||||
assert.equal(slices.length, 2);
|
||||
assert.equal(slices[0]?.done, true);
|
||||
assert.equal(slices[1]?.done, false);
|
||||
});
|
||||
|
||||
// --- Prose slice header completion marker tests (#1803) ---
|
||||
|
||||
test("parseRoadmapSlices: prose headers with ✓ marker detected as done", () => {
|
||||
const proseContent = `# M010: Prose Roadmap
|
||||
|
||||
## S01: ✓ First Feature
|
||||
Some description.
|
||||
|
||||
## S02: Second Feature
|
||||
Not done yet.
|
||||
|
||||
## S03: ✓ Third Feature
|
||||
Also done.
|
||||
`;
|
||||
const slices = parseRoadmapSlices(proseContent);
|
||||
assert.equal(slices.length, 3);
|
||||
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[0]?.title, "First Feature");
|
||||
assert.equal(slices[1]?.done, false);
|
||||
assert.equal(slices[2]?.done, true);
|
||||
});
|
||||
|
||||
test("parseRoadmapSlices: prose headers with (Complete) marker detected as done", () => {
|
||||
const proseContent = `# M011: Prose Roadmap
|
||||
|
||||
## S01: First Feature (Complete)
|
||||
Done slice.
|
||||
|
||||
## S02: Second Feature
|
||||
In progress.
|
||||
`;
|
||||
const slices = parseRoadmapSlices(proseContent);
|
||||
assert.equal(slices.length, 2);
|
||||
assert.equal(slices[0]?.done, true);
|
||||
assert.equal(slices[0]?.title, "First Feature");
|
||||
assert.equal(slices[1]?.done, false);
|
||||
});
|
||||
|
||||
test("parseRoadmapSlices: prose headers with ✓ prefix before title", () => {
|
||||
const proseContent = `# M012: Prose
|
||||
|
||||
## ✓ S01: Done Slice
|
||||
Complete.
|
||||
|
||||
## S02: Pending Slice
|
||||
Not done.
|
||||
`;
|
||||
const slices = parseRoadmapSlices(proseContent);
|
||||
assert.equal(slices.length, 2);
|
||||
assert.equal(slices[0]?.done, true);
|
||||
assert.equal(slices[0]?.title, "Done Slice");
|
||||
assert.equal(slices[1]?.done, false);
|
||||
assert.deepEqual(slices[1]?.depends, ["S01"]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue