Files
msd-core/tests/bug-3128-roadmap-plan-count-slug-layout.test.cjs
Tom Boucher 1a49d2fcfc feat(phase-plans): extract shared scanPhasePlans helper (k014) (#3308)
* test(phase-plans): red — shared scanPhasePlans contract + parity across call sites (#3262)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(phase-plans): extract shared scanPhasePlans helper (k014) (#3262)

Eliminates four divergent copies of the plan-scan algorithm:
- roadmap.cjs:countPhasePlansAndSummaries (root call site)
- state.cjs:buildStateFrontmatter (1 of 3)
- state.cjs:cmdStateValidate (2 of 3)
- state.cjs:cmdStateSync (3 of 3)
- init.cjs:listPhasePlanFiles / listPhaseSummaryFiles

New bin/lib/plan-scan.cjs exports scanPhasePlans(phaseDir) → {
  planCount, summaryCount, completed, hasNestedPlans,
  planFiles, summaryFiles
}

Divergences resolved:
- roadmap.cjs used a broad isPlanFile (any .md containing PLAN in name,
  matching the extended layout 5-PLAN-01-setup.md); canonical helper
  adopts this wider pattern as the reference implementation.
- state.cjs used a strict endsWith(-PLAN.md) filter, missing extended-
  layout root files; now unified with roadmap.cjs semantics.
- init.cjs listPhasePlanFiles used ^PLAN-\d+ for nested, missing
  the -PLAN-\d+ variant state.cjs also matched; helper includes both.
- pre-bounce exclusion broadened to /.pre-bounce.md$/i (any pre-bounce
  file), not just -PLAN.*\.pre-bounce\.md (roadmap form) or flat
  .pre-bounce.md (state form).
- OUTLINE exclusion broadened to /-OUTLINE\.md$/i to catch both
  flat (-PLAN-OUTLINE.md) and nested (PLAN-01-OUTLINE.md) forms.

Sibling audit: no 5th call site found. phase.cjs:looksLikePlanFile is
a diagnostic probe for non-canonical naming (not a counter) — left
in place per its distinct purpose.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(changelog): add entry for #3262 scanPhasePlans extraction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(changeset): add changeset fragment for #3262

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(inventory): add plan-scan.cjs row to INVENTORY.md CLI Modules table (#3262)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(3262): update bug-3128 test + INVENTORY counts for plan-scan.cjs

- Update tests/bug-3128-roadmap-plan-count-slug-layout.test.cjs to verify
  that roadmap.cjs delegates to plan-scan.cjs (require check) and that the
  extended filter lives in plan-scan.cjs as isRootPlanFile with /PLAN/i
- Bump docs/INVENTORY.md CLI Modules headline from 46 to 47 (plan-scan.cjs)
- Regenerate docs/INVENTORY-MANIFEST.json to include cli_modules/plan-scan.cjs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(3262): migrate two missed call sites to scanPhasePlans (k014)

- init.cjs cmdInitExecutePhase: replace inline /-PLAN\.md$/i filter
  with listPhasePlanFiles(path) to honour nested, extended-layout, OUTLINE
  and pre-bounce exclusions (CR finding)
- state.cjs cmdStateUpdateProgress: replace dual /-PLAN\.md$/i and
  /-SUMMARY\.md$/i filters with scanPhasePlans() so the progress-bar
  body field uses the same counts as buildStateFrontmatter frontmatter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(3262): correct INVENTORY-MANIFEST.json to tracked files only

Remove 3 untracked local entries from cli_modules so the manifest matches
what CI sees (47 tracked .cjs files, not 50 local). Previous regeneration
ran against the local filesystem which included cjs-command-router-adapter.cjs,
state-document.cjs, and workstream-inventory.cjs (all untracked on this branch).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:39:31 -04:00

99 lines
4.9 KiB
JavaScript

'use strict';
// allow-test-rule: reads roadmap.cjs source to verify isPlanFile pattern was adopted — structural contract prevents silent regression to old filter
// Regression guard for bug #3128.
//
// roadmap.cjs countPhasePlansAndSummaries() used to filter plan files with:
// f.endsWith('-PLAN.md') || f === 'PLAN.md'
// This misses the {N}-PLAN-{NN}-{slug}.md layout that gsd-plan-phase
// actually writes (e.g. 5-PLAN-01-setup-database.md), ending in -database.md.
// Result: init manager returned plan_count=0 and disk_status='discussed' for
// fully-planned phases, triggering unnecessary background planner agents.
//
// Root cause: same regex flaw as #2893 (fixed in phase.cjs via #2896), but
// the manager-dashboard path in roadmap.cjs was not updated alongside it.
//
// Fix: apply the same looksLikePlanFile logic from phase.cjs to roadmap.cjs.
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
// Require the module under test directly
const roadmapLib = path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'roadmap.cjs');
const planScanLib = path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'plan-scan.cjs');
// We test countPhasePlansAndSummaries indirectly via getManagerInfo since
// it is not exported. We build a real phaseDir on disk and call the full
// roadmap.cjs init manager path via its exported helper, or fall back to
// direct filesystem inspection of what the filter would produce.
// The simplest correct seam: inspect the source for the regex pattern and
// validate with a synthetic directory that the manager path returns correct counts.
// Build a temporary phase directory with the slug layout
function makeTempPhase(files) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3128-'));
for (const f of files) {
fs.writeFileSync(path.join(dir, f), `# ${f}\n`);
}
return dir;
}
// Import countPhasePlansAndSummaries by monkey-patching: we inline the
// fixed filter logic and verify it matches the file on disk.
// Since the function is module-private, we validate via its public caller
// by using the exported analyzeRoadmap / getPhaseInfo path with a
// synthetic .planning/ directory tree.
describe('bug #3128: roadmap.cjs plan-count for {N}-PLAN-{NN}-{slug}.md layout', () => {
test('isPlanFile rejects PLAN-OUTLINE and pre-bounce derivatives', () => {
// Inlined from fix — mirrors the exact logic in the fix
const PLAN_OUTLINE_RE = /-PLAN-OUTLINE\.md$/i;
const PLAN_PRE_BOUNCE_RE = /-PLAN.*\.pre-bounce\.md$/i;
const isPlanFile = (f) =>
(f.endsWith('-PLAN.md') || f === 'PLAN.md') ||
(/\.md$/i.test(f) && /PLAN/i.test(f) && !PLAN_OUTLINE_RE.test(f) && !PLAN_PRE_BOUNCE_RE.test(f));
// canonical forms — must match
assert.ok(isPlanFile('PLAN.md'), 'PLAN.md must match');
assert.ok(isPlanFile('5-PLAN.md'), '5-PLAN.md must match');
assert.ok(isPlanFile('05-PLAN.md'), '05-PLAN.md must match');
// slug form — was the bug; must now match
assert.ok(isPlanFile('5-PLAN-01-setup.md'), '5-PLAN-01-setup.md must match');
assert.ok(isPlanFile('05-PLAN-02-database.md'), '05-PLAN-02-database.md must match');
assert.ok(isPlanFile('5-PLAN-DELTA-2026-05-05.md'), '5-PLAN-DELTA-2026-05-05.md must match');
// derivative files — must NOT match
assert.ok(!isPlanFile('5-PLAN-OUTLINE.md'), 'PLAN-OUTLINE must not match');
assert.ok(!isPlanFile('5-PLAN-01.pre-bounce.md'), 'pre-bounce must not match');
assert.ok(!isPlanFile('CONTEXT.md'), 'CONTEXT.md must not match');
assert.ok(!isPlanFile('SUMMARY.md'), 'SUMMARY.md must not match');
assert.ok(!isPlanFile('5-RESEARCH.md'), 'RESEARCH.md must not match');
});
test('roadmap.cjs source uses the extended isPlanFile filter', () => {
const roadmapSrc = fs.readFileSync(roadmapLib, 'utf8');
// Verify the fix is in place: the old simple inline filter is gone from roadmap.cjs
assert.ok(
!roadmapSrc.includes("phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md')"),
'Old simple plan filter still present in roadmap.cjs — fix not applied',
);
// roadmap.cjs now delegates to plan-scan.cjs via require('./plan-scan.cjs')
assert.ok(
roadmapSrc.includes('plan-scan.cjs'),
'roadmap.cjs does not require plan-scan.cjs — delegation not applied',
);
// plan-scan.cjs is where the extended plan-file detection logic lives (isRootPlanFile)
const planScanSrc = fs.readFileSync(planScanLib, 'utf8');
assert.ok(
planScanSrc.includes('isRootPlanFile') && planScanSrc.includes('/PLAN/i'),
'isRootPlanFile with /PLAN/i not found in plan-scan.cjs — canonical helper missing extended filter',
);
});
});