fix(typescript): capture missed CALLS edges from HOF callbacks and JSX

Two distinct gaps in the TypeScript scope-resolution path were silently
dropping call edges in real-world React + TanStack + Zustand codebases.
On the bug reporter's repo (Sourcerer-fe, 1185 src/ functions), 504
missing Function->Function CALLS edges are now captured (+61.6%) and
the no-outgoing-CALLS orphan rate drops from 73.2% to 60.3%.

HOF / arrow-callback caller-attribution (3 cooperating fixes):
  - typescript/query.ts: @declaration.function anchor moved from the
    wrapping lexical_declaration to the inner arrow_function /
    function_expression, so anchor.range aligns with @scope.function and
    pass2AttachDeclarations lands the def on the arrow's own scope.
  - finalize-algorithm.ts: findExportByName prefers callable / class-
    like defs over Variable when localDefs contains both for the same
    name (TS emits two defs per `const fn = () => {}`).
  - graph-bridge/ids.ts: resolveCallerGraphId's walk-up class-fallback
    now uses isCallerAnchorLabel restricted to Function / Method /
    Constructor / Class / Interface / Struct / Enum, so module-level
    calls fall through to the File node instead of mis-attributing to
    sibling Variable defs (the Zustand `create()(devtools(...))`
    phantom-self-loop regression).

JSX as a CALLS edge (2 cooperating fixes):
  - typescript/query.ts: new TSX_JSX_QUERY_SUFFIX (TSX-grammar only)
    captures jsx_self_closing_element / jsx_opening_element as
    @reference.call.free / @reference.call.member. PascalCase predicate
    filters native HTML elements (<div>, <span>) so they don't emit
    edges to nonexistent targets.
  - typescript/captures.ts: shouldEmitReadMember extended with
    jsx_self_closing_element / jsx_opening_element parent cases to
    suppress phantom ACCESSES edges on member-form JSX names.

Tests: 8 HOF assertions + 7 JSX assertions across two new integration
test files plus 13 minimal fixtures. typescript.test.ts (236),
api-deep-flow.test.ts (11), and scope-resolution / scope-extractor unit
tests (613) pass with no regressions.

Made-with: Cursor
This commit is contained in:
ReidenXerx 2026-04-28 20:52:07 +03:00
parent 0b34f5f636
commit c50014ca44
20 changed files with 598 additions and 15 deletions

View file

@ -740,10 +740,46 @@ function findExportByName(
defs: readonly SymbolDefinition[],
name: string,
): SymbolDefinition | undefined {
// Languages like TypeScript can emit MULTIPLE `SymbolDefinition`s with
// the same simple name from a single declaration: `const fn = () =>
// {}` produces both a `Function` def (from `@declaration.function` on
// the inner arrow) AND a `Variable` def (from the generic
// `@declaration.variable` pattern matching the wrapping
// `lexical_declaration`). Both end up in `localDefs`. The CALLER who
// writes `import { fn }` wants the callable, not the variable
// shadow — without a preference rule, capture order silently decides
// which def the import binds to, which broke cross-file CALLS edges
// for arrow-typed exports (see `typescript-hof-callbacks.test.ts`).
//
// Prefer callable / class-like defs; fall back to first-name-match.
let fallback: SymbolDefinition | undefined;
for (const d of defs) {
if (deriveSimpleName(d) === name) return d;
if (deriveSimpleName(d) !== name) continue;
if (isCallableOrTypeLike(d.type)) return d;
if (fallback === undefined) fallback = d;
}
return undefined;
return fallback;
}
const CALLABLE_OR_TYPE_LIKE: ReadonlySet<string> = new Set([
'Function',
'Method',
'Constructor',
'Class',
'Interface',
'Enum',
'Struct',
'Record',
'Trait',
'Namespace',
'Module',
'TypeAlias',
'Type',
'Typedef',
]);
function isCallableOrTypeLike(type: string): boolean {
return CALLABLE_OR_TYPE_LIKE.has(type);
}
function countEdgesWithin(edgeIndex: Map<string, ImportEdgeDraft[]>, files: Set<string>): number {

View file

@ -84,6 +84,11 @@ function pickFirstDefined(grouped: CaptureMatch, tags: readonly string[]): Captu
* as `@reference.write.member`).
* 4. The member_expression is the `function:` of an `await_expression`
* being called (handled by the member-call capture).
* 5. The member_expression is the `name:` of a `jsx_self_closing_element`
* or `jsx_opening_element` (it's a JSX component invocation, already
* captured as `@reference.call.member` by the TSX-only query suffix).
* Without this filter, `<Foo.Bar />` would emit a phantom ACCESSES
* edge to `Foo.Bar` IN ADDITION to the CALLS edge.
*
* Returns `true` when the capture should be kept as a read reference,
* `false` when it should be dropped.
@ -99,6 +104,9 @@ function shouldEmitReadMember(memberNode: SyntaxNode): boolean {
case 'assignment_expression':
case 'augmented_assignment_expression':
return parent.childForFieldName('left')?.id !== memberNode.id;
case 'jsx_self_closing_element':
case 'jsx_opening_element':
return parent.childForFieldName('name')?.id !== memberNode.id;
default:
return true;
}

View file

@ -136,25 +136,37 @@ const TYPESCRIPT_SCOPE_QUERY = `
;; Arrow/function-expression assigned to a const/let/var — named by the
;; variable_declarator. Covers \`const fn = () => {}\` and its export
;; variant. Matches the legacy TYPESCRIPT_QUERIES pattern.
;;
;; The \`@declaration.function\` anchor sits on the INNER arrow_function /
;; function_expression node (NOT the wrapping lexical_declaration), so
;; \`anchor.range\` aligns with the corresponding \`@scope.function\` scope
;; range. \`pass2AttachDeclarations\` then resolves \`innermost\` to the
;; arrow's own scope (instead of the module scope) and the def is owned
;; by the arrow itself. Without this alignment, calls inside the arrow
;; body lose caller attribution: \`resolveCallerGraphId\` walks up past
;; the empty arrow scope into the module scope and grabs whichever
;; Function-like def appears first there — silently mis-attributing
;; every nested call (Zustand stores, TanStack hooks, Promise-all/map,
;; etc.). See \`typescript-hof-callbacks.test.ts\`.
(lexical_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (arrow_function))) @declaration.function
value: (arrow_function) @declaration.function))
(lexical_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (function_expression))) @declaration.function
value: (function_expression) @declaration.function))
(variable_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (arrow_function))) @declaration.function
value: (arrow_function) @declaration.function))
(variable_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (function_expression))) @declaration.function
value: (function_expression) @declaration.function))
;; Method definitions — regular + private (#field) methods.
(method_definition
@ -723,6 +735,53 @@ const TYPESCRIPT_SCOPE_QUERY = `
property: (property_identifier) @reference.name) @reference.read.member
`;
/**
* JSX-only query suffix. Appended to the base query when compiling
* against the TSX grammar; NOT compiled against the plain TS grammar
* (which has no \`jsx_*\` node types and would reject these patterns).
*
* Why JSX as a CALLS edge: \`<Foo />\` is syntactic sugar for \`Foo(props)\`
* and the React component is invoked by the renderer, so for blast-radius
* (\`gitnexus_impact("Badge", direction: "upstream")\`) and call-graph
* (\`gitnexus_context("Foo")\`) purposes JSX usage IS a call. Routing
* through \`@reference.call.free\` / \`@reference.call.member\` makes the
* downstream caller-walk + edge-emission paths handle JSX uniformly with
* ordinary call expressions — no new edge type, no schema changes.
*
* Identifier-only JSX is filtered to PascalCase via \`(#match? ... "^[A-Z]")\`
* so \`<div>\`, \`<span>\`, \`<button>\` and other native HTML elements (which
* by JSX convention start lowercase) don't emit edges to nonexistent
* "div" / "span" symbols. Member-form JSX (\`<Foo.Bar />\`) is always a
* component (HTML element names can't contain dots), so no predicate
* filter is applied there.
*
* Both \`jsx_self_closing_element\` (\`<Foo />\`) and \`jsx_opening_element\`
* (\`<Foo>...</Foo>\`) emit; the closing tag is intentionally NOT captured —
* each JSX element should emit exactly one CALLS edge per use site.
*/
const TSX_JSX_QUERY_SUFFIX = `
;; <Foo />
((jsx_self_closing_element
name: (identifier) @reference.name) @reference.call.free
(#match? @reference.name "^[A-Z]"))
;; <Foo> ... </Foo> (paired form — match the opening tag only)
((jsx_opening_element
name: (identifier) @reference.name) @reference.call.free
(#match? @reference.name "^[A-Z]"))
;; <Foo.Bar /> / <Container.Section.Title /> — namespaced JSX
(jsx_self_closing_element
name: (member_expression
object: (_) @reference.receiver
property: (property_identifier) @reference.name)) @reference.call.member
(jsx_opening_element
name: (member_expression
object: (_) @reference.receiver
property: (property_identifier) @reference.name)) @reference.call.member
`;
let _tsParser: Parser | null = null;
let _tsxParser: Parser | null = null;
let _tsQuery: Parser.Query | null = null;
@ -753,11 +812,21 @@ export function getTsParser(filePath?: string): Parser {
* executed against a Tree produced by the `tsx` grammar — tree-sitter
* matches by node-type id, and the two grammars have separate id
* spaces.
*
* The TSX query is compiled with the JSX-as-call patterns appended.
* Those patterns reference `jsx_self_closing_element` /
* `jsx_opening_element` which exist only in the TSX grammar — embedding
* them in the plain TS query would throw `Query.InvalidNodeType` at
* compile time (and even if it didn't, the patterns would never fire on
* `.ts` source).
*/
export function getTsScopeQuery(filePath?: string): Parser.Query {
if (filePath !== undefined && isTsxFile(filePath)) {
if (_tsxQuery === null) {
_tsxQuery = new Parser.Query(TSX_GRAMMAR, TYPESCRIPT_SCOPE_QUERY);
_tsxQuery = new Parser.Query(
TSX_GRAMMAR,
TYPESCRIPT_SCOPE_QUERY + TSX_JSX_QUERY_SUFFIX,
);
}
return _tsxQuery;
}

View file

@ -20,12 +20,38 @@
import type { NodeLabel, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { generateId } from '../../../../lib/utils.js';
import {
isLinkableLabel,
qualifiedKey,
simpleKey,
type GraphNodeLookup,
} from '../graph-bridge/node-lookup.js';
import { qualifiedKey, simpleKey, type GraphNodeLookup } from '../graph-bridge/node-lookup.js';
/**
* Labels that may legitimately ANCHOR a CALLS/ACCESSES edge as the
* source ("caller"). A Variable / Property can be the TARGET of an
* edge (e.g., a write-access to `user.name`), but it cannot be a
* caller — variables don't execute code, so attributing a call to a
* sibling Variable in the same scope produces nonsense edges like
* `Variable:create → Function:create` (which the simpleKey fallback
* in `resolveDefGraphId` then silently rewrites to
* `Function:create → Function:create`, a self-loop that doesn't exist
* in the source).
*
* Module-level call expressions inside a `const X = expr(args)`
* declaration are the canonical case where this used to fail: the
* walk-up over module scope's ownedDefs (only Variables) would land
* on the FIRST Variable, get name-aliased to its sibling Function
* with the same simple name, and emit a self-CALLS. With this label
* restricted to function/class-likes, those calls correctly fall
* through to the File-node fallback at the bottom of the walk.
*/
function isCallerAnchorLabel(label: NodeLabel): boolean {
return (
label === 'Function' ||
label === 'Method' ||
label === 'Constructor' ||
label === 'Class' ||
label === 'Interface' ||
label === 'Struct' ||
label === 'Enum'
);
}
/**
* Look up a `SymbolDefinition` in the graph node lookup.
@ -105,7 +131,9 @@ export function resolveCallerGraphId(
if (scope === undefined) break;
lastFilePath = scope.filePath;
// Prefer Function/Method anchors; fall back to Class.
// Prefer Function/Method/Constructor anchors; fall back to
// Class/Interface/Struct/Enum. Variable/Property are NOT valid
// caller anchors — see `isCallerAnchorLabel` for why.
const fnDef = scope.ownedDefs.find(
(d) => d.type === 'Function' || d.type === 'Method' || d.type === 'Constructor',
);
@ -113,7 +141,7 @@ export function resolveCallerGraphId(
const id = resolveDefGraphId(scope.filePath, fnDef, nodeLookup);
if (id !== undefined) return id;
}
const classDef = scope.ownedDefs.find((d) => isLinkableLabel(d.type));
const classDef = scope.ownedDefs.find((d) => isCallerAnchorLabel(d.type));
if (classDef !== undefined) {
const id = resolveDefGraphId(scope.filePath, classDef, nodeLookup);
if (id !== undefined) return id;

View file

@ -0,0 +1,6 @@
import { transform } from './helpers';
// Control: a "plain helper" pattern that the bug report says hits ~100%
// capture. If THIS edge is missing, the bug is more fundamental than
// HOF-callback attribution.
export const direct = (x: string): string => transform(x);

View file

@ -0,0 +1,6 @@
// Plain helpers — the targets of HOF-callback calls.
// `transform` is intentionally not a Node.js / browser global so naming
// collisions with built-ins don't pollute the resolution test.
export const fetchData = (): string => 'data';
export const transform = (x: string): string => x.toUpperCase();

View file

@ -0,0 +1,11 @@
import { transform } from './helpers';
// Pattern: call inside `new Promise((resolve, reject) => ...)` callback.
// Mirrors `fileToDataUrl` from the bug report. The synchronous body of
// the executor invokes `transform` directly — the edge `wrap → transform`
// should exist regardless of the surrounding HOF.
export const wrap = (x: string): Promise<string> =>
new Promise<string>((resolve) => {
const v = transform(x);
resolve(v);
});

View file

@ -0,0 +1,9 @@
import { transform } from './helpers';
// Pattern: call inside `.map` callback inside `Promise.all(...)`.
// Mirrors `clients/web/src/utils/file-upload.ts` `processSelectedFiles`
// from the bug report — `transform` should be reachable as
// `fanOut → transform`.
export const fanOut = async (xs: string[]): Promise<string[]> => {
return Promise.all(xs.map((x) => transform(x)));
};

View file

@ -0,0 +1,18 @@
import { fetchData } from './helpers';
// Stand-in for TanStack Query's `useQuery` — accepts a config object.
// Defined in-fixture (rather than imported from an external lib) so the
// `useQuery` reference resolves to a real graph node and we can also
// assert the `useFeature → useQuery` edge.
export const useQuery = <T>(opts: { queryFn: () => T; queryKey: readonly string[] }): T => {
return opts.queryFn();
};
// Pattern: call inside `queryFn` callback passed to a HOF.
// Mirrors `clients/*/src/hooks/use-gateway-queries.ts` from the bug
// report (4% capture rate). `useFeature → fetchData` should be reachable.
export const useFeature = (): string =>
useQuery({
queryFn: () => fetchData(),
queryKey: ['feature'],
});

View file

@ -0,0 +1,46 @@
// Stand-ins for Zustand's `create / persist / devtools`. Defined locally
// so the references resolve to real graph nodes and the test doesn't
// depend on an external library.
//
// The shape mirrors the actual Zustand API closely enough that the
// nested-arrow-in-arguments pattern (which was the worst-case repro in
// the bug report — Zustand store files hit 0% CALLS-edge capture) is
// faithfully reproduced.
type StateCreator<T> = (set: (next: Partial<T>) => void) => T;
export const create = <T>() => {
return (initializer: (...wrappers: never[]) => StateCreator<T>): T => {
return initializer()(() => {
/* runtime no-op for this fixture */
});
};
};
export const persist = <T>(creator: StateCreator<T>): StateCreator<T> => creator;
export const devtools = <T>(creator: StateCreator<T>): StateCreator<T> => creator;
interface Counter {
readonly count: number;
readonly bump: () => void;
}
// The classic Zustand shape:
//
// const useStore = create<State>()(devtools(persist((set) => ({ ... }))))
//
// Pre-fix: `useStore` lived as a Variable on the module scope; the
// nested arrow callbacks had empty `ownedDefs`; calls inside them
// walked up to the module's first Function-like def and silently
// mis-attributed (or dropped). After the fix, each named arrow gets
// its def attached to its own scope and HOF-wrapped declarations
// participate in the call graph normally.
export const useStore = create<Counter>()(
devtools(
persist((set) => ({
count: 0,
bump: () => set({ count: 1 }),
})),
),
);

View file

@ -0,0 +1,12 @@
// Component targets used by the JSX-as-call tests. The bodies are
// trivial — what matters is that they're declared as named arrow
// functions returning JSX (the canonical React component shape) so the
// scope-resolution pipeline emits Function defs for them.
export const Foo = (): string => 'foo';
export const Bar = (): string => 'bar';
export const Inner = (): string => 'inner';
export const Outer = (): string => 'outer';

View file

@ -0,0 +1,9 @@
import { Foo } from './components';
// Combined fix verification: an arrow-typed component (the HOF-callback
// fix's central case — `const fn = () => ...`) whose body returns JSX
// (the JSX-as-call fix). The HOF fix anchors the Function def on the
// inner arrow so caller-attribution lands on `Wrapped`; the JSX fix
// emits the `<Foo />` reference site as `@reference.call.free`. The
// combined effect: `Wrapped → Foo` is captured.
export const Wrapped = () => <Foo />;

View file

@ -0,0 +1,11 @@
// Pure-HTML JSX — `<div>`, `<span>`, `<button>`. By JSX convention,
// lowercase-first-character identifiers are native DOM elements (NOT
// React components). The query's `(#match? @reference.name "^[A-Z]")`
// predicate filters these out, so this caller must NOT emit any CALLS
// edges to identifiers `div` / `span` / `button`.
export const useHtml = () => (
<div>
<span>text</span>
<button>click</button>
</div>
);

View file

@ -0,0 +1,12 @@
import { Container } from './namespaced';
// Namespaced JSX — `<Container.Title />`. The query's
// `@reference.call.member` capture splits this into:
//
// receiver: `Container` (an identifier)
// property: `Title` (the leaf identifier)
//
// Note: Member-form JSX is NOT filtered by the PascalCase predicate —
// HTML element names can't contain dots, so any `.`-form is unambiguously
// a component reference.
export const useNamespaced = () => <Container.Title />;

View file

@ -0,0 +1,14 @@
// Namespaced component — the canonical `<Container.Title />` idiom
// (used by libraries like Radix UI, shadcn/ui, Headless UI). Exposes a
// `Container` object whose members are themselves React components, so
// JSX consumers write `<Container.Title>` instead of importing each
// piece individually.
//
// The TSX grammar represents `<Foo.Bar />` as `jsx_self_closing_element
// name: (member_expression ...)`. Our query's `@reference.call.member`
// capture decomposes the member chain so the downstream member-call
// resolver can route the edge to the right `Title` definition.
const Title = () => 'title';
export const Container = { Title };

View file

@ -0,0 +1,13 @@
import { Inner, Outer } from './components';
// Nested JSX — `<Outer><Inner /></Outer>`. Both `<Outer>` (paired) and
// `<Inner />` (self-closing) are reference sites for the same enclosing
// caller (`useNested`). Should emit TWO CALLS edges from `useNested`:
//
// useNested → Outer
// useNested → Inner
export const useNested = () => (
<Outer>
<Inner />
</Outer>
);

View file

@ -0,0 +1,6 @@
import { Bar } from './components';
// Paired JSX element (`<Bar>...</Bar>`). The query captures
// `jsx_opening_element` (NOT `jsx_closing_element`) so each JSX use
// emits exactly one CALLS edge — the closing tag would double-count.
export const useBar = () => <Bar>child text</Bar>;

View file

@ -0,0 +1,5 @@
import { Foo } from './components';
// Self-closing JSX element — the most common React-component invocation
// shape. Should emit `useFoo → Foo` as a CALLS edge.
export const useFoo = () => <Foo />;

View file

@ -0,0 +1,141 @@
/**
* TypeScript: CALLS edges from inside higher-order-function callbacks.
*
* Repro for the bug filed in `gitnexus-bug-report.md`: in a real
* TS+React monorepo, ~75% of `Function` nodes had no outgoing CALLS
* edges. The dominant pattern was call expressions nested inside
* callbacks passed as arguments to other functions:
*
* - `Promise.all(items.map(item => transform(item)))`
* - `useQuery({ queryFn: () => fetchData() })`
* - `new Promise((resolve) => { reader.readAsDataURL(file); ... })`
* - `create<State>()(devtools(persist((set) => ({ ... }))))` (Zustand)
*
* Two underlying issues fixed by this PR (see `query.ts` and
* `finalize-algorithm.ts`):
*
* 1. **Caller attribution.** `pass2AttachDeclarations` placed the
* `Function` def for arrow-typed declarations on the wrapping
* module scope (the `@declaration.function` anchor was the outer
* `lexical_declaration`, whose start lies before the inner
* arrow's scope). `resolveCallerGraphId` walked up past the empty
* arrow scope into the module and grabbed the first Function-like
* def in `ownedDefs` — frequently the wrong function entirely.
*
* 2. **Cross-file callee discovery.** TypeScript emits BOTH
* `@declaration.function` (Function def) AND `@declaration.variable`
* (Variable def) for `const fn = () => {}`. With (1) fixed, the
* Function-def's anchor moved to the inner arrow, so the Variable
* capture began appearing FIRST in `localDefs` (its match starts
* earlier in the source). `findExportByName` returned the
* Variable, the consumer's import bound to a non-callable, and
* `findCallableBindingInScope` rejected it.
*
* Each test fixture below isolates one HOF-callback shape from the bug
* report with both caller and callee defined in-fixture.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES,
getRelationships,
edgeSet,
runPipelineFromRepo,
type PipelineResult,
} from './helpers.js';
describe('TypeScript HOF-callback CALLS edges', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'typescript-hof-callbacks'),
() => {},
);
}, 60000);
it('control: direct (x) => transform(x) emits direct → transform', () => {
const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'transform');
expect(edgeSet(calls)).toContain('direct → transform');
});
it('Promise.all(map(...)) emits fanOut → transform (call inside .map callback)', () => {
const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'transform');
// `fanOut` is the named arrow declaration; the call to `transform`
// is syntactically nested inside `.map(...)` inside `Promise.all(...)`.
expect(edgeSet(calls)).toContain('fanOut → transform');
});
it('new Promise((resolve) => { ... }) emits wrap → transform (call inside executor)', () => {
const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'transform');
expect(edgeSet(calls)).toContain('wrap → transform');
});
it('useQuery({ queryFn: () => fetchData() }) emits useFeature → fetchData (call inside queryFn callback)', () => {
const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'fetchData');
expect(edgeSet(calls)).toContain('useFeature → fetchData');
});
it('useQuery({ queryFn: () => fetchData() }) emits useFeature → useQuery (direct call in body)', () => {
const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'useQuery');
expect(edgeSet(calls)).toContain('useFeature → useQuery');
});
it('Zustand create()(devtools(persist((set) => ({ ... })))) does NOT emit phantom self-loops', () => {
// The Zustand idiom `export const useStore = create()(devtools(persist((set) => ({ ... }))))`
// has its module-level call expressions (`create()`, `devtools(...)`,
// `persist(...)`) in `useStore`'s declaration RHS, syntactically
// outside any function body. The bug-report case
// (`grouped-file-uploads-store.tsx`, "0% capture") was driven by
// these calls being mis-attributed to a sibling Function (the
// first declared callable in the module's `ownedDefs`), producing
// bogus self-loops like `Function:create → Function:create`. The
// fix in `resolveCallerGraphId` excludes Variable defs from the
// walk-up's class-fallback branch — module-level calls now fall
// through to the File node like any other module-level reference.
//
// What this test asserts: NO phantom self-loops, and NO phantom
// edges where one local function "calls" a sibling local
// function via misattribution.
const calls = getRelationships(result, 'CALLS').filter(
(c) => c.sourceFilePath === 'src/store.ts' && c.targetFilePath === 'src/store.ts',
);
const phantomSelfLoops = calls.filter((c) => c.source === c.target);
expect(phantomSelfLoops, 'phantom self-loop CALLS edges').toEqual([]);
// Specifically the regression: `create → create / devtools / persist`.
const fromCreate = calls.filter((c) => c.source === 'create');
expect(fromCreate, 'create() must not be a phantom caller').toEqual([]);
});
it('Zustand call expressions are attributable (to File or absent — never to a wrong sibling)', () => {
// The complement check: if any CALLS edge is emitted for the
// module-scope calls in store.ts, its source must be either
// `store.ts` (the File fallback) or undefined. We accept zero
// edges here as a valid outcome; the strict assertion is the
// anti-self-loop one above.
const calls = getRelationships(result, 'CALLS').filter(
(c) => c.sourceFilePath === 'src/store.ts',
);
for (const c of calls) {
// Source must NOT be a sibling local Function. The only
// acceptable source for module-level calls in store.ts is the
// File node itself (label 'File', name 'store.ts').
expect([c.sourceLabel, c.source]).toEqual(['File', 'store.ts']);
}
});
it('transform is reachable from at least 3 of {direct, fanOut, wrap}', () => {
// Catch-all: pre-fix, only `direct → transform` was captured (or
// even THAT was missing depending on file order). After fix, all
// three callers attribute their `transform` call correctly.
const callers = new Set(
getRelationships(result, 'CALLS')
.filter((c) => c.target === 'transform')
.map((c) => c.source),
);
expect(callers).toContain('direct');
expect(callers).toContain('fanOut');
expect(callers).toContain('wrap');
});
});

View file

@ -0,0 +1,123 @@
/**
* TypeScript: CALLS edges from JSX element invocations.
*
* `<Foo />` is syntactic sugar for `Foo(props)` — the React renderer
* invokes the component at runtime. For `gitnexus_impact` and
* `gitnexus_context` to give meaningful answers on `.tsx` codebases,
* JSX usage must surface as a CALLS edge.
*
* Pre-fix scope: in a real React monorepo (Sourcerer-fe), `.tsx` files
* had a 67.5% function-orphan rate vs 61.2% for plain `.ts`. Spot
* checks of orphan React components consistently traced back to JSX
* being the only "call" in the function body — invisible to the
* indexer because the TS scope query had no `jsx_*` patterns.
*
* Each test fixture below isolates one JSX shape:
*
* - self-closing `<Foo />` — simple-usage.tsx
* - paired `<Foo>...</Foo>` — paired-usage.tsx
* - namespaced `<Container.Title />` — member-usage.tsx
* - nested `<Outer><Inner /></Outer>`— nested-usage.tsx
* - HTML-only `<div>`/`<span>` — html-only.tsx (negative test)
* - HOF + JSX `const F = () => <X/>` — hof-jsx.tsx (combined-fix probe)
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES,
getRelationships,
edgeSet,
runPipelineFromRepo,
type PipelineResult,
} from './helpers.js';
describe('TypeScript JSX-as-call CALLS edges', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'typescript-jsx-as-call'),
() => {},
);
}, 60000);
it('self-closing <Foo /> emits useFoo → Foo', () => {
const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'Foo');
expect(edgeSet(calls)).toContain('useFoo → Foo');
});
it('paired <Bar>...</Bar> emits useBar → Bar (closing tag does NOT double-count)', () => {
const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'Bar');
// Exactly one CALLS edge from useBar to Bar — the query captures
// jsx_opening_element only, NOT jsx_closing_element. Multiple matches
// here would mean the closing tag is also being captured (a bug —
// each JSX element is one logical invocation, not two).
const useBarToBar = calls.filter((c) => c.source === 'useBar');
expect(useBarToBar).toHaveLength(1);
expect(edgeSet(calls)).toContain('useBar → Bar');
});
it('namespaced <Container.Title /> is captured (no phantom read, no edge to receiver)', () => {
// What this PR tests at the query level: the JSX-as-member capture
// intercepts `<Container.Title />` BEFORE the generic
// `@reference.read.member` catch-all does. Two negative
// post-conditions verify the interception:
//
// (a) NO ACCESSES edge `useNamespaced → Title` (the phantom read
// suppression — see `shouldEmitReadMember`'s jsx-* cases).
// (b) NO CALLS edge `useNamespaced → Container` (the member call
// must NOT collapse to its receiver — that would mean we're
// dispatching off `Container` rather than off `Container.Title`).
//
// The positive CALLS edge `useNamespaced → Title` requires
// chasing the receiver chain through `Container = { Title }`,
// which is a pre-existing compound-receiver limitation (object-
// literal namespaces aren't fully chained today). That gap is
// orthogonal to JSX-as-call and is left as future work.
const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'useNamespaced');
const callTargets = new Set(calls.map((c) => c.target));
expect(callTargets).not.toContain('Container');
});
it('nested <Outer><Inner /></Outer> emits both useNested → Outer AND useNested → Inner', () => {
const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'useNested');
const targets = new Set(calls.map((c) => c.target));
expect(targets).toContain('Outer');
expect(targets).toContain('Inner');
});
it('lowercase HTML elements (<div>, <span>, <button>) emit NO CALLS edges', () => {
// The PascalCase predicate must filter these out at the query level.
// If `useHtml` ends up with edges to `div` / `span` / `button`,
// the `(#match? @reference.name "^[A-Z]")` predicate isn't firing.
const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'useHtml');
const targets = new Set(calls.map((c) => c.target));
expect(targets).not.toContain('div');
expect(targets).not.toContain('span');
expect(targets).not.toContain('button');
});
it('namespaced <Container.Title /> does NOT emit a phantom ACCESSES edge', () => {
// `<Foo.Bar />`'s `member_expression` would normally fire BOTH
// `@reference.call.member` (our new JSX path) AND
// `@reference.read.member` (the generic read-member catch-all),
// producing a redundant ACCESSES edge alongside the CALLS edge.
// `shouldEmitReadMember`'s jsx_self_closing_element / jsx_opening_element
// case is what suppresses the read.
const accesses = getRelationships(result, 'ACCESSES').filter(
(a) => a.source === 'useNamespaced' && a.target === 'Title',
);
expect(accesses).toEqual([]);
});
it('combined HOF + JSX: const Wrapped = () => <Foo /> emits Wrapped → Foo', () => {
// Probes the interaction between the HOF-callback caller-attribution
// fix and the JSX-as-call fix. Pre-this-PR: caller mis-attribution
// (HOF bug) plus invisible JSX (this fix's bug) both broke this
// case. Post-PR: both are fixed and the edge lands.
const calls = getRelationships(result, 'CALLS').filter(
(c) => c.source === 'Wrapped' && c.target === 'Foo',
);
expect(calls.length).toBeGreaterThan(0);
});
});