codegraph-engine 2.2.1__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 (82) hide show
  1. codegraph/__init__.py +1 -1
  2. codegraph/agent_brain.py +172 -2
  3. codegraph/agent_capabilities.py +564 -6
  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/branch_comparison.py +197 -0
  20. codegraph/change_impact.py +547 -0
  21. codegraph/cli.py +992 -3
  22. codegraph/cli_output.py +17 -2
  23. codegraph/context.py +38 -11
  24. codegraph/context_freshness.py +351 -0
  25. codegraph/database/__init__.py +14 -0
  26. codegraph/database/extractor.py +712 -17
  27. codegraph/database/interrogation.py +388 -2
  28. codegraph/database/schema_drift.py +862 -0
  29. codegraph/epistemic.py +7 -0
  30. codegraph/evidence_contract.py +21 -0
  31. codegraph/frameworks.py +171 -6
  32. codegraph/frameworks_nestjs.py +502 -0
  33. codegraph/frameworks_nextjs.py +337 -0
  34. codegraph/frameworks_react.py +457 -0
  35. codegraph/freshness.py +2 -0
  36. codegraph/frontend_html.py +109 -0
  37. codegraph/git.py +2 -0
  38. codegraph/git_state.py +545 -0
  39. codegraph/indexing/__init__.py +7 -1
  40. codegraph/indexing/indexer.py +291 -38
  41. codegraph/indexing/models.py +6 -0
  42. codegraph/indexing/parser.py +239 -0
  43. codegraph/indexing/pool.py +136 -0
  44. codegraph/indexing/scanner.py +6 -0
  45. codegraph/indexing/templates.py +418 -0
  46. codegraph/interrogation.py +128 -20
  47. codegraph/mcp/server.py +258 -27
  48. codegraph/models.py +7 -0
  49. codegraph/monorepo.py +3 -0
  50. codegraph/optimizer.py +42 -5
  51. codegraph/process_lifecycle.py +31 -13
  52. codegraph/refactor/__init__.py +36 -0
  53. codegraph/refactor/models.py +114 -0
  54. codegraph/refactor/renamer.py +844 -0
  55. codegraph/refactor/transactions.py +403 -0
  56. codegraph/resolver.py +1202 -159
  57. codegraph/retrieval_policy.py +6 -0
  58. codegraph/route_topology.py +172 -0
  59. codegraph/runtime/__init__.py +19 -0
  60. codegraph/runtime/collector.py +155 -0
  61. codegraph/runtime/db_watcher.py +191 -0
  62. codegraph/runtime/ingestor.py +30 -10
  63. codegraph/runtime/port_inspector.py +304 -0
  64. codegraph/runtime/runner.py +155 -6
  65. codegraph/runtime/trace_linker.py +175 -0
  66. codegraph/search/hybrid.py +26 -0
  67. codegraph/semantic_conflicts.py +270 -0
  68. codegraph/semantic_decorators.py +34 -3
  69. codegraph/structural_diff.py +542 -0
  70. codegraph/styles.py +144 -0
  71. codegraph/symbol_history.py +277 -0
  72. codegraph/tsconfig.py +236 -0
  73. codegraph/ui/__init__.py +6 -0
  74. codegraph/ui/server.py +715 -0
  75. codegraph/watcher.py +323 -0
  76. {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/METADATA +257 -39
  77. codegraph_engine-3.0.0.dist-info/RECORD +129 -0
  78. codegraph_engine-2.2.1.dist-info/RECORD +0 -87
  79. {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/WHEEL +0 -0
  80. {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/entry_points.txt +0 -0
  81. {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/licenses/LICENSE +0 -0
  82. {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,447 @@
1
+ """Query and graph traversal engine for asynchronous message queues and tasks.
2
+
3
+ Epistemic Invariants:
4
+ 1. Preserves distinct semantic relationships:
5
+ - PRODUCES_TO_QUEUE
6
+ - CONSUMES_FROM_QUEUE
7
+ - STATIC_QUEUE_LINKED
8
+ - OTEL_DISTRIBUTED_VERIFIED
9
+ 2. Bridges entrypoint handlers -> producers -> queues -> consumer workers -> downstream DB/operations.
10
+ 3. Fully deterministic ordering.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import sqlite3
15
+ from typing import Any
16
+
17
+
18
+ def list_async_queues(con: sqlite3.Connection) -> list[dict[str, Any]]:
19
+ """List all statically detected async queues, topics, and channels."""
20
+ con.row_factory = sqlite3.Row
21
+ try:
22
+ rows = con.execute(
23
+ """
24
+ SELECT q.queue_id, q.system, q.destination, q.service, q.environment, q.is_dlq,
25
+ COUNT(DISTINCT p.id) as producer_count,
26
+ COUNT(DISTINCT c.id) as consumer_count,
27
+ COUNT(DISTINCT l.link_id) as link_count
28
+ FROM async_queues q
29
+ LEFT JOIN async_producers p ON q.queue_id = p.queue_id
30
+ LEFT JOIN async_consumers c ON q.queue_id = c.queue_id
31
+ LEFT JOIN async_links l ON q.queue_id = l.queue_id
32
+ GROUP BY q.queue_id
33
+ ORDER BY q.queue_id ASC
34
+ """
35
+ ).fetchall()
36
+ return [
37
+ {
38
+ "queue_id": str(r["queue_id"]),
39
+ "system": str(r["system"]),
40
+ "destination": str(r["destination"]),
41
+ "service": str(r["service"]),
42
+ "environment": str(r["environment"]),
43
+ "is_dlq": bool(r["is_dlq"]),
44
+ "producer_count": int(r["producer_count"]),
45
+ "consumer_count": int(r["consumer_count"]),
46
+ "link_count": int(r["link_count"]),
47
+ }
48
+ for r in rows
49
+ ]
50
+ except sqlite3.OperationalError:
51
+ return []
52
+
53
+
54
+ def find_queue_producers(
55
+ con: sqlite3.Connection,
56
+ queue_or_topic: str,
57
+ ) -> list[dict[str, Any]]:
58
+ """Find all producer call sites for a given queue, topic, or destination."""
59
+ con.row_factory = sqlite3.Row
60
+ clean = queue_or_topic.strip()
61
+ if not clean:
62
+ return []
63
+
64
+ try:
65
+ rows = con.execute(
66
+ """
67
+ SELECT p.id, p.queue_id, p.system, p.destination, p.caller_canonical_id,
68
+ p.file_path, p.line, p.call_snippet, p.confidence, s.name as caller_name
69
+ FROM async_producers p
70
+ LEFT JOIN symbols s ON p.caller_canonical_id = s.canonical_id
71
+ WHERE p.queue_id = ? OR p.destination = ? OR p.destination LIKE ?
72
+ ORDER BY p.file_path ASC, p.line ASC
73
+ """,
74
+ (clean, clean, f"%{clean}%"),
75
+ ).fetchall()
76
+ return [
77
+ {
78
+ "id": int(r["id"]),
79
+ "queue_id": str(r["queue_id"]),
80
+ "system": str(r["system"]),
81
+ "destination": str(r["destination"]),
82
+ "caller_canonical_id": str(r["caller_canonical_id"]),
83
+ "caller_name": str(r["caller_name"] or r["caller_canonical_id"]),
84
+ "file_path": str(r["file_path"]),
85
+ "line": int(r["line"]),
86
+ "call_snippet": str(r["call_snippet"]),
87
+ "confidence": str(r["confidence"]),
88
+ }
89
+ for r in rows
90
+ ]
91
+ except sqlite3.OperationalError:
92
+ return []
93
+
94
+
95
+ def find_queue_consumers(
96
+ con: sqlite3.Connection,
97
+ queue_or_topic: str,
98
+ ) -> list[dict[str, Any]]:
99
+ """Find all consumer workers and handlers subscribed to a given queue or topic."""
100
+ con.row_factory = sqlite3.Row
101
+ clean = queue_or_topic.strip()
102
+ if not clean:
103
+ return []
104
+
105
+ try:
106
+ rows = con.execute(
107
+ """
108
+ SELECT c.id, c.queue_id, c.system, c.destination, c.handler_canonical_id,
109
+ c.file_path, c.line, c.handler_snippet, c.confidence, s.name as handler_name
110
+ FROM async_consumers c
111
+ LEFT JOIN symbols s ON c.handler_canonical_id = s.canonical_id
112
+ WHERE c.queue_id = ? OR c.destination = ? OR c.destination LIKE ?
113
+ ORDER BY c.file_path ASC, c.line ASC
114
+ """,
115
+ (clean, clean, f"%{clean}%"),
116
+ ).fetchall()
117
+ return [
118
+ {
119
+ "id": int(r["id"]),
120
+ "queue_id": str(r["queue_id"]),
121
+ "system": str(r["system"]),
122
+ "destination": str(r["destination"]),
123
+ "handler_canonical_id": str(r["handler_canonical_id"]),
124
+ "handler_name": str(r["handler_name"] or r["handler_canonical_id"]),
125
+ "file_path": str(r["file_path"]),
126
+ "line": int(r["line"]),
127
+ "handler_snippet": str(r["handler_snippet"]),
128
+ "confidence": str(r["confidence"]),
129
+ }
130
+ for r in rows
131
+ ]
132
+ except sqlite3.OperationalError:
133
+ return []
134
+
135
+
136
+ def trace_async_flow(
137
+ con: sqlite3.Connection,
138
+ entrypoint: str,
139
+ max_depth: int = 5,
140
+ ) -> dict[str, Any]:
141
+ """Trace cross-process asynchronous execution flow starting from an entrypoint.
142
+
143
+ Entrypoint can be:
144
+ - An HTTP endpoint or service function that publishes to a queue
145
+ - A queue or topic name directly
146
+ - A background consumer worker function
147
+
148
+ Returns deterministic flow steps across the async boundary, including
149
+ downstream database queries and callees invoked by consumer workers.
150
+ """
151
+ con.row_factory = sqlite3.Row
152
+ clean = entrypoint.strip()
153
+ if not clean:
154
+ return {
155
+ "status": "error",
156
+ "message": "Entrypoint cannot be empty.",
157
+ "entrypoint": entrypoint,
158
+ "steps": [],
159
+ }
160
+
161
+ # 1. Check if entrypoint is a queue or topic directly
162
+ queue_matches = con.execute(
163
+ "SELECT queue_id, system, destination FROM async_queues WHERE queue_id=? OR destination=?",
164
+ (clean, clean),
165
+ ).fetchall()
166
+
167
+ producers: list[dict[str, Any]] = []
168
+ consumers: list[dict[str, Any]] = []
169
+ flow_steps: list[dict[str, Any]] = []
170
+ downstream_ops: list[dict[str, Any]] = []
171
+
172
+ if queue_matches:
173
+ for q in queue_matches:
174
+ q_id = str(q["queue_id"])
175
+ q_prods = find_queue_producers(con, q_id)
176
+ q_cons = find_queue_consumers(con, q_id)
177
+ producers.extend(q_prods)
178
+ consumers.extend(q_cons)
179
+
180
+ for p in q_prods:
181
+ flow_steps.append({
182
+ "step": len(flow_steps) + 1,
183
+ "source": p["caller_canonical_id"],
184
+ "target": q_id,
185
+ "relationship": "PRODUCES_TO_QUEUE",
186
+ "system": p["system"],
187
+ "evidence_class": "STATIC_QUEUE_LINKED",
188
+ "confidence": p["confidence"],
189
+ "file": p["file_path"],
190
+ "line": p["line"],
191
+ "snippet": p["call_snippet"],
192
+ })
193
+
194
+ for c in q_cons:
195
+ flow_steps.append({
196
+ "step": len(flow_steps) + 1,
197
+ "source": q_id,
198
+ "target": c["handler_canonical_id"],
199
+ "relationship": "CONSUMES_FROM_QUEUE",
200
+ "system": c["system"],
201
+ "evidence_class": "STATIC_QUEUE_LINKED",
202
+ "confidence": c["confidence"],
203
+ "file": c["file_path"],
204
+ "line": c["line"],
205
+ "snippet": c["handler_snippet"],
206
+ })
207
+ else:
208
+ # 2. Check if entrypoint is a producer symbol or calls a producer
209
+ # Look up directly matching producers
210
+ direct_prods = con.execute(
211
+ """
212
+ SELECT p.id, p.queue_id, p.system, p.destination, p.caller_canonical_id,
213
+ p.file_path, p.line, p.call_snippet, p.confidence
214
+ FROM async_producers p
215
+ WHERE p.caller_canonical_id = ? OR p.caller_canonical_id LIKE ?
216
+ ORDER BY p.file_path ASC, p.line ASC
217
+ """,
218
+ (clean, f"%{clean}%"),
219
+ ).fetchall()
220
+
221
+ # If none direct, check if entrypoint calls a producer function (e.g. controller calling service)
222
+ if not direct_prods:
223
+ callee_prods = con.execute(
224
+ """
225
+ SELECT p.id, p.queue_id, p.system, p.destination, p.caller_canonical_id,
226
+ p.file_path, p.line, p.call_snippet, p.confidence, ge.source as entry_source
227
+ FROM graph_edges ge
228
+ JOIN async_producers p ON ge.target = p.caller_canonical_id
229
+ WHERE (ge.source = ? OR ge.source LIKE ?) AND ge.relationship = 'CALLS'
230
+ ORDER BY p.file_path ASC, p.line ASC
231
+ """,
232
+ (clean, f"%{clean}%"),
233
+ ).fetchall()
234
+ for cp in callee_prods:
235
+ flow_steps.append({
236
+ "step": len(flow_steps) + 1,
237
+ "source": str(cp["entry_source"]),
238
+ "target": str(cp["caller_canonical_id"]),
239
+ "relationship": "CALLS",
240
+ "evidence_class": "AST_VERIFIED",
241
+ "confidence": "HIGH",
242
+ "file": str(cp["file_path"]),
243
+ "line": int(cp["line"]),
244
+ "snippet": "",
245
+ })
246
+ direct_prods.append(cp)
247
+
248
+ for p in direct_prods:
249
+ q_id = str(p["queue_id"])
250
+ producers.append({
251
+ "queue_id": q_id,
252
+ "system": str(p["system"]),
253
+ "destination": str(p["destination"]),
254
+ "caller_canonical_id": str(p["caller_canonical_id"]),
255
+ "file_path": str(p["file_path"]),
256
+ "line": int(p["line"]),
257
+ "call_snippet": str(p["call_snippet"]),
258
+ "confidence": str(p["confidence"]),
259
+ })
260
+ flow_steps.append({
261
+ "step": len(flow_steps) + 1,
262
+ "source": str(p["caller_canonical_id"]),
263
+ "target": q_id,
264
+ "relationship": "PRODUCES_TO_QUEUE",
265
+ "system": str(p["system"]),
266
+ "evidence_class": "STATIC_QUEUE_LINKED",
267
+ "confidence": str(p["confidence"]),
268
+ "file": str(p["file_path"]),
269
+ "line": int(p["line"]),
270
+ "snippet": str(p["call_snippet"]),
271
+ })
272
+
273
+ # Find matching static consumers for this queue
274
+ q_cons = find_queue_consumers(con, q_id)
275
+ consumers.extend(q_cons)
276
+
277
+ # Look up runtime links to augment evidence and latency metrics
278
+ try:
279
+ rt_links = con.execute(
280
+ "SELECT * FROM async_links WHERE queue_id = ? OR destination = ?",
281
+ (q_id, str(p["destination"])),
282
+ ).fetchall()
283
+ except sqlite3.OperationalError:
284
+ rt_links = []
285
+
286
+ links_by_consumer = {str(rk["consumer_id"]): rk for rk in rt_links}
287
+
288
+ for c in q_cons:
289
+ matched_link = links_by_consumer.get(c["handler_canonical_id"])
290
+ ev_class = str(matched_link["evidence_class"]) if matched_link else "STATIC_QUEUE_LINKED"
291
+ step_dict: dict[str, Any] = {
292
+ "step": len(flow_steps) + 1,
293
+ "source": q_id,
294
+ "target": c["handler_canonical_id"],
295
+ "relationship": "CONSUMES_FROM_QUEUE",
296
+ "system": c["system"],
297
+ "evidence_class": ev_class,
298
+ "confidence": c["confidence"],
299
+ "file": c["file_path"],
300
+ "line": c["line"],
301
+ "snippet": c["handler_snippet"],
302
+ }
303
+ if matched_link and ev_class == "OTEL_DISTRIBUTED_VERIFIED":
304
+ step_dict["latency_p50_ms"] = float(matched_link["p50_ms"])
305
+ step_dict["latency_p95_ms"] = float(matched_link["p95_ms"])
306
+ step_dict["trace_count"] = int(matched_link["trace_count"])
307
+ flow_steps.append(step_dict)
308
+
309
+ # Include cross-repository / external runtime consumers
310
+ for rk in rt_links:
311
+ c_id = str(rk["consumer_id"])
312
+ if c_id.startswith("external:"):
313
+ consumers.append({
314
+ "queue_id": q_id,
315
+ "system": str(rk["system"]),
316
+ "destination": str(rk["destination"]),
317
+ "handler_canonical_id": c_id,
318
+ "file_path": "",
319
+ "line": 0,
320
+ "handler_snippet": f"External consumer ({rk['system']})",
321
+ "confidence": "HIGH",
322
+ })
323
+ flow_steps.append({
324
+ "step": len(flow_steps) + 1,
325
+ "source": q_id,
326
+ "target": c_id,
327
+ "relationship": "CONSUMES_FROM_QUEUE",
328
+ "system": str(rk["system"]),
329
+ "evidence_class": str(rk["evidence_class"]),
330
+ "confidence": "HIGH",
331
+ "file": "",
332
+ "line": 0,
333
+ "snippet": f"Observed via OTel (p50={rk['p50_ms']}ms, p95={rk['p95_ms']}ms)",
334
+ "latency_p50_ms": float(rk["p50_ms"]),
335
+ "latency_p95_ms": float(rk["p95_ms"]),
336
+ "trace_count": int(rk["trace_count"]),
337
+ })
338
+
339
+ # 3. If still no producers found, check if entrypoint is a consumer worker handler
340
+ if not producers:
341
+ direct_cons = con.execute(
342
+ """
343
+ SELECT c.id, c.queue_id, c.system, c.destination, c.handler_canonical_id,
344
+ c.file_path, c.line, c.handler_snippet, c.confidence
345
+ FROM async_consumers c
346
+ WHERE c.handler_canonical_id = ? OR c.handler_canonical_id LIKE ?
347
+ ORDER BY c.file_path ASC, c.line ASC
348
+ """,
349
+ (clean, f"%{clean}%"),
350
+ ).fetchall()
351
+
352
+ for c in direct_cons:
353
+ q_id = str(c["queue_id"])
354
+ consumers.append({
355
+ "queue_id": q_id,
356
+ "system": str(c["system"]),
357
+ "destination": str(c["destination"]),
358
+ "handler_canonical_id": str(c["handler_canonical_id"]),
359
+ "file_path": str(c["file_path"]),
360
+ "line": int(c["line"]),
361
+ "handler_snippet": str(c["handler_snippet"]),
362
+ "confidence": str(c["confidence"]),
363
+ })
364
+ # Find upstream producers
365
+ q_prods = find_queue_producers(con, q_id)
366
+ producers.extend(q_prods)
367
+ for p in q_prods:
368
+ flow_steps.append({
369
+ "step": len(flow_steps) + 1,
370
+ "source": p["caller_canonical_id"],
371
+ "target": q_id,
372
+ "relationship": "PRODUCES_TO_QUEUE",
373
+ "system": p["system"],
374
+ "evidence_class": "STATIC_QUEUE_LINKED",
375
+ "confidence": p["confidence"],
376
+ "file": p["file_path"],
377
+ "line": p["line"],
378
+ "snippet": p["call_snippet"],
379
+ })
380
+ flow_steps.append({
381
+ "step": len(flow_steps) + 1,
382
+ "source": q_id,
383
+ "target": str(c["handler_canonical_id"]),
384
+ "relationship": "CONSUMES_FROM_QUEUE",
385
+ "system": str(c["system"]),
386
+ "evidence_class": "STATIC_QUEUE_LINKED",
387
+ "confidence": str(c["confidence"]),
388
+ "file": str(c["file_path"]),
389
+ "line": int(c["line"]),
390
+ "snippet": str(c["handler_snippet"]),
391
+ })
392
+
393
+ # For each consumer handler discovered, find downstream database queries and operations
394
+ visited_handlers: set[str] = set()
395
+ for c in consumers:
396
+ h_id = c["handler_canonical_id"]
397
+ if h_id in visited_handlers:
398
+ continue
399
+ visited_handlers.add(h_id)
400
+
401
+ try:
402
+ db_rows = con.execute(
403
+ """
404
+ SELECT query_id, operation, relationship, table_name, file_path, start_line, evidence
405
+ FROM db_queries
406
+ WHERE caller_symbol_id = ?
407
+ ORDER BY file_path ASC, start_line ASC
408
+ """,
409
+ (h_id,),
410
+ ).fetchall()
411
+ for db in db_rows:
412
+ op_dict = {
413
+ "consumer": h_id,
414
+ "operation": str(db["operation"]),
415
+ "relationship": str(db["relationship"]),
416
+ "table_name": str(db["table_name"]),
417
+ "file": str(db["file_path"]),
418
+ "line": int(db["start_line"]),
419
+ "evidence": str(db["evidence"]),
420
+ }
421
+ downstream_ops.append(op_dict)
422
+ flow_steps.append({
423
+ "step": len(flow_steps) + 1,
424
+ "source": h_id,
425
+ "target": f"table:{db['table_name']}",
426
+ "relationship": str(db["relationship"]),
427
+ "evidence_class": "AST_VERIFIED",
428
+ "confidence": "HIGH",
429
+ "file": str(db["file_path"]),
430
+ "line": int(db["start_line"]),
431
+ "snippet": f"{db['operation']} on {db['table_name']}",
432
+ })
433
+ except sqlite3.OperationalError:
434
+ pass
435
+
436
+ return {
437
+ "status": "ok" if flow_steps else "not_found",
438
+ "entrypoint": clean,
439
+ "step_count": len(flow_steps),
440
+ "producers_count": len(producers),
441
+ "consumers_count": len(consumers),
442
+ "downstream_ops_count": len(downstream_ops),
443
+ "producers": producers,
444
+ "consumers": consumers,
445
+ "downstream_ops": downstream_ops,
446
+ "flow_steps": flow_steps,
447
+ }
@@ -0,0 +1,197 @@
1
+ """Branch Architecture Comparison Engine.
2
+
3
+ Provides hierarchical structural comparisons between Git branches:
4
+ package
5
+ ↓
6
+ module
7
+ ↓
8
+ symbol
9
+ ↓
10
+ relationships (routes, tests, DB writes, calls)
11
+
12
+ Supports:
13
+ - deterministic ordering
14
+ - token-budget awareness (max_tokens)
15
+ - cursor-based pagination
16
+ - explicit summary of changed routes, services, database models, and affected tests
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import sqlite3
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from codegraph.monorepo import detect_workspace
26
+ from codegraph.structural_diff import compare_branches
27
+
28
+
29
+ @dataclass
30
+ class SymbolChangeItem:
31
+ name: str
32
+ kind: str
33
+ change_type: str # "ADDED" | "MODIFIED" | "REMOVED"
34
+ line: int | None
35
+ relationships: list[str] = field(default_factory=list)
36
+
37
+ def as_dict(self) -> dict[str, Any]:
38
+ return {
39
+ "name": self.name,
40
+ "kind": self.kind,
41
+ "change_type": self.change_type,
42
+ "line": self.line,
43
+ "relationships": self.relationships,
44
+ }
45
+
46
+
47
+ @dataclass
48
+ class ModuleChangeItem:
49
+ module: str
50
+ file_path: str
51
+ symbols: list[SymbolChangeItem] = field(default_factory=list)
52
+
53
+ def as_dict(self) -> dict[str, Any]:
54
+ return {
55
+ "module": self.module,
56
+ "file_path": self.file_path,
57
+ "symbols": [s.as_dict() for s in self.symbols],
58
+ }
59
+
60
+
61
+ @dataclass
62
+ class PackageChangeItem:
63
+ package_id: str
64
+ modules: list[ModuleChangeItem] = field(default_factory=list)
65
+
66
+ def as_dict(self) -> dict[str, Any]:
67
+ return {
68
+ "package_id": self.package_id,
69
+ "modules": [m.as_dict() for m in self.modules],
70
+ }
71
+
72
+
73
+ @dataclass
74
+ class BranchArchitectureReport:
75
+ base_branch: str
76
+ head_branch: str
77
+ packages: list[PackageChangeItem] = field(default_factory=list)
78
+ changed_routes: list[dict[str, str]] = field(default_factory=list)
79
+ changed_services: list[str] = field(default_factory=list)
80
+ changed_db: list[dict[str, str]] = field(default_factory=list)
81
+ affected_tests: list[str] = field(default_factory=list)
82
+ next_cursor: str | None = None
83
+ total_packages: int = 0
84
+ total_symbols: int = 0
85
+ estimated_tokens: int = 0
86
+
87
+ def as_dict(self) -> dict[str, Any]:
88
+ return {
89
+ "base_branch": self.base_branch,
90
+ "head_branch": self.head_branch,
91
+ "packages": [p.as_dict() for p in self.packages],
92
+ "changed_routes": self.changed_routes,
93
+ "changed_services": self.changed_services,
94
+ "changed_db": self.changed_db,
95
+ "affected_tests": self.affected_tests,
96
+ "next_cursor": self.next_cursor,
97
+ "total_packages": self.total_packages,
98
+ "total_symbols": self.total_symbols,
99
+ "estimated_tokens": self.estimated_tokens,
100
+ }
101
+
102
+
103
+ def compare_branch_architecture(
104
+ repository: Path,
105
+ base_branch: str = "main",
106
+ head_branch: str = "HEAD",
107
+ con: sqlite3.Connection | None = None,
108
+ limit: int = 50,
109
+ cursor: str | None = None,
110
+ max_tokens: int = 4000,
111
+ ) -> BranchArchitectureReport:
112
+ """Compare two branches and return hierarchical architecture diff."""
113
+ if base_branch == head_branch:
114
+ return BranchArchitectureReport(base_branch=base_branch, head_branch=head_branch)
115
+
116
+ # 1. Run structural branch diff
117
+ diff_report = compare_branches(repository, base_branch=base_branch, head_branch=head_branch, con=con)
118
+
119
+ # 2. Workspace package mapper
120
+ ws = detect_workspace(repository, con=con)
121
+
122
+ # 3. Build hierarchical package -> module -> symbol map
123
+ package_map: dict[str, dict[str, list[SymbolChangeItem]]] = {}
124
+ services: set[str] = set()
125
+
126
+ all_symbols = diff_report.added_symbols + diff_report.changed_symbols + diff_report.removed_symbols
127
+ # Sort deterministically by path and name
128
+ all_symbols.sort(key=lambda s: (s.path, s.name))
129
+
130
+ # Apply cursor pagination if cursor provided
131
+ start_idx = 0
132
+ if cursor and cursor.isdigit():
133
+ start_idx = int(cursor)
134
+
135
+ paged_symbols = all_symbols[start_idx : start_idx + limit]
136
+ next_cursor = str(start_idx + limit) if (start_idx + limit) < len(all_symbols) else None
137
+
138
+ for s in paged_symbols:
139
+ pkg = ws.get_package_for_file(s.path)
140
+ pkg_id = pkg.package_id if pkg else "root"
141
+
142
+ if pkg_id not in package_map:
143
+ package_map[pkg_id] = {}
144
+ if s.path not in package_map[pkg_id]:
145
+ package_map[pkg_id][s.path] = []
146
+
147
+ # Find associated relationships
148
+ rels: list[str] = []
149
+ for r in diff_report.changed_routes:
150
+ if r.handler == s.name:
151
+ rels.append(f"{r.change_type} ROUTE {r.method} {r.path}")
152
+ for db in diff_report.db_impact:
153
+ if db.get("symbol") == s.name:
154
+ rels.append(f"{db.get('change_type')} DB {db.get('operation')} {db.get('table')}")
155
+
156
+ if "service" in s.name.lower() or "service" in s.path.lower():
157
+ services.add(s.name)
158
+
159
+ package_map[pkg_id][s.path].append(
160
+ SymbolChangeItem(
161
+ name=s.name,
162
+ kind=s.kind,
163
+ change_type=s.change_type,
164
+ line=s.new_line or s.old_line,
165
+ relationships=rels,
166
+ )
167
+ )
168
+
169
+ # Build ordered PackageChangeItem objects
170
+ package_items: list[PackageChangeItem] = []
171
+ total_sym_count = 0
172
+
173
+ for pkg_id in sorted(package_map.keys()):
174
+ module_items: list[ModuleChangeItem] = []
175
+ for f_path in sorted(package_map[pkg_id].keys()):
176
+ mod_name = Path(f_path).stem
177
+ sym_items = package_map[pkg_id][f_path]
178
+ total_sym_count += len(sym_items)
179
+ module_items.append(ModuleChangeItem(module=mod_name, file_path=f_path, symbols=sym_items))
180
+ package_items.append(PackageChangeItem(package_id=pkg_id, modules=module_items))
181
+
182
+ # Rough token estimation: ~25 tokens per symbol + overhead
183
+ token_est = total_sym_count * 25 + len(diff_report.changed_routes) * 15 + 150
184
+
185
+ return BranchArchitectureReport(
186
+ base_branch=base_branch,
187
+ head_branch=head_branch,
188
+ packages=package_items,
189
+ changed_routes=[r.as_dict() for r in diff_report.changed_routes],
190
+ changed_services=sorted(services),
191
+ changed_db=diff_report.db_impact,
192
+ affected_tests=diff_report.affected_tests,
193
+ next_cursor=next_cursor,
194
+ total_packages=len(package_items),
195
+ total_symbols=total_sym_count,
196
+ estimated_tokens=token_est,
197
+ )