fix(#2648): phase.complete refuses when non-retired plans lack summaries (fail-closed coverage gate) (#2953)

* test(#2648): failing-first — phase complete must refuse when plans lack summaries

Adds findUnsummarizedPlans to core-utils (mirrors countMatchedSummaries but
returns the unmatched plan files) and a PHASE_PLAN_COVERAGE_INCOMPLETE error
reason, plus a 3-case regression block in phase.test.cjs. The gate itself is
NOT yet wired into cmdPhaseComplete (reverted for the RED run), so the
'blocks completion' and 'superseded does not block' cases must FAIL (the
pre-fix code completes silently).

* fix(#2648): phase.complete refuses when non-retired plans lack summaries

cmdPhaseComplete gated only on a single *-VERIFICATION.md status, so a phase
could close complete while an arbitrary number of its plans had no completion
record (confirmed incident: 6/30 plans unexecuted incl. the phase's entire
final UI scope, every signal green). Add a fail-closed plan-coverage gate that
refuses completion when any plan lacks a matching *-SUMMARY.md, naming the
missing plans, UNLESS the plan is retired via machine-readable status:
superseded frontmatter (#2349) — closing the Goodhart hole (delete a SUMMARY to
raise the %) without regressing the lock/recovery pattern.

Uses scanPhasePlans (superseded-AWARE) + new findUnsummarizedPlans helper so the
gate, the count, and the named list can never disagree. Evaluated before the
verification-gate transaction so a refusal fails fast without mutating
ROADMAP/STATE. milestone.complete's parallel gap is explicitly out of scope
(separate seam, separate PR).

* fix(#2648): test fixtures — give #1752 phase plans summaries + STATE.md in coverage fixture

The plan-coverage gate (#2648) correctly blocks phase completion when a plan
lacks a SUMMARY. Two test fixtures needed updating to reflect the new contract:
- #1752 (total_phases-decrement cascade): its 8 phase dirs each had a PLAN.md
  with no SUMMARY. The test's concern is the total_phases cascade, not plan
  coverage, so add a matching SUMMARY to each to keep the phase fully-covered
  and isolate the #1752 behavior.
- the #2648 coverage-gate fixture: write STATE.md (createTempProject scaffolds
  .planning/phases but not STATE.md) so the 'ROADMAP/STATE unchanged on refusal'
  assertions have a file to read.

* fix(#2648): security — fail closed on unreadable plan dir + sanitize msg + surface superseded

Address the security-review blocker (B1) and hardening (M1/m2):
- B1 (blocker): the gate failed OPEN when scanPhasePlans could not read the
  phase dir (it swallows readdirSync errors → empty plan set → gate sees zero
  unsummarized plans → passes). A coverage gate that passes when it cannot read
  the plans re-opens the #2648 hole under any I/O failure. Now readdirSync the
  dir explicitly and fail closed (PHASE_PLAN_COVERAGE_INCOMPLETE) on a throw;
  a readable empty dir still passes (legitimately complete empty phase).
- m2: sanitize plan filenames (strip C0 controls / DEL) before interpolating
  into the error message — they come raw from readdirSync and could spoof the
  terminal in plain-error mode.
- M1: surface the count of plans excluded as status: superseded so a reviewer
  can audit which work was declared retired (the marker is a committable,
  review-time-trusted bypass; keep it visible).
- Add a 4th regression case: unreadable plan dir (ENOTDIR via a file, not chmod
  0o000 which root bypasses) must fail closed.

* test(#2648): drop unreachable B1 case — no root-safe unreadable-dir repro

The B1 fail-closed-on-unreadable-dir defense stays in src/phase.cts (cheap +
correct), but it cannot be unit-tested cross-platform: any condition that makes
the phase dir unreadable to the gate's readdirSync ALSO fails findPhaseInternal
upstream ('Phase N not found') before the gate runs, and chmod 0o000 is
forbidden (root bypasses it in root CI). Document the gap in the test file;
remove the case that asserted a reason the upstream error pre-empts.

* style(#2648): drop unnecessary type assertions flagged by lint:ci

scanPhasePlans returns typed string[] arrays, so the `as string[]` casts on
coverageScan.planFiles/summaryFiles were redundant (@typescript-eslint/
no-unnecessary-type-assertion). Compute supersededCount from typed lengths;
only the phaseInfo['plans'] cast remains (it is genuinely unknown).

* changeset(#2648): phase.complete refuses when plans lack summaries

* changeset(#2648): backfill PR number 2953

---------

Co-authored-by: sim <sim@users.noreply.github.com>
This commit is contained in:
Tom Boucher
2026-07-31 18:11:40 -04:00
committed by GitHub
parent 5a0a9f0972
commit 388837219d
5 changed files with 281 additions and 11 deletions

View File

@@ -277,20 +277,48 @@ function countMatchedSummaries(planFiles: string[], summaryFiles: string[]): num
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++;
if (summaryCandidates(plan).some((c) => summarySet.has(c))) matched++;
}
return matched;
}
/**
* The candidate `*-SUMMARY.md` filenames a single plan's completion record
* could take, per the three naming conventions documented above
* `countMatchedSummaries`. Extracted so `findUnsummarizedPlans` can reuse the
* exact same matching rule without duplicating it (a divergence between the
* count and the list would let a plan be counted as matched while still
* appearing in the unsummarized set, or vice versa).
*/
function summaryCandidates(plan: string): string[] {
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');
return candidates;
}
/**
* #2648: the plan files in `planFiles` that have NO matching completion record
* in `summaryFiles`, using the identical matching rule as `countMatchedSummaries`
* (so the count and the named list can never disagree). Callers that must NAME
* the missing plans — e.g. phase.complete's fail-closed coverage gate, which
* refuses completion when any non-retired plan lacks a SUMMARY — need the list,
* not just the count. `planFiles` is expected to be already superseded-filtered
* (the caller passes `scanPhasePlans(...).planFiles`, which drops
* `status: superseded` plans), so a deliberately-retired plan never appears
* here and never blocks completion.
*/
function findUnsummarizedPlans(planFiles: string[], summaryFiles: string[]): string[] {
const summarySet = new Set(summaryFiles);
return planFiles.filter((plan) => !summaryCandidates(plan).some((c) => summarySet.has(c)));
}
export = {
toPosixPath,
detectSubRepos,
@@ -305,4 +333,5 @@ export = {
timeAgo,
extractCanonicalPlanId,
countMatchedSummaries,
findUnsummarizedPlans,
};