fix(extraction): name a function bound through a curried wrapper (#1747)

reactHookBoundName already names an anonymous function after the
variable_declarator that binds it. The shape is general, but the method
is bounded to three React hooks, so `const run =
Effect.fn("Session.run")(function* () {…})` produced no function node at
all -- and its body's calls were attributed to the enclosing container,
so a file gained an outgoing edge belonging to a function and the
callee's caller list named the file instead.

Bound the new path by a different, equally decidable test: the callee is
itself a call, i.e. a factory returning the wrapper. That admits
Effect.fn(...)(fn), connect(mapState)(fn) and a project's own
wrap("n")(fn), and excludes the single-call forms whose argument is a
computation -- useMemo(() => …, []), arr.map(…) -- which stay anonymous
exactly as before. Widening to "any callee" would have given useMemo a
function node and reddened react-hook-handlers.test.ts.

Generators are admitted here and not in reactHookBoundName: a React
handler is never a generator, while function* is the common form in the
ecosystem this shape comes from, and it is what the report opens with --
broadening the callee test alone would not have fixed that repro.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgtWMywGTyu6twnk1Bu9nP
This commit is contained in:
Max Hsu
2026-09-09 10:18:54 +08:00
parent 3193800bc8
commit 4f2b40080d
4 changed files with 208 additions and 0 deletions
+59
View File
@@ -752,6 +752,13 @@ impl<'t> Walker<'t> {
self.extract_function(node, Some(bound));
return;
}
// `const run = Effect.fn("Session.run")(function* () {…})` (#1747):
// the same declarator binding through a curried wrapper. Mirrors
// TreeSitterExtractor's curriedWrapperBoundName.
if let Some(bound) = self.curried_wrapper_bound_name(node) {
self.extract_function(node, Some(bound));
return;
}
// `const handleClear = () => {…}` inside a body (#1669): named by
// its declarator, like at module scope. Mirrors
// TreeSitterExtractor's declaratorBoundFunction.
@@ -842,6 +849,58 @@ impl<'t> Walker<'t> {
Some(self.text(name_node).to_string())
}
/// The declarator name for an anonymous function passed to a CURRIED
/// wrapper call — `const NAME = factory(...)(function () {…})` — or 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
/// three React handler hooks. This is the same shape with a different,
/// equally decidable bound: the callee is itself a call, i.e. a factory
/// that returns the wrapper (#1747). Requiring that keeps it narrow —
/// `useMemo(|| …, [])` and `arr.map(…)` are single calls and stay
/// anonymous, exactly as before.
///
/// Generators are admitted here and not in `react_hook_bound_name`: a
/// React handler is never a generator, while `function*` is the common
/// form in the ecosystem this shape comes from.
///
/// Mirrors TreeSitterExtractor's curriedWrapperBoundName.
fn curried_wrapper_bound_name(&self, node: Node<'t>) -> Option<String> {
if !matches!(
node.kind(),
"arrow_function" | "function_expression" | "generator_function"
) {
return None;
}
let args = node.parent()?;
if args.kind() != "arguments" {
return None;
}
let first = args.named_child(0)?;
if first.start_byte() != node.start_byte() || first.end_byte() != node.end_byte() {
return None;
}
let call = args.parent()?;
if call.kind() != "call_expression" {
return None;
}
// The bound that replaces the hook allowlist: the thing being called
// is itself a call, so this is a curried wrapper's second application.
let callee = call.child_by_field_name("function")?;
if callee.kind() != "call_expression" {
return None;
}
let declarator = call.parent()?;
if declarator.kind() != "variable_declarator" {
return None;
}
let name_node = declarator.child_by_field_name("name")?;
if name_node.kind() != "identifier" {
return None;
}
Some(self.text(name_node).to_string())
}
/// extractName / extractNameRaw for the TS/JS configs.
fn extract_name(&self, node: Node) -> String {
// javascriptExtractor.resolveName: field_definition names its key the