fix(#4786): plan-row detection accepts the bare planId stem — suffix-less hand-written lists tick in place (#4880)

* 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 <sim@local>
This commit is contained in:
Tom Boucher
2026-09-19 21:06:05 -04:00
committed by GitHub
parent 6dcc0428dd
commit 5906a24ede
3 changed files with 142 additions and 1 deletions

View File

@@ -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)

View File

@@ -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 <details> 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) {

View File

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