From a76ce94d183f3fa152117f86b64731f70bb2bad0 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 27 Aug 2026 14:44:26 -0400 Subject: [PATCH] fix(#3741): anchor the loose plan-scan fallback's PLAN token (#3950) * test(#3741): REPLAN/PLANNING substrings must not count as plans * fix(#3741): anchor the loose plan fallback's PLAN token * chore(#3741): changeset fragment (pr number backfilled after PR creation) * chore(#3741): backfill changeset PR number (3950) --------- Co-authored-by: sim --- .changeset/eager-cats-squeak.md | 5 +++ src/plan-scan.cts | 15 ++++++-- tests/plan-count-single-owner.test.cjs | 50 ++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 .changeset/eager-cats-squeak.md diff --git a/.changeset/eager-cats-squeak.md b/.changeset/eager-cats-squeak.md new file mode 100644 index 000000000..953239c79 --- /dev/null +++ b/.changeset/eager-cats-squeak.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3950 +--- +**`total_plans` no longer counts REPLAN/PLANNING documents as plans** — a phase directory carrying a `REPLAN-INPUTS` or `PLANNING-NOTES` document no longer inflates the plan count that STATE.md derives on every state-mutating call. (#3741) diff --git a/src/plan-scan.cts b/src/plan-scan.cts index a97259b4e..332faab4f 100644 --- a/src/plan-scan.cts +++ b/src/plan-scan.cts @@ -83,7 +83,18 @@ function isRootPlanFile(fileName: string): boolean { // fallback so legacy `-PLAN--SUMMARY.md` names (which contain the // substring "PLAN") are not double-counted as plans. (#500 RC2) if (isRootSummaryFile(fileName)) return false; - return /\.md$/i.test(fileName) && /PLAN/i.test(fileName); + // #3741: the PLAN token must be DELIMITED — anchored at the start or after + // a hyphen, and followed only by an optional `-…` suffix before + // `.md` (the `…` keeps the legacy slug form `3-PLAN-01-setup.md` that + // gsd-plan-phase writes, per #3128). A bare substring test counted + // REPLAN-INPUTS / PLANNING-INPUTS / PLANNING-NOTES as plans, inflating + // planCount and STATE.md's derived total_plans. Delimited keeps the + // fallback's deliberate permissiveness for legacy single-token names + // (`plan.md`, `Plan.md`, `01-PLAN-02.md`, `3-PLAN-01-setup.md`) while + // excluding any name where PLAN is merely embedded in a larger word + // (REPLAN, PLANNING) — the same anchoring discipline isNestedPlanFile + // already applies. + return /(^|-)PLAN(-\d+.*)?\.md$/i.test(fileName); } function isNestedPlanFile(fileName: string): boolean { @@ -105,7 +116,7 @@ function isNestedSummaryFile(fileName: string): boolean { * `allPlanFiles` ENTRY (root form bare, nested form `plans/`-prefixed, exactly * as those arrays store them) — root `--PLAN.md`/bare `PLAN.md`, * or nested `plans/PLAN-....md`/`plans/-PLAN-....md` — WITHOUT - * `isRootPlanFile`'s loose `/\.md$/i && /PLAN/i` fallback. + * `isRootPlanFile`'s loose delimited-PLAN fallback. * * The `plans/` prefix check is load-bearing, not cosmetic: `isNestedPlanFile` * matches ANY basename containing `-PLAN-...md` with no anchor diff --git a/tests/plan-count-single-owner.test.cjs b/tests/plan-count-single-owner.test.cjs index 277ae24f7..2b8adb09c 100644 --- a/tests/plan-count-single-owner.test.cjs +++ b/tests/plan-count-single-owner.test.cjs @@ -380,6 +380,56 @@ describe('scope field — UNREADABLE / TRUNCATED / COMPLETE independence', () => // the same fallback) — these tests pin the current behavior at both // altitudes (scanPhasePlans and getPhaseFileStats) so a future change to // either rule is caught rather than silently drifting. +// ─── #3741: the loose fallback's PLAN token must be delimited ───────────── +// The legacy fallback was a bare substring test, so any *.md basename +// containing "PLAN" counted: REPLAN-INPUTS, PLANNING-INPUTS, hand-authored +// PLANNING-NOTES — inflating planCount and STATE.md's derived total_plans. +describe('#3741: the loose /PLAN/i fallback counts only a delimited PLAN token', () => { + test('REPLAN-INPUTS is not a plan — substring PLAN inside REPLAN must not count', (t) => { + const dir = createTempDir('gsd-3741-replan-'); + t.after(() => cleanup(dir)); + writeFile(dir, '01-01-PLAN.md', planBody()); + writeFile(dir, '01-01-REPLAN-INPUTS.md', 'Not a plan. Planning inputs document.\n'); + const scan = planScan(dir); + assert.strictEqual(scan.planCount, 1, '#3741: exactly the canonical plan counts'); + assert.ok(!scan.planFiles.includes('01-01-REPLAN-INPUTS.md')); + }); + + test('PLANNING-* and *-NOTES documents are not plans', (t) => { + const dir = createTempDir('gsd-3741-notes-'); + t.after(() => cleanup(dir)); + writeFile(dir, '01-01-PLAN.md', planBody()); + writeFile(dir, '01-PLANNING-INPUTS.md', 'inputs\n'); + writeFile(dir, 'PLANNING-NOTES.md', 'notes\n'); + writeFile(dir, 'REPLAN-NOTES.md', 'notes\n'); + const scan = planScan(dir); + assert.strictEqual(scan.planCount, 1); + assert.ok(!scan.planFiles.includes('01-PLANNING-INPUTS.md')); + assert.ok(!scan.planFiles.includes('PLANNING-NOTES.md')); + assert.ok(!scan.planFiles.includes('REPLAN-NOTES.md')); + }); + + test('legacy PLAN-token+numeric names still count via the anchored fallback', (t) => { + const dir = createTempDir('gsd-3741-legacy-'); + t.after(() => cleanup(dir)); + writeFile(dir, '01-PLAN-02.md', planBody()); + const scan = planScan(dir); + assert.strictEqual(scan.planCount, 1); + assert.ok(scan.planFiles.includes('01-PLAN-02.md')); + }); + + test('legacy PLAN-token + numeric + slug names still count (#3128 shape, #3741 review)', (t) => { + const dir = createTempDir('gsd-3741-slug-'); + t.after(() => cleanup(dir)); + // gsd-plan-phase writes this form; #3128 is the historical bug where + // dropping it made plan_count read 0. The anchor must keep it. + writeFile(dir, '3-PLAN-01-setup.md', planBody()); + const scan = planScan(dir); + assert.strictEqual(scan.planCount, 1); + assert.ok(scan.planFiles.includes('3-PLAN-01-setup.md')); + }); +}); + describe('case sensitivity: plan.md/Plan.md counted, summary.md/Summary.md NOT (#3183 asymmetry)', () => { test('lowercase plan.md is counted as a plan (loose /PLAN/i fallback is case-insensitive)', (t) => { const dir = createTempDir('gsd-plan-scan-case-');