From e54bd7a863423ceebb66cef263f058014b2a9a48 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Thu, 29 Oct 2026 13:00:46 -0500 Subject: [PATCH] fix(java): a call on a value a type holds in a static field reaches the method of the field's type A Java call written through a type's static field, Consts.HELPER.go() or Holder.SHARED.run(), was resolved by the method's name alone: the receiver Type.FIELD said nothing of its type, so the call went to a same-named method of whatever class the name matcher found, and a field of a library class (Intent.ACTION_VIEW.equals(...), System.out.print()) could reach a project method of that name. An interface's constants were not indexed at all (neither engine read a constant_declaration), so a constant a type declares in an interface, or inherits from one, had no declaration to read. A Java Type.FIELD.m() (Outer.Inner.FIELD.m(), a package written in front) now finds the type as the calling file names it (javaTypeRef), then the field, the type's own or one it inherits (five levels up), and calls the method on the value the field's declaration gives it, read with the field's own file's imports: an enum constant's own override, else the enum's method; the method of the anonymous class the field is initialized with (a CREATOR's createFromParcel); the class a new X(...) initializer builds; else the type the declaration writes. A Kotlin object's INSTANCE or Companion reaches the object's member. A library owner, or a field of a library type, links nothing. A receiver whose head is a local or a field links as before. Both engines index an interface's constant_declaration as a static final constant. Validation: - java-static-field-receivers.test.ts: 9 tests, 7 fail on main 60b0d7a9 (two already pass there: a field typed by its own declaration and an anonymous class's method), all pass here. - Kernel parity (java), 0 files with diffs: Torture.java 1/1, TortureRecord.java 1/1, jextract 218/218, spring-modulith 682/682, SystemUI 1141/1141, okhttp 71/71, Minestom 1859/1894 (deferrals are main's). - A/B against main 60b0d7a9, sites added/removed/retargeted (conf-only): Minestom +26003/-8/122 (751; 12,584 interface constants now indexed, 409 calls typed), spring-ai +14/-21/786 (823; 719 enum constants' own overrides), kestra +27/-273/14 (37), playframework +5/-1/0 (21), SystemUI 0/-11/0 (2), spring-modulith +5/-3/4 (26), jextract +1/-9/6 (1); okhttp, nowinandroid, mihon, click, gin, serilog identical. The removed sites are guesses on library fields and methods (kestra's HttpStatus.X.getCode() x180). - Cost (median of 3 interleaved): SystemUI CPU 92.0 -> 93.7 s, wall 59.4 -> 58.6 s; Minestom CPU 46.7 -> 56.4 s, wall 17.5 -> 19.4 s, as its graph grows by the generated registries' 12,584 interface constants (29% more nodes, 30% more edges). Reimplements #1949 by @mixxer. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/fixtures/kernel-parity/Torture.java | 6 + __tests__/java-static-field-receivers.test.ts | 196 ++++++++++++++++++ codegraph-kernel/src/java.rs | 9 +- src/extraction/languages/java.ts | 6 +- src/resolution/name-matcher.ts | 124 +++++++++++ 6 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 __tests__/java-static-field-receivers.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 842a67faed..534995aebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -266,6 +266,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Java and C#, a method called on a newly constructed object, like `new SqlSessionFactoryBuilder().build(reader)` or `new JsonSerializer().Serialize(writer, value)`, now links to that class's own or inherited method. When several packages or namespaces declare a class of that name, it links to the one the file imports, and to none when the file could mean either. Re-index Java and C# projects after upgrading. - In Java, a `record` is now indexed as a class, with its methods, its constructors, a field for each component and the accessor each component gets, like `info.remoteAddress()`. Before, a record had no symbol of its own and its methods were filed under the package or the class around it, so a call on a record-typed value linked to nothing or to the wrong method, and records were missing from callers, impact and implementations. A call like `Circle.unit()` on a type that a sealed interface's `permits` list names first now links too. Re-index Java projects after upgrading. Thanks @danusha2345. - In Java, a call now resolves on the type its receiver is declared with in more cases: a field of an outer class read inside an inner or anonymous class, `Outer.this.field`, a chain of fields like `this.owner.repo.save()`, a local declared with type arguments, a for-each variable, a variable declared with its package, like `org.app.Message message`, and a nested class that a parent class declares. When that type comes from a library, like Android's `Parcel`, `Handler` or `Notification.Builder`, or `java.util.List`, the call no longer links to a same-named method of your own. A type the file imports from a library (`import java.util.Iterator`) or as a nested class (`import p.Http.Cookie`) is no longer mistaken for a same-named type elsewhere in the project, and a Java type wins over a same-named Scala one. Re-index Java projects after upgrading. Thanks @danusha2345. +- In Java, a call on a value a type holds in a static field, like `Consts.HELPER.go()`, `Holder.SHARED.run()`, `Outer.Inner.NESTED.run()`, an enum constant's `Color.RED.hex()` or a `CREATOR`'s `createFromParcel(…)`, now links to the method of the field's type, as the field's declaration reads with its own file's imports: the class a `new FastRunner()` initializer builds, or the anonymous class the field holds. A field the type inherits counts, an enum constant's own override comes first, and a Kotlin object's `INSTANCE` reaches the object's member. An interface's constants are now indexed. Before, such a call was guessed by the method's name, often onto a same-named class in another package, and a field of a library class, like `Intent.ACTION_VIEW.equals(…)`, could link to a project method of that name; it now links nothing. Re-index Java projects after upgrading. Thanks @mixxer. - In Kotlin, a call on what one of your functions returns, like `repository().pending(1)` or `val repo = repository(); repo.pending(1)` with a `private fun repository(): RealRepo` in a test class, now links to the method of the type that function declares, including one the type inherits or an extension on it. Before, the chained call went to whichever `pending` the name matched first, like a test double's in another package, and the local was guessed from its name, which reached the interface. The function is found in the calling class, a class it inherits from or one around it, or at the top level, and a nested type like `Request.Builder` is that type, not another class's `Builder`. When the declared type has no such method, the call now links to nothing rather than a method of the same name elsewhere; a type from outside your project is matched by name as before. Re-index Kotlin projects after upgrading. Thanks @mindcipherrr for the report. (#2805) - In Kotlin, a call through a property now links to the method of the type the property is declared with, like `server.enqueue(…)` on a `lateinit var server: MockWebServer` or a call on a constructor property such as `class A(private val repo: Repo)`. So does a call through a chain like `engine.pump.drain()` or `Mode.ON.next()`, through a property one of your classes inherits, through a local set from a call like `val lease = HardwareLock.tryBegin()`, and through a local of the function around an anonymous `object : …`. Before, such a call went to an interface's method or to whichever method had the name: on okhttp, the tests' calls reached the deprecated `MockWebServer` instead of the one each file imports. A call on a type from outside your project, like `Regex` or a JDK class, no longer links to a same-named method of your own, unless your project extends that type with it. Re-index Kotlin projects after upgrading. Thanks @danusha2345. - In Kotlin, a `fun interface` is now indexed as an interface with its methods, like okhttp's `HttpLoggingInterceptor.Logger`. Before, it was read as a broken function, and when a comment followed it, the declaration after it went missing too, its members turning into loose functions. Re-index Kotlin projects after upgrading. Thanks @danusha2345. diff --git a/__tests__/fixtures/kernel-parity/Torture.java b/__tests__/fixtures/kernel-parity/Torture.java index 221f02b7ad..84ab79b1f6 100644 --- a/__tests__/fixtures/kernel-parity/Torture.java +++ b/__tests__/fixtures/kernel-parity/Torture.java @@ -140,3 +140,9 @@ public void on() {} public Integer call() { return 1; } }; } +interface TortureConsts { + int MAX = 10; + Helper HELPER = new Helper(); + String NAME = "x", OTHER = "y"; + Runnable TASK = new Runnable() { public void run() { go(); } }; +} diff --git a/__tests__/java-static-field-receivers.test.ts b/__tests__/java-static-field-receivers.test.ts new file mode 100644 index 0000000000..c8f09bbec8 --- /dev/null +++ b/__tests__/java-static-field-receivers.test.ts @@ -0,0 +1,196 @@ +/** + * Java's `Type.FIELD.m()` calls a method on a value a type holds in a static + * field: an interface's constant, a class's `static final`, an enum constant. + * The value is typed by the field's own declaration, with its own file's + * imports: the method an anonymous class the field is initialized with + * declares, else the class a `new X(…)` builds, else the declared type's. + * Before, nothing read the field: an interface's constants weren't indexed + * at all, and the call was guessed by the method's name alone, often onto a + * same-named class in another package, or onto a project method for a field + * of a library class (`Intent.ACTION_VIEW.equals(…)`). + * + * Cases from @mixxer's #1949 and the T5 triage fixtures. + */ +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-static-field-')); + const files: Record = { + 'r/Consts.java': `package r; +public interface Consts { + int MAX = 10; + Helper HELPER = new Helper(); + String NAME = "x"; +} +`, + 'r/Helper.java': `package r; +public class Helper { public void go() {} } +`, + 'r/Holder.java': `package r; +public class Holder { + public static final Helper SHARED = new Helper(); +} +`, + 'r/User.java': `package r; +public class User { + void viaInterface() { Consts.HELPER.go(); } + void viaClass() { Holder.SHARED.go(); } +} +`, + 'com/lib/ProjectInterpolator.java': `package com.lib; +public class ProjectInterpolator { public 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; +public class Consumer { + float projectCall() { return ProjectInterpolators.LOCAL.getInterpolation(1); } +} +`, + 's/Shapes.java': `package s; + +import android.os.Parcel; +import android.os.Parcelable; +import android.content.Intent; +import android.view.animation.Interpolators; + +class Parent { + public static final Runner SHARED = new Runner(); +} + +class Child extends Parent {} + +class Runner { public void run() {} public String getName() { return "r"; } } + +class FastRunner extends Runner { @Override public void run() {} } + +interface Defaults { + Runner DEFAULT = new FastRunner(); +} + +class Impl implements Defaults {} + +class Outer { + static class Inner { + static final Runner NESTED = new Runner(); + } +} + +enum Color { + RED, + GREEN { + @Override String hex() { return "0f0"; } + }; + String hex() { return "f00"; } +} + +class Foo implements Parcelable { + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + public Foo createFromParcel(Parcel in) { return new Foo(); } + public Foo[] newArray(int size) { return new Foo[size]; } + }; +} + +class Calls { + void inherited() { Child.SHARED.run(); } + void interfaceConstant() { Impl.DEFAULT.run(); } + void nested() { Outer.Inner.NESTED.run(); } + void enumConstant() { Color.RED.hex(); } + void enumBody() { Color.GREEN.hex(); } + void anonymous(Parcel p) { Foo.CREATOR.createFromParcel(p); } + boolean library(String a) { return Intent.ACTION_VIEW.equals(a); } + float libraryField(float t) { return Interpolators.FAST_OUT_SLOW_IN.getInterpolation(t); } + String javaLang() { return Integer.TYPE.getName(); } + void kotlinObject() { KObj.INSTANCE.go(); } +} +`, + 's/KObj.kt': `package s + +object KObj { + fun go() {} +} + +class Other { + fun equals(x: String): Boolean = true +} +`, + }; + 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 }); +}); + +/** Qualified names of what the method `name` in `file` calls. */ +function callees(file: string, name: string): string[] { + const from = cg.getNodesInFile(file).find((n) => n.kind === 'method' && n.name === name)!; + return cg + .getOutgoingEdgesFrom([from.id]) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)!.qualifiedName) + .sort(); +} + +describe('Java: an interface constant', () => { + it('is indexed, as a static final field is', () => { + const constants = cg.getNodesInFile('r/Consts.java').filter((n) => n.kind === 'constant').map((n) => n.qualifiedName); + expect(constants.sort()).toEqual(['r::Consts::HELPER', 'r::Consts::MAX', 'r::Consts::NAME']); + }); +}); + +describe('Java: Type.FIELD.m()', () => { + it('reaches the method of the field\'s type', () => { + expect(callees('r/User.java', 'viaInterface')).toEqual(['r::Helper::go']); + expect(callees('r/User.java', 'viaClass')).toEqual(['r::Helper::go']); + }); + + it('reads the field\'s type with the field\'s own file\'s imports', () => { + expect(callees('r/Consumer.java', 'projectCall')).toEqual(['p::ProjectInterpolator::getInterpolation']); + }); + + it('finds a field the type inherits, from a class or an interface', () => { + expect(callees('s/Shapes.java', 'inherited')).toEqual(['s::Runner::run']); + expect(callees('s/Shapes.java', 'interfaceConstant')).toEqual(['s::FastRunner::run']); + }); + + it('walks a nested type', () => { + expect(callees('s/Shapes.java', 'nested')).toEqual(['s::Runner::run']); + }); + + it('reaches an enum constant\'s own override, else the enum\'s method', () => { + expect(callees('s/Shapes.java', 'enumConstant')).toEqual(['s::Color::hex']); + expect(callees('s/Shapes.java', 'enumBody')).toEqual(['s::Color::GREEN::hex']); + }); + + it('reaches the method of an anonymous class the field holds', () => { + expect(callees('s/Shapes.java', 'anonymous')).toEqual(['s::Foo::CREATOR::::createFromParcel']); + }); + + it('links nothing for a library type\'s field', () => { + expect(callees('s/Shapes.java', 'library')).toEqual([]); + expect(callees('s/Shapes.java', 'libraryField')).toEqual([]); + expect(callees('s/Shapes.java', 'javaLang')).toEqual([]); + }); + + it('reaches a Kotlin object\'s member through INSTANCE', () => { + expect(callees('s/Shapes.java', 'kotlinObject')).toEqual(['s::KObj::go']); + }); +}); diff --git a/codegraph-kernel/src/java.rs b/codegraph-kernel/src/java.rs index eb8c7b70e6..fed6776b01 100644 --- a/codegraph-kernel/src/java.rs +++ b/codegraph-kernel/src/java.rs @@ -429,6 +429,10 @@ impl<'t> Walker<'t> { } fn is_static(&self, node: Node) -> bool { + // An interface constant is implicitly `public static final`. + 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") { @@ -441,6 +445,9 @@ 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" { @@ -510,7 +517,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 (kind == "field_declaration" || kind == "constant_declaration") && self.inside_class_like() { // The initializers extract_field walked under their fields took // their own candidates; the scan covers the rest. let walked = self.extract_field(node); diff --git a/src/extraction/languages/java.ts b/src/extraction/languages/java.ts index a54db15503..a7830a2d81 100644 --- a/src/extraction/languages/java.ts +++ b/src/extraction/languages/java.ts @@ -331,7 +331,8 @@ export const javaExtractor: LanguageExtractor = { importTypes: ['import_declaration'], callTypes: ['method_invocation'], variableTypes: ['local_variable_declaration'], - fieldTypes: ['field_declaration'], + // An interface's constants (`Helper HELPER = new Helper();`) are fields too. + fieldTypes: ['field_declaration', 'constant_declaration'], nameField: 'name', bodyField: 'body', paramsField: 'parameters', @@ -358,6 +359,8 @@ export const javaExtractor: LanguageExtractor = { return undefined; }, isStatic: (node) => { + // An interface constant is implicitly `public static final`. + 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')) { @@ -370,6 +373,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 7a5d027b88..041ee2d633 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -19811,6 +19811,123 @@ function resolveOnJavaType( return null; } +/** `Type.FIELD`, `Outer.Inner.FIELD`, `pkg.Type.FIELD`: a value a type holds, as a call's receiver. */ +const JAVA_STATIC_FIELD_RECEIVER = /^((?:[a-z_$][\w$]*\.)*(?:[A-Z][\w$]*\.)*[A-Z][\w$]*)\.([A-Za-z_$][\w$]*)$/; + +/** + * The static field `name` of Java type `owner`, its own or one it inherits + * from the types it extends or implements (an interface's constant), a few + * levels up. An enum's constant counts. + */ +function javaStaticField(owner: Node, name: string, context: ResolutionContext): Node | undefined { + const seen = new Set([owner.id]); + let frontier = [owner]; + for (let depth = 0; depth < 5 && frontier.length > 0; depth++) { + const next: Node[] = []; + for (const type of frontier) { + const own = context + .getNodesByQualifiedName(`${type.qualifiedName}::${name}`) + .find((n) => n.filePath === type.filePath && (n.kind === 'field' || n.kind === 'constant' || n.kind === 'enum_member')); + if (own) return own; + for (const sup of declaredSupertypes(type, context)) { + const written = javaTypeName(sup); + const ref = written ? javaTypeRef(written, type.filePath, context) : null; + const tail = written?.split('.').pop() ?? ''; + const decls = ref?.decl ? [ref.decl] + : ref ? jvmTypeDecls(tail.split('::').pop()!, context).filter((d) => d.qualifiedName === ref.name || d.qualifiedName.endsWith(`::${ref.name}`)) + : []; + for (const decl of decls) { + if (seen.has(decl.id)) continue; + seen.add(decl.id); + next.push(decl); + } + } + } + frontier = next; + } + return undefined; +} + +/** + * Java's `Type.FIELD.m()` — `Consts.HELPER.go()`, `Outer.Inner.SHARED.run()`, + * `ProjectInterpolators.LOCAL.getInterpolation(t)`, `Color.RED.hex()`: a call + * on a value a type holds in a static field, an interface's constant or an + * enum's constant. The field is the type's own or one it inherits, and its + * value is typed by its own declaration, read with its own file's imports: the + * method an anonymous class it is initialized with declares (a `CREATOR`'s + * `createFromParcel`), else the class a `new X(…)` initializer builds, else + * the declared type's. A Kotlin object's `INSTANCE` and a class's + * `Companion`, as Java writes them, are the type itself. Null — no edge — for + * a field of a type from outside the project (`Intent.ACTION_VIEW.equals(…)`) + * or a value of one; undefined when the receiver is no type's field (a local, + * a nested type), for the strategies below. + */ +function matchJavaStaticFieldCall( + receiver: string, + methodName: string, + ref: UnresolvedRef, + context: ResolutionContext, +): ResolvedRef | null | undefined { + const m = JAVA_STATIC_FIELD_RECEIVER.exec(receiver); + if (!m) return undefined; + const [, ownerWritten, fieldName] = m; + const head = ownerWritten!.split('.')[0]!; + // A local or a field of that name holds a value, not a type. + if (/^[A-Z]/.test(head) && (inferLocalReceiverType(head, ref, context) || inferJavaFieldReceiverType(head, ref, context))) return undefined; + const written = javaTypeName(ownerWritten!); + if (!written) return undefined; + const ownerType = javaTypeRef(written, ref.filePath, context); + if (!ownerType) return null; + const owners = ownerType.decl ? [ownerType.decl] + : jvmTypeDecls(ownerType.name.split('::').pop()!, context).filter((d) => + (d.qualifiedName === ownerType.name || d.qualifiedName.endsWith(`::${ownerType.name}`)) && isJavaTypeNameable(d, ref.filePath, context)); + const owner = owners.length === 1 ? owners[0]! : owners.find((d) => d.filePath === ref.filePath); + if (!owner) return undefined; + const found = (n: Node | undefined): ResolvedRef | null => + n ? { original: ref, targetNodeId: n.id, confidence: 0.9, resolvedBy: 'instance-method' } : null; + const methodOf = (typeQn: string, file: string): Node | undefined => + context.getNodesByQualifiedName(`${typeQn}::${methodName}`).find((n) => n.kind === 'method' && n.filePath === file); + if (owner.language === 'kotlin' && (fieldName === 'INSTANCE' || fieldName === 'Companion')) { + return found(methodOf(owner.qualifiedName, owner.filePath)); + } + // `Outer.Inner.m()` is a static call on a nested type, not on a field. + if (context.getNodesByQualifiedName(`${owner.qualifiedName}::${fieldName}`).some((n) => JVM_DECLARED_TYPE_KINDS.has(n.kind))) return undefined; + const field = javaStaticField(owner, fieldName!, context); + if (!field || field.language !== 'java') return undefined; + if (field.kind === 'enum_member') { + // A constant's own body overrides first (`PLUS { int apply(…) }`), then the enum's method. + const enumQn = field.qualifiedName.slice(0, field.qualifiedName.lastIndexOf('::')); + const enumDecl = context.getNodesByQualifiedName(enumQn).find((n) => n.filePath === field.filePath && JVM_DECLARED_TYPE_KINDS.has(n.kind)); + const segments = enumQn.split('::'); + const chain = segments.length > 1 && /^[a-z]/.test(segments[0]!) ? segments.slice(1).join('::') : enumQn; + return found(methodOf(field.qualifiedName, field.filePath) ?? methodOf(enumQn, field.filePath)) ?? + (enumDecl ? resolveOnJavaType({ name: chain, decl: enumDecl }, methodName, ref, context) : null); + } + // An anonymous class the field is initialized with: its own method. + const anonMethod = context + .getNodesInFile(field.filePath) + .filter((n) => n.kind === 'class' && n.qualifiedName.startsWith(`${field.qualifiedName}::<`) && n.name.includes('$anon@')) + .map((anon) => methodOf(anon.qualifiedName, field.filePath)) + .find((n) => n !== undefined); + if (anonMethod) return found(anonMethod); + // A `new X(…)` initializer builds X, the declared type's subtype at most. + const lines = context.getFileLines?.(field.filePath) ?? context.readFile(field.filePath)?.split(/\r?\n/) ?? []; + const declText = lines.slice(field.startLine - 1, Math.min(lines.length, (field.endLine ?? field.startLine))).join('\n'); + const built = new RegExp(`\\b${field.name.replace(/\$/g, '\\$')}\\s*=\\s*new\\s+((?:[\\w$]+\\s*\\.\\s*)*[\\w$]+)\\s*(?:<[^()]*>)?\\s*\\(`).exec(declText)?.[1]?.replace(/\s+/g, ''); + if (built) { + const builtName = javaTypeName(built); + const builtType = builtName ? javaTypeRef(builtName, field.filePath, context) : null; + if (builtType) { + const viaBuilt = resolveOnJavaType(builtType, methodName, ref, context); + if (viaBuilt) return viaBuilt; + } + } + const declared = fieldSignatureType(field); + if (!declared) return null; + const declaredType = javaTypeRef(declared, field.filePath, context); + return declaredType ? resolveOnJavaType(declaredType, methodName, ref, context) : null; +} + /** * Java: the declared type of a receiver the field lookup reaches — a field * (`mRepo`, `this.mRepo`, `Outer.this.mRepo`) or a chain of up to three @@ -22335,6 +22452,13 @@ export function matchMethodCall( if (typed !== undefined) rustBindingLine = typed.unread; } + // A Java call on a value a type holds in a static field, `Consts.HELPER.go()`: + // typed by the field's declaration, or no edge for a library type's field. + if (ref.language === 'java' && dotMatch) { + const onField = nmTimedT('mc-javastaticfield', ref, () => matchJavaStaticFieldCall(objectOrClass!, methodName!, ref, context)); + if (onField !== undefined) return onField; + } + // Infer the receiver's type from its local declaration/initializer in the // enclosing scope, then resolve the method on that type (#1108). C++ keeps its // dedicated inferrer (header scan + `auto`); every other language uses the