Files
msd-core/sdk/src/query/state.test.ts
Tom Boucher bdba40cc3d fix(#2618): thread --ws through query dispatch and sync root STATE.md on workstream.set (#2627)
* fix(#2618): thread --ws through query dispatch for state and init handlers

Gap 1 of #2618: the query dispatcher already accepts a workstream via
registry.dispatch(cmd, args, projectDir, ws), but several handlers drop it
before reaching planningPaths() / getMilestoneInfo() / findPhase() — so
stateJson and the init.* handlers return root-scoped results even when --ws
is provided.

Changes:

- sdk/src/query/state.ts: forward workstream into getMilestoneInfo() and
  extractCurrentMilestone() so buildStateFrontmatter resolves milestone data
  from the workstream ROADMAP/STATE instead of the root mirror.
- sdk/src/query/init.ts: thread workstream through initExecutePhase,
  initPlanPhase, initPhaseOp, and getPhaseInfoWithFallback (which fans out
  to findPhase() and roadmapGetPhase()). Also switch hardcoded
  join(projectDir, '.planning') to relPlanningPath(workstream) so returned
  state_path/roadmap_path/config_path reflect the workstream layout.

Regression test: stateJson with --ws workstream reads STATE.md from
.planning/workstreams/<name>/ when workstream is provided.

Closes #2618 (gap 1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#2618): sync root .planning/STATE.md mirror on workstream.set

Gap 2 of #2618: setActiveWorkstream only flips the active-workstream
pointer file; the root .planning/STATE.md mirror stays stale. Downstream
consumers (statusline, gsd-sdk query progress, any tool that reads the
root STATE.md) continue to see the previous workstream's state.

After setActiveWorkstream(), copy .planning/workstreams/<name>/STATE.md
verbatim to .planning/STATE.md via writeFileSync. The workstream STATE.md
is authoritative; the root file is a pass-through mirror. Missing source
STATE.md is a no-op rather than an error — a freshly created workstream
with no STATE.md yet should still activate cleanly.

The response now includes `mirror_synced: boolean` so callers can
observe whether the root mirror was updated.

Regression test: workstreamSet root STATE.md mirror sync — switches
from a stale root mirror to a workstream STATE.md with different
frontmatter and asserts the root file now matches.

Closes #2618 (gap 2)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:54:34 -04:00

382 lines
13 KiB
TypeScript

/**
* Unit tests for state query handlers.
*
* Tests stateJson, stateGet, and stateSnapshot handlers.
* Uses temp directories with real .planning/ structures.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
// Will be imported once implemented
import { stateJson, stateGet, stateSnapshot } from './state.js';
// ─── Fixtures ──────────────────────────────────────────────────────────────
const STATE_BODY = `# Project State
## Project Reference
See: .planning/PROJECT.md (updated 2026-04-07)
**Core value:** Improve the project.
**Current focus:** Phase 10
## Current Position
Phase: 10 (Read-Only Queries) — EXECUTING
Plan: 2 of 3
Status: Ready to execute
Last Activity: 2026-04-08
Last Activity Description: Completed plan 01
Progress: [████░░░░░░] 40%
## Decisions Made
Recent decisions affecting current work:
| Phase | Summary | Rationale |
|-------|---------|-----------|
| 09 | Used GSDError pattern | Consistent with existing SDK errors |
| 10 | Temp dir test pattern | ESM spy limitations |
## Blockers
- STATE.md parsing edge cases need audit
- Verification rule inventory needs review
## Session
Last session: 2026-04-08T05:00:00Z
Stopped At: Completed 10-01-PLAN.md
Resume File: None
`;
const STATE_WITH_FRONTMATTER = `---
gsd_state_version: 1.0
milestone: v3.0
milestone_name: SDK-First Migration
status: executing
stopped_at: Completed 10-01-PLAN.md
last_updated: "2026-04-08T05:01:21.919Z"
---
${STATE_BODY}`;
const ROADMAP_CONTENT = `# Roadmap
## Roadmap v3.0: SDK-First Migration
### Phase 09: Foundation
- Build infrastructure
### Phase 10: Read-Only Queries
- Port state queries
### Phase 11: Mutations
- Port write operations
`;
let tmpDir: string;
// ─── Setup / Teardown ──────────────────────────────────────────────────────
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-state-test-'));
const planningDir = join(tmpDir, '.planning');
const phasesDir = join(planningDir, 'phases');
// Create .planning structure
await mkdir(phasesDir, { recursive: true });
// Create STATE.md with frontmatter
await writeFile(join(planningDir, 'STATE.md'), STATE_WITH_FRONTMATTER);
// Create ROADMAP.md
await writeFile(join(planningDir, 'ROADMAP.md'), ROADMAP_CONTENT);
// Create config.json
await writeFile(join(planningDir, 'config.json'), JSON.stringify({
model_profile: 'quality',
workflow: { auto_advance: true },
}));
// Create phase directories with plans and summaries
const phase09 = join(phasesDir, '09-foundation');
await mkdir(phase09, { recursive: true });
await writeFile(join(phase09, '09-01-PLAN.md'), '---\nphase: 09\nplan: 01\n---\nPlan 1');
await writeFile(join(phase09, '09-01-SUMMARY.md'), 'Summary 1');
await writeFile(join(phase09, '09-02-PLAN.md'), '---\nphase: 09\nplan: 02\n---\nPlan 2');
await writeFile(join(phase09, '09-02-SUMMARY.md'), 'Summary 2');
await writeFile(join(phase09, '09-03-PLAN.md'), '---\nphase: 09\nplan: 03\n---\nPlan 3');
await writeFile(join(phase09, '09-03-SUMMARY.md'), 'Summary 3');
const phase10 = join(phasesDir, '10-read-only-queries');
await mkdir(phase10, { recursive: true });
await writeFile(join(phase10, '10-01-PLAN.md'), '---\nphase: 10\nplan: 01\n---\nPlan 1');
await writeFile(join(phase10, '10-01-SUMMARY.md'), 'Summary 1');
await writeFile(join(phase10, '10-02-PLAN.md'), '---\nphase: 10\nplan: 02\n---\nPlan 2');
await writeFile(join(phase10, '10-03-PLAN.md'), '---\nphase: 10\nplan: 03\n---\nPlan 3');
const phase11 = join(phasesDir, '11-mutations');
await mkdir(phase11, { recursive: true });
await writeFile(join(phase11, '11-01-PLAN.md'), '---\nphase: 11\nplan: 01\n---\nPlan 1');
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ─── stateJson (state json / state.json) ───────────────────────────────────
describe('stateJson', () => {
it('rebuilds frontmatter from body + disk', async () => {
const result = await stateJson([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.gsd_state_version).toBe('1.0');
expect(data.milestone).toBe('v3.0');
expect(data.milestone_name).toBe('SDK-First Migration');
expect(data.status).toBe('executing');
expect(data.last_updated).toBeDefined();
});
it('returns progress with disk-scanned counts', async () => {
const result = await stateJson([], tmpDir);
const data = result.data as Record<string, unknown>;
const progress = data.progress as Record<string, unknown>;
// 3 phases in roadmap (09, 10, 11), 7 total plans, 4 summaries
expect(progress.total_phases).toBe(3);
expect(progress.total_plans).toBe(7);
expect(progress.completed_plans).toBe(4);
// Phase 09 complete (3/3), phase 10 incomplete (1/3), phase 11 incomplete (0/1)
expect(progress.completed_phases).toBe(1);
// 4/7 = 57%
expect(progress.percent).toBe(57);
});
it('preserves stopped_at from existing frontmatter', async () => {
const result = await stateJson([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.stopped_at).toBe('Completed 10-01-PLAN.md');
});
it('preserves existing non-unknown status when body-derived is unknown', async () => {
// Create STATE.md with frontmatter status but no Status in body
const stateContent = `---
gsd_state_version: 1.0
status: paused
---
# Project State
Phase: 10
Plan: 2 of 3
`;
await writeFile(join(tmpDir, '.planning', 'STATE.md'), stateContent);
const result = await stateJson([], tmpDir);
const data = result.data as Record<string, unknown>;
// Body has no Status field -> derived is 'unknown', should preserve frontmatter 'paused'
expect(data.status).toBe('paused');
});
it('returns error object when STATE.md not found', async () => {
const emptyDir = await mkdtemp(join(tmpdir(), 'gsd-state-empty-'));
await mkdir(join(emptyDir, '.planning'), { recursive: true });
const result = await stateJson([], emptyDir);
const data = result.data as Record<string, unknown>;
expect(data.error).toBe('STATE.md not found');
await rm(emptyDir, { recursive: true, force: true });
});
it('normalizes status to known values', async () => {
const stateContent = `---
gsd_state_version: 1.0
---
# Project State
Status: In Progress
`;
await writeFile(join(tmpDir, '.planning', 'STATE.md'), stateContent);
const result = await stateJson([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.status).toBe('executing');
});
it('derives percent from disk counts (ground truth)', async () => {
// Body says 0% but disk has 4/7 summaries
const stateContent = `---
gsd_state_version: 1.0
---
# Project State
Status: Ready to execute
Progress: [░░░░░░░░░░] 0%
`;
await writeFile(join(tmpDir, '.planning', 'STATE.md'), stateContent);
const result = await stateJson([], tmpDir);
const data = result.data as Record<string, unknown>;
const progress = data.progress as Record<string, unknown>;
// Disk should override the body's 0%
expect(progress.percent).toBe(57);
});
});
// ─── stateGet ──────────────────────────────────────────────────────────────
describe('stateGet', () => {
it('returns full content when no field specified', async () => {
const result = await stateGet([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.content).toBeDefined();
expect(typeof data.content).toBe('string');
expect((data.content as string)).toContain('# Project State');
});
it('extracts bold-format field', async () => {
const result = await stateGet(['Core value'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data['Core value']).toBe('Improve the project.');
});
it('extracts plain-format field', async () => {
const result = await stateGet(['Plan'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data['Plan']).toBe('2 of 3');
});
it('extracts section content under ## heading', async () => {
const result = await stateGet(['Current Position'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data['Current Position']).toBeDefined();
expect((data['Current Position'] as string)).toContain('Phase: 10');
});
it('returns error for missing field', async () => {
const result = await stateGet(['Nonexistent Field'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.error).toBe('Section or field "Nonexistent Field" not found');
});
});
// ─── stateSnapshot ─────────────────────────────────────────────────────────
describe('stateSnapshot', () => {
it('returns structured snapshot', async () => {
const result = await stateSnapshot([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.current_phase).toBeDefined();
// Status field in body is "Ready to execute" but frontmatter has "executing"
// stateSnapshot reads full content and matches "status: executing" from frontmatter first
expect(data.status).toBeDefined();
});
it('parses decisions table into array', async () => {
const result = await stateSnapshot([], tmpDir);
const data = result.data as Record<string, unknown>;
const decisions = data.decisions as Array<Record<string, string>>;
expect(Array.isArray(decisions)).toBe(true);
expect(decisions.length).toBe(2);
expect(decisions[0].phase).toBe('09');
expect(decisions[0].summary).toBe('Used GSDError pattern');
expect(decisions[0].rationale).toBe('Consistent with existing SDK errors');
});
it('parses blockers list', async () => {
const result = await stateSnapshot([], tmpDir);
const data = result.data as Record<string, unknown>;
const blockers = data.blockers as string[];
expect(Array.isArray(blockers)).toBe(true);
expect(blockers.length).toBe(2);
expect(blockers[0]).toContain('STATE.md parsing edge cases');
});
it('parses session info', async () => {
const result = await stateSnapshot([], tmpDir);
const data = result.data as Record<string, unknown>;
const session = data.session as Record<string, string | null>;
expect(session).toBeDefined();
expect(session.stopped_at).toBe('Completed 10-01-PLAN.md');
});
it('returns error when STATE.md not found', async () => {
const emptyDir = await mkdtemp(join(tmpdir(), 'gsd-snap-empty-'));
await mkdir(join(emptyDir, '.planning'), { recursive: true });
const result = await stateSnapshot([], emptyDir);
const data = result.data as Record<string, unknown>;
expect(data.error).toBe('STATE.md not found');
await rm(emptyDir, { recursive: true, force: true });
});
it('returns numeric fields as numbers', async () => {
const result = await stateSnapshot([], tmpDir);
const data = result.data as Record<string, unknown>;
// progress_percent may be null if no Progress: N% format found
// but total_phases etc. should be numbers when present
if (data.total_phases !== null) {
expect(typeof data.total_phases).toBe('number');
}
});
});
// ─── Regression: --ws propagation (#2618 gap 1) ────────────────────────────
describe('stateJson with --ws workstream', () => {
it('reads STATE.md from .planning/workstreams/<name>/ when workstream is provided', async () => {
// Build a workstream-scoped layout alongside the default .planning/STATE.md
const wsName = 'example-ws';
const wsDir = join(tmpDir, '.planning', 'workstreams', wsName);
await mkdir(join(wsDir, 'phases'), { recursive: true });
const wsState = `---
gsd_state_version: 1.0
milestone: ws-1.0
milestone_name: Workstream Marker
status: planning
---
# Project State
Status: planning
`;
await writeFile(join(wsDir, 'STATE.md'), wsState);
await writeFile(join(wsDir, 'ROADMAP.md'), '# Roadmap\n');
// Root STATE.md still has the old values (SDK-First Migration).
// When --ws is threaded, stateJson must read the workstream STATE.md, not the root.
const result = await stateJson([], tmpDir, wsName);
const data = result.data as Record<string, unknown>;
expect(data.milestone).toBe('ws-1.0');
expect(data.milestone_name).toBe('Workstream Marker');
expect(data.status).toBe('planning');
});
});