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);
    });

    it('normal operation: add returns the constructed SymbolDefinition', () => {
      const def = table.add('src/index.ts', 'main', 'func:main', 'Function');
      expect(def.nodeId).toBe('func:main');
      expect(def.filePath).toBe('src/index.ts');
      expect(def.type).toBe('Function');
    });

    it('normal operation: add with metadata populates returnType', () => {
      const def = table.add('src/index.ts', 'fetch', 'func:fetch', 'Function', {
        returnType: 'Promise<void>',
      });
      expect(def.returnType).toBe('Promise<void>');
      expect(table.lookupExactFull('src/index.ts', 'fetch')).toBe(def);
    });
  });

  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');
  });
});
