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.
This commit is contained in:
Tom Boucher
2026-07-05 14:03:29 -04:00
committed by GitHub
parent 77aa85007a
commit ed31e52b67
6 changed files with 175 additions and 3 deletions

View File

@@ -186,6 +186,45 @@ function extractCanonicalPlanId(filename: string): string {
return base;
}
/**
* Count summaries that correspond to a real plan (#1988).
*
* A summary counts toward phase completion iff it pairs with an existing plan
* file. This excludes stray non-plan summaries — e.g. `30-FIX-CR02-SUMMARY.md`,
* `30-GAPCLOSURE-SUMMARY.md` — that inflate the raw `*-SUMMARY.md` count and
* silently flip a phase to Complete when plans are actually missing summaries.
*
* Pairing is layout-agnostic. For each plan, up to three candidate summary
* filenames are generated and any match suffices:
* 1. marker swap `PLAN`→`SUMMARY` on the basename — root padded
* (`30-01-PLAN.md`↔`30-01-SUMMARY.md`), nested (`PLAN-01.md`↔
* `SUMMARY-01.md`, incl. a `plans/` prefix), and bare (`PLAN.md`↔
* `SUMMARY.md`);
* 2. `<stem>-SUMMARY.md` — bare (`PLAN.md`↔`PLAN-SUMMARY.md`) and legacy
* (`14-PLAN-01.md`↔`14-PLAN-01-SUMMARY.md`);
* 3. extended `<n>-PLAN-<m>…`→`<n>-<m>-SUMMARY.md`
* (`3-PLAN-01-setup.md`↔`3-01-SUMMARY.md`).
* The swap is applied to the basename only so a lowercase `plans/` dir prefix
* isn't corrupted to `SUMMARYs/…`.
*/
function countMatchedSummaries(planFiles: string[], summaryFiles: string[]): number {
const summarySet = new Set(summaryFiles);
let matched = 0;
for (const plan of planFiles) {
const slashIdx = plan.lastIndexOf('/');
const dir = slashIdx >= 0 ? plan.slice(0, slashIdx + 1) : '';
const base = (dir ? plan.slice(dir.length) : plan).replace(/\.md$/i, '');
const candidates: string[] = [
dir + base.replace(/PLAN/i, 'SUMMARY') + '.md',
dir + base + '-SUMMARY.md',
];
const extended = base.match(/^(\d+)-PLAN-(\d+)/i);
if (extended) candidates.push(dir + extended[1] + '-' + extended[2] + '-SUMMARY.md');
if (candidates.some((c) => summarySet.has(c))) matched++;
}
return matched;
}
export = {
toPosixPath,
detectSubRepos,
@@ -198,4 +237,5 @@ export = {
readSubdirectories,
timeAgo,
extractCanonicalPlanId,
countMatchedSummaries,
};

View File

@@ -9,6 +9,9 @@
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;
@@ -82,7 +85,12 @@ function scanPhasePlans(phaseDir: string): PhaseScanResult {
const planFiles = rootPlanFiles.concat(nestedPlanFiles);
const summaryFiles = rootSummaryFiles.concat(nestedSummaryFiles);
const planCount = planFiles.length;
const summaryCount = summaryFiles.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,

View File

@@ -29,6 +29,9 @@ const { planningPaths, withPlanningLock, findContextMdIn } = planningWorkspace;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import scanPhasePlans = require('./plan-scan.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import coreUtils = require('./core-utils.cjs');
const { countMatchedSummaries } = coreUtils;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import frontmatter = require('./frontmatter.cjs');
const { extractFrontmatter, parseMustHavesBlock } = frontmatter;
@@ -491,7 +494,10 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
}
const planCount = phaseInfo!.plans.length;
const summaryCount = phaseInfo!.summaries.length;
// Count only summaries that pair with a real plan (#1988): stray non-plan
// summaries (30-FIX-CR02-SUMMARY.md, 30-GAPCLOSURE-SUMMARY.md, …) must not
// inflate summary_count and silently flip the phase to Complete.
const summaryCount = countMatchedSummaries(phaseInfo!.plans, phaseInfo!.summaries);
if (planCount === 0) {
output({ updated: false, reason: 'No plans found', plan_count: 0, summary_count: 0 }, raw, 'no plans');