* 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
366 lines
15 KiB
TypeScript
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');
|
|
});
|
|
});
|