Skip to content

resolver: recognize identifier args to arbitrary user-defined higher-order functions via parameter type, not name allowlist #1845

Description

@carlos-alm

Context

Follow-up from #1741 (identifier-argument dynamic-call gating). That issue fixed a false-positive bug where every bare identifier argument to any call was treated as a callback reference, regardless of the callee — fixed by gating on CALLBACK_ACCEPTING_CALLEES/HTTP_VERB_CALLEES (a curated name allowlist covering stdlib/framework APIs: .map, .forEach, .then, Express routes, Array.from, etc.), mirroring how member_expression args were already gated since #974.

A name allowlist can only ever cover known API names. It cannot, in principle, recognize a project's own arbitrary higher-order functions. Concretely, tests/benchmarks/resolution/fixtures/typescript/callbacks.ts:

export type UserProcessor = (user: User) => void;

export function processEach(users: User[], fn: UserProcessor): void {
  for (const user of users) fn(user);
}

export function runCallbackDemo(users: User[]): void {
  processEach(users, logUser); // logUser passed as fn — a genuine callback reference
}

processEach is a project-defined function, not a stdlib/framework name, so it is correctly absent from the allowlist — but that means #1741's fix now also drops the legitimate edge runCallbackDemo -> logUser. This is tracked in tests/benchmarks/resolution/fixtures/typescript/expected-edges.json (3 edges, runCallbackDemo -> logUser/upperUser/hasEmail, mode callback) which are intentionally left in place (not deleted) because they describe a real, decidable fact about the code — TS resolution recall for the typescript fixture dropped from 47/47 to 44/47 (93.6%, still well above the 0.72 CI floor) as a result.

Why a name allowlist can't fix this

There is no way to distinguish, from the call site alone, processEach(users, logUser) (real callback) from analyzeDrift(communities, communityDirs) (real issue #1741 repro — communities is plain data) using only the callee's name. Both are "some identifier called with another identifier as an argument."

Suggested approach

The distinguishing signal is available at the callee's own definition: processEach's second parameter is typed UserProcessor (a type alias for (user: User) => void — function-shaped), whereas analyzeDrift's parameters (communities: CommunityObject[], communityDirs: Map<...>) are not. A resolver-level (not extractor-level) enhancement could:

  1. For TS/TSX files, collect each function/method definition's parameter type annotations (name/position -> type text), plus type X = ... alias definitions, during extraction (this repo already has typeMap/returnTypeMap machinery to build on).
  2. When resolving an identifier-argument call site, look up the callee's definition and check whether the parameter at that position is function-shaped (arrow-function type literal, Function, or an alias that resolves to one).
  3. If so, treat the identifier argument as a callback reference regardless of the callee's name; otherwise, fall back to the existing name/position allowlist.
  4. Plain JS has no type annotations, so this only helps TS/TSX — JS would keep relying on the name allowlist alone.

Why this wasn't included in #1741

This is a genuinely new capability (type-aware parameter analysis), not a bug fix to existing logic:

  • Needs new same-file (and ideally cross-file/import-aware) parameter-type + type-alias tracking, threaded into the call-processing pass in both src/extractors/javascript.ts (walk and query paths) and crates/codegraph-core/src/extractors/javascript.rs (or more likely, into the resolver layer — src/domain/graph/resolve.rs / crates/codegraph-core/src/domain/graph/resolve.rs — since the callee's definition may live in a different file than the call site).
  • Requires deciding what counts as "function-shaped" (arrow type literal, Function, generic/union types, imported aliases, interface call signatures) with a defensible heuristic, not full type-checking.
  • Needs test coverage and parity verification across both engines.

Suggested scope for this issue

  1. Design the same-file case first (callee defined in the same file as the call site) — matches the callbacks.ts fixture exactly.
  2. Extend to cross-file via existing import-resolution machinery if the same-file case proves out.
  3. Add fixtures/tests distinguishing "identifier passed to function-typed param" (should resolve) from "identifier passed to data-typed param" (should not) for both engines.
  4. Re-evaluate whether typescript's resolution-benchmark recall floor (THRESHOLDS.typescript in tests/benchmarks/resolution/resolution-benchmark.test.ts) should ratchet up once these 3 edges resolve again.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    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