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,403 @@
1
+ """Repository-scoped refactor transaction manager and atomic rollback engine."""
2
+ from __future__ import annotations
3
+
4
+ import fcntl
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import shutil
9
+ import time
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from codegraph.errors import SecurityError
15
+ from codegraph.refactor.models import (
16
+ RefactorPlan,
17
+ RefactorResult,
18
+ RefactorRisk,
19
+ RefactorStatus,
20
+ )
21
+ from codegraph.security.paths import is_sensitive_path, safe_path
22
+
23
+
24
+ class RefactorLockError(Exception):
25
+ """Raised when repository refactoring lock cannot be acquired."""
26
+
27
+
28
+ class RefactorLock:
29
+ """Repository-scoped file lock for atomic refactor operations."""
30
+
31
+ def __init__(self, repository: Path, timeout_seconds: float = 5.0) -> None:
32
+ self.repository = repository
33
+ self.lock_dir = repository / ".codegraph"
34
+ self.lock_file = self.lock_dir / "refactor.lock"
35
+ self.timeout_seconds = timeout_seconds
36
+ self._fd: int | None = None
37
+
38
+ def __enter__(self) -> RefactorLock:
39
+ self.acquire()
40
+ return self
41
+
42
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
43
+ self.release()
44
+
45
+ def acquire(self) -> None:
46
+ self.lock_dir.mkdir(parents=True, exist_ok=True)
47
+ start = time.time()
48
+ while True:
49
+ try:
50
+ # Open or create lockfile
51
+ self._fd = os.open(
52
+ str(self.lock_file),
53
+ os.O_RDWR | os.O_CREAT | os.O_TRUNC,
54
+ 0o644,
55
+ )
56
+ fcntl.flock(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
57
+ # Write current PID into lock file
58
+ os.write(self._fd, f"{os.getpid()}:{datetime.now(UTC).isoformat()}".encode())
59
+ return
60
+ except (OSError, BlockingIOError) as err:
61
+ if self._fd is not None:
62
+ try:
63
+ os.close(self._fd)
64
+ except OSError:
65
+ pass
66
+ self._fd = None
67
+ if time.time() - start >= self.timeout_seconds:
68
+ raise RefactorLockError(
69
+ f"CONCURRENT_REFACTOR_IN_PROGRESS: Could not acquire refactor lock for {self.repository} "
70
+ f"within {self.timeout_seconds}s."
71
+ ) from err
72
+ time.sleep(0.05)
73
+
74
+ def release(self) -> None:
75
+ if self._fd is not None:
76
+ try:
77
+ fcntl.flock(self._fd, fcntl.LOCK_UN)
78
+ os.close(self._fd)
79
+ except OSError:
80
+ pass
81
+ self._fd = None
82
+ if self.lock_file.exists():
83
+ try:
84
+ self.lock_file.unlink()
85
+ except OSError:
86
+ pass
87
+
88
+
89
+ def compute_sha256(content: str | bytes) -> str:
90
+ """Compute deterministic SHA-256 hash."""
91
+ if isinstance(content, str):
92
+ content = content.encode("utf-8")
93
+ return hashlib.sha256(content).hexdigest()
94
+
95
+
96
+ def get_manifests_dir(repository: Path) -> Path:
97
+ """Get or create the transaction manifests storage directory."""
98
+ manifests_dir = repository / ".codegraph" / "refactor_manifests"
99
+ manifests_dir.mkdir(parents=True, exist_ok=True)
100
+ return manifests_dir
101
+
102
+
103
+ def write_manifest(repository: Path, manifest_data: dict[str, Any]) -> Path:
104
+ """Write a persistent transaction manifest."""
105
+ manifests_dir = get_manifests_dir(repository)
106
+ manifest_path = manifests_dir / f"{manifest_data['transaction_id']}.json"
107
+ with open(manifest_path, "w", encoding="utf-8") as f:
108
+ json.dump(manifest_data, f, indent=2)
109
+ return manifest_path
110
+
111
+
112
+ def read_manifest(repository: Path, transaction_id: str) -> dict[str, Any] | None:
113
+ """Read a transaction manifest by ID."""
114
+ manifests_dir = get_manifests_dir(repository)
115
+ manifest_path = manifests_dir / f"{transaction_id}.json"
116
+ if not manifest_path.exists():
117
+ return None
118
+ try:
119
+ with open(manifest_path, encoding="utf-8") as f:
120
+ data = json.load(f)
121
+ if isinstance(data, dict):
122
+ return data
123
+ return None
124
+ except Exception:
125
+ return None
126
+
127
+
128
+ def commit_refactor_transaction(
129
+ repository: Path,
130
+ plan: RefactorPlan,
131
+ modified_buffers: dict[str, str],
132
+ ) -> RefactorResult:
133
+ """Atomically commit all modified file buffers to disk with full rollback manifest.
134
+
135
+ Invariants:
136
+ 1. Repository-scoped lock acquired.
137
+ 2. All original file hashes re-validated against plan.
138
+ 3. Safe path and boundary validation enforced.
139
+ 4. Modified contents written to sibling temporary files.
140
+ 5. Atomic replacement using os.replace().
141
+ 6. Rollback manifest persisted.
142
+ """
143
+ repo_root = repository.resolve()
144
+ temp_files_created: list[Path] = []
145
+ original_contents: dict[str, str] = {}
146
+ replacement_hashes: dict[str, str] = {}
147
+
148
+ try:
149
+ with RefactorLock(repo_root):
150
+ # 1. Precondition check: verify file existence, safety, and unchanged hashes
151
+ for rel_path in plan.files:
152
+ if is_sensitive_path(rel_path):
153
+ return RefactorResult(
154
+ status=RefactorStatus.BLOCKED,
155
+ target_symbol=plan.target_symbol,
156
+ new_name=plan.new_name,
157
+ risk=RefactorRisk.HIGH,
158
+ errors=[f"SENSITIVE_FILE_ACCESS_DENIED: File '{rel_path}' is sensitive."],
159
+ )
160
+
161
+ try:
162
+ abs_path = safe_path(repo_root, rel_path)
163
+ except SecurityError as sec_err:
164
+ return RefactorResult(
165
+ status=RefactorStatus.BLOCKED,
166
+ target_symbol=plan.target_symbol,
167
+ new_name=plan.new_name,
168
+ risk=RefactorRisk.HIGH,
169
+ errors=[f"SECURITY_ERROR: {sec_err}"],
170
+ )
171
+
172
+ if not abs_path.exists() or not abs_path.is_file():
173
+ return RefactorResult(
174
+ status=RefactorStatus.ERROR,
175
+ target_symbol=plan.target_symbol,
176
+ new_name=plan.new_name,
177
+ risk=RefactorRisk.HIGH,
178
+ errors=[f"FILE_NOT_FOUND: Target file '{rel_path}' does not exist on disk."],
179
+ )
180
+
181
+ current_bytes = abs_path.read_bytes()
182
+ current_hash = hashlib.sha256(current_bytes).hexdigest()
183
+ expected_hash = plan.original_file_hashes.get(rel_path)
184
+
185
+ if expected_hash and current_hash != expected_hash:
186
+ return RefactorResult(
187
+ status=RefactorStatus.ERROR,
188
+ target_symbol=plan.target_symbol,
189
+ new_name=plan.new_name,
190
+ risk=RefactorRisk.HIGH,
191
+ errors=[
192
+ f"FILE_CHANGED_SINCE_PLAN: File '{rel_path}' has been modified externally since plan generation. Please re-plan."
193
+ ],
194
+ )
195
+
196
+ try:
197
+ original_contents[rel_path] = current_bytes.decode("utf-8")
198
+ except UnicodeDecodeError:
199
+ original_contents[rel_path] = current_bytes.decode("latin1")
200
+
201
+ # 2. Stage all modifications to sibling temporary files
202
+ for rel_path in plan.files:
203
+ abs_path = safe_path(repo_root, rel_path)
204
+ parent_dir = abs_path.parent
205
+ temp_file = parent_dir / f".tmp_cg_refactor_{plan.transaction_id}_{abs_path.name}"
206
+ temp_files_created.append(temp_file)
207
+
208
+ new_text = modified_buffers[rel_path]
209
+ replacement_hashes[rel_path] = compute_sha256(new_text)
210
+
211
+ # Write content to sibling temp file
212
+ temp_file.write_text(new_text, encoding="utf-8")
213
+
214
+ # Preserve original permissions
215
+ try:
216
+ shutil.copymode(abs_path, temp_file)
217
+ except OSError:
218
+ pass
219
+
220
+ # 3. All temp files written successfully — atomically replace original files
221
+ for rel_path in plan.files:
222
+ abs_path = safe_path(repo_root, rel_path)
223
+ temp_file = abs_path.parent / f".tmp_cg_refactor_{plan.transaction_id}_{abs_path.name}"
224
+ os.replace(temp_file, abs_path)
225
+
226
+ # 4. Save transaction manifest
227
+ manifest = {
228
+ "transaction_id": plan.transaction_id,
229
+ "target_symbol": plan.target_symbol,
230
+ "new_name": plan.new_name,
231
+ "timestamp": datetime.now(UTC).isoformat(),
232
+ "status": "APPLIED",
233
+ "files": plan.files,
234
+ "original_hashes": plan.original_file_hashes,
235
+ "replacement_hashes": replacement_hashes,
236
+ "original_contents": original_contents,
237
+ "spans_count": len(plan.spans),
238
+ }
239
+ write_manifest(repo_root, manifest)
240
+
241
+ return RefactorResult(
242
+ status=RefactorStatus.APPLIED,
243
+ target_symbol=plan.target_symbol,
244
+ new_name=plan.new_name,
245
+ risk=plan.risk,
246
+ uncertainty_reasons=plan.uncertainty_reasons,
247
+ files_changed=len(plan.files),
248
+ symbols_changed=1,
249
+ spans_count=len(plan.spans),
250
+ tests_affected=plan.affected_tests,
251
+ routes_affected=plan.affected_routes,
252
+ db_affected=plan.affected_db_relationships,
253
+ rollback_id=plan.transaction_id,
254
+ )
255
+
256
+ except RefactorLockError as lock_err:
257
+ return RefactorResult(
258
+ status=RefactorStatus.BLOCKED,
259
+ target_symbol=plan.target_symbol,
260
+ new_name=plan.new_name,
261
+ risk=RefactorRisk.HIGH,
262
+ errors=[str(lock_err)],
263
+ )
264
+ except Exception as exc:
265
+ # Clean up any leftover temp files
266
+ for tmp in temp_files_created:
267
+ if tmp.exists():
268
+ try:
269
+ tmp.unlink()
270
+ except OSError:
271
+ pass
272
+ return RefactorResult(
273
+ status=RefactorStatus.ERROR,
274
+ target_symbol=plan.target_symbol,
275
+ new_name=plan.new_name,
276
+ risk=RefactorRisk.HIGH,
277
+ errors=[f"TRANSACTION_FAILED: {exc}"],
278
+ )
279
+
280
+
281
+ def rollback_refactor(repository: Path, transaction_id: str) -> RefactorResult:
282
+ """Atomically rollback an applied refactoring transaction.
283
+
284
+ Invariants:
285
+ 1. Repository-scoped lock acquired.
286
+ 2. Manifest must exist and be in APPLIED status.
287
+ 3. Current file hashes must match expected post-refactor hashes.
288
+ If any file has changed externally, rollback is BLOCKED.
289
+ 4. Restores original contents atomically using os.replace().
290
+ """
291
+ repo_root = repository.resolve()
292
+
293
+ manifest = read_manifest(repo_root, transaction_id)
294
+ if not manifest:
295
+ return RefactorResult(
296
+ status=RefactorStatus.ERROR,
297
+ target_symbol="",
298
+ new_name="",
299
+ risk=RefactorRisk.HIGH,
300
+ errors=[f"ROLLBACK_MANIFEST_NOT_FOUND: No transaction manifest found for '{transaction_id}'."],
301
+ )
302
+
303
+ if manifest.get("status") == "ROLLED_BACK":
304
+ return RefactorResult(
305
+ status=RefactorStatus.BLOCKED,
306
+ target_symbol=manifest.get("target_symbol", ""),
307
+ new_name=manifest.get("new_name", ""),
308
+ risk=RefactorRisk.LOW,
309
+ errors=[f"TRANSACTION_ALREADY_ROLLED_BACK: Transaction '{transaction_id}' was already rolled back."],
310
+ )
311
+
312
+ files = manifest.get("files", [])
313
+ replacement_hashes = manifest.get("replacement_hashes", {})
314
+ original_contents = manifest.get("original_contents", {})
315
+
316
+ temp_files_created: list[Path] = []
317
+
318
+ try:
319
+ with RefactorLock(repo_root):
320
+ # 1. Verify that current file hashes match expected replacement hashes
321
+ for rel_path in files:
322
+ abs_path = safe_path(repo_root, rel_path)
323
+ if not abs_path.exists():
324
+ return RefactorResult(
325
+ status=RefactorStatus.ERROR,
326
+ target_symbol=manifest.get("target_symbol", ""),
327
+ new_name=manifest.get("new_name", ""),
328
+ risk=RefactorRisk.HIGH,
329
+ errors=[f"FILE_NOT_FOUND_FOR_ROLLBACK: Target file '{rel_path}' no longer exists on disk."],
330
+ )
331
+
332
+ current_hash = compute_sha256(abs_path.read_bytes())
333
+ expected_hash = replacement_hashes.get(rel_path)
334
+
335
+ if expected_hash and current_hash != expected_hash:
336
+ return RefactorResult(
337
+ status=RefactorStatus.BLOCKED,
338
+ target_symbol=manifest.get("target_symbol", ""),
339
+ new_name=manifest.get("new_name", ""),
340
+ risk=RefactorRisk.HIGH,
341
+ errors=[
342
+ f"ROLLBACK_BLOCKED_EXTERNAL_MODIFICATION: File '{rel_path}' was modified externally after the refactor was applied. "
343
+ f"Rollback cannot safely overwrite subsequent changes."
344
+ ],
345
+ )
346
+
347
+ # 2. Stage original contents to sibling temporary files
348
+ for rel_path in files:
349
+ abs_path = safe_path(repo_root, rel_path)
350
+ temp_file = abs_path.parent / f".tmp_cg_rollback_{transaction_id}_{abs_path.name}"
351
+ temp_files_created.append(temp_file)
352
+
353
+ orig_text = original_contents.get(rel_path, "")
354
+ temp_file.write_text(orig_text, encoding="utf-8")
355
+ try:
356
+ shutil.copymode(abs_path, temp_file)
357
+ except OSError:
358
+ pass
359
+
360
+ # 3. Atomically restore original files
361
+ for rel_path in files:
362
+ abs_path = safe_path(repo_root, rel_path)
363
+ temp_file = abs_path.parent / f".tmp_cg_rollback_{transaction_id}_{abs_path.name}"
364
+ os.replace(temp_file, abs_path)
365
+
366
+ # 4. Mark manifest as ROLLED_BACK
367
+ manifest["status"] = "ROLLED_BACK"
368
+ manifest["rolled_back_at"] = datetime.now(UTC).isoformat()
369
+ write_manifest(repo_root, manifest)
370
+
371
+ return RefactorResult(
372
+ status=RefactorStatus.ROLLED_BACK,
373
+ target_symbol=manifest.get("target_symbol", ""),
374
+ new_name=manifest.get("new_name", ""),
375
+ risk=RefactorRisk.LOW,
376
+ files_changed=len(files),
377
+ symbols_changed=1,
378
+ spans_count=manifest.get("spans_count", 0),
379
+ rollback_id=transaction_id,
380
+ )
381
+
382
+ except RefactorLockError as lock_err:
383
+ return RefactorResult(
384
+ status=RefactorStatus.BLOCKED,
385
+ target_symbol=manifest.get("target_symbol", ""),
386
+ new_name=manifest.get("new_name", ""),
387
+ risk=RefactorRisk.HIGH,
388
+ errors=[str(lock_err)],
389
+ )
390
+ except Exception as exc:
391
+ for tmp in temp_files_created:
392
+ if tmp.exists():
393
+ try:
394
+ tmp.unlink()
395
+ except OSError:
396
+ pass
397
+ return RefactorResult(
398
+ status=RefactorStatus.ERROR,
399
+ target_symbol=manifest.get("target_symbol", ""),
400
+ new_name=manifest.get("new_name", ""),
401
+ risk=RefactorRisk.HIGH,
402
+ errors=[f"ROLLBACK_FAILED: {exc}"],
403
+ )