mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-23 00:41:36 +00:00
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
This commit is contained in:
parent
6695c8aae6
commit
c159743cb1
3 changed files with 419 additions and 43 deletions
1
gitnexus/package-lock.json
generated
1
gitnexus/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1279,6 +1279,31 @@ const tryOverloadDisambiguation = (
|
|||
/** Per-file cache for the widen path's lookupFuzzy calls. 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. */
|
||||
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<string, SymbolDefinition>();
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue