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

@@ -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');