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
7 changes: 5 additions & 2 deletions code_review_graph/flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,11 @@ def detect_entry_points(
When *include_tests* is False (the default), Test nodes are excluded so
that flow analysis focuses on production entry points.
"""
# Build a set of all qualified names that are CALLS targets.
called_qnames = store.get_all_call_targets()
# Build a set of all qualified names that are CALLS targets. Exclude
# edges sourced at File nodes so that script-/notebook-/top-level-only
# callees (e.g. ``run_job()`` invoked from module scope, a top-level
# ``<App />`` render) remain detectable as entry points.
called_qnames = store.get_all_call_targets(include_file_sources=False)

# Scan all nodes for entry-point candidates.
candidate_nodes = store.get_nodes_by_kind(["Function", "Test"])
Expand Down
33 changes: 27 additions & 6 deletions code_review_graph/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -1075,12 +1075,33 @@ def get_node_kind_by_id(self, node_id: int) -> str | None:
).fetchone()
return row["kind"] if row else None

def get_all_call_targets(self) -> set[str]:
"""Return the set of all CALLS-edge target qualified names."""
rows = self._conn.execute(
"SELECT DISTINCT target_qualified FROM edges "
"WHERE kind = 'CALLS'"
).fetchall()
def get_all_call_targets(self, include_file_sources: bool = True) -> set[str]:
"""Return the set of all CALLS-edge target qualified names.

When ``include_file_sources`` is False, CALLS edges whose source is a
File node (module-scope calls from top-level script glue, CLI
entrypoints, or notebook cells) are excluded. Callers that treat "has
an incoming call" as "is not a root" (e.g. entry-point detection)
should pass ``include_file_sources=False`` — otherwise a script-only
callee looks called and is hidden from flow analysis.

The File-node filter joins against ``nodes.kind`` rather than pattern-
matching ``source_qualified`` so that file paths containing ``::`` or
any future change to the File-node naming convention cannot silently
miscategorize edges.
"""
if include_file_sources:
rows = self._conn.execute(
"SELECT DISTINCT target_qualified FROM edges "
"WHERE kind = 'CALLS'"
).fetchall()
else:
rows = self._conn.execute(
"SELECT DISTINCT e.target_qualified FROM edges e "
"LEFT JOIN nodes n ON n.qualified_name = e.source_qualified "
"WHERE e.kind = 'CALLS' "
"AND (n.kind IS NULL OR n.kind != 'File')"
).fetchall()
return {r["target_qualified"] for r in rows}

def get_communities_list(
Expand Down
116 changes: 69 additions & 47 deletions code_review_graph/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -1450,26 +1450,27 @@ def _extract_elixir_constructs(
return True

# ---- Everything else = a regular function/method call ----------
# Emit a CALLS edge when we're inside a function (same rule as
# the generic _extract_calls path).
if enclosing_func:
# For dotted calls like `IO.puts(msg)`, prefer the dotted
# identifier; for bare calls use the first identifier.
call_name = ident
caller = self._qualify(
enclosing_func, file_path, enclosing_class,
)
target = self._resolve_call_target(
call_name, file_path, language,
import_map or {}, defined_names or set(),
)
edges.append(EdgeInfo(
kind="CALLS",
source=caller,
target=target,
file_path=file_path,
line=node.start_point[0] + 1,
))
# Module-scope calls attribute to the File node (same rule as the
# generic _extract_calls path).
# For dotted calls like `IO.puts(msg)`, prefer the dotted
# identifier; for bare calls use the first identifier.
call_name = ident
caller = (
self._qualify(enclosing_func, file_path, enclosing_class)
if enclosing_func
else file_path
)
target = self._resolve_call_target(
call_name, file_path, language,
import_map or {}, defined_names or set(),
)
edges.append(EdgeInfo(
kind="CALLS",
source=caller,
target=target,
file_path=file_path,
line=node.start_point[0] + 1,
))
# Recurse into arguments + do_block so nested calls are caught.
for sub in node.children:
if sub.type in ("arguments", "do_block"):
Expand Down Expand Up @@ -2376,9 +2377,17 @@ def _extract_calls(
)
return True

if call_name and enclosing_func:
caller = self._qualify(
enclosing_func, file_path, enclosing_class,
if call_name:
# Module-scope calls (no enclosing function) are attributed to
# the File node. Matches the existing convention for CONTAINS
# edges and _extract_value_references. Without this fallback,
# any function called only from top-level script glue, CLI
# entrypoints, or Jupyter/Databricks notebook cells is flagged
# as dead by find_dead_code.
caller = (
self._qualify(enclosing_func, file_path, enclosing_class)
if enclosing_func
else file_path
)
target = self._resolve_call_target(
call_name, file_path, language,
Expand Down Expand Up @@ -2411,17 +2420,21 @@ def _extract_jsx_component_call(
Treat uppercase component tags such as ``<MarkdownMsg />`` as call-like
edges so caller/impact queries can cross the JSX boundary. Intrinsic DOM
tags (``<div>``) are ignored.
"""
if not enclosing_func:
return

Module-scope JSX (e.g. a top-level ``<App />`` render call) attributes
to the File node.
"""
target = self._resolve_jsx_component_target(
child, language, file_path, import_map or {}, defined_names or set(),
)
if not target:
return

caller = self._qualify(enclosing_func, file_path, enclosing_class)
caller = (
self._qualify(enclosing_func, file_path, enclosing_class)
if enclosing_func
else file_path
)
edges.append(EdgeInfo(
kind="CALLS",
source=caller,
Expand Down Expand Up @@ -2702,15 +2715,20 @@ def _extract_solidity_constructs(
Returns True if the child was fully handled and should skip
default recursion.
"""
# Emit statements: emit EventName(...) -> CALLS edge
if node_type == "emit_statement" and enclosing_func:
# Emit statements: emit EventName(...) -> CALLS edge.
# Module-scope emits attribute to the File node.
if node_type == "emit_statement":
for sub in child.children:
if sub.type == "expression":
for ident in sub.children:
if ident.type == "identifier":
caller = self._qualify(
enclosing_func, file_path,
enclosing_class,
caller = (
self._qualify(
enclosing_func, file_path,
enclosing_class,
)
if enclosing_func
else file_path
)
edges.append(EdgeInfo(
kind="CALLS",
Expand Down Expand Up @@ -3999,21 +4017,25 @@ def _handle_r_call(
import_map, defined_names,
)

if enclosing_func:
call_name = self._get_call_name(node, language, source)
if call_name:
caller = self._qualify(enclosing_func, file_path, enclosing_class)
target = self._resolve_call_target(
call_name, file_path, language,
import_map or {}, defined_names or set(),
)
edges.append(EdgeInfo(
kind="CALLS",
source=caller,
target=target,
file_path=file_path,
line=node.start_point[0] + 1,
))
# Module-scope R calls attribute to the File node.
call_name = self._get_call_name(node, language, source)
if call_name:
caller = (
self._qualify(enclosing_func, file_path, enclosing_class)
if enclosing_func
else file_path
)
target = self._resolve_call_target(
call_name, file_path, language,
import_map or {}, defined_names or set(),
)
edges.append(EdgeInfo(
kind="CALLS",
source=caller,
target=target,
file_path=file_path,
line=node.start_point[0] + 1,
))

self._extract_from_tree(
node, source, language, file_path, nodes, edges,
Expand Down
24 changes: 24 additions & 0 deletions tests/test_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,30 @@ def test_detect_entry_points_excludes_test_files(self):
ep_files_all = {ep.file_path for ep in eps_all}
assert "src/handler.spec.ts" in ep_files_all

def test_detect_entry_points_module_scope_caller_is_still_root(self):
"""A function called only from module scope (File-sourced CALLS) is a root.

Regression guard: the parser attributes module-scope calls to the File
node. Without filtering File-sourced callers, ``run_job`` here would
look "called" by ``script.py`` and be excluded from flow analysis,
even though in practice it IS an entry point (the script itself is
invoked externally).
"""
self._add_func("run_job", path="script.py")
# Ensure the File node exists so its qualified_name resolves cleanly
# (production code creates this automatically during parsing).
self.store.upsert_node(NodeInfo(
kind="File", name="script.py", file_path="script.py",
line_start=1, line_end=10, language="python",
))
self.store.commit()
# Module-scope call: source is the File node's qualified_name.
self._add_call("script.py", "script.py::run_job", path="script.py")

eps = detect_entry_points(self.store)
ep_names = {ep.name for ep in eps}
assert "run_job" in ep_names

def test_trace_simple_flow(self):
"""BFS traces a linear call chain: A -> B -> C."""
self._add_func("entry")
Expand Down
Loading
Loading