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,844 @@
1
+ """Deterministic, syntax-validated, transaction-safe AST symbol renaming engine.
2
+
3
+ Core Invariant:
4
+ DISCOVER -> PLAN -> PREVIEW -> VALIDATE -> COMMIT -> REINDEX -> VERIFY
5
+
6
+ Zero disk mutation during DISCOVER, PLAN, PREVIEW, or VALIDATE.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import difflib
12
+ import hashlib
13
+ import io
14
+ import keyword
15
+ import re
16
+ import sqlite3
17
+ import tokenize
18
+ import uuid
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from codegraph.indexing.indexer import Indexer
23
+ from codegraph.refactor.models import (
24
+ RefactorPlan,
25
+ RefactorResult,
26
+ RefactorRisk,
27
+ RefactorStatus,
28
+ TokenReplacementSpan,
29
+ )
30
+ from codegraph.refactor.transactions import commit_refactor_transaction
31
+ from codegraph.security.paths import safe_path
32
+ from codegraph.target_resolver import resolve_target
33
+
34
+ _TEST_FILE_RE = re.compile(
35
+ r"(^|[_/])test[_s]?[_/]|[_/]spec[_/]|test[_s]?\.(py|pyi)$|spec\.(py|pyi)$",
36
+ re.IGNORECASE,
37
+ )
38
+
39
+
40
+ def is_test_file(path: str) -> bool:
41
+ """Check if file path belongs to test suite."""
42
+ clean = path.replace("\\", "/")
43
+ return bool(_TEST_FILE_RE.search(clean))
44
+
45
+
46
+ def apply_spans_to_text(text: str, spans: list[TokenReplacementSpan]) -> str:
47
+ """Deterministically apply replacement spans to text buffer in reverse order.
48
+
49
+ Sorting spans by (start_line, start_col) descending ensures that replacements
50
+ later in the file/line do not shift coordinates for replacements earlier in the file/line.
51
+ Comments, docstrings, indentation, unrelated formatting, and CRLF/LF line endings
52
+ are preserved 100%.
53
+ """
54
+ lines = text.splitlines(keepends=True)
55
+ sorted_spans = sorted(
56
+ spans,
57
+ key=lambda s: (s.start_line, s.start_col),
58
+ reverse=True,
59
+ )
60
+ for span in sorted_spans:
61
+ line_idx = span.start_line - 1
62
+ if line_idx < 0 or line_idx >= len(lines):
63
+ raise IndexError(
64
+ f"Span line {span.start_line} out of range (1..{len(lines)}) for span {span}"
65
+ )
66
+ line = lines[line_idx]
67
+ col_start = span.start_col
68
+ col_end = span.end_col
69
+ actual_token = line[col_start:col_end]
70
+ if actual_token != span.old_token:
71
+ raise ValueError(
72
+ f"Token mismatch at line {span.start_line}, cols {col_start}..{col_end}: "
73
+ f"expected '{span.old_token}', found '{actual_token}'"
74
+ )
75
+ lines[line_idx] = line[:col_start] + span.new_token + line[col_end:]
76
+ return "".join(lines)
77
+
78
+
79
+ def generate_unified_diff(file_path: str, original: str, modified: str) -> str:
80
+ """Generate deterministic unified diff."""
81
+ original_lines = original.splitlines(keepends=True)
82
+ modified_lines = modified.splitlines(keepends=True)
83
+ diff = difflib.unified_diff(
84
+ original_lines,
85
+ modified_lines,
86
+ fromfile=f"a/{file_path}",
87
+ tofile=f"b/{file_path}",
88
+ )
89
+ return "".join(diff)
90
+
91
+
92
+ class PythonSpanCollector:
93
+ """Collects token replacement spans from a Python source file using AST + tokenizer."""
94
+
95
+ def __init__(
96
+ self,
97
+ content: str,
98
+ path: str,
99
+ target_name: str,
100
+ new_name: str,
101
+ target_canonical_id: str,
102
+ target_kind: str,
103
+ target_class_name: str | None = None,
104
+ is_definition_file: bool = False,
105
+ is_from_imported: bool = False,
106
+ import_alias: str | None = None,
107
+ is_module_imported: bool = False,
108
+ module_alias: str | None = None,
109
+ verified_call_lines: set[int] | None = None,
110
+ target_line: int | None = None,
111
+ ) -> None:
112
+ self.content = content
113
+ self.path = path
114
+ self.target_name = target_name
115
+ self.new_name = new_name
116
+ self.target_canonical_id = target_canonical_id
117
+ self.target_kind = target_kind
118
+ self.target_class_name = target_class_name
119
+ self.is_definition_file = is_definition_file
120
+ self.is_from_imported = is_from_imported
121
+ self.import_alias = import_alias
122
+ self.is_module_imported = is_module_imported
123
+ self.module_alias = module_alias
124
+ self.verified_call_lines = verified_call_lines or set()
125
+ self.target_line = target_line
126
+ self.spans: list[TokenReplacementSpan] = []
127
+
128
+ def collect(self) -> list[TokenReplacementSpan]:
129
+ try:
130
+ tokens = list(tokenize.tokenize(io.BytesIO(self.content.encode("utf-8")).readline))
131
+ tree = ast.parse(self.content)
132
+ except Exception:
133
+ return []
134
+
135
+ # 1. Definition span in definition file
136
+ if self.is_definition_file:
137
+ self._collect_definition(tree, tokens)
138
+
139
+ # 2. From-import statement spans
140
+ if self.is_from_imported:
141
+ self._collect_from_imports(tree, tokens)
142
+
143
+ # 3. Direct identifier references (ast.Name)
144
+ # When target is function/class in definition file or imported via `from mod import target` (unaliased)
145
+ if (self.is_definition_file and self.target_kind in ("function", "class")) or (
146
+ self.is_from_imported and self.import_alias is None
147
+ ):
148
+ self._collect_name_references(tree, tokens)
149
+
150
+ # 4. Attribute references (e.g. mod.target or obj.method)
151
+ self._collect_attribute_references(tree, tokens)
152
+
153
+ # De-duplicate spans deterministically by (start_line, start_col, end_line, end_col)
154
+ seen: set[tuple[int, int, int, int]] = set()
155
+ unique_spans: list[TokenReplacementSpan] = []
156
+ for span in sorted(self.spans, key=lambda s: (s.start_line, s.start_col)):
157
+ key = (span.start_line, span.start_col, span.end_line, span.end_col)
158
+ if key not in seen:
159
+ seen.add(key)
160
+ unique_spans.append(span)
161
+ return unique_spans
162
+
163
+ def _collect_definition(self, tree: ast.AST, tokens: list[tokenize.TokenInfo]) -> None:
164
+ target_nodes: list[ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef] = []
165
+ for node in ast.walk(tree):
166
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
167
+ if node.name == self.target_name:
168
+ if self.target_line is not None:
169
+ # Allow matching on or near target_line
170
+ node_end = getattr(node, "end_lineno", node.lineno)
171
+ if node.lineno <= self.target_line <= node_end or abs(node.lineno - self.target_line) <= 2:
172
+ target_nodes.append(node)
173
+ else:
174
+ target_nodes.append(node)
175
+
176
+ for node in target_nodes:
177
+ # Find the NAME token for node.name on node.lineno or following 'def' / 'class'
178
+ for tok in tokens:
179
+ if tok.type == tokenize.NAME and tok.string == self.target_name and tok.start[0] == node.lineno:
180
+ self.spans.append(
181
+ TokenReplacementSpan(
182
+ path=self.path,
183
+ start_line=tok.start[0],
184
+ start_col=tok.start[1],
185
+ end_line=tok.end[0],
186
+ end_col=tok.end[1],
187
+ old_token=self.target_name,
188
+ new_token=self.new_name,
189
+ evidence="AST_VERIFIED: definition",
190
+ symbol_id=self.target_canonical_id,
191
+ )
192
+ )
193
+ break
194
+
195
+ def _collect_from_imports(self, tree: ast.AST, tokens: list[tokenize.TokenInfo]) -> None:
196
+ for node in ast.walk(tree):
197
+ if isinstance(node, ast.ImportFrom):
198
+ for alias in node.names:
199
+ if alias.name == self.target_name:
200
+ alias_line = getattr(alias, "lineno", node.lineno)
201
+ col_offset = getattr(alias, "col_offset", 0)
202
+ for tok in tokens:
203
+ if (
204
+ tok.type == tokenize.NAME
205
+ and tok.string == self.target_name
206
+ and tok.start[0] == alias_line
207
+ and tok.start[1] >= col_offset
208
+ ):
209
+ self.spans.append(
210
+ TokenReplacementSpan(
211
+ path=self.path,
212
+ start_line=tok.start[0],
213
+ start_col=tok.start[1],
214
+ end_line=tok.end[0],
215
+ end_col=tok.end[1],
216
+ old_token=self.target_name,
217
+ new_token=self.new_name,
218
+ evidence="AST_VERIFIED: from-import",
219
+ symbol_id=self.target_canonical_id,
220
+ )
221
+ )
222
+ break
223
+
224
+ def _collect_name_references(self, tree: ast.AST, tokens: list[tokenize.TokenInfo]) -> None:
225
+ target_name = self.target_name
226
+ new_name = self.new_name
227
+ path = self.path
228
+ target_canonical_id = self.target_canonical_id
229
+ target_line = self.target_line if self.is_definition_file else None
230
+
231
+ class ScopeVisitor(ast.NodeVisitor):
232
+ def __init__(self, outer: PythonSpanCollector) -> None:
233
+ self.outer = outer
234
+ self.scopes: list[set[str]] = [set()]
235
+
236
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
237
+ # Local parameter scope
238
+ local_params = {arg.arg for arg in node.args.args}
239
+ if node.args.vararg:
240
+ local_params.add(node.args.vararg.arg)
241
+ if node.args.kwarg:
242
+ local_params.add(node.args.kwarg.arg)
243
+ for arg in node.args.kwonlyargs:
244
+ local_params.add(arg.arg)
245
+ self.scopes.append(local_params)
246
+ self.generic_visit(node)
247
+ self.scopes.pop()
248
+
249
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
250
+ local_params = {arg.arg for arg in node.args.args}
251
+ if node.args.vararg:
252
+ local_params.add(node.args.vararg.arg)
253
+ if node.args.kwarg:
254
+ local_params.add(node.args.kwarg.arg)
255
+ for arg in node.args.kwonlyargs:
256
+ local_params.add(arg.arg)
257
+ self.scopes.append(local_params)
258
+ self.generic_visit(node)
259
+ self.scopes.pop()
260
+
261
+ def visit_Name(self, node: ast.Name) -> None:
262
+ if node.id == target_name:
263
+ # Skip definition itself if target_line matches
264
+ if target_line is not None and node.lineno == target_line:
265
+ return
266
+ # Check if shadowed in current local scope
267
+ is_shadowed = any(target_name in s for s in self.scopes[1:])
268
+ if not is_shadowed:
269
+ self.outer.spans.append(
270
+ TokenReplacementSpan(
271
+ path=path,
272
+ start_line=node.lineno,
273
+ start_col=node.col_offset,
274
+ end_line=getattr(node, "end_lineno", node.lineno),
275
+ end_col=getattr(node, "end_col_offset", node.col_offset + len(target_name)),
276
+ old_token=target_name,
277
+ new_token=new_name,
278
+ evidence="AST_VERIFIED: name reference",
279
+ symbol_id=target_canonical_id,
280
+ )
281
+ )
282
+ self.generic_visit(node)
283
+
284
+ ScopeVisitor(self).visit(tree)
285
+
286
+ def _collect_attribute_references(self, tree: ast.AST, tokens: list[tokenize.TokenInfo]) -> None:
287
+ for node in ast.walk(tree):
288
+ if isinstance(node, ast.Attribute) and node.attr == self.target_name:
289
+ should_rename = False
290
+
291
+ # Case A: Module import (e.g. module.target or alias.target)
292
+ if self.is_module_imported:
293
+ expected_mod = self.module_alias or self.path.split("/")[-1].replace(".py", "")
294
+ if isinstance(node.value, ast.Name) and (
295
+ node.value.id == expected_mod or (self.module_alias and node.value.id == self.module_alias)
296
+ ):
297
+ should_rename = True
298
+
299
+ # Case B: Verified method call on instance (e.g. self.target or obj.target)
300
+ if self.target_kind == "method":
301
+ if self.is_definition_file and isinstance(node.value, ast.Name) and node.value.id in ("self", "cls"):
302
+ should_rename = True
303
+ elif node.lineno in self.verified_call_lines:
304
+ should_rename = True
305
+
306
+ # Case C: Explicitly verified call line
307
+ if node.lineno in self.verified_call_lines:
308
+ should_rename = True
309
+
310
+ if should_rename:
311
+ # Find exact token for attribute name
312
+ end_line = getattr(node, "end_lineno", node.lineno)
313
+ end_col = getattr(node, "end_col_offset", node.col_offset + len(self.target_name))
314
+ for tok in tokens:
315
+ if (
316
+ tok.type == tokenize.NAME
317
+ and tok.string == self.target_name
318
+ and tok.end == (end_line, end_col)
319
+ ):
320
+ self.spans.append(
321
+ TokenReplacementSpan(
322
+ path=self.path,
323
+ start_line=tok.start[0],
324
+ start_col=tok.start[1],
325
+ end_line=tok.end[0],
326
+ end_col=tok.end[1],
327
+ old_token=self.target_name,
328
+ new_token=self.new_name,
329
+ evidence="AST_VERIFIED: attribute access",
330
+ symbol_id=self.target_canonical_id,
331
+ )
332
+ )
333
+ break
334
+
335
+
336
+ def plan_safe_rename(
337
+ repository: Path,
338
+ con: sqlite3.Connection,
339
+ target: str,
340
+ new_name: str,
341
+ force_uncertain: bool = False,
342
+ ) -> RefactorPlan:
343
+ """Compute a deterministic AST refactoring plan with uncertainty auditing.
344
+
345
+ Invariants:
346
+ 1. Target symbol must resolve unambiguously to a known symbol.
347
+ 2. new_name must be a valid Python identifier and not a reserved keyword.
348
+ 3. Audit all references across calls, imports, and graph edges for uncertainty.
349
+ 4. Compute TokenReplacementSpan items with token-level precision.
350
+ 5. No disk mutation during plan generation.
351
+ """
352
+ repo_root = repository.resolve()
353
+ transaction_id = f"rf_{uuid.uuid4().hex[:12]}"
354
+
355
+ # 1. Identifier validation
356
+ if not new_name.isidentifier() or keyword.iskeyword(new_name):
357
+ return RefactorPlan(
358
+ transaction_id=transaction_id,
359
+ target_symbol=target,
360
+ new_name=new_name,
361
+ risk=RefactorRisk.HIGH,
362
+ uncertainty_reasons=[f"INVALID_IDENTIFIER: '{new_name}' is not a valid Python identifier or is a reserved keyword."],
363
+ )
364
+
365
+ # 2. Target resolution
366
+ target_res = resolve_target(target, con)
367
+
368
+ if target_res.ambiguity_state == "AMBIGUOUS" or len(target_res.alternatives) > 1:
369
+ candidates_str = ", ".join(
370
+ str(alt.get("canonical_id") or alt.get("qualified_name") or alt.get("path"))
371
+ for alt in target_res.alternatives[:5]
372
+ )
373
+ return RefactorPlan(
374
+ transaction_id=transaction_id,
375
+ target_symbol=target,
376
+ new_name=new_name,
377
+ risk=RefactorRisk.HIGH,
378
+ uncertainty_reasons=[
379
+ f"AMBIGUOUS_TARGET: Target '{target}' matches multiple candidates ({len(target_res.alternatives)} found): [{candidates_str}]. Please qualify with module or class name."
380
+ ],
381
+ )
382
+
383
+ if not target_res.canonical_id or target_res.confidence not in ("HIGH", "MEDIUM"):
384
+ return RefactorPlan(
385
+ transaction_id=transaction_id,
386
+ target_symbol=target,
387
+ new_name=new_name,
388
+ risk=RefactorRisk.HIGH,
389
+ uncertainty_reasons=[f"TARGET_NOT_FOUND: Symbol '{target}' could not be resolved in the repository."],
390
+ )
391
+
392
+ target_canonical_id = target_res.canonical_id
393
+
394
+ # Fetch symbol record from DB
395
+ row = con.execute(
396
+ "SELECT name, qualified_name, kind, path, start_line, end_line, module, scope, canonical_id "
397
+ "FROM symbols WHERE canonical_id=? LIMIT 1",
398
+ (target_canonical_id,),
399
+ ).fetchone()
400
+
401
+ if not row:
402
+ # Fallback query by path & start_line if canonical_id differs slightly
403
+ if target_res.file_path and target_res.line:
404
+ row = con.execute(
405
+ "SELECT name, qualified_name, kind, path, start_line, end_line, module, scope, canonical_id "
406
+ "FROM symbols WHERE path=? AND start_line=? LIMIT 1",
407
+ (target_res.file_path, target_res.line),
408
+ ).fetchone()
409
+
410
+ if not row:
411
+ return RefactorPlan(
412
+ transaction_id=transaction_id,
413
+ target_symbol=target,
414
+ new_name=new_name,
415
+ risk=RefactorRisk.HIGH,
416
+ uncertainty_reasons=[f"SYMBOL_RECORD_MISSING: No database record found for canonical ID '{target_canonical_id}'."],
417
+ )
418
+
419
+ target_name = str(row["name"])
420
+ target_kind = str(row["kind"])
421
+ target_path = str(row["path"])
422
+ target_line = int(row["start_line"])
423
+ target_module = str(row["module"])
424
+
425
+ if new_name == target_name:
426
+ return RefactorPlan(
427
+ transaction_id=transaction_id,
428
+ target_symbol=target,
429
+ new_name=new_name,
430
+ risk=RefactorRisk.LOW,
431
+ uncertainty_reasons=[f"NOOP_RENAME: New name '{new_name}' is identical to current name."],
432
+ )
433
+
434
+ # Determine parent class name if target is a method
435
+ target_class_name: str | None = None
436
+ if target_kind == "method":
437
+ scope_str = str(row["scope"] or "")
438
+ if scope_str:
439
+ target_class_name = scope_str.split(".")[-1]
440
+ elif "." in str(row["qualified_name"]):
441
+ parts = str(row["qualified_name"]).split(".")
442
+ if len(parts) >= 2:
443
+ target_class_name = parts[-2]
444
+
445
+ # 3. Uncertainty Auditing
446
+ uncertainty_reasons: list[str] = []
447
+ assessed_risk = RefactorRisk.LOW
448
+
449
+ # Check for unverified or low-confidence calls to target_name across the repo
450
+ try:
451
+ call_rows = con.execute(
452
+ "SELECT source_path, line, confidence, callee, qualified_callee, resolved_symbol_id "
453
+ "FROM calls WHERE (callee=? OR qualified_callee LIKE ?) AND source_path != ?",
454
+ (target_name, f"%.{target_name}", target_path),
455
+ ).fetchall()
456
+ for c in call_rows:
457
+ conf = str(c["confidence"] or "UNKNOWN").upper()
458
+ resolved = c["resolved_symbol_id"]
459
+ if resolved and resolved != target_canonical_id:
460
+ # Belongs to a different verified symbol (homonym) - safe to ignore
461
+ continue
462
+ if not resolved or conf in ("LOW", "UNKNOWN"):
463
+ uncertainty_reasons.append(
464
+ f"POSSIBLE_REFERENCE: Call site at {c['source_path']}:{c['line']} to '{target_name}' has unverified receiver type (confidence={conf})."
465
+ )
466
+ if assessed_risk != RefactorRisk.HIGH:
467
+ assessed_risk = RefactorRisk.MEDIUM
468
+ except sqlite3.OperationalError:
469
+ pass
470
+
471
+ # Check references table for unverified or possible relationships
472
+ try:
473
+ ref_rows = con.execute(
474
+ "SELECT path, start_line, relationship, confidence "
475
+ "FROM 'references' WHERE target_symbol_id=? OR (relationship='POSSIBLE_CALLS' AND evidence LIKE ?)",
476
+ (target_canonical_id, f"%{target_name}%"),
477
+ ).fetchall()
478
+ for r in ref_rows:
479
+ rel = str(r["relationship"])
480
+ conf = str(r["confidence"]).upper()
481
+ if rel == "POSSIBLE_CALLS" or conf in ("LOW", "UNKNOWN"):
482
+ uncertainty_reasons.append(
483
+ f"POSSIBLE_RELATIONSHIP: Reference at {r['path']}:{r['start_line']} has relationship={rel}, confidence={conf}."
484
+ )
485
+ assessed_risk = RefactorRisk.HIGH
486
+ except sqlite3.OperationalError:
487
+ pass
488
+
489
+ # 4. Discover affected files and collect replacement spans
490
+ affected_files: set[str] = {target_path}
491
+ spans: list[TokenReplacementSpan] = []
492
+
493
+ # A. Definition file spans
494
+ def_abs_path = safe_path(repo_root, target_path)
495
+ if def_abs_path.exists():
496
+ def_content = def_abs_path.read_bytes().decode("utf-8", errors="replace")
497
+ collector = PythonSpanCollector(
498
+ content=def_content,
499
+ path=target_path,
500
+ target_name=target_name,
501
+ new_name=new_name,
502
+ target_canonical_id=target_canonical_id,
503
+ target_kind=target_kind,
504
+ target_class_name=target_class_name,
505
+ is_definition_file=True,
506
+ target_line=target_line,
507
+ )
508
+ spans.extend(collector.collect())
509
+
510
+ # B. Discover importing files
511
+ importing_files_info: dict[str, dict[str, Any]] = {}
512
+ try:
513
+ imp_rows = con.execute(
514
+ "SELECT source_path, module, name, alias, line, local_name, resolved_path "
515
+ "FROM imports WHERE (resolved_path=? OR module=? OR module LIKE ?)",
516
+ (target_path, target_module, f"%.{target_module}"),
517
+ ).fetchall()
518
+ for imp in imp_rows:
519
+ src = str(imp["source_path"])
520
+ if src == target_path:
521
+ continue
522
+ name = imp["name"]
523
+ alias = imp["alias"]
524
+ mod = imp["module"]
525
+
526
+ if src not in importing_files_info:
527
+ importing_files_info[src] = {
528
+ "is_from_imported": False,
529
+ "import_alias": None,
530
+ "is_module_imported": False,
531
+ "module_alias": None,
532
+ "verified_lines": set(),
533
+ }
534
+
535
+ if name == target_name:
536
+ importing_files_info[src]["is_from_imported"] = True
537
+ if alias:
538
+ importing_files_info[src]["import_alias"] = str(alias)
539
+ elif not name and (mod == target_module or mod.endswith(f".{target_module}")):
540
+ importing_files_info[src]["is_module_imported"] = True
541
+ if alias:
542
+ importing_files_info[src]["module_alias"] = str(alias)
543
+ except sqlite3.OperationalError:
544
+ pass
545
+
546
+ # C. Discover verified call sites across other files
547
+ try:
548
+ call_sites = con.execute(
549
+ "SELECT source_path, line FROM calls WHERE resolved_symbol_id=? AND source_path != ?",
550
+ (target_canonical_id, target_path),
551
+ ).fetchall()
552
+ for cs in call_sites:
553
+ src = str(cs["source_path"])
554
+ line = int(cs["line"])
555
+ if src not in importing_files_info:
556
+ importing_files_info[src] = {
557
+ "is_from_imported": False,
558
+ "import_alias": None,
559
+ "is_module_imported": False,
560
+ "module_alias": None,
561
+ "verified_lines": set(),
562
+ }
563
+ importing_files_info[src]["verified_lines"].add(line)
564
+ except sqlite3.OperationalError:
565
+ pass
566
+
567
+ # D. Collect spans for all importing and referencing files
568
+ for file_path, info in sorted(importing_files_info.items()):
569
+ abs_p = safe_path(repo_root, file_path)
570
+ if not abs_p.exists():
571
+ continue
572
+ affected_files.add(file_path)
573
+ content = abs_p.read_bytes().decode("utf-8", errors="replace")
574
+ collector = PythonSpanCollector(
575
+ content=content,
576
+ path=file_path,
577
+ target_name=target_name,
578
+ new_name=new_name,
579
+ target_canonical_id=target_canonical_id,
580
+ target_kind=target_kind,
581
+ target_class_name=target_class_name,
582
+ is_definition_file=False,
583
+ is_from_imported=info["is_from_imported"],
584
+ import_alias=info["import_alias"],
585
+ is_module_imported=info["is_module_imported"],
586
+ module_alias=info["module_alias"],
587
+ verified_call_lines=info["verified_lines"],
588
+ )
589
+ file_spans = collector.collect()
590
+ spans.extend(file_spans)
591
+
592
+ # 5. Compute original file hashes, sizes, and mtimes
593
+ orig_hashes: dict[str, str] = {}
594
+ orig_sizes: dict[str, int] = {}
595
+ orig_mtimes: dict[str, float] = {}
596
+
597
+ for rel_path in sorted(affected_files):
598
+ abs_p = safe_path(repo_root, rel_path)
599
+ if abs_p.exists():
600
+ raw_bytes = abs_p.read_bytes()
601
+ orig_hashes[rel_path] = hashlib.sha256(raw_bytes).hexdigest()
602
+ orig_sizes[rel_path] = len(raw_bytes)
603
+ orig_mtimes[rel_path] = abs_p.stat().st_mtime
604
+
605
+ # 6. Query affected tests, routes, and DB impact
606
+ affected_tests: list[str] = []
607
+ affected_routes: list[str] = []
608
+ affected_db_rel: list[str] = []
609
+
610
+ # Tests
611
+ for f in affected_files:
612
+ if is_test_file(f):
613
+ affected_tests.append(f)
614
+ try:
615
+ t_rows = con.execute(
616
+ "SELECT DISTINCT source FROM graph_edges WHERE target=? AND relationship='TESTS'",
617
+ (target_canonical_id,),
618
+ ).fetchall()
619
+ for tr in t_rows:
620
+ affected_tests.append(str(tr["source"]))
621
+ except sqlite3.OperationalError:
622
+ pass
623
+
624
+ # Routes
625
+ try:
626
+ r_rows = con.execute(
627
+ "SELECT DISTINCT route_path FROM framework_routes WHERE handler_canonical_id=? OR handler_name=?",
628
+ (target_canonical_id, target_name),
629
+ ).fetchall()
630
+ for rr in r_rows:
631
+ affected_routes.append(str(rr["route_path"]))
632
+ except sqlite3.OperationalError:
633
+ pass
634
+
635
+ # DB relationships
636
+ try:
637
+ db_rows = con.execute(
638
+ "SELECT DISTINCT name FROM db_entities WHERE canonical_id=?",
639
+ (target_canonical_id,),
640
+ ).fetchall()
641
+ for dbr in db_rows:
642
+ affected_db_rel.append(str(dbr["name"]))
643
+ except sqlite3.OperationalError:
644
+ pass
645
+
646
+ # Deterministic sorting
647
+ sorted_files = sorted(affected_files)
648
+ sorted_spans = sorted(spans, key=lambda s: (s.path, s.start_line, s.start_col))
649
+
650
+ return RefactorPlan(
651
+ transaction_id=transaction_id,
652
+ target_symbol=target_canonical_id,
653
+ new_name=new_name,
654
+ files=sorted_files,
655
+ spans=sorted_spans,
656
+ risk=assessed_risk,
657
+ uncertainty_reasons=sorted(set(uncertainty_reasons)),
658
+ affected_tests=sorted(set(affected_tests)),
659
+ affected_routes=sorted(set(affected_routes)),
660
+ affected_db_relationships=sorted(set(affected_db_rel)),
661
+ original_file_hashes=orig_hashes,
662
+ original_file_sizes=orig_sizes,
663
+ original_file_mtimes=orig_mtimes,
664
+ )
665
+
666
+
667
+ def preview_safe_rename(
668
+ repository: Path,
669
+ plan: RefactorPlan,
670
+ ) -> tuple[dict[str, str], dict[str, str], list[str]]:
671
+ """In-memory preview and syntax validation.
672
+
673
+ Returns:
674
+ (diffs, modified_buffers, syntax_errors)
675
+
676
+ Invariants:
677
+ 1. Zero disk mutation.
678
+ 2. ast.parse() executed on all modified Python buffers.
679
+ 3. If any file fails syntax validation, returns syntax error.
680
+ """
681
+ repo_root = repository.resolve()
682
+ diffs: dict[str, str] = {}
683
+ modified_buffers: dict[str, str] = {}
684
+ errors: list[str] = []
685
+
686
+ # Group spans by file
687
+ spans_by_file: dict[str, list[TokenReplacementSpan]] = {f: [] for f in plan.files}
688
+ for s in plan.spans:
689
+ if s.path in spans_by_file:
690
+ spans_by_file[s.path].append(s)
691
+
692
+ for rel_path in plan.files:
693
+ abs_p = safe_path(repo_root, rel_path)
694
+ if not abs_p.exists():
695
+ errors.append(f"FILE_NOT_FOUND: '{rel_path}' does not exist on disk.")
696
+ continue
697
+
698
+ orig_text = abs_p.read_bytes().decode("utf-8", errors="replace")
699
+ file_spans = spans_by_file.get(rel_path, [])
700
+
701
+ if not file_spans:
702
+ # File had no spans, buffer remains unchanged
703
+ modified_buffers[rel_path] = orig_text
704
+ continue
705
+
706
+ try:
707
+ modified_text = apply_spans_to_text(orig_text, file_spans)
708
+ except Exception as exc:
709
+ errors.append(f"SPAN_APPLICATION_FAILED: Failed to apply spans to '{rel_path}': {exc}")
710
+ continue
711
+
712
+ # In-memory syntax validation
713
+ try:
714
+ ast.parse(modified_text, filename=rel_path)
715
+ except SyntaxError as syn_err:
716
+ errors.append(
717
+ f"SYNTAX_VALIDATION_FAILED: File '{rel_path}' has syntax error after replacement: {syn_err}"
718
+ )
719
+ continue
720
+
721
+ modified_buffers[rel_path] = modified_text
722
+ diff = generate_unified_diff(rel_path, orig_text, modified_text)
723
+ if diff:
724
+ diffs[rel_path] = diff
725
+
726
+ return diffs, modified_buffers, sorted(errors)
727
+
728
+
729
+ def execute_safe_rename(
730
+ repository: Path,
731
+ con: sqlite3.Connection,
732
+ target: str,
733
+ new_name: str,
734
+ dry_run: bool = True,
735
+ force_uncertain: bool = False,
736
+ ) -> RefactorResult:
737
+ """Execute the complete SAFE_RENAME workflow.
738
+
739
+ Workflow:
740
+ DISCOVER -> PLAN -> PREVIEW -> VALIDATE -> COMMIT -> REINDEX -> VERIFY
741
+
742
+ Invariants:
743
+ 1. dry_run=True performs zero disk mutation and returns preview packet.
744
+ 2. Any syntax validation failure aborts with status BLOCKED/ERROR.
745
+ 3. If risk != LOW and not force_uncertain, operation is BLOCKED.
746
+ 4. Post-commit triggers incremental index update and verifies new symbol.
747
+ """
748
+ repo_root = repository.resolve()
749
+
750
+ # 1. Plan
751
+ plan = plan_safe_rename(repo_root, con, target, new_name, force_uncertain=force_uncertain)
752
+
753
+ # Check for hard errors during plan
754
+ if plan.uncertainty_reasons and any(
755
+ r.startswith(("INVALID_IDENTIFIER", "TARGET_NOT_FOUND", "AMBIGUOUS_TARGET", "NOOP_RENAME"))
756
+ for r in plan.uncertainty_reasons
757
+ ):
758
+ return RefactorResult(
759
+ status=RefactorStatus.BLOCKED,
760
+ target_symbol=target,
761
+ new_name=new_name,
762
+ risk=plan.risk,
763
+ uncertainty_reasons=plan.uncertainty_reasons,
764
+ errors=plan.uncertainty_reasons,
765
+ )
766
+
767
+ # 2. In-memory preview & syntax validation
768
+ diffs, modified_buffers, syntax_errors = preview_safe_rename(repo_root, plan)
769
+
770
+ if syntax_errors:
771
+ return RefactorResult(
772
+ status=RefactorStatus.BLOCKED,
773
+ target_symbol=plan.target_symbol,
774
+ new_name=new_name,
775
+ risk=RefactorRisk.HIGH,
776
+ uncertainty_reasons=plan.uncertainty_reasons,
777
+ files_changed=len(plan.files),
778
+ spans_count=len(plan.spans),
779
+ diffs=diffs,
780
+ errors=syntax_errors,
781
+ )
782
+
783
+ # 3. Risk-based blocking
784
+ if plan.risk != RefactorRisk.LOW and not force_uncertain:
785
+ return RefactorResult(
786
+ status=RefactorStatus.BLOCKED,
787
+ target_symbol=plan.target_symbol,
788
+ new_name=new_name,
789
+ risk=plan.risk,
790
+ uncertainty_reasons=plan.uncertainty_reasons,
791
+ files_changed=len(plan.files),
792
+ spans_count=len(plan.spans),
793
+ tests_affected=plan.affected_tests,
794
+ routes_affected=plan.affected_routes,
795
+ db_affected=plan.affected_db_relationships,
796
+ diffs=diffs,
797
+ errors=[
798
+ f"REFACTOR_BLOCKED_DUE_TO_UNCERTAINTY: Risk is {plan.risk}. "
799
+ f"Use force_uncertain=True to apply despite uncertainty reasons: {plan.uncertainty_reasons}"
800
+ ],
801
+ )
802
+
803
+ # 4. Dry-run mode: return complete preview packet with zero disk mutation
804
+ if dry_run:
805
+ # Precondition check: verify that files on disk still match original hashes
806
+ for rel_p, orig_hash in plan.original_file_hashes.items():
807
+ abs_p = safe_path(repo_root, rel_p)
808
+ if abs_p.exists():
809
+ curr_hash = hashlib.sha256(abs_p.read_bytes()).hexdigest()
810
+ assert curr_hash == orig_hash, f"In-memory preview mutated file {rel_p}!"
811
+
812
+ return RefactorResult(
813
+ status=RefactorStatus.READY,
814
+ target_symbol=plan.target_symbol,
815
+ new_name=new_name,
816
+ risk=plan.risk,
817
+ uncertainty_reasons=plan.uncertainty_reasons,
818
+ files_changed=len(diffs),
819
+ symbols_changed=1 if plan.spans else 0,
820
+ spans_count=len(plan.spans),
821
+ tests_affected=plan.affected_tests,
822
+ routes_affected=plan.affected_routes,
823
+ db_affected=plan.affected_db_relationships,
824
+ diffs=diffs,
825
+ errors=[],
826
+ )
827
+
828
+ # 5. Apply mode: atomic commit transaction
829
+ commit_res = commit_refactor_transaction(repo_root, plan, modified_buffers)
830
+
831
+ if commit_res.status != RefactorStatus.APPLIED:
832
+ commit_res.diffs = diffs
833
+ return commit_res
834
+
835
+ # 6. Post-commit: trigger incremental reindexing & verification
836
+ try:
837
+ indexer = Indexer(repo_root)
838
+ indexer.index()
839
+ except Exception as idx_err:
840
+ # Commit succeeded, reindexing had warning
841
+ commit_res.errors.append(f"POST_COMMIT_REINDEX_WARNING: {idx_err}")
842
+
843
+ commit_res.diffs = diffs
844
+ return commit_res