diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index 5ebb098f11..d363c3c97f 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -740,10 +740,58 @@ function findExportByName( defs: readonly SymbolDefinition[], name: string, ): SymbolDefinition | undefined { + // GENERIC RULE (applies to every language using this finalize + // algorithm): when MULTIPLE `SymbolDefinition`s share the same simple + // name in `localDefs`, prefer callable / type-like defs over plain + // value defs (`Variable`, `Property`, …). The CALLER side of an + // import almost always wants the callable, not a value shadow that + // happens to share the name — and without a deterministic + // preference, capture order silently decides which def the import + // binds to. + // + // The single-def case is unchanged: when only one def has the name, + // it's returned regardless of its type (the `fallback` path below). + // + // TypeScript is the first known language where this matters in + // practice: `const fn = () => {}` emits 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`), and consumers of `import { fn }` + // need to bind to the callable. Other migrated languages don't + // currently produce dual emits of this shape, so the rule is a no-op + // for them today; future languages get the same correctness + // guarantee for free if they ever do. + // + // See `gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts` + // for the cross-file regression this rule prevents. + 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 = 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, files: Set): number { diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index e9dc21ab4b..e2ec9d23f1 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -57,8 +57,20 @@ import { } from './typescript/index.js'; /** - * TypeScript/JavaScript: arrow_function and function_expression get their name - * from the parent variable_declarator (e.g. `const foo = () => {}`). + * TypeScript/JavaScript: arrow_function and function_expression are + * anonymous AST nodes — they take their name from the surrounding + * declarative context. + * + * Recognised contexts: + * - `const foo = () => {}` (variable_declarator) → "foo" + * - `{ addItem: (item) => ... }` (pair / property_assignment) → "addItem" + * Covers Zustand stores, TanStack Query factories, React Context + * providers, and most other HOF-heavy idioms (issue #1166). + * + * Returns `null` for funcName when the arrow lives in a context that has + * no static name — call arguments, computed keys, return-from-arrow + * positions. The parent walk in findEnclosingFunctionId then continues + * up to the next named ancestor (or to the file). */ const tsExtractFunctionName = ( node: SyntaxNode, @@ -66,19 +78,43 @@ const tsExtractFunctionName = ( if (node.type !== 'arrow_function' && node.type !== 'function_expression') return null; const parent = node.parent; - if (parent?.type !== 'variable_declarator') return null; + if (!parent) return null; - let nameNode = parent.childForFieldName?.('name'); - if (!nameNode) { - for (let i = 0; i < parent.childCount; i++) { - const c = parent.child(i); - if (c?.type === 'identifier') { - nameNode = c; - break; + if (parent.type === 'variable_declarator') { + let nameNode = parent.childForFieldName?.('name'); + if (!nameNode) { + for (let i = 0; i < parent.childCount; i++) { + const c = parent.child(i); + if (c?.type === 'identifier') { + nameNode = c; + break; + } } } + return { funcName: nameNode?.text ?? null, label: 'Function' }; } - return { funcName: nameNode?.text ?? null, label: 'Function' }; + + // Object property pair: `{ addItem: (item) => ... }`. + // tree-sitter-typescript uses `pair`; tree-sitter-javascript also exposes + // `pair`. (Older grammars used `property_assignment`; we accept both.) + if (parent.type === 'pair' || parent.type === 'property_assignment') { + const keyNode = parent.childForFieldName?.('key'); + if (!keyNode) return { funcName: null, label: 'Function' }; + if (keyNode.type === 'property_identifier' || keyNode.type === 'identifier') { + return { funcName: keyNode.text, label: 'Function' }; + } + if (keyNode.type === 'string') { + // `"add-item": () => ...` — the literal text inside the quotes. + const fragment = keyNode.children?.find((c: SyntaxNode) => c.type === 'string_fragment'); + const text = fragment?.text ?? null; + return { funcName: text, label: 'Function' }; + } + // computed_property_name (`[ACTION_KEY]`) and other dynamic keys have + // no static name — fall through anonymous. + return { funcName: null, label: 'Function' }; + } + + return { funcName: null, label: 'Function' }; }; export const BUILT_INS: ReadonlySet = new Set([ diff --git a/gitnexus/src/core/ingestion/languages/typescript/captures.ts b/gitnexus/src/core/ingestion/languages/typescript/captures.ts index e7bb916988..9d083c15c7 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/captures.ts @@ -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, `` 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; } @@ -232,6 +240,20 @@ export function emitTsScopeCaptures( // arity filter can narrow overloads. Count the `argument` named // children of the backing `arguments` node. TypeScript constructor // calls use `new_expression`; regular calls use `call_expression`. + // + // JSX call anchors (`jsx_self_closing_element` / `jsx_opening_element` + // captured by the TSX-only suffix in `query.ts`) intentionally do + // NOT carry arity metadata. The lookup below would resolve `callNode` + // to `null` for a JSX anchor (the anchor is neither a call_expression + // nor a new_expression), so the synthesis branch silently no-ops and + // the JSX call enters the registry with name-only resolution. This + // is acceptable for React: components are virtually never + // overloaded in the current GitNexus graph model, so name-only + // dispatch matches the single component definition. If a future + // codebase introduces overloaded React components AND needs JSX + // calls to disambiguate by props-arity, a JSX-aware arity + // synthesizer would need to count `jsx_attribute` children of the + // opening tag instead of `arguments`. const callAnchor = pickFirstDefined(grouped, CALL_TAGS); if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) { const callNode = diff --git a/gitnexus/src/core/ingestion/languages/typescript/query.ts b/gitnexus/src/core/ingestion/languages/typescript/query.ts index 5645b0868f..07d02f507b 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/query.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/query.ts @@ -136,25 +136,83 @@ 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)) + +;; Object-property arrows / function expressions named by their pair key: +;; \`{ addItem: (item) => ..., removeItem: (item) => ... }\`. The legacy +;; TYPESCRIPT_QUERIES emits the same shape; mirroring it here keeps +;; scope-resolution declarations in sync (issue #1166). Computed keys +;; (\`[K]: () => ...\`) intentionally fall through anonymous. +;; +;; Same anchor discipline as the \`lexical_declaration\` block above: the +;; \`@declaration.function\` capture must sit on the INNER \`arrow_function\` +;; / \`function_expression\` node — NOT the outer \`pair\`. The pair node +;; starts at the property-key token, BEFORE the arrow's +;; \`@scope.function\` range. \`pass2AttachDeclarations.atPosition(pair.startLine, +;; pair.startCol)\` therefore resolves to the PARENT scope (the enclosing +;; function-like, e.g. the \`(set) => ({...})\` callback in +;; \`persist((set) => ({...}))\`), not the inner arrow's own scope. +;; +;; With the anchor on \`pair\`, ALL pair-function defs from the same object +;; literal land in the same parent scope's \`ownedDefs\`. \`resolveCallerGraphId\` +;; walking up from a call inside any of those arrows then matches the +;; FIRST Function-like def via \`ownedDefs.find()\` — silently mis-attributing +;; every call to the first sibling. Multi-action Zustand stores +;; (\`{ addItem, removeItem, fetchData, … }\`) — the dominant 0%-capture +;; pattern in the bug report — would land all calls on \`addItem\`. +;; +;; With the anchor on the inner \`arrow_function\` / \`function_expression\`, +;; \`anchor.range\` matches the arrow's own \`@scope.function\` range; the +;; def lands in the arrow scope's own \`ownedDefs\` and \`pass2AttachDeclarations\`'s +;; auto-hoist (\`rangesEqual(anchor.range, innermost.range)\`) promotes +;; the BINDING to the parent scope (so importers and lookups still find +;; the name in the object's surrounding scope). Each pair-arrow becomes +;; an independent caller anchor in the walk. +(pair + key: (property_identifier) @declaration.name + value: (arrow_function) @declaration.function) + +(pair + key: (property_identifier) @declaration.name + value: (function_expression) @declaration.function) + +(pair + key: (string (string_fragment) @declaration.name) + value: (arrow_function) @declaration.function) + +(pair + key: (string (string_fragment) @declaration.name) + value: (function_expression) @declaration.function) ;; Method definitions — regular + private (#field) methods. (method_definition @@ -723,6 +781,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: \`\` 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 \`
\`, \`\`, \` +
+); diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/member-usage.tsx b/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/member-usage.tsx new file mode 100644 index 0000000000..00f49c602f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/member-usage.tsx @@ -0,0 +1,12 @@ +import { Container } from './namespaced'; + +// Namespaced JSX — ``. 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 = () => ; diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/namespaced.tsx b/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/namespaced.tsx new file mode 100644 index 0000000000..2e83cec4c8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/namespaced.tsx @@ -0,0 +1,14 @@ +// Namespaced component — the canonical `` 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 `` instead of importing each +// piece individually. +// +// The TSX grammar represents `` 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 }; diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/nested-usage.tsx b/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/nested-usage.tsx new file mode 100644 index 0000000000..29d1fb7ea3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/nested-usage.tsx @@ -0,0 +1,13 @@ +import { Inner, Outer } from './components'; + +// Nested JSX — ``. Both `` (paired) and +// `` (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 = () => ( + + + +); diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/paired-usage.tsx b/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/paired-usage.tsx new file mode 100644 index 0000000000..2776205eb4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/paired-usage.tsx @@ -0,0 +1,6 @@ +import { Bar } from './components'; + +// Paired JSX element (`...`). 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 = () => child text; diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/simple-usage.tsx b/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/simple-usage.tsx new file mode 100644 index 0000000000..a41d2b0820 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-jsx-as-call/src/simple-usage.tsx @@ -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 = () => ; diff --git a/gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts b/gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts new file mode 100644 index 0000000000..f23df9c111 --- /dev/null +++ b/gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts @@ -0,0 +1,245 @@ +/** + * 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()(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 queryFn → fetchData (call inside named pair-arrow)', () => { + // The structurally correct attribution: `fetchData()` is called + // from inside the named pair-arrow `queryFn: () => fetchData()`. + // After moving `@declaration.function` to the inner arrow (mirroring + // the `lexical_declaration` fix), the pair-arrow becomes its own + // caller anchor — `resolveCallerGraphId`'s walk-up stops at + // `queryFn`'s scope rather than continuing into `useFeature`'s. + // + // Pre-fix this test asserted `useFeature → fetchData` because the + // pair pattern's `@declaration.function` anchor was on the outer + // `pair`, sending `queryFn`'s def into `useFeature`'s `ownedDefs` + // and bypassing `queryFn` as a caller anchor. That attribution + // was wrong twice over: it crossed a syntactic function boundary + // (the arrow body), and it depended on the pair-pattern bug to + // reroute the walk. Edges that capture intent should follow the + // syntax tree. + const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'fetchData'); + expect(edgeSet(calls)).toContain('queryFn → 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 module-level calls source from the File node (not a sibling Function)', () => { + // The positive complement to the anti-self-loop assertion above: + // module-level calls in `store.ts` (`create()`, `devtools(...)`, + // `persist(...)`) MUST attribute to the `File` node — that's the + // entire point of `isCallerAnchorLabel` excluding `Variable` from + // the caller-walk fallback. If the fix regresses (Variable defs + // re-enter the fallback, or the walk-up grabs a sibling Function), + // the source would change away from `File:store.ts`. + // + // Earlier formulation iterated `for (c of calls)` and asserted each + // edge sourced from File. That passed VACUOUSLY when `calls` was + // empty — any change that silenced ALL CALLS edges from `store.ts` + // would have slipped through. The structural assertion below is + // explicit: at least one File-rooted edge must exist (proving the + // fallback fired), and no edge may source from anything else + // (proving the fallback fired EXCLUSIVELY, not as one option + // alongside a buggy sibling-Function attribution). + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'src/store.ts', + ); + const fromFile = calls.filter((c) => c.sourceLabel === 'File' && c.source === 'store.ts'); + const fromOther = calls.filter((c) => !(c.sourceLabel === 'File' && c.source === 'store.ts')); + expect(fromOther, 'no module-level call may attribute to a non-File source').toEqual([]); + expect(fromFile.length, 'at least one File-rooted call edge must exist').toBeGreaterThan(0); + }); + + 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'); + }); + + // ───────────────────────────────────────────────────────────────── + // Multi-pair object literal — regression case the single-pair `bump` + // fixture in `store.ts` masked. See `multi-action-store.ts` and the + // anchor-discipline comment in `query.ts` above the four pair-with- + // arrow patterns. PR #1175 review (medium finding) flagged this. + // ───────────────────────────────────────────────────────────────── + + it('multi-action store: addItem → doA (calls inside addItem attribute to addItem, not first sibling)', () => { + // The diagnostic test for the pair-anchor fix. With the broken + // anchor (on outer `pair`), all three pair-function defs (addItem, + // removeItem, fetchData) landed in the same `(set) => ({...})` + // callback's `ownedDefs`, and `resolveCallerGraphId.ownedDefs.find()` + // returned the FIRST one — `addItem` — for every walk-up. So + // calls inside `removeItem` and `fetchData` got mis-attributed. + // + // After the fix, each pair-arrow gets its def in its OWN arrow + // scope's `ownedDefs`; the walk-up stops one level earlier and + // resolves to the correct sibling. + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'src/multi-action-store.ts', + ); + const fromAddItem = calls.filter((c) => c.source === 'addItem' && c.target === 'doA'); + expect(fromAddItem.length, 'addItem must call doA').toBeGreaterThan(0); + }); + + it('multi-action store: removeItem → doB (NOT addItem → doB)', () => { + // The exact regression fingerprint. Pre-fix, `removeItem`'s body + // would attribute its `doB(item)` call to `addItem` (the first + // pair-function def in the parent `(set) => ({...})` scope), + // producing the bogus edge `addItem → doB` and zero outgoing + // edges from `removeItem`. The negative + positive assertion + // pinpoints both halves: no mis-attribution AND a real edge. + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'src/multi-action-store.ts' && c.target === 'doB', + ); + const fromRemoveItem = calls.filter((c) => c.source === 'removeItem'); + const fromAddItem = calls.filter((c) => c.source === 'addItem'); + expect( + fromAddItem, + 'doB must NOT be attributed to addItem (mis-attribution regression)', + ).toEqual([]); + expect(fromRemoveItem.length, 'removeItem must call doB').toBeGreaterThan(0); + }); + + it('multi-action store: fetchData → doC (third action also attributes correctly)', () => { + // Three actions in the same object guarantees the `find()`-returns- + // first defect would mis-attribute fetchData's call. With the fix, + // each action's body is its own caller anchor. + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'src/multi-action-store.ts' && c.target === 'doC', + ); + const fromFetch = calls.filter((c) => c.source === 'fetchData'); + const fromAddItem = calls.filter((c) => c.source === 'addItem'); + expect(fromAddItem, 'doC must NOT be attributed to addItem').toEqual([]); + expect(fromFetch.length, 'fetchData must call doC').toBeGreaterThan(0); + }); + + it('multi-action store: each action attributes calls to itself (no cross-sibling leakage)', () => { + // Whole-of-fixture invariant: the set of (source, target) pairs + // for the three action calls must be exactly {addItem→doA, + // removeItem→doB, fetchData→doC}. No sibling leakage allowed. + const calls = getRelationships(result, 'CALLS').filter( + (c) => + c.sourceFilePath === 'src/multi-action-store.ts' && + ['doA', 'doB', 'doC'].includes(c.target as string), + ); + const pairs = new Set(calls.map((c) => `${c.source} → ${c.target}`)); + expect(pairs).toContain('addItem → doA'); + expect(pairs).toContain('removeItem → doB'); + expect(pairs).toContain('fetchData → doC'); + // No cross-attribution like `addItem → doB`, `addItem → doC`, etc. + const crossLeaks = [...pairs].filter( + (p) => + p === 'addItem → doB' || + p === 'addItem → doC' || + p === 'removeItem → doA' || + p === 'removeItem → doC' || + p === 'fetchData → doA' || + p === 'fetchData → doB', + ); + expect(crossLeaks, 'no pair-arrow may attribute calls to a sibling action').toEqual([]); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript-jsx-as-call.test.ts b/gitnexus/test/integration/resolvers/typescript-jsx-as-call.test.ts new file mode 100644 index 0000000000..d1fef45596 --- /dev/null +++ b/gitnexus/test/integration/resolvers/typescript-jsx-as-call.test.ts @@ -0,0 +1,128 @@ +/** + * TypeScript: CALLS edges from JSX element invocations. + * + * `` 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 `` — simple-usage.tsx + * - paired `...` — paired-usage.tsx + * - namespaced `` — member-usage.tsx + * - nested ``— nested-usage.tsx + * - HTML-only `
`/`` — html-only.tsx (negative test) + * - HOF + JSX `const F = () => ` — 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 emits useFoo → Foo', () => { + const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'Foo'); + expect(edgeSet(calls)).toContain('useFoo → Foo'); + }); + + it('paired ... 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 is captured (no phantom read, no edge to receiver)', () => { + // What this PR tests at the query level: the JSX-as-member capture + // intercepts `` 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 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 (
, ,