GitNexus/gitnexus/test/integration/js-array-method-callback-attribution.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

81 lines
3.4 KiB
TypeScript

/**
* JavaScript: CALLS-edge attribution for calls inside array higher-order-
* method callbacks (issue #1876).
*
* `const exportData = accountsList.map(account => transform(account))` matches
* the HOC-wrapped-arrow declaration pattern, so before this fix the JS scope
* model emitted a phantom `Function:exportData` for the `.map` callback (on
* top of the value binding). Calls nested in the callback (`transform`) then
* attributed to that phantom `Function` instead of the enclosing scope.
*
* U1 drops the `@declaration.function` for array-method callbacks, so the
* binding is value-only and the inner call falls through to the File scope —
* exactly the Zustand module-level-call behavior already pinned for TS.
*
* SCOPE: this asserts the registry-primary CALLS-edge ATTRIBUTION change only.
* The duplicate *graph node* (`Function:exportData`) is created by the legacy
* parse-worker node path, which this change does not touch; collapsing it is
* the deferred node-creation migration. Accordingly this file makes NO node-
* count assertion.
*
* Registry-primary-only correctness win: under the forced-legacy parity flag
* (`REGISTRY_PRIMARY_JAVASCRIPT=0`) the legacy DAG still emits the phantom
* attribution, so the suite is skipped there (mirrors the per-language
* expected-failure handling in `resolvers/helpers.ts`).
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES,
getRelationships,
isLegacyResolverParityRun,
runPipelineFromRepo,
type PipelineResult,
} from './resolvers/helpers.js';
describe.skipIf(isLegacyResolverParityRun('javascript'))(
'JavaScript array-method-callback CALLS attribution (#1876)',
() => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'javascript-array-method-callback'),
() => {},
);
}, 60000);
it('control: run() body calls transform directly (resolver is wired)', () => {
const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'transform');
expect(calls.map((c) => `${c.source}${c.target}`)).toContain('run → transform');
});
it('call inside .map callback attributes to File, not a phantom Function:exportData', () => {
const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'transform');
const fromExportData = calls.filter((c) => c.source === 'exportData');
expect(
fromExportData,
'transform must NOT be attributed to exportData (phantom Function)',
).toEqual([]);
const fromFile = calls.filter((c) => c.sourceLabel === 'File');
expect(
fromFile,
'the .map callback call to transform must source from the File node (exactly once)',
).toHaveLength(1);
});
it('call inside .find callback attributes to File, not a phantom Function:firstActive', () => {
const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'predicate');
const fromFirstActive = calls.filter((c) => c.source === 'firstActive');
expect(
fromFirstActive,
'predicate must NOT be attributed to firstActive (phantom Function)',
).toEqual([]);
const fromFile = calls.filter((c) => c.sourceLabel === 'File');
expect(
fromFile,
'the .find callback call to predicate must source from the File node (exactly once)',
).toHaveLength(1);
});
},
);