From 519173fc8d098f288cd7574beda1b1120f914f3f Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 8 Jun 2026 02:51:23 -0600 Subject: [PATCH 1/3] fix(extractor): narrow .call/.apply/.bind skip in extractCallbackReferenceCalls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For .call()/.apply(): the first argument is the this-context and is now skipped; subsequent identifier arguments are emitted as genuine callbacks. e.g. forEach.call(arr, handler) → emits handler, not arr. For .bind(): all arguments are absorbed into the partially-applied function and none are direct callbacks — return [] immediately. Closes #1406 --- src/extractors/javascript.ts | 20 +++++++++++++ .../fixtures/javascript/bind-call-apply.js | 10 +++++++ .../fixtures/javascript/expected-edges.json | 7 +++++ .../resolution/resolution-benchmark.test.ts | 3 +- tests/parsers/javascript.test.ts | 29 +++++++++++++++++++ 5 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index ad71b05d..bcf144b1 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -2726,12 +2726,24 @@ function firstArgIsStringLiteral(argsNode: TreeSitterNode): boolean { * member-expr args are only emitted when the first argument is a string * literal route path — matching Express/router shape and skipping * `cache.get(user.id)`-style calls. + * + * `.call()`/`.apply()`: the first argument is the `this` context (not a + * callback). Subsequent identifier arguments are genuine callbacks of the + * enclosing scope — e.g. `Array.prototype.forEach.call(arr, handler)` emits + * `handler`. `.bind()` returns a new partially-applied function; all + * arguments are absorbed and none are direct callbacks. */ function extractCallbackReferenceCalls(callNode: TreeSitterNode): Call[] { const args = callNode.childForFieldName('arguments') || findChild(callNode, 'arguments'); if (!args) return []; const calleeName = extractCalleeName(callNode); + + // .bind() absorbs all arguments into a partially-applied function — no direct callbacks. + if (calleeName === 'bind') return []; + + const skipFirstArg = calleeName === 'call' || calleeName === 'apply'; + let memberExprArgsAllowed = calleeName !== null && CALLBACK_ACCEPTING_CALLEES.has(calleeName); if (memberExprArgsAllowed && calleeName !== null && HTTP_VERB_CALLEES.has(calleeName)) { // HTTP verbs require a string-literal route path to be treated as a @@ -2742,10 +2754,18 @@ function extractCallbackReferenceCalls(callNode: TreeSitterNode): Call[] { const result: Call[] = []; const callLine = nodeStartLine(callNode); + let realArgIndex = 0; for (let i = 0; i < args.childCount; i++) { const child = args.child(i); if (!child) continue; + if (child.type === '(' || child.type === ')' || child.type === ',') continue; + + if (skipFirstArg && realArgIndex === 0) { + realArgIndex++; + continue; + } + realArgIndex++; if (child.type === 'identifier') { result.push({ name: child.text, line: callLine, dynamic: true }); diff --git a/tests/benchmarks/resolution/fixtures/javascript/bind-call-apply.js b/tests/benchmarks/resolution/fixtures/javascript/bind-call-apply.js index fe36c0e8..799becf3 100644 --- a/tests/benchmarks/resolution/fixtures/javascript/bind-call-apply.js +++ b/tests/benchmarks/resolution/fixtures/javascript/bind-call-apply.js @@ -22,3 +22,13 @@ export function runCall() { export function runApply() { return greet.apply(user, ['Hey']); } + +// .call() with a callback identifier after the this-context +function processItem(item) { + return item; +} +var items = [1, 2, 3]; + +export function runCallWithCallback() { + items.forEach.call(items, processItem); +} diff --git a/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json b/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json index 0352f960..4d9234da 100644 --- a/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json +++ b/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json @@ -192,6 +192,13 @@ "mode": "dynamic", "notes": "greet.apply(user, ['Hey']) — .apply() extracts greet as the callee" }, + { + "source": { "name": "runCallWithCallback", "file": "bind-call-apply.js" }, + "target": { "name": "processItem", "file": "bind-call-apply.js" }, + "kind": "calls", + "mode": "dynamic", + "notes": "forEach.call(items, processItem) — processItem is a genuine callback after the this-context; items is NOT emitted as a call" + }, { "source": { "name": "Dog.speak", "file": "inheritance.js" }, "target": { "name": "Animal.speak", "file": "inheritance.js" }, diff --git a/tests/benchmarks/resolution/resolution-benchmark.test.ts b/tests/benchmarks/resolution/resolution-benchmark.test.ts index 1e78c0ee..b9564dcc 100644 --- a/tests/benchmarks/resolution/resolution-benchmark.test.ts +++ b/tests/benchmarks/resolution/resolution-benchmark.test.ts @@ -121,7 +121,8 @@ const THRESHOLDS: Record = { // adds bind/call/apply resolution (3 new edges in bind-call-apply.js), total expected now 33. // Phase 8.3f adds Object.defineProperty accessor this-dispatch (#1335): getter→baz in // define-property.js and accessorGetter→accessorTarget.accessMethod in define-property-accessor.js, - // total expected now 35. + // total expected now 35. #1406 adds runCallWithCallback→processItem (forEach.call callback), + // total expected now 36. javascript: { precision: 1.0, recall: 0.9 }, // TS 0.72: Phase 8.3e adds this.method() same-class resolution (Shape.describe → Shape.area), // lifting recall from 69.4% to 72.2%. Remaining gap (interface-dispatch, CHA) is tracked diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index 4f02642b..3a03a4cc 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -800,6 +800,35 @@ describe('JavaScript parser', () => { expect(def.line).toBe(2); expect(def.endLine).toBe(4); }); + + // .call/.apply/.bind narrowing (#1406) + it('emits identifier args after the this-context for .call()', () => { + const symbols = parseJS(`Array.prototype.forEach.call(collection, handler);`); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ name: 'handler', dynamic: true }), + ); + expect(symbols.calls).not.toContainEqual(expect.objectContaining({ name: 'collection' })); + }); + + it('emits identifier args after the this-context for .apply()', () => { + const symbols = parseJS(`fn.apply(ctx, handler);`); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ name: 'handler', dynamic: true }), + ); + expect(symbols.calls).not.toContainEqual(expect.objectContaining({ name: 'ctx' })); + }); + + it('emits nothing for .call() with only the this-context arg', () => { + const symbols = parseJS(`fn.call(ctx);`); + const callbackCalls = symbols.calls.filter((c) => c.name === 'ctx'); + expect(callbackCalls).toHaveLength(0); + }); + + it('emits nothing for .bind() — all args are absorbed into the partial application', () => { + const symbols = parseJS(`Promise.resolve.bind(null, transform);`); + expect(symbols.calls).not.toContainEqual(expect.objectContaining({ name: 'transform' })); + expect(symbols.calls).not.toContainEqual(expect.objectContaining({ name: 'null' })); + }); }); describe('Phase 8.3f: object-destructuring rest parameter binding extraction', () => { From 3ab27b6c0f4691e8b4871907618bb4bc1b9a094c Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 8 Jun 2026 11:27:07 -0600 Subject: [PATCH 2/3] =?UTF-8?q?fix(extractor):=20treat=20.apply()=20like?= =?UTF-8?q?=20.bind()=20=E2=80=94=20its=20argsArray=20is=20never=20a=20cal?= =?UTF-8?q?lback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unlike .call(thisArg, cb1, cb2…), .apply() takes exactly two args: thisArg and a single argsArray. The array is never a direct callback, so emitting its identifier (e.g. Math.max.apply(null, numbers) → edge to numbers) is a false-positive. Return [] immediately for .apply(), same as .bind(). Update the unit test to assert no edges are emitted. --- src/extractors/javascript.ts | 20 ++++++++++++-------- tests/parsers/javascript.test.ts | 6 ++---- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index bcf144b1..831fb67b 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -2727,11 +2727,14 @@ function firstArgIsStringLiteral(argsNode: TreeSitterNode): boolean { * literal route path — matching Express/router shape and skipping * `cache.get(user.id)`-style calls. * - * `.call()`/`.apply()`: the first argument is the `this` context (not a - * callback). Subsequent identifier arguments are genuine callbacks of the - * enclosing scope — e.g. `Array.prototype.forEach.call(arr, handler)` emits - * `handler`. `.bind()` returns a new partially-applied function; all - * arguments are absorbed and none are direct callbacks. + * `.call()`: the first argument is the `this` context (not a callback). + * Subsequent identifier arguments are genuine callbacks of the enclosing + * scope — e.g. `Array.prototype.forEach.call(arr, handler)` emits `handler`. + * `.bind()` returns a new partially-applied function; all arguments are + * absorbed and none are direct callbacks. + * `.apply()` takes exactly two arguments: `thisArg` and a single `argsArray`. + * The array is never a direct callback, so `.apply()` is treated like `.bind()` + * and returns immediately with no edges. */ function extractCallbackReferenceCalls(callNode: TreeSitterNode): Call[] { const args = callNode.childForFieldName('arguments') || findChild(callNode, 'arguments'); @@ -2739,10 +2742,11 @@ function extractCallbackReferenceCalls(callNode: TreeSitterNode): Call[] { const calleeName = extractCalleeName(callNode); - // .bind() absorbs all arguments into a partially-applied function — no direct callbacks. - if (calleeName === 'bind') return []; + // .bind() and .apply() absorb all arguments — no direct callbacks. + // .apply(thisArg, argsArray): argsArray is a single array, never a callback. + if (calleeName === 'bind' || calleeName === 'apply') return []; - const skipFirstArg = calleeName === 'call' || calleeName === 'apply'; + const skipFirstArg = calleeName === 'call'; let memberExprArgsAllowed = calleeName !== null && CALLBACK_ACCEPTING_CALLEES.has(calleeName); if (memberExprArgsAllowed && calleeName !== null && HTTP_VERB_CALLEES.has(calleeName)) { diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index 3a03a4cc..b9c1242c 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -810,11 +810,9 @@ describe('JavaScript parser', () => { expect(symbols.calls).not.toContainEqual(expect.objectContaining({ name: 'collection' })); }); - it('emits identifier args after the this-context for .apply()', () => { + it('emits nothing for .apply() — second arg is an arguments array, not a callback', () => { const symbols = parseJS(`fn.apply(ctx, handler);`); - expect(symbols.calls).toContainEqual( - expect.objectContaining({ name: 'handler', dynamic: true }), - ); + expect(symbols.calls).not.toContainEqual(expect.objectContaining({ name: 'handler' })); expect(symbols.calls).not.toContainEqual(expect.objectContaining({ name: 'ctx' })); }); From 9162f67851f4ef0724adc83ec35f779c382368a0 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Tue, 9 Jun 2026 15:34:26 -0600 Subject: [PATCH 3/3] test(extractor): align .call()-with-only-this-context assertion style (#1420) --- tests/parsers/javascript.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index 7aa705a6..c666fe7e 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -868,8 +868,7 @@ describe('JavaScript parser', () => { it('emits nothing for .call() with only the this-context arg', () => { const symbols = parseJS(`fn.call(ctx);`); - const callbackCalls = symbols.calls.filter((c) => c.name === 'ctx'); - expect(callbackCalls).toHaveLength(0); + expect(symbols.calls).not.toContainEqual(expect.objectContaining({ name: 'ctx' })); }); it('emits nothing for .bind() — all args are absorbed into the partial application', () => {