Files
msd-core/tests/init.test.cjs
Tom Boucher f910352ea6 fix(2104): guard foreign-prefix collapse in init execute-phase/verify-work/phase-op
#2056 fixed the foreign-prefix collapse for init plan-phase only.
The identical defect remained in three sibling commands that called
findPhaseInternal/getRoadmapPhaseInternal without the guard:
- cmdInitExecutePhase
- cmdInitVerifyWork
- cmdInitPhaseOp

Extracted the #2056 guard into shared helpers (guardedFindPhase /
guardedGetRoadmapPhase) and routed all four init commands through them.
Deleted the local parsePhasePrefix/isForeignPrefixedPhaseQuery copies in
init.cts — the canonical export from phase-id.cts is now used directly,
eliminating the drift risk flagged by both reviewers.

Added 5 regression tests (3 reject + 2 accept-branch) mirroring the
#2056 plan-phase tests.
2026-07-10 14:24:14 -04:00

2660 lines
112 KiB
JavaScript

/**
* GSD Tools Tests - Init
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, cleanup } = require('./helpers.cjs');
const { createFixture, seedPhase } = require('./fixtures/index.cjs');
const { createTempProject } = require('./helpers.cjs');
describe('init commands', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createFixture();
});
afterEach(() => {
cleanup(tmpDir);
});
test('init execute-phase returns file paths', () => {
seedPhase(tmpDir, '03-api', {
'03-01-PLAN.md': '# Plan',
});
const result = runGsdTools('init execute-phase 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.state_path, '.planning/STATE.md');
assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md');
assert.strictEqual(output.config_path, '.planning/config.json');
});
test('init execute-phase respects model_overrides for executor_model', () => {
seedPhase(tmpDir, '01-foundation', {
'01-01-PLAN.md': '# Plan',
});
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({
model_profile: 'balanced',
model_overrides: { 'gsd-executor': 'openai/o4-mini' },
}));
const result = runGsdTools('init execute-phase 1 --raw', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.executor_model, 'openai/o4-mini',
'model_overrides["gsd-executor"] must take precedence over profile');
});
test('init execute-phase respects model_overrides when resolve_model_ids is omit', () => {
seedPhase(tmpDir, '01-foundation', {
'01-01-PLAN.md': '# Plan',
});
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({
resolve_model_ids: 'omit',
model_overrides: { 'gsd-executor': 'openai/o4-mini' },
}));
const result = runGsdTools('init execute-phase 1 --raw', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.executor_model, 'openai/o4-mini',
'model_overrides must take precedence even when resolve_model_ids is omit');
});
test('init plan-phase returns file paths', () => {
seedPhase(tmpDir, '03-api', {
'03-CONTEXT.md': '# Phase Context',
'03-RESEARCH.md': '# Research Findings',
'03-VERIFICATION.md': '# Verification',
'03-UAT.md': '# UAT',
});
const result = runGsdTools('init plan-phase 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.state_path, '.planning/STATE.md');
assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md');
assert.strictEqual(output.requirements_path, '.planning/REQUIREMENTS.md');
assert.strictEqual(output.context_path, '.planning/phases/03-api/03-CONTEXT.md');
assert.strictEqual(output.research_path, '.planning/phases/03-api/03-RESEARCH.md');
assert.strictEqual(output.verification_path, '.planning/phases/03-api/03-VERIFICATION.md');
assert.strictEqual(output.uat_path, '.planning/phases/03-api/03-UAT.md');
});
// #2056: normalizePhaseName() strips ANY [A-Z][A-Z0-9_]*- prefix as a project
// code, so a foreign-prefixed workstream/task id like "MEM-01" collapsed to
// "01" and resolved to the unrelated numeric Phase 01. init plan-phase must
// require exact prefixed evidence (a phase dir/roadmap entry literally
// carrying the foreign prefix) before accepting a numeric-fallback match.
test('#2056 — init plan-phase does not collapse foreign-prefixed task IDs into numeric phases', () => {
seedPhase(tmpDir, '01-stable-baseline-on-main', {
'01-CONTEXT.md': '# Phase Context',
});
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 1: Stable Baseline On Main\n**Goal:** Establish baseline\n**Plans:** 1 plan\n',
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ project_code: 'LKML' }, null, 2),
);
const result = runGsdTools('init plan-phase MEM-01', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, false, 'MEM-01 must NOT resolve to numeric Phase 01');
assert.strictEqual(output.phase_dir, null);
assert.strictEqual(output.phase_number, null);
});
// #2056 companion: the guard must not reject the configured project_code's
// OWN prefixed phases — LKML-01 (project_code = LKML) must still resolve.
test('#2056 — init plan-phase still resolves configured project-code-prefixed phases', () => {
seedPhase(tmpDir, 'LKML-01-stable-baseline-on-main', {
'LKML-01-CONTEXT.md': '# Phase Context',
});
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ project_code: 'LKML' }, null, 2),
);
const result = runGsdTools('init plan-phase LKML-01', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true, 'LKML-01 (own project code) must resolve');
assert.strictEqual(output.phase_dir, '.planning/phases/LKML-01-stable-baseline-on-main');
assert.strictEqual(output.phase_number, 'LKML-01');
});
// #2056 accept-branch: a foreign-prefixed query MUST still resolve when a phase
// directory literally carries that prefix (e.g. a real MEM-01-* workstream
// phase). Proves the guard accepts exact-prefixed evidence, not just rejects.
test('#2056 — init plan-phase resolves a real foreign-prefixed phase dir', () => {
seedPhase(tmpDir, 'MEM-01-integration', {
'MEM-01-CONTEXT.md': '# Phase Context',
});
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ project_code: 'LKML' }, null, 2),
);
const result = runGsdTools('init plan-phase MEM-01', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true, 'a real MEM-01-* dir must resolve under its own prefix');
assert.strictEqual(output.phase_dir, '.planning/phases/MEM-01-integration');
assert.strictEqual(output.phase_number, 'MEM-01');
});
// #2056 edge: with NO project_code configured, any prefixed query is foreign
// and must NOT collapse to a numeric phase. Pins the strict default.
test('#2056 — init plan-phase treats prefixed queries as foreign when no project_code is configured', () => {
seedPhase(tmpDir, '01-stable-baseline-on-main', {
'01-CONTEXT.md': '# Phase Context',
});
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({}, null, 2),
);
const result = runGsdTools('init plan-phase MEM-01', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, false, 'with no project_code, MEM-01 must not resolve to numeric Phase 01');
assert.strictEqual(output.phase_number, null);
});
// #2104: the #2056 guard must also cover init execute-phase, verify-work,
// and phase-op — all three had unguarded findPhaseInternal/getRoadmapPhaseInternal
// calls that collapsed foreign prefixes (MEM-01 → 01) to numeric phases.
test('#2104 — init execute-phase does not collapse foreign-prefixed task IDs', () => {
seedPhase(tmpDir, '01-stable-baseline-on-main', {
'01-CONTEXT.md': '# Phase Context',
});
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 1: Stable Baseline On Main\n**Goal:** Establish baseline\n**Plans:** 1 plan\n',
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ project_code: 'LKML' }, null, 2),
);
const result = runGsdTools('init execute-phase MEM-01', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, false, 'MEM-01 must NOT resolve to numeric Phase 01');
assert.strictEqual(output.phase_number, null);
});
test('#2104 — init verify-work does not collapse foreign-prefixed task IDs', () => {
seedPhase(tmpDir, '01-stable-baseline-on-main', {
'01-CONTEXT.md': '# Phase Context',
});
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 1: Stable Baseline On Main\n**Goal:** Establish baseline\n**Plans:** 1 plan\n',
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ project_code: 'LKML' }, null, 2),
);
const result = runGsdTools('init verify-work MEM-01', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, false, 'MEM-01 must NOT resolve to numeric Phase 01');
});
// #2104 accept-branch: a real foreign-prefixed phase dir MUST still resolve
// through the now-guarded commands, proving the guard narrows but does not block.
test('#2104 — init execute-phase resolves a real foreign-prefixed phase dir', () => {
seedPhase(tmpDir, 'MEM-01-integration', {
'MEM-01-CONTEXT.md': '# Phase Context',
});
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ project_code: 'LKML' }, null, 2),
);
const result = runGsdTools('init execute-phase MEM-01', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true, 'a real MEM-01-* dir must resolve under its own prefix');
assert.strictEqual(output.phase_dir, '.planning/phases/MEM-01-integration');
});
test('#2104 — init verify-work resolves a real foreign-prefixed phase dir', () => {
seedPhase(tmpDir, 'MEM-01-integration', {
'MEM-01-CONTEXT.md': '# Phase Context',
});
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ project_code: 'LKML' }, null, 2),
);
const result = runGsdTools('init verify-work MEM-01', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true, 'a real MEM-01-* dir must resolve under its own prefix');
});
test('#2104 — init phase-op does not collapse foreign-prefixed task IDs', () => {
seedPhase(tmpDir, '01-stable-baseline-on-main', {
'01-CONTEXT.md': '# Phase Context',
});
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 1: Stable Baseline On Main\n**Goal:** Establish baseline\n**Plans:** 1 plan\n',
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ project_code: 'LKML' }, null, 2),
);
const result = runGsdTools('init phase-op MEM-01', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, false, 'MEM-01 must NOT resolve to numeric Phase 01');
assert.strictEqual(output.phase_number, null);
});
test('init plan-phase exposes text_mode from config (defaults false)', () => {
const result = runGsdTools('init plan-phase 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.text_mode, false, 'text_mode should default to false');
});
test('init plan-phase exposes text_mode true when set in config', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
const existing = fs.existsSync(configPath)
? JSON.parse(fs.readFileSync(configPath, 'utf8'))
: {};
const config = { ...existing, workflow: { ...(existing.workflow || {}), text_mode: true } };
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const result = runGsdTools('init plan-phase 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.text_mode, true, 'text_mode should reflect config value');
});
test('init progress returns file paths', () => {
const result = runGsdTools('init progress', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.state_path, '.planning/STATE.md');
assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md');
assert.strictEqual(output.project_path, '.planning/PROJECT.md');
assert.strictEqual(output.config_path, '.planning/config.json');
});
test('init phase-op returns core and optional phase file paths', () => {
seedPhase(tmpDir, '03-api', {
'03-CONTEXT.md': '# Phase Context',
'03-RESEARCH.md': '# Research',
'03-VERIFICATION.md': '# Verification',
'03-UAT.md': '# UAT',
});
const result = runGsdTools('init phase-op 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.state_path, '.planning/STATE.md');
assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md');
assert.strictEqual(output.requirements_path, '.planning/REQUIREMENTS.md');
assert.strictEqual(output.context_path, '.planning/phases/03-api/03-CONTEXT.md');
assert.strictEqual(output.research_path, '.planning/phases/03-api/03-RESEARCH.md');
assert.strictEqual(output.verification_path, '.planning/phases/03-api/03-VERIFICATION.md');
assert.strictEqual(output.uat_path, '.planning/phases/03-api/03-UAT.md');
});
test('init plan-phase detects has_reviews and reviews_path when REVIEWS.md exists', () => {
seedPhase(tmpDir, '03-api', {
'03-REVIEWS.md': '# Cross-AI Reviews',
});
const result = runGsdTools('init plan-phase 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.has_reviews, true);
assert.strictEqual(output.reviews_path, '.planning/phases/03-api/03-REVIEWS.md');
});
test('init plan-phase omits optional paths if files missing', () => {
seedPhase(tmpDir, '03-api');
const result = runGsdTools('init plan-phase 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.context_path, undefined);
assert.strictEqual(output.research_path, undefined);
assert.strictEqual(output.reviews_path, undefined);
assert.strictEqual(output.has_reviews, false);
});
// ── phase_req_ids extraction (fix for #684) ──────────────────────────────
test('init plan-phase extracts phase_req_ids from ROADMAP', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n### Phase 3: API\n**Goal:** Build API\n**Requirements**: CP-01, CP-02, CP-03\n**Plans:** 0 plans\n`
);
const result = runGsdTools('init plan-phase 3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_req_ids, 'CP-01, CP-02, CP-03');
});
test('init plan-phase strips brackets from phase_req_ids', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n### Phase 3: API\n**Goal:** Build API\n**Requirements**: [CP-01, CP-02]\n**Plans:** 0 plans\n`
);
const result = runGsdTools('init plan-phase 3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_req_ids, 'CP-01, CP-02');
});
test('init plan-phase returns null phase_req_ids when Requirements line is absent', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n### Phase 3: API\n**Goal:** Build API\n**Plans:** 0 plans\n`
);
const result = runGsdTools('init plan-phase 3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_req_ids, null);
});
test('init plan-phase returns null phase_req_ids when ROADMAP is absent', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
const result = runGsdTools('init plan-phase 3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_req_ids, null);
});
test('init execute-phase extracts phase_req_ids from ROADMAP', () => {
seedPhase(tmpDir, '03-api', {
'03-01-PLAN.md': '# Plan',
});
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n### Phase 3: API\n**Goal:** Build API\n**Requirements**: EX-01, EX-02\n**Plans:** 1 plans\n`
);
const result = runGsdTools('init execute-phase 3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_req_ids, 'EX-01, EX-02');
});
test('init plan-phase returns null phase_req_ids when value is TBD', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n### Phase 3: API\n**Goal:** Build API\n**Requirements**: TBD\n**Plans:** 0 plans\n`
);
const result = runGsdTools('init plan-phase 3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_req_ids, null, 'TBD placeholder should return null');
});
// ── #2769: Requirements header bold/colon variants ───────────────────────
// The visible label "**Requirements:**" (colon INSIDE bold) and
// "**Requirements**:" (colon OUTSIDE bold) render identically. The parser
// must accept both, plus the spaced "**Requirements** :" variant and the
// plain "## Requirements" header form (used in REQUIREMENTS.md), so phase
// metadata is robust to authoring style.
const headerVariants = [
{ name: 'colon inside bold (**Requirements:**)', header: '**Requirements:** RV-01, RV-02' },
{ name: 'colon outside bold (**Requirements**:)', header: '**Requirements**: RV-01, RV-02' },
{ name: 'space before colon (**Requirements** :)', header: '**Requirements** : RV-01, RV-02' },
];
for (const variant of headerVariants) {
test(`init plan-phase parses Requirements with ${variant.name}`, () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
const roadmap = [
'# Roadmap',
'',
'### Phase 3: API',
'**Goal:** Build API',
variant.header,
'**Plans:** 0 plans',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
const result = runGsdTools('init plan-phase 3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_req_ids, 'RV-01, RV-02',
`phase_req_ids must be parsed when header uses "${variant.header}"`);
});
test(`init execute-phase parses Requirements with ${variant.name}`, () => {
seedPhase(tmpDir, '03-api', {
'03-01-PLAN.md': '# Plan',
});
const roadmap = [
'# Roadmap',
'',
'### Phase 3: API',
'**Goal:** Build API',
variant.header,
'**Plans:** 1 plans',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
const result = runGsdTools('init execute-phase 3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_req_ids, 'RV-01, RV-02',
`phase_req_ids must be parsed when header uses "${variant.header}"`);
});
}
test('init execute-phase returns null phase_req_ids when Requirements line is absent', () => {
seedPhase(tmpDir, '03-api', {
'03-01-PLAN.md': '# Plan',
});
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n### Phase 3: API\n**Goal:** Build API\n**Plans:** 1 plans\n`
);
const result = runGsdTools('init execute-phase 3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_req_ids, null);
});
test('init plan-phase resolves phase_req_ids from flat Phase Details after active milestone heading', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '11-second-active-phase'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), [
'---',
'milestone: v0.4.0',
'current_phase: 11',
'---',
'',
].join('\n'));
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
'# Roadmap: Example',
'',
'## Milestones',
'',
'- ✅ **v0.3.0 Foundations** - Phases 1-9 (shipped 2026-01-01)',
'- 🚧 **v0.4.0 Feature Work** - Phases 10-11 (in progress)',
'',
'## Phases',
'',
'<details>',
'<summary>✅ v0.3.0 Foundations (Phases 1-9) - SHIPPED 2026-01-01</summary>',
'',
'- [x] **Phase 1: Bootstrap**',
'',
'</details>',
'',
'### 🚧 v0.4.0 Feature Work (Active)',
'',
'**Milestone Goal:** Deliver the feature set.',
'',
'- [ ] **Phase 10: First Active Phase**',
'- [ ] **Phase 11: Second Active Phase**',
'',
'### 📋 v0.5+ (Planned)',
'',
'## Phase Details',
'',
'### Phase 10: First Active Phase',
'**Goal**: Build the first piece.',
'**Requirements**: REQ-01',
'',
'### Phase 11: Second Active Phase',
'**Goal**: Build the second piece.',
'**Requirements**: REQ-02, REQ-03',
'',
'## Progress',
'',
].join('\n'));
const result = runGsdTools('init plan-phase 11', tmpDir);
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true);
assert.strictEqual(output.phase_req_ids, 'REQ-02, REQ-03');
});
test('init execute-phase resolves phase_req_ids from flat Phase Details after active milestone heading', () => {
seedPhase(tmpDir, '11-second-active-phase', {
'11-01-PLAN.md': '# Plan',
});
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), [
'---',
'milestone: v0.4.0',
'current_phase: 11',
'---',
'',
].join('\n'));
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
'# Roadmap: Example',
'',
'## Phases',
'',
'### 🚧 v0.4.0 Feature Work (Active)',
'',
'- [ ] **Phase 10: First Active Phase**',
'- [ ] **Phase 11: Second Active Phase**',
'',
'### 📋 v0.5+ (Planned)',
'',
'## Phase Details',
'',
'### Phase 10: First Active Phase',
'**Goal**: Build the first piece.',
'**Requirements**: REQ-01',
'',
'### Phase 11: Second Active Phase',
'**Goal**: Build the second piece.',
'**Requirements**: REQ-02, REQ-03',
'',
].join('\n'));
const result = runGsdTools('init execute-phase 11', tmpDir);
assert.ok(result.success, `init execute-phase failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true);
assert.strictEqual(output.phase_req_ids, 'REQ-02, REQ-03');
});
test('init plan-phase prefers real phase details outside fenced examples and ignores backlog sentinels (#1588)', () => {
const projectDir = createTempProject('init-1588-');
try {
fs.writeFileSync(
path.join(projectDir, '.planning', 'STATE.md'),
[
'---',
'gsd_state_version: 1.0',
'milestone: v1.1',
'status: planning',
'---',
'',
].join('\n')
);
fs.writeFileSync(
path.join(projectDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'<details open>',
'<summary>v1.1 Current (Phases 8-9) - PLANNED</summary>',
'',
'- [ ] **Phase 9: Real Phase**',
'',
'</details>',
'',
'## Phase Details',
'',
'```markdown',
'### Phase 9: Fenced Example Phase',
'**Goal:** This example must not be treated as roadmap structure.',
'```',
'',
'### Phase 9: Real Phase',
'**Goal:** Use the real phase details outside the fenced block.',
'**Requirements:** REAL-01',
'',
'## Backlog',
'',
'### Phase 999.1: Backlog Thing',
'**Goal:** Future backlog item.',
'',
].join('\n')
);
const phase9 = runGsdTools('init plan-phase 9', projectDir);
assert.ok(phase9.success, `init plan-phase 9 failed: ${phase9.error}`);
const phase9Output = JSON.parse(phase9.output);
assert.equal(phase9Output.phase_name, 'Real Phase');
assert.equal(phase9Output.phase_req_ids, 'REAL-01');
const backlog = runGsdTools('init plan-phase 999.1', projectDir);
assert.ok(backlog.success, `init plan-phase 999.1 failed: ${backlog.error}`);
const backlogOutput = JSON.parse(backlog.output);
assert.equal(backlogOutput.phase_found, false);
} finally {
cleanup(projectDir);
}
});
test('init phase-op resolves a details-summary milestone phase from later flat Phase Details', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), [
'milestone: v1.11',
'current_phase: 86',
'',
].join('\n'));
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
'# Roadmap',
'',
'## Phases',
'<details open>',
'<summary>🔄 v1.11 A06 (Phases 86-91) — IN PROGRESS</summary>',
'',
'- [ ] **Phase 86: Details Block Regression** — Parser should resolve this (DATA-01)',
'- [ ] **Phase 87: Other Work** — Later phase',
'</details>',
'',
'## Phase Details',
'',
'### Phase 86: Details Block Regression',
'**Goal**: Resolve phase details after collapsed milestone block',
'**Requirements**: DATA-01',
'',
'### Phase 87: Other Work',
'**Goal**: Not relevant',
'',
].join('\n'));
const result = runGsdTools('init phase-op 86', tmpDir);
assert.ok(result.success, `init phase-op failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true);
assert.strictEqual(output.phase_name, 'Details Block Regression');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// ROADMAP fallback for init plan-phase / execute-phase / verify-work (#1238)
// ─────────────────────────────────────────────────────────────────────────────
describe('init commands ROADMAP fallback when phase directory does not exist (#1238)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createFixture();
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 1: Foundation Setup\n**Goal:** Bootstrap project\n**Requirements**: R-01, R-02\n**Plans:** TBD\n'
);
});
afterEach(() => {
cleanup(tmpDir);
});
test('init plan-phase falls back to ROADMAP when no phase directory exists', () => {
const result = runGsdTools('init plan-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true, 'phase_found should be true from ROADMAP fallback');
assert.strictEqual(output.phase_dir, null, 'phase_dir should be null (no directory yet)');
assert.strictEqual(output.phase_number, '1');
assert.strictEqual(output.phase_name, 'Foundation Setup');
assert.strictEqual(output.phase_slug, 'foundation-setup');
assert.strictEqual(output.padded_phase, '01');
});
test('init execute-phase falls back to ROADMAP when no phase directory exists', () => {
const result = runGsdTools('init execute-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true, 'phase_found should be true from ROADMAP fallback');
assert.strictEqual(output.phase_dir, null, 'phase_dir should be null (no directory yet)');
assert.strictEqual(output.phase_number, '1');
assert.strictEqual(output.phase_name, 'Foundation Setup');
assert.strictEqual(output.phase_slug, 'foundation-setup');
assert.strictEqual(output.phase_req_ids, 'R-01, R-02');
});
test('init verify-work falls back to ROADMAP when no phase directory exists', () => {
const result = runGsdTools('init verify-work 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true, 'phase_found should be true from ROADMAP fallback');
assert.strictEqual(output.phase_dir, null, 'phase_dir should be null (no directory yet)');
assert.strictEqual(output.phase_number, '1');
assert.strictEqual(output.phase_name, 'Foundation Setup');
});
test('init plan-phase returns phase_found false when neither directory nor ROADMAP entry exists', () => {
const result = runGsdTools('init plan-phase 99', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, false);
assert.strictEqual(output.phase_dir, null);
assert.strictEqual(output.phase_number, null);
assert.strictEqual(output.phase_name, null);
});
test('init plan-phase prefers disk directory over ROADMAP fallback', () => {
seedPhase(tmpDir, '01-foundation-setup', {
'01-01-PLAN.md': '# Plan',
});
const result = runGsdTools('init plan-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true);
assert.ok(output.phase_dir !== null, 'phase_dir should point to disk directory');
assert.ok(output.phase_dir.includes('01-foundation-setup'));
assert.strictEqual(output.plan_count, 1);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// init ignores archived phases from prior milestones that share a phase number
// ─────────────────────────────────────────────────────────────────────────────
describe('init commands ignore archived phases from prior milestones sharing a number', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createFixture();
// Current milestone ROADMAP has Phase 2 but no disk directory yet
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# v2.0 Roadmap\n\n### Phase 2: New Feature\n**Goal:** New v2.0 feature\n**Requirements**: NEW-01, NEW-02\n**Plans:** TBD\n'
);
// Prior milestone archive has a shipped Phase 2 with different slug and artifacts
const archivedDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '02-old-feature');
fs.mkdirSync(archivedDir, { recursive: true });
fs.writeFileSync(path.join(archivedDir, '2-CONTEXT.md'), '# OLD v1.0 Phase 2 context');
fs.writeFileSync(path.join(archivedDir, '2-RESEARCH.md'), '# OLD v1.0 Phase 2 research');
});
afterEach(() => {
cleanup(tmpDir);
});
test('init plan-phase prefers current ROADMAP entry over archived v1.0 phase of same number', () => {
const result = runGsdTools('init plan-phase 2', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true);
assert.strictEqual(output.phase_name, 'New Feature',
'phase_name must come from current ROADMAP.md, not archived v1.0');
assert.strictEqual(output.phase_slug, 'new-feature');
assert.strictEqual(output.phase_dir, null,
'phase_dir must be null — current milestone has no directory yet');
assert.strictEqual(output.has_context, false,
'has_context must not inherit archived v1.0 artifacts');
assert.strictEqual(output.has_research, false,
'has_research must not inherit archived v1.0 artifacts');
assert.ok(!output.context_path,
'context_path must not point at archived v1.0 file');
assert.ok(!output.research_path,
'research_path must not point at archived v1.0 file');
assert.strictEqual(output.phase_req_ids, 'NEW-01, NEW-02');
});
test('init execute-phase prefers current ROADMAP entry over archived v1.0 phase of same number', () => {
const result = runGsdTools('init execute-phase 2', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true);
assert.strictEqual(output.phase_name, 'New Feature');
assert.strictEqual(output.phase_slug, 'new-feature');
assert.strictEqual(output.phase_dir, null);
assert.strictEqual(output.phase_req_ids, 'NEW-01, NEW-02');
});
test('init verify-work prefers current ROADMAP entry over archived v1.0 phase of same number', () => {
const result = runGsdTools('init verify-work 2', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true);
assert.strictEqual(output.phase_name, 'New Feature');
assert.strictEqual(output.phase_dir, null);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Bug #2391: zero-padded phase numbers must not bypass archived-phase guard
// ─────────────────────────────────────────────────────────────────────────────
describe('init plan-phase zero-padded phase number (bug #2391)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createFixture();
// Current milestone ROADMAP has Phase 3 (unpadded heading)
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# v2.0 Roadmap\n\n### Phase 3: Rotation Engine + Availability\n**Goal**: Rotation\n**Requirements**: ROTA-01, ROTA-02\n**Plans:** TBD\n'
);
// Prior milestone archive has a shipped Phase 3 with different content
const archivedDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '03-plant-collection-and-rooms');
fs.mkdirSync(archivedDir, { recursive: true });
fs.writeFileSync(path.join(archivedDir, '03-CONTEXT.md'), '# OLD v1.0 Phase 3 context');
fs.writeFileSync(path.join(archivedDir, '03-RESEARCH.md'), '# OLD v1.0 Phase 3 research');
});
afterEach(() => {
cleanup(tmpDir);
});
test('zero-padded "03" returns current ROADMAP phase, not archived v1.0 phase', () => {
const result = runGsdTools('init plan-phase 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true);
assert.strictEqual(output.phase_name, 'Rotation Engine + Availability',
'phase_name must come from current ROADMAP.md, not the archived v1.0 phase');
assert.strictEqual(output.phase_dir, null,
'phase_dir must be null — current milestone has no directory yet');
assert.strictEqual(output.has_context, false,
'has_context must not inherit archived v1.0 artifacts');
assert.strictEqual(output.has_research, false,
'has_research must not inherit archived v1.0 artifacts');
assert.ok(!output.context_path || !output.context_path.includes('v1.0'),
'context_path must not point at archived v1.0 file');
assert.strictEqual(output.phase_req_ids, 'ROTA-01, ROTA-02');
});
test('unpadded "3" and zero-padded "03" return identical phase identity', () => {
const result3 = runGsdTools('init plan-phase 3', tmpDir);
const result03 = runGsdTools('init plan-phase 03', tmpDir);
assert.ok(result3.success && result03.success, 'both commands must succeed');
const out3 = JSON.parse(result3.output);
const out03 = JSON.parse(result03.output);
assert.strictEqual(out03.phase_name, out3.phase_name,
'phase_name must be identical regardless of padding');
assert.strictEqual(out03.phase_slug, out3.phase_slug,
'phase_slug must be identical regardless of padding');
assert.strictEqual(out03.phase_req_ids, out3.phase_req_ids,
'phase_req_ids must be identical regardless of padding');
});
// ── #904: branch_name must use normalized (stripped + zero-padded) phase number ──
// When project_code is set (e.g. "CK") the phase directory is prefixed:
// "CK-01-foundation". extractPhaseToken returns "CK-01" as phase_number.
// branch_name must call normalizePhaseName so it strips the prefix and zero-pads,
// producing "gsd/phase-01-foundation" rather than "gsd/phase-CK-01-foundation".
test('branch_name uses normalized phase number when project_code prefixes phase dir (#904)', () => {
seedPhase(tmpDir, 'CK-01-foundation', {
'CK-01-01-PLAN.md': '# Plan',
});
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({
project_code: 'CK',
git: {
branching_strategy: 'phase',
phase_branch_template: 'gsd/phase-{phase}-{slug}',
},
}, null, 2)
);
const result = runGsdTools('init execute-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// branch_name must use the normalized phase number, not the raw "CK-01" token
assert.strictEqual(output.branch_name, 'gsd/phase-01-foundation',
'branch_name must use normalized phase number (strip project_code prefix, zero-pad), not raw phase_number');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdInitTodos (INIT-01)
// ─────────────────────────────────────────────────────────────────────────────
describe('cmdInitTodos', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createFixture();
});
afterEach(() => {
cleanup(tmpDir);
});
test('empty pending dir returns zero count', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'todos', 'pending'), { recursive: true });
const result = runGsdTools('init todos', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.todo_count, 0);
assert.deepStrictEqual(output.todos, []);
assert.strictEqual(output.pending_dir_exists, true);
});
test('missing pending dir returns zero count', () => {
const result = runGsdTools('init todos', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.todo_count, 0);
assert.deepStrictEqual(output.todos, []);
assert.strictEqual(output.pending_dir_exists, false);
});
test('multiple todos with fields are read correctly', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(path.join(pendingDir, 'task-1.md'), 'title: Fix bug\narea: backend\ncreated: 2026-02-25');
fs.writeFileSync(path.join(pendingDir, 'task-2.md'), 'title: Add feature\narea: frontend\ncreated: 2026-02-24');
fs.writeFileSync(path.join(pendingDir, 'task-3.md'), 'title: Write docs\narea: backend\ncreated: 2026-02-23');
const result = runGsdTools('init todos', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.todo_count, 3);
assert.strictEqual(output.todos.length, 3);
const task1 = output.todos.find(t => t.file === 'task-1.md');
assert.ok(task1, 'task-1.md should be in todos');
assert.strictEqual(task1.title, 'Fix bug');
assert.strictEqual(task1.area, 'backend');
assert.strictEqual(task1.created, '2026-02-25');
assert.strictEqual(task1.path, '.planning/todos/pending/task-1.md');
});
test('area filter returns only matching todos', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(path.join(pendingDir, 'task-1.md'), 'title: Fix bug\narea: backend\ncreated: 2026-02-25');
fs.writeFileSync(path.join(pendingDir, 'task-2.md'), 'title: Add feature\narea: frontend\ncreated: 2026-02-24');
fs.writeFileSync(path.join(pendingDir, 'task-3.md'), 'title: Write docs\narea: backend\ncreated: 2026-02-23');
const result = runGsdTools('init todos backend', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.todo_count, 2);
assert.strictEqual(output.area_filter, 'backend');
for (const todo of output.todos) {
assert.strictEqual(todo.area, 'backend');
}
});
test('area filter miss returns zero count', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(path.join(pendingDir, 'task-1.md'), 'title: Fix bug\narea: backend\ncreated: 2026-02-25');
const result = runGsdTools('init todos nonexistent', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.todo_count, 0);
assert.strictEqual(output.area_filter, 'nonexistent');
});
test('malformed file uses defaults', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(path.join(pendingDir, 'broken.md'), 'some random content without fields');
const result = runGsdTools('init todos', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.todo_count, 1);
const todo = output.todos[0];
assert.strictEqual(todo.title, 'Untitled');
assert.strictEqual(todo.area, 'general');
assert.strictEqual(todo.created, 'unknown');
});
test('non-md files are ignored', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(path.join(pendingDir, 'task.md'), 'title: Real task\narea: dev\ncreated: 2026-01-01');
fs.writeFileSync(path.join(pendingDir, 'notes.txt'), 'title: Not a task\narea: dev\ncreated: 2026-01-01');
const result = runGsdTools('init todos', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.todo_count, 1);
assert.strictEqual(output.todos[0].file, 'task.md');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdInitMilestoneOp (INIT-02)
// ─────────────────────────────────────────────────────────────────────────────
describe('cmdInitMilestoneOp', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createFixture();
});
afterEach(() => {
cleanup(tmpDir);
});
test('no phase directories returns zero counts', () => {
const result = runGsdTools('init milestone-op', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_count, 0);
assert.strictEqual(output.completed_phases, 0);
assert.strictEqual(output.all_phases_complete, false);
});
test('multiple phases with no summaries', () => {
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
const phase2 = path.join(tmpDir, '.planning', 'phases', '02-api');
fs.mkdirSync(phase1, { recursive: true });
fs.mkdirSync(phase2, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phase2, '02-01-PLAN.md'), '# Plan');
const result = runGsdTools('init milestone-op', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_count, 2);
assert.strictEqual(output.completed_phases, 0);
assert.strictEqual(output.all_phases_complete, false);
});
test('mix of complete and incomplete phases', () => {
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
const phase2 = path.join(tmpDir, '.planning', 'phases', '02-api');
fs.mkdirSync(phase1, { recursive: true });
fs.mkdirSync(phase2, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phase1, '01-01-SUMMARY.md'), '# Summary');
fs.writeFileSync(path.join(phase2, '02-01-PLAN.md'), '# Plan');
const result = runGsdTools('init milestone-op', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_count, 2);
assert.strictEqual(output.completed_phases, 1);
assert.strictEqual(output.all_phases_complete, false);
});
test('all phases complete', () => {
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phase1, '01-01-SUMMARY.md'), '# Summary');
const result = runGsdTools('init milestone-op', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_count, 1);
assert.strictEqual(output.completed_phases, 1);
assert.strictEqual(output.all_phases_complete, true);
});
test('project_code-prefixed phase directories count as completed milestone phases (#1836)', () => {
seedPhase(tmpDir, 'PROJ-01-setup', {
'PROJ-01-01-PLAN.md': '# Plan',
'PROJ-01-01-SUMMARY.md': '# Summary',
});
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ project_code: 'PROJ' }, null, 2)
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'---',
'gsd_state_version: 1.0',
'milestone: v1.0.0',
'milestone_name: Test Milestone',
'status: executing',
'---',
'',
].join('\n')
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'## 🚧 v1.0.0 Test Milestone',
'### Phase 1: Setup',
'',
].join('\n')
);
const result = runGsdTools('init milestone-op', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_count, 1);
assert.strictEqual(output.completed_phases, 1);
assert.strictEqual(output.all_phases_complete, true);
});
test('backlog 999.x headings do not inflate milestone phase counts (#1838)', () => {
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phase1, '01-01-SUMMARY.md'), '# Summary');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'---',
'gsd_state_version: 1.0',
'milestone: v1.0.0',
'milestone_name: Test Milestone',
'status: completed',
'---',
'',
].join('\n')
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'## 🚧 v1.0.0 Test Milestone',
'### Phase 1: Setup',
'',
'## Backlog',
'### Phase 999.1: Deferred Idea',
'### Phase 999.2: Another Deferred Idea',
'',
].join('\n')
);
const result = runGsdTools('init milestone-op', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_count, 1);
assert.strictEqual(output.completed_phases, 1);
assert.strictEqual(output.all_phases_complete, true);
});
test('archive directory scanning', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'archive', 'v1.0'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'archive', 'v0.9'), { recursive: true });
const result = runGsdTools('init milestone-op', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.archive_count, 2);
assert.strictEqual(output.archived_milestones.length, 2);
});
test('no archive directory returns empty', () => {
const result = runGsdTools('init milestone-op', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.archive_count, 0);
assert.deepStrictEqual(output.archived_milestones, []);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdInitPhaseOp fallback (INIT-04)
// ─────────────────────────────────────────────────────────────────────────────
describe('cmdInitPhaseOp fallback', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createFixture();
});
afterEach(() => {
cleanup(tmpDir);
});
test('normal path with existing directory', () => {
seedPhase(tmpDir, '03-api', {
'03-CONTEXT.md': '# Context',
'03-01-PLAN.md': '# Plan',
});
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 3: API\n**Goal:** Build API\n**Plans:** 1 plans\n'
);
const result = runGsdTools('init phase-op 3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true);
assert.ok(output.phase_dir.includes('03-api'), 'phase_dir should contain 03-api');
assert.strictEqual(output.has_context, true);
assert.strictEqual(output.has_plans, true);
});
test('fallback to ROADMAP when no directory exists', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 5: Widget Builder\n**Goal:** Build widgets\n**Plans:** TBD\n'
);
const result = runGsdTools('init phase-op 5', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true);
assert.strictEqual(output.phase_dir, null);
assert.strictEqual(output.phase_slug, 'widget-builder');
assert.strictEqual(output.has_research, false);
assert.strictEqual(output.has_context, false);
assert.strictEqual(output.has_plans, false);
});
test('fallback resolves drifted project-code-prefixed roadmap heading by bare number (#1455)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase MANIFOLD-117: Prefixed Heading\n**Goal:** Build prefixed phase\n**Plans:** TBD\n'
);
const result = runGsdTools('init phase-op 117', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true);
assert.strictEqual(output.phase_dir, null);
assert.strictEqual(output.phase_number, '117');
assert.strictEqual(output.phase_name, 'Prefixed Heading');
assert.strictEqual(output.phase_slug, 'prefixed-heading');
});
test('fallback resolves drifted project-code-prefixed roadmap heading by prefixed ID (#1455)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase MANIFOLD-117: Prefixed Heading\n**Goal:** Build prefixed phase\n**Plans:** TBD\n'
);
const result = runGsdTools('init phase-op MANIFOLD-117', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true);
assert.strictEqual(output.phase_dir, null);
assert.strictEqual(output.phase_number, 'MANIFOLD-117');
assert.strictEqual(output.phase_name, 'Prefixed Heading');
assert.strictEqual(output.phase_slug, 'prefixed-heading');
});
test('prefers current milestone roadmap entry over archived phase with same number', () => {
const archiveDir = path.join(
tmpDir,
'.planning',
'milestones',
'v1.2-phases',
'02-event-parser-and-queue-schema'
);
fs.mkdirSync(archiveDir, { recursive: true });
fs.writeFileSync(path.join(archiveDir, '02-CONTEXT.md'), '# Archived context');
fs.writeFileSync(path.join(archiveDir, '02-01-PLAN.md'), '# Archived plan');
fs.writeFileSync(path.join(archiveDir, '02-VERIFICATION.md'), '# Archived verification');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
<details>
<summary>Shipped milestone v1.2</summary>
### Phase 2: Event Parser and Queue Schema
**Goal:** Archived milestone work
</details>
## Milestone v1.3 Current
### Phase 2: Retry Orchestration
**Goal:** Current milestone work
**Plans:** TBD
`
);
const result = runGsdTools('init phase-op 2', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true);
assert.strictEqual(output.phase_dir, null);
assert.strictEqual(output.phase_name, 'Retry Orchestration');
assert.strictEqual(output.phase_slug, 'retry-orchestration');
assert.strictEqual(output.has_context, false);
assert.strictEqual(output.has_plans, false);
assert.strictEqual(output.has_verification, false);
});
test('neither directory nor roadmap entry returns not found', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 1: Setup\n**Goal:** Setup project\n**Plans:** TBD\n'
);
const result = runGsdTools('init phase-op 99', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, false);
assert.strictEqual(output.phase_dir, null);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdInitProgress (INIT-03)
// ─────────────────────────────────────────────────────────────────────────────
describe('cmdInitProgress', () => {
let tmpDir;
function writePassedVerification(phaseDir, phaseToken) {
fs.writeFileSync(
path.join(phaseDir, `${phaseToken}-VERIFICATION.md`),
['---', 'status: passed', '---', '', '# Verification', ''].join('\n')
);
}
beforeEach(() => {
tmpDir = createFixture();
});
afterEach(() => {
cleanup(tmpDir);
});
test('no phases returns empty state', () => {
const result = runGsdTools('init progress', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_count, 0);
assert.deepStrictEqual(output.phases, []);
assert.strictEqual(output.current_phase, null);
assert.strictEqual(output.next_phase, null);
assert.strictEqual(output.has_work_in_progress, false);
});
test('multiple phases with mixed statuses', () => {
// Phase 01: complete (has plan + summary)
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phase1, '01-01-SUMMARY.md'), '# Summary');
writePassedVerification(phase1, '01');
// Phase 02: in_progress (has plan, no summary)
const phase2 = path.join(tmpDir, '.planning', 'phases', '02-api');
fs.mkdirSync(phase2, { recursive: true });
fs.writeFileSync(path.join(phase2, '02-01-PLAN.md'), '# Plan');
// Phase 03: pending (no plan, no research)
const phase3 = path.join(tmpDir, '.planning', 'phases', '03-ui');
fs.mkdirSync(phase3, { recursive: true });
fs.writeFileSync(path.join(phase3, '03-CONTEXT.md'), '# Context');
const result = runGsdTools('init progress', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_count, 3);
assert.strictEqual(output.completed_count, 1);
assert.strictEqual(output.in_progress_count, 1);
assert.strictEqual(output.has_work_in_progress, true);
assert.strictEqual(output.current_phase.number, '02');
assert.strictEqual(output.current_phase.status, 'in_progress');
assert.strictEqual(output.next_phase.number, '03');
assert.strictEqual(output.next_phase.status, 'pending');
// Verify phase entries have expected structure
const p1 = output.phases.find(p => p.number === '01');
assert.strictEqual(p1.status, 'complete');
assert.strictEqual(p1.plan_count, 1);
assert.strictEqual(p1.summary_count, 1);
});
test('researched status detected correctly', () => {
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-RESEARCH.md'), '# Research');
const result = runGsdTools('init progress', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const p1 = output.phases.find(p => p.number === '01');
assert.strictEqual(p1.status, 'researched');
assert.strictEqual(p1.has_research, true);
assert.strictEqual(output.current_phase.number, '01');
});
test('all phases complete returns no current or next', () => {
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phase1, '01-01-SUMMARY.md'), '# Summary');
writePassedVerification(phase1, '01');
const result = runGsdTools('init progress', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.completed_count, 1);
assert.strictEqual(output.current_phase, null);
assert.strictEqual(output.next_phase, null);
});
test('implementation-complete phase without passed verification remains current work', () => {
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phase1, '01-01-SUMMARY.md'), '# Summary');
const result = runGsdTools('init progress', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.completed_count, 0);
assert.strictEqual(output.in_progress_count, 1);
assert.strictEqual(output.has_work_in_progress, true);
assert.strictEqual(output.current_phase.number, '01');
assert.strictEqual(output.current_phase.status, 'executed');
assert.strictEqual(output.current_phase.implementation_complete, true);
assert.strictEqual(output.current_phase.verification_status, 'missing');
assert.strictEqual(output.current_phase.verification_passed, false);
});
test('paused_at detected from STATE.md', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\n**Paused At:** Phase 2, Task 3 — implementing auth\n'
);
const result = runGsdTools('init progress', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.paused_at, 'paused_at should be set');
assert.ok(output.paused_at.includes('Phase 2, Task 3'), 'paused_at should contain pause location');
});
test('no paused_at when STATE.md has no pause line', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\nSome content without pause.\n'
);
const result = runGsdTools('init progress', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.paused_at, null);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdInitQuick (INIT-05)
// ─────────────────────────────────────────────────────────────────────────────
describe('cmdInitQuick', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createFixture();
});
afterEach(() => {
cleanup(tmpDir);
});
test('with description generates slug and task_dir with YYMMDD-xxx format', () => {
const result = runGsdTools('init quick "Fix login bug"', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.branch_name, null);
assert.strictEqual(output.slug, 'fix-login-bug');
assert.strictEqual(output.description, 'Fix login bug');
// quick_id must match YYMMDD-xxx (6 digits, dash, 3 base36 chars)
assert.ok(/^\d{6}-[0-9a-z]{3}$/.test(output.quick_id),
`quick_id should match YYMMDD-xxx, got: "${output.quick_id}"`);
// task_dir must use the new ID format
assert.ok(output.task_dir.startsWith('.planning/quick/'),
`task_dir should start with .planning/quick/, got: "${output.task_dir}"`);
assert.ok(output.task_dir.endsWith('-fix-login-bug'),
`task_dir should end with -fix-login-bug, got: "${output.task_dir}"`);
assert.ok(/^\.planning\/quick\/\d{6}-[0-9a-z]{3}-fix-login-bug$/.test(output.task_dir),
`task_dir format wrong: "${output.task_dir}"`);
// next_num must NOT be present
assert.ok(!('next_num' in output), 'next_num should not be in output');
});
test('without description returns null slug and task_dir', () => {
const result = runGsdTools('init quick', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.slug, null);
assert.strictEqual(output.task_dir, null);
assert.strictEqual(output.description, null);
// quick_id is still generated even without description
assert.ok(/^\d{6}-[0-9a-z]{3}$/.test(output.quick_id),
`quick_id should match YYMMDD-xxx, got: "${output.quick_id}"`);
});
test('two rapid calls produce different quick_ids (no collision within 2s window)', () => {
// Both calls happen within the same test, which is sub-second.
// They may or may not land in the same 2-second block. We just verify format.
const r1 = runGsdTools('init quick "Task one"', tmpDir);
const r2 = runGsdTools('init quick "Task two"', tmpDir);
assert.ok(r1.success && r2.success);
const o1 = JSON.parse(r1.output);
const o2 = JSON.parse(r2.output);
assert.ok(/^\d{6}-[0-9a-z]{3}$/.test(o1.quick_id));
assert.ok(/^\d{6}-[0-9a-z]{3}$/.test(o2.quick_id));
// Directories are distinct because slugs differ
assert.notStrictEqual(o1.task_dir, o2.task_dir);
});
test('long description truncates slug to 40 chars', () => {
const result = runGsdTools('init quick "This is a very long description that should get truncated to forty characters maximum"', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.slug.length <= 40, `Slug should be <= 40 chars, got ${output.slug.length}: "${output.slug}"`);
});
test('returns quick branch name when quick_branch_template is configured', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({
git: {
quick_branch_template: 'gsd/quick-{num}-{slug}',
},
}, null, 2)
);
const result = runGsdTools('init quick "Fix login bug"', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.branch_name, 'branch_name should be set');
assert.ok(output.branch_name.startsWith('gsd/quick-'));
assert.ok(output.branch_name.endsWith('-fix-login-bug'));
assert.ok(output.branch_name.includes(output.quick_id), 'branch_name should include quick_id');
});
test('uses fallback slug in quick branch name when description is omitted', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({
git: {
quick_branch_template: 'gsd/quick-{quick}-{slug}',
},
}, null, 2)
);
const result = runGsdTools('init quick', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.branch_name, 'branch_name should be set');
assert.ok(output.branch_name.endsWith('-quick'), `Expected fallback slug in branch name, got "${output.branch_name}"`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdInitMapCodebase (INIT-05)
// ─────────────────────────────────────────────────────────────────────────────
describe('cmdInitMapCodebase', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createFixture();
});
afterEach(() => {
cleanup(tmpDir);
});
test('no codebase dir returns empty', () => {
const result = runGsdTools('init map-codebase', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.has_maps, false);
assert.deepStrictEqual(output.existing_maps, []);
assert.strictEqual(output.codebase_dir_exists, false);
});
test('with existing maps lists md files only', () => {
const codebaseDir = path.join(tmpDir, '.planning', 'codebase');
fs.mkdirSync(codebaseDir, { recursive: true });
fs.writeFileSync(path.join(codebaseDir, 'STACK.md'), '# Stack');
fs.writeFileSync(path.join(codebaseDir, 'ARCHITECTURE.md'), '# Architecture');
fs.writeFileSync(path.join(codebaseDir, 'notes.txt'), 'not a markdown file');
const result = runGsdTools('init map-codebase', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.has_maps, true);
assert.strictEqual(output.existing_maps.length, 2);
assert.ok(output.existing_maps.includes('STACK.md'), 'Should include STACK.md');
assert.ok(output.existing_maps.includes('ARCHITECTURE.md'), 'Should include ARCHITECTURE.md');
});
test('empty codebase dir returns no maps', () => {
const codebaseDir = path.join(tmpDir, '.planning', 'codebase');
fs.mkdirSync(codebaseDir, { recursive: true });
const result = runGsdTools('init map-codebase', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.has_maps, false);
assert.deepStrictEqual(output.existing_maps, []);
assert.strictEqual(output.codebase_dir_exists, true);
});
test('map-codebase workflow does not list OpenCode under runtimes without Task tool (#1316)', () => {
const workflow = fs.readFileSync(
path.join(__dirname, '..', 'gsd-core', 'workflows', 'map-codebase.md'), 'utf8'
);
// OpenCode must NOT appear in the "WITHOUT Task tool" / "NOT available" condition
const withoutLine = workflow.split(/\r?\n/).find(l =>
l.includes('NOT available') || l.includes('WITHOUT Task tool')
);
assert.ok(withoutLine, 'workflow should have a line about Task tool NOT being available');
assert.ok(!withoutLine.includes('OpenCode'), 'OpenCode must NOT be listed under runtimes WITHOUT Task tool');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdInitNewProject (INIT-06)
// ─────────────────────────────────────────────────────────────────────────────
describe('cmdInitNewProject', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createFixture();
});
afterEach(() => {
cleanup(tmpDir);
});
test('greenfield project with no code', () => {
const result = runGsdTools('init new-project', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.has_existing_code, false);
assert.strictEqual(output.has_package_file, false);
assert.strictEqual(output.is_brownfield, false);
assert.strictEqual(output.needs_codebase_map, false);
});
test('brownfield with package.json detected', () => {
fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"test"}');
const result = runGsdTools('init new-project', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.has_package_file, true);
assert.strictEqual(output.is_brownfield, true);
assert.strictEqual(output.needs_codebase_map, true);
});
test('brownfield with codebase map does not need map', () => {
fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"test"}');
fs.mkdirSync(path.join(tmpDir, '.planning', 'codebase'), { recursive: true });
for (const name of ['STACK', 'ARCHITECTURE', 'STRUCTURE', 'CONVENTIONS', 'TESTING', 'INTEGRATIONS', 'CONCERNS']) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'codebase', `${name}.md`), `# ${name}\n`);
}
const result = runGsdTools('init new-project', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.is_brownfield, true);
assert.strictEqual(output.needs_codebase_map, false);
});
test('planning_exists flag is correct', () => {
const result = runGsdTools('init new-project', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.planning_exists, true);
});
test('brownfield with Kotlin files detected (Android project)', () => {
const srcDir = path.join(tmpDir, 'app', 'src', 'main');
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, 'MainActivity.kt'), 'class MainActivity');
const result = runGsdTools('init new-project', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.has_existing_code, true);
assert.strictEqual(output.is_brownfield, true);
});
test('brownfield with build.gradle detected (Android/Gradle project)', () => {
fs.writeFileSync(path.join(tmpDir, 'build.gradle'), 'apply plugin: "com.android.application"');
const result = runGsdTools('init new-project', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.has_package_file, true);
assert.strictEqual(output.is_brownfield, true);
assert.strictEqual(output.needs_codebase_map, true);
});
test('brownfield with build.gradle.kts detected (Kotlin DSL)', () => {
fs.writeFileSync(path.join(tmpDir, 'build.gradle.kts'), 'plugins { id("com.android.application") }');
const result = runGsdTools('init new-project', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.has_package_file, true);
assert.strictEqual(output.is_brownfield, true);
});
test('brownfield with pom.xml detected (Maven project)', () => {
fs.writeFileSync(path.join(tmpDir, 'pom.xml'), '<project></project>');
const result = runGsdTools('init new-project', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.has_package_file, true);
assert.strictEqual(output.is_brownfield, true);
});
test('brownfield with pubspec.yaml detected (Flutter/Dart project)', () => {
fs.writeFileSync(path.join(tmpDir, 'pubspec.yaml'), 'name: my_app');
const result = runGsdTools('init new-project', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.has_package_file, true);
assert.strictEqual(output.is_brownfield, true);
});
test('brownfield with Dart files detected', () => {
const libDir = path.join(tmpDir, 'lib');
fs.mkdirSync(libDir, { recursive: true });
fs.writeFileSync(path.join(libDir, 'main.dart'), 'void main() {}');
const result = runGsdTools('init new-project', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.has_existing_code, true);
assert.strictEqual(output.is_brownfield, true);
});
test('brownfield with C++ files detected', () => {
fs.writeFileSync(path.join(tmpDir, 'main.cpp'), 'int main() { return 0; }');
const result = runGsdTools('init new-project', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.has_existing_code, true);
assert.strictEqual(output.is_brownfield, true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdInitNewMilestone (INIT-06)
// ─────────────────────────────────────────────────────────────────────────────
describe('cmdInitNewMilestone', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createFixture();
});
afterEach(() => {
cleanup(tmpDir);
});
test('returns expected fields', () => {
const result = runGsdTools('init new-milestone', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok('current_milestone' in output, 'Should have current_milestone');
assert.ok('current_milestone_name' in output, 'Should have current_milestone_name');
assert.ok('researcher_model' in output, 'Should have researcher_model');
assert.ok('synthesizer_model' in output, 'Should have synthesizer_model');
assert.ok('roadmapper_model' in output, 'Should have roadmapper_model');
assert.ok('commit_docs' in output, 'Should have commit_docs');
assert.strictEqual(output.project_path, '.planning/PROJECT.md');
assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md');
assert.strictEqual(output.state_path, '.planning/STATE.md');
});
test('file existence flags reflect actual state', () => {
// Default: no STATE.md, ROADMAP.md, or PROJECT.md
const result1 = runGsdTools('init new-milestone', tmpDir);
assert.ok(result1.success, `Command failed: ${result1.error}`);
const output1 = JSON.parse(result1.output);
assert.strictEqual(output1.state_exists, false);
assert.strictEqual(output1.roadmap_exists, false);
assert.strictEqual(output1.project_exists, false);
// Create files and verify flags change
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap');
fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project');
const result2 = runGsdTools('init new-milestone', tmpDir);
assert.ok(result2.success, `Command failed: ${result2.error}`);
const output2 = JSON.parse(result2.output);
assert.strictEqual(output2.state_exists, true);
assert.strictEqual(output2.roadmap_exists, true);
assert.strictEqual(output2.project_exists, true);
});
test('reports latest completed milestone and archive target for reset flow', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'MILESTONES.md'),
'# Milestones\n\n## v1.2 Search Refresh (Shipped: 2026-02-18)\n\n---\n'
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-refine-search'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '07-polish'), { recursive: true });
const result = runGsdTools('init new-milestone', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.latest_completed_milestone, 'v1.2');
assert.strictEqual(output.latest_completed_milestone_name, 'Search Refresh');
assert.strictEqual(output.phase_dir_count, 2);
assert.strictEqual(output.phase_archive_path, '.planning/milestones/v1.2-phases');
});
test('reset flow metadata is null-safe when no milestones file exists', () => {
const result = runGsdTools('init new-milestone', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.latest_completed_milestone, null);
assert.strictEqual(output.latest_completed_milestone_name, null);
assert.strictEqual(output.phase_dir_count, 0);
assert.strictEqual(output.phase_archive_path, null);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// findProjectRoot integration — gsd-tools resolves project root from sub-repo
// ─────────────────────────────────────────────────────────────────────────────
describe('findProjectRoot integration via --cwd', () => {
let projectRoot;
beforeEach(() => {
projectRoot = createFixture();
// Add ROADMAP.md so init quick doesn't error
fs.writeFileSync(
path.join(projectRoot, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## Phase 1: Foundation\n**Goal:** Setup\n'
);
// Write sub_repos config
fs.writeFileSync(
path.join(projectRoot, '.planning', 'config.json'),
JSON.stringify({ sub_repos: ['backend', 'frontend'] })
);
// Create sub-repo directory
fs.mkdirSync(path.join(projectRoot, 'backend'));
});
afterEach(() => {
cleanup(projectRoot);
});
test('init quick from sub-repo CWD returns project_root pointing to parent', () => {
const backendDir = path.join(projectRoot, 'backend');
const result = runGsdTools(['init', 'quick', 'test task', '--cwd', backendDir]);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok('project_root' in output, 'Should have project_root');
assert.strictEqual(output.project_root, projectRoot, 'project_root should be the parent, not the sub-repo');
assert.ok(output.roadmap_exists, 'Should find ROADMAP.md at project root');
});
test('init quick from project root returns project_root as-is', () => {
const result = runGsdTools(['init', 'quick', 'test task', '--cwd', projectRoot]);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.project_root, projectRoot);
});
test('state load from sub-repo CWD reads project root config', () => {
// Write STATE.md at project root
fs.writeFileSync(
path.join(projectRoot, '.planning', 'STATE.md'),
'---\ncurrent_phase: 1\nphase_name: Foundation\n---\n# State\n'
);
const backendDir = path.join(projectRoot, 'backend');
const result = runGsdTools(['state', '--cwd', backendDir]);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// Should find config from project root, not from backend/
assert.deepStrictEqual(output.config.sub_repos, ['backend', 'frontend'],
'Should read sub_repos from project root config');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// #2192: init plan-phase must include auto_advance, auto_chain_active, and mode
// so workflows don't need separate config-get calls that loop on Kimi K2.5
// ─────────────────────────────────────────────────────────────────────────────
describe('#2192: init plan-phase includes auto-advance config to prevent separate config-get loops', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createFixture();
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-auth'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
['# Roadmap', '', '## Milestone v1', '', '### Phase 1: Auth', '**Goal:** Auth'].join('\n')
);
});
afterEach(() => {
cleanup(tmpDir);
});
test('init plan-phase includes auto_advance field (defaults false)', () => {
const result = runGsdTools('init plan-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok('auto_advance' in output, 'init plan-phase must include auto_advance field');
assert.strictEqual(output.auto_advance, false, 'auto_advance should default to false');
});
test('init plan-phase includes auto_chain_active field (defaults false)', () => {
const result = runGsdTools('init plan-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok('auto_chain_active' in output, 'init plan-phase must include auto_chain_active field');
assert.strictEqual(output.auto_chain_active, false, 'auto_chain_active should default to false');
});
test('init plan-phase includes mode field (defaults to interactive)', () => {
const result = runGsdTools('init plan-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok('mode' in output, 'init plan-phase must include mode field');
assert.strictEqual(output.mode, 'interactive', 'mode should default to interactive');
});
test('init plan-phase reflects auto_advance true when set in config', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
const cfg = { workflow: { auto_advance: true } };
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
const result = runGsdTools('init plan-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.auto_advance, true, 'auto_advance should reflect config value');
});
test('init plan-phase reflects auto_chain_active true when set in config', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
const cfg = { workflow: { _auto_chain_active: true } };
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
const result = runGsdTools('init plan-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.auto_chain_active, true, 'auto_chain_active should reflect config value');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// withProjectRoot: project identity injection (#1948)
// ─────────────────────────────────────────────────────────────────────────────
describe('withProjectRoot project identity', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createFixture();
});
afterEach(() => {
cleanup(tmpDir);
});
test('injects project_code when config.project_code is set', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ project_code: 'CK' })
);
const result = runGsdTools(['init', 'quick', 'test task'], tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.project_code, 'CK');
});
test('injects project_title extracted from PROJECT.md H1', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ project_code: 'CK' })
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
'# CareKit\n\nA care management platform.\n'
);
const result = runGsdTools(['init', 'quick', 'test task'], tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.project_title, 'CareKit');
});
test('omits project_code and project_title when project_code is not set', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({})
);
const result = runGsdTools(['init', 'quick', 'test task'], tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.project_code, undefined,
'project_code should not be present when not configured');
// project_title may or may not be present depending on PROJECT.md existence,
// but without project_code the workflow omits the identity suffix entirely
});
test('omits project_title when PROJECT.md does not exist', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ project_code: 'CK' })
);
// Ensure no PROJECT.md exists (createFixture doesn't create one)
const projectMdPath = path.join(tmpDir, '.planning', 'PROJECT.md');
if (fs.existsSync(projectMdPath)) fs.unlinkSync(projectMdPath);
const result = runGsdTools(['init', 'quick', 'test task'], tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.project_code, 'CK',
'project_code should still be present');
assert.strictEqual(output.project_title, undefined,
'project_title should not be present when PROJECT.md is missing');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// ADR-0006: init handlers honor GSD_WORKSTREAM (planningPaths/planningDir consumption)
// Issue #1189 — regression guard: workstream-scoped paths must be resolved
// through planningDir(cwd) which picks up GSD_WORKSTREAM from env.
// ─────────────────────────────────────────────────────────────────────────────
describe('init handlers honor GSD_WORKSTREAM (ADR-0006 planningPaths consumption)', () => {
const { seedWorkstream } = require('./fixtures/index.cjs');
/**
* Build a workstream-scoped fixture under tmpDir.
* Only workstream-scoped files exist; flat .planning/ has only the phases dir.
*/
function buildWsFixture(tmpDir, ws = 'wsx') {
const wsDir = seedWorkstream(tmpDir, { name: ws });
// Write the planning files ONLY under the workstream path
fs.writeFileSync(
path.join(wsDir, 'STATE.md'),
'# State\n'
);
fs.writeFileSync(
path.join(wsDir, 'ROADMAP.md'),
'# Roadmap\n\n### Phase 1: Setup\n**Goal:** Bootstrap\n**Requirements**: R-01\n**Plans:** 1 plans\n'
);
fs.writeFileSync(
path.join(wsDir, 'config.json'),
JSON.stringify({})
);
// Create a phase plan under the workstream
fs.mkdirSync(path.join(wsDir, 'phases', '01-setup'), { recursive: true });
fs.writeFileSync(path.join(wsDir, 'phases', '01-setup', '01-01-PLAN.md'), '# Plan\n');
return wsDir;
}
// ── Test 1: execute-phase — workstream-scoped path fields (happy) ─────────
describe('execute-phase — workstream-scoped path fields', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createFixture();
buildWsFixture(tmpDir, 'wsx');
});
afterEach(() => {
cleanup(tmpDir);
});
test('execute-phase emits workstream-scoped state/roadmap/config paths (ADR-0006)', () => {
const result = runGsdTools('init execute-phase 1', tmpDir, { GSD_WORKSTREAM: 'wsx', GSD_PROJECT: '' });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// Positive: paths must be workstream-scoped
assert.strictEqual(output.state_path, '.planning/workstreams/wsx/STATE.md');
assert.strictEqual(output.roadmap_path, '.planning/workstreams/wsx/ROADMAP.md');
assert.strictEqual(output.config_path, '.planning/workstreams/wsx/config.json');
// Goodhart both-directions: must NOT be the flat form
assert.notStrictEqual(output.state_path, '.planning/STATE.md');
assert.notStrictEqual(output.roadmap_path, '.planning/ROADMAP.md');
assert.notStrictEqual(output.config_path, '.planning/config.json');
// phase_dir is emitted and must be workstream-scoped
assert.ok(
output.phase_dir && output.phase_dir.includes('workstreams/wsx'),
`phase_dir should include workstreams/wsx, got: ${output.phase_dir}`
);
});
test('execute-phase WITHOUT GSD_WORKSTREAM resolves flat paths (boundary control)', () => {
// Flat fixture: the workstream fixture exists but we do NOT pass GSD_WORKSTREAM.
// Handler should resolve flat .planning/ → state/roadmap/config are flat,
// and the workstream phase is NOT found (flat phases/ is empty).
const result = runGsdTools('init execute-phase 1', tmpDir, { GSD_WORKSTREAM: '', GSD_PROJECT: '' });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// Flat paths must be returned when no workstream is active
assert.strictEqual(output.state_path, '.planning/STATE.md');
assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md');
assert.strictEqual(output.config_path, '.planning/config.json');
// Phase is NOT found in flat .planning/phases/ (only exists under workstream)
assert.strictEqual(output.phase_found, false,
'phase should not be found in flat path when only workstream fixture exists');
});
});
// ── Test 2: milestone-op — reads workstream-scoped roadmap/state/phases ───
describe('milestone-op — reads workstream-scoped planning files', () => {
let tmpDir;
beforeEach(() => {
// Do NOT call createFixture (which would add flat .planning/phases/).
// Create a bare temp dir so files only exist under the workstream path.
const os = require('os');
tmpDir = fs.mkdtempSync(require('path').join(os.tmpdir(), 'gsd-test-'));
buildWsFixture(tmpDir, 'wsx');
});
afterEach(() => {
cleanup(tmpDir);
});
test('milestone-op with GSD_WORKSTREAM finds roadmap/state/phases in workstream scope (ADR-0006)', () => {
const result = runGsdTools('init milestone-op', tmpDir, { GSD_WORKSTREAM: 'wsx', GSD_PROJECT: '' });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.roadmap_exists, true,
'roadmap_exists must be true: ROADMAP.md exists only under workstream path');
assert.strictEqual(output.state_exists, true,
'state_exists must be true: STATE.md exists only under workstream path');
assert.strictEqual(output.phases_dir_exists, true,
'phases_dir_exists must be true: phases/ exists under workstream path');
});
test('milestone-op WITHOUT GSD_WORKSTREAM misses workstream-only files (negative discrimination)', () => {
const result = runGsdTools('init milestone-op', tmpDir, { GSD_WORKSTREAM: '', GSD_PROJECT: '' });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// Handler looked at flat .planning/ — no files there → all false
assert.strictEqual(output.roadmap_exists, false,
'roadmap_exists must be false: ROADMAP.md is only under workstream, not flat .planning/');
assert.strictEqual(output.state_exists, false,
'state_exists must be false: STATE.md is only under workstream, not flat .planning/');
});
});
// ── Test 3: plan-phase — workstream-scoped resolution ────────────────────
describe('plan-phase — workstream-scoped path resolution', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createFixture();
buildWsFixture(tmpDir, 'wsx');
});
afterEach(() => {
cleanup(tmpDir);
});
test('plan-phase emits workstream-scoped state/roadmap/requirements paths (ADR-0006)', () => {
const result = runGsdTools('init plan-phase 1', tmpDir, { GSD_WORKSTREAM: 'wsx', GSD_PROJECT: '' });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// Path fields must be scoped to the workstream
assert.strictEqual(output.state_path, '.planning/workstreams/wsx/STATE.md');
assert.strictEqual(output.roadmap_path, '.planning/workstreams/wsx/ROADMAP.md');
assert.strictEqual(output.requirements_path, '.planning/workstreams/wsx/REQUIREMENTS.md');
// Must NOT be flat
assert.notStrictEqual(output.state_path, '.planning/STATE.md');
assert.notStrictEqual(output.roadmap_path, '.planning/ROADMAP.md');
// phase_dir is workstream-scoped and phase is found
assert.strictEqual(output.phase_found, true);
assert.ok(
output.phase_dir && output.phase_dir.includes('workstreams/wsx'),
`phase_dir should include workstreams/wsx, got: ${output.phase_dir}`
);
});
test('plan-phase WITHOUT GSD_WORKSTREAM resolves flat paths (boundary control)', () => {
const result = runGsdTools('init plan-phase 1', tmpDir, { GSD_WORKSTREAM: '', GSD_PROJECT: '' });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.state_path, '.planning/STATE.md');
assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md');
assert.strictEqual(output.requirements_path, '.planning/REQUIREMENTS.md');
// Phase only exists under workstream, so not found via flat path
assert.strictEqual(output.phase_found, false);
});
});
// ── Test 4: phase-op — workstream-scoped phase resolution ────────────────
describe('phase-op — workstream-scoped phase resolution', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createFixture();
buildWsFixture(tmpDir, 'wsx');
});
afterEach(() => {
cleanup(tmpDir);
});
test('phase-op with GSD_WORKSTREAM finds phase in workstream scope and emits scoped paths (ADR-0006)', () => {
const result = runGsdTools('init phase-op 1', tmpDir, { GSD_WORKSTREAM: 'wsx', GSD_PROJECT: '' });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// Phase is found via workstream-scoped phases dir
assert.strictEqual(output.phase_found, true,
'phase_found must be true: phase exists under workstream path');
assert.ok(
output.phase_dir && output.phase_dir.includes('workstreams/wsx'),
`phase_dir should include workstreams/wsx, got: ${output.phase_dir}`
);
// Path fields are workstream-scoped
assert.strictEqual(output.state_path, '.planning/workstreams/wsx/STATE.md');
assert.strictEqual(output.roadmap_path, '.planning/workstreams/wsx/ROADMAP.md');
assert.notStrictEqual(output.state_path, '.planning/STATE.md');
});
test('phase-op WITHOUT GSD_WORKSTREAM does not find workstream-only phase (negative discrimination)', () => {
const result = runGsdTools('init phase-op 1', tmpDir, { GSD_WORKSTREAM: '', GSD_PROJECT: '' });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// Phase only exists under workstream path — flat path has no matching phase dir
assert.strictEqual(output.phase_found, false,
'phase_found must be false: phase only exists under workstream path');
// Flat paths are emitted
assert.strictEqual(output.state_path, '.planning/STATE.md');
assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md');
});
});
});
// ─────────────────────────────────────────────────────────────────────────────
// #1912: init.progress fails safe in workstream mode with no active workstream
// ─────────────────────────────────────────────────────────────────────────────
describe('#1912 — init.progress fails safe in workstream mode with no active workstream', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
function seedWs(name, milestoneVersion) {
const wsDir = path.join(tmpDir, '.planning', 'workstreams', name);
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });
fs.writeFileSync(
path.join(wsDir, 'STATE.md'),
`# State\n\n**Status:** executing\n**Milestone:** ${milestoneVersion}\n`,
);
fs.writeFileSync(
path.join(wsDir, 'ROADMAP.md'),
`# Roadmap\n\n## Milestones\n- ${milestoneVersion} Test (Phase 1)\n\n## Phases\n### Phase 1: X\n**Goal:** do x\n`,
);
return wsDir;
}
test('errors (does NOT report stale root) when workstreams exist but none active', () => {
seedWs('alpha', 'v9.0');
seedWs('beta', 'v9.0');
// Stale root STATE — the misleading value that must never be silently reported.
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'milestone: v7.1\nstatus: executing\n');
// No active-workstream pointer, no --ws.
const result = runGsdTools('init progress', tmpDir);
assert.equal(result.success, false, 'should fail safe rather than report the stale root milestone');
assert.match(result.error || '', /workstream|--ws/i, 'error should name the workstream requirement');
});
test('succeeds with --ws (reads the named workstream, not root)', () => {
seedWs('alpha', 'v9.0');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'milestone: v7.1\nstatus: executing\n');
const result = runGsdTools('init progress --ws alpha', tmpDir);
assert.ok(result.success, `should succeed with --ws: ${result.error}`);
});
test('flat mode (no workstreams dir) is unchanged', () => {
const result = runGsdTools('init progress', tmpDir);
assert.ok(result.success, `flat mode should still work: ${result.error}`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// roadmap analyze command
// ─────────────────────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3491-nested-git-worktree.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3491-nested-git-worktree (consolidation epic #1969 B6 #1975)", () => {
// allow-test-rule: source-text-is-the-product (see #3491)
// Bug #3491 — new-project workflow creates nested .git in subdirectory when
// parent already has git repo.
//
// The workflow's `has_git` boolean was derived from `pathExists(cwd, '.git')`
// — a shallow check that only sees a `.git` entry directly in the current
// directory. Subdirectories of an existing git worktree therefore reported
// `has_git: false`, causing the workflow's `git init` step to create a nested
// `.git` inside the outer repo's worktree. Subsequent gsd-sdk commits then
// targeted the nested repo instead of the outer one, silently dropping all
// planning artefacts from the outer repo's history.
//
// This test asserts the corrected semantics, mirroring `git rev-parse
// --is-inside-work-tree`:
//
// - `has_git: true` is reported whenever the cwd is inside a git worktree,
// even when no `.git` entry is in cwd itself.
// - The init payload surfaces `git_worktree_root` and `in_nested_subdir` so
// the workflow can warn the user and skip `git init`.
// - The workflow markdown's `git init` step is gated on
// `in_nested_subdir: false`, never unconditional under `has_git: false`.
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execSync } = require('node:child_process');
const { runGsdTools, cleanup } = require('./helpers.cjs');
const WORKFLOW_PATH = path.join(
__dirname,
'..',
'gsd-core',
'workflows',
'new-project.md',
);
// ─── Helper: create outer git repo with a nested workstream subdir ─────────
// On Windows the runtime emits forward slashes (git's convention) while
// path.join produces backslashes — normalize both sides via the shared
// toPosixPath helper before any equality comparison.
const { toPosixPath: normalizePath } = require('./helpers.cjs');
function createOuterRepoWithSubdir(prefix = 'bug-3491-') {
const outer = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
// macOS /tmp -> /private/tmp; on Windows the runner's %TEMP% is the 8.3
// short-name (RUNNER~1) and the runtime resolves to the long form.
// realpathSync.native handles both; then normalize separators for compare.
const outerReal = fs.realpathSync.native(outer);
execSync('git init', { cwd: outerReal, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', { cwd: outerReal, stdio: 'pipe' });
execSync('git config user.name "Test"', { cwd: outerReal, stdio: 'pipe' });
execSync('git config commit.gpgsign false', { cwd: outerReal, stdio: 'pipe' });
fs.writeFileSync(path.join(outerReal, 'README.md'), '# outer\n');
execSync('git add -A', { cwd: outerReal, stdio: 'pipe' });
execSync('git commit -m "initial"', { cwd: outerReal, stdio: 'pipe' });
const subdir = path.join(outerReal, 'workstreams', 'my-project');
fs.mkdirSync(subdir, { recursive: true });
return { outer: outerReal, subdir };
}
// ─── Behavioural tests against the live `init new-project` handler ─────────
test('bug-3491: init new-project reports has_git: true inside parent git worktree', () => {
const { outer, subdir } = createOuterRepoWithSubdir();
try {
const result = runGsdTools('init new-project', subdir);
assert.ok(result.success, `init new-project failed: ${result.error}`);
const payload = JSON.parse(result.output);
// Core fix: shallow `.git in cwd` check was wrong — we are inside the
// outer worktree, so the workflow MUST see has_git: true.
assert.strictEqual(
payload.has_git,
true,
'expected has_git=true when cwd is inside an existing git worktree (parent .git)',
);
// The workflow needs the worktree root and a nesting flag to decide
// whether to skip `git init` and emit a friendly warning.
assert.strictEqual(
normalizePath(payload.git_worktree_root),
normalizePath(outer),
`expected git_worktree_root to be the outer repo (${outer}), got: ${payload.git_worktree_root}`,
);
assert.strictEqual(
payload.in_nested_subdir,
true,
'expected in_nested_subdir=true when cwd is a subdirectory of the worktree root',
);
} finally {
cleanup(outer);
}
});
test('bug-3491: init new-project reports has_git: true at worktree root with in_nested_subdir: false', () => {
const { outer } = createOuterRepoWithSubdir();
try {
const result = runGsdTools('init new-project', outer);
assert.ok(result.success, `init new-project failed: ${result.error}`);
const payload = JSON.parse(result.output);
assert.strictEqual(payload.has_git, true, 'has_git must be true at the worktree root');
assert.strictEqual(normalizePath(payload.git_worktree_root), normalizePath(outer));
assert.strictEqual(
payload.in_nested_subdir,
false,
'at the worktree root, in_nested_subdir must be false',
);
} finally {
cleanup(outer);
}
});
test('bug-3491: init new-project reports has_git: false outside any git worktree', () => {
const tmp = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'bug-3491-bare-')));
try {
const result = runGsdTools('init new-project', tmp);
assert.ok(result.success, `init new-project failed: ${result.error}`);
const payload = JSON.parse(result.output);
assert.strictEqual(payload.has_git, false);
assert.strictEqual(payload.in_nested_subdir, false);
assert.strictEqual(payload.git_worktree_root, null);
} finally {
cleanup(tmp);
}
});
test('bug-3491: init ingest-docs mirrors the same has_git semantics', () => {
// ingest-docs.md has the same shallow check and the same nested-init risk.
const { outer, subdir } = createOuterRepoWithSubdir('bug-3491-ingest-');
try {
const result = runGsdTools('init ingest-docs', subdir);
assert.ok(result.success, `init ingest-docs failed: ${result.error}`);
const payload = JSON.parse(result.output);
assert.strictEqual(
payload.has_git,
true,
'init ingest-docs must also detect parent worktree (#3491 related path)',
);
assert.strictEqual(normalizePath(payload.git_worktree_root), normalizePath(outer));
assert.strictEqual(payload.in_nested_subdir, true);
} finally {
cleanup(outer);
}
});
// ─── Workflow-text test: the deployed `new-project.md` must gate `git init` ─
test('bug-3491: new-project.md gates `git init` on in_nested_subdir, not just has_git', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
// The pre-fix workflow had the literal sequence:
//
// **If `has_git` is false:** Initialize git:
// ```bash
// git init
// ```
//
// …which fires for any subdirectory of an existing repo. The fix must
// either gate the init on `in_nested_subdir`/worktree-root semantics or
// drop the unconditional `git init` block entirely.
const unconditionalInitPattern =
/\*\*If `has_git` is false:\*\* Initialize git:\s*\r?\n+```bash\s*\r?\ngit init\s*\r?\n```/;
assert.ok(
!unconditionalInitPattern.test(content),
'new-project.md must not run `git init` unconditionally on has_git=false (#3491). ' +
'Gate it on `in_nested_subdir === false` so the workflow refuses to create ' +
'a nested .git inside an existing worktree.',
);
// The fixed workflow MUST mention the new field so reviewers can see the
// gating exists. (Workflow markdown IS the deployed product — testing it
// as text is the only end-to-end signal we have.)
assert.ok(
/in_nested_subdir/.test(content),
'new-project.md must reference `in_nested_subdir` after the #3491 fix',
);
});
});
}