fix(java): an import from outside the project owns its name (#2195)

A single-type or static import (`import java.lang.reflect.Field;`,
`import static org.mockito.Mockito.verify;`) whose package prefix is none of
the project's JVM packages now makes that name mean the outside class: no
reference, call or instantiation resolves it to a same-named project symbol.
Imports of the project's own classes - nested ones included - resolve as
before, since their package prefix is a project package.

Wrong edges removed: gson's production code -> a test's nested
`ParameterizedTypesTest.Field` (42), jsoup's W3C converter -> jsoup's own
`Document`/`Element`/`Node` (51, it imports org.w3c.dom), retrofit's
`java.lang.reflect.Type` -> a test class `Type` (204) and `org.junit.Rule` ->
test rules, commons-lang `IOException` -> a test's nested exception (233),
halo's Mockito `verify(...)` -> a code store's `verify`, and
`new TemplateEngine()` -> an application.yaml key.

gson -60, commons-lang -389, jsoup -51, retrofit -466, halo -83, mall
unchanged; no edges gained.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 19:33:00 +00:00
committed by GitHub
parent ad77e5fc8e
commit 81ce7e5695
3 changed files with 135 additions and 0 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- In Java, a class you import from outside your project now owns its name. After `import java.lang.reflect.Field;` the file's `Field` no longer links to a project class that happens to share the name. In gson that was a test's nested `Field` class; in jsoup's W3C converter it was jsoup's own `Document` and `Element`. A static import like `import static org.mockito.Mockito.verify;` is covered the same way.
- Java enum constants and Kotlin enum entries with a body of their own now have their methods in the graph. Examples are `PLUS { int apply(…) { … } }` and `NewBuffer { override fun pipe() … }`. Before, those methods were missing and every call inside them was dropped. jsoup's HTML tokenizer and tree builder are written this way, so over 2,000 of their calls were invisible. Re-index to pick this up.
- Scala calls no longer land on another test's local value. A `val f` written inside one `test("…") { }` block was being picked for a lambda parameter `f` in another suite, and even for `f(…)` in cats' core library. A package import like `import cats.syntax.all._` no longer puts every class's members in scope. Members that really are in scope now resolve: those of an imported package object's parent types (`import sttp.client4._` gives `SttpApi`'s `multipart`), and of the file's own package object.
- A barrel's `export * as core from './core'` now exports only `core`, not everything in `./core`. In zod, `z.string()` and `z.number()` had been landing on same-named regex constants deep in its core module instead of the schema functions. Calls through `import * as ns` also find members a barrel forwards by name (`export { safeParse } from '../core'`). They follow a namespace path like `z.coerce.number()` too.
+99
View File
@@ -0,0 +1,99 @@
/**
* A Java single-type import names the class it imports: after
* `import java.lang.reflect.Field;` the file's `Field` is the JDK's — gson's
* production code bound it to a test's nested `ParameterizedTypesTest.Field`,
* jsoup's W3C converter (`import org.w3c.dom.Document;`) to jsoup's own
* `Document`. A static import (`import static org.junit.Assert.assertEquals;`)
* likewise owns its name. Imports of the project's own classes — nested ones
* included — resolve as before.
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-java-imports-'));
const files: Record<string, string> = {
'src/test/java/app/functional/TypesTest.java': `package app.functional;
public class TypesTest {
static class Field {
String name;
}
static void assertEquals(Object a, Object b) { }
}
`,
'src/main/java/app/model/Outer.java': `package app.model;
public class Outer {
public static class Inner {
public static Inner create() { return new Inner(); }
}
}
`,
'src/main/java/app/Reflect.java': `package app;
import java.lang.reflect.Field;
import app.model.Outer;
import app.model.Outer.Inner;
public class Reflect {
String nameOf(Field field) {
return field.getName();
}
Outer.Inner make() {
return Inner.create();
}
}
`,
'src/test/java/app/ReflectTest.java': `package app;
import static org.junit.Assert.assertEquals;
public class ReflectTest {
void names() {
assertEquals("a", "a");
}
}
`,
};
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
/** `kind qualified-name` of every non-structural edge out of a file. */
function edgesFrom(file: string): string[] {
const ids = cg.getNodesInFile(file).map((n) => n.id);
return cg
.getOutgoingEdgesFrom(ids)
.filter((e) => e.kind !== 'contains' && e.kind !== 'imports')
.map((e) => `${e.kind} ${cg.getNode(e.target)!.qualifiedName}`)
.sort();
}
describe('Java single-type and static imports from outside the project', () => {
it('own their names; the project’s own imports still resolve', () => {
const edges = edgesFrom('src/main/java/app/Reflect.java');
expect(edges).not.toContain('references app.functional::TypesTest::Field');
expect(edges).toContain('calls app.model::Outer::Inner::create');
});
it('a static import’s method is not a project method of that name', () => {
expect(edgesFrom('src/test/java/app/ReflectTest.java')).not.toContain('calls app.functional::TypesTest::assertEquals');
});
});
+35
View File
@@ -929,6 +929,33 @@ function isLuaLocal(candidate: Node, context: ResolutionContext): boolean {
return local;
}
/** Per context: every package the project's JVM sources declare. */
const JVM_PACKAGES = new WeakMap<ResolutionContext, Set<string>>();
/**
* Whether a Java file binds `name` with a single-type (or static) import from
* a package the project does not declare — `import java.lang.reflect.Field;`,
* `import static org.junit.Assert.assertEquals;`. A nested class of a project
* type (`import com.acme.Outer.Inner;`) is under a project package, so it is not.
*/
function isJavaOutsideImport(name: string, ref: UnresolvedRef, context: ResolutionContext): boolean {
const binding = context.getImportMappings(ref.filePath, 'java').find((m) => m.localName === name);
if (!binding) return false;
let packages = JVM_PACKAGES.get(context);
if (!packages) {
packages = new Set<string>();
for (const n of context.getNodesByKind('namespace')) {
if (n.language === 'java' || n.language === 'kotlin' || n.language === 'scala') packages.add(n.qualifiedName);
}
JVM_PACKAGES.set(context, packages);
}
const parts = binding.source.split('.');
for (let i = 1; i < parts.length; i++) {
if (packages.has(parts.slice(0, i).join('.'))) return false;
}
return true;
}
/** Lua's global functions, and the test runner's: `local type = type` is the standard library's `type`. */
const LUA_GLOBAL_FUNCTIONS: ReadonlySet<string> = new Set([
'assert', 'error', 'ipairs', 'pairs', 'next', 'type', 'tostring', 'tonumber', 'setmetatable', 'getmetatable',
@@ -4925,6 +4952,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
ESM_EXPORT_LISTS.delete(context);
LUA_LOCALS.delete(context);
LUA_MEMBERS.delete(context);
JVM_PACKAGES.delete(context);
PHP_FILE_SCOPES.delete(context);
JAVA_STATIC_IMPORTS.delete(context);
PY_IMPORTS.delete(context);
@@ -7671,6 +7699,13 @@ function matchReferenceInner(
};
}
// `import java.lang.reflect.Field;` — the file's `Field` is the JDK's, never
// a project class of that name (gson's production code bound it to a test's
// nested `ParameterizedTypesTest.Field`).
if (ref.language === 'java' && ref.referenceKind !== 'imports' && isJavaOutsideImport(ref.referenceName.split('.')[0]!, ref, context)) {
return null;
}
// A bare Lua call through a `local` alias reaches what the alias names.
if ((ref.language === 'lua' || ref.language === 'luau') && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*$/.test(ref.referenceName)) {
const aliased = luaAliasTarget(ref, context);