From 96c25dc3232aeacc8a0417988f297ef98761916d Mon Sep 17 00:00:00 2001 From: joptimus Date: Thu, 11 Jun 2026 09:26:33 -0500 Subject: [PATCH] fix(health): score module-level JS callbacks --- .../core/analysis/health/complexity/walker.py | 106 ++++++++++++++---- tests/unit/health/test_complexity_walker.py | 55 +++++++++ 2 files changed, 142 insertions(+), 19 deletions(-) diff --git a/packages/core/src/repowise/core/analysis/health/complexity/walker.py b/packages/core/src/repowise/core/analysis/health/complexity/walker.py index 1a61cdb70..4a121c40a 100644 --- a/packages/core/src/repowise/core/analysis/health/complexity/walker.py +++ b/packages/core/src/repowise/core/analysis/health/complexity/walker.py @@ -14,8 +14,9 @@ do not contribute (kept simple in v1). Anonymous functions (lambdas, arrow functions, closures) recurse for -their containing function's metrics — they do not produce their own -``FunctionComplexity`` row. +their containing function's metrics when they are nested in a named +function. Module-level lambdas, such as route callbacks, produce their +own ``FunctionComplexity`` row. """ from __future__ import annotations @@ -181,6 +182,71 @@ def _find_name(node: Node) -> str: return "" +def _node_text(node: Node) -> str: + return (node.text or b"").decode("utf-8", errors="replace") + + +def _find_assigned_lambda_name(node: Node) -> str | None: + parent = node.parent + while parent is not None: + if parent.type == "variable_declarator": + name = parent.child_by_field_name("name") + if name is not None and name.text is not None: + return _node_text(name) + if parent.type in {"assignment_expression", "assignment_pattern"}: + left = parent.child_by_field_name("left") + if left is None: + left = next((child for child in parent.children if child is not node), None) + if left is not None and left.text is not None: + return _node_text(left) + if parent.type not in {"parenthesized_expression", "as_expression", "satisfies_expression"}: + return None + parent = parent.parent + return None + + +def _find_call_callback_callee(node: Node) -> str | None: + parent = node.parent + while parent is not None and parent.type in { + "parenthesized_expression", + "as_expression", + "satisfies_expression", + }: + parent = parent.parent + if parent is None or parent.type != "arguments": + return None + call = parent.parent + if call is None or call.type != "call_expression": + return None + callee = call.child_by_field_name("function") + if callee is None or callee.text is None: + return None + return " ".join(_node_text(callee).split()) + + +_TEST_SUITE_CALLBACK_CALLEES = frozenset({"describe", "context", "suite"}) + + +def _is_test_suite_callback(node: Node, lmap: LanguageNodeMap) -> bool: + if node.type not in lmap.lambda_kinds: + return False + callee_text = _find_call_callback_callee(node) + if callee_text is None: + return False + return any(part in _TEST_SUITE_CALLBACK_CALLEES for part in callee_text.split(".")) + + +def _find_function_entry_name(node: Node, lmap: LanguageNodeMap) -> str: + if node.type not in lmap.lambda_kinds: + return _find_name(node) + assigned = _find_assigned_lambda_name(node) + if assigned: + return assigned + if callee := _find_call_callback_callee(node): + return f"{callee} callback" + return f"" + + def _count_nloc(node: Node, source: bytes) -> int: """Return the count of non-blank lines spanned by *node*.""" start = node.start_point[0] @@ -355,10 +421,7 @@ def _is_flat_match(node: Node, lmap: LanguageNodeMap) -> bool: cases = _collect_case_children(node, lmap) if not cases: return False - for arm in cases: - if _subtree_contains_complex(arm, complex_types): - return False - return True + return all(not _subtree_contains_complex(arm, complex_types) for arm in cases) def _subtree_contains_complex(arm_node: Node, complex_types: frozenset[str]) -> bool: @@ -429,11 +492,15 @@ def _recurse(node: Node, depth: int) -> None: # so we check both parent and grandparent. _parent = node.parent is_flat_match_arm = False - if node.type in lmap.case_kinds and _parent is not None: - if _parent.id in flat_match_ids: - is_flat_match_arm = True - elif _parent.parent is not None and _parent.parent.id in flat_match_ids: - is_flat_match_arm = True + if ( + node.type in lmap.case_kinds + and _parent is not None + and ( + _parent.id in flat_match_ids + or (_parent.parent is not None and _parent.parent.id in flat_match_ids) + ) + ): + is_flat_match_arm = True if is_flat_match_arm: # Flat match arms: no CCN increment, no nesting increment. @@ -627,19 +694,20 @@ def _collect_function_nodes(root: Node, lmap: LanguageNodeMap) -> list[Node]: """All function / method definition nodes in the file. Iterative pre-order traversal. We descend into class / module - bodies but do **not** recurse below a function — nested defs are - rare and treated as their own top-level entry. Lambda kinds are - skipped (they're walked inline as part of their enclosing fn). + bodies but do **not** recurse below a function or lambda. Lambdas found + before any function boundary are module-level executable units (for + example route callbacks) and get their own entry; lambdas inside an + already-collected function still roll up into that function. """ out: list[Node] = [] stack: list[Node] = [root] while stack: node = stack.pop() - if node.type in lmap.function_kinds: + if node.type in lmap.lambda_kinds and _is_test_suite_callback(node, lmap): + stack.extend(node.children) + continue + if node.type in lmap.function_kinds or node.type in lmap.lambda_kinds: out.append(node) - # Don't descend further — nested defs roll up into the - # enclosing fn's complexity via _walk_function_body - # (which skips nested function_kinds). continue for child in node.children: stack.append(child) @@ -1070,7 +1138,7 @@ def walk_file( body = fn_node.child_by_field_name("body") or fn_node ccn, max_nest, cognitive, bumps, conditions = _walk_function_body(body, lmap) fc = FunctionComplexity( - name=_find_name(fn_node), + name=_find_function_entry_name(fn_node, lmap), start_line=fn_node.start_point[0] + 1, end_line=fn_node.end_point[0] + 1, ccn=ccn, diff --git a/tests/unit/health/test_complexity_walker.py b/tests/unit/health/test_complexity_walker.py index 450ff944f..e2918ef54 100644 --- a/tests/unit/health/test_complexity_walker.py +++ b/tests/unit/health/test_complexity_walker.py @@ -41,6 +41,15 @@ def _find(results, name): return matches[0] if matches else None +def _require_language(language: str) -> None: + try: + from repowise.core.ingestion.parser import _get_language + except Exception: + pytest.skip(f"tree-sitter language pack missing for {language}") + if _get_language(language) is None: + pytest.skip(f"tree-sitter language pack missing for {language}") + + def test_python_nested_depth(): results = _walk("python/nested.py", "python") deep = _find(results, "deeply_nested") @@ -83,6 +92,52 @@ def test_javascript_nested_depth(): assert deep.max_nesting >= 4 +def test_javascript_module_level_arrow_callback_is_function_entry(): + _require_language("javascript") + source = b""" +router.get("/users", async (req, res) => { + if (req.query.active && req.user) { + return res.json(await loadUsers()); + } + return res.status(400).end(); +}); + +function wrapper(items) { + return items.map((item) => item.id); +} +""" + fcx = walk_file("/tmp/routes.js", "javascript", source) + names = [fn.name for fn in fcx.functions] + assert "router.get callback" in names + assert "wrapper" in names + assert "item" not in names + + route = _find(fcx.functions, "router.get callback") + assert route is not None + assert route.ccn >= 3 + assert route.max_nesting >= 1 + + +def test_javascript_test_suite_wrapper_callback_is_not_function_entry(): + _require_language("javascript") + source = b""" +test.describe("routes", () => { + beforeEach(() => resetDb()); + + it("handles active users", async () => { + if (ready()) { + await request(app).get("/users"); + } + }); +}); +""" + fcx = walk_file("/tmp/routes.test.js", "javascript", source) + names = [fn.name for fn in fcx.functions] + assert "test.describe callback" not in names + assert "it callback" in names + assert "beforeEach callback" in names + + def test_rust_flat_match_complexity(): """A flat match (all arms are simple expressions) should count as 1 CCN point for the match itself; individual arms should NOT add CCN."""