This commit is contained in:
137
src/phase.cts
137
src/phase.cts
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user