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.
Files changed (80) hide show
  1. codegraph/__init__.py +1 -1
  2. codegraph/agent_brain.py +172 -2
  3. codegraph/agent_capabilities.py +320 -3
  4. codegraph/agent_rules.py +1 -1
  5. codegraph/api_drift.py +455 -0
  6. codegraph/async_queue/__init__.py +50 -0
  7. codegraph/async_queue/detectors/__init__.py +48 -0
  8. codegraph/async_queue/detectors/celery.py +160 -0
  9. codegraph/async_queue/detectors/kafka.py +168 -0
  10. codegraph/async_queue/detectors/rabbitmq.py +116 -0
  11. codegraph/async_queue/detectors/redis.py +130 -0
  12. codegraph/async_queue/detectors/sqs.py +135 -0
  13. codegraph/async_queue/models.py +161 -0
  14. codegraph/async_queue/otel_models.py +74 -0
  15. codegraph/async_queue/otel_parser.py +337 -0
  16. codegraph/async_queue/reconciler.py +427 -0
  17. codegraph/async_queue/scanner.py +291 -0
  18. codegraph/async_queue/traversal.py +447 -0
  19. codegraph/change_impact.py +320 -110
  20. codegraph/cli.py +776 -25
  21. codegraph/cli_output.py +17 -2
  22. codegraph/context.py +38 -11
  23. codegraph/context_freshness.py +2 -0
  24. codegraph/database/__init__.py +14 -0
  25. codegraph/database/extractor.py +712 -17
  26. codegraph/database/interrogation.py +388 -2
  27. codegraph/database/schema_drift.py +862 -0
  28. codegraph/epistemic.py +7 -0
  29. codegraph/evidence_contract.py +21 -0
  30. codegraph/frameworks.py +171 -6
  31. codegraph/frameworks_nestjs.py +502 -0
  32. codegraph/frameworks_nextjs.py +337 -0
  33. codegraph/frameworks_react.py +457 -0
  34. codegraph/freshness.py +2 -0
  35. codegraph/frontend_html.py +109 -0
  36. codegraph/git.py +2 -0
  37. codegraph/git_state.py +2 -0
  38. codegraph/indexing/__init__.py +7 -1
  39. codegraph/indexing/indexer.py +291 -38
  40. codegraph/indexing/models.py +6 -0
  41. codegraph/indexing/parser.py +239 -0
  42. codegraph/indexing/pool.py +136 -0
  43. codegraph/indexing/scanner.py +6 -0
  44. codegraph/indexing/templates.py +418 -0
  45. codegraph/interrogation.py +128 -20
  46. codegraph/mcp/server.py +190 -28
  47. codegraph/models.py +7 -0
  48. codegraph/monorepo.py +3 -0
  49. codegraph/optimizer.py +42 -5
  50. codegraph/process_lifecycle.py +31 -13
  51. codegraph/refactor/__init__.py +36 -0
  52. codegraph/refactor/models.py +114 -0
  53. codegraph/refactor/renamer.py +844 -0
  54. codegraph/refactor/transactions.py +403 -0
  55. codegraph/resolver.py +1202 -159
  56. codegraph/retrieval_policy.py +6 -0
  57. codegraph/route_topology.py +172 -0
  58. codegraph/runtime/__init__.py +19 -0
  59. codegraph/runtime/collector.py +155 -0
  60. codegraph/runtime/db_watcher.py +191 -0
  61. codegraph/runtime/ingestor.py +30 -10
  62. codegraph/runtime/port_inspector.py +304 -0
  63. codegraph/runtime/runner.py +155 -6
  64. codegraph/runtime/trace_linker.py +175 -0
  65. codegraph/search/hybrid.py +26 -0
  66. codegraph/semantic_decorators.py +34 -3
  67. codegraph/structural_diff.py +2 -0
  68. codegraph/styles.py +144 -0
  69. codegraph/symbol_history.py +2 -0
  70. codegraph/tsconfig.py +236 -0
  71. codegraph/ui/__init__.py +6 -0
  72. codegraph/ui/server.py +715 -0
  73. codegraph/watcher.py +323 -0
  74. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/METADATA +150 -38
  75. codegraph_engine-3.0.0.dist-info/RECORD +129 -0
  76. codegraph_engine-2.3.0.dist-info/RECORD +0 -94
  77. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/WHEEL +0 -0
  78. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/entry_points.txt +0 -0
  79. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/licenses/LICENSE +0 -0
  80. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/top_level.txt +0 -0
@@ -1,22 +1,22 @@
1
- """Deep Change Impact Analysis Engine.
1
+ """Production-Grade Semantic Change Impact & PR Blast Radius Engine.
2
2
 
3
- Extends existing Git impact analysis by tracing complete downstream chains:
4
- changed symbol
3
+ Extends Git and AST impact analysis by tracing complete downstream chains:
4
+ changed symbol / git diff
5
5
  ↓
6
- callers (direct & transitive)
6
+ direct & transitive callers
7
7
  ↓
8
- callees
8
+ frontend UI components (React/Next.js/Vue)
9
9
  ↓
10
- routes & endpoints
10
+ framework routes & public endpoints
11
11
  ↓
12
- tests & test suites
12
+ database readers & writers (mutations, migrations)
13
13
  ↓
14
- DB readers & writers
14
+ covering tests & smart test execution recommendation
15
15
  ↓
16
- downstream package dependencies
16
+ monorepo package boundaries & PR risk score
17
17
 
18
18
  Every item carries explicit evidence, relationship type, source location,
19
- and confidence/epistemic state (FACT, POSSIBLE, UNKNOWN).
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
- # Fast path: identical refs have zero changes
105
- if base == head and head != "WORKING_TREE":
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
- diff_report = compare_revisions(repository, base=base, head=head, con=con)
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
- changed_symbols_list: list[dict[str, Any]] = [
111
- s.as_dict() for s in (diff_report.changed_symbols + diff_report.added_symbols + diff_report.removed_symbols)
112
- ]
113
- changed_files = diff_report.modified_files + diff_report.added_files + diff_report.deleted_files
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
- if not changed_symbols_list and not changed_files:
116
- return DeepImpactReport(base_ref=base, head_ref=head)
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["path"]
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
- c_key = f"CALLER:1:{c_file}:{c_name}"
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
- direct_callers.append(
148
- ImpactedEntity(
149
- name=c_name,
150
- kind="CALLER",
151
- file=c_file,
152
- line=c_line,
153
- relationship="CALLS",
154
- depth=1,
155
- confidence="FACT",
156
- reason=f"Invokes modified symbol {s_name}",
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
- tc_key = f"CALLER:2:{tc_file}:{tc_name}"
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
- transitive_callers.append(
173
- ImpactedEntity(
174
- name=tc_name,
175
- kind="CALLER",
176
- file=tc_file,
177
- line=tc_line,
178
- relationship="CALLS",
179
- depth=2,
180
- confidence="FACT",
181
- reason=f"Transitively invokes {s_name} via {c_name}",
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: does this symbol handle an endpoint or get called by a route?
210
- try:
211
- route_rows = con.execute(
212
- "SELECT path, method, handler_name, file FROM routes WHERE handler_name=? OR symbol=?",
213
- (s_name, s_name),
214
- ).fetchall()
215
- for rr in route_rows:
216
- r_key = f"ROUTE:{rr[0]}:{rr[1]}"
217
- if r_key not in seen_keys:
218
- seen_keys.add(r_key)
219
- affected_routes.append(
220
- ImpactedEntity(
221
- name=f"{rr[1]} {rr[0]}",
222
- kind="ROUTE",
223
- file=rr[3],
224
- line=None,
225
- relationship="ROUTES_TO",
226
- depth=1,
227
- confidence="FACT",
228
- reason=f"Endpoint handled by changed symbol {s_name}",
229
- evidence=f"Route {rr[1]} {rr[0]} -> {s_name}",
230
- )
231
- )
232
- except (sqlite3.OperationalError, sqlite3.DatabaseError):
233
- pass
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=20)
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
- t_name = str(t.get("symbol") or t.get("test") or "")
239
- t_file = str(t.get("file") or "")
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
- try:
261
- db_rows = con.execute(
262
- "SELECT table_name, operation, file, line FROM database_queries WHERE symbol=? OR caller_symbol=?",
263
- (s_name, s_name),
264
- ).fetchall()
265
- for dr in db_rows:
266
- tbl, op, f_path, l_num = dr[0], dr[1], dr[2], dr[3]
267
- is_mutating = op.upper() in ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER")
268
- kind = "DB_WRITER" if is_mutating else "DB_READER"
269
- rel = "WRITES_TABLE" if is_mutating else "READS_TABLE"
270
- db_key = f"{kind}:{tbl}:{op}:{s_name}"
271
- if db_key not in seen_keys:
272
- seen_keys.add(db_key)
273
- entity = ImpactedEntity(
274
- name=f"{op} {tbl}",
275
- kind=kind,
276
- file=f_path or s_path,
277
- line=l_num,
278
- relationship=rel,
279
- depth=1,
280
- confidence="FACT",
281
- reason=f"{s_name} executes {op} on {tbl}",
282
- evidence=f"{s_name} -> {op} {tbl} ({f_path}:{l_num})",
283
- )
284
- if is_mutating:
285
- db_writers.append(entity)
286
- else:
287
- db_readers.append(entity)
288
- except (sqlite3.OperationalError, sqlite3.DatabaseError):
289
- pass
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
  )