codegraph-engine 3.0.0__1-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 (130) hide show
  1. codegraph/__init__.py +37 -0
  2. codegraph/agent.py +26 -0
  3. codegraph/agent_brain.py +2212 -0
  4. codegraph/agent_capabilities.py +4058 -0
  5. codegraph/agent_rules.py +531 -0
  6. codegraph/api_drift.py +455 -0
  7. codegraph/architecture.py +371 -0
  8. codegraph/async_queue/__init__.py +50 -0
  9. codegraph/async_queue/detectors/__init__.py +48 -0
  10. codegraph/async_queue/detectors/celery.py +160 -0
  11. codegraph/async_queue/detectors/kafka.py +168 -0
  12. codegraph/async_queue/detectors/rabbitmq.py +116 -0
  13. codegraph/async_queue/detectors/redis.py +130 -0
  14. codegraph/async_queue/detectors/sqs.py +135 -0
  15. codegraph/async_queue/models.py +161 -0
  16. codegraph/async_queue/otel_models.py +74 -0
  17. codegraph/async_queue/otel_parser.py +337 -0
  18. codegraph/async_queue/reconciler.py +427 -0
  19. codegraph/async_queue/scanner.py +291 -0
  20. codegraph/async_queue/traversal.py +447 -0
  21. codegraph/audit.py +106 -0
  22. codegraph/binding_resolver.py +616 -0
  23. codegraph/branch_comparison.py +197 -0
  24. codegraph/cache.py +95 -0
  25. codegraph/change_impact.py +547 -0
  26. codegraph/cli.py +2436 -0
  27. codegraph/cli_output.py +205 -0
  28. codegraph/config.py +45 -0
  29. codegraph/constraints.py +238 -0
  30. codegraph/context.py +2182 -0
  31. codegraph/context_freshness.py +351 -0
  32. codegraph/database/__init__.py +72 -0
  33. codegraph/database/extractor.py +3651 -0
  34. codegraph/database/interrogation.py +1494 -0
  35. codegraph/database/models.py +310 -0
  36. codegraph/database/schema_drift.py +862 -0
  37. codegraph/epistemic.py +200 -0
  38. codegraph/errors.py +282 -0
  39. codegraph/evidence/__init__.py +15 -0
  40. codegraph/evidence/citations.py +418 -0
  41. codegraph/evidence_contract.py +486 -0
  42. codegraph/frameworks.py +960 -0
  43. codegraph/frameworks_nestjs.py +502 -0
  44. codegraph/frameworks_nextjs.py +337 -0
  45. codegraph/frameworks_react.py +457 -0
  46. codegraph/freshness.py +335 -0
  47. codegraph/frontend_html.py +109 -0
  48. codegraph/git.py +362 -0
  49. codegraph/git_state.py +545 -0
  50. codegraph/graph/__init__.py +46 -0
  51. codegraph/graph/models.py +56 -0
  52. codegraph/graph/traversal.py +1993 -0
  53. codegraph/indexing/__init__.py +24 -0
  54. codegraph/indexing/classifier.py +564 -0
  55. codegraph/indexing/indexer.py +2578 -0
  56. codegraph/indexing/models.py +427 -0
  57. codegraph/indexing/parser.py +3404 -0
  58. codegraph/indexing/pool.py +136 -0
  59. codegraph/indexing/scanner.py +211 -0
  60. codegraph/indexing/telemetry.py +326 -0
  61. codegraph/indexing/templates.py +418 -0
  62. codegraph/indexing/test_framework.py +116 -0
  63. codegraph/installer.py +1702 -0
  64. codegraph/interrogation.py +2355 -0
  65. codegraph/llm/__init__.py +3 -0
  66. codegraph/llm/base.py +15 -0
  67. codegraph/llm/context.py +20 -0
  68. codegraph/mcp/__init__.py +3 -0
  69. codegraph/mcp/server.py +1476 -0
  70. codegraph/mcp_diagnostics.py +882 -0
  71. codegraph/memory/__init__.py +3 -0
  72. codegraph/memory/store.py +46 -0
  73. codegraph/models.py +310 -0
  74. codegraph/monorepo.py +925 -0
  75. codegraph/observability.py +262 -0
  76. codegraph/optimizer.py +1014 -0
  77. codegraph/planner.py +417 -0
  78. codegraph/process_lifecycle.py +1153 -0
  79. codegraph/py.typed +1 -0
  80. codegraph/query_expansion.py +199 -0
  81. codegraph/ranking.py +408 -0
  82. codegraph/refactor/__init__.py +36 -0
  83. codegraph/refactor/models.py +114 -0
  84. codegraph/refactor/renamer.py +844 -0
  85. codegraph/refactor/transactions.py +414 -0
  86. codegraph/resolver.py +3968 -0
  87. codegraph/resources/__init__.py +46 -0
  88. codegraph/resources/cache.py +117 -0
  89. codegraph/resources/coalescer.py +83 -0
  90. codegraph/resources/debouncer.py +98 -0
  91. codegraph/resources/governor.py +219 -0
  92. codegraph/resources/monitor.py +102 -0
  93. codegraph/resources/policy.py +123 -0
  94. codegraph/retrieval_policy.py +320 -0
  95. codegraph/roblox.py +2174 -0
  96. codegraph/route_composer.py +363 -0
  97. codegraph/route_topology.py +172 -0
  98. codegraph/runtime/__init__.py +51 -0
  99. codegraph/runtime/collector.py +155 -0
  100. codegraph/runtime/db_watcher.py +191 -0
  101. codegraph/runtime/ingestor.py +692 -0
  102. codegraph/runtime/models.py +130 -0
  103. codegraph/runtime/port_inspector.py +304 -0
  104. codegraph/runtime/reconciliation.py +492 -0
  105. codegraph/runtime/runner.py +353 -0
  106. codegraph/runtime/trace_linker.py +175 -0
  107. codegraph/search/__init__.py +25 -0
  108. codegraph/search/hybrid.py +846 -0
  109. codegraph/search/semantic.py +28 -0
  110. codegraph/security/__init__.py +51 -0
  111. codegraph/security/paths.py +189 -0
  112. codegraph/security/redaction.py +633 -0
  113. codegraph/semantic_conflicts.py +270 -0
  114. codegraph/semantic_decorators.py +372 -0
  115. codegraph/structural_diff.py +542 -0
  116. codegraph/styles.py +144 -0
  117. codegraph/symbol_history.py +277 -0
  118. codegraph/target_resolver.py +348 -0
  119. codegraph/task.py +637 -0
  120. codegraph/tool_selection_eval.py +1158 -0
  121. codegraph/tsconfig.py +236 -0
  122. codegraph/ui/__init__.py +6 -0
  123. codegraph/ui/server.py +755 -0
  124. codegraph/watcher.py +323 -0
  125. codegraph_engine-3.0.0.dist-info/METADATA +859 -0
  126. codegraph_engine-3.0.0.dist-info/RECORD +130 -0
  127. codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
  128. codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
  129. codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
  130. codegraph_engine-3.0.0.dist-info/top_level.txt +1 -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
+ }
codegraph/audit.py ADDED
@@ -0,0 +1,106 @@
1
+ """Optional local audit log for MCP tool operations.
2
+
3
+ Records: timestamp, tool, operation, repository, files accessed, duration.
4
+ File contents are NEVER recorded.
5
+ Log can be cleared via CLI. Disabled by default.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sqlite3
11
+ import time
12
+ from collections.abc import Iterator
13
+ from contextlib import contextmanager
14
+ from pathlib import Path
15
+
16
+
17
+ class AuditLog:
18
+ """SQLite-backed local audit log. Contents never include source code."""
19
+
20
+ def __init__(self, db_path: Path, enabled: bool = False) -> None:
21
+ self.db_path = db_path
22
+ self.enabled = enabled
23
+ if enabled:
24
+ self._ensure_table()
25
+
26
+ def _connect(self) -> sqlite3.Connection:
27
+ con = sqlite3.connect(self.db_path)
28
+ con.row_factory = sqlite3.Row
29
+ return con
30
+
31
+ def _ensure_table(self) -> None:
32
+ with self._session() as con:
33
+ con.execute("""
34
+ CREATE TABLE IF NOT EXISTS audit_log (
35
+ id INTEGER PRIMARY KEY,
36
+ ts INTEGER NOT NULL,
37
+ tool TEXT NOT NULL,
38
+ operation TEXT NOT NULL,
39
+ repository TEXT NOT NULL,
40
+ files_accessed TEXT NOT NULL DEFAULT '[]',
41
+ duration_ms REAL NOT NULL DEFAULT 0
42
+ )
43
+ """)
44
+
45
+ @contextmanager
46
+ def _session(self) -> Iterator[sqlite3.Connection]:
47
+ con = self._connect()
48
+ try:
49
+ yield con
50
+ con.commit()
51
+ except BaseException:
52
+ con.rollback()
53
+ raise
54
+ finally:
55
+ con.close()
56
+
57
+ def record(
58
+ self,
59
+ tool: str,
60
+ operation: str,
61
+ repository: str,
62
+ files_accessed: list[str] | None = None,
63
+ duration_ms: float = 0.0,
64
+ ) -> None:
65
+ if not self.enabled:
66
+ return
67
+ with self._session() as con:
68
+ con.execute(
69
+ "INSERT INTO audit_log(ts, tool, operation, repository, files_accessed, duration_ms) "
70
+ "VALUES (?, ?, ?, ?, ?, ?)",
71
+ (
72
+ int(time.time()),
73
+ tool,
74
+ operation,
75
+ repository,
76
+ json.dumps(files_accessed or []),
77
+ duration_ms,
78
+ ),
79
+ )
80
+
81
+ def recent(self, limit: int = 100) -> list[dict[str, object]]:
82
+ if not self.enabled:
83
+ return []
84
+ with self._session() as con:
85
+ rows = con.execute(
86
+ "SELECT ts, tool, operation, repository, files_accessed, duration_ms "
87
+ "FROM audit_log ORDER BY id DESC LIMIT ?",
88
+ (limit,),
89
+ ).fetchall()
90
+ return [
91
+ {
92
+ "ts": r["ts"],
93
+ "tool": r["tool"],
94
+ "operation": r["operation"],
95
+ "repository": r["repository"],
96
+ "files_accessed": json.loads(r["files_accessed"]),
97
+ "duration_ms": r["duration_ms"],
98
+ }
99
+ for r in rows
100
+ ]
101
+
102
+ def clear(self) -> None:
103
+ if not self.enabled:
104
+ return
105
+ with self._session() as con:
106
+ con.execute("DELETE FROM audit_log")