Skip to content

Function-valued consts inside a function body are never indexed (React handlers invisible to callers/impact) #1669

Closed

Description

@wenmin-wu

Summary

Function-valued consts are indexed at module scope but not inside a function
body. In a React codebase that means most event handlers are absent from the
graph, and callers / callees / impact answer Symbol not found for them
— which reads identically to "this symbol has no callers".

The boundary is scope, not syntax: a top-level const fn = () => {} indexes
fine, and the same declaration inside a component does not. useCallback is
not involved — a bare arrow inside the component is missing too.

Reproduction

Two files, no dependencies, codegraph init:

src/widget.jsx

import { useCallback } from "react";

export function formatLabel(value) {          // 1. top-level declaration
  return String(value).trim();
}

export const parseLabel = (raw) => raw.split(":")[1];   // 2. top-level arrow const

export class Formatter {                       // 3. class + method
  render(value) { return formatLabel(value); }
}

export default function Widget({ items, onPick }) {
  const handleSelect = useCallback((item) => {  // 4. const in a component, via hook
    onPick(formatLabel(item), parseLabel(item));
  }, [onPick]);

  const handleClear = () => {                   // 5. plain const in a component
    onPick(null, null);
  };

  return items.map((i) => (
    <button key={i} onClick={() => handleSelect(i)} onDoubleClick={handleClear}>
      {formatLabel(i)}
    </button>
  ));
}

src/page.jsx

import Widget, { formatLabel, parseLabel } from "./widget";

export default function Page() {
  const label = formatLabel("a:b");
  const parsed = parseLabel("a:b");
  return <Widget items={[label, parsed]} onPick={() => {}} />;
}

Result

codegraph init reports 10 nodes. Every node in the index:

file         page.jsx         src/page.jsx:1
import       ./widget         src/page.jsx:1
function     Page             src/page.jsx:3
file         widget.jsx       src/widget.jsx:1
import       react            src/widget.jsx:1
function     formatLabel      src/widget.jsx:4
function     parseLabel       src/widget.jsx:9
class        Formatter        src/widget.jsx:12
method       render           src/widget.jsx:13
function     Widget           src/widget.jsx:18

handleSelect (line 19) and handleClear (line 23) are absent.

codegraph callers formatLabel   → 4 callers
codegraph callers parseLabel    → 3 callers
codegraph callers Widget        → 1 caller
codegraph callers handleSelect  → Symbol "handleSelect" not found
codegraph callers handleClear   → Symbol "handleClear"  not found

Note line 9: parseLabel is an arrow function and indexes correctly. Only the
two declared inside Widget are missing.

Expected

handleSelect and handleClear are declared functions with a name, a body and
call sites, and should appear as nodes the same way parseLabel does.

Why it matters

This is the dominant shape for handlers in React, so on a real front end a
large share of the functions an agent needs are invisible. The failure is also
silent in the worst direction: Symbol not found is indistinguishable from
"nothing calls this", so an agent asked to update every call site of a handler
gets an empty answer and concludes there are none. Grep finds them, which is
the fallback CodeGraph is meant to replace.

The same gap presumably affects const x = function () {}, object-literal
methods, and IIFE-scoped helpers in plain JS/TS, though I only measured the
React case.

Environment

  • codegraph 1.6.0 (npm @colbymchenry/codegraph)
  • node v26.7.0
  • macOS 26.4.1, arm64

Possibly related

#1656 (route callers/callees/impact through one symbol resolver) looks adjacent
— though this appears to be extraction rather than resolution, since the nodes
never reach the database at all.

Activity

  1. inth3shadows commented on Sep 4, 2026

    @inth3shadows
    Contributor

    Reproduced on 1.6.0 (npm latest), and narrowed to an exact boundary — it is not "function-valued consts" in general, it is arrow/function expressions bound inside a function body.

    // src/store.ts
    export function outer() {
      const handleClick = () => { return 1; };      // NOT indexed
      const named = function inner() { return 2; }; // indexed as `inner`
      return handleClick() + named();
    }
    
    export const topLevelArrow = () => { return 3; };  // indexed

    Observed nodes for that file

    name kind line
    Store / get / set / has class / method 1-4
    outer function 7
    inner function 9
    topLevelArrow function 13

    handleClick is absent. So three cases that look alike behave differently:

    • top-level const x = () => {} → indexed (the documented export const useAuth case)
    • in-body const x = function name() {} → indexed, under the expression's own name
    • in-body const x = () => {} → no node at all

    This is why React handlers disappear: they are almost always the third form.

    Cause

    Naming is not the problem. extractFunction already resolves an arrow's name from its parent variable_declarator (src/extraction/tree-sitter.ts, around the export const useAuth = () => { ... } comment) — that path is simply never reached for an in-body arrow.

    Function bodies are walked by visitFunctionBody → visitForCallsAndStructure (src/extraction/tree-sitter.ts:5166). That walker dispatches on callTypes, INSTANTIATION_KINDS, macro_invocation and friends, and fires maybeCaptureFnRefs, but it has no branch that treats a variable_declarator whose value is an arrow_function/function_expression as a function declaration. inner survives only because it is a named function expression and is picked up as a function node in its own right.

    Fix direction

    In visitForCallsAndStructure, before the call/instantiation dispatch, add the declarator case and recurse into it as a function:

    // A function-valued binding inside a body is a declaration, not just an
    // expression: `const handleClick = () => {…}` is the symbol React code
    // calls, passes and tests. extractFunction already resolves the name from
    // the parent variable_declarator, so it only needs to be reached.
    if (nodeType === 'variable_declarator') {
      const value = getChildByField(node, 'value');
      if (value && (value.type === 'arrow_function' || value.type === 'function_expression')) {
        this.extractFunction(value);   // names itself from this declarator
        return;                        // extractFunction walks the body itself
      }
    }

    Two things worth deciding while implementing, which is why I am not sending this as a PR:

    1. Nesting depth. scanFnRefSubtree (:603) already halts at nested function definitions with a depth > 12 guard; the same containment probably wants to apply here so a deeply-curried factory does not explode the node count.
    2. Whether these should be function or a distinct kind. An in-body arrow is lexically unreachable from outside its enclosing function, so indexing it as a plain top-level-looking function invites exactly the cross-scope name collisions [python] matching built-ins #1230/fix(resolution): literal-receiver builtins and nested locals stop fabricating call edges #1317 fixed for nested locals. Emitting it with the enclosing function as its scope would avoid re-opening that.

    Environment

    1.6.0 (also 1.5.0), Linux, fresh codegraph init, verified via direct SQL against .codegraph/codegraph.db.

  2. danusha2345 commented on Sep 4, 2026

    @danusha2345
    Contributor

    Thanks for narrowing it — that is exactly the boundary. #1679 implements the declarator case in both walkers (wasm visitForCallsAndStructure and the Rust kernel's mirror), so an in-body const x = () => {} / = function () {} is extracted through the existing extractFunction name resolution.

    On your two open points:

    1. Scope — the node is emitted under the enclosing function (Widget::handleClear, a contains edge from Widget), the same shape the useCallback handlers already have on main, so it does not reopen the [python] matching built-ins #1230/fix(resolution): literal-receiver builtins and nested locals stop fabricating call edges #1317 cross-scope collisions.
    2. Depth — no extra cap: each level names one function, and a nested arrow inside a handler is reached through the same path, so a curried factory yields one node per binding rather than an explosion. Happy to add a guard if the maintainer prefers one.
  3. colbymchenry commented on Sep 8, 2026

    @colbymchenry
    Owner

    Fixed in #1774 (lands/rebases #1679 onto current main).

    Cause: the JS/TS body walker only promoted nested named function decls and React hook-bound arrows (useCallback / …). A plain const handleClear = () => {…} inside a component fell through, so the handler never became a symbol and callers/impact answered "Symbol not found".

    Fix: wasm + kernel treat an anonymous arrow/function expression that is the whole value of a plain-identifier variable_declarator as a function of the enclosing one (same naming extractFunction already uses at module scope). Destructuring / inline JSX arrows / non-function values unchanged.

    Verified on Linux:

    Re-index after upgrading.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions