fix(resolution): Java/Kotlin imports disambiguate same-name classes (#314) (#472)

A Maven multi-module project where `dao/converter/FooConverter` and
`service/converter/FooConverter` both expose a `convert` method used to
resolve by file-path proximity — picking whichever class was closer to
the caller, which is wrong any time the caller lives in an equidistant
cross-cutting module. `extractImportMappings` had no Java branch at all,
so the FQN signal Java imports carry — `import
com.example.dao.converter.FooConverter;` — was thrown away.

- `extractJavaImports` parses regular and `import static` directives;
  wildcard imports (`*`) are intentionally skipped.
- `resolveViaImport` has a new Java/Kotlin cross-file branch that
  converts the imported FQN to a file-path suffix
  (`com/example/dao/converter/FooConverter.java`, or `.kt`) and
  resolves the symbol against the file whose path matches by suffix.
- For the field-receiver pattern (`@Autowired private FooConverter
  fooConverter; fooConverter.convert(...)`), `matchMethodCall` now
  looks up the receiver's inferred type in the caller file's imports
  and threads the resulting FQN through to `resolveMethodOnType`.
  When two `FooConverter::convert` candidates exist, the import — not
  iteration order — picks the right one.

Validated with a synthetic 3-module repro: swapping only the import
line on the caller swaps the resolved target between dao and service.

spring-petclinic (47 .java files): +15 newly import-resolved edges,
+2 references, no regression elsewhere.

Closes #314.
This commit is contained in:
Colby Mchenry
2026-05-26 17:42:14 -05:00
committed by GitHub
parent 186632fa88
commit 8c69001289
4 changed files with 228 additions and 2 deletions
+133
View File
@@ -232,6 +232,8 @@ export function extractImportMappings(
mappings.push(...extractPythonImports(content));
} else if (language === 'go') {
mappings.push(...extractGoImports(content));
} else if (language === 'java' || language === 'kotlin') {
mappings.push(...extractJavaImports(content));
} else if (language === 'php') {
mappings.push(...extractPHPImports(content));
}
@@ -441,6 +443,49 @@ function extractGoImports(content: string): ImportMapping[] {
return mappings;
}
/**
* Extract Java / Kotlin import mappings.
*
* Java/Kotlin imports carry the full qualified name of the imported
* symbol — `import com.example.dao.converter.FooConverter;` — which is
* exactly the disambiguation signal we need when two packages both
* declare a `FooConverter`. Pre-#314 the resolver had no Java branch
* here at all, so this mapping was empty and cross-module name
* collisions were resolved by file-path proximity (often wrongly).
*
* `import static com.example.Foo.bar;` is parsed as a local-name `bar`
* pointing at FQN `com.example.Foo.bar` so static-method call sites
* (`bar(...)`) can resolve through the same import lookup.
*/
function extractJavaImports(content: string): ImportMapping[] {
const mappings: ImportMapping[] = [];
// Strip line and block comments so `// import foo;` doesn't false-match.
const stripped = content
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/[^\n]*/g, '');
// `import [static] <fqn>[.*];`
const re = /^\s*import\s+(static\s+)?([\w.]+(?:\.\*)?)\s*;/gm;
let match: RegExpExecArray | null;
while ((match = re.exec(stripped)) !== null) {
const fqn = match[2]!;
// `import com.example.*;` — wildcard. We can't materialize a single
// local name; skip and let name-matching handle members reachable
// through the wildcard. (Future enhancement: enumerate package files.)
if (fqn.endsWith('.*')) continue;
const parts = fqn.split('.');
const localName = parts[parts.length - 1];
if (!localName) continue;
mappings.push({
localName,
exportedName: localName,
source: fqn,
isDefault: false,
isNamespace: false,
});
}
return mappings;
}
/**
* Extract PHP import mappings (use statements)
*/
@@ -619,6 +664,17 @@ export function resolveViaImport(
if (goResult) return goResult;
}
// Java / Kotlin: imports are FQNs (`import com.example.Foo;`) — no
// resolvable file path the JS/TS-style chain below could follow. Look
// up the symbol by name and filter to the candidate whose file path
// matches the imported FQN. This is the disambiguation signal that
// breaks the same-name class collision the path-proximity matcher
// can't resolve (issue #314).
if (ref.language === 'java' || ref.language === 'kotlin') {
const javaResult = resolveJavaImportedReference(ref, imports, context);
if (javaResult) return javaResult;
}
// Check if the reference name matches any import
for (const imp of imports) {
if (imp.localName === ref.referenceName || ref.referenceName.startsWith(imp.localName + '.')) {
@@ -659,6 +715,83 @@ export function resolveViaImport(
return null;
}
/**
* Resolve a Java/Kotlin reference whose receiver is the simple name of
* an imported FQN: `Foo.bar(...)` where `import com.example.Foo;`. The
* imported FQN converts to a file-path suffix (`com/example/Foo.java`
* or `.kt`) which uniquely identifies the right symbol when multiple
* classes share the same simple name.
*
* Also handles bare references to the imported class itself
* (`new Foo()` extraction emits `Foo` as a `references`/`instantiates`
* ref) and `import static <Foo>.bar` style imports of a single member.
*/
function resolveJavaImportedReference(
ref: UnresolvedRef,
imports: ImportMapping[],
context: ResolutionContext
): ResolvedRef | null {
if (imports.length === 0) return null;
const ext = ref.language === 'kotlin' ? '.kt' : '.java';
for (const imp of imports) {
const matchesBare = imp.localName === ref.referenceName;
const matchesQualified = ref.referenceName.startsWith(imp.localName + '.');
if (!matchesBare && !matchesQualified) continue;
// Convert FQN to a file-path suffix. `com.example.Foo` ->
// `com/example/Foo.java` (or `.kt`). The actual file may live
// under any source root (`src/main/java/`, `src/`, etc.), so match
// by suffix rather than exact path.
const fqnPath = imp.source.replace(/\./g, '/') + ext;
// Which symbol name to look up: the class itself, or a member.
const memberName = matchesBare
? imp.localName
: ref.referenceName.substring(imp.localName.length + 1);
const candidates = context.getNodesByName(memberName);
for (const node of candidates) {
if (node.language !== ref.language) continue;
const fp = node.filePath.replace(/\\/g, '/');
if (fp.endsWith(fqnPath) || fp.endsWith('/' + fqnPath)) {
return {
original: ref,
targetNodeId: node.id,
confidence: 0.9,
resolvedBy: 'import',
};
}
}
// `import static com.example.Foo.bar;` — the FQN's tail is the
// member name, the part before is the owner class. Look up the
// member named `<imp.localName>` (e.g. `bar`) and prefer the
// candidate whose file matches the parent FQN's path.
if (matchesBare) {
const dot = imp.source.lastIndexOf('.');
if (dot > 0) {
const ownerFqn = imp.source.substring(0, dot);
const ownerPath = ownerFqn.replace(/\./g, '/') + ext;
for (const node of candidates) {
if (node.language !== ref.language) continue;
const fp = node.filePath.replace(/\\/g, '/');
if (fp.endsWith(ownerPath) || fp.endsWith('/' + ownerPath)) {
return {
original: ref,
targetNodeId: node.id,
confidence: 0.9,
resolvedBy: 'import',
};
}
}
}
}
}
return null;
}
/**
* Resolve a Go cross-package qualified reference (`pkga.FuncX`) by matching
* the package alias against an in-module import, stripping the module prefix
+36 -2
View File
@@ -153,6 +153,15 @@ function resolveMethodOnType(
context: ResolutionContext,
confidence: number,
resolvedBy: ResolvedRef['resolvedBy'],
/**
* Optional FQN that identifies WHICH class declaration `typeName`
* refers to in the caller's file. When multiple candidates share
* the same qualifiedName (`FooConverter::convert` in both
* `dao/converter/` and `service/converter/`), the FQN's
* file-path-suffix picks the right one — the disambiguation
* signal Java imports carry but the call site doesn't (#314).
*/
preferredFqn?: string,
): ResolvedRef | null {
// Look up methods by name and match by qualifiedName ending in
// `<typeName>::<methodName>`. This works whether the method is defined
@@ -161,21 +170,40 @@ function resolveMethodOnType(
// The previous same-file approach missed the latter — the typical C++ layout.
const methodCandidates = context.getNodesByName(methodName);
const want = `${typeName}::${methodName}`;
const matches: Node[] = [];
for (const m of methodCandidates) {
if (m.kind !== 'method') continue;
if (m.language !== ref.language) continue;
const qn = m.qualifiedName;
if (qn === want || qn.endsWith(`::${want}`)) {
matches.push(m);
}
}
if (matches.length === 0) return null;
if (matches.length > 1 && preferredFqn) {
const ext = ref.language === 'kotlin' ? '.kt' : '.java';
const fqnPath = preferredFqn.replace(/\./g, '/') + ext;
const chosen = matches.find((m) => {
const fp = m.filePath.replace(/\\/g, '/');
return fp.endsWith(fqnPath) || fp.endsWith('/' + fqnPath);
});
if (chosen) {
return {
original: ref,
targetNodeId: m.id,
targetNodeId: chosen.id,
confidence,
resolvedBy,
};
}
}
return null;
return {
original: ref,
targetNodeId: matches[0]!.id,
confidence,
resolvedBy,
};
}
// C++ keywords/control-flow tokens that can appear right before a receiver
@@ -365,6 +393,11 @@ export function matchMethodCall(
if ((ref.language === 'java' || ref.language === 'kotlin') && dotMatch) {
const inferredType = inferJavaFieldReceiverType(objectOrClass!, ref, context);
if (inferredType) {
// When two classes share the same simple name, the caller file's
// import is the only signal that names WHICH one — pass the
// imported FQN so resolveMethodOnType can disambiguate (#314).
const imports = context.getImportMappings(ref.filePath, ref.language);
const importedFqn = imports.find((i) => i.localName === inferredType)?.source;
const typedMatch = resolveMethodOnType(
inferredType,
methodName!,
@@ -372,6 +405,7 @@ export function matchMethodCall(
context,
0.9,
'instance-method',
importedFqn,
);
if (typedMatch) {
return typedMatch;