Skip to content

feat(resolver): resolve .call()/.apply() this-rebinding and add fun fixture (JS)#1405

Merged
carlos-alm merged 9 commits into
mainfrom
feat/bind-call-apply-1380
Jun 9, 2026
Merged

feat(resolver): resolve .call()/.apply() this-rebinding and add fun fixture (JS)#1405
carlos-alm merged 9 commits into
mainfrom
feat/bind-call-apply-1380

Conversation

@carlos-alm

Copy link
Copy Markdown
Contributor

Summary

  • Adds the Jelly micro-test fun fixture (fun.js + expected-edges.json) with corrected <root> source names for module-level edges; the 4 named edges (baz/baz2/baz3/baz4 → bar) score 100% recall
  • Implements .call()/.apply() this-rebinding: when fn.call(namedCtx, ...) is seen, seeds fn::this → namedCtx in the PTS map so that this() calls inside fn resolve to namedCtx
  • Fixes a long-standing false positive in extractCallbackReferenceCalls: identifier arguments to .call()/.apply()/.bind() were incorrectly emitted as dynamic callback calls; now they are skipped since this-context and delegated function arguments should not be attributed to the outer scope
  • Adds buildThisCallBindingsPtsPostPass so the native engine benefits from this-rebinding via the same JS post-pass pattern used for fnRefBindings and paramBindings

Test plan

  • npx vitest run tests/benchmarks/resolution/resolution-benchmark.test.ts — JS benchmark 100% precision, all 171 tests pass
  • npx vitest run tests/benchmarks/resolution/jelly-micro.test.tsfun scores recall=100% (4/4), all 10 tests pass
  • npx vitest run tests/parsers/javascript.test.ts tests/unit/points-to.test.ts tests/engines/parity.test.ts — passes
  • npm run lint — no errors

Closes #1380

The test was using auto engine (native-preferred), causing it to pick the
published npm native binary which predates the prototype-method fixes.
WASM correctly extracts Dog.prototype.bark and resolves all call edges.

Fixes #1381
…lookup

When a method is called without a receiver inside a class-qualified method
(e.g. `IsValidEmail()` inside `Validators.ValidateUser`), both the WASM and
native engines now try the class-qualified name as a fallback.

Root cause: the same-class method lookup in `resolveByMethodOrGlobal` was
gated on `call.receiver && callerName`, which excluded no-receiver calls.
Static sibling calls in C#/Java (e.g. `IsValidEmail()` inside a static class)
have no receiver — the guard prevented the `Validators.IsValidEmail` lookup.

Fixes:
- WASM (call-resolver.ts): `if (call.receiver && callerName)` → `if (callerName)`
- Native (edge_builder.rs): moves class-scoped exact lookup outside the
  `call.receiver.is_some()` guard; suffix scan remains gated on receiver-present
  to avoid false positives on global function calls inside class methods.

Also fixes a latent CHA re-classification bug exposed by this change: the Rust
orchestrator classifies roles before the CHA post-pass, so the global fan-out
median was computed from pre-CHA edges. After CHA added edges, the median
shifted but Validators.cs (not directly connected to CHA-affected files) was
excluded from the incremental re-classification, leaving stale roles. Fixed by
switching the post-CHA re-classification from incremental to full.

C# same-file recall: 0/2 → 2/2 (100%).
Overall C# recall: 73.9% → 82.6% (19/23 expected edges).
Remaining gap: receiver-typed (0/4) tracked in #1402.
…ixture (JS)

- add fun Jelly micro-test fixture (fun.js + expected-edges.json) with correct
  <root> source names; scores 4/4 named edges (baz/baz2/baz3/baz4 → bar)

- add ThisCallBinding type: extracts fn.call(namedCtx,...)/fn.apply(namedCtx,...)
  and seeds fn::this → namedCtx in the PTS map; when this() is called inside fn,
  the scoped key lookup resolves to namedCtx

- add extractThisCallsWalk: captures this(args) call expressions (where this is
  the callee, not a receiver) so they participate in PTS resolution

- fix extractCallbackReferenceCalls: skip identifier args for .call()/.apply()/.bind()
  invocations; those are the this-context and function arguments flowing into the
  delegated function, not callbacks for the current scope (was producing FPs)

- add buildThisCallBindingsPtsPostPass: native-engine post-pass that resolves
  this() calls the same way as WASM, mirroring buildFnRefBindingsPtsPostPass

- update JS benchmark fixture: add invoker.call(handler, 10) test case and two
  new expected edges (runCallThis→invoker dynamic, invoker→handler points-to);
  JS precision holds at 100%, total expected now 37

Closes #1380
@greptile-apps

greptile-apps Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds .call()/.apply() this-rebinding resolution to the JS call-graph pipeline: when fn.call(namedCtx, ...) is seen, fn::this → namedCtx is seeded in the points-to map so that bare this() calls inside fn resolve to namedCtx. It also fixes a false-positive in extractCallbackReferenceCalls where identifier arguments to .call/.apply/.bind were incorrectly emitted as dynamic callback calls.

  • Adds ThisCallBinding type and extracts it in both the query path (extractThisCallAndBindingsWalk) and the walk path (handleCallExpr); the two paths have identical detection logic but are correctly isolated from each other.
  • Introduces buildThisCallBindingsPtsPostPass on the native engine path, mirroring the pattern used by buildFnRefBindingsPtsPostPass; scoped fn::this PTS entries are also visible to the earlier-running buildParamFlowPtsPostPass, which can resolve this() calls as a side-effect (producing correct edges, but making the dedicated post-pass a no-op for files that also have paramBindings).
  • Validates the new behaviour with an extended bind-call-apply.js fixture and two new expected edges (runCallThis → invoker, invoker → handler).

Confidence Score: 5/5

Safe to merge — the new this-rebinding logic is gated behind a thisCallBindings presence check, produces correct edges confirmed by the benchmark at 100% precision, and the deduplication guard prevents any accidental double-edges from the post-pass ordering interaction.

All changed code paths produce correct call-graph edges. The interaction between buildParamFlowPtsPostPass and buildThisCallBindingsPtsPostPass is a design-clarity concern rather than a correctness risk — the seenByPair set is always seeded from the current allEdgeRows before each post-pass, so no incorrect or duplicate edges reach the database. The duplicated extraction logic between the walk and query paths is a maintenance concern, not a defect. Tests show 100% precision and the expected edge count is updated consistently.

The dual-maintenance extraction code in src/extractors/javascript.ts (identical logic in handleCallExpr and extractThisCallAndBindingsWalk) and the post-pass ordering interaction in src/domain/graph/builder/stages/build-edges.ts are worth revisiting if the binding heuristic needs to evolve.

Important Files Changed

Filename Overview
src/extractors/javascript.ts Adds this() call detection and fn.call(ctx,...)/fn.apply(ctx,...) binding extraction; identical logic appears in both handleCallExpr (walk path) and extractThisCallAndBindingsWalk (query path), creating a dual-maintenance surface.
src/domain/graph/builder/stages/build-edges.ts Adds buildThisCallBindingsPtsPostPass and converts thisCallBindings to scoped fn::this PTS entries inside buildPointsToMapForFile; the scoped keys are also visible to buildParamFlowPtsPostPass, which runs first and can silently resolve this() calls before the dedicated post-pass does.
src/domain/graph/builder/stages/native-orchestrator.ts Adds a performance note to the classifyNodeRoles full-pass comment; no logic changes.
src/types.ts Adds ThisCallBinding interface and thisCallBindings?: ThisCallBinding[] to ExtractorOutput; well-documented and consistent with existing binding types.
tests/benchmarks/resolution/fixtures/javascript/bind-call-apply.js Adds invoker/handler/runCallThis fixture covering the fn.call(handler, 10)this() rebinding case.
tests/benchmarks/resolution/fixtures/javascript/expected-edges.json Adds two expected edges: runCallThis → invoker (calls) and invoker → handler (points-to); matches the new fixture and the intended resolution.
tests/benchmarks/resolution/resolution-benchmark.test.ts Updates comment to reflect the new expected edge count (37); no threshold changes, precision remains at 1.0.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["fn.call(namedCtx, ...)"] --> B{Code path}
    B -->|Query path| C["extractThisCallAndBindingsWalk\n→ thisCallBindings[]"]
    B -->|Walk path| D["handleCallExpr\n→ ctx.thisCallBindings[]"]

    C --> E["buildPointsToMapForFile\nInjects fn::this → namedCtx\ninto PTS map"]
    D --> E

    E --> F["buildParamFlowPtsPostPass\n(runs first)\nResolves invoker::this\nif file has paramBindings"]
    E --> G["buildThisCallBindingsPtsPostPass\n(runs after)\nResolves this() calls\nvia fn::this PTS key"]

    F -->|seenByPair dedup| H["Edge: invoker → handler"]
    G -->|seenByPair dedup| H

    style F fill:#ffe0b2
    style G fill:#c8e6c9
    style H fill:#bbdefb
Loading

Fix All in Claude Code

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

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Codegraph Impact Analysis

15 functions changed18 callers affected across 8 files

  • buildThisCallBindingsPtsPostPass in src/domain/graph/builder/stages/build-edges.ts:722 (3 transitive callers)
  • buildPointsToMapForFile in src/domain/graph/builder/stages/build-edges.ts:1216 (6 transitive callers)
  • buildEdges in src/domain/graph/builder/stages/build-edges.ts:1816 (4 transitive callers)
  • tryNativeOrchestrator in src/domain/graph/builder/stages/native-orchestrator.ts:1436 (5 transitive callers)
  • extractSymbolsQuery in src/extractors/javascript.ts:324 (1 transitive callers)
  • extractSymbolsWalk in src/extractors/javascript.ts:675 (1 transitive callers)
  • handleCallExpr in src/extractors/javascript.ts:1128 (3 transitive callers)
  • extractCallbackReferenceCalls in src/extractors/javascript.ts:2872 (6 transitive callers)
  • extractThisCallAndBindingsWalk in src/extractors/javascript.ts:2922 (2 transitive callers)
  • ThisCallBinding.callee in src/types.ts:574 (0 transitive callers)
  • ThisCallBinding.thisArg in src/types.ts:576 (0 transitive callers)
  • ExtractorOutput.thisCallBindings in src/types.ts:693 (0 transitive callers)
  • invoker in tests/benchmarks/resolution/fixtures/javascript/bind-call-apply.js:28 (1 transitive callers)
  • handler in tests/benchmarks/resolution/fixtures/javascript/bind-call-apply.js:32 (1 transitive callers)
  • runCallThis in tests/benchmarks/resolution/fixtures/javascript/bind-call-apply.js:36 (0 transitive callers)

@carlos-alm

Copy link
Copy Markdown
Contributor Author

Addressed Greptile's review feedback:

Comment 1 — Broad skip in extractCallbackReferenceCalls (line 2354): Valid concern. The current skip is intentionally broad to prevent false positives for the this-rebinding use case (fn.call(ctx)). Narrowing it to only skip the first arg for .call()/.apply() while preserving subsequent callback args is a follow-up improvement tracked in #1406.

Comment 2 — No-receiver class-scoped lookup JS false-positive risk (lines 185-194): The JS benchmark maintains 100% precision after the receiver-guard removal. A dedicated fixture to make the regression boundary explicit has been filed as #1407.

Comment 3 — Full role re-classification performance (lines 282-283): Added a comment to native-orchestrator.ts documenting the O(all_nodes) cost and suggesting a two-pass strategy as a future optimization path if this becomes a bottleneck (commit c708cfc).

@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai

…duce O(n) passes

In extractSymbolsQuery, replace two separate extractThisCallsWalk +
extractThisCallBindingsWalk full-tree traversals with a single combined
extractThisCallAndBindingsWalk pass, halving the per-file traversal cost
for the query (WASM) path.

In extractSymbolsWalk, inline the this() call detection and .call()/.apply()
binding extraction into handleCallExpr, which is already called during the
main walkJavaScriptNode traversal, eliminating two extra O(n) passes from
the walk path entirely.
@carlos-alm

Copy link
Copy Markdown
Contributor Author

Pushed a perf optimization (commit e502d39) to address the benchmark regression caught by the regression guard:

Pre-publish benchmark gate failure: Build ms/file: 18.7 → 33 (+76%, threshold 70%) (wasm engine)

The original implementation added two separate full-AST recursive traversals (extractThisCallsWalk + extractThisCallBindingsWalk) to every JS/TS file parse. With the codebase having 600+ files, these two extra O(n) walks were measurable WASM build overhead.

Fix:

  • In the extractSymbolsQuery path (used by WASM): merged the two separate walks into a single extractThisCallAndBindingsWalk pass — halves the traversal cost.
  • In the extractSymbolsWalk path: inlined both detections into the existing handleCallExpr handler, which is already called during the main traversal — eliminates both extra passes entirely.

Behavioral output is identical — verified by 104 JS parser tests + cross-engine parity tests all passing.

@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai

@carlos-alm

Copy link
Copy Markdown
Contributor Author

Merged origin/main to resolve conflicts — only change was removing a stale TODO comment from tests/integration/prototype-method-resolution.test.ts (the comment was already removed on main in a prior merge). No logic changes.

@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai

@carlos-alm

Copy link
Copy Markdown
Contributor Author

Merged origin/main to resolve conflicts with 5 commits that landed on main since the previous merge:

Conflict resolutions:

  1. edge_builder.rs: PR removed the call.receiver.is_some() guard to enable no-receiver static sibling calls; main added a compute_confidence >= 0.5 filter to the class-scoped lookup. Combined both changes — kept the guard removal and added the confidence filter.
  2. native-orchestrator.ts: PR and main both moved to a full classifyNodeRoles(db) call; main simplified the runPostNativeCha return type from Set<number> to number. Adopted the simpler number return type and preserved the PR's performance cost comment.

@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai

@carlos-alm carlos-alm merged commit 30367bf into main Jun 9, 2026
22 checks passed
@carlos-alm carlos-alm deleted the feat/bind-call-apply-1380 branch June 9, 2026 07:25
@github-actions github-actions Bot locked and limited conversation to collaborators Jun 9, 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): resolve .bind(), .call(), .apply() dispatch and argument flow (JS)

1 participant