fix(#4801): init.manager resolves archived phase directories through findPhaseInternal (#4893)

* test(#4801): failing-first — an archived phase directory must resolve and report complete in init.manager

* fix(#4801): init.manager resolves archived phase directories through findPhaseInternal

The private current-milestone-only scan (matchPhaseDirs over listMilestonePhaseDirs)
counted archived phase directories as missing, so an archived phase with a passed
verification reported no_directory/phase_complete:false. findPhaseInternal — already
imported, already the shared primitive for five other init commands — searches the live
directory first and falls back through the workstream-scoped archive (#2855); the private
copy and its single-consumer entries list are retired.

* chore(#4801): backfill changeset PR number (4893)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-20 06:45:38 -04:00
committed by GitHub
parent 88b5775dc8
commit a8394713d1
5 changed files with 120 additions and 25 deletions

View File

@@ -102,7 +102,6 @@ const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils;
const {
comparePhaseNum,
normalizePhaseName,
matchPhaseDirs,
stripProjectCodePrefix,
PHASE_NUMBER_TOKEN_SOURCE,
PHASE_DEP_REF_SOURCE,
@@ -2865,18 +2864,12 @@ function cmdInitManager(cwd: string, raw: boolean): void {
}
const rawContent = fs.readFileSync(paths.roadmap, 'utf-8');
const content = extractCurrentMilestone(rawContent, cwd);
const phasesDir = paths.phases;
// #3185 (ADR-3180 Decision 1): "which phase directories belong to the
// CURRENT milestone" is the scoped question listMilestonePhaseDirs owns —
// routed through it instead of a hand-rolled readdirSync + a separate
// getMilestonePhaseFilter window check (which also never excluded
// sentinels, unlike the owner).
const _phaseDirEntries = listMilestonePhaseDirs(phasesDir, {
cwd,
phaseIdConvention,
}).value;
const _checkboxStates = new Map<string, boolean>();
const _cbPattern = new RegExp(
`-\\s*\\[(x| )\\]\\s*.*${phaseHeadingPrefix}(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`,
@@ -2928,7 +2921,6 @@ function cmdInitManager(cwd: string, raw: boolean): void {
const dependsMatch = section.match(/\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i);
const depends_on = dependsMatch ? dependsMatch[1].trim() : null;
const normalized = normalizePhaseName(phaseNum);
let diskStatus = 'no_directory';
let planCount = 0;
let summaryCount = 0;
@@ -2949,19 +2941,24 @@ function cmdInitManager(cwd: string, raw: boolean): void {
);
try {
// #3185 (ADR-3180 Decision 2) moved this lookup off the
// milestone-scoped set and onto the physical one; that scope choice is
// kept. Only the matcher is this PR's: matchPhaseDirs resolves
// digit-leading directory names the token predicate cannot (#2528).
const dirMatch = matchPhaseDirs(
_phaseDirEntries,
normalized,
phaseIdConvention,
).matches[0];
// #4801: resolve through the canonical locator instead of a private
// current-milestone-only scan. findPhaseInternal searches the live
// .planning/phases directory FIRST (same set the retired
// matchPhaseDirs/_phaseDirEntries pair scanned — the #3185 physical-dir
// scope choice is inherited by the locator's live arm) and then falls
// back through listArchiveVersionDirs (workstream-scoped, #2855), so an
// ARCHIVED phase directory with a passing verification resolves instead
// of reporting no_directory/phase_complete:false. Five other init
// commands already route through this same primitive; this was the
// holdout private copy (#4793-family declared-owner drift).
const located = findPhaseInternal(cwd, phaseNum, phaseIdConvention) as unknown as Record<string, unknown> | null;
const dirMatch = located && located['found']
? path.posix.basename(String(located['directory']))
: null;
if (dirMatch) {
const fullDir = path.join(phasesDir, dirMatch);
const phaseDirRel = toPosixPath(path.relative(cwd, fullDir));
const fullDir = path.join(cwd, String(located!['directory']));
const phaseDirRel = String(located!['directory']);
// #4014 (epic #3473 B4-unreadable): this whole block used to swallow
// ANY readdirSync failure below into the bare `catch { /* empty */ }`
// at the bottom — an unreadable phase directory reported the exact

View File

@@ -204,13 +204,15 @@ function listArchiveVersionDirs(cwd: string, wsOverride?: string | null): Archiv
return out;
}
function searchPhaseInDir(baseDir: string, relBase: string, normalized: string): PhaseSearchResult | null {
function searchPhaseInDir(baseDir: string, relBase: string, normalized: string, convention?: string | null): PhaseSearchResult | null {
try {
const dirs = readSubdirectories(baseDir, true);
// #2528: canonical two-pass selection (exact token match, then the
// bare-integer leading-digit-run fallback) shared with the find-phase and
// phase-plan-index scans — see phase-id.cts::matchPhaseDirs.
const { matches, usedBareFallback } = matchPhaseDirs(dirs, normalized);
// #4801: the convention is threaded (optional) so bracket-convention
// consumers get exact token matching here too.
const { matches, usedBareFallback } = matchPhaseDirs(dirs, normalized, convention);
if (matches.length === 0) return null;
// #2237: fail loud when multiple directories match the same bare phase
@@ -351,14 +353,15 @@ function searchPhaseInDir(baseDir: string, relBase: string, normalized: string):
}
}
function findPhaseInternal(cwd: string, phase: unknown): PhaseSearchResult | null {
function findPhaseInternal(cwd: string, phase: unknown, convention?: string | null): PhaseSearchResult | null {
if (!phase) return null;
const phasesDir = path.join(planningDir(cwd), 'phases');
const normalized = normalizePhaseName(phase);
const relPhasesDir = toPosixPath(path.relative(cwd, phasesDir));
const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized);
// #4801: convention threaded through to the matcher (see searchPhaseInDir).
const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized, convention);
if (current) return current;
// #2855: scope the archived-milestone fallback to the SAME workstream as the
@@ -372,7 +375,7 @@ function findPhaseInternal(cwd: string, phase: unknown): PhaseSearchResult | nul
// getArchivedPhaseDirs via listArchiveVersionDirs (see its doc comment).
for (const { version, archivePath } of listArchiveVersionDirs(cwd)) {
const relBase = toPosixPath(path.relative(cwd, archivePath));
const result = searchPhaseInDir(archivePath, relBase, normalized);
const result = searchPhaseInDir(archivePath, relBase, normalized, convention);
if (result) {
result.archived = version;
return result;