diff --git a/src/graph/bindings.ts b/src/graph/bindings.ts index f637ff449..9d0664e21 100644 --- a/src/graph/bindings.ts +++ b/src/graph/bindings.ts @@ -220,6 +220,11 @@ export function resolveRecvType( return ( (ctx.lang === "go" && receiver === ctx.goReceiverVar ? ctx.enclosingClass : undefined) ?? ctx.bindings.lookup(ctx.scope, receiver) ?? + // Java static call `Strings.trim(s)`: an uppercase receiver with no local binding is the + // class itself (types are UpperCamelCase, values lowerCamelCase — and a shadowing binding + // was already tried above). A constant such as `LOG.info()` or a JDK type (`List.of()`) + // owns no in-repo method of that name, so it still resolves to nothing. + (ctx.lang === "java" && /^[A-Z]/.test(receiver) ? receiver : undefined) ?? // Swift type-member call `Animal.staticThing()`: an uppercase receiver with no // local binding is the type itself (Swift naming: types are UpperCamelCase, // values lowerCamelCase — and a shadowing binding was already tried above). diff --git a/src/graph/extract.ts b/src/graph/extract.ts index b18a56c01..70b6609d0 100644 --- a/src/graph/extract.ts +++ b/src/graph/extract.ts @@ -338,6 +338,8 @@ export interface WalkCtx { goReceiverVar: string | null; // Go receiver var, e.g. `w` in `func (w *Worker)` importedSymbols: ReadonlyMap; goImports: ReadonlyMap; + /** Java single-type imports: simple class name → fully qualified name. */ + javaImports: ReadonlyMap; goLocals: ReadonlyMap; // R6 (Phase 2): which list we're inside while walking an `R6Class(...)` call's // arguments — set only for the direct span of a `public =`/`private =`/ @@ -389,6 +391,7 @@ function extractTree(rel: string, source: string, lang: Language, root: SyntaxNo const bindings = collectBindings(root, lang); const importedSymbols = collectImportedSymbols(root, lang); const goImports = lang === "go" ? collectGoImports(root) : new Map(); + const javaImports = lang === "java" ? collectJavaImports(root) : new Map(); const rGenerics = lang === "r" ? collectRGenerics(root) : EMPTY_SET; const nodes: NodeV1[] = [ @@ -423,6 +426,7 @@ function extractTree(rel: string, source: string, lang: Language, root: SyntaxNo goReceiverVar: null, importedSymbols, goImports, + javaImports, goLocals: new Map(), rR6Access: null, rGenerics, @@ -793,7 +797,16 @@ function walk(node: SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEdge[], m !recvType && callee.receiver && node.startIndex < (ctx.goLocals.get(callee.receiver) ?? Infinity) ? ctx.goImports.get(callee.receiver) : undefined; - edges.push(recvType ? { ...callEdge, recvType } : goPackage ? { ...callEdge, specifier: goPackage } : callEdge); + // A Java receiver that is a single-type import names where its class lives, so a + // class imported from outside the repo is not matched to an in-repo one of the same name. + const javaImport = ctx.lang === "java" && recvType === callee.receiver ? ctx.javaImports.get(recvType!) : undefined; + edges.push( + recvType + ? { ...callEdge, recvType, ...(javaImport ? { specifier: javaImport } : {}) } + : goPackage + ? { ...callEdge, specifier: goPackage } + : callEdge, + ); } } } else if (ctx.lang === "php" && node.type === "use_declaration") { @@ -921,6 +934,17 @@ function collectGoLocals(definition: SyntaxNode): Map { return out; } +function collectJavaImports(root: SyntaxNode): Map { + const out = new Map(); + for (const node of root.namedChildren) { + if (node.type !== "import_declaration" || node.children.some((c) => c.type === "static" || c.type === "asterisk")) continue; + const fqn = importSpecifier(node, "java"); + const simple = fqn?.split(".").at(-1); + if (fqn && simple) out.set(simple, fqn); + } + return out; +} + function collectGoImports(root: SyntaxNode): Map { const out = new Map(); const visit = (node: SyntaxNode): void => { diff --git a/src/graph/resolve.ts b/src/graph/resolve.ts index 50192a227..ef93e3637 100644 --- a/src/graph/resolve.ts +++ b/src/graph/resolve.ts @@ -341,6 +341,9 @@ export function resolveEdges( if (candidates.length === 1) add(e.source, candidates[0].id, "calls", "extracted"); continue; } + // A Java receiver imported by name: a class that lives outside the repo has no in-repo + // method to point at, even when a class of the same simple name exists here. + if (e.specifier && e.file.endsWith(".java") && !byId.has(resolveJavaImport(e.specifier, javaFilesBySuffix))) continue; if (e.viaMember) { if (!e.recvType) continue; const hit = resolveTypedMember(e.recvType, e.name!, e.file, ownerMethod, classParents, classTraits, e.argCount); diff --git a/test/graph-java.test.ts b/test/graph-java.test.ts index b03015511..d07614695 100644 --- a/test/graph-java.test.ts +++ b/test/graph-java.test.ts @@ -1526,3 +1526,73 @@ test("Java extraction: external @Service does not collapse onto a plain interfac rmSync(dir, { recursive: true, force: true }); } }); + +test("Java extraction: a static call through the class name resolves to that class's method", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-java-static-call-")); + try { + mkdirSync(join(dir, PKG), { recursive: true }); + writeFileSync( + join(dir, PKG, "Strings.java"), + "package com.acme;\n\npublic final class Strings {\n public static String trim(String s) { return s.trim(); }\n public static String pad(String s) { return trim(s); }\n}\n", + ); + writeFileSync( + join(dir, PKG, "Codes.java"), + "package com.acme;\n\npublic final class Codes {\n public static String trim(String s) { return s; }\n}\n", + ); + writeFileSync( + join(dir, PKG, "Caller.java"), + "package com.acme;\n\npublic final class Caller {\n" + + " private static final Logger LOG = null;\n" + + " public String run(String s) { return Strings.trim(s); }\n" + + " public String both(String s) { return Strings.pad(Codes.trim(s)); }\n" + + " public int jdk(java.util.List l) { LOG.info(); return Math.max(1, l.size()); }\n" + + " public String shadow(Codes Strings) { return Strings.trim(\"x\"); }\n" + + "}\n", + ); + + await buildGraph(dir); + const graph = readGraph(wiringPath(join(dir, "graft")))!; + const calls = (source: string) => + graph.edges.filter((e) => e.relation === "calls" && e.source === source).map((e) => e.target).sort(); + + assert.deepEqual(calls(`${PKG}/Caller.java#Caller.run`), [`${PKG}/Strings.java#Strings.trim`]); + assert.deepEqual(calls(`${PKG}/Caller.java#Caller.both`), [ + `${PKG}/Codes.java#Codes.trim`, + `${PKG}/Strings.java#Strings.pad`, + ]); + assert.deepEqual(calls(`${PKG}/Caller.java#Caller.jdk`), [], "LOG and Math own no in-repo method"); + assert.deepEqual(calls(`${PKG}/Caller.java#Caller.shadow`), [`${PKG}/Codes.java#Codes.trim`], "a typed parameter wins over the class name"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Java extraction: a static call through a class imported from outside the repo is not matched to an in-repo class of that name", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-java-static-import-")); + try { + mkdirSync(join(dir, PKG), { recursive: true }); + mkdirSync(join(dir, "com/acme/util"), { recursive: true }); + writeFileSync( + join(dir, "com/acme/util/Strings.java"), + "package com.acme.util;\n\npublic final class Strings {\n public static String trim(String s) { return s.trim(); }\n}\n", + ); + writeFileSync( + join(dir, PKG, "Guava.java"), + "package com.acme;\n\nimport com.google.common.base.Strings;\n\npublic final class Guava {\n public String run(String s) { return Strings.trim(s); }\n}\n", + ); + writeFileSync( + join(dir, PKG, "Local.java"), + "package com.acme;\n\nimport com.acme.util.Strings;\n\npublic final class Local {\n public String run(String s) { return Strings.trim(s); }\n}\n", + ); + + await buildGraph(dir); + const graph = readGraph(wiringPath(join(dir, "graft")))!; + const calls = (source: string) => + graph.edges.filter((e) => e.relation === "calls" && e.source === source).map((e) => e.target); + + assert.deepEqual(calls(`${PKG}/Guava.java#Guava.run`), []); + assert.deepEqual(calls(`${PKG}/Local.java#Local.run`), ["com/acme/util/Strings.java#Strings.trim"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +});