/** * GSD Tools Tests - Commands */ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const { execSync } = require('node:child_process'); const fs = require('fs'); const path = require('path'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); describe('history-digest command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('empty phases directory returns valid schema', () => { const result = runGsdTools('history-digest', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const digest = JSON.parse(result.output); assert.deepStrictEqual(digest.phases, {}, 'phases should be empty object'); assert.deepStrictEqual(digest.decisions, [], 'decisions should be empty array'); assert.deepStrictEqual(digest.tech_stack, [], 'tech_stack should be empty array'); }); test('nested frontmatter fields extracted correctly', () => { // Create phase directory with SUMMARY containing nested frontmatter const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phaseDir, { recursive: true }); const summaryContent = `--- phase: "01" name: "Foundation Setup" dependency-graph: provides: - "Database schema" - "Auth system" affects: - "API layer" tech-stack: added: - "prisma" - "jose" patterns-established: - "Repository pattern" - "JWT auth flow" key-decisions: - "Use Prisma over Drizzle" - "JWT in httpOnly cookies" --- # Summary content here `; fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), summaryContent); const result = runGsdTools('history-digest', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const digest = JSON.parse(result.output); // Check nested dependency-graph.provides assert.ok(digest.phases['01'], 'Phase 01 should exist'); assert.deepStrictEqual( digest.phases['01'].provides.sort(), ['Auth system', 'Database schema'], 'provides should contain nested values' ); // Check nested dependency-graph.affects assert.deepStrictEqual( digest.phases['01'].affects, ['API layer'], 'affects should contain nested values' ); // Check nested tech-stack.added assert.deepStrictEqual( digest.tech_stack.sort(), ['jose', 'prisma'], 'tech_stack should contain nested values' ); // Check patterns-established (flat array) assert.deepStrictEqual( digest.phases['01'].patterns.sort(), ['JWT auth flow', 'Repository pattern'], 'patterns should be extracted' ); // Check key-decisions assert.strictEqual(digest.decisions.length, 2, 'Should have 2 decisions'); assert.ok( digest.decisions.some(d => d.decision === 'Use Prisma over Drizzle'), 'Should contain first decision' ); }); test('multiple phases merged into single digest', () => { // Create phase 01 const phase01Dir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phase01Dir, { recursive: true }); fs.writeFileSync( path.join(phase01Dir, '01-01-SUMMARY.md'), `--- phase: "01" name: "Foundation" provides: - "Database" patterns-established: - "Pattern A" key-decisions: - "Decision 1" --- ` ); // Create phase 02 const phase02Dir = path.join(tmpDir, '.planning', 'phases', '02-api'); fs.mkdirSync(phase02Dir, { recursive: true }); fs.writeFileSync( path.join(phase02Dir, '02-01-SUMMARY.md'), `--- phase: "02" name: "API" provides: - "REST endpoints" patterns-established: - "Pattern B" key-decisions: - "Decision 2" tech-stack: added: - "zod" --- ` ); const result = runGsdTools('history-digest', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const digest = JSON.parse(result.output); // Both phases present assert.ok(digest.phases['01'], 'Phase 01 should exist'); assert.ok(digest.phases['02'], 'Phase 02 should exist'); // Decisions merged assert.strictEqual(digest.decisions.length, 2, 'Should have 2 decisions total'); // Tech stack merged assert.deepStrictEqual(digest.tech_stack, ['zod'], 'tech_stack should have zod'); }); test('malformed SUMMARY.md skipped gracefully', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(phaseDir, { recursive: true }); // Valid summary fs.writeFileSync( path.join(phaseDir, '01-01-SUMMARY.md'), `--- phase: "01" provides: - "Valid feature" --- ` ); // Malformed summary (no frontmatter) fs.writeFileSync( path.join(phaseDir, '01-02-SUMMARY.md'), `# Just a heading No frontmatter here ` ); // Another malformed summary (broken YAML) fs.writeFileSync( path.join(phaseDir, '01-03-SUMMARY.md'), `--- broken: [unclosed --- ` ); const result = runGsdTools('history-digest', tmpDir); assert.ok(result.success, `Command should succeed despite malformed files: ${result.error}`); const digest = JSON.parse(result.output); assert.ok(digest.phases['01'], 'Phase 01 should exist'); assert.ok( digest.phases['01'].provides.includes('Valid feature'), 'Valid feature should be extracted' ); }); test('flat provides field still works (backward compatibility)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '01-01-SUMMARY.md'), `--- phase: "01" provides: - "Direct provides" --- ` ); const result = runGsdTools('history-digest', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const digest = JSON.parse(result.output); assert.deepStrictEqual( digest.phases['01'].provides, ['Direct provides'], 'Direct provides should work' ); }); test('inline array syntax supported', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '01-01-SUMMARY.md'), `--- phase: "01" provides: [Feature A, Feature B] patterns-established: ["Pattern X", "Pattern Y"] --- ` ); const result = runGsdTools('history-digest', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const digest = JSON.parse(result.output); assert.deepStrictEqual( digest.phases['01'].provides.sort(), ['Feature A', 'Feature B'], 'Inline array should work' ); assert.deepStrictEqual( digest.phases['01'].patterns.sort(), ['Pattern X', 'Pattern Y'], 'Inline quoted array should work' ); }); }); // ───────────────────────────────────────────────────────────────────────────── // phases list command // ───────────────────────────────────────────────────────────────────────────── describe('summary-extract command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('missing file returns error', () => { const result = runGsdTools('summary-extract .planning/phases/01-test/01-01-SUMMARY.md', tmpDir); assert.ok(result.success, `Command should succeed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.error, 'File not found', 'should report missing file'); }); test('extracts all fields from SUMMARY.md', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '01-01-SUMMARY.md'), `--- one-liner: Set up Prisma with User and Project models key-files: - prisma/schema.prisma - src/lib/db.ts tech-stack: added: - prisma - zod patterns-established: - Repository pattern - Dependency injection key-decisions: - Use Prisma over Drizzle: Better DX and ecosystem - Single database: Start simple, shard later requirements-completed: - AUTH-01 - AUTH-02 --- # Summary Full summary content here. ` ); const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.path, '.planning/phases/01-foundation/01-01-SUMMARY.md', 'path correct'); assert.strictEqual(output.one_liner, 'Set up Prisma with User and Project models', 'one-liner extracted'); assert.deepStrictEqual(output.key_files, ['prisma/schema.prisma', 'src/lib/db.ts'], 'key files extracted'); assert.deepStrictEqual(output.tech_added, ['prisma', 'zod'], 'tech added extracted'); assert.deepStrictEqual(output.patterns, ['Repository pattern', 'Dependency injection'], 'patterns extracted'); assert.strictEqual(output.decisions.length, 2, 'decisions extracted'); assert.deepStrictEqual(output.requirements_completed, ['AUTH-01', 'AUTH-02'], 'requirements completed extracted'); }); test('selective extraction with --fields', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '01-01-SUMMARY.md'), `--- one-liner: Set up database key-files: - prisma/schema.prisma tech-stack: added: - prisma patterns-established: - Repository pattern key-decisions: - Use Prisma: Better DX requirements-completed: - AUTH-01 --- ` ); const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md --fields one_liner,key_files,requirements_completed', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.one_liner, 'Set up database', 'one_liner included'); assert.deepStrictEqual(output.key_files, ['prisma/schema.prisma'], 'key_files included'); assert.deepStrictEqual(output.requirements_completed, ['AUTH-01'], 'requirements_completed included'); assert.strictEqual(output.tech_added, undefined, 'tech_added excluded'); assert.strictEqual(output.patterns, undefined, 'patterns excluded'); assert.strictEqual(output.decisions, undefined, 'decisions excluded'); }); test('extracts one-liner from body when not in frontmatter', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '01-01-SUMMARY.md'), `--- phase: "01" key-files: - src/lib/db.ts --- # Phase 1: Foundation Summary **JWT auth with refresh rotation using jose library** ## Performance - **Duration:** 28 min - **Tasks:** 5 ` ); const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.one_liner, 'JWT auth with refresh rotation using jose library', 'one-liner should be extracted from body **bold** line'); }); test('handles missing frontmatter fields gracefully', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '01-01-SUMMARY.md'), `--- one-liner: Minimal summary --- # Summary ` ); const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.one_liner, 'Minimal summary', 'one-liner extracted'); assert.deepStrictEqual(output.key_files, [], 'key_files defaults to empty'); assert.deepStrictEqual(output.tech_added, [], 'tech_added defaults to empty'); assert.deepStrictEqual(output.patterns, [], 'patterns defaults to empty'); assert.deepStrictEqual(output.decisions, [], 'decisions defaults to empty'); assert.deepStrictEqual(output.requirements_completed, [], 'requirements_completed defaults to empty'); }); test('parses key-decisions with rationale', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '01-01-SUMMARY.md'), `--- key-decisions: - Use Prisma: Better DX than alternatives - JWT tokens: Stateless auth for scalability --- ` ); const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.decisions[0].summary, 'Use Prisma', 'decision summary parsed'); assert.strictEqual(output.decisions[0].rationale, 'Better DX than alternatives', 'decision rationale parsed'); assert.strictEqual(output.decisions[1].summary, 'JWT tokens', 'second decision summary'); assert.strictEqual(output.decisions[1].rationale, 'Stateless auth for scalability', 'second decision rationale'); }); }); // ───────────────────────────────────────────────────────────────────────────── // init commands tests // ───────────────────────────────────────────────────────────────────────────── describe('progress command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('renders JSON progress', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0 MVP\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Done'); fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan 2'); const result = runGsdTools('progress json', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.total_plans, 2, '2 total plans'); assert.strictEqual(output.total_summaries, 1, '1 summary'); assert.strictEqual(output.percent, 50, '50%'); assert.strictEqual(output.phases.length, 1, '1 phase'); assert.strictEqual(output.phases[0].status, 'In Progress', 'phase in progress'); }); test('renders bar format', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Done'); const result = runGsdTools('progress bar --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); assert.ok(result.output.includes('1/1'), 'should include count'); assert.ok(result.output.includes('100%'), 'should include 100%'); }); test('renders table format', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0 MVP\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); const result = runGsdTools('progress table --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); assert.ok(result.output.includes('Phase'), 'should have table header'); assert.ok(result.output.includes('foundation'), 'should include phase name'); }); test('does not crash when summaries exceed plans (orphaned SUMMARY.md)', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0 MVP\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); // 1 plan but 2 summaries (orphaned SUMMARY.md after PLAN.md deletion) fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Done'); fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Orphaned summary'); // bar format - should not crash with RangeError const barResult = runGsdTools('progress bar --raw', tmpDir); assert.ok(barResult.success, `Bar format crashed: ${barResult.error}`); assert.ok(barResult.output.includes('100%'), 'percent should be clamped to 100%'); // table format - should not crash with RangeError const tableResult = runGsdTools('progress table --raw', tmpDir); assert.ok(tableResult.success, `Table format crashed: ${tableResult.error}`); // json format - percent should be clamped const jsonResult = runGsdTools('progress json', tmpDir); assert.ok(jsonResult.success, `JSON format crashed: ${jsonResult.error}`); const output = JSON.parse(jsonResult.output); assert.ok(output.percent <= 100, `percent should be <= 100 but got ${output.percent}`); }); }); // ───────────────────────────────────────────────────────────────────────────── // todo complete command // ───────────────────────────────────────────────────────────────────────────── describe('todo complete command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('moves todo from pending to completed', () => { const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending'); fs.mkdirSync(pendingDir, { recursive: true }); fs.writeFileSync( path.join(pendingDir, 'add-dark-mode.md'), `title: Add dark mode\narea: ui\ncreated: 2025-01-01\n` ); const result = runGsdTools('todo complete add-dark-mode.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.completed, true); // Verify moved assert.ok( !fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'pending', 'add-dark-mode.md')), 'should be removed from pending' ); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'completed', 'add-dark-mode.md')), 'should be in completed' ); // Verify completion timestamp added const content = fs.readFileSync( path.join(tmpDir, '.planning', 'todos', 'completed', 'add-dark-mode.md'), 'utf-8' ); assert.ok(content.startsWith('completed:'), 'should have completed timestamp'); }); test('fails for nonexistent todo', () => { const result = runGsdTools('todo complete nonexistent.md', tmpDir); assert.ok(!result.success, 'should fail'); assert.ok(result.error.includes('not found'), 'error mentions not found'); }); }); // ───────────────────────────────────────────────────────────────────────────── // todo match-phase command // ───────────────────────────────────────────────────────────────────────────── describe('todo match-phase command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => cleanup(tmpDir)); test('returns empty matches when no todos exist', () => { const result = runGsdTools('todo match-phase 01', tmpDir); assert.ok(result.success, 'should succeed'); const output = JSON.parse(result.output); assert.strictEqual(output.todo_count, 0); assert.deepStrictEqual(output.matches, []); }); test('matches todo by keyword overlap with phase name', () => { const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending'); fs.mkdirSync(pendingDir, { recursive: true }); fs.writeFileSync(path.join(pendingDir, 'auth-todo.md'), 'title: Add OAuth token refresh\narea: auth\ncreated: 2026-03-01\n\nNeed to handle token expiry for OAuth flows.'); fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n### Phase 01: Authentication and Session Management\n\n**Goal:** Implement OAuth login and session handling\n'); const result = runGsdTools('todo match-phase 01', tmpDir); assert.ok(result.success, 'should succeed'); const output = JSON.parse(result.output); assert.strictEqual(output.todo_count, 1, 'should find 1 todo'); assert.ok(output.matches.length > 0, 'should have matches'); assert.strictEqual(output.matches[0].title, 'Add OAuth token refresh'); assert.ok(output.matches[0].score > 0, 'score should be positive'); assert.ok(output.matches[0].reasons.length > 0, 'should have reasons'); }); test('does not match unrelated todo', () => { const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending'); fs.mkdirSync(pendingDir, { recursive: true }); fs.writeFileSync(path.join(pendingDir, 'auth-todo.md'), 'title: Add OAuth token refresh\narea: auth\ncreated: 2026-03-01\n\nOAuth token expiry.'); fs.writeFileSync(path.join(pendingDir, 'unrelated-todo.md'), 'title: Fix CSS grid layout in dashboard\narea: ui\ncreated: 2026-03-01\n\nGrid columns break on mobile.'); fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n### Phase 01: Authentication and Session Management\n\n**Goal:** Implement OAuth login and session handling\n'); const result = runGsdTools('todo match-phase 01', tmpDir); assert.ok(result.success, 'should succeed'); const output = JSON.parse(result.output); const matchTitles = output.matches.map(m => m.title); assert.ok(matchTitles.includes('Add OAuth token refresh'), 'auth todo should match'); assert.ok(!matchTitles.includes('Fix CSS grid layout in dashboard'), 'unrelated todo should not match'); }); test('matches todo by area overlap', () => { const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending'); fs.mkdirSync(pendingDir, { recursive: true }); fs.writeFileSync(path.join(pendingDir, 'auth-todo.md'), 'title: Add OAuth token refresh\narea: auth\ncreated: 2026-03-01\n\nOAuth token handling.'); fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n### Phase 01: Auth System\n\n**Goal:** Build auth module\n'); const result = runGsdTools('todo match-phase 01', tmpDir); const output = JSON.parse(result.output); const authMatch = output.matches.find(m => m.title === 'Add OAuth token refresh'); assert.ok(authMatch, 'should find auth todo'); const hasAreaReason = authMatch.reasons.some(r => r.startsWith('area:')); assert.ok(hasAreaReason, 'should match on area'); }); test('sorts matches by score descending', () => { const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending'); fs.mkdirSync(pendingDir, { recursive: true }); fs.writeFileSync(path.join(pendingDir, 'weak-match.md'), 'title: Check token format\narea: general\ncreated: 2026-03-01\n\nToken format validation.'); fs.writeFileSync(path.join(pendingDir, 'strong-match.md'), 'title: Session management authentication OAuth token handling\narea: auth\ncreated: 2026-03-01\n\nSession auth OAuth tokens.'); fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n### Phase 01: Authentication and Session Management\n\n**Goal:** Implement OAuth login, session handling, and token management\n'); const result = runGsdTools('todo match-phase 01', tmpDir); const output = JSON.parse(result.output); assert.ok(output.matches.length >= 2, 'should have multiple matches'); for (let i = 1; i < output.matches.length; i++) { assert.ok(output.matches[i - 1].score >= output.matches[i].score, `match ${i-1} score (${output.matches[i-1].score}) should be >= match ${i} score (${output.matches[i].score})`); } }); }); // ───────────────────────────────────────────────────────────────────────────── // scaffold command // ───────────────────────────────────────────────────────────────────────────── describe('scaffold command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('scaffolds context file', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true }); const result = runGsdTools('scaffold context --phase 3', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.created, true); // Verify file content const content = fs.readFileSync( path.join(tmpDir, '.planning', 'phases', '03-api', '03-CONTEXT.md'), 'utf-8' ); assert.ok(content.includes('Phase 3'), 'should reference phase number'); assert.ok(content.includes('Decisions'), 'should have decisions section'); assert.ok(content.includes('Discretion Areas'), 'should have discretion section'); }); test('scaffolds UAT file', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true }); const result = runGsdTools('scaffold uat --phase 3', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.created, true); const content = fs.readFileSync( path.join(tmpDir, '.planning', 'phases', '03-api', '03-UAT.md'), 'utf-8' ); assert.ok(content.includes('User Acceptance Testing'), 'should have UAT heading'); assert.ok(content.includes('Test Results'), 'should have test results section'); }); test('scaffolds verification file', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true }); const result = runGsdTools('scaffold verification --phase 3', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.created, true); const content = fs.readFileSync( path.join(tmpDir, '.planning', 'phases', '03-api', '03-VERIFICATION.md'), 'utf-8' ); assert.ok(content.includes('Goal-Backward Verification'), 'should have verification heading'); }); test('scaffolds phase directory', () => { const result = runGsdTools('scaffold phase-dir --phase 5 --name User Dashboard', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.created, true); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '05-user-dashboard')), 'directory should be created' ); }); test('does not overwrite existing files', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Existing content'); const result = runGsdTools('scaffold context --phase 3', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.created, false, 'should not overwrite'); assert.strictEqual(output.reason, 'already_exists'); }); }); // ───────────────────────────────────────────────────────────────────────────── // cmdGenerateSlug tests (CMD-01) // ───────────────────────────────────────────────────────────────────────────── describe('generate-slug command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('converts normal text to slug', () => { const result = runGsdTools('generate-slug "Hello World"', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.slug, 'hello-world'); }); test('strips special characters', () => { const result = runGsdTools('generate-slug "Test@#$%^Special!!!"', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.slug, 'test-special'); }); test('preserves numbers', () => { const result = runGsdTools('generate-slug "Phase 3 Plan"', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.slug, 'phase-3-plan'); }); test('strips leading and trailing hyphens', () => { const result = runGsdTools('generate-slug "---leading-trailing---"', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.slug, 'leading-trailing'); }); test('fails when no text provided', () => { const result = runGsdTools('generate-slug', tmpDir); assert.ok(!result.success, 'should fail without text'); assert.ok(result.error.includes('text required'), 'error should mention text required'); }); }); // ───────────────────────────────────────────────────────────────────────────── // cmdCurrentTimestamp tests (CMD-01) // ───────────────────────────────────────────────────────────────────────────── describe('current-timestamp command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('date format returns YYYY-MM-DD', () => { const result = runGsdTools('current-timestamp date', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.match(output.timestamp, /^\d{4}-\d{2}-\d{2}$/, 'should be YYYY-MM-DD format'); }); test('filename format returns ISO without colons or fractional seconds', () => { const result = runGsdTools('current-timestamp filename', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.match(output.timestamp, /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}$/, 'should replace colons with hyphens and strip fractional seconds'); }); test('full format returns full ISO string', () => { const result = runGsdTools('current-timestamp full', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.match(output.timestamp, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, 'should be full ISO format'); }); test('default (no format) returns full ISO string', () => { const result = runGsdTools('current-timestamp', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.match(output.timestamp, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, 'default should be full ISO format'); }); }); // ───────────────────────────────────────────────────────────────────────────── // cmdListTodos tests (CMD-02) // ───────────────────────────────────────────────────────────────────────────── describe('list-todos command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('empty directory returns zero count', () => { const result = runGsdTools('list-todos', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.count, 0, 'count should be 0'); assert.deepStrictEqual(output.todos, [], 'todos should be empty'); }); test('returns multiple todos with correct fields', () => { const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending'); fs.mkdirSync(pendingDir, { recursive: true }); fs.writeFileSync(path.join(pendingDir, 'add-tests.md'), 'title: Add unit tests\narea: testing\ncreated: 2026-01-15\n'); fs.writeFileSync(path.join(pendingDir, 'fix-bug.md'), 'title: Fix login bug\narea: auth\ncreated: 2026-01-20\n'); const result = runGsdTools('list-todos', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.count, 2, 'should have 2 todos'); assert.strictEqual(output.todos.length, 2, 'todos array should have 2 entries'); const testTodo = output.todos.find(t => t.file === 'add-tests.md'); assert.ok(testTodo, 'add-tests.md should be in results'); assert.strictEqual(testTodo.title, 'Add unit tests'); assert.strictEqual(testTodo.area, 'testing'); assert.strictEqual(testTodo.created, '2026-01-15'); }); 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, 'ui-task.md'), 'title: UI task\narea: ui\ncreated: 2026-01-01\n'); fs.writeFileSync(path.join(pendingDir, 'api-task.md'), 'title: API task\narea: api\ncreated: 2026-01-01\n'); const result = runGsdTools('list-todos ui', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.count, 1, 'should have 1 matching todo'); assert.strictEqual(output.todos[0].area, 'ui', 'should only return ui area'); }); 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.md'), 'title: Some task\narea: backend\ncreated: 2026-01-01\n'); const result = runGsdTools('list-todos nonexistent-area', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.count, 0, 'should have 0 matching todos'); }); test('malformed files use defaults', () => { const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending'); fs.mkdirSync(pendingDir, { recursive: true }); // File with no title or area fields fs.writeFileSync(path.join(pendingDir, 'malformed.md'), 'some random content\nno fields here\n'); const result = runGsdTools('list-todos', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.count, 1, 'malformed file should still be counted'); assert.strictEqual(output.todos[0].title, 'Untitled', 'missing title defaults to Untitled'); assert.strictEqual(output.todos[0].area, 'general', 'missing area defaults to general'); assert.strictEqual(output.todos[0].created, 'unknown', 'missing created defaults to unknown'); }); }); // ───────────────────────────────────────────────────────────────────────────── // cmdVerifyPathExists tests (CMD-02) // ───────────────────────────────────────────────────────────────────────────── describe('verify-path-exists command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('existing file returns exists=true with type=file', () => { fs.writeFileSync(path.join(tmpDir, 'test-file.txt'), 'hello'); const result = runGsdTools('verify-path-exists test-file.txt', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.exists, true); assert.strictEqual(output.type, 'file'); }); test('existing directory returns exists=true with type=directory', () => { fs.mkdirSync(path.join(tmpDir, 'test-dir'), { recursive: true }); const result = runGsdTools('verify-path-exists test-dir', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.exists, true); assert.strictEqual(output.type, 'directory'); }); test('missing path returns exists=false', () => { const result = runGsdTools('verify-path-exists nonexistent/path', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.exists, false); assert.strictEqual(output.type, null); }); test('absolute path resolves correctly', () => { const absFile = path.join(tmpDir, 'abs-test.txt'); fs.writeFileSync(absFile, 'content'); const result = runGsdTools(`verify-path-exists ${absFile}`, tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.exists, true); assert.strictEqual(output.type, 'file'); }); test('fails when no path provided', () => { const result = runGsdTools('verify-path-exists', tmpDir); assert.ok(!result.success, 'should fail without path'); assert.ok(result.error.includes('path required'), 'error should mention path required'); }); }); // ───────────────────────────────────────────────────────────────────────────── // cmdResolveModel tests (CMD-03) // ───────────────────────────────────────────────────────────────────────────── describe('resolve-model command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('known agent returns model and profile without unknown_agent', () => { const result = runGsdTools('resolve-model gsd-planner', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.ok(output.model, 'should have model field'); assert.ok(output.profile, 'should have profile field'); assert.strictEqual(output.unknown_agent, undefined, 'should not have unknown_agent for known agent'); }); test('unknown agent returns unknown_agent=true', () => { const result = runGsdTools('resolve-model fake-nonexistent-agent', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.unknown_agent, true, 'should flag unknown agent'); }); test('default profile fallback when no config exists', () => { // tmpDir has no config.json, so defaults to balanced profile const result = runGsdTools('resolve-model gsd-executor', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.profile, 'balanced', 'should default to balanced profile'); assert.ok(output.model, 'should resolve a model'); }); test('fails when no agent-type provided', () => { const result = runGsdTools('resolve-model', tmpDir); assert.ok(!result.success, 'should fail without agent-type'); assert.ok(result.error.includes('agent-type required'), 'error should mention agent-type required'); }); }); // ───────────────────────────────────────────────────────────────────────────── // cmdCommit tests (CMD-04) // ───────────────────────────────────────────────────────────────────────────── describe('commit command', () => { const { createTempGitProject } = require('./helpers.cjs'); const { execSync } = require('child_process'); let tmpDir; beforeEach(() => { tmpDir = createTempGitProject(); }); afterEach(() => { cleanup(tmpDir); }); test('skips when commit_docs is false', () => { // Write config with commit_docs: false fs.writeFileSync( path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ commit_docs: false }) ); const result = runGsdTools('commit "test message"', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.committed, false); assert.strictEqual(output.reason, 'skipped_commit_docs_false'); }); test('skips when .planning is gitignored', () => { // Add .planning/ to .gitignore and commit it so git recognizes the ignore fs.writeFileSync(path.join(tmpDir, '.gitignore'), '.planning/\n'); execSync('git add .gitignore', { cwd: tmpDir, stdio: 'pipe' }); execSync('git commit -m "add gitignore"', { cwd: tmpDir, stdio: 'pipe' }); const result = runGsdTools('commit "test message"', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.committed, false); assert.strictEqual(output.reason, 'skipped_gitignored'); }); test('handles nothing to commit', () => { // Don't modify any files after initial commit const result = runGsdTools('commit "test message"', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.committed, false); assert.strictEqual(output.reason, 'nothing_to_commit'); }); test('creates real commit with correct hash', () => { // Create a new file in .planning/ fs.writeFileSync(path.join(tmpDir, '.planning', 'test-file.md'), '# Test\n'); const result = runGsdTools('commit "test: add test file" --files .planning/test-file.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.committed, true, 'should have committed'); assert.ok(output.hash, 'should have a commit hash'); assert.strictEqual(output.reason, 'committed'); // Verify via git log const gitLog = execSync('git log --oneline -1', { cwd: tmpDir, encoding: 'utf-8' }).trim(); assert.ok(gitLog.includes('test: add test file'), 'git log should contain the commit message'); assert.ok(gitLog.includes(output.hash), 'git log should contain the returned hash'); }); test('amend mode works without crashing', () => { // Create a file and commit it first fs.writeFileSync(path.join(tmpDir, '.planning', 'amend-file.md'), '# Initial\n'); execSync('git add .planning/amend-file.md', { cwd: tmpDir, stdio: 'pipe' }); execSync('git commit -m "initial file"', { cwd: tmpDir, stdio: 'pipe' }); // Modify the file and amend fs.writeFileSync(path.join(tmpDir, '.planning', 'amend-file.md'), '# Amended\n'); const result = runGsdTools('commit "ignored" --files .planning/amend-file.md --amend', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.committed, true, 'amend should succeed'); // Verify only 2 commits total (initial setup + amended) const logCount = execSync('git log --oneline', { cwd: tmpDir, encoding: 'utf-8' }).trim().split('\n').length; assert.strictEqual(logCount, 2, 'should have 2 commits (initial + amended)'); }); test('creates strategy branch before first commit when branching_strategy is milestone', () => { // Configure milestone branching strategy fs.writeFileSync( path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ commit_docs: true, branching_strategy: 'milestone', milestone_branch_template: 'gsd/{milestone}-{slug}', }) ); // getMilestoneInfo reads ROADMAP.md for milestone version/name fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), '## v1.0: Initial Release\n\n### Phase 1: Setup\n' ); // Create a file to commit fs.writeFileSync(path.join(tmpDir, '.planning', 'test-context.md'), '# Context\n'); const result = runGsdTools('commit "docs: add context" --files .planning/test-context.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.committed, true, 'should have committed'); // Verify we're on the strategy branch const { execFileSync } = require('child_process'); const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir, encoding: 'utf-8' }).trim(); assert.strictEqual(branch, 'gsd/v1.0-initial-release', 'should be on milestone branch'); }); test('creates strategy branch before first commit when branching_strategy is phase', () => { // Configure phase branching strategy fs.writeFileSync( path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ commit_docs: true, branching_strategy: 'phase', phase_branch_template: 'gsd/phase-{phase}-{slug}', }) ); // Create ROADMAP.md with a phase fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-setup'), { recursive: true }); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n## Phase 1: Setup\nGoal: Initial setup\n' ); // Create a context file for phase 1 fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '01-setup', '01-CONTEXT.md'), '# Context\n'); const result = runGsdTools( 'commit "docs(01): add context" --files .planning/phases/01-setup/01-CONTEXT.md', tmpDir ); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.committed, true, 'should have committed'); // Verify we're on the strategy branch const { execFileSync } = require('child_process'); const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir, encoding: 'utf-8' }).trim(); assert.strictEqual(branch, 'gsd/phase-01-setup', 'should be on phase branch'); }); test('decimal phase numbers are captured correctly in branching strategy', () => { // Configure phase branching strategy fs.writeFileSync( path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ commit_docs: true, branching_strategy: 'phase', phase_branch_template: 'gsd/phase-{phase}-{slug}', }) ); // Create ROADMAP.md with a decimal phase fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '45.14-golden-capture'), { recursive: true }); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n## Phase 45.14: Golden Capture\nGoal: Capture golden standard\n' ); // Create a context file for phase 45.14 fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '45.14-golden-capture', '45.14-CONTEXT.md'), '# Context\n'); const result = runGsdTools( 'commit "docs(45.14): add context" --files .planning/phases/45.14-golden-capture/45.14-CONTEXT.md', tmpDir ); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.committed, true, 'should have committed'); // Verify we're on the correct branch (45.14, not 14) const { execFileSync } = require('child_process'); const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir, encoding: 'utf-8' }).trim(); assert.strictEqual(branch, 'gsd/phase-45.14-golden-capture', 'should be on decimal phase branch, not integer-only'); }); }); // ───────────────────────────────────────────────────────────────────────────── // cmdWebsearch tests (CMD-05) // ───────────────────────────────────────────────────────────────────────────── describe('websearch command', () => { const { cmdWebsearch } = require('../get-shit-done/bin/lib/commands.cjs'); let origFetch; let origApiKey; let origWriteSync; let captured; beforeEach(() => { origFetch = global.fetch; origApiKey = process.env.BRAVE_API_KEY; origWriteSync = fs.writeSync; captured = ''; // output() uses fs.writeSync(1, data) since #1276 — mock it to capture output fs.writeSync = (fd, data) => { if (fd === 1) captured += data; return Buffer.byteLength(String(data)); }; }); afterEach(() => { global.fetch = origFetch; if (origApiKey !== undefined) { process.env.BRAVE_API_KEY = origApiKey; } else { delete process.env.BRAVE_API_KEY; } fs.writeSync = origWriteSync; }); test('returns available=false when BRAVE_API_KEY is unset', async () => { delete process.env.BRAVE_API_KEY; await cmdWebsearch('test query', {}, false); const output = JSON.parse(captured); assert.strictEqual(output.available, false); assert.ok(output.reason.includes('BRAVE_API_KEY'), 'should mention missing API key'); }); test('returns error when no query provided', async () => { process.env.BRAVE_API_KEY = 'test-key'; await cmdWebsearch(null, {}, false); const output = JSON.parse(captured); assert.strictEqual(output.available, false); assert.ok(output.error.includes('Query required'), 'should mention query required'); }); test('returns results for successful API response', async () => { process.env.BRAVE_API_KEY = 'test-key'; global.fetch = async () => ({ ok: true, json: async () => ({ web: { results: [ { title: 'Test Result', url: 'https://example.com', description: 'A test result', age: '1d' }, ], }, }), }); await cmdWebsearch('test query', { limit: 5, freshness: 'pd' }, false); const output = JSON.parse(captured); assert.strictEqual(output.available, true); assert.strictEqual(output.query, 'test query'); assert.strictEqual(output.count, 1); assert.strictEqual(output.results[0].title, 'Test Result'); assert.strictEqual(output.results[0].url, 'https://example.com'); assert.strictEqual(output.results[0].age, '1d'); }); test('constructs correct URL parameters', async () => { process.env.BRAVE_API_KEY = 'test-key'; let capturedUrl = ''; global.fetch = async (url) => { capturedUrl = url; return { ok: true, json: async () => ({ web: { results: [] } }), }; }; await cmdWebsearch('node.js testing', { limit: 5, freshness: 'pd' }, false); const parsed = new URL(capturedUrl); assert.strictEqual(parsed.searchParams.get('q'), 'node.js testing', 'query param should decode to original string'); assert.strictEqual(parsed.searchParams.get('count'), '5', 'count param should be 5'); assert.strictEqual(parsed.searchParams.get('freshness'), 'pd', 'freshness param should be pd'); }); test('handles API error (non-200 status)', async () => { process.env.BRAVE_API_KEY = 'test-key'; global.fetch = async () => ({ ok: false, status: 429, }); await cmdWebsearch('test query', {}, false); const output = JSON.parse(captured); assert.strictEqual(output.available, false); assert.ok(output.error.includes('429'), 'error should include status code'); }); test('handles network failure', async () => { process.env.BRAVE_API_KEY = 'test-key'; global.fetch = async () => { throw new Error('Network timeout'); }; await cmdWebsearch('test query', {}, false); const output = JSON.parse(captured); assert.strictEqual(output.available, false); assert.strictEqual(output.error, 'Network timeout'); }); }); describe('stats command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('returns valid JSON with empty project', () => { const result = runGsdTools('stats', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const stats = JSON.parse(result.output); assert.ok(Array.isArray(stats.phases), 'phases should be an array'); assert.strictEqual(stats.total_plans, 0); assert.strictEqual(stats.total_summaries, 0); assert.strictEqual(stats.percent, 0); assert.strictEqual(stats.phases_completed, 0); assert.strictEqual(stats.phases_total, 0); assert.strictEqual(stats.requirements_total, 0); assert.strictEqual(stats.requirements_complete, 0); }); test('counts phases, plans, and summaries correctly', () => { const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth'); const p2 = path.join(tmpDir, '.planning', 'phases', '02-api'); fs.mkdirSync(p1, { recursive: true }); fs.mkdirSync(p2, { recursive: true }); // Phase 1: 2 plans, 2 summaries, passing verification (complete) fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Summary'); fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification'); // Phase 2: 1 plan, 0 summaries (planned) fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan'); const result = runGsdTools('stats', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const stats = JSON.parse(result.output); assert.strictEqual(stats.phases_total, 2); assert.strictEqual(stats.phases_completed, 1); assert.strictEqual(stats.total_plans, 3); assert.strictEqual(stats.total_summaries, 2); assert.strictEqual(stats.percent, 50); assert.strictEqual(stats.plan_percent, 67); }); test('counts requirements from REQUIREMENTS.md', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), `# Requirements ## v1 Requirements - [x] **AUTH-01**: User can sign up - [x] **AUTH-02**: User can log in - [ ] **API-01**: REST endpoints - [ ] **API-02**: GraphQL support ` ); const result = runGsdTools('stats', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const stats = JSON.parse(result.output); assert.strictEqual(stats.requirements_total, 4); assert.strictEqual(stats.requirements_complete, 2); }); test('reads last activity from STATE.md', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Last Activity:** 2025-06-15\n**Last Activity Description:** Working\n` ); const result = runGsdTools('stats', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const stats = JSON.parse(result.output); assert.strictEqual(stats.last_activity, '2025-06-15'); }); test('reads last activity from plain STATE.md template format', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# Project State\n\n## Current Position\n\nPhase: 1 of 2 (Foundation)\nPlan: 1 of 1 in current phase\nStatus: In progress\nLast activity: 2025-06-16 — Finished plan 01-01\n` ); const result = runGsdTools('stats', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const stats = JSON.parse(result.output); assert.strictEqual(stats.last_activity, '2025-06-16 — Finished plan 01-01'); }); test('includes roadmap-only phases in totals and preserves hyphenated names', () => { const p1 = path.join(tmpDir, '.planning', 'phases', '14-auth-hardening'); const p2 = path.join(tmpDir, '.planning', 'phases', '15-proof-generation'); fs.mkdirSync(p1, { recursive: true }); fs.mkdirSync(p2, { recursive: true }); fs.writeFileSync(path.join(p1, '14-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '14-01-SUMMARY.md'), '# Summary'); fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified'); fs.writeFileSync(path.join(p2, '15-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p2, '15-01-SUMMARY.md'), '# Summary'); fs.writeFileSync(path.join(p2, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified'); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [x] **Phase 14: Auth Hardening** - [x] **Phase 15: Proof Generation** - [ ] **Phase 16: Multi-Claim Verification & UX** ## Milestone v1.0 Growth ### Phase 14: Auth Hardening **Goal:** Improve auth checks ### Phase 15: Proof Generation **Goal:** Improve proof generation ### Phase 16: Multi-Claim Verification & UX **Goal:** Support multi-claim verification ` ); const result = runGsdTools('stats', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const stats = JSON.parse(result.output); assert.strictEqual(stats.phases_total, 3); assert.strictEqual(stats.phases_completed, 2); assert.strictEqual(stats.percent, 67); assert.strictEqual(stats.plan_percent, 100); assert.strictEqual( stats.phases.find(p => p.number === '16')?.name, 'Multi-Claim Verification & UX' ); assert.strictEqual( stats.phases.find(p => p.number === '16')?.status, 'Not Started' ); }); test('reports git commit count and first commit date from repository history', () => { execSync('git init', { cwd: tmpDir, stdio: 'pipe' }); execSync('git config user.email "test@example.com"', { cwd: tmpDir, stdio: 'pipe' }); execSync('git config user.name "Test User"', { cwd: tmpDir, stdio: 'pipe' }); fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\n'); execSync('git add -A', { cwd: tmpDir, stdio: 'pipe' }); execSync('git commit -m "initial commit"', { cwd: tmpDir, stdio: 'pipe', env: { ...process.env, GIT_AUTHOR_DATE: '2026-01-01T00:00:00Z', GIT_COMMITTER_DATE: '2026-01-01T00:00:00Z', }, }); fs.writeFileSync(path.join(tmpDir, 'README.md'), '# Updated\n'); execSync('git add README.md', { cwd: tmpDir, stdio: 'pipe' }); execSync('git commit -m "second commit"', { cwd: tmpDir, stdio: 'pipe', env: { ...process.env, GIT_AUTHOR_DATE: '2026-02-01T00:00:00Z', GIT_COMMITTER_DATE: '2026-02-01T00:00:00Z', }, }); const result = runGsdTools('stats', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const stats = JSON.parse(result.output); assert.strictEqual(stats.git_commits, 2); assert.strictEqual(stats.git_first_commit_date, '2026-01-01'); }); test('table format renders readable output', () => { const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified'); const result = runGsdTools('stats table', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const parsed = JSON.parse(result.output); assert.ok(parsed.rendered, 'table format should include rendered field'); assert.ok(parsed.rendered.includes('Statistics'), 'should include Statistics header'); assert.ok(parsed.rendered.includes('| Phase |'), 'should include table header'); assert.ok(parsed.rendered.includes('| 1 |'), 'should include phase row'); assert.ok(parsed.rendered.includes('1/1 phases'), 'should report phase progress'); }); test('phase with summaries but no verification is Executed, not Complete', () => { const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const result = runGsdTools('stats', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const stats = JSON.parse(result.output); const phase = stats.phases.find(p => p.number === '01' || p.number === '1'); assert.strictEqual(phase.status, 'Executed', 'should be Executed without verification'); assert.strictEqual(stats.phases_completed, 0, 'unverified phase should not count as completed'); }); test('phase with passing verification is Complete', () => { const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification'); const result = runGsdTools('stats', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const stats = JSON.parse(result.output); const phase = stats.phases.find(p => p.number === '01' || p.number === '1'); assert.strictEqual(phase.status, 'Complete', 'should be Complete with passing verification'); assert.strictEqual(stats.phases_completed, 1); }); test('phase with gaps_found verification is Executed', () => { const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: gaps_found\n---\n# Verification'); const result = runGsdTools('stats', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const stats = JSON.parse(result.output); const phase = stats.phases.find(p => p.number === '01' || p.number === '1'); assert.strictEqual(phase.status, 'Executed', 'gaps_found should show as Executed'); }); test('phase with human_needed verification shows Needs Review', () => { const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: human_needed\n---\n# Verification'); const result = runGsdTools('stats', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const stats = JSON.parse(result.output); const phase = stats.phases.find(p => p.number === '01' || p.number === '1'); assert.strictEqual(phase.status, 'Needs Review', 'human_needed should show as Needs Review'); }); test('progress command also uses verification-aware status', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0 MVP\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const result = runGsdTools('progress json', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phases[0].status, 'Executed', 'progress should show Executed without verification'); }); test('does not duplicate phases when ROADMAP uses unpadded numbers and dirs use padded numbers', () => { // ROADMAP.md uses "Phase 1:" (unpadded) but directory is "01-auth" (padded). // Without normalization, the Map holds two entries: "1" and "01", doubling phases_total. const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified'); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# Roadmap', '', '## Milestone v1', '', '### Phase 1: Auth', '**Goal:** Authentication', ].join('\n') ); const result = runGsdTools('stats', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const stats = JSON.parse(result.output); assert.strictEqual(stats.phases_total, 1, 'unpadded ROADMAP heading and padded dir should merge into one phase'); assert.strictEqual(stats.phases_completed, 1); assert.strictEqual(stats.phases.length, 1); }); }); // ───────────────────────────────────────────────────────────────────────────── // check-commit command (#1395) // ───────────────────────────────────────────────────────────────────────────── describe('check-commit command', () => { const { createTempGitProject } = require('./helpers.cjs'); let tmpDir; beforeEach(() => { tmpDir = createTempGitProject(); }); afterEach(() => { cleanup(tmpDir); }); test('allows commit when commit_docs is true', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ commit_docs: true }) ); const result = runGsdTools('check-commit', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.allowed, true); }); test('allows commit when no .planning/ files staged and commit_docs is false', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ commit_docs: false }) ); // Stage a non-planning file fs.writeFileSync(path.join(tmpDir, 'src.js'), 'console.log("hi")'); execSync('git add src.js', { cwd: tmpDir, stdio: 'pipe' }); const result = runGsdTools('check-commit', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.allowed, true); }); test('blocks commit when .planning/ files staged and commit_docs is false', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ commit_docs: false }) ); fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State'); execSync('git add .planning/STATE.md', { cwd: tmpDir, stdio: 'pipe' }); const result = runGsdTools('check-commit', tmpDir); assert.ok(!result.success, 'should block commit'); assert.ok(result.error.includes('.planning/'), 'error should mention .planning/ files'); assert.ok(result.error.includes('unstage'), 'error should suggest unstage command'); }); });