codegraph-engine 2.3.0__py3-none-any.whl → 3.0.0__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 +1 -1
- codegraph/agent_brain.py +172 -2
- codegraph/agent_capabilities.py +320 -3
- codegraph/agent_rules.py +1 -1
- codegraph/api_drift.py +455 -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/change_impact.py +320 -110
- codegraph/cli.py +776 -25
- codegraph/cli_output.py +17 -2
- codegraph/context.py +38 -11
- codegraph/context_freshness.py +2 -0
- codegraph/database/__init__.py +14 -0
- codegraph/database/extractor.py +712 -17
- codegraph/database/interrogation.py +388 -2
- codegraph/database/schema_drift.py +862 -0
- codegraph/epistemic.py +7 -0
- codegraph/evidence_contract.py +21 -0
- codegraph/frameworks.py +171 -6
- codegraph/frameworks_nestjs.py +502 -0
- codegraph/frameworks_nextjs.py +337 -0
- codegraph/frameworks_react.py +457 -0
- codegraph/freshness.py +2 -0
- codegraph/frontend_html.py +109 -0
- codegraph/git.py +2 -0
- codegraph/git_state.py +2 -0
- codegraph/indexing/__init__.py +7 -1
- codegraph/indexing/indexer.py +291 -38
- codegraph/indexing/models.py +6 -0
- codegraph/indexing/parser.py +239 -0
- codegraph/indexing/pool.py +136 -0
- codegraph/indexing/scanner.py +6 -0
- codegraph/indexing/templates.py +418 -0
- codegraph/interrogation.py +128 -20
- codegraph/mcp/server.py +190 -28
- codegraph/models.py +7 -0
- codegraph/monorepo.py +3 -0
- codegraph/optimizer.py +42 -5
- codegraph/process_lifecycle.py +31 -13
- codegraph/refactor/__init__.py +36 -0
- codegraph/refactor/models.py +114 -0
- codegraph/refactor/renamer.py +844 -0
- codegraph/refactor/transactions.py +403 -0
- codegraph/resolver.py +1202 -159
- codegraph/retrieval_policy.py +6 -0
- codegraph/route_topology.py +172 -0
- codegraph/runtime/__init__.py +19 -0
- codegraph/runtime/collector.py +155 -0
- codegraph/runtime/db_watcher.py +191 -0
- codegraph/runtime/ingestor.py +30 -10
- codegraph/runtime/port_inspector.py +304 -0
- codegraph/runtime/runner.py +155 -6
- codegraph/runtime/trace_linker.py +175 -0
- codegraph/search/hybrid.py +26 -0
- codegraph/semantic_decorators.py +34 -3
- codegraph/structural_diff.py +2 -0
- codegraph/styles.py +144 -0
- codegraph/symbol_history.py +2 -0
- codegraph/tsconfig.py +236 -0
- codegraph/ui/__init__.py +6 -0
- codegraph/ui/server.py +715 -0
- codegraph/watcher.py +323 -0
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/METADATA +150 -38
- codegraph_engine-3.0.0.dist-info/RECORD +129 -0
- codegraph_engine-2.3.0.dist-info/RECORD +0 -94
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/WHEEL +0 -0
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/entry_points.txt +0 -0
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/licenses/LICENSE +0 -0
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/top_level.txt +0 -0
codegraph/change_impact.py
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Production-Grade Semantic Change Impact & PR Blast Radius Engine.
|
|
2
2
|
|
|
3
|
-
Extends
|
|
4
|
-
changed symbol
|
|
3
|
+
Extends Git and AST impact analysis by tracing complete downstream chains:
|
|
4
|
+
changed symbol / git diff
|
|
5
5
|
↓
|
|
6
|
-
|
|
6
|
+
direct & transitive callers
|
|
7
7
|
↓
|
|
8
|
-
|
|
8
|
+
frontend UI components (React/Next.js/Vue)
|
|
9
9
|
↓
|
|
10
|
-
routes & endpoints
|
|
10
|
+
framework routes & public endpoints
|
|
11
11
|
↓
|
|
12
|
-
|
|
12
|
+
database readers & writers (mutations, migrations)
|
|
13
13
|
↓
|
|
14
|
-
|
|
14
|
+
covering tests & smart test execution recommendation
|
|
15
15
|
↓
|
|
16
|
-
|
|
16
|
+
monorepo package boundaries & PR risk score
|
|
17
17
|
|
|
18
18
|
Every item carries explicit evidence, relationship type, source location,
|
|
19
|
-
|
|
19
|
+
epistemic confidence (FACT, POSSIBLE, UNKNOWN), and breaking change risk.
|
|
20
20
|
"""
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
@@ -29,14 +29,17 @@ from codegraph.graph.traversal import find_callees, find_callers, find_related_t
|
|
|
29
29
|
from codegraph.monorepo import detect_workspace
|
|
30
30
|
from codegraph.structural_diff import compare_revisions
|
|
31
31
|
|
|
32
|
+
_FRONTEND_EXTENSIONS = (".tsx", ".jsx", ".vue", ".svelte")
|
|
33
|
+
_MUTATING_SQL_OPS = frozenset({"INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "MIGRATE", "TRUNCATE"})
|
|
34
|
+
|
|
32
35
|
|
|
33
36
|
@dataclass
|
|
34
37
|
class ImpactedEntity:
|
|
35
38
|
name: str
|
|
36
|
-
kind: str # "CALLER" | "CALLEE" | "ROUTE" | "TEST" | "DB_WRITER" | "DB_READER" | "PACKAGE"
|
|
39
|
+
kind: str # "CALLER" | "CALLEE" | "FRONTEND_COMPONENT" | "ROUTE" | "TEST" | "DB_WRITER" | "DB_READER" | "PACKAGE"
|
|
37
40
|
file: str
|
|
38
41
|
line: int | None
|
|
39
|
-
relationship: str # "CALLS" | "HANDLED_BY" | "TESTS" | "WRITES_TABLE" | "READS_TABLE" | "DEPENDS_ON"
|
|
42
|
+
relationship: str # "CALLS" | "HANDLED_BY" | "TESTS" | "WRITES_TABLE" | "READS_TABLE" | "DEPENDS_ON" | "FETCHES_ROUTE"
|
|
40
43
|
depth: int
|
|
41
44
|
confidence: str # "FACT" | "POSSIBLE" | "UNKNOWN"
|
|
42
45
|
reason: str
|
|
@@ -60,11 +63,13 @@ class ImpactedEntity:
|
|
|
60
63
|
class DeepImpactReport:
|
|
61
64
|
base_ref: str
|
|
62
65
|
head_ref: str
|
|
66
|
+
target_symbol: str | None = None
|
|
63
67
|
changed_files: list[str] = field(default_factory=list)
|
|
64
68
|
changed_symbols: list[dict[str, Any]] = field(default_factory=list)
|
|
65
69
|
direct_callers: list[ImpactedEntity] = field(default_factory=list)
|
|
66
70
|
transitive_callers: list[ImpactedEntity] = field(default_factory=list)
|
|
67
71
|
affected_callees: list[ImpactedEntity] = field(default_factory=list)
|
|
72
|
+
affected_frontend_components: list[ImpactedEntity] = field(default_factory=list)
|
|
68
73
|
affected_routes: list[ImpactedEntity] = field(default_factory=list)
|
|
69
74
|
affected_tests: list[ImpactedEntity] = field(default_factory=list)
|
|
70
75
|
db_readers: list[ImpactedEntity] = field(default_factory=list)
|
|
@@ -72,16 +77,21 @@ class DeepImpactReport:
|
|
|
72
77
|
affected_packages: list[str] = field(default_factory=list)
|
|
73
78
|
total_impacted_count: int = 0
|
|
74
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 = ""
|
|
75
83
|
|
|
76
84
|
def as_dict(self) -> dict[str, Any]:
|
|
77
85
|
return {
|
|
78
86
|
"base_ref": self.base_ref,
|
|
79
87
|
"head_ref": self.head_ref,
|
|
88
|
+
"target_symbol": self.target_symbol,
|
|
80
89
|
"changed_files": self.changed_files,
|
|
81
90
|
"changed_symbols": self.changed_symbols,
|
|
82
91
|
"direct_callers": [c.as_dict() for c in self.direct_callers],
|
|
83
92
|
"transitive_callers": [c.as_dict() for c in self.transitive_callers],
|
|
84
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],
|
|
85
95
|
"affected_routes": [r.as_dict() for r in self.affected_routes],
|
|
86
96
|
"affected_tests": [t.as_dict() for t in self.affected_tests],
|
|
87
97
|
"db_readers": [d.as_dict() for d in self.db_readers],
|
|
@@ -89,9 +99,44 @@ class DeepImpactReport:
|
|
|
89
99
|
"affected_packages": self.affected_packages,
|
|
90
100
|
"total_impacted_count": self.total_impacted_count,
|
|
91
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,
|
|
92
105
|
}
|
|
93
106
|
|
|
94
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
|
+
|
|
95
140
|
def get_deep_change_impact(
|
|
96
141
|
repository: Path,
|
|
97
142
|
con: sqlite3.Connection,
|
|
@@ -99,25 +144,56 @@ def get_deep_change_impact(
|
|
|
99
144
|
head: str = "HEAD",
|
|
100
145
|
max_depth: int = 2,
|
|
101
146
|
max_results: int = 50,
|
|
147
|
+
symbol: str | None = None,
|
|
102
148
|
) -> DeepImpactReport:
|
|
103
|
-
"""Analyze full blast radius of git revision changes across AST, routes, tests, DB, and packages."""
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return DeepImpactReport(base_ref=base, head_ref=head)
|
|
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] = []
|
|
107
152
|
|
|
108
|
-
|
|
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)
|
|
109
183
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
114
189
|
|
|
115
|
-
|
|
116
|
-
|
|
190
|
+
if not changed_symbols_list and not changed_files:
|
|
191
|
+
return DeepImpactReport(base_ref=base, head_ref=head)
|
|
117
192
|
|
|
118
193
|
direct_callers: list[ImpactedEntity] = []
|
|
119
194
|
transitive_callers: list[ImpactedEntity] = []
|
|
120
195
|
affected_callees: list[ImpactedEntity] = []
|
|
196
|
+
affected_frontend_components: list[ImpactedEntity] = []
|
|
121
197
|
affected_routes: list[ImpactedEntity] = []
|
|
122
198
|
affected_tests: list[ImpactedEntity] = []
|
|
123
199
|
db_readers: list[ImpactedEntity] = []
|
|
@@ -129,7 +205,8 @@ def get_deep_change_impact(
|
|
|
129
205
|
|
|
130
206
|
for s_info in changed_symbols_list[:max_results]:
|
|
131
207
|
s_name = s_info["name"]
|
|
132
|
-
s_path = s_info
|
|
208
|
+
s_path = s_info.get("path") or ""
|
|
209
|
+
s_cid = s_info.get("canonical_id") or s_name
|
|
133
210
|
|
|
134
211
|
if not s_name or s_name in processed_symbols:
|
|
135
212
|
continue
|
|
@@ -137,26 +214,37 @@ def get_deep_change_impact(
|
|
|
137
214
|
|
|
138
215
|
# 1. Direct callers (1-hop)
|
|
139
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
|
+
|
|
140
221
|
for c in raw_callers:
|
|
141
222
|
c_name = c.get("caller") or c.get("symbol") or ""
|
|
142
223
|
c_file = c.get("file") or ""
|
|
143
224
|
c_line = c.get("line")
|
|
144
|
-
|
|
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
|
+
|
|
145
231
|
if c_key not in seen_keys:
|
|
146
232
|
seen_keys.add(c_key)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
evidence=f"{c_file}:{c_line} -> {s_name}",
|
|
158
|
-
)
|
|
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}",
|
|
159
243
|
)
|
|
244
|
+
if is_frontend:
|
|
245
|
+
affected_frontend_components.append(caller_entity)
|
|
246
|
+
else:
|
|
247
|
+
direct_callers.append(caller_entity)
|
|
160
248
|
|
|
161
249
|
# 2. Transitive callers (2-hop) if max_depth >= 2
|
|
162
250
|
if max_depth >= 2 and c_name and c_name not in processed_transitive:
|
|
@@ -166,22 +254,27 @@ def get_deep_change_impact(
|
|
|
166
254
|
tc_name = tc.get("caller") or tc.get("symbol") or ""
|
|
167
255
|
tc_file = tc.get("file") or ""
|
|
168
256
|
tc_line = tc.get("line")
|
|
169
|
-
|
|
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
|
+
|
|
170
261
|
if tc_key not in seen_keys and tc_name != s_name:
|
|
171
262
|
seen_keys.add(tc_key)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
evidence=f"{tc_file}:{tc_line} -> {c_name} -> {s_name}",
|
|
183
|
-
)
|
|
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}",
|
|
184
273
|
)
|
|
274
|
+
if tc_fe:
|
|
275
|
+
affected_frontend_components.append(trans_entity)
|
|
276
|
+
else:
|
|
277
|
+
transitive_callers.append(trans_entity)
|
|
185
278
|
|
|
186
279
|
# 3. Direct callees
|
|
187
280
|
raw_callees = find_callees(con, s_name, max_results=max_results)
|
|
@@ -206,37 +299,103 @@ def get_deep_change_impact(
|
|
|
206
299
|
)
|
|
207
300
|
)
|
|
208
301
|
|
|
209
|
-
# 4. Route impact
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
234
385
|
|
|
235
386
|
# 5. Tests targeting this symbol
|
|
236
|
-
tests = find_related_tests(con, s_name, max_results=
|
|
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
|
+
|
|
237
391
|
for t in tests:
|
|
238
|
-
|
|
239
|
-
|
|
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
|
+
|
|
240
399
|
t_line_raw = t.get("line")
|
|
241
400
|
t_line = int(t_line_raw) if isinstance(t_line_raw, (int, str)) and str(t_line_raw).isdigit() else None
|
|
242
401
|
t_key = f"TEST:{t_file}:{t_name}"
|
|
@@ -257,36 +416,57 @@ def get_deep_change_impact(
|
|
|
257
416
|
)
|
|
258
417
|
|
|
259
418
|
# 6. Database readers and writers
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
(
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
line
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
)
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
else
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
290
470
|
|
|
291
471
|
# 7. Monorepo package impact
|
|
292
472
|
ws = detect_workspace(repository, con=con)
|
|
@@ -302,31 +482,58 @@ def get_deep_change_impact(
|
|
|
302
482
|
len(direct_callers)
|
|
303
483
|
+ len(transitive_callers)
|
|
304
484
|
+ len(affected_callees)
|
|
485
|
+
+ len(affected_frontend_components)
|
|
305
486
|
+ len(affected_routes)
|
|
306
487
|
+ len(affected_tests)
|
|
307
488
|
+ len(db_readers)
|
|
308
489
|
+ len(db_writers)
|
|
309
490
|
)
|
|
310
491
|
|
|
311
|
-
# Blast radius score calculation: 0 - 100
|
|
312
|
-
# Factors: number of direct callers, routes impacted, db writers impacted, package boundaries crossed
|
|
492
|
+
# 8. Blast radius score calculation: 0 - 100 scale
|
|
313
493
|
score = min(
|
|
314
494
|
100.0,
|
|
315
495
|
(len(direct_callers) * 3.5)
|
|
316
496
|
+ (len(transitive_callers) * 1.5)
|
|
497
|
+
+ (len(affected_frontend_components) * 5.0)
|
|
317
498
|
+ (len(affected_routes) * 8.0)
|
|
318
499
|
+ (len(db_writers) * 6.0)
|
|
319
500
|
+ (len(affected_packages) * 5.0),
|
|
320
501
|
)
|
|
321
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
|
+
|
|
322
527
|
return DeepImpactReport(
|
|
323
528
|
base_ref=base,
|
|
324
529
|
head_ref=head,
|
|
530
|
+
target_symbol=symbol,
|
|
325
531
|
changed_files=changed_files,
|
|
326
532
|
changed_symbols=changed_symbols_list,
|
|
327
533
|
direct_callers=direct_callers[:max_results],
|
|
328
534
|
transitive_callers=transitive_callers[:max_results],
|
|
329
535
|
affected_callees=affected_callees[:max_results],
|
|
536
|
+
affected_frontend_components=affected_frontend_components[:max_results],
|
|
330
537
|
affected_routes=affected_routes,
|
|
331
538
|
affected_tests=affected_tests[:max_results],
|
|
332
539
|
db_readers=db_readers,
|
|
@@ -334,4 +541,7 @@ def get_deep_change_impact(
|
|
|
334
541
|
affected_packages=sorted(affected_packages),
|
|
335
542
|
total_impacted_count=total_count,
|
|
336
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,
|
|
337
547
|
)
|