Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions code_review_graph/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 11 additions & 3 deletions tests/fixtures/sample_callback_refs.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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)
Expand All @@ -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)
14 changes: 12 additions & 2 deletions tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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."""
Expand All @@ -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 "
Expand Down