* 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>
139 lines
4.2 KiB
JavaScript
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;
|