* fix(#2237): fail loud on ambiguous bare-number phase directory collision When two unrelated projects share a .planning/phases/ tree, a bare phase number silently resolved to the first 0N-* directory found — risking cross-project file writes. The fix detects multiple matches for the same phase number and surfaces an ambiguous_matches result instead of silently taking the first. Changes: - src/phase-locator.cts: searchPhaseInDir uses filter() + ambiguity check - src/phase.cts: cmdFindPhase same pattern - src/init.cts: cmdInitPhaseOp surfaces ambiguous_matches in the result - tests/phase-locator.test.cjs: 3 regression tests * docs: backfill changeset PR number (#2262) * merge: keep up to date with next * fix: regenerate stale capability-registry after next merge
205 lines
7.2 KiB
TypeScript
205 lines
7.2 KiB
TypeScript
/**
|
|
* 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,
|
|
};
|