Files
msd-core/sdk/src/gsd-tools.test.ts
Tom Boucher 5975f06b6a refactor(query): extract command catalog seam for registry wiring (#3060)
* refactor(sdk): extract gsdtools transport seam with per-command policy

* refactor(query): centralize registry command catalog wiring

* refactor(query): unify command resolution seam across sdk callers

* fix(sdk): address CodeRabbit transport policy and timeout findings

* refactor(query): extract mutation event mapper seam

* refactor(query): converge mutation and transport policy data

* refactor(query): share fallback orchestration across cli and sdk

* refactor(query): split static registry catalog by domain clusters

* refactor(query): extract mutation event emission decorator seam

* refactor(query): extract alias-family handler catalog module

* refactor(query): extract cjs fallback execution adapter

* refactor(query): deepen command semantics seam

* refactor(query): extract deep dispatch seam

* refactor(query): deepen cjs fallback execution seam

* refactor(query): merge routing plan into dispatch seam

* fix(query): address CodeRabbit review findings on PR #3060

Critical: prevent double-execution race by checking timeout errors
before subprocess fallback (gsd-transport.ts).

Major: fix execRaw() to respect transport policy outputMode instead
of hardcoding 'raw' (gsd-tools.ts).

Major: add explicit 30s timeout to subprocess fallback execution
(query-fallback-executor.ts).

Major: remove raw args from stderr banner to prevent secret leakage
(query-fallback-executor.ts).

Minor: ensure native text output has trailing newline for CLI parity
(query-dispatch.ts).

Update gsd-tools.test.ts to match new execRaw() behavior.

* fix(tests): update CLI integration tests for catalog-based registration

The refactoring moved handler registration from inline registry.register()
calls to catalog-based registration (registerStaticCatalog/registerAliasCatalog).

- gsd-sdk-query-registry-integration.test.cjs: collectRegisteredNames() now
  also scans catalog files for handler names registered via the new system.
- bug-2492-context-coverage-gate.test.cjs: checks for catalog-based
  registration (DECISION_ROUTING_STATIC_CATALOG) instead of inline strings.
- bug-2524-sdk-query-ws-flag.test.cjs: checks for dispatchNative callback
  pattern instead of direct registry.dispatch() call.

* fix(query): address remaining CodeRabbit review findings

- query-command-semantics.ts: guard stats/progress rewrite so option
  tokens (e.g. --pick) are not turned into subcommands, preserving the
  top-level handler dispatch.

- query-dispatch.ts: formatOutput now skips --pick for text-format
  responses (matching CJS fallback behavior) and surfaces a proper error
  when extractField returns undefined instead of silently producing
  'undefined'.

- query-dispatch.ts: fix backwards error message — 'registered' is the
  restrictive policy that disables fallback, not enables it.

- tests/bug-2492-context-coverage-gate.test.cjs: check
  VERIFY_DECISION_STATIC_CATALOG (the correct catalog for plan-gate
  handlers) instead of DECISION_ROUTING_STATIC_CATALOG.

- tests/gsd-sdk-query-registry-integration.test.cjs: resolve catalog
  variable before loading entries so the drift guard checks each
  referenced catalog individually.

* refactor(query): deepen registry assembly module with strict invariants

- extract registry assembly into dedicated module
- split build vs mutation decoration internals
- add strict assembly invariants:
  1) no duplicate keys
  2) alias canonicals must have handlers
  3) mutation commands must be registered
  4) raw-output policy commands must be registered
- slim query index to thin re-export seam
- add focused registry assembly tests
- update drift-guard tests to target new seam

* test(query): add thin-seam coverage for query index re-exports

* fix(query): return structured native dispatch errors + tighten decisions.parse guard

- runQueryDispatch native path now catches adapter errors and returns
  QueryDispatchResult.error instead of throwing.
- preserve legacy CLI exit contract by using code=1 for native dispatch
  failures.
- strengthen bug-2492 guard: decisions.parse assertion now checks
  VERIFY_DECISION_STATIC_CATALOG OR explicit command token.
2026-05-03 13:57:32 -04:00

447 lines
16 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { GSDTools, GSDToolsError, resolveGsdToolsPath } from './gsd-tools.js';
import { setTransportPolicy, clearTransportPolicy } from './gsd-transport-policy.js';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir, homedir } from 'node:os';
import { fileURLToPath } from 'node:url';
const BUNDLED_GSD_TOOLS_PATH = fileURLToPath(
new URL('../../get-shit-done/bin/gsd-tools.cjs', import.meta.url),
);
describe('GSDTools', () => {
let tmpDir: string;
let fixtureDir: string;
beforeEach(async () => {
tmpDir = join(tmpdir(), `gsd-tools-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
fixtureDir = join(tmpDir, 'fixtures');
await mkdir(fixtureDir, { recursive: true });
await mkdir(join(tmpDir, '.planning'), { recursive: true });
});
afterEach(async () => {
clearTransportPolicy();
await rm(tmpDir, { recursive: true, force: true });
});
// ─── Helper: create a Node script that outputs something ────────────────
async function createScript(name: string, code: string): Promise<string> {
const scriptPath = join(fixtureDir, name);
await writeFile(scriptPath, code, { mode: 0o755 });
return scriptPath;
}
// ─── exec() tests ──────────────────────────────────────────────────────
describe('exec()', () => {
it('parses valid JSON output', async () => {
// Create a script that ignores args and outputs JSON
const scriptPath = await createScript(
'echo-json.cjs',
`process.stdout.write(JSON.stringify({ status: "ok", count: 42 }));`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
const result = await tools.exec('state', ['load']);
expect(result).toEqual({ status: 'ok', count: 42 });
});
it('handles @file: prefix by reading referenced file', async () => {
// Write a large JSON result to a file
const resultFile = join(fixtureDir, 'big-result.json');
const bigData = { items: Array.from({ length: 100 }, (_, i) => ({ id: i })) };
await writeFile(resultFile, JSON.stringify(bigData));
// Script outputs @file: prefix
const scriptPath = await createScript(
'file-ref.cjs',
`process.stdout.write('@file:${resultFile.replace(/\\/g, '\\\\')}');`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
const result = await tools.exec('state', ['load']);
expect(result).toEqual(bigData);
});
it('returns null for empty stdout', async () => {
const scriptPath = await createScript(
'empty-output.cjs',
`// outputs nothing`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
const result = await tools.exec('state', ['load']);
expect(result).toBeNull();
});
it('throws GSDToolsError on non-zero exit code', async () => {
const scriptPath = await createScript(
'fail.cjs',
`process.stderr.write('something went wrong\\n'); process.exit(1);`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
try {
await tools.exec('state', ['load']);
expect.fail('Should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(GSDToolsError);
const gsdErr = err as GSDToolsError;
expect(gsdErr.command).toBe('state');
expect(gsdErr.args).toEqual(['load']);
expect(gsdErr.stderr).toContain('something went wrong');
expect(gsdErr.exitCode).toBeGreaterThan(0);
}
});
it('throws GSDToolsError with context when gsd-tools.cjs not found', async () => {
const tools = new GSDTools({
projectDir: tmpDir,
gsdToolsPath: '/nonexistent/path/gsd-tools.cjs',
preferNativeQuery: false,
});
await expect(tools.exec('state', ['load'])).rejects.toThrow(GSDToolsError);
});
it('throws parse error when stdout is non-JSON', async () => {
const scriptPath = await createScript(
'bad-json.cjs',
`process.stdout.write('Not JSON at all');`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
try {
await tools.exec('state', ['load']);
expect.fail('Should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(GSDToolsError);
const gsdErr = err as GSDToolsError;
expect(gsdErr.message).toContain('Failed to parse');
expect(gsdErr.message).toContain('Not JSON at all');
}
});
it('throws when @file: points to nonexistent file', async () => {
const scriptPath = await createScript(
'bad-file-ref.cjs',
`process.stdout.write('@file:/tmp/does-not-exist-${Date.now()}.json');`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
await expect(tools.exec('state', ['load'])).rejects.toThrow(GSDToolsError);
});
it('handles timeout by killing child process', async () => {
const scriptPath = await createScript(
'hang.cjs',
`setTimeout(() => {}, 60000); // hang for 60s`,
);
const tools = new GSDTools({
projectDir: tmpDir,
gsdToolsPath: scriptPath,
timeoutMs: 500,
preferNativeQuery: false,
});
try {
await tools.exec('state', ['load']);
expect.fail('Should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(GSDToolsError);
const gsdErr = err as GSDToolsError;
expect(gsdErr.message).toContain('timed out');
}
}, 10_000);
it('uses subprocess fallback when native handler throws and policy allows fallback', async () => {
const scriptPath = await createScript(
'fallback-ok.cjs',
`process.stdout.write(JSON.stringify({ from: 'subprocess-fallback' }));`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath });
setTransportPolicy('verify.path-exists', { allowFallbackToSubprocess: true });
const result = await tools.exec('verify.path-exists', []);
expect(result).toEqual({ from: 'subprocess-fallback' });
});
it('preserves GSDToolsError contract when native handler throws and fallback disabled', async () => {
const scriptPath = await createScript(
'should-not-run.cjs',
`process.stdout.write(JSON.stringify({ should: 'not-run' }));`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath });
setTransportPolicy('verify.path-exists', { allowFallbackToSubprocess: false });
try {
await tools.exec('verify.path-exists', []);
expect.fail('Should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(GSDToolsError);
const gsdErr = err as GSDToolsError;
expect(gsdErr.command).toBe('verify.path-exists');
expect(gsdErr.args).toEqual([]);
expect(gsdErr.stderr).toBe('');
expect(typeof gsdErr.exitCode === 'number').toBe(true);
}
});
});
// ─── Typed method tests ────────────────────────────────────────────────
describe('typed methods', () => {
it('stateLoad() calls exec with correct args', async () => {
const scriptPath = await createScript(
'state-load.cjs',
`
const args = process.argv.slice(2);
// Script receives: state load (no --raw when policy is json)
if (args[0] === 'state' && args[1] === 'load') {
process.stdout.write(JSON.stringify({ phase: '3', status: 'executing' }));
} else {
process.stderr.write('unexpected args: ' + args.join(' '));
process.exit(1);
}
`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
const result = await tools.stateLoad();
expect(result).toEqual({ phase: '3', status: 'executing' });
});
it('commit() passes message and optional files', async () => {
const scriptPath = await createScript(
'commit.cjs',
`
const args = process.argv.slice(2);
// commit <msg> --files f1 f2 --raw — returns a git SHA
process.stdout.write('f89ae07');
`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
const result = await tools.commit('test message', ['file1.md', 'file2.md']);
expect(result).toBe('f89ae07');
});
it('roadmapAnalyze() calls roadmap analyze', async () => {
const scriptPath = await createScript(
'roadmap.cjs',
`
const args = process.argv.slice(2);
if (args[0] === 'roadmap' && args[1] === 'analyze') {
process.stdout.write(JSON.stringify({ phases: [] }));
} else {
process.exit(1);
}
`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
const result = await tools.roadmapAnalyze();
expect(result).toEqual({ phases: [] });
});
it('verifySummary() passes path argument', async () => {
const scriptPath = await createScript(
'verify.cjs',
`
const args = process.argv.slice(2);
if (args[0] === 'verify-summary' && args[1] === '/path/to/SUMMARY.md') {
process.stdout.write('passed');
} else {
process.exit(1);
}
`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
const result = await tools.verifySummary('/path/to/SUMMARY.md');
expect(result).toBe('passed');
});
});
// ─── Integration-style test ────────────────────────────────────────────
describe('integration', () => {
it('handles large JSON output (>100KB)', async () => {
const largeArray = Array.from({ length: 5000 }, (_, i) => ({
id: i,
name: `item-${i}`,
data: 'x'.repeat(20),
}));
const largeJson = JSON.stringify(largeArray);
const scriptPath = await createScript(
'large-output.cjs',
`process.stdout.write(${JSON.stringify(largeJson)});`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
const result = await tools.exec('state', ['load']);
expect(Array.isArray(result)).toBe(true);
expect((result as unknown[]).length).toBe(5000);
});
});
// ─── initNewProject() tests ────────────────────────────────────────────
describe('initNewProject()', () => {
it('calls init new-project and returns typed result', async () => {
const mockResult = {
researcher_model: 'claude-sonnet-4-6',
synthesizer_model: 'claude-sonnet-4-6',
roadmapper_model: 'claude-sonnet-4-6',
commit_docs: true,
project_exists: false,
has_codebase_map: false,
planning_exists: false,
has_existing_code: false,
has_package_file: false,
is_brownfield: false,
needs_codebase_map: false,
has_git: true,
brave_search_available: false,
firecrawl_available: false,
exa_search_available: false,
project_path: '.planning/PROJECT.md',
project_root: '/tmp/test',
};
const scriptPath = await createScript(
'init-new-project.cjs',
`
const args = process.argv.slice(2);
if (args[0] === 'init' && args[1] === 'new-project') {
process.stdout.write(JSON.stringify(${JSON.stringify(mockResult)}));
} else {
process.stderr.write('unexpected args: ' + args.join(' '));
process.exit(1);
}
`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
const result = await tools.initNewProject();
expect(result.researcher_model).toBe('claude-sonnet-4-6');
expect(result.project_exists).toBe(false);
expect(result.has_git).toBe(true);
expect(result.is_brownfield).toBe(false);
expect(result.project_path).toBe('.planning/PROJECT.md');
});
it('propagates errors from gsd-tools', async () => {
const scriptPath = await createScript(
'init-fail.cjs',
`process.stderr.write('init failed\\n'); process.exit(1);`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
await expect(tools.initNewProject()).rejects.toThrow(GSDToolsError);
});
});
// ─── resolveGsdToolsPath() tests ────────────────────────────────────────
describe('resolveGsdToolsPath()', () => {
it('prefers bundled gsd-tools over project .claude when the bundled file exists', async () => {
const localBinDir = join(tmpDir, '.claude', 'get-shit-done', 'bin');
await mkdir(localBinDir, { recursive: true });
await writeFile(join(localBinDir, 'gsd-tools.cjs'), '// stub');
const result = resolveGsdToolsPath(tmpDir);
if (existsSync(BUNDLED_GSD_TOOLS_PATH)) {
expect(result).toBe(BUNDLED_GSD_TOOLS_PATH);
} else {
expect(result).toBe(join(localBinDir, 'gsd-tools.cjs'));
}
});
it('falls back to bundled repo path when repo-local does not exist', () => {
const result = resolveGsdToolsPath(tmpDir);
const expected = existsSync(BUNDLED_GSD_TOOLS_PATH)
? BUNDLED_GSD_TOOLS_PATH
: join(homedir(), '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs');
expect(result).toBe(expected);
});
it('uses explicit gsdToolsPath when provided (overrides bundled / .claude resolution)', async () => {
const localBinDir = join(tmpDir, '.claude', 'get-shit-done', 'bin');
await mkdir(localBinDir, { recursive: true });
const scriptPath = join(localBinDir, 'gsd-tools.cjs');
await writeFile(
scriptPath,
`process.stdout.write(JSON.stringify({ source: "local" }));`,
{ mode: 0o755 },
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
const result = await tools.exec('test', []);
expect(result).toEqual({ source: 'local' });
});
});
// ─── configSet() tests ─────────────────────────────────────────────────
describe('configSet()', () => {
it('calls config-set with key and value args', async () => {
const scriptPath = await createScript(
'config-set.cjs',
`
const args = process.argv.slice(2);
if (args[0] === 'config-set' && args[1] === 'workflow.auto_advance' && args[2] === 'true' && args.includes('--raw')) {
process.stdout.write('workflow.auto_advance=true');
} else {
process.stderr.write('unexpected args: ' + args.join(' '));
process.exit(1);
}
`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
const result = await tools.configSet('workflow.auto_advance', 'true');
expect(result).toBe('workflow.auto_advance=true');
});
it('passes string values without coercion', async () => {
const scriptPath = await createScript(
'config-set-str.cjs',
`
const args = process.argv.slice(2);
// config-set mode yolo --raw
process.stdout.write(args[1] + '=' + args[2]);
`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
const result = await tools.configSet('mode', 'yolo');
expect(result).toBe('mode=yolo');
});
});
});