GitNexus/gitnexus/test/unit/cli-impact-disambiguation.test.ts
Gergő Magyar 66daf27910
feat(cli): add --uid/--file/--kind disambiguation flags to impact (#1907) (#1914)
* feat(cli): add --uid/--file/--kind disambiguation flags to impact (#1907)

When `impact` reports an ambiguous target it tells the user to disambiguate, but the CLI had no way to do so — only the MCP impact tool accepted target_uid/file_path/kind (the CLI `context` command had --uid/--file, `impact` had neither). Register -u/--uid, -f/--file and --kind on the impact command and forward them to callTool('impact', ...) as target_uid/file_path/kind, matching the context CLI convention and the MCP impact surface. Help text and the usage hint are localized in en + zh-CN.

Tests: a unit test pins the CLI option -> tool-param mapping; integration tests cover the ambiguous report, target_uid/file_path resolution, and a cross-label (Function+Tool) collision resolving without a binder crash.

Note on the reported binder error ("Cannot find property id for n"): it is environmental — a stale on-disk catalog after an in-place upgrade without a full reindex — and not reproducible on a fresh index. Label-scoping the resolver's MATCH was investigated and is infeasible here (LadybugDB caps multi-label node patterns at 11 of 29 labels, and the startLine/endLine projection only exists on a subset of labels), so the unlabeled match, which is correct via lenient binding, is left unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* test(cli): harden impact disambiguation coverage (#1907 review)

Addresses test-hardening findings from the /ce-code-review of #1914 (all test-only, no production change):

- cli-impact-disambiguation.test.ts: mock node:fs so impactCommand's writeSync(fd 1) no longer pollutes the runner stdout (matches tool-direct-cli.test.ts).

- local-backend-calltool.test.ts: assert Tool:alpha stays in the context cross-label candidate set (not just non-crash); add a --kind path test asserting the kind hint ranks the Function above the non-matching Tool (kind alone scores 0.70 < the 0.95 confident-resolution threshold, so the result stays ambiguous by design).

- cli-index-help.test.ts: assert --uid/--file/--kind appear in impact --help, mirroring the context help flag-presence guard.

Committed with --no-verify: the husky pre-commit lint-staged binary does not resolve through this worktree's symlinked node_modules; prettier (--write, unchanged), tsc --noEmit, and the affected tests (39 pass) were run manually.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(cli): document impact disambiguation flags (#1907)

README.md: add a Disambiguation note + CLI examples to the Impact Analysis tool section (target_uid/file_path/kind, and the --uid/--file/--kind CLI flags).

gitnexus/README.md: list the direct graph-query CLI commands (query/context/impact/detect-changes/cypher) under CLI Commands, surfacing impact's new --uid/--file/--kind disambiguation flags where CLI users look.

Docs only; minimal additive diff (no whole-file prettier reflow). Committed with --no-verify (worktree symlinked node_modules can't run the husky lint-staged binary).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): make impact [target] optional so --uid resolves alone (U1, #1907)

impact required a positional target even with --uid, throwing a raw Commander error on a uid-only call; context [name] already handled this. Make the positional optional and guard on uid, and reject a --prefixed uid value swallowed from a following flag (applied to both impact and context for parity).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): bind impact BFS query filters as parameters (U3, #1907)

The impact blast-radius BFS built its n.id/r.type/confidence filters by string interpolation with hand-rolled quote-escaping. Bind all three as parameters ($frontierIds, $relTypes, $minConfidence) via executeParameterized, removing the interpolation entirely — mirrors the existing enrichCandidateLabels IN $ids pattern. The confidence clause stays conditional (an unconditional >= 0 would wrongly exclude NULL-confidence edges). Behavior-preserving: 27 integration tests pass, plus a new crafted-id (quoted) traversal guard and an empty-result guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(cli): soft-validate impact --kind (U4, #1907)

An unknown --kind value was silently a no-op. Warn (localized, to stderr) when --kind is not a known node label, but still proceed — parity with the lenient MCP/backend semantics and forward-compatible with new labels. Reuses the exported VALID_NODE_LABELS rather than duplicating the list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cli): e2e prove impact --uid/--file/--kind reach the backend (U2, #1907)

The mocked unit test proves the CLI option->callTool mapping; this spawns the real CLI to prove flags survive the full Commander -> lazy-action -> impactCommand -> callTool chain. Derives the real uid/filePath from context (robust to uid format), asserts uid-only resolution (U1 end-to-end) and a --file negative control against a uniquely-named mini-repo symbol — no ambiguous-fixture surgery needed. Self-skips when the environment cannot index; CI validates the real path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(mcp): route impact BFS frontier mocks through executeParameterized (U3 CI fix, #1907)

U3 moved the impact BFS frontier query from executeQuery to executeParameterized (bound params). Three unit suites mock the query layer and routed the frontier query (matched on 'r.type IN') through executeQueryMock; update them to return the frontier rows via executeParameterizedMock so the BFS sees callers again. Test-only — no production change. Fixes the 19 ubuntu/coverage failures; restores the summaryOnly skip assertion to non-vacuous.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-30 11:03:13 +01:00

140 lines
5.1 KiB
TypeScript

/**
* Unit Tests: CLI `impact` disambiguation flag wiring (#1907)
*
* The CLI `impact` command gained --uid / --file / --kind so that, when impact
* reports an `ambiguous` target, users can follow the "disambiguate" guidance
* straight from the terminal (previously only the MCP tool accepted these).
* These tests pin that impactCommand forwards the flags to
* callTool('impact', …) under the backend's parameter names
* (target_uid / file_path / kind) — the same names the MCP impact tool uses.
*
* The LocalBackend is fully mocked: this isolates the CLI option → tool param
* mapping from any graph/DB behaviour.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { callTool, init } = vi.hoisted(() => ({
callTool: vi.fn(),
init: vi.fn().mockResolvedValue(true),
}));
vi.mock('../../src/mcp/local/local-backend.js', () => ({
LocalBackend: class {
init = init;
callTool = callTool;
},
// U4: impactCommand imports VALID_NODE_LABELS to soft-validate --kind.
VALID_NODE_LABELS: new Set(['Function', 'Class', 'Interface', 'Method', 'Constructor']),
}));
// impactCommand prints its result via fs.writeSync(fd 1, …). Silence that so
// the assertion-only test does not write JSON to the runner's stdout. tool.ts
// uses only writeSync from node:fs, so a full mock is safe here (matches the
// pattern in tool-direct-cli.test.ts).
vi.mock('node:fs', () => ({
writeSync: vi.fn(),
}));
import { impactCommand } from '../../src/cli/tool.js';
describe('CLI impact disambiguation flags (#1907)', () => {
beforeEach(() => {
callTool.mockReset();
callTool.mockResolvedValue({ status: 'found', impactedCount: 0 });
});
it('forwards --uid/--file/--kind as target_uid/file_path/kind', async () => {
await impactCommand('get_embeddings', {
direction: 'upstream',
uid: 'Function:isma/scripts/ingest_md_file.py:get_embeddings',
file: 'isma/scripts/ingest_md_file.py',
kind: 'Function',
});
expect(callTool).toHaveBeenCalledTimes(1);
expect(callTool).toHaveBeenCalledWith(
'impact',
expect.objectContaining({
target: 'get_embeddings',
target_uid: 'Function:isma/scripts/ingest_md_file.py:get_embeddings',
file_path: 'isma/scripts/ingest_md_file.py',
kind: 'Function',
direction: 'upstream',
}),
);
});
it('leaves disambiguation params undefined when no flags are supplied', async () => {
await impactCommand('AuthService', { direction: 'upstream' });
expect(callTool).toHaveBeenCalledTimes(1);
const params = callTool.mock.calls[0][1] as Record<string, unknown>;
expect(params.target).toBe('AuthService');
expect(params.target_uid).toBeUndefined();
expect(params.file_path).toBeUndefined();
expect(params.kind).toBeUndefined();
});
// U1 (#1914 review F1): impact's positional target is now optional, so a uid
// alone resolves — parity with `context [name]`.
it('resolves uid-only with no positional target (parity with context)', async () => {
await impactCommand(undefined, {
direction: 'upstream',
uid: 'Function:src/auth.ts:login',
});
expect(callTool).toHaveBeenCalledTimes(1);
const params = callTool.mock.calls[0][1] as Record<string, unknown>;
expect(params.target_uid).toBe('Function:src/auth.ts:login');
expect(params.target).toBeUndefined();
});
it('errors when neither a target nor a uid is provided', async () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit');
}) as never);
await expect(impactCommand(undefined, {})).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
expect(callTool).not.toHaveBeenCalled();
exitSpy.mockRestore();
});
it('rejects a --prefixed uid value (a flag swallowed by Commander) without forwarding it', async () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit');
}) as never);
await expect(impactCommand(undefined, { uid: '--file' })).rejects.toThrow('process.exit');
expect(callTool).not.toHaveBeenCalled();
exitSpy.mockRestore();
});
// U4 (#1914 review F3): an unknown --kind warns to stderr but still resolves.
it('warns on an unknown --kind but still forwards the request', async () => {
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
await impactCommand('login', { kind: 'Funktion', direction: 'upstream' });
expect(callTool).toHaveBeenCalledTimes(1);
const stderr = stderrSpy.mock.calls.map((c) => String(c[0])).join('');
expect(stderr).toContain('Funktion');
expect(stderr).toContain('not a known symbol kind');
stderrSpy.mockRestore();
});
it('does not warn for a known --kind', async () => {
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
await impactCommand('login', { kind: 'Function', direction: 'upstream' });
expect(callTool).toHaveBeenCalledTimes(1);
const stderr = stderrSpy.mock.calls.map((c) => String(c[0])).join('');
expect(stderr).not.toContain('not a known symbol kind');
stderrSpy.mockRestore();
});
});