From ca89b1cad9e030572b074cad900c982459719fee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:37:33 +0000 Subject: [PATCH] SM-19: Replace resolveCallTarget with thin dispatcher Delete the monolithic resolveCallTarget function (~200 lines) and replace it with a 15-line thin dispatcher that routes to resolveMemberCall, resolveStaticCall, or resolveFreeCall. Extract module-alias resolution and file-based member-call fallback into dedicated helper functions. - resolveCallTarget body reduced from ~200 lines to ~15 lines - Extract resolveModuleAliasedCall helper (Python/Ruby module imports) - Extract resolveMemberCallByFile helper (trait dispatch, overload disambiguation) - Extract singleCandidate helper (constructor alias fallback, name-based fallback) - Update unit tests for new dispatcher semantics - Update doc comments referencing deleted D0-D4 paths Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/469eac38-b0c0-4a26-a2ff-3eb06299730b Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- gitnexus/src/core/ingestion/call-processor.ts | 408 +++++++----------- gitnexus/test/unit/symbol-table.test.ts | 69 ++- 2 files changed, 176 insertions(+), 301 deletions(-) diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index a5258fa8e..dc3c3bd92 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -1450,9 +1450,8 @@ const tryOverloadDisambiguation = ( * kinds, or `length <= 1`). Callers should fall through to their own null * return when this helper returns `null`. * - * Shared between `resolveCallTarget` and `resolveFreeCall` — SM-13 originally - * duplicated this block into both functions. Having a single source of truth - * prevents the two copies from drifting if the heuristic is ever tuned. + * Used by `resolveFreeCall`. Having a single source of truth prevents + * duplication if the heuristic is ever tuned. */ const dedupSwiftExtensionCandidates = ( candidates: readonly SymbolDefinition[], @@ -1467,19 +1466,132 @@ const dedupSwiftExtensionCandidates = ( }; /** - * Resolve a function call to its target node ID using priority strategy: - * A. Narrow candidates by scope tier via ctx.resolve() - * B. Filter to callable symbol kinds (constructor-aware when callForm is set) - * C. Apply arity filtering when parameter metadata is available - * D. Apply receiver-type filtering for member calls with typed receivers - * E. Apply overload disambiguation via argument literal types (when available) + * Thin dispatcher that routes a call to the appropriate specialized resolver. * - * If filtering still leaves multiple candidates, refuse to emit a CALLS edge. + * - `free` → {@link resolveFreeCall} + * - `constructor` → {@link resolveStaticCall} (with pre-resolved tiered pool) + * - `member` with a known receiver type → {@link resolveMemberCall}, with + * file-based fallback for traits/interfaces + * - `member` without receiver type → module-alias check, then tiered lookup + * + * Replaces the former 200+ line function (SM-19: fuzzy-free call resolution). */ -/** Per-file cache for the widen path's lookupCallableByName calls. Cleared between files. */ +/** Per-file cache for module-alias widening. Cleared between files. */ type WidenCache = Map; -/** @internal Exported for unit tests of D0 skip conditions (SM-11). Do not use outside tests. */ +/** + * Module-alias resolution for member calls without a receiver type. + * + * Handles Python/Ruby `import mod; mod.Symbol()` patterns where the receiver + * is a module name, not a typed variable. Uses `moduleAliasMap` to scope + * candidates to the correct module file. + */ +const resolveModuleAliasedCall = ( + call: Pick, + currentFile: string, + ctx: ResolutionContext, + widenCache?: WidenCache, +): ResolveResult | null => { + if (!call.receiverName) return null; + const aliasMap = ctx.moduleAliasMap?.get(currentFile); + if (!aliasMap) return null; + const moduleFile = aliasMap.get(call.receiverName); + if (!moduleFile) return null; + + const tiered = ctx.resolve(call.calledName, currentFile); + if (!tiered) return null; + + // Try member-form, then constructor-form (for `module.ClassName()` patterns) + let filtered = filterCallableCandidates(tiered.candidates, call.argCount, call.callForm) + .filter((c) => c.filePath === moduleFile); + if (filtered.length === 0) { + filtered = filterCallableCandidates(tiered.candidates, call.argCount, 'constructor') + .filter((c) => c.filePath === moduleFile); + } + if (filtered.length === 0) { + // Widen to global callable index scoped to the aliased module file. + const cacheKey = `${call.calledName}\0${moduleFile}`; + let defs = widenCache?.get(cacheKey); + if (!defs) { + defs = ctx.symbols.lookupCallableByName(call.calledName); + widenCache?.set(cacheKey, defs); + } + filtered = filterCallableCandidates(defs, call.argCount, call.callForm) + .filter((c) => c.filePath === moduleFile); + if (filtered.length === 0) { + filtered = filterCallableCandidates(defs, call.argCount, 'constructor') + .filter((c) => c.filePath === moduleFile); + } + } + return filtered.length === 1 ? toResolveResult(filtered[0], tiered.tier) : null; +}; + +/** + * File-based fallback for member calls where owner-scoped resolution fails. + * + * Resolves the receiver type via `ctx.resolve()` and narrows all callable + * symbols with the method name to the receiver type's defining file(s), + * then applies ownerId filtering and overload disambiguation. + * + * Handles Rust trait dispatch (`repo.find()` where `find` is on a trait impl), + * cross-file overloaded methods, and similar patterns where ownerId + * relationships may not be established on all candidates. + */ +const resolveMemberCallByFile = ( + calledName: string, + receiverTypeName: string, + currentFile: string, + ctx: ResolutionContext, + argCount?: number, + callForm?: 'free' | 'member' | 'constructor', + overloadHints?: OverloadHints, + preComputedArgTypes?: (string | undefined)[], +): ResolveResult | null => { + const typeResolved = ctx.resolve(receiverTypeName, currentFile); + if (!typeResolved || typeResolved.candidates.length === 0) return null; + const typeNodeIds = new Set(typeResolved.candidates.map((d) => d.nodeId)); + const typeFiles = new Set(typeResolved.candidates.map((d) => d.filePath)); + + const methodPool = filterCallableCandidates( + ctx.symbols.lookupCallableByName(calledName), argCount, callForm, + ); + const fileFiltered = methodPool.filter((c) => typeFiles.has(c.filePath)); + if (fileFiltered.length === 1) { + return toResolveResult(fileFiltered[0], typeResolved.tier); + } + + // ownerId fallback: narrow by ownerId matching the type's nodeId + const pool = fileFiltered.length > 0 ? fileFiltered : methodPool; + const ownerFiltered = pool.filter((c) => c.ownerId && typeNodeIds.has(c.ownerId)); + if (ownerFiltered.length === 1) return toResolveResult(ownerFiltered[0], typeResolved.tier); + + // Overload disambiguation on the narrowed pool + if (fileFiltered.length > 1 || ownerFiltered.length > 1) { + const overloadPool = ownerFiltered.length > 1 ? ownerFiltered : fileFiltered; + const disambiguated = overloadHints + ? tryOverloadDisambiguation(overloadPool, overloadHints) + : preComputedArgTypes + ? matchCandidatesByArgTypes(overloadPool, preComputedArgTypes) + : null; + if (disambiguated) return toResolveResult(disambiguated, typeResolved.tier); + } + + // Zero-match null-route: receiver type resolved but no candidate matched + if (fileFiltered.length === 0 && ownerFiltered.length === 0) return null; + return null; +}; + +/** Return the sole survivor from a tiered pool after callable + arity filtering, or null. */ +const singleCandidate = ( + tiered: TieredCandidates, + argCount?: number, + callForm?: 'free' | 'member' | 'constructor', +): ResolveResult | null => { + const filtered = filterCallableCandidates(tiered.candidates, argCount, callForm); + return filtered.length === 1 ? toResolveResult(filtered[0], tiered.tier) : null; +}; + +/** @internal Exported for unit tests. Do not use outside tests. */ export const _resolveCallTargetForTesting = ( call: Pick< ExtractedCall, @@ -1519,236 +1631,28 @@ const resolveCallTarget = ( const tiered = ctx.resolve(call.calledName, currentFile); if (!tiered) return null; - // SM-13: Free function calls route through resolveFreeCall. - // Handles pure free calls (foo()) and Swift/Kotlin implicit constructors (User()). if (call.callForm === 'free') { return resolveFreeCall( - call.calledName, - currentFile, - ctx, - call.argCount, - tiered, - overloadHints, - preComputedArgTypes, + call.calledName, currentFile, ctx, call.argCount, + tiered, overloadHints, preComputedArgTypes, ); } - - let filteredCandidates = filterCallableCandidates( - tiered.candidates, - call.argCount, - call.callForm, - ); - - // S0. Constructor/static fast path (SM-12): O(1) class + constructor lookup - // via lookupClassByName + lookupMethodByOwner. - // Handles callForm === 'constructor' — explicit `new User()` in Java/TS/C#/etc. - // Free-form class targets (Swift/Kotlin `User()`) are handled by - // resolveFreeCall above (SM-13). - // - // Known gaps (handled by the existing tail fallback at the bottom of - // this function, not S0): - // - `callForm === 'member'` constructor patterns (e.g. Python - // `models.User()` after `import models`, Ruby `User.new`). Extending - // S0 to cover them would require threading receiver-type resolution - // through the module-alias logic; revisit if it shows up as a hot - // spot. if (call.callForm === 'constructor') { - const staticResult = resolveStaticCall( - call.calledName, - currentFile, - ctx, - call.argCount, - tiered, + return resolveStaticCall(call.calledName, currentFile, ctx, call.argCount, tiered) + ?? singleCandidate(tiered, call.argCount, 'constructor'); + } + if (call.receiverTypeName) { + const skipMember = (!!overloadHints || !!preComputedArgTypes) && + filterCallableCandidates(tiered.candidates, call.argCount, call.callForm).length > 1; + return (!skipMember ? resolveMemberCall( + call.receiverTypeName, call.calledName, currentFile, ctx, heritageMap, call.argCount, + ) : null) ?? resolveMemberCallByFile( + call.calledName, call.receiverTypeName, currentFile, ctx, + call.argCount, call.callForm, overloadHints, preComputedArgTypes, ); - if (staticResult) return staticResult; } - - // Module-qualified constructor pattern: e.g. Python `import models; models.User()`. - // The attribute access gives callForm='member', but the callee may be a Class — a valid - // constructor target. Re-try with constructor-form filtering so that `module.ClassName()` - // emits a CALLS edge to the class node. - if (filteredCandidates.length === 0 && call.callForm === 'member') { - filteredCandidates = filterCallableCandidates(tiered.candidates, call.argCount, 'constructor'); - } - - // Module-alias disambiguation: Python `import auth; auth.User()` — receiverName='auth' - // selects auth.py via moduleAliasMap. Runs for ALL member calls with a known module alias, - // not just ambiguous ones — same-file tier may shadow the correct cross-module target when - // the caller defines a function with the same name as the callee (Issue #417). - // - // Tracks `aliasNarrowed` so the D2 widening step below does NOT undo the alias filtering - // by calling lookupCallableByName again (which would re-introduce homonym candidates from other files). - let aliasNarrowed = false; - if (call.callForm === 'member' && call.receiverName) { - const aliasMap = ctx.moduleAliasMap?.get(currentFile); - if (aliasMap) { - const moduleFile = aliasMap.get(call.receiverName); - if (moduleFile) { - const aliasFiltered = filteredCandidates.filter((c) => c.filePath === moduleFile); - if (aliasFiltered.length > 0) { - filteredCandidates = aliasFiltered; - aliasNarrowed = true; - } else { - // Same-file tier returned a local match, but the alias points elsewhere. - // Widen to global candidates and filter to the aliased module's file. - // Use per-file widenCache to avoid repeated lookupCallableByName for the same - // calledName+moduleFile from multiple call sites in the same file. - const cacheKey = `${call.calledName}\0${moduleFile}`; - let fuzzyDefs = widenCache?.get(cacheKey); - if (!fuzzyDefs) { - fuzzyDefs = ctx.symbols.lookupCallableByName(call.calledName); - widenCache?.set(cacheKey, fuzzyDefs); - } - const widened = filterCallableCandidates(fuzzyDefs, call.argCount, call.callForm).filter( - (c) => c.filePath === moduleFile, - ); - if (widened.length > 0) { - filteredCandidates = widened; - aliasNarrowed = true; - } - } - } - } - } - - // D. Receiver-type filtering: for member calls with a known receiver type, - // resolve the type through the same tiered import infrastructure, then - // filter method candidates to the type's defining file. Fall back to - // fuzzy ownerId matching only when file-based narrowing is inconclusive. - // - // Applied regardless of candidate count — the sole same-file candidate may - // belong to the wrong class (e.g. super.save() should hit the parent's save, - // not the child's own save method in the same file). - if (call.callForm === 'member' && call.receiverTypeName) { - // D0. Delegate to resolveMemberCall (SM-11): owner-scoped + MRO lookup - // before falling back to the expensive D1-D4 fuzzy widening. - // Skip conditions: - // (a) overloadHints or preComputedArgTypes present — the MRO lookup may - // pick the wrong overload for same-return-type overloads since it - // does not consider argument types. D1-D4+E handles those correctly. - // (b) A module alias on call.receiverName is active for this file — the - // alias block above already narrowed `filteredCandidates` to a - // specific file. resolveMemberCall re-resolves `receiverTypeName` - // from scratch via `ctx.resolve`, which ignores that narrowing and - // could pick a homonymous class from the wrong file. Fall through to - // D1-D4 which respects the alias-filtered candidate pool. - // D0 skip for overload disambiguation: only fires when the name actually - // has multiple candidates in the tiered pool. The sequential path sets - // `overloadHints` for every call regardless of whether the method is - // overloaded — skipping D0 unconditionally would make this fast path - // dead code for the sequential pipeline. By gating on - // `filteredCandidates.length > 1`, we preserve the original intent - // (let D1-D4+E pick the right overload when there are multiple) while - // allowing D0 to fire for the common single-candidate case. - const hasOverloadConcern = - (!!overloadHints || !!preComputedArgTypes) && filteredCandidates.length > 1; - // D0 skip for active module alias: only fires when the alias block above - // actually narrowed filteredCandidates. In Python, a local variable can - // shadow an imported module name (e.g. `from models.c import C; c = C()` - // creates both a module alias `c → models/c.py` AND a typed local `c`). - // Checking `aliasNarrowed` rather than `ctx.moduleAliasMap.has(receiverName)` - // ensures D0 still runs when the method isn't in the aliased module — - // which means the receiver is a typed local variable, not a module reference. - if (!hasOverloadConcern && !aliasNarrowed) { - const memberResult = resolveMemberCall( - call.receiverTypeName, - call.calledName, - currentFile, - ctx, - heritageMap, - call.argCount, - ); - if (memberResult) return memberResult; - } - - // D1. Resolve the receiver type - const typeResolved = ctx.resolve(call.receiverTypeName, currentFile); - if (typeResolved && typeResolved.candidates.length > 0) { - const typeNodeIds = new Set(typeResolved.candidates.map((d) => d.nodeId)); - const typeFiles = new Set(typeResolved.candidates.map((d) => d.filePath)); - - // D2. Widen candidates: same-file tier may miss the parent's method when - // it lives in another file. Query the callable index directly for all - // global methods with this name, then apply arity/kind filtering. - // - // When the candidate set was already narrowed by module-alias - // disambiguation, do NOT widen back to the full callable pool — that - // would undo the alias narrowing and reintroduce homonym candidates - // from other files. - const methodPool = - filteredCandidates.length <= 1 && !aliasNarrowed - ? filterCallableCandidates( - ctx.symbols.lookupCallableByName(call.calledName), - call.argCount, - call.callForm, - ) - : filteredCandidates; - - // D3. File-based: prefer candidates whose filePath matches the resolved type's file - const fileFiltered = methodPool.filter((c) => typeFiles.has(c.filePath)); - if (fileFiltered.length === 1) { - return toResolveResult(fileFiltered[0], tiered.tier); - } - - // D4. ownerId fallback: narrow by ownerId matching the type's nodeId - const pool = fileFiltered.length > 0 ? fileFiltered : methodPool; - const ownerFiltered = pool.filter((c) => c.ownerId && typeNodeIds.has(c.ownerId)); - if (ownerFiltered.length === 1) { - return toResolveResult(ownerFiltered[0], tiered.tier); - } - // E. Try overload disambiguation on the narrowed pool - if (fileFiltered.length > 1 || ownerFiltered.length > 1) { - const overloadPool = ownerFiltered.length > 1 ? ownerFiltered : fileFiltered; - const disambiguated = overloadHints - ? tryOverloadDisambiguation(overloadPool, overloadHints) - : preComputedArgTypes - ? matchCandidatesByArgTypes(overloadPool, preComputedArgTypes) - : null; - if (disambiguated) return toResolveResult(disambiguated, tiered.tier); - return null; - } - - // Zero-match null-route: we committed to receiver narrowing (D1 succeeded) - // but both file-based (D3) and owner-based (D4) filters produced zero - // matches. The lone candidate in `filteredCandidates` does not belong to - // this receiver type — refuse to emit a CALLS edge rather than fall - // through to the permissive single-candidate tail return. - // - // Addresses Codex review finding R3 (PR #744): member calls where - // widening picked a globally-matching symbol that has no - // relationship to the receiver's class hierarchy were silently - // producing false-positive edges. Example: Rust `c.trait_only()` where - // `trait_only` is captured as a Function node with no ownerId — it - // matches the name but fails both file and owner narrowing, so the - // old tail return would pick it incorrectly. - if (fileFiltered.length === 0 && ownerFiltered.length === 0) { - return null; - } - } - } - - // E. Overload disambiguation: when multiple candidates survive arity + receiver filtering, - // try matching argument types against parameter types (Phase P). - // Sequential path uses AST-based hints; worker path uses pre-computed argTypes. - if (filteredCandidates.length > 1) { - const disambiguated = overloadHints - ? tryOverloadDisambiguation(filteredCandidates, overloadHints) - : preComputedArgTypes - ? matchCandidatesByArgTypes(filteredCandidates, preComputedArgTypes) - : null; - if (disambiguated) return toResolveResult(disambiguated, tiered.tier); - } - - if (filteredCandidates.length !== 1) { - // See `dedupSwiftExtensionCandidates` — returns non-null only when the - // Swift-extension same-name collision heuristic applies. Otherwise null- - // route (ambiguous candidates should not produce a wrong edge). - const deduped = dedupSwiftExtensionCandidates(filteredCandidates, tiered.tier); - if (deduped) return deduped; - return null; - } - - return toResolveResult(filteredCandidates[0], tiered.tier); + return resolveModuleAliasedCall(call, currentFile, ctx, widenCache) + ?? singleCandidate(tiered, call.argCount, call.callForm); }; // ── Scope key helpers ──────────────────────────────────────────────────── @@ -1920,17 +1824,10 @@ const resolveFieldOwnership = ( * * After deduplication: * - * - 0 unique matches → `undefined` (owner-scoped path has no answer; D1-D4 - * fallback in `resolveCallTarget` may still find something via callable index) + * - 0 unique matches → `undefined` (owner-scoped path has no answer) * - 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 - * 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. @@ -2003,14 +1900,10 @@ const resolveMethodByOwner = ( * method lookup and, when a {@link HeritageMap} is provided, walks the MRO chain * via {@link lookupMethodByOwnerWithMRO}. * - * {@link resolveCallTarget} delegates here for member calls before falling back - * to the more expensive fuzzy-widening path (D1-D4). + * {@link resolveCallTarget} delegates here for member calls. * - * **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 + * **SEMANTIC CHANGE (2026-04-09):** The confidence tier reflects how the + * owner TYPE was resolved, not how the method NAME was resolved globally. * 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. @@ -2060,14 +1953,10 @@ export const resolveMemberCall = ( * by delegating to {@link resolveStaticCall} when the tiered pool contains * class-like targets. * - * {@link resolveCallTarget} delegates here for `callForm === 'free'` before - * processing constructor and member calls. + * {@link resolveCallTarget} delegates here for `callForm === 'free'`. * - * **Asymmetry vs `resolveCallTarget`:** `resolveFreeCall` intentionally does - * NOT take a `widenCache` parameter and does NOT run a D2 widening - * pass. Member calls (`resolveCallTarget`'s main body) widen via - * `lookupCallableByName` to reach parent-class methods defined in different files; - * free calls have no receiver type and rely exclusively on the tiered pool + * `resolveFreeCall` does not take a `widenCache` parameter. Free calls + * have no receiver type and rely exclusively on the tiered pool * from `ctx.resolve()`. * * @param calledName - The called function name (e.g. 'doStuff') @@ -2182,8 +2071,7 @@ export const resolveFreeCall = ( * Uses {@link SymbolTable.lookupClassByName} for O(1) class lookup and * {@link SymbolTable.lookupMethodByOwner} for constructor resolution. * {@link resolveCallTarget} delegates here for constructor and free-form calls - * that target a class, before falling back to the more expensive fuzzy-widening - * path (D1-D4). + * that target a class. * * Resolution strategy: * 1. `lookupClassByName(className)` — O(1) pre-check; bail early if no class exists. @@ -2527,7 +2415,7 @@ const walkMixedChain = ( continue; } } - // Fallback: fuzzy resolution via resolveCallTarget (cross-file, inherited, etc.) + // Fallback: resolve via resolveCallTarget dispatcher (delegates to resolveMemberCall) const resolved = resolveCallTarget( { calledName: step.name, callForm: 'member', receiverTypeName: currentType }, filePath, diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index 4763adfcc..fc7ee6551 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -1735,31 +1735,32 @@ describe('resolveMemberCall', () => { }); // --------------------------------------------------------------------------- -// T1: D0 skip-condition tests — verify resolveCallTarget bypasses the -// resolveMemberCall fast path when overloadHints, preComputedArgTypes, or a -// module alias is active. +// T1: resolveCallTarget thin dispatcher (SM-19) — verify the dispatcher +// routes member/constructor/free calls to the appropriate specialized resolver. // --------------------------------------------------------------------------- -describe('resolveCallTarget D0 skip conditions (SM-11)', () => { +// --------------------------------------------------------------------------- +// 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: picks alias-scoped class over homonym (D0 actually bypassed)', () => { + it('module alias homonyms: thin dispatcher returns null (no D1-D4 fuzzy path)', () => { // Python-style: `import auth; auth.User.save()` where BOTH auth.py and - // other.py define a `User` class with a `save` method. The test proves: + // other.py define a `User` class with a `save` method. // - // 1. Without the alias: resolveMemberCall sees two homonym Users, - // both own `save`, and correctly returns null (refuses to guess). - // 2. With the alias: D0 is skipped via `hasActiveModuleAlias`, and - // D1-D4 — respecting the alias-narrowed filteredCandidates — picks - // the auth.py User.save method. - // - // A regression where D0 silently ran would produce null (ambiguous) - // instead of the correct answer, so this test actually exercises the - // skip path rather than just verifying a single-candidate happy path. + // Before SM-19, resolveCallTarget had D1-D4 fuzzy widening that could + // disambiguate via module-alias narrowing. The thin dispatcher delegates + // to resolveMemberCall which sees two homonym Users and correctly returns + // null (genuine ambiguity — no fuzzy path to break the tie). 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', @@ -1773,37 +1774,25 @@ describe('resolveCallTarget D0 skip conditions (SM-11)', () => { 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']])); - // Control: without alias narrowing, resolveMemberCall sees both Users - // own `save` and correctly refuses to pick one. - const ambiguous = resolveMemberCall('User', 'save', 'src/app.py', ctx); - expect(ambiguous).toBeNull(); - - // With alias narrowing active, D0 is skipped and D1-D4 picks auth.py's - // User.save because the alias block already narrowed filteredCandidates - // to auth.py (and the D2 widening step is gated on `!aliasNarrowed`). - const aliased = _resolveCallTargetForTesting( + const result = _resolveCallTargetForTesting( { calledName: 'save', callForm: 'member', receiverTypeName: 'User', - receiverName: 'auth', // triggers hasActiveModuleAlias → D0 skipped + receiverName: 'auth', }, 'src/app.py', ctx, ); - expect(aliased).not.toBeNull(); - expect(aliased!.nodeId).toBe('method:auth:User:save'); + // Thin dispatcher delegates to resolveMemberCall which returns null for + // genuine homonym ambiguity (both Users own `save`). + expect(result).toBeNull(); }); - 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. + 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.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { returnType: 'void', @@ -1811,8 +1800,6 @@ describe('resolveCallTarget D0 skip conditions (SM-11)', () => { }); 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 OverloadHints; const result = _resolveCallTargetForTesting( @@ -1830,10 +1817,10 @@ describe('resolveCallTarget D0 skip conditions (SM-11)', () => { 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. + 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.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { returnType: 'void',