mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-22 00:31:17 +00:00
3262 lines
132 KiB
TypeScript
3262 lines
132 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { SymbolTableWriter } from '../../src/core/ingestion/model/symbol-table.js';
|
|
import {
|
|
createSemanticModel,
|
|
type MutableSemanticModel,
|
|
} from '../../src/core/ingestion/model/semantic-model.js';
|
|
|
|
describe('SymbolTable', () => {
|
|
// SM-23: SymbolTable is now a pure leaf with no registry knowledge.
|
|
// Tests that exercise owner-scoped lookups (lookupClassByName,
|
|
// lookupMethodByOwner, lookupFieldByOwner, lookupClassByQualifiedName,
|
|
// lookupImplByName) must go through SemanticModel which composes
|
|
// SymbolTable with the registries. We build a model and alias
|
|
// `table = model.symbols` so the 200+ file/callable test cases keep
|
|
// their existing call sites unchanged.
|
|
let model: MutableSemanticModel;
|
|
let table: SymbolTableWriter;
|
|
|
|
beforeEach(() => {
|
|
model = createSemanticModel();
|
|
table = model.symbols;
|
|
});
|
|
|
|
describe('add', () => {
|
|
it('registers a symbol in the table', () => {
|
|
table.add('src/index.ts', 'main', 'func:main', 'Function');
|
|
expect(table.getStats().fileCount).toBe(1);
|
|
});
|
|
|
|
it('handles multiple symbols in the same file', () => {
|
|
table.add('src/index.ts', 'main', 'func:main', 'Function');
|
|
table.add('src/index.ts', 'helper', 'func:helper', 'Function');
|
|
expect(table.getStats().fileCount).toBe(1);
|
|
});
|
|
|
|
it('handles same name in different files', () => {
|
|
table.add('src/a.ts', 'init', 'func:a:init', 'Function');
|
|
table.add('src/b.ts', 'init', 'func:b:init', 'Function');
|
|
expect(table.getStats().fileCount).toBe(2);
|
|
});
|
|
|
|
it('allows duplicate adds for same file and name (overloads preserved)', () => {
|
|
table.add('src/a.ts', 'foo', 'func:foo:1', 'Function');
|
|
table.add('src/a.ts', 'foo', 'func:foo:2', 'Function');
|
|
// File index stores both overloads; lookupExact returns first
|
|
expect(table.lookupExact('src/a.ts', 'foo')).toBe('func:foo:1');
|
|
// lookupExactAll returns all overloads
|
|
expect(table.lookupExactAll('src/a.ts', 'foo')).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe('lookupExact', () => {
|
|
it('finds a symbol by file path and name', () => {
|
|
table.add('src/index.ts', 'main', 'func:main', 'Function');
|
|
expect(table.lookupExact('src/index.ts', 'main')).toBe('func:main');
|
|
});
|
|
|
|
it('returns undefined for unknown file', () => {
|
|
table.add('src/index.ts', 'main', 'func:main', 'Function');
|
|
expect(table.lookupExact('src/other.ts', 'main')).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined for unknown symbol name', () => {
|
|
table.add('src/index.ts', 'main', 'func:main', 'Function');
|
|
expect(table.lookupExact('src/index.ts', 'notExist')).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined for empty table', () => {
|
|
expect(table.lookupExact('src/index.ts', 'main')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('getStats', () => {
|
|
it('returns zero counts for empty table', () => {
|
|
expect(table.getStats()).toEqual({
|
|
fileCount: 0,
|
|
});
|
|
});
|
|
|
|
it('tracks unique file count correctly', () => {
|
|
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
|
|
table.add('src/a.ts', 'bar', 'func:bar', 'Function');
|
|
table.add('src/b.ts', 'baz', 'func:baz', 'Function');
|
|
expect(table.getStats().fileCount).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('returnType metadata', () => {
|
|
it('stores returnType in SymbolDefinition', () => {
|
|
table.add('src/utils.ts', 'getUser', 'func:getUser', 'Function', { returnType: 'User' });
|
|
const def = table.lookupExactFull('src/utils.ts', 'getUser');
|
|
expect(def).toBeDefined();
|
|
expect(def!.returnType).toBe('User');
|
|
});
|
|
|
|
it('returnType is available via lookupExactFull', () => {
|
|
table.add('src/utils.ts', 'getUser', 'func:getUser', 'Function', {
|
|
returnType: 'Promise<User>',
|
|
});
|
|
const result = table.lookupExactFull('src/utils.ts', 'getUser');
|
|
expect(result).toBeDefined();
|
|
expect(result!.returnType).toBe('Promise<User>');
|
|
});
|
|
|
|
it('omits returnType when not provided', () => {
|
|
table.add('src/utils.ts', 'helper', 'func:helper', 'Function');
|
|
const def = table.lookupExactFull('src/utils.ts', 'helper');
|
|
expect(def).toBeDefined();
|
|
expect(def!.returnType).toBeUndefined();
|
|
});
|
|
|
|
it('stores returnType alongside parameterCount and ownerId', () => {
|
|
table.add('src/models.ts', 'save', 'method:save', 'Method', {
|
|
parameterCount: 1,
|
|
returnType: 'boolean',
|
|
ownerId: 'class:User',
|
|
});
|
|
const def = table.lookupExactFull('src/models.ts', 'save');
|
|
expect(def).toBeDefined();
|
|
expect(def!.parameterCount).toBe(1);
|
|
expect(def!.returnType).toBe('boolean');
|
|
expect(def!.ownerId).toBe('class:User');
|
|
});
|
|
});
|
|
|
|
describe('declaredType metadata', () => {
|
|
it('stores declaredType in SymbolDefinition', () => {
|
|
table.add('src/models.ts', 'address', 'prop:address', 'Property', {
|
|
declaredType: 'Address',
|
|
ownerId: 'class:User',
|
|
});
|
|
const def = table.lookupExactFull('src/models.ts', 'address');
|
|
expect(def).toBeDefined();
|
|
expect(def!.declaredType).toBe('Address');
|
|
});
|
|
|
|
it('omits declaredType when not provided', () => {
|
|
table.add('src/models.ts', 'name', 'prop:name', 'Property', { ownerId: 'class:User' });
|
|
const def = table.lookupExactFull('src/models.ts', 'name');
|
|
expect(def).toBeDefined();
|
|
expect(def!.declaredType).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Property exclusion from callable index', () => {
|
|
it('Property with ownerId is NOT in callable index', () => {
|
|
table.add('src/models.ts', 'name', 'prop:name', 'Property', {
|
|
declaredType: 'string',
|
|
ownerId: 'class:User',
|
|
});
|
|
// Should not appear in callable lookup
|
|
expect(table.lookupCallableByName('name')).toEqual([]);
|
|
// But should still be in fileIndex
|
|
expect(table.lookupExact('src/models.ts', 'name')).toBe('prop:name');
|
|
});
|
|
|
|
it('Property without ownerId is NOT in callable index', () => {
|
|
table.add('src/models.ts', 'name', 'prop:name', 'Property');
|
|
expect(table.lookupCallableByName('name')).toEqual([]);
|
|
});
|
|
|
|
it('Property without declaredType is still added to fieldByOwner index only', () => {
|
|
table.add('src/models.ts', 'name', 'prop:name', 'Property', { ownerId: 'class:User' });
|
|
// No declaredType → still indexed in fieldByOwner (for write-access tracking
|
|
// in dynamically-typed languages like Ruby/JS), but excluded from callable index
|
|
expect(table.lookupCallableByName('name')).toEqual([]);
|
|
expect(model.fields.lookupFieldByOwner('class:User', 'name')).toEqual({
|
|
nodeId: 'prop:name',
|
|
filePath: 'src/models.ts',
|
|
type: 'Property',
|
|
ownerId: 'class:User',
|
|
});
|
|
});
|
|
|
|
it('post-A4: Method with ownerId lands in methodsByName, not callableByName', () => {
|
|
// Plan 006 Unit 4 shrank FREE_CALLABLE_TYPES to free callables only.
|
|
// Method registrations now flow through the method registry.
|
|
table.add('src/models.ts', 'save', 'method:save', 'Method', { ownerId: 'class:User' });
|
|
expect(table.lookupCallableByName('save')).toHaveLength(0);
|
|
expect(model.methods.lookupMethodByName('save')).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('conditional callable index behaviour', () => {
|
|
it('adding a Function makes it available in callable index', () => {
|
|
table.add('src/a.ts', 'foo', 'func:foo', 'Function', { returnType: 'void' });
|
|
expect(table.lookupCallableByName('foo')).toHaveLength(1);
|
|
// Free Macro is a callable (C/C++ preprocessor macro).
|
|
table.add('src/macros.h', 'BAR', 'macro:BAR', 'Macro');
|
|
expect(table.lookupCallableByName('BAR')).toHaveLength(1);
|
|
});
|
|
|
|
it('adding a Property does NOT add it to callable index', () => {
|
|
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
|
|
expect(table.lookupCallableByName('foo')).toHaveLength(1);
|
|
// Add a Property — callable index should still only contain foo
|
|
table.add('src/models.ts', 'name', 'prop:name', 'Property', {
|
|
declaredType: 'string',
|
|
ownerId: 'class:User',
|
|
});
|
|
expect(table.lookupCallableByName('foo')).toHaveLength(1);
|
|
});
|
|
|
|
it('adding a Class does NOT add it to callable index', () => {
|
|
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
|
|
expect(table.lookupCallableByName('foo')).toHaveLength(1);
|
|
table.add('src/models.ts', 'User', 'class:User', 'Class');
|
|
// Class is not callable, should not appear
|
|
expect(table.lookupCallableByName('foo')).toHaveLength(1);
|
|
});
|
|
|
|
it('Macro (C/C++) is indexed in callable index', () => {
|
|
table.add('src/macros.h', 'ASSERT', 'macro:ASSERT', 'Macro');
|
|
expect(table.lookupCallableByName('ASSERT')).toHaveLength(1);
|
|
expect(table.lookupCallableByName('ASSERT')[0].type).toBe('Macro');
|
|
});
|
|
|
|
it('Delegate (C#) is indexed in callable index', () => {
|
|
table.add('src/Events.cs', 'OnClick', 'delegate:OnClick', 'Delegate');
|
|
expect(table.lookupCallableByName('OnClick')).toHaveLength(1);
|
|
expect(table.lookupCallableByName('OnClick')[0].type).toBe('Delegate');
|
|
});
|
|
|
|
it('Method WITHOUT ownerId falls back to the callable index', () => {
|
|
// Orphaned Method (extractor contract violation / degraded AST).
|
|
// The dispatch hook silently skips it because it has no owner to
|
|
// key under; the callable-index fallback keeps it reachable at
|
|
// Tier 3 global resolution.
|
|
table.add('src/a.ts', 'orphan', 'method:orphan', 'Method');
|
|
expect(table.lookupCallableByName('orphan')).toHaveLength(1);
|
|
expect(table.lookupCallableByName('orphan')[0].type).toBe('Method');
|
|
});
|
|
|
|
it('Constructor WITHOUT ownerId falls back to the callable index', () => {
|
|
table.add('src/a.ts', 'Orphan', 'ctor:Orphan', 'Constructor');
|
|
expect(table.lookupCallableByName('Orphan')).toHaveLength(1);
|
|
expect(table.lookupCallableByName('Orphan')[0].type).toBe('Constructor');
|
|
});
|
|
|
|
it('Method WITH ownerId does NOT land in the callable index (goes to MethodRegistry instead)', () => {
|
|
table.add('src/user.ts', 'greet', 'method:User.greet', 'Method', {
|
|
ownerId: 'class:User',
|
|
});
|
|
expect(table.lookupCallableByName('greet')).toHaveLength(0);
|
|
});
|
|
|
|
it('Constructor WITH ownerId does NOT land in the callable index', () => {
|
|
table.add('src/user.ts', 'User', 'ctor:User', 'Constructor', {
|
|
ownerId: 'class:User',
|
|
});
|
|
expect(table.lookupCallableByName('User')).toHaveLength(0);
|
|
});
|
|
|
|
it('Property WITHOUT ownerId still does NOT fall back to the callable index', () => {
|
|
// Property fallback would pollute common names like `id` / `name` /
|
|
// `type` — kept disjoint from the Method/Constructor fallback.
|
|
table.add('src/a.ts', 'orphanField', 'prop:orphan', 'Property');
|
|
expect(table.lookupCallableByName('orphanField')).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('lookupFieldByOwner', () => {
|
|
it('finds a Property by ownerNodeId and fieldName', () => {
|
|
table.add('src/models.ts', 'address', 'prop:address', 'Property', {
|
|
declaredType: 'Address',
|
|
ownerId: 'class:User',
|
|
});
|
|
const def = model.fields.lookupFieldByOwner('class:User', 'address');
|
|
expect(def).toBeDefined();
|
|
expect(def!.declaredType).toBe('Address');
|
|
expect(def!.nodeId).toBe('prop:address');
|
|
});
|
|
|
|
it('returns undefined for unknown owner', () => {
|
|
table.add('src/models.ts', 'address', 'prop:address', 'Property', {
|
|
declaredType: 'Address',
|
|
ownerId: 'class:User',
|
|
});
|
|
expect(model.fields.lookupFieldByOwner('class:Unknown', 'address')).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined for unknown field name', () => {
|
|
table.add('src/models.ts', 'address', 'prop:address', 'Property', {
|
|
declaredType: 'Address',
|
|
ownerId: 'class:User',
|
|
});
|
|
expect(model.fields.lookupFieldByOwner('class:User', 'email')).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined for empty table', () => {
|
|
expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined();
|
|
});
|
|
|
|
it('indexes Property without declaredType (for dynamic language write-access)', () => {
|
|
table.add('src/models.ts', 'name', 'prop:name', 'Property', { ownerId: 'class:User' });
|
|
expect(model.fields.lookupFieldByOwner('class:User', 'name')).toEqual({
|
|
nodeId: 'prop:name',
|
|
filePath: 'src/models.ts',
|
|
type: 'Property',
|
|
ownerId: 'class:User',
|
|
});
|
|
});
|
|
|
|
it('distinguishes fields by owner', () => {
|
|
table.add('src/models.ts', 'name', 'prop:user:name', 'Property', {
|
|
declaredType: 'string',
|
|
ownerId: 'class:User',
|
|
});
|
|
table.add('src/models.ts', 'name', 'prop:repo:name', 'Property', {
|
|
declaredType: 'RepoName',
|
|
ownerId: 'class:Repo',
|
|
});
|
|
expect(model.fields.lookupFieldByOwner('class:User', 'name')!.declaredType).toBe('string');
|
|
expect(model.fields.lookupFieldByOwner('class:Repo', 'name')!.declaredType).toBe('RepoName');
|
|
});
|
|
});
|
|
|
|
describe('lookupMethodByOwner', () => {
|
|
it('finds a Method by ownerNodeId and method name', () => {
|
|
table.add('src/models.ts', 'getAddress', 'method:getAddress', 'Method', {
|
|
returnType: 'Address',
|
|
ownerId: 'class:User',
|
|
});
|
|
const def = model.methods.lookupMethodByOwner('class:User', 'getAddress');
|
|
expect(def).toBeDefined();
|
|
expect(def!.returnType).toBe('Address');
|
|
expect(def!.nodeId).toBe('method:getAddress');
|
|
});
|
|
|
|
it('finds multiple methods on the same owner', () => {
|
|
table.add('src/models.ts', 'getAddress', 'method:getAddress', 'Method', {
|
|
returnType: 'Address',
|
|
ownerId: 'class:User',
|
|
});
|
|
table.add('src/models.ts', 'getName', 'method:getName', 'Method', {
|
|
returnType: 'String',
|
|
ownerId: 'class:User',
|
|
});
|
|
expect(model.methods.lookupMethodByOwner('class:User', 'getAddress')!.returnType).toBe(
|
|
'Address',
|
|
);
|
|
expect(model.methods.lookupMethodByOwner('class:User', 'getName')!.returnType).toBe('String');
|
|
});
|
|
|
|
it('distinguishes methods by owner', () => {
|
|
table.add('src/models.ts', 'save', 'method:user:save', 'Method', {
|
|
returnType: 'boolean',
|
|
ownerId: 'class:User',
|
|
});
|
|
table.add('src/models.ts', 'save', 'method:address:save', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:Address',
|
|
});
|
|
expect(model.methods.lookupMethodByOwner('class:User', 'save')!.nodeId).toBe(
|
|
'method:user:save',
|
|
);
|
|
expect(model.methods.lookupMethodByOwner('class:Address', 'save')!.nodeId).toBe(
|
|
'method:address:save',
|
|
);
|
|
});
|
|
|
|
it('returns undefined for unknown owner', () => {
|
|
table.add('src/models.ts', 'save', 'method:save', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:User',
|
|
});
|
|
expect(model.methods.lookupMethodByOwner('class:Unknown', 'save')).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined for unknown method name', () => {
|
|
table.add('src/models.ts', 'save', 'method:save', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:User',
|
|
});
|
|
expect(model.methods.lookupMethodByOwner('class:User', 'delete')).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined for empty table', () => {
|
|
expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeUndefined();
|
|
});
|
|
|
|
it('Method without ownerId is not in MethodRegistry but falls back to callable index', () => {
|
|
// methodHook silently skips Method-without-ownerId (methods.register
|
|
// requires an owner). The orphan-owner-scoped fallback in
|
|
// `SymbolTable.add()` routes such defs through `callableByName` so
|
|
// Tier 3 global resolution can still find them.
|
|
table.add('src/utils.ts', 'helper', 'method:helper', 'Method');
|
|
expect(model.methods.lookupMethodByOwner('', 'helper')).toBeUndefined();
|
|
expect(model.methods.lookupMethodByName('helper')).toHaveLength(0);
|
|
expect(table.lookupCallableByName('helper')).toHaveLength(1);
|
|
expect(table.lookupCallableByName('helper')[0].type).toBe('Method');
|
|
expect(table.lookupExact('src/utils.ts', 'helper')).toBe('method:helper');
|
|
});
|
|
|
|
it('returns first match for overloads with same returnType (unambiguous)', () => {
|
|
table.add('src/models.ts', 'find', 'method:find:1', 'Method', {
|
|
parameterCount: 1,
|
|
returnType: 'User',
|
|
ownerId: 'class:UserRepo',
|
|
});
|
|
table.add('src/models.ts', 'find', 'method:find:2', 'Method', {
|
|
parameterCount: 2,
|
|
returnType: 'User',
|
|
ownerId: 'class:UserRepo',
|
|
});
|
|
const def = model.methods.lookupMethodByOwner('class:UserRepo', 'find');
|
|
expect(def).toBeDefined();
|
|
expect(def!.nodeId).toBe('method:find:1');
|
|
expect(def!.returnType).toBe('User');
|
|
});
|
|
|
|
it('returns undefined for overloads both missing returnType (ambiguous)', () => {
|
|
table.add('src/models.ts', 'process', 'method:process:1', 'Method', {
|
|
parameterCount: 1,
|
|
ownerId: 'class:Handler',
|
|
});
|
|
table.add('src/models.ts', 'process', 'method:process:2', 'Method', {
|
|
parameterCount: 2,
|
|
ownerId: 'class:Handler',
|
|
});
|
|
expect(model.methods.lookupMethodByOwner('class:Handler', 'process')).toBeUndefined();
|
|
});
|
|
|
|
it('indexes Constructor in methodByOwner', () => {
|
|
table.add('src/models.ts', 'User', 'ctor:User', 'Constructor', {
|
|
parameterCount: 0,
|
|
ownerId: 'class:User',
|
|
});
|
|
expect(model.methods.lookupMethodByOwner('class:User', 'User')).toEqual({
|
|
nodeId: 'ctor:User',
|
|
filePath: 'src/models.ts',
|
|
type: 'Constructor',
|
|
parameterCount: 0,
|
|
ownerId: 'class:User',
|
|
});
|
|
// Post-A4 Unit 4: Constructor no longer lands in callableByName.
|
|
// It is reachable via methodsByName instead.
|
|
expect(table.lookupCallableByName('User')).toHaveLength(0);
|
|
expect(model.methods.lookupMethodByName('User')).toHaveLength(1);
|
|
});
|
|
|
|
it('returns undefined for overloads with different returnTypes (ambiguous)', () => {
|
|
table.add('src/models.ts', 'convert', 'method:convert:1', 'Method', {
|
|
parameterCount: 1,
|
|
returnType: 'String',
|
|
ownerId: 'class:Converter',
|
|
});
|
|
table.add('src/models.ts', 'convert', 'method:convert:2', 'Method', {
|
|
parameterCount: 2,
|
|
returnType: 'Number',
|
|
ownerId: 'class:Converter',
|
|
});
|
|
expect(model.methods.lookupMethodByOwner('class:Converter', 'convert')).toBeUndefined();
|
|
});
|
|
|
|
it('post-A4: Method with ownerId is reachable via methodsByName, not callableByName', () => {
|
|
table.add('src/models.ts', 'save', 'method:save', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:User',
|
|
});
|
|
expect(table.lookupCallableByName('save')).toHaveLength(0);
|
|
expect(model.methods.lookupMethodByName('save')).toHaveLength(1);
|
|
expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeDefined();
|
|
});
|
|
|
|
it('after clear(), lookupMethodByOwner returns undefined', () => {
|
|
table.add('src/models.ts', 'save', 'method:save', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:User',
|
|
});
|
|
expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeDefined();
|
|
model.clear();
|
|
expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('lookupCallableByName', () => {
|
|
it('post-A4: returns only free callables (Function/Macro/Delegate)', () => {
|
|
// Post-Unit 4, FREE_CALLABLE_TYPES = {Function, Macro, Delegate}.
|
|
// Method and Constructor flow through the method registry instead.
|
|
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
|
|
table.add('src/a.ts', 'bar', 'method:bar', 'Method', { ownerId: 'class:X' });
|
|
table.add('src/a.ts', 'Baz', 'ctor:Baz', 'Constructor', { ownerId: 'class:Baz' });
|
|
table.add('src/a.ts', 'User', 'class:User', 'Class');
|
|
table.add('src/a.ts', 'IUser', 'iface:IUser', 'Interface');
|
|
expect(table.lookupCallableByName('foo')).toHaveLength(1);
|
|
expect(table.lookupCallableByName('bar')).toEqual([]);
|
|
expect(table.lookupCallableByName('Baz')).toEqual([]);
|
|
expect(model.methods.lookupMethodByName('bar')).toHaveLength(1);
|
|
expect(model.methods.lookupMethodByName('Baz')).toHaveLength(1);
|
|
expect(table.lookupCallableByName('User')).toEqual([]);
|
|
expect(table.lookupCallableByName('IUser')).toEqual([]);
|
|
});
|
|
|
|
it('returns empty array for unknown name', () => {
|
|
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
|
|
expect(table.lookupCallableByName('unknown')).toEqual([]);
|
|
});
|
|
|
|
it('includes newly added callable', () => {
|
|
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
|
|
expect(table.lookupCallableByName('foo')).toHaveLength(1);
|
|
expect(table.lookupCallableByName('bar')).toEqual([]);
|
|
table.add('src/a.ts', 'bar', 'func:bar', 'Function');
|
|
expect(table.lookupCallableByName('bar')).toHaveLength(1);
|
|
});
|
|
|
|
it('filters non-callable types from mixed name entries', () => {
|
|
table.add('src/a.ts', 'save', 'func:save', 'Function');
|
|
table.add('src/b.ts', 'save', 'class:save', 'Class');
|
|
const callables = table.lookupCallableByName('save');
|
|
expect(callables).toHaveLength(1);
|
|
expect(callables[0].type).toBe('Function');
|
|
});
|
|
});
|
|
|
|
describe('clear', () => {
|
|
it('resets all state including fieldByOwner, methodByOwner, and classByName', () => {
|
|
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
|
|
table.add('src/b.ts', 'bar', 'func:bar', 'Function');
|
|
table.add('src/models.ts', 'address', 'prop:address', 'Property', {
|
|
declaredType: 'Address',
|
|
ownerId: 'class:User',
|
|
});
|
|
table.add('src/models.ts', 'save', 'method:save', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:User',
|
|
});
|
|
table.add('src/models.ts', 'User', 'class:User', 'Class');
|
|
model.clear();
|
|
expect(table.getStats()).toEqual({
|
|
fileCount: 0,
|
|
});
|
|
expect(table.lookupExact('src/a.ts', 'foo')).toBeUndefined();
|
|
expect(model.fields.lookupFieldByOwner('class:User', 'address')).toBeUndefined();
|
|
expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeUndefined();
|
|
expect(table.lookupCallableByName('foo')).toEqual([]);
|
|
expect(model.types.lookupClassByName('User')).toEqual([]);
|
|
});
|
|
|
|
it('allows re-adding after clear', () => {
|
|
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
|
|
model.clear();
|
|
table.add('src/b.ts', 'bar', 'func:bar', 'Function');
|
|
expect(table.getStats()).toEqual({
|
|
fileCount: 1,
|
|
});
|
|
});
|
|
|
|
it('resets callable index so first lookup after clear rebuilds from scratch', () => {
|
|
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
|
|
// Verify callable is found
|
|
expect(table.lookupCallableByName('foo')).toHaveLength(1);
|
|
model.clear();
|
|
// After clear the callable index must be gone — empty table returns nothing
|
|
expect(table.lookupCallableByName('foo')).toEqual([]);
|
|
// Re-adding and looking up works correctly
|
|
table.add('src/a.ts', 'foo', 'func:foo2', 'Function');
|
|
expect(table.lookupCallableByName('foo')).toHaveLength(1);
|
|
expect(table.lookupCallableByName('foo')[0].nodeId).toBe('func:foo2');
|
|
});
|
|
});
|
|
|
|
describe('metadata spread branches (individual optional fields)', () => {
|
|
it('stores only parameterCount when no other metadata is given', () => {
|
|
table.add('src/utils.ts', 'compute', 'func:compute', 'Function', { parameterCount: 3 });
|
|
const def = table.lookupExactFull('src/utils.ts', 'compute');
|
|
expect(def).toBeDefined();
|
|
expect(def!.parameterCount).toBe(3);
|
|
expect(def!.returnType).toBeUndefined();
|
|
expect(def!.declaredType).toBeUndefined();
|
|
expect(def!.ownerId).toBeUndefined();
|
|
});
|
|
|
|
it('stores only ownerId on a Method — reachable via methodsByName (post-A4)', () => {
|
|
table.add('src/models.ts', 'save', 'method:save', 'Method', { ownerId: 'class:Repo' });
|
|
const def = table.lookupExactFull('src/models.ts', 'save');
|
|
expect(def).toBeDefined();
|
|
expect(def!.ownerId).toBe('class:Repo');
|
|
expect(def!.parameterCount).toBeUndefined();
|
|
expect(def!.returnType).toBeUndefined();
|
|
expect(def!.declaredType).toBeUndefined();
|
|
// Post-A4 Unit 4: owner-scoped Method lives in methodsByName,
|
|
// not callableByName.
|
|
expect(table.lookupCallableByName('save')).toHaveLength(0);
|
|
expect(model.methods.lookupMethodByName('save')).toHaveLength(1);
|
|
});
|
|
|
|
it('stores declaredType alone (no ownerId) — symbol in file index', () => {
|
|
// A Variable/Property without an owner should still be accessible via file index
|
|
table.add('src/config.ts', 'DEFAULT_TIMEOUT', 'var:DEFAULT_TIMEOUT', 'Variable', {
|
|
declaredType: 'number',
|
|
});
|
|
const def = table.lookupExactFull('src/config.ts', 'DEFAULT_TIMEOUT');
|
|
expect(def).toBeDefined();
|
|
expect(def!.declaredType).toBe('number');
|
|
expect(def!.ownerId).toBeUndefined();
|
|
});
|
|
|
|
it('stores all four optional metadata fields simultaneously on a Method', () => {
|
|
table.add('src/models.ts', 'find', 'method:find', 'Method', {
|
|
parameterCount: 2,
|
|
returnType: 'User | undefined',
|
|
declaredType: 'QueryResult',
|
|
ownerId: 'class:UserRepository',
|
|
});
|
|
const def = table.lookupExactFull('src/models.ts', 'find');
|
|
expect(def).toBeDefined();
|
|
expect(def!.parameterCount).toBe(2);
|
|
expect(def!.returnType).toBe('User | undefined');
|
|
expect(def!.declaredType).toBe('QueryResult');
|
|
expect(def!.ownerId).toBe('class:UserRepository');
|
|
});
|
|
|
|
it('omits all optional fields when metadata is not provided at all', () => {
|
|
table.add('src/utils.ts', 'noop', 'func:noop', 'Function');
|
|
const def = table.lookupExactFull('src/utils.ts', 'noop');
|
|
expect(def).toBeDefined();
|
|
expect(def!.parameterCount).toBeUndefined();
|
|
expect(def!.returnType).toBeUndefined();
|
|
expect(def!.declaredType).toBeUndefined();
|
|
expect(def!.ownerId).toBeUndefined();
|
|
});
|
|
|
|
it('stores parameterCount: 0 (falsy value) correctly', () => {
|
|
// parameterCount of 0 must not be dropped by the spread guard
|
|
table.add('src/utils.ts', 'noArgs', 'func:noArgs', 'Function', { parameterCount: 0 });
|
|
const def = table.lookupExactFull('src/utils.ts', 'noArgs');
|
|
expect(def).toBeDefined();
|
|
expect(def!.parameterCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('lookupCallableByName — eager index behavior', () => {
|
|
it('returns empty array when table has no callables', () => {
|
|
table.add('src/models.ts', 'User', 'class:User', 'Class');
|
|
table.add('src/models.ts', 'IUser', 'iface:IUser', 'Interface');
|
|
expect(table.lookupCallableByName('User')).toEqual([]);
|
|
expect(table.lookupCallableByName('IUser')).toEqual([]);
|
|
});
|
|
|
|
it('returns consistent result on repeated calls', () => {
|
|
table.add('src/a.ts', 'fetch', 'func:fetch', 'Function', { returnType: 'Response' });
|
|
const first = table.lookupCallableByName('fetch');
|
|
expect(first).toHaveLength(1);
|
|
const second = table.lookupCallableByName('fetch');
|
|
expect(second).toHaveLength(1);
|
|
expect(second[0].nodeId).toBe('func:fetch');
|
|
});
|
|
|
|
it('post-A4: newly added Method is reachable via methodsByName, not callableByName', () => {
|
|
table.add('src/a.ts', 'alpha', 'func:alpha', 'Function');
|
|
expect(table.lookupCallableByName('alpha')).toHaveLength(1);
|
|
expect(table.lookupCallableByName('beta')).toEqual([]);
|
|
table.add('src/a.ts', 'beta', 'method:beta', 'Method', { ownerId: 'class:X' });
|
|
expect(table.lookupCallableByName('beta')).toHaveLength(0);
|
|
const byName = model.methods.lookupMethodByName('beta');
|
|
expect(byName).toHaveLength(1);
|
|
expect(byName[0].type).toBe('Method');
|
|
});
|
|
|
|
it('post-A4: newly added Constructor is reachable via methodsByName, not callableByName', () => {
|
|
table.add('src/a.ts', 'existing', 'func:existing', 'Function');
|
|
expect(table.lookupCallableByName('existing')).toHaveLength(1);
|
|
table.add('src/models.ts', 'MyClass', 'ctor:MyClass', 'Constructor', {
|
|
ownerId: 'class:MyClass',
|
|
});
|
|
expect(table.lookupCallableByName('MyClass')).toHaveLength(0);
|
|
const byName = model.methods.lookupMethodByName('MyClass');
|
|
expect(byName).toHaveLength(1);
|
|
expect(byName[0].type).toBe('Constructor');
|
|
});
|
|
});
|
|
|
|
describe('lookupExactFull — full SymbolDefinition shape', () => {
|
|
it('returns undefined for unknown file', () => {
|
|
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
|
|
expect(table.lookupExactFull('src/other.ts', 'foo')).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined for unknown symbol name within a known file', () => {
|
|
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
|
|
expect(table.lookupExactFull('src/a.ts', 'bar')).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined for empty table', () => {
|
|
expect(table.lookupExactFull('src/a.ts', 'foo')).toBeUndefined();
|
|
});
|
|
|
|
it('returns the full SymbolDefinition including nodeId, filePath, and type', () => {
|
|
table.add('src/models.ts', 'address', 'prop:address', 'Property', {
|
|
declaredType: 'Address',
|
|
ownerId: 'class:User',
|
|
});
|
|
const def = table.lookupExactFull('src/models.ts', 'address');
|
|
expect(def).toBeDefined();
|
|
expect(def!.nodeId).toBe('prop:address');
|
|
expect(def!.filePath).toBe('src/models.ts');
|
|
expect(def!.type).toBe('Property');
|
|
expect(def!.declaredType).toBe('Address');
|
|
expect(def!.ownerId).toBe('class:User');
|
|
});
|
|
|
|
it('returns first definition when same file and name are added twice (overloads preserved)', () => {
|
|
table.add('src/a.ts', 'foo', 'func:foo:v1', 'Function', { returnType: 'void' });
|
|
table.add('src/a.ts', 'foo', 'func:foo:v2', 'Function', { returnType: 'string' });
|
|
// lookupExactFull returns first match
|
|
const def = table.lookupExactFull('src/a.ts', 'foo');
|
|
expect(def).toBeDefined();
|
|
expect(def!.nodeId).toBe('func:foo:v1');
|
|
expect(def!.returnType).toBe('void');
|
|
// lookupExactAll returns all overloads
|
|
const all = table.lookupExactAll('src/a.ts', 'foo');
|
|
expect(all).toHaveLength(2);
|
|
expect(all[0].nodeId).toBe('func:foo:v1');
|
|
expect(all[1].nodeId).toBe('func:foo:v2');
|
|
expect(all[1].returnType).toBe('string');
|
|
});
|
|
});
|
|
|
|
describe('lookupFieldByOwner — additional coverage', () => {
|
|
it('stores multiple distinct fields under the same owner', () => {
|
|
table.add('src/models.ts', 'id', 'prop:user:id', 'Property', {
|
|
declaredType: 'number',
|
|
ownerId: 'class:User',
|
|
});
|
|
table.add('src/models.ts', 'email', 'prop:user:email', 'Property', {
|
|
declaredType: 'string',
|
|
ownerId: 'class:User',
|
|
});
|
|
table.add('src/models.ts', 'createdAt', 'prop:user:createdAt', 'Property', {
|
|
declaredType: 'Date',
|
|
ownerId: 'class:User',
|
|
});
|
|
expect(model.fields.lookupFieldByOwner('class:User', 'id')!.declaredType).toBe('number');
|
|
expect(model.fields.lookupFieldByOwner('class:User', 'email')!.declaredType).toBe('string');
|
|
expect(model.fields.lookupFieldByOwner('class:User', 'createdAt')!.declaredType).toBe('Date');
|
|
});
|
|
|
|
it('returns the full SymbolDefinition (nodeId + filePath + type) not just declaredType', () => {
|
|
table.add('src/models.ts', 'score', 'prop:score', 'Property', {
|
|
declaredType: 'number',
|
|
ownerId: 'class:Player',
|
|
});
|
|
const def = model.fields.lookupFieldByOwner('class:Player', 'score');
|
|
expect(def).toBeDefined();
|
|
expect(def!.nodeId).toBe('prop:score');
|
|
expect(def!.filePath).toBe('src/models.ts');
|
|
expect(def!.type).toBe('Property');
|
|
});
|
|
|
|
it('key collision is impossible between different owners sharing a field name', () => {
|
|
// Ensures the null-byte separator in the key prevents cross-owner leakage
|
|
table.add('src/models.ts', 'id', 'prop:a:id', 'Property', {
|
|
declaredType: 'string',
|
|
ownerId: 'class:A',
|
|
});
|
|
table.add('src/models.ts', 'id', 'prop:b:id', 'Property', {
|
|
declaredType: 'UUID',
|
|
ownerId: 'class:B',
|
|
});
|
|
expect(model.fields.lookupFieldByOwner('class:A', 'id')!.nodeId).toBe('prop:a:id');
|
|
expect(model.fields.lookupFieldByOwner('class:B', 'id')!.nodeId).toBe('prop:b:id');
|
|
// An owner whose id is the concatenation of A's ownerId + fieldName must not match
|
|
expect(model.fields.lookupFieldByOwner('class:A\0id', '')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('lookupClassByName', () => {
|
|
it('returns Class definitions by name', () => {
|
|
table.add('src/models.ts', 'User', 'class:User', 'Class');
|
|
const results = model.types.lookupClassByName('User');
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0]).toEqual({
|
|
nodeId: 'class:User',
|
|
filePath: 'src/models.ts',
|
|
type: 'Class',
|
|
qualifiedName: 'User',
|
|
});
|
|
});
|
|
|
|
it('returns Struct definitions by name', () => {
|
|
table.add('src/models.rs', 'Point', 'struct:Point', 'Struct');
|
|
const results = model.types.lookupClassByName('Point');
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].type).toBe('Struct');
|
|
});
|
|
|
|
it('returns Interface definitions by name', () => {
|
|
table.add('src/types.ts', 'Serializable', 'iface:Serializable', 'Interface');
|
|
const results = model.types.lookupClassByName('Serializable');
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].type).toBe('Interface');
|
|
});
|
|
|
|
it('returns Enum definitions by name', () => {
|
|
table.add('src/types.ts', 'Color', 'enum:Color', 'Enum');
|
|
const results = model.types.lookupClassByName('Color');
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].type).toBe('Enum');
|
|
});
|
|
|
|
it('returns Record definitions by name', () => {
|
|
table.add('src/models.java', 'Config', 'record:Config', 'Record');
|
|
const results = model.types.lookupClassByName('Config');
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].type).toBe('Record');
|
|
});
|
|
|
|
it('does NOT include Function with the same name', () => {
|
|
table.add('src/models.ts', 'User', 'class:User', 'Class');
|
|
table.add('src/utils.ts', 'User', 'func:User', 'Function');
|
|
const results = model.types.lookupClassByName('User');
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].type).toBe('Class');
|
|
expect(results[0].nodeId).toBe('class:User');
|
|
});
|
|
|
|
it('does NOT include Method, Variable, Property, or Constructor', () => {
|
|
table.add('src/a.ts', 'Foo', 'method:Foo', 'Method');
|
|
table.add('src/a.ts', 'Bar', 'var:Bar', 'Variable');
|
|
table.add('src/a.ts', 'Baz', 'prop:Baz', 'Property');
|
|
table.add('src/a.ts', 'Qux', 'ctor:Qux', 'Constructor');
|
|
expect(model.types.lookupClassByName('Foo')).toEqual([]);
|
|
expect(model.types.lookupClassByName('Bar')).toEqual([]);
|
|
expect(model.types.lookupClassByName('Baz')).toEqual([]);
|
|
expect(model.types.lookupClassByName('Qux')).toEqual([]);
|
|
});
|
|
|
|
it('includes Trait in the class set (PHP use, Rust impl, Scala traits)', () => {
|
|
// Traits are class-like for heritage resolution — they contribute
|
|
// methods to the using/implementing type's hierarchy. buildHeritageMap
|
|
// relies on this to resolve `use Trait;` edges in PHP, `impl Trait for
|
|
// Struct` in Rust, etc. Added as part of PR #744 (SM-11 Codex review
|
|
// fixes) after the PHP HasTimestamps trait walk gap was discovered.
|
|
table.add('src/a.rs', 'Writer', 'trait:Writer', 'Trait');
|
|
const results = model.types.lookupClassByName('Writer');
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].nodeId).toBe('trait:Writer');
|
|
});
|
|
|
|
it('does NOT include other type-like labels outside the allowed class set', () => {
|
|
table.add('src/a.ts', 'User', 'type:User', 'Type');
|
|
expect(model.types.lookupClassByName('User')).toEqual([]);
|
|
});
|
|
|
|
it('returns multiple classes with the same name from different files', () => {
|
|
table.add('src/models/user.ts', 'User', 'class:user:User', 'Class');
|
|
table.add('src/dto/user.ts', 'User', 'class:dto:User', 'Class');
|
|
const results = model.types.lookupClassByName('User');
|
|
expect(results).toHaveLength(2);
|
|
expect(results[0].filePath).toBe('src/models/user.ts');
|
|
expect(results[1].filePath).toBe('src/dto/user.ts');
|
|
});
|
|
|
|
it('returns empty array for unknown name', () => {
|
|
table.add('src/models.ts', 'User', 'class:User', 'Class');
|
|
expect(model.types.lookupClassByName('NonExistent')).toEqual([]);
|
|
});
|
|
|
|
it('returns empty array for empty table', () => {
|
|
expect(model.types.lookupClassByName('User')).toEqual([]);
|
|
});
|
|
|
|
it('after clear(), returns empty array', () => {
|
|
table.add('src/models.ts', 'User', 'class:User', 'Class');
|
|
expect(model.types.lookupClassByName('User')).toHaveLength(1);
|
|
model.clear();
|
|
expect(model.types.lookupClassByName('User')).toEqual([]);
|
|
});
|
|
|
|
it('returns mixed class-like types with the same name', () => {
|
|
// e.g. a Class and an Interface both named 'Comparable' in different files
|
|
table.add('src/base.ts', 'Comparable', 'class:Comparable', 'Class');
|
|
table.add('src/types.ts', 'Comparable', 'iface:Comparable', 'Interface');
|
|
const results = model.types.lookupClassByName('Comparable');
|
|
expect(results).toHaveLength(2);
|
|
expect(results.map((r) => r.type)).toEqual(['Class', 'Interface']);
|
|
});
|
|
|
|
it('preserves metadata on indexed class definitions', () => {
|
|
table.add('src/models.ts', 'User', 'class:User', 'Class', {
|
|
returnType: 'User',
|
|
ownerId: 'module:models',
|
|
});
|
|
const results = model.types.lookupClassByName('User');
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].ownerId).toBe('module:models');
|
|
});
|
|
|
|
it('class-like symbols are available via lookupClassByName', () => {
|
|
table.add('src/models.ts', 'User', 'class:User', 'Class');
|
|
// classByName is the dedicated index for class-like lookups
|
|
expect(model.types.lookupClassByName('User')).toHaveLength(1);
|
|
});
|
|
|
|
it('allows re-adding after clear and returns correct results', () => {
|
|
table.add('src/models.ts', 'User', 'class:User:v1', 'Class');
|
|
model.clear();
|
|
table.add('src/models.ts', 'User', 'class:User:v2', 'Class');
|
|
const results = model.types.lookupClassByName('User');
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].nodeId).toBe('class:User:v2');
|
|
});
|
|
});
|
|
|
|
describe('lookupClassByQualifiedName', () => {
|
|
it('indexes class-like definitions by qualified name without replacing simple-name lookup', () => {
|
|
table.add('src/services/user.cs', 'User', 'class:services:User', 'Class', {
|
|
qualifiedName: 'Services.User',
|
|
});
|
|
table.add('src/data/user.cs', 'User', 'class:data:User', 'Class', {
|
|
qualifiedName: 'Data.User',
|
|
});
|
|
|
|
expect(model.types.lookupClassByName('User')).toHaveLength(2);
|
|
expect(model.types.lookupClassByQualifiedName('Services.User')).toEqual([
|
|
{
|
|
nodeId: 'class:services:User',
|
|
filePath: 'src/services/user.cs',
|
|
type: 'Class',
|
|
qualifiedName: 'Services.User',
|
|
},
|
|
]);
|
|
const dataUserMatches = model.types.lookupClassByQualifiedName('Data.User');
|
|
expect(dataUserMatches).toHaveLength(1);
|
|
expect(dataUserMatches[0].qualifiedName).toBe('Data.User');
|
|
});
|
|
|
|
it('falls back to the simple name when no qualified metadata is provided', () => {
|
|
table.add('src/models.ts', 'User', 'class:User', 'Class');
|
|
expect(model.types.lookupClassByQualifiedName('User')).toEqual([
|
|
{
|
|
nodeId: 'class:User',
|
|
filePath: 'src/models.ts',
|
|
type: 'Class',
|
|
qualifiedName: 'User',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('returns empty array for non-class-like types even when qualified metadata is present', () => {
|
|
table.add('src/utils.ts', 'User', 'func:User', 'Function', {
|
|
qualifiedName: 'Services.User',
|
|
});
|
|
expect(model.types.lookupClassByQualifiedName('Services.User')).toEqual([]);
|
|
});
|
|
|
|
it('after clear(), returns empty array', () => {
|
|
table.add('src/services/user.cs', 'User', 'class:User', 'Class', {
|
|
qualifiedName: 'Services.User',
|
|
});
|
|
expect(model.types.lookupClassByQualifiedName('Services.User')).toHaveLength(1);
|
|
model.clear();
|
|
expect(model.types.lookupClassByQualifiedName('Services.User')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('SemanticModel container (SM-21 inversion)', () => {
|
|
// Post-inversion, the SemanticModel is the top-level container and
|
|
// SymbolTable is a nested `symbols` subfield. These tests exercise the
|
|
// inverted access pattern directly via createSemanticModel() so the
|
|
// factory wiring is covered end-to-end: feeding the symbol table via
|
|
// its `add()` populates the parent registries (types/methods/fields).
|
|
const buildModel = (): MutableSemanticModel => createSemanticModel();
|
|
|
|
it('exposes types, methods, fields, and symbols subfields', () => {
|
|
const model = buildModel();
|
|
expect(model.types).toBeDefined();
|
|
expect(model.methods).toBeDefined();
|
|
expect(model.fields).toBeDefined();
|
|
expect(model.symbols).toBeDefined();
|
|
});
|
|
|
|
it('feeding a Class via model.symbols.add populates model.types', () => {
|
|
const model = buildModel();
|
|
model.symbols.add('src/user.ts', 'User', 'class:User', 'Class', {
|
|
qualifiedName: 'app.User',
|
|
});
|
|
expect(model.types.lookupClassByName('User')).toHaveLength(1);
|
|
expect(model.types.lookupClassByName('User')[0]!.nodeId).toBe('class:User');
|
|
expect(model.types.lookupClassByQualifiedName('app.User')).toHaveLength(1);
|
|
});
|
|
|
|
it('feeding a Method with ownerId populates model.methods', () => {
|
|
const model = buildModel();
|
|
model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
|
model.symbols.add('src/user.ts', 'save', 'mtd:User.save', 'Method', {
|
|
ownerId: 'class:User',
|
|
parameterCount: 0,
|
|
});
|
|
expect(model.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('mtd:User.save');
|
|
});
|
|
|
|
it('feeding a Property with ownerId populates model.fields', () => {
|
|
const model = buildModel();
|
|
model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
|
model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', {
|
|
ownerId: 'class:User',
|
|
declaredType: 'string',
|
|
});
|
|
expect(model.fields.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name');
|
|
});
|
|
|
|
it('feeding an Impl populates model.types.lookupImplByName', () => {
|
|
const model = buildModel();
|
|
model.symbols.add('src/user.rs', 'User', 'impl:User', 'Impl');
|
|
expect(model.types.lookupImplByName('User')).toHaveLength(1);
|
|
});
|
|
|
|
it('arity filtering disambiguates overloads via model.methods', () => {
|
|
const model = buildModel();
|
|
model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
|
model.symbols.add('src/user.ts', 'greet', 'mtd:greet:0', 'Method', {
|
|
ownerId: 'class:User',
|
|
parameterCount: 0,
|
|
});
|
|
model.symbols.add('src/user.ts', 'greet', 'mtd:greet:1', 'Method', {
|
|
ownerId: 'class:User',
|
|
parameterCount: 1,
|
|
});
|
|
expect(model.methods.lookupMethodByOwner('class:User', 'greet', 0)?.nodeId).toBe(
|
|
'mtd:greet:0',
|
|
);
|
|
expect(model.methods.lookupMethodByOwner('class:User', 'greet', 1)?.nodeId).toBe(
|
|
'mtd:greet:1',
|
|
);
|
|
});
|
|
|
|
it('clear() cascades through all three registries and the nested symbol table', () => {
|
|
const model = buildModel();
|
|
model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
|
model.symbols.add('src/user.ts', 'save', 'mtd:User.save', 'Method', {
|
|
ownerId: 'class:User',
|
|
});
|
|
model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', {
|
|
ownerId: 'class:User',
|
|
declaredType: 'string',
|
|
});
|
|
|
|
// Pre-clear: every store is populated.
|
|
expect(model.types.lookupClassByName('User')).toHaveLength(1);
|
|
expect(model.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('mtd:User.save');
|
|
expect(model.fields.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name');
|
|
expect(model.symbols.lookupExact('src/user.ts', 'User')).toBe('class:User');
|
|
|
|
model.clear();
|
|
|
|
// Post-clear: every store is empty — types, methods, fields, symbols.
|
|
expect(model.types.lookupClassByName('User')).toEqual([]);
|
|
expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeUndefined();
|
|
expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined();
|
|
expect(model.symbols.lookupExact('src/user.ts', 'User')).toBeUndefined();
|
|
});
|
|
|
|
it('feeds Function-with-ownerId into model.methods (Python-style class method)', () => {
|
|
// Python/Rust/Kotlin extractors emit class methods as `Function` with
|
|
// ownerId. The add() branch must route these into the method registry
|
|
// so owner-scoped resolution works uniformly across languages.
|
|
const model = buildModel();
|
|
model.symbols.add('src/user.py', 'User', 'class:User', 'Class');
|
|
model.symbols.add('src/user.py', 'save', 'fn:User.save', 'Function', {
|
|
ownerId: 'class:User',
|
|
});
|
|
expect(model.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('fn:User.save');
|
|
});
|
|
|
|
it('silently skips Property without ownerId (no model.fields registration)', () => {
|
|
// Properties without ownerId are kept in the file index but never
|
|
// reach the fields registry — documenting the intentional behavior.
|
|
const model = buildModel();
|
|
model.symbols.add('src/user.ts', 'name', 'prop:orphan.name', 'Property', {
|
|
declaredType: 'string',
|
|
});
|
|
expect(model.symbols.lookupExact('src/user.ts', 'name')).toBe('prop:orphan.name');
|
|
expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// SM-22 — dispatch table routing invariants
|
|
// -------------------------------------------------------------------------
|
|
|
|
describe('registration dispatch table (SM-22)', () => {
|
|
it('registering a Class hits types.registerClass exactly once and touches no other registry', () => {
|
|
const model = createSemanticModel();
|
|
const classSpy = vi.spyOn(model.types, 'registerClass');
|
|
const implSpy = vi.spyOn(model.types, 'registerImpl');
|
|
const methodsSpy = vi.spyOn(model.methods, 'register');
|
|
const fieldsSpy = vi.spyOn(model.fields, 'register');
|
|
|
|
model.symbols.add('src/user.ts', 'User', 'class:User', 'Class', {
|
|
qualifiedName: 'app.User',
|
|
});
|
|
|
|
expect(classSpy).toHaveBeenCalledTimes(1);
|
|
expect(implSpy).not.toHaveBeenCalled();
|
|
expect(methodsSpy).not.toHaveBeenCalled();
|
|
expect(fieldsSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('registering a Property populates fields.register and DOES NOT append to callableByName', () => {
|
|
const model = createSemanticModel();
|
|
model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
|
model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', {
|
|
ownerId: 'class:User',
|
|
declaredType: 'string',
|
|
});
|
|
|
|
expect(model.fields.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name');
|
|
// Property must NOT leak into callableByName — Property is not in
|
|
// FREE_CALLABLE_TYPES, so SymbolTable.add() never appends it.
|
|
expect(model.symbols.lookupCallableByName('name')).toHaveLength(0);
|
|
});
|
|
|
|
it('registering a free Function populates callableByName but not methods.register', () => {
|
|
const model = createSemanticModel();
|
|
const methodsSpy = vi.spyOn(model.methods, 'register');
|
|
|
|
model.symbols.add('src/utils.ts', 'format', 'fn:format', 'Function');
|
|
|
|
expect(model.symbols.lookupCallableByName('format')).toHaveLength(1);
|
|
expect(methodsSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('registering a Function-with-ownerId routes to methods.register via pre-dispatch normalization AND appears in callableByName', () => {
|
|
const model = createSemanticModel();
|
|
model.symbols.add('src/user.py', 'User', 'class:User', 'Class');
|
|
model.symbols.add('src/user.py', 'save', 'fn:User.save', 'Function', {
|
|
ownerId: 'class:User',
|
|
});
|
|
|
|
// Owner-scoped method lookup resolves it (Python-style class method).
|
|
expect(model.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('fn:User.save');
|
|
// Function is in FREE_CALLABLE_TYPES, so it also appears in callableByName.
|
|
expect(model.symbols.lookupCallableByName('save')).toHaveLength(1);
|
|
});
|
|
|
|
it('registering an Impl populates lookupImplByName but NOT lookupClassByName', () => {
|
|
const model = createSemanticModel();
|
|
model.symbols.add('src/user.rs', 'User', 'impl:User', 'Impl');
|
|
// Impl is kept separate from class-like so heritage resolution
|
|
// does not treat it as a parent type candidate.
|
|
expect(model.types.lookupImplByName('User')).toHaveLength(1);
|
|
expect(model.types.lookupClassByName('User')).toHaveLength(0);
|
|
});
|
|
|
|
it('registering an inert NodeLabel only populates the file index', () => {
|
|
const model = createSemanticModel();
|
|
const classSpy = vi.spyOn(model.types, 'registerClass');
|
|
const implSpy = vi.spyOn(model.types, 'registerImpl');
|
|
const methodsSpy = vi.spyOn(model.methods, 'register');
|
|
const fieldsSpy = vi.spyOn(model.fields, 'register');
|
|
|
|
// `Variable` is in INERT_LABELS — no specialized registry, no
|
|
// callable index (it's not in FREE_CALLABLE_TYPES).
|
|
model.symbols.add('src/main.ts', 'CONFIG', 'var:CONFIG', 'Variable');
|
|
|
|
expect(model.symbols.lookupExact('src/main.ts', 'CONFIG')).toBe('var:CONFIG');
|
|
expect(classSpy).not.toHaveBeenCalled();
|
|
expect(implSpy).not.toHaveBeenCalled();
|
|
expect(methodsSpy).not.toHaveBeenCalled();
|
|
expect(fieldsSpy).not.toHaveBeenCalled();
|
|
expect(model.symbols.lookupCallableByName('CONFIG')).toHaveLength(0);
|
|
});
|
|
|
|
it('Method-without-ownerId skips methods.register and falls back to the callable index', () => {
|
|
const model = createSemanticModel();
|
|
const methodsSpy = vi.spyOn(model.methods, 'register');
|
|
|
|
model.symbols.add('src/orphan.ts', 'orphan', 'mtd:orphan', 'Method');
|
|
|
|
// File index still populated.
|
|
expect(model.symbols.lookupExact('src/orphan.ts', 'orphan')).toBe('mtd:orphan');
|
|
// Method registry NOT populated (no ownerId to key under) — the
|
|
// dispatch hook silently skips.
|
|
expect(methodsSpy).not.toHaveBeenCalled();
|
|
expect(model.methods.lookupMethodByName('orphan')).toHaveLength(0);
|
|
// Callable-index fallback: an orphaned Method/Constructor is an
|
|
// extractor contract violation (AST-degraded parse), but we keep
|
|
// it reachable at Tier 3 global resolution by routing it through
|
|
// `callableByName`. Matches pre-dispatch-table behavior.
|
|
expect(model.symbols.lookupCallableByName('orphan')).toHaveLength(1);
|
|
expect(model.symbols.lookupCallableByName('orphan')[0].type).toBe('Method');
|
|
});
|
|
|
|
it('exhaustiveness guard does not fire for the current NodeLabel taxonomy', () => {
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
// Fresh SymbolTable — triggers the guard at construction.
|
|
createSemanticModel();
|
|
// No warnings about missing NodeLabels — every label is accounted
|
|
// for in one of the three allowlists.
|
|
const mismatchWarnings = warnSpy.mock.calls.filter((args) =>
|
|
String(args[0]).startsWith('[SymbolTable] NodeLabel '),
|
|
);
|
|
expect(mismatchWarnings).toHaveLength(0);
|
|
warnSpy.mockRestore();
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// lookupMethodByOwnerWithMRO — MRO-aware method resolution via HeritageMap
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import { buildHeritageMap } from '../../src/core/ingestion/model/heritage-map.js';
|
|
import { lookupMethodByOwnerWithMRO } from '../../src/core/ingestion/model/index.js';
|
|
import {
|
|
createResolutionContext,
|
|
type ResolutionContext,
|
|
} from '../../src/core/ingestion/model/resolution-context.js';
|
|
import type { ExtractedHeritage } from '../../src/core/ingestion/model/heritage-map.js';
|
|
|
|
describe('lookupMethodByOwnerWithMRO', () => {
|
|
let ctx: ResolutionContext;
|
|
|
|
beforeEach(() => {
|
|
ctx = createResolutionContext();
|
|
});
|
|
|
|
it('child.parentMethod() resolves to Parent#parentMethod via MRO walk', () => {
|
|
ctx.model.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class');
|
|
ctx.model.symbols.add('src/child.java', 'Child', 'class:Child', 'Class');
|
|
ctx.model.symbols.add(
|
|
'src/parent.java',
|
|
'parentMethod',
|
|
'method:Parent:parentMethod',
|
|
'Method',
|
|
{
|
|
returnType: 'String',
|
|
ownerId: 'class:Parent',
|
|
},
|
|
);
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{ filePath: 'src/child.java', className: 'Child', parentName: 'Parent', kind: 'extends' },
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = lookupMethodByOwnerWithMRO(
|
|
'class:Child',
|
|
'parentMethod',
|
|
map,
|
|
ctx.model,
|
|
'implements-split',
|
|
);
|
|
expect(result).toBeDefined();
|
|
expect(result!.nodeId).toBe('method:Parent:parentMethod');
|
|
expect(result!.returnType).toBe('String');
|
|
});
|
|
|
|
it('child override returns child version (direct hit, no walk)', () => {
|
|
ctx.model.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class');
|
|
ctx.model.symbols.add('src/child.java', 'Child', 'class:Child', 'Class');
|
|
ctx.model.symbols.add('src/parent.java', 'save', 'method:Parent:save', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:Parent',
|
|
});
|
|
ctx.model.symbols.add('src/child.java', 'save', 'method:Child:save', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:Child',
|
|
});
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{ filePath: 'src/child.java', className: 'Child', parentName: 'Parent', kind: 'extends' },
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = lookupMethodByOwnerWithMRO(
|
|
'class:Child',
|
|
'save',
|
|
map,
|
|
ctx.model,
|
|
'implements-split',
|
|
);
|
|
expect(result).toBeDefined();
|
|
expect(result!.nodeId).toBe('method:Child:save');
|
|
});
|
|
|
|
it('3-level inheritance: grandchild → child → parent, method on parent found', () => {
|
|
ctx.model.symbols.add('src/a.java', 'A', 'class:A', 'Class');
|
|
ctx.model.symbols.add('src/b.java', 'B', 'class:B', 'Class');
|
|
ctx.model.symbols.add('src/c.java', 'C', 'class:C', 'Class');
|
|
ctx.model.symbols.add('src/a.java', 'greet', 'method:A:greet', 'Method', {
|
|
returnType: 'Greeting',
|
|
ownerId: 'class:A',
|
|
});
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{ filePath: 'src/c.java', className: 'C', parentName: 'B', kind: 'extends' },
|
|
{ filePath: 'src/b.java', className: 'B', parentName: 'A', kind: 'extends' },
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = lookupMethodByOwnerWithMRO(
|
|
'class:C',
|
|
'greet',
|
|
map,
|
|
ctx.model,
|
|
'implements-split',
|
|
);
|
|
expect(result).toBeDefined();
|
|
expect(result!.nodeId).toBe('method:A:greet');
|
|
expect(result!.returnType).toBe('Greeting');
|
|
});
|
|
|
|
it('diamond pattern: first-wins strategy returns first ancestor match in BFS order', () => {
|
|
ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class');
|
|
ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class');
|
|
ctx.model.symbols.add('src/c.ts', 'C', 'class:C', 'Class');
|
|
ctx.model.symbols.add('src/d.ts', 'D', 'class:D', 'Class');
|
|
ctx.model.symbols.add('src/b.ts', 'foo', 'method:B:foo', 'Method', {
|
|
returnType: 'String',
|
|
ownerId: 'class:B',
|
|
});
|
|
ctx.model.symbols.add('src/c.ts', 'foo', 'method:C:foo', 'Method', {
|
|
returnType: 'String',
|
|
ownerId: 'class:C',
|
|
});
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{ filePath: 'src/d.ts', className: 'D', parentName: 'B', kind: 'extends' },
|
|
{ filePath: 'src/d.ts', className: 'D', parentName: 'C', kind: 'extends' },
|
|
{ filePath: 'src/b.ts', className: 'B', parentName: 'A', kind: 'extends' },
|
|
{ filePath: 'src/c.ts', className: 'C', parentName: 'A', kind: 'extends' },
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
// TypeScript uses 'first-wins' — B is first parent, so B.foo wins
|
|
const result = lookupMethodByOwnerWithMRO('class:D', 'foo', map, ctx.model, 'first-wins');
|
|
expect(result).toBeDefined();
|
|
expect(result!.nodeId).toBe('method:B:foo');
|
|
});
|
|
|
|
it('diamond pattern: c3 strategy uses C3 linearization order', () => {
|
|
ctx.model.symbols.add('src/a.py', 'A', 'class:A', 'Class');
|
|
ctx.model.symbols.add('src/b.py', 'B', 'class:B', 'Class');
|
|
ctx.model.symbols.add('src/c.py', 'C', 'class:C', 'Class');
|
|
ctx.model.symbols.add('src/d.py', 'D', 'class:D', 'Class');
|
|
ctx.model.symbols.add('src/b.py', 'foo', 'method:B:foo', 'Method', {
|
|
returnType: 'str',
|
|
ownerId: 'class:B',
|
|
});
|
|
ctx.model.symbols.add('src/c.py', 'foo', 'method:C:foo', 'Method', {
|
|
returnType: 'str',
|
|
ownerId: 'class:C',
|
|
});
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{ filePath: 'src/d.py', className: 'D', parentName: 'B', kind: 'extends' },
|
|
{ filePath: 'src/d.py', className: 'D', parentName: 'C', kind: 'extends' },
|
|
{ filePath: 'src/b.py', className: 'B', parentName: 'A', kind: 'extends' },
|
|
{ filePath: 'src/c.py', className: 'C', parentName: 'A', kind: 'extends' },
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
// Python uses 'c3' — C3 linearization for D(B,C): [B, C, A]
|
|
const result = lookupMethodByOwnerWithMRO('class:D', 'foo', map, ctx.model, 'c3');
|
|
expect(result).toBeDefined();
|
|
// C3 linearization resolves to B before C in this hierarchy
|
|
expect(result!.nodeId).toBe('method:B:foo');
|
|
});
|
|
|
|
it('c3 (Python): cyclic hierarchy falls back to BFS ancestor order', () => {
|
|
// Build a legitimately cyclic heritage: A extends B, B extends A.
|
|
// c3Linearize returns null for this case (inconsistent linearization).
|
|
// The MRO walker must then fall back to heritageMap.getAncestors()
|
|
// (BFS order) instead of silently returning undefined.
|
|
ctx.model.symbols.add('src/a.py', 'A', 'class:A', 'Class');
|
|
ctx.model.symbols.add('src/b.py', 'B', 'class:B', 'Class');
|
|
ctx.model.symbols.add('src/b.py', 'foo', 'method:B:foo', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:B',
|
|
});
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{ filePath: 'src/a.py', className: 'A', parentName: 'B', kind: 'extends' },
|
|
{ filePath: 'src/b.py', className: 'B', parentName: 'A', kind: 'extends' },
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
// Even with a cyclic hierarchy, BFS via heritageMap.getAncestors()
|
|
// walks A → B and finds `foo` on B. The method lookup must succeed.
|
|
const result = lookupMethodByOwnerWithMRO('class:A', 'foo', map, ctx.model, 'c3');
|
|
expect(result).toBeDefined();
|
|
expect(result!.nodeId).toBe('method:B:foo');
|
|
});
|
|
|
|
it('qualified-syntax (Rust): returns undefined for inherited methods', () => {
|
|
ctx.model.symbols.add('src/parent.rs', 'Parent', 'class:Parent', 'Class');
|
|
ctx.model.symbols.add('src/child.rs', 'Child', 'class:Child', 'Class');
|
|
ctx.model.symbols.add('src/parent.rs', 'process', 'method:Parent:process', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:Parent',
|
|
});
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{ filePath: 'src/child.rs', className: 'Child', parentName: 'Parent', kind: 'extends' },
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = lookupMethodByOwnerWithMRO(
|
|
'class:Child',
|
|
'process',
|
|
map,
|
|
ctx.model,
|
|
'qualified-syntax',
|
|
);
|
|
// Rust requires qualified syntax — no auto-resolution
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
it('method not on any ancestor returns undefined', () => {
|
|
ctx.model.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class');
|
|
ctx.model.symbols.add('src/child.java', 'Child', 'class:Child', 'Class');
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{ filePath: 'src/child.java', className: 'Child', parentName: 'Parent', kind: 'extends' },
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = lookupMethodByOwnerWithMRO(
|
|
'class:Child',
|
|
'nonExistent',
|
|
map,
|
|
ctx.model,
|
|
'implements-split',
|
|
);
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
it('leftmost-base (C++): walks ancestors in BFS order', () => {
|
|
ctx.model.symbols.add('src/a.cpp', 'A', 'class:A', 'Class');
|
|
ctx.model.symbols.add('src/b.cpp', 'B', 'class:B', 'Class');
|
|
ctx.model.symbols.add('src/c.cpp', 'C', 'class:C', 'Class');
|
|
ctx.model.symbols.add('src/a.cpp', 'render', 'method:A:render', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:A',
|
|
});
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{ filePath: 'src/c.cpp', className: 'C', parentName: 'B', kind: 'extends' },
|
|
{ filePath: 'src/b.cpp', className: 'B', parentName: 'A', kind: 'extends' },
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = lookupMethodByOwnerWithMRO('class:C', 'render', map, ctx.model, 'leftmost-base');
|
|
expect(result).toBeDefined();
|
|
expect(result!.nodeId).toBe('method:A:render');
|
|
});
|
|
|
|
it('implements-split (Java): walks ancestors to find inherited method', () => {
|
|
ctx.model.symbols.add('src/base.java', 'Base', 'class:Base', 'Class');
|
|
ctx.model.symbols.add('src/iface.java', 'IRepo', 'iface:IRepo', 'Interface');
|
|
ctx.model.symbols.add('src/child.java', 'Child', 'class:Child', 'Class');
|
|
ctx.model.symbols.add('src/base.java', 'save', 'method:Base:save', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:Base',
|
|
});
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{ filePath: 'src/child.java', className: 'Child', parentName: 'Base', kind: 'extends' },
|
|
{
|
|
filePath: 'src/child.java',
|
|
className: 'Child',
|
|
parentName: 'IRepo',
|
|
kind: 'implements',
|
|
},
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = lookupMethodByOwnerWithMRO(
|
|
'class:Child',
|
|
'save',
|
|
map,
|
|
ctx.model,
|
|
'implements-split',
|
|
);
|
|
expect(result).toBeDefined();
|
|
expect(result!.nodeId).toBe('method:Base:save');
|
|
});
|
|
|
|
it('implements-split (Java): ambiguous default from two interfaces → BFS first-wins', () => {
|
|
// Java: class C implements I1, I2; both I1 and I2 declare the same
|
|
// default method. Full ambiguity detection (Java's "class must override
|
|
// conflicting defaults" rule) is deferred to computeMRO at the graph
|
|
// level. lookupMethodByOwnerWithMRO itself uses BFS order and returns
|
|
// the first match — this test pins that contract so a future regression
|
|
// that starts returning undefined (or flips the order) fails loudly.
|
|
ctx.model.symbols.add('src/I1.java', 'I1', 'iface:I1', 'Interface');
|
|
ctx.model.symbols.add('src/I2.java', 'I2', 'iface:I2', 'Interface');
|
|
ctx.model.symbols.add('src/C.java', 'C', 'class:C', 'Class');
|
|
ctx.model.symbols.add('src/I1.java', 'handle', 'method:I1:handle', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'iface:I1',
|
|
});
|
|
ctx.model.symbols.add('src/I2.java', 'handle', 'method:I2:handle', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'iface:I2',
|
|
});
|
|
|
|
// Insertion order is I1 then I2, so BFS returns I1 first.
|
|
const heritage: ExtractedHeritage[] = [
|
|
{ filePath: 'src/C.java', className: 'C', parentName: 'I1', kind: 'implements' },
|
|
{ filePath: 'src/C.java', className: 'C', parentName: 'I2', kind: 'implements' },
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = lookupMethodByOwnerWithMRO(
|
|
'class:C',
|
|
'handle',
|
|
map,
|
|
ctx.model,
|
|
'implements-split',
|
|
);
|
|
expect(result).toBeDefined();
|
|
// BFS first-wins — I1 was declared first, so it wins.
|
|
expect(result!.nodeId).toBe('method:I1:handle');
|
|
});
|
|
|
|
it('implements-split (Java): class method takes precedence over interface default in BFS order', () => {
|
|
// Child extends Base implements IFoo. Both Base (class) and IFoo
|
|
// (interface) declare the same method. HeritageMap records extends
|
|
// before implements in the emitter's declaration order, so BFS visits
|
|
// Base before IFoo — class wins. Documents the current BFS-level
|
|
// behavior; the strict Java "class always wins" rule is enforced at
|
|
// the mro-processor graph pass.
|
|
ctx.model.symbols.add('src/Base.java', 'Base', 'class:Base', 'Class');
|
|
ctx.model.symbols.add('src/IFoo.java', 'IFoo', 'iface:IFoo', 'Interface');
|
|
ctx.model.symbols.add('src/Child.java', 'Child', 'class:Child', 'Class');
|
|
ctx.model.symbols.add('src/Base.java', 'handle', 'method:Base:handle', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:Base',
|
|
});
|
|
ctx.model.symbols.add('src/IFoo.java', 'handle', 'method:IFoo:handle', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'iface:IFoo',
|
|
});
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{ filePath: 'src/Child.java', className: 'Child', parentName: 'Base', kind: 'extends' },
|
|
{ filePath: 'src/Child.java', className: 'Child', parentName: 'IFoo', kind: 'implements' },
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = lookupMethodByOwnerWithMRO(
|
|
'class:Child',
|
|
'handle',
|
|
map,
|
|
ctx.model,
|
|
'implements-split',
|
|
);
|
|
expect(result).toBeDefined();
|
|
expect(result!.nodeId).toBe('method:Base:handle');
|
|
});
|
|
|
|
it('implements-split (Kotlin): walks ancestors to find inherited method', () => {
|
|
ctx.model.symbols.add('src/base.kt', 'Base', 'class:Base', 'Class');
|
|
ctx.model.symbols.add('src/child.kt', 'Child', 'class:Child', 'Class');
|
|
ctx.model.symbols.add('src/base.kt', 'handle', 'method:Base:handle', 'Method', {
|
|
returnType: 'Unit',
|
|
ownerId: 'class:Base',
|
|
});
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{ filePath: 'src/child.kt', className: 'Child', parentName: 'Base', kind: 'extends' },
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = lookupMethodByOwnerWithMRO(
|
|
'class:Child',
|
|
'handle',
|
|
map,
|
|
ctx.model,
|
|
'implements-split',
|
|
);
|
|
expect(result).toBeDefined();
|
|
expect(result!.nodeId).toBe('method:Base:handle');
|
|
});
|
|
|
|
it('implements-split (C#): walks ancestors to find inherited method', () => {
|
|
ctx.model.symbols.add('src/Base.cs', 'Base', 'class:Base', 'Class');
|
|
ctx.model.symbols.add('src/Child.cs', 'Child', 'class:Child', 'Class');
|
|
ctx.model.symbols.add('src/Base.cs', 'Execute', 'method:Base:Execute', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:Base',
|
|
});
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{ filePath: 'src/Child.cs', className: 'Child', parentName: 'Base', kind: 'extends' },
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = lookupMethodByOwnerWithMRO(
|
|
'class:Child',
|
|
'Execute',
|
|
map,
|
|
ctx.model,
|
|
'implements-split',
|
|
);
|
|
expect(result).toBeDefined();
|
|
expect(result!.nodeId).toBe('method:Base:Execute');
|
|
});
|
|
|
|
it('first-wins (JavaScript): walks ancestors to find inherited method', () => {
|
|
// JavaScript provider is wired separately from TypeScript — this guards
|
|
// the provider wiring independent of the TS path.
|
|
ctx.model.symbols.add('src/animal.js', 'Animal', 'class:Animal', 'Class');
|
|
ctx.model.symbols.add('src/dog.js', 'Dog', 'class:Dog', 'Class');
|
|
ctx.model.symbols.add('src/animal.js', 'speak', 'method:Animal:speak', 'Method', {
|
|
returnType: 'string',
|
|
ownerId: 'class:Animal',
|
|
});
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{ filePath: 'src/dog.js', className: 'Dog', parentName: 'Animal', kind: 'extends' },
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = lookupMethodByOwnerWithMRO('class:Dog', 'speak', map, ctx.model, 'first-wins');
|
|
expect(result).toBeDefined();
|
|
expect(result!.nodeId).toBe('method:Animal:speak');
|
|
});
|
|
|
|
it('leftmost-base (C++): diamond inheritance resolves leftmost branch first', () => {
|
|
// Diamond: D extends B, C; B extends A; C extends A.
|
|
// Both B and C define render(). leftmost-base must return B#render (first
|
|
// branch in declaration order), not A#render or C#render.
|
|
ctx.model.symbols.add('src/a.cpp', 'A', 'class:A', 'Class');
|
|
ctx.model.symbols.add('src/b.cpp', 'B', 'class:B', 'Class');
|
|
ctx.model.symbols.add('src/c.cpp', 'C', 'class:C', 'Class');
|
|
ctx.model.symbols.add('src/d.cpp', 'D', 'class:D', 'Class');
|
|
ctx.model.symbols.add('src/a.cpp', 'render', 'method:A:render', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:A',
|
|
});
|
|
ctx.model.symbols.add('src/b.cpp', 'render', 'method:B:render', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:B',
|
|
});
|
|
ctx.model.symbols.add('src/c.cpp', 'render', 'method:C:render', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:C',
|
|
});
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
// Declaration order matters: B before C for leftmost-base semantics.
|
|
{ filePath: 'src/d.cpp', className: 'D', parentName: 'B', kind: 'extends' },
|
|
{ filePath: 'src/d.cpp', className: 'D', parentName: 'C', kind: 'extends' },
|
|
{ filePath: 'src/b.cpp', className: 'B', parentName: 'A', kind: 'extends' },
|
|
{ filePath: 'src/c.cpp', className: 'C', parentName: 'A', kind: 'extends' },
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = lookupMethodByOwnerWithMRO('class:D', 'render', map, ctx.model, 'leftmost-base');
|
|
expect(result).toBeDefined();
|
|
// BFS via HeritageMap visits B before C (insertion order), so leftmost
|
|
// branch wins — matches C++ leftmost-base semantics for non-virtual base.
|
|
expect(result!.nodeId).toBe('method:B:render');
|
|
});
|
|
|
|
it('returns direct method on owner without walking (no heritage needed)', () => {
|
|
ctx.model.symbols.add('src/user.java', 'User', 'class:User', 'Class');
|
|
ctx.model.symbols.add('src/user.java', 'getName', 'method:User:getName', 'Method', {
|
|
returnType: 'String',
|
|
ownerId: 'class:User',
|
|
});
|
|
|
|
const map = buildHeritageMap([], ctx);
|
|
|
|
const result = lookupMethodByOwnerWithMRO(
|
|
'class:User',
|
|
'getName',
|
|
map,
|
|
ctx.model,
|
|
'implements-split',
|
|
);
|
|
expect(result).toBeDefined();
|
|
expect(result!.nodeId).toBe('method:User:getName');
|
|
});
|
|
|
|
// ── ruby-mixin: kind-aware MRO walk (prepend > self > include) ────
|
|
//
|
|
// Ruby's `'ruby-mixin'` strategy is the only one that does NOT short-circuit
|
|
// on direct-owner lookup first — prepend providers must beat the class's
|
|
// own method of the same name. These tests exercise the walk order directly
|
|
// through lookupMethodByOwnerWithMRO rather than through the full pipeline.
|
|
|
|
it("ruby-mixin: prepend provider beats class's own method (shadow)", () => {
|
|
ctx.model.symbols.add('lib/account.rb', 'Account', 'class:Account', 'Class');
|
|
ctx.model.symbols.add('lib/prep.rb', 'PrependedOverride', 'trait:PrependedOverride', 'Trait');
|
|
ctx.model.symbols.add('lib/account.rb', 'serialize', 'method:Account:serialize', 'Method', {
|
|
returnType: 'String',
|
|
ownerId: 'class:Account',
|
|
});
|
|
ctx.model.symbols.add(
|
|
'lib/prep.rb',
|
|
'serialize',
|
|
'method:PrependedOverride:serialize',
|
|
'Method',
|
|
{ returnType: 'String', ownerId: 'trait:PrependedOverride' },
|
|
);
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{
|
|
filePath: 'lib/account.rb',
|
|
className: 'Account',
|
|
parentName: 'PrependedOverride',
|
|
kind: 'prepend',
|
|
},
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = lookupMethodByOwnerWithMRO(
|
|
'class:Account',
|
|
'serialize',
|
|
map,
|
|
ctx.model,
|
|
'ruby-mixin',
|
|
);
|
|
expect(result).toBeDefined();
|
|
expect(result!.nodeId).toBe('method:PrependedOverride:serialize');
|
|
});
|
|
|
|
it("ruby-mixin: class's own method wins over include provider (shadow)", () => {
|
|
ctx.model.symbols.add('lib/account.rb', 'Account', 'class:Account', 'Class');
|
|
ctx.model.symbols.add('lib/mixin.rb', 'Greetable', 'trait:Greetable', 'Trait');
|
|
ctx.model.symbols.add('lib/account.rb', 'greet', 'method:Account:greet', 'Method', {
|
|
returnType: 'String',
|
|
ownerId: 'class:Account',
|
|
});
|
|
ctx.model.symbols.add('lib/mixin.rb', 'greet', 'method:Greetable:greet', 'Method', {
|
|
returnType: 'String',
|
|
ownerId: 'trait:Greetable',
|
|
});
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{
|
|
filePath: 'lib/account.rb',
|
|
className: 'Account',
|
|
parentName: 'Greetable',
|
|
kind: 'include',
|
|
},
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = lookupMethodByOwnerWithMRO(
|
|
'class:Account',
|
|
'greet',
|
|
map,
|
|
ctx.model,
|
|
'ruby-mixin',
|
|
);
|
|
expect(result).toBeDefined();
|
|
expect(result!.nodeId).toBe('method:Account:greet');
|
|
});
|
|
|
|
it('ruby-mixin: include provider used when class lacks the method', () => {
|
|
ctx.model.symbols.add('lib/account.rb', 'Account', 'class:Account', 'Class');
|
|
ctx.model.symbols.add('lib/mixin.rb', 'Greetable', 'trait:Greetable', 'Trait');
|
|
ctx.model.symbols.add('lib/mixin.rb', 'greet', 'method:Greetable:greet', 'Method', {
|
|
returnType: 'String',
|
|
ownerId: 'trait:Greetable',
|
|
});
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{
|
|
filePath: 'lib/account.rb',
|
|
className: 'Account',
|
|
parentName: 'Greetable',
|
|
kind: 'include',
|
|
},
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = lookupMethodByOwnerWithMRO(
|
|
'class:Account',
|
|
'greet',
|
|
map,
|
|
ctx.model,
|
|
'ruby-mixin',
|
|
);
|
|
expect(result).toBeDefined();
|
|
expect(result!.nodeId).toBe('method:Greetable:greet');
|
|
});
|
|
|
|
it('ruby-mixin: extend providers excluded from instance-dispatch walk', () => {
|
|
ctx.model.symbols.add('lib/account.rb', 'Account', 'class:Account', 'Class');
|
|
ctx.model.symbols.add('lib/logger.rb', 'LoggerMixin', 'trait:LoggerMixin', 'Trait');
|
|
ctx.model.symbols.add('lib/logger.rb', 'log', 'method:LoggerMixin:log', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'trait:LoggerMixin',
|
|
});
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{
|
|
filePath: 'lib/account.rb',
|
|
className: 'Account',
|
|
parentName: 'LoggerMixin',
|
|
kind: 'extend',
|
|
},
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
// Instance dispatch: `extend` providers MUST NOT appear in the walk.
|
|
// Result is undefined — Account has no instance `log`.
|
|
const result = lookupMethodByOwnerWithMRO('class:Account', 'log', map, ctx.model, 'ruby-mixin');
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
it('ruby-mixin: singleton ancestryOverride routes to extend provider', () => {
|
|
ctx.model.symbols.add('lib/account.rb', 'Account', 'class:Account', 'Class');
|
|
ctx.model.symbols.add('lib/logger.rb', 'LoggerMixin', 'trait:LoggerMixin', 'Trait');
|
|
ctx.model.symbols.add('lib/logger.rb', 'log', 'method:LoggerMixin:log', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'trait:LoggerMixin',
|
|
});
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{
|
|
filePath: 'lib/account.rb',
|
|
className: 'Account',
|
|
parentName: 'LoggerMixin',
|
|
kind: 'extend',
|
|
},
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
// Singleton dispatch: caller pre-computes the singleton ancestry and
|
|
// passes it as ancestryOverride. The walker scans it linearly without
|
|
// the prepend/direct/include partition.
|
|
const singletonAncestry = map.getSingletonAncestry('class:Account').map((e) => e.parentId);
|
|
const result = lookupMethodByOwnerWithMRO(
|
|
'class:Account',
|
|
'log',
|
|
map,
|
|
ctx.model,
|
|
'ruby-mixin',
|
|
undefined,
|
|
singletonAncestry,
|
|
);
|
|
expect(result).toBeDefined();
|
|
expect(result!.nodeId).toBe('method:LoggerMixin:log');
|
|
});
|
|
|
|
it('ruby-mixin: transitive mixin — module provides method via an included module', () => {
|
|
// class Account; include Outer; end
|
|
// module Outer; include Inner; end
|
|
// module Inner; def helper; end; end
|
|
ctx.model.symbols.add('lib/account.rb', 'Account', 'class:Account', 'Class');
|
|
ctx.model.symbols.add('lib/outer.rb', 'Outer', 'trait:Outer', 'Trait');
|
|
ctx.model.symbols.add('lib/inner.rb', 'Inner', 'trait:Inner', 'Trait');
|
|
ctx.model.symbols.add('lib/inner.rb', 'helper', 'method:Inner:helper', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'trait:Inner',
|
|
});
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{
|
|
filePath: 'lib/account.rb',
|
|
className: 'Account',
|
|
parentName: 'Outer',
|
|
kind: 'include',
|
|
},
|
|
{ filePath: 'lib/outer.rb', className: 'Outer', parentName: 'Inner', kind: 'include' },
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = lookupMethodByOwnerWithMRO(
|
|
'class:Account',
|
|
'helper',
|
|
map,
|
|
ctx.model,
|
|
'ruby-mixin',
|
|
);
|
|
expect(result).toBeDefined();
|
|
expect(result!.nodeId).toBe('method:Inner:helper');
|
|
});
|
|
|
|
it('ruby-mixin: stacked prepends — last-prepended wins', () => {
|
|
// class A; prepend P1; prepend P2; end
|
|
// Ruby MRO places P2 ahead of P1; last-prepended is closest to self.
|
|
ctx.model.symbols.add('lib/a.rb', 'A', 'class:A', 'Class');
|
|
ctx.model.symbols.add('lib/p1.rb', 'P1', 'trait:P1', 'Trait');
|
|
ctx.model.symbols.add('lib/p2.rb', 'P2', 'trait:P2', 'Trait');
|
|
ctx.model.symbols.add('lib/p1.rb', 'foo', 'method:P1:foo', 'Method', {
|
|
returnType: 'String',
|
|
ownerId: 'trait:P1',
|
|
});
|
|
ctx.model.symbols.add('lib/p2.rb', 'foo', 'method:P2:foo', 'Method', {
|
|
returnType: 'String',
|
|
ownerId: 'trait:P2',
|
|
});
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{ filePath: 'lib/a.rb', className: 'A', parentName: 'P1', kind: 'prepend' },
|
|
{ filePath: 'lib/a.rb', className: 'A', parentName: 'P2', kind: 'prepend' },
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = lookupMethodByOwnerWithMRO('class:A', 'foo', map, ctx.model, 'ruby-mixin');
|
|
expect(result).toBeDefined();
|
|
expect(result!.nodeId).toBe('method:P2:foo');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// resolveMemberCall — SM-11: owner-scoped + MRO member-call resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import {
|
|
_resolveCallTargetForTesting,
|
|
resolveMemberCall,
|
|
resolveFreeCall,
|
|
type OverloadHints,
|
|
} from '../../src/core/ingestion/call-processor.js';
|
|
|
|
describe('resolveMemberCall', () => {
|
|
let ctx: ResolutionContext;
|
|
|
|
beforeEach(() => {
|
|
ctx = createResolutionContext();
|
|
});
|
|
|
|
it('resolves direct method on owner type', () => {
|
|
ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
|
ctx.model.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:User',
|
|
});
|
|
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
|
|
|
const result = resolveMemberCall('User', 'save', 'src/app.ts', ctx);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('method:User:save');
|
|
expect(result!.returnType).toBe('void');
|
|
expect(result!.confidence).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('resolves inherited method via MRO walk', () => {
|
|
ctx.model.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class');
|
|
ctx.model.symbols.add('src/child.java', 'Child', 'class:Child', 'Class');
|
|
ctx.model.symbols.add('src/parent.java', 'validate', 'method:Parent:validate', 'Method', {
|
|
returnType: 'boolean',
|
|
ownerId: 'class:Parent',
|
|
});
|
|
ctx.importMap.set('src/app.java', new Set(['src/child.java', 'src/parent.java']));
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{ filePath: 'src/child.java', className: 'Child', parentName: 'Parent', kind: 'extends' },
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = resolveMemberCall('Child', 'validate', 'src/app.java', ctx, map);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('method:Parent:validate');
|
|
expect(result!.returnType).toBe('boolean');
|
|
});
|
|
|
|
it('returns null for unknown owner type', () => {
|
|
const result = resolveMemberCall('NonExistent', 'save', 'src/app.ts', ctx);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('returns null for unknown method on known owner', () => {
|
|
ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
|
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
|
|
|
const result = resolveMemberCall('User', 'nonExistentMethod', 'src/app.ts', ctx);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('returns result with correct confidence tier for same-file resolution', () => {
|
|
ctx.model.symbols.add('src/app.ts', 'User', 'class:User', 'Class');
|
|
ctx.model.symbols.add('src/app.ts', 'save', 'method:User:save', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:User',
|
|
});
|
|
|
|
const result = resolveMemberCall('User', 'save', 'src/app.ts', ctx);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.confidence).toBe(0.95); // same-file tier
|
|
expect(result!.reason).toBe('same-file');
|
|
});
|
|
|
|
it('returns result with import-scoped tier for cross-file resolution', () => {
|
|
ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
|
ctx.model.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:User',
|
|
});
|
|
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
|
|
|
const result = resolveMemberCall('User', 'save', 'src/app.ts', ctx);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.confidence).toBe(0.9); // import-scoped tier
|
|
expect(result!.reason).toBe('import-resolved');
|
|
});
|
|
|
|
it('resolves with heritage map across C3 MRO chain (Python)', () => {
|
|
ctx.model.symbols.add('src/a.py', 'A', 'class:A', 'Class');
|
|
ctx.model.symbols.add('src/b.py', 'B', 'class:B', 'Class');
|
|
ctx.model.symbols.add('src/c.py', 'C', 'class:C', 'Class');
|
|
ctx.model.symbols.add('src/a.py', 'foo', 'method:A:foo', 'Method', {
|
|
returnType: 'str',
|
|
ownerId: 'class:A',
|
|
});
|
|
ctx.importMap.set('src/main.py', new Set(['src/a.py', 'src/b.py', 'src/c.py']));
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{ filePath: 'src/c.py', className: 'C', parentName: 'B', kind: 'extends' },
|
|
{ filePath: 'src/b.py', className: 'B', parentName: 'A', kind: 'extends' },
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = resolveMemberCall('C', 'foo', 'src/main.py', ctx, map);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('method:A:foo');
|
|
expect(result!.returnType).toBe('str');
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Locks in the B2 semantic change: tier reflects how the OWNER TYPE was
|
|
// resolved, not how the method name was resolved globally.
|
|
// -------------------------------------------------------------------------
|
|
it('uses owner-type tier: cross-file class resolution → import-scoped confidence', () => {
|
|
// Scenario: owner class 'User' is defined in user.ts (imported from app.ts).
|
|
// The method 'save' exists ONLY on User (no homonyms). Old behaviour would
|
|
// have used the tier of resolving "save" globally; new behaviour uses the
|
|
// tier of resolving "User". Both happen to yield import-scoped here —
|
|
// the test locks that the reported tier tracks the class lookup.
|
|
ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
|
ctx.model.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:User',
|
|
});
|
|
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
|
|
|
const result = resolveMemberCall('User', 'save', 'src/app.ts', ctx);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.confidence).toBe(0.9); // import-scoped
|
|
expect(result!.reason).toBe('import-resolved');
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// T2: Rust qualified-syntax — trait-inherited methods must return null
|
|
// because they require `TraitName::method(obj)` call syntax, not `obj.method()`.
|
|
// Only struct's OWN impl methods are reachable via direct member calls.
|
|
// -------------------------------------------------------------------------
|
|
it('Rust: returns null for trait-inherited method (qualified-syntax MRO)', () => {
|
|
// Trait Writer defines `save`. Struct User has an impl_item but NO save
|
|
// method of its own — save is only available via trait.
|
|
ctx.model.symbols.add('src/writer.rs', 'Writer', 'trait:Writer', 'Trait');
|
|
ctx.model.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct');
|
|
ctx.model.symbols.add('src/writer.rs', 'save', 'method:Writer:save', 'Method', {
|
|
returnType: 'bool',
|
|
ownerId: 'trait:Writer',
|
|
});
|
|
ctx.importMap.set('src/app.rs', new Set(['src/writer.rs', 'src/user.rs']));
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
// User implements Writer — in Rust this is `impl Writer for User`.
|
|
{ filePath: 'src/user.rs', className: 'User', parentName: 'Writer', kind: 'implements' },
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
// Rust's qualified-syntax strategy short-circuits trait inheritance walks,
|
|
// so `user.save()` (direct call) does not resolve.
|
|
const result = resolveMemberCall('User', 'save', 'src/app.rs', ctx, map);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('Rust: direct impl methods still resolve (distinction check for T2)', () => {
|
|
// Positive control: a method defined directly on User (not via trait)
|
|
// resolves normally — demonstrates the null in the previous test is
|
|
// specifically due to the trait-inheritance path, not a broken fixture.
|
|
ctx.model.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct');
|
|
ctx.model.symbols.add('src/user.rs', 'name', 'method:User:name', 'Method', {
|
|
returnType: 'String',
|
|
ownerId: 'struct:User',
|
|
});
|
|
ctx.importMap.set('src/app.rs', new Set(['src/user.rs']));
|
|
|
|
const result = resolveMemberCall('User', 'name', 'src/app.rs', ctx);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('method:User:name');
|
|
expect(result!.returnType).toBe('String');
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// T3: C/C++ leftmost-base diamond inheritance at the resolveMemberCall layer.
|
|
// -------------------------------------------------------------------------
|
|
// -------------------------------------------------------------------------
|
|
// Homonym disambiguation: when two class candidates share a name but only
|
|
// ONE of them owns the method, resolveMemberCall should return that one
|
|
// without falling through to the fuzzy D2 widening path. Absorbs what was
|
|
// previously D4's ownerId-filtering job into the owner-scoped path.
|
|
// -------------------------------------------------------------------------
|
|
it('disambiguates homonym classes: only one owns the method', () => {
|
|
// Two classes both named `User` — one in auth.py (has `save`), one in
|
|
// legacy.py (has `archive` but no `save`). Both are imported from app.py.
|
|
ctx.model.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class');
|
|
ctx.model.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', {
|
|
returnType: 'None',
|
|
ownerId: 'class:auth:User',
|
|
});
|
|
ctx.model.symbols.add('src/legacy.py', 'User', 'class:legacy:User', 'Class');
|
|
ctx.model.symbols.add('src/legacy.py', 'archive', 'method:legacy:User:archive', 'Method', {
|
|
returnType: 'None',
|
|
ownerId: 'class:legacy:User',
|
|
});
|
|
ctx.importMap.set('src/app.py', new Set(['src/auth.py', 'src/legacy.py']));
|
|
|
|
// `user.save()` is unambiguous — only auth.User has `save`.
|
|
const saveResult = resolveMemberCall('User', 'save', 'src/app.py', ctx);
|
|
expect(saveResult).not.toBeNull();
|
|
expect(saveResult!.nodeId).toBe('method:auth:User:save');
|
|
|
|
// `user.archive()` is also unambiguous — only legacy.User has `archive`.
|
|
const archiveResult = resolveMemberCall('User', 'archive', 'src/app.py', ctx);
|
|
expect(archiveResult).not.toBeNull();
|
|
expect(archiveResult!.nodeId).toBe('method:legacy:User:archive');
|
|
});
|
|
|
|
it('returns null when homonym classes BOTH own the method (genuine ambiguity)', () => {
|
|
// Both homonym Users define a `save` method — resolveMemberCall refuses
|
|
// to pick one. The caller (resolveCallTarget) falls through to D1-D4 which
|
|
// may or may not be able to narrow further.
|
|
ctx.model.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class');
|
|
ctx.model.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', {
|
|
returnType: 'None',
|
|
ownerId: 'class:auth:User',
|
|
});
|
|
ctx.model.symbols.add('src/legacy.py', 'User', 'class:legacy:User', 'Class');
|
|
ctx.model.symbols.add('src/legacy.py', 'save', 'method:legacy:User:save', 'Method', {
|
|
returnType: 'None',
|
|
ownerId: 'class:legacy:User',
|
|
});
|
|
ctx.importMap.set('src/app.py', new Set(['src/auth.py', 'src/legacy.py']));
|
|
|
|
const result = resolveMemberCall('User', 'save', 'src/app.py', ctx);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('homonym + shared ancestor: both walk MRO to the same method (dedups to 1)', () => {
|
|
// Two homonym `User` classes in different files, both extending a common
|
|
// `BaseUser` that owns `save`. Direct lookup on either User misses; MRO
|
|
// walks both find BaseUser.save. Dedup by nodeId yields a single result.
|
|
ctx.model.symbols.add('src/base.ts', 'BaseUser', 'class:BaseUser', 'Class');
|
|
ctx.model.symbols.add('src/base.ts', 'save', 'method:BaseUser:save', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:BaseUser',
|
|
});
|
|
ctx.model.symbols.add('src/a.ts', 'User', 'class:a:User', 'Class');
|
|
ctx.model.symbols.add('src/b.ts', 'User', 'class:b:User', 'Class');
|
|
ctx.importMap.set('src/app.ts', new Set(['src/base.ts', 'src/a.ts', 'src/b.ts']));
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{ filePath: 'src/a.ts', className: 'User', parentName: 'BaseUser', kind: 'extends' },
|
|
{ filePath: 'src/b.ts', className: 'User', parentName: 'BaseUser', kind: 'extends' },
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = resolveMemberCall('User', 'save', 'src/app.ts', ctx, map);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('method:BaseUser:save');
|
|
});
|
|
|
|
it('C++: resolves diamond inheritance via leftmost-base MRO', () => {
|
|
// Diamond:
|
|
// Base
|
|
// / \
|
|
// A B
|
|
// \ /
|
|
// Derived
|
|
//
|
|
// Both A and B inherit `method` from Base. Derived extends (A, B).
|
|
// Leftmost-base strategy walks A's chain first → finds Base::method.
|
|
ctx.model.symbols.add('src/base.h', 'Base', 'class:Base', 'Class');
|
|
ctx.model.symbols.add('src/a.h', 'A', 'class:A', 'Class');
|
|
ctx.model.symbols.add('src/b.h', 'B', 'class:B', 'Class');
|
|
ctx.model.symbols.add('src/derived.h', 'Derived', 'class:Derived', 'Class');
|
|
ctx.model.symbols.add('src/base.h', 'method', 'method:Base:method', 'Method', {
|
|
returnType: 'int',
|
|
ownerId: 'class:Base',
|
|
});
|
|
ctx.importMap.set(
|
|
'src/app.cpp',
|
|
new Set(['src/base.h', 'src/a.h', 'src/b.h', 'src/derived.h']),
|
|
);
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{ filePath: 'src/a.h', className: 'A', parentName: 'Base', kind: 'extends' },
|
|
{ filePath: 'src/b.h', className: 'B', parentName: 'Base', kind: 'extends' },
|
|
{ filePath: 'src/derived.h', className: 'Derived', parentName: 'A', kind: 'extends' },
|
|
{ filePath: 'src/derived.h', className: 'Derived', parentName: 'B', kind: 'extends' },
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = resolveMemberCall('Derived', 'method', 'src/app.cpp', ctx, map);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('method:Base:method');
|
|
expect(result!.returnType).toBe('int');
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// L1: C# / Kotlin implements-split strategy through resolveMemberCall.
|
|
// lookupMethodByOwnerWithMRO already has strategy-level coverage for these
|
|
// languages; these tests add the resolveMemberCall layer (tier resolution
|
|
// + class candidate iteration + MRO walk) on top.
|
|
// -------------------------------------------------------------------------
|
|
it('C#: walks implements-split to find inherited method via interface', () => {
|
|
// C# uses implements-split MRO: class base chain walked first, then
|
|
// interfaces. Here IService declares Save which is implemented by the
|
|
// base class BaseService — MyService inherits Save through the class.
|
|
ctx.model.symbols.add('src/iservice.cs', 'IService', 'interface:IService', 'Interface');
|
|
ctx.model.symbols.add('src/base.cs', 'BaseService', 'class:BaseService', 'Class');
|
|
ctx.model.symbols.add('src/my.cs', 'MyService', 'class:MyService', 'Class');
|
|
ctx.model.symbols.add('src/base.cs', 'Save', 'method:BaseService:Save', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:BaseService',
|
|
});
|
|
ctx.importMap.set('src/app.cs', new Set(['src/iservice.cs', 'src/base.cs', 'src/my.cs']));
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{
|
|
filePath: 'src/base.cs',
|
|
className: 'BaseService',
|
|
parentName: 'IService',
|
|
kind: 'implements',
|
|
},
|
|
{ filePath: 'src/my.cs', className: 'MyService', parentName: 'BaseService', kind: 'extends' },
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = resolveMemberCall('MyService', 'Save', 'src/app.cs', ctx, map);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('method:BaseService:Save');
|
|
expect(result!.returnType).toBe('void');
|
|
});
|
|
|
|
it('Kotlin: walks implements-split to find inherited method via interface', () => {
|
|
// Kotlin shares the implements-split MRO strategy with Java/C#. A class
|
|
// inheriting from an interface that provides a default method should
|
|
// resolve `obj.method()` to the interface's implementation.
|
|
ctx.model.symbols.add('src/validator.kt', 'Validator', 'interface:Validator', 'Interface');
|
|
ctx.model.symbols.add('src/user.kt', 'User', 'class:User', 'Class');
|
|
ctx.model.symbols.add('src/validator.kt', 'validate', 'method:Validator:validate', 'Method', {
|
|
returnType: 'Boolean',
|
|
ownerId: 'interface:Validator',
|
|
});
|
|
ctx.importMap.set('src/app.kt', new Set(['src/validator.kt', 'src/user.kt']));
|
|
|
|
const heritage: ExtractedHeritage[] = [
|
|
{
|
|
filePath: 'src/user.kt',
|
|
className: 'User',
|
|
parentName: 'Validator',
|
|
kind: 'implements',
|
|
},
|
|
];
|
|
const map = buildHeritageMap(heritage, ctx);
|
|
|
|
const result = resolveMemberCall('User', 'validate', 'src/app.kt', ctx, map);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('method:Validator:validate');
|
|
expect(result!.returnType).toBe('Boolean');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// T1: resolveCallTarget thin dispatcher (SM-19) — verify the dispatcher
|
|
// routes member/constructor/free calls to the appropriate specialized resolver.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// resolveCallTarget thin dispatcher (SM-19)
|
|
// After SM-19, resolveCallTarget is a thin dispatcher that routes to
|
|
// resolveMemberCall, resolveStaticCall, or resolveFreeCall. The D0-D4 fuzzy
|
|
// widening paths have been removed.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('resolveCallTarget thin dispatcher (SM-19)', () => {
|
|
let ctx: ResolutionContext;
|
|
|
|
beforeEach(() => {
|
|
ctx = createResolutionContext();
|
|
});
|
|
|
|
it('module alias homonyms: dispatcher resolves via module-alias narrowing to aliased file', () => {
|
|
// Python-style: `import auth; auth.User.save()` where BOTH auth.py and
|
|
// other.py define a `User` class with a `save` method.
|
|
//
|
|
// When both homonym files are imported, owner-scoped resolution sees
|
|
// genuine ambiguity (both `User` classes own a `save` method) and the
|
|
// only remaining disambiguation signal is the module alias on
|
|
// `call.receiverName`. The dispatcher consults alias narrowing as a
|
|
// guarded fallback after owner/file-scoped resolvers return null; the
|
|
// type-file verification guard requires the alias target file to be
|
|
// among the receiver type's defining files before alias narrowing is
|
|
// considered a valid signal.
|
|
ctx.model.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class');
|
|
ctx.model.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', {
|
|
returnType: 'None',
|
|
ownerId: 'class:auth:User',
|
|
});
|
|
ctx.model.symbols.add('src/other.py', 'User', 'class:other:User', 'Class');
|
|
ctx.model.symbols.add('src/other.py', 'save', 'method:other:User:save', 'Method', {
|
|
returnType: 'None',
|
|
ownerId: 'class:other:User',
|
|
});
|
|
ctx.importMap.set('src/app.py', new Set(['src/auth.py', 'src/other.py']));
|
|
ctx.moduleAliasMap.set('src/app.py', new Map([['auth', 'src/auth.py']]));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{
|
|
calledName: 'save',
|
|
callForm: 'member',
|
|
receiverTypeName: 'User',
|
|
receiverName: 'auth',
|
|
},
|
|
'src/app.py',
|
|
ctx,
|
|
);
|
|
|
|
// Module-alias narrowing picks auth.py's save, not other.py's.
|
|
expect(result).not.toBeNull();
|
|
expect(result?.nodeId).toBe('method:auth:User:save');
|
|
});
|
|
|
|
it('overloadHints ignored for member calls — resolveMemberCall resolves directly', () => {
|
|
// With the thin dispatcher, overloadHints are not passed to resolveMemberCall
|
|
// (it does not accept them). Single-candidate member calls still resolve.
|
|
ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
|
ctx.model.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:User',
|
|
});
|
|
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
|
|
|
const dummyHints = {} as OverloadHints;
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{
|
|
calledName: 'save',
|
|
callForm: 'member',
|
|
receiverTypeName: 'User',
|
|
},
|
|
'src/app.ts',
|
|
ctx,
|
|
{ overloadHints: dummyHints },
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('method:User:save');
|
|
});
|
|
|
|
it('preComputedArgTypes ignored for member calls — resolveMemberCall resolves directly', () => {
|
|
// Analogous to the overloadHints case: thin dispatcher delegates to
|
|
// resolveMemberCall which resolves the single candidate without needing
|
|
// argument-type disambiguation.
|
|
ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
|
ctx.model.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', {
|
|
returnType: 'void',
|
|
ownerId: 'class:User',
|
|
});
|
|
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{
|
|
calledName: 'save',
|
|
callForm: 'member',
|
|
receiverTypeName: 'User',
|
|
argCount: 0,
|
|
},
|
|
'src/app.ts',
|
|
ctx,
|
|
{ preComputedArgTypes: [] },
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('method:User:save');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// resolveStaticCall — SM-12: constructor/static call resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import { resolveStaticCall } from '../../src/core/ingestion/call-processor.js';
|
|
|
|
describe('resolveStaticCall', () => {
|
|
let ctx: ResolutionContext;
|
|
|
|
beforeEach(() => {
|
|
ctx = createResolutionContext();
|
|
});
|
|
|
|
it('resolves constructor with ownerId via lookupMethodByOwner', () => {
|
|
ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
|
ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', {
|
|
returnType: 'User',
|
|
ownerId: 'class:User',
|
|
});
|
|
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
|
|
|
const result = resolveStaticCall('User', 'src/app.ts', ctx);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('ctor:User');
|
|
});
|
|
|
|
it('returns class node when no constructor exists', () => {
|
|
ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
|
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
|
|
|
const result = resolveStaticCall('User', 'src/app.ts', ctx);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('class:User');
|
|
});
|
|
|
|
it('returns null for non-class symbol', () => {
|
|
ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper', 'Function');
|
|
ctx.importMap.set('src/app.ts', new Set(['src/utils.ts']));
|
|
|
|
const result = resolveStaticCall('helper', 'src/app.ts', ctx);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('returns null when className does not exist', () => {
|
|
const result = resolveStaticCall('NonExistent', 'src/app.ts', ctx);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('returns null when Constructor nodes lack ownerId', () => {
|
|
ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
|
ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', {
|
|
parameterCount: 1,
|
|
});
|
|
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
|
|
|
// Constructor lacks ownerId, so lookupMethodByOwner won't find it.
|
|
// resolveStaticCall detects Constructor nodes and returns null to
|
|
// let filterCallableCandidates handle the Constructor-vs-Class preference.
|
|
const result = resolveStaticCall('User', 'src/app.ts', ctx);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('disambiguates constructor by arity', () => {
|
|
ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
|
ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User:0', 'Constructor', {
|
|
parameterCount: 0,
|
|
returnType: 'User',
|
|
ownerId: 'class:User',
|
|
});
|
|
ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User:2', 'Constructor', {
|
|
parameterCount: 2,
|
|
returnType: 'User',
|
|
ownerId: 'class:User',
|
|
});
|
|
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
|
|
|
const result = resolveStaticCall('User', 'src/app.ts', ctx, 2);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('ctor:User:2');
|
|
});
|
|
|
|
it('returns correct confidence tier for import-scoped class', () => {
|
|
ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
|
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
|
|
|
const result = resolveStaticCall('User', 'src/app.ts', ctx);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.confidence).toBe(0.9); // import-scoped tier
|
|
expect(result!.reason).toBe('import-resolved');
|
|
});
|
|
|
|
it('returns correct confidence tier for same-file class', () => {
|
|
ctx.model.symbols.add('src/app.ts', 'User', 'class:User', 'Class');
|
|
|
|
const result = resolveStaticCall('User', 'src/app.ts', ctx);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.confidence).toBe(0.95); // same-file tier
|
|
expect(result!.reason).toBe('same-file');
|
|
});
|
|
|
|
it('returns null for ambiguous homonym classes without constructor', () => {
|
|
ctx.model.symbols.add('src/a.ts', 'User', 'class:a:User', 'Class');
|
|
ctx.model.symbols.add('src/b.ts', 'User', 'class:b:User', 'Class');
|
|
ctx.importMap.set('src/app.ts', new Set(['src/a.ts', 'src/b.ts']));
|
|
|
|
const result = resolveStaticCall('User', 'src/app.ts', ctx);
|
|
|
|
// Two classes with same name — ambiguous, should return null
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('routes through resolveCallTarget for constructor callForm', () => {
|
|
ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
|
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{
|
|
calledName: 'User',
|
|
callForm: 'constructor',
|
|
},
|
|
'src/app.ts',
|
|
ctx,
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('class:User');
|
|
});
|
|
|
|
it('routes through resolveCallTarget for free-form call targeting a class (Swift/Kotlin)', () => {
|
|
ctx.model.symbols.add('src/user.swift', 'User', 'class:User', 'Class');
|
|
ctx.importMap.set('src/app.swift', new Set(['src/user.swift']));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{
|
|
calledName: 'User',
|
|
callForm: 'free',
|
|
},
|
|
'src/app.swift',
|
|
ctx,
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('class:User');
|
|
});
|
|
|
|
it('reuses the pre-computed tiered result instead of calling ctx.resolve twice', () => {
|
|
ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
|
ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', {
|
|
returnType: 'User',
|
|
ownerId: 'class:User',
|
|
});
|
|
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
|
|
|
// Spy on ctx.resolve to prove the override short-circuits the second lookup.
|
|
const originalResolve = ctx.resolve.bind(ctx);
|
|
let resolveCallCount = 0;
|
|
ctx.resolve = ((name: string, fromFile: string) => {
|
|
resolveCallCount++;
|
|
return originalResolve(name, fromFile);
|
|
}) as typeof ctx.resolve;
|
|
|
|
const tieredOverride = originalResolve('User', 'src/app.ts');
|
|
expect(tieredOverride).not.toBeNull();
|
|
resolveCallCount = 0; // reset after the setup call
|
|
|
|
const result = resolveStaticCall('User', 'src/app.ts', ctx, undefined, tieredOverride!);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('ctor:User');
|
|
expect(resolveCallCount).toBe(0); // ctx.resolve must not have been called again
|
|
});
|
|
|
|
it('routes through resolveCallTarget for Java constructor call (new User())', () => {
|
|
ctx.model.symbols.add('src/User.java', 'User', 'class:java:User', 'Class');
|
|
ctx.model.symbols.add('src/User.java', 'User', 'ctor:java:User', 'Constructor', {
|
|
returnType: 'User',
|
|
ownerId: 'class:java:User',
|
|
});
|
|
ctx.importMap.set('src/App.java', new Set(['src/User.java']));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{
|
|
calledName: 'User',
|
|
callForm: 'constructor',
|
|
},
|
|
'src/App.java',
|
|
ctx,
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
// Prefers Constructor node over Class node when ownerId is present.
|
|
expect(result!.nodeId).toBe('ctor:java:User');
|
|
});
|
|
|
|
it('routes through resolveCallTarget for Python free-form constructor (User())', () => {
|
|
ctx.model.symbols.add('models/user.py', 'User', 'class:py:User', 'Class');
|
|
ctx.importMap.set('app.py', new Set(['models/user.py']));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{
|
|
calledName: 'User',
|
|
callForm: 'free',
|
|
},
|
|
'app.py',
|
|
ctx,
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('class:py:User');
|
|
});
|
|
|
|
it('routes through resolveCallTarget for Kotlin free-form constructor (User())', () => {
|
|
ctx.model.symbols.add('src/User.kt', 'User', 'class:kt:User', 'Class');
|
|
ctx.importMap.set('src/App.kt', new Set(['src/User.kt']));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{
|
|
calledName: 'User',
|
|
callForm: 'free',
|
|
},
|
|
'src/App.kt',
|
|
ctx,
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('class:kt:User');
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Instantiability guard (Codex review follow-up, plan 2026-04-09-002):
|
|
// The step-5 class-node fallback must only return instantiable kinds
|
|
// (Class / Struct / Record). Interface / Trait / Impl / Enum targets are
|
|
// null-routed to prevent false CALLS edges to non-instantiable nodes.
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('returns a Struct node when no constructor exists (positive regression guard)', () => {
|
|
ctx.model.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct');
|
|
ctx.importMap.set('src/app.rs', new Set(['src/user.rs']));
|
|
|
|
const result = resolveStaticCall('User', 'src/app.rs', ctx);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('struct:User');
|
|
});
|
|
|
|
it('returns a Record node when no constructor exists (positive regression guard)', () => {
|
|
ctx.model.symbols.add('src/User.cs', 'User', 'record:User', 'Record');
|
|
ctx.importMap.set('src/App.cs', new Set(['src/User.cs']));
|
|
|
|
const result = resolveStaticCall('User', 'src/App.cs', ctx);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('record:User');
|
|
});
|
|
|
|
it('null-routes when the sole candidate is an Interface (Java/C#/TS)', () => {
|
|
// Constructor-shaped call on an interface name — not legal source, but
|
|
// the resolver must refuse to emit a CALLS edge to a non-instantiable node.
|
|
ctx.model.symbols.add('src/validator.java', 'IValidator', 'iface:IValidator', 'Interface');
|
|
ctx.importMap.set('src/app.java', new Set(['src/validator.java']));
|
|
|
|
const result = resolveStaticCall('IValidator', 'src/app.java', ctx);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('null-routes when the sole candidate is a Trait (PHP/Rust/Scala)', () => {
|
|
// PHP `HasTimestamps` trait — not instantiable via constructor syntax.
|
|
ctx.model.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait');
|
|
ctx.importMap.set('src/model.php', new Set(['src/timestamps.php']));
|
|
|
|
const result = resolveStaticCall('HasTimestamps', 'src/model.php', ctx);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('null-routes when the sole candidate is a Rust Trait (Display)', () => {
|
|
ctx.model.symbols.add('src/fmt.rs', 'Display', 'trait:rs:Display', 'Trait');
|
|
ctx.importMap.set('src/app.rs', new Set(['src/fmt.rs']));
|
|
|
|
const result = resolveStaticCall('Display', 'src/app.rs', ctx);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('prefers the Struct over the Impl when both share the same name and file (Rust shadowing)', () => {
|
|
// Rust `impl User { ... }` alongside `struct User { ... }` in the same file.
|
|
// Same-file tier returns both via lookupExactAll, both pass CLASS_LIKE_TYPES,
|
|
// but the instantiability filter must strip the Impl so the Struct wins.
|
|
ctx.model.symbols.add('src/user.rs', 'User', 'struct:rs:User', 'Struct');
|
|
ctx.model.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl');
|
|
|
|
const result = resolveStaticCall('User', 'src/user.rs', ctx);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('struct:rs:User');
|
|
});
|
|
|
|
it('null-routes when the sole candidate is a Rust Impl block (no Struct present)', () => {
|
|
// Pathological extractor output: only the Impl survives tier resolution.
|
|
// The instantiability filter must reject it rather than emit a wrong edge.
|
|
ctx.model.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl');
|
|
|
|
const result = resolveStaticCall('User', 'src/user.rs', ctx);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('still returns an explicit Constructor even when the owner is an Impl (step-3 preservation)', () => {
|
|
// Step 3 (lookupMethodByOwner walk) must not be affected by the step-5
|
|
// tightening — a legitimate Constructor node owned by an Impl in a Rust
|
|
// extractor still resolves correctly. The Struct is also present so that
|
|
// step-1's lookupClassByName pre-check succeeds (Impl alone isn't in the
|
|
// classByName index).
|
|
ctx.model.symbols.add('src/user.rs', 'User', 'struct:rs:User', 'Struct');
|
|
ctx.model.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl');
|
|
ctx.model.symbols.add('src/user.rs', 'User', 'ctor:rs:User', 'Constructor', {
|
|
returnType: 'User',
|
|
ownerId: 'impl:rs:User',
|
|
});
|
|
ctx.importMap.set('src/app.rs', new Set(['src/user.rs']));
|
|
|
|
const result = resolveStaticCall('User', 'src/app.rs', ctx);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('ctor:rs:User');
|
|
});
|
|
|
|
it('routes through resolveCallTarget and null-routes Interface constructor-shaped calls', () => {
|
|
ctx.model.symbols.add('src/validator.java', 'IValidator', 'iface:IValidator', 'Interface');
|
|
ctx.importMap.set('src/app.java', new Set(['src/validator.java']));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{
|
|
calledName: 'IValidator',
|
|
callForm: 'constructor',
|
|
},
|
|
'src/app.java',
|
|
ctx,
|
|
);
|
|
|
|
// Full cascade: S0 → resolveStaticCall → step-5 instantiability filter → null.
|
|
// If any downstream path silently re-introduces the wrong edge, this fails.
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('routes through resolveCallTarget and null-routes Trait free-form calls', () => {
|
|
ctx.model.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait');
|
|
ctx.importMap.set('src/model.php', new Set(['src/timestamps.php']));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{
|
|
calledName: 'HasTimestamps',
|
|
callForm: 'free',
|
|
},
|
|
'src/model.php',
|
|
ctx,
|
|
);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('routes Record free-form constructor call through S0 (C# record / Kotlin data class)', () => {
|
|
// Verifies that `freeFormHasClassTarget` triggers S0 for Record candidates.
|
|
// Before the alignment fix, `Record` was absent from the trigger `.some()`,
|
|
// so S0 was bypassed and Record free-form calls fell through to the
|
|
// constructor-form retry path. This test would have silently passed with
|
|
// the old (wasteful) code path — with the fix, S0 resolves it directly.
|
|
ctx.model.symbols.add('src/User.cs', 'User', 'record:cs:User', 'Record');
|
|
ctx.importMap.set('src/App.cs', new Set(['src/User.cs']));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{
|
|
calledName: 'User',
|
|
callForm: 'free',
|
|
},
|
|
'src/App.cs',
|
|
ctx,
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('record:cs:User');
|
|
});
|
|
|
|
it('threads argCount through resolveCallTarget → S0 → resolveStaticCall for arity disambiguation', () => {
|
|
// Regression guard: if call.argCount were ever dropped at the S0 call
|
|
// site, the 2-arg constructor would resolve to the 0-arg overload (or
|
|
// return null via ambiguity). This test fails in either case.
|
|
ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
|
ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User:0', 'Constructor', {
|
|
parameterCount: 0,
|
|
returnType: 'User',
|
|
ownerId: 'class:User',
|
|
});
|
|
ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User:2', 'Constructor', {
|
|
parameterCount: 2,
|
|
returnType: 'User',
|
|
ownerId: 'class:User',
|
|
});
|
|
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{
|
|
calledName: 'User',
|
|
callForm: 'constructor',
|
|
argCount: 2,
|
|
},
|
|
'src/app.ts',
|
|
ctx,
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('ctor:User:2');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// resolveFreeCall — SM-13: free-function call resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('resolveFreeCall', () => {
|
|
let ctx: ResolutionContext;
|
|
|
|
beforeEach(() => {
|
|
ctx = createResolutionContext();
|
|
});
|
|
|
|
it('resolves a free function call via import-scoped resolution', () => {
|
|
ctx.model.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function');
|
|
ctx.importMap.set('src/app.ts', new Set(['src/utils.ts']));
|
|
|
|
const result = resolveFreeCall('doStuff', 'src/app.ts', ctx);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('func:doStuff');
|
|
expect(result!.confidence).toBe(0.9); // import-scoped tier
|
|
expect(result!.reason).toBe('import-resolved');
|
|
});
|
|
|
|
it('resolves a free function call via same-file resolution', () => {
|
|
ctx.model.symbols.add('src/app.ts', 'helper', 'func:helper', 'Function');
|
|
|
|
const result = resolveFreeCall('helper', 'src/app.ts', ctx);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('func:helper');
|
|
expect(result!.confidence).toBe(0.95); // same-file tier
|
|
expect(result!.reason).toBe('same-file');
|
|
});
|
|
|
|
it('returns null when no candidates exist', () => {
|
|
const result = resolveFreeCall('nonexistent', 'src/app.ts', ctx);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('returns null for ambiguous free function calls (multiple candidates)', () => {
|
|
ctx.model.symbols.add('src/a.ts', 'doStuff', 'func:a:doStuff', 'Function');
|
|
ctx.model.symbols.add('src/b.ts', 'doStuff', 'func:b:doStuff', 'Function');
|
|
ctx.importMap.set('src/app.ts', new Set(['src/a.ts', 'src/b.ts']));
|
|
|
|
const result = resolveFreeCall('doStuff', 'src/app.ts', ctx);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('delegates to resolveStaticCall for free-form class targets (Swift/Kotlin)', () => {
|
|
ctx.model.symbols.add('src/user.swift', 'User', 'class:User', 'Class');
|
|
ctx.importMap.set('src/app.swift', new Set(['src/user.swift']));
|
|
|
|
const result = resolveFreeCall('User', 'src/app.swift', ctx);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('class:User');
|
|
});
|
|
|
|
it('delegates to resolveStaticCall for Record free-form targets (C#/Kotlin)', () => {
|
|
ctx.model.symbols.add('src/User.cs', 'User', 'record:cs:User', 'Record');
|
|
ctx.importMap.set('src/App.cs', new Set(['src/User.cs']));
|
|
|
|
const result = resolveFreeCall('User', 'src/App.cs', ctx);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('record:cs:User');
|
|
});
|
|
|
|
it('null-routes Trait free-form calls via resolveStaticCall', () => {
|
|
ctx.model.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait');
|
|
ctx.importMap.set('src/model.php', new Set(['src/timestamps.php']));
|
|
|
|
const result = resolveFreeCall('HasTimestamps', 'src/model.php', ctx);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('uses tieredOverride when provided', () => {
|
|
ctx.model.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function');
|
|
ctx.importMap.set('src/app.ts', new Set(['src/utils.ts']));
|
|
|
|
const tiered = ctx.resolve('doStuff', 'src/app.ts');
|
|
expect(tiered).not.toBeNull();
|
|
|
|
// Spy on ctx.resolve to verify it is NOT called again
|
|
const originalResolve = ctx.resolve.bind(ctx);
|
|
let resolveCallCount = 0;
|
|
ctx.resolve = ((name: string, fromFile: string) => {
|
|
resolveCallCount++;
|
|
return originalResolve(name, fromFile);
|
|
}) as typeof ctx.resolve;
|
|
|
|
const result = resolveFreeCall('doStuff', 'src/app.ts', ctx, undefined, tiered!);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('func:doStuff');
|
|
expect(resolveCallCount).toBe(0);
|
|
});
|
|
|
|
it('routes through resolveCallTarget for free-form calls', () => {
|
|
ctx.model.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function');
|
|
ctx.importMap.set('src/app.ts', new Set(['src/utils.ts']));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{
|
|
calledName: 'doStuff',
|
|
callForm: 'free',
|
|
},
|
|
'src/app.ts',
|
|
ctx,
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('func:doStuff');
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// PR #756 review follow-up (plan 2026-04-09-003): language coverage,
|
|
// arity threading, Tier 3 resolution, preComputedArgTypes worker path,
|
|
// Enum null-route, and Swift extension dedup guard.
|
|
// -------------------------------------------------------------------------
|
|
|
|
// R2 — Language coverage: Go, Python, Rust, Java, JavaScript free-function
|
|
// dispatch through _resolveCallTargetForTesting. resolveFreeCall has no
|
|
// file-extension branching; these guard the dispatch chain per language.
|
|
|
|
it('resolves a Go free function (doStuff())', () => {
|
|
ctx.model.symbols.add('src/helper.go', 'doStuff', 'func:go:doStuff', 'Function');
|
|
ctx.importMap.set('src/main.go', new Set(['src/helper.go']));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{ calledName: 'doStuff', callForm: 'free' },
|
|
'src/main.go',
|
|
ctx,
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('func:go:doStuff');
|
|
});
|
|
|
|
it('resolves a Python free function (def helper(): ... helper())', () => {
|
|
ctx.model.symbols.add('helpers.py', 'helper', 'func:py:helper', 'Function');
|
|
ctx.importMap.set('app.py', new Set(['helpers.py']));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{ calledName: 'helper', callForm: 'free' },
|
|
'app.py',
|
|
ctx,
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('func:py:helper');
|
|
});
|
|
|
|
it('resolves a Rust free function outside any impl block (free_fn())', () => {
|
|
ctx.model.symbols.add('src/helpers.rs', 'free_fn', 'func:rs:free_fn', 'Function');
|
|
ctx.importMap.set('src/main.rs', new Set(['src/helpers.rs']));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{ calledName: 'free_fn', callForm: 'free' },
|
|
'src/main.rs',
|
|
ctx,
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('func:rs:free_fn');
|
|
});
|
|
|
|
it('resolves a Java statically-imported function (doStuff() after import static Utils.doStuff)', () => {
|
|
// Note: this simulates the extractor output post static import by
|
|
// indexing the function directly in its declaring file. The test guards
|
|
// the dispatch chain for .java files, not the extractor's handling of
|
|
// static imports specifically.
|
|
ctx.model.symbols.add('src/Utils.java', 'doStuff', 'func:java:doStuff', 'Function');
|
|
ctx.importMap.set('src/App.java', new Set(['src/Utils.java']));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{ calledName: 'doStuff', callForm: 'free' },
|
|
'src/App.java',
|
|
ctx,
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('func:java:doStuff');
|
|
});
|
|
|
|
it('resolves a JavaScript module-level function (moduleFn())', () => {
|
|
ctx.model.symbols.add('src/helpers.js', 'moduleFn', 'func:js:moduleFn', 'Function');
|
|
ctx.importMap.set('src/app.js', new Set(['src/helpers.js']));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{ calledName: 'moduleFn', callForm: 'free' },
|
|
'src/app.js',
|
|
ctx,
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('func:js:moduleFn');
|
|
});
|
|
|
|
// R3 — Arity filtering: call.argCount must narrow overloaded free functions
|
|
// differing only in parameter count.
|
|
|
|
it('narrows overloaded free functions by argCount (2-arg overload selected)', () => {
|
|
ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:0', 'Function', {
|
|
parameterCount: 0,
|
|
});
|
|
ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:2', 'Function', {
|
|
parameterCount: 2,
|
|
});
|
|
ctx.importMap.set('src/app.ts', new Set(['src/utils.ts']));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{ calledName: 'helper', callForm: 'free', argCount: 2 },
|
|
'src/app.ts',
|
|
ctx,
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('func:helper:2');
|
|
});
|
|
|
|
it('narrows overloaded free functions by argCount (0-arg overload selected)', () => {
|
|
ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:0', 'Function', {
|
|
parameterCount: 0,
|
|
});
|
|
ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:2', 'Function', {
|
|
parameterCount: 2,
|
|
});
|
|
ctx.importMap.set('src/app.ts', new Set(['src/utils.ts']));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{ calledName: 'helper', callForm: 'free', argCount: 0 },
|
|
'src/app.ts',
|
|
ctx,
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('func:helper:0');
|
|
});
|
|
|
|
// R4 — Tier 3 (global) resolution: function globally visible but not
|
|
// imported. Locks in TIER_CONFIDENCE.global === 0.5 and reason === 'global'
|
|
// so a silent tier-table refactor surfaces here.
|
|
|
|
it('resolves a globally-visible free function via Tier 3 with global confidence', () => {
|
|
ctx.model.symbols.add('lib/global.ts', 'helper', 'func:global:helper', 'Function');
|
|
// No importMap entry — must fall through to Tier 3 (global).
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{ calledName: 'helper', callForm: 'free' },
|
|
'src/app.ts',
|
|
ctx,
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('func:global:helper');
|
|
expect(result!.confidence).toBe(0.5); // TIER_CONFIDENCE.global
|
|
expect(result!.reason).toBe('global');
|
|
});
|
|
|
|
// R5 — preComputedArgTypes worker path: when parse-worker pre-computes
|
|
// argument types, the disambiguation routes through matchCandidatesByArgTypes.
|
|
// Preconditions (verified at feasibility review):
|
|
// 1. filteredCandidates.length > 1 — both overloads must survive arity
|
|
// filtering, so argCount left unset here.
|
|
// 2. overloadHints must be undefined — it takes precedence over
|
|
// preComputedArgTypes at the disambiguation site.
|
|
|
|
it('disambiguates overloads via preComputedArgTypes (String overload matched)', () => {
|
|
ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:str', 'Function', {
|
|
parameterCount: 1,
|
|
parameterTypes: ['String'],
|
|
});
|
|
ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:int', 'Function', {
|
|
parameterCount: 1,
|
|
parameterTypes: ['Int'],
|
|
});
|
|
ctx.importMap.set('src/app.ts', new Set(['src/utils.ts']));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{ calledName: 'helper', callForm: 'free', argCount: 1 },
|
|
'src/app.ts',
|
|
ctx,
|
|
{ preComputedArgTypes: ['String'] },
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('func:helper:str');
|
|
});
|
|
|
|
it('disambiguates overloads via preComputedArgTypes (Int overload matched)', () => {
|
|
ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:str', 'Function', {
|
|
parameterCount: 1,
|
|
parameterTypes: ['String'],
|
|
});
|
|
ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:int', 'Function', {
|
|
parameterCount: 1,
|
|
parameterTypes: ['Int'],
|
|
});
|
|
ctx.importMap.set('src/app.ts', new Set(['src/utils.ts']));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{ calledName: 'helper', callForm: 'free', argCount: 1 },
|
|
'src/app.ts',
|
|
ctx,
|
|
// `Int` is normalized to `int` on the stored side via normalizeJvmTypeName
|
|
// (matchCandidatesByArgTypes:1287). Real parse-worker-emitted argTypes are
|
|
// already lowercase primitive names inferred from literals, so this
|
|
// mirrors production call-site shape.
|
|
{ preComputedArgTypes: ['int'] },
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('func:helper:int');
|
|
});
|
|
|
|
// R6 — Enum free-form null-route: locks in the current behavior that
|
|
// `Color()`-style calls on Enum types return null because Enum is
|
|
// deliberately excluded from INSTANTIABLE_CLASS_TYPES. This is intentional
|
|
// per PR #754 round 1 (see `call-processor.ts` INSTANTIABLE_CLASS_TYPES
|
|
// JSDoc — "Enum excluded pending language-specific support with motivating
|
|
// test fixtures"). If a future extension adds Enum to the set, this test
|
|
// will need to be updated alongside that work — that is the correct signal.
|
|
|
|
it('null-routes Enum free-form calls (Color() — no instantiable fallback)', () => {
|
|
ctx.model.symbols.add('src/color.ts', 'Color', 'enum:Color', 'Enum');
|
|
ctx.importMap.set('src/app.ts', new Set(['src/color.ts']));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{ calledName: 'Color', callForm: 'free' },
|
|
'src/app.ts',
|
|
ctx,
|
|
);
|
|
|
|
// Enum not in INSTANTIABLE_CLASS_TYPES → hasClassTarget is false →
|
|
// resolveStaticCall is not called → tail dedup also doesn't fire →
|
|
// falls through to the final null return.
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
// R7 — Swift extension dedup `filePath.length` heuristic guard:
|
|
// Two same-name Class entries at different path lengths. The free-form
|
|
// dispatch chain goes:
|
|
// 1. filterCallableCandidates(tiered, argCount, 'free') strips Class →
|
|
// filteredCandidates.length === 0
|
|
// 2. hasClassTarget is true (both are Class)
|
|
// 3. resolveStaticCall runs, has 2 homonym Class candidates →
|
|
// instantiableCandidates.length > 1 → returns null (SM-12 round-1
|
|
// null-route contract)
|
|
// 4. Constructor-form retry: filterCallableCandidates(tiered, argCount,
|
|
// 'constructor') keeps Class entries → filteredCandidates.length === 2
|
|
// 5. Falls through to the Swift extension dedup block → sorts by
|
|
// filePath.length → returns the shortest path.
|
|
|
|
it('dedupes Swift extension candidates by shortest file path (free-form retry path)', () => {
|
|
// Two same-name Class entries, different path lengths.
|
|
ctx.model.symbols.add('src/User.swift', 'User', 'class:User:primary', 'Class');
|
|
ctx.model.symbols.add(
|
|
'src/Extensions/UserExtensions.swift',
|
|
'User',
|
|
'class:User:extension',
|
|
'Class',
|
|
);
|
|
ctx.importMap.set(
|
|
'src/App.swift',
|
|
new Set(['src/User.swift', 'src/Extensions/UserExtensions.swift']),
|
|
);
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{ calledName: 'User', callForm: 'free' },
|
|
'src/App.swift',
|
|
ctx,
|
|
);
|
|
|
|
// The shortest file path wins per the existing heuristic. This is a
|
|
// behavior guard for finding #4 in the PR #756 review — if the dedup
|
|
// heuristic changes, this test surfaces that intent.
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('class:User:primary');
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// PR #756 final review follow-up (comment 4215739052):
|
|
// - Finding #3 low: ownerless-Constructor retry path (previously covered
|
|
// by comment only) — adds the concrete test the reviewer asked for.
|
|
// - Low-severity coverage gap: PHP free function (from the language
|
|
// coverage table in the same review).
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('routes through resolveStaticCall retry when tiered pool contains an ownerless Constructor (free-form)', () => {
|
|
// This exercises the third null-return reason documented in the retry
|
|
// comment inside resolveFreeCall: resolveStaticCall's step-4 bailout when
|
|
// the tiered pool contains Constructor nodes that lack ownerId (common in
|
|
// some extractors). In that case:
|
|
// 1. resolveStaticCall step 3 walks classCandidates via lookupMethodByOwner
|
|
// — the ownerless Constructor is NOT in methodByOwner, so nothing found.
|
|
// 2. Step 4 detects the Constructor in the tiered pool and bails out
|
|
// with null so filterCallableCandidates can handle Constructor-vs-
|
|
// Class preference correctly.
|
|
// 3. resolveFreeCall's retry re-runs filterCallableCandidates with
|
|
// 'constructor' form, which — per CONSTRUCTOR_TARGET_TYPES — prefers
|
|
// the Constructor node over the Class node.
|
|
// 4. Single survivor → returned as the call target.
|
|
ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
|
ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User:ownerless', 'Constructor', {
|
|
parameterCount: 0,
|
|
// No ownerId — this is the pathological extractor output the retry path
|
|
// exists to handle.
|
|
});
|
|
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{ calledName: 'User', callForm: 'free' },
|
|
'src/app.ts',
|
|
ctx,
|
|
);
|
|
|
|
// The Constructor survives filterCallableCandidates's 'constructor' form
|
|
// filter and is preferred over the Class (CONSTRUCTOR_TARGET_TYPES puts
|
|
// Constructor first). Guards the (c) case in the retry-reasons comment.
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('ctor:User:ownerless');
|
|
});
|
|
|
|
it('resolves a PHP free function (top-level helper())', () => {
|
|
// PHP allows top-level function definitions outside any class. The
|
|
// language coverage table in PR #756 review flagged this as uncovered;
|
|
// this test exercises the `.php` dispatch path for free calls. Matches
|
|
// the shape of the existing Go/Python/Rust/Java/JS language tests above.
|
|
ctx.model.symbols.add('src/helpers.php', 'helper', 'func:php:helper', 'Function');
|
|
ctx.importMap.set('src/app.php', new Set(['src/helpers.php']));
|
|
|
|
const result = _resolveCallTargetForTesting(
|
|
{ calledName: 'helper', callForm: 'free' },
|
|
'src/app.php',
|
|
ctx,
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.nodeId).toBe('func:php:helper');
|
|
});
|
|
});
|