Skip to content

TypeScript: interface method stub gets a spurious direct calls edge alongside the correct CHA-expanded one #2084

Description

@carlos-alm

Discovered while investigating a resolution-benchmark precision regression flagged during PR #2028's sweep. Confirmed via a from-source build at commit fa678d4 (tip of PR #2026, fix(resolver): kind-filter same-file bare-name lookup for receiver-bearing calls) that this bug predates PR #2028 entirely — it is not introduced by the constructor-attribution feature or any of #2028's fix commits, so it's out of scope for that PR. Filing here rather than fixing inline, per this repo's scope-discipline convention.

Repro: tests/benchmarks/resolution/fixtures/typescript/{types.ts,service.ts,serializer.ts}.

// types.ts
export interface Serializer<T> {
  serialize(item: T): string;
  deserialize(raw: string): T;
}

// service.ts
export class UserService {
  private serializer: Serializer<User>;
  constructor(repo: Repository<User>, serializer: Serializer<User>) {
    this.serializer = serializer;
  }
  getUser(id: string): string | null {
    const user = this.repo.findById(id);
    return this.serializer.serialize(user);   // <-- this call
  }
}

this.serializer.serialize(user) correctly produces the expected CHA-expanded edge to the concrete implementer (JsonSerializer.serialize, technique cha-rta) — that edge is present and correct. But it ALSO produces a second, spurious direct edge straight to the interface's own abstract method declaration:

UserService.getUser -> Serializer.serialize (kind: method, technique: ts-native, confidence: 0.7)
UserService.addUser -> Serializer.deserialize (kind: method, technique: ts-native, confidence: 0.7)

An interface method signature has no body/implementation — a calls edge targeting it doesn't represent a real runtime dependency; it's always the concrete implementer that actually executes. This inflates the resolver's false-positive count in the tests/benchmarks/resolution/resolution-benchmark.test.ts / tests/benchmarks/regression-guard.test.ts precision metrics.

Suspected mechanism (not fully traced): the receiver-type qualified-name lookup (resolveByReceiver cascade in src/domain/graph/resolver/strategy.ts / src/domain/graph/builder/call-resolver.ts) resolves this.serializer's typeMap-recorded type (Serializer, the interface) directly against ${typeName}.${callName} and finds the interface's own method-stub node, in addition to (not instead of) the CHA/RTA expansion (emitChaCallEdgesForCall / resolveChaTargets) that correctly finds the concrete implementer. The direct qualified-name tier doesn't currently exclude interface-kind method declarations that have no body/implementation.

Suggested fix direction: either (a) don't extract interface method signatures as method-kind nodes eligible for direct calls-edge targeting at all (they could stay kind: method for definition/reference purposes but be excluded from the receiver-typed qualified-name resolution tier specifically), or (b) have that resolution tier skip a match when the found node's enclosing declaration is an interface (not a class), deferring entirely to the CHA/RTA tier for interface-typed receivers.

Verify parity: check whether the native/Rust engine has the same behavior for the equivalent construct — not checked as part of this filing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    follow-upDeferred work from PR reviews that needs tracking

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions