/** * Unit tests for STATE.md mutation handlers. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtemp, writeFile, readFile, rm, mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { existsSync } from 'node:fs'; // ─── Helpers (internal) ───────────────────────────────────────────────────── /** Minimal STATE.md for testing. */ const MINIMAL_STATE = `--- gsd_state_version: 1.0 milestone: v3.0 milestone_name: SDK-First Migration status: executing --- # Project State ## Project Reference **Core value:** Test project ## Current Position Phase: 10 (Read-Only Queries) — EXECUTING Plan: 2 of 3 Status: Executing Phase 10 Last activity: 2026-04-08 -- Phase 10 execution started Progress: [░░░░░░░░░░] 50% ## Performance Metrics **Velocity:** | Phase | Duration | Tasks | Files | |-------|----------|-------|-------| ## Accumulated Context ### Decisions None yet. ### Pending Todos None yet. ### Blockers/Concerns None yet. ## Session Continuity Last session: 2026-04-07T10:00:00.000Z Stopped at: Completed 10-02-PLAN.md Resume file: None `; /** Create a minimal .planning directory for testing. */ async function setupTestProject(tmpDir: string, stateContent?: string): Promise { const planningDir = join(tmpDir, '.planning'); await mkdir(planningDir, { recursive: true }); await mkdir(join(planningDir, 'phases'), { recursive: true }); await writeFile(join(planningDir, 'STATE.md'), stateContent || MINIMAL_STATE, 'utf-8'); // Minimal ROADMAP.md for buildStateFrontmatter await writeFile(join(planningDir, 'ROADMAP.md'), '# Roadmap\n\n## Current Milestone: v3.0 SDK-First Migration\n\n### Phase 10: Read-Only Queries\n\nGoal: Port queries.\n', 'utf-8'); await writeFile(join(planningDir, 'config.json'), '{"model_profile":"balanced"}', 'utf-8'); return tmpDir; } // ─── Import tests ─────────────────────────────────────────────────────────── describe('state-mutation imports', () => { it('exports stateUpdate handler', async () => { const mod = await import('./state-mutation.js'); expect(typeof mod.stateUpdate).toBe('function'); }); it('exports statePatch handler', async () => { const mod = await import('./state-mutation.js'); expect(typeof mod.statePatch).toBe('function'); }); it('exports stateBeginPhase handler', async () => { const mod = await import('./state-mutation.js'); expect(typeof mod.stateBeginPhase).toBe('function'); }); it('exports stateAdvancePlan handler', async () => { const mod = await import('./state-mutation.js'); expect(typeof mod.stateAdvancePlan).toBe('function'); }); it('exports stateRecordMetric handler', async () => { const mod = await import('./state-mutation.js'); expect(typeof mod.stateRecordMetric).toBe('function'); }); it('exports stateUpdateProgress handler', async () => { const mod = await import('./state-mutation.js'); expect(typeof mod.stateUpdateProgress).toBe('function'); }); it('exports stateAddDecision handler', async () => { const mod = await import('./state-mutation.js'); expect(typeof mod.stateAddDecision).toBe('function'); }); it('exports stateAddBlocker handler', async () => { const mod = await import('./state-mutation.js'); expect(typeof mod.stateAddBlocker).toBe('function'); }); it('exports stateResolveBlocker handler', async () => { const mod = await import('./state-mutation.js'); expect(typeof mod.stateResolveBlocker).toBe('function'); }); it('exports stateRecordSession handler', async () => { const mod = await import('./state-mutation.js'); expect(typeof mod.stateRecordSession).toBe('function'); }); }); // ─── stateReplaceField ────────────────────────────────────────────────────── describe('stateReplaceField', () => { it('replaces bold format field', async () => { const { stateReplaceField } = await import('./state-mutation.js'); const content = '**Status:** executing\n**Plan:** 1'; const result = stateReplaceField(content, 'Status', 'done'); expect(result).toContain('**Status:** done'); }); it('replaces plain format field', async () => { const { stateReplaceField } = await import('./state-mutation.js'); const content = 'Status: executing\nPlan: 1'; const result = stateReplaceField(content, 'Status', 'done'); expect(result).toContain('Status: done'); }); it('returns null when field not found', async () => { const { stateReplaceField } = await import('./state-mutation.js'); const result = stateReplaceField('no fields here', 'Missing', 'value'); expect(result).toBeNull(); }); it('is case-insensitive', async () => { const { stateReplaceField } = await import('./state-mutation.js'); const content = '**status:** executing'; const result = stateReplaceField(content, 'Status', 'done'); expect(result).toContain('done'); }); }); // ─── acquireStateLock / releaseStateLock ───────────────────────────────────── describe('acquireStateLock / releaseStateLock', () => { let tmpDir: string; beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'gsd-lock-')); }); afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }); }); it('creates and removes lockfile', async () => { const { acquireStateLock, releaseStateLock } = await import('./state-mutation.js'); const statePath = join(tmpDir, 'STATE.md'); await writeFile(statePath, 'test', 'utf-8'); const lockPath = await acquireStateLock(statePath); expect(existsSync(lockPath)).toBe(true); await releaseStateLock(lockPath); expect(existsSync(lockPath)).toBe(false); }); it('tracks lockPath in _heldStateLocks on acquire and removes on release', async () => { const { acquireStateLock, releaseStateLock, _heldStateLocks } = await import('./state-mutation.js'); const statePath = join(tmpDir, 'STATE.md'); await writeFile(statePath, 'test', 'utf-8'); const lockPath = await acquireStateLock(statePath); expect(_heldStateLocks.has(lockPath)).toBe(true); await releaseStateLock(lockPath); expect(_heldStateLocks.has(lockPath)).toBe(false); }); it('returns lockPath on non-EEXIST errors instead of throwing', async () => { // Simulate a non-EEXIST error by using a path in a non-existent directory // This triggers ENOENT (not EEXIST), which should return lockPath gracefully const { acquireStateLock } = await import('./state-mutation.js'); const badPath = join(tmpDir, 'nonexistent-dir', 'subdir', 'STATE.md'); // Should NOT throw — should return lockPath gracefully const lockPath = await acquireStateLock(badPath); expect(lockPath).toBe(badPath + '.lock'); }); }); // ─── stateUpdate ──────────────────────────────────────────────────────────── describe('stateUpdate', () => { let tmpDir: string; beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'gsd-state-update-')); await setupTestProject(tmpDir); }); afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }); }); it('updates a single field and round-trips through stateLoad', async () => { const { stateUpdate } = await import('./state-mutation.js'); const { stateJson } = await import('./state.js'); const result = await stateUpdate(['Status', 'Phase complete'], tmpDir); const data = result.data as Record; expect(data.updated).toBe(true); // Verify round-trip const loaded = await stateJson([], tmpDir); const loadedData = loaded.data as Record; // Status gets normalized by buildStateFrontmatter expect(loadedData.status).toBeTruthy(); }); it('returns updated false when field not found', async () => { const { stateUpdate } = await import('./state-mutation.js'); const result = await stateUpdate(['NonExistentField', 'value'], tmpDir); const data = result.data as Record; expect(data.updated).toBe(false); }); it('preserves curated progress frontmatter during body-only updates', async () => { const stateContent = `--- gsd_state_version: 1.0 milestone: v3.0 milestone_name: SDK-First Migration status: executing progress: total_phases: 12 completed_phases: 6 total_plans: 22 completed_plans: 22 percent: 50 --- # Project State ## Current Position Status: Executing Last Activity: 2026-01-01 Progress: [█████░░░░░] 50% `; await setupTestProject(tmpDir, stateContent); const { stateUpdate } = await import('./state-mutation.js'); const { stateJson } = await import('./state.js'); const result = await stateUpdate(['Last Activity', '2026-05-07'], tmpDir); expect((result.data as Record).updated).toBe(true); const loaded = await stateJson([], tmpDir); const progress = (loaded.data as Record).progress as Record; expect(Number(progress.total_phases)).toBe(12); expect(Number(progress.completed_phases)).toBe(6); expect(Number(progress.total_plans)).toBe(22); expect(Number(progress.completed_plans)).toBe(22); expect(Number(progress.percent)).toBe(50); const after = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); expect(after).toContain('Last Activity: 2026-05-07'); }); it('resyncs progress frontmatter when updating the Progress body field', async () => { const stateContent = `--- gsd_state_version: 1.0 milestone: v3.0 milestone_name: SDK-First Migration status: executing progress: total_phases: 12 completed_phases: 6 total_plans: 22 completed_plans: 22 percent: 50 --- # Project State ## Current Position Status: Executing Progress: [█████░░░░░] 50% `; await setupTestProject(tmpDir, stateContent); await rm(join(tmpDir, '.planning', 'ROADMAP.md'), { force: true }); const { stateUpdate } = await import('./state-mutation.js'); await stateUpdate(['Progress', '[████████░░] 80%'], tmpDir); const after = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); const { extractFrontmatter } = await import('./frontmatter.js'); const fm = extractFrontmatter(after); const progress = fm.progress as Record; expect(Number(progress.percent)).toBe(80); }); it('syncs full-file workstream STATE.md frontmatter from the selected workstream', async () => { const planningDir = join(tmpDir, '.planning'); const wsDir = join(planningDir, 'workstreams', 'feature'); await mkdir(join(wsDir, 'phases'), { recursive: true }); await writeFile( join(planningDir, 'STATE.md'), [ '---', 'milestone: v0.1', 'milestone_name: Root Milestone', '---', '', '# Root State', '', 'Status: Root', '', ].join('\n'), 'utf-8', ); await writeFile(join(planningDir, 'ROADMAP.md'), '# Roadmap\n\n## v0.1 Root Milestone\n', 'utf-8'); await writeFile( join(wsDir, 'STATE.md'), [ '---', 'milestone: v2.0', 'milestone_name: Feature Milestone', '---', '', '# Workstream State', '', 'Status: Executing', '', ].join('\n'), 'utf-8', ); await writeFile(join(wsDir, 'ROADMAP.md'), '# Roadmap\n\n## v2.0 Feature Milestone\n', 'utf-8'); const { readModifyWriteStateMdFull } = await import('./state-mutation.js'); await readModifyWriteStateMdFull(tmpDir, content => content.replace('Status: Executing', 'Status: Complete'), 'feature'); const after = await readFile(join(wsDir, 'STATE.md'), 'utf-8'); const { extractFrontmatter } = await import('./frontmatter.js'); const fm = extractFrontmatter(after); expect(fm.milestone).toBe('v2.0'); expect(fm.milestone_name).toBe('Feature Milestone'); expect(fm.status).toBe('completed'); }); it('throws on missing args', async () => { const { stateUpdate } = await import('./state-mutation.js'); await expect(stateUpdate([], tmpDir)).rejects.toThrow(/field and value required/); }); }); // ─── statePatch ───────────────────────────────────────────────────────────── describe('statePatch', () => { let tmpDir: string; beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'gsd-state-patch-')); await setupTestProject(tmpDir); }); afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }); }); it('updates multiple fields in one lock cycle', async () => { const { statePatch } = await import('./state-mutation.js'); const patches = JSON.stringify({ Status: 'done', Progress: '100%' }); const result = await statePatch([patches], tmpDir); const data = result.data as Record; expect((data.updated as string[]).length).toBeGreaterThan(0); // Verify file was updated const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); expect(content).toContain('done'); }); it('preserves curated progress frontmatter when patching body-only fields', async () => { const stateContent = `--- gsd_state_version: 1.0 milestone: v3.0 milestone_name: SDK-First Migration status: executing progress: total_phases: 12 completed_phases: 6 total_plans: 22 completed_plans: 22 percent: 50 --- # Project State ## Current Position Status: Executing Last Activity: 2026-01-01 Progress: [█████░░░░░] 50% `; await setupTestProject(tmpDir, stateContent); const { statePatch } = await import('./state-mutation.js'); await statePatch([JSON.stringify({ 'Last Activity': '2026-05-07' })], tmpDir); const after = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); const { extractFrontmatter } = await import('./frontmatter.js'); const fm = extractFrontmatter(after); const progress = fm.progress as Record; expect(Number(progress.total_phases)).toBe(12); expect(Number(progress.completed_phases)).toBe(6); expect(Number(progress.total_plans)).toBe(22); expect(Number(progress.completed_plans)).toBe(22); expect(Number(progress.percent)).toBe(50); expect(after).toContain('Last Activity: 2026-05-07'); }); }); // ─── stateBeginPhase ──────────────────────────────────────────────────────── describe('stateBeginPhase', () => { let tmpDir: string; beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'gsd-state-begin-')); await setupTestProject(tmpDir); }); afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }); }); it('sets all expected fields', async () => { const { stateBeginPhase } = await import('./state-mutation.js'); const result = await stateBeginPhase(['11', 'State Mutations', '3'], tmpDir); const data = result.data as Record; expect(data.phase).toBe('11'); const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); expect(content).toContain('Executing Phase 11'); expect(content).toContain('State Mutations'); }); // ─── Bug #2420: flag-form args not parsed ──────────────────────────── it('bug-2420: parses --phase/--name/--plans flag-form args correctly', async () => { const { stateBeginPhase } = await import('./state-mutation.js'); // This is how execute-phase.md calls it: flag form const result = await stateBeginPhase( ['--phase', '99', '--name', 'probe-test', '--plans', '1'], tmpDir ); const data = result.data as Record; // Must return the actual values, not the flag names expect(data.phase).toBe('99'); expect(data.phase_name).toBe('probe-test'); expect(data.plan_count).toBe(1); // STATE.md must contain clean output, not literal "--phase" const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); expect(content).not.toContain('--phase'); expect(content).not.toContain('--name'); expect(content).not.toContain('--plans'); expect(content).toContain('Executing Phase 99'); expect(content).toContain('probe-test'); }); it('bug-2420: positional args still work after flag-parsing fix', async () => { const { stateBeginPhase } = await import('./state-mutation.js'); const result = await stateBeginPhase(['42', 'Positional Test', '5'], tmpDir); const data = result.data as Record; expect(data.phase).toBe('42'); expect(data.phase_name).toBe('Positional Test'); expect(data.plan_count).toBe(5); }); it('bug-2420: flag parser throws when a flag value is missing (next token is a flag)', async () => { const { stateBeginPhase } = await import('./state-mutation.js'); // --phase has no value — next token is --name, which is itself a flag. await expect( stateBeginPhase(['--phase', '--name', 'Title', '--plans', '1'], tmpDir) ).rejects.toThrow('missing value for --phase'); }); it('bug-2420: flag parser throws when a flag is last token with no value', async () => { const { stateBeginPhase } = await import('./state-mutation.js'); await expect( stateBeginPhase(['--name', 'Title', '--plans', '1', '--phase'], tmpDir) ).rejects.toThrow('missing value for --phase'); }); it('does not treat argv after named flags as positional name/plans', async () => { const { stateBeginPhase } = await import('./state-mutation.js'); const result = await stateBeginPhase(['--phase', '2', '--plans', '3'], tmpDir); const data = result.data as Record; expect(data.phase).toBe('2'); expect(data.phase_name).toBeFalsy(); expect(data.plan_count).toBe(3); const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); expect(content).toContain('Plan: 1 of 3'); }); it('preserves literal dollar amounts in Current Position body', async () => { const { stateBeginPhase } = await import('./state-mutation.js'); const withBudget = MINIMAL_STATE.replace( 'Last activity: 2026-04-08 -- Phase 10 execution started', 'Last activity: 2026-04-08 -- Phase 10 execution started\nBudget: $2,500 max test', ); await setupTestProject(tmpDir, withBudget); await stateBeginPhase(['11', 'State Mutations', '3'], tmpDir); const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); expect(content).toContain('Budget: $2,500 max test'); expect((content.match(/^Budget:/gm) || []).length).toBe(1); }); }); // ─── stateAdvancePlan ─────────────────────────────────────────────────────── describe('stateAdvancePlan', () => { let tmpDir: string; beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'gsd-state-advance-')); await setupTestProject(tmpDir); }); afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }); }); it('increments plan counter', async () => { const { stateAdvancePlan } = await import('./state-mutation.js'); const result = await stateAdvancePlan([], tmpDir); const data = result.data as Record; expect(data.advanced).toBe(true); expect(data.current_plan).toBe(3); }); it('keeps literal dollar amounts stable after multiple updates', async () => { const { stateAdvancePlan } = await import('./state-mutation.js'); const withBudget = MINIMAL_STATE.replace( 'Last activity: 2026-04-08 -- Phase 10 execution started', 'Last activity: 2026-04-08 -- Phase 10 execution started\nBudget: $2,500 max test', ).replace('Plan: 2 of 3', 'Plan: 1 of 20'); await setupTestProject(tmpDir, withBudget); for (let i = 0; i < 8; i += 1) { await stateAdvancePlan([], tmpDir); } const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); expect(content).toContain('Budget: $2,500 max test'); expect((content.match(/^Budget:/gm) || []).length).toBe(1); }); }); // ─── stateAddDecision ─────────────────────────────────────────────────────── describe('stateAddDecision', () => { let tmpDir: string; beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'gsd-state-decision-')); await setupTestProject(tmpDir); }); afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }); }); it('appends decision and removes placeholder', async () => { const { stateAddDecision } = await import('./state-mutation.js'); const result = await stateAddDecision( ['--phase', '10', '--summary', 'Use lockfile atomicity'], tmpDir, ); const data = result.data as Record; expect(data.added).toBe(true); const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); expect(content).toContain('Use lockfile atomicity'); // Verify "None yet." was removed from the Decisions section specifically const decisionsMatch = content.match(/###?\s*Decisions\s*\n([\s\S]*?)(?=\n###?|\n##[^#]|$)/i); expect(decisionsMatch).not.toBeNull(); expect(decisionsMatch![1]).not.toContain('None yet.'); }); }); // ─── stateAddRoadmapEvolution (bug #2662) ────────────────────────────────── describe('stateAddRoadmapEvolution', () => { let tmpDir: string; beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'gsd-state-evo-')); }); afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }); }); it('creates the Roadmap Evolution subsection when missing and appends the entry', async () => { await setupTestProject(tmpDir); // MINIMAL_STATE has no Roadmap Evolution. const { stateAddRoadmapEvolution } = await import('./state-mutation.js'); const result = await stateAddRoadmapEvolution( ['--phase', '72.1', '--action', 'inserted', '--after', '72', '--note', 'Fix critical auth bug', '--urgent'], tmpDir, ); const data = result.data as Record; expect(data.added).toBe(true); expect(data.entry).toBe('- Phase 72.1 inserted after Phase 72: Fix critical auth bug (URGENT)'); const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); expect(content).toContain('### Roadmap Evolution'); expect(content).toContain('- Phase 72.1 inserted after Phase 72: Fix critical auth bug (URGENT)'); // Subsection sits under Accumulated Context. const idxAccum = content.indexOf('## Accumulated Context'); const idxEvo = content.indexOf('### Roadmap Evolution'); expect(idxAccum).toBeGreaterThan(-1); expect(idxEvo).toBeGreaterThan(idxAccum); }); it('appends to an existing Roadmap Evolution subsection preserving prior entries', async () => { const stateWithEvo = `--- gsd_state_version: 1.0 milestone: v3.0 milestone_name: SDK-First Migration status: executing --- # Project State ## Current Position Phase: 10 — EXECUTING ## Accumulated Context ### Decisions None yet. ### Roadmap Evolution - Phase 5 added: Baseline work - Phase 6 added: Follow-up ## Session Continuity Last session: 2026-04-07T10:00:00.000Z `; await setupTestProject(tmpDir, stateWithEvo); const { stateAddRoadmapEvolution } = await import('./state-mutation.js'); const result = await stateAddRoadmapEvolution( ['--phase', '72.1', '--action', 'inserted', '--after', '72', '--note', 'Urgent fix', '--urgent'], tmpDir, ); expect((result.data as Record).added).toBe(true); const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); expect(content).toContain('- Phase 5 added: Baseline work'); expect(content).toContain('- Phase 6 added: Follow-up'); expect(content).toContain('- Phase 72.1 inserted after Phase 72: Urgent fix (URGENT)'); // Order preserved: existing entries come before the new one. const idx5 = content.indexOf('Phase 5 added'); const idx6 = content.indexOf('Phase 6 added'); const idxNew = content.indexOf('Phase 72.1 inserted'); expect(idx5).toBeLessThan(idx6); expect(idx6).toBeLessThan(idxNew); }); it('dedupes exact-match entries and reports reason=duplicate', async () => { await setupTestProject(tmpDir); const { stateAddRoadmapEvolution } = await import('./state-mutation.js'); const argv = ['--phase', '72.1', '--action', 'inserted', '--after', '72', '--note', 'Fix X', '--urgent']; const first = await stateAddRoadmapEvolution(argv, tmpDir); expect((first.data as Record).added).toBe(true); const second = await stateAddRoadmapEvolution(argv, tmpDir); const data = second.data as Record; expect(data.added).toBe(false); expect(data.reason).toBe('duplicate'); // Entry appears exactly once. const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); const matches = content.match(/Phase 72\.1 inserted after Phase 72: Fix X \(URGENT\)/g) || []; expect(matches.length).toBe(1); }); it('is idempotent: calling twice with same input leaves a single entry', async () => { await setupTestProject(tmpDir); const { stateAddRoadmapEvolution } = await import('./state-mutation.js'); const argv = ['--phase', '9', '--action', 'added', '--note', 'new work']; await stateAddRoadmapEvolution(argv, tmpDir); await stateAddRoadmapEvolution(argv, tmpDir); const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); const matches = content.match(/Phase 9 added: new work/g) || []; expect(matches.length).toBe(1); }); it('throws GSDError(Validation) when phase is missing', async () => { await setupTestProject(tmpDir); const { stateAddRoadmapEvolution } = await import('./state-mutation.js'); const { GSDError, ErrorClassification } = await import('../errors.js'); await expect(stateAddRoadmapEvolution( ['--action', 'inserted'], tmpDir, )).rejects.toSatisfy((err: unknown) => { return err instanceof GSDError && err.classification === ErrorClassification.Validation; }); }); it('throws GSDError(Validation) when action is missing', async () => { await setupTestProject(tmpDir); const { stateAddRoadmapEvolution } = await import('./state-mutation.js'); const { GSDError, ErrorClassification } = await import('../errors.js'); await expect(stateAddRoadmapEvolution( ['--phase', '72.1'], tmpDir, )).rejects.toSatisfy((err: unknown) => { return err instanceof GSDError && err.classification === ErrorClassification.Validation; }); }); it('throws GSDError(Validation) on invalid action', async () => { await setupTestProject(tmpDir); const { stateAddRoadmapEvolution } = await import('./state-mutation.js'); const { GSDError, ErrorClassification } = await import('../errors.js'); await expect(stateAddRoadmapEvolution( ['--phase', '72.1', '--action', 'frobnicated'], tmpDir, )).rejects.toSatisfy((err: unknown) => { return err instanceof GSDError && err.classification === ErrorClassification.Validation; }); }); }); // ─── stateRecordSession ───────────────────────────────────────────────────── describe('stateRecordSession', () => { let tmpDir: string; beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'gsd-state-session-')); await setupTestProject(tmpDir); }); afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }); }); it('updates session fields', async () => { const { stateRecordSession } = await import('./state-mutation.js'); const result = await stateRecordSession( ['--stopped-at', 'Completed 11-01-PLAN.md', '--resume-file', 'None'], tmpDir, ); const data = result.data as Record; expect(data.recorded).toBe(true); const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); expect(content).toContain('Completed 11-01-PLAN.md'); }); }); // ─── Bug #2613: write-side frontmatter preservation ───────────────────────── describe('Bug #2613: STATE.md frontmatter preservation through mutations', () => { let tmpDir: string; beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'gsd-state-2613-')); }); afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }); }); it('record-session preserves milestone + milestone_name when ROADMAP has a different current milestone', async () => { // STATE.md declares v12.0 / Focus (shipped). ROADMAP's heading-parseable // current is v11.0 / Research-Depth. Before the fix, re-derivation pulled // v11.0 / Research-Depth into STATE.md's frontmatter on every mutation. const stateContent = `--- gsd_state_version: 1.0 milestone: v12.0 milestone_name: Focus status: shipped --- # Project State ## Session Continuity Last session: 2026-04-20T00:00:00Z Stopped at: v12.0 SHIPPED Resume file: None `; const roadmapContent = `# Roadmap ## Phases ## v11.0 Research-Depth Scoring (In Progress) ### Phase 55 - stuff ## v12.0 Focus — ✅ SHIPPED 2026-04-20 ### Phase 60 - shipped stuff `; const planningDir = join(tmpDir, '.planning'); await mkdir(join(planningDir, 'phases'), { recursive: true }); await writeFile(join(planningDir, 'STATE.md'), stateContent, 'utf-8'); await writeFile(join(planningDir, 'ROADMAP.md'), roadmapContent, 'utf-8'); await writeFile(join(planningDir, 'config.json'), '{}', 'utf-8'); const { stateRecordSession } = await import('./state-mutation.js'); await stateRecordSession( ['--stopped-at', 'regression test', '--resume-file', '.planning/MILESTONES.md'], tmpDir, ); const after = await readFile(join(planningDir, 'STATE.md'), 'utf-8'); const { extractFrontmatter } = await import('./frontmatter.js'); const fm = extractFrontmatter(after); expect(fm.milestone).toBe('v12.0'); expect(fm.milestone_name).toBe('Focus'); }); it('record-session preserves status from existing frontmatter when body has no Status field', async () => { // STATE.md frontmatter declares status: shipped. Body has no "Status:" line. // Before the fix, derived status defaulted to 'unknown' and the frontmatter // value was lost because existingFm was {} at the preservation branch. const stateContent = `--- gsd_state_version: 1.0 milestone: v12.0 milestone_name: Focus status: shipped --- # Project State ## Session Continuity Last session: 2026-04-20T00:00:00Z Stopped at: v12.0 SHIPPED Resume file: None `; const planningDir = join(tmpDir, '.planning'); await mkdir(join(planningDir, 'phases'), { recursive: true }); await writeFile(join(planningDir, 'STATE.md'), stateContent, 'utf-8'); await writeFile(join(planningDir, 'ROADMAP.md'), '# Roadmap\n\n## v12.0 Focus\n', 'utf-8'); await writeFile(join(planningDir, 'config.json'), '{}', 'utf-8'); const { stateRecordSession } = await import('./state-mutation.js'); await stateRecordSession( ['--stopped-at', 'regression test', '--resume-file', 'None'], tmpDir, ); const after = await readFile(join(planningDir, 'STATE.md'), 'utf-8'); const { extractFrontmatter } = await import('./frontmatter.js'); const fm = extractFrontmatter(after); expect(fm.status).toBe('shipped'); }); it('record-session preserves progress from frontmatter when disk scan returns zero counts', async () => { // Shipped milestone: phase directories have been archived, so disk scan // returns total_plans=0. Existing frontmatter has authoritative counts // (5/5, 12/12, 100%). Before the fix, disk scan stomped the counts to 0/0. const stateContent = `--- gsd_state_version: 1.0 milestone: v12.0 milestone_name: Focus status: shipped progress: total_phases: 5 completed_phases: 5 total_plans: 12 completed_plans: 12 percent: 100 --- # Project State ## Session Continuity Last session: 2026-04-20T00:00:00Z Stopped at: v12.0 SHIPPED Resume file: None `; const planningDir = join(tmpDir, '.planning'); await mkdir(join(planningDir, 'phases'), { recursive: true }); await writeFile(join(planningDir, 'STATE.md'), stateContent, 'utf-8'); await writeFile(join(planningDir, 'ROADMAP.md'), '# Roadmap\n\n## v12.0 Focus\n', 'utf-8'); await writeFile(join(planningDir, 'config.json'), '{}', 'utf-8'); const { stateRecordSession } = await import('./state-mutation.js'); await stateRecordSession( ['--stopped-at', 'regression test', '--resume-file', 'None'], tmpDir, ); const after = await readFile(join(planningDir, 'STATE.md'), 'utf-8'); const { extractFrontmatter } = await import('./frontmatter.js'); const fm = extractFrontmatter(after); const progress = fm.progress as Record; expect(Number(progress.total_plans)).toBe(12); expect(Number(progress.completed_plans)).toBe(12); expect(Number(progress.percent)).toBe(100); }); it('regression guard: state.update Status still updates frontmatter status when body is mutated', async () => { // Legitimate status change must still propagate. If the body's Status // field becomes "executing", derived status is 'executing' and option 2 // must NOT overwrite it with the frontmatter's prior 'shipped'. await setupTestProject(tmpDir); const { stateUpdate } = await import('./state-mutation.js'); await stateUpdate(['Status', 'executing'], tmpDir); const after = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); const { extractFrontmatter } = await import('./frontmatter.js'); const fm = extractFrontmatter(after); expect(fm.status).toBe('executing'); }); it('regression guard: disk-scanned progress wins when scan returns non-zero counts', async () => { // Mid-milestone: disk has real phase directories with plans + summaries. // Disk is the ground truth — frontmatter progress must not override it. const stateContent = `--- gsd_state_version: 1.0 milestone: v3.0 milestone_name: SDK-First Migration status: executing progress: total_phases: 99 completed_phases: 99 total_plans: 99 completed_plans: 99 percent: 99 --- # Project State ## Current Position Status: Executing Last activity: today ## Session Continuity Last session: 2026-04-08T05:00:00Z Stopped at: work Resume file: None `; const planningDir = join(tmpDir, '.planning'); const phasesDir = join(planningDir, 'phases'); await mkdir(phasesDir, { recursive: true }); // Real phase with 1 plan and 1 summary — disk scan must report these. const phase10 = join(phasesDir, '10-foo'); await mkdir(phase10, { recursive: true }); await writeFile(join(phase10, '10-01-PLAN.md'), 'plan', 'utf-8'); await writeFile(join(phase10, '10-01-SUMMARY.md'), 'summary', 'utf-8'); await writeFile(join(planningDir, 'STATE.md'), stateContent, 'utf-8'); await writeFile(join(planningDir, 'ROADMAP.md'), '# Roadmap\n\n### Phase 10: Foo\n', 'utf-8'); await writeFile(join(planningDir, 'config.json'), '{}', 'utf-8'); const { stateRecordSession } = await import('./state-mutation.js'); await stateRecordSession(['--stopped-at', 'x', '--resume-file', 'None'], tmpDir); const after = await readFile(join(planningDir, 'STATE.md'), 'utf-8'); const { extractFrontmatter } = await import('./frontmatter.js'); const fm = extractFrontmatter(after); const progress = fm.progress as Record; // Disk ground truth — not the stale 99/99 from frontmatter. expect(Number(progress.total_plans)).toBe(1); expect(Number(progress.completed_plans)).toBe(1); expect(Number(progress.percent)).toBe(100); }); }); // ─── stateMilestoneSwitch (#2630) ────────────────────────────────────────── describe('stateMilestoneSwitch', () => { let tmpDir: string; beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'gsd-milestone-switch-')); }); afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }); }); it('writes milestone/milestone_name/status into STATE.md frontmatter and resets progress on milestone switch', async () => { // Previous milestone shipped: STATE.md frontmatter points at v1.0 with // non-zero progress. ROADMAP.md now advertises the NEW milestone v1.1. // Regardless of what getMilestoneInfo derives from the old STATE.md // frontmatter, a milestone switch must stomp the frontmatter with the new // version/name and reset progress counters. const stateContent = `--- gsd_state_version: 1.0 milestone: v1.0 milestone_name: Foundation status: completed progress: total_phases: 5 completed_phases: 5 total_plans: 12 completed_plans: 12 percent: 100 --- # Project State ## Current Position Phase: 5 (Foundation) — COMPLETED Plan: 3 of 3 Status: v1.0 milestone complete Last activity: 2026-04-20 -- v1.0 shipped ## Accumulated Context ### Decisions - [Phase 1]: Use Node 20 `; const planningDir = join(tmpDir, '.planning'); await mkdir(join(planningDir, 'phases'), { recursive: true }); await writeFile(join(planningDir, 'STATE.md'), stateContent, 'utf-8'); // ROADMAP advertises the new milestone await writeFile( join(planningDir, 'ROADMAP.md'), '# Roadmap\n\n## v1.1 Notifications\n\n### Phase 6: Notify\n', 'utf-8', ); await writeFile(join(planningDir, 'config.json'), '{}', 'utf-8'); const { stateMilestoneSwitch } = await import('./state-mutation.js'); const result = await stateMilestoneSwitch( ['--milestone', 'v1.1', '--name', 'Notifications'], tmpDir, ); const data = result.data as Record; expect(data.switched).toBe(true); expect(data.version).toBe('v1.1'); expect(data.name).toBe('Notifications'); const after = await readFile(join(planningDir, 'STATE.md'), 'utf-8'); const { extractFrontmatter } = await import('./frontmatter.js'); const fm = extractFrontmatter(after); // The heart of #2630 — frontmatter must reflect the NEW milestone. expect(fm.milestone).toBe('v1.1'); expect(fm.milestone_name).toBe('Notifications'); // Status resets to planning (Defining requirements phase). expect(fm.status).toBe('planning'); // Progress counters reset for the new milestone (no phases executed yet). const progress = fm.progress as Record | undefined; if (progress) { expect(Number(progress.completed_phases ?? 0)).toBe(0); expect(Number(progress.completed_plans ?? 0)).toBe(0); expect(Number(progress.percent ?? 0)).toBe(0); } // Accumulated Context is preserved across the milestone switch. expect(after).toContain('[Phase 1]: Use Node 20'); // Current Position body is reset to the new milestone's starting state. expect(after).toMatch(/Status:\s*Defining requirements/); }); it('rejects missing --milestone', async () => { await writeFile(join(tmpDir, '.planning', 'config.json'), '{}', 'utf-8').catch(async () => { await mkdir(join(tmpDir, '.planning'), { recursive: true }); await writeFile(join(tmpDir, '.planning', 'config.json'), '{}', 'utf-8'); }); const { stateMilestoneSwitch } = await import('./state-mutation.js'); const result = await stateMilestoneSwitch([], tmpDir); const data = result.data as Record; expect(data.error).toBeDefined(); }); }); describe('statePrune current phase extraction (#3471)', () => { let tmpDir: string; beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'gsd-sdk-stateprune-')); }); afterEach(async () => { if (tmpDir) await rm(tmpDir, { recursive: true, force: true }); }); it('reads Current Phase from body text (CJS-aligned); frontmatter progress fields are not used', async () => { // Phase 6 alignment: SDK now uses stateExtractField(content, 'Current Phase') // as the primary/only source, matching CJS state.cjs:1615. Frontmatter // progress.completed_phases is no longer consulted. // STATE.md below has progress.completed_phases:12 but no body "Current Phase:" // field → currentPhase = 0 → cutoff = -3 ≤ 0 → "Only 0 phases" (no-op). const stateContent = `--- gsd_state_version: 1.0 milestone: v1.1 status: executing progress: total_phases: 21 completed_phases: 12 total_plans: 79 completed_plans: 75 percent: 95 --- # Session State ## Current Position Phase 12 execution in progress. `; await setupTestProject(tmpDir, stateContent); const { statePrune } = await import('./state-mutation.js'); const result = await statePrune(['--keep-recent', '3', '--dry-run'], tmpDir); const data = result.data as Record; // No body "Current Phase:" field → defaults to 0 → cutoff ≤ 0 → early exit. expect(data.pruned).toBe(false); expect(typeof data.reason).toBe('string'); expect(String(data.reason)).toContain('Only 0 phases'); expect(data.dry_run).toBeUndefined(); expect(data.cutoff_phase).toBeUndefined(); }); it('returns a targeted reason when no current phase source can be parsed', async () => { // Phase 6 alignment: when no body "Current Phase:" field exists, currentPhase // defaults to 0 (like CJS `parseInt(...) || 0`). The reason message matches // CJS: "Only 0 phases — nothing to prune with --keep-recent N". const stateContent = `--- gsd_state_version: 1.0 milestone: v1.1 status: executing --- # Session State `; await setupTestProject(tmpDir, stateContent); const { statePrune } = await import('./state-mutation.js'); const result = await statePrune(['--keep-recent', '3', '--dry-run'], tmpDir); const data = result.data as Record; expect(data.pruned).toBe(false); expect(typeof data.reason).toBe('string'); // Matches CJS: "Only 0 phases — nothing to prune with --keep-recent 3" expect(String(data.reason)).toContain('Only 0 phases'); expect(String(data.reason)).toContain('nothing to prune'); }); });