diff --git a/.gitignore b/.gitignore index 6308fdcf7..247e9bae1 100644 --- a/.gitignore +++ b/.gitignore @@ -130,6 +130,7 @@ build/ /gsd-core/bin/lib/core-utils.cjs /gsd-core/bin/lib/io.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/drift.cjs /gsd-core/bin/lib/cjs-command-router-adapter.cjs diff --git a/CONTEXT.md b/CONTEXT.md index 4c619e31c..47af56fd8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -20,6 +20,9 @@ Module owning the pure phase-id parsing and matching helpers: phase-name normali ### 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.) +### 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 Module owning dispatch error mapping, fallback policy, timeout classification, and CLI exit mapping contract. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d0a93506c..d8272ca4f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 | | `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-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) | | `planning-workspace.cjs` | Planning seam (`planningDir`, `planningPaths`, active workstream routing, `.planning/.lock`) | | `state.cjs` | STATE.md parsing, updating, progression, metrics | diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index baa67ffd0..12955f818 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -313,6 +313,7 @@ "phase-command-router.cjs", "phase-id.cjs", "phase-lifecycle.cjs", + "phase-locator.cjs", "phase.cjs", "phases-command-router.cjs", "plan-scan.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index fe77ef96f..cc1f9ac94 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -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`. @@ -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-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-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 | | `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) | diff --git a/eslint.config.mjs b/eslint.config.mjs index 5df0b730a..c2c2a3cb1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -92,6 +92,7 @@ export default tseslint.config( 'gsd-core/bin/lib/core-utils.cjs', 'gsd-core/bin/lib/io.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/drift.cjs', 'gsd-core/bin/lib/cjs-command-router-adapter.cjs', diff --git a/src/core.cts b/src/core.cts index f5cef5faa..018cae0cb 100644 --- a/src/core.cts +++ b/src/core.cts @@ -56,8 +56,10 @@ const { getPhaseFileStats, readSubdirectories, timeAgo, - extractCanonicalPlanId, } = 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 { 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. // extractCanonicalPlanId moved to core-utils.cjs (ADR-857 phase 2c / #877). -// The destructured binding above (from coreUtilsModule) makes it available to -// core-internal callers (searchPhaseInDir). It is NOT in core.cjs's public export = -// block (it was never public). +// It is consumed exclusively by phase-locator.cjs, which imports it from +// core-utils.cjs directly. It is NOT destructured in core.cts and is NOT +// in core.cjs's public export = block (it was never public). -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; -} - -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; -} +// searchPhaseInDir, findPhaseInternal, getArchivedPhaseDirs moved to phase-locator.cjs +// (ADR-857 phase 2d / #881). The destructured bindings above (from phaseLocatorModule) +// make them available to core-internal callers; core.cjs re-exports findPhaseInternal, +// getArchivedPhaseDirs, and searchPhaseInDir for back-compat. // ─── Roadmap milestone scoping (re-exported from roadmap-parser.cjs) ────────── // stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, diff --git a/src/phase-locator.cts b/src/phase-locator.cts new file mode 100644 index 000000000..a6f4c313c --- /dev/null +++ b/src/phase-locator.cts @@ -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, +}; diff --git a/tests/phase-locator.test.cjs b/tests/phase-locator.test.cjs new file mode 100644 index 000000000..5b7d2252f --- /dev/null +++ b/tests/phase-locator.test.cjs @@ -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); + }); +});