diff --git a/CHANGELOG.md b/CHANGELOG.md index 403458b13..ba63418ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/__tests__/java-outside-imports.test.ts b/__tests__/java-outside-imports.test.ts new file mode 100644 index 000000000..9f2f44beb --- /dev/null +++ b/__tests__/java-outside-imports.test.ts @@ -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 = { + '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'); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 49ed35bb6..5eb90e5b8 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -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>(); + +/** + * 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(); + 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 = 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);