refactor(#881): extract phase-locator fs-search into phase-locator.cts (#882)

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:
Tom Boucher
2026-06-08 14:38:26 -04:00
committed by GitHub
parent b5a02da106
commit dd81e3d120
9 changed files with 632 additions and 145 deletions

1
.gitignore vendored
View File

@@ -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

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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",

View File

@@ -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) |

View File

@@ -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',

View File

@@ -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
View 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,
};

View 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);
});
});