mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* fix(java): materialize record component accessors * fix(java): ignore receiver params in record accessor arity * fix(java): address record-accessor review findings (#2917) Five findings from the tri-review of #2936. P1 — a synthesized callable evicted a source-written one from the method map. `getMethodInfo` keyed its per-class map by `name:line`, but a callable that is SYNTHESIZED at a position that is not its own declaration shares its owner's line: a record's implicit accessor is minted at the component, and a C# 12 primary constructor at the owner's `parameter_list`. Both are appended last by their extractor, so on a single line the synthesized entry overwrote the explicit method's MethodInfo and both definitions collapsed onto one id — `record P(int x, int y) { int x(int s) {...} }` lost `P.x#1` and rebound the arity-1 call to the zero-argument accessor. Adds a required `MethodInfo.column` and keys the map by `name:line:column` through a single `methodInfoKey` helper. Required, not optional: an absent column would key an entry no lookup could reach — a silent, whole-language loss of enrichment instead of a compile error. All three lookup sites move together; the file's own lockstep docblock warns that a half-applied change loses caller edges silently rather than dangling. This also fixes the same collision in C#, which never touched record code. Degenerate component names no longer mint a node. tree-sitter's zero-width MISSING recovery token satisfies `name: (identifier)`, so `record M(int x, y) {}` minted an empty-named Method whose returnType was the neighbouring `y`; and the grammar admits `underscore_pattern` in the same field, which the query rejected but the scope path accepted, so `record R(int _) {}` left a scope declaration with no node behind it. One `isRecordComponentName` predicate now gates all three emitters — query suppression, scope synthesis, and the method extractor — so they cannot drift apart again. Component annotations reach the implicit accessor (JLS 8.10.3 / 9.7.4) by reusing the shared `extractAnnotations` helper. Deliberately over-approximate and commented as such: `@Target` lives in another file and parsing is per-file. `explicitZeroArgAccessorNames` is memoised per record node. It was rebuilt on every component capture — O(components x body members) for one record, measured at ~4x per 2x input — while the scope path already hoisted the identical call. Docs: the `java-local-types` baseline now stores the `capture_groups_fp` its own note cites, the SCHEMA_BUMP ledger no longer claims a v65 that nothing holds, and `shouldSkipDefinitionCapture` documents that `defaultLabel` may be ignored. Scope-capture fingerprints are unchanged (`measure.mjs --check` PASS, 15 languages): the bench corpus contains no degenerate components, so the new predicate is inert on it. SCHEMA_BUMP stays 67 — this branch's existing claim already covers the changed worker output; re-check it against origin/main before merging. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB * docs(ingestion): reunite the overload-suffix JSDoc with typeTagForId The block describing the `~type1,type2` same-arity discriminator was stranded above `buildCollisionGroups` when that function was inserted between it and the `typeTagForId` it documents (#658). Adding `methodInfoKey` in this branch parked it directly above yet another unrelated function, which gitnexus-check flagged. Moves the comment down to the function it describes. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
424 lines
16 KiB
TypeScript
424 lines
16 KiB
TypeScript
/**
|
|
* Low-level coverage for the Java scope-captures orchestrator
|
|
* (`emitJavaScopeCaptures`), focused on the #1928 parsing-layer fixes:
|
|
*
|
|
* - F35: qualified / qualified-generic constructor calls bind the simple-name
|
|
* tail as @reference.name (not the raw `pkg.Foo` text).
|
|
* - F38: `super(...)` / `this(...)` explicit constructor invocations are
|
|
* captured as @reference.call.constructor references with arity.
|
|
*
|
|
* Runs against the installed tree-sitter-java grammar so it catches grammar
|
|
* drift before the integration parity gate.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import { emitJavaScopeCaptures } from '../../../../src/core/ingestion/languages/java/captures.js';
|
|
|
|
function wrapExpr(expr: string): string {
|
|
return `class C { void m() { ${expr}; } }`;
|
|
}
|
|
|
|
/** All constructor-call matches in `src`, as `{ name, qualified, arity }`. */
|
|
function ctorRefs(src: string) {
|
|
return emitJavaScopeCaptures(src, 'C.java')
|
|
.filter((m) => m['@reference.call.constructor'] !== undefined)
|
|
.map((m) => ({
|
|
name: m['@reference.name']?.text,
|
|
qualified: m['@reference.call.constructor.qualified']?.text,
|
|
arity: m['@reference.arity']?.text,
|
|
}));
|
|
}
|
|
|
|
/** All synthesized inheritance references in `src`, reduced to lookup names. */
|
|
function inheritanceRefs(src: string): string[] {
|
|
return emitJavaScopeCaptures(src, 'C.java')
|
|
.filter((m) => m['@reference.inherits'] !== undefined)
|
|
.flatMap((m) => m['@reference.name']?.text ?? [])
|
|
.sort();
|
|
}
|
|
|
|
function recordAccessorDeclarations(src: string) {
|
|
return emitJavaScopeCaptures(src, 'C.java')
|
|
.filter((m) => m['@declaration.method'] !== undefined)
|
|
.map((m) => ({
|
|
name: m['@declaration.name']?.text,
|
|
arity: m['@declaration.parameter-count']?.text,
|
|
requiredArity: m['@declaration.required-parameter-count']?.text,
|
|
returnType: m['@declaration.return-type']?.text,
|
|
}));
|
|
}
|
|
|
|
describe('emitJavaScopeCaptures — constructor reference names (F35 #1928)', () => {
|
|
it('binds the simple name for an unqualified `new User()`', () => {
|
|
const refs = ctorRefs(wrapExpr('new User()'));
|
|
expect(refs).toContainEqual({ name: 'User', qualified: undefined, arity: '0' });
|
|
});
|
|
|
|
it('binds the simple-name tail for a qualified `new pkg.Foo()`', () => {
|
|
const refs = ctorRefs(wrapExpr('new pkg.Foo()'));
|
|
const foo = refs.find((r) => r.name === 'Foo');
|
|
expect(foo).toBeDefined();
|
|
expect(foo!.qualified).toBe('pkg.Foo');
|
|
// The name must be the bare tail, never the raw scoped text.
|
|
expect(refs.some((r) => r.name === 'pkg.Foo')).toBe(false);
|
|
});
|
|
|
|
it('binds the simple-name tail for a deeply-nested `new a.b.Foo()`', () => {
|
|
const refs = ctorRefs(wrapExpr('new a.b.Foo()'));
|
|
const foo = refs.find((r) => r.name === 'Foo');
|
|
expect(foo).toBeDefined();
|
|
expect(foo!.qualified).toBe('a.b.Foo');
|
|
expect(refs.some((r) => r.name === 'a' || r.name === 'b')).toBe(false);
|
|
});
|
|
|
|
it('binds the simple name for a simple-generic `new Box<String>()`', () => {
|
|
const refs = ctorRefs(wrapExpr('new Box<String>()'));
|
|
const box = refs.find((r) => r.name === 'Box');
|
|
expect(box).toBeDefined();
|
|
expect(box!.qualified).toBeUndefined();
|
|
});
|
|
|
|
it('binds the simple-name tail for a qualified-generic `new pkg.Box<String>()`', () => {
|
|
const refs = ctorRefs(wrapExpr('new pkg.Box<String>()'));
|
|
const box = refs.find((r) => r.name === 'Box');
|
|
expect(box).toBeDefined();
|
|
expect(box!.qualified).toBe('pkg.Box');
|
|
expect(refs.some((r) => r.name === 'pkg.Box' || r.name === 'String')).toBe(false);
|
|
});
|
|
|
|
it('carries the argument arity on a qualified constructor call', () => {
|
|
const refs = ctorRefs(wrapExpr('new pkg.Foo(1, 2, 3)'));
|
|
const foo = refs.find((r) => r.name === 'Foo');
|
|
expect(foo!.arity).toBe('3');
|
|
});
|
|
|
|
it('emits exactly one constructor reference per `new` expression', () => {
|
|
// Regression guard: the qualified + qualified-generic arms must not
|
|
// double-match the plain/generic arms.
|
|
expect(ctorRefs(wrapExpr('new pkg.Foo()')).length).toBe(1);
|
|
expect(ctorRefs(wrapExpr('new pkg.Box<String>()')).length).toBe(1);
|
|
expect(ctorRefs(wrapExpr('new a.b.Foo()')).length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('emitJavaScopeCaptures — record and enum interface heritage (#2900, #2918)', () => {
|
|
it('captures simple, generic, and qualified record interfaces by lookup name', () => {
|
|
const refs = inheritanceRefs(
|
|
'record User(int id) implements Named, Comparable<User>, audit.Auditable {}',
|
|
);
|
|
|
|
expect(refs).toEqual(['Auditable', 'Comparable', 'Named']);
|
|
});
|
|
|
|
it('captures simple, generic, and qualified enum interfaces by lookup name', () => {
|
|
const refs = inheritanceRefs(
|
|
'enum Status implements Named, Tagged<Status>, audit.Auditable { ACTIVE }',
|
|
);
|
|
|
|
expect(refs).toEqual(['Auditable', 'Named', 'Tagged']);
|
|
});
|
|
|
|
it('unwraps type-use annotations on enum interface names', () => {
|
|
const refs = inheritanceRefs(
|
|
'enum Status implements @Marker Named, @Marker Tagged<Status>, audit.@Marker Auditable { ACTIVE }',
|
|
);
|
|
|
|
expect(refs).toEqual(['Auditable', 'Named', 'Tagged']);
|
|
});
|
|
|
|
it.each([
|
|
['class extends', 'class Child extends @Marker Base {}', ['Base']],
|
|
['class implements', 'class Child implements @Marker Named {}', ['Named']],
|
|
['record implements', 'record Child(int id) implements @Marker Named {}', ['Named']],
|
|
['interface extends', 'interface Child extends @Marker Named {}', ['Named']],
|
|
])('unwraps type-use annotations for %s', (_label, source, expected) => {
|
|
expect(inheritanceRefs(source)).toEqual(expected);
|
|
});
|
|
|
|
it('does not emit an empty inheritance name from a torn annotated base', () => {
|
|
expect(inheritanceRefs('enum Status implements @Marker {')).toEqual([]);
|
|
});
|
|
|
|
it('preserves the enum constant-body link while adding enum interface heritage', () => {
|
|
expect(
|
|
inheritanceRefs(
|
|
'enum Status implements Named { ACTIVE { public String label() { return "active"; } } }',
|
|
),
|
|
).toEqual(['Named', 'Status']);
|
|
});
|
|
});
|
|
|
|
describe('emitJavaScopeCaptures — record component accessors (#2917)', () => {
|
|
it('synthesizes zero-argument declarations with full generic return types', () => {
|
|
expect(
|
|
recordAccessorDeclarations('record User(String name, java.util.List<String> tags) {}'),
|
|
).toEqual([
|
|
{ name: 'name', arity: '0', requiredArity: '0', returnType: 'String' },
|
|
{
|
|
name: 'tags',
|
|
arity: '0',
|
|
requiredArity: '0',
|
|
returnType: 'java.util.List<String>',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('uses the array return type for a varargs component accessor', () => {
|
|
expect(recordAccessorDeclarations('record Samples(String... values) {}')).toEqual([
|
|
{ name: 'values', arity: '0', requiredArity: '0', returnType: 'String[]' },
|
|
]);
|
|
});
|
|
|
|
it('does not duplicate an explicit canonical accessor', () => {
|
|
const declarations = recordAccessorDeclarations(
|
|
'record User(String name) { public String name(/* canonical */) { return name; } }',
|
|
);
|
|
|
|
expect(declarations.filter((declaration) => declaration.name === 'name')).toHaveLength(1);
|
|
});
|
|
|
|
it('does not count an explicit accessor receiver parameter toward arity', () => {
|
|
const declarations = recordAccessorDeclarations(
|
|
'record User(String name) { public String name(User this) { return name; } }',
|
|
);
|
|
|
|
expect(declarations.filter((declaration) => declaration.name === 'name')).toEqual([
|
|
expect.objectContaining({ arity: '0', requiredArity: '0' }),
|
|
]);
|
|
});
|
|
|
|
it('keeps an overload alongside the implicit zero-argument accessor', () => {
|
|
const declarations = recordAccessorDeclarations(
|
|
'record User(String name) { public String name(int repeat) { return name; } }',
|
|
).filter((declaration) => declaration.name === 'name');
|
|
|
|
expect(declarations.map((declaration) => declaration.arity).sort()).toEqual(['0', '1']);
|
|
});
|
|
|
|
// A component is named by a real `identifier` and nothing else. tree-sitter's
|
|
// zero-width MISSING recovery token satisfies `name: (identifier)`, and the
|
|
// grammar admits `underscore_pattern` in the same field, so both would mint an
|
|
// accessor for source that does not compile.
|
|
it.each([
|
|
['a dropped component type', 'record M(int x, y) {}', ['x']],
|
|
['a nameless varargs component', 'record W(int... ) {}', []],
|
|
['an underscore component', 'record R(int _) {}', []],
|
|
['an underscore varargs component', 'record S(int... _) {}', []],
|
|
])('emits no accessor declaration for %s', (_label, source, expected) => {
|
|
expect(recordAccessorDeclarations(source).map((declaration) => declaration.name)).toEqual(
|
|
expected,
|
|
);
|
|
});
|
|
|
|
it('emits no accessor scope for a component with no usable name', () => {
|
|
const scopes = emitJavaScopeCaptures('record M(int x, y) {}', 'C.java')
|
|
.filter((m) => m['@scope.function'] !== undefined)
|
|
.map((m) => m['@scope.function']?.text);
|
|
|
|
expect(scopes).toEqual(['int x']);
|
|
});
|
|
|
|
it('is unaffected for a valid record', () => {
|
|
expect(recordAccessorDeclarations('record P(int x, String... ys) {}')).toEqual([
|
|
{ name: 'x', arity: '0', requiredArity: '0', returnType: 'int' },
|
|
{ name: 'ys', arity: '0', requiredArity: '0', returnType: 'String[]' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('emitJavaScopeCaptures — explicit constructor invocations (F38 #1928)', () => {
|
|
it('captures `super(...)` as a constructor ref to the superclass simple name', () => {
|
|
const src = 'class C extends pkg.Base { C() { super(1, 2); } }';
|
|
const refs = ctorRefs(src);
|
|
const sup = refs.find((r) => r.name === 'Base');
|
|
expect(sup).toBeDefined();
|
|
expect(sup!.arity).toBe('2');
|
|
});
|
|
|
|
it('reduces a generic superclass `super(...)` target to the bare name', () => {
|
|
const src = 'class C extends Box<String> { C() { super(); } }';
|
|
const refs = ctorRefs(src);
|
|
expect(refs.some((r) => r.name === 'Box' && r.arity === '0')).toBe(true);
|
|
});
|
|
|
|
it('unwraps an annotated superclass for explicit `super(...)`', () => {
|
|
const refs = ctorRefs('class C extends @Marker Base { C() { super(); } }');
|
|
expect(refs.some((r) => r.name === 'Base' && r.arity === '0')).toBe(true);
|
|
});
|
|
|
|
it('captures `this(...)` as a constructor ref to the enclosing class name', () => {
|
|
const src = 'class C { C() { this(1); } C(int x) {} }';
|
|
const refs = ctorRefs(src);
|
|
const self = refs.find((r) => r.name === 'C' && r.arity === '1');
|
|
expect(self).toBeDefined();
|
|
});
|
|
|
|
it('does NOT synthesize a super ref when there is no explicit superclass', () => {
|
|
// Implicit `Object` super — no in-graph symbol, so no reference is emitted.
|
|
const src = 'class C { C() { super(); } }';
|
|
const refs = ctorRefs(src);
|
|
expect(refs.length).toBe(0);
|
|
});
|
|
|
|
it('captures `this(...)` inside an enum constructor', () => {
|
|
const src = 'enum E { A; E() { this(1); } E(int x) {} }';
|
|
const refs = ctorRefs(src);
|
|
expect(refs.some((r) => r.name === 'E' && r.arity === '1')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('emitJavaScopeCaptures — callable-flow protocol methods (#2522 review)', () => {
|
|
function invokeFactsFor(src: string): number {
|
|
return emitJavaScopeCaptures(src, 'C.java').filter(
|
|
(m) => m['@callable-flow.invoke'] !== undefined,
|
|
).length;
|
|
}
|
|
|
|
it('does not emit invoke facts for ordinary container accessors', () => {
|
|
const src = `
|
|
import java.util.HashMap;
|
|
class C {
|
|
static Object entry(HashMap<String, Object> map) {
|
|
return map.get("x");
|
|
}
|
|
}
|
|
`;
|
|
expect(invokeFactsFor(src)).toBe(0);
|
|
});
|
|
|
|
it('still emits invoke facts for functional-interface dispatch', () => {
|
|
const src = `
|
|
class C {
|
|
static void invoke(Runnable callback) { callback.run(); }
|
|
}
|
|
`;
|
|
expect(invokeFactsFor(src)).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('emitJavaScopeCaptures — local-type identities (#2562)', () => {
|
|
it('uses the source-type-relative identity for the definition and the simple lexical binding', () => {
|
|
const matches = emitJavaScopeCaptures(
|
|
'class Outer { void m() { class Local {} new Local(); } }',
|
|
'Outer.java',
|
|
);
|
|
const local = matches.find((m) => m['@declaration.name']?.text === 'Outer$1Local');
|
|
|
|
expect(local?.['@declaration.binding-name']?.text).toBe('Local');
|
|
});
|
|
|
|
it('leaves non-local class declarations unchanged', () => {
|
|
const matches = emitJavaScopeCaptures('class Outer { class Member {} }', 'Outer.java');
|
|
const member = matches.find((m) => m['@declaration.name']?.text === 'Member');
|
|
|
|
expect(member?.['@declaration.binding-name']).toBeUndefined();
|
|
});
|
|
|
|
it('recognizes a local class inside a record compact constructor', () => {
|
|
const matches = emitJavaScopeCaptures(
|
|
'record R(int x) { R { class Local {} new Runnable() {}; } }',
|
|
'R.java',
|
|
);
|
|
const names = matches.flatMap((m) => m['@declaration.name']?.text ?? []);
|
|
|
|
expect(names).toContain('R$1Local');
|
|
expect(names).toContain('R$1');
|
|
});
|
|
|
|
it('uses javac-compatible independent sequences for anonymous and named local types', () => {
|
|
const matches = emitJavaScopeCaptures(
|
|
`class Outer {
|
|
void first() {
|
|
new Runnable() {};
|
|
class Local {}
|
|
class Other {}
|
|
new Runnable() {};
|
|
}
|
|
void second() { class Local {} }
|
|
}`,
|
|
'Outer.java',
|
|
);
|
|
const names = matches.flatMap((m) => m['@declaration.name']?.text ?? []);
|
|
|
|
expect(names).toEqual(
|
|
expect.arrayContaining([
|
|
'Outer$1',
|
|
'Outer$2',
|
|
'Outer$1Local',
|
|
'Outer$2Local',
|
|
'Outer$1Other',
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('synthesizes every legal local type kind with its lexical binding name', () => {
|
|
const matches = emitJavaScopeCaptures(
|
|
`class Outer {
|
|
void types() {
|
|
class C {}
|
|
enum E { A }
|
|
record R(int x) {}
|
|
interface I { void run(); }
|
|
}
|
|
}`,
|
|
'Outer.java',
|
|
);
|
|
|
|
for (const [tag, identityName, bindingName] of [
|
|
['@declaration.class', 'Outer$1C', 'C'],
|
|
['@declaration.enum', 'Outer$1E', 'E'],
|
|
['@declaration.record', 'Outer$1R', 'R'],
|
|
['@declaration.interface', 'Outer$1I', 'I'],
|
|
] as const) {
|
|
const declaration = matches.find(
|
|
(match) => match[tag] !== undefined && match['@declaration.name']?.text === identityName,
|
|
);
|
|
expect(declaration?.['@declaration.binding-name']?.text).toBe(bindingName);
|
|
}
|
|
});
|
|
|
|
it('detects local types from block position in initializers, lambdas, and anonymous bodies', () => {
|
|
const matches = emitJavaScopeCaptures(
|
|
`class Outer {
|
|
static { class StaticLocal {} }
|
|
{ record InstanceLocal(int x) {} }
|
|
Runnable task = () -> { interface LambdaLocal {} };
|
|
Runnable anon = new Runnable() {
|
|
{ enum AnonymousLocal { A } }
|
|
public void run() {}
|
|
};
|
|
}`,
|
|
'Outer.java',
|
|
);
|
|
const names = matches.flatMap((match) => match['@declaration.name']?.text ?? []);
|
|
|
|
expect(names).toEqual(
|
|
expect.arrayContaining([
|
|
'Outer$1StaticLocal',
|
|
'Outer$1InstanceLocal',
|
|
'Outer$1LambdaLocal',
|
|
'Outer$1$1AnonymousLocal',
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('emits declaration-to-block visibility scopes for local types', () => {
|
|
const matches = emitJavaScopeCaptures(
|
|
`class Outer {
|
|
void blocks() {
|
|
new Local();
|
|
class Local {}
|
|
new Local();
|
|
}
|
|
}`,
|
|
'Outer.java',
|
|
);
|
|
const local = matches.find((match) => match['@declaration.name']?.text === 'Outer$1Local');
|
|
const visibility = matches.find(
|
|
(match) =>
|
|
match['@scope.block']?.range.startLine === local?.['@declaration.class']?.range.startLine,
|
|
);
|
|
|
|
expect(visibility?.['@scope.block']?.range.endLine).toBe(6);
|
|
});
|
|
});
|