Files
msd-core/get-shit-done/bin/lib/plan-scan.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

139 lines
4.2 KiB
JavaScript

'use strict';
/**
* plan-scan — canonical phase-plan scanner (k014)
*
* Single source of truth for detecting plan and summary files in a phase
* directory, replacing four divergent copies in state.cjs, roadmap.cjs,
* init.cjs, and phase.cjs (#3262).
*
* Layout support:
* Flat (pre-#3139): phases/<N>/*-PLAN.md, *-SUMMARY.md
* Nested (post-#3139): phases/<N>/plans/PLAN-<NN>-*.md, SUMMARY-<NN>-*.md
*
* @module plan-scan
*/
const fs = require('fs');
const path = require('path');
// Excluded derivative files — present alongside real plans but must not be
// counted. OUTLINE exclusion catches both flat (-PLAN-OUTLINE.md) and nested
// (PLAN-NN-OUTLINE.md) forms via a broad -OUTLINE.md$ pattern. The
// pre-bounce pattern is intentionally broad (matches any *.pre-bounce.md) so
// stale bounce files never inflate plan counts (#3257 regression root cause).
const PLAN_OUTLINE_RE = /-OUTLINE\.md$/i;
const PLAN_PRE_BOUNCE_RE = /\.pre-bounce\.md$/i;
/**
* Determine whether a filename from the flat phase root is a plan file.
*
* Accepts:
* - Bare PLAN.md
* - Canonical padded 01-01-PLAN.md
* - Extended layout 5-PLAN-01-setup.md (the format gsd-plan-phase writes;
* looksLikePlanFile in phase.cjs / isPlanFile in roadmap.cjs)
*
* Rejects: -PLAN-OUTLINE.md, *.pre-bounce.md
*/
function isRootPlanFile(f) {
if (PLAN_OUTLINE_RE.test(f)) return false;
if (PLAN_PRE_BOUNCE_RE.test(f)) return false;
// Canonical suffix or bare name
if (f.endsWith('-PLAN.md') || f === 'PLAN.md') return true;
// Extended layout: any .md that contains PLAN (case-insensitive) in the name
return /\.md$/i.test(f) && /PLAN/i.test(f);
}
/**
* Determine whether a filename from the nested plans/ subdir is a plan file.
*
* Nested layout names: PLAN-NN-slug.md or N-PLAN-NN-slug.md.
* Excludes OUTLINE and pre-bounce suffixes.
*/
function isNestedPlanFile(f) {
if (PLAN_OUTLINE_RE.test(f)) return false;
if (PLAN_PRE_BOUNCE_RE.test(f)) return false;
return /^PLAN-\d+.*\.md$/i.test(f) || /-PLAN-\d+.*\.md$/i.test(f);
}
/**
* Determine whether a filename from the flat phase root is a summary file.
*/
function isRootSummaryFile(f) {
return f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md';
}
/**
* Determine whether a filename from the nested plans/ subdir is a summary.
*/
function isNestedSummaryFile(f) {
return /^SUMMARY-\d+.*\.md$/i.test(f) || /-SUMMARY-\d+.*\.md$/i.test(f);
}
/**
* Scan a single phase directory for plan and summary files.
*
* @param {string} phaseDir — absolute path to the phase directory
* @returns {{
* planCount: number,
* summaryCount: number,
* completed: boolean,
* hasNestedPlans: boolean,
* planFiles: string[],
* summaryFiles: string[],
* }}
*/
function scanPhasePlans(phaseDir) {
let rootFiles;
try {
rootFiles = fs.readdirSync(phaseDir);
} catch {
return {
planCount: 0,
summaryCount: 0,
completed: false,
hasNestedPlans: false,
planFiles: [],
summaryFiles: [],
};
}
const rootPlanFiles = rootFiles.filter(isRootPlanFile);
const rootSummaryFiles = rootFiles.filter(isRootSummaryFile);
let nestedPlanFiles = [];
let nestedSummaryFiles = [];
let hasNestedPlans = false;
const nestedDir = path.join(phaseDir, 'plans');
if (fs.existsSync(nestedDir)) {
try {
const nested = fs.readdirSync(nestedDir);
nestedPlanFiles = nested.filter(isNestedPlanFile);
nestedSummaryFiles = nested.filter(isNestedSummaryFile);
hasNestedPlans = nestedPlanFiles.length > 0;
} catch { /* ignore if plans/ is not a readable directory */ }
}
const planFiles = rootPlanFiles.concat(nestedPlanFiles);
const summaryFiles = rootSummaryFiles.concat(nestedSummaryFiles);
const planCount = planFiles.length;
const summaryCount = summaryFiles.length;
return {
planCount,
summaryCount,
completed: planCount > 0 && summaryCount >= planCount,
hasNestedPlans,
planFiles,
summaryFiles,
};
}
module.exports = scanPhasePlans;
module.exports.scanPhasePlans = scanPhasePlans;
module.exports.isRootPlanFile = isRootPlanFile;
module.exports.isNestedPlanFile = isNestedPlanFile;
module.exports.isRootSummaryFile = isRootSummaryFile;
module.exports.isNestedSummaryFile = isNestedSummaryFile;