test(type-resolution): Phase P integration tests + fixes for all overloading languages

Integration tests for overload disambiguation (Java, Kotlin, C#, C++)
and virtual dispatch (Java, TypeScript) with strict toBe() assertions.

Unit tests verify exact parameterTypes extraction per language:
- Java: ['int'], ['String'], ['int', 'String']
- Kotlin: ['Int'], ['String']
- C#: ['int'], ['string']
- C++: ['int'], ['string']

Fixes discovered during testing:
- extractSimpleTypeName: handle Java integral_type/boolean_type/etc
- tryOverloadDisambiguation: unwrap C# argument + Kotlin value_argument
  wrapper nodes; traverse Kotlin call_suffix for value_arguments
- Kotlin boxed→primitive normalization (Int→int, Long→long, etc.)
- C++ tree-sitter queries: capture pointer-returning inline class methods
- extractFunctionName: handle C++ field_identifier for inline methods
This commit is contained in:
Gergo Magyar 2026-03-19 22:47:24 +00:00
parent 700c9d16e4
commit bc771574d8
18 changed files with 478 additions and 14 deletions

View file

@ -528,6 +528,29 @@ interface OverloadHints {
inferLiteralType: (node: SyntaxNode) => string | undefined;
}
/**
* Kotlin (and JVM in general) uses boxed type names in parameter declarations
* (e.g. `Int`, `Long`, `Boolean`) while inferJvmLiteralType returns unboxed
* primitives (`int`, `long`, `boolean`). Normalise both sides to lowercase so
* that the comparison `'Int' === 'int'` does not fail.
*
* Only applied to single-word identifiers that look like a JVM primitive alias;
* multi-word or qualified names are left untouched.
*/
const KOTLIN_BOXED_TO_PRIMITIVE: Readonly<Record<string, string>> = {
Int: 'int',
Long: 'long',
Short: 'short',
Byte: 'byte',
Float: 'float',
Double: 'double',
Boolean: 'boolean',
Char: 'char',
};
const normalizeJvmTypeName = (name: string): string =>
KOTLIN_BOXED_TO_PRIMITIVE[name] ?? name;
/**
* Try to disambiguate overloaded candidates using argument literal types.
* Only invoked when filteredCandidates.length > 1 and at least one has parameterTypes.
@ -539,18 +562,33 @@ const tryOverloadDisambiguation = (
): SymbolDefinition | null => {
if (!candidates.some(c => c.parameterTypes)) return null;
// Find the argument list node in the call expression
const argList = hints.callNode.childForFieldName?.('arguments')
?? hints.callNode.children.find(c =>
// Find the argument list node in the call expression.
// Kotlin wraps value_arguments inside a call_suffix child, so we must also
// search one level deeper when a direct match is not found.
let argList: any = hints.callNode.childForFieldName?.('arguments')
?? hints.callNode.children.find((c: any) =>
c.type === 'arguments' || c.type === 'argument_list' || c.type === 'value_arguments'
);
if (!argList) {
// Kotlin: call_expression → call_suffix → value_arguments
const callSuffix = hints.callNode.children.find((c: any) => c.type === 'call_suffix');
if (callSuffix) {
argList = callSuffix.children.find((c: any) => c.type === 'value_arguments');
}
}
if (!argList) return null;
const argTypes: (string | undefined)[] = [];
for (const arg of argList.namedChildren) {
if (arg.type === 'comment') continue;
// For named arguments (Kotlin), extract the value expression
const valueNode = arg.childForFieldName?.('value') ?? arg;
// Unwrap argument wrapper nodes before passing to inferLiteralType:
// - Kotlin value_argument: literal is the first (and only) named child
// - C# argument: no named fields — the literal is the only named child
// - Java/others: arg IS the literal directly (no unwrapping needed)
const valueNode = arg.childForFieldName?.('value')
?? (arg.type === 'argument' || arg.type === 'value_argument'
? arg.firstNamedChild ?? arg
: arg);
argTypes.push(hints.inferLiteralType(valueNode));
}
@ -559,9 +597,12 @@ const tryOverloadDisambiguation = (
const matched = candidates.filter(c => {
if (!c.parameterTypes) return true; // Keep candidates without type info
return c.parameterTypes.every((pType, i) =>
i >= argTypes.length || !argTypes[i] || pType === argTypes[i]
);
return c.parameterTypes.every((pType, i) => {
if (i >= argTypes.length || !argTypes[i]) return true;
// Normalise Kotlin boxed type names (Int→int, Boolean→boolean, etc.) so
// that the stored declaration type matches the inferred literal type.
return normalizeJvmTypeName(pType) === argTypes[i];
});
});
return matched.length === 1 ? matched[0] : null;

View file

@ -425,6 +425,20 @@ export const CPP_QUERIES = `
declarator: (function_declarator
declarator: [(field_identifier) (identifier) (operator_name) (destructor_name)] @name)) @definition.method)
; Inline class methods returning a pointer type (User* lookup(int id) { ... })
(field_declaration_list
(function_definition
declarator: (pointer_declarator
declarator: (function_declarator
declarator: [(field_identifier) (identifier) (operator_name)] @name))) @definition.method)
; Inline class methods returning a reference type (User& lookup(int id) { ... })
(field_declaration_list
(function_definition
declarator: (reference_declarator
(function_declarator
declarator: [(field_identifier) (identifier) (operator_name)] @name))) @definition.method)
; Templates
(template_declaration (class_specifier name: (type_identifier) @name)) @definition.template
(template_declaration (function_definition declarator: (function_declarator declarator: (identifier) @name))) @definition.template

View file

@ -275,7 +275,11 @@ export const extractSimpleTypeName = (typeNode: SyntaxNode, depth = 0): string |
// Primitive/predefined types: string, int, float, bool, number, unknown, any
// PHP: primitive_type; TS/JS: predefined_type
if (typeNode.type === 'primitive_type' || typeNode.type === 'predefined_type') {
// Java: integral_type (int/long/short/byte), floating_point_type (float/double),
// boolean_type (boolean), void_type (void)
if (typeNode.type === 'primitive_type' || typeNode.type === 'predefined_type'
|| typeNode.type === 'integral_type' || typeNode.type === 'floating_point_type'
|| typeNode.type === 'boolean_type' || typeNode.type === 'void_type') {
return typeNode.text;
}

View file

@ -406,7 +406,8 @@ export const extractFunctionName = (node: SyntaxNode): { funcName: string | null
if (!innerDeclarator) {
for (let i = 0; i < declarator.childCount; i++) {
const c = declarator.child(i);
if (c?.type === 'qualified_identifier' || c?.type === 'identifier' || c?.type === 'parenthesized_declarator') { innerDeclarator = c; break; }
if (c?.type === 'qualified_identifier' || c?.type === 'identifier'
|| c?.type === 'field_identifier' || c?.type === 'parenthesized_declarator') { innerDeclarator = c; break; }
}
}
@ -422,8 +423,10 @@ export const extractFunctionName = (node: SyntaxNode): { funcName: string | null
funcName = nameNode.text;
label = 'Method';
}
} else if (innerDeclarator?.type === 'identifier') {
} else if (innerDeclarator?.type === 'identifier' || innerDeclarator?.type === 'field_identifier') {
// field_identifier is used for method names inside C++ class bodies
funcName = innerDeclarator.text;
if (innerDeclarator.type === 'field_identifier') label = 'Method';
} else if (innerDeclarator?.type === 'parenthesized_declarator') {
let nestedId: SyntaxNode | null = null;
for (let i = 0; i < innerDeclarator.childCount; i++) {

View file

@ -0,0 +1,19 @@
#include <string>
class User {};
class UserService {
public:
User* lookup(int id) {
return nullptr;
}
User* lookup(std::string name) {
return nullptr;
}
void run() {
lookup(42); // literal int → should disambiguate to lookup(int)
lookup("alice"); // literal string → should disambiguate to lookup(string)
}
};

View file

@ -0,0 +1,25 @@
namespace Models;
public class User
{
public string GetName() => "user";
}
public class UserService
{
public User Lookup(int id)
{
return new User();
}
public User Lookup(string name)
{
return new User();
}
public void Run()
{
Lookup(42); // literal int → should disambiguate to Lookup(int)
Lookup("alice"); // literal string → should disambiguate to Lookup(string)
}
}

View file

@ -0,0 +1,7 @@
package models;
public class User {
public String getName() {
return "user";
}
}

View file

@ -0,0 +1,16 @@
package models;
public class UserService {
public User lookup(int id) {
return new User();
}
public User lookup(String name) {
return new User();
}
public void run() {
lookup(42); // literal int → should disambiguate to lookup(int)
lookup("alice"); // literal String → should disambiguate to lookup(String)
}
}

View file

@ -0,0 +1,31 @@
package models;
// All classes in same file so parentMap captures the extends relationship
class Animal {
public String speak() {
return "...";
}
}
class Dog extends Animal {
public String speak() {
return "woof";
}
public String fetchBall() {
return "ball";
}
}
public class App {
public void run() {
// Virtual dispatch: declared as Animal, constructed as Dog
Animal animal = new Dog();
animal.fetchBall(); // Only Dog has fetchBall — proves virtual dispatch override
// Direct type: no override needed
Dog dog = new Dog();
dog.fetchBall(); // Direct resolution to Dog#fetchBall
}
}

View file

@ -0,0 +1,18 @@
package services
class User
class UserService {
fun lookup(id: Int): User? {
return null
}
fun lookup(name: String): User? {
return null
}
fun run() {
lookup(42) // literal Int → should disambiguate to lookup(Int)
lookup("alice") // literal String → should disambiguate to lookup(String)
}
}

View file

@ -0,0 +1,27 @@
// All classes in same file so parentMap captures the extends relationship
class Animal {
speak(): string {
return '...';
}
}
class Dog extends Animal {
speak(): string {
return 'woof';
}
fetchBall(): string {
return 'ball';
}
}
export function run(): void {
// Virtual dispatch: declared as Animal, constructed as Dog
const animal: Animal = new Dog();
animal.fetchBall(); // Only Dog has fetchBall — proves virtual dispatch override
// Direct type: no override needed
const dog: Dog = new Dog();
dog.fetchBall(); // Direct resolution to Dog#fetchBall
}

View file

@ -1061,3 +1061,29 @@ describe('C++ grandparent method resolution via MRO (Phase B)', () => {
expect(greetCall).toBeDefined();
});
});
// ── Phase P: Overload Disambiguation via Parameter Types ─────────────────
describe('C++ overload disambiguation by parameter types', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-overload-param-types'),
() => {},
);
}, 60000);
it('detects lookup method (1 graph node — ID collision for same-file overloads)', () => {
const methods = getNodesByLabel(result, 'Method');
const lookupMethods = methods.filter(m => m === 'lookup');
expect(lookupMethods.length).toBe(1);
});
it('emits CALLS edge from run() → lookup() via overload disambiguation', () => {
const calls = getRelationships(result, 'CALLS');
const lookupCalls = calls.filter(c => c.source === 'run' && c.target === 'lookup');
// Both lookup(42) and lookup("alice") resolve to same nodeId → 1 CALLS edge
expect(lookupCalls.length).toBe(1);
});
});

View file

@ -1469,3 +1469,29 @@ describe('C# null-check narrowing resolution (Phase C)', () => {
expect(saveCall).toBeDefined();
});
});
// ── Phase P: Overload Disambiguation via Parameter Types ─────────────────
describe('C# overload disambiguation by parameter types', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'csharp-overload-param-types'),
() => {},
);
}, 60000);
it('detects Lookup method in UserService (1 graph node — ID collision for same-file overloads)', () => {
const methods = getNodesByLabel(result, 'Method');
const lookupMethods = methods.filter(m => m === 'Lookup');
expect(lookupMethods.length).toBe(1);
});
it('emits CALLS edge from Run() → Lookup() via overload disambiguation', () => {
const calls = getRelationships(result, 'CALLS');
const lookupCalls = calls.filter(c => c.source === 'Run' && c.target === 'Lookup');
// Both Lookup(42) and Lookup("alice") resolve to same nodeId → 1 CALLS edge
expect(lookupCalls.length).toBe(1);
});
});

View file

@ -1324,3 +1324,74 @@ describe('Java grandparent method resolution via MRO (Phase B)', () => {
expect(greetCall).toBeDefined();
});
});
// ── Phase P: Overload Disambiguation via Parameter Types ─────────────────
describe('Java overload disambiguation by parameter types', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'java-overload-param-types'),
() => {},
);
}, 60000);
it('detects lookup method in UserService (1 graph node — ID collision for same-file overloads)', () => {
const methods = getNodesByLabel(result, 'Method');
const lookupMethods = methods.filter(m => m === 'lookup');
// generateId produces same ID for same-file same-name overloads;
// graph deduplicates, so 1 node. SymbolTable stores both via fileIndex.
expect(lookupMethods.length).toBe(1);
});
it('emits CALLS edge from run() → lookup() via overload disambiguation', () => {
const calls = getRelationships(result, 'CALLS');
const lookupCalls = calls.filter(c => c.source === 'run' && c.target === 'lookup');
// Phase 0 (fileIndex stores both overloads) + Phase 2 (literal type matching)
// enables resolution where previously 2 same-arity candidates → null.
// Both calls resolve to same nodeId (ID collision) → 1 CALLS edge after dedup.
expect(lookupCalls.length).toBe(1);
});
});
// ── Phase P: Virtual Dispatch via Constructor Type ───────────────────────
describe('Java virtual dispatch via constructor type (same-file)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'java-virtual-dispatch'),
() => {},
);
}, 60000);
it('detects Animal, Dog, and App classes in same file', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Animal');
expect(classes).toContain('Dog');
expect(classes).toContain('App');
});
it('detects Dog extends Animal heritage', () => {
const extends_ = getRelationships(result, 'EXTENDS');
const dogExtends = extends_.find(e => e.source === 'Dog' && e.target === 'Animal');
expect(dogExtends).toBeDefined();
});
it('detects fetchBall() as Dog-only method', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('fetchBall');
});
it('resolves fetchBall() calls from run() — proves virtual dispatch override', () => {
const calls = getRelationships(result, 'CALLS');
const fetchCalls = calls.filter(c => c.source === 'run' && c.target === 'fetchBall');
// animal.fetchBall() only resolves if constructorTypeMap overrides
// receiver from Animal → Dog (since only Dog has fetchBall).
// dog.fetchBall() resolves directly via Dog type.
// Both target same nodeId → 1 CALLS edge after dedup.
expect(fetchCalls.length).toBe(1);
});
});

View file

@ -1532,3 +1532,29 @@ describe('Kotlin null-check narrowing resolution (Phase C)', () => {
expect(saveCall).toBeDefined();
});
});
// ── Phase P: Overload Disambiguation via Parameter Types ─────────────────
describe('Kotlin overload disambiguation by parameter types', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'kotlin-overload-param-types'),
() => {},
);
}, 60000);
it('detects lookup function (1 graph node — ID collision for same-file overloads)', () => {
const methods = getNodesByLabel(result, 'Function');
const lookupFuncs = methods.filter(m => m === 'lookup');
expect(lookupFuncs.length).toBe(1);
});
it('emits CALLS edge from run() → lookup() via overload disambiguation', () => {
const calls = getRelationships(result, 'CALLS');
const lookupCalls = calls.filter(c => c.source === 'run' && c.target === 'lookup');
// Both lookup(42) and lookup("alice") resolve to same nodeId → 1 CALLS edge
expect(lookupCalls.length).toBe(1);
});
});

View file

@ -2222,3 +2222,39 @@ describe('TypeScript null-check narrowing resolution (Phase C)', () => {
expect(saveCall).toBeDefined();
});
});
// ── Phase P: Virtual Dispatch via Constructor Type ───────────────────────
describe('TypeScript virtual dispatch via constructor type (same-file)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ts-virtual-dispatch'),
() => {},
);
}, 60000);
it('detects Animal and Dog classes with same-file heritage', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Animal');
expect(classes).toContain('Dog');
const extends_ = getRelationships(result, 'EXTENDS');
const dogExtends = extends_.find(e => e.source === 'Dog' && e.target === 'Animal');
expect(dogExtends).toBeDefined();
});
it('detects fetchBall() as Dog-only method', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('fetchBall');
});
it('resolves fetchBall() calls from run() — proves virtual dispatch override', () => {
const calls = getRelationships(result, 'CALLS');
const fetchCalls = calls.filter(c => c.source === 'run' && c.target === 'fetchBall');
// animal.fetchBall() only resolves if constructorTypeMap overrides
// receiver from Animal → Dog. dog.fetchBall() resolves directly.
// Both target same nodeId → 1 CALLS edge after dedup.
expect(fetchCalls.length).toBe(1);
});
});

View file

@ -140,6 +140,29 @@ describe('extractMethodSignature', () => {
const sig = extractMethodSignature(methodNode);
expect(sig.parameterCount).toBe(0);
});
it('extracts parameterTypes for Java overloaded methods', () => {
parser.setLanguage(Java);
const code = `class Svc {
public User lookup(int id) { return null; }
public User lookup(String name) { return null; }
public void process(int code, String msg) {}
}`;
const tree = parser.parse(code);
const classBody = tree.rootNode.child(0)!.childForFieldName('body')!;
const sig0 = extractMethodSignature(classBody.namedChild(0)!);
expect(sig0.parameterCount).toBe(1);
expect(sig0.parameterTypes).toEqual(['int']);
const sig1 = extractMethodSignature(classBody.namedChild(1)!);
expect(sig1.parameterCount).toBe(1);
expect(sig1.parameterTypes).toEqual(['String']);
const sig2 = extractMethodSignature(classBody.namedChild(2)!);
expect(sig2.parameterCount).toBe(2);
expect(sig2.parameterTypes).toEqual(['int', 'String']);
});
});
describe('Kotlin', () => {
@ -174,6 +197,24 @@ describe('extractMethodSignature', () => {
const sig = extractMethodSignature(functionNode);
expect(sig.parameterCount).toBe(0);
});
it('extracts parameterTypes for Kotlin overloaded functions', () => {
parser.setLanguage(Kotlin);
const code = `class Svc {
fun lookup(id: Int): User? { return null }
fun lookup(name: String): User? { return null }
}`;
const tree = parser.parse(code);
const classBody = tree.rootNode.child(0)!.namedChild(1)!;
const sig0 = extractMethodSignature(classBody.namedChild(0)!);
expect(sig0.parameterCount).toBe(1);
expect(sig0.parameterTypes).toEqual(['Int']);
const sig1 = extractMethodSignature(classBody.namedChild(1)!);
expect(sig1.parameterCount).toBe(1);
expect(sig1.parameterTypes).toEqual(['String']);
});
});
describe('C++', () => {
@ -200,6 +241,21 @@ describe('extractMethodSignature', () => {
const sig = extractMethodSignature(functionNode);
expect(sig.parameterCount).toBe(0);
});
it('extracts parameterTypes for C++ overloaded functions', () => {
parser.setLanguage(CPP);
const code = `User* lookup(int id) { return nullptr; }
User* lookup(string name) { return nullptr; }`;
const tree = parser.parse(code);
const sig0 = extractMethodSignature(tree.rootNode.namedChild(0)!);
expect(sig0.parameterCount).toBe(1);
expect(sig0.parameterTypes).toEqual(['int']);
const sig1 = extractMethodSignature(tree.rootNode.namedChild(1)!);
expect(sig1.parameterCount).toBe(1);
expect(sig1.parameterTypes).toEqual(['string']);
});
});
describe('C#', () => {
@ -217,6 +273,24 @@ describe('extractMethodSignature', () => {
expect(sig.parameterCount).toBe(2);
});
it('extracts parameterTypes for C# overloaded methods', () => {
parser.setLanguage(CSharp);
const code = `class Svc {
public User Lookup(int id) { return null; }
public User Lookup(string name) { return null; }
}`;
const tree = parser.parse(code);
const classBody = tree.rootNode.child(0)!.childForFieldName('body')!;
const sig0 = extractMethodSignature(classBody.namedChild(0)!);
expect(sig0.parameterCount).toBe(1);
expect(sig0.parameterTypes).toEqual(['int']);
const sig1 = extractMethodSignature(classBody.namedChild(1)!);
expect(sig1.parameterCount).toBe(1);
expect(sig1.parameterTypes).toEqual(['string']);
});
it('handles C# method with no params', () => {
parser.setLanguage(CSharp);
const code = `class Foo {

View file

@ -1375,7 +1375,7 @@ class RepoService {
expect(flatGet(env, 'user')).toBe('User');
});
it('does NOT extract binding from plain instanceof without variable', () => {
it('extracts boolean type from plain instanceof (no pattern variable)', () => {
const tree = parse(`
class App {
void process(Object obj) {
@ -1384,8 +1384,8 @@ class RepoService {
}
`, Java);
const { env } = buildTypeEnv(tree, 'java');
// No pattern variable declared — no binding
expect(flatGet(env, 'b')).toBeUndefined();
// No pattern variable — b gets its declared type 'boolean', not 'User'
expect(flatGet(env, 'b')).toBe('boolean');
});
it('extracts correct type when multiple instanceof patterns exist', () => {