diff --git a/CHANGELOG.md b/CHANGELOG.md index 523391273..5ec095274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -295,7 +295,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Python calls and file dependencies through `from package import module as alias` now appear in the graph, so renamed imports no longer hide live callers or imported modules. Thanks @JoeyNPP. (#1626) -- **A function bound through a wrapper call is now part of the graph.** `const run = Effect.fn("Session.run")(function* () {…})` — and the same shape with Redux's `connect(mapState)(…)` or a project's own `wrap("name")(…)` — produced no function at all: only React's `useCallback`, `useEffectEvent` and `useEvent` were recognized as naming the function they wrap. The calls inside those bodies were attributed to the file instead, so a file appeared to call what the function called, and asking who calls a helper named the file rather than the function that really calls it. One codebase carried over a thousand of these. Any wrapper that is itself the result of a call now names the function it wraps, generators included; a callback passed straight to a single call, such as `useMemo` or `map`, is a computation and stays anonymous as before. Re-index after upgrading. (#1747) +- **A function bound through a wrapper call is now part of the graph.** `const run = Effect.fn("Session.run")(function* () {…})` — and the same shape with Redux's `connect(mapState)(…)` or a project's own `wrap("name")(…)` — produced no function at all: only React's `useCallback`, `useEffectEvent` and `useEvent` were recognized as naming the function they wrap. The calls inside those bodies were attributed to the file instead, so a file appeared to call what the function called, and asking who calls a helper named the file rather than the function that really calls it. One codebase carried over a thousand of these. Any wrapper that is itself the result of a call now names the function it wraps — after the variable it is assigned to, or after its property key when it is an object member such as `{ getMode: Effect.fn("…")(function* () {…}) }` — generators included; a callback passed straight to a single call, such as `useMemo` or `map`, is a computation and stays anonymous as before. Re-index after upgrading. (#1747) ## [1.6.0] - 2026-08-26 diff --git a/__tests__/curried-wrapper-handlers.test.ts b/__tests__/curried-wrapper-handlers.test.ts index 3ba3e3dde..0ccefd483 100644 --- a/__tests__/curried-wrapper-handlers.test.ts +++ b/__tests__/curried-wrapper-handlers.test.ts @@ -82,3 +82,70 @@ describe('curried wrapper handlers', () => { expect(refsFrom(result, file!.id)).not.toContain('helper'); }); }); + +// Object members (#1747 follow-up). An Effect service is usually an object a +// factory returns, so the wrapper's result lands in a `pair`, not a declarator. +// The property key names it — the same key extractObjectLiteralFunctions +// already uses for `key: () => {}`. +const MEMBER_CODE = ` +declare const Effect: { fn: (n: string) => (b: unknown) => unknown }; +declare function wrap(n: string): (c: unknown) => unknown; +declare function useMemo(f: () => T, d: unknown[]): T; + +function helper() { return 1; } + +function make() { + return { + getMode: Effect.fn("ACP.Session.getMode")(function* (id: string) { return helper(); }), + 'quotedKey': wrap("n")(() => { return helper(); }), + computed: useMemo(() => 1 + 1, []), + mapped: [1, 2].map((x) => x + 1), + }; +} + +const service = { + run: Effect.fn("Service.run")(function* () { return helper(); }), + plain: () => helper(), +}; + +// Exported with an inline function member: extractObjectLiteralFunctions mints +// the members one by one and the initializer is not walked, so the curried +// member has to be reached from there too. +export const mixed = { + direct: () => helper(), + wrapped: Effect.fn("Mixed.wrapped")(function* () { return helper(); }), +}; +`; + +describe('curried wrapper handlers as object members', () => { + const fnNodes = (result: ReturnType) => + result.nodes.filter((n) => n.kind === 'function'); + + it('names the wrapped function after its property key', () => { + const names = fnNodes(extractFromSource('src/m.ts', MEMBER_CODE)).map((n) => n.name); + expect(names).toContain('getMode'); + expect(names).toContain('quotedKey'); + expect(names).toContain('run'); + expect(names).toContain('wrapped'); + }); + + it('leaves single-call member values anonymous, and does not duplicate plain members', () => { + const names = fnNodes(extractFromSource('src/m.ts', MEMBER_CODE)).map((n) => n.name); + expect(names).not.toContain('computed'); + expect(names).not.toContain('mapped'); + // Neither path may mint a member twice. + expect(names.filter((n) => n === 'run')).toHaveLength(1); + expect(names.filter((n) => n === 'wrapped')).toHaveLength(1); + expect(names.filter((n) => n === 'plain').length).toBeLessThanOrEqual(1); + }); + + it('attributes the member body calls to the member, not to the factory', () => { + const result = extractFromSource('src/m.ts', MEMBER_CODE); + const getMode = fnNodes(result).find((n) => n.name === 'getMode'); + expect(getMode, 'no function node for getMode').toBeDefined(); + expect(refsFrom(result, getMode!.id)).toContain('helper'); + const make = fnNodes(result).find((n) => n.name === 'make'); + expect(make, 'no function node for make').toBeDefined(); + expect(refsFrom(result, make!.id)).not.toContain('helper'); + }); +}); diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index 83f070b76..ce6f4dc5e 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -559,6 +559,17 @@ impl<'t> Walker<'t> { if matches!(v.kind(), "arrow_function" | "function_expression") { let name = util::object_key_name(self.text(k)); self.extract_function(v, Some(name)); + } else if v.kind() == "call_expression" { + // `key: Effect.fn("…")(function* () {…})` — see + // curried_wrapper_bound_name. + let fun = v + .child_by_field_name("arguments") + .and_then(|a| a.named_child(0)); + if let Some(f) = fun { + if let Some(bound) = self.curried_wrapper_bound_name(f) { + self.extract_function(f, Some(bound)); + } + } } } } else if member.kind() == "method_definition" { diff --git a/codegraph-kernel/src/tsjs/mod.rs b/codegraph-kernel/src/tsjs/mod.rs index b13ad7b4e..28f299f5e 100644 --- a/codegraph-kernel/src/tsjs/mod.rs +++ b/codegraph-kernel/src/tsjs/mod.rs @@ -850,7 +850,9 @@ impl<'t> Walker<'t> { } /// The declarator name for an anonymous function passed to a CURRIED - /// wrapper call — `const NAME = factory(...)(function () {…})` — or None. + /// wrapper call — `const NAME = factory(...)(function () {…})` — or the + /// property key when the call is an object member, `{ NAME: factory(...)(fn) }`; + /// else None. /// /// `react_hook_bound_name` above names a function through the declarator /// that binds it; the shape is general, but that method is bounded to the @@ -890,11 +892,22 @@ impl<'t> Walker<'t> { if callee.kind() != "call_expression" { return None; } - let declarator = call.parent()?; - if declarator.kind() != "variable_declarator" { + let binder = call.parent()?; + // `{ getMode: Effect.fn("…")(function* () {…}) }`: an object member is + // named by its property key, as extract_object_literal_functions names + // `key: () => {}`. + if binder.kind() == "pair" { + let key = binder.child_by_field_name("key")?; + let value = binder.child_by_field_name("value")?; + if value.start_byte() != call.start_byte() || value.end_byte() != call.end_byte() { + return None; + } + return Some(util::object_key_name(self.text(key))); + } + if binder.kind() != "variable_declarator" { return None; } - let name_node = declarator.child_by_field_name("name")?; + let name_node = binder.child_by_field_name("name")?; if name_node.kind() != "identifier" { return None; } diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 6bb67e147..bd7de7b06 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -2305,6 +2305,11 @@ export class TreeSitterExtractor { const value = getChildByField(member, 'value'); if (key && value && (value.type === 'arrow_function' || value.type === 'function_expression')) { this.extractFunction(value, this.objectKeyName(key)); + } else if (value?.type === 'call_expression') { + // `key: Effect.fn("…")(function* () {…})` — see curriedWrapperBoundName. + const fn = getChildByField(value, 'arguments')?.namedChild(0); + const bound = fn ? this.curriedWrapperBoundName(fn) : null; + if (fn && bound) this.extractFunction(fn, bound); } } else if (member.type === 'method_definition') { // Method shorthand: `{ fetchUser() {...} }`. extractMethod deliberately @@ -5593,7 +5598,8 @@ export class TreeSitterExtractor { /** * The declarator name for an anonymous function passed to a CURRIED wrapper - * call — `const NAME = factory(...)(function () {…})` — or null. + * call — `const NAME = factory(...)(function () {…})` — or the property key + * when the call is an object member, `{ NAME: factory(...)(fn) }`; else null. * * `reactHookBoundName` above already names a function through the declarator * that binds it; the shape is general, but that method is deliberately @@ -5638,9 +5644,20 @@ export class TreeSitterExtractor { // itself a call, so this is the second application of a curried wrapper. const callee = getChildByField(call, 'function'); if (!callee || callee.type !== 'call_expression') return null; - const declarator = call.parent; - if (!declarator || declarator.type !== 'variable_declarator') return null; - const nameNode = getChildByField(declarator, 'name'); + const binder = call.parent; + if (!binder) return null; + // `{ getMode: Effect.fn("…")(function* () {…}) }` — a service is often an + // object a factory returns, so the wrapper's result lands in a `pair`. The + // property key names it, as extractObjectLiteralFunctions names + // `key: () => {}`. + if (binder.type === 'pair') { + const key = getChildByField(binder, 'key'); + const value = getChildByField(binder, 'value'); + if (!key || !value || value.startIndex !== call.startIndex || value.endIndex !== call.endIndex) return null; + return this.objectKeyName(key); + } + if (binder.type !== 'variable_declarator') return null; + const nameNode = getChildByField(binder, 'name'); return nameNode?.type === 'identifier' ? getNodeText(nameNode, this.source) : null; }