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:
Gergo Magyar 2026-04-09 07:17:57 +01:00
parent 6695c8aae6
commit c159743cb1
3 changed files with 419 additions and 43 deletions

View file

@ -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",

View file

@ -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;

View file

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