fix(#480): resolve impact/context returning empty results for Java cl… (#489)

This commit is contained in:
Yash 2026-03-25 03:16:46 +05:30 committed by GitHub
parent 7999b6ba7b
commit a191c26571
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 721 additions and 18 deletions

View file

@ -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<string, { score: number; data: any }>();
@ -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<any[]> {
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<string>([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,

View file

@ -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<string, any[]>)
.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<string, any[]>)
.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;
},
});

View file

@ -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<string, any[]>)
.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<string, any[]>)
.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<string, any[]>)
.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;
},
});