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:
Copilot 2026-04-09 12:09:07 +01:00 • committed by GitHub
parent a861baa869
commit 503de8bed8
2 changed files with 663 additions and 6 deletions

View file

@ -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)
// ---------------------------------------------------------------------------

View file

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