diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 5a9290394..24e68facb 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -2920,8 +2920,14 @@ export class LocalBackend { typeof opts.offset === 'number' && Number.isFinite(opts.offset) ? opts.offset : 0; const paginationOffset = Math.max(0, Math.trunc(rawOffset)); const summaryOnly = opts.summaryOnly ?? false; - const relTypeFilter = relationTypes.map((t) => `'${t}'`).join(', '); - const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : ''; + // Bind the BFS frontier query's filters as parameters (#1907 review F5): + // node ids and relation types as bound lists, the confidence floor as a + // bound number — no string interpolation reaches the query text. Preserve + // the original "no confidence clause when minConfidence <= 0" behavior: an + // unconditional `>= 0` would wrongly exclude NULL-confidence edges that the + // unfiltered query includes. + const safeMinConfidence = Number.isFinite(minConfidence) ? minConfidence : 0; + const confidenceFilter = safeMinConfidence > 0 ? ' AND r.confidence >= $minConfidence' : ''; const symId = sym.id || sym[0]; @@ -3009,15 +3015,19 @@ export class LocalBackend { for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) { const nextFrontier: string[] = []; - // Batch frontier nodes into a single Cypher query per depth level - const idList = frontier.map((id) => `'${id.replace(/'/g, "''")}'`).join(', '); + // Batch frontier nodes into a single Cypher query per depth level. + // ids/types/confidence are bound parameters (see above) — no interpolation. const query = direction === 'upstream' - ? `MATCH (caller)-[r:CodeRelation]->(n) WHERE n.id IN [${idList}] AND r.type IN [${relTypeFilter}]${confidenceFilter} RETURN n.id AS sourceId, caller.id AS id, caller.name AS name, labels(caller)[0] AS type, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence` - : `MATCH (n)-[r:CodeRelation]->(callee) WHERE n.id IN [${idList}] AND r.type IN [${relTypeFilter}]${confidenceFilter} RETURN n.id AS sourceId, callee.id AS id, callee.name AS name, labels(callee)[0] AS type, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence`; + ? `MATCH (caller)-[r:CodeRelation]->(n) WHERE n.id IN $frontierIds AND r.type IN $relTypes${confidenceFilter} RETURN n.id AS sourceId, caller.id AS id, caller.name AS name, labels(caller)[0] AS type, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence` + : `MATCH (n)-[r:CodeRelation]->(callee) WHERE n.id IN $frontierIds AND r.type IN $relTypes${confidenceFilter} RETURN n.id AS sourceId, callee.id AS id, callee.name AS name, labels(callee)[0] AS type, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence`; try { - const related = await executeQuery(repo.id, query); + const related = await executeParameterized(repo.id, query, { + frontierIds: frontier, + relTypes: relationTypes, + ...(safeMinConfidence > 0 ? { minConfidence: safeMinConfidence } : {}), + }); for (const rel of related) { const relId = rel.id || rel[1]; diff --git a/gitnexus/test/integration/local-backend-calltool.test.ts b/gitnexus/test/integration/local-backend-calltool.test.ts index ee8bf6c5a..e2640deab 100644 --- a/gitnexus/test/integration/local-backend-calltool.test.ts +++ b/gitnexus/test/integration/local-backend-calltool.test.ts @@ -437,3 +437,68 @@ withTestLbugDB( }, }, ); + +// ─── 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; + }, + }, +);