mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
feat(SM-12): Extract resolveStaticCall from resolveCallTarget (#754)
* Initial plan * feat(SM-12): extract resolveStaticCall from resolveCallTarget - Add resolveStaticCall(className, methodName, currentFile, ctx, argCount?) using lookupClassByName + lookupMethodByOwner for O(1) constructor/static resolution - Add S0 fast path in resolveCallTarget for constructor/free-form class calls - Export resolveStaticCall from call-processor.ts - Add 11 unit tests covering constructor resolution, confidence tiers, arity disambiguation, and resolveCallTarget delegation Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c9471ca9-57ff-4dae-956e-e7ffdc326bc4 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * chore: revert unrelated package-lock.json change Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c9471ca9-57ff-4dae-956e-e7ffdc326bc4 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * refactor: shorten verbose test name per code review feedback Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c9471ca9-57ff-4dae-956e-e7ffdc326bc4 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix(SM-12): address PR #754 review findings Addresses Claude's review comments on PR #754: Performance - Pass pre-computed `tiered` result into `resolveStaticCall` as optional `tieredOverride` parameter, eliminating the duplicate `ctx.resolve(className, currentFile)` on every constructor call path. - Cache `freeFormHasClassTarget` in `resolveCallTarget` so the S0 fast path and the free-form constructor retry share a single `.some()` scan. Architecture - Reconcile `CLASS_LIKE_TYPES` (call-processor) with `CLASS_TYPES` (symbol-table): `CLASS_LIKE_TYPES = [...CLASS_TYPES, 'Impl']`. This makes the relationship explicit — the call resolver's set is a strict superset of the heritage-index set, guaranteeing anything reachable via `lookupClassByName` also passes the resolver filter. Trait is now included (harmless: traits have no Constructor nodes, so step-3 returns undefined and step-5 still returns the class-like node when unique). Documented the Interface inclusion rationale (static methods + MRO walker). - Collapse `resolveStaticCall`'s `methodName` parameter into `className` — all call sites passed identical values. Named constructors (Dart `User.fromJson()`) arrive as member calls and go through `resolveMemberCall`. Documented the reserved path for when a language surfaces a static-method-shaped call with a distinct member name. - Document the known gap: `callForm === 'member'` constructor patterns (e.g. Python `models.User()`) are handled by the tail fallback, not S0. Tests - Add tiered-override test asserting `ctx.resolve` is not re-invoked when a pre-computed result is passed in. - Add language-specific `_resolveCallTargetForTesting` integration tests for Java (`new User()`), Python (`User()`), and Kotlin (`User()`). Verification: 3031 unit + 1766 integration tests pass, zero regressions. * fix(SM-12): restrict resolveStaticCall fallback to instantiable kinds Addresses the high-severity finding from the Codex adversarial review of PR #754: `resolveStaticCall`'s step-5 "return the class itself when no Constructor node is found" fallback reused `CLASS_LIKE_TYPES`, which — after SM-11 and PR #754's reconciliation — now includes `Interface`, `Trait`, and `Impl`. That is the method-dispatch set, not the instantiable set, so constructor-shaped calls could resolve to non-instantiable nodes and emit false `CALLS` edges. Concrete failure: Rust same-file `impl User { ... }` alongside `struct User { ... }` — both land at same-file tier, the Impl is not filtered out, and the step-5 fallback produces a `CALLS` edge to the `Impl` block instead of the `Struct`. The same widening exposed Interface / Trait targets in Java / C# / PHP / Scala. Fix - Introduce `INSTANTIABLE_CLASS_TYPES = {'Class', 'Struct', 'Record'}` as a sibling to `CLASS_LIKE_TYPES`, documenting the contract explicitly and cross-referencing `CONSTRUCTOR_TARGET_TYPES`. - Update `CLASS_LIKE_TYPES` JSDoc to clarify it is the method-dispatch set and add an anti-pattern warning against reusing it for constructor-fallback filtering. - Tighten `resolveStaticCall` step 5: filter `classCandidates` through `INSTANTIABLE_CLASS_TYPES` before the `length === 1` check. This strips `Impl` from the Rust shadowing scenario (leaving `Struct` as the sole instantiable target) and null-routes Interface / Trait / `Impl`-alone scenarios, matching the SM-10 R3 null-route precedent. - Step 3 (explicit Constructor lookup via `lookupMethodByOwner`) is intentionally unchanged — its `def.type === 'Constructor'` check is the correct contract, and legitimate Constructor nodes attached to `Impl` owners still resolve correctly. Tests (+10 regression scenarios) - Positive guards: Struct, Record fallback paths. - Null-route: Interface (Java/C#/TS), PHP Trait, Rust Trait. - Rust same-file shadowing: Struct wins over Impl. - Rust Impl-alone: null-routes (no Struct present). - Step-3 preservation: Constructor owned by Impl still resolves to the Constructor node, proving step-5 tightening doesn't leak into step 3. - Full cascade via `_resolveCallTargetForTesting` for Interface and Trait — confirms no downstream path silently re-introduces the edge. Verification - `tsc --noEmit` clean - 3041 unit tests pass (+10) - 1766 integration tests pass - Zero regressions Plan: docs/plans/2026-04-09-002-fix-sm12-constructor-fallback-instantiable-only-plan.md Codex review job: review-mnrao7fr-nv9y0e * fix(SM-12): address PR #754 second review round Addresses the 9 findings from the follow-up review on PR #754. Performance - Align `freeFormHasClassTarget` with `INSTANTIABLE_CLASS_TYPES`: drop `Enum` (S0 would always return null for it — wasted lookup work) and add `Record` (C# records and Kotlin data classes were bypassing S0 entirely). The trigger set and the fallback filter set now agree by construction, documented inline. Documentation - Remove stale single-line JSDoc on `CLASS_LIKE_TYPES` (line 57) that duplicated the full multi-line block immediately below it — tooling picks up the first block so the old one-liner was shadowing the current explanation. - Rewrite the `resolveStaticCall` JSDoc step list to match the actual step boundaries in the implementation (steps 3, 4, 5 were blurred in the old description). - Add inline comment on step 3 documenting the same-name lookup assumption (`${candidate.nodeId}\0${className}`) and the symmetric miss case for Python `__init__`-style constructors. - Add inline comment on step 4 documenting that it also catches the ambiguous-step-3 case, and warning against removing the check without handling that path explicitly. - Add inline comment on step 5 enumerating the three length outcomes (0 / 1 / >1) so future readers see the dominant null-route case. - Document Ruby `User.new` as a known gap alongside Python `models.User()` in the S0 header comment. Tests (+2 scenarios) - Record free-form constructor call via `_resolveCallTargetForTesting` exercises the aligned `freeFormHasClassTarget` trigger end-to-end, closing the gap where the direct `resolveStaticCall` test passed but the integration path was silently bypassing S0. - Arity threading via `_resolveCallTargetForTesting` asserts that `call.argCount` flows through resolveCallTarget → S0 → resolveStaticCall → lookupMethodByOwner, catching any future regression where the argCount is dropped at the S0 call site. Verification - `tsc --noEmit` clean - 3043 unit tests pass (+2) - 1766 integration tests pass - Zero regressions Plan: docs/plans/2026-04-09-002-fix-sm12-constructor-fallback-instantiable-only-plan.md Review: https://github.com/abhigyanpatwari/GitNexus/pull/754#issuecomment-4213536094 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
This commit is contained in:
parent
a861baa869
commit
503de8bed8
2 changed files with 663 additions and 6 deletions
|
|
@ -1,9 +1,11 @@
|
|||
import { KnowledgeGraph } from '../graph/types.js';
|
||||
import { ASTCache } from './ast-cache.js';
|
||||
import type { SymbolDefinition, SymbolTable } from './symbol-table.js';
|
||||
import { CLASS_TYPES } from './symbol-table.js';
|
||||
import Parser from 'tree-sitter';
|
||||
import type { ResolutionContext } from './resolution-context.js';
|
||||
import { TIER_CONFIDENCE, type ResolutionTier } from './resolution-context.js';
|
||||
import type { TieredCandidates } from './resolution-context.js';
|
||||
import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
|
||||
import { getProvider } from './languages/index.js';
|
||||
import { generateId } from '../../lib/utils.js';
|
||||
|
|
@ -52,8 +54,51 @@ import { extractParsedCallSite } from './call-sites/extract-language-call-site.j
|
|||
* Populated during call processing, consumed by Phase 14 re-resolution pass. */
|
||||
export type ExportedTypeMap = Map<string, Map<string, string>>;
|
||||
|
||||
/** Types that represent class-like declarations (used for receiver/owner resolution). */
|
||||
const CLASS_LIKE_TYPES = new Set(['Class', 'Struct', 'Interface', 'Enum', 'Record', 'Impl']);
|
||||
/**
|
||||
* Type labels treated as class-like **method-dispatch receivers** by the call
|
||||
* resolver — the set walked by the MRO / heritage path for member and static
|
||||
* method calls.
|
||||
*
|
||||
* Derived from `CLASS_TYPES` (the heritage-index set in symbol-table) plus
|
||||
* `Impl` — Rust `impl` blocks are the definition site of methods for a struct
|
||||
* and must be walkable as receiver-type candidates even though they are not
|
||||
* indexed by `lookupClassByName` (which keys off struct/trait names). Keeping
|
||||
* this set a strict superset of `CLASS_TYPES` guarantees that anything
|
||||
* reachable via `lookupClassByName` also passes this filter, so the two call
|
||||
* paths cannot diverge silently.
|
||||
*
|
||||
* `Interface` is included even though interfaces cannot be directly
|
||||
* instantiated in Java/C#/TypeScript: the resolver still needs to reach
|
||||
* interface nodes for static-method dispatch (`Interface.staticMethod()`) and
|
||||
* default-method resolution via the MRO walker.
|
||||
*
|
||||
* **Do not reuse this set for constructor-fallback filtering.** Constructors
|
||||
* can only instantiate a narrower subset — see `INSTANTIABLE_CLASS_TYPES`
|
||||
* below. `resolveStaticCall`'s step-5 class-node fallback uses the narrower
|
||||
* set to prevent false `CALLS` edges from constructor-shaped calls to
|
||||
* `Interface`, `Trait`, or `Impl` nodes.
|
||||
*/
|
||||
const CLASS_LIKE_TYPES = new Set<string>([...CLASS_TYPES, 'Impl']);
|
||||
|
||||
/**
|
||||
* Type labels that can be the target of a constructor-shaped call when no
|
||||
* explicit `Constructor` symbol is indexed — the "return the type itself as
|
||||
* the call target" fallback set.
|
||||
*
|
||||
* Strict subset of both `CLASS_LIKE_TYPES` and `CONSTRUCTOR_TARGET_TYPES`.
|
||||
* Excludes:
|
||||
* - `Interface` / `Trait` — not instantiable by definition in any
|
||||
* supported language.
|
||||
* - `Impl` — Rust `impl` blocks are method-definition containers, not
|
||||
* the type itself; the owning `Struct` is the correct target.
|
||||
* - `Enum` — excluded pending language-specific support with motivating
|
||||
* test fixtures (matches `CONSTRUCTOR_TARGET_TYPES`).
|
||||
*
|
||||
* Used exclusively by `resolveStaticCall`'s step-5 class-node fallback.
|
||||
* Keep in sync with `CONSTRUCTOR_TARGET_TYPES` (which additionally contains
|
||||
* `'Constructor'` for explicit-constructor-node filtering) when extending.
|
||||
*/
|
||||
const INSTANTIABLE_CLASS_TYPES = new Set<string>(['Class', 'Struct', 'Record']);
|
||||
|
||||
const MAX_EXPORTS_PER_FILE = 500;
|
||||
const MAX_TYPE_NAME_LENGTH = 256;
|
||||
|
|
@ -1331,14 +1376,55 @@ const resolveCallTarget = (
|
|||
call.callForm,
|
||||
);
|
||||
|
||||
// S0. Constructor/static fast path (SM-12): O(1) class + constructor lookup
|
||||
// via lookupClassByName + lookupMethodByOwner before falling back to the
|
||||
// existing filtering + fuzzy-widening path. Falls back to the class node
|
||||
// itself when no Constructor symbol is indexed for the type.
|
||||
//
|
||||
// Handles:
|
||||
// (a) callForm === 'constructor' — explicit `new User()` in Java/TS/C#/etc.
|
||||
// (b) callForm === 'free' with class target — implicit `User()` in Swift/Kotlin
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// The `.some()` trigger below must stay aligned with
|
||||
// `INSTANTIABLE_CLASS_TYPES` — any type admitted here that is not in
|
||||
// that set will cause S0 → `resolveStaticCall` to run and return null,
|
||||
// wasting two lookup passes per call. `Enum` is deliberately excluded
|
||||
// (same rationale as `INSTANTIABLE_CLASS_TYPES`); `Record` is included
|
||||
// so C# records and Kotlin data classes reach the fast path.
|
||||
const freeFormHasClassTarget =
|
||||
call.callForm === 'free' &&
|
||||
filteredCandidates.length === 0 &&
|
||||
tiered.candidates.some((c) => c.type === 'Class' || c.type === 'Struct' || c.type === 'Record');
|
||||
if (call.callForm === 'constructor' || freeFormHasClassTarget) {
|
||||
// Reuse the pre-computed `tiered` result — resolveStaticCall's class name
|
||||
// is identical to `call.calledName` here, so re-running ctx.resolve would
|
||||
// duplicate the tiered-lookup work performed at the top of this function.
|
||||
const staticResult = resolveStaticCall(
|
||||
call.calledName,
|
||||
currentFile,
|
||||
ctx,
|
||||
call.argCount,
|
||||
tiered,
|
||||
);
|
||||
if (staticResult) return staticResult;
|
||||
}
|
||||
|
||||
// Swift/Kotlin: constructor calls look like free function calls (no `new` keyword).
|
||||
// If free-form filtering found no callable candidates but the symbol resolves to a
|
||||
// Class/Struct, retry with constructor form so CONSTRUCTOR_TARGET_TYPES applies.
|
||||
if (filteredCandidates.length === 0 && call.callForm === 'free') {
|
||||
const hasTypeTarget = tiered.candidates.some(
|
||||
(c) => c.type === 'Class' || c.type === 'Struct' || c.type === 'Enum',
|
||||
);
|
||||
if (hasTypeTarget) {
|
||||
// `freeFormHasClassTarget` was already computed for the S0 fast path
|
||||
// above under the same `callForm === 'free' && filteredCandidates.length === 0`
|
||||
// precondition. Reuse it to avoid a second `.some()` scan on the same pool.
|
||||
if (freeFormHasClassTarget) {
|
||||
filteredCandidates = filterCallableCandidates(
|
||||
tiered.candidates,
|
||||
call.argCount,
|
||||
|
|
@ -1843,6 +1929,153 @@ export const resolveMemberCall = (
|
|||
return toResolveResult(resolved.def, resolved.tier);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SM-12: Constructor/static call resolution (no fuzzy lookup)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve a constructor or static call using class-scoped lookup (no fuzzy lookup).
|
||||
* Used for `new User()` / `User()` calls where the calledName targets a class.
|
||||
*
|
||||
* 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).
|
||||
*
|
||||
* Resolution strategy:
|
||||
* 1. `lookupClassByName(className)` — O(1) pre-check; bail early if no class exists.
|
||||
* 2. `ctx.resolve(className, currentFile)` — import-scoped tier for confidence.
|
||||
* 3. Filter to class-like candidates via `CLASS_LIKE_TYPES` and walk each
|
||||
* with `lookupMethodByOwner(classNodeId, className, argCount)` — O(1)
|
||||
* constructor lookup. Only accept results with `type === 'Constructor'`.
|
||||
* 4. If step 3 found nothing and the tiered pool contains ownerless
|
||||
* `Constructor` nodes (common in some extractors), bail out so
|
||||
* `filterCallableCandidates` downstream handles Constructor-vs-Class
|
||||
* preference correctly.
|
||||
* 5. Class-node fallback: filter `classCandidates` through
|
||||
* `INSTANTIABLE_CLASS_TYPES` and return the sole survivor when there is
|
||||
* exactly one. Null-route on zero survivors (Interface / Trait / Impl
|
||||
* stripped) or multiple (homonym ambiguity).
|
||||
*
|
||||
* @param className - The class name (e.g. 'User'). Also used as the method
|
||||
* name for the `lookupMethodByOwner` scan, because the
|
||||
* only constructor-shaped call we handle today is
|
||||
* `ClassName(...)` / `new ClassName(...)`. Named
|
||||
* constructors like Dart `User.fromJson()` arrive as
|
||||
* member calls and route through `resolveMemberCall`,
|
||||
* so this function does not yet need a separate
|
||||
* `methodName` parameter. Revisit if a language surfaces
|
||||
* a static-method-shaped call with a distinct member
|
||||
* name.
|
||||
* @param currentFile - File path of the call site
|
||||
* @param ctx - Resolution context
|
||||
* @param argCount - Optional argument count for arity filtering
|
||||
* @param tieredOverride - Pre-computed tiered candidates for `className` from
|
||||
* an upstream `ctx.resolve` call. When provided, skips
|
||||
* the redundant lookup inside this function. Leave
|
||||
* unset for direct callers without a prior resolution.
|
||||
*/
|
||||
export const resolveStaticCall = (
|
||||
className: string,
|
||||
currentFile: string,
|
||||
ctx: ResolutionContext,
|
||||
argCount?: number,
|
||||
tieredOverride?: TieredCandidates,
|
||||
): ResolveResult | null => {
|
||||
// 1. Pre-check: does a class with this name exist at all? (O(1))
|
||||
// This guards against the expensive `ctx.resolve` walk when the name
|
||||
// is clearly not class-like (e.g. plain functions). When `tieredOverride`
|
||||
// is supplied, the caller has already paid for the tiered lookup, so this
|
||||
// pre-check still prevents the class-candidate filter + lookupMethodByOwner
|
||||
// loop from running on obviously non-class targets.
|
||||
const allClasses = ctx.symbols.lookupClassByName(className);
|
||||
if (allClasses.length === 0) return null;
|
||||
|
||||
// 2. Scope via ctx.resolve for import-tier information. Reuse the caller's
|
||||
// tiered result when provided — it is computed from the same name and
|
||||
// file context, so re-running the walk would be a pure waste.
|
||||
const typeResolved = tieredOverride ?? ctx.resolve(className, currentFile);
|
||||
if (!typeResolved) return null;
|
||||
|
||||
const classCandidates = typeResolved.candidates.filter((c) => CLASS_LIKE_TYPES.has(c.type));
|
||||
if (classCandidates.length === 0) return null;
|
||||
|
||||
// 3. Try lookupMethodByOwner for explicit Constructor nodes.
|
||||
// Only accept results with type === 'Constructor' — a Method or Function
|
||||
// that happens to share the class name (e.g. C++ methods named after
|
||||
// their class) is not a constructor for resolution purposes.
|
||||
// Same dedup logic as resolveMethodByOwner: diamond inheritance converging
|
||||
// on the same constructor collapses to one hit.
|
||||
//
|
||||
// Same-name assumption: the lookup key is `${candidate.nodeId}\0${className}`,
|
||||
// so this finds Constructor nodes whose symbol name equals the class name
|
||||
// (`class User` with a `Constructor` named `User`). Constructors indexed
|
||||
// under a different name (e.g. Python `__init__`) will not be found here —
|
||||
// but they also won't appear in the tiered pool for `ctx.resolve(className)`
|
||||
// for the same reason, so step 4's Constructor-presence check will not
|
||||
// see them either. The two miss cases are symmetric. If a future extractor
|
||||
// indexes Constructor nodes under an alternative name while still setting
|
||||
// `ownerId`, this assumption will need revisiting.
|
||||
let firstDef: SymbolDefinition | undefined;
|
||||
let ambiguous = false;
|
||||
for (const candidate of classCandidates) {
|
||||
const def = ctx.symbols.lookupMethodByOwner(candidate.nodeId, className, argCount);
|
||||
if (!def || def.type !== 'Constructor') continue;
|
||||
if (!firstDef) {
|
||||
firstDef = def;
|
||||
} else if (def.nodeId !== firstDef.nodeId) {
|
||||
ambiguous = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstDef && !ambiguous) {
|
||||
return toResolveResult(firstDef, typeResolved.tier);
|
||||
}
|
||||
|
||||
// 4. lookupMethodByOwner found nothing — check whether the tiered pool
|
||||
// contains Constructor nodes that lack ownerId (common in some extractors).
|
||||
// If so, bail out so the existing filterCallableCandidates path handles
|
||||
// Constructor-vs-Class preference correctly.
|
||||
//
|
||||
// This branch also catches the step-3 ambiguous case (`ambiguous = true`
|
||||
// with two distinct Constructor nodes across multiple class candidates):
|
||||
// the same Constructor nodes are indexed under the class name in the
|
||||
// tiered pool, so `.some(Constructor)` is true here and we defer to
|
||||
// `filterCallableCandidates` downstream rather than guess which overload
|
||||
// to pick. Do not remove this check without also handling the ambiguous
|
||||
// step-3 path explicitly.
|
||||
if (typeResolved.candidates.some((c) => c.type === 'Constructor')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 5. No constructor nodes at all — fall back to the class node itself, but
|
||||
// ONLY when it is actually instantiable. Interface / Trait / Impl / Enum
|
||||
// are deliberately excluded via `INSTANTIABLE_CLASS_TYPES` to prevent
|
||||
// false `CALLS` edges from constructor-shaped calls to non-instantiable
|
||||
// nodes. This also disambiguates the Rust same-file shadowing case
|
||||
// (`struct User` + `impl User` both present at same-file tier): the
|
||||
// Impl is stripped, leaving the Struct as the sole instantiable target.
|
||||
// Addresses Codex review finding on PR #754.
|
||||
const instantiableCandidates = classCandidates.filter((c) =>
|
||||
INSTANTIABLE_CLASS_TYPES.has(c.type),
|
||||
);
|
||||
// Three outcomes below, in order of likelihood after the fix:
|
||||
// length === 0 → all candidates were stripped as non-instantiable (e.g.
|
||||
// Interface / Trait / Impl). Null-route via the fall-through `return
|
||||
// null` — this is the dominant Codex-fix case.
|
||||
// length === 1 → a single instantiable candidate remains, return it.
|
||||
// length > 1 → two or more instantiable classes share the name (e.g.
|
||||
// homonym classes across files with no import narrowing). Fall through
|
||||
// to `return null` so the caller null-routes rather than guess.
|
||||
if (instantiableCandidates.length === 1) {
|
||||
return toResolveResult(instantiableCandidates[0], typeResolved.tier);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MRO-aware method resolution via HeritageMap (SM-9)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1905,3 +1905,427 @@ describe('resolveCallTarget D0 skip conditions (SM-11)', () => {
|
|||
expect(result!.nodeId).toBe('method:User:save');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveStaticCall — SM-12: constructor/static call resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { resolveStaticCall } from '../../src/core/ingestion/call-processor.js';
|
||||
|
||||
describe('resolveStaticCall', () => {
|
||||
let ctx: ResolutionContext;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = createResolutionContext();
|
||||
});
|
||||
|
||||
it('resolves constructor with ownerId via lookupMethodByOwner', () => {
|
||||
ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
||||
ctx.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', {
|
||||
returnType: 'User',
|
||||
ownerId: 'class:User',
|
||||
});
|
||||
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
||||
|
||||
const result = resolveStaticCall('User', 'src/app.ts', ctx);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('ctor:User');
|
||||
});
|
||||
|
||||
it('returns class node when no constructor exists', () => {
|
||||
ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
||||
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
||||
|
||||
const result = resolveStaticCall('User', 'src/app.ts', ctx);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('class:User');
|
||||
});
|
||||
|
||||
it('returns null for non-class symbol', () => {
|
||||
ctx.symbols.add('src/utils.ts', 'helper', 'func:helper', 'Function');
|
||||
ctx.importMap.set('src/app.ts', new Set(['src/utils.ts']));
|
||||
|
||||
const result = resolveStaticCall('helper', 'src/app.ts', ctx);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when className does not exist', () => {
|
||||
const result = resolveStaticCall('NonExistent', 'src/app.ts', ctx);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when Constructor nodes lack ownerId', () => {
|
||||
ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
||||
ctx.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', {
|
||||
parameterCount: 1,
|
||||
});
|
||||
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
||||
|
||||
// Constructor lacks ownerId, so lookupMethodByOwner won't find it.
|
||||
// resolveStaticCall detects Constructor nodes and returns null to
|
||||
// let filterCallableCandidates handle the Constructor-vs-Class preference.
|
||||
const result = resolveStaticCall('User', 'src/app.ts', ctx);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('disambiguates constructor by arity', () => {
|
||||
ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
||||
ctx.symbols.add('src/user.ts', 'User', 'ctor:User:0', 'Constructor', {
|
||||
parameterCount: 0,
|
||||
returnType: 'User',
|
||||
ownerId: 'class:User',
|
||||
});
|
||||
ctx.symbols.add('src/user.ts', 'User', 'ctor:User:2', 'Constructor', {
|
||||
parameterCount: 2,
|
||||
returnType: 'User',
|
||||
ownerId: 'class:User',
|
||||
});
|
||||
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
||||
|
||||
const result = resolveStaticCall('User', 'src/app.ts', ctx, 2);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('ctor:User:2');
|
||||
});
|
||||
|
||||
it('returns correct confidence tier for import-scoped class', () => {
|
||||
ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
||||
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
||||
|
||||
const result = resolveStaticCall('User', 'src/app.ts', ctx);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.confidence).toBe(0.9); // import-scoped tier
|
||||
expect(result!.reason).toBe('import-resolved');
|
||||
});
|
||||
|
||||
it('returns correct confidence tier for same-file class', () => {
|
||||
ctx.symbols.add('src/app.ts', 'User', 'class:User', 'Class');
|
||||
|
||||
const result = resolveStaticCall('User', 'src/app.ts', ctx);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.confidence).toBe(0.95); // same-file tier
|
||||
expect(result!.reason).toBe('same-file');
|
||||
});
|
||||
|
||||
it('returns null for ambiguous homonym classes without constructor', () => {
|
||||
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/a.ts', 'src/b.ts']));
|
||||
|
||||
const result = resolveStaticCall('User', 'src/app.ts', ctx);
|
||||
|
||||
// Two classes with same name — ambiguous, should return null
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('routes through resolveCallTarget for constructor callForm', () => {
|
||||
ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
||||
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
||||
|
||||
const result = _resolveCallTargetForTesting(
|
||||
{
|
||||
calledName: 'User',
|
||||
callForm: 'constructor',
|
||||
},
|
||||
'src/app.ts',
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('class:User');
|
||||
});
|
||||
|
||||
it('routes through resolveCallTarget for free-form call targeting a class (Swift/Kotlin)', () => {
|
||||
ctx.symbols.add('src/user.swift', 'User', 'class:User', 'Class');
|
||||
ctx.importMap.set('src/app.swift', new Set(['src/user.swift']));
|
||||
|
||||
const result = _resolveCallTargetForTesting(
|
||||
{
|
||||
calledName: 'User',
|
||||
callForm: 'free',
|
||||
},
|
||||
'src/app.swift',
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('class:User');
|
||||
});
|
||||
|
||||
it('reuses the pre-computed tiered result instead of calling ctx.resolve twice', () => {
|
||||
ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
||||
ctx.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', {
|
||||
returnType: 'User',
|
||||
ownerId: 'class:User',
|
||||
});
|
||||
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
||||
|
||||
// Spy on ctx.resolve to prove the override short-circuits the second lookup.
|
||||
const originalResolve = ctx.resolve.bind(ctx);
|
||||
let resolveCallCount = 0;
|
||||
ctx.resolve = ((name: string, fromFile: string) => {
|
||||
resolveCallCount++;
|
||||
return originalResolve(name, fromFile);
|
||||
}) as typeof ctx.resolve;
|
||||
|
||||
const tieredOverride = originalResolve('User', 'src/app.ts');
|
||||
expect(tieredOverride).not.toBeNull();
|
||||
resolveCallCount = 0; // reset after the setup call
|
||||
|
||||
const result = resolveStaticCall('User', 'src/app.ts', ctx, undefined, tieredOverride!);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('ctor:User');
|
||||
expect(resolveCallCount).toBe(0); // ctx.resolve must not have been called again
|
||||
});
|
||||
|
||||
it('routes through resolveCallTarget for Java constructor call (new User())', () => {
|
||||
ctx.symbols.add('src/User.java', 'User', 'class:java:User', 'Class');
|
||||
ctx.symbols.add('src/User.java', 'User', 'ctor:java:User', 'Constructor', {
|
||||
returnType: 'User',
|
||||
ownerId: 'class:java:User',
|
||||
});
|
||||
ctx.importMap.set('src/App.java', new Set(['src/User.java']));
|
||||
|
||||
const result = _resolveCallTargetForTesting(
|
||||
{
|
||||
calledName: 'User',
|
||||
callForm: 'constructor',
|
||||
},
|
||||
'src/App.java',
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// Prefers Constructor node over Class node when ownerId is present.
|
||||
expect(result!.nodeId).toBe('ctor:java:User');
|
||||
});
|
||||
|
||||
it('routes through resolveCallTarget for Python free-form constructor (User())', () => {
|
||||
ctx.symbols.add('models/user.py', 'User', 'class:py:User', 'Class');
|
||||
ctx.importMap.set('app.py', new Set(['models/user.py']));
|
||||
|
||||
const result = _resolveCallTargetForTesting(
|
||||
{
|
||||
calledName: 'User',
|
||||
callForm: 'free',
|
||||
},
|
||||
'app.py',
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('class:py:User');
|
||||
});
|
||||
|
||||
it('routes through resolveCallTarget for Kotlin free-form constructor (User())', () => {
|
||||
ctx.symbols.add('src/User.kt', 'User', 'class:kt:User', 'Class');
|
||||
ctx.importMap.set('src/App.kt', new Set(['src/User.kt']));
|
||||
|
||||
const result = _resolveCallTargetForTesting(
|
||||
{
|
||||
calledName: 'User',
|
||||
callForm: 'free',
|
||||
},
|
||||
'src/App.kt',
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('class:kt:User');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Instantiability guard (Codex review follow-up, plan 2026-04-09-002):
|
||||
// The step-5 class-node fallback must only return instantiable kinds
|
||||
// (Class / Struct / Record). Interface / Trait / Impl / Enum targets are
|
||||
// null-routed to prevent false CALLS edges to non-instantiable nodes.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns a Struct node when no constructor exists (positive regression guard)', () => {
|
||||
ctx.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct');
|
||||
ctx.importMap.set('src/app.rs', new Set(['src/user.rs']));
|
||||
|
||||
const result = resolveStaticCall('User', 'src/app.rs', ctx);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('struct:User');
|
||||
});
|
||||
|
||||
it('returns a Record node when no constructor exists (positive regression guard)', () => {
|
||||
ctx.symbols.add('src/User.cs', 'User', 'record:User', 'Record');
|
||||
ctx.importMap.set('src/App.cs', new Set(['src/User.cs']));
|
||||
|
||||
const result = resolveStaticCall('User', 'src/App.cs', ctx);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('record:User');
|
||||
});
|
||||
|
||||
it('null-routes when the sole candidate is an Interface (Java/C#/TS)', () => {
|
||||
// Constructor-shaped call on an interface name — not legal source, but
|
||||
// the resolver must refuse to emit a CALLS edge to a non-instantiable node.
|
||||
ctx.symbols.add('src/validator.java', 'IValidator', 'iface:IValidator', 'Interface');
|
||||
ctx.importMap.set('src/app.java', new Set(['src/validator.java']));
|
||||
|
||||
const result = resolveStaticCall('IValidator', 'src/app.java', ctx);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('null-routes when the sole candidate is a Trait (PHP/Rust/Scala)', () => {
|
||||
// PHP `HasTimestamps` trait — not instantiable via constructor syntax.
|
||||
ctx.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait');
|
||||
ctx.importMap.set('src/model.php', new Set(['src/timestamps.php']));
|
||||
|
||||
const result = resolveStaticCall('HasTimestamps', 'src/model.php', ctx);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('null-routes when the sole candidate is a Rust Trait (Display)', () => {
|
||||
ctx.symbols.add('src/fmt.rs', 'Display', 'trait:rs:Display', 'Trait');
|
||||
ctx.importMap.set('src/app.rs', new Set(['src/fmt.rs']));
|
||||
|
||||
const result = resolveStaticCall('Display', 'src/app.rs', ctx);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers the Struct over the Impl when both share the same name and file (Rust shadowing)', () => {
|
||||
// Rust `impl User { ... }` alongside `struct User { ... }` in the same file.
|
||||
// Same-file tier returns both via lookupExactAll, both pass CLASS_LIKE_TYPES,
|
||||
// but the instantiability filter must strip the Impl so the Struct wins.
|
||||
ctx.symbols.add('src/user.rs', 'User', 'struct:rs:User', 'Struct');
|
||||
ctx.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl');
|
||||
|
||||
const result = resolveStaticCall('User', 'src/user.rs', ctx);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('struct:rs:User');
|
||||
});
|
||||
|
||||
it('null-routes when the sole candidate is a Rust Impl block (no Struct present)', () => {
|
||||
// Pathological extractor output: only the Impl survives tier resolution.
|
||||
// The instantiability filter must reject it rather than emit a wrong edge.
|
||||
ctx.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl');
|
||||
|
||||
const result = resolveStaticCall('User', 'src/user.rs', ctx);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('still returns an explicit Constructor even when the owner is an Impl (step-3 preservation)', () => {
|
||||
// Step 3 (lookupMethodByOwner walk) must not be affected by the step-5
|
||||
// tightening — a legitimate Constructor node owned by an Impl in a Rust
|
||||
// extractor still resolves correctly. The Struct is also present so that
|
||||
// step-1's lookupClassByName pre-check succeeds (Impl alone isn't in the
|
||||
// classByName index).
|
||||
ctx.symbols.add('src/user.rs', 'User', 'struct:rs:User', 'Struct');
|
||||
ctx.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl');
|
||||
ctx.symbols.add('src/user.rs', 'User', 'ctor:rs:User', 'Constructor', {
|
||||
returnType: 'User',
|
||||
ownerId: 'impl:rs:User',
|
||||
});
|
||||
ctx.importMap.set('src/app.rs', new Set(['src/user.rs']));
|
||||
|
||||
const result = resolveStaticCall('User', 'src/app.rs', ctx);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('ctor:rs:User');
|
||||
});
|
||||
|
||||
it('routes through resolveCallTarget and null-routes Interface constructor-shaped calls', () => {
|
||||
ctx.symbols.add('src/validator.java', 'IValidator', 'iface:IValidator', 'Interface');
|
||||
ctx.importMap.set('src/app.java', new Set(['src/validator.java']));
|
||||
|
||||
const result = _resolveCallTargetForTesting(
|
||||
{
|
||||
calledName: 'IValidator',
|
||||
callForm: 'constructor',
|
||||
},
|
||||
'src/app.java',
|
||||
ctx,
|
||||
);
|
||||
|
||||
// Full cascade: S0 → resolveStaticCall → step-5 instantiability filter → null.
|
||||
// If any downstream path silently re-introduces the wrong edge, this fails.
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('routes through resolveCallTarget and null-routes Trait free-form calls', () => {
|
||||
ctx.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait');
|
||||
ctx.importMap.set('src/model.php', new Set(['src/timestamps.php']));
|
||||
|
||||
const result = _resolveCallTargetForTesting(
|
||||
{
|
||||
calledName: 'HasTimestamps',
|
||||
callForm: 'free',
|
||||
},
|
||||
'src/model.php',
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('routes Record free-form constructor call through S0 (C# record / Kotlin data class)', () => {
|
||||
// Verifies that `freeFormHasClassTarget` triggers S0 for Record candidates.
|
||||
// Before the alignment fix, `Record` was absent from the trigger `.some()`,
|
||||
// so S0 was bypassed and Record free-form calls fell through to the
|
||||
// constructor-form retry path. This test would have silently passed with
|
||||
// the old (wasteful) code path — with the fix, S0 resolves it directly.
|
||||
ctx.symbols.add('src/User.cs', 'User', 'record:cs:User', 'Record');
|
||||
ctx.importMap.set('src/App.cs', new Set(['src/User.cs']));
|
||||
|
||||
const result = _resolveCallTargetForTesting(
|
||||
{
|
||||
calledName: 'User',
|
||||
callForm: 'free',
|
||||
},
|
||||
'src/App.cs',
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('record:cs:User');
|
||||
});
|
||||
|
||||
it('threads argCount through resolveCallTarget → S0 → resolveStaticCall for arity disambiguation', () => {
|
||||
// Regression guard: if call.argCount were ever dropped at the S0 call
|
||||
// site, the 2-arg constructor would resolve to the 0-arg overload (or
|
||||
// return null via ambiguity). This test fails in either case.
|
||||
ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
||||
ctx.symbols.add('src/user.ts', 'User', 'ctor:User:0', 'Constructor', {
|
||||
parameterCount: 0,
|
||||
returnType: 'User',
|
||||
ownerId: 'class:User',
|
||||
});
|
||||
ctx.symbols.add('src/user.ts', 'User', 'ctor:User:2', 'Constructor', {
|
||||
parameterCount: 2,
|
||||
returnType: 'User',
|
||||
ownerId: 'class:User',
|
||||
});
|
||||
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
||||
|
||||
const result = _resolveCallTargetForTesting(
|
||||
{
|
||||
calledName: 'User',
|
||||
callForm: 'constructor',
|
||||
argCount: 2,
|
||||
},
|
||||
'src/app.ts',
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('ctor:User:2');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue