diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 9d3015454..915fe9f24 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -450,10 +450,13 @@ export class LocalBackend { // Step 1: Run hybrid search to get matching symbols const searchLimit = processLimit * maxSymbolsPerProcess; // fetch enough raw results - const [bm25Results, semanticResults] = await Promise.all([ + const [bm25SearchResult, semanticResults] = await Promise.all([ this.bm25Search(repo, searchQuery, searchLimit), this.semanticSearch(repo, searchQuery, searchLimit), ]); + + const bm25Results = bm25SearchResult.results; + const ftsUsed = bm25SearchResult.ftsUsed; // Merge via reciprocal rank fusion const scoreMap = new Map(); @@ -627,21 +630,24 @@ export class LocalBackend { processes, process_symbols: dedupedSymbols, definitions: definitions.slice(0, 20), // cap standalone definitions + ...(!ftsUsed && { warning: 'FTS extension unavailable - keyword search degraded. Run: gitnexus analyze --force to rebuild indexes.' }), }; } /** * BM25 keyword search helper - uses LadybugDB FTS for always-fresh results */ - private async bm25Search(repo: RepoHandle, query: string, limit: number): Promise { + private async bm25Search(repo: RepoHandle, query: string, limit: number): Promise<{ results: any[]; ftsUsed: boolean }> { const { searchFTSFromLbug } = await import('../../core/search/bm25-index.js'); let bm25Results; try { bm25Results = await searchFTSFromLbug(query, limit, repo.id); } catch (err: any) { console.error('GitNexus: BM25/FTS search failed (FTS indexes may not exist) -', err.message); - return []; + return { results: [], ftsUsed: false }; } + + const ftsUsed = bm25Results.length === 0 || (bm25Results[0]?.ftsUsed !== false); const results: any[] = []; @@ -687,7 +693,7 @@ export class LocalBackend { } } - return results; + return { results, ftsUsed }; } /** @@ -966,6 +972,44 @@ export class LocalBackend { } // 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) { return { status: 'ambiguous', @@ -985,13 +1029,74 @@ export class LocalBackend { const symId = sym.id || sym[0]; // Categorized incoming refs - const incomingRows = await executeParameterized(repo.id, ` + let incomingRows = await executeParameterized(repo.id, ` MATCH (caller)-[r:CodeRelation]->(n {id: $symId}) WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES'] RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind LIMIT 30 `, { symId }); + // Fix #480: Class/Interface nodes have no direct CALLS/IMPORTS edges — + // those point to Constructor and File nodes respectively. Fetch those + // extra incoming refs and merge them in so context() shows real callers. + // + // Determine if this is a Class/Interface node. If resolvedLabel was set + // during disambiguation (Step 2), use it directly — no extra round-trip. + // Otherwise fall back to a single label check only when the type field is + // empty (LadybugDB labels(n)[0] limitation). + const symRawType = sym.type || sym[2] || ''; + let isClassLike = resolvedLabel === 'Class' || resolvedLabel === 'Interface'; + if (!isClassLike && symRawType === '') { + try { + // Single UNION query instead of two serial round-trips. + const typeCheck = await executeParameterized(repo.id, ` + MATCH (n:Class) WHERE n.id = $symId RETURN 'Class' AS label LIMIT 1 + UNION ALL + MATCH (n:Interface) WHERE n.id = $symId RETURN 'Interface' AS label LIMIT 1 + `, { symId }); + isClassLike = typeCheck.length > 0; + } catch { /* not a Class/Interface node */ } + } else if (!isClassLike) { + isClassLike = symRawType === 'Class' || symRawType === 'Interface'; + } + + if (isClassLike) { + try { + // Run both incoming-ref queries in parallel — they are independent. + const [ctorIncoming, fileIncoming] = await Promise.all([ + executeParameterized(repo.id, ` + MATCH (n)-[hm:CodeRelation]->(ctor:Constructor) + WHERE n.id = $symId AND hm.type = 'HAS_METHOD' + MATCH (caller)-[r:CodeRelation]->(ctor) + WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'ACCESSES'] + RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind + LIMIT 30 + `, { symId }), + executeParameterized(repo.id, ` + MATCH (f:File)-[rel:CodeRelation]->(n) + WHERE n.id = $symId AND rel.type = 'DEFINES' + MATCH (caller)-[r:CodeRelation]->(f) + WHERE r.type IN ['CALLS', 'IMPORTS'] + RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind + LIMIT 30 + `, { symId }), + ]); + + // Deduplicate by (relType, uid) — a caller can have multiple relation + // types to the same target (e.g. both IMPORTS and CALLS), and each + // must be preserved so every category appears in the output. + const seenKeys = new Set( + incomingRows.map((r: any) => `${r.relType || r[0]}:${r.uid || r[1]}`), + ); + for (const r of [...ctorIncoming, ...fileIncoming]) { + const key = `${r.relType || r[0]}:${r.uid || r[1]}`; + if (!seenKeys.has(key)) { seenKeys.add(key); incomingRows.push(r); } + } + } catch (e) { + logQueryError('context:class-incoming-expansion', e); + } + } + // Categorized outgoing refs const outgoingRows = await executeParameterized(repo.id, ` MATCH (n {id: $symId})-[r:CodeRelation]->(target) @@ -1031,7 +1136,7 @@ export class LocalBackend { symbol: { uid: sym.id || sym[0], name: sym.name || sym[1], - kind: sym.type || sym[2], + kind: isClassLike ? (resolvedLabel || 'Class') : (sym.type || sym[2]), filePath: sym.filePath || sym[3], startLine: sym.startLine || sym[4], endLine: sym.endLine || sym[5], @@ -1456,21 +1561,103 @@ export class LocalBackend { const relTypeFilter = relationTypes.map(t => `'${t}'`).join(', '); const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : ''; - const targets = await executeParameterized(repo.id, ` - MATCH (n) - WHERE n.name = $targetName - RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath - LIMIT 1 - `, { targetName: target }); - if (targets.length === 0) return { error: `Target '${target}' not found` }; - - const sym = targets[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 = ''; + + 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 */ } + + // 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 (!sym) return { error: `Target '${target}' not found` }; + const symId = sym.id || sym[0]; - + const impacted: any[] = []; const visited = new Set([symId]); let frontier = [symId]; let traversalComplete = true; + + // Fix #480: For Java (and other JVM) Class/Interface nodes, CALLS edges + // point to Constructor nodes and IMPORTS edges point to File nodes — not + // the Class/Interface itself. Seed the frontier with the Constructor(s) + // and owning File so the BFS traversal finds those edges naturally. + // The owning File is kept only as an internal seed (frontier/visited) and + // is NOT added to impacted — it is the definition container, not an + // upstream dependent. The BFS will discover IMPORTS edges on it naturally. + if (symType === 'Class' || symType === 'Interface') { + try { + // Run both seed queries in parallel — they are independent. + const [ctorRows, fileRows] = await Promise.all([ + executeParameterized(repo.id, ` + MATCH (n)-[hm:CodeRelation]->(c:Constructor) + WHERE n.id = $symId AND hm.type = 'HAS_METHOD' + RETURN c.id AS id, c.name AS name, labels(c)[0] AS type, c.filePath AS filePath + `, { symId }), + // Restrict to DEFINES edges only — other File->Class edge types (if + // any) should not be treated as the owning file relationship. + executeParameterized(repo.id, ` + MATCH (f:File)-[rel:CodeRelation]->(n) + WHERE n.id = $symId AND rel.type = 'DEFINES' + RETURN f.id AS id, f.name AS name, labels(f)[0] AS type, f.filePath AS filePath + `, { symId }), + ]); + + for (const r of ctorRows) { + const rid = r.id || r[0]; + if (rid && !visited.has(rid)) { visited.add(rid); frontier.push(rid); } + } + for (const r of fileRows) { + const rid = r.id || r[0]; + if (rid && !visited.has(rid)) { + visited.add(rid); + frontier.push(rid); + } + } + } catch (e) { + logQueryError('impact:class-node-expansion', e); + } + } for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) { const nextFrontier: string[] = []; @@ -1615,8 +1802,8 @@ export class LocalBackend { target: { id: symId, name: sym.name || sym[1], - type: sym.type || sym[2], - filePath: sym.filePath || sym[3], + type: symType, + filePath: sym.filePath || sym[2], }, direction, impactedCount: impacted.length, diff --git a/gitnexus/test/integration/class-impact-all-languages.test.ts b/gitnexus/test/integration/class-impact-all-languages.test.ts new file mode 100644 index 000000000..595530618 --- /dev/null +++ b/gitnexus/test/integration/class-impact-all-languages.test.ts @@ -0,0 +1,210 @@ +/** + * Integration Tests: Class impact/context traversal across all supported languages (#480) + * + * Ensures the fix for Java class traversal (CALLS->Constructor, IMPORTS->File) + * does not regress for any supported language, and that each language's class + * topology is handled correctly by impact() and context(). + * + * Language topologies: + * JVM (Java, Kotlin): CALLS -> Constructor, IMPORTS -> File + * Non-JVM with classes: CALLS -> Class directly, IMPORTS -> File + * (TypeScript, JavaScript, Python, C#, Ruby, PHP, Rust, Go, Swift, C, C++) + * + * All languages share a single DB instance (withTestLbugDB clears+reseeds per + * file). Node IDs are namespaced by language to avoid collisions. + */ +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { listRegisteredRepos } from '../../src/storage/repo-manager.js'; +import { withTestLbugDB } from '../helpers/test-indexed-db.js'; + +vi.mock('../../src/storage/repo-manager.js', () => ({ + listRegisteredRepos: vi.fn().mockResolvedValue([]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), +})); + +// ─── Seed builders ─────────────────────────────────────────────────────────── + +/** JVM topology: Class -HAS_METHOD-> Constructor, File -DEFINES-> Class. + * Callers CALL the Constructor; importers IMPORT the owning File. */ +function jvmNodes(lang: string, ext: string, cls: string, caller: string, importer: string, clsPath: string, callerPath: string, importerPath: string): string[] { + return [ + `CREATE (f:File {id:'${lang}:file:${cls}', name:'${cls}.${ext}', filePath:'${clsPath}', content:''})`, + `CREATE (c:Class {id:'${lang}:class:${cls}', name:'${cls}', filePath:'${clsPath}', startLine:1, endLine:50, isExported:true, content:'', description:''})`, + `CREATE (ctor:Constructor {id:'${lang}:ctor:${cls}', name:'${cls}', filePath:'${clsPath}', startLine:5, endLine:10, content:'', description:''})`, + `CREATE (caller:Method {id:'${lang}:method:${caller}', name:'${caller}', filePath:'${callerPath}', startLine:1, endLine:10, isExported:false, content:'', description:''})`, + `CREATE (imp:File {id:'${lang}:file:${importer}', name:'${importer}.${ext}', filePath:'${importerPath}', content:''})`, + `MATCH (c:Class {id:'${lang}:class:${cls}'}), (ctor:Constructor {id:'${lang}:ctor:${cls}'}) CREATE (c)-[:CodeRelation {type:'HAS_METHOD', confidence:1.0, reason:'class-method', step:0}]->(ctor)`, + `MATCH (f:File {id:'${lang}:file:${cls}'}), (c:Class {id:'${lang}:class:${cls}'}) CREATE (f)-[:CodeRelation {type:'DEFINES', confidence:1.0, reason:'', step:0}]->(c)`, + `MATCH (a:Method {id:'${lang}:method:${caller}'}), (b:Constructor {id:'${lang}:ctor:${cls}'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.9, reason:'direct', step:0}]->(b)`, + `MATCH (a:File {id:'${lang}:file:${importer}'}), (b:File {id:'${lang}:file:${cls}'}) CREATE (a)-[:CodeRelation {type:'IMPORTS', confidence:0.9, reason:'import', step:0}]->(b)`, + ]; +} + +/** Non-JVM topology: CALLS -> Class directly, IMPORTS -> File. */ +function nonJvmNodes(lang: string, ext: string, cls: string, caller: string, callerLabel: string, importer: string, clsPath: string, callerPath: string, importerPath: string): string[] { + return [ + `CREATE (f:File {id:'${lang}:file:${cls}', name:'${cls}.${ext}', filePath:'${clsPath}', content:''})`, + `CREATE (c:Class {id:'${lang}:class:${cls}', name:'${cls}', filePath:'${clsPath}', startLine:1, endLine:50, isExported:true, content:'', description:''})`, + `CREATE (caller:${callerLabel} {id:'${lang}:fn:${caller}', name:'${caller}', filePath:'${callerPath}', startLine:1, endLine:10, isExported:false, content:'', description:''})`, + `CREATE (imp:File {id:'${lang}:file:${importer}', name:'${importer}.${ext}', filePath:'${importerPath}', content:''})`, + `MATCH (f:File {id:'${lang}:file:${cls}'}), (c:Class {id:'${lang}:class:${cls}'}) CREATE (f)-[:CodeRelation {type:'DEFINES', confidence:1.0, reason:'', step:0}]->(c)`, + `MATCH (a:${callerLabel} {id:'${lang}:fn:${caller}'}), (b:Class {id:'${lang}:class:${cls}'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.9, reason:'direct', step:0}]->(b)`, + `MATCH (a:File {id:'${lang}:file:${importer}'}), (b:File {id:'${lang}:file:${cls}'}) CREATE (a)-[:CodeRelation {type:'IMPORTS', confidence:0.9, reason:'import', step:0}]->(b)`, + ]; +} + +// ─── Combined seed for all languages ───────────────────────────────────────── + +const SEED = [ + // Java — JVM topology + ...jvmNodes('java', 'java', 'PaymentService', 'processPayment', 'OrderController', + 'src/main/java/payments/PaymentService.java', + 'src/main/java/orders/OrderController.java', + 'src/main/java/orders/OrderController.java'), + + // Kotlin — JVM topology + ...jvmNodes('kotlin', 'kt', 'UserRepository', 'fetchUser', 'UserService', + 'src/main/kotlin/data/UserRepository.kt', + 'src/main/kotlin/service/UserService.kt', + 'src/main/kotlin/service/UserService.kt'), + + // TypeScript — non-JVM + ...nonJvmNodes('ts', 'ts', 'AuthService', 'loginUser', 'Function', 'app', + 'src/auth/AuthService.ts', 'src/routes/auth.ts', 'src/app.ts'), + + // JavaScript — non-JVM + ...nonJvmNodes('js', 'js', 'EventEmitter', 'subscribe', 'Function', 'index', + 'src/events/EventEmitter.js', 'src/handlers/handler.js', 'src/index.js'), + + // Python — non-JVM + ...nonJvmNodes('py', 'py', 'DatabaseClient', 'connect', 'Function', 'app', + 'src/db/database_client.py', 'src/services/service.py', 'src/app.py'), + + // C# — non-JVM + ...nonJvmNodes('cs', 'cs', 'OrderProcessor', 'ProcessOrder', 'Method', 'Startup', + 'src/Orders/OrderProcessor.cs', 'src/Controllers/OrderController.cs', 'src/Startup.cs'), + + // Ruby — non-JVM + ...nonJvmNodes('rb', 'rb', 'SessionManager', 'create_session', 'Function', 'application', + 'lib/session/session_manager.rb', 'lib/controllers/auth_controller.rb', 'lib/application.rb'), + + // PHP — non-JVM + ...nonJvmNodes('php', 'php', 'CacheService', 'getCache', 'Method', 'bootstrap', + 'src/Cache/CacheService.php', 'src/Controllers/HomeController.php', 'src/bootstrap.php'), + + // Rust — non-JVM + ...nonJvmNodes('rs', 'rs', 'HttpClient', 'send_request', 'Function', 'main', + 'src/http/client.rs', 'src/api/handler.rs', 'src/main.rs'), + + // Go — non-JVM + ...nonJvmNodes('go', 'go', 'Router', 'handleRequest', 'Function', 'main', + 'pkg/router/router.go', 'pkg/handlers/handler.go', 'main.go'), + + // Swift — non-JVM + ...nonJvmNodes('swift', 'swift', 'NetworkManager', 'fetchData', 'Method', 'AppDelegate', + 'Sources/Network/NetworkManager.swift', 'Sources/ViewControllers/HomeVC.swift', 'Sources/AppDelegate.swift'), + + // C — non-JVM + ...nonJvmNodes('c', 'c', 'MemoryPool', 'allocate', 'Function', 'main', + 'src/memory/pool.c', 'src/runtime/runtime.c', 'src/main.c'), + + // C++ — non-JVM + ...nonJvmNodes('cpp', 'cpp', 'ThreadPool', 'enqueue', 'Function', 'main', + 'src/threading/thread_pool.cpp', 'src/workers/worker.cpp', 'src/main.cpp'), +]; + +// ─── Shared assertion helper ────────────────────────────────────────────────── + +function suiteFor( + lang: string, + topology: 'JVM (Constructor+File)' | 'direct CALLS', + getBackend: () => LocalBackend, + className: string, + callerName: string, + importerFileName: string, + classFilePath: string, +) { + describe(`${lang}: Class impact/context via ${topology}`, () => { + it(`impact(upstream) surfaces the caller`, async () => { + const result = await getBackend().callTool('impact', { + target: className, direction: 'upstream', includeTests: true, + }); + expect(result).not.toHaveProperty('error'); + expect(result.impactedCount).toBeGreaterThanOrEqual(1); + const allNames = Object.values(result.byDepth as Record) + .flat().map((d: any) => d.name); + expect(allNames).toContain(callerName); + }); + + it(`impact(upstream) surfaces the file importer`, async () => { + const result = await getBackend().callTool('impact', { + target: className, direction: 'upstream', includeTests: true, + }); + expect(result).not.toHaveProperty('error'); + const allNames = Object.values(result.byDepth as Record) + .flat().map((d: any) => d.name); + expect(allNames).toContain(importerFileName); + }); + + it(`context() returns found with kind Class`, async () => { + const result = await getBackend().callTool('context', { + name: className, file_path: classFilePath, + }); + expect(result.status).toBe('found'); + expect(result.symbol.kind).toBe('Class'); + }); + + it(`context() has non-empty incoming containing the caller`, async () => { + const result = await getBackend().callTool('context', { + name: className, file_path: classFilePath, + }); + expect(result.status).toBe('found'); + const allIncoming = [ + ...(result.incoming.calls || []), + ...(result.incoming.imports || []), + ]; + expect(allIncoming.length).toBeGreaterThanOrEqual(1); + expect(allIncoming.map((r: any) => r.name)).toContain(callerName); + }); + }); +} + +// ─── Single DB instance, all languages ─────────────────────────────────────── + +withTestLbugDB('class-impact-all-languages', (handle) => { + let backend: LocalBackend; + beforeAll(() => { backend = (handle as any)._backend; }); + + // JVM languages + suiteFor('Java', 'JVM (Constructor+File)', () => backend, 'PaymentService', 'processPayment', 'OrderController.java', 'src/main/java/payments/PaymentService.java'); + suiteFor('Kotlin', 'JVM (Constructor+File)', () => backend, 'UserRepository', 'fetchUser', 'UserService.kt', 'src/main/kotlin/data/UserRepository.kt'); + + // Non-JVM languages + suiteFor('TypeScript', 'direct CALLS', () => backend, 'AuthService', 'loginUser', 'app.ts', 'src/auth/AuthService.ts'); + suiteFor('JavaScript', 'direct CALLS', () => backend, 'EventEmitter', 'subscribe', 'index.js', 'src/events/EventEmitter.js'); + suiteFor('Python', 'direct CALLS', () => backend, 'DatabaseClient', 'connect', 'app.py', 'src/db/database_client.py'); + suiteFor('C#', 'direct CALLS', () => backend, 'OrderProcessor', 'ProcessOrder', 'Startup.cs', 'src/Orders/OrderProcessor.cs'); + suiteFor('Ruby', 'direct CALLS', () => backend, 'SessionManager', 'create_session', 'application.rb', 'lib/session/session_manager.rb'); + suiteFor('PHP', 'direct CALLS', () => backend, 'CacheService', 'getCache', 'bootstrap.php', 'src/Cache/CacheService.php'); + suiteFor('Rust', 'direct CALLS', () => backend, 'HttpClient', 'send_request', 'main.rs', 'src/http/client.rs'); + suiteFor('Go', 'direct CALLS', () => backend, 'Router', 'handleRequest', 'main.go', 'pkg/router/router.go'); + suiteFor('Swift', 'direct CALLS', () => backend, 'NetworkManager', 'fetchData', 'AppDelegate.swift', 'Sources/Network/NetworkManager.swift'); + suiteFor('C', 'direct CALLS', () => backend, 'MemoryPool', 'allocate', 'main.c', 'src/memory/pool.c'); + suiteFor('C++', 'direct CALLS', () => backend, 'ThreadPool', 'enqueue', 'main.cpp', 'src/threading/thread_pool.cpp'); + +}, { + seed: SEED, + poolAdapter: true, + afterSetup: async (handle) => { + vi.mocked(listRegisteredRepos).mockResolvedValue([{ + name: 'test-repo', path: '/test/repo', + storagePath: handle.tmpHandle.dbPath, + indexedAt: new Date().toISOString(), lastCommit: 'abc123', + stats: { files: 20, nodes: 60, communities: 0, processes: 0 }, + }]); + const backend = new LocalBackend(); + await backend.init(); + (handle as any)._backend = backend; + }, +}); diff --git a/gitnexus/test/integration/java-class-impact.test.ts b/gitnexus/test/integration/java-class-impact.test.ts new file mode 100644 index 000000000..fcf916f96 --- /dev/null +++ b/gitnexus/test/integration/java-class-impact.test.ts @@ -0,0 +1,306 @@ +/** + * Integration Tests: Java Class node traversal fix (#480) + * + * Reproduces the exact scenario from the issue: + * - SessionTracker class with 1 production caller + 4 test callers + * - RankPermissionHandler class with 1 caller + 10 importers + * + * Before fix: impact(upstream) → impactedCount: 0, context() → incoming: {} + * After fix: both tools surface callers/importers correctly + */ +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { executeQuery } from '../../src/mcp/core/lbug-adapter.js'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { listRegisteredRepos } from '../../src/storage/repo-manager.js'; +import { withTestLbugDB } from '../helpers/test-indexed-db.js'; + +vi.mock('../../src/storage/repo-manager.js', () => ({ + listRegisteredRepos: vi.fn().mockResolvedValue([]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), +})); + +// Mirrors the exact graph structure from issue #480: +// SessionTracker: 1 prod caller + 4 test callers via Constructor +// 1 importer via File +// RankPermissionHandler: 1 caller via Constructor, 10 importers via File +const SEED = [ + // ── SessionTracker ────────────────────────────────────────────────── + `CREATE (f:File {id: 'file:SessionTracker.java', name: 'SessionTracker.java', filePath: 'api/session/SessionTracker.java', content: ''})`, + `CREATE (c:Class {id: 'class:SessionTracker', name: 'SessionTracker', filePath: 'api/session/SessionTracker.java', startLine: 1, endLine: 80, isExported: true, content: 'class SessionTracker {}', description: 'Session tracker'})`, + `CREATE (ctor:Constructor {id: 'ctor:SessionTracker', name: 'SessionTracker', filePath: 'api/session/SessionTracker.java', startLine: 10, endLine: 15, content: 'SessionTracker() {}', description: ''})`, + + // 1 production caller + `CREATE (m1:Method {id: 'method:registerSessionTracker', name: 'registerSessionTracker', filePath: 'core/bootstrap/ServerBootstrap.java', startLine: 20, endLine: 30, isExported: false, content: '', description: ''})`, + // 4 test callers — use src/test/java/... paths so isTestFilePath() filters them + `CREATE (m2:Method {id: 'method:setUp', name: 'setUp', filePath: 'src/test/java/api/session/SessionTrackerTest.java', startLine: 5, endLine: 10, isExported: false, content: '', description: ''})`, + `CREATE (m3:Method {id: 'method:constructor_nullGameMode_accepted', name: 'constructor_nullGameMode_accepted', filePath: 'src/test/java/api/session/SessionTrackerTest.java', startLine: 15, endLine: 22, isExported: false, content: '', description: ''})`, + `CREATE (m4:Method {id: 'method:constructor_nullServerId_accepted', name: 'constructor_nullServerId_accepted', filePath: 'src/test/java/api/session/SessionTrackerTest.java', startLine: 24, endLine: 31, isExported: false, content: '', description: ''})`, + `CREATE (m5:Method {id: 'method:startPlayerSession_passesGameModeAndServerId', name: 'startPlayerSession_passesGameModeAndServerId', filePath: 'src/test/java/api/session/SessionTrackerTest.java', startLine: 33, endLine: 42, isExported: false, content: '', description: ''})`, + + // 1 importer file + `CREATE (f2:File {id: 'file:ServerBootstrap.java', name: 'ServerBootstrap.java', filePath: 'core/bootstrap/ServerBootstrap.java', content: ''})`, + + // CALLS → Constructor (Java graph structure — NOT to Class) + `MATCH (a:Method {id:'method:registerSessionTracker'}), (b:Constructor {id:'ctor:SessionTracker'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.9, reason:'direct', step:0}]->(b)`, + `MATCH (a:Method {id:'method:setUp'}), (b:Constructor {id:'ctor:SessionTracker'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.9, reason:'direct', step:0}]->(b)`, + `MATCH (a:Method {id:'method:constructor_nullGameMode_accepted'}), (b:Constructor {id:'ctor:SessionTracker'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.9, reason:'direct', step:0}]->(b)`, + `MATCH (a:Method {id:'method:constructor_nullServerId_accepted'}), (b:Constructor {id:'ctor:SessionTracker'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.9, reason:'direct', step:0}]->(b)`, + `MATCH (a:Method {id:'method:startPlayerSession_passesGameModeAndServerId'}), (b:Constructor {id:'ctor:SessionTracker'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.9, reason:'direct', step:0}]->(b)`, + + // IMPORTS → File (Java graph structure — NOT to Class) + `MATCH (a:File {id:'file:ServerBootstrap.java'}), (b:File {id:'file:SessionTracker.java'}) CREATE (a)-[:CodeRelation {type:'IMPORTS', confidence:0.9, reason:'import', step:0}]->(b)`, + + // Class structure edges + `MATCH (c:Class {id:'class:SessionTracker'}), (ctor:Constructor {id:'ctor:SessionTracker'}) CREATE (c)-[:CodeRelation {type:'HAS_METHOD', confidence:1.0, reason:'class-method', step:0}]->(ctor)`, + `MATCH (f:File {id:'file:SessionTracker.java'}), (c:Class {id:'class:SessionTracker'}) CREATE (f)-[:CodeRelation {type:'DEFINES', confidence:1.0, reason:'', step:0}]->(c)`, + + // ── RankPermissionHandler ─────────────────────────────────────────── + `CREATE (f3:File {id: 'file:RankPermissionHandler.java', name: 'RankPermissionHandler.java', filePath: 'core/rank/RankPermissionHandler.java', content: ''})`, + `CREATE (c2:Class {id: 'class:RankPermissionHandler', name: 'RankPermissionHandler', filePath: 'core/rank/RankPermissionHandler.java', startLine: 1, endLine: 60, isExported: true, content: 'class RankPermissionHandler {}', description: ''})`, + `CREATE (ctor2:Constructor {id: 'ctor:RankPermissionHandler', name: 'RankPermissionHandler', filePath: 'core/rank/RankPermissionHandler.java', startLine: 5, endLine: 10, content: '', description: ''})`, + + // 1 caller via Constructor + `CREATE (m6:Method {id: 'method:initRankHandler', name: 'initRankHandler', filePath: 'core/rank/RankService.java', startLine: 10, endLine: 20, isExported: false, content: '', description: ''})`, + `MATCH (a:Method {id:'method:initRankHandler'}), (b:Constructor {id:'ctor:RankPermissionHandler'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.9, reason:'direct', step:0}]->(b)`, + + // 10 importers via File + `CREATE (fi1:File {id:'file:imp1.java', name:'RankCommand.java', filePath:'core/rank/RankCommand.java', content:''})`, + `CREATE (fi2:File {id:'file:imp2.java', name:'RankListener.java', filePath:'core/rank/RankListener.java', content:''})`, + `CREATE (fi3:File {id:'file:imp3.java', name:'RankManager.java', filePath:'core/rank/RankManager.java', content:''})`, + `CREATE (fi4:File {id:'file:imp4.java', name:'RankConfig.java', filePath:'core/rank/RankConfig.java', content:''})`, + `CREATE (fi5:File {id:'file:imp5.java', name:'RankAPI.java', filePath:'core/rank/RankAPI.java', content:''})`, + `CREATE (fi6:File {id:'file:imp6.java', name:'RankTest1.java', filePath:'src/test/java/core/rank/RankTest1.java', content:''})`, + `CREATE (fi7:File {id:'file:imp7.java', name:'RankTest2.java', filePath:'src/test/java/core/rank/RankTest2.java', content:''})`, + `CREATE (fi8:File {id:'file:imp8.java', name:'RankTest3.java', filePath:'src/test/java/core/rank/RankTest3.java', content:''})`, + `CREATE (fi9:File {id:'file:imp9.java', name:'RankTest4.java', filePath:'src/test/java/core/rank/RankTest4.java', content:''})`, + `CREATE (fi10:File {id:'file:imp10.java', name:'RankTest5.java', filePath:'src/test/java/core/rank/RankTest5.java', content:''})`, + `MATCH (a:File {id:'file:imp1.java'}), (b:File {id:'file:RankPermissionHandler.java'}) CREATE (a)-[:CodeRelation {type:'IMPORTS', confidence:0.9, reason:'import', step:0}]->(b)`, + `MATCH (a:File {id:'file:imp2.java'}), (b:File {id:'file:RankPermissionHandler.java'}) CREATE (a)-[:CodeRelation {type:'IMPORTS', confidence:0.9, reason:'import', step:0}]->(b)`, + `MATCH (a:File {id:'file:imp3.java'}), (b:File {id:'file:RankPermissionHandler.java'}) CREATE (a)-[:CodeRelation {type:'IMPORTS', confidence:0.9, reason:'import', step:0}]->(b)`, + `MATCH (a:File {id:'file:imp4.java'}), (b:File {id:'file:RankPermissionHandler.java'}) CREATE (a)-[:CodeRelation {type:'IMPORTS', confidence:0.9, reason:'import', step:0}]->(b)`, + `MATCH (a:File {id:'file:imp5.java'}), (b:File {id:'file:RankPermissionHandler.java'}) CREATE (a)-[:CodeRelation {type:'IMPORTS', confidence:0.9, reason:'import', step:0}]->(b)`, + `MATCH (a:File {id:'file:imp6.java'}), (b:File {id:'file:RankPermissionHandler.java'}) CREATE (a)-[:CodeRelation {type:'IMPORTS', confidence:0.9, reason:'import', step:0}]->(b)`, + `MATCH (a:File {id:'file:imp7.java'}), (b:File {id:'file:RankPermissionHandler.java'}) CREATE (a)-[:CodeRelation {type:'IMPORTS', confidence:0.9, reason:'import', step:0}]->(b)`, + `MATCH (a:File {id:'file:imp8.java'}), (b:File {id:'file:RankPermissionHandler.java'}) CREATE (a)-[:CodeRelation {type:'IMPORTS', confidence:0.9, reason:'import', step:0}]->(b)`, + `MATCH (a:File {id:'file:imp9.java'}), (b:File {id:'file:RankPermissionHandler.java'}) CREATE (a)-[:CodeRelation {type:'IMPORTS', confidence:0.9, reason:'import', step:0}]->(b)`, + `MATCH (a:File {id:'file:imp10.java'}), (b:File {id:'file:RankPermissionHandler.java'}) CREATE (a)-[:CodeRelation {type:'IMPORTS', confidence:0.9, reason:'import', step:0}]->(b)`, + + `MATCH (c2:Class {id:'class:RankPermissionHandler'}), (ctor2:Constructor {id:'ctor:RankPermissionHandler'}) CREATE (c2)-[:CodeRelation {type:'HAS_METHOD', confidence:1.0, reason:'class-method', step:0}]->(ctor2)`, + `MATCH (f3:File {id:'file:RankPermissionHandler.java'}), (c2:Class {id:'class:RankPermissionHandler'}) CREATE (f3)-[:CodeRelation {type:'DEFINES', confidence:1.0, reason:'', step:0}]->(c2)`, +]; + +withTestLbugDB('java-class-impact', (handle) => { + + // ─── Confirm root cause is present in graph ───────────────────────── + + describe('root cause confirmed: Java graph edge structure', () => { + it('CALLS edges go to Constructor, not Class — so naive Class traversal finds 0', async () => { + const onClass = await executeQuery(handle.repoId, + `MATCH (a)-[r:CodeRelation {type:'CALLS'}]->(b:Class {name:'SessionTracker'}) RETURN a.name AS name`, + ); + expect(onClass).toHaveLength(0); // this is the bug — Class has no CALLS edges + + const onCtor = await executeQuery(handle.repoId, + `MATCH (a)-[r:CodeRelation {type:'CALLS'}]->(b:Constructor {name:'SessionTracker'}) RETURN a.name AS name`, + ); + expect(onCtor).toHaveLength(5); // all 5 callers are on Constructor + }); + + it('IMPORTS edges go to File, not Class', async () => { + const onClass = await executeQuery(handle.repoId, + `MATCH (a)-[r:CodeRelation {type:'IMPORTS'}]->(b:Class {name:'SessionTracker'}) RETURN a.name AS name`, + ); + expect(onClass).toHaveLength(0); + + const onFile = await executeQuery(handle.repoId, + `MATCH (a)-[r:CodeRelation {type:'IMPORTS'}]->(b:File {name:'SessionTracker.java'}) RETURN a.name AS name`, + ); + expect(onFile).toHaveLength(1); // ServerBootstrap.java + }); + }); + + // ─── Bug 1: impact() fix ──────────────────────────────────────────── + + describe('Bug 1 fix: impact(upstream) on Class returns callers', () => { + let backend: LocalBackend; + beforeAll(async () => { backend = (handle as any)._backend; }); + + it('default call (no includeTests) finds the 1 production caller and excludes test callers', async () => { + // Exact call from the issue: gitnexus_impact({target: "SessionTracker", direction: "upstream"}) + // Before fix: impactedCount: 0, risk: LOW, byDepth: {} + const result = await backend.callTool('impact', { + target: 'SessionTracker', + direction: 'upstream', + }); + + expect(result).not.toHaveProperty('error'); + // At minimum: 1 production caller (registerSessionTracker) + 1 file + // importer (ServerBootstrap.java discovered via BFS from the seeded File) + expect(result.impactedCount).toBeGreaterThanOrEqual(2); + + const d1 = result.byDepth[1] || result.byDepth['1'] || []; + const names = d1.map((d: any) => d.name); + + // Production caller must be present + expect(names).toContain('registerSessionTracker'); + + // Test callers must be excluded (paths match /test/ via isTestFilePath) + expect(names).not.toContain('setUp'); + expect(names).not.toContain('constructor_nullGameMode_accepted'); + expect(names).not.toContain('constructor_nullServerId_accepted'); + expect(names).not.toContain('startPlayerSession_passesGameModeAndServerId'); + }); + + it('with includeTests finds all 5 callers (1 prod + 4 tests)', async () => { + const result = await backend.callTool('impact', { + target: 'SessionTracker', + direction: 'upstream', + includeTests: true, + }); + + expect(result).not.toHaveProperty('error'); + expect(result.impactedCount).toBeGreaterThanOrEqual(5); + + const d1 = result.byDepth[1] || result.byDepth['1'] || []; + const names = d1.map((d: any) => d.name); + expect(names).toContain('registerSessionTracker'); + expect(names).toContain('setUp'); + expect(names).toContain('constructor_nullGameMode_accepted'); + expect(names).toContain('constructor_nullServerId_accepted'); + expect(names).toContain('startPlayerSession_passesGameModeAndServerId'); + + // Owning file (SessionTracker.java) must NOT appear — it is the + // definition container, not an upstream dependent (#480 Copilot review) + const allNames = Object.values(result.byDepth as Record) + .flat().map((d: any) => d.name); + expect(allNames).not.toContain('SessionTracker.java'); + }); + + it('RankPermissionHandler: 1 caller via Constructor + 10 importers via File (was 0 before fix)', async () => { + const result = await backend.callTool('impact', { + target: 'RankPermissionHandler', + direction: 'upstream', + includeTests: true, + }); + + expect(result).not.toHaveProperty('error'); + // 1 caller (depth 1 via Constructor) + 10 importers (depth 2 via File) + expect(result.impactedCount).toBeGreaterThanOrEqual(11); + + const allNames = Object.values(result.byDepth as Record) + .flat().map((d: any) => d.name); + expect(allNames).toContain('initRankHandler'); + expect(allNames).toContain('RankCommand.java'); + expect(allNames).toContain('RankManager.java'); + + // Owning file must NOT appear in results + expect(allNames).not.toContain('RankPermissionHandler.java'); + }); + + it('RankPermissionHandler: default call (no includeTests) excludes test importers', async () => { + const result = await backend.callTool('impact', { + target: 'RankPermissionHandler', + direction: 'upstream', + }); + + expect(result).not.toHaveProperty('error'); + + const allNames = Object.values(result.byDepth as Record) + .flat().map((d: any) => d.name); + + // Production importers must be present + expect(allNames).toContain('RankCommand.java'); + + // Test importers (src/test/java/... paths) must be excluded + expect(allNames).not.toContain('RankTest1.java'); + expect(allNames).not.toContain('RankTest2.java'); + expect(allNames).not.toContain('RankTest3.java'); + expect(allNames).not.toContain('RankTest4.java'); + expect(allNames).not.toContain('RankTest5.java'); + }); + }); + + // ─── Bug 2: context() fix ─────────────────────────────────────────── + + describe('Bug 2 fix: context() on Class shows non-empty incoming', () => { + let backend: LocalBackend; + beforeAll(async () => { backend = (handle as any)._backend; }); + + it('incoming.calls contains Constructor callers (was empty before fix)', async () => { + // Exact call from issue: gitnexus_context({name: "SessionTracker", file_path: "api/.../SessionTracker.java"}) + // Before fix: incoming: {} + const result = await backend.callTool('context', { + name: 'SessionTracker', + file_path: 'api/session/SessionTracker.java', + }); + + expect(result.status).toBe('found'); + expect(result.symbol.kind).toBe('Class'); + expect(result.incoming).toBeDefined(); + + // Should have calls from the Constructor callers + const calls = result.incoming.calls || []; + expect(calls.length).toBeGreaterThanOrEqual(1); + const callerNames = calls.map((c: any) => c.name); + expect(callerNames).toContain('registerSessionTracker'); + }); + + it('incoming.imports contains File importers', async () => { + const result = await backend.callTool('context', { + name: 'SessionTracker', + file_path: 'api/session/SessionTracker.java', + }); + + expect(result.status).toBe('found'); + const imports = result.incoming.imports || []; + expect(imports.length).toBeGreaterThanOrEqual(1); + const importerNames = imports.map((c: any) => c.name); + expect(importerNames).toContain('ServerBootstrap.java'); + }); + + it('contrast: context() on a Method still works (regression check)', async () => { + // The issue notes context() on methods worked before — must still work after fix + const result = await backend.callTool('context', { + name: 'registerSessionTracker', + }); + expect(result.status).toBe('found'); + // registerSessionTracker calls SessionTracker constructor — should appear in outgoing + expect(result.outgoing).toBeDefined(); + }); + + it('RankPermissionHandler: incoming shows caller and importers', async () => { + const result = await backend.callTool('context', { + name: 'RankPermissionHandler', + file_path: 'core/rank/RankPermissionHandler.java', + }); + + expect(result.status).toBe('found'); + expect(result.symbol.kind).toBe('Class'); + + const calls = result.incoming.calls || []; + expect(calls.map((c: any) => c.name)).toContain('initRankHandler'); + + const imports = result.incoming.imports || []; + expect(imports.length).toBeGreaterThanOrEqual(10); + }); + }); + +}, { + seed: SEED, + poolAdapter: true, + afterSetup: async (handle) => { + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: 'test-repo', + path: '/test/repo', + storagePath: handle.tmpHandle.dbPath, + indexedAt: new Date().toISOString(), + lastCommit: 'abc123', + stats: { files: 10, nodes: 20, communities: 0, processes: 0 }, + }, + ]); + const backend = new LocalBackend(); + await backend.init(); + (handle as any)._backend = backend; + }, +});