diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 3ef4ab2f..04b51d95 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -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'); @@ -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 diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index b69185fc..c666fe7e 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -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', () => {