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
+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);