codegraph-engine 3.0.0__2-py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codegraph/__init__.py +37 -0
- codegraph/agent.py +26 -0
- codegraph/agent_brain.py +2212 -0
- codegraph/agent_capabilities.py +4058 -0
- codegraph/agent_rules.py +531 -0
- codegraph/api_drift.py +455 -0
- codegraph/architecture.py +371 -0
- codegraph/async_queue/__init__.py +50 -0
- codegraph/async_queue/detectors/__init__.py +48 -0
- codegraph/async_queue/detectors/celery.py +160 -0
- codegraph/async_queue/detectors/kafka.py +168 -0
- codegraph/async_queue/detectors/rabbitmq.py +116 -0
- codegraph/async_queue/detectors/redis.py +130 -0
- codegraph/async_queue/detectors/sqs.py +135 -0
- codegraph/async_queue/models.py +161 -0
- codegraph/async_queue/otel_models.py +74 -0
- codegraph/async_queue/otel_parser.py +337 -0
- codegraph/async_queue/reconciler.py +427 -0
- codegraph/async_queue/scanner.py +291 -0
- codegraph/async_queue/traversal.py +447 -0
- codegraph/audit.py +106 -0
- codegraph/binding_resolver.py +616 -0
- codegraph/branch_comparison.py +197 -0
- codegraph/cache.py +95 -0
- codegraph/change_impact.py +547 -0
- codegraph/cli.py +2436 -0
- codegraph/cli_output.py +205 -0
- codegraph/config.py +45 -0
- codegraph/constraints.py +238 -0
- codegraph/context.py +2182 -0
- codegraph/context_freshness.py +351 -0
- codegraph/database/__init__.py +72 -0
- codegraph/database/extractor.py +3651 -0
- codegraph/database/interrogation.py +1494 -0
- codegraph/database/models.py +310 -0
- codegraph/database/schema_drift.py +862 -0
- codegraph/epistemic.py +200 -0
- codegraph/errors.py +282 -0
- codegraph/evidence/__init__.py +15 -0
- codegraph/evidence/citations.py +418 -0
- codegraph/evidence_contract.py +486 -0
- codegraph/frameworks.py +960 -0
- codegraph/frameworks_nestjs.py +502 -0
- codegraph/frameworks_nextjs.py +337 -0
- codegraph/frameworks_react.py +457 -0
- codegraph/freshness.py +335 -0
- codegraph/frontend_html.py +109 -0
- codegraph/git.py +362 -0
- codegraph/git_state.py +545 -0
- codegraph/graph/__init__.py +46 -0
- codegraph/graph/models.py +56 -0
- codegraph/graph/traversal.py +1993 -0
- codegraph/indexing/__init__.py +24 -0
- codegraph/indexing/classifier.py +564 -0
- codegraph/indexing/indexer.py +2578 -0
- codegraph/indexing/models.py +427 -0
- codegraph/indexing/parser.py +3404 -0
- codegraph/indexing/pool.py +136 -0
- codegraph/indexing/scanner.py +211 -0
- codegraph/indexing/telemetry.py +326 -0
- codegraph/indexing/templates.py +418 -0
- codegraph/indexing/test_framework.py +116 -0
- codegraph/installer.py +1702 -0
- codegraph/interrogation.py +2355 -0
- codegraph/llm/__init__.py +3 -0
- codegraph/llm/base.py +15 -0
- codegraph/llm/context.py +20 -0
- codegraph/mcp/__init__.py +3 -0
- codegraph/mcp/server.py +1476 -0
- codegraph/mcp_diagnostics.py +882 -0
- codegraph/memory/__init__.py +3 -0
- codegraph/memory/store.py +46 -0
- codegraph/models.py +310 -0
- codegraph/monorepo.py +925 -0
- codegraph/observability.py +262 -0
- codegraph/optimizer.py +1014 -0
- codegraph/planner.py +417 -0
- codegraph/process_lifecycle.py +1153 -0
- codegraph/py.typed +1 -0
- codegraph/query_expansion.py +199 -0
- codegraph/ranking.py +408 -0
- codegraph/refactor/__init__.py +36 -0
- codegraph/refactor/models.py +114 -0
- codegraph/refactor/renamer.py +844 -0
- codegraph/refactor/transactions.py +414 -0
- codegraph/resolver.py +3968 -0
- codegraph/resources/__init__.py +46 -0
- codegraph/resources/cache.py +117 -0
- codegraph/resources/coalescer.py +83 -0
- codegraph/resources/debouncer.py +98 -0
- codegraph/resources/governor.py +219 -0
- codegraph/resources/monitor.py +102 -0
- codegraph/resources/policy.py +123 -0
- codegraph/retrieval_policy.py +320 -0
- codegraph/roblox.py +2174 -0
- codegraph/route_composer.py +363 -0
- codegraph/route_topology.py +172 -0
- codegraph/runtime/__init__.py +51 -0
- codegraph/runtime/collector.py +155 -0
- codegraph/runtime/db_watcher.py +191 -0
- codegraph/runtime/ingestor.py +692 -0
- codegraph/runtime/models.py +130 -0
- codegraph/runtime/port_inspector.py +304 -0
- codegraph/runtime/reconciliation.py +492 -0
- codegraph/runtime/runner.py +353 -0
- codegraph/runtime/trace_linker.py +175 -0
- codegraph/search/__init__.py +25 -0
- codegraph/search/hybrid.py +846 -0
- codegraph/search/semantic.py +28 -0
- codegraph/security/__init__.py +51 -0
- codegraph/security/paths.py +189 -0
- codegraph/security/redaction.py +633 -0
- codegraph/semantic_conflicts.py +270 -0
- codegraph/semantic_decorators.py +372 -0
- codegraph/structural_diff.py +542 -0
- codegraph/styles.py +144 -0
- codegraph/symbol_history.py +277 -0
- codegraph/target_resolver.py +348 -0
- codegraph/task.py +637 -0
- codegraph/tool_selection_eval.py +1158 -0
- codegraph/tsconfig.py +236 -0
- codegraph/ui/__init__.py +6 -0
- codegraph/ui/server.py +755 -0
- codegraph/watcher.py +323 -0
- codegraph_engine-3.0.0.dist-info/METADATA +868 -0
- codegraph_engine-3.0.0.dist-info/RECORD +130 -0
- codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
- codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
- codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
- codegraph_engine-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
"""Production-Grade Semantic Change Impact & PR Blast Radius Engine.
|
|
2
|
+
|
|
3
|
+
Extends Git and AST impact analysis by tracing complete downstream chains:
|
|
4
|
+
changed symbol / git diff
|
|
5
|
+
↓
|
|
6
|
+
direct & transitive callers
|
|
7
|
+
↓
|
|
8
|
+
frontend UI components (React/Next.js/Vue)
|
|
9
|
+
↓
|
|
10
|
+
framework routes & public endpoints
|
|
11
|
+
↓
|
|
12
|
+
database readers & writers (mutations, migrations)
|
|
13
|
+
↓
|
|
14
|
+
covering tests & smart test execution recommendation
|
|
15
|
+
↓
|
|
16
|
+
monorepo package boundaries & PR risk score
|
|
17
|
+
|
|
18
|
+
Every item carries explicit evidence, relationship type, source location,
|
|
19
|
+
epistemic confidence (FACT, POSSIBLE, UNKNOWN), and breaking change risk.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import sqlite3
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from codegraph.graph.traversal import find_callees, find_callers, find_related_tests
|
|
29
|
+
from codegraph.monorepo import detect_workspace
|
|
30
|
+
from codegraph.structural_diff import compare_revisions
|
|
31
|
+
|
|
32
|
+
_FRONTEND_EXTENSIONS = (".tsx", ".jsx", ".vue", ".svelte")
|
|
33
|
+
_MUTATING_SQL_OPS = frozenset({"INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "MIGRATE", "TRUNCATE"})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ImpactedEntity:
|
|
38
|
+
name: str
|
|
39
|
+
kind: str # "CALLER" | "CALLEE" | "FRONTEND_COMPONENT" | "ROUTE" | "TEST" | "DB_WRITER" | "DB_READER" | "PACKAGE"
|
|
40
|
+
file: str
|
|
41
|
+
line: int | None
|
|
42
|
+
relationship: str # "CALLS" | "HANDLED_BY" | "TESTS" | "WRITES_TABLE" | "READS_TABLE" | "DEPENDS_ON" | "FETCHES_ROUTE"
|
|
43
|
+
depth: int
|
|
44
|
+
confidence: str # "FACT" | "POSSIBLE" | "UNKNOWN"
|
|
45
|
+
reason: str
|
|
46
|
+
evidence: str
|
|
47
|
+
|
|
48
|
+
def as_dict(self) -> dict[str, Any]:
|
|
49
|
+
return {
|
|
50
|
+
"name": self.name,
|
|
51
|
+
"kind": self.kind,
|
|
52
|
+
"file": self.file,
|
|
53
|
+
"line": self.line,
|
|
54
|
+
"relationship": self.relationship,
|
|
55
|
+
"depth": self.depth,
|
|
56
|
+
"confidence": self.confidence,
|
|
57
|
+
"reason": self.reason,
|
|
58
|
+
"evidence": self.evidence,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class DeepImpactReport:
|
|
64
|
+
base_ref: str
|
|
65
|
+
head_ref: str
|
|
66
|
+
target_symbol: str | None = None
|
|
67
|
+
changed_files: list[str] = field(default_factory=list)
|
|
68
|
+
changed_symbols: list[dict[str, Any]] = field(default_factory=list)
|
|
69
|
+
direct_callers: list[ImpactedEntity] = field(default_factory=list)
|
|
70
|
+
transitive_callers: list[ImpactedEntity] = field(default_factory=list)
|
|
71
|
+
affected_callees: list[ImpactedEntity] = field(default_factory=list)
|
|
72
|
+
affected_frontend_components: list[ImpactedEntity] = field(default_factory=list)
|
|
73
|
+
affected_routes: list[ImpactedEntity] = field(default_factory=list)
|
|
74
|
+
affected_tests: list[ImpactedEntity] = field(default_factory=list)
|
|
75
|
+
db_readers: list[ImpactedEntity] = field(default_factory=list)
|
|
76
|
+
db_writers: list[ImpactedEntity] = field(default_factory=list)
|
|
77
|
+
affected_packages: list[str] = field(default_factory=list)
|
|
78
|
+
total_impacted_count: int = 0
|
|
79
|
+
blast_radius_score: float = 0.0 # 0.0 - 100.0 scale
|
|
80
|
+
risk_level: str = "LOW" # "LOW" | "MEDIUM" | "HIGH" | "CRITICAL"
|
|
81
|
+
breaking_change_flags: list[str] = field(default_factory=list)
|
|
82
|
+
recommended_test_command: str = ""
|
|
83
|
+
|
|
84
|
+
def as_dict(self) -> dict[str, Any]:
|
|
85
|
+
return {
|
|
86
|
+
"base_ref": self.base_ref,
|
|
87
|
+
"head_ref": self.head_ref,
|
|
88
|
+
"target_symbol": self.target_symbol,
|
|
89
|
+
"changed_files": self.changed_files,
|
|
90
|
+
"changed_symbols": self.changed_symbols,
|
|
91
|
+
"direct_callers": [c.as_dict() for c in self.direct_callers],
|
|
92
|
+
"transitive_callers": [c.as_dict() for c in self.transitive_callers],
|
|
93
|
+
"affected_callees": [c.as_dict() for c in self.affected_callees],
|
|
94
|
+
"affected_frontend_components": [f.as_dict() for f in self.affected_frontend_components],
|
|
95
|
+
"affected_routes": [r.as_dict() for r in self.affected_routes],
|
|
96
|
+
"affected_tests": [t.as_dict() for t in self.affected_tests],
|
|
97
|
+
"db_readers": [d.as_dict() for d in self.db_readers],
|
|
98
|
+
"db_writers": [d.as_dict() for d in self.db_writers],
|
|
99
|
+
"affected_packages": self.affected_packages,
|
|
100
|
+
"total_impacted_count": self.total_impacted_count,
|
|
101
|
+
"blast_radius_score": self.blast_radius_score,
|
|
102
|
+
"risk_level": self.risk_level,
|
|
103
|
+
"breaking_change_flags": self.breaking_change_flags,
|
|
104
|
+
"recommended_test_command": self.recommended_test_command,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _is_frontend_file(path: str) -> bool:
|
|
109
|
+
return any(path.endswith(ext) for ext in _FRONTEND_EXTENSIONS)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _generate_test_command(affected_tests: list[ImpactedEntity]) -> str:
|
|
113
|
+
"""Generate smart targeted test execution command for only the affected test suite."""
|
|
114
|
+
if not affected_tests:
|
|
115
|
+
return ""
|
|
116
|
+
|
|
117
|
+
test_files = sorted({t.file for t in affected_tests if t.file})
|
|
118
|
+
py_files = [f for f in test_files if f.endswith(".py")]
|
|
119
|
+
js_files = [f for f in test_files if f.endswith((".ts", ".tsx", ".js", ".jsx"))]
|
|
120
|
+
|
|
121
|
+
commands: list[str] = []
|
|
122
|
+
|
|
123
|
+
if py_files:
|
|
124
|
+
test_fn_names = sorted({
|
|
125
|
+
t.name for t in affected_tests
|
|
126
|
+
if t.name and t.file.endswith(".py") and t.name.startswith("test_")
|
|
127
|
+
})
|
|
128
|
+
if test_fn_names and len(test_fn_names) <= 6:
|
|
129
|
+
k_expr = " or ".join(test_fn_names)
|
|
130
|
+
commands.append(f"pytest {' '.join(py_files[:4])} -k \"{k_expr}\"")
|
|
131
|
+
else:
|
|
132
|
+
commands.append(f"pytest {' '.join(py_files[:5])}")
|
|
133
|
+
|
|
134
|
+
if js_files:
|
|
135
|
+
commands.append(f"npm test -- {' '.join(js_files[:4])}")
|
|
136
|
+
|
|
137
|
+
return " && ".join(commands)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_deep_change_impact(
|
|
141
|
+
repository: Path,
|
|
142
|
+
con: sqlite3.Connection,
|
|
143
|
+
base: str = "HEAD~1",
|
|
144
|
+
head: str = "HEAD",
|
|
145
|
+
max_depth: int = 2,
|
|
146
|
+
max_results: int = 50,
|
|
147
|
+
symbol: str | None = None,
|
|
148
|
+
) -> DeepImpactReport:
|
|
149
|
+
"""Analyze full blast radius of git revision changes or a specific symbol across AST, UI, routes, tests, DB, and packages."""
|
|
150
|
+
changed_symbols_list: list[dict[str, Any]] = []
|
|
151
|
+
changed_files: list[str] = []
|
|
152
|
+
|
|
153
|
+
# 1. Target symbol mode: analyze blast radius of a specific symbol on demand
|
|
154
|
+
if symbol:
|
|
155
|
+
clean_sym = symbol.strip()
|
|
156
|
+
sym_rows = con.execute(
|
|
157
|
+
"SELECT name, path, canonical_id, start_line, kind FROM symbols "
|
|
158
|
+
"WHERE canonical_id = ? OR qualified_name = ? OR name = ? LIMIT 10",
|
|
159
|
+
(clean_sym, clean_sym, clean_sym),
|
|
160
|
+
).fetchall()
|
|
161
|
+
if sym_rows:
|
|
162
|
+
for sr in sym_rows:
|
|
163
|
+
changed_symbols_list.append({
|
|
164
|
+
"name": str(sr["name"]),
|
|
165
|
+
"path": str(sr["path"]),
|
|
166
|
+
"canonical_id": str(sr["canonical_id"]),
|
|
167
|
+
"line": int(sr["start_line"] or 1),
|
|
168
|
+
"kind": str(sr["kind"]),
|
|
169
|
+
"change_type": "TARGET",
|
|
170
|
+
})
|
|
171
|
+
changed_files.append(str(sr["path"]))
|
|
172
|
+
else:
|
|
173
|
+
changed_symbols_list.append({
|
|
174
|
+
"name": clean_sym,
|
|
175
|
+
"path": "",
|
|
176
|
+
"canonical_id": clean_sym,
|
|
177
|
+
"change_type": "TARGET",
|
|
178
|
+
})
|
|
179
|
+
else:
|
|
180
|
+
# Fast path: identical refs have zero changes
|
|
181
|
+
if base == head and head != "WORKING_TREE":
|
|
182
|
+
return DeepImpactReport(base_ref=base, head_ref=head)
|
|
183
|
+
|
|
184
|
+
diff_report = compare_revisions(repository, base=base, head=head, con=con)
|
|
185
|
+
changed_symbols_list = [
|
|
186
|
+
s.as_dict() for s in (diff_report.changed_symbols + diff_report.added_symbols + diff_report.removed_symbols)
|
|
187
|
+
]
|
|
188
|
+
changed_files = diff_report.modified_files + diff_report.added_files + diff_report.deleted_files
|
|
189
|
+
|
|
190
|
+
if not changed_symbols_list and not changed_files:
|
|
191
|
+
return DeepImpactReport(base_ref=base, head_ref=head)
|
|
192
|
+
|
|
193
|
+
direct_callers: list[ImpactedEntity] = []
|
|
194
|
+
transitive_callers: list[ImpactedEntity] = []
|
|
195
|
+
affected_callees: list[ImpactedEntity] = []
|
|
196
|
+
affected_frontend_components: list[ImpactedEntity] = []
|
|
197
|
+
affected_routes: list[ImpactedEntity] = []
|
|
198
|
+
affected_tests: list[ImpactedEntity] = []
|
|
199
|
+
db_readers: list[ImpactedEntity] = []
|
|
200
|
+
db_writers: list[ImpactedEntity] = []
|
|
201
|
+
|
|
202
|
+
seen_keys: set[str] = set()
|
|
203
|
+
processed_symbols: set[str] = set()
|
|
204
|
+
processed_transitive: set[str] = set()
|
|
205
|
+
|
|
206
|
+
for s_info in changed_symbols_list[:max_results]:
|
|
207
|
+
s_name = s_info["name"]
|
|
208
|
+
s_path = s_info.get("path") or ""
|
|
209
|
+
s_cid = s_info.get("canonical_id") or s_name
|
|
210
|
+
|
|
211
|
+
if not s_name or s_name in processed_symbols:
|
|
212
|
+
continue
|
|
213
|
+
processed_symbols.add(s_name)
|
|
214
|
+
|
|
215
|
+
# 1. Direct callers (1-hop)
|
|
216
|
+
raw_callers = find_callers(con, s_name, max_results=max_results)
|
|
217
|
+
# Also check canonical_id callers if distinct
|
|
218
|
+
if s_cid != s_name:
|
|
219
|
+
raw_callers.extend(find_callers(con, s_cid, max_results=max_results))
|
|
220
|
+
|
|
221
|
+
for c in raw_callers:
|
|
222
|
+
c_name = c.get("caller") or c.get("symbol") or ""
|
|
223
|
+
c_file = c.get("file") or ""
|
|
224
|
+
c_line = c.get("line")
|
|
225
|
+
|
|
226
|
+
# Check if this caller is a frontend UI component (Pillar 1 bridge)
|
|
227
|
+
is_frontend = _is_frontend_file(c_file)
|
|
228
|
+
c_kind = "FRONTEND_COMPONENT" if is_frontend else "CALLER"
|
|
229
|
+
c_key = f"{c_kind}:1:{c_file}:{c_name}"
|
|
230
|
+
|
|
231
|
+
if c_key not in seen_keys:
|
|
232
|
+
seen_keys.add(c_key)
|
|
233
|
+
caller_entity = ImpactedEntity(
|
|
234
|
+
name=c_name,
|
|
235
|
+
kind=c_kind,
|
|
236
|
+
file=c_file,
|
|
237
|
+
line=c_line,
|
|
238
|
+
relationship="CALLS",
|
|
239
|
+
depth=1,
|
|
240
|
+
confidence="FACT",
|
|
241
|
+
reason=f"{'Frontend component' if is_frontend else 'Caller'} invokes modified symbol {s_name}",
|
|
242
|
+
evidence=f"{c_file}:{c_line} -> {s_name}",
|
|
243
|
+
)
|
|
244
|
+
if is_frontend:
|
|
245
|
+
affected_frontend_components.append(caller_entity)
|
|
246
|
+
else:
|
|
247
|
+
direct_callers.append(caller_entity)
|
|
248
|
+
|
|
249
|
+
# 2. Transitive callers (2-hop) if max_depth >= 2
|
|
250
|
+
if max_depth >= 2 and c_name and c_name not in processed_transitive:
|
|
251
|
+
processed_transitive.add(c_name)
|
|
252
|
+
raw_trans = find_callers(con, c_name, max_results=10)
|
|
253
|
+
for tc in raw_trans:
|
|
254
|
+
tc_name = tc.get("caller") or tc.get("symbol") or ""
|
|
255
|
+
tc_file = tc.get("file") or ""
|
|
256
|
+
tc_line = tc.get("line")
|
|
257
|
+
tc_fe = _is_frontend_file(tc_file)
|
|
258
|
+
tc_kind = "FRONTEND_COMPONENT" if tc_fe else "CALLER"
|
|
259
|
+
tc_key = f"{tc_kind}:2:{tc_file}:{tc_name}"
|
|
260
|
+
|
|
261
|
+
if tc_key not in seen_keys and tc_name != s_name:
|
|
262
|
+
seen_keys.add(tc_key)
|
|
263
|
+
trans_entity = ImpactedEntity(
|
|
264
|
+
name=tc_name,
|
|
265
|
+
kind=tc_kind,
|
|
266
|
+
file=tc_file,
|
|
267
|
+
line=tc_line,
|
|
268
|
+
relationship="CALLS",
|
|
269
|
+
depth=2,
|
|
270
|
+
confidence="FACT",
|
|
271
|
+
reason=f"Transitively invokes {s_name} via {c_name}",
|
|
272
|
+
evidence=f"{tc_file}:{tc_line} -> {c_name} -> {s_name}",
|
|
273
|
+
)
|
|
274
|
+
if tc_fe:
|
|
275
|
+
affected_frontend_components.append(trans_entity)
|
|
276
|
+
else:
|
|
277
|
+
transitive_callers.append(trans_entity)
|
|
278
|
+
|
|
279
|
+
# 3. Direct callees
|
|
280
|
+
raw_callees = find_callees(con, s_name, max_results=max_results)
|
|
281
|
+
for ce in raw_callees:
|
|
282
|
+
ce_name = ce.get("callee") or ce.get("symbol") or ""
|
|
283
|
+
ce_file = ce.get("file") or ""
|
|
284
|
+
ce_line = ce.get("line")
|
|
285
|
+
ce_key = f"CALLEE:{ce_file}:{ce_name}"
|
|
286
|
+
if ce_key not in seen_keys:
|
|
287
|
+
seen_keys.add(ce_key)
|
|
288
|
+
affected_callees.append(
|
|
289
|
+
ImpactedEntity(
|
|
290
|
+
name=ce_name,
|
|
291
|
+
kind="CALLEE",
|
|
292
|
+
file=ce_file,
|
|
293
|
+
line=ce_line,
|
|
294
|
+
relationship="CALLED_BY",
|
|
295
|
+
depth=1,
|
|
296
|
+
confidence="FACT",
|
|
297
|
+
reason=f"Called by modified symbol {s_name}",
|
|
298
|
+
evidence=f"{s_path} -> {ce_name}",
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# 4. Route impact across framework_routes, raw_framework_routes, and routes
|
|
303
|
+
candidate_handlers = [(s_name, s_cid, 1, f"changed symbol {s_name}")]
|
|
304
|
+
for c in raw_callers:
|
|
305
|
+
c_cand_name = c.get("caller") or c.get("symbol") or ""
|
|
306
|
+
if c_cand_name:
|
|
307
|
+
candidate_handlers.append((c_cand_name, c_cand_name, 2, f"caller {c_cand_name} of {s_name}"))
|
|
308
|
+
|
|
309
|
+
for rt_table in ("framework_routes", "raw_framework_routes", "routes"):
|
|
310
|
+
try:
|
|
311
|
+
table_check = con.execute(
|
|
312
|
+
f"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='{rt_table}'"
|
|
313
|
+
).fetchone()
|
|
314
|
+
if not table_check or table_check[0] == 0:
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
for h_name, h_cid, h_depth, h_reason in candidate_handlers:
|
|
318
|
+
if rt_table == "routes":
|
|
319
|
+
route_rows = con.execute(
|
|
320
|
+
"SELECT path, method, handler_name, file, NULL FROM routes "
|
|
321
|
+
"WHERE handler_name = ? OR symbol = ?",
|
|
322
|
+
(h_name, h_name),
|
|
323
|
+
).fetchall()
|
|
324
|
+
else:
|
|
325
|
+
route_rows = con.execute(
|
|
326
|
+
f"SELECT route_path, http_method, handler_name, file_path, line FROM {rt_table} "
|
|
327
|
+
"WHERE handler_name = ? OR handler_canonical_id = ? OR handler_canonical_id LIKE ?",
|
|
328
|
+
(h_name, h_cid, f"%{h_name}"),
|
|
329
|
+
).fetchall()
|
|
330
|
+
|
|
331
|
+
for rr in route_rows:
|
|
332
|
+
r_path = str(rr[0] or "")
|
|
333
|
+
r_method = str(rr[1] or "GET").upper()
|
|
334
|
+
r_file = str(rr[3] or "")
|
|
335
|
+
r_line = int(rr[4]) if rr[4] is not None else None
|
|
336
|
+
r_key = f"ROUTE:{r_path}:{r_method}"
|
|
337
|
+
if r_key not in seen_keys:
|
|
338
|
+
seen_keys.add(r_key)
|
|
339
|
+
affected_routes.append(
|
|
340
|
+
ImpactedEntity(
|
|
341
|
+
name=f"{r_method} {r_path}",
|
|
342
|
+
kind="ROUTE",
|
|
343
|
+
file=r_file,
|
|
344
|
+
line=r_line,
|
|
345
|
+
relationship="ROUTES_TO",
|
|
346
|
+
depth=h_depth,
|
|
347
|
+
confidence="FACT",
|
|
348
|
+
reason=f"Endpoint reached via {h_reason}",
|
|
349
|
+
evidence=f"Route {r_method} {r_path} -> {h_name} ({r_file})",
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
# Check if any frontend component fetches this route
|
|
354
|
+
try:
|
|
355
|
+
fetch_rows = con.execute(
|
|
356
|
+
"SELECT target_name, file_path, line, scope FROM local_bindings "
|
|
357
|
+
"WHERE expr_kind = 'REACT_FETCH_ROUTE' AND (target_name = ? OR target_name LIKE ?)",
|
|
358
|
+
(r_path, f"%{r_path.rstrip('/')}%"),
|
|
359
|
+
).fetchall()
|
|
360
|
+
for fr in fetch_rows:
|
|
361
|
+
fe_name = str(fr[3] or Path(str(fr[1])).stem)
|
|
362
|
+
fe_file = str(fr[1])
|
|
363
|
+
fe_line = int(fr[2] or 1)
|
|
364
|
+
fe_key = f"FRONTEND_COMPONENT:{fe_file}:{fe_name}"
|
|
365
|
+
if fe_key not in seen_keys:
|
|
366
|
+
seen_keys.add(fe_key)
|
|
367
|
+
affected_frontend_components.append(
|
|
368
|
+
ImpactedEntity(
|
|
369
|
+
name=fe_name,
|
|
370
|
+
kind="FRONTEND_COMPONENT",
|
|
371
|
+
file=fe_file,
|
|
372
|
+
line=fe_line,
|
|
373
|
+
relationship="FETCHES_ROUTE",
|
|
374
|
+
depth=h_depth + 1,
|
|
375
|
+
confidence="FACT",
|
|
376
|
+
reason=f"Frontend component fetches affected route {r_method} {r_path}",
|
|
377
|
+
evidence=f"{fe_file}:{fe_line} -> {r_path}",
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
except sqlite3.OperationalError:
|
|
381
|
+
pass
|
|
382
|
+
break
|
|
383
|
+
except (sqlite3.OperationalError, sqlite3.DatabaseError):
|
|
384
|
+
pass
|
|
385
|
+
|
|
386
|
+
# 5. Tests targeting this symbol
|
|
387
|
+
tests = find_related_tests(con, s_name, max_results=30)
|
|
388
|
+
if s_cid != s_name:
|
|
389
|
+
tests.extend(find_related_tests(con, s_cid, max_results=30))
|
|
390
|
+
|
|
391
|
+
for t in tests:
|
|
392
|
+
if t.get("result") == "no_static_link_found":
|
|
393
|
+
continue
|
|
394
|
+
t_name = str(t.get("symbol") or t.get("test") or "").strip()
|
|
395
|
+
t_file = str(t.get("file") or "").strip()
|
|
396
|
+
if not t_name and not t_file:
|
|
397
|
+
continue
|
|
398
|
+
|
|
399
|
+
t_line_raw = t.get("line")
|
|
400
|
+
t_line = int(t_line_raw) if isinstance(t_line_raw, (int, str)) and str(t_line_raw).isdigit() else None
|
|
401
|
+
t_key = f"TEST:{t_file}:{t_name}"
|
|
402
|
+
if t_key not in seen_keys:
|
|
403
|
+
seen_keys.add(t_key)
|
|
404
|
+
affected_tests.append(
|
|
405
|
+
ImpactedEntity(
|
|
406
|
+
name=t_name,
|
|
407
|
+
kind="TEST",
|
|
408
|
+
file=t_file,
|
|
409
|
+
line=t_line,
|
|
410
|
+
relationship="TESTS",
|
|
411
|
+
depth=1,
|
|
412
|
+
confidence="FACT",
|
|
413
|
+
reason=f"Test targets modified symbol {s_name}",
|
|
414
|
+
evidence=f"{t_file}:{t_line} tests {s_name}",
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
# 6. Database readers and writers
|
|
419
|
+
# Query db_queries table (and database_queries for backward compatibility)
|
|
420
|
+
for db_table in ("db_queries", "database_queries"):
|
|
421
|
+
try:
|
|
422
|
+
table_check = con.execute(
|
|
423
|
+
f"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='{db_table}'"
|
|
424
|
+
).fetchone()
|
|
425
|
+
if not table_check or table_check[0] == 0:
|
|
426
|
+
continue
|
|
427
|
+
|
|
428
|
+
if db_table == "db_queries":
|
|
429
|
+
db_rows = con.execute(
|
|
430
|
+
"SELECT table_name, operation, file_path, start_line FROM db_queries "
|
|
431
|
+
"WHERE caller_symbol_id = ? OR caller_symbol_id LIKE ?",
|
|
432
|
+
(s_cid, f"%{s_name}"),
|
|
433
|
+
).fetchall()
|
|
434
|
+
else:
|
|
435
|
+
db_rows = con.execute(
|
|
436
|
+
"SELECT table_name, operation, file, line FROM database_queries "
|
|
437
|
+
"WHERE symbol = ? OR caller_symbol = ?",
|
|
438
|
+
(s_name, s_name),
|
|
439
|
+
).fetchall()
|
|
440
|
+
|
|
441
|
+
for dr in db_rows:
|
|
442
|
+
tbl = str(dr[0] or "")
|
|
443
|
+
op = str(dr[1] or "SELECT").upper()
|
|
444
|
+
f_path = str(dr[2] or s_path)
|
|
445
|
+
l_num = int(dr[3]) if dr[3] is not None else None
|
|
446
|
+
is_mutating = op in _MUTATING_SQL_OPS
|
|
447
|
+
kind = "DB_WRITER" if is_mutating else "DB_READER"
|
|
448
|
+
rel = "WRITES_TABLE" if is_mutating else "READS_TABLE"
|
|
449
|
+
db_key = f"{kind}:{tbl}:{op}:{s_name}"
|
|
450
|
+
if db_key not in seen_keys:
|
|
451
|
+
seen_keys.add(db_key)
|
|
452
|
+
entity = ImpactedEntity(
|
|
453
|
+
name=f"{op} {tbl}",
|
|
454
|
+
kind=kind,
|
|
455
|
+
file=f_path,
|
|
456
|
+
line=l_num,
|
|
457
|
+
relationship=rel,
|
|
458
|
+
depth=1,
|
|
459
|
+
confidence="FACT",
|
|
460
|
+
reason=f"{s_name} executes {op} on {tbl}",
|
|
461
|
+
evidence=f"{s_name} -> {op} {tbl} ({f_path}:{l_num})",
|
|
462
|
+
)
|
|
463
|
+
if is_mutating:
|
|
464
|
+
db_writers.append(entity)
|
|
465
|
+
else:
|
|
466
|
+
db_readers.append(entity)
|
|
467
|
+
break
|
|
468
|
+
except (sqlite3.OperationalError, sqlite3.DatabaseError):
|
|
469
|
+
pass
|
|
470
|
+
|
|
471
|
+
# 7. Monorepo package impact
|
|
472
|
+
ws = detect_workspace(repository, con=con)
|
|
473
|
+
affected_packages: set[str] = set()
|
|
474
|
+
for p in changed_files:
|
|
475
|
+
pkg = ws.get_package_for_file(p)
|
|
476
|
+
if pkg:
|
|
477
|
+
affected_packages.add(pkg.package_id)
|
|
478
|
+
for dep in ws.get_dependent_packages(pkg.package_id):
|
|
479
|
+
affected_packages.add(dep.package_id)
|
|
480
|
+
|
|
481
|
+
total_count = (
|
|
482
|
+
len(direct_callers)
|
|
483
|
+
+ len(transitive_callers)
|
|
484
|
+
+ len(affected_callees)
|
|
485
|
+
+ len(affected_frontend_components)
|
|
486
|
+
+ len(affected_routes)
|
|
487
|
+
+ len(affected_tests)
|
|
488
|
+
+ len(db_readers)
|
|
489
|
+
+ len(db_writers)
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
# 8. Blast radius score calculation: 0 - 100 scale
|
|
493
|
+
score = min(
|
|
494
|
+
100.0,
|
|
495
|
+
(len(direct_callers) * 3.5)
|
|
496
|
+
+ (len(transitive_callers) * 1.5)
|
|
497
|
+
+ (len(affected_frontend_components) * 5.0)
|
|
498
|
+
+ (len(affected_routes) * 8.0)
|
|
499
|
+
+ (len(db_writers) * 6.0)
|
|
500
|
+
+ (len(affected_packages) * 5.0),
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
# Risk level classification
|
|
504
|
+
if score >= 75.0:
|
|
505
|
+
risk_level = "CRITICAL"
|
|
506
|
+
elif score >= 50.0:
|
|
507
|
+
risk_level = "HIGH"
|
|
508
|
+
elif score >= 25.0:
|
|
509
|
+
risk_level = "MEDIUM"
|
|
510
|
+
else:
|
|
511
|
+
risk_level = "LOW"
|
|
512
|
+
|
|
513
|
+
# Breaking change flags
|
|
514
|
+
breaking_change_flags: list[str] = []
|
|
515
|
+
if affected_routes:
|
|
516
|
+
breaking_change_flags.append("PUBLIC_API_MODIFIED")
|
|
517
|
+
if db_writers:
|
|
518
|
+
breaking_change_flags.append("DB_MUTATION_IMPACT")
|
|
519
|
+
if len(affected_packages) > 1:
|
|
520
|
+
breaking_change_flags.append("CROSS_PACKAGE_IMPACT")
|
|
521
|
+
if changed_symbols_list and not affected_tests:
|
|
522
|
+
breaking_change_flags.append("UNCOVERED_BY_TESTS")
|
|
523
|
+
|
|
524
|
+
# Smart test command recommendation
|
|
525
|
+
rec_test_cmd = _generate_test_command(affected_tests)
|
|
526
|
+
|
|
527
|
+
return DeepImpactReport(
|
|
528
|
+
base_ref=base,
|
|
529
|
+
head_ref=head,
|
|
530
|
+
target_symbol=symbol,
|
|
531
|
+
changed_files=changed_files,
|
|
532
|
+
changed_symbols=changed_symbols_list,
|
|
533
|
+
direct_callers=direct_callers[:max_results],
|
|
534
|
+
transitive_callers=transitive_callers[:max_results],
|
|
535
|
+
affected_callees=affected_callees[:max_results],
|
|
536
|
+
affected_frontend_components=affected_frontend_components[:max_results],
|
|
537
|
+
affected_routes=affected_routes,
|
|
538
|
+
affected_tests=affected_tests[:max_results],
|
|
539
|
+
db_readers=db_readers,
|
|
540
|
+
db_writers=db_writers,
|
|
541
|
+
affected_packages=sorted(affected_packages),
|
|
542
|
+
total_impacted_count=total_count,
|
|
543
|
+
blast_radius_score=round(score, 1),
|
|
544
|
+
risk_level=risk_level,
|
|
545
|
+
breaking_change_flags=breaking_change_flags,
|
|
546
|
+
recommended_test_command=rec_test_cmd,
|
|
547
|
+
)
|