* fix: require fresh phase verification before transition * no-mistakes(review): Fix canonical verification closeout gates * no-mistakes(review): Fix verify-work frontmatter promotion command * no-mistakes(review): Fix stale verification gates * no-mistakes(review): Fix canonical verification routing gates * no-mistakes(review): Fix verification dependency and runtime routing gates * no-mistakes(review): Block stale verification bypasses * fix: handle large init manager outputs in verification workflows * chore: update changeset pr number * fix(verify-work): use fresh verification.status for stale gate The stale check after UAT used phase_completion.verification_status from session-start INIT while human_needed promotion already queried fresh verification.status. Align the stale gate with the canonical query so mid-session verification refresh is not ignored. * fix(init): skip roadmap-checked phases when selecting next_phase Roadmap-only phases without a disk directory were still promoted to next_phase when their checkbox was already checked. Exclude checkboxComplete phases so progress routing does not point at work the roadmap already marks done. * fix: gaps_found not overridden by stale, transition uses canonical verification - verification.cts: check gaps_found before stale so gap-closure routing is not masked by a newer summary mtime - phase.cts: remove redundant findStaleVerificationSummary — readVerificationStatus already handles stale detection - transition.md: replace raw grep on file content with verification.status query to avoid false-positive blocks from body text matching * ci: retrigger tests after rebase * fix(transition): replace gsd_run advisory check with awk frontmatter extraction The runtime launcher is not defined until the update_roadmap_and_state step bash block (~line 165). The early verify_completion block used gsd_run to query verification.status, which violated the runtime-launcher-parity test: 'preamble appears AFTER the first gsd_run reference'. Replace the gsd_run call with an awk-based frontmatter extractor that reads only the status: field between the two --- fences. This avoids both the preamble-ordering constraint and the original false-positive grep bug where body text like 'previous_status: gaps_found' would match a full-text regex. The phase.complete gate at update_roadmap_and_state is the canonical enforcement point; this early check is advisory only. Also update workflow-size-baseline.json for the updated transition.md size. Fixes: runtime-launcher-parity test (B) Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * fix: re-check verification under planning lock in phase complete Move readVerificationStatus into withPlanningLock so stale verification cannot slip through when a SUMMARY.md is written between the gate and the roadmap/state mutation. Return the blocked status from the lock callback and emit the error after release to avoid leaving .lock behind. * fix(transition): gate on canonical verification.status including stale Replace awk frontmatter read with verification.status query so transition blocks when summaries are newer than VERIFICATION.md, matching phase.complete and other workflows (autonomous, progress, verify-work). * Fix workflow verification gates for yolo transition and stale routing Require VERIFY_STATUS passed before yolo/interactive transition advance. Route stale verification recovery to verify-work, matching canonical projection. * fix(transition): use verification.status query for stale-aware advisory check The awk-based check read raw frontmatter status: passed, which misses the stale case where summaries are newer than the VERIFICATION.md file even though the frontmatter still says passed. The stale status is computed from file modification times, not stored in frontmatter. Move the preamble to the verify_completion bash block (the first block with a gsd_run call) so gsd_run query verification.status can be used for the advisory check. This gives the full readVerificationStatus logic including mtime-based staleness detection, matching the enforcement gate at phase.complete. Capture full JSON (VERIFY_JSON) so next_action can be included in the advisory output alongside the status. Also update workflow-size-baseline.json for the updated transition.md size. Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * ci: trigger test matrix for 525b946 Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * fix(transition): restore awk frontmatter extraction for pre-shim verification check The gsd_run launcher shim is not defined until line ~163 of transition.md, so the verification debt check at line ~80 cannot use gsd_run. Restore the awk-based frontmatter extraction that correctly reads status without needing the runtime, and restore the shim at its proper location before phase.complete. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(#1522): clarify transition verification gate wording * fix(#1522): update transition workflow size baseline * fix(#1522): update workflow-size-baseline after rebase onto next Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * fix(#1522): guard findStaleVerificationSummary FS calls + thread opts.fs seam (review) Address review blocker B1 on #1548: findStaleVerificationSummary ran fs.readdirSync and two fs.statSync calls unguarded between readVerificationStatus's try/catch sections, so a TOCTOU race (a SUMMARY listed by scanPhasePlans then removed before statSync) or any FS error threw uncaught into callers NOT under the planning lock (init.manager / init.progress / uat-predicate). Wrap the body in try/catch degrading to 'not stale', and thread the injectable opts.fs seam (add statSync to FsLike, pass fsImpl from the caller) for parity with readVerificationStatus's no-throw contract and testability. Also adds the Verification Module glossary entry to CONTEXT.md (review B3). --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2082 lines
89 KiB
JavaScript
2082 lines
89 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');
|
|
|
|
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');
|
|
});
|
|
|
|
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 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('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('\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 });
|
|
|
|
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');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// roadmap analyze command
|
|
// ─────────────────────────────────────────────────────────────────────────────
|