mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
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:
parent
0b34f5f636
commit
c50014ca44
20 changed files with 598 additions and 15 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
6
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/control.ts
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/control.ts
vendored
Normal 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);
|
||||
6
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/helpers.ts
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/helpers.ts
vendored
Normal 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();
|
||||
11
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/promise-ctor.ts
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/promise-ctor.ts
vendored
Normal 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);
|
||||
});
|
||||
9
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/promise-map.ts
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/promise-map.ts
vendored
Normal 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)));
|
||||
};
|
||||
18
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/query-hook.ts
vendored
Normal file
18
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/query-hook.ts
vendored
Normal 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'],
|
||||
});
|
||||
46
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/store.ts
vendored
Normal file
46
gitnexus/test/fixtures/lang-resolution/typescript-hof-callbacks/src/store.ts
vendored
Normal 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 }),
|
||||
})),
|
||||
),
|
||||
);
|
||||
12
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/components.tsx
vendored
Normal file
12
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/components.tsx
vendored
Normal 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';
|
||||
9
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/hof-jsx.tsx
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/hof-jsx.tsx
vendored
Normal 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 />;
|
||||
11
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/html-only.tsx
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/html-only.tsx
vendored
Normal 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>
|
||||
);
|
||||
12
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/member-usage.tsx
vendored
Normal file
12
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/member-usage.tsx
vendored
Normal 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 />;
|
||||
14
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/namespaced.tsx
vendored
Normal file
14
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/namespaced.tsx
vendored
Normal 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 };
|
||||
13
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/nested-usage.tsx
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/nested-usage.tsx
vendored
Normal 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>
|
||||
);
|
||||
6
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/paired-usage.tsx
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/paired-usage.tsx
vendored
Normal 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>;
|
||||
5
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/simple-usage.tsx
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/simple-usage.tsx
vendored
Normal 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 />;
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue