From 8051bc4fd8b4b3c61b446b10033ded55167b53fb Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 30 Apr 2026 14:10:28 -0400 Subject: [PATCH] test(golden): expand phases/validate/roadmap parity matrix (#2909) Merging parity-matrix expansion after stack foundation. --- sdk/src/golden/golden-mutation-covered.ts | 12 +- sdk/src/golden/golden.integration.test.ts | 304 ++++++++++++++++++++++ tests/phases-command-router.test.cjs | 74 ++++++ tests/roadmap-command-router.test.cjs | 76 ++++++ 4 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 tests/phases-command-router.test.cjs create mode 100644 tests/roadmap-command-router.test.cjs diff --git a/sdk/src/golden/golden-mutation-covered.ts b/sdk/src/golden/golden-mutation-covered.ts index 6e75c5f33..f514db7cf 100644 --- a/sdk/src/golden/golden-mutation-covered.ts +++ b/sdk/src/golden/golden-mutation-covered.ts @@ -4,4 +4,14 @@ * tests land; other mutations rely on `MUTATION_DEFERRED_REASON` in golden-policy. */ -export const GOLDEN_MUTATION_SUBPROCESS_COVERED: readonly string[] = []; +export const GOLDEN_MUTATION_SUBPROCESS_COVERED: readonly string[] = [ + 'state.update', + 'state.patch', + 'state.begin-phase', + 'state.sync', + 'phase.add', + 'phase.add-batch', + 'phase.insert', + 'phases.clear', + 'roadmap.update-plan-progress', +]; diff --git a/sdk/src/golden/golden.integration.test.ts b/sdk/src/golden/golden.integration.test.ts index bb1a643f6..e13f9e76c 100644 --- a/sdk/src/golden/golden.integration.test.ts +++ b/sdk/src/golden/golden.integration.test.ts @@ -35,6 +35,73 @@ function omitAgentInstallFields(data: Record): Record { + await mkdir(join(root, '.planning', 'phases'), { recursive: true }); + await writeFile(join(root, '.planning', 'STATE.md'), MINIMAL_STATE, 'utf-8'); + await writeFile( + join(root, '.planning', 'ROADMAP.md'), + '# Roadmap\n\n## Current Milestone: v3.0 SDK-First Migration\n\n### Phase 10: Read-Only Queries\n', + 'utf-8', + ); + await writeFile(join(root, '.planning', 'config.json'), '{"model_profile":"balanced"}', 'utf-8'); +} + +async function setupPhasesFixture(root: string): Promise { + await setupMinimalStateProject(root); + const phasesRoot = join(root, '.planning', 'phases'); + await mkdir(join(phasesRoot, '10-read-only-queries'), { recursive: true }); + await mkdir(join(phasesRoot, '11-foundation-cleanup'), { recursive: true }); + await mkdir(join(phasesRoot, '999-backlog'), { recursive: true }); + await writeFile(join(phasesRoot, '10-read-only-queries', '10-01-PLAN.md'), '# plan\n', 'utf-8'); + await writeFile(join(phasesRoot, '10-read-only-queries', '10-02-PLAN.md'), '# plan\n', 'utf-8'); + await writeFile(join(phasesRoot, '11-foundation-cleanup', '11-01-SUMMARY.md'), '# summary\n', 'utf-8'); + + await writeFile( + join(root, '.planning', 'ROADMAP.md'), + [ + '# Roadmap', + '', + '| Phase | Plans | Status | Completed |', + '|---|---|---|---|', + '| 10. | 0/2 | Planned | |', + '| 11. | 1/1 | Complete | 2026-04-01 |', + '', + '### Phase 10: Read-Only Queries', + '', + '**Plans:** 0/2 plans executed', + '', + 'Plans:', + '- [ ] 10-01', + '- [ ] 10-02', + '', + '### Phase 11: Foundation Cleanup', + ].join('\n'), + 'utf-8', + ); + + const archivedRoot = join(root, '.planning', 'milestones', 'v0.9-phases', '09-legacy-foundation'); + await mkdir(archivedRoot, { recursive: true }); +} + describe('Golden file tests', () => { describe('generate-slug', () => { it('SDK output matches gsd-tools.cjs and checked-in golden fixture (fixture must track CLI, not SDK alone)', async () => { @@ -121,6 +188,43 @@ describe('Golden file tests', () => { }); }); + describe('roadmap parity (subprocess parity)', () => { + async function withFreshRoadmapProjects(): Promise<{ gsdDir: string; sdkDir: string }> { + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const gsdDir = join(tmpdir(), `gsd-golden-roadmap-gsd-${suffix}`); + const sdkDir = join(tmpdir(), `gsd-golden-roadmap-sdk-${suffix}`); + await setupPhasesFixture(gsdDir); + await setupPhasesFixture(sdkDir); + return { gsdDir, sdkDir }; + } + + it('roadmap.get-phase matches gsd-tools.cjs on fixture', async () => { + const { gsdDir, sdkDir } = await withFreshRoadmapProjects(); + try { + const gsdOutput = await captureGsdToolsOutput('roadmap', ['get-phase', '10'], gsdDir); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('roadmap.get-phase', ['10'], sdkDir); + expect(sdkResult.data).toEqual(gsdOutput); + } finally { + await rm(gsdDir, { recursive: true, force: true }); + await rm(sdkDir, { recursive: true, force: true }); + } + }); + + it('roadmap.update-plan-progress matches gsd-tools.cjs on fixture', async () => { + const { gsdDir, sdkDir } = await withFreshRoadmapProjects(); + try { + const gsdOutput = await captureGsdToolsOutput('roadmap', ['update-plan-progress', '10'], gsdDir); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('roadmap.update-plan-progress', ['10'], sdkDir); + expect(sdkResult.data).toEqual(gsdOutput); + } finally { + await rm(gsdDir, { recursive: true, force: true }); + await rm(sdkDir, { recursive: true, force: true }); + } + }); + }); + describe('progress', () => { it('SDK JSON matches gsd-tools.cjs (`progress json`)', async () => { const gsdOutput = await captureGsdToolsOutput('progress', ['json'], REPO_ROOT); @@ -168,6 +272,188 @@ describe('Golden file tests', () => { }); }); + describe('state mutations (subprocess parity)', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = join(tmpdir(), `gsd-golden-state-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await setupMinimalStateProject(tmpDir); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('state.update matches gsd-tools.cjs', async () => { + const gsdOutput = await captureGsdToolsOutput('state', ['update', 'Status', 'Executing SDK'], tmpDir); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('state.update', ['Status', 'Executing SDK'], tmpDir); + expect(sdkResult.data).toEqual(gsdOutput); + }); + + it('state.patch matches gsd-tools.cjs', async () => { + const gsdOutput = await captureGsdToolsOutput('state', ['patch', '--status', 'Patched via parity'], tmpDir); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('state.patch', ['--status', 'Patched via parity'], tmpDir); + expect(sdkResult.data).toEqual(gsdOutput); + }); + + it('state.begin-phase matches gsd-tools.cjs', async () => { + const argv = ['begin-phase', '--phase', '11', '--name', 'State Pilot', '--plans', '3']; + const gsdOutput = await captureGsdToolsOutput('state', argv, tmpDir); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('state.begin-phase', ['--phase', '11', '--name', 'State Pilot', '--plans', '3'], tmpDir); + expect(sdkResult.data).toEqual(gsdOutput); + }); + + it('state.sync --verify matches gsd-tools.cjs', async () => { + const gsdOutput = await captureGsdToolsOutput('state', ['sync', '--verify'], tmpDir); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('state.sync', ['--verify'], tmpDir); + expect(sdkResult.data).toEqual(gsdOutput); + }); + }); + + describe('phase mutations (subprocess parity)', () => { + async function withFreshPhaseProjects(): Promise<{ gsdDir: string; sdkDir: string }> { + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const gsdDir = join(tmpdir(), `gsd-golden-phase-gsd-${suffix}`); + const sdkDir = join(tmpdir(), `gsd-golden-phase-sdk-${suffix}`); + await setupMinimalStateProject(gsdDir); + await setupMinimalStateProject(sdkDir); + return { gsdDir, sdkDir }; + } + + it('phase.add matches gsd-tools.cjs', async () => { + const { gsdDir, sdkDir } = await withFreshPhaseProjects(); + try { + const gsdOutput = await captureGsdToolsOutput('phase', ['add', 'Phase parity add'], gsdDir); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('phase.add', ['Phase parity add'], sdkDir); + expect(sdkResult.data).toEqual(gsdOutput); + } finally { + await rm(gsdDir, { recursive: true, force: true }); + await rm(sdkDir, { recursive: true, force: true }); + } + }); + + it('phase.add-batch matches gsd-tools.cjs', async () => { + const { gsdDir, sdkDir } = await withFreshPhaseProjects(); + try { + const argv = ['add-batch', '--descriptions', '["Batch A","Batch B"]']; + const gsdOutput = await captureGsdToolsOutput('phase', argv, gsdDir); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('phase.add-batch', ['--descriptions', '["Batch A","Batch B"]'], sdkDir); + expect(sdkResult.data).toEqual(gsdOutput); + } finally { + await rm(gsdDir, { recursive: true, force: true }); + await rm(sdkDir, { recursive: true, force: true }); + } + }); + + it('phase.insert matches gsd-tools.cjs', async () => { + const { gsdDir, sdkDir } = await withFreshPhaseProjects(); + try { + const gsdOutput = await captureGsdToolsOutput('phase', ['insert', '10', 'Inserted parity phase'], gsdDir); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('phase.insert', ['10', 'Inserted parity phase'], sdkDir); + expect(sdkResult.data).toEqual(gsdOutput); + } finally { + await rm(gsdDir, { recursive: true, force: true }); + await rm(sdkDir, { recursive: true, force: true }); + } + }); + }); + + describe('phases parity (subprocess parity)', () => { + async function withFreshPhasesProjects(): Promise<{ gsdDir: string; sdkDir: string }> { + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const gsdDir = join(tmpdir(), `gsd-golden-phases-gsd-${suffix}`); + const sdkDir = join(tmpdir(), `gsd-golden-phases-sdk-${suffix}`); + await setupPhasesFixture(gsdDir); + await setupPhasesFixture(sdkDir); + return { gsdDir, sdkDir }; + } + + it('phases.list matches gsd-tools.cjs', async () => { + const { gsdDir, sdkDir } = await withFreshPhasesProjects(); + try { + const gsdOutput = await captureGsdToolsOutput('phases', ['list'], gsdDir); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('phases.list', [], sdkDir); + expect(sdkResult.data).toEqual(gsdOutput); + } finally { + await rm(gsdDir, { recursive: true, force: true }); + await rm(sdkDir, { recursive: true, force: true }); + } + }); + + it('phases.list --type plans matches gsd-tools.cjs', async () => { + const { gsdDir, sdkDir } = await withFreshPhasesProjects(); + try { + const gsdOutput = await captureGsdToolsOutput('phases', ['list', '--type', 'plans'], gsdDir); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('phases.list', ['--type', 'plans'], sdkDir); + expect(sdkResult.data).toEqual(gsdOutput); + } finally { + await rm(gsdDir, { recursive: true, force: true }); + await rm(sdkDir, { recursive: true, force: true }); + } + }); + + it('phases.list --type summaries matches gsd-tools.cjs', async () => { + const { gsdDir, sdkDir } = await withFreshPhasesProjects(); + try { + const gsdOutput = await captureGsdToolsOutput('phases', ['list', '--type', 'summaries'], gsdDir); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('phases.list', ['--type', 'summaries'], sdkDir); + expect(sdkResult.data).toEqual(gsdOutput); + } finally { + await rm(gsdDir, { recursive: true, force: true }); + await rm(sdkDir, { recursive: true, force: true }); + } + }); + + it('phases.list --phase 10 matches gsd-tools.cjs', async () => { + const { gsdDir, sdkDir } = await withFreshPhasesProjects(); + try { + const gsdOutput = await captureGsdToolsOutput('phases', ['list', '--phase', '10'], gsdDir); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('phases.list', ['--phase', '10'], sdkDir); + expect(sdkResult.data).toEqual(gsdOutput); + } finally { + await rm(gsdDir, { recursive: true, force: true }); + await rm(sdkDir, { recursive: true, force: true }); + } + }); + + it('phases.list --include-archived matches gsd-tools.cjs', async () => { + const { gsdDir, sdkDir } = await withFreshPhasesProjects(); + try { + const gsdOutput = await captureGsdToolsOutput('phases', ['list', '--include-archived'], gsdDir); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('phases.list', ['--include-archived'], sdkDir); + expect(sdkResult.data).toEqual(gsdOutput); + } finally { + await rm(gsdDir, { recursive: true, force: true }); + await rm(sdkDir, { recursive: true, force: true }); + } + }); + + it('phases.clear --confirm matches gsd-tools.cjs', async () => { + const { gsdDir, sdkDir } = await withFreshPhasesProjects(); + try { + const gsdOutput = await captureGsdToolsOutput('phases', ['clear', '--confirm'], gsdDir); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('phases.clear', ['--confirm'], sdkDir); + expect(sdkResult.data).toEqual(gsdOutput); + } finally { + await rm(gsdDir, { recursive: true, force: true }); + await rm(sdkDir, { recursive: true, force: true }); + } + }); + }); + describe('current-timestamp', () => { it('SDK full format matches gsd-tools.cjs output structure', async () => { const gsdOutput = await captureGsdToolsOutput('current-timestamp', ['full'], PROJECT_DIR) as { timestamp: string }; @@ -241,6 +527,24 @@ describe('Golden file tests', () => { }); }); + describe('validate.health', () => { + it('SDK JSON matches gsd-tools.cjs', async () => { + const gsdOutput = await captureGsdToolsOutput('validate', ['health'], REPO_ROOT); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('validate.health', [], REPO_ROOT); + expect(sdkResult.data).toEqual(gsdOutput); + }); + }); + + describe('validate.agents', () => { + it('SDK JSON matches gsd-tools.cjs', async () => { + const gsdOutput = await captureGsdToolsOutput('validate', ['agents'], REPO_ROOT); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('validate.agents', [], REPO_ROOT); + expect(sdkResult.data).toEqual(gsdOutput); + }); + }); + // ─── Init composition handler golden tests ───────────────────────────── describe('init.execute-phase', () => { diff --git a/tests/phases-command-router.test.cjs b/tests/phases-command-router.test.cjs new file mode 100644 index 000000000..6f61ecc2a --- /dev/null +++ b/tests/phases-command-router.test.cjs @@ -0,0 +1,74 @@ +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { routePhasesCommand } = require('../get-shit-done/bin/lib/phases-command-router.cjs'); + +describe('phases-command-router', () => { + test('routes phases list with parsed options', () => { + const calls = []; + const phase = { + cmdPhasesList: (cwd, options, raw) => calls.push({ cwd, options, raw }), + }; + + routePhasesCommand({ + phase, + milestone: {}, + args: ['phases', 'list', '--type', 'plans', '--phase', '10', '--include-archived'], + cwd: '/tmp/proj', + raw: true, + error: (msg) => { + throw new Error(msg); + }, + }); + + assert.equal(calls.length, 1); + assert.deepEqual(calls[0], { + cwd: '/tmp/proj', + options: { type: 'plans', phase: '10', includeArchived: true }, + raw: true, + }); + }); + + test('routes phases clear with trailing args', () => { + const calls = []; + const milestone = { + cmdPhasesClear: (cwd, raw, trailing) => calls.push({ cwd, raw, trailing }), + }; + + routePhasesCommand({ + phase: {}, + milestone, + args: ['phases', 'clear', '--confirm'], + cwd: '/tmp/proj', + raw: false, + error: (msg) => { + throw new Error(msg); + }, + }); + + assert.equal(calls.length, 1); + assert.deepEqual(calls[0], { + cwd: '/tmp/proj', + raw: false, + trailing: ['--confirm'], + }); + }); + + test('errors on unknown phases subcommand', () => { + let message = null; + routePhasesCommand({ + phase: {}, + milestone: {}, + args: ['phases', 'archive'], + cwd: '/tmp/proj', + raw: false, + error: (msg) => { + message = msg; + }, + }); + + assert.equal(message, 'Unknown phases subcommand. Available: list, clear'); + }); +}); diff --git a/tests/roadmap-command-router.test.cjs b/tests/roadmap-command-router.test.cjs new file mode 100644 index 000000000..14d2e7fcb --- /dev/null +++ b/tests/roadmap-command-router.test.cjs @@ -0,0 +1,76 @@ +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { routeRoadmapCommand } = require('../get-shit-done/bin/lib/roadmap-command-router.cjs'); + +describe('roadmap-command-router', () => { + test('routes roadmap analyze', () => { + const calls = []; + const roadmap = { + cmdRoadmapAnalyze: (cwd, raw) => calls.push({ cwd, raw }), + }; + + routeRoadmapCommand({ + roadmap, + args: ['roadmap', 'analyze'], + cwd: '/tmp/proj', + raw: true, + error: (msg) => { + throw new Error(msg); + }, + }); + + assert.equal(calls.length, 1); + assert.deepEqual(calls[0], { cwd: '/tmp/proj', raw: true }); + }); + + test('routes roadmap get-phase and update-plan-progress with phase arg', () => { + const calls = []; + const roadmap = { + cmdRoadmapGetPhase: (cwd, phase, raw) => calls.push({ kind: 'get', cwd, phase, raw }), + cmdRoadmapUpdatePlanProgress: (cwd, phase, raw) => calls.push({ kind: 'update', cwd, phase, raw }), + }; + + routeRoadmapCommand({ + roadmap, + args: ['roadmap', 'get-phase', '10'], + cwd: '/tmp/proj', + raw: false, + error: (msg) => { + throw new Error(msg); + }, + }); + + routeRoadmapCommand({ + roadmap, + args: ['roadmap', 'update-plan-progress', '10'], + cwd: '/tmp/proj', + raw: false, + error: (msg) => { + throw new Error(msg); + }, + }); + + assert.deepEqual(calls, [ + { kind: 'get', cwd: '/tmp/proj', phase: '10', raw: false }, + { kind: 'update', cwd: '/tmp/proj', phase: '10', raw: false }, + ]); + }); + + test('errors on unknown roadmap subcommand', () => { + let message = null; + routeRoadmapCommand({ + roadmap: {}, + args: ['roadmap', 'nonsense'], + cwd: '/tmp/proj', + raw: false, + error: (msg) => { + message = msg; + }, + }); + + assert.equal(message, 'Unknown roadmap subcommand. Available: analyze, get-phase, update-plan-progress, annotate-dependencies'); + }); +});