fix(#3511): scope every phase-directory scan to the phase it belongs to (#3535)

This commit is contained in:
Tom Boucher
2026-08-15 07:02:33 -04:00
committed by GitHub
parent 3d17569d5b
commit 59e7a677fe
30 changed files with 1903 additions and 146 deletions

View File

@@ -510,27 +510,20 @@ function comparePhaseNum(a: unknown, b: unknown): number {
}
/**
* Extract the phase token from a directory name.
* Segmentation core shared by `extractPhaseToken` (the token VALUE) and
* `isPhaseArtifact` (the DERIVABILITY check — #3511). Factored out so the two
* questions ("what is this dir's token" and "did a real token exist at all")
* can never diverge — see CLAUDE.md's "Generative Fix Divergence" note: this
* is exactly a shared parser between two parallel surfaces.
*
* Returns `tokenSegments.length === 0` iff dirName's own leading segment
* carries no phase-number token (the `extractPhaseToken` dirName-unchanged
* fallback) — i.e. the directory name itself does not start with a digit or a
* short letter+digit prefix, so no reliable phase token can be read from it.
*/
function extractPhaseToken(dirName: string, convention?: string): string {
// #612 bracket dir form `{CODE}.{MM}-{PP}[.{SS}]-slug` → phase token `PP[.SS]`.
// GATED on convention === 'bracket' (mirrors getMilestoneFromPhaseId's READING-B
// decision above). A bracket dir `{CODE}.{MM}-{PP}` is string-INDISTINGUISHABLE
// from the legacy #2043/#1324 letter-prefixed-decimal family (`P0.3-2`,
// `P0.12-34`) whenever the project code ends in a digit, so NO string-only
// discriminator can separate the two conventions — auto-detecting here silently
// reinterpreted `P0.3-2` → `2` (was `P0.3-2`), a byte-identical-read regression
// on this CRITICAL 6-caller helper (ADR-2121). Requiring an explicit convention
// signal keeps every existing (convention-less) call site byte-identical to
// prior behaviour — see the #2043 numeric-tail characterization in
// tests/phase-id.test.cjs — while keeping the helper pure (optional param, no
// config read). The captured token is dot-only (`PP[.SS]`); the milestone↔phase
// hyphen and any trailing plan/slug are excluded.
if (convention === 'bracket') {
const bracketDir = dirName.match(/^[A-Z][A-Z0-9_]*\.\d+-(\d+(?:\.\d+)?)/);
if (bracketDir) return bracketDir[1];
}
function derivePhaseTokenSegments(
dirName: string,
): { prefix: string; tokenSegments: string[]; firstLetterPrefixed: boolean } {
const codePrefixMatch = dirName.match(PROJECT_CODE_PREFIX_CAPTURE_RE_I);
let prefix = '';
let rest = dirName;
@@ -575,6 +568,33 @@ function extractPhaseToken(dirName: string, convention?: string): string {
}
}
return { prefix, tokenSegments, firstLetterPrefixed };
}
/**
* Extract the phase token from a directory name.
*/
function extractPhaseToken(dirName: string, convention?: string): string {
// #612 bracket dir form `{CODE}.{MM}-{PP}[.{SS}]-slug` → phase token `PP[.SS]`.
// GATED on convention === 'bracket' (mirrors getMilestoneFromPhaseId's READING-B
// decision above). A bracket dir `{CODE}.{MM}-{PP}` is string-INDISTINGUISHABLE
// from the legacy #2043/#1324 letter-prefixed-decimal family (`P0.3-2`,
// `P0.12-34`) whenever the project code ends in a digit, so NO string-only
// discriminator can separate the two conventions — auto-detecting here silently
// reinterpreted `P0.3-2` → `2` (was `P0.3-2`), a byte-identical-read regression
// on this CRITICAL 6-caller helper (ADR-2121). Requiring an explicit convention
// signal keeps every existing (convention-less) call site byte-identical to
// prior behaviour — see the #2043 numeric-tail characterization in
// tests/phase-id.test.cjs — while keeping the helper pure (optional param, no
// config read). The captured token is dot-only (`PP[.SS]`); the milestone↔phase
// hyphen and any trailing plan/slug are excluded.
if (convention === 'bracket') {
const bracketDir = dirName.match(/^[A-Z][A-Z0-9_]*\.\d+-(\d+(?:\.\d+)?)/);
if (bracketDir) return bracketDir[1];
}
const { prefix, tokenSegments, firstLetterPrefixed } = derivePhaseTokenSegments(dirName);
if (tokenSegments.length === 0) {
return dirName;
}
@@ -614,6 +634,238 @@ function extractPhaseToken(dirName: string, convention?: string): string {
return prefix + tokenSegments.join('-');
}
/**
* #3511 (reworked — adversarial review found the membership rule wrong in
* approach, not just detail): predicate for AGGREGATE phase-directory scans
* (every matching file contributes, e.g.
* uat-predicate/phase.cts/state.cts/uat.cts/audit.cts's `*-UAT.md` /
* `*-VERIFICATION.md` scans) — answers "does fileName belong to THIS phase"
* so a stray, cross-phase, or ad-hoc file (`04-VERIFICATION.md` sitting in
* phase 03's directory) cannot contribute its status to phase 03. This is
* deliberately NOT `resolveVerificationFile` (`src/verification.cts`,
* #3357/#3492) — that resolver answers a SINGLE-PICK question ("which one
* candidate is THE report") for a phase dir already known to hold one; this
* answers a per-file membership question for a scan that must fold in EVERY
* match. See "Reconciliation" below — the two do NOT fully agree.
*
* THE ORIGINAL BUG: files are named by `normalizePhaseName`
* (`cmdScaffold`, `src/commands.cts` — PADDED, project-code-STRIPPED), while
* this predicate read the directory's OWN token via the literal, unpadded,
* project-code-CARRYING `extractPhaseToken(phaseDirName)`. Two different
* normalizations of the same phase number, so a literal
* `startsWith(token + '-')` excluded a phase's own artifacts whenever they
* disagreed: `CK-01-foundation` (token `CK-01`, file `01-VERIFICATION.md`),
* `1-unpadded` (token `1`, file `01-VERIFICATION.md`), and the #2528
* digit-leading-slug family `05-80-20-cleanup` / `10-24-7-autonomy` (token
* over-absorbs past the digit run `cmdScaffold` actually writes: `05-80-20`
* vs the real `05-UAT.md`).
*
* THE FIX: build the set of every phase-number READING this directory could
* plausibly resolve to elsewhere in the module, then check fileName against
* ALL of them — reusing the exact readings `matchPhaseDirs` /
* `phaseNumberForMatch` (#2528, below) already carry for directory
* RESOLUTION, so this membership check can never diverge from what "the
* directory for phase N" means elsewhere in the module (no third
* normalization — CLAUDE.md's Generative Fix Divergence class):
* 1. the literal token (`extractPhaseToken(phaseDirName)` — still correct
* for the common case and for genuine decimal / letter-suffixed
* sub-phase dirs);
* 2. the same token read off the project-code-STRIPPED name
* (`stripProjectCodePrefix` — the exact fallback `phaseTokenMatches`
* already applies, #612/#1324);
* 3. the directory's own LEADING DIGIT RUN on the stripped name
* (`LEADING_DIGIT_RUN_RE` — the #2528 bare-integer-fallback reading,
* the one that actually matches what `cmdScaffold` writes for the
* digit-leading-slug family); and
* 4. each of (1)-(3) additionally passed through `normalizePhaseName`,
* since files always carry the PADDED form and directories often do
* not (`1-unpadded` vs `01-...`).
* A file belongs when it starts with any candidate + `-` OR any candidate +
* `.`, compared case-insensitively (matching `phaseTokenMatches`' own rule —
* review item 8: `03A-VERIFICATION.md` vs `03a-foo`). This is a PREFIX check,
* not a full-token equality — `03-01-SUMMARY.md` (phase 03, plan 01) must
* still match dir `03-foo` on candidate `03`, even though
* `extractPhaseToken('03-01-SUMMARY.md')` would (wrongly, for this purpose)
* read `03-01` as a mis-absorbed 2-digit continuation.
*
* DOTTED SUB-PHASE CONTINUATION: the dot arm of the check exists because this
* module's own token grammar (`PHASE_NUMBER_TOKEN_SOURCE`) admits a dotted
* sub-phase continuation (`(?:\.\d+)*`) alongside dash-continuations — a
* sub-phase artifact `01.1-CONTEXT.md` is `01`'s own file, written into `01`'s
* directory, not a stray from a different phase. A dash-only prefix check
* excluded it (`01.1-` does not start with `01-`), which is over-exclusion:
* the dangerous direction for an aggregate scan whose fail-safes above all
* default to inclusion when membership is unclear. Widening dash-only to
* dash-OR-dot only ever ADDS a match a candidate already earned; it cannot
* newly admit a file whose leading digits differ from `candidate`, so it
* cannot resolve a genuinely different phase's artifact (`02.1-...` still
* fails every `01`-rooted candidate).
*
* BRACKET CONVENTION (review item 7): a letter-prefixed-decimal dir
* (`P0.3-2-slug`) is string-INDISTINGUISHABLE from a bracket-dir token
* (`extractPhaseToken` above, gated on `convention === 'bracket'`) without an
* explicit convention signal — and this predicate is never given one: none
* of its 9 call sites thread `convention`/config through today. Rather than
* guess a reading it cannot know is active and risk excluding the phase's OWN
* artifact (the exact defect class this rework exists to fix), this family
* (`firstLetterPrefixed` dirs) falls into the same include-everything
* fail-safe as the zero-segment case below — a documented, deliberate
* widening (it also stops excluding a genuine stray from a DIFFERENT
* letter-prefixed-decimal phase, narrowly) accepted in trade for never
* dropping the phase's own report. Convention-aware scoping for this family
* is deferred to whenever a call site actually threads `convention` through.
*
* FAIL-SAFE (#3511, unchanged): when dirName's own leading segment carries no
* phase-number token at all (`derivePhaseTokenSegments` finds zero segments —
* the same condition `extractPhaseToken` treats as "return dirName
* unchanged"), no reliable token exists to scope against. Excluding on an
* unreliable token would make an aggregate gate silently PERMISSIVE in the
* wrong direction — dropping the phase's own real blockers — which is worse
* than the cross-phase-contamination bug this predicate exists to fix.
* Instead every file is treated as belonging to the phase (returns `true`
* unconditionally), matching pre-fix (unscoped) behaviour for that directory.
*
* FIX 2 — bare `VERIFICATION.md` / `UAT.md` (no dash, no token of its own):
* `derivePhaseTokenSegments(fileName)` also finds zero segments for these —
* the file carries no phase number to compare against anything. Directory
* containment is the only signal available for a token-less file, and it is
* sufficient: every call site passes `fs.readdirSync` results for ONE
* specific phase dir, so a token-less candidate already reaching this
* predicate (past each call site's own verification/UAT suffix pre-filter)
* is, by construction, that phase's own
* listing. Returns `true` unconditionally, same as the dir-side fail-safe.
*
* RECONCILIATION WITH resolveVerificationFile (#3357/#3492/#3511) — the two
* surfaces now AGREE. `resolveVerificationFile`'s fallback (`verification.cts`,
* "Fallback" step in its own docblock) filters its dashed candidates through
* THIS predicate — `isPhaseArtifact(f, phaseDirName)` — before picking
* alphabetically-first, via a new `phaseDirName` option every call site
* threads in (the same basename each already derives for `phaseToken`). So a
* stray cross-phase file can no longer win the single-pick fallback either:
* it is excluded there for the identical reason it is excluded from the
* aggregate scans here — membership, not canonical shape. The fail-safes stay
* aligned too: when this predicate cannot determine membership for a
* directory (returns `true` unconditionally — see FAIL-SAFE above),
* `resolveVerificationFile`'s filter is a no-op and its fallback degrades to
* the original pre-#3357 "alphabetically first of ALL dashed candidates"
* behavior, exactly as it always did for that directory shape.
*/
function isPhaseArtifact(fileName: string, phaseDirName: string): boolean {
const { tokenSegments, firstLetterPrefixed } = derivePhaseTokenSegments(phaseDirName);
if (tokenSegments.length === 0) return true;
const literalToken = extractPhaseToken(phaseDirName);
const strippedDir = stripProjectCodePrefix(phaseDirName);
const strippedToken = strippedDir !== phaseDirName ? extractPhaseToken(strippedDir) : literalToken;
const leadingRunMatch = strippedDir.match(LEADING_DIGIT_RUN_RE);
const rawCandidates = [literalToken, strippedToken, leadingRunMatch?.[1]].filter(
(t): t is string => Boolean(t),
);
// Each reading is compared in BOTH its padded and de-padded form: files are
// written padded by `normalizePhaseName` (`cmdScaffold`) while directories
// are often not (`1-unpadded`), and legacy trees carry the reverse pairing.
// De-padding is numeric-only — a token with a letter suffix or a dotted
// sub-phase (`03A`, `03.1`) has no meaningful de-padded form and is left
// alone, so this only ever ADDS a reading and can never drop one.
const depad = (t: string): string => (/^\d+$/.test(t) ? String(Number(t)) : t);
const candidates = new Set(
rawCandidates
.flatMap(t => [t, normalizePhaseName(t), depad(t)])
.map(t => t.toUpperCase()),
);
const fileUpper = fileName.toUpperCase();
for (const candidate of candidates) {
// A dotted sub-phase segment (e.g. `01.1-CONTEXT.md`) is a legitimate
// continuation of `candidate` per this module's own token grammar
// (PHASE_NUMBER_TOKEN_SOURCE admits `(?:\.\d+)*`), so it belongs to
// `candidate`'s own directory just as a dash-continuation does. Inclusion
// is the safe direction for these aggregate scans (see FAIL-SAFE above) —
// widening a dash-only check to dash-OR-dot never drops a genuine match,
// it only stops wrongly excluding one.
//
// Accepted separator class after a matched candidate: `-`, `.`, or `_`.
// The underscore was added for state.cts's `cmdStateValidate` S006/S007
// scan, whose own pre-filter is deliberately broader than the dashed
// grammar every other call site uses (`.includes('VERIFICATION')`, no
// dash required — see the WARNING-4 comment there), so it admits names
// like `03_VERIFICATION.md`. Before this predicate accepted `_` as a
// boundary, such a file failed the `-`/`.`-only check here even though
// its digits matched `candidate` exactly, and `scopeToPhase` dropped it —
// a real same-phase verification report reported as absent. Widening the
// separator class only ever EXTENDS a candidate whose digits already
// match exactly; it cannot admit a genuinely different phase's file,
// since the candidate comparison itself is unchanged.
if (
fileUpper.startsWith(`${candidate}-`) ||
fileUpper.startsWith(`${candidate}.`) ||
fileUpper.startsWith(`${candidate}_`)
) return true;
}
// FIX 2: token-less filename (bare "VERIFICATION.md"/"UAT.md") — containment
// in this phase's own directory listing is sufficient.
if (derivePhaseTokenSegments(fileName).tokenSegments.length === 0) return true;
// Bracket-convention ambiguity fail-safe — see docblock above.
if (firstLetterPrefixed) return true;
return false;
}
/**
* #3511: scope `fileNames` to the subset that passes
* `isPhaseArtifact(fileName, phaseDirName)`. The single seam every
* phase-directory scan routes through, so the membership rule has ONE owner.
*
* AN EMPTY RESULT IS A REAL ANSWER — deliberately, and this is the hard-won
* part. An earlier revision of this helper carried an extra rule ("scoping
* must never turn a non-empty set into an empty one": if the filter removed
* every file, return the unfiltered input). It was added to rescue a
* directory whose basename merely PARSES phase-shaped —
* `gsd-651-broad-grep-a1b2`, an `mkdtemp`-style fixture name that
* `extractPhaseToken` reads as project code `gsd` + phase `651` (the capture
* regex is case-INSENSITIVE) — holding only `01-bg-VERIFICATION.md`, which
* the filter then dropped, yielding an empty set indistinguishable from "no
* report exists".
*
* That rescue was wrong, and no local rule can make it right: a directory
* whose own name says phase 651 holding only a file that says phase 01 is
* STRING-INDISTINGUISHABLE from `03-foo/` holding only `04-VERIFICATION.md`
* — the exact cross-phase stray #3511 exists to exclude. Keeping the rule
* meant a real phase directory holding only a MISFILED report would resolve
* to it and publish another phase's `passed` as its own: the reported bug, in
* its single most damaging form. `missing` is the correct answer when a
* phase's own report is genuinely absent, and every caller already has a
* `missing`/`null` branch for it.
*
* The over-exclusion that rule was reaching for is instead handled where it
* is actually determinable, inside `isPhaseArtifact`: the zero-token dir
* fail-safe, the `firstLetterPrefixed` bracket-ambiguity fail-safe, the
* token-less-filename rule, and the multi-reading candidate set (literal /
* project-code-stripped / leading-digit-run, each also padded AND de-padded)
* that covers every normalization a phase directory and its files can
* legitimately disagree on. A file excluded after all of those genuinely
* names a different phase.
*
* SITE DISCIPLINE: every aggregate-scan call site (`uat.cts`,
* `uat-predicate.cts`, `phase.cts`, `audit.cts`, `state.cts`,
* `core-utils.cts`'s `getPhaseFileStats` — #3511 BLOCKER-2 — and
* `init.cts`'s two phase-info-projection sites — #3511 BLOCKER-3, both of
* which scope the raw listing once up front and reuse it for every bare
* `.find()`/`.some()` artifact predicate: context/research/UAT/reviews/
* patterns) and `resolveVerificationFile`'s single-pick fallback
* (`verification.cts`) MUST route through this helper rather than calling
* `isPhaseArtifact` in a filter position directly, so the rule cannot be
* re-derived per site (CLAUDE.md's Generative Fix Divergence class).
* `isPhaseArtifact` stays exported for single-item membership questions and
* its own unit tests.
*/
function scopeToPhase(fileNames: string[], phaseDirName: string): string[] {
return fileNames.filter((f) => isPhaseArtifact(f, phaseDirName));
}
/**
* Check if a directory name's phase token matches the normalized phase exactly.
*/
@@ -950,6 +1202,8 @@ export = {
phaseMarkdownRegexSourceExact,
comparePhaseNum,
extractPhaseToken,
isPhaseArtifact,
scopeToPhase,
phaseTokenMatches,
matchPhaseDirs,
phaseNumberForMatch,