* 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:
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