mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-25 20:12:11 +08:00
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>
208 lines
8.5 KiB
TypeScript
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);
|
|
});
|
|
});
|