feat(mcp): rank context/impact disambiguation candidates and expose kind/file_path hints

The `context` MCP tool already returned `{ status: 'ambiguous', candidates }`
when a name hit multiple symbols, but the candidates were returned in
arbitrary DB order and the only hint it accepted was file_path. The
`impact` tool was worse: when its name resolver found multiple viable
matches it silently picked the first one from a priority UNION, with no
signal back to the caller that a different symbol might have been
intended.

Both failure modes were flagged in issue #470 and reconfirmed in the
comments by a second user who described impact as returning "incorrect
parsing results and meaningless tool calls" in the multi-match case.

Changes:

* Add `resolveSymbolCandidates(repo, query, hints)` private helper on
  LocalBackend. Single place that:
   - Short-circuits on direct uid (zero-ambiguity)
   - Runs the same name-or-qualified-id match as before, with LIMIT 20
     (was 10) so the ranker has headroom instead of arbitrary truncation
   - Preserves the #480 Class/Constructor preference -- when the only
     ambiguity is a Class and its own Constructor, the Class wins
     silently
   - Scores each candidate (pure TS, no extra DB round-trip): base 0.50,
     +0.40 for file_path match, +0.20 for kind match, plus a small
     kind-priority tiebreaker (Class > Interface > Function > Method >
     Constructor) when no explicit kind hint is given
   - Sorts desc by score with stable tiebreakers (shorter filePath,
     then lex uid)
   - Promotes to a single confident resolve when the top score is
     >= 0.95 AND beats the runner-up by >= 0.10 -- lets a strong hint
     cut through without forcing the caller through a disambiguation
     round-trip

* Rewire `context()` to use the shared helper. Response shape is a
  strict superset of today's: candidates gain a `score` field, the
  existing `{ uid, name, kind, filePath, line }` keys are preserved so
  every downstream consumer (rename, eval-server formatter, etc.) keeps
  working. New `kind` input hint accepted.

* Rewire `impact()` to use the shared helper. Now emits the same
  `{ status: 'ambiguous', candidates, impactedCount: 0, risk: 'UNKNOWN' }`
  shape instead of silent first-pick. New inputs accepted:
  `target_uid`, `file_path`, `kind`.

* Update tool schemas in mcp/tools.ts to advertise the new inputs and
  describe ranked disambiguation.

Backward compatibility:

The #480 Class/Constructor collapse is preserved and covered by the
existing java-class-impact integration test (still green). The
ambiguous response shape is a strict superset -- `eval-formatters`
unit test that parses the old shape is unchanged and still passes.
`impact` going from silent-first-pick to structured ambiguous is a
semantic improvement that is the entire point of the issue; callers
relying on silent first-pick now get an actionable response.

Scope declined for v1:

module/community hint -- the issue lists it as one of several hints,
but kind + file_path cover the vast majority of disambiguation needs
in practice, and a community-label filter requires an extra graph
query per candidate. Natural v2 follow-up.

Tests: calltool-dispatch.test.ts gains 5 new cases covering file_path
boost, kind hint boost, impact ambiguous shape, impact target_uid
short-circuit, and score field presence on the existing ambiguous
test. Plus the extended assertions on the existing
`context tool returns disambiguation for multiple matches`.

Verification:
  npx vitest run test/unit/calltool-dispatch.test.ts       -> 64 pass
  npx vitest run test/integration/java-class-impact.test.ts -> pass
  npm run test:unit                                         -> 3642 pass
    (4 pre-existing env failures unchanged: skip-git-cli needs built
    dist/, git-utils tmpdir on Windows worktree -- same on main)
  npx tsc --noEmit                                          -> clean

Closes #470
This commit is contained in:
azizur1992 2026-04-16 17:17:36 +01:00
parent a32f5b6adb
commit 604c1f7889
3 changed files with 474 additions and 153 deletions

View file

@ -1070,9 +1070,222 @@ export class LocalBackend {
return result;
}
/**
* Score a symbol candidate for disambiguation ranking.
*
* Deterministic, no DB round-trip:
* - base 0.50
* - +0.40 when file_path hint matches (substring, case-insensitive)
* - +0.20 when kind hint exactly matches the candidate's kind
* - when no kind hint, a small priority bonus (Class > Interface >
* Function > Method > Constructor) to preserve the intuition that
* class-level names are usually what the user wanted.
*
* Capped at 1.0. Intentionally simple and inspectable — a future v2 can
* plug in BM25/embedding signals here without changing the surrounding
* resolver shape.
*/
private scoreCandidate(
c: { kind: string; filePath: string },
hints: { file_path?: string; kind?: string },
): number {
let s = 0.5;
if (hints.file_path && c.filePath && typeof c.filePath === 'string') {
if (c.filePath.toLowerCase().includes(hints.file_path.toLowerCase())) {
s += 0.4;
}
}
if (hints.kind && c.kind === hints.kind) {
s += 0.2;
}
if (!hints.kind) {
const priority: Record<string, number> = {
Class: 5,
Interface: 4,
Function: 3,
Method: 2,
Constructor: 1,
};
s += (priority[c.kind] ?? 0) * 0.02;
}
return Math.min(1.0, s);
}
/**
* Shared symbol resolver used by `context` and `impact`.
*
* Returns one of:
* - `{ kind: 'ok', symbol, resolvedLabel }` — single confident match
* (either direct UID, only one candidate after filtering, Class/
* Constructor collapse, or a top-scoring candidate with a clear gap
* to the runner-up).
* - `{ kind: 'ambiguous', candidates }` — multiple viable matches,
* sorted by score desc. Each candidate carries a relevance score.
* - `{ kind: 'not_found' }` — no matches at all.
*
* Preserves the #480 Class/Constructor preference: when the only
* ambiguity is between a Class and its own Constructor (same name,
* same filePath), the Class wins silently.
*/
private async resolveSymbolCandidates(
repo: RepoHandle,
query: { uid?: string; name?: string; include_content?: boolean },
hints: { file_path?: string; kind?: string },
): Promise<
| {
kind: 'ok';
symbol: {
id: string;
name: string;
type: string;
filePath: string;
startLine: number;
endLine: number;
content?: string;
};
resolvedLabel: string;
}
| {
kind: 'ambiguous';
candidates: Array<{
id: string;
name: string;
type: string;
filePath: string;
startLine: number;
endLine: number;
score: number;
}>;
}
| { kind: 'not_found' }
> {
const { uid, name, include_content } = query;
const selectClause = `n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}`;
// Direct UID — zero-ambiguity path.
if (uid) {
const rows = await executeParameterized(
repo.id,
`MATCH (n {id: $uid}) RETURN ${selectClause} LIMIT 1`,
{ uid },
);
if (rows.length === 0) return { kind: 'not_found' };
const r = rows[0] as any;
return {
kind: 'ok',
symbol: {
id: r.id ?? r[0],
name: r.name ?? r[1],
type: r.type ?? r[2] ?? '',
filePath: r.filePath ?? r[3],
startLine: r.startLine ?? r[4],
endLine: r.endLine ?? r[5],
...(include_content ? { content: r.content ?? r[6] } : {}),
},
resolvedLabel: '',
};
}
if (!name) return { kind: 'not_found' };
const isQualified = name.includes('/') || name.includes(':');
let whereClause: string;
const queryParams: Record<string, any> = { symName: name };
if (hints.file_path) {
whereClause = `WHERE n.name = $symName AND n.filePath CONTAINS $filePath`;
queryParams.filePath = hints.file_path;
} else if (isQualified) {
whereClause = `WHERE n.id = $symName OR n.name = $symName`;
} else {
whereClause = `WHERE n.name = $symName`;
}
// LIMIT 20 (was 10) — scoring is the point now, so give the ranker
// headroom instead of arbitrary truncation.
const rows = await executeParameterized(
repo.id,
`MATCH (n) ${whereClause} RETURN ${selectClause} LIMIT 20`,
queryParams,
);
if (rows.length === 0) return { kind: 'not_found' };
// Normalise row shape across object / tuple returns from LadybugDB.
const normalized = rows.map((r: any) => ({
id: (r.id ?? r[0]) as string,
name: (r.name ?? r[1]) as string,
type: (r.type ?? r[2] ?? '') as string,
filePath: (r.filePath ?? r[3]) as string,
startLine: (r.startLine ?? r[4]) as number,
endLine: (r.endLine ?? r[5]) as number,
...(include_content ? { content: (r.content ?? r[6]) as string | undefined } : {}),
}));
// Preserve #480 Class/Constructor collapse: if we have exactly one
// Class (or Interface) candidate and one Constructor sharing name +
// filePath, fold into the Class. This used to require a follow-up
// label query because LadybugDB sometimes returns an empty labels()[0]
// for Class nodes — we still fall back to that check when type is
// blank on at least one candidate.
if (!hints.kind && normalized.length > 1) {
const ambiguousType = normalized.some((s) => s.type === '' || s.type === 'Constructor');
if (ambiguousType) {
const candidateIds = normalized.map((s) => s.id).filter(Boolean);
for (const label of ['Class', 'Interface']) {
const labelRows = await executeParameterized(
repo.id,
`MATCH (n:\`${label}\`) WHERE n.id IN $candidateIds RETURN n.id AS id LIMIT 1`,
{ candidateIds },
).catch(() => []);
if (labelRows.length > 0) {
const preferredId = (labelRows[0] as any).id ?? (labelRows[0] as any)[0];
const preferred = normalized.find((s) => s.id === preferredId);
if (preferred) {
return {
kind: 'ok',
symbol: preferred,
resolvedLabel: label,
};
}
}
}
}
}
if (normalized.length === 1) {
return {
kind: 'ok',
symbol: normalized[0],
resolvedLabel: '',
};
}
// Score, sort desc, stable tiebreak on shorter filePath then lex uid.
const scored = normalized.map((s) => ({
...s,
score: this.scoreCandidate({ kind: s.type, filePath: s.filePath || '' }, hints),
}));
scored.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const fpA = (a.filePath || '').length;
const fpB = (b.filePath || '').length;
if (fpA !== fpB) return fpA - fpB;
return String(a.id).localeCompare(String(b.id));
});
// Confident single-result: top score ≥ 0.95 AND beats runner-up by ≥ 0.10.
// This lets a very strong file_path/kind hint resolve cleanly instead of
// forcing the caller through a disambiguation round-trip.
if (scored.length >= 2 && scored[0].score >= 0.95 && scored[0].score - scored[1].score >= 0.1) {
return { kind: 'ok', symbol: scored[0], resolvedLabel: scored[0].type };
}
return { kind: 'ambiguous', candidates: scored };
}
/**
* Context tool — 360-degree symbol view with categorized refs.
* Disambiguation when multiple symbols share a name.
* Disambiguation (ranked) when multiple symbols share a name.
* UID-based direct lookup. No cluster in output.
*/
private async context(
@ -1081,124 +1294,47 @@ export class LocalBackend {
name?: string;
uid?: string;
file_path?: string;
kind?: string;
include_content?: boolean;
},
): Promise<any> {
await this.ensureInitialized(repo.id);
const { name, uid, file_path, include_content } = params;
const { name, uid, file_path, kind, include_content } = params;
if (!name && !uid) {
return { error: 'Either "name" or "uid" parameter is required.' };
}
// Step 1: Find the symbol
let symbols: any[];
const outcome = await this.resolveSymbolCandidates(
repo,
{ uid, name, include_content },
{ file_path, kind },
);
if (uid) {
symbols = await executeParameterized(
repo.id,
`
MATCH (n {id: $uid})
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}
LIMIT 1
`,
{ uid },
);
} else {
const isQualified = name!.includes('/') || name!.includes(':');
let whereClause: string;
let queryParams: Record<string, any>;
if (file_path) {
whereClause = `WHERE n.name = $symName AND n.filePath CONTAINS $filePath`;
queryParams = { symName: name!, filePath: file_path };
} else if (isQualified) {
whereClause = `WHERE n.id = $symName OR n.name = $symName`;
queryParams = { symName: name! };
} else {
whereClause = `WHERE n.name = $symName`;
queryParams = { symName: name! };
}
symbols = await executeParameterized(
repo.id,
`
MATCH (n) ${whereClause}
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}
LIMIT 10
`,
queryParams,
);
}
if (symbols.length === 0) {
if (outcome.kind === 'not_found') {
return { error: `Symbol '${name || uid}' not found` };
}
// Step 2: Disambiguation
// When multiple nodes share the same name (e.g. a Java Class and its
// Constructor both named 'SessionTracker'), prefer the Class node so
// context() returns the semantically meaningful result rather than
// triggering ambiguous disambiguation (#480).
// labels(n)[0] returns empty string in LadybugDB, so we resolve the
// preferred node by re-querying with explicit label filters, scoped to
// the candidate IDs already in symbols.
//
// Guard: only attempt Class-preference when at least one candidate has an
// empty/unknown type (LadybugDB limitation) or is a Constructor — meaning
// the ambiguity may be a Class/Constructor name collision rather than two
// genuinely distinct symbols (e.g. two Functions in different files).
//
// resolvedLabel is set here and threaded to Step 3 to avoid a redundant
// classCheck round-trip later.
let resolvedLabel = '';
if (symbols.length > 1 && !uid) {
const hasAmbiguousType = symbols.some((s: any) => {
const t = s.type || s[2] || '';
return t === '' || t === 'Constructor';
});
if (hasAmbiguousType) {
const candidateIds = symbols.map((s: any) => s.id || s[0]).filter(Boolean);
const PREFER_LABELS = ['Class', 'Interface'];
let preferred: any = null;
for (const label of PREFER_LABELS) {
const match = await executeParameterized(
repo.id,
`
MATCH (n:\`${label}\`) WHERE n.id IN $candidateIds RETURN n.id AS id LIMIT 1
`,
{ candidateIds },
).catch(() => []);
if (match.length > 0) {
preferred = symbols.find((s: any) => (s.id || s[0]) === (match[0].id || match[0][0]));
if (preferred) {
resolvedLabel = label;
break;
}
}
}
if (preferred) symbols = [preferred];
}
}
if (symbols.length > 1 && !uid) {
if (outcome.kind === 'ambiguous') {
return {
status: 'ambiguous',
message: `Found ${symbols.length} symbols matching '${name}'. Use uid or file_path to disambiguate.`,
candidates: symbols.map((s: any) => ({
uid: s.id || s[0],
name: s.name || s[1],
kind: s.type || s[2],
filePath: s.filePath || s[3],
line: s.startLine || s[4],
message: `Found ${outcome.candidates.length} symbols matching '${name}'. Use uid, file_path, or kind to disambiguate.`,
candidates: outcome.candidates.map((c) => ({
uid: c.id,
name: c.name,
kind: c.type,
filePath: c.filePath,
line: c.startLine,
score: Number(c.score.toFixed(2)),
})),
};
}
// Step 3: Build full context
const sym = symbols[0];
const symId = sym.id || sym[0];
const sym = outcome.symbol;
const resolvedLabel = outcome.resolvedLabel;
const symId = sym.id;
// Categorized incoming refs
const incomingRows = await executeParameterized(
@ -1892,6 +2028,9 @@ export class LocalBackend {
repo: RepoHandle,
params: {
target: string;
target_uid?: string;
file_path?: string;
kind?: string;
direction: 'upstream' | 'downstream';
maxDepth?: number;
relationTypes?: string[];
@ -1918,6 +2057,9 @@ export class LocalBackend {
repo: RepoHandle,
params: {
target: string;
target_uid?: string;
file_path?: string;
kind?: string;
direction: 'upstream' | 'downstream';
maxDepth?: number;
relationTypes?: string[];
@ -1960,65 +2102,57 @@ export class LocalBackend {
const includeTests = params.includeTests ?? false;
const minConfidence = params.minConfidence ?? 0;
// Resolve target by name, preferring Class/Interface over Constructor
// (fix #480: Java class and constructor share the same name).
// labels(n)[0] returns empty string in LadybugDB, so we use explicit
// label-typed sub-queries in a single UNION ordered by priority to avoid
// up to 6 serial round-trips for non-Class targets.
let sym: any = null;
let symType = '';
// Resolve target via the shared symbol resolver. When the caller passes
// target_uid we skip the name lookup entirely (zero-ambiguity). Otherwise
// we rank candidates (#470) and either proceed with a confident single
// match, or return a structured ambiguous response instead of silently
// picking the wrong symbol.
//
// The resolver preserves the #480 Class/Constructor preference heuristic:
// when a Class and its Constructor share name + filePath, the Class is
// selected silently.
const outcome = await this.resolveSymbolCandidates(
repo,
{ uid: params.target_uid, name: target },
{ file_path: params.file_path, kind: params.kind },
);
try {
const rows = await executeParameterized(
repo.id,
`
MATCH (n:\`Class\`) WHERE n.name = $targetName
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 0 AS priority LIMIT 1
UNION ALL
MATCH (n:\`Interface\`) WHERE n.name = $targetName
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 1 AS priority LIMIT 1
UNION ALL
MATCH (n:\`Function\`) WHERE n.name = $targetName
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 2 AS priority LIMIT 1
UNION ALL
MATCH (n:\`Method\`) WHERE n.name = $targetName
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 3 AS priority LIMIT 1
UNION ALL
MATCH (n:\`Constructor\`) WHERE n.name = $targetName
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 4 AS priority LIMIT 1
`,
{ targetName: target },
).catch(() => []);
if (rows.length > 0) {
// Pick the row with the lowest priority value (Class wins over Constructor)
const best = rows.reduce((a: any, b: any) =>
(a.priority ?? a[3] ?? 99) <= (b.priority ?? b[3] ?? 99) ? a : b,
);
sym = best;
const priorityToLabel = ['Class', 'Interface', 'Function', 'Method', 'Constructor'];
symType = priorityToLabel[best.priority ?? best[3]] ?? '';
}
} catch {
/* fall through to unlabeled match */
if (outcome.kind === 'not_found') {
const missing = params.target_uid ?? target;
return {
error: `Target '${missing}' not found`,
target: { name: target },
direction,
impactedCount: 0,
risk: 'UNKNOWN',
};
}
// Fall back to unlabeled match for any other node type
if (!sym) {
const rows = await executeParameterized(
repo.id,
`
MATCH (n)
WHERE n.name = $targetName
RETURN n.id AS id, n.name AS name, n.filePath AS filePath
LIMIT 1
`,
{ targetName: target },
);
if (rows.length > 0) sym = rows[0];
if (outcome.kind === 'ambiguous') {
return {
status: 'ambiguous',
message: `Found ${outcome.candidates.length} symbols matching '${target}'. Use target_uid, file_path, or kind to disambiguate.`,
target: { name: target },
direction,
impactedCount: 0,
risk: 'UNKNOWN',
candidates: outcome.candidates.map((c) => ({
uid: c.id,
name: c.name,
kind: c.type,
filePath: c.filePath,
line: c.startLine,
score: Number(c.score.toFixed(2)),
})),
};
}
if (!sym) return { error: `Target '${target}' not found` };
const sym = {
id: outcome.symbol.id,
name: outcome.symbol.name,
filePath: outcome.symbol.filePath,
};
const symType = outcome.resolvedLabel || outcome.symbol.type || '';
return this._runImpactBFS(repo, sym, symType, direction, {
maxDepth,

View file

@ -154,7 +154,7 @@ Shows categorized incoming/outgoing references (calls, imports, extends, impleme
WHEN TO USE: After query() to understand a specific symbol in depth. When you need to know all callers, callees, and what execution flows a symbol participates in.
AFTER THIS: Use impact() if planning changes, or READ gitnexus://repo/{name}/process/{processName} for full execution trace.
Handles disambiguation: if multiple symbols share the same name, returns candidates for you to pick from. Use uid param for zero-ambiguity lookup from prior results.
Handles disambiguation: if multiple symbols share the same name, returns ranked candidates (each with a relevance score) for you to pick from. Use uid for zero-ambiguity lookup, or narrow the search with file_path and/or kind hints.
NOTE: ACCESSES edges (field read/write tracking) are included in context results with reason 'read' or 'write'. CALLS edges resolve through field access chains and method-call chains (e.g., user.address.getCity().save() produces CALLS edges at each step).`,
inputSchema: {
@ -166,6 +166,11 @@ NOTE: ACCESSES edges (field read/write tracking) are included in context results
description: 'Direct symbol UID from prior tool results (zero-ambiguity lookup)',
},
file_path: { type: 'string', description: 'File path to disambiguate common names' },
kind: {
type: 'string',
description:
"Kind filter to disambiguate common names (e.g. 'Function', 'Class', 'Method', 'Interface', 'Constructor')",
},
include_content: {
type: 'boolean',
description: 'Include full symbol source code (default: false)',
@ -265,16 +270,32 @@ Depth groups:
TIP: Default traversal uses CALLS/IMPORTS/EXTENDS/IMPLEMENTS. For class members, include HAS_METHOD and HAS_PROPERTY in relationTypes. For field access analysis, include ACCESSES in relationTypes.
Handles disambiguation: when multiple symbols share the target name, returns ranked candidates (each with a relevance score) instead of silently picking one. Use target_uid for zero-ambiguity lookup, or narrow with file_path and/or kind hints.
EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, METHOD_OVERRIDES, METHOD_IMPLEMENTS, ACCESSES
Confidence: 1.0 = certain, <0.8 = fuzzy match`,
inputSchema: {
type: 'object',
properties: {
target: { type: 'string', description: 'Name of function, class, or file to analyze' },
target_uid: {
type: 'string',
description:
'Direct symbol UID from prior tool results (zero-ambiguity lookup, skips target resolution)',
},
direction: {
type: 'string',
description: 'upstream (what depends on this) or downstream (what this depends on)',
},
file_path: {
type: 'string',
description: 'File path hint to disambiguate common names',
},
kind: {
type: 'string',
description:
"Kind filter to disambiguate common names (e.g. 'Function', 'Class', 'Method', 'Interface', 'Constructor')",
},
maxDepth: {
type: 'number',
description: 'Max relationship depth (default: 3)',

View file

@ -248,6 +248,172 @@ describe('LocalBackend.callTool', () => {
const result = await backend.callTool('context', { name: 'main' });
expect(result.status).toBe('ambiguous');
expect(result.candidates).toHaveLength(2);
// #470: every candidate carries a relevance score in [0, 1] and the list
// is sorted descending by score (with deterministic tiebreakers).
for (const c of result.candidates) {
expect(typeof c.score).toBe('number');
expect(c.score).toBeGreaterThanOrEqual(0);
expect(c.score).toBeLessThanOrEqual(1);
}
expect(result.candidates[0].score).toBeGreaterThanOrEqual(result.candidates[1].score);
});
it('context tool ranks file_path match higher than non-match (#470)', async () => {
(executeParameterized as any).mockResolvedValue([
{
id: 'func:handleConnect:1',
name: 'handleConnect',
type: 'Function',
filePath: 'src/lib/socket.ts',
startLine: 10,
endLine: 20,
},
{
id: 'func:handleConnect:2',
name: 'handleConnect',
type: 'Function',
filePath: 'src/App.tsx',
startLine: 42,
endLine: 60,
},
]);
const result = await backend.callTool('context', {
name: 'handleConnect',
file_path: 'App.tsx',
});
// Single confident match expected (App.tsx hit gets 0.50 base + 0.40
// file_path bonus + 0.06 Function priority = 0.96 ≥ 0.95 threshold and
// beats the other candidate by > 0.10).
expect(result.status).toBe('found');
expect(result.symbol.filePath).toBe('src/App.tsx');
});
it('context tool returns ranked candidates when file_path only partially narrows (#470)', async () => {
(executeParameterized as any).mockResolvedValue([
{
id: 'func:foo:1',
name: 'foo',
type: 'Function',
filePath: 'src/a.ts',
startLine: 1,
endLine: 5,
},
{
id: 'func:foo:2',
name: 'foo',
type: 'Function',
filePath: 'src/b.ts',
startLine: 1,
endLine: 5,
},
]);
// No hints → both candidates score 0.56 (0.50 base + 0.06 Function
// priority). Tied scores fall back to deterministic tiebreakers.
const result = await backend.callTool('context', { name: 'foo' });
expect(result.status).toBe('ambiguous');
expect(result.candidates).toHaveLength(2);
expect(result.candidates[0].score).toBeCloseTo(0.56, 2);
expect(result.candidates[1].score).toBeCloseTo(0.56, 2);
});
it('context tool boosts the candidate whose kind matches the hint (#470)', async () => {
(executeParameterized as any).mockResolvedValue([
{
id: 'method:save:1',
name: 'save',
type: 'Method',
filePath: 'src/service.ts',
startLine: 10,
endLine: 20,
},
{
id: 'func:save:1',
name: 'save',
type: 'Function',
filePath: 'src/util.ts',
startLine: 5,
endLine: 15,
},
]);
const result = await backend.callTool('context', { name: 'save', kind: 'Function' });
// When kind hint is given, kind-priority bonus is suppressed and +0.20
// kind-match bonus applies instead. Function becomes the top candidate.
expect(result.status).toBe('ambiguous');
expect(result.candidates[0].kind).toBe('Function');
expect(result.candidates[0].score).toBeGreaterThan(result.candidates[1].score);
});
it('impact tool returns ambiguous shape with ranked candidates when target has multiple matches (#470)', async () => {
// resolveSymbolCandidates issues a single name query; mock it to return
// two Function rows in different files with no hints.
(executeParameterized as any).mockResolvedValue([
{
id: 'func:login:1',
name: 'login',
type: 'Function',
filePath: 'src/auth.ts',
startLine: 5,
endLine: 15,
},
{
id: 'func:login:2',
name: 'login',
type: 'Function',
filePath: 'src/admin/login.ts',
startLine: 8,
endLine: 20,
},
]);
const result = await backend.callTool('impact', { target: 'login', direction: 'upstream' });
expect(result.status).toBe('ambiguous');
expect(result.candidates).toHaveLength(2);
expect(result.impactedCount).toBe(0);
expect(result.risk).toBe('UNKNOWN');
expect(result.target.name).toBe('login');
for (const c of result.candidates) {
expect(typeof c.score).toBe('number');
expect(c.uid).toBeDefined();
expect(c.kind).toBe('Function');
}
});
it('impact tool resolves via target_uid without running the name-based resolver (#470)', async () => {
// UID path: exactly one executeParameterized call for the lookup, then
// the BFS issues executeQuery calls (which we mock empty). Crucially,
// no `WHERE n.name =` query fires.
(executeParameterized as any).mockResolvedValue([
{
id: 'uid:1234',
name: 'pickedByUid',
type: 'Function',
filePath: 'src/pick.ts',
startLine: 1,
endLine: 10,
},
]);
(executeQuery as any).mockResolvedValue([]);
const result = await backend.callTool('impact', {
target: 'ignoredName',
target_uid: 'uid:1234',
direction: 'upstream',
});
// No ambiguous shape and no name-lookup error — the uid short-circuit won.
expect(result.status).not.toBe('ambiguous');
expect(result.target).toBeDefined();
// All executeParameterized calls this test dispatched must have been
// uid-keyed, never name-keyed. That proves the name resolver was skipped.
const calls = (executeParameterized as any).mock.calls as Array<
[string, string, Record<string, unknown>]
>;
for (const [, cypher] of calls) {
expect(cypher).not.toMatch(/WHERE n\.name = \$symName/);
}
});
it('dispatches impact tool', async () => {