mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-21 00:38:18 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user