/** * Phase Locator — Phase-directory search and location * * ADR-857 rollout phase 2d: extracted from core.cts (issue #881). * Owns active-phase discovery against the `.planning/phases/` tree * (`searchPhaseInDir`, `findPhaseInternal`) and archived-phase-dir * enumeration (`getArchivedPhaseDirs`), matching phase ids/tokens against * the filesystem. Behaviour is preserved byte-for-behaviour from the prior * location; only the module boundary moved. The core.cjs re-export spine * was retired in epic #1267; callers import phase-locator helpers directly. * * Dependencies (leaf modules only — no loadConfig): * - node:fs / node:path (stdlib) * - ./phase-id.cjs (normalizePhaseName, phaseTokenMatches, extractPhaseToken) * - ./core-utils.cjs (readSubdirectories, getPhaseFileStats, extractCanonicalPlanId, toPosixPath) * - ./planning-workspace.cjs (planningDir) */ 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 { normalizePhaseName, phaseTokenMatches, extractPhaseToken } = phaseIdModule; // eslint-disable-next-line @typescript-eslint/no-require-imports import coreUtilsModule = require('./core-utils.cjs'); const { readSubdirectories, getPhaseFileStats, extractCanonicalPlanId, toPosixPath } = coreUtilsModule; // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspace = require('./planning-workspace.cjs'); const { planningDir } = planningWorkspace; // ─── Phase search types ─────────────────────────────────────────────────────── interface PhaseSearchResult { found: boolean; directory: string; phase_number: string; phase_name: string | null; phase_slug: string | null; plans: string[]; summaries: string[]; incomplete_plans: string[]; has_research: boolean; has_context: boolean; has_verification: boolean; has_reviews: boolean; archived?: string; ambiguous_matches?: string[]; } interface ArchivedPhaseDir { name: string; milestone: string; basePath: string; fullPath: string; } // ─── Phase search helpers ───────────────────────────────────────────────────── function searchPhaseInDir(baseDir: string, relBase: string, normalized: string): PhaseSearchResult | null { try { const dirs = readSubdirectories(baseDir, true); const matches = dirs.filter(d => phaseTokenMatches(d, normalized)); if (matches.length === 0) return null; // #2237: fail loud when multiple directories match the same bare phase // number — this happens when unrelated projects share a .planning/phases/ // tree. Silently taking the first match risks cross-project file writes. if (matches.length > 1) { return { found: false, directory: '', phase_number: normalized, phase_name: null, phase_slug: null, plans: [], summaries: [], incomplete_plans: [], has_research: false, has_context: false, has_verification: false, has_reviews: false, ambiguous_matches: matches, }; } const match = matches[0]; const phaseToken = extractPhaseToken(match); const phaseNumber = phaseToken || normalized; const afterToken = match.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, ''); const phaseName = afterToken || null; const phaseDir = path.join(baseDir, match); const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = getPhaseFileStats(phaseDir); const plans = unsortedPlans.sort(); const summaries = unsortedSummaries.sort(); const completedPlanIds = new Set( summaries.flatMap(s => { const exact = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''); const canonical = extractCanonicalPlanId(s); return canonical === exact ? [exact] : [exact, canonical]; }) ); const incompletePlans = plans.filter(p => { const planId = p.replace('-PLAN.md', '').replace('PLAN.md', ''); const canonical = extractCanonicalPlanId(p); return !completedPlanIds.has(planId) && !completedPlanIds.has(canonical); }); return { found: true, directory: toPosixPath(path.join(relBase, match)), phase_number: phaseNumber, phase_name: phaseName, phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null, plans, summaries, incomplete_plans: incompletePlans, has_research: hasResearch, has_context: hasContext, has_verification: hasVerification, has_reviews: hasReviews, }; } catch { return null; } } function findPhaseInternal(cwd: string, phase: unknown): 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); if (current) return current; const milestonesDir = path.join(cwd, '.planning', 'milestones'); if (!fs.existsSync(milestonesDir)) return null; try { const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true }); const archiveDirs = milestoneEntries .filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name)) .map(e => e.name) .sort() .reverse(); for (const archiveName of archiveDirs) { const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/); const version = versionMatch![1]; const archivePath = path.join(milestonesDir, archiveName); const relBase = '.planning/milestones/' + archiveName; const result = searchPhaseInDir(archivePath, relBase, normalized); if (result) { result.archived = version; return result; } } } catch { /* intentionally empty */ } return null; } function getArchivedPhaseDirs(cwd: string): ArchivedPhaseDir[] { const milestonesDir = path.join(cwd, '.planning', 'milestones'); const results: ArchivedPhaseDir[] = []; if (!fs.existsSync(milestonesDir)) return results; try { const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true }); const phaseDirs = milestoneEntries .filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name)) .map(e => e.name) .sort() .reverse(); for (const archiveName of phaseDirs) { const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/); const version = versionMatch![1]; const archivePath = path.join(milestonesDir, archiveName); const dirs = readSubdirectories(archivePath, true); for (const dir of dirs) { results.push({ name: dir, milestone: version, basePath: path.join('.planning', 'milestones', archiveName), fullPath: path.join(archivePath, dir), }); } } } catch { /* intentionally empty */ } return results; } export = { searchPhaseInDir, findPhaseInternal, getArchivedPhaseDirs, };