Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/graph/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
26 changes: 25 additions & 1 deletion src/graph/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,8 @@ export interface WalkCtx {
goReceiverVar: string | null; // Go receiver var, e.g. `w` in `func (w *Worker)`
importedSymbols: ReadonlyMap<string, { name: string; specifier: string }>;
goImports: ReadonlyMap<string, string>;
/** Java single-type imports: simple class name → fully qualified name. */
javaImports: ReadonlyMap<string, string>;
goLocals: ReadonlyMap<string, number>;
// 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 =`/
Expand Down Expand Up @@ -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<string, string>();
const javaImports = lang === "java" ? collectJavaImports(root) : new Map<string, string>();
const rGenerics = lang === "r" ? collectRGenerics(root) : EMPTY_SET;

const nodes: NodeV1[] = [
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -921,6 +934,17 @@ function collectGoLocals(definition: SyntaxNode): Map<string, number> {
return out;
}

function collectJavaImports(root: SyntaxNode): Map<string, string> {
const out = new Map<string, string>();
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<string, string> {
const out = new Map<string, string>();
const visit = (node: SyntaxNode): void => {
Expand Down
3 changes: 3 additions & 0 deletions src/graph/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
70 changes: 70 additions & 0 deletions test/graph-java.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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 });
}
});
Loading