From 93a0be2310263fe1db4af050eb95bb16f99f75ad Mon Sep 17 00:00:00 2001 From: abhigyanpatwari Date: Wed, 29 Apr 2026 05:33:40 +0530 Subject: [PATCH] fix(ts): attribute calls inside HOF/callback patterns to the right function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two roots in `findEnclosingFunctionId` (parse-worker) and the parallel `findEnclosingFunction` (call-processor): A. `genericFuncName` scanned `arrow_function` / `function_expression` children for the first identifier and returned it. For unparenthesized arrows like `file => processFile(file)` the first identifier is the parameter `file`, so calls inside got attributed to a phantom `Function file` ID and emitted dangling CALLS edges that never showed up in `(:Function)-[:CALLS]->()` queries. B. `tsExtractFunctionName` only named arrows whose parent was `variable_declarator`. Object-property arrows like `addItem: (item) => set(...)` (Zustand stores, TanStack queryFn, React Context providers, config objects) live under a `pair`, so they were treated as anonymous. With no named ancestor up to the file, every call inside fell back to the File and became invisible to `context()` / `impact()`. Fix: - `genericFuncName` returns null for anonymous JS/TS function-likes — the language hook is authoritative. - `tsExtractFunctionName` resolves names from `pair` parents (property_identifier / string keys; computed keys stay anonymous). - Mirror the new shape in `TYPESCRIPT_QUERIES` / `JAVASCRIPT_QUERIES` / the scope-resolution query so pair-with-arrow becomes a Function declaration node — call sourceIds resolve to a real graph node. Adds 18 unit tests pinning attribution and definition behaviour for plain helpers, `arr.map(x => fn(x))`, Promise constructor callbacks, Zustand-style nested HOFs, TanStack query factories, string-keyed pairs, and computed-key anonymity. Fixes #1166 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../core/ingestion/languages/typescript.ts | 60 ++- .../ingestion/languages/typescript/query.ts | 21 + .../src/core/ingestion/tree-sitter-queries.ts | 41 ++ .../src/core/ingestion/utils/ast-helpers.ts | 16 +- .../unit/call-attribution-issue-1166.test.ts | 395 ++++++++++++++++++ 5 files changed, 521 insertions(+), 12 deletions(-) create mode 100644 gitnexus/test/unit/call-attribution-issue-1166.test.ts diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index e9dc21ab4..880c00426 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,45 @@ 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/query.ts b/gitnexus/src/core/ingestion/languages/typescript/query.ts index 5645b0868..7c84ce5df 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/query.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/query.ts @@ -156,6 +156,27 @@ const TYPESCRIPT_SCOPE_QUERY = ` name: (identifier) @declaration.name value: (function_expression))) @declaration.function +;; Object-property arrows / function expressions named by their pair key: +;; \`{ addItem: (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. +(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 name: (property_identifier) @declaration.name) @declaration.method diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 18b163777..61030ff66 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -61,6 +61,29 @@ export const TYPESCRIPT_QUERIES = ` name: (identifier) @name value: (function_expression)))) @definition.function +; Object-property arrows / function expressions: \`{ addItem: () => ... }\`. +; The pair's key field carries the meaningful name. Without these patterns, +; calls inside the arrow are attributed to the file (issue #1166), and the +; arrow itself is invisible to context() / impact() despite carrying real +; behaviour (Zustand actions, TanStack queryFn, React Context providers). +; String-key variant covers \`"add-item": () => ...\`; computed keys +; (\`[K]: () => ...\`) intentionally fall through anonymous. +(pair + key: (property_identifier) @name + value: (arrow_function)) @definition.function + +(pair + key: (property_identifier) @name + value: (function_expression)) @definition.function + +(pair + key: (string (string_fragment) @name) + value: (arrow_function)) @definition.function + +(pair + key: (string (string_fragment) @name) + value: (function_expression)) @definition.function + ; Variable/constant declarations (non-function values). ; Overlap with @definition.function patterns is handled by parse-worker dedup. (lexical_declaration @@ -219,6 +242,24 @@ export const JAVASCRIPT_QUERIES = ` name: (identifier) @name value: (function_expression)))) @definition.function +; Object-property arrows / function expressions: \`{ addItem: () => ... }\`. +; See TYPESCRIPT_QUERIES for rationale (issue #1166). +(pair + key: (property_identifier) @name + value: (arrow_function)) @definition.function + +(pair + key: (property_identifier) @name + value: (function_expression)) @definition.function + +(pair + key: (string (string_fragment) @name) + value: (arrow_function)) @definition.function + +(pair + key: (string (string_fragment) @name) + value: (function_expression)) @definition.function + ; Variable/constant declarations (non-function values). ; Overlap with @definition.function patterns is handled by parse-worker dedup. (lexical_declaration diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 05cd99024..ec76cd1db 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -429,10 +429,24 @@ export const findSiblingChild = ( /** Generic name extraction from a function-like AST node. * Tries `node.childForFieldName('name')?.text`, then scans children for - * `identifier` / `property_identifier` / `simple_identifier`. */ + * `identifier` / `property_identifier` / `simple_identifier`. + * + * `arrow_function` and `function_expression` (TS/JS) are inherently + * anonymous — they have no `name` field, and their first identifier + * child is a *parameter*, not a function name. Returning a parameter + * identifier here would synthesize phantom Function IDs (e.g. callers + * walking up from a call inside `arr.map(x => fn(x))` would get + * attributed to a non-existent "Function x"). The language's + * `methodExtractor.extractFunctionName` hook is responsible for naming + * these via parent context (variable_declarator, pair, etc.); when it + * declines, the parent walk should continue rather than fall through + * here. See issue #1166. */ export const genericFuncName = (node: SyntaxNode): string | null => { const nameField = node.childForFieldName?.('name'); if (nameField) return nameField.text; + if (node.type === 'arrow_function' || node.type === 'function_expression') { + return null; + } for (let i = 0; i < node.childCount; i++) { const c = node.child(i); if ( diff --git a/gitnexus/test/unit/call-attribution-issue-1166.test.ts b/gitnexus/test/unit/call-attribution-issue-1166.test.ts new file mode 100644 index 000000000..5dfbb06ad --- /dev/null +++ b/gitnexus/test/unit/call-attribution-issue-1166.test.ts @@ -0,0 +1,395 @@ +/** + * Regression coverage for issue #1166: CALLS edge collector misses ~75% of + * functions in HOF / callback patterns. + * + * The bug had two distinct roots, both in the funcName fallback used by + * `findEnclosingFunctionId` (parse-worker.ts) and `findEnclosingFunction` + * (call-processor.ts): + * + * const efnResult = provider.methodExtractor?.extractFunctionName?.(current); + * const funcName = efnResult?.funcName ?? genericFuncName(current); + * + * A. `genericFuncName` scanned `arrow_function` / `function_expression` + * children for the first identifier and returned it. For unparenthesized + * arrows like `file => processFile(file)` the first identifier is the + * parameter `file`, so calls inside got attributed to a phantom + * `Function file` ID. The CALLS edges were emitted with a dangling + * sourceId and never showed up in `(:Function)-[:CALLS]->()` queries. + * + * B. `tsExtractFunctionName` only named arrows whose parent was + * `variable_declarator`. Object-property arrows like + * `addItem: (item) => set(...)` (Zustand / TanStack / config objects) + * have a `pair` parent and were treated as anonymous. With no named + * ancestor up to the file, every call inside fell back to the File ID. + * + * These tests pin attribution behavior for both root causes and the common + * patterns from the issue (Zustand store, Promise.all+map, TanStack query). + */ + +import { describe, it, expect } from 'vitest'; +import Parser from 'tree-sitter'; +import TS from 'tree-sitter-typescript'; +import { TYPESCRIPT_QUERIES } from '../../src/core/ingestion/tree-sitter-queries.js'; +import { typescriptProvider } from '../../src/core/ingestion/languages/typescript.js'; +import { + FUNCTION_NODE_TYPES, + genericFuncName, + inferFunctionLabel, + type SyntaxNode, +} from '../../src/core/ingestion/utils/ast-helpers.js'; + +// ─── Test harness ──────────────────────────────────────────────────────────── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const TS_GRAMMAR = (TS as any).typescript as Parameters[0]; + +function makeParserAndQuery(): { parser: Parser; query: Parser.Query } { + const parser = new Parser(); + parser.setLanguage(TS_GRAMMAR); + const query = new Parser.Query(TS_GRAMMAR, TYPESCRIPT_QUERIES); + return { parser, query }; +} + +/** + * Mirror of the name-resolution slice of `findEnclosingFunctionId` + * (parse-worker.ts) and `findEnclosingFunction` (call-processor.ts). + * + * We deliberately re-implement the parent walk here rather than importing + * the production function: parse-worker.ts is a Worker entry point that + * can't be loaded from the main thread, and the function is private to + * its module. Using the same exported primitives (FUNCTION_NODE_TYPES, + * genericFuncName, provider.methodExtractor.extractFunctionName) means a + * real fix flows through here unchanged. + */ +function attributeCall(callNode: SyntaxNode): { name: string | null; nodeType: string | null } { + let current = callNode.parent; + while (current) { + if (FUNCTION_NODE_TYPES.has(current.type)) { + const efn = typescriptProvider.methodExtractor?.extractFunctionName?.(current); + const funcName = efn?.funcName ?? genericFuncName(current); + // Touch inferFunctionLabel so the import isn't dead — it's the same + // call the real findEnclosingFunctionId makes and we want to keep + // this harness aligned with production. + void inferFunctionLabel(current.type); + if (funcName) return { name: funcName, nodeType: current.type }; + } + current = current.parent; + } + return { name: null, nodeType: null }; +} + +interface CallSite { + calledName: string; + line: number; + attributedTo: string | null; +} + +function collectCallAttributions(code: string): CallSite[] { + const { parser, query } = makeParserAndQuery(); + const tree = parser.parse(code); + const results: CallSite[] = []; + for (const match of query.matches(tree.rootNode)) { + const captures: Record = {}; + for (const c of match.captures) captures[c.name] = c.node; + if (!captures['call'] || !captures['call.name']) continue; + const callNode = captures['call']; + const name = captures['call.name'].text; + results.push({ + calledName: name, + line: callNode.startPosition.row + 1, + attributedTo: attributeCall(callNode).name, + }); + } + return results; +} + +function findCall(sites: CallSite[], name: string): CallSite | undefined { + return sites.find((s) => s.calledName === name); +} + +// ─── Bug A: genericFuncName must not return parameter identifiers ─────────── + +describe('issue #1166 — Bug A: anonymous arrows do not borrow parameter names', () => { + it('returns null for arrow_function (would otherwise leak parameter identifier)', () => { + const { parser } = makeParserAndQuery(); + // Single-param unparenthesized arrow: `file => processFile(file)`. + // tree-sitter-typescript wires the parameter as a direct identifier + // child of the arrow, so genericFuncName's "first identifier child" + // fallback used to return "file". + const tree = parser.parse('const x = files.map(file => processFile(file));'); + let arrow: SyntaxNode | null = null; + const walk = (n: SyntaxNode) => { + if (n.type === 'arrow_function') { + arrow = n; + return; + } + for (let i = 0; i < n.namedChildCount; i++) { + if (arrow) return; + const child = n.namedChild(i); + if (child) walk(child); + } + }; + walk(tree.rootNode); + expect(arrow).not.toBeNull(); + expect(genericFuncName(arrow!)).toBeNull(); + }); + + it('returns null for function_expression (would otherwise leak named-funexpr identifier)', () => { + const { parser } = makeParserAndQuery(); + // `function (x) { return x; }` (anonymous function expression). We + // also accept named expressions like `function inner(x) { ... }` + // returning null here — naming for those flows through the parent + // (variable_declarator / pair / call argument), same as arrows. + const tree = parser.parse('const x = arr.filter(function (x) { return isOk(x); });'); + let fnExpr: SyntaxNode | null = null; + const walk = (n: SyntaxNode) => { + if (n.type === 'function_expression') { + fnExpr = n; + return; + } + for (let i = 0; i < n.namedChildCount; i++) { + if (fnExpr) return; + const child = n.namedChild(i); + if (child) walk(child); + } + }; + walk(tree.rootNode); + expect(fnExpr).not.toBeNull(); + expect(genericFuncName(fnExpr!)).toBeNull(); + }); + + it('attributes call inside `.map(file => fn(file))` to the outer named function, not "file"', () => { + const sites = collectCallAttributions(` + export const processSelectedFiles = async (files: File[]) => { + return Promise.all(files.map(file => processFile(file))); + }; + `); + const processFileCall = findCall(sites, 'processFile'); + expect(processFileCall, 'processFile call should be captured').toBeDefined(); + expect(processFileCall!.attributedTo).toBe('processSelectedFiles'); + // Defensive: assert we never produce the bogus parameter-as-name + // attribution for ANY call in this snippet. + for (const s of sites) { + expect(s.attributedTo, `call ${s.calledName} at L${s.line}`).not.toBe('file'); + } + }); + + it('attributes call inside parenthesized single-param arrow to the outer function', () => { + // `(item) => doStuff(item)` — different from unparenthesized `item => ...` + // because the param sits inside `formal_parameters` and isn't a direct + // child. Used to work; we pin it to guard against future regressions. + const sites = collectCallAttributions(` + export const handler = (items: Item[]) => items.forEach((item) => doStuff(item)); + `); + const doStuff = findCall(sites, 'doStuff'); + expect(doStuff?.attributedTo).toBe('handler'); + }); +}); + +// ─── Bug B: object-property arrows take their name from pair.key ──────────── + +describe('issue #1166 — Bug B: object-property arrows are named by pair.key', () => { + it('attributes call inside `addItem: (item) => fn(item)` to "addItem"', () => { + const sites = collectCallAttributions(` + export const store = { + addItem: (item) => doSomething(item), + fetchData: async () => { + const result = await api.fetch(); + return result; + }, + }; + `); + const doSomething = findCall(sites, 'doSomething'); + expect(doSomething?.attributedTo).toBe('addItem'); + const fetchCall = findCall(sites, 'fetch'); + // `fetch` is in BUILT_INS in typescript.ts, but the harness here doesn't + // filter built-ins — what we care about is the *attribution*, which + // should be 'fetchData', not the file. + expect(fetchCall?.attributedTo).toBe('fetchData'); + }); + + it('handles function_expression pair values (`addItem: function(item) { ... }`)', () => { + const sites = collectCallAttributions(` + export const store = { + addItem: function (item) { doSomething(item); }, + }; + `); + expect(findCall(sites, 'doSomething')?.attributedTo).toBe('addItem'); + }); + + it('handles string-key pairs (`"add-item": (item) => ...`)', () => { + const sites = collectCallAttributions(` + export const store = { + "add-item": (item) => doSomething(item), + }; + `); + expect(findCall(sites, 'doSomething')?.attributedTo).toBe('add-item'); + }); + + it('handles computed property keys gracefully — falls back to outer scope or file', () => { + // Computed keys like `[ACTION_KEY]: (item) => fn(item)` cannot be + // statically named. We don't want to invent a name from inner tokens. + // Either attribute to the enclosing named scope, or to null (file) — + // both are acceptable; what matters is no phantom IDs. + const sites = collectCallAttributions(` + export const buildStore = () => ({ + [ACTION_KEY]: (item) => doSomething(item), + }); + `); + const attr = findCall(sites, 'doSomething')?.attributedTo; + expect([null, 'buildStore']).toContain(attr); + }); + + it('handles Zustand-style nested HOF — calls inside addItem attribute to "addItem"', () => { + const sites = collectCallAttributions(` + export const useStore = create()( + devtools(persist((set, get) => ({ + addItem: (item) => set((state) => doSomething(state, item)), + fetchData: async () => { + const result = await api.fetch(); + return result; + }, + }), { name: 'store' })) + ); + `); + const doSomething = findCall(sites, 'doSomething'); + expect(doSomething, 'doSomething call should be captured').toBeDefined(); + expect(doSomething!.attributedTo).toBe('addItem'); + const fetchCall = findCall(sites, 'fetch'); + expect(fetchCall?.attributedTo).toBe('fetchData'); + // `set` and `state` are local to the callback chain. `set` lives in the + // body of the addItem arrow → addItem is the right caller. + const setCall = findCall(sites, 'set'); + expect(setCall?.attributedTo).toBe('addItem'); + }); + + it('handles TanStack Query factory — `queryFn: () => api.getUser()` attributes to "queryFn"', () => { + const sites = collectCallAttributions(` + export const useUserQuery = () => + useQuery({ + queryFn: () => api.getUser(), + queryKey: ['user'], + }); + `); + const getUser = findCall(sites, 'getUser'); + expect(getUser?.attributedTo).toBe('queryFn'); + }); +}); + +// ─── Definition-phase consistency ─────────────────────────────────────────── + +describe('issue #1166 — definition-phase consistency', () => { + /** Run TYPESCRIPT_QUERIES and return the names captured under @definition.function. */ + function definedFunctionNames(code: string): string[] { + const { parser, query } = makeParserAndQuery(); + const tree = parser.parse(code); + const out: string[] = []; + for (const match of query.matches(tree.rootNode)) { + let isFn = false; + let name: string | undefined; + for (const c of match.captures) { + if (c.name === 'definition.function') isFn = true; + if (c.name === 'name') name = c.node.text; + } + if (isFn && name) out.push(name); + } + return out; + } + + it('captures pair-with-arrow as @definition.function so call sourceIds resolve', () => { + const names = definedFunctionNames(` + export const store = { + addItem: (item) => doSomething(item), + fetchData: async () => api.fetch(), + }; + `); + // Both addItem and fetchData should appear so that the Function nodes + // exist when calls inside them claim sourceId = Function:file:addItem. + expect(names).toContain('addItem'); + expect(names).toContain('fetchData'); + }); + + it('captures pair-with-function-expression as @definition.function', () => { + const names = definedFunctionNames(` + export const store = { + legacy: function (x) { return doStuff(x); }, + }; + `); + expect(names).toContain('legacy'); + }); + + it('captures string-key pairs (`"add-item": () => ...`)', () => { + const names = definedFunctionNames(` + export const store = { + "add-item": (item) => doSomething(item), + }; + `); + expect(names).toContain('add-item'); + }); + + it('does not invent names for computed-key pairs (`[K]: () => ...`)', () => { + const names = definedFunctionNames(` + export const store = { + [ACTION_KEY]: (item) => doSomething(item), + }; + `); + // Whatever else is captured, we must NOT capture a Function named + // "ACTION_KEY" (it's a value reference, not a property name). + expect(names).not.toContain('ACTION_KEY'); + }); + + it('still captures top-level `const fn = () => ...` (regression)', () => { + const names = definedFunctionNames(` + export const helper = (x: number) => x + 1; + `); + expect(names).toContain('helper'); + }); +}); + +// ─── Regression guards: existing patterns still work ──────────────────────── + +describe('issue #1166 — regression guards', () => { + it('attributes calls in plain helper functions correctly (control)', () => { + const sites = collectCallAttributions(` + export const validateFile = (file: File) => { + return sharedValidateFile(file); + }; + + export const processFile = async (file: File) => { + const result = validateFile(file); + return fileToDataUrl(file); + }; + `); + expect(findCall(sites, 'sharedValidateFile')?.attributedTo).toBe('validateFile'); + expect(findCall(sites, 'validateFile')?.attributedTo).toBe('processFile'); + expect(findCall(sites, 'fileToDataUrl')?.attributedTo).toBe('processFile'); + }); + + it('attributes calls inside Promise constructor callbacks to the enclosing named arrow', () => { + // `new Promise((resolve, reject) => { ... })` — the callback is anonymous, + // its parent is `arguments`. Walk continues to the outer + // `(file) => new Promise(...)` arrow, which IS named via variable_declarator. + const sites = collectCallAttributions(` + export const fileToDataUrl = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + }); + `); + expect(findCall(sites, 'FileReader')?.attributedTo).toBe('fileToDataUrl'); + expect(findCall(sites, 'readAsDataURL')?.attributedTo).toBe('fileToDataUrl'); + }); + + it('attributes top-level calls in module-init expressions to the file (no enclosing function)', () => { + // `const useStore = create(...)(...)` — the calls live in the value + // expression of useStore itself, NOT inside any function body. The + // right answer here is "no enclosing function" (file-level). This pins + // that we don't accidentally start treating Variables as Functions. + const sites = collectCallAttributions(` + export const useStore = create()(devtools(persist({}, { name: 'store' }))); + `); + expect(findCall(sites, 'create')?.attributedTo).toBeNull(); + expect(findCall(sites, 'devtools')?.attributedTo).toBeNull(); + expect(findCall(sites, 'persist')?.attributedTo).toBeNull(); + }); +});