GitNexus/gitnexus/test/unit/scope-resolution/javascript/javascript-captures.test.ts
Gergő Magyar 252fbabd51
fix(ingestion): stop emitting phantom Function defs for array-method callbacks (#1906)
* fix(ingestion): stop emitting phantom Function defs for array-method callbacks

The HOC-wrapped-arrow scope-query pattern (`const X = HOC(args => ...)`),
added for React idioms such as forwardRef/memo/useCallback, also matched
array higher-order-method callbacks like `const x = arr.map(a => ...)`.
Those produced a spurious `@declaration.function` named after the
binding, on top of its value def, so calls inside the callback attributed
to a phantom `Function:x` instead of the enclosing scope.

- Add a shared `isArrayMethodCallbackArrow` detector
  (`ARRAY_CALLBACK_METHODS` blocklist) and suppress the
  `@declaration.function` emit-side in both the JS and TS scope-captures
  emitters, leaving the value binding as the sole def.
- Add `selectNodeBearingDef` in scope-extractor: the tested
  collapse-rule contract (function-like > value > first) the deferred
  node-creation migration will consume to keep one graph node per
  binding.

This corrects the registry-primary scope model and CALLS-edge
attribution (calls inside array-method callbacks now source from the
enclosing File scope). The duplicate graph *node* itself is still
created by the legacy parse-worker path and is removed by the follow-up
node-creation migration.

Refs #1876

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(ingestion): strengthen array-callback coverage; document receiver-blind suppression

Follow-ups from the production-readiness review of PR #1906:

- array-callback.ts: document that isArrayMethodCallbackArrow is
  receiver-blind — an in-set method name on a NON-array receiver
  (Map/Set.forEach, RxJS observable.map, query-builder .sort, lodash
  chain .filter) is also suppressed. Accepted limitation, not a bug:
  the binding holds the call's result value, not a callable.
- captures unit tests (JS + TS): add a non-array-receiver
  characterization case, and extend the it.each lists to cover
  findLast, findLastIndex, reduceRight — the full 13-entry
  ARRAY_CALLBACK_METHODS set is now exercised in both languages.
- js-array-method-callback-attribution integration test: tighten the
  File-sourced CALLS assertions from toBeGreaterThan(0) to
  toHaveLength(1) (now also catches over-attribution).
- scope-extractor.ts: note that the dead selectNodeBearingDef export is
  intentional and tracked by #1876 (deferred node-creation migration).

Comment-and-test only; no production behavior change. Verified locally:
tsc clean, prettier/eslint clean, captures unit 106 passed,
scope-extractor 31 passed, integration 3 passed.

Refs #1876

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:35:06 +01:00

124 lines
5.3 KiB
TypeScript

/**
* Coverage for the JavaScript scope-captures orchestrator, focused on the
* #1876 array-method-callback narrowing.
*
* `const x = arr.map(a => …)` must NOT produce a `@declaration.function`
* named `x` (the binding holds a value, not a callable) — only the
* `@declaration.const`. Identifier-callee HOCs (`forwardRef`, `useMemo`)
* and direct arrow assignments keep their `@declaration.function`.
*
* Runs against tree-sitter-javascript so it catches grammar drift before
* the integration parity gate.
*/
import { describe, it, expect } from 'vitest';
import { emitJsScopeCaptures } from '../../../../src/core/ingestion/languages/javascript/captures.js';
function matchesFor(src: string) {
return emitJsScopeCaptures(src, 'test.js');
}
/** True when some match carries `tag` and its @declaration.name is `name`. */
function hasDecl(src: string, tag: string, name: string): boolean {
return matchesFor(src).some((m) => m[tag] !== undefined && m['@declaration.name']?.text === name);
}
/** Count matches carrying `tag` (any name). */
function countTag(src: string, tag: string): number {
return matchesFor(src).filter((m) => m[tag] !== undefined).length;
}
describe('emitJsScopeCaptures — #1876 array-method-callback narrowing', () => {
it('does not emit @declaration.function for `const x = arr.map(a => …)`', () => {
const src = 'const exportData = accountsList.map(account => ({ id: account.id }));';
expect(hasDecl(src, '@declaration.const', 'exportData')).toBe(true);
expect(hasDecl(src, '@declaration.function', 'exportData')).toBe(false);
// Exactly one binding-bearing declaration for the name.
expect(countTag(src, '@declaration.function')).toBe(0);
});
// Every method in ARRAY_CALLBACK_METHODS except `map` (covered above).
it.each([
'filter',
'find',
'findIndex',
'findLast',
'findLastIndex',
'reduce',
'reduceRight',
'forEach',
'some',
'every',
'flatMap',
'sort',
])('suppresses the Function def for array method .%s()', (method) => {
const src = `const x = arr.${method}((a) => a);`;
expect(hasDecl(src, '@declaration.function', 'x')).toBe(false);
expect(hasDecl(src, '@declaration.const', 'x')).toBe(true);
});
it('keeps @declaration.function for an identifier-callee HOC (forwardRef)', () => {
const src = 'const Button = forwardRef((props, ref) => null);';
expect(hasDecl(src, '@declaration.function', 'Button')).toBe(true);
});
it('keeps @declaration.function for useMemo (identifier callee, unchanged this round)', () => {
const src = 'const value = useMemo(() => compute(), []);';
expect(hasDecl(src, '@declaration.function', 'value')).toBe(true);
});
it('keeps dual classification for a direct arrow `const fn = () => {}`', () => {
const src = 'const fn = () => { doThing(); };';
expect(hasDecl(src, '@declaration.function', 'fn')).toBe(true);
expect(hasDecl(src, '@declaration.const', 'fn')).toBe(true);
});
it('keeps @declaration.function for a non-array fluent-API member call (accepted limitation)', () => {
const src = 'const q = qb.where((row) => row.ok);';
expect(hasDecl(src, '@declaration.function', 'q')).toBe(true);
});
it('suppresses an in-set method name on a NON-array receiver (accepted receiver-blind limitation)', () => {
// The predicate keys on the method NAME only, never the receiver type —
// tree-sitter has no type info. So `.map` on an RxJS observable (or
// Map/Set `.forEach`, a query builder `.sort`, a lodash chain `.filter`)
// is also treated as a callback and loses its Function def. Accepted: the
// binding holds the call's result value, so a value def is correct anyway.
const src = 'const stream = source$.map((event) => handle(event));';
expect(hasDecl(src, '@declaration.function', 'stream')).toBe(false);
expect(hasDecl(src, '@declaration.const', 'stream')).toBe(true);
});
it('suppresses the outer .map() callback in a chained array call', () => {
const src = 'const x = arr.filter((a) => a).map((b) => b);';
expect(hasDecl(src, '@declaration.function', 'x')).toBe(false);
expect(hasDecl(src, '@declaration.const', 'x')).toBe(true);
});
it('suppresses through an export_statement wrapper', () => {
const src = 'export const x = arr.map((a) => a);';
expect(hasDecl(src, '@declaration.function', 'x')).toBe(false);
expect(hasDecl(src, '@declaration.const', 'x')).toBe(true);
});
it('suppresses a function_expression callback', () => {
const src = 'const x = arr.map(function (a) { return a; });';
expect(hasDecl(src, '@declaration.function', 'x')).toBe(false);
expect(hasDecl(src, '@declaration.const', 'x')).toBe(true);
});
it('suppresses an optional-chained array call `arr?.map(...)`', () => {
const src = 'const x = arr?.map((a) => a);';
expect(hasDecl(src, '@declaration.function', 'x')).toBe(false);
});
it('does NOT suppress a parenthesized callee `(arr.map)(cb)` (intentional gap)', () => {
const src = 'const x = (arr.map)((a) => a);';
expect(hasDecl(src, '@declaration.function', 'x')).toBe(true);
});
it('does NOT suppress a computed callee `arr["map"](cb)` (intentional gap)', () => {
const src = 'const x = arr["map"]((a) => a);';
expect(hasDecl(src, '@declaration.function', 'x')).toBe(true);
});
});