diff --git a/code_review_graph/parser.py b/code_review_graph/parser.py index 31723aeb..e4a61acb 100644 --- a/code_review_graph/parser.py +++ b/code_review_graph/parser.py @@ -12194,13 +12194,17 @@ def _ref_from_arguments( ) -> None: """Extract REFERENCES from identifier arguments (callbacks).""" for ch in args_node.children: - if ch.type == "identifier": - name = ch.text.decode("utf-8", errors="replace") - self._emit_reference_if_known( - name, language, file_path, caller, edges, - import_map, defined_names, - line=ch.start_point[0] + 1, - ) + reference_node = ch + if ch.type == "keyword_argument" and language == "python": + reference_node = ch.child_by_field_name("value") + if reference_node is None or reference_node.type != "identifier": + continue + name = reference_node.text.decode("utf-8", errors="replace") + self._emit_reference_if_known( + name, language, file_path, caller, edges, + import_map, defined_names, + line=reference_node.start_point[0] + 1, + ) def _extract_solidity_constructs( self, diff --git a/tests/fixtures/sample_callback_refs.py b/tests/fixtures/sample_callback_refs.py index 9415a7a8..174b2890 100644 --- a/tests/fixtures/sample_callback_refs.py +++ b/tests/fixtures/sample_callback_refs.py @@ -1,8 +1,8 @@ """Fixture for issue #363: function references in callback positions. -Each `*_callback` function is passed as a bare-identifier argument to -another call. They are never invoked with parens, so without REFERENCES -edge tracking they would be flagged as dead code. +Each `*_callback` function is passed as an argument to another call, either +directly or as a keyword value. They are never invoked with parens, so without +REFERENCES edge tracking they would be flagged as dead code. """ from concurrent.futures import ThreadPoolExecutor @@ -19,6 +19,10 @@ def map_callback(item): return item * 2 +def keyword_callback(args): + return args + + def trigger_executor(): with ThreadPoolExecutor() as executor: future = executor.submit(executor_callback) @@ -33,3 +37,7 @@ def trigger_filter(): def trigger_map(): items = [1, 2, 3] return list(map(map_callback, items)) + + +def register_keyword_callback(parser): + parser.set_defaults(func=keyword_callback) diff --git a/tests/test_parser.py b/tests/test_parser.py index 70103abc..4365db96 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -823,7 +823,7 @@ def test_vitest_tested_by_edges(self): f"All edges: {[(e.kind, e.source, e.target) for e in edges]}" ) - # --- Python callback REFERENCES (#363) --- + # --- Python callback REFERENCES (#363, #840) --- # Functions passed as bare-identifier arguments (executor.submit(fn), # filter(fn, xs), map(fn, xs), df.apply(fn), ...) should produce # REFERENCES edges so dead-code detection does not flag them as unused. @@ -842,6 +842,13 @@ def test_python_callback_references_emitted(self): f"{ref_target_names}" ) + def test_python_keyword_callback_reference_emitted(self): + """A function passed as a keyword-argument value should produce a reference.""" + nodes, edges = self.parser.parse_file(FIXTURES / "sample_callback_refs.py") + refs = [e for e in edges if e.kind == "REFERENCES"] + ref_target_names = {e.target.rsplit("::", 1)[-1] for e in refs} + assert "keyword_callback" in ref_target_names + def test_python_callback_references_not_treated_as_dead(self): """End-to-end: with REFERENCES edges in place, find_dead_code should not flag callback functions as dead.""" @@ -862,7 +869,10 @@ def test_python_callback_references_not_treated_as_dead(self): dead = find_dead_code(store) dead_names = {d["name"] for d in dead} for callback in ( - "executor_callback", "filter_callback", "map_callback", + "executor_callback", + "filter_callback", + "map_callback", + "keyword_callback", ): assert callback not in dead_names, ( f"{callback} was flagged as dead but is used as a "