mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
This commit is contained in:
parent
7999b6ba7b
commit
a191c26571
3 changed files with 721 additions and 18 deletions
|
|
@ -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,
|
||||
|
|
|
|||
210
gitnexus/test/integration/class-impact-all-languages.test.ts
Normal file
210
gitnexus/test/integration/class-impact-all-languages.test.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
306
gitnexus/test/integration/java-class-impact.test.ts
Normal file
306
gitnexus/test/integration/java-class-impact.test.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue