Files
msd-core/sdk/src/query/decomposed-handlers.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

366 lines
15 KiB
TypeScript

/**
* Cross-module handler tests for code decomposed from the legacy `stubs.ts` module.
*
* Each suite imports real handlers from their domain modules and exercises behavior
* against temp fixtures (no standalone stubs).
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { agentSkills } from './skills.js';
import { roadmapUpdatePlanProgress } from './roadmap-update-plan-progress.js';
import { requirementsMarkComplete } from './roadmap.js';
import { statePlannedPhase } from './state-mutation.js';
import { verifySchemaDrift } from './verify.js';
import { todoMatchPhase, statsJson, progressBar } from './progress.js';
import { milestoneComplete } from './phase-lifecycle.js';
import { summaryExtract, historyDigest } from './summary.js';
import { commitToSubrepo } from './commit.js';
import {
workstreamList, workstreamCreate, workstreamSet,
workstreamStatus, workstreamComplete,
} from './workstream.js';
import { docsInit } from './docs-init.js';
import { websearch } from './websearch.js';
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-stubs-'));
await mkdir(join(tmpDir, '.planning', 'phases', '09-foundation'), { recursive: true });
await mkdir(join(tmpDir, '.planning', 'phases', '10-queries'), { recursive: true });
await writeFile(join(tmpDir, '.planning', 'config.json'), JSON.stringify({
model_profile: 'balanced',
commit_docs: false,
git: { branching_strategy: 'none' },
workflow: {},
}));
await writeFile(join(tmpDir, '.planning', 'STATE.md'), '---\nmilestone: v3.0\n---\n# State\n');
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), [
'# Roadmap',
'## v3.0: Test',
'### Phase 9: Foundation',
'**Goal:** Build it',
'- [ ] Plan 1',
'### Phase 10: Queries',
'**Goal:** Query it',
].join('\n'));
await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), [
'# Requirements',
'- [ ] REQ-01: First requirement',
'- [ ] REQ-02: Second requirement',
'- [x] REQ-03: Already done',
].join('\n'));
await writeFile(join(tmpDir, '.planning', 'phases', '09-foundation', '09-01-PLAN.md'), '---\nphase: 09\nplan: 01\ntype: execute\nmust_haves:\n truths: []\n---');
await writeFile(join(tmpDir, '.planning', 'phases', '09-foundation', '09-01-SUMMARY.md'), '# Done');
await writeFile(join(tmpDir, '.planning', 'phases', '10-queries', '10-01-PLAN.md'), '---\nphase: 10\nplan: 01\ntype: execute\nmust_haves:\n truths: []\n---');
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ─── skills.ts ───────────────────────────────────────────────────────────
describe('agentSkills', () => {
it('returns valid QueryResult with skills array', async () => {
const result = await agentSkills(['gsd-executor'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(Array.isArray(data.skills)).toBe(true);
expect(typeof data.skill_count).toBe('number');
expect(data.agent_type).toBe('gsd-executor');
});
});
// ─── roadmap.ts ──────────────────────────────────────────────────────────
describe('roadmapUpdatePlanProgress', () => {
it('returns QueryResult without error', async () => {
const result = await roadmapUpdatePlanProgress(['9'], tmpDir);
expect(result.data).toBeDefined();
const data = result.data as Record<string, unknown>;
expect(typeof data.updated).toBe('boolean');
});
it('throws when no phase arg', async () => {
await expect(roadmapUpdatePlanProgress([], tmpDir)).rejects.toThrow();
});
});
describe('requirementsMarkComplete', () => {
it('returns QueryResult without error', async () => {
const result = await requirementsMarkComplete(['REQ-01'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(typeof data.updated).toBe('boolean');
});
it('throws when no IDs provided', async () => {
await expect(requirementsMarkComplete([], tmpDir)).rejects.toThrow();
});
});
// ─── state-mutation.ts ───────────────────────────────────────────────────
describe('statePlannedPhase', () => {
it('returns cmdStatePlannedPhase-shaped data', async () => {
const result = await statePlannedPhase(['--phase', '10', '--name', 'queries', '--plans', '2'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(Array.isArray(data.updated)).toBe(true);
expect(data.phase).toBe('10');
expect(data.plan_count).toBe(2);
});
it('returns error when --phase is missing', async () => {
const result = await statePlannedPhase([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.error).toMatch(/phase required/);
});
});
// ─── verify.ts ───────────────────────────────────────────────────────────
describe('verifySchemaDrift', () => {
it('returns drift_detected shape (cmdVerifySchemaDrift parity)', async () => {
const result = await verifySchemaDrift(['9'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(typeof data.drift_detected).toBe('boolean');
expect(typeof data.blocking).toBe('boolean');
expect(Array.isArray(data.schema_files)).toBe(true);
});
});
// ─── progress.ts ─────────────────────────────────────────────────────────
describe('todoMatchPhase', () => {
it('returns matches and todo_count (cmdTodoMatchPhase parity)', async () => {
const result = await todoMatchPhase(['9'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(Array.isArray(data.matches)).toBe(true);
expect(data.phase).toBe('9');
expect(typeof data.todo_count).toBe('number');
});
});
describe('statsJson', () => {
it('returns cmdStats JSON shape with phases table and git fields', async () => {
const result = await statsJson([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(typeof data.milestone_version).toBe('string');
expect(Array.isArray(data.phases)).toBe(true);
expect(typeof data.phases_total).toBe('number');
expect(typeof data.total_plans).toBe('number');
expect(typeof data.percent).toBe('number');
expect((data.phases_total as number)).toBeGreaterThanOrEqual(2);
expect(typeof data.git_commits).toBe('number');
});
});
describe('progressBar', () => {
it('returns bar string and percent', async () => {
const result = await progressBar([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(typeof data.bar).toBe('string');
expect(typeof data.percent).toBe('number');
expect(data.bar as string).toContain('[');
});
});
// ─── summary.ts ──────────────────────────────────────────────────────────
describe('summaryExtract', () => {
it('returns error when file not found', async () => {
const result = await summaryExtract(['.planning/nonexistent.md'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.error).toBeDefined();
});
it('extracts frontmatter fields from an existing summary file', async () => {
const summaryPath = join(tmpDir, '.planning', 'phases', '09-foundation', '09-01-SUMMARY.md');
await writeFile(
summaryPath,
['---', 'phase: "09"', 'one-liner: Built it.', 'key-files:', ' - x.ts', '---', '', '# Summary', ''].join('\n'),
'utf-8',
);
const result = await summaryExtract(['.planning/phases/09-foundation/09-01-SUMMARY.md'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.one_liner).toBe('Built it.');
expect(data.key_files).toEqual(['x.ts']);
});
});
describe('historyDigest', () => {
it('returns phases object with completed summaries', async () => {
const result = await historyDigest([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(typeof data.phases).toBe('object');
expect(Array.isArray(data.decisions)).toBe(true);
expect(Array.isArray(data.tech_stack)).toBe(true);
});
});
// ─── workstream.ts ───────────────────────────────────────────────────────
describe('workstream handlers', () => {
it('workstreamList returns workstreams array', async () => {
const result = await workstreamList([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(Array.isArray(data.workstreams)).toBe(true);
});
it('workstreamCreate creates a directory', async () => {
const result = await workstreamCreate(['my-ws'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(typeof data.created).toBe('boolean');
});
it('workstreamCreate rejects path traversal', async () => {
const result = await workstreamCreate(['../../bad'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.created).toBe(false);
});
it('workstreamSet returns set=true for existing workstream', async () => {
await mkdir(join(tmpDir, '.planning', 'workstreams', 'backend'), { recursive: true });
const result = await workstreamSet(['backend'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.set).toBe(true);
expect(data.active).toBe('backend');
});
it('workstreamStatus returns found boolean', async () => {
const result = await workstreamStatus(['nonexistent'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(typeof data.found).toBe('boolean');
});
it('workstreamComplete archives existing workstream', async () => {
await mkdir(join(tmpDir, '.planning', 'workstreams', 'my-ws', 'phases'), { recursive: true });
await writeFile(join(tmpDir, '.planning', 'workstreams', 'my-ws', 'STATE.md'), '# State\n');
const result = await workstreamComplete(['my-ws'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.completed).toBe(true);
expect(data.archived_to).toBeDefined();
});
});
// ─── init.ts ─────────────────────────────────────────────────────────────
describe('docsInit', () => {
it('returns docs context matching gsd-tools docs-init', async () => {
const result = await docsInit([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(typeof data.planning_exists).toBe('boolean');
expect(data.project_root).toBe(tmpDir);
expect(typeof data.doc_writer_model).toBe('string');
expect(Array.isArray(data.existing_docs)).toBe(true);
expect(data.project_type).toBeDefined();
expect(data.doc_tooling).toBeDefined();
expect(Array.isArray(data.monorepo_workspaces)).toBe(true);
expect(typeof data.agents_installed).toBe('boolean');
expect(Array.isArray(data.missing_agents)).toBe(true);
});
});
// ─── websearch.ts ────────────────────────────────────────────────────────
describe('websearch', () => {
const originalEnv = process.env.BRAVE_API_KEY;
afterEach(() => {
if (originalEnv === undefined) {
delete process.env.BRAVE_API_KEY;
} else {
process.env.BRAVE_API_KEY = originalEnv;
}
vi.restoreAllMocks();
});
it('returns available:false when BRAVE_API_KEY is not set', async () => {
delete process.env.BRAVE_API_KEY;
const result = await websearch([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.available).toBe(false);
expect(data.reason).toBe('BRAVE_API_KEY not set');
});
it('returns error when query is empty', async () => {
process.env.BRAVE_API_KEY = 'test-key';
const result = await websearch([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.available).toBe(false);
expect(data.error).toBe('Query required');
});
it('returns results on successful API call', async () => {
process.env.BRAVE_API_KEY = 'test-key';
const mockResults = {
web: {
results: [
{ title: 'Result 1', url: 'https://example.com', description: 'Desc 1', age: '2d' },
{ title: 'Result 2', url: 'https://example.org', description: 'Desc 2' },
],
},
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
json: async () => mockResults,
} as Response);
const result = await websearch(['typescript generics'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.available).toBe(true);
expect(data.query).toBe('typescript generics');
expect(data.count).toBe(2);
const results = data.results as Array<Record<string, unknown>>;
expect(results[0].title).toBe('Result 1');
expect(results[0].age).toBe('2d');
expect(results[1].age).toBeNull();
});
it('passes --limit and --freshness params to API', async () => {
process.env.BRAVE_API_KEY = 'test-key';
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ web: { results: [] } }),
} as Response);
await websearch(['query', '--limit', '5', '--freshness', 'week'], tmpDir);
const url = new URL((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0] as string);
expect(url.searchParams.get('count')).toBe('5');
expect(url.searchParams.get('freshness')).toBe('week');
});
it('returns error on non-ok response', async () => {
process.env.BRAVE_API_KEY = 'test-key';
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: false,
status: 429,
} as Response);
const result = await websearch(['rate limited query'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.available).toBe(false);
expect(data.error).toBe('API error: 429');
});
it('returns error on network failure', async () => {
process.env.BRAVE_API_KEY = 'test-key';
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('ECONNREFUSED'));
const result = await websearch(['network fail'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.available).toBe(false);
expect(data.error).toBe('ECONNREFUSED');
});
});