From c159743cb18ff9c5f98495b4d980fc4b4bf045f8 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Thu, 9 Apr 2026 07:17:57 +0100 Subject: [PATCH] fix(SM-11): address PR #744 review Blocking fixes: - B1: Revert unrelated package-lock.json gitnexus-shared addition - B2: Document confidence-tier semantic change on resolveMemberCall Performance / coupling fixes: - S1: walkMixedChain now calls resolveMethodByOwner directly (hot path) to avoid throwaway ResolveResult allocation per chain step - S2: Thread tier from resolveMethodByOwner via { def, tier } tuple; eliminates double ctx.resolve Alignment with semantic-model plan (Phase 3 target): - resolveMethodByOwner now iterates ALL class-like candidates from ctx.resolve, deduplicating matches by nodeId. Absorbs D4's ownerId-filtering into the owner-scoped path. - Handles homonym classes (two Users in different files) without falling through to D1-D4 fuzzy widening - Shared-ancestor MRO walks automatically dedup (both homonyms walk to same base method) - Unified direct-vs-MRO lookup under a single canWalkMRO check Tests added: - T1: Three D0 skip-condition tests via new _resolveCallTargetForTesting internal export (overloadHints, preComputedArgTypes, hasActiveModuleAlias) - T2: Rust qualified-syntax null test (trait-inherited method) + direct impl control - T3: C++ leftmost-base diamond inheritance test - B2 lock-in: cross-file class tier assertion - Homonym disambiguation: only-one-owns-method, both-own-method ambiguity, shared-ancestor MRO convergence Verification: - tsc --noEmit: clean - vitest run test/unit/: 3014 passed - vitest run test/integration/resolvers/: 1746 passed --- gitnexus/package-lock.json | 1 - gitnexus/src/core/ingestion/call-processor.ts | 140 +++++--- gitnexus/test/unit/symbol-table.test.ts | 321 +++++++++++++++++- 3 files changed, 419 insertions(+), 43 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 3736d0c46..9abd15b0e 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -17,7 +17,6 @@ "commander": "^12.0.0", "cors": "^2.8.5", "express": "^4.19.2", - "gitnexus-shared": "file:../gitnexus-shared", "glob": "^11.0.0", "graphology": "^0.25.4", "graphology-indices": "^0.17.0", diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 1ec22ea5f..1cbb8bd65 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -1279,6 +1279,31 @@ const tryOverloadDisambiguation = ( /** Per-file cache for the widen path's lookupFuzzy calls. Cleared between files. */ type WidenCache = Map; +/** @internal Exported for unit tests of D0 skip conditions (SM-11). Do not use outside tests. */ +export const _resolveCallTargetForTesting = ( + call: Pick< + ExtractedCall, + 'calledName' | 'argCount' | 'callForm' | 'receiverTypeName' | 'receiverName' + >, + currentFile: string, + ctx: ResolutionContext, + opts?: { + overloadHints?: OverloadHints; + widenCache?: WidenCache; + preComputedArgTypes?: (string | undefined)[]; + heritageMap?: HeritageMap; + }, +): ResolveResult | null => + resolveCallTarget( + call, + currentFile, + ctx, + opts?.overloadHints, + opts?.widenCache, + opts?.preComputedArgTypes, + opts?.heritageMap, + ); + const resolveCallTarget = ( call: Pick< ExtractedCall, @@ -1626,9 +1651,33 @@ const resolveFieldOwnership = ( /** * Resolve a method by owner type name using the eagerly-populated methodByOwner index. - * Returns the SymbolDefinition if an unambiguous method is found, undefined otherwise. - * Falls through to undefined for: unknown type, no class-like candidates, ambiguous overloads. - * When heritageMap is provided, falls back to MRO-aware parent chain walking. + * Returns `{ def, tier }` when an unambiguous method is found, `undefined` otherwise. + * + * **Multi-candidate iteration (homonym disambiguation):** when `ctx.resolve(ownerType)` + * returns multiple class-like candidates (e.g. two classes named `User` in different + * files reachable from the call site), each is probed with `lookupMethodByOwnerWithMRO`. + * Results are deduplicated by `nodeId` so that: + * + * - homonym classes that both walk up to the SAME ancestor's method collapse to 1 hit + * - aliased re-exports that produce two candidates pointing at the same def collapse too + * + * After deduplication: + * + * - 0 unique matches → `undefined` (owner-scoped path has no answer; D1-D4 fuzzy + * fallback in `resolveCallTarget` may still find something via lookupFuzzy) + * - 1 unique match → return it + * - ≥2 unique matches → `undefined` (genuine homonym ambiguity; don't silently pick one) + * + * This absorbs what was previously D4's job inside `resolveCallTarget` — "filter fuzzy + * candidates to those whose ownerId is in the receiver type's nodeId set" — into the + * owner-scoped path, aligning with the plan's target: + * + * `resolveCallTarget` D2 widening → `model.lookupMethodWithMRO(ownerNodeId, name)` + * + * The returned `tier` reflects how the owner TYPE was resolved (not the method name). + * Threaded out here so callers don't need a second `ctx.resolve(ownerType, ...)` call — + * this decouples callers from `ctx.resolve`'s per-file caching contract, which SM-16 + * will restructure when it replaces the `lookupFuzzy` data source. */ const resolveMethodByOwner = ( receiverTypeName: string, @@ -1636,35 +1685,31 @@ const resolveMethodByOwner = ( filePath: string, ctx: ResolutionContext, heritageMap?: HeritageMap, -): SymbolDefinition | undefined => { +): { def: SymbolDefinition; tier: ResolutionTier } | undefined => { const typeResolved = ctx.resolve(receiverTypeName, filePath); if (!typeResolved) return undefined; - const classDef = typeResolved.candidates.find((d) => CLASS_LIKE_TYPES.has(d.type)); - if (!classDef) return undefined; - // When HeritageMap is available, delegate to MRO-aware lookup which performs - // the direct owner lookup itself before walking ancestors — avoids a double - // direct lookup on the hot path. - if (heritageMap) { - const language = getLanguageFromFilename(filePath); - if (language) { - return lookupMethodByOwnerWithMRO( - classDef.nodeId, - methodName, - heritageMap, - ctx.symbols, - language, - ); - } + // MRO walking needs a language hint; compute once and reuse for every candidate. + // Unknown extension → fall back to plain direct lookup (D1-D4 still runs on miss). + const language = heritageMap ? getLanguageFromFilename(filePath) : null; + const canWalkMRO = heritageMap != null && language != null; + + // Iterate ALL class-like candidates. Unique hits (by nodeId) land in `matches`: + // matches.size === 0 → owner-scoped resolution found nothing + // matches.size === 1 → unambiguous answer + // matches.size > 1 → genuine homonym ambiguity — refuse to pick one + const matches = new Map(); + for (const candidate of typeResolved.candidates) { + if (!CLASS_LIKE_TYPES.has(candidate.type)) continue; + const def = canWalkMRO + ? lookupMethodByOwnerWithMRO(candidate.nodeId, methodName, heritageMap, ctx.symbols, language) + : ctx.symbols.lookupMethodByOwner(candidate.nodeId, methodName); + if (def) matches.set(def.nodeId, def); } - // Fallback when no HeritageMap (or the file extension is unrecognized by - // `getLanguageFromFilename`, e.g. a synthetic path or an extension that is - // not registered in supported-languages.ts): plain direct lookup with no - // ancestor walk. All primary languages register their extensions, so this - // branch is only reached for edge cases where the MRO walk would not be - // applicable anyway. D1-D4 in resolveCallTarget still runs on D0 miss. - return ctx.symbols.lookupMethodByOwner(classDef.nodeId, methodName); + if (matches.size !== 1) return undefined; + const [def] = matches.values(); + return { def: def!, tier: typeResolved.tier }; }; // --------------------------------------------------------------------------- @@ -1682,6 +1727,21 @@ const resolveMethodByOwner = ( * {@link resolveCallTarget} delegates here for member calls before falling back * to the more expensive fuzzy-widening path (D1-D4). * + * **SEMANTIC CHANGE (2026-04-09):** The confidence tier now reflects how the + * owner TYPE was resolved, not how the method NAME was resolved globally. The + * previous D0 fast path in `resolveCallTarget` used `tiered.tier` from + * `ctx.resolve(calledName, ...)` — a name-based tier that matched what D1-D4 + * fuzzy widening would produce. The new tier is owner-type-based, which is + * more accurate for owner-scoped resolution (the discriminant IS the class, + * not the method name). Downstream consumers that filter CALLS edges by + * confidence threshold may see shifted values on otherwise-unchanged code. + * See the "returns result with correct confidence tier" tests below for the + * locked-in behavior. + * + * **Performance:** Callers that only need the return type (e.g. `walkMixedChain`) + * should call {@link resolveMethodByOwner} directly and use the `.def.returnType` + * field instead, to avoid building a throwaway `ResolveResult`. + * * @param ownerType - The receiver's type name (e.g. 'User') * @param methodName - The method being called (e.g. 'save') * @param currentFile - File path of the call site @@ -1695,16 +1755,9 @@ export const resolveMemberCall = ( ctx: ResolutionContext, heritageMap?: HeritageMap, ): ResolveResult | null => { - const methodDef = resolveMethodByOwner(ownerType, methodName, currentFile, ctx, heritageMap); - if (!methodDef) return null; - - // Determine confidence tier from how the owner type was resolved. - // ctx.resolve is per-file cached so this second call (resolveMethodByOwner - // already called it internally) is essentially free. - const ownerResolved = ctx.resolve(ownerType, currentFile); - const tier = ownerResolved?.tier ?? 'global'; - - return toResolveResult(methodDef, tier); + const resolved = resolveMethodByOwner(ownerType, methodName, currentFile, ctx, heritageMap); + if (!resolved) return null; + return toResolveResult(resolved.def, resolved.tier); }; // --------------------------------------------------------------------------- @@ -1896,12 +1949,17 @@ const walkMixedChain = ( currentType = fieldResolved.typeName; continue; } - // SM-11: delegate to resolveMemberCall for owner-scoped + MRO resolution. + // Fast path: O(1) owner-scoped method lookup via methodByOwner index. // Note: CALLS edges for intermediate chain steps are NOT emitted here — walkMixedChain // only threads types. CALLS edges come from the outer per-call-expression loop in processCalls. - const memberResult = resolveMemberCall(currentType, step.name, filePath, ctx, heritageMap); - if (memberResult?.returnType) { - const fastRetType = extractReturnTypeName(memberResult.returnType); + // + // We call `resolveMethodByOwner` directly (NOT `resolveMemberCall`) because this is + // a hot path — called per chain step per call expression — and we only need the + // return type string. Going through `resolveMemberCall` would allocate a throwaway + // `ResolveResult` with confidence/reason that we immediately discard. + const owned = resolveMethodByOwner(currentType, step.name, filePath, ctx, heritageMap); + if (owned?.def.returnType) { + const fastRetType = extractReturnTypeName(owned.def.returnType); if (fastRetType) { currentType = fastRetType; continue; diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index e3578b416..da5ea76ae 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -1403,7 +1403,10 @@ describe('lookupMethodByOwnerWithMRO', () => { // resolveMemberCall — SM-11: owner-scoped + MRO member-call resolution // --------------------------------------------------------------------------- -import { resolveMemberCall } from '../../src/core/ingestion/call-processor.js'; +import { + _resolveCallTargetForTesting, + resolveMemberCall, +} from '../../src/core/ingestion/call-processor.js'; describe('resolveMemberCall', () => { let ctx: ResolutionContext; @@ -1513,4 +1516,320 @@ describe('resolveMemberCall', () => { 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.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.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.symbols.add('src/writer.rs', 'Writer', 'trait:Writer', 'Trait'); + ctx.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct'); + ctx.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.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct'); + ctx.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.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); + ctx.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { + returnType: 'None', + ownerId: 'class:auth:User', + }); + ctx.symbols.add('src/legacy.py', 'User', 'class:legacy:User', 'Class'); + ctx.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.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); + ctx.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { + returnType: 'None', + ownerId: 'class:auth:User', + }); + ctx.symbols.add('src/legacy.py', 'User', 'class:legacy:User', 'Class'); + ctx.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.symbols.add('src/base.ts', 'BaseUser', 'class:BaseUser', 'Class'); + ctx.symbols.add('src/base.ts', 'save', 'method:BaseUser:save', 'Method', { + returnType: 'void', + ownerId: 'class:BaseUser', + }); + ctx.symbols.add('src/a.ts', 'User', 'class:a:User', 'Class'); + ctx.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.symbols.add('src/base.h', 'Base', 'class:Base', 'Class'); + ctx.symbols.add('src/a.h', 'A', 'class:A', 'Class'); + ctx.symbols.add('src/b.h', 'B', 'class:B', 'Class'); + ctx.symbols.add('src/derived.h', 'Derived', 'class:Derived', 'Class'); + ctx.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'); + }); +}); + +// --------------------------------------------------------------------------- +// T1: D0 skip-condition tests — verify resolveCallTarget bypasses the +// resolveMemberCall fast path when overloadHints, preComputedArgTypes, or a +// module alias is active. +// --------------------------------------------------------------------------- + +describe('resolveCallTarget D0 skip conditions (SM-11)', () => { + let ctx: ResolutionContext; + + beforeEach(() => { + ctx = createResolutionContext(); + }); + + it('module alias: resolution succeeds when hasActiveModuleAlias triggers D0 skip', () => { + // Python-style: `import auth; auth.User.save()`. The `receiverName='auth'` + // matches a moduleAliasMap entry, which sets `hasActiveModuleAlias=true` + // and bypasses the D0 fast path. The test verifies that D1-D4 still + // produces the correct result in this skip scenario — a regression here + // (e.g. D0 being called when it shouldn't) would silently pick a homonym + // from another file, and an unintentional skip would cause resolution to + // fail entirely. + ctx.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); + ctx.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { + returnType: 'None', + ownerId: 'class:auth:User', + }); + ctx.importMap.set('src/app.py', new Set(['src/auth.py'])); + ctx.moduleAliasMap.set('src/app.py', new Map([['auth', 'src/auth.py']])); + + const result = _resolveCallTargetForTesting( + { + calledName: 'save', + callForm: 'member', + receiverTypeName: 'User', + receiverName: 'auth', // triggers hasActiveModuleAlias → D0 skipped + }, + 'src/app.py', + ctx, + ); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('method:auth:User:save'); + }); + + it('module alias: resolveMemberCall called directly still works (control)', () => { + // Control case: calling resolveMemberCall directly (the path D0 would have + // taken) produces the same result. Demonstrates that the skip is a safety + // measure for the D2-widening interaction with alias narrowing, not because + // resolveMemberCall itself is broken here. + ctx.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); + ctx.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { + returnType: 'None', + ownerId: 'class:auth:User', + }); + ctx.importMap.set('src/app.py', new Set(['src/auth.py'])); + + const result = resolveMemberCall('User', 'save', 'src/app.py', ctx); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('method:auth:User:save'); + }); + + it('overloadHints present: D0 bypassed, D1-D4 handles resolution', () => { + // When overloadHints is supplied, the D0 fast path must be skipped + // because lookupMethodByOwner does not consider argument types and + // would pick an arbitrary overload for same-return-type overloads. + // + // This test verifies that the skip does not break resolution: passing + // a dummy overloadHints object should still yield the correct method + // via the D1-D4 path. + ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.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'])); + + // Minimal stub; D1-D4 only calls tryOverloadDisambiguation when there are + // multiple candidates, so an empty object is fine for single-candidate cases. + const dummyHints = {} as unknown as Parameters< + typeof _resolveCallTargetForTesting + >[3] extends infer O + ? O extends { overloadHints?: infer H } + ? H + : never + : never; + + 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 present: D0 bypassed, D1-D4 handles resolution', () => { + // Analogous to the overloadHints case: when preComputedArgTypes is supplied + // (worker path), D0 must be skipped so that type-based overload + // disambiguation in D1-D4 is authoritative. + ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.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'); + }); });