Files
codegraph/__tests__/java-records.test.ts
T
danusha2345andClaude Opus 5.5 3a93f57ae1 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>
2026-10-23 22:14:17 +00:00

208 lines
8.5 KiB
TypeScript

/**
* 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);
});
});