fix(#3345): stop counting blocked summaries as completed plans (#3459)

* fix(#3345): stop counting blocked summaries as completed plans

* chore(#3345): set changeset pr reference

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-14 10:24:05 -04:00
committed by GitHub
parent 69e7afd0c7
commit da7d4dac52
6 changed files with 425 additions and 5 deletions

View File

@@ -79,7 +79,7 @@ import verifyMod = require('./verify.cjs');
const { readVerificationStatus } = verificationMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-dependency-graph.cjs is an export= CommonJS module
import planDependencyGraphMod = require('./plan-dependency-graph.cjs');
const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted } = planDependencyGraphMod;
const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted, isSummaryFileBlocked } = planDependencyGraphMod;
const { planningDir, withPlanningLock, listAvailableWorkstreams, getActiveWorkstream } =
planningWorkspace;
@@ -769,7 +769,20 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
// `plans/SUMMARY-01.md` correctly) instead of a bespoke ID-Set built from
// extractCanonicalPlanId, which only ever handled the root-canonical
// `-PLAN.md`/`-SUMMARY.md` naming form.
const unsummarizedPlanFiles = new Set(findUnsummarizedPlans(planFiles, summaryFiles));
//
// #3345: the summary list is filtered through the SAME shared predicate
// scanPhasePlans filters its countable set with
// (plan-dependency-graph.cjs's isSummaryFileBlocked), so a SUMMARY declaring
// `status: blocked` reads as NO completion record here — has_summary false,
// the plan lands in `incomplete` — exactly matching the count side. Fail-open
// on a SUMMARY with no status key / unreadable file (filename fallback);
// `status: halted` stays summarized (#2830 designed stop). summaryFileByPlanId
// below still indexes EVERY summary on disk because the halted lookup is a
// file resolution for reading status, not a completion pairing.
const countableSummaryFiles = summaryFiles.filter(
(f) => !isSummaryFileBlocked(path.join(phaseDir, f)),
);
const unsummarizedPlanFiles = new Set(findUnsummarizedPlans(planFiles, countableSummaryFiles));
// #2830: reverse lookup from a completed plan's id (exact or canonical) to
// the actual summary filename, so a plan's own SUMMARY frontmatter can be
// read for its `status`. Shared builder (also used by phase-locator.cts's

View File

@@ -115,6 +115,66 @@ function buildSummaryFileIndex(
return index;
}
/**
* #3345: the one place "does this SUMMARY status value mean blocked" is
* decided, sibling to `isHaltedStatus` above. A SUMMARY declaring
* `status: blocked` records a plan that could NOT finish — it is a failure
* record, not a completion record — so it must not count toward
* `completed_plans` (scanPhasePlans's summaryCount) nor read as
* `has_summary: true` in phase-plan-index's `incomplete` construction.
*
* `halted` is deliberately NOT matched here: a designed stop still writes a
* completion record (#2830's model — its dependents get `blocked_by` halt
* propagation), so a `status: halted` SUMMARY keeps counting as summarized.
* Case-insensitive, trims whitespace, and strips an unquoted trailing YAML
* comment exactly like `isHaltedStatus` (same #2830 review defect-2 rule).
*/
function isBlockedStatus(status: unknown): boolean {
if (typeof status !== 'string') return false;
const withoutTrailingComment = status.replace(/\s+#.*$/, '');
return withoutTrailingComment.trim().toLowerCase() === 'blocked';
}
// #3345: SUMMARY frontmatter sits at byte 0 and closes well before the body,
// so only a bounded prefix is ever needed to read the `status` marker — the
// same cap discipline as plan-scan.cts's PLAN_FRONTMATTER_READ_CAP (#2349),
// kept here because this predicate's primary caller (scanPhasePlans) loops
// over every phase directory on hot paths (state sync, roadmap progress).
const SUMMARY_FRONTMATTER_READ_CAP = 64 * 1024;
/**
* #3345: read a SUMMARY file's frontmatter `status` (bounded-prefix read) and
* report whether it declares `status: blocked`. Returns false — never throws —
* on a missing/unreadable/malformed/non-regular SUMMARY, so an unreadable file
* degrades to the pre-#3345 filename-existence behaviour rather than breaking
* either caller. Fail-open by design, mirroring `isPlanSuperseded`'s posture.
*
* Callers: plan-scan.cts's `scanPhasePlans` (the count side) and phase.cts's
* `cmdPhasePlanIndex` (the read side) BOTH filter their summary lists through
* this one predicate, so the count and the `incomplete` list can never
* re-diverge on the blocked rule.
*/
function isSummaryFileBlocked(summaryPath: string): boolean {
let content: string;
try {
const st = fs.statSync(summaryPath); // follows symlinks → resolves to the target's real type
if (!st.isFile()) return false;
const length = Math.min(st.size, SUMMARY_FRONTMATTER_READ_CAP);
if (length === 0) return false;
const fd = fs.openSync(summaryPath, 'r');
try {
const buf = Buffer.allocUnsafe(length);
const bytesRead = fs.readSync(fd, buf, 0, length, 0);
content = buf.toString('utf8', 0, bytesRead);
} finally {
fs.closeSync(fd);
}
} catch {
return false;
}
return isBlockedStatus(extractFrontmatter(content, summaryPath)['status']);
}
interface PlanHaltNode {
/** Canonical plan id, already resolved — matches another node's `id` for a dependency edge to count. */
id: string;
@@ -253,4 +313,13 @@ function computeHaltPropagation(nodes: PlanHaltNode[], precomputedOrder?: string
return { order, visited, blockedBy };
}
export = { computeHaltPropagation, isHaltedStatus, buildSummaryFileIndex, isSummaryFileHalted };
export = {
computeHaltPropagation,
isHaltedStatus,
buildSummaryFileIndex,
isSummaryFileHalted,
// #3345: blocked-SUMMARY detection, shared by the count side (scanPhasePlans)
// and the read side (phase-plan-index) so the two cannot diverge.
isBlockedStatus,
isSummaryFileBlocked,
};

View File

@@ -18,6 +18,9 @@ const { extractFrontmatter } = frontmatterMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningScopeMod = require('./planning-scope.cjs');
const { SCOPE } = planningScopeMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planDependencyGraphMod = require('./plan-dependency-graph.cjs');
const { isSummaryFileBlocked } = planDependencyGraphMod;
// Excluded derivative files
const PLAN_OUTLINE_RE = /-OUTLINE\.md$/i;
@@ -207,7 +210,20 @@ function scanPhasePlans(phaseDir: string): PhaseScanResult {
// 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);
//
// #3345: a SUMMARY whose frontmatter declares `status: blocked` is a failure
// record, not a completion record — it is dropped from the COUNTABLE pairing
// set before matching. The bounded-prefix status read is the SHARED predicate
// (plan-dependency-graph.cjs's isSummaryFileBlocked) that phase.cts's read
// path also filters through, so the count and the `incomplete` list cannot
// diverge. Fail-open: a SUMMARY with no `status` key, or one that cannot be read,
// keeps its pre-#3345 filename-existence meaning — untouched projects are
// byte-for-behaviour identical. `status: halted` stays counted (#2830: a
// designed stop still writes a completion record).
const countableSummaryFiles = summaryFiles.filter(
(f) => !isSummaryFileBlocked(join(phaseDir, f)),
);
const summaryCount = countMatchedSummaries(planFiles, countableSummaryFiles);
return {
planCount,