mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-07 02:58:02 +00:00
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>
This commit is contained in:
parent
1ff9e79fa4
commit
ca89b1cad9
2 changed files with 176 additions and 301 deletions
|
|
@ -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<string, readonly SymbolDefinition[]>;
|
||||
|
||||
/** @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<ExtractedCall, 'calledName' | 'argCount' | 'callForm' | 'receiverName'>,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue