diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index c70eacb10..20755d7cd 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -27,6 +27,7 @@ import { javaMethodConfig } from '../method-extractors/configs/jvm.js'; import { createVariableExtractor } from '../variable-extractors/generic.js'; import { javaVariableConfig } from '../variable-extractors/configs/jvm.js'; import { createHeritageExtractor } from '../heritage-extractors/generic.js'; +import type { SymbolDefinition } from 'gitnexus-shared'; import { emitJavaScopeCaptures, interpretJavaImport, @@ -39,6 +40,48 @@ import { resolveJavaImportTarget, } from './java/index.js'; +const orderJavaSameNameTypeCandidates = ({ + callSiteFilePath, + candidates, +}: { + readonly typeName: string; + readonly callSiteFilePath: string; + readonly candidates: readonly SymbolDefinition[]; +}): readonly SymbolDefinition[] | null => { + if (!callSiteFilePath.endsWith('.java')) return null; + if (candidates.length <= 1) return null; + const callerDir = splitDirectorySegments(callSiteFilePath); + + const scored = candidates.map((candidate, index) => ({ + candidate, + index, + score: sharedPrefixLength(callerDir, splitDirectorySegments(candidate.filePath)), + })); + const bestScore = Math.max(...scored.map((entry) => entry.score)); + // When all candidates tie, we have no structural signal to prefer one path. + // Returning null keeps downstream ambiguity handling conservative. + if (scored.every((entry) => entry.score === bestScore)) return null; + + const ordered = [...scored] + .sort((a, b) => b.score - a.score || a.index - b.index) + .map((entry) => entry.candidate); + return ordered; +}; + +const splitDirectorySegments = (filePath: string): string[] => { + const normalized = filePath.replace(/\\/g, '/'); + // Remove empty segments from leading/trailing/multiple slashes, then drop filename. + const segments = normalized.split('/').filter(Boolean); + return segments.slice(0, -1); +}; + +const sharedPrefixLength = (left: readonly string[], right: readonly string[]): number => { + const max = Math.min(left.length, right.length); + let idx = 0; + while (idx < max && left[idx] === right[idx]) idx += 1; + return idx; +}; + export const javaProvider = defineLanguage({ id: SupportedLanguages.Java, extensions: ['.java'], @@ -87,4 +130,5 @@ export const javaProvider = defineLanguage({ receiverBinding: javaReceiverBinding, arityCompatibility: javaArityCompatibility, resolveImportTarget: resolveJavaImportTarget, + orderSameNameTypeCandidates: orderJavaSameNameTypeCandidates, }); diff --git a/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module1/src/main/java/com/example/Module1App.java b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module1/src/main/java/com/example/Module1App.java new file mode 100644 index 000000000..242956497 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module1/src/main/java/com/example/Module1App.java @@ -0,0 +1,8 @@ +package com.example; + +public class Module1App { + public void run() { + UserService service = new UserService(); + service.ping(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module1/src/main/java/com/example/UserService.java b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module1/src/main/java/com/example/UserService.java new file mode 100644 index 000000000..9daea3d45 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module1/src/main/java/com/example/UserService.java @@ -0,0 +1,6 @@ +package com.example; + +public class UserService { + public void ping() { + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module2/src/main/java/com/example/Module2App.java b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module2/src/main/java/com/example/Module2App.java new file mode 100644 index 000000000..7d24142b8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module2/src/main/java/com/example/Module2App.java @@ -0,0 +1,8 @@ +package com.example; + +public class Module2App { + public void run() { + UserService service = new UserService(); + service.ping(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module2/src/main/java/com/example/UserService.java b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module2/src/main/java/com/example/UserService.java new file mode 100644 index 000000000..9daea3d45 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module2/src/main/java/com/example/UserService.java @@ -0,0 +1,6 @@ +package com.example; + +public class UserService { + public void ping() { + } +} diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index cc2697849..bd5eaa474 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -34,6 +34,13 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly { }); }); +describe('Java same-module priority for duplicate FQNs', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'java-duplicate-fqn-modules'), () => {}); + }, 60000); + + it('resolves Module1App.run calls to module1 UserService, not module2', () => { + const calls = getRelationships(result, 'CALLS'); + const module1ToModule1 = calls.filter( + (c) => + c.source === 'run' && + c.target === 'UserService' && + c.sourceFilePath === 'module1/src/main/java/com/example/Module1App.java' && + c.targetFilePath === 'module1/src/main/java/com/example/UserService.java', + ); + const module1ToModule2 = calls.filter( + (c) => + c.source === 'run' && + c.target === 'UserService' && + c.sourceFilePath === 'module1/src/main/java/com/example/Module1App.java' && + c.targetFilePath === 'module2/src/main/java/com/example/UserService.java', + ); + const module1ToAnyUserService = calls.filter( + (c) => + c.source === 'run' && + c.target === 'UserService' && + c.sourceFilePath === 'module1/src/main/java/com/example/Module1App.java' && + /module[12]\/src\/main\/java\/com\/example\/UserService\.java/.test(c.targetFilePath), + ); + + expect(module1ToModule1.length).toBe(1); + expect(module1ToModule2.length).toBe(0); + expect(module1ToAnyUserService.length).toBe(1); + }); + + it('resolves Module2App.run calls to module2 UserService, not module1', () => { + const calls = getRelationships(result, 'CALLS'); + const module2ToModule2 = calls.filter( + (c) => + c.source === 'run' && + c.target === 'UserService' && + c.sourceFilePath === 'module2/src/main/java/com/example/Module2App.java' && + c.targetFilePath === 'module2/src/main/java/com/example/UserService.java', + ); + const module2ToModule1 = calls.filter( + (c) => + c.source === 'run' && + c.target === 'UserService' && + c.sourceFilePath === 'module2/src/main/java/com/example/Module2App.java' && + c.targetFilePath === 'module1/src/main/java/com/example/UserService.java', + ); + const module2ToAnyUserService = calls.filter( + (c) => + c.source === 'run' && + c.target === 'UserService' && + c.sourceFilePath === 'module2/src/main/java/com/example/Module2App.java' && + /module[12]\/src\/main\/java\/com\/example\/UserService\.java/.test(c.targetFilePath), + ); + + expect(module2ToModule2.length).toBe(1); + expect(module2ToModule1.length).toBe(0); + expect(module2ToAnyUserService.length).toBe(1); + }); +}); + // --------------------------------------------------------------------------- // Member-call resolution: obj.method() resolves through pipeline // ---------------------------------------------------------------------------