From 5906a24ede861e0f7ecb32c18b16e4801754b2e8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 19 Sep 2026 21:06:05 -0400 Subject: [PATCH] =?UTF-8?q?fix(#4786):=20plan-row=20detection=20accepts=20?= =?UTF-8?q?the=20bare=20planId=20stem=20=E2=80=94=20suffix-less=20hand-wri?= =?UTF-8?q?tten=20lists=20tick=20in=20place=20(#4880)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#4786): failing-first — a suffix-less hand-written plan list is ticked in place, never duplicated * fix(#4786): plan-row detection accepts the bare planId stem — a suffix-less hand-written list is ticked in place, never duplicated * chore(#4786): backfill changeset PR number (4880) --------- Co-authored-by: sim --- .changeset/bold-otters-fly.md | 5 ++ src/roadmap.cts | 21 +++++- tests/roadmap.test.cjs | 117 ++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 .changeset/bold-otters-fly.md diff --git a/.changeset/bold-otters-fly.md b/.changeset/bold-otters-fly.md new file mode 100644 index 000000000..b3dede3cb --- /dev/null +++ b/.changeset/bold-otters-fly.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4880 +--- +**roadmap update-plan-progress stops inserting a duplicate plan list beside hand-written ones** — plan checkbox rows written without the -PLAN.md suffix (the hand-written form) were not recognized, so the verb inserted its own canonical list above them, leaving two competing lists for the same plans; suffix-less rows are now recognized and ticked in place. (#4786) diff --git a/src/roadmap.cts b/src/roadmap.cts index 52cd41b42..a098b5f77 100644 --- a/src/roadmap.cts +++ b/src/roadmap.cts @@ -1274,9 +1274,28 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und // Detection is scoped to the active region so a plan that appears in an // archived
block is still correctly detected as missing from the // active milestone section. + // + // #4786: a plan row may be written WITH the `-PLAN.md` suffix (canonical + // template form) or WITHOUT it (hand-written form: `- [x] 659-01 — desc`). + // The tick loop above keys on the bare planId stem with a PREFIX match + // (pre-existing, out of scope here), while detection required the full + // `-PLAN.md` filename — so every suffix-less row was counted missing and + // the insertion fired BESIDE the recognized list — 32 checkbox lines for + // 16 plans, exit 0. The stem arm below accepts the bare id only up to a + // boundary (whitespace / `:` / dashes / `-PLAN.md` / `.md` / `**` / `)`), + // so `5-011` never satisfies `5-01`. Deliberately NOT in the boundary set: + // `.` — a dotted sub-id (`5-01.5`) is a real distinct plan, and counting + // its row as 5-01's presence would suppress a genuine insertion. const missingPlans = phaseInfo!.plans.filter((planFile) => { const planEscaped = escapeRegex(planFile); - return !new RegExp(`-\\s*\\[[x ]\\]\\s*(?:\\*\\*)?${planEscaped}`, 'i').test(activeRegion); + if (new RegExp(`-\\s*\\[[x ]\\]\\s*(?:\\*\\*)?${planEscaped}`, 'i').test(activeRegion)) return false; + const stem = planFile.replace(/-PLAN\.md$/i, ''); + const stemEscaped = escapeRegex(stem); + const stemPresent = new RegExp( + `-\\s*\\[[x ]\\]\\s*(?:\\*\\*)?${stemEscaped}(?=$|\\s|:|—|–|-PLAN\\.md|\\.md|\\*\\*|\\))`, + 'i' + ).test(activeRegion); + return !stemPresent; }); if (missingPlans.length > 0) { diff --git a/tests/roadmap.test.cjs b/tests/roadmap.test.cjs index 94188f31b..f2ae4264c 100644 --- a/tests/roadmap.test.cjs +++ b/tests/roadmap.test.cjs @@ -5279,3 +5279,120 @@ describe('roadmap update-plan-progress — superseded plans (#4741)', () => { assert.ok(roadmap.includes('1/1 plans executed'), 'numbers and checkboxes agree'); }); }); + +// ─── #4786: suffix-less hand-written plan lists tick in place, never duplicated ── + +describe('#4786: suffix-less hand-written plan lists are recognized, not duplicated', () => { + let tmpDir; + let roadmapPath; + + beforeEach(() => { + tmpDir = createTempProject('gsd-4786-'); + roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + function writtenAfter() { + return fs.readFileSync(roadmapPath, 'utf-8'); + } + + test('#4786: a suffix-less hand-written plan list is ticked in place, never duplicated', () => { + // The issue's measured shape: a hand-written list WITHOUT the -PLAN.md + // suffix, carrying em-dash descriptions. The old detection keyed on the + // full plan filename, counted every plan "missing", and inserted a + // canonical list above the hand-written one (32 checkbox lines for 16 + // plans, exit 0). + fs.writeFileSync(roadmapPath, [ + '# ROADMAP', + '', + '### Phase 5: test phase', + '', + '**Plans:** 2/3 plans executed', + '', + 'Plans:', + '', + '- [x] 5-01 — first: does the thing with a long hand-written description', + '- [x] 5-02 — second: does the other thing through submit(true)', + '- [ ] 5-03 — union run, redeploy and demo rebuild, live measurement', + '', + ].join('\n')); + createPhaseWithPlans(tmpDir, '5', [ + '5-01-PLAN.md', + '5-02-PLAN.md', + '5-03-PLAN.md', + ]); + // All three plans have summaries (the issue's shape: the call lands the + // last summary, 15/16 → 16/16). #4741: only plans the count counts are + // tickable — a summary-less row is correctly left alone. + for (const n of ['5-01', '5-02', '5-03']) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '05-test-phase', `${n}-SUMMARY.md`), '# Summary\n'); + } + + const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const parsed = JSON.parse(result.output); + assert.equal(parsed.updated, true, 'the unchecked plan must be ticked'); + assert.ok( + writtenAfter(result).includes('**Plans:** 3/3 plans executed'), + 'the count line must update to 3/3', + ); + + const written = fs.readFileSync(roadmapPath, 'utf-8'); + // The damage was the INSERTION — none may appear. + assert.ok(!written.includes('5-01-PLAN.md'), 'no canonical row may be inserted beside a recognized suffix-less list'); + assert.ok(!written.includes('5-02-PLAN.md'), 'no canonical row may be inserted beside a recognized suffix-less list'); + assert.ok(!written.includes('5-03-PLAN.md'), 'no canonical row may be inserted beside a recognized suffix-less list'); + // The tick happened IN PLACE, with the hand-written description byte-identical. + assert.ok( + written.includes('- [x] 5-03 — union run, redeploy and demo rebuild, live measurement'), + `the unchecked suffix-less row must be ticked in place with its description intact; ROADMAP:\n${written}`, + ); + assert.ok( + written.includes('- [x] 5-01 — first: does the thing with a long hand-written description'), + 'already-ticked rows must be byte-identical', + ); + assert.equal( + (written.match(/- \[.\] 5-0/g) || []).length, + 3, + `exactly three checkbox rows must remain (no duplication); ROADMAP:\n${written}`, + ); + }); + + test('#4786: a stem does not match a longer plan id', () => { + // Boundary: the new stem recognition must not let `- [x] 5-011` satisfy + // plan 5-01 (5-011 is a different plan). 5-01 carries no recognizable row, + // so the #1163 fresh-template insertion still fires for it — that is the + // documented contract; the pin is that 5-011 is not treated as 5-01. + fs.writeFileSync(roadmapPath, [ + '# ROADMAP', + '', + '### Phase 5: test phase', + '', + 'Plans:', + '', + '- [x] 5-011 — bogus longer id, not plan 5-01', + '', + ].join('\n')); + createPhaseWithPlans(tmpDir, '5', [ + '5-01-PLAN.md', + '5-02-PLAN.md', + '5-03-PLAN.md', + ]); + + const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const written = fs.readFileSync(roadmapPath, 'utf-8'); + assert.ok(written.includes('- [ ] 5-01-PLAN.md'), '5-01 must still count as missing (5-011 is a different plan)'); + assert.ok(written.includes('- [ ] 5-02-PLAN.md') && written.includes('- [ ] 5-03-PLAN.md'), + 'all genuinely absent plans still insert'); + assert.equal( + (written.match(/- \[.\] 5-0/g) || []).length, + 4, + `3 inserted rows + the hand-written 5-011 row; ROADMAP:\n${written}`, + ); + }); +});