Files
2026-07-13 12:42:18 +08:00

699 lines
24 KiB
Python

"""Execution flow detection, tracing, and criticality scoring.
Detects entry points in the codebase (functions with no incoming CALLS edges,
framework-decorated handlers, and conventional name patterns), traces execution
paths via forward BFS through CALLS edges, scores each flow for criticality,
and persists results to the ``flows`` / ``flow_memberships`` tables.
"""
from __future__ import annotations
import json
import logging
import re
from collections import deque
from typing import Optional
from .constants import SECURITY_KEYWORDS as _SECURITY_KEYWORDS
from .graph import FlowAdjacency, GraphNode, GraphStore, _sanitize_name
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
# Decorator patterns that indicate a function is a framework entry point.
_FRAMEWORK_DECORATOR_PATTERNS: list[re.Pattern[str]] = [
# Python web frameworks
re.compile(r"app\.(get|post|put|delete|patch|route|websocket|on_event)", re.IGNORECASE),
re.compile(r"router\.(get|post|put|delete|patch|route)", re.IGNORECASE),
re.compile(r"blueprint\.(route|before_request|after_request)", re.IGNORECASE),
re.compile(r"(before|after)_(request|response)", re.IGNORECASE),
# CLI frameworks
re.compile(r"click\.(command|group)", re.IGNORECASE),
re.compile(r"\w+\.(command|group)\b", re.IGNORECASE), # Click subgroups: @mygroup.command()
# Pydantic validators/serializers
re.compile(r"(field|model)_(serializer|validator)", re.IGNORECASE),
# Task queues
re.compile(r"(celery\.)?(task|shared_task|periodic_task)", re.IGNORECASE),
# Django
re.compile(r"receiver", re.IGNORECASE),
re.compile(r"api_view", re.IGNORECASE),
re.compile(r"\baction\b", re.IGNORECASE),
# Testing
re.compile(r"pytest\.(fixture|mark)"),
re.compile(r"(override_settings|modify_settings)", re.IGNORECASE),
# SQLAlchemy / event systems
re.compile(r"(event\.)?listens_for", re.IGNORECASE),
# Java Spring
re.compile(r"(Get|Post|Put|Delete|Patch|RequestMapping)Mapping", re.IGNORECASE),
re.compile(r"(Scheduled|EventListener|Bean|Configuration)", re.IGNORECASE),
# JS/TS frameworks
re.compile(r"(Component|Injectable|Controller|Module|Guard|Pipe)", re.IGNORECASE),
re.compile(r"(Subscribe|Mutation|Query|Resolver)", re.IGNORECASE),
# Express / Koa / Hono route handlers
re.compile(r"(app|router)\.(get|post|put|delete|patch|use|all)\b"),
# Android lifecycle
re.compile(r"@(Override|OnLifecycleEvent|Composable)", re.IGNORECASE),
# Kotlin coroutines / Android ViewModel
re.compile(r"(HiltViewModel|AndroidEntryPoint|Inject)", re.IGNORECASE),
# AI/agent frameworks (pydantic-ai, langchain, etc.)
re.compile(r"\w+\.(tool|tool_plain|system_prompt|result_validator)\b", re.IGNORECASE),
re.compile(r"^tool\b"), # bare @tool (LangChain, etc.)
# Middleware and exception handlers (Starlette, FastAPI, Sanic)
re.compile(r"\w+\.(middleware|exception_handler|on_exception)\b", re.IGNORECASE),
# Generic route decorator (Flask blueprints: @bp.route, @auth_bp.route, etc.)
re.compile(r"\w+\.route\b", re.IGNORECASE),
]
# Name patterns that indicate conventional entry points.
_ENTRY_NAME_PATTERNS: list[re.Pattern[str]] = [
re.compile(r"^main$"),
re.compile(r"^__main__$"),
re.compile(r"^test_"),
re.compile(r"^Test[A-Z]"),
re.compile(r"^on_"),
re.compile(r"^handle_"),
# Lambda / serverless handler functions (wired via config, not code calls)
re.compile(r"^handler$"),
re.compile(r"^handle$"),
re.compile(r"^lambda_handler$"),
# Alembic migration entry points
re.compile(r"^upgrade$"),
re.compile(r"^downgrade$"),
# FastAPI lifecycle / dependency injection
re.compile(r"^lifespan$"),
re.compile(r"^get_db$"),
# Android Activity/Fragment lifecycle
re.compile(r"^on(Create|Start|Resume|Pause|Stop|Destroy|Bind|Receive)"),
# Servlet / JAX-RS
re.compile(r"^do(Get|Post|Put|Delete)$"),
# Python BaseHTTPRequestHandler
re.compile(r"^do_(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)$"),
re.compile(r"^log_message$"),
# Express middleware signature
re.compile(r"^(middleware|errorHandler)$"),
# Angular lifecycle hooks
re.compile(
r"^ng(OnInit|OnChanges|OnDestroy|DoCheck"
r"|AfterContentInit|AfterContentChecked|AfterViewInit|AfterViewChecked)$"
),
# Angular Pipe / ControlValueAccessor / Guards / Resolvers
re.compile(r"^(transform|writeValue|registerOnChange|registerOnTouched|setDisabledState)$"),
re.compile(r"^(canActivate|canDeactivate|canActivateChild|canLoad|canMatch|resolve)$"),
# React class component lifecycle
re.compile(
r"^(componentDidMount|componentDidUpdate|componentWillUnmount"
r"|shouldComponentUpdate|render)$"
),
]
# ---------------------------------------------------------------------------
# Entry-point detection
# ---------------------------------------------------------------------------
def _has_framework_decorator(node: GraphNode) -> bool:
"""Return True if *node* has a decorator matching a framework pattern."""
decorators = node.extra.get("decorators")
if not decorators:
return False
if isinstance(decorators, str):
decorators = [decorators]
for dec in decorators:
for pat in _FRAMEWORK_DECORATOR_PATTERNS:
if pat.search(dec):
return True
return False
def _matches_entry_name(node: GraphNode) -> bool:
"""Return True if *node*'s name matches a conventional entry-point pattern."""
for pat in _ENTRY_NAME_PATTERNS:
if pat.search(node.name):
return True
return False
_TEST_FILE_RE = re.compile(
r"([\\/]__tests__[\\/]|\.spec\.[jt]sx?$|\.test\.[jt]sx?$|[\\/]test_[^/\\]*\.py$)",
)
def _is_test_file(file_path: str) -> bool:
"""Return True if *file_path* looks like a test file."""
return bool(_TEST_FILE_RE.search(file_path))
def detect_entry_points(
store: GraphStore,
include_tests: bool = False,
) -> list[GraphNode]:
"""Find functions that are entry points in the graph.
An entry point is a Function/Test node that either:
1. Has no incoming CALLS edges (true root), or
2. Has a framework decorator (e.g. ``@app.get``), or
3. Matches a conventional name pattern (``main``, ``test_*``, etc.).
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. 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"])
entry_points: list[GraphNode] = []
seen_qn: set[str] = set()
for node in candidate_nodes:
if not include_tests and (node.is_test or _is_test_file(node.file_path)):
continue
is_entry = False
# True root: no one calls this function.
if node.qualified_name not in called_qnames:
is_entry = True
# Framework decorator match.
if _has_framework_decorator(node):
is_entry = True
# Conventional name match.
if _matches_entry_name(node):
is_entry = True
if is_entry and node.qualified_name not in seen_qn:
entry_points.append(node)
seen_qn.add(node.qualified_name)
return entry_points
# ---------------------------------------------------------------------------
# Flow tracing (BFS)
# ---------------------------------------------------------------------------
def _trace_single_flow(
adj: FlowAdjacency,
ep: GraphNode,
max_depth: int = 15,
) -> Optional[dict]:
"""Trace a single execution flow from *ep* via forward BFS.
Returns a flow dict (see :func:`trace_flows` for the schema) or ``None``
if the flow is trivial (single-node, no outgoing CALLS that resolve).
"""
path_ids: list[int] = [ep.id]
path_qnames: list[str] = [ep.qualified_name]
visited: set[str] = {ep.qualified_name}
queue: deque[tuple[str, int]] = deque([(ep.qualified_name, 0)])
actual_depth = 0
nodes_by_qn = adj.nodes_by_qn
calls_out = adj.calls_out
while queue:
current_qn, depth = queue.popleft()
if depth > actual_depth:
actual_depth = depth
if depth >= max_depth:
continue
for target_qn in calls_out.get(current_qn, ()):
if target_qn in visited:
continue
target_node = nodes_by_qn.get(target_qn)
if target_node is None:
continue
visited.add(target_qn)
path_ids.append(target_node.id)
path_qnames.append(target_qn)
queue.append((target_qn, depth + 1))
# Skip trivial single-node flows.
if len(path_ids) < 2:
return None
files = list({
n.file_path
for qn in path_qnames
if (n := nodes_by_qn.get(qn)) is not None
})
flow: dict = {
"name": _sanitize_name(ep.name),
"entry_point": ep.qualified_name,
"entry_point_id": ep.id,
"path": path_ids,
"depth": actual_depth,
"node_count": len(path_ids),
"file_count": len(files),
"files": files,
"criticality": 0.0,
}
flow["criticality"] = compute_criticality(flow, adj)
return flow
def trace_flows(
store: GraphStore,
max_depth: int = 15,
include_tests: bool = False,
) -> list[dict]:
"""Trace execution flows from every entry point via forward BFS.
Returns a list of flow dicts, each containing:
- name: human-readable flow name (entry point name)
- entry_point: qualified name of the entry point
- entry_point_id: node database id of the entry point
- path: ordered list of node IDs in the flow
- depth: maximum BFS depth reached
- node_count: number of distinct nodes in the path
- file_count: number of distinct files touched
- files: list of distinct file paths
- criticality: computed criticality score (0.0-1.0)
"""
entry_points = detect_entry_points(store, include_tests=include_tests)
if not entry_points:
return []
adj = store.load_flow_adjacency()
flows: list[dict] = []
for ep in entry_points:
flow = _trace_single_flow(adj, ep, max_depth)
if flow is not None:
flows.append(flow)
# Sort by criticality descending.
flows.sort(key=lambda f: f["criticality"], reverse=True)
return flows
# ---------------------------------------------------------------------------
# Criticality scoring
# ---------------------------------------------------------------------------
def compute_criticality(flow: dict, adj: FlowAdjacency) -> float:
"""Score a flow from 0.0 to 1.0 based on multiple weighted factors.
Weights:
- File spread: 0.30
- External calls: 0.20
- Security sensitivity: 0.25
- Test coverage gap: 0.15
- Depth: 0.10
"""
node_ids: list[int] = flow.get("path", [])
if not node_ids:
return 0.0
nodes_by_id = adj.nodes_by_id
nodes_by_qn = adj.nodes_by_qn
calls_out = adj.calls_out
has_tested_by = adj.has_tested_by
nodes: list[GraphNode] = [
n for nid in node_ids if (n := nodes_by_id.get(nid)) is not None
]
if not nodes:
return 0.0
# --- File spread (0.0 - 1.0) ---
file_count = len({n.file_path for n in nodes})
# Normalize: 1 file => 0.0, 5+ files => 1.0
file_spread = min((file_count - 1) / 4.0, 1.0) if file_count > 1 else 0.0
# --- External calls (0.0 - 1.0) ---
# Calls that target nodes NOT in the graph are considered external.
external_count = 0
for n in nodes:
for target_qn in calls_out.get(n.qualified_name, ()):
if target_qn not in nodes_by_qn:
external_count += 1
# Normalize: 0 => 0.0, 5+ => 1.0
external_score = min(external_count / 5.0, 1.0)
# --- Security sensitivity (0.0 - 1.0) ---
security_hits = 0
for n in nodes:
name_lower = n.name.lower()
qn_lower = n.qualified_name.lower()
for kw in _SECURITY_KEYWORDS:
if kw in name_lower or kw in qn_lower:
security_hits += 1
break # Count each node at most once.
security_score = min(security_hits / max(len(nodes), 1), 1.0)
# --- Test coverage gap (0.0 - 1.0) ---
tested_count = sum(1 for n in nodes if n.qualified_name in has_tested_by)
coverage = tested_count / max(len(nodes), 1)
test_gap = 1.0 - coverage
# --- Depth (0.0 - 1.0) ---
depth = flow.get("depth", 0)
# Normalize: 0 => 0.0, 10+ => 1.0
depth_score = min(depth / 10.0, 1.0)
# --- Weighted sum ---
criticality = (
file_spread * 0.30
+ external_score * 0.20
+ security_score * 0.25
+ test_gap * 0.15
+ depth_score * 0.10
)
return round(min(max(criticality, 0.0), 1.0), 4)
# ---------------------------------------------------------------------------
# Persistence
# ---------------------------------------------------------------------------
def store_flows(store: GraphStore, flows: list[dict]) -> int:
"""Clear existing flows and persist new ones.
Returns the number of flows stored.
"""
# NOTE: store_flows uses _conn directly because it performs
# multi-statement batch writes (DELETE + INSERT loop) that are
# tightly coupled to the DB transaction lifecycle.
conn = store._conn
if conn.in_transaction:
logger.warning("Rolling back uncommitted transaction before BEGIN IMMEDIATE")
conn.rollback()
# Wrap the full DELETE + INSERT sequence in an explicit transaction
# so partial writes cannot occur if an exception interrupts the loop.
conn.execute("BEGIN IMMEDIATE")
try:
conn.execute("DELETE FROM flow_memberships")
conn.execute("DELETE FROM flows")
count = 0
for flow in flows:
path_json = json.dumps(flow.get("path", []))
conn.execute(
"""INSERT INTO flows
(name, entry_point_id, depth, node_count, file_count,
criticality, path_json)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(
flow["name"],
flow["entry_point_id"],
flow["depth"],
flow["node_count"],
flow["file_count"],
flow["criticality"],
path_json,
),
)
flow_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
# Insert memberships.
node_ids = flow.get("path", [])
for position, node_id in enumerate(node_ids):
conn.execute(
"INSERT OR IGNORE INTO flow_memberships (flow_id, node_id, position) "
"VALUES (?, ?, ?)",
(flow_id, node_id, position),
)
count += 1
conn.commit()
except BaseException:
conn.rollback()
raise
return count
def incremental_trace_flows(
store: GraphStore,
changed_files: list[str],
max_depth: int = 15,
) -> int:
"""Re-trace only flows that touch *changed_files*. Much faster than full trace.
1. Find flow IDs whose memberships reference nodes in *changed_files*.
2. Collect the entry-point node IDs of those flows before deleting them.
3. Delete only the affected flows and their memberships.
4. Re-detect entry points, keeping those in *changed_files* **or** whose
node ID was an entry point of a deleted flow.
5. BFS-trace each relevant entry point via :func:`_trace_single_flow`.
6. INSERT the new flows (without clearing unrelated flows).
Returns the number of re-traced flows that were stored.
"""
if not changed_files:
return 0
conn = store._conn
changed_file_set = set(changed_files)
# ------------------------------------------------------------------
# 1. Find affected flow IDs
# ------------------------------------------------------------------
placeholders = ",".join("?" * len(changed_files))
affected_rows = conn.execute(
f"SELECT DISTINCT fm.flow_id FROM flow_memberships fm " # nosec B608
f"JOIN nodes n ON n.id = fm.node_id "
f"WHERE n.file_path IN ({placeholders})",
changed_files,
).fetchall()
affected_ids = [r[0] for r in affected_rows]
# ------------------------------------------------------------------
# 2. Collect old entry-point node IDs before deletion
# ------------------------------------------------------------------
entry_point_ids: set[int] = set()
if affected_ids:
ep_placeholders = ",".join("?" * len(affected_ids))
ep_rows = conn.execute(
f"SELECT entry_point_id FROM flows " # nosec B608
f"WHERE id IN ({ep_placeholders})",
affected_ids,
).fetchall()
entry_point_ids = {r[0] for r in ep_rows}
# ------------------------------------------------------------------
# 3. Delete affected flows and their memberships
# ------------------------------------------------------------------
# Wrap in an explicit transaction so a crash mid-loop cannot leave
# orphaned flow_memberships rows pointing at deleted flows. See #258.
if affected_ids:
if conn.in_transaction:
conn.commit()
conn.execute("BEGIN IMMEDIATE")
try:
for fid in affected_ids:
conn.execute(
"DELETE FROM flow_memberships WHERE flow_id = ?", (fid,),
)
conn.execute("DELETE FROM flows WHERE id = ?", (fid,))
conn.commit()
except BaseException:
conn.rollback()
raise
# ------------------------------------------------------------------
# 4. Re-detect entry points and filter to relevant ones
# ------------------------------------------------------------------
entry_points = detect_entry_points(store)
relevant_eps = [
ep for ep in entry_points
if ep.file_path in changed_file_set or ep.id in entry_point_ids
]
# ------------------------------------------------------------------
# 5. BFS-trace each relevant entry point
# ------------------------------------------------------------------
new_flows: list[dict] = []
if relevant_eps:
adj = store.load_flow_adjacency()
for ep in relevant_eps:
flow = _trace_single_flow(adj, ep, max_depth)
if flow is not None:
new_flows.append(flow)
# ------------------------------------------------------------------
# 6. INSERT new flows without clearing unrelated ones
# ------------------------------------------------------------------
count = 0
for flow in new_flows:
path_json = json.dumps(flow.get("path", []))
conn.execute(
"""INSERT INTO flows
(name, entry_point_id, depth, node_count, file_count,
criticality, path_json)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(
flow["name"],
flow["entry_point_id"],
flow["depth"],
flow["node_count"],
flow["file_count"],
flow["criticality"],
path_json,
),
)
flow_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
node_ids = flow.get("path", [])
for position, node_id in enumerate(node_ids):
conn.execute(
"INSERT OR IGNORE INTO flow_memberships (flow_id, node_id, position) "
"VALUES (?, ?, ?)",
(flow_id, node_id, position),
)
count += 1
conn.commit()
return count
# ---------------------------------------------------------------------------
# Query helpers
# ---------------------------------------------------------------------------
def get_flows(
store: GraphStore,
sort_by: str = "criticality",
limit: int = 50,
) -> list[dict]:
"""Retrieve stored flows from the database.
Args:
store: The graph store.
sort_by: Column to sort by (``criticality``, ``depth``, ``node_count``).
limit: Maximum number of flows to return.
"""
allowed_sort = {"criticality", "depth", "node_count", "file_count", "name"}
if sort_by not in allowed_sort:
sort_by = "criticality"
order = "DESC" if sort_by in ("criticality", "depth", "node_count", "file_count") else "ASC"
# NOTE: get_flows reads from the flows table which is managed by
# the flows module; _conn access is documented coupling.
rows = store._conn.execute(
f"SELECT * FROM flows ORDER BY {sort_by} {order} LIMIT ?", # nosec B608
(limit,),
).fetchall()
results: list[dict] = []
for row in rows:
results.append({
"id": row["id"],
"name": _sanitize_name(row["name"]),
"entry_point_id": row["entry_point_id"],
"depth": row["depth"],
"node_count": row["node_count"],
"file_count": row["file_count"],
"criticality": row["criticality"],
"path": json.loads(row["path_json"]),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
})
return results
def get_flow_by_id(store: GraphStore, flow_id: int) -> Optional[dict]:
"""Retrieve a single flow with full path details.
Returns a dict with the flow metadata plus a ``steps`` list containing
each node's name, kind, file, and line info.
"""
# NOTE: get_flow_by_id reads from the flows table; see store_flows note.
row = store._conn.execute(
"SELECT * FROM flows WHERE id = ?", (flow_id,)
).fetchone()
if row is None:
return None
path_ids: list[int] = json.loads(row["path_json"])
# Build detailed step info.
steps: list[dict] = []
for nid in path_ids:
node = store.get_node_by_id(nid)
if node:
steps.append({
"node_id": node.id,
"name": _sanitize_name(node.name),
"kind": node.kind,
"file": node.file_path,
"line_start": node.line_start,
"line_end": node.line_end,
"qualified_name": _sanitize_name(node.qualified_name),
})
return {
"id": row["id"],
"name": _sanitize_name(row["name"]),
"entry_point_id": row["entry_point_id"],
"depth": row["depth"],
"node_count": row["node_count"],
"file_count": row["file_count"],
"criticality": row["criticality"],
"path": path_ids,
"steps": steps,
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def get_affected_flows(
store: GraphStore,
changed_files: list[str],
) -> dict:
"""Find flows that include nodes from the given changed files.
Returns::
{
"affected_flows": [<flow dicts>],
"total": <int>,
}
"""
if not changed_files:
return {"affected_flows": [], "total": 0}
# Find node IDs belonging to changed files.
node_ids = store.get_node_ids_by_files(changed_files)
if not node_ids:
return {"affected_flows": [], "total": 0}
# Find flow IDs that contain any of these nodes.
flow_ids = store.get_flow_ids_by_node_ids(node_ids)
if not flow_ids:
return {"affected_flows": [], "total": 0}
affected: list[dict] = []
for fid in flow_ids:
flow = get_flow_by_id(store, fid)
if flow:
affected.append(flow)
# Sort by criticality descending.
affected.sort(key=lambda f: f.get("criticality", 0), reverse=True)
return {
"affected_flows": affected,
"total": len(affected),
}