* 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.
447 lines
16 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|