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

@@ -24,7 +24,7 @@ import frontmatter = require('./frontmatter.cjs');
const { extractFrontmatter } = frontmatter;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
const { PHASE_NUMBER_TOKEN_SOURCE, scopeToPhase } = phaseIdMod;
import { requireSafePath, sanitizeForDisplay } from './security.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -527,7 +527,13 @@ function scanUatGaps(planDir: string): UatGapItem[] {
continue;
}
for (const file of files.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
// Scoped to THIS phase's own token (#3511) so a stray, cross-phase, or
// ad-hoc UAT file cannot surface as this phase's gap — same fix as
// scanVerificationGaps below.
for (const file of scopeToPhase(
files.filter(f => f.includes('-UAT') && f.endsWith('.md')),
dir,
)) {
const filePath = path.join(phaseDir, file);
let safeFilePath: string;
@@ -597,7 +603,12 @@ function scanVerificationGaps(planDir: string): VerificationGapItem[] {
continue;
}
for (const file of files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
// Scoped to THIS phase's own token (#3511) so a stray, cross-phase, or
// ad-hoc VERIFICATION file cannot surface as this phase's gap.
for (const file of scopeToPhase(
files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md')),
dir,
)) {
const filePath = path.join(phaseDir, file);
let safeFilePath: string;

View File

@@ -170,8 +170,9 @@ function determinePhaseStatus(plans: number, summaries: number, phaseDir: string
// #3492: pin selection to THIS phase's own token so a stray cross-phase
// or sentinel-numbered canonically-shaped file cannot outrank this
// phase's own (possibly non-canonical) report.
const phaseToken = extractPhaseToken(path.basename(phaseDir));
const verificationFile = resolveVerificationFile(files, { allowBare: true, phaseToken });
const phaseDirName = path.basename(phaseDir);
const phaseToken = extractPhaseToken(phaseDirName);
const verificationFile = resolveVerificationFile(files, { allowBare: true, phaseToken, phaseDirName });
if (verificationFile) {
const verificationFilePath = path.join(phaseDir, verificationFile);
const content = platformReadSync(verificationFilePath) || '';

View File

@@ -20,7 +20,7 @@ import fs from 'node:fs';
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdModule = require('./phase-id.cjs');
const { comparePhaseNum } = phaseIdModule;
const { comparePhaseNum, scopeToPhase } = phaseIdModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
const { findContextMdIn } = planningWorkspace;
@@ -199,6 +199,17 @@ interface PlanScanResultShape {
* `hasResearch`/`hasContext`/`hasVerification`/`hasReviews` stay on the raw
* `readdirSync` listing — they are not plan-scan concerns.
*
* #3511 BLOCKER-2: the raw listing is scoped through `scopeToPhase` (keyed on
* `path.basename(phaseDir)`) before any of the four artifact predicates run,
* so a stray cross-phase file (e.g. `04-VERIFICATION.md` sitting inside phase
* 03's directory) cannot flip `hasResearch`/`hasContext`/`hasVerification`/
* `hasReviews` true for a phase it does not belong to — the same membership
* rule every other aggregate phase-directory scan (`uat.cts`, `audit.cts`,
* `phase.cts`, `state.cts`) already routes through. `hasContext` is scoped by
* passing the already-scoped array into `findContextMdIn` at this call site
* only — `findContextMdIn` itself is unchanged and its other 4 call sites
* (roadmap.cts, gap-checker.cts, init.cts) are unaffected.
*
* Degrades on an unreadable directory instead of throwing: empty arrays,
* every flag false, scope UNREADABLE (mirroring scanPhasePlans's own
* degrade path).
@@ -223,13 +234,15 @@ function getPhaseFileStats(phaseDir: string): PhaseFileStats {
};
}
const scopedFiles = scopeToPhase(files, path.basename(phaseDir));
return {
plans: scan.planFiles,
summaries: scan.summaryFiles,
hasResearch: files.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
hasContext: findContextMdIn(files) !== null,
hasVerification: files.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'),
hasReviews: files.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'),
hasResearch: scopedFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
hasContext: findContextMdIn(scopedFiles) !== null,
hasVerification: scopedFiles.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'),
hasReviews: scopedFiles.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'),
scope: scan.scope,
};
}

View File

@@ -30,6 +30,9 @@ import { iterateBullets } from './markdown-sectionizer.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planScanMod = require('./plan-scan.cjs');
const { scanPhasePlans } = planScanMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { scopeToPhase } = phaseIdMod;
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -353,7 +356,13 @@ function runGapAnalysis(cwd: string, phaseDir: string, options: RunGapAnalysisOp
if (fs.existsSync(absPhaseDir)) phaseDirFiles = fs.readdirSync(absPhaseDir);
} catch { /* unreadable */ }
const ctxFile = findContextMdIn(phaseDirFiles);
// #3511-class: scope the raw listing to this phase dir before the
// phase-numbered -CONTEXT.md predicate. `phaseDirFiles` itself stays raw —
// it is also reused below only as a `.length > 0` guard ahead of
// scanPhasePlans's own plan-sequence-numbered enumeration, a different
// grammar that must not be scoped by phase number.
const scopedPhaseDirFiles = scopeToPhase(phaseDirFiles, path.basename(absPhaseDir));
const ctxFile = findContextMdIn(scopedPhaseDirFiles);
const ctxPath = ctxFile ? path.join(absPhaseDir, ctxFile) : null;
const ctxMd = ctxPath ? fs.readFileSync(ctxPath, 'utf-8') : '';

View File

@@ -89,7 +89,7 @@ const {
extractCurrentMilestone,
} = roadmapParser;
const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils;
const { normalizePhaseName, matchPhaseDirs, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE, isForeignPrefixedPhaseQuery, isSentinelPhaseId, extractPhaseToken } = phaseId;
const { normalizePhaseName, matchPhaseDirs, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE, isForeignPrefixedPhaseQuery, isSentinelPhaseId, extractPhaseToken, scopeToPhase } = phaseId;
const { pruneOrphanedWorktrees } = worktreeSafety;
const {
@@ -518,7 +518,12 @@ function detectHasPriorPhases(cwd: string, phaseInfo: Record<string, unknown> |
} catch {
continue;
}
if (files.some((f) => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md')) {
// #3511-class: scope the raw listing to THIS entry's own phase artifacts
// before the bare `.some()` predicate runs, so a stray `07-VERIFICATION.md`
// physically sitting in another phase's directory cannot make that
// directory appear to have its own verification report.
const scopedFiles = scopeToPhase(files, entry.name);
if (scopedFiles.some((f) => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md')) {
return true;
}
}
@@ -706,7 +711,11 @@ function detectUiPhaseActive(cwd: string, phaseInfo: Record<string, unknown> | n
// comments elsewhere in this file), same technique as detectHasPriorPhases above.
const dirName = path.basename(rawDir);
const files = fs.readdirSync(path.join(planningDir(cwd), 'phases', dirName));
hasUiSpecFile = files.some((f) => f.endsWith('-UI-SPEC.md') || f === 'UI-SPEC.md');
// #3511-class: scope the raw listing to this phase dir before the
// phase-numbered -UI-SPEC.md predicate, so a stray cross-phase
// UI-SPEC file cannot flip this phase's ui-phase-active flag.
const scopedFiles = scopeToPhase(files, dirName);
hasUiSpecFile = scopedFiles.some((f) => f.endsWith('-UI-SPEC.md') || f === 'UI-SPEC.md');
} catch {
hasUiSpecFile = false;
}
@@ -1134,11 +1143,21 @@ function cmdInitPlanPhase(
const phaseDirFull = path.join(cwd, phaseInfo['directory'] as string);
try {
const files = fs.readdirSync(phaseDirFull);
const contextFile = findContextMdIn(phaseDirFull);
const phaseDirName = path.basename(phaseDirFull);
// #3511 BLOCKER-3: scope the raw listing to THIS phase's own artifacts
// before any bare `.find()` predicate runs, so a `04-UAT.md` (or
// `04-RESEARCH.md`/`04-REVIEWS.md`/`04-PATTERNS.md`) sitting in phase
// 03's directory cannot win a phase-03 lookup — the same
// `isPhaseArtifact` membership rule `resolveVerificationFile` already
// applies via `phaseDirName` below. `findContextMdIn` is passed the
// scoped array (rather than the raw directory path) so this call site
// alone is scoped; its other call sites are unaffected.
const scopedFiles = scopeToPhase(files, phaseDirName);
const contextFile = findContextMdIn(scopedFiles);
if (contextFile) {
result['context_path'] = toPosixPath(path.join(phaseDirFull, contextFile));
}
const researchFile = files.find(
const researchFile = scopedFiles.find(
(f) => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md',
);
if (researchFile) {
@@ -1151,10 +1170,11 @@ function cmdInitPlanPhase(
// #3492: pin selection to THIS phase's own token so a stray cross-phase
// or sentinel-numbered canonically-shaped file cannot outrank this
// phase's own (possibly non-canonical) report.
const phaseToken = extractPhaseToken(path.basename(phaseDirFull));
const phaseToken = extractPhaseToken(phaseDirName);
const verificationFile = resolveVerificationFile(files, {
allowBare: true,
phaseToken,
phaseDirName,
});
if (verificationFile) {
result['verification_path'] = toPosixPath(path.join(phaseDirFull, verificationFile));
@@ -1163,21 +1183,24 @@ function cmdInitPlanPhase(
// `.find()` over unsorted readdir order had no phase check and no
// ordering, so a stray cross-phase 02-UAT.md could become this phase's
// uat_path, filesystem-dependently. Pinned to this phase's own token
// (same rule as verification_path above).
// (same rule as verification_path above), and phase-scoped via
// phaseDirName (#3511) so the alphabetically-first fallback tier also
// excludes cross-phase strays.
const uatFile = resolveUatFile(files, {
allowBare: true,
phaseToken,
phaseDirName,
});
if (uatFile) {
result['uat_path'] = toPosixPath(path.join(phaseDirFull, uatFile));
}
const reviewsFile = files.find(
const reviewsFile = scopedFiles.find(
(f) => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md',
);
if (reviewsFile) {
result['reviews_path'] = toPosixPath(path.join(phaseDirFull, reviewsFile));
}
const patternsFile = files.find(
const patternsFile = scopedFiles.find(
(f) => f.endsWith('-PATTERNS.md') || f === 'PATTERNS.md',
);
if (patternsFile) {
@@ -1976,11 +1999,15 @@ function cmdInitPhaseOp(cwd: string, phase: string, raw: boolean): void {
const phaseDirFull = path.join(cwd, phaseInfo['directory'] as string);
try {
const files = fs.readdirSync(phaseDirFull);
const contextFile = findContextMdIn(phaseDirFull);
const phaseDirName = path.basename(phaseDirFull);
// #3511 BLOCKER-3: see the parallel site above — scope before any bare
// `.find()` predicate so a misfiled cross-phase artifact cannot win.
const scopedFiles = scopeToPhase(files, phaseDirName);
const contextFile = findContextMdIn(scopedFiles);
if (contextFile) {
result['context_path'] = toPosixPath(path.join(phaseDirFull, contextFile));
}
const researchFile = files.find(
const researchFile = scopedFiles.find(
(f) => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md',
);
if (researchFile) {
@@ -1993,10 +2020,11 @@ function cmdInitPhaseOp(cwd: string, phase: string, raw: boolean): void {
// #3492: pin selection to THIS phase's own token so a stray cross-phase
// or sentinel-numbered canonically-shaped file cannot outrank this
// phase's own (possibly non-canonical) report.
const phaseToken = extractPhaseToken(path.basename(phaseDirFull));
const phaseToken = extractPhaseToken(phaseDirName);
const verificationFile = resolveVerificationFile(files, {
allowBare: true,
phaseToken,
phaseDirName,
});
if (verificationFile) {
result['verification_path'] = toPosixPath(path.join(phaseDirFull, verificationFile));
@@ -2005,15 +2033,18 @@ function cmdInitPhaseOp(cwd: string, phase: string, raw: boolean): void {
// `.find()` over unsorted readdir order had no phase check and no
// ordering, so a stray cross-phase 02-UAT.md could become this phase's
// uat_path, filesystem-dependently. Pinned to this phase's own token
// (same rule as verification_path above).
// (same rule as verification_path above), and phase-scoped via
// phaseDirName (#3511) so the alphabetically-first fallback tier also
// excludes cross-phase strays.
const uatFile = resolveUatFile(files, {
allowBare: true,
phaseToken,
phaseDirName,
});
if (uatFile) {
result['uat_path'] = toPosixPath(path.join(phaseDirFull, uatFile));
}
const reviewsFile = files.find(
const reviewsFile = scopedFiles.find(
(f) => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md',
);
if (reviewsFile) {
@@ -2324,8 +2355,13 @@ function cmdInitManager(cwd: string, raw: boolean): void {
const phaseFiles = fs.readdirSync(fullDir);
planCount = listPhasePlanFiles(fullDir).length;
summaryCount = listPhaseSummaryFiles(fullDir).length;
hasContext = findContextMdIn(fullDir) !== null;
hasResearch = phaseFiles.some(
// #3511-class: scope the raw listing to THIS phase's own artifacts
// before the hasContext/hasResearch predicates run, so a stray
// cross-phase `-CONTEXT.md`/`-RESEARCH.md` sitting in this directory
// cannot win this phase's lookup.
const scopedFiles = scopeToPhase(phaseFiles, dirMatch);
hasContext = findContextMdIn(scopedFiles) !== null;
hasResearch = scopedFiles.some(
(f) => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md',
);
completion = buildPhaseCompletionProjection(
@@ -2938,7 +2974,12 @@ function cmdInitProgress(cwd: string, raw: boolean, options: Record<string, unkn
const plans = listPhasePlanFiles(phasePath);
const summaries = listPhaseSummaryFiles(phasePath);
const hasResearch = phaseFiles.some(
// #3511-class: scope the raw listing to THIS phase's own artifacts
// before the hasResearch predicate runs, so a stray cross-phase
// `-RESEARCH.md` sitting in this directory cannot win this phase's
// lookup.
const scopedPhaseFiles = scopeToPhase(phaseFiles, dir);
const hasResearch = scopedPhaseFiles.some(
(f) => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md',
);
const phaseDirRel = toPosixPath(

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,

View File

@@ -43,6 +43,7 @@ const {
comparePhaseNum,
matchPhaseDirs,
isSentinelPhaseId,
scopeToPhase,
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
OPTIONAL_PHASE_TAG_SOURCE,
PHASE_NUMBER_TOKEN_SOURCE,
@@ -1521,12 +1522,39 @@ function renameDecimalPhases(
return { renamedDirs, renamedFiles };
}
/**
* Find a free name to move an occupying file aside to, on collision, so the
* intended rename can proceed without destroying either file. Appends the
* literal `.orphaned` suffix to the whole existing filename (never `.md`,
* so no phase-directory scan predicate — all of which filter on
* `.endsWith('.md')` / `.endsWith('-VERIFICATION.md')` etc — can ever pick
* the displaced file back up as any phase's artifact). Falls back to a
* numeric discriminator (`.orphaned.2`, `.orphaned.3`, ...) if `.orphaned`
* itself is taken, bounded at 100 attempts so a pathological directory
* cannot loop forever; returns null if no free name is found within that
* bound, letting the caller fall back to skip-and-report.
*/
function findOrphanedDisplacementName(dir: string, fileName: string): string | null {
const base = `${fileName}.orphaned`;
if (!fs.existsSync(path.join(dir, base))) return base;
for (let n = 2; n <= 100; n++) {
const candidate = `${base}.${n}`;
if (!fs.existsSync(path.join(dir, candidate))) return candidate;
}
return null;
}
function renameIntegerPhases(
phasesDir: string,
removedInt: number,
): { renamedDirs: { from: string; to: string }[]; renamedFiles: { from: string; to: string }[] } {
): {
renamedDirs: { from: string; to: string }[];
renamedFiles: { from: string; to: string }[];
renamedFileCollisions: { from: string; to: string; displaced_to: string | null }[];
} {
const renamedDirs: { from: string; to: string }[] = [];
const renamedFiles: { from: string; to: string }[] = [];
const renamedFileCollisions: { from: string; to: string; displaced_to: string | null }[] = [];
const dirs = readSubdirectories(phasesDir, true);
const toRename: RenameIntInfo[] = dirs
.map((dir) => {
@@ -1558,20 +1586,76 @@ function renameIntegerPhases(
const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`;
const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`;
const newDirName = `${newPrefix}-${item.slug}`;
// WARNING-3 (#3511 review): the directory match above accepts an
// UNPADDED leading number (`\d+`), so a supported rename can pair a
// 2-padded dir with an unpadded-numbered artifact — dir `9-slug` holding
// `9-VERIFICATION.md`. Renaming files by `f.startsWith(oldPrefix)` alone
// (oldPrefix always 2-padded) misses that file: it becomes desynced from
// its now-renamed directory and the phase reads `missing`. Try the
// UNPADDED old-prefix form as a fallback so such an artifact renames
// alongside its directory. A trailing-digit boundary check keeps the
// unpadded form from over-matching a DIFFERENT phase's file (unpadded
// prefix "1" must not match "10-…").
const oldPrefixUnpadded = `${item.oldInt}${letterSuffix}${decimalSuffix}`;
retryRenameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
renamedDirs.push({ from: item.dir, to: newDirName });
for (const f of fs.readdirSync(path.join(phasesDir, newDirName))) {
let matchedPrefix: string | null = null;
if (f.startsWith(oldPrefix)) {
const newFileName = newPrefix + f.slice(oldPrefix.length);
retryRenameSync(
path.join(phasesDir, newDirName, f),
path.join(phasesDir, newDirName, newFileName),
);
matchedPrefix = oldPrefix;
} else if (
oldPrefixUnpadded !== oldPrefix &&
f.startsWith(oldPrefixUnpadded) &&
// Token-boundary check: the character immediately after the unpadded
// prefix must be a separator (`-`, `.`) or end-of-name, not any
// non-digit. A bare `!/^\d/` test (prior form) let a LETTER through
// too, so unpadded prefix "2" wrongly matched "2FA-notes.md" (a
// wholly unrelated file whose name merely starts with the digit).
(f.length === oldPrefixUnpadded.length || /^[-.]/.test(f.slice(oldPrefixUnpadded.length)))
) {
matchedPrefix = oldPrefixUnpadded;
}
if (matchedPrefix) {
const newFileName = newPrefix + f.slice(matchedPrefix.length);
const destPath = path.join(phasesDir, newDirName, newFileName);
// Collision guard: the padded and unpadded prefix forms can both
// resolve to the SAME destination (e.g. `09-VERIFICATION.md` and
// `9-VERIFICATION.md` in one directory both target
// `08-VERIFICATION.md`), and a stray cross-phase file can already sit
// at the destination name (e.g. a leftover `08-VERIFICATION.md`
// belonging to a DIFFERENT phase, inside phase 9's directory).
// Renaming blindly over an existing target silently destroys
// whichever file loses; skipping the rename instead lets the stray
// outrank the phase's own renamed artifact once it lands at the
// canonical name. Neither is acceptable: move the OCCUPYING file
// aside first (never overwrite, never skip the real rename), then
// complete the intended rename so the phase's own artifact takes the
// canonical name. This also handles a target that was already
// claimed by an EARLIER file in this same pass, since that earlier
// rename already created it on disk.
if (fs.existsSync(destPath)) {
const displacedName = findOrphanedDisplacementName(
path.join(phasesDir, newDirName),
newFileName,
);
if (displacedName === null) {
// No free displacement name within the bounded search — fall
// back to skip-and-report rather than looping or overwriting.
renamedFileCollisions.push({ from: f, to: newFileName, displaced_to: null });
continue;
}
retryRenameSync(destPath, path.join(phasesDir, newDirName, displacedName));
retryRenameSync(path.join(phasesDir, newDirName, f), destPath);
renamedFiles.push({ from: f, to: newFileName });
renamedFileCollisions.push({ from: f, to: newFileName, displaced_to: displacedName });
continue;
}
retryRenameSync(path.join(phasesDir, newDirName, f), destPath);
renamedFiles.push({ from: f, to: newFileName });
}
}
}
return { renamedDirs, renamedFiles };
return { renamedDirs, renamedFiles, renamedFileCollisions };
}
function decrementRoadmapPhaseNumber(raw: string, removedInt: number): string {
@@ -1897,16 +1981,22 @@ function cmdPhaseRemove(
let renamedDirs: { from: string; to: string }[] = [];
let renamedFiles: { from: string; to: string }[] = [];
let renamedFileCollisions: { from: string; to: string; displaced_to: string | null }[] = [];
try {
const renamed = isDecimal
? renameDecimalPhases(
phasesDir,
parseInt(normalized.split('.')[0], 10),
parseInt(normalized.split('.')[1], 10),
)
: renameIntegerPhases(phasesDir, parseInt(normalized, 10));
renamedDirs = renamed.renamedDirs;
renamedFiles = renamed.renamedFiles;
if (isDecimal) {
const renamed = renameDecimalPhases(
phasesDir,
parseInt(normalized.split('.')[0], 10),
parseInt(normalized.split('.')[1], 10),
);
renamedDirs = renamed.renamedDirs;
renamedFiles = renamed.renamedFiles;
} else {
const renamed = renameIntegerPhases(phasesDir, parseInt(normalized, 10));
renamedDirs = renamed.renamedDirs;
renamedFiles = renamed.renamedFiles;
renamedFileCollisions = renamed.renamedFileCollisions;
}
} catch (e) {
// #2245 audit (was ERROR-HIDING): renameDecimalPhases/renameIntegerPhases
// rename subsequent phase directories ON DISK one at a time — a mid-loop
@@ -1999,6 +2089,7 @@ function cmdPhaseRemove(
directory_deleted: targetDir,
renamed_directories: renamedDirs,
renamed_files: renamedFiles,
renamed_file_collisions: renamedFileCollisions,
roadmap_updated: true,
state_updated: stateUpdated,
},
@@ -2194,8 +2285,15 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
try {
const phaseFiles = fs.readdirSync(phaseFullDir);
// #3511: scope this advisory pre-scan to THIS phase's own token so a
// stray, cross-phase, or ad-hoc file cannot name a warning against a
// phase it does not belong to.
const phaseFullDirBaseName = path.basename(phaseFullDir);
for (const file of phaseFiles.filter((f) => f.includes('-UAT') && f.endsWith('.md'))) {
for (const file of scopeToPhase(
phaseFiles.filter((f) => f.includes('-UAT') && f.endsWith('.md')),
phaseFullDirBaseName,
)) {
const content = fs.readFileSync(path.join(phaseFullDir, file), 'utf-8');
if (/result: pending/.test(content)) warnings.push(`${file}: has pending tests`);
if (/result: blocked/.test(content)) warnings.push(`${file}: has blocked tests`);
@@ -2203,8 +2301,9 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
if (/status: diagnosed/.test(content)) warnings.push(`${file}: has diagnosed gaps`);
}
for (const file of phaseFiles.filter(
(f) => f.includes('-VERIFICATION') && f.endsWith('.md'),
for (const file of scopeToPhase(
phaseFiles.filter((f) => f.includes('-VERIFICATION') && f.endsWith('.md')),
phaseFullDirBaseName,
)) {
const verificationFilePath = path.join(phaseFullDir, file);
const content = fs.readFileSync(verificationFilePath, 'utf-8');

View File

@@ -59,7 +59,7 @@ import worktreeSafetyMod = require('./worktree-safety.cjs');
const { inspectWorktreeHealth } = worktreeSafetyMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { PHASE_NUMBER_TOKEN_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, stripProjectCodePrefix } = phaseIdMod;
const { PHASE_NUMBER_TOKEN_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, stripProjectCodePrefix, scopeToPhase } = phaseIdMod;
import { buildRoadmapPhaseVariants, PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE } from './validate.cjs';
// ─── worstScope — the one new piece of coordination logic ───────────────────
@@ -630,8 +630,13 @@ function buildResearchValidationStatusField(
} catch {
return { dir, hasValidationArchitecture: false, hasValidationMd: false };
}
const researchFile = files.find((f) => f.endsWith('-RESEARCH.md'));
const hasValidationMd = files.some((f) => f.endsWith('-VALIDATION.md'));
// #3511: scope the raw listing to this phase dir before the two
// phase-numbered-artifact predicates, so a stray cross-phase
// -RESEARCH.md/-VALIDATION.md sitting in the wrong directory cannot flip
// this phase's flags — mirrors core-utils.cts's getPhaseFileStats.
const scopedFiles = scopeToPhase(files, dir);
const researchFile = scopedFiles.find((f) => f.endsWith('-RESEARCH.md'));
const hasValidationMd = scopedFiles.some((f) => f.endsWith('-VALIDATION.md'));
let hasValidationArchitecture = false;
if (researchFile) {
try {

View File

@@ -16,7 +16,7 @@ import ioMod = require('./io.cjs');
const { output, error } = ioMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { normalizePhaseName, phaseMarkdownRegexSource, matchPhaseDirs, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE, roadmapPhaseLookupSources, isSentinelPhaseId } = phaseIdMod;
const { normalizePhaseName, phaseMarkdownRegexSource, matchPhaseDirs, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE, roadmapPhaseLookupSources, isSentinelPhaseId, scopeToPhase } = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseLocatorMod = require('./phase-locator.cjs');
const { findPhaseInternal, listMilestonePhaseDirs } = phaseLocatorMod;
@@ -114,11 +114,17 @@ function countPhasePlansAndSummaries(phaseDir: string): PhasePlansAndSummaries {
// once and share the listing for all non-plan metadata that cmdRoadmapAnalyze needs.
let phaseFiles: string[] = [];
try { phaseFiles = fs.readdirSync(phaseDir); } catch { /* empty */ }
// #3511: scope the raw listing to this phase dir before the
// phase-numbered-artifact predicates (hasContext/hasResearch) — planCount/
// summaryCount above stay on scanPhasePlans's own unscoped listing since a
// PLAN/SUMMARY leading number is a plan sequence number, not a phase
// number. Mirrors core-utils.cts's getPhaseFileStats.
const scopedFiles = scopeToPhase(phaseFiles, path.basename(phaseDir));
return {
planCount,
summaryCount,
hasContext: findContextMdIn(phaseFiles) !== null,
hasResearch: phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
hasContext: findContextMdIn(scopedFiles) !== null,
hasResearch: scopedFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
};
}

View File

@@ -17,7 +17,14 @@ import configLoaderMod = require('./config-loader.cjs');
const { loadConfig } = configLoaderMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFromToken, phaseKeyFromDir, isSentinelPhaseId } = phaseIdMod;
const {
parsePhaseFromProse,
PHASE_NUMBER_TOKEN_SOURCE,
phaseKeyFromToken,
phaseKeyFromDir,
isSentinelPhaseId,
scopeToPhase,
} = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserMod = require('./roadmap-parser.cjs');
const { getMilestoneInfo, extractCurrentMilestone, isMilestoneBoundedInRoadmap, hasMilestoneSectioning } = roadmapParserMod;
@@ -4187,9 +4194,35 @@ function cmdStateValidate(cwd: string, raw: boolean): void {
));
}
// Check for VERIFICATION.md
// Check for VERIFICATION.md — scoped to THIS phase's own token (#3511)
// so a stray, cross-phase, or ad-hoc VERIFICATION file cannot claim
// this phase's status has drifted.
//
// WARNING-4 (#3511 review): the pre-filter grammar here is
// deliberately BROADER than the `-VERIFICATION.md` suffix every
// other site in the codebase uses — `.includes('VERIFICATION')`
// admits names like `03_VERIFICATION.md` (underscore, no dash) that
// the dashed grammar would reject outright. That breadth predates
// #3511 and is intentional here (this is a best-effort drift
// WARNING scan, not an authoritative single-pick resolver), so it is
// left as-is rather than narrowed to match the dashed sites — doing
// so would be a separate, un-asked-for behavior change (S006/S007).
// What #3511 DOES change is that a name this broader grammar admits
// is now ALSO subject to the same `scopeToPhase` membership check as
// every dashed-grammar site, so a stray `04_VERIFICATION.md`-shaped
// file in phase 03's directory is excluded exactly like a stray
// `04-VERIFICATION.md` would be — while `03_VERIFICATION.md` (own
// phase, underscore separator) is NOT excluded: `isPhaseArtifact`
// (`phase-id.cts`) accepts `_` as a candidate-boundary separator
// alongside `-` and `.` for exactly this reason, so an S006/S007
// scan of `03-alpha/03_VERIFICATION.md` still resolves to S006
// ("verification passed" drift), not a false S007.
const files = fs.readdirSync(phaseDirPath);
const verificationFiles = files.filter(f => f.includes('VERIFICATION') && f.endsWith('.md'));
const phaseDirBaseName = path.basename(phaseDirPath);
const verificationFiles = scopeToPhase(
files.filter(f => f.includes('VERIFICATION') && f.endsWith('.md')),
phaseDirBaseName,
);
for (const vf of verificationFiles) {
try {
const vContent = fs.readFileSync(path.join(phaseDirPath, vf), 'utf-8');

View File

@@ -22,6 +22,9 @@ const { stripFencedCode } = markdownSectionizer;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import verification = require('./verification.cjs');
const { readVerificationStatus } = verification;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { scopeToPhase } = phaseIdMod;
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -243,9 +246,18 @@ function evaluateUatPassed(
};
}
// Filter UAT and VERIFICATION files using the same filter as cmdPhaseComplete
const uatFileNames = dirEntries.filter(f => f.includes('-UAT') && f.endsWith('.md'));
const verFileNames = dirEntries.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'));
// Filter UAT and VERIFICATION files using the same filter as cmdPhaseComplete,
// scoped to THIS phase's own token (#3511) — a stray, cross-phase, or ad-hoc
// file can no longer contribute a blocker to a phase it does not belong to.
const phaseDirBaseName = path.basename(phaseFullDir);
const uatFileNames = scopeToPhase(
dirEntries.filter(f => f.includes('-UAT') && f.endsWith('.md')),
phaseDirBaseName,
);
const verFileNames = scopeToPhase(
dirEntries.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md')),
phaseDirBaseName,
);
// ── Process UAT files ──────────────────────────────────────────────────────
for (const file of uatFileNames) {

View File

@@ -31,7 +31,7 @@ import frontmatter = require('./frontmatter.cjs');
const { extractFrontmatter } = frontmatter;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
const { PHASE_NUMBER_TOKEN_SOURCE, scopeToPhase } = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseLocator = require('./phase-locator.cjs');
const { getArchivedPhaseDirs, listMilestonePhaseDirs } = phaseLocator;
@@ -134,8 +134,12 @@ function cmdAuditUat(cwd: string, raw: boolean): void {
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
const files = fs.readdirSync(phaseDir);
// Process UAT files
for (const file of files.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
// Process UAT files — scoped to THIS phase's own token (#3511) via
// scopeToPhase, so a stray, cross-phase, or ad-hoc file cannot be reported
// under this phase's audit-uat entry. A phase whose own UAT file is
// genuinely absent scopes to empty and contributes nothing — correct, and
// the reason scopeToPhase has no unfiltered fallback.
for (const file of scopeToPhase(files.filter(f => f.includes('-UAT') && f.endsWith('.md')), dir)) {
const uatFilePath = path.join(phaseDir, file);
const content = fs.readFileSync(uatFilePath, 'utf-8');
const items = parseUatItems(content);
@@ -153,8 +157,9 @@ function cmdAuditUat(cwd: string, raw: boolean): void {
}
}
// Process VERIFICATION files
for (const file of files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
// Process VERIFICATION files — scoped to THIS phase's own token (#3511)
// for the same reason as the UAT loop above.
for (const file of scopeToPhase(files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md')), dir)) {
const verificationFilePath = path.join(phaseDir, file);
const content = fs.readFileSync(verificationFilePath, 'utf-8');
const status = extractFrontmatter(content, verificationFilePath).status as string || 'unknown';

View File

@@ -43,7 +43,7 @@ import { execGit } from './shell-command-projection.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
const { output, error } = io;
const { extractPhaseToken } = phaseId;
const { extractPhaseToken, scopeToPhase } = phaseId;
const { extractFrontmatter } = frontmatterMod;
const { SCOPE } = planningScopeMod;
type Scope = planningScopeMod.Scope;
@@ -309,10 +309,20 @@ interface ResolveVerificationFileOptions {
* (`12-review-VERIFICATION.md`) — a regression this option closes.
*
* Omitted / empty when the token cannot be derived: falls back to plain
* alphabetically-first among all dashed candidates (the original pre-#3357
* behavior), never to null.
* alphabetically-first among the SCOPED dashed candidates (see
* `phaseDirName` below), never to null.
*/
phaseToken?: string;
/**
* #3511 reconciliation: the phase directory's own basename (the same value
* every call site already passes through `extractPhaseToken` to derive
* `phaseToken` above) — needed separately because the fallback below scopes
* by `isPhaseArtifact(fileName, phaseDirName)`, not by `phaseToken`.
*
* Omitted: the fallback degrades to the plain (unscoped) alphabetically-first
* pick — the original pre-#3357 behavior — never to null.
*/
phaseDirName?: string;
}
/**
@@ -332,22 +342,59 @@ type ResolveUatFileOptions = ResolveVerificationFileOptions;
* `bareName` is the artifact filename WITHOUT the leading dash (`'UAT.md'`);
* a "dashed" candidate is any entry ending `-${bareName}`.
*
* Selection order (identical to resolveVerificationFile's documented tiers,
* generalized off the suffix):
* Selection order:
* 1. `options.phaseToken` given and `<phaseToken>-${bareName}` is among
* the candidates — that exact file always wins: it is THIS phase's own
* artifact, and no other candidate (whichever phase's token it carries)
* can outrank it (#3492 / #3518).
* 2. Fallback — no exact phase-token match (or no token given):
* alphabetically first of ALL dashed candidates (deterministic —
* `entries` is an unsorted readdir listing whose order is
* filesystem-dependent, so the sort is what makes the answer
* machine-independent). Load-bearing: a phase whose only artifact is
* non-canonically named must keep resolving to it, not to null — the
* fix must not turn "found an artifact" into "found nothing".
* 3. `options.allowBare` only — a bare `${bareName}`, ranked BELOW both of
* the above (a dashed file names its phase; a bare one does not).
* Reached only when (1) and (2) found no dashed candidate at all.
* 2. Fallback — no exact phase-token match (or no token given): alphabetically
* first of the dashed candidates that are THIS phase's own, per
* `scopeToPhase(candidates, options.phaseDirName)` (#3511 reconciliation,
* below). Load-bearing: a phase whose only artifact is non-canonically
* named must keep resolving to it, not to null — this fix must not turn
* "found an artifact" into "found nothing" for anyone. A
* non-canonically-named artifact of THIS phase (e.g.
* `03-CORRECTION-VERIFICATION.md` in `03-foo`) still passes
* `isPhaseArtifact` (it names phase 03, same as the directory), so it
* is still returned here.
* 3. `options.allowBare` only — a bare `${bareName}`, ranked BELOW both
* of the above. Rationale: a dashed file names its phase, a bare one
* does not, so a dashed file (canonical or not) is always the better
* answer when both exist. Reached when neither (1) nor (2) found any
* candidate — including when (2)'s scoping filtered every dashed
* candidate out as belonging to some OTHER phase.
*
* #3511 RECONCILIATION with `isPhaseArtifact` (`src/phase-id.cts`): that
* predicate's own docblock used to flag this fallback as an open gap — its
* aggregate scans exclude a cross-phase stray, but this single-pick resolver
* did not, so it could return a stray as THE artifact while the aggregate
* scans correctly ignored it. Closed by scoping step (2) above through
* `scopeToPhase` (`src/phase-id.cts`, itself built on `isPhaseArtifact`):
* `options.phaseDirName` threads the phase directory's basename in, and the
* fallback now filters candidates through `scopeToPhase(candidates,
* phaseDirName)` before picking alphabetically-first. This does NOT reopen
* the #3357 guarantee — that guarantee is "a phase whose only report is
* non-canonically named must keep working", and a non-canonically-named
* artifact of THIS phase still passes `isPhaseArtifact` (it is membership by
* phase number, not by canonical shape), so it is still returned. Only a
* file belonging to a DIFFERENT phase is now excluded — and excluding it is
* correct: returning another phase's artifact as this phase's own is worse
* than reporting none (confidently wrong beats honestly empty).
* The fail-safe now lives entirely inside `isPhaseArtifact`, not in
* `scopeToPhase` (which is a plain filter with no unfiltered fallback):
* (a) when phase-number membership cannot be determined for `phaseDirName` at
* all (no reliable token — the zero-token directory case), every candidate is
* treated as belonging to the phase; (b) the `firstLetterPrefixed`
* bracket-ambiguity case, where a letter-prefixed-decimal dir is
* string-indistinguishable from a bracket-dir token, also includes
* everything rather than guess; (c) a token-less filename (bare
* `${bareName}`) is accepted by directory containment alone. Outside
* those cases, when scoping DOES remove every dashed candidate — a real
* cross-phase stray, or a phase whose own artifact is genuinely absent — the
* fallback below correctly falls through to `allowBare`/`null`: reporting no
* artifact, not another phase's. `options.phaseDirName` omitted entirely skips
* the filter outright (the ternary below), which is unscoped, pre-#3511
* behavior.
*
* Pure — takes an already-read directory listing and does no I/O of its own,
* so every call site keeps its existing `fsImpl` seam and no-throw contract
@@ -364,7 +411,16 @@ function resolvePhaseArtifactFile(
const thisPhaseFile = `${options.phaseToken}-${bareName}`;
if (candidates.includes(thisPhaseFile)) return thisPhaseFile;
}
return candidates[0];
// #3511: scope the fallback to files that belong to THIS phase, so a
// stray cross-phase file can no longer outrank a return of null.
// `phaseDirName` omitted, or membership undeterminable for it, →
// unscoped `candidates` (pre-#3511 behavior); otherwise strays are
// filtered out, and if that leaves nothing the code falls through to
// `allowBare`/`null` deliberately.
const scoped = options.phaseDirName
? scopeToPhase(candidates, options.phaseDirName)
: candidates;
if (scoped.length > 0) return scoped[0];
}
if (options.allowBare && entries.includes(bareName)) return bareName;
return null;
@@ -384,9 +440,11 @@ function resolvePhaseArtifactFile(
* single resolver both now call (#3473 F2).
*
* Selection order: see `resolvePhaseArtifactFile` (the shared core this
* delegates to since #3518) — phase-token-pinned, then alphabetically-first
* dashed fallback, then (allowBare only) a bare `VERIFICATION.md`. Behavior
* is byte-identical to the pre-#3518 standalone implementation.
* delegates to since #3518, itself phase-scoped since #3511) —
* phase-token-pinned, then phase-scoped alphabetically-first dashed
* fallback, then (allowBare only) a bare `VERIFICATION.md`. #3518 extracted
* this into the shared core without changing behavior; #3511's
* `phaseDirName` scoping now lives inside that shared core rather than here.
*/
function resolveVerificationFile(
entries: string[],
@@ -411,7 +469,11 @@ function resolveVerificationFile(
* is consumed downstream by workflows that then read the named file, so a
* wrong path routes UAT state from another phase.
*
* Deterministic by construction: same answer on every machine.
* Deterministic by construction: same answer on every machine. Phase-scoped
* (#3511): passing `options.phaseDirName` filters the alphabetically-first
* fallback (tier 2) to artifacts that belong to THIS phase — see
* `resolvePhaseArtifactFile` for the full selection order and scoping
* rationale.
*/
function resolveUatFile(
entries: string[],
@@ -477,9 +539,11 @@ function findStaleVerificationSummary(
const phaseFiles = fsImpl.readdirSync(phaseDir);
// #3492: pin selection to THIS phase's own token so a stray cross-phase
// or sentinel-numbered canonically-shaped file cannot outrank this
// phase's own (possibly non-canonical) report.
const phaseToken = extractPhaseToken(path.basename(phaseDir));
const verificationFile = resolveVerificationFile(phaseFiles, { phaseToken });
// phase's own (possibly non-canonical) report. #3511: phaseDirName scopes
// the fallback path to this same phase (see resolveVerificationFile docs).
const phaseDirName = path.basename(phaseDir);
const phaseToken = extractPhaseToken(phaseDirName);
const verificationFile = resolveVerificationFile(phaseFiles, { phaseToken, phaseDirName });
if (!verificationFile) return { determined: true, stale: false };
const summaryFiles = (scanPhasePlans(phaseDir) as { summaryFiles: string[] }).summaryFiles
@@ -522,8 +586,8 @@ function findStaleVerificationSummary(
* Behavior:
* 1. Find the phase's verification report via `resolveVerificationFile`
* (canonical `<phase-token>-VERIFICATION.md` preferred; falls back to the
* alphabetically-first `*-VERIFICATION.md` when none is canonical — #3357).
* If none → status 'missing'.
* alphabetically-first `*-VERIFICATION.md` that belongs to THIS phase when
* none is canonical — #3357/#3511). If none → status 'missing'.
* 2. Extract `status` from FRONTMATTER ONLY via the shared extractFrontmatter
* parser (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix — parser anchors at byte 0).
* If no frontmatter block or no `status` key → status 'missing'.
@@ -569,8 +633,9 @@ function readVerificationStatus(
// #3492: pin selection to THIS phase's own token (already derived above
// for the routed command argument) so a stray cross-phase or
// sentinel-numbered canonically-shaped file cannot outrank this phase's
// own (possibly non-canonical) report.
verificationFile = resolveVerificationFile(entries, { phaseToken });
// own (possibly non-canonical) report. #3511: baseName also scopes the
// fallback path to this same phase (see resolveVerificationFile docs).
verificationFile = resolveVerificationFile(entries, { phaseToken, phaseDirName: baseName });
} catch {
// Directory unreadable → treat as missing
verificationFile = null;
@@ -775,8 +840,9 @@ function cmdVerificationResolveFile(cwd: string, phaseDirArg: string | undefined
let verificationPath = '';
try {
const entries = fs.readdirSync(phaseDir);
const phaseToken = extractPhaseToken(path.basename(phaseDir));
const verificationFile = resolveVerificationFile(entries, { allowBare: true, phaseToken });
const phaseDirName = path.basename(phaseDir);
const phaseToken = extractPhaseToken(phaseDirName);
const verificationFile = resolveVerificationFile(entries, { allowBare: true, phaseToken, phaseDirName });
if (verificationFile) {
verificationPath = path.join(phaseDir, verificationFile);
}