refactor(#3183): route live-plan counting through scanPhasePlans (#3199)

* refactor(#3183): route live-plan counting through scanPhasePlans

scanPhasePlans becomes the sole owner of the live-plan derivation. Twenty-one
independent re-derivations across seven modules now route through it, and
scripts/lint-plan-count-drift.cjs reports zero, scanning the whole repo rather
than an allowlist (ADR-3180 Decision 4a).

The epic scoped this at three copies. A whole-repo guard found twenty-six sites
across nine files, so Phase 1 absorbs every live-plan re-derivation and Phase 3
narrows to window plus sentinel enumeration.

Two sites are exempt with a documented reason rather than a bare allowlist:
audit.cts scans one quick task's own directory for a single completion record,
and gsd2-import.cts reads a foreign GSD-2 tasks/ layout during a one-time
import. Neither is a phase directory.

scanPhasePlans gains allPlanFiles (pre-supersession) alongside planFiles so one
owner answers both questions: verify.cts's numbering-gap check wants every plan
on disk, its pairing check wants the live set. Both fields are additive.

Highest-severity fix: cmdPhasePlanIndex, which feeds execute-phase wave
scheduling, was scheduling status:superseded plans into waves and reporting zero
plans for the post-#3139 nested layout.

filterPlanFiles and filterSummaryFiles are deleted; getPhaseFileStats orphaned
them and only their own tests still called them.

New leaf module src/planning-scope.cts carries the frozen SCOPE discriminator,
with its six-gate ripple closed: gitignore, inventory manifest, INVENTORY.md and
the CONTEXT.md glossary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma

* docs(#3183): amend ADR-3180 for the Phase 1/3 boundary re-slice

The contract held; the phase boundary did not. The whole-repo drift guard found
26 re-derivations across 9 files against the epic's estimate of 3, and
cmdProgressRender re-derives both enumeration and plan counting on adjacent
lines, so DW4 was unsatisfiable within Phase 1's original file scope.

Records the amended scope, scanPhasePlans's new allPlanFiles field,
findOrphanSummaries, the two documented exemptions, the re-derived Tier-2
table, and the describeNonCanonicalPlans trap for later phases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma

* fix(#3183): complete the canonical pairing rule and gate the naming diagnostic

The remote runner went red with 13 deterministic failures on both lanes,
and they were right: replacing verify.cts's canonicalPlanStem pairing with
summaryCandidates dropped a case the bespoke rule covered. A plan carrying a
descriptive slug after its id (68-01-scaffolding-PLAN.md) pairs with its
canonical-stem summary (68-01-SUMMARY.md), and summaryCandidates generated no
such candidate, so the plan read unsummarized.

The fix is to complete the one rule rather than restore a second:
summaryCandidates gains a canonical-id candidate, narrowed to fire only when an
id pair was actually extracted. countMatchedSummaries, findUnsummarizedPlans
and findOrphanSummaries all inherit it. The two-plans-one-summary collision
behaviour of the original rule is preserved deliberately and documented in
place.

Second defect, independently root-caused while verifying: routing the #2893
naming diagnostic through scanPhasePlans exposed it to the loose /PLAN/i
fallback, which is correct for counting and wrong for a naming check — a
non-canonically-named file was accepted as a valid plan and the diagnostic
went silent. cmdPhasesList, cmdFindPhase and cmdPhasePlanIndex now intersect
with a strict isCanonicalPlanFile predicate before reporting names.

Same class as the describeNonCanonicalPlans trap already recorded in ADR-3180:
a question about file naming wants the physical, strictly-matched set; only a
question about outstanding work wants the live set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma

* chore(#3183): register planning-scope.cjs in the eslint migration list

tests/repo-invariants.test.cjs asserts every bin/lib/*.cjs is linted xor
ignored per its ADR-457 migration state. The new planning-scope module closed
five of the six .cts ripple gates - gitignore, inventory manifest, INVENTORY.md
and the CONTEXT.md glossary - but not eslint, because that one is enforced by a
test rather than by lint:ci, so the local pipeline stayed green while it was
missing.

Generated from src/planning-scope.cts, so the .cjs is ignored and the .cts is
linted, matching every other migrated module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma

* fix(#3183): replace the plan-count drift detector with a literal tokenizer

CodeQL reported 4 high-severity js/redos alerts on REGEX_LITERAL_MD_RE, the
backtracking regex that finds "a regex literal mentioning PLAN/SUMMARY and an
escaped \.md". Five review rounds found it had two defects, not one:

  - EXPONENTIAL, then CUBIC. Its "any char" atom `(?:\\.|[^/\r\n])` let a `\.`
    pair be consumed either as one escape or as two class characters, which is
    exponential backtracking: 27,464ms on `"/\.mdplan" + "\.".repeat(28) + "X"`.
    Excluding `\` from the class killed that but left a cubic path — 23ms at
    N=200, 172ms at N=400, 1362ms at N=800 on `"/" + "PLAN\.md".repeat(N)` with
    no closing `/`. This guard is the last stage of `npm run lint:ci`, which CI
    runs on fork pull requests, so a crafted src/*.cts could stall the job.
  - A DETECTION HOLE. A character class holding a bare, unescaped `/` — e.g.
    `/SUMMARY[^/]*\.md$/`, an ordinary path-excluding filter — terminated the
    literal at that `/`, so the scan never reached `\.md` and the guard missed
    it entirely. (Classes holding an ESCAPED `\/` were already matched; the
    tests cover those separately as parity, not as regressions.)

Both defects have one root cause: regex-literal grammar — `\x` escapes, and
`/` inside `[...]` not terminating — is not expressible in a backtracking
regex. So the detector is now a tokenizer, not a regex.

readRegexLiteralAt reads the literal at a given `/` in a single left-to-right
pass with no backtracking, treating escapes as two-character units and
suppressing the `/` terminator inside a character class. findRegexLiteralMdMatch
restarts it at every `/` on the line, preserving the old "find anywhere"
behaviour; MAX_REGEX_LITERAL_LEN (400) bounds each read — including the
trailing-flag scan — which keeps the whole-line cost linear.

Results: cubic shape flat at 0.06-0.39ms out to N=3200 (25KB), exponential
shape 0.01ms at 28 reps and 0.00ms at 64, and the bare-`/` class shapes are now
caught. Differential against the old regex over 28,474 lines (those matching
FILENAME_TEST_RE but not PLAN_SUMMARY_LITERAL_RE, across src/tests/scripts/
gsd-core/bin/eslint-rules, excluding 265 lines with >6 backslashes on which the
old regex hangs): 6 differences, all the tokenizer returning the fuller or
newly-correct literal, 0 old-only misses. The `\.md` token stays
case-insensitive, matching the `/i` the old regex carried.

Also closes three holes in the same new file:

  - walk() tested entry.isFile(), false for a symlink, so a symlinked
    src/*.cts was silently unscanned — an evasion of a guard whose stated
    principle (ADR-3180 Decision 4a) is whole-repo discovery with no allowlist.
    It now resolves symlinks, but confined: file links must resolve inside the
    repo root, directory links inside the scanned dir itself. Every sibling
    drift guard in scripts/ uses the Dirent classification and never follows
    links, so following them unconfined would have made this the only linter
    able to read outside the tree — on fork PRs an arbitrary out-of-repo read
    whose matched fragments reach a public CI log. The narrower directory rule
    additionally stops `src/up -> ..` from sweeping the whole repo, and the
    skip list is now checked against resolved paths so `src/g -> ../.git`
    cannot reach .git/** or node_modules/**. Real paths are de-duplicated and
    files reported canonically, so a symlink alias cannot shift which
    FUNCTION_SCOPED_EXEMPTIONS key applies.
  - Both the reported fragment and the reported FILE PATH are attacker-
    controlled source text written straight to a CI log, and git permits
    control bytes in a filename. Both are now escaped — C0/C1/DEL plus the
    bidi and zero-width controls — so a crafted literal or filename cannot
    recolour the log, overwrite a line with CR, or fabricate a line that looks
    like this guard's own success output.

Regression coverage in tests/plan-count-single-owner.test.cjs: a child-process
probe over both pathological shapes (catastrophic backtracking is synchronous
and would freeze the suite rather than fail one test), the bare-`/` class
shapes verified to fail against the parent-commit blob, root-confinement tests
covering the outside-file, outside-directory, cycle, broken-link and duplicate
cases, direct isInsideRoot coverage including the sibling-prefix case that a
bare startsWith would let through, sanitizeForReport coverage, and
limit-1/limit/limit+1 coverage of MAX_REGEX_LITERAL_LEN derived from the
exported constant. The earlier structural assertion was dropped — it checked
for the substring `[^/`, which respelling the class as `[^\r\n/]` defeats
while staying exponential.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma

* chore(#3183): backfill changeset PR number

Restores b77931869, which a force-push during the ReDoS remediation dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-08-08 01:20:55 -04:00
committed by GitHub
parent 409da3223c
commit 343835facc
24 changed files with 2084 additions and 202 deletions

View File

@@ -11,7 +11,7 @@ import path from 'node:path';
import os from 'node:os';
import { phaseVariants, buildRoadmapPhaseVariants, buildNotStartedPhaseVariants } from './validate.cjs';
import { realClock } from './clock.cjs';
import { phaseDirNameRe, PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE, canonicalPlanStem, textEncodingError } from './validate.cjs';
import { phaseDirNameRe, PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE, textEncodingError } from './validate.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
import planningWorkspace = require('./planning-workspace.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
@@ -22,6 +22,12 @@ import stateMod = require('./state.cjs');
import modelProfilesMod = require('./model-profiles.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
import planScanMod = require('./plan-scan.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module
import coreUtilsMod = require('./core-utils.cjs');
const { findOrphanSummaries, findUnsummarizedPlans } = coreUtilsMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-scope.cjs is an export= CommonJS module
import planningScopeMod = require('./planning-scope.cjs');
const { SCOPE } = planningScopeMod;
import { execGit, platformReadSync as safeReadFile, platformWriteSync, posixNormalize } from './shell-command-projection.cjs';
import { PACKAGE_NAME } from './package-identity.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
@@ -940,26 +946,28 @@ function cmdVerifyPhaseCompleteness(cwd: string, phase: string, raw: boolean): v
const warnings: string[] = [];
const phaseDir = path.join(cwd, phaseInfo['directory'] as string);
let files: string[];
try {
files = fs.readdirSync(phaseDir);
} catch {
// #3183 (lint-plan-count-drift / ADR-3180 Decision 2): source plans/
// summaries and their pairing from the single owner (scanPhasePlans +
// findUnsummarizedPlans/findOrphanSummaries) instead of a bespoke
// root-only `-PLAN.md`/`-SUMMARY.md` filter and exact-suffix-stem
// Set-diff. The prior pairing missed bare PLAN.md/SUMMARY.md, nested
// (#3139 layout) plans, and any of the canonical pairing's other
// recognized naming forms — producing false "Plans without summaries" /
// "Summaries without plans" for names it could not recognize as paired
// (the same failure class #1988/#2648 fixed for the owner's own callers).
const scan = planScanMod.scanPhasePlans(phaseDir);
if (scan.scope === SCOPE.UNREADABLE) {
output({ error: 'Cannot read phase directory' }, raw);
return;
}
const { planFiles, summaryFiles } = scan;
const plans = files.filter((f) => f.match(/-PLAN\.md$/i));
const summaries = files.filter((f) => f.match(/-SUMMARY\.md$/i));
const planIds = new Set(plans.map((p) => p.replace(/-PLAN\.md$/i, '')));
const summaryIds = new Set(summaries.map((s) => s.replace(/-SUMMARY\.md$/i, '')));
const incompletePlans = [...planIds].filter((id) => !summaryIds.has(id));
const incompletePlans = findUnsummarizedPlans(planFiles, summaryFiles);
if (incompletePlans.length > 0) {
errors.push(`Plans without summaries: ${incompletePlans.join(', ')}`);
}
const orphanSummaries = [...summaryIds].filter((id) => !planIds.has(id));
const orphanSummaries = findOrphanSummaries(planFiles, summaryFiles);
if (orphanSummaries.length > 0) {
warnings.push(`Summaries without plans: ${orphanSummaries.join(', ')}`);
}
@@ -968,8 +976,8 @@ function cmdVerifyPhaseCompleteness(cwd: string, phase: string, raw: boolean): v
{
complete: errors.length === 0,
phase: phaseInfo['phase_number'],
plan_count: plans.length,
summary_count: summaries.length,
plan_count: planFiles.length,
summary_count: summaryFiles.length,
incomplete_plans: incompletePlans,
orphan_summaries: orphanSummaries,
errors,
@@ -1461,15 +1469,42 @@ function cmdValidateConsistency(cwd: string, raw: boolean): void {
for (const dir of dirs) {
const phasePath = path.join(phaseRoot, dir);
const phaseLabel = posixNormalize(path.relative(planBase, phasePath));
const phaseFiles = fs.readdirSync(phasePath);
const plans = phaseFiles.filter((f) => f.endsWith('-PLAN.md')).sort();
const planNums = plans
// #3183: this loop mixes two DIFFERENT questions — split explicitly
// rather than migrating it as one blind swap-in.
// QUESTION 1 — physical numbering-gap detection: wants EVERY plan
// file that physically exists, superseded or not (a retired plan
// still occupied a number in the sequence), root+nested. Uses the
// single owner's allPlanFiles rather than a root-only readdirSync
// filter. The strict `-NN-PLAN.md` suffix regex below already
// ignores any entry (nested, loose-named, bare PLAN.md) that isn't
// in the root canonical numbered form, so widening the input set is
// a pure visibility fix with no change to which files feed a number.
//
// One scan serves both questions below: `allPlanFiles` answers
// Question 1 (numbering-gap), `planFiles`/`summaryFiles` answer
// Question 2 (pairing) a few lines down.
const { allPlanFiles, planFiles, summaryFiles } = planScanMod.scanPhasePlans(phasePath);
// Root-canonical numbered plans only (`<phase>-NN-PLAN.md`) — the
// shape the numbering-gap sequence check operates on. Matched via
// the numbering regex itself rather than a separate suffix filter,
// so this stays a single derivation from the owner's output, not a
// second independent re-derivation of its filename grammar.
const numberedPlans = allPlanFiles
.map((p) => {
const pm = p.match(/-(\d{2})-PLAN\.md$/);
return pm ? parseInt(pm[1], 10) : null;
return pm ? { file: p, num: parseInt(pm[1], 10) } : null;
})
.filter((n): n is number => n !== null);
.filter((e): e is { file: string; num: number } => e !== null)
.sort((a, b) => a.num - b.num);
// numberedPlans (and planNums below) answers Question 1 ONLY — the
// numbering-gap sequence check. It is a strict `-NN-PLAN.md` subset
// and must NOT be reused as a general "all live plans" set: a plan
// whose filename isn't in that canonical 2-digit form (a 3-digit
// continuation, a bare PLAN.md, etc.) is silently absent from it.
const planNums = numberedPlans.map((e) => e.num);
for (let i = 1; i < planNums.length; i++) {
if (planNums[i] !== planNums[i - 1] + 1) {
@@ -1479,17 +1514,25 @@ function cmdValidateConsistency(cwd: string, raw: boolean): void {
}
}
const summaries = phaseFiles.filter((f) => f.endsWith('-SUMMARY.md'));
const planIds = new Set(plans.map((p) => p.replace('-PLAN.md', '')));
const summaryIds = new Set(summaries.map((s) => s.replace('-SUMMARY.md', '')));
for (const sid of summaryIds) {
if (!planIds.has(sid)) {
warnings.push(`Summary ${sid}-SUMMARY.md in ${phaseLabel} has no matching PLAN.md`);
}
// QUESTION 2 — plan↔summary pairing: "does this summary have a
// matching LIVE plan" wants the single owner's superseded-excluded
// planFiles and the canonical summaryCandidates-based pairing
// (findOrphanSummaries) instead of an exact-suffix Set-diff, which
// produced false "orphan summary" warnings for legacy/nested naming
// forms it could not recognize as paired.
const orphanSummaries = findOrphanSummaries(planFiles, summaryFiles);
for (const orphan of orphanSummaries) {
warnings.push(`Summary ${orphan} in ${phaseLabel} has no matching PLAN.md`);
}
for (const plan of plans) {
// QUESTION 3 — wave-frontmatter presence: "does every LIVE plan
// declare a wave" wants the same live (superseded-excluded) set as
// Question 2's pairing check — planFiles, NOT numberedPlans/Question
// 1's strict 2-digit subset. A superseded plan legitimately carries
// no wave, and a live plan whose filename isn't in canonical 2-digit
// form (a 3-digit continuation, a bare PLAN.md, etc.) must still be
// checked here even though it is invisible to the numbering-gap scan.
for (const plan of planFiles) {
const planFilePath = path.join(phasePath, plan);
const content = fs.readFileSync(planFilePath, 'utf-8');
const fmData = extractFrontmatter(content, planFilePath);
@@ -1737,6 +1780,14 @@ function cmdValidateHealth(
let phaseDirEntries: fs.Dirent[] = [];
const phaseDirFiles = new Map<string, string[]>();
// #3183: companion map of the single owner's scan per phase dir
// (root+nested, superseded-excluded plan/summary sets + canonical
// pairing), computed alongside the raw readdirSync listing above. The
// W023 duplicate-dir describer and the I001 unsummarized-plan detector
// below use THIS map for plan/summary counts and pairing; phaseDirFiles
// stays raw for the RESEARCH/VALIDATION and phase-dir-naming checks that
// are not plan-count questions.
const phaseDirScans = new Map<string, ReturnType<typeof planScanMod.scanPhasePlans>>();
try {
phaseDirEntries = fs
.readdirSync(phasesDir, { withFileTypes: true })
@@ -1747,6 +1798,7 @@ function cmdValidateHealth(
} catch {
phaseDirFiles.set(e.name, []);
}
phaseDirScans.set(e.name, planScanMod.scanPhasePlans(path.join(phasesDir, e.name)));
}
} catch {
/* intentionally empty */
@@ -1793,9 +1845,11 @@ function cmdValidateHealth(
.slice()
.sort((a, b) => comparePhaseNum(a, b) || String(a).localeCompare(String(b)))
.map((d) => {
const files = phaseDirFiles.get(d) || [];
const plans = files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
// #3183: canonical plan/summary counts (root+nested,
// superseded-excluded, canonical pairing) from the single owner.
const scan = phaseDirScans.get(d);
const plans = scan ? scan.planCount : 0;
const summaries = scan ? scan.summaryCount : 0;
const status = determinePhaseStatus(plans, summaries, path.join(phasesDir, d), 'Not Started');
return `${d} (${status})`;
})
@@ -1809,23 +1863,18 @@ function cmdValidateHealth(
}
}
// I001 (#3183): this IS findUnsummarizedPlans's exact question — routed
// through the single owner's scan (root+nested, superseded-excluded plan
// set) and the canonical summaryCandidates-based pairing, instead of a
// bespoke canonicalPlanStem reimplementation. Fixes: a superseded plan is
// no longer permanently flagged "may be in progress" (false noise
// forever), and nested (#3139 layout) plans are no longer invisible.
for (const e of phaseDirEntries) {
const phaseFiles = phaseDirFiles.get(e.name) || [];
const plans = phaseFiles.filter((f) => f.endsWith('-PLAN.md') || f === 'PLAN.md');
const summaries = phaseFiles.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
const summaryBases = new Set<string>();
for (const s of summaries) {
const summaryBase = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
summaryBases.add(summaryBase);
summaryBases.add(canonicalPlanStem(summaryBase));
}
for (const plan of plans) {
const planBase = plan.replace('-PLAN.md', '').replace('PLAN.md', '');
const canonicalBase = canonicalPlanStem(planBase);
if (!summaryBases.has(planBase) && !summaryBases.has(canonicalBase)) {
addIssue('info', 'I001', `${e.name}/${plan} has no SUMMARY.md`, 'May be in progress');
}
const scan = phaseDirScans.get(e.name);
const planFiles = scan ? scan.planFiles : [];
const summaryFiles = scan ? scan.summaryFiles : [];
for (const plan of findUnsummarizedPlans(planFiles, summaryFiles)) {
addIssue('info', 'I001', `${e.name}/${plan} has no SUMMARY.md`, 'May be in progress');
}
}
@@ -2473,8 +2522,15 @@ function cmdVerifySchemaDrift(
return;
}
// #3183: canonical LIVE plan/summary sets (root+nested,
// status: superseded EXCLUDED) from the single owner, rather than a
// root-only readdirSync filter — a superseded plan's claimed
// files_modified is no longer treated as an expected drift target, and
// nested (#3139 layout) plans/summaries are no longer invisible to the
// drift check.
const { planFiles, summaryFiles } = planScanMod.scanPhasePlans(phaseDir);
const allFiles: string[] = [];
const planFiles = fs.readdirSync(phaseDir).filter((f) => f.endsWith('-PLAN.md'));
for (const pf of planFiles) {
const content = fs.readFileSync(path.join(phaseDir, pf), 'utf-8');
const fmMatch = content.match(/files_modified:\s*\[([^\]]{0,8000})\]/);
@@ -2485,7 +2541,6 @@ function cmdVerifySchemaDrift(
}
let executionLog = '';
const summaryFiles = fs.readdirSync(phaseDir).filter((f) => f.endsWith('-SUMMARY.md'));
for (const sf of summaryFiles) {
executionLog += fs.readFileSync(path.join(phaseDir, sf), 'utf-8') + '\n';
}