Merge pull request #2597 from magyargergo/fix/2564-record-newexpr-callgraph

fix(java): record container node + new-expression chained call receiver typing (#2564)
This commit is contained in:
Gergő Magyar 2026-07-21 09:51:16 +01:00 • committed by GitHub
commit 092411d6cb
9 changed files with 143 additions and 7 deletions

View file

@ -89,7 +89,7 @@
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0."
},
"java": {
"fingerprint": "975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca",
"fingerprint": "85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537",
"scaling_budget": 1.5,
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata; same-name lexical regions use an O(ancestor-depth) ID-set lookup. Prior d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a -> 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4; scaling 0.992 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Java method-reference/SAM callable flow facts with invocation-result suppression. Prior 062d754764aaa8a6772fb90875c710502a63e3e7a300e633942381ed914faada -> d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a; scaling 1.074 < 1.5.",
@ -97,7 +97,8 @@
"_note": "#1928 / #2045: F35 adds qualified + qualified-generic constructor query captures (`new pkg.Foo()`, `new a.b.Foo()`, `new pkg.Box<T>()`); F38 synthesizes `@reference.call.constructor` on `super(...)`/`this(...)` explicit_constructor_invocation nodes; F41 generic-aware stripQualifier in interpret (type-binding normalization). + java-qualified-constructor and java-explicit-constructor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.06).",
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: get/test dropped from callableProtocolMethods. Prior 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4 -> f3b4f4b6610e07c3ac90deb1c53d3572b6ad55a36e5d7134984876d30031ff67; scaling ratio re-verified within budget.",
"_rebaselined_2550_instance_model": "PR #2549 (#2550): anonymous class bodies emit synthesized @declaration.class/@declaration.name (Worker$N), an @reference.inherits to the constructed type, and receiver @type-binding.* captures; six new java-* fixtures joined the corpus. Prior f3b4f4b6610e07c3ac90deb1c53d3572b6ad55a36e5d7134984876d30031ff67 -> d79c3b92acfc866094981499b977388ca14f90839bca0c040342ab1cec00aa90; scaling 1.058 < 1.5.",
"_rebaselined_2555_enum_constant_bodies": "PR for #2555: enum constant bodies emit synthesized E$N classes + @reference.inherits to the host enum; anonymous naming follows JLS 13.1 immediately-enclosing-type chains INCLUDING anonymous enclosing types (NestHost$1$1, N$1$1); six new java-* fixtures joined the corpus. Prior d79c3b92acfc866094981499b977388ca14f90839bca0c040342ab1cec00aa90 -> 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca; scaling 1.05 < 1.5."
"_rebaselined_2555_enum_constant_bodies": "PR for #2555: enum constant bodies emit synthesized E$N classes + @reference.inherits to the host enum; anonymous naming follows JLS 13.1 immediately-enclosing-type chains INCLUDING anonymous enclosing types (NestHost$1$1, N$1$1); six new java-* fixtures joined the corpus. Prior d79c3b92acfc866094981499b977388ca14f90839bca0c040342ab1cec00aa90 -> 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca; scaling 1.05 < 1.5.",
"_rebaselined_2564_record_capture": "PR for #2564: JAVA_QUERIES gained a (record_declaration name: (identifier) @name) @definition.record capture, previously entirely missing (record_declaration had no structure-phase capture at all, unlike class/interface/enum) - a record's methods existed as ownerless Method nodes with no HAS_METHOD edge. Two new java-* fixtures (java-record-methods, java-new-expr-chain-call) joined the corpus. Prior 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca -> 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537; scaling 1.059 < 1.5."
},
"typescript": {
"fingerprint": "3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4",

View file

@ -150,6 +150,29 @@ export function emitJavaScopeCaptures(
continue;
}
// Normalize a `new`-expression receiver to its constructed type's simple
// name: `new Local().inner()` binds the WHOLE `object_creation_expression`
// as `@reference.receiver`, so its raw text is `"new Local()"` — a string
// that can never match a scope binding, so the compound-receiver resolver
// silently falls through to name-only fallback resolution and picks the
// wrong same-named method on a collision (#2564). Rewriting the text to
// just `Local` lets Case 2 (class-name / static receiver) in
// receiver-bound-calls.ts resolve it via its normal MRO walk. Mirrors the
// established `normalizePhpReceiver` precedent (php/captures.ts) — a
// language-local capture rewrite, no shared-pipeline change.
if (grouped['@reference.receiver'] !== undefined) {
const receiverNode = nodeIfType(nodeMap['@reference.receiver'], 'object_creation_expression');
const typeNode = receiverNode?.childForFieldName('type');
const simpleName = typeNode ? javaBaseSimpleNameOf(typeNode) : undefined;
if (simpleName !== undefined) {
grouped['@reference.receiver'] = syntheticCapture(
'@reference.receiver',
receiverNode!,
simpleName,
);
}
}
// Filter read.member when it's a child of method_invocation or assignment.
// `@reference.read.member` is captured directly on the `field_access` node.
if (grouped['@reference.read.member'] !== undefined) {

View file

@ -743,10 +743,11 @@ export const PYTHON_QUERIES = `
// Java queries - works with tree-sitter-java
export const JAVA_QUERIES = `
; Classes, Interfaces, Enums, Annotations
; Classes, Interfaces, Enums, Records, Annotations
(class_declaration name: (identifier) @name) @definition.class
(interface_declaration name: (identifier) @name) @definition.interface
(enum_declaration name: (identifier) @name) @definition.enum
(record_declaration name: (identifier) @name) @definition.record
(annotation_type_declaration name: (identifier) @name) @definition.annotation
; Anonymous class bodies: new Runnable() { ... } — no @name capture; the

View file

@ -429,8 +429,16 @@ export interface RepoMeta {
* `E.hook` to `E$1.hook`, and nested-host anonymous names re-key
* (`EnumWrap$1` → `EnumWrap$Mode$1`). Same contract as v8: identities move
* on unchanged files; force a full re-analyze.
* v10: Java `record_declaration` now emits a first-class `Record` graph node
* (#2564): a record's container node was previously never created (JAVA_QUERIES
* had no capture for it), so its methods existed as ownerless Method nodes
* with no `HAS_METHOD` edge. The incremental write set only covers changed
* files — a top-up against a pre-v10 index would keep silently omitting the
* `Record` node and its `HAS_METHOD` edges for every unchanged record file
* (same v7 contract: new nodes/edges the incremental path would otherwise
* never backfill); force a full re-analyze instead.
*/
export const INCREMENTAL_SCHEMA_VERSION = 9;
export const INCREMENTAL_SCHEMA_VERSION = 10;
export interface IndexedRepo {
repoPath: string;

View file

@ -0,0 +1,12 @@
package probe;
public class LocalChain {
void m() {
class Local {
void inner() {
System.out.println("right target");
}
}
new Local().inner();
}
}

View file

@ -0,0 +1,7 @@
package probe;
class Other {
void inner() {
System.out.println("wrong target");
}
}

View file

@ -0,0 +1,11 @@
package probe;
public record Point(int x, int y) {
public int sum() {
return x + y;
}
public int scaled(int factor) {
return sum() * factor;
}
}

View file

@ -1174,6 +1174,75 @@ describe('Java chained method call resolution', () => {
});
});
// ---------------------------------------------------------------------------
// Chained call on a new-expression receiver: new Local().inner()
// The receiver of inner() is an object_creation_expression, not a variable.
// Regression test for #2564: without treating `new Local()` as a typed
// receiver, the call falls back to name-only resolution and can pick an
// unrelated same-named method (Other.inner) instead of Local.inner.
// ---------------------------------------------------------------------------
describe('Java chained call on a new-expression receiver (#2564)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-new-expr-chain-call'), () => {});
}, 60000);
it('detects LocalChain and Other classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('LocalChain');
expect(classes).toContain('Other');
});
it('resolves new Local().inner() to the local Local#inner, NOT Other#inner', () => {
const calls = getRelationships(result, 'CALLS');
const localInner = calls.find(
(c) =>
c.target === 'inner' && c.source === 'm' && c.targetFilePath.includes('LocalChain.java'),
);
const otherInner = calls.find(
(c) => c.target === 'inner' && c.source === 'm' && c.targetFilePath.includes('Other.java'),
);
expect(localInner).toBeDefined();
expect(otherInner).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Java record: container node + HAS_METHOD edges
// Regression test for #2564: JAVA_QUERIES previously had no @definition.record
// capture, so a record never got a Class/Record graph node — its methods
// existed as ownerless orphans with no HAS_METHOD edge.
// ---------------------------------------------------------------------------
describe('Java record method resolution (#2564)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-record-methods'), () => {});
}, 60000);
it('detects a Record node for Point', () => {
const records = getNodesByLabel(result, 'Record');
expect(records).toContain('Point');
});
it('emits HAS_METHOD edges linking sum and scaled to Point', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const sumEdge = hasMethod.find((e) => e.source === 'Point' && e.target === 'sum');
const scaledEdge = hasMethod.find((e) => e.source === 'Point' && e.target === 'scaled');
expect(sumEdge).toBeDefined();
expect(scaledEdge).toBeDefined();
});
it('resolves scaled() calling sum() via a CALLS edge', () => {
const calls = getRelationships(result, 'CALLS');
const sumCall = calls.find((c) => c.target === 'sum' && c.source === 'scaled');
expect(sumCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Java 16+ instanceof pattern variable: `if (obj instanceof User user)`
// Phase 5.2: extractPatternBinding on instanceof_expression binds user → User.

View file

@ -73,8 +73,8 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => {
});
describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
it('INCREMENTAL_SCHEMA_VERSION is bumped to 9 (Java enum-constant-body + JLS-naming re-index window)', () => {
expect(INCREMENTAL_SCHEMA_VERSION).toBe(9);
it('INCREMENTAL_SCHEMA_VERSION is bumped to 10 (Java record container-node re-index window)', () => {
expect(INCREMENTAL_SCHEMA_VERSION).toBe(10);
});
it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => {
@ -108,7 +108,11 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
// topmost-anchored `EnumWrap$1`-style ids would be stranded alongside
// the re-keyed ones on unchanged files → must NOT reuse.
expect(passesReuseGate(8)).toBe(false);
// A pre-v10 (v9) index predates the Java record container-node fix
// (#2564) — a record's methods would keep being ownerless Method nodes
// with no HAS_METHOD edge on unchanged files → must NOT reuse.
expect(passesReuseGate(9)).toBe(false);
// A current-version stamp passes the gate (incremental top-up eligible).
expect(passesReuseGate(9)).toBe(true);
expect(passesReuseGate(10)).toBe(true);
});
});