ADR-857 rollout phase 2d. Move the phase-directory search/location functions (searchPhaseInDir, findPhaseInternal, getArchivedPhaseDirs) + their interfaces (PhaseSearchResult, ArchivedPhaseDir) out of core.cts into a new module src/phase-locator.cts. core.cts re-exports all three (callers unchanged). Cycle-free: phase-locator depends only on leaves (phase-id for token/name matching, core-utils for fs-scan/path helpers, planning-workspace for planningDir) — unblocked by the core-utils leaf (2c). This completes the phase-search split: parsing in phase-id (2a), fs-search in phase-locator. New-CLI-module checklist done (.gitignore, eslint, INVENTORY 94->95 + row, manifest, ARCHITECTURE, CONTEXT.md "Phase Locator Module"). Adds tests/phase-locator.test.cjs (37 tests: behavioral + shim-identity + adversarial phase-dir fixtures). Gates: lint, code-review, security-review, codex adversarial-review (0 findings; verbatim move checksum-verified). Mac 4078 pass; clean-build docker 12972 pass, 0 fail. Closes #881 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -130,6 +130,7 @@ build/
|
|||||||
/gsd-core/bin/lib/core-utils.cjs
|
/gsd-core/bin/lib/core-utils.cjs
|
||||||
/gsd-core/bin/lib/io.cjs
|
/gsd-core/bin/lib/io.cjs
|
||||||
/gsd-core/bin/lib/phase-id.cjs
|
/gsd-core/bin/lib/phase-id.cjs
|
||||||
|
/gsd-core/bin/lib/phase-locator.cjs
|
||||||
/gsd-core/bin/lib/roadmap-parser.cjs
|
/gsd-core/bin/lib/roadmap-parser.cjs
|
||||||
/gsd-core/bin/lib/drift.cjs
|
/gsd-core/bin/lib/drift.cjs
|
||||||
/gsd-core/bin/lib/cjs-command-router-adapter.cjs
|
/gsd-core/bin/lib/cjs-command-router-adapter.cjs
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ Module owning the pure phase-id parsing and matching helpers: phase-name normali
|
|||||||
### Phase Lifecycle Module
|
### Phase Lifecycle Module
|
||||||
Module owning phase create, rename, complete, remove, list, and plan-index operations, plus phase-dir prefix validation, STATE.md staleness detection, and auto-prune behaviour. Entry point: `gsd-core/bin/lib/phase.cjs` (CJS surface). Typed phase events: `GSDPhaseStartEvent`, `GSDPhaseStepStartEvent`, `GSDPhaseStepCompleteEvent`, `GSDPhaseCompleteEvent`. (The SDK native-query surface, the `types.ts` event definitions, `phase-runner.ts`, and `phase-prompt.ts` were retired with the SDK package per ADR-0174.)
|
Module owning phase create, rename, complete, remove, list, and plan-index operations, plus phase-dir prefix validation, STATE.md staleness detection, and auto-prune behaviour. Entry point: `gsd-core/bin/lib/phase.cjs` (CJS surface). Typed phase events: `GSDPhaseStartEvent`, `GSDPhaseStepStartEvent`, `GSDPhaseStepCompleteEvent`, `GSDPhaseCompleteEvent`. (The SDK native-query surface, the `types.ts` event definitions, `phase-runner.ts`, and `phase-prompt.ts` were retired with the SDK package per ADR-0174.)
|
||||||
|
|
||||||
|
### Phase Locator Module
|
||||||
|
Module owning phase-directory search and location: active-phase discovery against the `.planning/phases/` tree (`searchPhaseInDir`, `findPhaseInternal`) and archived-phase-dir enumeration (`getArchivedPhaseDirs`), matching phase ids/tokens against the filesystem. Depends only on leaf modules (`phase-id` for token/name matching, `core-utils` for fs-scan/path helpers, `planning-workspace` for `planningDir`) — no `loadConfig`, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2d (#881); `core.cjs` re-exports `searchPhaseInDir`, `findPhaseInternal`, and `getArchivedPhaseDirs` for back-compat. Source of truth: `gsd-core/bin/lib/phase-locator.cjs` (generated from `src/phase-locator.cts`).
|
||||||
|
|
||||||
### Dispatch Policy Module
|
### Dispatch Policy Module
|
||||||
Module owning dispatch error mapping, fallback policy, timeout classification, and CLI exit mapping contract.
|
Module owning dispatch error mapping, fallback policy, timeout classification, and CLI exit mapping contract.
|
||||||
|
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core
|
|||||||
| `core.cjs` | Shared utilities; compatibility re-exports for planning, I/O (`io.cjs`), and phase-id helpers |
|
| `core.cjs` | Shared utilities; compatibility re-exports for planning, I/O (`io.cjs`), and phase-id helpers |
|
||||||
| `io.cjs` | CLI I/O primitives — output/error emission, JSON-error mode, large-payload temp-file spillover |
|
| `io.cjs` | CLI I/O primitives — output/error emission, JSON-error mode, large-payload temp-file spillover |
|
||||||
| `phase-id.cjs` | Pure phase-id parsing/matching helpers — normalize, token match, regex builders (extracted from `core.cjs`, ADR-857) |
|
| `phase-id.cjs` | Pure phase-id parsing/matching helpers — normalize, token match, regex builders (extracted from `core.cjs`, ADR-857) |
|
||||||
|
| `phase-locator.cjs` | Phase-directory search and location — active-phase discovery (`searchPhaseInDir`, `findPhaseInternal`) and archived-phase-dir enumeration (`getArchivedPhaseDirs`), matching phase ids/tokens against the filesystem (extracted from `core.cjs`, ADR-857) |
|
||||||
| `roadmap-parser.cjs` | ROADMAP.md parsing — milestone slicing, current-milestone extraction, phase/milestone lookups, milestone-phase filter (extracted from `core.cjs`, ADR-857) |
|
| `roadmap-parser.cjs` | ROADMAP.md parsing — milestone slicing, current-milestone extraction, phase/milestone lookups, milestone-phase filter (extracted from `core.cjs`, ADR-857) |
|
||||||
| `planning-workspace.cjs` | Planning seam (`planningDir`, `planningPaths`, active workstream routing, `.planning/.lock`) |
|
| `planning-workspace.cjs` | Planning seam (`planningDir`, `planningPaths`, active workstream routing, `.planning/.lock`) |
|
||||||
| `state.cjs` | STATE.md parsing, updating, progression, metrics |
|
| `state.cjs` | STATE.md parsing, updating, progression, metrics |
|
||||||
|
|||||||
@@ -313,6 +313,7 @@
|
|||||||
"phase-command-router.cjs",
|
"phase-command-router.cjs",
|
||||||
"phase-id.cjs",
|
"phase-id.cjs",
|
||||||
"phase-lifecycle.cjs",
|
"phase-lifecycle.cjs",
|
||||||
|
"phase-locator.cjs",
|
||||||
"phase.cjs",
|
"phase.cjs",
|
||||||
"phases-command-router.cjs",
|
"phases-command-router.cjs",
|
||||||
"plan-scan.cjs",
|
"plan-scan.cjs",
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CLI Modules (94 shipped)
|
## CLI Modules (95 shipped)
|
||||||
|
|
||||||
Full listing: `gsd-core/bin/lib/*.cjs`.
|
Full listing: `gsd-core/bin/lib/*.cjs`.
|
||||||
|
|
||||||
@@ -424,6 +424,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
|
|||||||
| `phase-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phase` |
|
| `phase-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phase` |
|
||||||
| `phase-id.cjs` | Pure phase-id parsing/matching helpers — normalize, token match, milestone/phase-dir id parsing, phase-markdown regex builders (extracted from `core.cjs`, ADR-857) |
|
| `phase-id.cjs` | Pure phase-id parsing/matching helpers — normalize, token match, milestone/phase-dir id parsing, phase-markdown regex builders (extracted from `core.cjs`, ADR-857) |
|
||||||
| `phase-lifecycle.cjs` | Pure-computation phase lifecycle helpers extracted from the phase-lifecycle SDK handler |
|
| `phase-lifecycle.cjs` | Pure-computation phase lifecycle helpers extracted from the phase-lifecycle SDK handler |
|
||||||
|
| `phase-locator.cjs` | Phase-directory search/location — active + archived phase-dir discovery, phase-id matching against the filesystem (extracted from `core.cjs`, ADR-857) |
|
||||||
| `phase.cjs` | Phase directory operations, decimal numbering, plan indexing |
|
| `phase.cjs` | Phase directory operations, decimal numbering, plan indexing |
|
||||||
| `phases-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phases` |
|
| `phases-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phases` |
|
||||||
| `plan-scan.cjs` | Canonical phase-plan scanner for detecting plan and summary files in flat and nested layouts (k014) |
|
| `plan-scan.cjs` | Canonical phase-plan scanner for detecting plan and summary files in flat and nested layouts (k014) |
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export default tseslint.config(
|
|||||||
'gsd-core/bin/lib/core-utils.cjs',
|
'gsd-core/bin/lib/core-utils.cjs',
|
||||||
'gsd-core/bin/lib/io.cjs',
|
'gsd-core/bin/lib/io.cjs',
|
||||||
'gsd-core/bin/lib/phase-id.cjs',
|
'gsd-core/bin/lib/phase-id.cjs',
|
||||||
|
'gsd-core/bin/lib/phase-locator.cjs',
|
||||||
'gsd-core/bin/lib/roadmap-parser.cjs',
|
'gsd-core/bin/lib/roadmap-parser.cjs',
|
||||||
'gsd-core/bin/lib/drift.cjs',
|
'gsd-core/bin/lib/drift.cjs',
|
||||||
'gsd-core/bin/lib/cjs-command-router-adapter.cjs',
|
'gsd-core/bin/lib/cjs-command-router-adapter.cjs',
|
||||||
|
|||||||
154
src/core.cts
154
src/core.cts
@@ -56,8 +56,10 @@ const {
|
|||||||
getPhaseFileStats,
|
getPhaseFileStats,
|
||||||
readSubdirectories,
|
readSubdirectories,
|
||||||
timeAgo,
|
timeAgo,
|
||||||
extractCanonicalPlanId,
|
|
||||||
} = coreUtilsModule;
|
} = coreUtilsModule;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
import phaseLocatorModule = require('./phase-locator.cjs');
|
||||||
|
const { searchPhaseInDir, findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorModule;
|
||||||
import { findProjectRoot } from './project-root.cjs';
|
import { findProjectRoot } from './project-root.cjs';
|
||||||
import { getGlobalConfigDir } from './runtime-homes.cjs';
|
import { getGlobalConfigDir } from './runtime-homes.cjs';
|
||||||
|
|
||||||
@@ -520,150 +522,14 @@ function pruneOrphanedWorktrees(repoRoot: string): string[] {
|
|||||||
// — all imported via `phaseIdModule` above; internal callers use the destructured bindings.
|
// — all imported via `phaseIdModule` above; internal callers use the destructured bindings.
|
||||||
|
|
||||||
// extractCanonicalPlanId moved to core-utils.cjs (ADR-857 phase 2c / #877).
|
// extractCanonicalPlanId moved to core-utils.cjs (ADR-857 phase 2c / #877).
|
||||||
// The destructured binding above (from coreUtilsModule) makes it available to
|
// It is consumed exclusively by phase-locator.cjs, which imports it from
|
||||||
// core-internal callers (searchPhaseInDir). It is NOT in core.cjs's public export =
|
// core-utils.cjs directly. It is NOT destructured in core.cts and is NOT
|
||||||
// block (it was never public).
|
// in core.cjs's public export = block (it was never public).
|
||||||
|
|
||||||
interface PhaseSearchResult {
|
// searchPhaseInDir, findPhaseInternal, getArchivedPhaseDirs moved to phase-locator.cjs
|
||||||
found: boolean;
|
// (ADR-857 phase 2d / #881). The destructured bindings above (from phaseLocatorModule)
|
||||||
directory: string;
|
// make them available to core-internal callers; core.cjs re-exports findPhaseInternal,
|
||||||
phase_number: string;
|
// getArchivedPhaseDirs, and searchPhaseInDir for back-compat.
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchPhaseInDir(baseDir: string, relBase: string, normalized: string): PhaseSearchResult | null {
|
|
||||||
try {
|
|
||||||
const dirs = readSubdirectories(baseDir, true);
|
|
||||||
const match = dirs.find(d => phaseTokenMatches(d, normalized));
|
|
||||||
if (!match) return null;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ArchivedPhaseDir {
|
|
||||||
name: string;
|
|
||||||
milestone: string;
|
|
||||||
basePath: string;
|
|
||||||
fullPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Roadmap milestone scoping (re-exported from roadmap-parser.cjs) ──────────
|
// ─── Roadmap milestone scoping (re-exported from roadmap-parser.cjs) ──────────
|
||||||
// stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone,
|
// stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone,
|
||||||
|
|||||||
183
src/phase-locator.cts
Normal file
183
src/phase-locator.cts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* 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. core.cjs re-exports
|
||||||
|
* `searchPhaseInDir`, `findPhaseInternal`, and `getArchivedPhaseDirs` for back-compat.
|
||||||
|
*
|
||||||
|
* New imports should pull phase-locator helpers from phase-locator.cjs
|
||||||
|
* directly.
|
||||||
|
*
|
||||||
|
* Dependencies (leaf modules only — no core.cjs, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 match = dirs.find(d => phaseTokenMatches(d, normalized));
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
430
tests/phase-locator.test.cjs
Normal file
430
tests/phase-locator.test.cjs
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
/**
|
||||||
|
* Tests for src/phase-locator.cts (compiled to gsd-core/bin/lib/phase-locator.cjs).
|
||||||
|
*
|
||||||
|
* Verifies behavioural contracts of the phase-locator helpers extracted from
|
||||||
|
* core.cjs per ADR-857 rollout phase 2d (#881):
|
||||||
|
* - searchPhaseInDir
|
||||||
|
* - findPhaseInternal
|
||||||
|
* - getArchivedPhaseDirs
|
||||||
|
* - core.cjs re-export shims resolve to the exact same functions (shim-identity)
|
||||||
|
*
|
||||||
|
* Adversarial inputs: decimal/repeated phase ids, path-traversal-like names,
|
||||||
|
* unicode, missing/empty phases dir, milestone-prefixed dirs.
|
||||||
|
* Uses helpers.cjs createTempProject/cleanup for filesystem tests.
|
||||||
|
*
|
||||||
|
* Phase dir naming convention: zero-padded (e.g. "01-setup", "02-auth").
|
||||||
|
* normalizePhaseName('1') → '01'; phaseTokenMatches('01-setup', '01') → true.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { test, describe, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const phaseLocator = require('../gsd-core/bin/lib/phase-locator.cjs');
|
||||||
|
const core = require('../gsd-core/bin/lib/core.cjs');
|
||||||
|
const { createTempProject, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
// ─── Shim-identity assertions ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('phase-locator: shim-identity — core.cjs re-exports same function objects', () => {
|
||||||
|
test('core.findPhaseInternal === phaseLocator.findPhaseInternal', () => {
|
||||||
|
assert.strictEqual(core.findPhaseInternal, phaseLocator.findPhaseInternal);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('core.getArchivedPhaseDirs === phaseLocator.getArchivedPhaseDirs', () => {
|
||||||
|
assert.strictEqual(core.getArchivedPhaseDirs, phaseLocator.getArchivedPhaseDirs);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('core.searchPhaseInDir === phaseLocator.searchPhaseInDir', () => {
|
||||||
|
assert.strictEqual(core.searchPhaseInDir, phaseLocator.searchPhaseInDir);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── findPhaseInternal — basic active-phase lookup ────────────────────────────
|
||||||
|
|
||||||
|
describe('findPhaseInternal: active phase lookup', () => {
|
||||||
|
let tmpDir;
|
||||||
|
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
||||||
|
|
||||||
|
test('returns null for falsy phase argument', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
assert.strictEqual(phaseLocator.findPhaseInternal(tmpDir, null), null);
|
||||||
|
assert.strictEqual(phaseLocator.findPhaseInternal(tmpDir, ''), null);
|
||||||
|
assert.strictEqual(phaseLocator.findPhaseInternal(tmpDir, 0), null);
|
||||||
|
assert.strictEqual(phaseLocator.findPhaseInternal(tmpDir, undefined), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when phases dir does not exist', () => {
|
||||||
|
// Use a raw tmpDir (no phases subdir) to simulate missing phases dir
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'gsd-pl-test-'));
|
||||||
|
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||||||
|
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when phases dir is empty', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds a simple phase by number (zero-padded dir)', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
// Phase dirs use zero-padded format: normalizePhaseName('1') = '01'
|
||||||
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||||
|
fs.mkdirSync(phaseDir, { recursive: true });
|
||||||
|
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||||
|
assert.ok(result !== null, 'expected a result for phase 1');
|
||||||
|
assert.strictEqual(result.found, true);
|
||||||
|
assert.strictEqual(result.phase_number, '01');
|
||||||
|
assert.strictEqual(result.phase_name, 'setup');
|
||||||
|
assert.strictEqual(result.phase_slug, 'setup');
|
||||||
|
assert.ok(result.directory.includes('01-setup'));
|
||||||
|
assert.strictEqual(result.archived, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds phase by full normalized id', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-auth');
|
||||||
|
fs.mkdirSync(phaseDir, { recursive: true });
|
||||||
|
const result = phaseLocator.findPhaseInternal(tmpDir, '02');
|
||||||
|
assert.ok(result !== null);
|
||||||
|
assert.strictEqual(result.phase_number, '02');
|
||||||
|
assert.strictEqual(result.phase_name, 'auth');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports plans and summaries from phase directory', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-impl');
|
||||||
|
fs.mkdirSync(phaseDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(phaseDir, 'FEATURE-PLAN.md'), '# Plan');
|
||||||
|
fs.writeFileSync(path.join(phaseDir, 'FEATURE-SUMMARY.md'), '# Summary');
|
||||||
|
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||||
|
assert.ok(result !== null);
|
||||||
|
assert.ok(result.plans.includes('FEATURE-PLAN.md'));
|
||||||
|
assert.ok(result.summaries.includes('FEATURE-SUMMARY.md'));
|
||||||
|
assert.deepEqual(result.incomplete_plans, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('includes incomplete plans (plans without corresponding summaries)', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-work');
|
||||||
|
fs.mkdirSync(phaseDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(phaseDir, 'A-PLAN.md'), '# A Plan');
|
||||||
|
fs.writeFileSync(path.join(phaseDir, 'B-PLAN.md'), '# B Plan');
|
||||||
|
fs.writeFileSync(path.join(phaseDir, 'A-SUMMARY.md'), '# A Summary');
|
||||||
|
const result = phaseLocator.findPhaseInternal(tmpDir, '3');
|
||||||
|
assert.ok(result !== null);
|
||||||
|
assert.ok(result.incomplete_plans.includes('B-PLAN.md'));
|
||||||
|
assert.ok(!result.incomplete_plans.includes('A-PLAN.md'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('directory is a posix-style relative path from cwd', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||||
|
fs.mkdirSync(phaseDir, { recursive: true });
|
||||||
|
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||||
|
assert.ok(result !== null);
|
||||||
|
assert.ok(!result.directory.includes('\\'), 'directory should use forward slashes');
|
||||||
|
assert.ok(result.directory.startsWith('.planning/phases/'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when requested phase is not present', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-other'), { recursive: true });
|
||||||
|
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── findPhaseInternal — decimal/complex phase ids ────────────────────────────
|
||||||
|
|
||||||
|
describe('findPhaseInternal: decimal and complex phase ids (adversarial)', () => {
|
||||||
|
let tmpDir;
|
||||||
|
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
||||||
|
|
||||||
|
test('finds decimal sub-phase (e.g. 01.1)', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
// normalizePhaseName('1.1') = '01.1'
|
||||||
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01.1-subsection');
|
||||||
|
fs.mkdirSync(phaseDir, { recursive: true });
|
||||||
|
const result = phaseLocator.findPhaseInternal(tmpDir, '1.1');
|
||||||
|
assert.ok(result !== null, 'should find decimal phase 1.1');
|
||||||
|
assert.strictEqual(result.found, true);
|
||||||
|
assert.strictEqual(result.phase_number, '01.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decimal sub-phase dir is not matched by integer-only search', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
// Create only 01.1-sub, NOT 01-something: searching for '1' should return null
|
||||||
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01.1-sub');
|
||||||
|
fs.mkdirSync(phaseDir, { recursive: true });
|
||||||
|
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||||
|
// '01' does not match '01.1-sub' (they are distinct tokens)
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles phases with multi-segment names', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '05-some-long-phase-name');
|
||||||
|
fs.mkdirSync(phaseDir, { recursive: true });
|
||||||
|
const result = phaseLocator.findPhaseInternal(tmpDir, '5');
|
||||||
|
assert.ok(result !== null);
|
||||||
|
assert.strictEqual(result.phase_name, 'some-long-phase-name');
|
||||||
|
assert.strictEqual(result.phase_slug, 'some-long-phase-name');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('phase with unicode in name — does not throw', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
try {
|
||||||
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '06-中文');
|
||||||
|
fs.mkdirSync(phaseDir, { recursive: true });
|
||||||
|
const result = phaseLocator.findPhaseInternal(tmpDir, '6');
|
||||||
|
// If the filesystem supports unicode dir names, we get a result; if not, null is acceptable
|
||||||
|
if (result !== null) {
|
||||||
|
assert.strictEqual(result.found, true);
|
||||||
|
assert.ok(typeof result.phase_name === 'string' || result.phase_name === null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Some environments may not support unicode filenames; that's fine
|
||||||
|
assert.ok(e instanceof Error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── findPhaseInternal — archived phase search ────────────────────────────────
|
||||||
|
|
||||||
|
describe('findPhaseInternal: archived milestone phase lookup', () => {
|
||||||
|
let tmpDir;
|
||||||
|
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
||||||
|
|
||||||
|
test('finds archived phase when not in active phases', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
const milestonesDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0.0-phases');
|
||||||
|
fs.mkdirSync(path.join(milestonesDir, '01-archived'), { recursive: true });
|
||||||
|
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||||
|
assert.ok(result !== null, 'should find archived phase');
|
||||||
|
assert.strictEqual(result.found, true);
|
||||||
|
assert.strictEqual(result.archived, 'v1.0.0');
|
||||||
|
assert.ok(result.directory.startsWith('.planning/milestones/v1.0.0-phases/'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prefers active phase over archived phase', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
// Set up both active and archived phase 01
|
||||||
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-active'), { recursive: true });
|
||||||
|
const milestonesDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0.0-phases');
|
||||||
|
fs.mkdirSync(path.join(milestonesDir, '01-archived'), { recursive: true });
|
||||||
|
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||||
|
assert.ok(result !== null);
|
||||||
|
// Should return active (no archived property)
|
||||||
|
assert.strictEqual(result.archived, undefined);
|
||||||
|
assert.ok(result.directory.startsWith('.planning/phases/'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searches most recent milestone first (reverse sort)', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
// v1.2.0 archive has phase 03, v1.1.0 archive also has phase 03
|
||||||
|
const v110 = path.join(tmpDir, '.planning', 'milestones', 'v1.1.0-phases');
|
||||||
|
const v120 = path.join(tmpDir, '.planning', 'milestones', 'v1.2.0-phases');
|
||||||
|
fs.mkdirSync(path.join(v110, '03-old'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(v120, '03-new'), { recursive: true });
|
||||||
|
const result = phaseLocator.findPhaseInternal(tmpDir, '3');
|
||||||
|
assert.ok(result !== null);
|
||||||
|
// v1.2.0 is more recent; reverse-sort means it's checked first
|
||||||
|
assert.strictEqual(result.archived, 'v1.2.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when phase exists in neither active nor archive', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
const milestonesDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0.0-phases');
|
||||||
|
fs.mkdirSync(path.join(milestonesDir, '02-other'), { recursive: true });
|
||||||
|
const result = phaseLocator.findPhaseInternal(tmpDir, '99');
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when milestones dir does not exist', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
// No .planning/milestones dir — only .planning/phases (empty)
|
||||||
|
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores non-matching milestone dir names (not vX.Y.Z-phases)', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
// Directory that doesn't match /^v[\d.]+-phases$/ should be skipped
|
||||||
|
const badDir = path.join(tmpDir, '.planning', 'milestones', 'not-a-phases-dir');
|
||||||
|
fs.mkdirSync(path.join(badDir, '01-phase'), { recursive: true });
|
||||||
|
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── getArchivedPhaseDirs ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getArchivedPhaseDirs', () => {
|
||||||
|
let tmpDir;
|
||||||
|
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
||||||
|
|
||||||
|
test('returns empty array when .planning/milestones does not exist', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
const result = phaseLocator.getArchivedPhaseDirs(tmpDir);
|
||||||
|
assert.deepEqual(result, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns empty array when milestones dir has no matching phase-archive dirs', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
const milestonesDir = path.join(tmpDir, '.planning', 'milestones');
|
||||||
|
fs.mkdirSync(milestonesDir, { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(milestonesDir, 'not-phases-dir'));
|
||||||
|
const result = phaseLocator.getArchivedPhaseDirs(tmpDir);
|
||||||
|
assert.deepEqual(result, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns phase entries from a single milestone archive', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0.0-phases');
|
||||||
|
fs.mkdirSync(path.join(archiveDir, '01-feature'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(archiveDir, '02-bugfix'), { recursive: true });
|
||||||
|
const result = phaseLocator.getArchivedPhaseDirs(tmpDir);
|
||||||
|
assert.ok(Array.isArray(result));
|
||||||
|
assert.strictEqual(result.length, 2);
|
||||||
|
const names = result.map(r => r.name).sort();
|
||||||
|
assert.deepEqual(names, ['01-feature', '02-bugfix']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('result entries have correct shape', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v2.1.0-phases');
|
||||||
|
fs.mkdirSync(path.join(archiveDir, '03-auth'), { recursive: true });
|
||||||
|
const result = phaseLocator.getArchivedPhaseDirs(tmpDir);
|
||||||
|
assert.strictEqual(result.length, 1);
|
||||||
|
const entry = result[0];
|
||||||
|
assert.strictEqual(entry.name, '03-auth');
|
||||||
|
assert.strictEqual(entry.milestone, 'v2.1.0');
|
||||||
|
assert.strictEqual(entry.basePath, path.join('.planning', 'milestones', 'v2.1.0-phases'));
|
||||||
|
assert.strictEqual(entry.fullPath, path.join(archiveDir, '03-auth'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aggregates phases from multiple milestone archives (most recent first)', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
const v1Dir = path.join(tmpDir, '.planning', 'milestones', 'v1.0.0-phases');
|
||||||
|
const v2Dir = path.join(tmpDir, '.planning', 'milestones', 'v2.0.0-phases');
|
||||||
|
fs.mkdirSync(path.join(v1Dir, '01-old'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(v2Dir, '01-new'), { recursive: true });
|
||||||
|
const result = phaseLocator.getArchivedPhaseDirs(tmpDir);
|
||||||
|
assert.strictEqual(result.length, 2);
|
||||||
|
// Reverse sort: v2.0.0 comes before v1.0.0
|
||||||
|
const milestones = result.map(r => r.milestone);
|
||||||
|
assert.strictEqual(milestones[0], 'v2.0.0');
|
||||||
|
assert.strictEqual(milestones[1], 'v1.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adversarial: milestone-prefixed dir names that do not match pattern are skipped', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
const milestonesDir = path.join(tmpDir, '.planning', 'milestones');
|
||||||
|
fs.mkdirSync(milestonesDir, { recursive: true });
|
||||||
|
// These should all be ignored (do not match /^v[\d.]+-phases$/):
|
||||||
|
for (const bad of ['v1.0.0', 'phases', 'v1.0.0-phase', 'v-phases', '1.0.0-phases']) {
|
||||||
|
fs.mkdirSync(path.join(milestonesDir, bad), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(milestonesDir, bad, '01-sub'), { recursive: true });
|
||||||
|
}
|
||||||
|
const result = phaseLocator.getArchivedPhaseDirs(tmpDir);
|
||||||
|
assert.deepEqual(result, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns empty array for empty milestone archive dirs', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0.0-phases');
|
||||||
|
fs.mkdirSync(archiveDir, { recursive: true });
|
||||||
|
// Archive dir exists but has no phase subdirs
|
||||||
|
const result = phaseLocator.getArchivedPhaseDirs(tmpDir);
|
||||||
|
assert.deepEqual(result, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── searchPhaseInDir — direct tests ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('searchPhaseInDir: direct filesystem search', () => {
|
||||||
|
let tmpDir;
|
||||||
|
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
||||||
|
|
||||||
|
test('returns null for non-existent baseDir', () => {
|
||||||
|
const result = phaseLocator.searchPhaseInDir('/nonexistent-dir-xyz-' + Date.now(), 'rel/base', '01');
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when no matching subdirectory exists', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||||||
|
fs.mkdirSync(path.join(phasesDir, '02-other'), { recursive: true });
|
||||||
|
const result = phaseLocator.searchPhaseInDir(phasesDir, '.planning/phases', '01');
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds matching dir and returns correct structure', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||||||
|
fs.mkdirSync(path.join(phasesDir, '01-hello'), { recursive: true });
|
||||||
|
const result = phaseLocator.searchPhaseInDir(phasesDir, '.planning/phases', '01');
|
||||||
|
assert.ok(result !== null);
|
||||||
|
assert.strictEqual(result.found, true);
|
||||||
|
assert.strictEqual(result.phase_number, '01');
|
||||||
|
assert.strictEqual(result.phase_name, 'hello');
|
||||||
|
assert.strictEqual(result.phase_slug, 'hello');
|
||||||
|
assert.strictEqual(result.directory, '.planning/phases/01-hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('relBase is prepended to directory in result', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0.0-phases');
|
||||||
|
fs.mkdirSync(path.join(archiveDir, '03-feat'), { recursive: true });
|
||||||
|
const result = phaseLocator.searchPhaseInDir(archiveDir, '.planning/milestones/v1.0.0-phases', '03');
|
||||||
|
assert.ok(result !== null);
|
||||||
|
assert.strictEqual(result.directory, '.planning/milestones/v1.0.0-phases/03-feat');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adversarial: dir with normal name does not produce path traversal', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||||||
|
fs.mkdirSync(path.join(phasesDir, '01-normal-phase'), { recursive: true });
|
||||||
|
const result = phaseLocator.searchPhaseInDir(phasesDir, '.planning/phases', '01');
|
||||||
|
assert.ok(result !== null);
|
||||||
|
// The directory value should not escape its base
|
||||||
|
assert.ok(!result.directory.includes('..'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adversarial: phase number with repeated decimal segments (e.g. 1.1.1)', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||||||
|
fs.mkdirSync(path.join(phasesDir, '01.1.1-deep'), { recursive: true });
|
||||||
|
// Result may be found or null depending on normalization; must not throw
|
||||||
|
const result = phaseLocator.searchPhaseInDir(phasesDir, '.planning/phases', '01.1.1');
|
||||||
|
assert.ok(result === null || typeof result.found === 'boolean');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns has_research/has_context/has_verification/has_reviews as booleans', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||||||
|
const phaseDir = path.join(phasesDir, '01-test');
|
||||||
|
fs.mkdirSync(phaseDir, { recursive: true });
|
||||||
|
const result = phaseLocator.searchPhaseInDir(phasesDir, '.planning/phases', '01');
|
||||||
|
assert.ok(result !== null);
|
||||||
|
assert.strictEqual(typeof result.has_research, 'boolean');
|
||||||
|
assert.strictEqual(typeof result.has_context, 'boolean');
|
||||||
|
assert.strictEqual(typeof result.has_verification, 'boolean');
|
||||||
|
assert.strictEqual(typeof result.has_reviews, 'boolean');
|
||||||
|
assert.strictEqual(result.has_research, false);
|
||||||
|
assert.strictEqual(result.has_context, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adversarial: empty phases dir (no subdirs) returns null', () => {
|
||||||
|
tmpDir = createTempProject('gsd-pl-test-');
|
||||||
|
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||||||
|
const result = phaseLocator.searchPhaseInDir(phasesDir, '.planning/phases', '01');
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user