GitNexus/gitnexus/src/cli/tool.ts
林 駿甫 (Shunsuke Hayashi) b48cfe9894
fix(impact): return structured error + partial results instead of crashing (#321) (#345)
* fix(impact): return structured error + partial results instead of crashing (#321)

- Wrap impact() in try-catch to return structured error JSON instead of
  process crash (SIGSEGV/exit 139)
- Extract core logic to _impactImpl() for clean error boundary
- Break out of depth traversal loop on query failure, return partial
  results collected so far (previously silently swallowed errors)
- Add 'partial' flag to response when traversal was interrupted
- Add try-catch in CLI impactCommand with structured error output
- Improve formatImpactResult to show suggestion text and partial warning
- Add 3 new unit tests for error/suggestion/partial scenarios

Fixes #321

* fix: address review feedback — 4 bugs from @claude review

Per @claude's review (requested by @magyargergo):

- [BUG 1] Consistent target field shape: error responses now return
  {name: string} instead of raw string, matching success response schema
- [BUG 2] Remove misleading partial:true from total-failure responses
  (partial is only meaningful when some depth levels succeeded)
- [BUG 3] Move getBackend() inside try-catch in impactCommand so
  backend init failures return structured JSON instead of crashing
- [BUG 4] Safe error message extraction: use instanceof Error check
  to handle thrown strings correctly (err?.message is undefined for
  non-Error thrown values)
- [MINOR] Add radix argument to parseInt (10)

* test: add integration tests for impact error handling (#321)

Per @claude's recommendation (requested by @magyargergo):

- impact: structured error for unknown symbol (no crash)
- impact: error response has consistent {name: string} target shape
- impact: partial:true only set when some results were collected

Tests use existing withTestLbugDB + seeded graph fixture.
2026-03-18 06:45:43 +00:00

153 lines
4.6 KiB
TypeScript

/**
* Direct CLI Tool Commands
*
* Exposes GitNexus tools (query, context, impact, cypher) as direct CLI commands.
* Bypasses MCP entirely — invokes LocalBackend directly for minimal overhead.
*
* Usage:
* gitnexus query "authentication flow"
* gitnexus context --name "validateUser"
* gitnexus impact --target "AuthService" --direction upstream
* gitnexus cypher "MATCH (n:Function) RETURN n.name LIMIT 10"
*
* Note: Output goes to stdout via fs.writeSync(fd 1), bypassing LadybugDB's
* native module which captures the Node.js process.stdout stream during init.
* See the output() function for details (#324).
*/
import { writeSync } from 'node:fs';
import { LocalBackend } from '../mcp/local/local-backend.js';
let _backend: LocalBackend | null = null;
async function getBackend(): Promise<LocalBackend> {
if (_backend) return _backend;
_backend = new LocalBackend();
const ok = await _backend.init();
if (!ok) {
console.error('GitNexus: No indexed repositories found. Run: gitnexus analyze');
process.exit(1);
}
return _backend;
}
/**
* Write tool output to stdout using low-level fd write.
*
* LadybugDB's native module captures Node.js process.stdout during init,
* but the underlying OS file descriptor 1 (stdout) remains intact.
* By using fs.writeSync(1, ...) we bypass the Node.js stream layer
* and write directly to the real stdout fd (#324).
*
* Falls back to stderr if the fd write fails (e.g., broken pipe).
*/
function output(data: any): void {
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
try {
writeSync(1, text + '\n');
} catch (err: any) {
if (err?.code === 'EPIPE') {
// Consumer closed the pipe (e.g., `gitnexus cypher ... | head -1`)
// Exit cleanly per Unix convention
process.exit(0);
}
// Fallback: stderr (previous behavior, works on all platforms)
process.stderr.write(text + '\n');
}
}
export async function queryCommand(queryText: string, options?: {
repo?: string;
context?: string;
goal?: string;
limit?: string;
content?: boolean;
}): Promise<void> {
if (!queryText?.trim()) {
console.error('Usage: gitnexus query <search_query>');
process.exit(1);
}
const backend = await getBackend();
const result = await backend.callTool('query', {
query: queryText,
task_context: options?.context,
goal: options?.goal,
limit: options?.limit ? parseInt(options.limit) : undefined,
include_content: options?.content ?? false,
repo: options?.repo,
});
output(result);
}
export async function contextCommand(name: string, options?: {
repo?: string;
file?: string;
uid?: string;
content?: boolean;
}): Promise<void> {
if (!name?.trim() && !options?.uid) {
console.error('Usage: gitnexus context <symbol_name> [--uid <uid>] [--file <path>]');
process.exit(1);
}
const backend = await getBackend();
const result = await backend.callTool('context', {
name: name || undefined,
uid: options?.uid,
file_path: options?.file,
include_content: options?.content ?? false,
repo: options?.repo,
});
output(result);
}
export async function impactCommand(target: string, options?: {
direction?: string;
repo?: string;
depth?: string;
includeTests?: boolean;
}): Promise<void> {
if (!target?.trim()) {
console.error('Usage: gitnexus impact <symbol_name> [--direction upstream|downstream]');
process.exit(1);
}
try {
const backend = await getBackend();
const result = await backend.callTool('impact', {
target,
direction: options?.direction || 'upstream',
maxDepth: options?.depth ? parseInt(options.depth, 10) : undefined,
includeTests: options?.includeTests ?? false,
repo: options?.repo,
});
output(result);
} catch (err: unknown) {
// Belt-and-suspenders: catch infrastructure failures (getBackend, callTool transport)
// The backend's impact() already returns structured errors for graph query failures
output({
error: (err instanceof Error ? err.message : String(err)) || 'Impact analysis failed unexpectedly',
target: { name: target },
direction: options?.direction || 'upstream',
suggestion: 'Try reducing --depth or using gitnexus context <symbol> as a fallback',
});
process.exit(1);
}
}
export async function cypherCommand(query: string, options?: {
repo?: string;
}): Promise<void> {
if (!query?.trim()) {
console.error('Usage: gitnexus cypher <cypher_query>');
process.exit(1);
}
const backend = await getBackend();
const result = await backend.callTool('cypher', {
query,
repo: options?.repo,
});
output(result);
}