diff --git a/src/features/boulder-state/storage.test.ts b/src/features/boulder-state/storage.test.ts index 4bced7abc..65f6ad87e 100644 --- a/src/features/boulder-state/storage.test.ts +++ b/src/features/boulder-state/storage.test.ts @@ -433,63 +433,89 @@ describe("boulder-state", () => { }) describe("getPlanProgress", () => { - test("should count completed and uncompleted checkboxes", () => { - // given - plan file with checkboxes + test("should count only top-level tasks under TODOs and Final Verification Wave sections", () => { + // given - plan with top-level tasks in tracked sections const planPath = join(TEST_DIR, "test-plan.md") writeFileSync(planPath, `# Plan -- [ ] Task 1 -- [x] Task 2 -- [ ] Task 3 -- [X] Task 4 + +## TODOs +- [ ] 1. Task 1 +- [x] 2. Task 2 +- [ ] 3. Task 3 +- [X] 4. Task 4 + +## Final Verification Wave +- [ ] F1. Final review `) // when const progress = getPlanProgress(planPath) // then - expect(progress.total).toBe(4) + expect(progress.total).toBe(5) expect(progress.completed).toBe(2) expect(progress.isComplete).toBe(false) }) - test("should count space-indented unchecked checkbox", () => { - // given - plan file with a two-space indented checkbox - const planPath = join(TEST_DIR, "space-indented-plan.md") + test("should ignore nested Acceptance Criteria checkboxes under TODOs (issue #3066)", () => { + // given - plan with 9 completed top-level tasks and unchecked nested acceptance criteria + const planPath = join(TEST_DIR, "issue-3066-plan.md") writeFileSync(planPath, `# Plan - - [ ] indented task + +## TODOs +- [x] 1. Implement feature A + + **Acceptance Criteria** + - [ ] criterion 1 + - [ ] criterion 2 + +- [x] 2. Implement feature B + + **Acceptance Criteria** + - [ ] criterion 3 + - [ ] criterion 4 + +- [x] 3. Implement feature C +- [x] 4. Implement feature D +- [x] 5. Implement feature E +- [x] 6. Implement feature F +- [x] 7. Implement feature G +- [x] 8. Implement feature H +- [x] 9. Implement feature I + +## Final Verification Wave +- [ ] F1. Final review `) // when const progress = getPlanProgress(planPath) // then - expect(progress.total).toBe(1) - expect(progress.completed).toBe(0) + expect(progress.total).toBe(10) + expect(progress.completed).toBe(9) expect(progress.isComplete).toBe(false) }) - test("should count tab-indented unchecked checkbox", () => { - // given - plan file with a tab-indented checkbox - const planPath = join(TEST_DIR, "tab-indented-plan.md") + test("should ignore checkboxes outside TODOs and Final Verification Wave sections", () => { + // given - plan with checkboxes in Work Objectives, Success Criteria, and other sections + const planPath = join(TEST_DIR, "ignore-other-sections-plan.md") writeFileSync(planPath, `# Plan - - [ ] tab-indented task -`) - // when - const progress = getPlanProgress(planPath) +## Work Objectives - // then - expect(progress.total).toBe(1) - expect(progress.completed).toBe(0) - expect(progress.isComplete).toBe(false) - }) +### Definition of Done +- [ ] Verifiable condition with command - test("should count mixed top-level checked and indented unchecked checkboxes", () => { - // given - plan file with checked top-level and unchecked indented task - const planPath = join(TEST_DIR, "mixed-indented-plan.md") - writeFileSync(planPath, `# Plan -- [x] top-level completed task - - [ ] nested unchecked task +## TODOs +- [x] 1. Real task one +- [ ] 2. Real task two + +## Success Criteria + +### Final Checklist +- [ ] All Must Have present +- [ ] All Must NOT Have absent +- [ ] All tests pass `) // when @@ -501,11 +527,14 @@ describe("boulder-state", () => { expect(progress.isComplete).toBe(false) }) - test("should count space-indented completed checkbox", () => { - // given - plan file with a two-space indented completed checkbox - const planPath = join(TEST_DIR, "indented-completed-plan.md") + test("should ignore indented checkboxes under top-level tasks", () => { + // given - plan with indented unchecked nested checkboxes + const planPath = join(TEST_DIR, "nested-indented-plan.md") writeFileSync(planPath, `# Plan - - [x] indented completed task + +## TODOs +- [x] 1. top-level completed task + - [ ] nested unchecked task `) // when @@ -517,20 +546,67 @@ describe("boulder-state", () => { expect(progress.isComplete).toBe(true) }) - test("should return isComplete true when all checked", () => { - // given - all tasks completed - const planPath = join(TEST_DIR, "complete-plan.md") + test("should require proper task label format in TODOs", () => { + // given - plan with malformed labels (no numeric prefix) + const planPath = join(TEST_DIR, "malformed-labels-plan.md") writeFileSync(planPath, `# Plan -- [x] Task 1 -- [X] Task 2 + +## TODOs +- [ ] no number prefix +- [x] 1. Valid numbered task `) // when const progress = getPlanProgress(planPath) // then - expect(progress.total).toBe(2) + expect(progress.total).toBe(1) + expect(progress.completed).toBe(1) + expect(progress.isComplete).toBe(true) + }) + + test("should require F-prefix label format in Final Verification Wave", () => { + // given - plan with malformed final-wave labels + const planPath = join(TEST_DIR, "malformed-final-plan.md") + writeFileSync(planPath, `# Plan + +## TODOs +- [x] 1. Implementation done + +## Final Verification Wave +- [ ] missing F-prefix +- [ ] F1. Proper final review +- [x] F2. Another final review +`) + + // when + const progress = getPlanProgress(planPath) + + // then + expect(progress.total).toBe(3) expect(progress.completed).toBe(2) + expect(progress.isComplete).toBe(false) + }) + + test("should return isComplete true when all top-level tasks checked", () => { + // given - all top-level tasks completed + const planPath = join(TEST_DIR, "complete-plan.md") + writeFileSync(planPath, `# Plan + +## TODOs +- [x] 1. Task 1 +- [X] 2. Task 2 + +## Final Verification Wave +- [x] F1. Final review +`) + + // when + const progress = getPlanProgress(planPath) + + // then + expect(progress.total).toBe(3) + expect(progress.completed).toBe(3) expect(progress.isComplete).toBe(true) }) @@ -555,6 +631,25 @@ describe("boulder-state", () => { expect(progress.total).toBe(0) expect(progress.isComplete).toBe(true) }) + + test("should support asterisk bullet top-level tasks", () => { + // given - plan with asterisk bullet tasks + const planPath = join(TEST_DIR, "asterisk-bullet-plan.md") + writeFileSync(planPath, `# Plan + +## TODOs +* [x] 1. Task using asterisk bullet +* [ ] 2. Another asterisk task +`) + + // when + const progress = getPlanProgress(planPath) + + // then + expect(progress.total).toBe(2) + expect(progress.completed).toBe(1) + expect(progress.isComplete).toBe(false) + }) }) describe("getPlanName", () => { diff --git a/src/features/boulder-state/storage.ts b/src/features/boulder-state/storage.ts index 0b3eefe54..f72a57037 100644 --- a/src/features/boulder-state/storage.ts +++ b/src/features/boulder-state/storage.ts @@ -196,8 +196,25 @@ export function findPrometheusPlans(directory: string): string[] { } } +const TODO_HEADING_PATTERN = /^##\s+TODOs\b/i +const FINAL_VERIFICATION_HEADING_PATTERN = /^##\s+Final Verification Wave\b/i +const SECOND_LEVEL_HEADING_PATTERN = /^##\s+/ +const UNCHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[\s*\]\s*(.+)$/ +const CHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[[xX]\]\s*(.+)$/ +const TODO_TASK_PATTERN = /^\d+\.\s+/ +const FINAL_WAVE_TASK_PATTERN = /^F\d+\.\s+/i + +type ProgressSection = "todo" | "final-wave" | "other" + /** * Parse a plan file and count checkbox progress. + * + * Only top-level (zero-indent) checkboxes under `## TODOs` and + * `## Final Verification Wave` sections are counted. The checkbox + * body must carry a valid task label (`N.` for TODOs, `FN.` for + * Final Verification Wave). Nested acceptance-criteria checkboxes + * and checkboxes in other sections are intentionally ignored so + * that progress tracking stays aligned with `readCurrentTopLevelTask`. */ export function getPlanProgress(planPath: string): PlanProgress { if (!existsSync(planPath)) { @@ -206,13 +223,47 @@ export function getPlanProgress(planPath: string): PlanProgress { try { const content = readFileSync(planPath, "utf-8") - - // Match markdown checkboxes: - [ ] or - [x] or - [X] - const uncheckedMatches = content.match(/^\s*[-*]\s*\[\s*\]/gm) || [] - const checkedMatches = content.match(/^\s*[-*]\s*\[[xX]\]/gm) || [] + const lines = content.split(/\r?\n/) + let section: ProgressSection = "other" + let total = 0 + let completed = 0 - const total = uncheckedMatches.length + checkedMatches.length - const completed = checkedMatches.length + for (const line of lines) { + if (SECOND_LEVEL_HEADING_PATTERN.test(line)) { + section = TODO_HEADING_PATTERN.test(line) + ? "todo" + : FINAL_VERIFICATION_HEADING_PATTERN.test(line) + ? "final-wave" + : "other" + continue + } + + if (section !== "todo" && section !== "final-wave") { + continue + } + + const checkedMatch = line.match(CHECKED_CHECKBOX_PATTERN) + const uncheckedMatch = checkedMatch ? null : line.match(UNCHECKED_CHECKBOX_PATTERN) + const match = checkedMatch ?? uncheckedMatch + if (!match) { + continue + } + + if (match[1].length > 0) { + continue + } + + const taskBody = match[2].trim() + const labelPattern = section === "todo" ? TODO_TASK_PATTERN : FINAL_WAVE_TASK_PATTERN + if (!labelPattern.test(taskBody)) { + continue + } + + total++ + if (checkedMatch) { + completed++ + } + } return { total, diff --git a/src/features/boulder-state/top-level-task.ts b/src/features/boulder-state/top-level-task.ts index d92970b56..feff10f0c 100644 --- a/src/features/boulder-state/top-level-task.ts +++ b/src/features/boulder-state/top-level-task.ts @@ -6,6 +6,7 @@ const TODO_HEADING_PATTERN = /^##\s+TODOs\b/i const FINAL_VERIFICATION_HEADING_PATTERN = /^##\s+Final Verification Wave\b/i const SECOND_LEVEL_HEADING_PATTERN = /^##\s+/ const UNCHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[\s*\]\s*(.+)$/ +const CHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[[xX]\]\s*(.+)$/ const TODO_TASK_PATTERN = /^(\d+)\.\s+(.+)$/ const FINAL_WAVE_TASK_PATTERN = /^(F\d+)\.\s+(.+)$/i