GitNexus/gitnexus/test/integration/java-class-impact.test.ts
Copilot 962f22482b
Some checks are pending
CI / quality (push) Waiting to run
CI / tests (push) Waiting to run
CI / e2e (push) Waiting to run
CI / scope-parity (push) Waiting to run
CI / Save PR Metadata (push) Blocked by required conditions
CI / CI Gate (push) Blocked by required conditions
Release Candidate / Check if release candidate should run (push) Waiting to run
Release Candidate / ci (push) Blocked by required conditions
Release Candidate / Publish release candidate to npm (push) Blocked by required conditions
Release Candidate / Build & Push RC Docker images (push) Blocked by required conditions
feat(cli): Fingerprint indexed repos by remote URL to detect sibling-clone graph drift (#982)
* Initial plan

* feat: detect sibling-clone graph drift via remote URL fingerprint

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e5decb67-7fec-40e7-b2a1-b5e94a0d393f

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test: address review feedback — fake commit, same-commit case, regex docs

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e5decb67-7fec-40e7-b2a1-b5e94a0d393f

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(mcp): address review feedback — CI green, perf, dead branch, one-shot test

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/cc2259f7-94e4-4243-aaa9-e03b7c632d32

* Merge branch 'main' into copilot/fix-single-path-indexing-issue

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/5840b3dd-e879-4854-a067-d1622bec2634

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* Merge branch 'main' into copilot/fix-single-path-indexing-issue

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9025262f-4dd4-4774-8f32-e14434100004

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* style: prettier format run-analyze.ts after merge with main

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a7be18dd-102f-4a7b-ac56-53fbd414fe3b

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test: realpath both sides of cwdGitRoot assertion for Windows 8.3 short-name compat

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b2a1c6a3-e454-4b87-b0e4-69d7c0d9a51b

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(test): use path-agnostic assertion for cwdGitRoot on Windows (#1015)

git rev-parse --show-toplevel returns long path names on Windows
while os.tmpdir() returns 8.3 short names. fs.realpathSync does not
expand short names, so exact path comparison always fails on Windows
CI runners. Replace with behavioral assertions instead.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <copilot-swe-agent[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: evolution <wjc163@sina.cn>
2026-04-21 21:58:54 +01:00

320 lines
19 KiB
TypeScript

/**
* 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 }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
// 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;
},
},
);