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
112 changes: 112 additions & 0 deletions scripts/graph_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,91 @@
}


# ─── Module-level synthetic source_id (issue #223) ────────────
#
# JS/TS parsers emit module-top-level calls (calls not nested inside any
# function declaration) with a synthetic ``source_id = "<file>:0:<module>"``
# — see ``js_backend_parser.py:248``, ``ts_backend_parser.py:125``,
# ``fallback_js_backend.py:196``. The synthetic id has NO matching entry
# in ``graph_nodes`` (intentional — keeps ``list``/``search`` output free
# of fake ``<module>`` function entries, per PR #219 design).
#
# Pre-#223 this caused ``trace --direction up`` and ``impact`` to silently
# drop module-level callers: the BFS JOIN ``graph_nodes.node_id = ?``
# returned no row, and the caller was skipped. ``ref_count`` (computed
# from the target side) was correct, but ``trace``/``impact`` (computed
# from the source side) were wrong — inconsistent and dangerous for
# anyone using trace to decide "safe to delete?".
#
# ``_MODULE_LEVEL_SENTINEL`` is the literal name component used to mark
# these synthetic ids. ``is_module_level_source_id(node_id)`` lets
# traversal code recognize them and synthesize a human-readable
# "module-level caller in <file>" entry WITHOUT creating a fake node
# in ``graph_nodes`` (constraint from issue #223).
_MODULE_LEVEL_SENTINEL = "<module>"


def is_module_level_source_id(node_id: str) -> bool:
"""Return True if ``node_id`` is a synthetic module-level caller id.

Synthetic ids follow the format ``"<file>:0:<module>"`` — emitted by
JS/TS parsers for calls at module top level (not nested in any
function body). They have no matching entry in ``graph_nodes`` by
design (issue #219 / PR #219), so traversal code must special-case
them to avoid silently dropping module-level callers from
``trace``/``impact`` output (issue #223).

Detection is intentionally strict on the ``:0:<module>`` suffix so
that real node ids (which use the actual line number and a real
function name) never match — a real function named ``<module>`` at
line 0 would be exotic enough that we accept the theoretical
false-positive risk.
"""
if not node_id:
return False
return node_id.endswith(":" + _MODULE_LEVEL_SENTINEL) and ":0:" in node_id


def _module_level_caller_entry(
node_id: str,
depth: int,
edge_dict: Dict[str, Any],
) -> Dict[str, Any]:
"""Build a result entry for a synthetic module-level caller.

Used by ``_bfs`` when a CALLS edge's ``source_id`` is a synthetic
``<file>:0:<module>`` id (no ``graph_nodes`` row). The entry exposes
enough info for ``trace_engine`` / ``impact_engine`` to render the
caller as "module-level caller in <file>" without needing a real
node row.

Args:
node_id: The synthetic source_id (``<file>:0:<module>``).
depth: BFS depth at which this neighbor was found.
edge_dict: The raw graph_edges row (for file/line/confidence).

Returns:
Dict with the same shape as a resolved caller entry, plus
``module_level=True`` and ``node_type="module"`` so consumers
can render it distinctly.
"""
src_file, src_line = _parse_file_line_from_node_id(node_id)
return {
"node_id": node_id,
"name": _MODULE_LEVEL_SENTINEL,
"node_type": NODE_TYPE_MODULE,
"file": src_file,
"line": src_line,
"depth": depth,
"edge_file": edge_dict.get("file", "") or src_file,
"edge_line": edge_dict.get("line", 0) or src_line,
"confidence": edge_dict.get("confidence", 1.0),
"resolved": True, # edge IS resolvable to a file:line
"cyclic": False,
"module_level": True, # marker for trace_engine / impact_engine
}


# ─── SQL Statements ───────────────────────────────────────────

_CREATE_GRAPH_NODES = """
Expand Down Expand Up @@ -1067,6 +1152,15 @@ def _bfs(
continue
reported_cycles.add(cycle_key)

# Issue #223: synthetic ``<file>:0:<module>`` source_id has
# no graph_nodes row — emit a module-level caller entry
# instead of silently dropping the cyclic reference.
if is_module_level_source_id(neighbor_id):
entry = _module_level_caller_entry(neighbor_id, depth, edge_dict)
entry["cyclic"] = True
results.append(entry)
continue

neighbor_row = conn.execute(
"SELECT * FROM {t} WHERE node_id = ?".format(t=GRAPH_NODES_TABLE),
(neighbor_id,),
Expand All @@ -1088,6 +1182,24 @@ def _bfs(
continue

visited.add(neighbor_id)

# Issue #223: synthetic ``<file>:0:<module>`` source_id has no
# graph_nodes row. Pre-#223 this caused ``trace --direction up``
# and ``impact`` to silently drop all module-level callers —
# inconsistent with ``ref_count`` (computed from target side,
# already correct after PR #219). Emit a synthesized entry so
# consumers can render "module-level caller in <file>" without
# needing a fake node in graph_nodes (constraint from #223).
#
# Module-level callers are terminal: they have no further
# callers of their own (module scope is the top of the file's
# call hierarchy), so we do NOT enqueue them for further BFS.
if is_module_level_source_id(neighbor_id):
results.append(
_module_level_caller_entry(neighbor_id, depth, edge_dict)
)
continue

neighbor_row = conn.execute(
"SELECT * FROM {t} WHERE node_id = ?".format(t=GRAPH_NODES_TABLE),
(neighbor_id,),
Expand Down
59 changes: 59 additions & 0 deletions scripts/impact_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,34 @@
except ImportError:
_STD_LIB_METHODS = frozenset()

# Issue #223: detect synthetic module-level source_ids emitted by JS/TS
# parsers (``<file>:0:<module>``). These have no entry in the flat-registry
# node list (intentional — keeps `list`/`search` output free of fake
# ``<module>`` function entries), so the pre-#223 impact engine silently
# dropped all module-level callers. ``ref_count`` was correct (computed
# from target side), but ``impact`` showed 0 dependents — dangerous for
# anyone using impact to decide "safe to delete?".
try:
from graph_model import is_module_level_source_id
except ImportError:
# Defensive fallback — should never trigger since graph_model is a
# core module, but keeps impact_engine functional in degenerate envs.
def is_module_level_source_id(node_id: str) -> bool: # type: ignore[no-redef]
return bool(node_id) and node_id.endswith(":0:<module>")


def _file_from_module_level_id(node_id: str) -> str:
"""Extract the file path from a synthetic ``<file>:0:<module>`` id.

Returns ``""`` if the format doesn't match. Used by issue #223
impact entries so the human-readable "module-level caller in <file>"
line shows the originating file.
"""
if not node_id or not is_module_level_source_id(node_id):
return ""
# Strip the trailing ``:0:<module>`` suffix.
return node_id[: -len(":0:<module>")]


def analyze_impact(
name: str,
Expand Down Expand Up @@ -108,6 +136,21 @@ def analyze_impact(
"relation": "calls " + name,
"domain": "backend"
})
elif is_module_level_source_id(from_id):
# Issue #223: synthetic ``<file>:0:<module>`` source_id
# has no flat-registry node entry. Emit a human-readable
# "module-level caller in <file>" so impact no longer
# silently drops module-level calls (was inconsistent
# with ref_count which is computed from target side).
affected["direct"].append({
"type": "module",
"name": "<module>",
"file": _file_from_module_level_id(from_id),
"line": 0,
"relation": "module-level caller — calls " + name,
"domain": "backend",
"module_level": True,
})

# Direct callees (1 hop)
direct_callee_edges = callees_map.get(target_id, [])
Expand Down Expand Up @@ -165,6 +208,22 @@ def analyze_impact(
"domain": "backend"
})
queue.append((from_id, current_depth + 1))
elif is_module_level_source_id(from_id) and current_depth >= 1:
# Issue #223: module-level caller has no node
# entry but IS a real dependent. Emit it as an
# indirect dependent so impact count is
# consistent with ref_count. Do NOT enqueue for
# further BFS — module scope is terminal.
affected["indirect"].append({
"type": "module",
"name": "<module>",
"file": _file_from_module_level_id(from_id),
"line": 0,
"relation": f"{current_depth + 1} hops from {name} (module-level caller)",
"depth": current_depth + 1,
"domain": "backend",
"module_level": True,
})

# If deleting, all callees that are only called by this function become at-risk
if action == "delete":
Expand Down
19 changes: 17 additions & 2 deletions scripts/trace_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,7 +621,7 @@ def _is_std_lib_method(_fn: str) -> bool:
continue
reported_cycles.add(cycle_key)
neighbor_extra = neighbor.get("extra", {}) or {}
chain.append({
cyclic_entry = {
"depth": depth,
"direction": direction_label,
"node_id": neighbor_id,
Expand All @@ -632,7 +632,12 @@ def _is_std_lib_method(_fn: str) -> bool:
"cyclic": True,
"status": neighbor_extra.get("status", "active"),
"async": neighbor_extra.get("async", False),
})
}
# Issue #223: preserve module_level marker on cyclic entries.
if neighbor.get("module_level"):
cyclic_entry["module_level"] = True
cyclic_entry["fn"] = "<module>"
chain.append(cyclic_entry)
continue

visited.add(neighbor_id)
Expand All @@ -652,6 +657,16 @@ def _is_std_lib_method(_fn: str) -> bool:
chain_entry["impl_for"] = neighbor_extra["impl_for"]
if neighbor_extra.get("component"):
chain_entry["component"] = True
# Issue #223: module-level caller entry from graph_model._bfs.
# Mark it so formatters can render "module-level caller in <file>"
# distinctly. Do NOT enqueue for further BFS — module scope is
# the top of a file's call hierarchy, so module-level callers
# have no further callers of their own.
if neighbor.get("module_level"):
chain_entry["module_level"] = True
chain_entry["fn"] = "<module>"
chain.append(chain_entry)
continue
chain.append(chain_entry)

if depth < max_depth:
Expand Down
Loading
Loading