From c81cb13f4d0a0122efa236228f4d70d95130c32d Mon Sep 17 00:00:00 2001 From: Wolfvin Date: Sun, 12 Jul 2026 03:13:53 +0000 Subject: [PATCH] fix(callgraph): register object-literal arrow fns as nodes (closes #222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arrow functions assigned to object-literal properties (e.g. `const svc = { list: (ctx) => { ... } }`) were invisible as function nodes — `_parse_variable_declarator` only handles value berupa arrow_function/function_expression/defineStore call, bukan object node. Sebelum fix: calls di dalam arrow body sudah ter-extract oleh module-level pass (PR #219, issue #210) dan di-attribute ke synthetic node, jadi ref_count callee sudah benar. Tapi arrow function-nya sendiri TIDAK terdaftar sebagai node, sehingga trace/impact/search --mode symbol tidak bisa resolve method by name. Fix: - TS parser: tambah _find_object_literal_method_decls yang walk AST, cari variable_declarator dengan object value, register setiap pair dengan arrow/function value sebagai node .. - JS parser: tambah _collect_object_literal_method_calls yang serupa tapi collect calls inline (konsisten dengan single-pass design JS parser). - Update _find_module_level_calls di kedua parser untuk skip pair nodes dengan arrow/function value — hindari double-counting antara per-function pass (yang sekarang register method tsb) dan module-level pass. Hasil: - Arrow function terdaftar sebagai node . (e.g. assignmentService.list, assignmentService.create, assignmentService.remove) - Calls di dalam arrow body di-attribute ke method node id, bukan - ref_count callee tetap sama (tidak double-counted) - trace/impact/search sekarang bisa resolve method by name Tests: 16 regression tests (8 TS + 8 JS) — registration, call attribution, multi-method, ref_count end-to-end, non-arrow pair handling, no double-count, object literal in function body. --- scripts/parsers/js_backend_parser.py | 167 ++++++++++++++++++++ scripts/parsers/ts_backend_parser.py | 141 +++++++++++++++++ tests/test_js_backend_parser.py | 180 ++++++++++++++++++++++ tests/test_ts_backend_parser.py | 219 +++++++++++++++++++++++++++ 4 files changed, 707 insertions(+) diff --git a/scripts/parsers/js_backend_parser.py b/scripts/parsers/js_backend_parser.py index 819c9b36..22b4f628 100755 --- a/scripts/parsers/js_backend_parser.py +++ b/scripts/parsers/js_backend_parser.py @@ -205,6 +205,24 @@ def extract_references(self, content: str, file_path: str) -> Dict[str, List[Dic keep_alive.append(child) stack.append((child, depth + 1)) + # Issue #222: collect arrow functions / function expressions + # assigned to object-literal properties (e.g. + # ``const svc = { list: (ctx) => { ... } }``) and register + # them as ``.`` nodes. Without this pass the + # arrow function is invisible as a node — ``trace``/``impact``/ + # ``search --mode symbol`` cannot resolve it by name, even + # though ``ref_count`` of callees is correct (calls inside + # the arrow body are extracted by the module-level pass below + # and attributed to ````). + # + # This pass must run BEFORE _find_module_level_calls so the + # module-level pass can skip pair subtrees that are now + # registered as method nodes (see _find_module_level_calls + # pair-skip branch). + self._collect_object_literal_method_calls( + root, source, file_path, nodes, edges, keep_alive, + ) + # Issue #210: module-top-level pass. # The main walk above only extracts calls via _parse_and_collect_calls, # which is invoked on declaration nodes (function_declaration, @@ -297,6 +315,142 @@ def _parse_and_collect_calls( decl_info.pop("body_node", None) return decl_info + def _collect_object_literal_method_calls( + self, + root: Node, + source: bytes, + file_path: str, + nodes: List[Dict], + edges: List[Dict], + keep_alive: List[Node], + ) -> None: + """Find arrow functions / function expressions assigned to + object-literal properties and register them as function nodes, + then collect call edges from their bodies. + + Issue #222: ``const svc = { list: (ctx) => { ... } }`` — the + ``variable_declarator`` value is an ``object`` node (not + ``arrow_function``), so ``_parse_variable_declarator`` returns + None and the arrow function is invisible as a node. Calls + inside the arrow body are still extracted by the module-level + pass (issue #210) and attributed to ````, so + ``ref_count`` of callees is correct — but ``trace``/``impact``/ + ``search --mode symbol`` cannot resolve the method by name. + + This pass walks the AST and, for every ``variable_declarator`` + whose value is an ``object`` literal, iterates the object's + ``pair`` children. Each pair whose value is an ``arrow_function`` + or ``function_expression`` is: + + 1. Registered as a function node named ``.`` + (mirroring the Pinia store convention). + 2. Has its body calls collected and attributed to the new + node id, so the module-level pass can skip the pair + subtree without losing the calls. + + Naming: + - ``const svc = { list: (ctx) => {} }`` → node ``svc.list`` + - Object literal not assigned to a variable (e.g. returned + from a function or passed as a call argument): skipped — + there is no stable name to use, and the module-level pass + already extracts body calls for those. + + Uses the same iterative DFS + ``keep_alive`` pattern as the + main walk (issue #116/#163) so tree-sitter Node references + don't dangle mid-walk. + """ + MAX_DEPTH = 200 + stack: List[Tuple[Node, int]] = [(root, 0)] + while stack: + node, depth = stack.pop() + if depth > MAX_DEPTH: + continue + + if node.type == 'variable_declarator': + # Find the identifier (var name) and the object value. + var_name = None + object_node = None + for child in node.children: + keep_alive.append(child) + if child.type == 'identifier' and var_name is None: + var_name = self.get_text(child, source) + elif child.type == 'object': + object_node = child + + if var_name and object_node: + # Walk the object's pair children and register + # arrow/function values as method declarations. + for pair in object_node.children: + keep_alive.append(pair) + if pair.type != 'pair': + continue + key_node = None + value_node = None + for pc in pair.children: + keep_alive.append(pc) + if pc.type in ('property_identifier', 'identifier') and key_node is None: + key_node = pc + elif pc.type in ('arrow_function', 'function_expression'): + value_node = pc + if key_node is None or value_node is None: + continue + + key_name = self.get_text(key_node, source) + fn_name = f"{var_name}.{key_name}" + + # Check async + is_async = False + for vc in value_node.children: + keep_alive.append(vc) + if vc.type == 'async': + is_async = True + break + + line = self.get_line(pair) + node_id = f"{file_path}:{line}" + + # Find the body + body_node = None + for vc in value_node.children: + keep_alive.append(vc) + if vc.type in ('statement_block', 'expression'): + body_node = vc + break + + node_dict = { + "id": node_id, + "fn": fn_name, + "file": file_path, + "line": line, + "async": is_async, + "node_type": "object_method", + } + nodes.append(node_dict) + + if body_node is not None: + fn_calls = self._find_calls_in_scope( + body_node, source, file_path, + ) + for call_info in fn_calls: + edge = { + "from": node_id, + "to_fn": call_info["fn_name"], + "via_self": call_info.get("via_self", False) + } + if call_info.get("is_ipc_call"): + edge["is_ipc_call"] = True + edges.append(edge) + # Don't recurse into the variable_declarator — + # pairs are handled above, and non-arrow values + # (e.g. numbers, strings) have no calls to extract. + continue + + if depth + 1 <= MAX_DEPTH: + children = node.children + for child in reversed(children): + keep_alive.append(child) + stack.append((child, depth + 1)) + def _find_function_declarations(self, root: Node, source: bytes, file_path: str) -> List[Dict]: """Find all function declarations in the AST.""" @@ -619,6 +773,19 @@ def _is_registered_value(child: Node) -> bool: if _is_registered_value(child): skip_subtree = True break + elif node.type == 'pair': + # Issue #222: skip pair nodes whose value is an + # arrow_function or function_expression — these are + # now registered as ``.`` method nodes + # by :meth:`_collect_object_literal_method_calls`, so + # their body calls are already collected. Without + # this skip, calls inside the arrow body would be + # double-counted (once as ``.`` → callee, + # once as ```` → callee). + for child in node.children: + if child.type in ('arrow_function', 'function_expression'): + skip_subtree = True + break if skip_subtree: continue diff --git a/scripts/parsers/ts_backend_parser.py b/scripts/parsers/ts_backend_parser.py index 2de4fb0e..aa79b61b 100644 --- a/scripts/parsers/ts_backend_parser.py +++ b/scripts/parsers/ts_backend_parser.py @@ -54,6 +54,20 @@ def extract_references(self, content: str, file_path: str) -> Dict[str, List[Dic Returns: {"nodes": [...], "edges": [...]} + + Issue #222: arrow functions assigned to object-literal properties + (e.g. ``const svc = { list: (ctx) => { ... } }``) are now registered + as function nodes named ``.`` so that: + - ``trace``/``impact`` can resolve them as call targets + - ``search --mode symbol`` finds them by name + - calls inside their bodies are attributed to the named method + rather than to the synthetic ```` caller + + Previously these arrow functions were invisible as nodes — calls + inside their bodies were extracted by the module-level pass + (issue #210) and attributed to ````, so ``ref_count`` was + correct but ``trace``/``impact``/``search`` could not see the + method itself. """ source = content.encode('utf-8') tree = self.parse(source) @@ -64,6 +78,14 @@ def extract_references(self, content: str, file_path: str) -> Dict[str, List[Dic # First pass: find all function declarations fn_declarations = self._find_function_declarations(tree, source, file_path) + # Issue #222: also find arrow functions / function expressions + # assigned to object-literal properties (e.g. + # ``const svc = { list: (ctx) => { ... } }``) and register them + # as ``.`` nodes so trace/impact/search can see them. + fn_declarations.extend( + self._find_object_literal_method_decls(tree, source, file_path) + ) + # Build a map of line -> function for scope resolution fn_scope_map = self._build_scope_map(fn_declarations) @@ -375,6 +397,113 @@ def visit(node: Node, _, depth): self.walk_tree(body_node, source, visit) return calls + def _find_object_literal_method_decls(self, root: Node, source: bytes, + file_path: str) -> List[Dict]: + """Find arrow functions / function expressions assigned to + object-literal properties and register them as function nodes. + + Issue #222: ``const svc = { list: (ctx) => { ... } }`` — the + ``variable_declarator`` value is an ``object`` node (not + ``arrow_function``), so ``_parse_variable_declarator`` returns + None and the arrow function is invisible as a node. + + This pass walks the AST and, for every ``variable_declarator`` + whose value is an ``object`` literal, iterates the object's + ``pair`` children. Each pair whose value is an ``arrow_function`` + or ``function_expression`` is registered as a function node named + ``.`` — mirroring the Pinia store convention so + downstream tools (``trace``, ``impact``, ``search --mode symbol``) + can resolve the method by name. + + Returns a list of declaration dicts in the same shape as + :meth:`_find_function_declarations`, so the caller can merge + them into ``fn_declarations`` and let the per-function pass + collect body calls uniformly. + + Naming: + - ``const svc = { list: (ctx) => {} }`` → node ``svc.list`` + - Nested object: ``const svc = { api: { list: () => {} } }`` + only the outermost var name is used → ``svc.list`` (the + inner ``api`` key is not part of the name; this keeps + naming simple and matches how Pinia store methods are + named today). + - Object literal not assigned to a variable (e.g. returned + from a function or passed as a call argument): skipped — + there is no stable name to use, and the module-level pass + (issue #210) already extracts body calls for those. + """ + declarations: List[Dict] = [] + + def visit(node: Node, _, depth): + if node.type != 'variable_declarator': + return True + # Find the identifier (var name) and the object value. + var_name = None + object_node = None + for child in node.children: + if child.type == 'identifier' and var_name is None: + var_name = self.get_text(child, source) + elif child.type == 'object': + object_node = child + if not var_name or not object_node: + return True + + # Walk the object's pair children and register arrow/function + # values as method declarations. + for pair in object_node.children: + if pair.type != 'pair': + continue + key_node = None + value_node = None + for pc in pair.children: + if pc.type in ('property_identifier', 'identifier') and key_node is None: + key_node = pc + elif pc.type in ('arrow_function', 'function_expression'): + value_node = pc + if key_node is None or value_node is None: + continue + + key_name = self.get_text(key_node, source) + fn_name = f"{var_name}.{key_name}" + + # Check async + is_async = False + for vc in value_node.children: + if vc.type == 'async': + is_async = True + break + + line = self.get_line(pair) + node_id = f"{file_path}:{line}" + + # Find the body + body_node = None + for vc in value_node.children: + if vc.type in ('statement_block', 'expression'): + body_node = vc + break + + declarations.append({ + "node": { + "id": node_id, + "fn": fn_name, + "file": file_path, + "line": line, + "async": is_async, + "node_type": "object_method", + }, + "body_node": body_node, + "scope_start": pair.start_point.row, + "scope_end": pair.end_point.row, + }) + # Don't recurse into the object — pairs are handled above, + # and nested non-arrow values (e.g. numbers, strings) have + # no calls to extract. + return False + + self.walk_tree(root, source, visit) + return declarations + def _find_module_level_calls(self, root: Node, source: bytes, file_path: str) -> List[Dict]: """Find call sites that live outside any registered function declaration. @@ -439,6 +568,18 @@ def visit(node: Node, _, depth): for child in node.children: if _is_registered_value(child): return False + # Issue #222: skip pair nodes whose value is an arrow_function + # or function_expression — these are now registered as + # ``.`` method nodes by + # :meth:`_find_object_literal_method_decls`, so their body + # calls are already collected by the per-function pass. + # Without this skip, calls inside the arrow body would be + # double-counted (once as ``.`` → callee, once + # as ```` → callee). + if node.type == 'pair': + for child in node.children: + if child.type in ('arrow_function', 'function_expression'): + return False if node.type == 'call_expression': call_info = self._parse_call(node, source) diff --git a/tests/test_js_backend_parser.py b/tests/test_js_backend_parser.py index 6ddb2697..a0c905ca 100644 --- a/tests/test_js_backend_parser.py +++ b/tests/test_js_backend_parser.py @@ -321,3 +321,183 @@ def test_module_level_call_extracts_router_post(self): f"router.post call not extracted at module top-level. " f"edges={edge_to_fns}" ) + + +@pytest.mark.skipif(not _js_be_parser_available, reason="Tree-sitter JavaScript grammar not installed") +class TestJSBackendParserIssue222ObjectLiteralMethods: + """Issue #222 regression tests: arrow functions assigned to object-literal + properties must be registered as function nodes. + + Mirrors TestTSBackendParserIssue222ObjectLiteralMethods for the JS parser + (which uses a different code path — single-pass iterative walk with + keep_alive, vs. TS parser's two-pass design). + """ + + def test_object_literal_arrow_function_registered_as_node(self): + """``const svc = { list: (ctx) => {} }`` → node ``svc.list``.""" + js = """\ +const svc = { + list: (ctx) => { return helper(); }, +}; +""" + result = _js_be_parser.extract_references(js, "test.js") + fn_names = [n["fn"] for n in result["nodes"]] + assert "svc.list" in fn_names, ( + f"Object-literal arrow function should be registered as " + f"svc.list, got fn_names={fn_names}" + ) + + def test_object_literal_function_expression_registered_as_node(self): + """``const svc = { list: function() {} }`` → node ``svc.list``.""" + js = """\ +const svc = { + list: function(ctx) { return helper(); }, +}; +""" + result = _js_be_parser.extract_references(js, "test.js") + fn_names = [n["fn"] for n in result["nodes"]] + assert "svc.list" in fn_names, ( + f"Object-literal function expression should be registered as " + f"svc.list, got fn_names={fn_names}" + ) + + def test_object_literal_method_calls_attributed_to_method_node(self): + """Calls inside an object-literal arrow function body must be + attributed to the ``.`` node id, not to ````.""" + js = """\ +const svc = { + list: (ctx) => { return helper(); }, +}; +""" + result = _js_be_parser.extract_references(js, "test.js") + helper_edges = [e for e in result["edges"] if e.get("to_fn") == "helper"] + assert len(helper_edges) == 1, ( + f"Expected exactly 1 helper edge (no double-count), got " + f"{len(helper_edges)}. edges={helper_edges}" + ) + edge_from = helper_edges[0]["from"] + assert ":0:" not in edge_from, ( + f"helper edge should come from svc.list's node id, not . " + f"edge={helper_edges[0]}" + ) + + def test_object_literal_multiple_methods_all_registered(self): + """Multiple arrow functions in the same object literal must all + be registered as separate nodes.""" + js = """\ +export const assignmentService = { + list: (ctx) => { return hasPermission(ctx.user, 'read'); }, + create: (ctx) => { return hasPermission(ctx.user, 'write'); }, + remove: (ctx) => { return hasPermission(ctx.user, 'delete'); }, +}; +""" + result = _js_be_parser.extract_references(js, "test.js") + fn_names = [n["fn"] for n in result["nodes"]] + assert "assignmentService.list" in fn_names + assert "assignmentService.create" in fn_names + assert "assignmentService.remove" in fn_names + + hp_edges = [e for e in result["edges"] if e.get("to_fn") == "hasPermission"] + assert len(hp_edges) == 3, ( + f"Expected 3 hasPermission edges (one per method), got " + f"{len(hp_edges)}. edges={hp_edges}" + ) + for e in hp_edges: + assert ":0:" not in e["from"], ( + f"hasPermission edge should come from method node, not . " + f"edge={e}" + ) + + def test_object_literal_method_ref_count_correct(self): + """End-to-end: hasPermission called only via object-literal arrow + functions must have correct ref_count after edge resolution.""" + from edge_resolver import resolve_edges + js_permissions = """\ +export function hasPermission(user, perm) { + return user && user.permissions && user.permissions.includes(perm); +} +""" + js_service = """\ +import { hasPermission } from './permissions'; +export const assignmentService = { + list: (ctx) => { if (!hasPermission(ctx.user, 'read')) return null; return []; }, + create: (ctx) => { if (!hasPermission(ctx.user, 'write')) throw new Error('no'); return {}; }, + remove: (ctx) => { if (!hasPermission(ctx.user, 'delete')) throw new Error('no'); return true; }, +}; +""" + all_nodes = [] + all_edges = [] + for fpath, content in [ + ("src/lib/permissions.js", js_permissions), + ("src/services/assignments.js", js_service), + ]: + r = _js_be_parser.extract_references(content, fpath) + all_nodes.extend(r.get("nodes", [])) + all_edges.extend(r.get("edges", [])) + + resolved_nodes, _ = resolve_edges(all_nodes, all_edges) + for n in resolved_nodes: + if n["fn"] == "hasPermission": + assert n.get("ref_count", 0) == 3, ( + f"hasPermission ref_count should be 3 (3 call sites in " + f"object-literal arrow functions), got {n.get('ref_count', 0)}" + ) + return + pytest.fail("hasPermission node not found in resolved nodes") + + def test_object_literal_with_non_arrow_pairs_not_affected(self): + """Object literals with non-arrow pairs (e.g. ``count: 5``) must + not break parsing — non-arrow pairs are simply skipped.""" + js = """\ +const config = { + count: 5, + name: 'svc', + handler: (ctx) => { return helper(ctx); }, +}; +""" + result = _js_be_parser.extract_references(js, "test.js") + fn_names = [n["fn"] for n in result["nodes"]] + assert "config.handler" in fn_names, ( + f"config.handler should be registered, got fn_names={fn_names}" + ) + assert "config.count" not in fn_names + assert "config.name" not in fn_names + + def test_no_double_count_object_literal_arrow_and_module_level(self): + """Calls inside object-literal arrow functions must NOT be + double-counted by the module-level pass.""" + js = """\ +const svc = { + list: (ctx) => { return helper(); }, +}; +""" + result = _js_be_parser.extract_references(js, "test.js") + helper_edges = [e for e in result["edges"] if e.get("to_fn") == "helper"] + assert len(helper_edges) == 1, ( + f"Expected exactly 1 helper edge (no double-count between " + f"per-function pass and module-level pass), got {len(helper_edges)}. " + f"edges={helper_edges}" + ) + + def test_object_literal_in_function_body_not_double_counted(self): + """Object literal returned from a function: the function body + is already walked by the per-function pass, so the object-literal + method pass must not re-walk the same pairs.""" + js = """\ +function makeService() { + return { + list: (ctx) => { return helper(); }, + }; +} +""" + result = _js_be_parser.extract_references(js, "test.js") + helper_edges = [e for e in result["edges"] if e.get("to_fn") == "helper"] + assert len(helper_edges) == 1, ( + f"Expected 1 helper edge (from makeService), got {len(helper_edges)}. " + f"edges={helper_edges}" + ) + from_text = helper_edges[0]["from"] + assert ":0:" not in from_text, ( + f"helper edge should come from makeService's node id, not . " + f"edge={helper_edges[0]}" + ) diff --git a/tests/test_ts_backend_parser.py b/tests/test_ts_backend_parser.py index 26ca507d..f5044c3a 100644 --- a/tests/test_ts_backend_parser.py +++ b/tests/test_ts_backend_parser.py @@ -253,3 +253,222 @@ def test_exported_arrow_function_not_double_counted(self): f"Expected exactly 1 helper edge (no double-count from exported " f"arrow function), got {len(helper_edges)}. edges={helper_edges}" ) + + +@pytest.mark.skipif(not _ts_be_parser_available, reason="Tree-sitter TypeScript grammar not installed") +class TestTSBackendParserIssue222ObjectLiteralMethods: + """Issue #222 regression tests: arrow functions assigned to object-literal + properties must be registered as function nodes. + + Before fix: ``const svc = { list: (ctx) => { ... } }`` — the + ``variable_declarator`` value is an ``object`` node (not + ``arrow_function``), so ``_parse_variable_declarator`` returned None + and the arrow function was invisible as a node. Calls inside the + arrow body were extracted by the module-level pass (issue #210) and + attributed to ````, so ``ref_count`` was correct but + ``trace``/``impact``/``search --mode symbol`` could not resolve the + method by name. + + After fix: each arrow/function pair in an object literal is registered + as a node named ``.``, and calls inside its body are + attributed to that node id. + """ + + def test_object_literal_arrow_function_registered_as_node(self): + """``const svc = { list: (ctx) => {} }`` → node ``svc.list``.""" + ts = """\ +const svc = { + list: (ctx: any) => { return helper(); }, +}; +""" + result = _parse(ts, "test.ts") + fn_names = [n["fn"] for n in result["nodes"]] + assert "svc.list" in fn_names, ( + f"Object-literal arrow function should be registered as " + f"svc.list, got fn_names={fn_names}" + ) + + def test_object_literal_function_expression_registered_as_node(self): + """``const svc = { list: function() {} }`` → node ``svc.list``.""" + ts = """\ +const svc = { + list: function(ctx: any) { return helper(); }, +}; +""" + result = _parse(ts, "test.ts") + fn_names = [n["fn"] for n in result["nodes"]] + assert "svc.list" in fn_names, ( + f"Object-literal function expression should be registered as " + f"svc.list, got fn_names={fn_names}" + ) + + def test_object_literal_method_calls_attributed_to_method_node(self): + """Calls inside an object-literal arrow function body must be + attributed to the ``.`` node id, not to ````. + + Before fix: calls were attributed to ```` (via the + module-level pass). After fix: calls are attributed to the + method node id, and the module-level pass skips the pair subtree + to avoid double-counting. + """ + ts = """\ +const svc = { + list: (ctx: any) => { return helper(); }, +}; +""" + result = _parse(ts, "test.ts") + helper_edges = [e for e in result["edges"] if e.get("to_fn") == "helper"] + assert len(helper_edges) == 1, ( + f"Expected exactly 1 helper edge (no double-count), got " + f"{len(helper_edges)}. edges={helper_edges}" + ) + # The edge should come from svc.list's node id, not + edge_from = helper_edges[0]["from"] + assert ":0:" not in edge_from, ( + f"helper edge should come from svc.list's node id, not . " + f"edge={helper_edges[0]}" + ) + + def test_object_literal_multiple_methods_all_registered(self): + """Multiple arrow functions in the same object literal must all + be registered as separate nodes.""" + ts = """\ +export const assignmentService = { + list: (ctx: any) => { return hasPermission(ctx.user, 'read'); }, + create: (ctx: any) => { return hasPermission(ctx.user, 'write'); }, + remove: (ctx: any) => { return hasPermission(ctx.user, 'delete'); }, +}; +""" + result = _parse(ts, "test.ts") + fn_names = [n["fn"] for n in result["nodes"]] + assert "assignmentService.list" in fn_names + assert "assignmentService.create" in fn_names + assert "assignmentService.remove" in fn_names + + # 3 hasPermission edges, one per method + hp_edges = [e for e in result["edges"] if e.get("to_fn") == "hasPermission"] + assert len(hp_edges) == 3, ( + f"Expected 3 hasPermission edges (one per method), got " + f"{len(hp_edges)}. edges={hp_edges}" + ) + # None should come from + for e in hp_edges: + assert ":0:" not in e["from"], ( + f"hasPermission edge should come from method node, not . " + f"edge={e}" + ) + + def test_object_literal_method_ref_count_correct(self): + """End-to-end: hasPermission called only via object-literal arrow + functions must have correct ref_count after edge resolution.""" + from edge_resolver import resolve_edges + ts_permissions = """\ +export function hasPermission(user: any, perm: string): boolean { + return user?.permissions?.includes(perm) ?? false; +} +""" + ts_service = """\ +import { hasPermission } from './permissions'; +export const assignmentService = { + list: (ctx: any) => { if (!hasPermission(ctx.user, 'read')) return null; return []; }, + create: (ctx: any) => { if (!hasPermission(ctx.user, 'write')) throw new Error('no'); return {}; }, + remove: (ctx: any) => { if (!hasPermission(ctx.user, 'delete')) throw new Error('no'); return true; }, +}; +""" + all_nodes = [] + all_edges = [] + for fpath, content in [ + ("src/lib/permissions.ts", ts_permissions), + ("src/services/assignments.ts", ts_service), + ]: + r = _ts_be_parser.extract_references(content, fpath) + all_nodes.extend(r.get("nodes", [])) + all_edges.extend(r.get("edges", [])) + + resolved_nodes, _ = resolve_edges(all_nodes, all_edges) + for n in resolved_nodes: + if n["fn"] == "hasPermission": + assert n.get("ref_count", 0) == 3, ( + f"hasPermission ref_count should be 3 (3 call sites in " + f"object-literal arrow functions), got {n.get('ref_count', 0)}" + ) + return + pytest.fail("hasPermission node not found in resolved nodes") + + def test_object_literal_with_non_arrow_pairs_not_affected(self): + """Object literals with non-arrow pairs (e.g. ``count: 5``) must + not break parsing — non-arrow pairs are simply skipped.""" + ts = """\ +const config = { + count: 5, + name: 'svc', + handler: (ctx: any) => { return helper(ctx); }, +}; +""" + result = _parse(ts, "test.ts") + fn_names = [n["fn"] for n in result["nodes"]] + assert "config.handler" in fn_names, ( + f"config.handler should be registered, got fn_names={fn_names}" + ) + # Non-arrow pairs should not produce nodes + assert "config.count" not in fn_names + assert "config.name" not in fn_names + + def test_no_double_count_object_literal_arrow_and_module_level(self): + """Calls inside object-literal arrow functions must NOT be + double-counted by the module-level pass. + + Before fix #222: the module-level pass would extract calls inside + the arrow body AND the per-function pass would also extract them + (after registering the arrow as a node), leading to ref_count=2x. + After fix: the module-level pass skips pair subtrees with + arrow/function values. + """ + ts = """\ +const svc = { + list: (ctx: any) => { return helper(); }, +}; +""" + result = _parse(ts, "test.ts") + helper_edges = [e for e in result["edges"] if e.get("to_fn") == "helper"] + assert len(helper_edges) == 1, ( + f"Expected exactly 1 helper edge (no double-count between " + f"per-function pass and module-level pass), got {len(helper_edges)}. " + f"edges={helper_edges}" + ) + + def test_object_literal_in_function_body_not_double_counted(self): + """Object literal returned from a function: the function body + is already walked by the per-function pass, so the object-literal + method pass must not re-walk the same pairs. + + Note: object literals inside function bodies don't have a + variable_declarator parent at module scope, so they are skipped + by _find_object_literal_method_decls (no stable var name). + """ + ts = """\ +function makeService() { + return { + list: (ctx: any) => { return helper(); }, + }; +} +""" + result = _parse(ts, "test.ts") + # helper() is inside the arrow function inside the object literal + # inside makeService's body. The per-function pass for makeService + # walks its body and extracts helper() attributed to makeService. + # The object-literal method pass skips this pair (no + # variable_declarator parent with object value at module scope). + # The module-level pass skips makeService's subtree (registered decl). + # So helper should have exactly 1 edge, from makeService. + helper_edges = [e for e in result["edges"] if e.get("to_fn") == "helper"] + assert len(helper_edges) == 1, ( + f"Expected 1 helper edge (from makeService), got {len(helper_edges)}. " + f"edges={helper_edges}" + ) + # Should NOT come from — makeService is a registered decl + from_text = helper_edges[0]["from"] + assert ":0:" not in from_text, ( + f"helper edge should come from makeService's node id, not . " + f"edge={helper_edges[0]}" + )