Skip to content

feat(resolver): track Function.bind/call/apply for receiver-typed resolution (JS)#1330

Merged
carlos-alm merged 4 commits into
mainfrom
feat/bind-call-apply-resolution
Jun 6, 2026
Merged

feat(resolver): track Function.bind/call/apply for receiver-typed resolution (JS)#1330
carlos-alm merged 4 commits into
mainfrom
feat/bind-call-apply-resolution

Conversation

@carlos-alm

Copy link
Copy Markdown
Contributor

Summary

  • bind: var f = fn.bind(ctx) now seeds a fnRefBinding {lhs: f, rhs: fn}, so the points-to solver propagates pts(f) ⊇ pts(fn). Subsequent direct calls f() resolve to fn via the pts path.
  • call/apply: fn.call(ctx, args) and fn.apply(ctx, argsArray) already extracted fn as the callee via extractMemberExprCallInfo; no new extraction needed.
  • pts gate (build-edges.ts): adds case (c) — flat unscoped key lookup for module-level alias bindings. Previously only dynamic calls (callbacks) and scoped parameter-flow calls used pts; now direct calls to pts-tracked aliases (e.g. the result of bind) also resolve. Safe: resolveViaPointsTo filters self-references, so self-seeded locally-defined function names never produce spurious edges.

Changes

File Change
src/extractors/javascript.ts Extend handleVarDeclaratorTypeMap to detect fn.bind(ctx) call_expression and push fnRefBinding {lhs, rhs: fn}
src/domain/graph/builder/stages/build-edges.ts Add case (c) to pts gate: check flat unscoped key as fallback for module-level aliases
tests/benchmarks/resolution/fixtures/javascript/bind-call-apply.js New fixture with bind, call, apply patterns
tests/benchmarks/resolution/fixtures/javascript/expected-edges.json 3 new expected edges (runBind→greet, runCall→greet, runApply→greet)

Test plan

  • All 171 benchmark tests pass (JS stays at 100% precision, ≥90% recall)
  • npm run lint — no errors in modified files
  • New edges resolved: runBind→greet (points-to), runCall→greet (dynamic), runApply→greet (dynamic)

Closes #1318

…olution (JS)

- bind: var f = fn.bind(ctx) seeds fnRefBinding {lhs:f, rhs:fn} so pts(f) ⊇ pts(fn);
  direct calls f() then resolve to fn via points-to.
- call/apply: f.call(ctx, args) and f.apply(ctx, args) already extract f as the
  callee via extractMemberExprCallInfo; no new extraction needed.
- pts gate: extend build-edges to also check the flat unscoped pts key for
  module-level alias bindings (case c) — safe because resolveViaPointsTo filters
  self-references, so self-seeded definitions never produce spurious edges.

Adds bind-call-apply.js fixture; JS benchmark stays at 100% precision.

Closes #1318
docs check acknowledged
@claude

claude Bot commented Jun 5, 2026

Copy link
Copy Markdown

Claude finished @carlos-alm's task —— View job


PR Review: feat(resolver): track Function.bind/call/apply for receiver-typed resolution (JS)

Review complete.

  • Gather context and read changed files
  • Phase 1: Root cause analysis
  • Phase 2: Configuration change detection
  • Phase 3: Test integrity
  • Phase 4: Code quality
  • Phase 5: Backlog compliance
  • Final assessment

Root Cause Analysis

Problem Being Solved: JS functions aliased via fn.bind(ctx) were not resolvable — a call to greetUser() where greetUser = greet.bind(user) produced no edge to greet. The call/apply cases were already partially working via extractMemberExprCallInfo, but the bind variant creates a new alias binding that requires points-to tracking.

Why This Approach: Correct and principled. bind semantically creates a new function alias, so seeding a fnRefBinding {lhs: greetUser, rhs: greet} and letting the existing Andersen-style pts solver propagate it is exactly the right way to model this. The call/apply path through extractMemberExprCallInfo already marks calls as dynamic: true, which fires the pts/dynamic resolution path in buildFileCallEdges.

Risk Assessment: Low. The bind-detection code is narrowly scoped: it only triggers on call_expression where the property is exactly "bind" and the object is an identifier. The pts self-reference filter prevents false loops. The flat unscoped pts key in case (c) of build-edges.ts is safe because resolveViaPointsTo filters self-references.


Phase 2: Configuration Change Detection

No linting rules, TypeScript flags, test timeouts, or coverage thresholds were modified. ✓


Phase 3: Test Integrity

  • New fixture file (bind-call-apply.js) added with minimal, targeted test cases — appropriate scope.
  • 3 new expected edges added; no existing edges were weakened, removed, or made conditional.
  • The mode annotation on runBind→greet is "points-to" (indirect) and on runCall→greet/runApply→greet is "dynamic" — both accurately reflect the resolution mechanism used.

No red flags. ✓


Phase 4: Code Quality

src/extractors/javascript.tshandleVarDeclaratorTypeMap (lines 1470–1483)

The bind detection is correct and well-guarded:

} else if (valueN.type === 'call_expression') {
  const callFn = valueN.childForFieldName('function');
  if (callFn?.type === 'member_expression') {
    const bindProp = callFn.childForFieldName('property');
    if (bindProp?.text === 'bind') {
      const boundFn = callFn.childForFieldName('object');
      if (boundFn?.type === 'identifier' && !BUILTIN_GLOBALS.has(boundFn.text)) {
        fnRefBindings.push({ lhs: nameN.text, rhs: boundFn.text });
      }
    }
  }
}

One observation: fn.bind(ctx) where fn is itself a method call (e.g. obj.method.bind(ctx)) is not handled — boundFn.type would be member_expression, not identifier, so it falls through silently. This is consistent with how the existing identifier case handles simple aliases and is a reasonable scope decision, but it should be documented if the team ever expects to extend this. Not a blocker.

src/domain/graph/builder/stages/build-edges.ts — pts gate (lines 969–981)

The new case (c) in the pts gate comment (flatPtsKey) is well-reasoned:

const flatPtsKey = !call.dynamic && ptsMap?.has(call.name) ? call.name : null;
if (
  targets.length === 0 &&
  !call.receiver &&
  ptsMap &&
  (call.dynamic || (scopedPtsKey != null && ptsMap.has(scopedPtsKey)) || flatPtsKey != null)
) {
  const ptsLookupName = call.dynamic
    ? call.name
    : scopedPtsKey != null && ptsMap.has(scopedPtsKey)
      ? scopedPtsKey
      : (flatPtsKey ?? call.name);

Minor concern: the condition || flatPtsKey != null is logically equivalent to || (!call.dynamic && ptsMap?.has(call.name)). Since call.dynamic is already the first branch and ptsMap is a gate on the outer if, this is fine but slightly redundant. Not a blocker.

Fixture (bind-call-apply.js): Clean, minimal, representative. The var keyword for user and greetUser tests that the extractor works for both var and const/let declarations — good.

Native engine coverage: The bind case seeds fnRefBindings which flows into buildCallEdgesNative via nativeFiles.fnRefBindings. However, the native pts post-pass (buildParamFlowPtsPostPass) only handles paramBindings, not module-level fnRefBindings. This means the runBind→greet edge will be resolved correctly on the WASM/JS path but may not be resolved on the native path — it would depend on whether the Rust engine handles fnRefBindings in its call-edge phase. This is worth verifying, especially given the CLAUDE.md mandate that "both engines must produce identical results."


Phase 5: Backlog Compliance

  • Zero-dep: ✓ — no new runtime dependencies; builds entirely on the existing pts solver, tree-sitter AST access, and FnRefBinding type already in the type system.
  • Foundation-aligned: ✓ — purely local, no cloud calls, no external toolchain, doesn't edit files or make decisions, doesn't break the programmatic API.
  • Problem-fit: 5/5 — directly improves call-graph accuracy for AI agents; a missing greetUser→greet edge would cause hallucinated dependency gaps and incorrect blast-radius analysis. This is exactly the kind of precision improvement the tool is designed for.
  • Breaking: No — purely additive; new edges added where previously none were emitted. No CLI output format, API signature, or DB schema changes.
  • Tier: Tier 1 (zero-dep ✓ + foundation-aligned ✓).

Critical Concerns

  1. Native engine parity gap (potential)
    buildParamFlowPtsPostPass handles paramBindings but not fnRefBindings. If the Rust engine doesn't resolve fnRefBindings-seeded pts keys, the runBind→greet edge will be missing on the native path.
    The PR description says the fixture was tested with "JS stays at 100% precision, ≥90% recall" but doesn't explicitly state whether this was tested with --engine native. If the benchmark was run WASM-only, the native parity gap is silently undetected.
    Action required: Confirm whether the benchmark was run with both engines, or add a native post-pass for module-level fnRefBindings (mirroring what buildParamFlowPtsPostPass does for paramBindings).

  2. var declaration scopebind-call-apply.js uses var greetUser = greet.bind(user) at module scope. The handleVarDeclaratorTypeMap function processes variable_declarator nodes regardless of var/const/let, so this is handled correctly. ✓


Final Recommendation

## Root Cause Analysis
Problem Being Solved: Missing call edges for fn.bind(ctx) alias patterns in JS
Why This Approach: Correct use of existing pts solver via FnRefBinding — no new machinery needed
Risk Assessment: Low — narrowly scoped, self-reference filter prevents false loops

## Backlog Compliance
- Zero-dep: ✓ — no new npm packages
- Foundation-aligned: ✓ — fully local, no cloud, no agent behavior
- Problem-fit: 5/5 — directly fixes a call-graph accuracy gap for AI agent navigation
- Breaking: No — purely additive
- Tier: 1

## Critical Concerns
- Native engine parity: confirm bind-resolution works on --engine native, not just WASM

## Final Recommendation
- Rating: ⭐⭐⭐⭐ (4/5)
- Action: APPROVE WITH CONDITIONS
- Reasoning: The implementation is correct, well-scoped, and uses the right abstraction. The only blocker-adjacent concern is potential native engine parity — the PR should confirm that runBind→greet is also produced on the native path (or document the gap as a tracked issue per CLAUDE.md). If native engine parity is confirmed, this is a clean ⭐⭐⭐⭐⭐.

@greptile-apps

greptile-apps Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR extends JS call-graph resolution to handle Function.prototype.bind, .call, and .apply patterns. fn.bind(ctx) is now detected in the extractor and recorded as an fnRefBinding, which the points-to solver propagates so that subsequent calls to the bound alias resolve to the original function.

  • javascript.ts: New call_expression branch in handleVarDeclaratorTypeMap detects x = fn.bind(ctx) and seeds fnRefBinding{lhs:x, rhs:fn}, restricted to flat-identifier bind targets (documented limitation).
  • build-edges.ts (JS path): Adds case (c) to the pts gate in buildFileCallEdges, guarded by a pre-computed fnRefBindingLhs set to avoid matching self-seeded local/import entries.
  • build-edges.ts (native path): Adds buildFnRefBindingsPtsPostPass — a new JS post-pass mirroring buildParamFlowPtsPostPass — plus a shared makeContextLookup instance to avoid redundant construction across both pts post-passes.

Confidence Score: 5/5

Safe to merge; the new bind-tracking path is narrowly guarded and cannot produce spurious edges for calls that already resolve directly.

The extractor change is tightly bounded to fn.bind(ctx) on flat identifiers. The pts-gate addition is guarded by fnRefBindingLhs (a pre-computed set of only bind/alias lhs names), preventing the previously-raised over-broad ptsMap matching. The new native-path post-pass is a near-exact parallel of the existing buildParamFlowPtsPostPass with the same dedup and filtering guarantees.

No files require special attention.

Important Files Changed

Filename Overview
src/domain/graph/builder/stages/build-edges.ts Adds case (c) pts gate in buildFileCallEdges (guarded by fnRefBindingLhs set), a new buildFnRefBindingsPtsPostPass for the native path, and shared lookup construction. Logic is consistent with existing post-passes; seenByPair dedup and fnRefBindingLhs narrowing are correctly applied.
src/extractors/javascript.ts New call_expression branch in handleVarDeclaratorTypeMap correctly identifies fn.bind(ctx) patterns and pushes an fnRefBinding entry. Guard is tight (member_expression function, property text "bind", identifier object, not a BUILTIN_GLOBAL).
tests/benchmarks/resolution/fixtures/javascript/bind-call-apply.js New fixture covering bind, call, and apply patterns; all three expected edges map correctly to the implemented resolution paths.
tests/benchmarks/resolution/fixtures/javascript/expected-edges.json Three new expected edges added matching the fixture: runBind→greet (points-to), runCall→greet (dynamic), runApply→greet (dynamic). Modes correctly reflect the underlying resolution mechanisms.

Reviews (5): Last reviewed commit: "fix: resolve merge conflicts with main" | Re-trigger Greptile

Comment on lines +977 to +981
const ptsLookupName = call.dynamic
? call.name
: scopedPtsKey != null && ptsMap.has(scopedPtsKey)
? scopedPtsKey
: (flatPtsKey ?? call.name);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The ?? call.name fallback in the ternary is unreachable. For the ternary to reach flatPtsKey ?? call.name, it must be non-dynamic AND scopedPtsKey is absent from the map. But if flatPtsKey is also null, the outer if condition can only be entered via scopedPtsKey != null && ptsMap.has(scopedPtsKey) — which takes the scopedPtsKey branch first. So flatPtsKey ?? call.name always evaluates to flatPtsKey in reachable code; the ?? call.name arm is dead.

Suggested change
const ptsLookupName = call.dynamic
? call.name
: scopedPtsKey != null && ptsMap.has(scopedPtsKey)
? scopedPtsKey
: (flatPtsKey ?? call.name);
const ptsLookupName = call.dynamic
? call.name
: scopedPtsKey != null && ptsMap.has(scopedPtsKey)
? scopedPtsKey
: flatPtsKey;

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — removed the dead ?? call.name fallback. The ternary now ends with flatPtsKey! (non-null assertion), which is correct since the outer if condition guarantees flatPtsKey != null when neither call.dynamic nor scopedPtsKey branches match. Commit: 2466048.

Comment on lines +970 to +975
const flatPtsKey = !call.dynamic && ptsMap?.has(call.name) ? call.name : null;
if (
targets.length === 0 &&
!call.receiver &&
ptsMap &&
(call.dynamic || (scopedPtsKey != null && ptsMap.has(scopedPtsKey)))
(call.dynamic || (scopedPtsKey != null && ptsMap.has(scopedPtsKey)) || flatPtsKey != null)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Case (c) gate is broader than the comment implies

buildPointsToMap seeds every locally-defined function AND every imported name with a self-pointing entry (pts.set(name, new Set([name]))). As a result, ptsMap.has(call.name) is true for virtually any unresolved call, not only bind-seeded aliases — so flatPtsKey != null fires far more often than the "module-level alias bindings" comment suggests. Safety is maintained today because resolveViaPointsTo filters self-references, yielding [] for names whose only pts entry is themselves. However, this invariant breaks if a module has both a locally-defined function foo and an fnRefBinding with lhs = 'foo' (e.g. const foo = externalFn shadowing the declaration): pts('foo') would grow to {'foo', 'externalFn'}, and a failed direct call to foo() would produce a spurious edge to externalFn. A narrower guard — checking whether call.name appears as an lhs in fnRefBindings rather than in the full ptsMap — would restrict case (c) to exactly the bind/alias entries it intends to cover.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the flatPtsKey guard now checks fnRefBindingLhs.has(call.name) (a pre-computed set of lhs names from symbols.fnRefBindings) in addition to ptsMap.has(call.name). This restricts case (c) to exactly the names that appear as bind/alias lhs entries, preventing the over-broad match against self-seeded local definitions and imports. A parallel buildFnRefBindingsPtsPostPass was also added for the native engine path using the same narrowed guard. Commit: 2466048.

@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Codegraph Impact Analysis

9 functions changed17 callers affected across 6 files

  • buildParamFlowPtsPostPass in src/domain/graph/builder/stages/build-edges.ts:551 (3 transitive callers)
  • buildFnRefBindingsPtsPostPass in src/domain/graph/builder/stages/build-edges.ts:636 (3 transitive callers)
  • buildFileCallEdges in src/domain/graph/builder/stages/build-edges.ts:962 (3 transitive callers)
  • buildEdges in src/domain/graph/builder/stages/build-edges.ts:1394 (4 transitive callers)
  • handleVarDeclaratorTypeMap in src/extractors/javascript.ts:1442 (12 transitive callers)
  • greet in tests/benchmarks/resolution/fixtures/javascript/bind-call-apply.js:3 (3 transitive callers)
  • runBind in tests/benchmarks/resolution/fixtures/javascript/bind-call-apply.js:12 (0 transitive callers)
  • runCall in tests/benchmarks/resolution/fixtures/javascript/bind-call-apply.js:17 (0 transitive callers)
  • runApply in tests/benchmarks/resolution/fixtures/javascript/bind-call-apply.js:22 (0 transitive callers)

…bind aliases

- Case (c) flatPtsKey guard now checks fnRefBindingLhs (lhs names from
  fnRefBindings) instead of the full ptsMap, preventing spurious edges when a
  locally-defined function shares a name with a bind/alias lhs.
- Remove dead `?? call.name` fallback in the ptsLookupName ternary; use
  flatPtsKey! (non-null assertion) which is guaranteed by the outer if guard.
- Add buildFnRefBindingsPtsPostPass for the native engine path so that
  bind-created aliases resolve on --engine native with the same correctness as
  the WASM/JS path.
@carlos-alm

Copy link
Copy Markdown
Contributor Author

Addressed Claude's review feedback:

  • Native engine parity gap: Added buildFnRefBindingsPtsPostPass — a new JS post-pass that runs after the native call-edge pass and resolves bind-created aliases on the native path, mirroring exactly what buildParamFlowPtsPostPass does for paramBindings. This ensures runBind→greet is emitted on --engine native with the same correctness as the WASM/JS path.

  • Case (c) gate correctness: The flatPtsKey check now uses a pre-computed fnRefBindingLhs set (lhs names from symbols.fnRefBindings) as the primary guard instead of the full ptsMap. This prevents the over-broad match Greptile flagged (where any locally-defined function or import in ptsMap would erroneously trigger case (c)).

  • Dead code removal: Removed the unreachable ?? call.name fallback in the ptsLookupName ternary; replaced with flatPtsKey! (non-null assertion guaranteed by the outer if guard).

All tests pass (171 resolution benchmark + 71 JS parser + 620 integration tests). Commit: 2466048.

@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai

@carlos-alm

Copy link
Copy Markdown
Contributor Author

@claude

@claude

claude Bot commented Jun 5, 2026

Copy link
Copy Markdown

Claude finished @carlos-alm's task —— View job


Re-Review: feat(resolver): track Function.bind/call/apply for receiver-typed resolution (JS)

Reviewing commit 2466048 (follow-up fixes)

  • Gather context and read changed files
  • Review buildFnRefBindingsPtsPostPass (native engine parity fix)
  • Review narrowed fnRefBindingLhs guard (case (c) precision fix)
  • Review dead code removal (flatPtsKey!)
  • Final assessment

All three concerns from the initial review are correctly resolved.

1. Native engine parity — buildFnRefBindingsPtsPostPass (lines 635–708)

The new post-pass mirrors buildParamFlowPtsPostPass faithfully:

  • Filters files to only those with fnRefBindings (fast-path exit when none present)
  • Seeds seenByPair from existing allEdgeRows to prevent duplicating native-emitted edges
  • Uses fnRefBindingLhs.has(call.name) as the primary guard before checking ptsMap — same narrowed guard as case (c) in buildFileCallEdges
  • Skips call.dynamic and call.receiver calls, which is correct for flat bind-alias resolution
  • Is correctly wired into the native path at line 1479, between buildParamFlowPtsPostPass and buildChaPostPass

The only structural observation: buildFnRefBindingsPtsPostPass seeds seenByPair from all allEdgeRows (not just calls rows), while buildChaPostPass seeds only from calls rows. The difference is intentional — the pts post-passes want to avoid any duplicate (src, tgt) pair across all edge kinds (overly conservative but safe), while CHA specifically wants to avoid call-edge overlap. This is fine as-is.

2. Case (c) guard narrowing — fnRefBindingLhs set (lines 979–1065)

const fnRefBindingLhs = new Set(symbols.fnRefBindings?.map((b) => b.lhs) ?? []);
// ...
const flatPtsKey =
  !call.dynamic && fnRefBindingLhs.has(call.name) && ptsMap?.has(call.name) ? call.name : null;

This correctly fixes the over-broad ptsMap.has(call.name) guard. The condition is now:

  • !call.dynamic — not a callback alias (case (a) handles those)
  • fnRefBindingLhs.has(call.name) — the call target is explicitly an lhs from a bind/alias declaration
  • ptsMap?.has(call.name) — it has a non-trivial pts entry (needed to call resolveViaPointsTo)

The shadowing concern Greptile raised (const foo = externalFn shadowing a local foo definition causing pts('foo') = {foo, externalFn} and a spurious edge on failed direct resolution) is now fully guarded: case (c) only fires for names in fnRefBindingLhs, which are exactly the lhs sides of bind/alias bindings, not every locally-defined function.

3. Dead code removal — flatPtsKey! non-null assertion (line 1078)

: // flatPtsKey != null is guaranteed by the outer if condition: if neither
  // call.dynamic nor scopedPtsKey matched, flatPtsKey != null must be true.
  flatPtsKey!;

Correct. The outer if condition (call.dynamic || (scopedPtsKey != null && ptsMap.has(scopedPtsKey)) || flatPtsKey != null) guarantees that when the call.dynamic and scopedPtsKey branches are both false, flatPtsKey != null must hold. The ! assertion is sound and the comment explains why. The dead ?? call.name arm is gone.


One minor observation (non-blocking)

buildFnRefBindingsPtsPostPass at line 667 constructs fnRefBindingLhs inside the per-file loop:

const fnRefBindingLhs = new Set(symbols.fnRefBindings!.map((b) => b.lhs));

This is fine — the ! is safe because the outer filter (filesWithBindings) already guarantees symbols.fnRefBindings is a non-empty array. The construction cost is negligible for the small number of bind bindings expected per file.


Final Assessment

All three critical concerns from the initial review are resolved cleanly. The implementation is now:

  • Correct on both engine paths — WASM/JS via buildFileCallEdges case (c), native via buildFnRefBindingsPtsPostPass
  • Precisely scopedfnRefBindingLhs guard ensures case (c) only fires for genuine bind/alias entries, not every locally-defined function
  • No dead code — unreachable ?? call.name removed

Rating: ⭐⭐⭐⭐⭐ (5/5) — APPROVE

View job

@carlos-alm

Copy link
Copy Markdown
Contributor Author

Addressed Greptile's follow-up suggestions from the review summary:

  1. Flat-identifier bind scope comment (javascript.ts:1471-1473): Added a comment explaining that only flat-identifier binds (fn.bind(ctx)) are tracked and that method-receiver binds like obj.method.bind(ctx) are intentionally not captured (boundFn must be an identifier). Commit: 58937cf.

  2. Shared makeContextLookup between post-passes (build-edges.ts:1472-1479): Both buildParamFlowPtsPostPass and buildFnRefBindingsPtsPostPass now accept an optional sharedLookup?: CallNodeLookup parameter. The call site constructs the lookup once (const sharedLookup = makeContextLookup(ctx, getNodeIdStmt)) and threads it into both calls, eliminating the redundant construction. Commit: 58937cf.

@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai

@carlos-alm carlos-alm merged commit a233110 into main Jun 6, 2026
22 checks passed
@carlos-alm carlos-alm deleted the feat/bind-call-apply-resolution branch June 6, 2026 02:07
@github-actions github-actions Bot locked and limited conversation to collaborators Jun 6, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(resolver): track Function.bind/call/apply for receiver-typed resolution (JS)

1 participant