Skip to content

fix(parser): index JS/TS components wrapped in forwardRef, memo and other HOCs - #972

Open
L4XB wants to merge 15 commits into
tirth8205:stagingfrom
L4XB:fix/js-wrapped-component-functions
Open

L4XB wants to merge 15 commits into
tirth8205:stagingfrom
L4XB:fix/js-wrapped-component-functions

Conversation

@L4XB

@L4XB L4XB commented Sep 10, 2026

Copy link
Copy Markdown

Pull Request

Linked issue

Closes #971

What & why

_extract_js_var_functions only created a Function node when a declarator's value was directly an arrow function or function expression. A component wrapped in a higher-order call, like the issue's forwardRef<HTMLButtonElement, Props>((props, ref) => ...), has its function inside a call_expression, so no node was created. JSX uses still resolved to <file>::<Name>, so every use became a CALLS edge to a node that does not exist, and callers_of, tests_for and get_impact_radius came back empty for these components.

What counts as a wrapped component. _js_wrapped_function accepts a call only when all of these hold:

  • The wrapper name is one of forwardRef, memo, observer, withRouter, withStyles, withTheme, connect, styled, inject, withTranslation, withErrorBoundary (_JS_COMPONENT_WRAPPERS). The name is the callee itself, the last segment of a member callee (React.memo), or for a curried call the name of the inner callee (styled(Base)(fn), connect(mapState)(fn)).
  • The call takes exactly one argument. memo is the one exception: it also takes the props comparator, memo(Component, arePropsEqual), and nothing more. Comments between the arguments are not counted.
  • That argument is a function literal, or another wrapper call that passes these checks, nested at most three levels below the outer call (memo(forwardRef(fn))).

The name is needed because the shape alone does not show that the result is callable: const result = evaluate(() => compute()) has the same shape as memo(() => paint()) and returns a number. Calls that fail a check are handled exactly as before. Examples: useMemo(() => x, [dep]), useCallback(fn, deps), createClient({...}), styled.h1`...` , memo(wrap(fn)) with an unknown wrap, and wrappers nested past the limit.

A wrapped declaration gets the same treatment as a directly assigned function:

  • a Function node with the declaration's line range, and parameters and return type from the wrapped function;
  • a CONTAINS edge;
  • a walk of the function body, so calls inside the component are attributed to it.

What stays in the enclosing scope. Only the wrapped function is the component. The following parts run where the declaration is, so their edges keep the enclosing function (or the file, at module scope) as the source, as on staging:

  • every wrapper call in the chain (setup -> memo, setup -> forwardRef);
  • the inner call of a curried wrapper, with the calls and references in it (styled(Base), connect(mapState, ...));
  • type arguments (forwardRef<HTMLButtonElement, Props>) and the type annotation of the declaration (const Card: FC<Props> = memo(...));
  • memo's comparator: the calls in an inline one, and the reference to a named one.

Other declarators in the same statement are now walked in the enclosing scope, both when a declarator is wrapped and when a function is assigned directly. This is a change from staging, which skipped the whole statement whenever one declarator was a directly assigned function: const a = () => x(), b = y() lost the call to y. The type annotation of a directly assigned function is still not walked, as on staging.

Known false positives. Wrappers are recognised by name, and for a member callee by its last segment, without checking where the name comes from. So a non-React call with the same name is also read as a wrapper if it takes a single callback (for memo, a callback plus at most one more argument). Examples are pool.connect(cb) and cache.memo(fn): the variable becomes a Function node, and the calls inside the callback are attributed to it. The corpus below has no such case: all 644 wrapped declarations there are memo, React.memo, forwardRef, React.forwardRef, ReactModule.forwardRef or withErrorBoundary. Restricting member callees to React.* would remove these false positives, but it would also miss namespace imports under other names (ReactModule.forwardRef above), so I have left that choice to you.

Corpus check. I parsed 2058 React source files from cherry-studio, dify, LibreChat and grafana with staging (f0e4eb7) and with this branch, and compared their nodes and edges.

  • Lost: one staging edge, a TESTED_BY edge in grafana's validators.test.tsx. That test renders <Component /> inside memo(() => ...). The call now belongs to the new wrapped component rather than to the test, and TESTED_BY follows the call. Staging does the same when the function is assigned directly.
  • Added: 706 new nodes. 644 of them are wrapped components; calls moved into them or now resolve to them. Two of those are named TestRunMenu and TestInfoTab, so the existing name rule makes them Test nodes and 36 new TESTED_BY edges point at them. Staging does the same for a directly assigned function with those names.
  • Monaco bundles: the other 62 new nodes are in two minified Monaco bundles under dify/web/public/vs. There the branch now walks the other declarators of a statement that assigns a function directly, which also adds 280 call edges that staging did not record.

How it was tested

These are the CI commands, run at d26609b in a Python 3.10 virtualenv (pip install -e ".[dev]" pytest-cov, plus mypy, types-networkx and bandit):

ruff check code_review_graph/                                          # All checks passed!
ruff format --check tests/test_js_wrapped_functions.py                 # 1 file already formatted
mypy code_review_graph/ --ignore-missing-imports --no-strict-optional  # mypy 2.3.1 and 1.15.0: no issues found in 75 source files
bandit -r code_review_graph/ -c pyproject.toml                         # No issues identified
pytest --tb=short -q -m "not browser and not upgrade" \
  --cov=code_review_graph --cov-report=term-missing --cov-fail-under=65
# 3983 passed, 728 skipped, 46 deselected, 2 xfailed, 2 xpassed; total coverage 86.39%

The skips are mostly the opt-in suites: 715 of 728 (cli_surface, platform_lifecycle, action_e2e, exports, concurrency, corpus, surface, determinism). The other 13 are igraph not installed (5), Playwright not installed (2), four token-budget worst cases that the suite reports instead of asserting, one Windows-only path test, and one interpreter probe that needs user-site packages.

On Python 3.13:

pytest -q tests/test_js_wrapped_functions.py tests/test_callback_context_preservation.py   # 23 passed

tests/test_js_wrapped_functions.py (21 tests) covers:

  • the issue's forwardRef<HTMLButtonElement, Props> reproduction (node, line range, CONTAINS edge);
  • nested and curried wrappers;
  • React.memo and React.forwardRef<T, P> callees;
  • the two-file Toolbar -> Button case, where the CALLS target now exists;
  • calls inside a component belong to it, while wrapper calls, the curried inner call, type arguments, the declaration's annotation and memo's comparator belong to the enclosing function;
  • values that are not definitions: choose(() => left(), () => right()), useMemo(fn, [dep]), evaluate(() => compute()), memo(wrap(fn)), wrappers nested past the depth limit, forwardRef(fn, extra) and memo(fn, eq, extra);
  • memo(Component, arePropsEqual) with a named and an inline comparator;
  • comments among the arguments;
  • the other declarators of a statement, in both the wrapped and the direct form;
  • that a directly assigned function's annotation is still not walked.

I also mutated the wrapper code in 25 ways (argument counts, depth limit, name check, member callees, comment filtering, which scope each part is walked in). Every mutation fails at least one test.

Checklist

  • Tests added for new functionality
  • All tests pass (ran the CI test job's command above)
  • Linting passes (ruff check code_review_graph/)
  • Type checking passes (mypy code_review_graph/ --ignore-missing-imports --no-strict-optional)
  • Lines are at most 100 characters
  • Docs updated where behavior changed (docstrings of _js_wrapped_function, _js_wrapper_name and _extract_js_var_functions; no README or docs/ page covers this)

…ther HOCs

`_extract_js_var_functions` only created a Function node when a variable
declarator's value was directly an arrow/function expression. A component
wrapped in a higher-order call (`forwardRef(fn)`, `memo(fn)`, `observer(fn)`,
`withRouter(fn)`, `styled(Base)(fn)`, nested `memo(forwardRef(fn))`) puts the
function inside a `call_expression`, so the declaration was skipped and every
JSX use of the component produced a CALLS edge to a node that did not exist.
callers_of / tests_for / get_impact_radius then came back empty for the most
common way to declare a ref-forwarding React component.

The extractor now looks for the function literal among a wrapper call's
arguments (unwrapping nested wrappers) and treats the variable as that
function: same node, CONTAINS edge and body recursion as a direct assignment.
Calls whose arguments carry no function (`createClient({...})`,
``styled.h1`...` ``, `sum(1, 2)`) are unchanged.

Fixes tirth8205#971
@tirth8205

Copy link
Copy Markdown
Owner

Changes required: stopping traversal at the first callback removes calls already present on main. Parse function setup() { const result = choose(() => left(), () => right()); return result; } with CodeParser(repo_root).parse_file(path); this PR retains only left under a new result Function and loses choose and right. Preserve wrapper and other-argument calls, and avoid treating ordinary values returned by callback-taking functions as callable definitions.

Two things the first pass got wrong, both found in review.

A call that takes a callback among others computes a value, it does not
define a function. `choose(() => left(), () => right())` was read as a
wrapped component: the variable became a Function named after the call's
result and only the first callback was walked, so `choose` and `right`
disappeared from the graph. A wrapper call now has to take the function
as its only argument, which is what forwardRef, memo, observer and
styled(Base)(fn) do, and leaves useMemo and friends alone.

The body walk started at the wrapped function, so the wrapper call was
not recorded either. It starts at the declarator now, which covers the
wrapper, any nested wrapper and everything else in the argument list,
while the signature still comes from the function itself.
@L4XB

L4XB commented Sep 12, 2026

Copy link
Copy Markdown
Author

Fixed at c0668b0, and you were right on both halves. I reproduced your snippet first:

main:        funcs=[setup]          calls=[setup->choose, setup->left, setup->right]
this PR:     funcs=[setup, result]  calls=[result->left]
now:         funcs=[setup]          calls=[setup->choose, setup->left, setup->right]

Ordinary values are no longer treated as definitions. A wrapper call now has to take the function as its only argument. That is the shape of the cases this PR is for, forwardRef(fn), memo(fn), observer(fn), memo(forwardRef(fn)), styled(Base)(fn), and it excludes every call that takes a callback among others, choose(() => left(), () => right()) and useMemo(() => compute(), [dep]), which assign whatever the call returns. Those go back to the generic path, exactly as on main. The argument count is read from arguments.named_children, so forwardRef<HTMLButtonElement, Props>(fn) still matches: type arguments are a separate node.

The wrapper and its siblings are preserved. The body walk started at the wrapped function, which is why the wrapper call itself went missing even in the cases the PR handles. It starts at the declarator now, so everything in the initializer is visited, while the signature still comes from the function:

export const Card = memo(forwardRef((props, ref) => { paint(); return null; }));
  funcs=[Card], calls=[Card->memo, Card->forwardRef, Card->paint], params=(props, ref)
export const Button = forwardRef((props, ref) => { paint(); return null; });
  funcs=[Button], calls=[Button->forwardRef, Button->paint]
export const Box = styled(Base)((props) => { theme(); return null; });
  funcs=[Box], calls=[Box->styled, Box->theme]

Before this commit the first case reported only Card->forwardRef and Card->paint, and the second only Button->paint.

Three tests added for it: your snippet with the exact expected call set, the two-argument hook, and the nested wrapper asserting all three calls. All three fail if the single-argument rule is relaxed. tests/test_js_wrapped_functions.py is at 8 passed, the JS and parser suites at 303 passed, and the unit suite at 2952 passed, 9 skipped, 2 xpassed. ruff check is clean on both files and ruff format --check is clean on the test file (parser.py is already unformatted on main under ruff 0.14, so I left it as it is rather than reformat 8000 lines).

@tirth8205

Copy link
Copy Markdown
Owner

Rechecked c0668b0: the original two-callback regression is fixed, but ten preservation checks still fail despite 392 existing tests passing. With const result = evaluate(() => compute()) inside setup, the graph invents a result Function and makes callers_of(compute) return result instead of setup. A sibling declaration such as const Button = memo(() => paint()), token = nextToken() also drops nextToken; copy tests/test_callback_context_preservation.py from integration/token-efficiency-hardening and run python -m pytest -q tests/test_callback_context_preservation.py to reproduce both regressions. Please establish that the result is callable, preserve every declarator, and keep wrapper initialisation in its enclosing scope.

A single function argument does not establish that a call returns
something callable. `const result = evaluate(() => compute())` has the
same shape as `memo(() => paint())` and returns a number, so reading it
as a definition invented a `result` function and moved `compute`'s
caller off `setup`, the function that really makes the call.

The wrapper name is what establishes it, so unwrapping is now limited
to wrappers documented to return a component. A wrapper outside that
set is not a definition here, which is what the parser did before
wrapped components were indexed at all.

Two scope defects went with it:

The wrapper call runs where the declaration is, not inside the
component it produces. The component's body walk is now the wrapped
function alone, and every wrapper in the chain is recorded against the
enclosing function through the call extractor, which does not descend
into the wrapped function a second time.

One declaration can define a component AND call a function:
`const Button = memo(() => paint()), token = nextToken()`. The caller
skips its generic recursion over the whole declaration as soon as
anything is extracted, so declarators this pass does not own are now
walked here, in the enclosing scope, instead of being dropped.

`test_a_wrapped_component_keeps_the_wrapper_calls` asserted
("Card", "memo"), which read the defect the other way round: the
component credited with a call made before it existed. It now places
the declaration inside a function and asserts the wrappers belong to
that function.
@L4XB

L4XB commented Sep 13, 2026

Copy link
Copy Markdown
Author

Both reproduced and fixed in 1d0dbb6. Your file runs clean against it:

$ python -m pytest -q tests/test_callback_context_preservation.py
..                                                                       [100%]
2 passed

"Establish that the result is callable." That was the real defect, and the shape check could never do it: evaluate(() => compute()) and memo(() => paint()) are the same tree, and one returns a number. The name is what establishes it, so unwrapping is now limited to wrappers documented to return a component (forwardRef, memo, observer, withRouter, withStyles, withTheme, connect, styled, inject, withTranslation, withErrorBoundary), resolved through React.memo and the curried styled(Base)(fn) / connect(map)(fn) forms. A wrapper outside the set is simply not a definition here — which is what the parser did before wrapped components were indexed at all, so the failure direction is a component that stays unindexed rather than a function that does not exist.

"Keep wrapper initialisation in its enclosing scope." The component's body walk is now the wrapped function alone, and every wrapper in the chain is recorded against the enclosing function through _extract_calls rather than by walking the node, which would descend into the wrapped function again and credit its calls to the enclosing function as well:

function setup() { const Card = memo(forwardRef(() => paint())); return Card; }

  CALLS  setup -> memo
  CALLS  setup -> forwardRef
  CALLS  Card  -> paint

"Preserve every declarator." The caller skips its generic recursion over the whole declaration as soon as this pass extracts anything, so a declarator it does not own was being dropped. Those are now walked here, in the enclosing scope:

const Button = memo(() => paint()), token = nextToken();

  CALLS  setup  -> memo
  CALLS  setup  -> nextToken
  CALLS  Button -> paint

One of my own tests was the defect written down. test_a_wrapped_component_keeps_the_wrapper_calls asserted ("Card", "memo") — the component credited with a call made before it existed. At module scope that reads as harmless; inside a function it is exactly the misattribution you found. It now puts the declaration inside setup and asserts the wrappers belong to setup, with a comment saying what it used to claim.

I added the two cases to tests/test_js_wrapped_functions.py rather than committing your file, so the branch does not collide with integration/token-efficiency-hardening when both land; your version is what I ran to verify.

pytest -q tests/         2997 passed, 9 skipped, 2 xpassed

@tirth8205

tirth8205 commented Sep 15, 2026

Copy link
Copy Markdown
Owner

This fails the checks once merged into staging.

pytest (py3.13, merged origin/staging + PR head 1d0dbb6): "3233 passed, 9 skipped, 2 xpassed, 1 warning in 115.23s". ruff: "All checks passed!". mypy 2.3.0: "code_review_graph/parser.py:8827: error: Need type annotation for "chain" (hint: "chain: list[] = ...") [var-annotated] / Found 1 error in 1 file (checked 72 source files)". Same error reproduced with mypy==1.15.0, so it is not version-dependent.

To fix:

  • Fix mypy: annotate chain: list = [] (or list[Any]) at code_review_graph/parser.py:8827 - CI type-check job fails as-is.
  • Test for member-expression callee React.memo(fn) / React.forwardRef<T, P>(fn) (the member_expression branch in _js_wrapper_name is untested).
  • Test documenting behaviour of memo(Component, arePropsEqual) (documented React API, currently NOT indexed - either support it for memo or add a test pinning the limitation and mention it in the PR body).
  • Test for the failure branch of nested unwrapping: memo(wrap(fn)) with an unknown inner wrapper, and for wrapper depth > _JS_WRAPPER_MAX_DEPTH.

Merge origin/staging into your branch first to reproduce it.

PRs now target staging, not main. Yours was retargeted already, so nothing to do there.

@tirth8205 tirth8205 added the checks-failing Fails CI when merged into staging label Sep 15, 2026
L4XB added 11 commits September 17, 2026 17:28
mypy cannot infer the element type of an empty list that is only
filled by the callee, and the type-check job failed on it with
var-annotated. The chain holds tree-sitter nodes, which this file
types as a bare list elsewhere.
The member_expression branch of _js_wrapper_name had no test. Pin that
React.memo(fn) and React.forwardRef<T, P>(fn) are unwrapped, that the
signature and line range come from the declaration and the wrapped
function, and that the wrapper calls stay on the enclosing function.
memo(wrap(fn)) with a wrapper outside the component set, and wrappers
nested one level past _JS_WRAPPER_MAX_DEPTH, are not definitions and
keep every call on the enclosing function. The depth test also checks
the boundary itself and that each wrapper call is recorded once.
Only the wrapped function is the component, but the wrapper-call pass
recorded the call edge alone and never visited the other parts of the
call. staging records all of them, so this branch lost:

- the inner call of a curried wrapper, styled(Base)(fn) and
  connect(mapState)(fn), with the calls in its arguments;
- the references that keep Base and mapState from looking unused;
- the Props reference in forwardRef<HTMLButtonElement, Props>(fn),
  the shape from the issue.

Every child of each wrapper call except its arguments is now visited in
the enclosing scope, through a stand-in parent so the generic walk
treats them exactly as a walk over the declaration would.
memo(Component, arePropsEqual) is the documented React API, and the
single-argument rule turned it back into a plain call. memo now accepts
the comparator as a second argument; forwardRef and the other wrappers
still take exactly one.

The comparator is not part of the component. It joins the rest of the
wrapper call in the enclosing scope, and identifier arguments keep the
reference the generic walk records for them, so a named arePropsEqual
does not start to look unused.

Also corrects the chain docstring: wrapper calls are collected
innermost first.
staging walks const Card: FC<Props> = memo(...) as a whole, so Props is
referenced from the enclosing scope. Once the declaration counts as a
definition, only the component and the wrapper calls were visited, and
the reference was lost. Parsing 2058 React source files from four large
apps with staging and with this branch, these 75 references were the
last staging edges the branch dropped. The one other difference is a
TESTED_BY edge that follows its call into a newly indexed component,
exactly as staging already does for a directly assigned one.

The annotation of a wrapped declaration now joins the rest of the
wrapper calls in the enclosing scope. A directly assigned function,
const Card: FC<Props> = () => ..., keeps the behaviour it has on
staging.
memo accepts one props comparator besides the component, and nothing
else. No test covered a third argument, so letting memo take any number
of arguments went unnoticed. memo(fn, eq, extra) is not a definition,
and its calls stay on the enclosing function.
tree-sitter lists comment nodes among a call's named arguments, so they
counted as arguments. A lint directive above the function turned
forwardRef(fn) into a two-argument call and the component was not
indexed; memo(fn, /* c */ eq) and memo(/* c */ fn) failed the same way.

Wrapper arguments are now counted and chosen without comment and
html_comment nodes, the two comment kinds of the JavaScript and
TypeScript grammars. The rest of the call, which is walked in the
enclosing scope, is taken from the same list, so a comment before the
function cannot make the component look like the comparator and get
walked a second time.
…tion

Since the enclosing-scope walk of other declarators was added, it also
covers a statement that assigns a function directly. staging extracted
a in const a = () => x(), b = y() and skipped the whole declaration, so
the call to y was lost; it is now recorded on the enclosing function.
The existing sibling test only covers the wrapped form, so dropping the
walk for the direct form went unnoticed.
The type annotation of a wrapped declaration is walked in the enclosing
scope because staging walked that declaration whole. A directly
assigned function, const Card: FC<Props> = () => ..., only ever had the
function walked, and this branch does not change that. No test covered
the gate, so extending the annotation walk to the direct form went
unnoticed.
@L4XB

L4XB commented Sep 17, 2026

Copy link
Copy Markdown
Author

Thanks for the list. I merged origin/staging first (73053ca), so the branch is up to date with f0e4eb7. Everything below is at d26609b.

mypy. 3b690d8 annotates the chain as chain: list = [], which after the merge sits at code_review_graph/parser.py:10083. mypy code_review_graph/ --ignore-missing-imports --no-strict-optional finds no issues in 75 source files, with both mypy 2.3.1 and 1.15.0.

Member-expression callees. 7fcf2a3 adds test_a_member_expression_wrapper_is_unwrapped. It checks three things for React.memo(fn) and React.forwardRef<HTMLInputElement, Props>(fn): both are unwrapped, the parameters and line range come from the wrapped function and the declaration, and both wrapper calls stay on the enclosing function.

memo(Component, arePropsEqual) is now supported (558690e). memo also accepts the comparator as a second argument; every other wrapper still takes exactly one. The comparator is not part of the component, so calls in an inline comparator and the reference to a named one stay in the enclosing scope. forwardRef(fn, extra) and memo(fn, eq, extra) remain plain calls, and both are tested (d886d0b adds the second).

Failure branches of nested unwrapping (1bad097). memo(wrap(fn)) with an unknown inner wrapper is not a definition, and every call stays on the enclosing function. The same goes for wrappers nested one level past _JS_WRAPPER_MAX_DEPTH. The test also checks the boundary itself, and that each wrapper call is recorded once.

While comparing the branch with staging edge by edge, I found and fixed a few more cases:

  • 58d79eb: the inner call of a curried wrapper (styled(Base)(fn), connect(mapState, ...)(fn)), with the calls and references in it, and the type arguments of forwardRef<El, Props>(fn) are walked in the enclosing scope again. Before this, the branch lost the styled/connect calls and the references to Base, mapState and Props.
  • 50c3308: const Card: FC<Props> = memo(...) keeps its Props reference, as on staging. A directly assigned function still does not get one, again as on staging, and d26609b pins that.
  • 84c4d2d: tree-sitter lists comments among a call's arguments, so a // eslint-disable-next-line line above the function made forwardRef(fn) look like a two-argument call. Comments are no longer counted. In the corpus below, this indexes one real component, LibreChat's OriginalDialog.tsx.
  • 3c4ad67: pins that the other declarators of a statement are also walked in the enclosing scope when a function is assigned directly. Staging lost y in const a = () => x(), b = y().

Corpus differential. I parsed 2058 React files from cherry-studio, dify, LibreChat and grafana with staging and with the branch.

  • Before these commits: the branch lost 219 staging edges (218 REFERENCES, 1 TESTED_BY).
  • Now: it loses one, the TESTED_BY edge in grafana's validators.test.tsx. That test renders <Component /> inside memo(() => ...). The call now belongs to the new wrapped component rather than to the test, and TESTED_BY follows the call, exactly as staging does when the function is assigned directly.
  • Other differences:
    • 644 of the new nodes are wrapped components; calls moved into them or now resolve to them.
    • Two of those are named TestRunMenu and TestInfoTab. The existing name rule makes them Test nodes, as it does for a directly assigned function, and 36 TESTED_BY edges point at them.
    • The other 62 new nodes are in two minified Monaco bundles under dify/web/public/vs. There the branch now walks the other declarators of a statement that assigns a function directly.

One correction to my comment of 13 Sep. I wrote that the failure direction is a component that stays unindexed rather than a function that does not exist. That is not true for member callees. The wrapper name is the last segment of the callee, so a non-React call with the same name that takes one callback, such as pool.connect(cb) or cache.memo(fn), is also read as a wrapper and becomes a Function node. It did not occur in the corpus: all 644 wrapped declarations there are memo, forwardRef, React.memo, React.forwardRef, ReactModule.forwardRef or withErrorBoundary. Restricting member callees to React.* would remove the false positive, but it would also miss namespace imports such as ReactModule. The PR body describes this, and I have left the choice to you.

Checks, as CI runs them.

  • Python 3.10:
    • ruff check code_review_graph/ and ruff format --check on the test file: clean.
    • bandit: no issues.
    • pytest --tb=short -q -m "not browser and not upgrade" --cov=code_review_graph --cov-report=term-missing --cov-fail-under=65: 3983 passed, 728 skipped (715 of them in the opt-in suites), 46 deselected, 2 xfailed, 2 xpassed; coverage 86.39%.
  • Python 3.13: tests/test_js_wrapped_functions.py plus your tests/test_callback_context_preservation.py: 23 passed.

@tirth8205

Copy link
Copy Markdown
Owner

The PR drops 63 TESTED_BY edges on material-ui across 22 test files, not the one edge in grafana's validators.test.tsx that the body reports, and three production symbols lose all of their test coverage in the graph.

Build a repo from material-ui's own packages/mui-utils/src/useForkRef and getReactElementRef, commit it, then run the CLI in a staging worktree and in this branch:

# staging
$ uv run code-review-graph build --repo $R
7 files, 31 nodes, 281 edges
$ uv run code-review-graph query --repo $R tests_for "$R/src/useForkRef/useForkRef.ts::useForkRef"
ok, n=2
  it:forks if only one of the branches requires a ref@L33
  it:does nothing if none of the forked branches requires a ref@L55

# this PR
7 files, 33 nodes, 275 edges
Found 0 result(s)

Both tests build their probe as React.forwardRef(function Component(props, ref) { ... useForkRef(handleOwnRef, ref) ... }) inside the it() body. The call moves off the test onto the new component node, and TESTED_BY follows the call. Across all 27,701 JS/TS files of a fresh material-ui clone, TESTED_BY goes from 41,668 to 41,605, and all 22 per-file deltas are negative: Slide.test.js -10, Grow.test.js -8, useForkRef.test.tsx -8, RadioGroup.test.js -7, FocusTrap.test.tsx -6. useForkRef, getReactElementRef and useSlot drop to zero. The 23 names that gain coverage are all fixtures the PR itself invented: FakeDiv, RealDiv, BrokenButton. tests_for is a documented workflow, and no test in the PR parses a test file at all.

The false positive you disclosed is also a wrong answer:

// src/db.js
import { logErr } from './log';
export function handle(pool) {
  const session = pool.connect(function (err) { logErr(err); });
  return session;
}

query callers_of logErr answers src/db.js::handle on staging and src/db.js::session here, a local variable holding a connection, and handle leaves the caller set entirely. dead-code on the same repo reports two dead symbols, App and session, where staging reports one. Same shape for const sock = connect(function () { onOpen(); }) and const cached = utils.memo(() => compute()). It is rare, 0 new lowercase-named Function nodes across 33,454 files from express, socket.io, strapi and material-ui, but it is silent when it hits.

_js_wrapper_name recurses through curried callees with no depth cap, unlike _js_wrapped_function. const S = connect(a)(b)(b)...(() => { paint(); }); first fails at 992 curried steps with RecursionError: maximum recursion depth exceeded, repeating parser.py:10140. Staging parses the same file and returns one edge. Not realistic source, but it is a crash where there was none.

All four items from the last round are there and they are load-bearing. mypy is clean on 77 files on the merged tree. I mutated the new suite rather than trusting the count: relaxing the _JS_COMPONENT_WRAPPERS name check fails 3 tests, dropping the unowned sibling-declarator walk fails 3, walking the declarator instead of the wrapped function fails 7. On 5,910 files from excalidraw and strapi the branch adds 97 Function nodes, every one a genuine component, with zero net target-level edge loss and parse time inside run-to-run noise. The branch is behind staging only because staging moved, and it merges without conflict.

To land it:

  • Keep calls made inside a wrapped component defined in a test body attributed to the enclosing test, and add a test that parses a test file.
  • Restrict member callees to React.*. Losing ReactModule.forwardRef namespace imports is the better trade than turning pool.connect(cb) into a function.
  • Apply _JS_WRAPPER_MAX_DEPTH in _js_wrapper_name too.

@github-actions

Copy link
Copy Markdown

code-review-graph review

Overall risk: 0.40 (MEDIUM) — 31 changed function(s)/class(es), 17 affected flow(s), 5 test gap(s)

Risk-scored changes

Risk Level Symbol Location Tested
0.40 medium code_review_graph/parser.py::CodeParser._js_argument_values code_review_graph/parser.py:10123 no
0.40 medium code_review_graph/parser.py::CodeParser._js_wrapped_function code_review_graph/parser.py:10145 no
0.35 low code_review_graph/parser.py::_NodeGroup code_review_graph/parser.py:119 no
0.30 low code_review_graph/parser.py::CodeParser code_review_graph/parser.py:2946 yes
0.25 low tests/test_js_wrapped_functions.py::test_a_directly_assigned_function_keeps_its_sibling_declarators tests/test_js_wrapped_functions.py:200 (test)
0.25 low tests/test_js_wrapped_functions.py::test_a_directly_assigned_function_does_not_walk_its_annotation tests/test_js_wrapped_functions.py:463 (test)
0.20 low code_review_graph/parser.py::CodeParser._js_wrapper_name code_review_graph/parser.py:10130 no
0.15 low tests/test_js_wrapped_functions.py::_parse tests/test_js_wrapped_functions.py:8 yes
0.15 low tests/test_js_wrapped_functions.py::_functions tests/test_js_wrapped_functions.py:14 yes
0.15 low tests/test_js_wrapped_functions.py::_call_pairs tests/test_js_wrapped_functions.py:91 yes

Affected execution flows

  • visit_FunctionDef — criticality 0.41, 55 node(s) across 1 file(s)
  • visit_AsyncFunctionDef — criticality 0.41, 55 node(s) across 1 file(s)
  • visit_ClassDef — criticality 0.41, 54 node(s) across 1 file(s)
  • visit_If — criticality 0.36, 3 node(s) across 1 file(s)
  • repo_relative_path — criticality 0.36, 9 node(s) across 1 file(s)
  • ...and 12 more affected flow(s)

Test gaps

  • code_review_graph/parser.py::_NodeGroup (code_review_graph/parser.py:119)
  • code_review_graph/parser.py::CodeParser._js_argument_values (code_review_graph/parser.py:10123)
  • code_review_graph/parser.py::CodeParser._js_wrapper_name (code_review_graph/parser.py:10130)
  • code_review_graph/parser.py::CodeParser._js_wrapped_function (code_review_graph/parser.py:10145)
  • code_review_graph/parser.py::CodeParser._extract_js_var_functions (code_review_graph/parser.py:10190)

Token savings: this graph-backed report used ~176,816 fewer tokens (~88%) than reading every changed file in full (estimated, chars/4 approximation).


Powered by code-review-graph — local-first analysis; no code leaves the CI runner.

This branch has not been deployed

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

Labels

checks-failing Fails CI when merged into staging

Projects

None yet

Development

Successfully merging this pull request may close these issues.

export const X = forwardRef(...) produces no node, so every JSX use of a wrapped component dangles

2 participants