Files
msd-core/src/plan-scan.cts
Tom Boucher ed31e52b67 fix(#1988): exclude stray non-plan *-SUMMARY.md from phase completion count (#2016)
* fix(#1988): exclude stray non-plan *-SUMMARY.md from phase completion count

Stray remediation/gap-closure summaries (30-FIX-CR02-SUMMARY.md,
30-GAPCLOSURE-SUMMARY.md, …) inflated summary_count, and once
summary_count >= plan_count the phase silently flipped to Complete even
though several plans had no summary. A summary now counts toward completion
only if it pairs with a real plan file.

- core-utils.cts: new countMatchedSummaries(planFiles, summaryFiles) —
  layout-agnostic pairing via the PLAN→SUMMARY marker swap (root/nested/bare)
  plus the <stem>-SUMMARY.md form (bare PLAN.md↔PLAN-SUMMARY.md); the swap is
  applied to the basename only so a 'plans/' dir prefix isn't corrupted.
- plan-scan.cts: scanPhasePlans.summaryCount/.completed use the matched count
  (summaryFiles array still holds every summary on disk for listing/reading).
  Fixes roadmap listing, state sync, verification, workstream inventory.
- roadmap.cts: cmdRoadmapUpdatePlanProgress uses the matched count.
- tests/roadmap.test.cjs: countMatchedSummaries unit tests (root/nested/bare/
  stray) + E2E reproducing the exact #1988 report (4 plans, 1 plan summary,
  3 strays → 1/4 In Progress, NOT Complete).

Closes #1988

* docs(#1988): backfill changeset pr 2016

* test(#1988): strengthen countMatchedSummaries unit tests for mutation coverage

Add direct unit tests for the extended (N-PLAN-MM-slug↔N-MM-SUMMARY), bare
(PLAN↔SUMMARY, PLAN↔PLAN-SUMMARY), legacy (N-PLAN-NN↔N-PLAN-NN-SUMMARY), and
stray-exclusion pairings so every branch of countMatchedSummaries is exercised
(Stryker mutation-score coverage).

* test(#1988): move countMatchedSummaries unit tests into core-utils.test.cjs

The Stryker core-utils shard runs ONLY tests/core-utils.test.cjs (per
scripts/mutation-matrix.cjs), so the unit tests for countMatchedSummaries
must live there to be mutation-covered (previously in roadmap.test.cjs, the
shard never ran them → mutants survived → Stryker gate failed). The E2E
#1988 reproduction stays in roadmap.test.cjs. Added an absolute-path case to
guard the lastIndexOf('/') >= 0 boundary.
2026-07-05 14:03:29 -04:00

115 lines
4.0 KiB
TypeScript

/**
* Plan Scan Module — detects plan and summary files in a phase directory.
* Supports both flat (pre-#3139) and nested (post-#3139) layouts.
*
* ADR-457 build-at-publish: the hand-written bin/lib/plan-scan.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only types are added.
*/
import { existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import coreUtils = require('./core-utils.cjs');
const { countMatchedSummaries } = coreUtils;
// Excluded derivative files
const PLAN_OUTLINE_RE = /-OUTLINE\.md$/i;
const PLAN_PRE_BOUNCE_RE = /\.pre-bounce\.md$/i;
function isRootPlanFile(fileName: string): boolean {
if (PLAN_OUTLINE_RE.test(fileName)) return false;
if (PLAN_PRE_BOUNCE_RE.test(fileName)) return false;
if (fileName.endsWith('-PLAN.md') || fileName === 'PLAN.md') return true;
// A summary is never a plan. Reject summaries before the loose /PLAN/i
// fallback so legacy `<N>-PLAN-<NN>-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);
}
function isNestedPlanFile(fileName: string): boolean {
if (PLAN_OUTLINE_RE.test(fileName)) return false;
if (PLAN_PRE_BOUNCE_RE.test(fileName)) return false;
return /^PLAN-\d+.*\.md$/i.test(fileName) || /-PLAN-\d+.*\.md$/i.test(fileName);
}
function isRootSummaryFile(fileName: string): boolean {
return fileName.endsWith('-SUMMARY.md') || fileName === 'SUMMARY.md';
}
function isNestedSummaryFile(fileName: string): boolean {
return /^SUMMARY-\d+.*\.md$/i.test(fileName) || /-SUMMARY-\d+.*\.md$/i.test(fileName);
}
interface PhaseScanResult {
planCount: number;
summaryCount: number;
completed: boolean;
hasNestedPlans: boolean;
planFiles: string[];
summaryFiles: string[];
}
function scanPhasePlans(phaseDir: string): PhaseScanResult {
let rootFiles: string[];
try {
rootFiles = 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: string[] = [];
let nestedSummaryFiles: string[] = [];
let hasNestedPlans = false;
const nestedDir = join(phaseDir, 'plans');
if (existsSync(nestedDir)) {
try {
const nestedFiles = readdirSync(nestedDir);
nestedPlanFiles = nestedFiles.filter(isNestedPlanFile).map((file) => `plans/${file}`);
nestedSummaryFiles = nestedFiles.filter(isNestedSummaryFile).map((file) => `plans/${file}`);
hasNestedPlans = nestedPlanFiles.length > 0;
} catch { /* ignore unreadable nested layout */ }
}
const planFiles = rootPlanFiles.concat(nestedPlanFiles);
const summaryFiles = rootSummaryFiles.concat(nestedSummaryFiles);
const planCount = planFiles.length;
// Count only summaries that are the PLAN→SUMMARY partner of an existing plan
// (#1988): stray non-plan summaries (e.g. 30-FIX-CR02-SUMMARY.md,
// 30-GAPCLOSURE-SUMMARY.md) must not inflate summary_count or flip a phase to
// Complete when plans are still missing summaries. summaryFiles (the array)
// still holds every summary on disk for callers that read/list them.
const summaryCount = countMatchedSummaries(planFiles, summaryFiles);
return {
planCount,
summaryCount,
completed: planCount > 0 && summaryCount >= planCount,
hasNestedPlans,
planFiles,
summaryFiles,
};
}
// CJS callers do: const scanPhasePlans = require('./plan-scan.cjs')
// and also destructure named exports — support both call styles.
// Using export = with extra properties attached.
export = Object.assign(scanPhasePlans, {
scanPhasePlans,
isRootPlanFile,
isNestedPlanFile,
isRootSummaryFile,
isNestedSummaryFile,
});