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

@@ -529,8 +529,72 @@ describe('extractCanonicalPlanId', () => {
// extractCanonicalPlanId operates on a filename string (not a real path).
// The function does not sanitize slashes — it strips .md suffixes and
// attempts to find phase tokens. The result is a string (no crash).
const result = coreUtils.extractCanonicalPlanId('../../../etc/passwd');
const result = coreUtils.extractCanonicalPlanId('../../../etc/passwd');
assert.ok(typeof result === 'string');
assert.ok(result.length > 0);
});
});
// ─── countMatchedSummaries (#1988) ───────────────────────────────────────────
// Lives in core-utils.test.cjs (not roadmap.test.cjs) so the Stryker core-utils
// shard — which runs only this file — actually covers the helper's mutants.
describe('countMatchedSummaries — stray non-plan summaries excluded (#1988)', () => {
const { countMatchedSummaries } = coreUtils;
test('counts only summaries that are the PLAN→SUMMARY partner of a plan', () => {
const plans = ['30-01-PLAN.md', '30-02-PLAN.md', '30-10-PLAN.md'];
const summaries = ['30-01-SUMMARY.md', '30-FIX-CR02-SUMMARY.md', '30-GAPCLOSURE-SUMMARY.md'];
assert.strictEqual(countMatchedSummaries(plans, summaries), 1);
});
test('all plans have a partner summary → counts every plan', () => {
const plans = ['01-01-PLAN.md', '01-02-PLAN.md'];
const summaries = ['01-01-SUMMARY.md', '01-02-SUMMARY.md'];
assert.strictEqual(countMatchedSummaries(plans, summaries), 2);
});
test('nested layout pairing preserved (plans/PLAN-NN ↔ plans/SUMMARY-NN)', () => {
const plans = ['plans/PLAN-01.md', 'plans/PLAN-02.md'];
const summaries = ['plans/SUMMARY-01.md'];
assert.strictEqual(countMatchedSummaries(plans, summaries), 1);
});
test('extended layout pairing (N-PLAN-MM-slug ↔ N-MM-SUMMARY)', () => {
const plans = ['3-PLAN-01-setup.md', '5-PLAN-02-migrations.md'];
const summaries = ['3-01-SUMMARY.md', '5-02-SUMMARY.md'];
assert.strictEqual(countMatchedSummaries(plans, summaries), 2);
});
test('extended layout: only the matching plan counts (no cross-pairing)', () => {
const plans = ['3-PLAN-01-setup.md', '3-PLAN-02-seed.md'];
const summaries = ['3-01-SUMMARY.md']; // only plan 01 has a summary
assert.strictEqual(countMatchedSummaries(plans, summaries), 1);
});
test('bare PLAN.md ↔ SUMMARY.md', () => {
assert.strictEqual(countMatchedSummaries(['PLAN.md'], ['SUMMARY.md']), 1);
assert.strictEqual(countMatchedSummaries(['PLAN.md'], []), 0);
});
test('bare PLAN.md ↔ PLAN-SUMMARY.md (stem-suffix convention)', () => {
assert.strictEqual(countMatchedSummaries(['PLAN.md'], ['PLAN-SUMMARY.md']), 1);
});
test('legacy <N>-PLAN-<NN> ↔ <N>-PLAN-<NN>-SUMMARY', () => {
const plans = ['14-PLAN-01.md', '14-PLAN-02.md'];
const summaries = ['14-PLAN-01-SUMMARY.md', '14-PLAN-02-SUMMARY.md'];
assert.strictEqual(countMatchedSummaries(plans, summaries), 2);
});
test('stray summaries never count when no plan partners exist', () => {
const plans = ['30-01-PLAN.md'];
const strays = ['30-FIX-CR02-SUMMARY.md', '30-GAPCLOSURE-SUMMARY.md', '30-01-EXTRA-SUMMARY.md'];
assert.strictEqual(countMatchedSummaries(plans, strays), 0);
});
test('absolute path (leading slash) still pairs via the dir split', () => {
// Guards the lastIndexOf('/') >= 0 boundary (slash at index 0).
assert.strictEqual(countMatchedSummaries(['/abs/PLAN-01.md'], ['/abs/SUMMARY-01.md']), 1);
});
});