Skip to content
6 changes: 6 additions & 0 deletions src/extractors/javascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2905,6 +2905,11 @@ 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()` / `.bind()` — the first arg is the `this` context (not a callback of
* the enclosing function) and subsequent args flow into the delegated function's parameters.
* Emitting them here would produce false-positive edges from the *calling* function.
* This-rebinding (fn::this → ctx) is handled separately by extractThisCallBindingsWalk.
*/
function extractCallbackReferenceCalls(callNode: TreeSitterNode): Call[] {
const args = callNode.childForFieldName('arguments') || findChild(callNode, 'arguments');
Expand All @@ -2916,6 +2921,7 @@ function extractCallbackReferenceCalls(callNode: TreeSitterNode): Call[] {
// Emitting them here would produce false-positive edges from the *calling* function.
// This-rebinding (fn::this → ctx) is handled separately by extractThisCallBindingsWalk.
if (calleeName === 'call' || calleeName === 'apply' || calleeName === 'bind') return [];

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
Expand Down
26 changes: 26 additions & 0 deletions tests/parsers/javascript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,32 @@ describe('JavaScript parser', () => {
expect(def.line).toBe(2);
expect(def.endLine).toBe(4);
});

// .call/.apply/.bind narrowing (#1406)
// All args flow into the delegated function, not as callbacks for the current scope.
// This-rebinding (fn::this → ctx) is handled by extractThisCallBindingsWalk instead.
it('emits nothing for .call() — args flow into the delegated function, not the current scope', () => {
const symbols = parseJS(`Array.prototype.forEach.call(collection, handler);`);
expect(symbols.calls).not.toContainEqual(expect.objectContaining({ name: 'handler' }));
expect(symbols.calls).not.toContainEqual(expect.objectContaining({ name: 'collection' }));
});

it('emits nothing for .apply() — second arg is an arguments array, not a callback', () => {
const symbols = parseJS(`fn.apply(ctx, handler);`);
expect(symbols.calls).not.toContainEqual(expect.objectContaining({ name: 'handler' }));
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);`);
expect(symbols.calls).not.toContainEqual(expect.objectContaining({ name: 'ctx' }));
});

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', () => {
Expand Down
Loading