From 3a93f57ae1796b47cf6f27e8685e58de957392c3 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Fri, 23 Oct 2026 16:37:08 -0500 Subject: [PATCH] fix(java): a record is indexed as a class, with its components' fields and accessors Neither engine treated record_declaration as a class type, so a Java record left no node of its own. Its methods and explicit constructor became package-level functions (p::norm, p::Point), a nested record's methods were filed under the class around it, compact constructors, static fields and records nested in a record were dropped, and calls on a record-typed value resolved to nothing or to a same-named member of another type. The Java extractor and the kernel now treat record_declaration as a class and a compact constructor as a method. Each component becomes a private field (" ") and a public accessor name() with the component's type as its return type, unless the body declares it; they are synthesized before Lombok's members, the same way. An implicit accessor is reached only through what types its receiver: a typed receiver, a method reference, a qualified call, or a call from inside the record. The name-only strategies (the receiver-word guesses, exact-name and fuzzy matching) no longer settle on one, so result.content() on a library type stops linking to whichever record has a content component. Records usually sit in sealed hierarchies, and that exposed a resolver gap: the member-type reader took the head "sealed interface Blocks permits Block" for a member named Block of type "permits", typed the receiver of Block.fromKey(...) inside the record as a built-in and dropped the call. "permits" and "throws" are now among the words that are not a type there. Validation: - java-records.test.ts, 5 cases on both engines (10 tests), all red on main; the sealed-permits and name-only-guess cases are each red without their resolver line. - Kernel parity: TortureRecord.java in the torture and CRLF runs; real-repo sweeps at full parity with deferrals unchanged (jextract 218/218, spring-modulith 682/682, playframework 801/801, kestra 2997/2997, spring-ai 2491/2492, Minestom 1859/1894). - A/B against main d02a88d0, every bucket triaged by sampling (edge sites added/removed): jextract +207/-1, spring-modulith +375/-1, playframework +80/-2, Minestom +17,963/-287, spring-ai +8,806/-471, kestra +10,077/-497; most retargets are a constructor function becoming its record. The accessor rule drops only name-only results (spring-ai 1,063, kestra 765, Minestom 408 sites at 0.65-0.8 confidence); no typed call, method reference or qualified call moves. click and gin byte-identical. - Cost (process CPU, median of 3 interleaved indexes): Minestom 47.2s -> 50.3s (+6.6%, for 17% more nodes and 23% more edges), spring-ai 93.5s -> 94.8s (+1.4%). Original PR #1946 by @danusha2345. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../fixtures/kernel-parity/TortureRecord.java | 49 +++++ __tests__/java-records.test.ts | 207 ++++++++++++++++++ __tests__/kernel-tsjs-parity.test.ts | 6 + codegraph-kernel/src/java.rs | 109 +++++++-- src/extraction/languages/java.ts | 71 +++++- src/resolution/index.ts | 4 +- src/resolution/name-matcher.ts | 22 +- src/resolution/types.ts | 2 + src/types.ts | 7 + 10 files changed, 453 insertions(+), 25 deletions(-) create mode 100644 __tests__/fixtures/kernel-parity/TortureRecord.java create mode 100644 __tests__/java-records.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f9c2301..91342128 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -222,6 +222,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Java, Kotlin, C# and Scala, each overload of an interface or base-class method now links only to the overrides with the same parameters. Before, every overload linked to every method of that name in the implementing class, so `ExampleService.putTairValue(String)` also led to `putTairValue(List, double)`, and `codegraph_explore`'s call path, callers and impact went into overloads that call never runs. Where a parameter is a type parameter, like `save(T entity)` implemented as `save(String entity)`, or the class is written in another language than its interface, the number of parameters decides; a method declared once on each side links as before. Re-index Java, Kotlin, C# and Scala projects after upgrading. Thanks @hxj1225 for the report and @danusha2345 for the pull request. (#2663) - In Java and TypeScript, a class that implements or extends a parameterized type, like `implements Store`, `extends Base`, an interface's `extends Store` or TypeScript's `implements Store`, is now linked to that type, so its methods get their interface-to-implementation links and `codegraph_explore` follows a call on the interface into the class. Before, such a supertype was read with its type arguments and matched nothing. Re-index Java and TypeScript projects after upgrading. Thanks @danusha2345, and @paiyahooJay for the earlier pull request. (#2705) - 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 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 JavaScript and TypeScript, a `super.run()` call now links to the method of the class's parent, or of the nearest ancestor that has it, like three.js's `super.copy(source)` reaching `Object3D.copy`, so `codegraph_explore`'s call path and impact follow an override into the method it builds on. Before, such a call inside a method of the same name was dropped, and elsewhere it went to whatever method had the name, often the class's own or a sibling class's in the same file. A call from a static method reaches the parent's static method, and a call to a parent from outside your project, like `HTMLElement`, `Set` or `Promise`, links nothing. Re-index JavaScript and TypeScript projects after upgrading. Thanks @Puma7 for the report and the pull request. (#2497) - In JavaScript and TypeScript, a named class expression, like `register(class Widget extends Base {…})`, `customElements.define('x-card', class XCard extends HTMLElement {…})` or `const Panel = class PanelImpl extends Base {…}`, is now indexed as a class with its own methods and the class it extends, so calls between its methods and its `super` calls link as a class declaration's do. Before, its methods were free functions or weren't indexed at all, and it extended nothing. Its name stays bound inside the expression alone, as JavaScript has it: `new Widget()` elsewhere links nothing, a constant, class or interface of the same name around it keeps its links, and code outside never reaches its methods by a guess. A class a CommonJS module exports, like webpack's `module.exports = class MultiCompiler {…}` or `exports.Store = class Store {…}`, is reached through `require` as a class declaration is, along with its static methods and the methods called on its instances. Re-index JavaScript and TypeScript projects after upgrading. Thanks @Puma7 for the report and the pull request. (#2499) diff --git a/__tests__/fixtures/kernel-parity/TortureRecord.java b/__tests__/fixtures/kernel-parity/TortureRecord.java new file mode 100644 index 00000000..5107fa57 --- /dev/null +++ b/__tests__/fixtures/kernel-parity/TortureRecord.java @@ -0,0 +1,49 @@ +package com.example.records; + +import java.util.List; +import lombok.Builder; + +/** A 2-D point. */ +public record Point(int x, int y) implements Comparable, Shape { + public static final Point ORIGIN = new Point(0, 0); + private static int created; + + public Point { + if (x < 0) throw new IllegalArgumentException("x"); + created++; + } + + public Point(int v) { this(v, v); } + + /** Explicit accessor — no implicit one is minted. */ + public int x() { return x; } + + public double norm() { return Math.sqrt(x * x + y * y); } + + @Override + public int compareTo(Point o) { return Double.compare(norm(), o.norm()); } + + record Inner(String name, List tags) { + String first() { return tags.get(0); } + } +} + +record Pair(A first, B second) { + A left() { return first; } +} + +@Builder +record Config(@Deprecated String host, int[] ports, java.util.Map limits, String... rest) {} + +interface Shape { double norm(); } + +class Client { + private Point.Inner inner; + + String use(Point p, Pair pr) { + record Local(int n) { int twice() { return n * 2; } } + Local l = new Local(3); + Config c = Config.builder().build(); + return inner.first() + p.y() + pr.left() + l.twice() + c.host() + Point.ORIGIN.norm(); + } +} diff --git a/__tests__/java-records.test.ts b/__tests__/java-records.test.ts new file mode 100644 index 00000000..8ac32389 --- /dev/null +++ b/__tests__/java-records.test.ts @@ -0,0 +1,207 @@ +/** + * Java `record` declarations are first-class types. + * + * A record is a class: its body holds methods, constructors (including the + * compact `Point { … }` form), static fields and nested types, and its header + * declares components — each an implicit private field plus a public accessor + * `x()`. Before, the grammar's `record_declaration` was not a class type, so a + * record produced no node, its methods were orphaned onto the package, its + * `implements` were lost, and `p.norm()` / `info.remoteAddress()` on a + * record-typed receiver resolved to nothing (or to an interface's method). + * + * Runs through both extraction paths: the wasm extractor and the native + * kernel (default routing when a kernel is staged). + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { CodeGraph } from '../src'; + +type NodeRow = { kind: string; name: string; qualified_name: string; signature: string | null; docstring: string | null }; +type EdgeRow = { kind: string; src: string; tgt: string; tgtKind: string }; + +describe.each([ + ['wasm', '0'], + ['default routing', undefined], +] as const)('Java records (%s)', (_label, kernelEnv) => { + let dir: string; + let savedKernel: string | undefined; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'java-records-')); + savedKernel = process.env.CODEGRAPH_KERNEL; + if (kernelEnv === undefined) delete process.env.CODEGRAPH_KERNEL; + else process.env.CODEGRAPH_KERNEL = kernelEnv; + }); + afterEach(() => { + if (savedKernel === undefined) delete process.env.CODEGRAPH_KERNEL; + else process.env.CODEGRAPH_KERNEL = savedKernel; + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const write = (rel: string, body: string) => { + const p = path.join(dir, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, body); + }; + + const load = async () => { + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + const nodes: NodeRow[] = db + .prepare(`SELECT kind, name, qualified_name, signature, docstring FROM nodes`) + .all(); + const edges: EdgeRow[] = db + .prepare( + `SELECT e.kind kind, s.qualified_name src, t.qualified_name tgt, t.kind tgtKind FROM edges e + JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target` + ) + .all(); + cg.close?.(); + const has = (kind: string, src: string, tgt: string) => + edges.some((e) => e.kind === kind && e.src === src && e.tgt === tgt); + const node = (qn: string, kind: string) => nodes.filter((n) => n.qualified_name === qn && n.kind === kind); + return { nodes, edges, has, node }; + }; + + const fixture = () => { + write('p/Shape.java', `package p; +public interface Shape { double norm(); } +`); + write('p/Point.java', `package p; + +public record Point(int x, int y) implements Shape { + public static final Point ORIGIN = new Point(0, 0); + public Point { + if (x < 0) throw new IllegalArgumentException(); + } + public Point(int v) { this(v, v); } + public double norm() { return Math.sqrt(x * x + y * y); } + record Inner(String name) {} +} +`); + write('p/Pair.java', `package p; +public record Pair(A first, B second) { + public A left() { return first; } +} +`); + write('p/Http.java', `package p; +public class Http { + public record RemoteInfo(String remoteAddress, int port) { + public String describe() { return remoteAddress + ":" + port; } + public String remoteAddress() { return remoteAddress.trim(); } + } + private RemoteInfo info; + public String use(Point p, RemoteInfo param) { + Point local = new Point(1, 2); + double a = local.norm(); + double b = p.norm(); + String c = info.remoteAddress(); + String d = param.describe(); + int e = p.x(); + Pair pr = new Pair<>("a", 1); + String f = pr.left(); + return c + d + a + b + e + f; + } +} +`); + }; + + it('indexes a record as a class that contains its members and implements its interfaces', async () => { + fixture(); + const { node, has } = await load(); + expect(node('p::Point', 'class')).toHaveLength(1); + expect(node('p::Pair', 'class')).toHaveLength(1); + // Nested records qualify under their outer type. + expect(node('p::Http::RemoteInfo', 'class')).toHaveLength(1); + expect(node('p::Point::Inner', 'class')).toHaveLength(1); + expect(has('contains', 'p::Http', 'p::Http::RemoteInfo')).toBe(true); + expect(has('contains', 'p::Point', 'p::Point::Inner')).toBe(true); + + // Body members hang off the record, not the package. + expect(has('contains', 'p::Point', 'p::Point::norm')).toBe(true); + expect(node('p::norm', 'function')).toHaveLength(0); + expect(node('p::Point::ORIGIN', 'constant')).toHaveLength(1); + // Both the compact and the explicit constructor. + expect(node('p::Point::Point', 'method')).toHaveLength(2); + + expect(has('implements', 'p::Point', 'p::Shape')).toBe(true); + }); + + it('synthesizes a field and an accessor per component, never shadowing a declared accessor', async () => { + fixture(); + const { node } = await load(); + expect(node('p::Point::x', 'field').map((n) => n.signature)).toEqual(['int x']); + expect(node('p::Point::x', 'method').map((n) => n.signature)).toEqual(['int x()']); + expect(node('p::Pair::first', 'field').map((n) => n.signature)).toEqual(['A first']); + expect(node('p::Point::Inner::name', 'method')).toHaveLength(1); + + // RemoteInfo declares remoteAddress() itself — exactly one, the hand-written one. + const accessor = node('p::Http::RemoteInfo::remoteAddress', 'method'); + expect(accessor).toHaveLength(1); + expect(accessor[0]!.docstring).toBeNull(); + expect(node('p::Http::RemoteInfo::port', 'method')[0]!.docstring).toBe('Implicit record component accessor'); + }); + + it('resolves calls on record-typed locals, fields and parameters to the record', async () => { + fixture(); + const { has } = await load(); + expect(has('calls', 'p::Http::use', 'p::Point::norm')).toBe(true); // local + param + expect(has('calls', 'p::Http::use', 'p::Shape::norm')).toBe(false); + expect(has('calls', 'p::Http::use', 'p::Http::RemoteInfo::remoteAddress')).toBe(true); // field + expect(has('calls', 'p::Http::use', 'p::Http::RemoteInfo::describe')).toBe(true); // param + expect(has('calls', 'p::Http::use', 'p::Point::x')).toBe(true); // implicit accessor + expect(has('calls', 'p::Http::use', 'p::Pair::left')).toBe(true); // generic record + expect(has('instantiates', 'p::Http::use', 'p::Point')).toBe(true); + }); + + it('resolves a call on the record type itself when a sealed interface permits it first', async () => { + // `permits Circle,` in the interface's head is not a member named `Circle` + // of type `permits`: read as one, it typed the receiver of `Circle.unit()` + // as a built-in and dropped the call, now that the record encloses it. + write('p/Shape.java', `package p; +public sealed interface Shape permits Circle, Square { + double area(); +} +`); + write('p/Circle.java', `package p; +public record Circle(double r) implements Shape { + static Circle unit() { return new Circle(1); } + public double area() { return r * r; } + Circle twice() { return Circle.unit(); } +} +`); + write('p/Square.java', `package p; +public record Square(double side) implements Shape { + public double area() { return side * side; } +} +`); + const { has } = await load(); + expect(has('calls', 'p::Circle::twice', 'p::Circle::unit')).toBe(true); + expect(has('implements', 'p::Circle', 'p::Shape')).toBe(true); + }); + + it('reaches an implicit accessor through a typed receiver, never by a name-only guess', async () => { + // `point` in the lambda has no type the resolver can read; its name + // matching the record's is no evidence that it is one. + write('p/Point.java', `package p; +public record Point(int x, int y) { + int sum() { return x() + y(); } +} +`); + write('p/Use.java', `package p; +import java.util.List; +public class Use { + int typed(Point p) { return p.x(); } + void guessed(List points) { points.forEach(point -> point.x()); } + int ref(List ps) { return ps.stream().mapToInt(Point::y).sum(); } +} +`); + const { has } = await load(); + expect(has('calls', 'p::Use::typed', 'p::Point::x')).toBe(true); + expect(has('references', 'p::Use::ref', 'p::Point::y')).toBe(true); + expect(has('calls', 'p::Point::sum', 'p::Point::x')).toBe(true); + expect(has('calls', 'p::Use::guessed', 'p::Point::x')).toBe(false); + }); +}); diff --git a/__tests__/kernel-tsjs-parity.test.ts b/__tests__/kernel-tsjs-parity.test.ts index a0f3b7dd..915aeca3 100644 --- a/__tests__/kernel-tsjs-parity.test.ts +++ b/__tests__/kernel-tsjs-parity.test.ts @@ -409,6 +409,11 @@ export const Box = () =>
; assertParity('fixtures/Torture.java', fs.readFileSync(file, 'utf8'), 'java'); }); + it('torture fixture (java records): components, compact ctors, nested/local/generic records', () => { + const file = path.join(FIXTURE_DIR, 'TortureRecord.java'); + assertParity('fixtures/TortureRecord.java', fs.readFileSync(file, 'utf8'), 'java'); + }); + it('torture fixture (python): decorators, self fn-refs, imports, shadowing', () => { const file = path.join(FIXTURE_DIR, 'torture.py'); assertParity('fixtures/torture.py', fs.readFileSync(file, 'utf8'), 'python'); @@ -503,6 +508,7 @@ func wire(s *Store) { ['torture.tsx', 'tsx'], ['torture.js', 'javascript'], ['Torture.java', 'java'], + ['TortureRecord.java', 'java'], ['torture.py', 'python'], ['torture.go', 'go'], ] as const)('torture fixture CRLF parity: %s', (name, lang) => { diff --git a/codegraph-kernel/src/java.rs b/codegraph-kernel/src/java.rs index 35cea85a..6a7735a2 100644 --- a/codegraph-kernel/src/java.rs +++ b/codegraph-kernel/src/java.rs @@ -23,7 +23,11 @@ use tree_sitter::{Node, Parser}; const MAX_VALUE_REF_NODES: usize = 20_000; fn is_method_type(kind: &str) -> bool { - matches!(kind, "method_declaration" | "constructor_declaration") + matches!(kind, "method_declaration" | "constructor_declaration" | "compact_constructor_declaration") +} +/// classTypes (languages/java.ts): a `record` is a class. +fn is_class_type(kind: &str) -> bool { + matches!(kind, "class_declaration" | "record_declaration") } fn is_interface_type(kind: &str) -> bool { matches!(kind, "interface_declaration" | "annotation_type_declaration") @@ -494,7 +498,7 @@ impl<'t> Walker<'t> { self.maybe_capture_fn_refs(node); - if kind == "class_declaration" { + if is_class_type(kind) { self.extract_class(node); skip_children = true; } else if is_method_type(kind) { @@ -562,7 +566,7 @@ impl<'t> Walker<'t> { // Static-member / value-read (`Type.CONST`) — self-gates on field_access. self.extract_static_member_ref(node); - if kind == "class_declaration" { + if is_class_type(kind) { self.extract_class(node); return; } @@ -603,7 +607,11 @@ impl<'t> Walker<'t> { self.visit_node(c); } } - // Lombok member synthesis (#912) — class still on the stack. + // synthesizeJavaMembers — class still on the stack: record + // components first, then Lombok (#912). + if node.kind() == "record_declaration" { + self.synthesize_record_components(node, row); + } self.synthesize_lombok_members(node, row); self.stack.pop(); } @@ -1418,6 +1426,82 @@ impl<'t> Walker<'t> { names } + /// Members already declared directly in a class (exact `classQN::name` + /// matches), split into the method and field namespaces. + fn declared_member_names(&self, class_row: u32) -> (HashSet, HashSet) { + let class_qn = self.nodes_meta[class_row as usize].qualified_name.clone(); + let mut taken_methods: HashSet = HashSet::new(); + let mut taken_fields: HashSet = HashSet::new(); + for m in &self.nodes_meta { + if m.qualified_name == format!("{class_qn}::{}", m.name) { + match m.kind { + "method" | "function" => { + taken_methods.insert(m.name.clone()); + } + "field" | "variable" | "constant" | "property" => { + taken_fields.insert(m.name.clone()); + } + _ => {} + } + } + } + (taken_methods, taken_fields) + } + + /// synthesizeRecordComponents (languages/java.ts): per record component a + /// private field and a public accessor, anchored on the component's name. + fn synthesize_record_components(&mut self, record_node: Node<'t>, record_row: u32) { + let Some(params) = record_node.child_by_field_name("parameters") else { return }; + let (mut taken_methods, mut taken_fields) = self.declared_member_names(record_row); + for i in 0..params.named_child_count() { + let Some(param) = params.named_child(i) else { continue }; + if param.kind() != "formal_parameter" { + continue; + } + let (Some(name_node), Some(type_node)) = + (param.child_by_field_name("name"), param.child_by_field_name("type")) + else { + continue; + }; + let name = self.text(name_node).trim().to_string(); + let type_text = self.text(type_node).trim().to_string(); + if name.is_empty() { + continue; + } + if !taken_fields.contains(&name) { + taken_fields.insert(name.clone()); + self.create_node( + "field", + &name, + name_node, + Extra { + visibility: Some(2), + is_static: Some(false), + signature: Some(format!("{type_text} {name}")), + ..Extra::default() + }, + ); + } + if !taken_methods.contains(&name) { + taken_methods.insert(name.clone()); + let return_type = self.normalize_java_type(Some(type_node)); + self.create_node( + "method", + &name, + name_node, + Extra { + visibility: Some(1), + is_static: Some(false), + signature: Some(format!("{type_text} {name}()")), + docstring: Some("Implicit record component accessor".to_string()), + return_type, + ..Extra::default() + }, + ); + } + } + } + fn synthesize_lombok_members(&mut self, class_node: Node<'t>, class_row: u32) { let class_anns = self.lombok_annotation_names(class_node); let class_getter = class_anns.contains("Getter"); @@ -1448,23 +1532,8 @@ impl<'t> Walker<'t> { } // Members the source already declares (exact `classQN::name` matches). - let class_qn = self.nodes_meta[class_row as usize].qualified_name.clone(); let class_name = self.nodes_meta[class_row as usize].name.clone(); - let mut taken_methods: HashSet = HashSet::new(); - let mut taken_fields: HashSet = HashSet::new(); - for m in &self.nodes_meta { - if m.qualified_name == format!("{class_qn}::{}", m.name) { - match m.kind { - "method" | "function" => { - taken_methods.insert(m.name.clone()); - } - "field" | "variable" | "constant" | "property" => { - taken_fields.insert(m.name.clone()); - } - _ => {} - } - } - } + let (mut taken_methods, taken_fields) = self.declared_member_names(class_row); let class_name_node = class_node.child_by_field_name("name").unwrap_or(class_node); diff --git a/src/extraction/languages/java.ts b/src/extraction/languages/java.ts index 5db82589..a54db155 100644 --- a/src/extraction/languages/java.ts +++ b/src/extraction/languages/java.ts @@ -1,6 +1,7 @@ import type { Node as SyntaxNode } from 'web-tree-sitter'; import { getNodeText, getChildByField } from '../tree-sitter-helpers'; import type { ExtractorContext, LanguageExtractor } from '../tree-sitter-types'; +import { IMPLICIT_RECORD_ACCESSOR_DOC } from '../../types'; /** * Tree-sitter-java node types for a method's `type` (return) field that can @@ -250,10 +251,74 @@ function synthesizeLombokMembers(classNode: SyntaxNode, ctx: ExtractorContext): } } +// --------------------------------------------------------------------------- +// Record component members +// --------------------------------------------------------------------------- +// A record header `record Point(int x, int y)` declares, per component, a +// private final field and a public accessor `x()` — neither appears in the +// body, so without them `p.x()` resolves to nothing and a component's type +// can't type a receiver. Each is anchored on the component's name token (a +// leaf, like the Lombok members). The canonical constructor is not +// synthesized: `new Point(…)` already links to the record via `instantiates`. +// Varargs components (`String... rest`) are skipped. +function synthesizeRecordComponents(recordNode: SyntaxNode, ctx: ExtractorContext): void { + const params = getChildByField(recordNode, 'parameters'); + if (!params) return; + const recordId = ctx.nodeStack[ctx.nodeStack.length - 1]; + const recordQN = ctx.nodes.find((n) => n.id === recordId)?.qualifiedName; + const takenMethods = new Set(); + const takenFields = new Set(); + if (recordQN) { + for (const n of ctx.nodes) { + if (n.filePath === ctx.filePath && n.qualifiedName === `${recordQN}::${n.name}`) { + if (n.kind === 'method' || n.kind === 'function') takenMethods.add(n.name); + else if (n.kind === 'field' || n.kind === 'variable' || n.kind === 'constant' || n.kind === 'property') { + takenFields.add(n.name); + } + } + } + } + for (const param of params.namedChildren) { + if (param.type !== 'formal_parameter') continue; + const nameNode = getChildByField(param, 'name'); + const typeNode = getChildByField(param, 'type'); + if (!nameNode || !typeNode) continue; + const name = getNodeText(nameNode, ctx.source).trim(); + const typeText = getNodeText(typeNode, ctx.source).trim(); + if (!name) continue; + if (!takenFields.has(name)) { + takenFields.add(name); + ctx.createNode('field', name, nameNode, { + visibility: 'private', + isStatic: false, + signature: `${typeText} ${name}`, + }); + } + if (!takenMethods.has(name)) { + takenMethods.add(name); + ctx.createNode('method', name, nameNode, { + visibility: 'public', + isStatic: false, + signature: `${typeText} ${name}()`, + docstring: IMPLICIT_RECORD_ACCESSOR_DOC, + returnType: normalizeJavaType(typeNode, ctx.source), + }); + } + } +} + +function synthesizeJavaMembers(classNode: SyntaxNode, ctx: ExtractorContext): void { + if (classNode.type === 'record_declaration') synthesizeRecordComponents(classNode, ctx); + synthesizeLombokMembers(classNode, ctx); +} + export const javaExtractor: LanguageExtractor = { functionTypes: [], - classTypes: ['class_declaration'], - methodTypes: ['method_declaration', 'constructor_declaration'], + // `record_declaration` (Java 16+) is a class: its body holds methods, + // constructors, static fields and nested types like any class body. + classTypes: ['class_declaration', 'record_declaration'], + // `compact_constructor_declaration` is a record's `Point { … }` constructor. + methodTypes: ['method_declaration', 'constructor_declaration', 'compact_constructor_declaration'], // `annotation_type_declaration` is `@interface Foo { … }` — an annotation // definition. Without it, annotation types (`@SerializedName`, `@GetMapping`, // JPA/Spring annotations) aren't nodes, so the `@Foo` usages that DO get @@ -272,7 +337,7 @@ export const javaExtractor: LanguageExtractor = { paramsField: 'parameters', returnField: 'type', getReturnType: extractJavaReturnType, - synthesizeMembers: synthesizeLombokMembers, + synthesizeMembers: synthesizeJavaMembers, getSignature: (node, source) => { const params = getChildByField(node, 'parameters'); const returnType = getChildByField(node, 'type'); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index a4c8c35c..45538ccc 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -21,7 +21,7 @@ import { isImportableKind, CPP_DEFINE_SIGNATURE, } from './types'; -import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, isRustPathCallReachable, rustChainedCallByName, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, goDotImportedSupertype, GO_TYPE_KINDS, matchGoAssertedCall, matchGoIndexedCall, matchGoChainedCall, preferGoBuildTwin, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves, matchPythonTypedLocalCall, isImportShadowedByJsBinding, isManualMockPath, isTestSuitePath, matchNewReceiverCall, newReceiverClass, NEW_RECEIVER_SHAPE } from './name-matcher'; +import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, isRustPathCallReachable, rustChainedCallByName, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, goDotImportedSupertype, GO_TYPE_KINDS, matchGoAssertedCall, matchGoIndexedCall, matchGoChainedCall, preferGoBuildTwin, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves, matchPythonTypedLocalCall, isImportShadowedByJsBinding, isManualMockPath, isTestSuitePath, matchNewReceiverCall, newReceiverClass, NEW_RECEIVER_SHAPE, isGuessedRecordAccessor } from './name-matcher'; import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility'; import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; import { isCppSupertypeRef, matchCppSupertype, clearCppSupertypeMemos } from './cpp-supertypes'; @@ -1465,6 +1465,8 @@ export class ReferenceResolver { const module = this.nodeById(resolved.targetNodeId); if (module && module.kind !== 'file' && module.kind !== 'module') return null; } + if (resolved && (resolved.byName || resolved.resolvedBy === 'exact-match' || resolved.resolvedBy === 'fuzzy') && + isGuessedRecordAccessor(resolved, ref, this.context)) return null; // A name the calling JS/TS function binds itself (a parameter, a local) in // scope at the reference is that binding even when the file imports the // name, which the import resolver answers before the matcher's own check. diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index bfaca9ef..b50b2b9a 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -6,7 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { Language, Node } from '../types'; +import { Language, Node, IMPLICIT_RECORD_ACCESSOR_DOC } from '../types'; import { escapeRegExp } from '../utils'; import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, isSupertypeTarget, CPP_DEFINE_SIGNATURE, isInheritanceRef, isImportableKind } from './types'; import { blankStringContents, stripCommentsForRegex } from './strip-comments'; @@ -2155,6 +2155,21 @@ function luaModuleFile(name: string, decl: Node, context: ResolutionContext): st */ const PRIVATE_IS_FILE_LOCAL = new Set(['kotlin', 'java', 'csharp', 'swift', 'scala', 'php']); +/** + * Whether a resolution lands on a Java record's implicit accessor from outside + * the record. One is reached through what types its receiver (a typed + * receiver, `Record::x`, `Record.x`) or from inside the record; a name-only + * guess bound `result.content()` on a library type to any record with a + * `content` component. Applied by ReferenceResolver to name-only results. + */ +export function isGuessedRecordAccessor(resolved: ResolvedRef, ref: UnresolvedRef, context: ResolutionContext): boolean { + const target = context.getNodeById?.(resolved.targetNodeId); + if (!target || target.kind !== 'method' || target.docstring !== IMPLICIT_RECORD_ACCESSOR_DOC) return false; + const record = target.qualifiedName.slice(0, target.qualifiedName.lastIndexOf('::')); + return ref.filePath !== target.filePath || !context.getNodesInFile(target.filePath).some((n) => + n.kind === 'class' && n.qualifiedName === record && n.startLine <= ref.line && (n.endLine ?? n.startLine) >= ref.line); +} + /** Per-context memo: node id → "this C/C++ function is declared `static`". */ const C_STATIC_MEMO = new WeakMap>(); @@ -18454,6 +18469,8 @@ const MEMBER_TYPE_NON_TYPES: ReadonlySet = new Set([ 'var', 'val', 'goto', 'nameof', 'typeof', 'sizeof', 'default', 'when', 'where', 'get', 'set', 'init', 'class', 'interface', 'enum', 'struct', 'record', 'object', 'namespace', 'package', 'import', 'extends', 'implements', 'fun', 'static', 'final', 'abstract', 'sealed', 'override', 'virtual', 'delegate', 'event', + // Java's `sealed interface Shape permits Circle, Square` and `void run() throws Failure {`. + 'permits', 'throws', ]); /** @@ -20434,6 +20451,7 @@ export function matchMethodCall( targetNodeId: methodNode.id, confidence: 0.8, resolvedBy: 'instance-method', + byName: true, }; } } @@ -20556,6 +20574,7 @@ export function matchMethodCall( targetNodeId: targetMethods[0]!.id, confidence: 0.7, resolvedBy: 'instance-method', + byName: true, }; } @@ -20640,6 +20659,7 @@ export function matchMethodCall( targetNodeId: bestMatch.id, confidence: 0.65, resolvedBy: 'instance-method', + byName: true, }; } } diff --git a/src/resolution/types.ts b/src/resolution/types.ts index f580b03f..21f3cc0c 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -55,6 +55,8 @@ export interface ResolvedRef { edgeKind?: EdgeKind; /** Extra metadata the strategy wants persisted on the edge (`href`, …). */ metadata?: Record; + /** Picked by a name-only strategy (the receiver's word, not its type). Not persisted. */ + byName?: boolean; /** * The OTHER targets, when one reference names several. * diff --git a/src/types.ts b/src/types.ts index f3008000..9c4c4461 100644 --- a/src/types.ts +++ b/src/types.ts @@ -47,6 +47,13 @@ export const NODE_KINDS = [ export type NodeKind = (typeof NODE_KINDS)[number]; +/** + * The docstring both engines give a Java record's implicit component accessor + * (languages/java.ts, codegraph-kernel/src/java.rs). The resolver reaches one + * only through what types its receiver, never by a name-only guess. + */ +export const IMPLICIT_RECORD_ACCESSOR_DOC = 'Implicit record component accessor'; + /** * Types of edges (relationships) between nodes. *