fix(extraction): name a curried-wrapper function that is an object member (#1747)

The curried-wrapper bound stopped at a variable_declarator parent, so
`return { getMode: Effect.fn("ACP.Session.getMode")(function* () {…}) }`
still produced no function node. An Effect service is usually an object a
factory returns, and on sst/opencode (df23b7f) these member positions were
the bulk of what the branch missed.

The member already has a name in the source: its property key. This repo
names `key: () => {}` pairs by that key (extractObjectLiteralFunctions /
objectKeyName), so a `pair` parent is now named the same way. The
callee-is-a-call rule is unchanged, so `key: useMemo(() => …, [])` and
`key: arr.map(…)` stay anonymous.

extractObjectLiteralFunctions also reaches such a member, because an
exported object with an inline function member is extracted member by member
and its initializer is never walked.

A wrapper passed as an argument to another call has no binding at all and is
left as it was.

Mirrored in the Rust kernel; kernel-parity.mjs reports byte-parity on the
new shapes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dfb3aazPY1ccmp5vam4k3L
This commit is contained in:
Max Hsu
2026-09-17 10:11:10 +08:00
parent 4f2b40080d
commit bbdef20542
5 changed files with 117 additions and 9 deletions
+1 -1
View File
@@ -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
@@ -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<T>(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<typeof extractFromSource>) =>
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');
});
});
+11
View File
@@ -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" {
+17 -4
View File
@@ -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;
}
+21 -4
View File
@@ -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;
}