GitNexus/gitnexus/test/unit/method-props.test.ts
azizur100389 3d4a95360d
fix(java): materialize record component accessors (#2936)
* 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>
2026-08-13 08:43:11 +00:00

259 lines
9.2 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
typeTagForId,
arityForIdFromInfo,
constTagForId,
} from '../../src/core/ingestion/utils/method-props.js';
import type { MethodInfo } from '../../src/core/ingestion/method-types.js';
import { SupportedLanguages } from 'gitnexus-shared';
function makeMethodInfo(
name: string,
params: Array<{ name: string; type: string | null; rawType?: string | null }>,
overrides: Partial<MethodInfo> = {},
): MethodInfo {
return {
name,
receiverType: null,
returnType: null,
parameters: params.map((p) => ({
name: p.name,
type: p.type,
...(p.rawType !== undefined ? { rawType: p.rawType } : {}),
isOptional: false,
isVariadic: false,
})),
visibility: 'public',
isStatic: false,
isAbstract: false,
isFinal: false,
annotations: [],
sourceFile: 'test.java',
line: 1,
column: 0,
...overrides,
};
}
function buildMethodMap(methods: MethodInfo[]): Map<string, MethodInfo> {
const map = new Map<string, MethodInfo>();
for (const m of methods) {
map.set(`${m.name}:${m.line}`, m);
}
return map;
}
describe('typeTagForId', () => {
it('returns type tag for same-arity collision: find(int) vs find(String)', () => {
const findInt = makeMethodInfo('find', [{ name: 'id', type: 'int' }], { line: 10 });
const findString = makeMethodInfo('find', [{ name: 'name', type: 'String' }], { line: 15 });
const map = buildMethodMap([findInt, findString]);
expect(typeTagForId(map, 'find', 1, findInt)).toBe('~int');
expect(typeTagForId(map, 'find', 1, findString)).toBe('~String');
});
it('returns type tag for three-way collision with multi-param methods', () => {
const a = makeMethodInfo(
'process',
[
{ name: 'x', type: 'int' },
{ name: 'y', type: 'int' },
],
{ line: 10 },
);
const b = makeMethodInfo(
'process',
[
{ name: 'x', type: 'int' },
{ name: 'y', type: 'String' },
],
{ line: 15 },
);
const c = makeMethodInfo(
'process',
[
{ name: 'x', type: 'String' },
{ name: 'y', type: 'String' },
],
{ line: 20 },
);
const map = buildMethodMap([a, b, c]);
expect(typeTagForId(map, 'process', 2, a)).toBe('~int,int');
expect(typeTagForId(map, 'process', 2, b)).toBe('~int,String');
expect(typeTagForId(map, 'process', 2, c)).toBe('~String,String');
});
it('returns empty string for single method (no collision)', () => {
const save = makeMethodInfo('save', [{ name: 'user', type: 'User' }], { line: 10 });
const map = buildMethodMap([save]);
expect(typeTagForId(map, 'save', 1, save)).toBe('');
});
it('returns empty string when same name but different arity (no collision in group)', () => {
const find1 = makeMethodInfo('find', [{ name: 'id', type: 'int' }], { line: 10 });
const find2 = makeMethodInfo(
'find',
[
{ name: 'id', type: 'int' },
{ name: 'name', type: 'String' },
],
{ line: 15 },
);
const map = buildMethodMap([find1, find2]);
expect(typeTagForId(map, 'find', 1, find1)).toBe('');
expect(typeTagForId(map, 'find', 2, find2)).toBe('');
});
it('returns empty string when collision exists but a method has null type', () => {
const findInt = makeMethodInfo('find', [{ name: 'id', type: 'int' }], { line: 10 });
const findUntyped = makeMethodInfo('find', [{ name: 'name', type: null }], { line: 15 });
const map = buildMethodMap([findInt, findUntyped]);
expect(typeTagForId(map, 'find', 1, findInt)).toBe('');
expect(typeTagForId(map, 'find', 1, findUntyped)).toBe('');
});
it('returns empty string for variadic method (arity undefined)', () => {
const m = makeMethodInfo('log', [{ name: 'args', type: 'String' }]);
m.parameters[0].isVariadic = true;
const map = buildMethodMap([m]);
expect(typeTagForId(map, 'log', undefined, m)).toBe('');
});
it('handles constructor overloads the same as methods', () => {
const ctorInt = makeMethodInfo('constructor', [{ name: 'id', type: 'int' }], { line: 5 });
const ctorString = makeMethodInfo('constructor', [{ name: 'name', type: 'String' }], {
line: 10,
});
const map = buildMethodMap([ctorInt, ctorString]);
expect(typeTagForId(map, 'constructor', 1, ctorInt)).toBe('~int');
expect(typeTagForId(map, 'constructor', 1, ctorString)).toBe('~String');
});
it('returns empty string for single zero-arity method', () => {
const m = makeMethodInfo('getName', [], { line: 10 });
const map = buildMethodMap([m]);
expect(typeTagForId(map, 'getName', 0, m)).toBe('');
});
it('returns empty string for zero-arity collision (no types to disambiguate)', () => {
const m1 = makeMethodInfo('begin', [], { line: 10 });
const m2 = makeMethodInfo('begin', [], { line: 15 });
const map = buildMethodMap([m1, m2]);
expect(typeTagForId(map, 'begin', 0, m1)).toBe('');
expect(typeTagForId(map, 'begin', 0, m2)).toBe('');
});
it('returns empty string for TypeScript (overload signatures should collapse)', () => {
const findNum = makeMethodInfo('find', [{ name: 'id', type: 'number' }], { line: 10 });
const findStr = makeMethodInfo('find', [{ name: 'name', type: 'string' }], { line: 15 });
const map = buildMethodMap([findNum, findStr]);
// Without language, same-arity collision → type tag
expect(typeTagForId(map, 'find', 1, findNum)).toBe('~number');
// With TypeScript language, type tag is skipped
expect(typeTagForId(map, 'find', 1, findNum, SupportedLanguages.TypeScript)).toBe('');
expect(typeTagForId(map, 'find', 1, findStr, SupportedLanguages.JavaScript)).toBe('');
});
it('rawType preserves generics: vector<int> vs vector<string> produce distinct tags', () => {
// With rawType, template/generic args are preserved for the type tag.
const vecInt = makeMethodInfo(
'process',
[{ name: 'items', type: 'vector', rawType: 'vector<int>' }],
{ line: 10 },
);
const vecStr = makeMethodInfo(
'process',
[{ name: 'items', type: 'vector', rawType: 'vector<string>' }],
{ line: 15 },
);
const map = buildMethodMap([vecInt, vecStr]);
expect(typeTagForId(map, 'process', 1, vecInt)).toBe('~vector<int>');
expect(typeTagForId(map, 'process', 1, vecStr)).toBe('~vector<string>');
});
it('falls back to type when rawType is not set', () => {
// Backward compat: old ParameterInfo without rawType still works
const findInt = makeMethodInfo('find', [{ name: 'id', type: 'int' }], { line: 10 });
const findStr = makeMethodInfo('find', [{ name: 'name', type: 'String' }], { line: 15 });
const map = buildMethodMap([findInt, findStr]);
expect(typeTagForId(map, 'find', 1, findInt)).toBe('~int');
expect(typeTagForId(map, 'find', 1, findStr)).toBe('~String');
});
});
describe('arityForIdFromInfo', () => {
it('returns parameter count for non-variadic methods', () => {
const m = makeMethodInfo('find', [
{ name: 'id', type: 'int' },
{ name: 'name', type: 'String' },
]);
expect(arityForIdFromInfo(m)).toBe(2);
});
it('returns undefined for variadic methods', () => {
const m = makeMethodInfo('log', [{ name: 'args', type: 'String' }]);
m.parameters[0].isVariadic = true;
expect(arityForIdFromInfo(m)).toBeUndefined();
});
it('returns 0 for parameterless methods', () => {
const m = makeMethodInfo('getName', []);
expect(arityForIdFromInfo(m)).toBe(0);
});
});
describe('constTagForId', () => {
it('returns $const for const method when non-const collision exists', () => {
const beginConst = makeMethodInfo('begin', [], { line: 10, isConst: true });
const beginNonConst = makeMethodInfo('begin', [], { line: 15 });
const map = buildMethodMap([beginConst, beginNonConst]);
expect(constTagForId(map, 'begin', 0, beginConst)).toBe('$const');
});
it('returns empty string for non-const method even when const collision exists', () => {
const beginConst = makeMethodInfo('begin', [], { line: 10, isConst: true });
const beginNonConst = makeMethodInfo('begin', [], { line: 15 });
const map = buildMethodMap([beginConst, beginNonConst]);
expect(constTagForId(map, 'begin', 0, beginNonConst)).toBe('');
});
it('returns empty string for single const method (no collision)', () => {
const sizeConst = makeMethodInfo('size', [], { line: 10, isConst: true });
const map = buildMethodMap([sizeConst]);
expect(constTagForId(map, 'size', 0, sizeConst)).toBe('');
});
it('works with typed parameters: find(int) vs find(int) const', () => {
const findConst = makeMethodInfo('find', [{ name: 'id', type: 'int' }], {
line: 10,
isConst: true,
});
const findNonConst = makeMethodInfo('find', [{ name: 'id', type: 'int' }], { line: 15 });
const map = buildMethodMap([findConst, findNonConst]);
expect(constTagForId(map, 'find', 1, findConst)).toBe('$const');
expect(constTagForId(map, 'find', 1, findNonConst)).toBe('');
});
it('returns empty string for non-const method without isConst field', () => {
const m = makeMethodInfo('clear', [], { line: 10 });
const map = buildMethodMap([m]);
expect(constTagForId(map, 'clear', 0, m)).toBe('');
});
});