GitNexus/gitnexus/test/integration/local-backend-calltool.test.ts
Gergő Magyar 084ac4514d
fix(query): send hub content once across process_symbols rows (#3356)
* fix(query): send hub content once across process_symbols rows

With include_content, a symbol in several execution flows carried its
full source text on every (id, process_id) row. Keep content on the
first row for each symbol id and omit it from later rows. Membership
fields, is_entry_point, and symbol_count are unchanged.

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

* fix(query): point agents to the content row and lock the dedup in tests

The query tool text now says content is kept once per symbol id across
the whole process_symbols array, possibly under a different process_id,
and names context({uid, include_content: true}) as the fallback.

Tests: the integration suite asserts func:validate has two rows with
content on exactly one. Unit tests cover a later entry-point row that
is flagged and stripped, a hub in three processes, and that the shaper
does not mutate its input.

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

* fix(query): state the content-once rule on include_content and in the query hint

The query tool's include_content property now says content is sent once
per symbol id, on its first process_symbols row, and that
context({uid: "<id>", include_content: true}) returns it for any row.
When a query asked for content, the Next hint adds that same fallback;
without include_content the hint is unchanged. The example call now
uses the "<id>" placeholder style used elsewhere in the tool text.

Tests: pin the property sentence, check the hint with and without
include_content, and cover a max_symbols slice that moves the content
row to a later process and a first row that is also the entry point.

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 07:58:51 +01:00

958 lines
44 KiB
TypeScript

/**
* P0 Integration Tests: Local Backend — callTool dispatch
*
* Tests the full LocalBackend.callTool() dispatch with a real LadybugDB
* instance, verifying cypher, context, impact, and query tools work
* end-to-end against seeded graph data with FTS indexes.
*/
import fs from 'fs/promises';
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos, saveMeta } from '../../src/storage/repo-manager.js';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
import {
LOCAL_BACKEND_SEED_DATA,
LOCAL_BACKEND_FTS_INDEXES,
} from '../fixtures/local-backend-seed.js';
// Partial mock: registry access is faked, but everything else — critically
// `loadMeta`, which the staleness check in LocalBackend.ensureInitialized
// calls on every throttled window — stays REAL. A factory that omitted
// loadMeta made that call site throw a TypeError that the staleness check's
// catch silently swallowed, so the code path was never actually exercised.
vi.mock('../../src/storage/repo-manager.js', async (importActual) => ({
...(await importActual<typeof import('../../src/storage/repo-manager.js')>()),
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
// ─── Block 2: callTool dispatch tests ────────────────────────────────
withTestLbugDB(
'local-backend-calltool',
(handle) => {
describe('callTool dispatch with real DB', () => {
let backend: LocalBackend;
beforeAll(async () => {
// backend is created in afterSetup and attached to the handle
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) {
throw new Error(
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
);
}
backend = ext._backend;
});
it('cypher tool returns function names', async () => {
const result = await backend.callTool('cypher', {
query: 'MATCH (n:Function) RETURN n.name AS name ORDER BY n.name',
});
// cypher tool wraps results as markdown
expect(result).toHaveProperty('markdown');
expect(result).toHaveProperty('row_count');
expect(result.row_count).toBeGreaterThanOrEqual(3);
expect(result.markdown).toContain('login');
expect(result.markdown).toContain('validate');
expect(result.markdown).toContain('hash');
});
it('cypher no-match write probe returns read-only error or empty rows', async () => {
const result = await backend.callTool('cypher', {
query:
"MATCH (n:Function) WHERE n.name = '__missing__' SET n.name = 'x' RETURN n.name AS name",
});
if (result?.error) {
expect(result.error).toMatch(/write operations|read-only/i);
return;
}
expect(result).toEqual([]);
});
it('context tool returns symbol info with callers and callees', async () => {
const result = await backend.callTool('context', { name: 'login' });
expect(result).not.toHaveProperty('error');
expect(result.status).toBe('found');
// Should have the symbol identity
expect(result.symbol).toBeDefined();
expect(result.symbol.name).toBe('login');
expect(result.symbol.filePath).toBe('src/auth.ts');
// login calls validate and hash — should appear in outgoing.calls
expect(result.outgoing).toBeDefined();
expect(result.outgoing.calls).toBeDefined();
expect(result.outgoing.calls.length).toBeGreaterThanOrEqual(2);
const calleeNames = result.outgoing.calls.map((c: any) => c.name);
expect(calleeNames).toContain('validate');
expect(calleeNames).toContain('hash');
});
it('impact tool returns upstream dependents', async () => {
const result = await backend.callTool('impact', {
target: 'validate',
direction: 'upstream',
});
expect(result).not.toHaveProperty('error');
// validate is called by login, so login should appear at depth 1
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
expect(result.byDepth).toBeDefined();
const directDeps = result.byDepth[1] || result.byDepth['1'] || [];
expect(directDeps.length).toBeGreaterThanOrEqual(1);
const depNames = directDeps.map((d: any) => d.name);
expect(depNames).toContain('login');
});
it.each(['name', 'symbol'] as const)(
'impact tool resolves the %s compatibility alias against a real index',
async (alias) => {
const result = await backend.callTool('impact', {
[alias]: 'validate',
direction: 'upstream',
});
expect(result).not.toHaveProperty('error');
expect(result.target?.name).toBe('validate');
const directDeps = result.byDepth[1] || result.byDepth['1'] || [];
expect(directDeps.map((d: any) => d.name)).toContain('login');
},
);
it('context tool resolves the file compatibility alias against a real index', async () => {
const result = await backend.callTool('context', {
name: 'authenticate',
file: 'src/base.ts',
});
expect(result).not.toHaveProperty('error');
expect(result.status).toBe('found');
expect(result.symbol?.name).toBe('authenticate');
expect(result.symbol?.filePath).toBe('src/base.ts');
});
it('query tool returns results for keyword search', async () => {
const result = await backend.callTool('query', { query: 'login' });
expect(result).not.toHaveProperty('error');
expect(result).toHaveProperty('processes');
expect(result).toHaveProperty('definitions');
expect(result.processes.map((p: any) => p.id)).toContain('proc:login-flow');
expect(result.process_symbols.map((s: any) => s.id)).toContain('func:login');
// #553: query response carries per-phase timing metadata.
expect(result.timing).toBeDefined();
expect(typeof result.timing.wall).toBe('number');
expect(result.timing.wall).toBeGreaterThanOrEqual(0);
// At least one of the search phases must have fired for any
// non-error response — bm25 and/or vector always runs.
expect(result.timing.bm25 ?? result.timing.vector).toBeGreaterThanOrEqual(0);
// Success path (FTS present + Process/Community tables exist): no degraded
// signal. Guards R6 — the response shape stays byte-identical when nothing
// fails (the `warning`/`partial` fields appear only on degradation).
expect(result).not.toHaveProperty('warning');
expect(result).not.toHaveProperty('partial');
});
// #2175: end-to-end proof that the renamed parameters work against a real
// index (Claude Code drops a tool arg named exactly "query").
it('query tool returns results via the new search_query param (#2175)', async () => {
const result = await backend.callTool('query', { search_query: 'login' });
expect(result).not.toHaveProperty('error');
expect(result).toHaveProperty('processes');
expect(result.processes.map((p: any) => p.id)).toContain('proc:login-flow');
expect(result.process_symbols.map((s: any) => s.id)).toContain('func:login');
});
it('cypher tool executes via the new statement param (#2175)', async () => {
const result = await backend.callTool('cypher', {
statement: 'MATCH (n:Function) RETURN n.name AS name ORDER BY n.name',
});
expect(result).toHaveProperty('markdown');
expect(result).toHaveProperty('row_count');
expect(result.row_count).toBeGreaterThanOrEqual(3);
expect(result.markdown).toContain('login');
});
// PR #222 port: the query tool batches per-symbol process/cohesion/content
// lookups (N+1 → 2-3 `WHERE n.id IN $nodeIds` queries). These assertions
// guard the batch-adaptation hazards that a naive cherry-pick would break:
// (1) each symbol keeps ITS OWN community (the per-node first-row pick that
// replaced the per-symbol `LIMIT 1`), and (2) content maps to the right
// node — both depend on the +1 positional-index shift after prepending
// `n.id AS nodeId`. func:login is MEMBER_OF comm:auth ("Authentication");
// func:validate has no community, so it must NOT inherit login's.
it('query batches per-symbol enrichment without cross-assigning community/content', async () => {
const findSym = (res: any, id: string) =>
(res.process_symbols ?? []).find((s: any) => s.id === id) ??
(res.definitions ?? []).find((s: any) => s.id === id);
const loginRes = await backend.callTool('query', {
query: 'login',
include_content: true,
});
expect(loginRes).not.toHaveProperty('error');
const login = findSym(loginRes, 'func:login');
expect(login).toBeDefined();
// Community correctly associated to its own node (not dropped, not leaked).
expect(login.module).toBe('Authentication');
// Content correctly mapped to its own node (positional [1] after nodeId).
expect(login.content).toBe('function login() {}');
const validateRes = await backend.callTool('query', {
query: 'validate',
include_content: true,
});
expect(validateRes).not.toHaveProperty('error');
const validate = findSym(validateRes, 'func:validate');
expect(validate).toBeDefined();
// validate has no MEMBER_OF edge — a flat batched `LIMIT 1` would have
// leaked some other node's community onto it. It must have none.
expect(validate.module).toBeUndefined();
expect(validate.content).toBe('function validate() {}');
// validate is a step in two processes, so it has two process_symbols
// rows; content is emitted once per symbol id — only the first row
// carries it, the sibling row omits the key entirely.
const validateRows = (validateRes.process_symbols ?? []).filter(
(s: { id: string }) => s.id === 'func:validate',
);
expect(validateRows).toHaveLength(2);
const withContent = validateRows.filter(
(s: { content?: string }) => s.content === 'function validate() {}',
);
expect(withContent).toHaveLength(1);
const withoutContent = validateRows.filter((s: object) => !('content' in s));
expect(withoutContent).toHaveLength(1);
});
it('reports content capability for the default full profile', async () => {
const query = await backend.callTool('query', { query: 'login', include_content: true });
const context = await backend.callTool('context', { name: 'login', include_content: true });
expect(query.contentAvailability).toEqual({
requested: true,
profile: 'full',
available: true,
scope: 'full',
});
expect(context.contentAvailability).toEqual(query.contentAvailability);
expect(context.symbol.content).toBe('function login() {}');
});
it('does not disclose lingering source text when metadata says retention is none', async () => {
const storagePath = handle.tmpHandle.dbPath;
await saveMeta(storagePath, {
repoPath: '/test/repo',
storagePath,
lastCommit: 'abc123',
indexedAt: new Date().toISOString(),
contentRetention: 'none',
contentRetentionSchemaVersion: 1,
ftsProfile: 'name-only',
});
try {
const query = await backend.callTool('query', { query: 'login', include_content: true });
const context = await backend.callTool('context', {
name: 'login',
include_content: true,
});
const login = (query.process_symbols ?? []).find(
(symbol: any) => symbol.id === 'func:login',
);
expect(query.contentAvailability).toEqual({
requested: true,
profile: 'none',
available: false,
scope: 'none',
reason: 'Source-derived content is not retained by this index.',
});
expect(login?.content).toBeUndefined();
expect(context.contentAvailability).toEqual(query.contentAvailability);
expect(context.symbol.content).toBeUndefined();
} finally {
await Promise.all([
fs.rm(`${storagePath}/gitnexus.json`, { force: true }),
fs.rm(`${storagePath}/meta.json`, { force: true }),
]);
}
});
it('does not disclose lingering source text when retention metadata is invalid', async () => {
const storagePath = handle.tmpHandle.dbPath;
await saveMeta(storagePath, {
repoPath: '/test/repo',
storagePath,
lastCommit: 'abc123',
indexedAt: new Date().toISOString(),
contentRetention: 'invalid' as never,
contentRetentionSchemaVersion: 1,
ftsProfile: 'name-only',
});
try {
const query = await backend.callTool('query', { query: 'login', include_content: true });
const context = await backend.callTool('context', {
name: 'login',
include_content: true,
});
const login = (query.process_symbols ?? []).find(
(symbol: any) => symbol.id === 'func:login',
);
expect(query.contentAvailability).toMatchObject({
requested: true,
profile: 'none',
available: false,
scope: 'none',
});
expect(login?.content).toBeUndefined();
expect(context.contentAvailability).toEqual(query.contentAvailability);
expect(context.symbol.content).toBeUndefined();
} finally {
await Promise.all([
fs.rm(`${storagePath}/gitnexus.json`, { force: true }),
fs.rm(`${storagePath}/meta.json`, { force: true }),
]);
}
});
// PR #222 port: a symbol in MULTIPLE processes is what fully exercises the
// +1 positional shift in the batched STEP_IN_PROCESS aggregation — with a
// single process row, `row.pid ?? row[1]` succeeds whether the shift is
// right or wrong. func:validate is a step in BOTH proc:login-flow (step 2)
// and proc:beta-flow (step 3), so both rows for the one node must be parsed
// (pid=row[1], step=row[6]); an off-by-one would drop a process or mis-pair
// pid↔step. Also pins process ranking (totalScore via the regroup-by-nodeId).
it('query batches a multi-process symbol and ranks processes (positional shift across rows)', async () => {
const res = await backend.callTool('query', { query: 'validate' });
expect(res).not.toHaveProperty('error');
const processIds = (res.processes ?? []).map((p: any) => p.id);
// Both of validate's processes must appear — both STEP_IN_PROCESS rows
// were parsed and grouped by the correct pid (row[1]).
expect(processIds).toContain('proc:login-flow');
expect(processIds).toContain('proc:beta-flow');
// process_symbols keeps one row per (id, process_id): validate is a
// step in both flows and must appear under each, with that row's
// shifted pid↔step pairing (login-flow⇒step 2, beta-flow⇒step 3).
const validates = (res.process_symbols ?? []).filter((s: any) => s.id === 'func:validate');
expect(validates).toHaveLength(2);
const byProcess = Object.fromEntries(validates.map((s: any) => [s.process_id, s]));
expect(byProcess['proc:login-flow']?.step_index).toBe(2);
expect(byProcess['proc:beta-flow']?.step_index).toBe(3);
expect(byProcess['proc:login-flow']?.is_entry_point).toBeUndefined();
expect(byProcess['proc:beta-flow']?.is_entry_point).toBeUndefined();
for (const procId of ['proc:login-flow', 'proc:beta-flow'] as const) {
const card = (res.processes ?? []).find((p: any) => p.id === procId);
const attachCount = (res.process_symbols ?? []).filter(
(s: any) => s.process_id === procId,
).length;
expect(card?.symbol_count).toBe(attachCount);
}
// Ranking: 'login' surfaces proc:login-flow as the top process.
const loginRes = await backend.callTool('query', { query: 'login' });
expect((loginRes.processes ?? [])[0]?.id).toBe('proc:login-flow');
});
it('tool_map returns per-tool flows without cross-attributing same-file tools', async () => {
const result = await backend.callTool('tool_map', {});
expect(result).not.toHaveProperty('error');
const tools = new Map(result.tools.map((tool: any) => [tool.name, tool]));
expect(tools.get('alpha')?.description).toBe('Calls chain A.');
expect(tools.get('beta')?.description).toBe('Calls chain B.');
expect(tools.get('alpha')?.flows).toEqual(['AlphaFlow']);
expect(tools.get('beta')?.flows).toEqual(['BetaFlow']);
});
it('unknown tool throws', async () => {
await expect(backend.callTool('nonexistent_tool', {})).rejects.toThrow(/unknown tool/i);
});
});
describe('impact tool relationTypes filtering', () => {
let backend: LocalBackend;
beforeAll(async () => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) {
throw new Error(
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
);
}
backend = ext._backend;
});
it('filters by HAS_METHOD only', async () => {
const result = await backend.callTool('impact', {
target: 'AuthService',
direction: 'downstream',
relationTypes: ['HAS_METHOD'],
});
expect(result).not.toHaveProperty('error');
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
const d1 = result.byDepth[1] || result.byDepth['1'] || [];
const names = d1.map((d: any) => d.name);
expect(names).toContain('authenticate');
// Should NOT include CALLS-reachable symbols like validate/hash
expect(names).not.toContain('validate');
expect(names).not.toContain('hash');
});
it('filters by OVERRIDES only', async () => {
// The seed has two Method nodes named 'authenticate' (AuthService's
// override and BaseService's base). Per #470, `impact` now returns
// a ranked-ambiguous response when the target name hits multiple
// symbols, so we must disambiguate with file_path to get the
// AuthService override (the one with the outgoing METHOD_OVERRIDES
// edge we want to follow downstream).
const result = await backend.callTool('impact', {
target: 'authenticate',
file_path: 'src/auth.ts',
direction: 'downstream',
relationTypes: ['METHOD_OVERRIDES'],
});
expect(result).not.toHaveProperty('error');
expect(result.status).not.toBe('ambiguous');
// AuthService.authenticate overrides BaseService.authenticate
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
const d1 = result.byDepth[1] || result.byDepth['1'] || [];
const names = d1.map((d: any) => d.name);
expect(names).toContain('authenticate');
});
it('expands legacy OVERRIDES to include METHOD_OVERRIDES (dual-read)', async () => {
// Pass the LEGACY alias 'OVERRIDES' — impactByUid should flatMap-expand
// it to ['OVERRIDES', 'METHOD_OVERRIDES'] so the METHOD_OVERRIDES edge
// between BaseService.authenticate and AuthService.authenticate is found.
// file_path hint disambiguates the two 'authenticate' methods per #470.
const result = await backend.callTool('impact', {
target: 'authenticate',
file_path: 'src/auth.ts',
direction: 'downstream',
relationTypes: ['OVERRIDES'],
});
expect(result).not.toHaveProperty('error');
expect(result.status).not.toBe('ambiguous');
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
const d1 = result.byDepth[1] || result.byDepth['1'] || [];
const names = d1.map((d: any) => d.name);
expect(names).toContain('authenticate');
});
it('does not return HAS_METHOD results when filtering by CALLS only', async () => {
const result = await backend.callTool('impact', {
target: 'AuthService',
direction: 'downstream',
relationTypes: ['CALLS'],
});
expect(result).not.toHaveProperty('error');
// AuthService has no outgoing CALLS edges, only HAS_METHOD
expect(result.impactedCount).toBe(0);
});
});
describe('tool parameter edge cases', () => {
let backend: LocalBackend;
beforeAll(async () => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) {
throw new Error(
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
);
}
backend = ext._backend;
});
it('context tool returns error for nonexistent symbol', async () => {
const result = await backend.callTool('context', { name: 'nonexistent_xyz_symbol_999' });
expect(result).toHaveProperty('error');
expect(result.error).toMatch(/not found/i);
});
it('query tool returns error for empty query', async () => {
const result = await backend.callTool('query', { query: '' });
expect(result).toHaveProperty('error');
expect(result.error).toMatch(/required/i);
});
it('query tool returns error for missing query param', async () => {
const result = await backend.callTool('query', {});
expect(result).toHaveProperty('error');
});
it('cypher tool returns error for invalid Cypher syntax', async () => {
const result = await backend.callTool('cypher', {
query: 'THIS IS NOT VALID CYPHER AT ALL',
});
expect(result).toHaveProperty('error');
});
it('context tool returns error when no name or uid provided', async () => {
const result = await backend.callTool('context', {});
expect(result).toHaveProperty('error');
expect(result.error).toMatch(/required/i);
});
// ─── impact error handling tests (#321) ───────────────────────────
// Verify that impact() returns structured JSON instead of crashing
it('impact tool returns structured error for unknown symbol', async () => {
const result = await backend.callTool('impact', {
target: 'nonexistent_symbol_xyz_999',
direction: 'upstream',
});
// Must return structured JSON, not throw
expect(result).toBeDefined();
// Should have either an error field (not found) or impactedCount 0
// Either outcome is valid — the key is it doesn't crash
if (result.error) {
expect(typeof result.error).toBe('string');
} else {
expect(result.impactedCount).toBe(0);
}
});
it('impact error response has consistent target shape', async () => {
const result = await backend.callTool('impact', {
target: 'nonexistent_symbol_xyz_999',
direction: 'downstream',
});
// When an error is returned, target must be an object (not raw string)
// so downstream API consumers can safely access result.target.name
if (result.error && result.target !== undefined) {
expect(typeof result.target).toBe('object');
expect(result.target).not.toBeNull();
}
});
it('impact partial results: traversalComplete flag when depth fails', async () => {
// Even if traversal fails at some depth, partial results should be returned
// and partial:true should only be set when some results were collected
const result = await backend.callTool('impact', {
target: 'validate',
direction: 'upstream',
maxDepth: 10, // Large depth to trigger multi-level traversal
});
// Should succeed (validate exists in seed data)
expect(result).not.toHaveProperty('error');
if (result.partial) {
// If partial, must still have some results
expect(result.impactedCount).toBeGreaterThan(0);
}
});
});
// ─── impact disambiguation + label-scoped resolution (#1907) ─────────
// Covers the disambiguation surface the CLI --uid/--file/--kind flags
// wire through to, and guards the label-scoped resolver against the
// binder failure that motivated the fix.
describe('impact disambiguation (#1907)', () => {
let backend: LocalBackend;
beforeAll(async () => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) {
throw new Error(
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
);
}
backend = ext._backend;
});
it('reports an ambiguous target with disambiguation guidance', async () => {
// Two Methods named 'authenticate' (AuthService + BaseService).
const result = await backend.callTool('impact', {
target: 'authenticate',
direction: 'upstream',
});
expect(result).not.toHaveProperty('error');
expect(result.status).toBe('ambiguous');
expect(result.message).toMatch(/disambiguate/i);
const uids = (result.candidates ?? []).map((c: any) => c.uid);
expect(uids).toContain('method:AuthService.authenticate');
expect(uids).toContain('method:BaseService.authenticate');
});
it('resolves the ambiguous target via target_uid (the --uid flag path)', async () => {
const result = await backend.callTool('impact', {
target: 'authenticate',
target_uid: 'method:BaseService.authenticate',
direction: 'upstream',
});
expect(result).not.toHaveProperty('error');
expect(result.status).not.toBe('ambiguous');
// target_uid selects the exact symbol, bypassing the name ranker.
expect(result.target?.id).toBe('method:BaseService.authenticate');
expect(result.target?.filePath).toBe('src/base.ts');
});
it('resolves the ambiguous target via file_path (the --file flag path)', async () => {
const result = await backend.callTool('impact', {
target: 'authenticate',
file_path: 'src/base.ts',
direction: 'upstream',
});
expect(result).not.toHaveProperty('error');
expect(result.status).not.toBe('ambiguous');
expect(result.target?.id).toBe('method:BaseService.authenticate');
});
it('does not crash when a name collides across symbol and non-symbol labels', async () => {
// 'alpha' exists as both a Function and a Tool sharing src/tools.py.
// The Tool node table has no startLine/endLine columns, so the
// resolver's `RETURN n.startLine` projection only binds because the
// candidate set also contains a label that *does* have those columns
// (lenient multi-table binding). This guards that the disambiguation
// path keeps tolerating non-symbol node types — and would catch a
// future naive label-scoping that reintroduces the #1907 binder error
// ("Cannot find property … for n") by matching property-poor tables
// in isolation.
const result = await backend.callTool('impact', {
target: 'alpha',
direction: 'upstream',
});
expect(result).not.toHaveProperty('error');
expect(result.status).toBe('ambiguous');
const uids = (result.candidates ?? []).map((c: any) => c.uid);
expect(uids).toContain('func:alpha');
expect(uids).toContain('Tool:alpha');
});
it('context resolves the same cross-label collision without crashing', async () => {
const result = await backend.callTool('context', { name: 'alpha' });
expect(result).not.toHaveProperty('error');
expect(result.status).toBe('ambiguous');
const uids = (result.candidates ?? []).map((c: any) => c.uid);
expect(uids).toContain('func:alpha');
// Assert the non-symbol Tool node stays in the candidate set, not just
// that nothing crashed — a regression that silently dropped Tool from
// the lenient-binding match would otherwise pass the non-crash check.
expect(uids).toContain('Tool:alpha');
});
it('retries unfiltered and still ranks by kind when the hint matches no id prefix (the --kind flag path, #2787 review F5)', async () => {
// WHY THIS FIXTURE TAKES THE FALLBACK, NOT THE FILTER (#2787 review F5):
// `kind` is no longer a pure scoring term — it filters with
// `n.id STARTS WITH 'Function:'`, which relies on the production node-id
// convention `Label:filePath:qualifiedName`. This fixture predates that
// convention: its Function node is `func:alpha` (lowercase, in
// test/fixtures/local-backend-seed.ts), so NO row in this DB satisfies
// the `Function:` prefix and the filtered window comes back EMPTY.
// `kind` is a free-form string on the tool schema, so an empty filtered
// window must not degrade a real name to `not_found`: the resolver
// retries UNFILTERED and the hint reverts to scoreCandidate's +0.20
// ranking term. That fallback — not the filter — is what this pins.
// The filtering path is covered against production-shaped ids by the
// `resolver-kind-filter-2787` suite below.
const result = await backend.callTool('impact', {
target: 'alpha',
kind: 'Function',
direction: 'upstream',
});
expect(result).not.toHaveProperty('error');
// The unfiltered retry ran: BOTH candidates are back, including the
// Tool the filter would have excluded. Without the fallback this is
// `{ error: "Symbol 'alpha' not found" }`.
expect(result.status).toBe('ambiguous');
const candidates = result.candidates ?? [];
expect(candidates.map((c: any) => c.uid).sort()).toEqual(['Tool:alpha', 'func:alpha']);
// …and the hint still ranks, exactly as it did before the filter
// existed: 0.50 + 0.20 = 0.70, below the 0.95 confident gate, so the
// response stays ambiguous with the Function promoted.
expect(candidates[0]).toMatchObject({ uid: 'func:alpha', kind: 'Function' });
const tool = candidates.find((c: any) => c.uid === 'Tool:alpha');
expect(candidates[0]?.score).toBeGreaterThan(tool?.score);
});
});
},
{
seed: LOCAL_BACKEND_SEED_DATA,
ftsIndexes: LOCAL_BACKEND_FTS_INDEXES,
poolAdapter: true,
afterSetup: async (handle) => {
// Configure listRegisteredRepos mock with handle values
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'test-repo',
path: '/test/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
stats: { files: 2, nodes: 3, communities: 1, processes: 1 },
},
]);
const backend = new LocalBackend();
await backend.init();
// Stash backend on handle so tests can access it
(handle as any)._backend = backend;
},
},
);
// ─── impact BFS bound parameters (#1907 review F5) ───────────────────────
// Isolated DB (not the shared seed) with a frontier node whose id contains a
// single quote. Under the old string-interpolated query this id had to be
// hand-escaped; the parameterized query (executeParameterized with bound
// $frontierIds/$relTypes) carries it as data. Guards that a quote-bearing id
// traverses without a Prepare/parser error, and that a no-caller symbol
// returns an empty result rather than erroring.
withTestLbugDB(
'local-backend-impact-param',
(handle) => {
describe('impact BFS bound parameters (#1907 F5)', () => {
let backend: LocalBackend;
beforeAll(() => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) {
throw new Error('LocalBackend not initialized — afterSetup did not attach _backend');
}
backend = ext._backend;
});
it('traverses a caller whose id contains a single quote without a query error', async () => {
const result = await backend.callTool('impact', { target: 'sink', direction: 'upstream' });
expect(result).not.toHaveProperty('error');
const d1 = result.byDepth?.[1] || result.byDepth?.['1'] || [];
const callerIds = d1.map((d: any) => d.uid ?? d.id);
expect(callerIds).toContain("func:o'd");
});
it('returns an empty result (not an error) for a symbol with no callers', async () => {
const result = await backend.callTool('impact', {
target: 'sink',
direction: 'downstream',
});
expect(result).not.toHaveProperty('error');
expect(result.impactedCount).toBe(0);
});
});
},
{
seed: [
`CREATE (a:Function {id: "func:o'd", name: 'odd', filePath: 'src/q.ts', startLine: 1, endLine: 3, isExported: true, content: 'function odd() {}', description: 'caller with a quote in its id'})`,
`CREATE (b:Function {id: 'func:sink', name: 'sink', filePath: 'src/q.ts', startLine: 5, endLine: 8, isExported: true, content: 'function sink() {}', description: 'callee'})`,
`MATCH (a:Function), (b:Function) WHERE a.id = "func:o'd" AND b.id = 'func:sink'
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 1.0, reason: 'direct', step: 0}]->(b)`,
],
poolAdapter: true,
afterSetup: async (handle) => {
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'param-repo',
path: '/param/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
stats: { files: 1, nodes: 2, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as any)._backend = backend;
},
},
);
// ─── resolver `kind` hint FILTERS (#2787 review F5) ──────────────────────
// See `resolveSymbolCandidates` (#2787 F5) for why the id order is label-major
// and why the hint is therefore a WHERE-clause filter on BOTH the window and
// its COUNT rather than a scoring term.
//
// This suite is deliberately seeded with PRODUCTION-shaped ids, unlike the
// shared `local-backend-seed` fixture whose Function is `func:alpha` — that
// legacy shape exercises the unfiltered-retry fallback instead (see the
// "retries unfiltered…" test above).
const KIND_RUNNER_METHOD_ID = 'Method:src/pipeline.ts:Runner.run';
const KIND_RUN_FUNCTION_IDS = ['Function:src/cli/exec.ts:run', 'Function:src/pipeline.ts:run'];
withTestLbugDB(
'resolver-kind-filter-2787',
(handle) => {
describe('resolver kind hint filters rather than scores (#2787 review F5)', () => {
let backend: LocalBackend;
beforeAll(() => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) {
throw new Error('LocalBackend not initialized — afterSetup did not attach _backend');
}
backend = ext._backend;
});
it('narrows a 3-way name collision to a confident resolution', async () => {
// Three symbols are named `run`: two Functions and one Method. As a
// scoring term the Method could only reach 0.50 + 0.20 = 0.70, far
// below the 0.95 confident gate, so this came back `ambiguous` and the
// caller had to round-trip for a uid. As a filter the window contains
// exactly one row, which resolves outright.
const result = await backend.callTool('impact', {
target: 'run',
kind: 'Method',
direction: 'upstream',
});
expect(result).not.toHaveProperty('error');
expect(result.status).not.toBe('ambiguous');
expect(result.target).toMatchObject({ id: KIND_RUNNER_METHOD_ID, name: 'run' });
// The BFS still ran on the filtered pick: `boot` calls Runner.run.
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
});
it('excludes non-matching kinds from the candidate set AND from the match count', async () => {
// The load-bearing difference between filtering and scoring: with
// kind:'Function' the Method must be ABSENT, not merely ranked last —
// and `totalCandidates` (the COUNT leg) must agree with the page, or a
// filtered window ships alongside the unfiltered population.
const result = await backend.callTool('context', { name: 'run', kind: 'Function' });
expect(result).toMatchObject({ status: 'ambiguous', totalCandidates: 2 });
expect((result.candidates as Array<{ uid: string }>).map((c) => c.uid).sort()).toEqual(
KIND_RUN_FUNCTION_IDS,
);
expect(result).not.toHaveProperty('totalIsLowerBound');
});
});
},
{
seed: [
`CREATE (:Function {id: 'Function:src/pipeline.ts:run', name: 'run', filePath: 'src/pipeline.ts', startLine: 1, endLine: 9, isExported: true, content: 'export function run() {}', description: 'pipeline entry'})`,
`CREATE (:Function {id: 'Function:src/cli/exec.ts:run', name: 'run', filePath: 'src/cli/exec.ts', startLine: 1, endLine: 9, isExported: true, content: 'export function run() {}', description: 'cli entry'})`,
`CREATE (:Method {id: '${KIND_RUNNER_METHOD_ID}', name: 'run', filePath: 'src/pipeline.ts', startLine: 20, endLine: 30, isExported: false, content: 'run() {}', description: 'Runner.run'})`,
`CREATE (:Function {id: 'Function:src/main.ts:boot', name: 'boot', filePath: 'src/main.ts', startLine: 1, endLine: 5, isExported: true, content: 'function boot() {}', description: 'caller of Runner.run'})`,
`MATCH (a:Function), (b:Method) WHERE a.id = 'Function:src/main.ts:boot' AND b.id = '${KIND_RUNNER_METHOD_ID}'
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 0.9, reason: 'direct', step: 0}]->(b)`,
],
poolAdapter: true,
afterSetup: async (handle) => {
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'kind-filter-repo',
path: '/kind/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
stats: { files: 4, nodes: 4, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as any)._backend = backend;
},
},
);
// ─── context ref window must SPREAD across categories (#2787 review F1) ──
// See the incoming-ref window in `_contextImpl` (#2787 F1) for why the 30-row
// page is keyed `ORDER BY uid, relType` and not category-major.
//
// Shape below: 40 incoming refs on one Method — 35 CALLS, 3 ACCESSES, 1 USES,
// 1 HAS_METHOD (the owning class, which is how `context` names the owner).
// relType-major → ACCESSES(3) + CALLS(27); HAS_METHOD and USES gone.
// uid-major → HAS_METHOD(1) + ACCESSES(3) + USES(1) + CALLS(25).
// The caller ids are spread through the id space on purpose (`a1` in f05, `u1`
// in f10, `a2` in f15, `a3` in f25), so the rare categories are NOT stacked at
// the front of the uid order — they survive because the key interleaves them,
// not because they were placed first.
const REF_TARGET_ID = 'Method:src/owner.ts:Owner.handle';
const REF_OWNER_ID = 'Class:src/owner.ts:Owner';
const REF_ACCESSES_IDS = [
'Function:src/f05.ts:a1',
'Function:src/f15.ts:a2',
'Function:src/f25.ts:a3',
];
const REF_USES_ID = 'Function:src/f10.ts:u1';
const refCaller = (id: string, name: string, filePath: string): string =>
`CREATE (:Function {id: '${id}', name: '${name}', filePath: '${filePath}', startLine: 1, endLine: 3, isExported: true, content: '', description: ''})`;
const refEdge = (fromLabel: string, fromId: string, relType: string): string =>
`MATCH (a:${fromLabel}), (b:Method) WHERE a.id = '${fromId}' AND b.id = '${REF_TARGET_ID}'
CREATE (a)-[:CodeRelation {type: '${relType}', confidence: 0.9, reason: 'direct', step: 0}]->(b)`;
const REF_CALLS_IDS = Array.from({ length: 35 }, (_, i) => {
const nn = String(i + 1).padStart(2, '0');
return `Function:src/f${nn}.ts:c${nn}`;
});
withTestLbugDB(
'context-ref-window-2787',
(handle) => {
describe('context incoming-ref window spreads across categories (#2787 review F1)', () => {
let backend: LocalBackend;
beforeAll(() => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) {
throw new Error('LocalBackend not initialized — afterSetup did not attach _backend');
}
backend = ext._backend;
});
it('keeps single-edge categories that a category-major ORDER BY starved', async () => {
const result = await backend.callTool('context', { uid: REF_TARGET_ID });
expect(result).not.toHaveProperty('error');
expect(result.status).toBe('found');
// The rare categories are present at all — this is the assertion the
// pre-fix key fails: HAS_METHOD and USES sort after CALLS, whose 35 rows
// overflow the window on their own.
expect(Object.keys(result.incoming).sort()).toEqual([
'accesses',
'calls',
'has_method',
'uses',
]);
expect(result.incoming.has_method.map((r: { uid: string }) => r.uid)).toEqual([
REF_OWNER_ID,
]);
expect(result.incoming.uses.map((r: { uid: string }) => r.uid)).toEqual([REF_USES_ID]);
expect(result.incoming.accesses.map((r: { uid: string }) => r.uid)).toEqual(
REF_ACCESSES_IDS,
);
// …and the window is still exactly 30 rows: the fix REDISTRIBUTES the
// page, it does not widen it. The dominant category gives up the 5 slots
// the starved ones need.
expect(result.incoming.calls).toHaveLength(25);
expect(Object.values(result.incoming).flat()).toHaveLength(30);
});
});
},
{
seed: [
`CREATE (:Method {id: '${REF_TARGET_ID}', name: 'handle', filePath: 'src/owner.ts', startLine: 10, endLine: 20, isExported: false, content: '', description: ''})`,
`CREATE (:Class {id: '${REF_OWNER_ID}', name: 'Owner', filePath: 'src/owner.ts', startLine: 1, endLine: 40, isExported: true, content: '', description: ''})`,
...REF_CALLS_IDS.map((id, i) => {
const nn = String(i + 1).padStart(2, '0');
return refCaller(id, `c${nn}`, `src/f${nn}.ts`);
}),
...REF_ACCESSES_IDS.map((id, i) => refCaller(id, `a${i + 1}`, id.split(':')[1])),
refCaller(REF_USES_ID, 'u1', 'src/f10.ts'),
...REF_CALLS_IDS.map((id) => refEdge('Function', id, 'CALLS')),
...REF_ACCESSES_IDS.map((id) => refEdge('Function', id, 'ACCESSES')),
refEdge('Function', REF_USES_ID, 'USES'),
refEdge('Class', REF_OWNER_ID, 'HAS_METHOD'),
],
poolAdapter: true,
afterSetup: async (handle) => {
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'ref-window-repo',
path: '/ref/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
stats: { files: 41, nodes: 41, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as any)._backend = backend;
},
},
);