GitNexus/gitnexus/test/unit/method-props.test.ts
Gergő Magyar 5a7c0fdbb1
feat: same-arity overload disambiguation via type-hash suffix (#651) (#658)
* feat: same-arity overload disambiguation via type-hash suffix (#651)

Add ~type1,type2 suffix to Method/Constructor node IDs when same-arity
overloads with different parameter types exist in the same class. Also add
$const suffix for C++ const-qualified method overloads via new isConst field.

Key changes:
- typeTagForId() detects same-arity collisions and appends ~typeTag
- constTagForId() detects const/non-const collisions and appends $const
- TS/JS excluded from type-hashing (overload signatures collapse to impl body)
- Sequential findEnclosingFunction fixed: falls through on ambiguous same-class
  candidates instead of picking first; fallback path includes typeTag + constTag
- Per-call-site integration tests across Java, C#, Kotlin, C++, TypeScript
- Cross-file + chain resolution tests for all 5 languages
- C++ isConst extraction via tree-sitter type_qualifier in function_declarator

1710 integration + 18 unit tests pass.

* fix: preserve generic/template args in type-hash, perf + type safety fixes

- Add rawType field to ParameterInfo preserving full type text (vector<int>)
  while type stays simplified (vector). typeTagForId uses rawType for tags.
- Populate rawType in all 11 language method extractors
- Add buildCollisionGroups() to pre-group methods by name#arity (O(N) once
  per class instead of O(N) per method call)
- Cache method extraction in call-processor findEnclosingFunction fallback
- Fix null guards on getLanguageFromFilename in all findEnclosing paths
- Tighten SKIP_TYPE_HASH_LANGUAGES to ReadonlySet<SupportedLanguages>
- Document ID stability invariant on first overload introduction
- C++ integration tests: template overloads (vector<int> vs vector<string>),
  cross-file template + chain resolution, out-of-class method definitions

1718 integration + 20 unit tests pass.

* fix: add rawType to method-extraction unit test assertions

All 26 parameter .toEqual() assertions in method-extraction.test.ts
needed the new rawType field added to match ParameterInfo schema change.

* perf: cache tempMap/groups per class, consolidate extractFromNode

- Cache derived method map + collision groups per classNode.id in
  parsing-processor (avoids rebuild per method in same class)
- Replace per-call extractFromNode with cached class extraction +
  funcName:line lookup in call-processor fallback (avoids AST walk
  per call site)
- Remove dead clearEnclosingFunctionCache export, fix JSDoc

* test: add sequential-path integration test for same-arity overloads

Add skipWorkers option to PipelineOptions to force sequential parsing.
New test suite verifies type-hash disambiguation produces identical
results through the sequential path (parsing-processor + call-processor
findEnclosingFunction) as the worker path.
2026-04-05 21:51:55 +01:00

258 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,
...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('');
});
});