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 ("<Type> <name>") 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 <noreply@anthropic.com>
This commit is contained in:
danusha2345
2026-10-23 22:14:17 +00:00
committed by Colby Mchenry
co-authored by Claude Opus 5.5
parent a565f215f1
commit 3a93f57ae1
10 changed files with 453 additions and 25 deletions
+1
View File
@@ -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<String>, 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<String>`, `extends Base<Integer>`, an interface's `extends Store<T>` or TypeScript's `implements Store<string>`, 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)
@@ -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<Point>, 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<String> tags) {
String first() { return tags.get(0); }
}
}
record Pair<A, B>(A first, B second) {
A left() { return first; }
}
@Builder
record Config(@Deprecated String host, int[] ports, java.util.Map<String, Integer> limits, String... rest) {}
interface Shape { double norm(); }
class Client {
private Point.Inner inner;
String use(Point p, Pair<String, Integer> 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();
}
}
+207
View File
@@ -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, B>(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<String, Integer> 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<Object> points) { points.forEach(point -> point.x()); }
int ref(List<Point> 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);
});
});
+6
View File
@@ -409,6 +409,11 @@ export const Box = () => <div data-size={THEME_TOKENS.size} />;
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) => {
+89 -20
View File
@@ -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<String>, HashSet<String>) {
let class_qn = self.nodes_meta[class_row as usize].qualified_name.clone();
let mut taken_methods: HashSet<String> = HashSet::new();
let mut taken_fields: HashSet<String> = 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<String> = HashSet::new();
let mut taken_fields: HashSet<String> = 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);
+68 -3
View File
@@ -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<string>();
const takenFields = new Set<string>();
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');
+3 -1
View File
@@ -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.
+21 -1
View File
@@ -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<string>(['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<ResolutionContext, Map<string, boolean>>();
@@ -18454,6 +18469,8 @@ const MEMBER_TYPE_NON_TYPES: ReadonlySet<string> = 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,
};
}
}
+2
View File
@@ -55,6 +55,8 @@ export interface ResolvedRef {
edgeKind?: EdgeKind;
/** Extra metadata the strategy wants persisted on the edge (`href`, …). */
metadata?: Record<string, unknown>;
/** 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.
*
+7
View File
@@ -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.
*