From 2b7f5c643020cbc0396f2090622bea7f2a1f1536 Mon Sep 17 00:00:00 2001 From: mixxer Date: Fri, 25 Sep 2026 01:33:25 +0900 Subject: [PATCH 1/6] fix(java): resolve calls through static fields --- __tests__/resolution.test.ts | 94 ++++++++++++++++++++++++++++++++++ src/resolution/name-matcher.ts | 65 +++++++++++++++++++++++ 2 files changed, 159 insertions(+) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 14b5198f47..56d6491647 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -81,6 +81,100 @@ describe('Resolution Module', () => { }); describe('Name Matcher', () => { + it('follows a Java static field to its anonymous method without guessing a different field', () => { + const makeNode = (id: string, kind: Node['kind'], name: string, qualifiedName: string, + filePath: string): Node => ({ id, kind, name, qualifiedName, filePath, + language: 'java', startLine: 1, endLine: 10, startColumn: 0, endColumn: 0, updatedAt: 0 }); + const field = makeNode('field', 'constant', 'CREATOR', 'pkg::SelectGesture::CREATOR', 'SelectGesture.java'); + field.signature = 'Creator CREATOR'; + const anon = makeNode('anon', 'class', '', + 'pkg::SelectGesture::CREATOR::', 'SelectGesture.java'); + const correct = makeNode('correct', 'method', 'createFromParcel', + `${anon.qualifiedName}::createFromParcel`, 'SelectGesture.java'); + const wrong = makeNode('wrong', 'method', 'createFromParcel', + 'pkg::Other::createFromParcel', 'Other.java'); + const entry = makeNode('entry', 'enum_member', 'KEYGUARD', 'pkg::ScrimState::KEYGUARD', 'ScrimState.java'); + const enumMethod = makeNode('enum-method', 'method', 'getBehindTint', + 'pkg::ScrimState::getBehindTint', 'ScrimState.java'); + const object = { ...makeNode('object', 'class', 'ComposeInitializer', + 'pkg::ComposeInitializer', 'ComposeInitializer.kt'), language: 'kotlin' as const }; + const objectMethod = { ...makeNode('object-method', 'method', 'onDetachedFromWindow', + 'pkg::ComposeInitializer::onDetachedFromWindow', 'ComposeInitializer.kt'), language: 'kotlin' as const }; + const nodes = [field, anon, correct, wrong, entry, enumMethod, object, objectMethod]; + const context = { + getNodesByName: (name: string) => nodes.filter(n => n.name === name), + getNodesByLowerName: (name: string) => nodes.filter(n => n.name.toLowerCase() === name), + getNodesByQualifiedName: (name: string) => nodes.filter(n => n.qualifiedName === name), + getNodesInFile: (filePath: string) => nodes.filter(n => n.filePath === filePath), + getNodesByKind: (kind: Node['kind']) => nodes.filter(n => n.kind === kind), + getNodeById: (id: string) => nodes.find(n => n.id === id) ?? null, + getImportMappings: () => [], getAllFiles: () => ['SelectGesture.java', 'Other.java'], + fileExists: () => true, readFile: (path: string) => path === 'ComposeInitializer.kt' + ? 'object ComposeInitializer {\n fun onDetachedFromWindow() {}\n}' : null, + getProjectRoot: () => tempDir, + } as ResolutionContext; + const ref: UnresolvedRef = { fromNodeId: 'reader', referenceName: 'SelectGesture.CREATOR.createFromParcel', + referenceKind: 'calls', filePath: 'Reader.java', language: 'java', line: 5, column: 0 }; + expect(matchReference(ref, context)?.targetNodeId).toBe(correct.id); + expect(matchReference({ ...ref, referenceName: 'TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel' }, context)).toBeNull(); + expect(matchReference({ ...ref, referenceName: 'ScrimState.KEYGUARD.getBehindTint' }, context)?.targetNodeId).toBe(enumMethod.id); + expect(matchReference({ ...ref, referenceName: 'ComposeInitializer.INSTANCE.onDetachedFromWindow' }, context)?.targetNodeId).toBe(objectMethod.id); + }); + + it('resolves Java field calls through imports and declared types in an indexed project', async () => { + for (const [file, source] of Object.entries({ + 'p/Selected.java': `package p; +public class Selected { + public static final Creator CREATOR = new Creator() { public int createFromParcel() { return 1; } }; + public interface Creator { int createFromParcel(); } +}`, + 'q/Selected.java': `package q; +public class Selected { + public static final Creator CREATOR = new Creator() { public int createFromParcel() { return 2; } }; + public interface Creator { int createFromParcel(); } +}`, + 'r/Consumer.java': `package r; +import p.Selected; +import java.util.Map; +interface Runner { void run(); } +class Impl implements Runner { + static final Runner INSTANCE = new Impl(); + public void run() {} +} +enum Mode { ON; int label() { return 1; } } +class Holder { static final Map LOOKUP = null; } +class Decoy { String get(String key) { return key; } boolean equals(Object value) { return false; } } +public class Consumer { + int select() { return Selected.CREATOR.createFromParcel(); } + String miss() { return Holder.LOOKUP.get("x"); } + int enumCall() { return Mode.ON.label(); } + boolean enumEquals() { return Mode.ON.equals(null); } + void interfaceCall() { Impl.INSTANCE.run(); } +}`, + })) { + const target = path.join(tempDir, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, source); + } + cg = CodeGraph.initSync(tempDir, { config: { include: ['**/*.java'], exclude: [] } }); + await cg.indexAll(); + const targets = (method: string) => { + const caller = cg.searchNodes(method).map(r => r.node) + .find(n => n.qualifiedName === `r::Consumer::${method}`)!; + return cg.getCallees(caller.id).filter(c => c.edge.kind === 'calls') + .map(c => c.node.qualifiedName); + }; + expect(targets('select')).toEqual([expect.stringMatching(/^p::Selected::CREATOR::<.*>::createFromParcel$/)]); + expect(targets('miss')).toEqual([]); + expect(targets('enumCall')).toEqual(['r::Mode::label']); + expect(targets('enumEquals')).toEqual([]); + expect(targets('interfaceCall')).toEqual(['r::Runner::run']); + const declaration = cg.searchNodes('run').map(r => r.node) + .find(n => n.qualifiedName === 'r::Runner::run')!; + expect(cg.getCallees(declaration.id).some(c => + c.node.qualifiedName === 'r::Impl::run' && c.edge.metadata?.synthesizedBy === 'interface-impl')).toBe(true); + }); + it('should match exact name references', () => { // Create a mock context const mockNodes: Node[] = [ diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index e0b4ad28b8..0704893e2b 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -6446,6 +6446,7 @@ function importedFqnOf( return imports.find((i) => i.localName === typeName)?.source; } + /** * Java/Kotlin: infer a receiver's declared type by walking field declarations * in the class enclosing the call site. The field's `signature` is already in @@ -7624,6 +7625,8 @@ function inferPhpAssignedPropertyType( /** * Try to resolve by method name on a class/object */ +const JAVA_STATIC_FIELD_CALL = /^([A-Z]\w*(?:\.[A-Z]\w*)*)\.([A-Z][A-Z0-9_]*)\.(\w+)$/; + export function matchMethodCall( ref: UnresolvedRef, context: ResolutionContext @@ -7901,6 +7904,66 @@ export function matchMethodCall( if (literalMatch) return literalMatch; } + // Java Type.FIELD.method(): the field names a value, not a receiver type. + // Follow that exact field to its anonymous initializer before name matching. + const staticField = ref.language === 'java' + ? ref.referenceName.match(JAVA_STATIC_FIELD_CALL) + : null; + if (staticField) { + const [, owner, fieldName] = staticField; + const fieldQn = `${owner!.replace(/\./g, '::')}::${fieldName}`; + const firstOwner = owner!.split('.')[0]!; + const importedOwner = importedFqnOf(firstOwner, ref, context); + const ownerFqn = importedOwner ? importedOwner + owner!.slice(firstOwner.length) : null; + let fields = context.getNodesByName(fieldName!).filter(n => + (n.kind === 'constant' || n.kind === 'field' || n.kind === 'property' || n.kind === 'enum_member') && + n.language === 'java' && n.qualifiedName.endsWith(`::${fieldQn}`) && + (!ownerFqn || n.qualifiedName.replace(/::/g, '.') === `${ownerFqn}.${fieldName}`)); + if (fields.length > 1) { + const local = fields.filter(n => n.filePath === ref.filePath); + if (local.length === 1) fields = local; + } + if (fields.length === 0 && fieldName === 'INSTANCE') { + let objects = context.getNodesByName(owner!.split('.').pop()!).filter(n => + n.kind === 'class' && n.language === 'kotlin' && + n.qualifiedName.endsWith(`::${owner!.replace(/\./g, '::')}`) && + (!ownerFqn || n.qualifiedName.replace(/::/g, '.') === ownerFqn) && + context.readFile(n.filePath)?.split('\n')[n.startLine - 1]?.includes(`object ${n.name}`)); + if (objects.length > 1) { + const local = objects.filter(n => n.filePath === ref.filePath); + if (local.length === 1) objects = local; + } + if (objects.length === 1) { + const method = context.getNodesInFile(objects[0]!.filePath).find(n => + n.kind === 'method' && n.qualifiedName === `${objects[0]!.qualifiedName}::${methodName}`); + if (method) return { original: ref, targetNodeId: method.id, + confidence: 0.9, resolvedBy: 'qualified-name' }; + } + } + if (fields.length !== 1) return null; + const field = fields[0]!; + if (field.kind === 'enum_member') { + const enumName = field.qualifiedName.slice(0, field.qualifiedName.lastIndexOf('::')); + const method = context.getNodesInFile(field.filePath).find(n => + n.kind === 'method' && n.qualifiedName === `${enumName}::${methodName}`); + return method ? { original: ref, targetNodeId: method.id, + confidence: 0.9, resolvedBy: 'qualified-name' } : null; + } + const inFile = context.getNodesInFile(field.filePath); + const owners = inFile.filter(n => n.kind === 'class' && + n.qualifiedName.startsWith(`${field.qualifiedName}::<`) && n.qualifiedName.includes('$anon@')); + const methods = owners.flatMap(ownerNode => inFile.filter(n => + n.kind === 'method' && n.qualifiedName === `${ownerNode.qualifiedName}::${methodName}`)); + if (methods.length === 1) { + return { original: ref, targetNodeId: methods[0]!.id, + confidence: 0.9, resolvedBy: 'qualified-name' }; + } + const declaredType = field.signature?.slice(0, field.signature.lastIndexOf(field.name)).trim(); + const typeName = declaredType ? normalizeInferredTypeName(declaredType) : null; + return typeName ? resolveMethodOnType(typeName, methodName!, ref, context, 0.9, + 'instance-method', importedFqnOf(typeName, ref, context)) : null; + } + // Strategy 1: Direct class name match (existing logic). When the receiver // names a class that exists in several files (`Logger.log()` / `Logger::log()` // with a `Logger` in both `a/` and `b/`), try the class in the call site's @@ -9806,6 +9869,8 @@ function matchReferenceInner( // 2. Method call pattern result = nmTimed('methodCall', ref, () => matchMethodCall(ref, context)); if (result) return result; + if (ref.language === 'java' && ref.referenceKind === 'calls' && + JAVA_STATIC_FIELD_CALL.test(ref.referenceName)) return null; // 3. Exact name match result = nmTimed('exactName', ref, () => matchByExactName(ref, context)); From 4f4b0b1892c65c159c5e9eb529aa893b7bb1b139 Mon Sep 17 00:00:00 2001 From: mixxer Date: Fri, 25 Sep 2026 02:42:43 +0900 Subject: [PATCH 2/6] fix(java): disambiguate static field call targets --- CHANGELOG.md | 1 + __tests__/resolution.test.ts | 22 +++++++- docs/design/java-static-field-corpus.md | 16 ++++++ src/extraction/languages/java.ts | 4 +- src/resolution/name-matcher.ts | 71 +++++++++++++++++++++++-- 5 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 docs/design/java-static-field-corpus.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ca319d98..6c430f157e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -270,6 +270,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Zustand actions keep their callers when read through typed stores, destructured from store state, or selected by a hook. - Direct React Native bridge calls retain their native implementations and cross-platform relationships. - Dart extension-type getters remain searchable when using the WebAssembly parser. +- Java calls through static fields now follow the correct nested type, inherited field, or concrete initializer without linking external library calls to unrelated project methods. (#1949) - Calling a built-in method on an awaited value no longer records a call into an unrelated class that happens to declare a method of the same name, and a variable bound to an awaited call now resolves methods on the type that call returns. Thanks @maxmilian. (#1840) - Spring mappings now include every declared path combination and resolve constants declared in the same file, while unresolved paths no longer appear as false root routes. (#1461) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 56d6491647..e19a7af091 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -133,14 +133,24 @@ public class Selected { public static final Creator CREATOR = new Creator() { public int createFromParcel() { return 2; } }; public interface Creator { int createFromParcel(); } }`, + 'r/A.java': `package r; +class A { static final Companion INSTANCE = new Companion(); static class Companion { void m() {} } }`, + 'r/B.java': `package r; +class B { static final Companion INSTANCE = new Companion(); static class Companion { void m() {} } }`, 'r/Consumer.java': `package r; import p.Selected; import java.util.Map; interface Runner { void run(); } +interface Consts { Helper HELPER = new Helper(); } +class Helper { void go() {} } +class Parent { static final Helper H = new Helper(); } +class Child extends Parent {} +class Util { static class IO { static void read() {} } } class Impl implements Runner { static final Runner INSTANCE = new Impl(); public void run() {} } +class Conditional { static final Runner INSTANCE = true ? new Impl() : new Impl(); } enum Mode { ON; int label() { return 1; } } class Holder { static final Map LOOKUP = null; } class Decoy { String get(String key) { return key; } boolean equals(Object value) { return false; } } @@ -150,6 +160,11 @@ public class Consumer { int enumCall() { return Mode.ON.label(); } boolean enumEquals() { return Mode.ON.equals(null); } void interfaceCall() { Impl.INSTANCE.run(); } + void conditionalCall() { Conditional.INSTANCE.run(); } + void nestedType() { B.INSTANCE.m(); } + void interfaceField() { Consts.HELPER.go(); } + void inheritedField() { Child.H.go(); } + void capsType() { Util.IO.read(); } }`, })) { const target = path.join(tempDir, file); @@ -168,7 +183,12 @@ public class Consumer { expect(targets('miss')).toEqual([]); expect(targets('enumCall')).toEqual(['r::Mode::label']); expect(targets('enumEquals')).toEqual([]); - expect(targets('interfaceCall')).toEqual(['r::Runner::run']); + expect(targets('interfaceCall')).toEqual(['r::Impl::run']); + expect(targets('conditionalCall')).toEqual(['r::Runner::run']); + expect(targets('nestedType')).toEqual(['r::B::Companion::m']); + expect(targets('interfaceField')).toEqual(['r::Helper::go']); + expect(targets('inheritedField')).toEqual(['r::Helper::go']); + expect(targets('capsType')).toEqual(['r::Util::IO::read']); const declaration = cg.searchNodes('run').map(r => r.node) .find(n => n.qualifiedName === 'r::Runner::run')!; expect(cg.getCallees(declaration.id).some(c => diff --git a/docs/design/java-static-field-corpus.md b/docs/design/java-static-field-corpus.md new file mode 100644 index 0000000000..dd2f914f6b --- /dev/null +++ b/docs/design/java-static-field-corpus.md @@ -0,0 +1,16 @@ +# Java static-field call corpus checks (#1949) + +Compare `calls` edges by caller file, line, column, and target qualified name after a fresh `codegraph init --yes` with the same source tree. The baseline is CodeGraph `ba3c21e`; the follow-up is this branch. Both runs used Node 24.21.0 on macOS. + +| Public source | Revision and scope | Baseline Java calls | Follow-up Java calls | Delta | +|---|---|---:|---:|---| +| [Play Framework](https://github.com/playframework/playframework/tree/e1f3c2a92be8aa8b2f853d7d3175955efbe18a1e) | `e1f3c2a92be8aa8b2f853d7d3175955efbe18a1e`, 790 Java files | 13,926 | 13,897 | 29 removed, 0 added | +| [AOSP SystemUI](https://android.googlesource.com/platform/frameworks/base/+/94b4c163b7dfe5ce3607f7bb8456f9573f7de57d/packages/SystemUI/src) | `94b4c163b7dfe5ce3607f7bb8456f9573f7de57d`, `packages/SystemUI/src`, 1,077 Java and 4,089 Kotlin files | 37,221 | 37,153 | 84 removed, 16 added | + +The original #1949 head (`126a2e6`) and this follow-up produce **identical Java `calls` edges** on both public source trees: 13,897 for Play and 37,153 for AOSP SystemUI. The follow-up addresses cases absent from these source sets, especially decompiled Java representations of Kotlin companion singletons. + +Play's 29 removed calls comprise 27 `Integer.TYPE` and other primitive `TYPE` `.equals` calls in `ClassUtils.java`, one `XMLConstants.DEFAULT_NS_PREFIX.equals` in `XPath.java`, and one `PLAY_GROUP_ID.equals` in a Java test. Excluding test sources yields **28 removed, 0 added**, matching the maintainer's production-source count. All 29 baseline targets were unrelated project methods. + +The 16 added AOSP calls were checked individually against their Java call sites and Kotlin declarations. Each is a Java `Object.INSTANCE.method()` call to one of eight indexed Kotlin `object` declarations. The 84 removed edges pointed to unrelated project methods from Android framework constants and fields, including `Intent.ACTION_*`, `Interpolators.*`, `AccessibilityAction.*`, and framework `CREATOR`s; two removed `bind` calls and one `getSectionSubLists` call were replaced by the correct Kotlin object targets among the 16 additions. + +These AOSP source files are **not** the maintainer's decompiled SystemUI corpus of about 3,400 Java files. The exact 37 gained call sites cannot be classified from this source comparison. The public build revision, decompiler output, or 37-row edge diff is needed for a same-corpus audit. diff --git a/src/extraction/languages/java.ts b/src/extraction/languages/java.ts index 5db825890e..52e194684e 100644 --- a/src/extraction/languages/java.ts +++ b/src/extraction/languages/java.ts @@ -266,7 +266,7 @@ export const javaExtractor: LanguageExtractor = { importTypes: ['import_declaration'], callTypes: ['method_invocation'], variableTypes: ['local_variable_declaration'], - fieldTypes: ['field_declaration'], + fieldTypes: ['field_declaration', 'constant_declaration'], nameField: 'name', bodyField: 'body', paramsField: 'parameters', @@ -293,6 +293,7 @@ export const javaExtractor: LanguageExtractor = { return undefined; }, isStatic: (node) => { + if (node.type === 'constant_declaration') return true; for (let i = 0; i < node.childCount; i++) { const child = node.child(i); if (child?.type === 'modifiers' && child.text.includes('static')) { @@ -305,6 +306,7 @@ export const javaExtractor: LanguageExtractor = { // shared config). Drives `constant` kind so value-reference edges target it; // instance / `final`-only / `static`-only fields stay mutable `field`s. isConst: (node) => { + if (node.type === 'constant_declaration') return true; for (let i = 0; i < node.childCount; i++) { const child = node.child(i); if (child?.type === 'modifiers') { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 0704893e2b..300cc1b2ed 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -7919,6 +7919,32 @@ export function matchMethodCall( (n.kind === 'constant' || n.kind === 'field' || n.kind === 'property' || n.kind === 'enum_member') && n.language === 'java' && n.qualifiedName.endsWith(`::${fieldQn}`) && (!ownerFqn || n.qualifiedName.replace(/::/g, '.') === `${ownerFqn}.${fieldName}`)); + if (fields.length === 0) { + const ownerName = owner!.split('.').pop()!; + const ownerNodes = context.getNodesByName(ownerName).filter(n => + n.kind === 'class' && n.language === 'java' && + n.qualifiedName.endsWith(`::${owner!.replace(/\./g, '::')}`) && + (!ownerFqn || n.qualifiedName.replace(/::/g, '.') === ownerFqn)); + if (ownerNodes.length === 1) { + const child = ownerNodes[0]!; + const nestedType = `${child.qualifiedName}::${fieldName}`; + if (context.getNodesByQualifiedName(nestedType).some(n => + n.kind === 'class' || n.kind === 'interface')) { + const nestedMethod = context.getNodesByQualifiedName(`${nestedType}::${methodName}`) + .find(n => n.kind === 'method'); + if (nestedMethod) return { original: ref, targetNodeId: nestedMethod.id, + confidence: 0.9, resolvedBy: 'qualified-name' }; + } + const header = context.readFile(child.filePath)?.split('\n') + .slice(child.startLine - 1, Math.min(child.endLine, child.startLine + 4)).join(' ') ?? ''; + const parentName = /\bextends\s+([\w.]+)/.exec(header)?.[1]?.split('.').pop(); + if (parentName) { + fields = context.getNodesByName(fieldName!).filter(n => + (n.kind === 'constant' || n.kind === 'field') && n.language === 'java' && + n.qualifiedName === `${child.qualifiedName.slice(0, child.qualifiedName.lastIndexOf('::'))}::${parentName}::${fieldName}`); + } + } + } if (fields.length > 1) { const local = fields.filter(n => n.filePath === ref.filePath); if (local.length === 1) fields = local; @@ -7960,8 +7986,30 @@ export function matchMethodCall( } const declaredType = field.signature?.slice(0, field.signature.lastIndexOf(field.name)).trim(); const typeName = declaredType ? normalizeInferredTypeName(declaredType) : null; - return typeName ? resolveMethodOnType(typeName, methodName!, ref, context, 0.9, - 'instance-method', importedFqnOf(typeName, ref, context)) : null; + if (!typeName) return null; + const fieldOwner = field.qualifiedName.slice(0, field.qualifiedName.lastIndexOf('::')); + const fieldSource = context.readFile(field.filePath)?.split('\n') + .slice(field.startLine - 1, field.endLine).join('\n') ?? ''; + const initializedType = field.kind === 'constant' + ? new RegExp(`\\b${field.name}\\s*=\\s*new\\s+([\\w.]+)\\s*\\(`).exec(fieldSource)?.[1] + : undefined; + const receiverType = initializedType?.split('.').pop() ?? typeName; + const types = context.getNodesByName(receiverType).filter(n => + (n.kind === 'class' || n.kind === 'interface') && n.language === 'java'); + const nested = types.filter(n => n.qualifiedName === `${fieldOwner}::${receiverType}`); + const typeRef = { ...ref, filePath: field.filePath }; + const importedType = importedFqnOf(receiverType, typeRef, context); + const imported = importedType + ? types.filter(n => n.qualifiedName.replace(/::/g, '.') === importedType) : []; + const packageName = /\bpackage\s+([\w.]+)\s*;/.exec(context.readFile(field.filePath) ?? '')?.[1]; + const samePackage = packageName + ? types.filter(n => n.qualifiedName === `${packageName.replace(/\./g, '::')}::${receiverType}`) : []; + const chosen = [nested, imported, samePackage, types].find(group => group.length === 1)?.[0]; + if (!chosen) return null; + const target = context.getNodesByName(methodName!).find(n => + n.kind === 'method' && n.qualifiedName === `${chosen.qualifiedName}::${methodName}`); + return target ? { original: ref, targetNodeId: target.id, + confidence: 0.9, resolvedBy: 'instance-method' } : null; } // Strategy 1: Direct class name match (existing logic). When the receiver @@ -9869,8 +9917,23 @@ function matchReferenceInner( // 2. Method call pattern result = nmTimed('methodCall', ref, () => matchMethodCall(ref, context)); if (result) return result; - if (ref.language === 'java' && ref.referenceKind === 'calls' && - JAVA_STATIC_FIELD_CALL.test(ref.referenceName)) return null; + if (ref.language === 'java' && ref.referenceKind === 'calls') { + const staticField = JAVA_STATIC_FIELD_CALL.exec(ref.referenceName); + if (staticField) { + const [, owner, fieldName] = staticField; + const ownerName = owner!.split('.').pop()!; + const importedOwner = importedFqnOf(owner!.split('.')[0]!, ref, context); + const projectOwner = context.getNodesByName(ownerName).some(n => + (n.kind === 'class' || n.kind === 'interface' || n.kind === 'enum') && + n.language === 'java' && n.qualifiedName.endsWith(`::${owner!.replace(/\./g, '::')}`) && + (!importedOwner || n.qualifiedName.replace(/::/g, '.') === + importedOwner + owner!.slice(owner!.split('.')[0]!.length))); + const indexedField = context.getNodesByName(fieldName!).some(n => + (n.kind === 'constant' || n.kind === 'field' || n.kind === 'property') && + n.language === 'java' && n.qualifiedName.endsWith(`::${owner!.replace(/\./g, '::')}::${fieldName}`)); + if (!projectOwner || indexedField) return null; + } + } // 3. Exact name match result = nmTimed('exactName', ref, () => matchByExactName(ref, context)); From 49dc40df6d9dc1ab806763e566437ef3d13a6735 Mon Sep 17 00:00:00 2001 From: mixxer Date: Fri, 25 Sep 2026 03:35:49 +0900 Subject: [PATCH 3/6] fix(java): honor imports for static field initializer calls --- __tests__/resolution.test.ts | 27 +++++++++++++++++++++++++++ src/resolution/name-matcher.ts | 11 ++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index e19a7af091..3c78f5b439 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -137,6 +137,25 @@ public class Selected { class A { static final Companion INSTANCE = new Companion(); static class Companion { void m() {} } }`, 'r/B.java': `package r; class B { static final Companion INSTANCE = new Companion(); static class Companion { void m() {} } }`, + 'r/Interpolators.java': `package r; +import android.view.animation.Interpolator; +import android.view.animation.PathInterpolator; +class Interpolators { + static final Interpolator ALPHA_OUT = new PathInterpolator(0, 0, 1, 1); + static final Interpolator DIRECT = new android.view.animation.PathInterpolator(0, 0, 1, 1); +}`, + 'r/WildcardInterpolators.java': `package r; +import android.view.animation.*; +class WildcardInterpolators { static final Interpolator ALPHA_OUT = new PathInterpolator(0, 0, 1, 1); }`, + 'q/PathInterpolatorBuilder.java': `package q; +class PathInterpolatorBuilder { + private static class PathInterpolator { float getInterpolation(float t) { return t; } } +}`, + 'p/ProjectInterpolator.java': `package p; +public class ProjectInterpolator { public float getInterpolation(float t) { return t; } }`, + 'r/ProjectInterpolators.java': `package r; +import p.ProjectInterpolator; +class ProjectInterpolators { static final ProjectInterpolator LOCAL = new ProjectInterpolator(); }`, 'r/Consumer.java': `package r; import p.Selected; import java.util.Map; @@ -165,6 +184,10 @@ public class Consumer { void interfaceField() { Consts.HELPER.go(); } void inheritedField() { Child.H.go(); } void capsType() { Util.IO.read(); } + float frameworkCall() { return Interpolators.ALPHA_OUT.getInterpolation(1); } + float qualifiedFrameworkCall() { return Interpolators.DIRECT.getInterpolation(1); } + float wildcardFrameworkCall() { return WildcardInterpolators.ALPHA_OUT.getInterpolation(1); } + float projectCall() { return ProjectInterpolators.LOCAL.getInterpolation(1); } }`, })) { const target = path.join(tempDir, file); @@ -189,6 +212,10 @@ public class Consumer { expect(targets('interfaceField')).toEqual(['r::Helper::go']); expect(targets('inheritedField')).toEqual(['r::Helper::go']); expect(targets('capsType')).toEqual(['r::Util::IO::read']); + expect(targets('frameworkCall')).toEqual([]); + expect(targets('qualifiedFrameworkCall')).toEqual([]); + expect(targets('wildcardFrameworkCall')).toEqual([]); + expect(targets('projectCall')).toEqual(['p::ProjectInterpolator::getInterpolation']); const declaration = cg.searchNodes('run').map(r => r.node) .find(n => n.qualifiedName === 'r::Runner::run')!; expect(cg.getCallees(declaration.id).some(c => diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 300cc1b2ed..4ac018d5fa 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -7995,16 +7995,21 @@ export function matchMethodCall( : undefined; const receiverType = initializedType?.split('.').pop() ?? typeName; const types = context.getNodesByName(receiverType).filter(n => - (n.kind === 'class' || n.kind === 'interface') && n.language === 'java'); + (n.kind === 'class' || n.kind === 'interface') && n.language === 'java' && + (n.visibility !== 'private' || n.filePath === field.filePath)); const nested = types.filter(n => n.qualifiedName === `${fieldOwner}::${receiverType}`); const typeRef = { ...ref, filePath: field.filePath }; - const importedType = importedFqnOf(receiverType, typeRef, context); + const qualifiedType = initializedType?.includes('.') ? initializedType : undefined; + const importedType = qualifiedType ?? importedFqnOf(receiverType, typeRef, context); const imported = importedType ? types.filter(n => n.qualifiedName.replace(/::/g, '.') === importedType) : []; const packageName = /\bpackage\s+([\w.]+)\s*;/.exec(context.readFile(field.filePath) ?? '')?.[1]; const samePackage = packageName ? types.filter(n => n.qualifiedName === `${packageName.replace(/\./g, '::')}::${receiverType}`) : []; - const chosen = [nested, imported, samePackage, types].find(group => group.length === 1)?.[0]; + const chosen = qualifiedType ? (imported.length === 1 ? imported[0] : undefined) + : nested.length === 1 ? nested[0] + : importedType ? (imported.length === 1 ? imported[0] : undefined) + : [samePackage, types].find(group => group.length === 1)?.[0]; if (!chosen) return null; const target = context.getNodesByName(methodName!).find(n => n.kind === 'method' && n.qualifiedName === `${chosen.qualifiedName}::${methodName}`); From fd07d3bfc2cc16dbb4b4253e37eceabbf97ec6b4 Mon Sep 17 00:00:00 2001 From: mixxer Date: Fri, 25 Sep 2026 21:07:11 +0900 Subject: [PATCH 4/6] fix(java): index interface constants in native kernel --- __tests__/fixtures/kernel-parity/Torture.java | 1 + __tests__/kernel-tsjs-parity.test.ts | 3 +- __tests__/resolution.test.ts | 28 +++++++++++++++++++ codegraph-kernel/src/java.rs | 4 ++- src/resolution/name-matcher.ts | 16 +++++++---- 5 files changed, 45 insertions(+), 7 deletions(-) diff --git a/__tests__/fixtures/kernel-parity/Torture.java b/__tests__/fixtures/kernel-parity/Torture.java index d1e71cbd20..b9e2cbe8db 100644 --- a/__tests__/fixtures/kernel-parity/Torture.java +++ b/__tests__/fixtures/kernel-parity/Torture.java @@ -101,6 +101,7 @@ int apply(int at) { } interface Listener { + int DEFAULT_PRIORITY = 1; void onChange(TortureService svc); } } diff --git a/__tests__/kernel-tsjs-parity.test.ts b/__tests__/kernel-tsjs-parity.test.ts index dd4c8e13ae..95c212fc1d 100644 --- a/__tests__/kernel-tsjs-parity.test.ts +++ b/__tests__/kernel-tsjs-parity.test.ts @@ -200,7 +200,8 @@ async function exprReceivers(x, y) { it('torture fixture (java): Lombok, anonymous classes, method refs, chains', () => { const file = path.join(FIXTURE_DIR, 'Torture.java'); - assertParity('fixtures/Torture.java', fs.readFileSync(file, 'utf8'), 'java'); + const result = assertParity('fixtures/Torture.java', fs.readFileSync(file, 'utf8'), 'java'); + expect(result.nodes.find(n => n.name === 'DEFAULT_PRIORITY')).toMatchObject({ kind: 'constant', isStatic: true }); }); it('torture fixture (python): decorators, self fn-refs, imports, shadowing', () => { diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 3c78f5b439..6f61a7f38a 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -156,8 +156,28 @@ public class ProjectInterpolator { public float getInterpolation(float t) { retu 'r/ProjectInterpolators.java': `package r; import p.ProjectInterpolator; class ProjectInterpolators { static final ProjectInterpolator LOCAL = new ProjectInterpolator(); }`, + 'r/WildcardProjectInterpolators.java': `package r; +import p.*; +class WildcardProjectInterpolators { static final ProjectInterpolator LOCAL = new ProjectInterpolator(); }`, + 'com/app/W.java': `package com.app; +import android.view.animation.*; +public class W { public static final Interpolator DECEL = new DecelerateInterpolator(); }`, + 'com/third/DecelerateInterpolator.java': `package com.third; +public class DecelerateInterpolator { public float getInterpolation(float t) { return t; } }`, + 'com/app/ExternalConsts.java': `package com.app; +public interface ExternalConsts { ExternalHelper HELPER = new ExternalHelper(); }`, + 'com/app/ExternalHelper.java': `package com.app; +public class ExternalHelper { public void go() {} }`, + 'com/lib/ProjectInterpolator.java': `package com.lib; +public class ProjectInterpolator { public float getInterpolation(float t) { return t; } }`, + 'com/app/WildcardProject.java': `package com.app; +import com.lib.*; +public class WildcardProject { public static final ProjectInterpolator LOCAL = new ProjectInterpolator(); }`, 'r/Consumer.java': `package r; import p.Selected; +import com.app.W; +import com.app.ExternalConsts; +import com.app.WildcardProject; import java.util.Map; interface Runner { void run(); } interface Consts { Helper HELPER = new Helper(); } @@ -188,6 +208,10 @@ public class Consumer { float qualifiedFrameworkCall() { return Interpolators.DIRECT.getInterpolation(1); } float wildcardFrameworkCall() { return WildcardInterpolators.ALPHA_OUT.getInterpolation(1); } float projectCall() { return ProjectInterpolators.LOCAL.getInterpolation(1); } + float wildcardProjectCall() { return WildcardProjectInterpolators.LOCAL.getInterpolation(1); } + float unrelatedWildcardCall() { return W.DECEL.getInterpolation(1); } + void dottedPackageInterfaceCall() { ExternalConsts.HELPER.go(); } + float dottedWildcardCall() { return WildcardProject.LOCAL.getInterpolation(1); } }`, })) { const target = path.join(tempDir, file); @@ -216,6 +240,10 @@ public class Consumer { expect(targets('qualifiedFrameworkCall')).toEqual([]); expect(targets('wildcardFrameworkCall')).toEqual([]); expect(targets('projectCall')).toEqual(['p::ProjectInterpolator::getInterpolation']); + expect(targets('wildcardProjectCall')).toEqual(['p::ProjectInterpolator::getInterpolation']); + expect(targets('unrelatedWildcardCall')).toEqual([]); + expect(targets('dottedPackageInterfaceCall')).toEqual(['com.app::ExternalHelper::go']); + expect(targets('dottedWildcardCall')).toEqual(['com.lib::ProjectInterpolator::getInterpolation']); const declaration = cg.searchNodes('run').map(r => r.node) .find(n => n.qualifiedName === 'r::Runner::run')!; expect(cg.getCallees(declaration.id).some(c => diff --git a/codegraph-kernel/src/java.rs b/codegraph-kernel/src/java.rs index daaf49de0e..a60f2ee84a 100644 --- a/codegraph-kernel/src/java.rs +++ b/codegraph-kernel/src/java.rs @@ -425,6 +425,7 @@ impl<'t> Walker<'t> { } fn is_static(&self, node: Node) -> bool { + if node.kind() == "constant_declaration" { return true; } for i in 0..node.child_count() { if let Some(child) = node.child(i) { if child.kind() == "modifiers" && self.text(child).contains("static") { @@ -437,6 +438,7 @@ impl<'t> Walker<'t> { /// javaExtractor.isConst: `static final` field → constant. fn is_const(&self, node: Node) -> bool { + if node.kind() == "constant_declaration" { return true; } for i in 0..node.child_count() { if let Some(child) = node.child(i) { if child.kind() == "modifiers" { @@ -506,7 +508,7 @@ impl<'t> Walker<'t> { } else if kind == "enum_declaration" { self.extract_enum(node); skip_children = true; - } else if kind == "field_declaration" && self.inside_class_like() { + } else if matches!(kind, "field_declaration" | "constant_declaration") && self.inside_class_like() { self.extract_field(node); self.scan_fn_ref_subtree(node, 0); skip_children = true; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 4ac018d5fa..55cb5a4d6f 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -7988,8 +7988,9 @@ export function matchMethodCall( const typeName = declaredType ? normalizeInferredTypeName(declaredType) : null; if (!typeName) return null; const fieldOwner = field.qualifiedName.slice(0, field.qualifiedName.lastIndexOf('::')); - const fieldSource = context.readFile(field.filePath)?.split('\n') - .slice(field.startLine - 1, field.endLine).join('\n') ?? ''; + const fieldContent = context.readFile(field.filePath) ?? ''; + const fieldSource = fieldContent.split('\n') + .slice(field.startLine - 1, field.endLine).join('\n'); const initializedType = field.kind === 'constant' ? new RegExp(`\\b${field.name}\\s*=\\s*new\\s+([\\w.]+)\\s*\\(`).exec(fieldSource)?.[1] : undefined; @@ -8003,13 +8004,18 @@ export function matchMethodCall( const importedType = qualifiedType ?? importedFqnOf(receiverType, typeRef, context); const imported = importedType ? types.filter(n => n.qualifiedName.replace(/::/g, '.') === importedType) : []; - const packageName = /\bpackage\s+([\w.]+)\s*;/.exec(context.readFile(field.filePath) ?? '')?.[1]; + const packageName = /\bpackage\s+([\w.]+)\s*;/.exec(fieldContent)?.[1]; const samePackage = packageName - ? types.filter(n => n.qualifiedName === `${packageName.replace(/\./g, '::')}::${receiverType}`) : []; + ? types.filter(n => n.qualifiedName === `${packageName}::${receiverType}`) : []; + const wildcardPackages = [...fieldContent.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '') + .matchAll(/^\s*import\s+([\w.]+)\.\*\s*;/gm)] + .map(m => m[1]!); + const wildcard = types.filter(n => wildcardPackages.some(pkg => + n.qualifiedName === `${pkg}::${receiverType}`)); const chosen = qualifiedType ? (imported.length === 1 ? imported[0] : undefined) : nested.length === 1 ? nested[0] : importedType ? (imported.length === 1 ? imported[0] : undefined) - : [samePackage, types].find(group => group.length === 1)?.[0]; + : [samePackage, wildcard].find(group => group.length === 1)?.[0]; if (!chosen) return null; const target = context.getNodesByName(methodName!).find(n => n.kind === 'method' && n.qualifiedName === `${chosen.qualifiedName}::${methodName}`); From a30736059796fd9ab31a909ca1b05a433cd5ab31 Mon Sep 17 00:00:00 2001 From: mixxer Date: Sat, 26 Sep 2026 00:32:53 +0900 Subject: [PATCH 5/6] fix(java): resolve nested static field initializer types --- __tests__/resolution.test.ts | 14 ++++++++++++++ src/resolution/name-matcher.ts | 7 ++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 6f61a7f38a..927cb8c655 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -173,8 +173,18 @@ public class ProjectInterpolator { public float getInterpolation(float t) { retu 'com/app/WildcardProject.java': `package com.app; import com.lib.*; public class WildcardProject { public static final ProjectInterpolator LOCAL = new ProjectInterpolator(); }`, + 'p/Outer.java': `package p; +public class Outer { + public static class Sibling { public int getId() { return 1; } } + public static class Inner { + public static final Inner A = new Inner(); + public static final Sibling B = new Sibling(); + public int getId() { return 0; } + } +}`, 'r/Consumer.java': `package r; import p.Selected; +import p.Outer; import com.app.W; import com.app.ExternalConsts; import com.app.WildcardProject; @@ -212,6 +222,8 @@ public class Consumer { float unrelatedWildcardCall() { return W.DECEL.getInterpolation(1); } void dottedPackageInterfaceCall() { ExternalConsts.HELPER.go(); } float dottedWildcardCall() { return WildcardProject.LOCAL.getInterpolation(1); } + int nestedOwnerFieldCall() { return Outer.Inner.A.getId(); } + int enclosingTypeFieldCall() { return Outer.Inner.B.getId(); } }`, })) { const target = path.join(tempDir, file); @@ -244,6 +256,8 @@ public class Consumer { expect(targets('unrelatedWildcardCall')).toEqual([]); expect(targets('dottedPackageInterfaceCall')).toEqual(['com.app::ExternalHelper::go']); expect(targets('dottedWildcardCall')).toEqual(['com.lib::ProjectInterpolator::getInterpolation']); + expect(targets('nestedOwnerFieldCall')).toEqual(['p::Outer::Inner::getId']); + expect(targets('enclosingTypeFieldCall')).toEqual(['p::Outer::Sibling::getId']); const declaration = cg.searchNodes('run').map(r => r.node) .find(n => n.qualifiedName === 'r::Runner::run')!; expect(cg.getCallees(declaration.id).some(c => diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 55cb5a4d6f..6a085f7983 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -7998,7 +7998,12 @@ export function matchMethodCall( const types = context.getNodesByName(receiverType).filter(n => (n.kind === 'class' || n.kind === 'interface') && n.language === 'java' && (n.visibility !== 'private' || n.filePath === field.filePath)); - const nested = types.filter(n => n.qualifiedName === `${fieldOwner}::${receiverType}`); + let nested: Node[] = []; + for (let scope = fieldOwner; scope.includes('::'); scope = scope.slice(0, scope.lastIndexOf('::'))) { + nested = types.filter(n => n.qualifiedName === scope || + n.qualifiedName === `${scope}::${receiverType}`); + if (nested.length) break; + } const typeRef = { ...ref, filePath: field.filePath }; const qualifiedType = initializedType?.includes('.') ? initializedType : undefined; const importedType = qualifiedType ?? importedFqnOf(receiverType, typeRef, context); From 30581659913438165d6dbfa34019cb4a2dc0758b Mon Sep 17 00:00:00 2001 From: mixxer Date: Mon, 5 Oct 2026 00:16:30 +0900 Subject: [PATCH 6/6] fix: adapt PR to current upstream behavior --- CHANGELOG.md | 5 ++++- __tests__/resolution.test.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c430f157e..39c2e88dee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixes + +- Java calls through static fields now follow the correct nested type, inherited field, or concrete initializer without linking external library calls to unrelated project methods. (#1949) + ## [1.6.2] - 2026-10-03 @@ -270,7 +274,6 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Zustand actions keep their callers when read through typed stores, destructured from store state, or selected by a hook. - Direct React Native bridge calls retain their native implementations and cross-platform relationships. - Dart extension-type getters remain searchable when using the WebAssembly parser. -- Java calls through static fields now follow the correct nested type, inherited field, or concrete initializer without linking external library calls to unrelated project methods. (#1949) - Calling a built-in method on an awaited value no longer records a call into an unrelated class that happens to declare a method of the same name, and a variable bound to an awaited call now resolves methods on the type that call returns. Thanks @maxmilian. (#1840) - Spring mappings now include every declared path combination and resolve constants declared in the same file, while unresolved paths no longer appear as false root routes. (#1461) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 927cb8c655..d3a04cf4e4 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -230,7 +230,7 @@ public class Consumer { fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, source); } - cg = CodeGraph.initSync(tempDir, { config: { include: ['**/*.java'], exclude: [] } }); + cg = CodeGraph.initSync(tempDir); await cg.indexAll(); const targets = (method: string) => { const caller = cg.searchNodes(method).map(r => r.node)