Files
msd-core/sdk/src/query/state-mutation.test.ts
Rezolv c5b1445529 feat(sdk): golden parity harness and query handler CJS alignment (#2302 Track A) (#2341)
* feat(sdk): golden parity harness and query handler CJS alignment (#2302 Track A)

Golden/read-only parity tests and registry alignment, query handler fixes
(check-completion, state-mutation, commit, validate, summary, etc.), and
WAITING.json dual-write for .gsd/.planning readers.

Refs gsd-build/get-shit-done#2341

* fix(sdk): getMilestoneInfo matches GSD ROADMAP (🟡, last bold, STATE fallback)

- Recognize in-flight 🟡 milestone bullets like 🚧.
- Derive from last **vX.Y Title** before ## Phases when emoji absent.
- Fall back to STATE.md milestone when ROADMAP is missing; use last bare vX.Y
  in cleaned text instead of first (avoids v1.0 from shipped list).
- Fixes init.execute-phase milestone_version and buildStateFrontmatter after
  state.begin-phase (syncStateFrontmatter).

* feat(sdk): phase list, plan task structure, requirements extract handlers

- Register phase.list-plans, phase.list-artifacts, plan.task-structure,
  requirements.extract-from-plans (SDK-only; golden-policy exceptions).
- Add unit tests; document in QUERY-HANDLERS.md.
- writeProfile: honor --output, render dimensions, return profile_path and dimensions_scored.

* feat(sdk): centralize getGsdAgentsDir in query helpers

Extract agent directory resolution to helpers (GSD_AGENTS_DIR, primary
~/.claude/agents, legacy path). Use from init and docs-init init bundles.

docs(15): add 15-CONTEXT for autonomous phase-15 run.

* feat(sdk): query CLI CJS fallback and session correlation

- createRegistry(eventStream, sessionId) threads correlation into mutation events
- gsd-sdk query falls back to gsd-tools.cjs when no native handler matches
  (disable with GSD_QUERY_FALLBACK=off); stderr bridge warnings
- Export createRegistry from @gsd-build/sdk; add sdk/README.md
- Update QUERY-HANDLERS.md and registry module docs for fallback + sessionId
- Agents: prefer node dist/cli.js query over cat/grep for STATE and plans

* fix(sdk): init phase_found parity, docs-init agents path, state field extract

- Normalize findPhase not-found to null before roadmap fallback (matches findPhaseInternal)

- docs-init: use detectRuntime + resolveAgentsDir for checkAgentsInstalled

- state.cjs stateExtractField: horizontal whitespace only after colon (YAML progress guard)

- Tests: commit_docs default true; config-get golden uses temp config; golden integration green

Refs: #2302

* refactor(sdk): share SessionJsonlRecord in profile-extract-messages

CodeRabbit nit: dedupe JSONL record shape for isGenuineUserMessage and streamExtractMessages.

* fix(sdk): address CodeRabbit major threads (paths, gates, audit, verify)

- Resolve @file: and CLI JSON indirection relative to projectDir; guard empty normalized query command

- plan.task-structure + intel extract/patch-meta: resolvePathUnderProject containment

- check.config-gates: safe string booleans; plan_checker alias precedence over plan_check default

- state.validate/sync: phaseTokenMatches + comparePhaseNum ordering

- verify.schema-drift: token match phase dirs; files_modified from parsed frontmatter

- audit-open: has_scan_errors, unreadable rows, human report when scans fail

- requirements PLANNED key PLAN for root PLAN.md; gsd-tools timeout note

- ingest-docs: repo-root path containment; classifier output slug-hash

Golden parity test strips has_scan_errors until CJS adds field.

* fix: Resolve CodeRabbit security and quality findings
- Secure intel.ts and cli.ts against path traversal
- Catch and validate git add status in commit.ts
- Expand roadmap milestone marker extraction
- Fix parsing array-of-objects in frontmatter YAML
- Fix unhandled config evaluations
- Improve coverage test parity mapping

* test: raise planner character extraction limit to 48K

* fix(sdk): resolve TS build error in docs-init passing config
2026-04-20 18:09:02 -04:00

451 lines
16 KiB
TypeScript

/**
* 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<string> {
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<string, unknown>;
expect(data.updated).toBe(true);
// Verify round-trip
const loaded = await stateJson([], tmpDir);
const loadedData = loaded.data as Record<string, unknown>;
// 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<string, unknown>;
expect(data.updated).toBe(false);
});
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<string, unknown>;
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');
});
});
// ─── 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<string, unknown>;
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<string, unknown>;
// Must return the actual values, not the flag names
expect(data.phase).toBe('99');
expect(data.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<string, unknown>;
expect(data.phase).toBe('42');
expect(data.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('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<string, unknown>;
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');
});
});
// ─── 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<string, unknown>;
expect(data.advanced).toBe(true);
expect(data.current_plan).toBe(3);
});
});
// ─── 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<string, unknown>;
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.');
});
});
// ─── 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<string, unknown>;
expect(data.recorded).toBe(true);
const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
expect(content).toContain('Completed 11-01-PLAN.md');
});
});