* test: phase-dir prefix parity across creation paths (#3287 RED) Add failing tests asserting that init.phase-op and init.plan-phase expose expected_phase_dir with the project_code prefix when the phase directory does not yet exist — matching the prefix applied by phase.add. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(phase): unify phase-dir naming via shared getPhaseDirName helper (#3287) Both init.phase-op (discuss-phase workflow) and init.plan-phase (plan-phase workflow) now compute expected_phase_dir — the canonical directory name including the project_code prefix when set — and expose it in their JSON bundle. Workflow fallback mkdir calls are updated to use ${expected_phase_dir} instead of constructing the path from padded_phase + phase_slug, which was missing the project_code prefix. This eliminates the two-headed naming convention where phase.add/insert produced XR-01-foundation/ while the first-touch paths produced 01-foundation/ for the same project. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(phase): apply project_code prefix in scaffold phase-dir (#3287) Audit finding: phase.scaffold (CJS commands.cjs + SDK phase-lifecycle.ts) also constructed phase dir names without project_code prefix. Both implementations now read config.project_code and apply the same prefix logic as phase.add/phase.insert. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * changeset: pr=3292 for #3287 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(changelog): add entry for #3287 phase-dir prefix parity fix Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/witty-geese-purr.md
Normal file
5
.changeset/witty-geese-purr.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3292
|
||||
---
|
||||
**`/gsd-discuss-phase` and `/gsd-plan-phase` first-touch creation now apply `project_code` prefix consistently with `phase.add`/`phase.insert`** — projects with `project_code` set in `.planning/config.json` no longer accumulate a two-headed naming convention (`01-foundation/` mixed with `XR-02.1-spike/`). Routes all phase-directory creation through a single shared `getPhaseDirName` helper to prevent future drift.
|
||||
@@ -8,6 +8,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`/gsd-discuss-phase` and `/gsd-plan-phase` first-touch creation now apply `project_code` prefix consistently with `phase.add`/`phase.insert`** — projects with `project_code` set in `.planning/config.json` no longer accumulate a two-headed naming convention (`01-foundation/` mixed with `XR-02.1-spike/`). `init.phase-op` and `init.plan-phase` now expose `expected_phase_dir` (with prefix) in their JSON bundle; workflow fallback mkdir calls use this value instead of constructing the path from `padded_phase`+`phase_slug`. `phase.scaffold phase-dir` (CJS and SDK) also fixed. (#3287)
|
||||
- **`buildStateFrontmatter` now counts nested `plans/<N>-PLAN-<NN>-<slug>.md` files** — repos using the nested layout (post-#3139) no longer get `progress.*` counters silently overwritten downward on every state mutation. Sibling fix to #3115/#3139/#3191. (#3261)
|
||||
|
||||
## [1.41.0](https://github.com/gsd-build/get-shit-done/compare/v1.40.0...v1.41.0) - 2026-05-07
|
||||
|
||||
@@ -792,7 +792,11 @@ function cmdScaffold(cwd, type, options, raw) {
|
||||
error('phase and name required for phase-dir scaffold');
|
||||
}
|
||||
const slug = generateSlugInternal(name);
|
||||
const dirName = `${padded}-${slug}`;
|
||||
// #3287: apply project_code prefix to stay consistent with phase.add/phase.insert
|
||||
const scaffoldConfig = loadConfig(cwd);
|
||||
const scaffoldProjectCode = scaffoldConfig.project_code || '';
|
||||
const scaffoldPrefix = scaffoldProjectCode ? `${scaffoldProjectCode}-` : '';
|
||||
const dirName = `${scaffoldPrefix}${padded}-${slug}`;
|
||||
const phasesParent = planningPaths(cwd).phases;
|
||||
fs.mkdirSync(phasesParent, { recursive: true });
|
||||
const dirPath = path.join(phasesParent, dirName);
|
||||
|
||||
@@ -272,6 +272,23 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
|
||||
: null;
|
||||
const phase_req_ids = (reqExtracted && reqExtracted !== 'TBD') ? reqExtracted : null;
|
||||
|
||||
// #3287: compute the canonical directory name with project_code prefix so
|
||||
// the first-touch mkdir in /gsd-plan-phase stays consistent with phase.add.
|
||||
const phaseDirPlan = phaseInfo?.directory || null;
|
||||
const phaseNumberPlan = phaseInfo?.phase_number || null;
|
||||
const phaseNamePlan = phaseInfo?.phase_name || null;
|
||||
const rawProjectCodePlan = config.project_code || '';
|
||||
let expectedPhaseDirPlan = null;
|
||||
if (!phaseDirPlan && phaseNumberPlan && phaseNamePlan) {
|
||||
const paddedNum = normalizePhaseName(phaseNumberPlan);
|
||||
const slug = generateSlugInternal(phaseNamePlan).substring(0, 60);
|
||||
if (slug) {
|
||||
const prefix = rawProjectCodePlan ? `${rawProjectCodePlan}-` : '';
|
||||
const dirName = `${prefix}${paddedNum}-${slug}`;
|
||||
expectedPhaseDirPlan = toPosixPath(path.relative(cwd, path.join(planningPaths(cwd).phases, dirName)));
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
// Models
|
||||
researcher_model: resolveModelInternal(cwd, 'gsd-phase-researcher'),
|
||||
@@ -294,11 +311,12 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
|
||||
|
||||
// Phase info
|
||||
phase_found: !!phaseInfo,
|
||||
phase_dir: phaseInfo?.directory || null,
|
||||
phase_number: phaseInfo?.phase_number || null,
|
||||
phase_name: phaseInfo?.phase_name || null,
|
||||
phase_dir: phaseDirPlan,
|
||||
expected_phase_dir: expectedPhaseDirPlan,
|
||||
phase_number: phaseNumberPlan,
|
||||
phase_name: phaseNamePlan,
|
||||
phase_slug: phaseInfo?.phase_slug || null,
|
||||
padded_phase: phaseInfo?.phase_number ? normalizePhaseName(phaseInfo.phase_number) : null,
|
||||
padded_phase: phaseNumberPlan ? normalizePhaseName(phaseNumberPlan) : null,
|
||||
phase_req_ids,
|
||||
|
||||
// Existing artifacts
|
||||
@@ -747,6 +765,23 @@ function cmdInitPhaseOp(cwd, phase, raw) {
|
||||
}
|
||||
}
|
||||
|
||||
// #3287: compute the canonical directory name with project_code prefix so
|
||||
// the first-touch mkdir in /gsd-discuss-phase stays consistent with phase.add.
|
||||
const phaseDir = phaseInfo?.directory || null;
|
||||
const phaseNumber = phaseInfo?.phase_number || null;
|
||||
const phaseName = phaseInfo?.phase_name || null;
|
||||
const rawProjectCode = config.project_code || '';
|
||||
let expectedPhaseDir = null;
|
||||
if (!phaseDir && phaseNumber && phaseName) {
|
||||
const paddedNum = normalizePhaseName(phaseNumber);
|
||||
const slug = generateSlugInternal(phaseName).substring(0, 60);
|
||||
if (slug) {
|
||||
const prefix = rawProjectCode ? `${rawProjectCode}-` : '';
|
||||
const dirName = `${prefix}${paddedNum}-${slug}`;
|
||||
expectedPhaseDir = toPosixPath(path.relative(cwd, path.join(planningPaths(cwd).phases, dirName)));
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
// Config
|
||||
commit_docs: config.commit_docs,
|
||||
@@ -760,11 +795,12 @@ function cmdInitPhaseOp(cwd, phase, raw) {
|
||||
|
||||
// Phase info
|
||||
phase_found: !!phaseInfo,
|
||||
phase_dir: phaseInfo?.directory || null,
|
||||
phase_number: phaseInfo?.phase_number || null,
|
||||
phase_name: phaseInfo?.phase_name || null,
|
||||
phase_dir: phaseDir,
|
||||
expected_phase_dir: expectedPhaseDir,
|
||||
phase_number: phaseNumber,
|
||||
phase_name: phaseName,
|
||||
phase_slug: phaseInfo?.phase_slug || null,
|
||||
padded_phase: phaseInfo?.phase_number ? normalizePhaseName(phaseInfo.phase_number) : null,
|
||||
padded_phase: phaseNumber ? normalizePhaseName(phaseNumber) : null,
|
||||
|
||||
// Existing artifacts
|
||||
has_research: phaseInfo?.has_research || false,
|
||||
|
||||
@@ -373,11 +373,13 @@ DISCUSSION-LOG.md is for human reference only (audits, retrospectives) and is NO
|
||||
|
||||
**Find or create phase directory:**
|
||||
|
||||
Use values from init: `phase_dir`, `phase_slug`, `padded_phase`. If `phase_dir` is null:
|
||||
Use values from init: `phase_dir`, `expected_phase_dir`, `phase_slug`, `padded_phase`. If `phase_dir` is null:
|
||||
```bash
|
||||
mkdir -p ".planning/phases/${padded_phase}-${phase_slug}"
|
||||
mkdir -p "${expected_phase_dir}"
|
||||
```
|
||||
|
||||
Set `phase_dir="${expected_phase_dir}"` after creation.
|
||||
|
||||
**File location:** `${phase_dir}/${padded_phase}-CONTEXT.md`
|
||||
|
||||
**Read the CONTEXT.md template now (lazy-loaded):**
|
||||
|
||||
@@ -108,11 +108,13 @@ Extract `--prd <filepath>` from $ARGUMENTS. If present, set PRD_FILE to the file
|
||||
|
||||
**If no phase number:** Detect next unplanned phase from roadmap.
|
||||
|
||||
**If `phase_found` is false:** Validate phase exists in ROADMAP.md. If valid, create the directory using `phase_slug` and `padded_phase` from init:
|
||||
**If `phase_found` is false:** Validate phase exists in ROADMAP.md. If valid, create the directory using `expected_phase_dir` from init (includes `project_code` prefix when set):
|
||||
```bash
|
||||
mkdir -p ".planning/phases/${padded_phase}-${phase_slug}"
|
||||
mkdir -p "${expected_phase_dir}"
|
||||
```
|
||||
|
||||
Set `phase_dir="${expected_phase_dir}"` after creation.
|
||||
|
||||
**Existing artifacts from init:** `has_research`, `has_plans`, `plan_count`.
|
||||
|
||||
Set `CHUNKED_MODE` from flag or config:
|
||||
|
||||
@@ -29,6 +29,7 @@ import { maskIfSecret } from './secrets.js';
|
||||
import { findPhase } from './phase.js';
|
||||
import { roadmapGetPhase, getMilestoneInfo, extractCurrentMilestone, extractPhasesFromSection } from './roadmap.js';
|
||||
import { planningPaths, normalizePhaseName, toPosixPath, resolveAgentsDir, detectRuntime } from './helpers.js';
|
||||
import { generatePhaseSlug, assertSafeProjectCode } from './phase-lifecycle-policy.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
|
||||
// ─── Internal helpers ──────────────────────────────────────────────────────
|
||||
@@ -60,6 +61,27 @@ function pathExists(base: string, relPath: string): boolean {
|
||||
return existsSync(join(base, relPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the canonical phase directory name for a known phase entry from the
|
||||
* roadmap when no directory exists yet. Applies the project_code prefix so
|
||||
* the first-touch creation path used by /gsd-discuss-phase and /gsd-plan-phase
|
||||
* stays consistent with the prefix produced by `phase.add` / `phase.insert`.
|
||||
*
|
||||
* Returns null when phaseNumber or phaseName cannot be determined.
|
||||
*/
|
||||
function computeExpectedPhaseDirName(
|
||||
phaseNumber: string | null,
|
||||
phaseName: string | null,
|
||||
projectCode: string,
|
||||
): string | null {
|
||||
if (!phaseNumber || !phaseName) return null;
|
||||
const paddedNum = normalizePhaseName(phaseNumber);
|
||||
const slug = generatePhaseSlug(phaseName);
|
||||
if (!slug) return null;
|
||||
const prefix = projectCode ? `${projectCode}-` : '';
|
||||
return `${prefix}${paddedNum}-${slug}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest completed milestone from MILESTONES.md.
|
||||
* Port of getLatestCompletedMilestone from init.cjs lines 10-25.
|
||||
@@ -377,8 +399,21 @@ export const initPlanPhase: QueryHandler = async (args, projectDir, workstream)
|
||||
: ['', '', ''];
|
||||
|
||||
const phaseNumber = (phaseInfo?.phase_number as string) || null;
|
||||
const phaseName = (phaseInfo?.phase_name as string) ?? null;
|
||||
const phaseDir = (phaseInfo?.directory as string) ?? null;
|
||||
const plans = (phaseInfo?.plans || []) as string[];
|
||||
|
||||
// #3287: compute the canonical directory name with project_code prefix so
|
||||
// the first-touch mkdir in /gsd-plan-phase stays consistent with phase.add.
|
||||
const rawProjectCode = (config as Record<string, unknown>).project_code as string || '';
|
||||
assertSafeProjectCode(rawProjectCode);
|
||||
const expectedPhaseDirName = phaseDir
|
||||
? null // directory already exists — no need to create
|
||||
: computeExpectedPhaseDirName(phaseNumber, phaseName, rawProjectCode);
|
||||
const expectedPhaseDir = expectedPhaseDirName
|
||||
? toPosixPath(relative(projectDir, join(paths.phases, expectedPhaseDirName)))
|
||||
: null;
|
||||
|
||||
const cfg = config as GSDConfig;
|
||||
const result: Record<string, unknown> = {
|
||||
researcher_model: researcherModel,
|
||||
@@ -394,9 +429,10 @@ export const initPlanPhase: QueryHandler = async (args, projectDir, workstream)
|
||||
auto_chain_active: !!config.workflow._auto_chain_active,
|
||||
mode: cfg.mode ?? 'interactive',
|
||||
phase_found: !!phaseInfo,
|
||||
phase_dir: (phaseInfo?.directory as string) ?? null,
|
||||
phase_dir: phaseDir,
|
||||
expected_phase_dir: expectedPhaseDir,
|
||||
phase_number: phaseNumber,
|
||||
phase_name: (phaseInfo?.phase_name as string) ?? null,
|
||||
phase_name: phaseName,
|
||||
phase_slug: (phaseInfo?.phase_slug as string) ?? null,
|
||||
padded_phase: phaseNumber ? normalizePhaseName(phaseNumber) : null,
|
||||
phase_req_ids,
|
||||
@@ -414,22 +450,22 @@ export const initPlanPhase: QueryHandler = async (args, projectDir, workstream)
|
||||
};
|
||||
|
||||
// Add artifact paths if phase directory exists
|
||||
if (phaseInfo?.directory) {
|
||||
const phaseDirFull = join(projectDir, phaseInfo.directory as string);
|
||||
if (phaseDir) {
|
||||
const phaseDirFull = join(projectDir, phaseDir);
|
||||
try {
|
||||
const files = readdirSync(phaseDirFull);
|
||||
const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
|
||||
if (contextFile) result.context_path = toPosixPath(join(phaseInfo.directory as string, contextFile));
|
||||
if (contextFile) result.context_path = toPosixPath(join(phaseDir, contextFile));
|
||||
const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
|
||||
if (researchFile) result.research_path = toPosixPath(join(phaseInfo.directory as string, researchFile));
|
||||
if (researchFile) result.research_path = toPosixPath(join(phaseDir, researchFile));
|
||||
const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md');
|
||||
if (verificationFile) result.verification_path = toPosixPath(join(phaseInfo.directory as string, verificationFile));
|
||||
if (verificationFile) result.verification_path = toPosixPath(join(phaseDir, verificationFile));
|
||||
const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md');
|
||||
if (uatFile) result.uat_path = toPosixPath(join(phaseInfo.directory as string, uatFile));
|
||||
if (uatFile) result.uat_path = toPosixPath(join(phaseDir, uatFile));
|
||||
const reviewsFile = files.find(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md');
|
||||
if (reviewsFile) result.reviews_path = toPosixPath(join(phaseInfo.directory as string, reviewsFile));
|
||||
if (reviewsFile) result.reviews_path = toPosixPath(join(phaseDir, reviewsFile));
|
||||
const patternsFile = files.find(f => f.endsWith('-PATTERNS.md') || f === 'PATTERNS.md');
|
||||
if (patternsFile) result.patterns_path = toPosixPath(join(phaseInfo.directory as string, patternsFile));
|
||||
if (patternsFile) result.patterns_path = toPosixPath(join(phaseDir, patternsFile));
|
||||
} catch { /* intentionally empty */ }
|
||||
}
|
||||
|
||||
@@ -681,8 +717,21 @@ export const initPhaseOp: QueryHandler = async (args, projectDir, workstream) =>
|
||||
|
||||
const phaseFound = !!(phaseInfo && phaseInfo.found);
|
||||
const phaseNumber = (phaseInfo?.phase_number as string) || null;
|
||||
const phaseName = (phaseInfo?.phase_name as string) ?? null;
|
||||
const phaseDir = (phaseInfo?.directory as string) ?? null;
|
||||
const plans = (phaseInfo?.plans || []) as string[];
|
||||
|
||||
// #3287: compute the canonical directory name with project_code prefix so
|
||||
// the first-touch mkdir in /gsd-discuss-phase stays consistent with phase.add.
|
||||
const rawProjectCode = (config as Record<string, unknown>).project_code as string || '';
|
||||
assertSafeProjectCode(rawProjectCode);
|
||||
const expectedPhaseDirName = phaseDir
|
||||
? null // directory already exists — no need to create
|
||||
: computeExpectedPhaseDirName(phaseNumber, phaseName, rawProjectCode);
|
||||
const expectedPhaseDir = expectedPhaseDirName
|
||||
? toPosixPath(relative(projectDir, join(paths.phases, expectedPhaseDirName)))
|
||||
: null;
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
commit_docs: config.commit_docs,
|
||||
// #2997: secret config keys (brave_search, firecrawl, exa_search) may be
|
||||
@@ -694,9 +743,10 @@ export const initPhaseOp: QueryHandler = async (args, projectDir, workstream) =>
|
||||
firecrawl: typeof config.firecrawl === 'string' ? maskIfSecret('firecrawl', config.firecrawl) : config.firecrawl,
|
||||
exa_search: typeof config.exa_search === 'string' ? maskIfSecret('exa_search', config.exa_search) : config.exa_search,
|
||||
phase_found: phaseFound,
|
||||
phase_dir: (phaseInfo?.directory as string) ?? null,
|
||||
phase_dir: phaseDir,
|
||||
expected_phase_dir: expectedPhaseDir,
|
||||
phase_number: phaseNumber,
|
||||
phase_name: (phaseInfo?.phase_name as string) ?? null,
|
||||
phase_name: phaseName,
|
||||
phase_slug: (phaseInfo?.phase_slug as string) ?? null,
|
||||
padded_phase: phaseNumber ? normalizePhaseName(phaseNumber) : null,
|
||||
has_research: (phaseInfo?.has_research as boolean) || false,
|
||||
@@ -713,20 +763,20 @@ export const initPhaseOp: QueryHandler = async (args, projectDir, workstream) =>
|
||||
};
|
||||
|
||||
// Add artifact paths if phase directory exists
|
||||
if (phaseInfo?.directory) {
|
||||
const phaseDirFull = join(projectDir, phaseInfo.directory as string);
|
||||
if (phaseDir) {
|
||||
const phaseDirFull = join(projectDir, phaseDir);
|
||||
try {
|
||||
const files = readdirSync(phaseDirFull);
|
||||
const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
|
||||
if (contextFile) result.context_path = toPosixPath(join(phaseInfo.directory as string, contextFile));
|
||||
if (contextFile) result.context_path = toPosixPath(join(phaseDir, contextFile));
|
||||
const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
|
||||
if (researchFile) result.research_path = toPosixPath(join(phaseInfo.directory as string, researchFile));
|
||||
if (researchFile) result.research_path = toPosixPath(join(phaseDir, researchFile));
|
||||
const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md');
|
||||
if (verificationFile) result.verification_path = toPosixPath(join(phaseInfo.directory as string, verificationFile));
|
||||
if (verificationFile) result.verification_path = toPosixPath(join(phaseDir, verificationFile));
|
||||
const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md');
|
||||
if (uatFile) result.uat_path = toPosixPath(join(phaseInfo.directory as string, uatFile));
|
||||
if (uatFile) result.uat_path = toPosixPath(join(phaseDir, uatFile));
|
||||
const reviewsFile = files.find(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md');
|
||||
if (reviewsFile) result.reviews_path = toPosixPath(join(phaseInfo.directory as string, reviewsFile));
|
||||
if (reviewsFile) result.reviews_path = toPosixPath(join(phaseDir, reviewsFile));
|
||||
} catch { /* intentionally empty */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -539,7 +539,15 @@ export const phaseScaffold: QueryHandler = async (args, projectDir, workstream)
|
||||
throw new GSDError('phase and name required for phase-dir scaffold', ErrorClassification.Validation);
|
||||
}
|
||||
const slug = generatePhaseSlug(name);
|
||||
const dirNameNew = `${padded}-${slug}`;
|
||||
// #3287: apply project_code prefix to stay consistent with phase.add/phase.insert
|
||||
let scaffoldConfig: Record<string, unknown> = {};
|
||||
try {
|
||||
scaffoldConfig = JSON.parse(await readFile(planningPaths(projectDir, workstream).config, 'utf-8'));
|
||||
} catch { /* use defaults */ }
|
||||
const scaffoldProjectCode = (scaffoldConfig.project_code as string) || '';
|
||||
assertSafeProjectCode(scaffoldProjectCode);
|
||||
const scaffoldPrefix = scaffoldProjectCode ? `${scaffoldProjectCode}-` : '';
|
||||
const dirNameNew = `${scaffoldPrefix}${padded}-${slug}`;
|
||||
assertSafePhaseDirName(dirNameNew, 'scaffold phase directory');
|
||||
const phasesParent = planningPaths(projectDir, workstream).phases;
|
||||
await mkdir(phasesParent, { recursive: true });
|
||||
|
||||
198
tests/bug-3287-phase-dir-prefix-parity.test.cjs
Normal file
198
tests/bug-3287-phase-dir-prefix-parity.test.cjs
Normal file
@@ -0,0 +1,198 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Regression test for #3287 — phase-dir prefix parity across creation paths.
|
||||
*
|
||||
* Projects with `project_code` set in `.planning/config.json` must get the
|
||||
* same `<CODE>-<NN>-<slug>` directory shape from ALL phase-creation paths,
|
||||
* not just from `phase.add` / `phase.insert`.
|
||||
*
|
||||
* Three tests:
|
||||
* A — sanity: `phase.add` emits the prefixed dir (already works).
|
||||
* B — init phase-op exposes `expected_phase_dir` with the prefix when
|
||||
* the directory does not yet exist (first-touch path for /gsd-discuss-phase).
|
||||
* C — init plan-phase exposes `expected_phase_dir` with the prefix when
|
||||
* the directory does not yet exist (first-touch path for /gsd-plan-phase).
|
||||
*
|
||||
* Tests B and C are RED until the fix lands.
|
||||
*/
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── shared fixture ──────────────────────────────────────────────────────────
|
||||
|
||||
function makeXRProject(tmpDir) {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ project_code: 'XR' }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
[
|
||||
'# Roadmap v1.0',
|
||||
'',
|
||||
'### Phase 1: Foundation',
|
||||
'**Goal:** Setup project',
|
||||
'**Plans:** 0 plans',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Test A — sanity: phase.add honours project_code ─────────────────────────
|
||||
|
||||
describe('bug-3287 — phase.add emits project_code prefix (sanity)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempProject(); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('phase.add creates XR-02-<slug> when project_code is XR', () => {
|
||||
makeXRProject(tmpDir);
|
||||
|
||||
const result = runGsdTools('phase add auth service', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(result.success, `phase.add failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.phase_number, 2, 'phase number should be 2');
|
||||
|
||||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||||
const dirs = fs.readdirSync(phasesDir);
|
||||
const prefixedDirs = dirs.filter(d => d.startsWith('XR-'));
|
||||
assert.ok(
|
||||
prefixedDirs.length > 0,
|
||||
`Expected at least one XR- prefixed dir, got: ${JSON.stringify(dirs)}`,
|
||||
);
|
||||
assert.ok(
|
||||
dirs.some(d => d === 'XR-02-auth-service'),
|
||||
`Expected XR-02-auth-service, got: ${JSON.stringify(dirs)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test B — init phase-op exposes expected_phase_dir with prefix ────────────
|
||||
|
||||
describe('bug-3287 — init phase-op exposes expected_phase_dir with project_code prefix', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempProject(); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('returns expected_phase_dir with XR- prefix when phase directory does not exist', () => {
|
||||
makeXRProject(tmpDir);
|
||||
|
||||
// Phase 1 is in the roadmap but has no directory yet — the first-touch path
|
||||
const result = runGsdTools('init phase-op 1', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(result.success, `init phase-op failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.phase_found, true, 'phase should be found in roadmap');
|
||||
assert.strictEqual(output.phase_dir, null, 'phase_dir should be null (no dir yet)');
|
||||
|
||||
// The fix: expected_phase_dir must carry the project_code prefix
|
||||
assert.ok(
|
||||
typeof output.expected_phase_dir === 'string',
|
||||
`expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`,
|
||||
);
|
||||
assert.ok(
|
||||
output.expected_phase_dir.includes('XR-'),
|
||||
`expected_phase_dir should contain XR- prefix, got: "${output.expected_phase_dir}"`,
|
||||
);
|
||||
assert.ok(
|
||||
output.expected_phase_dir.includes('foundation'),
|
||||
`expected_phase_dir should contain the phase slug, got: "${output.expected_phase_dir}"`,
|
||||
);
|
||||
});
|
||||
|
||||
test('expected_phase_dir is null when no project_code is set', () => {
|
||||
// No project_code — expected_phase_dir should still be present but without prefix
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
'# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n',
|
||||
);
|
||||
|
||||
const result = runGsdTools('init phase-op 1', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(result.success, `init phase-op failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.phase_dir, null);
|
||||
assert.ok(
|
||||
typeof output.expected_phase_dir === 'string',
|
||||
`expected_phase_dir should be a string even without project_code, got: ${JSON.stringify(output.expected_phase_dir)}`,
|
||||
);
|
||||
// Without project_code, should have no prefix — just NN-slug
|
||||
assert.ok(
|
||||
!output.expected_phase_dir.match(/^[A-Z][A-Z0-9]*-/),
|
||||
`expected_phase_dir should have NO prefix without project_code, got: "${output.expected_phase_dir}"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test C — init plan-phase exposes expected_phase_dir with prefix ──────────
|
||||
|
||||
describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_code prefix', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempProject(); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('returns expected_phase_dir with XR- prefix when phase directory does not exist', () => {
|
||||
makeXRProject(tmpDir);
|
||||
|
||||
// Phase 1 is in the roadmap but has no directory yet — the first-touch path
|
||||
const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.phase_found, true, 'phase should be found in roadmap');
|
||||
assert.strictEqual(output.phase_dir, null, 'phase_dir should be null (no dir yet)');
|
||||
|
||||
// The fix: expected_phase_dir must carry the project_code prefix
|
||||
assert.ok(
|
||||
typeof output.expected_phase_dir === 'string',
|
||||
`expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`,
|
||||
);
|
||||
assert.ok(
|
||||
output.expected_phase_dir.includes('XR-'),
|
||||
`expected_phase_dir should contain XR- prefix, got: "${output.expected_phase_dir}"`,
|
||||
);
|
||||
assert.ok(
|
||||
output.expected_phase_dir.includes('foundation'),
|
||||
`expected_phase_dir should contain the phase slug, got: "${output.expected_phase_dir}"`,
|
||||
);
|
||||
});
|
||||
|
||||
test('expected_phase_dir omits prefix when project_code is not set', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
'# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n',
|
||||
);
|
||||
|
||||
const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.phase_dir, null);
|
||||
assert.ok(
|
||||
typeof output.expected_phase_dir === 'string',
|
||||
`expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`,
|
||||
);
|
||||
assert.ok(
|
||||
!output.expected_phase_dir.match(/^[A-Z][A-Z0-9]*-/),
|
||||
`expected_phase_dir should have NO prefix without project_code, got: "${output.expected_phase_dir}"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user