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.
- codegraph/__init__.py +37 -0
- codegraph/agent.py +26 -0
- codegraph/agent_brain.py +2212 -0
- codegraph/agent_capabilities.py +4058 -0
- codegraph/agent_rules.py +531 -0
- codegraph/api_drift.py +455 -0
- codegraph/architecture.py +371 -0
- codegraph/async_queue/__init__.py +50 -0
- codegraph/async_queue/detectors/__init__.py +48 -0
- codegraph/async_queue/detectors/celery.py +160 -0
- codegraph/async_queue/detectors/kafka.py +168 -0
- codegraph/async_queue/detectors/rabbitmq.py +116 -0
- codegraph/async_queue/detectors/redis.py +130 -0
- codegraph/async_queue/detectors/sqs.py +135 -0
- codegraph/async_queue/models.py +161 -0
- codegraph/async_queue/otel_models.py +74 -0
- codegraph/async_queue/otel_parser.py +337 -0
- codegraph/async_queue/reconciler.py +427 -0
- codegraph/async_queue/scanner.py +291 -0
- codegraph/async_queue/traversal.py +447 -0
- codegraph/audit.py +106 -0
- codegraph/binding_resolver.py +616 -0
- codegraph/branch_comparison.py +197 -0
- codegraph/cache.py +95 -0
- codegraph/change_impact.py +547 -0
- codegraph/cli.py +2436 -0
- codegraph/cli_output.py +205 -0
- codegraph/config.py +45 -0
- codegraph/constraints.py +238 -0
- codegraph/context.py +2182 -0
- codegraph/context_freshness.py +351 -0
- codegraph/database/__init__.py +72 -0
- codegraph/database/extractor.py +3651 -0
- codegraph/database/interrogation.py +1494 -0
- codegraph/database/models.py +310 -0
- codegraph/database/schema_drift.py +862 -0
- codegraph/epistemic.py +200 -0
- codegraph/errors.py +282 -0
- codegraph/evidence/__init__.py +15 -0
- codegraph/evidence/citations.py +418 -0
- codegraph/evidence_contract.py +486 -0
- codegraph/frameworks.py +960 -0
- codegraph/frameworks_nestjs.py +502 -0
- codegraph/frameworks_nextjs.py +337 -0
- codegraph/frameworks_react.py +457 -0
- codegraph/freshness.py +335 -0
- codegraph/frontend_html.py +109 -0
- codegraph/git.py +362 -0
- codegraph/git_state.py +545 -0
- codegraph/graph/__init__.py +46 -0
- codegraph/graph/models.py +56 -0
- codegraph/graph/traversal.py +1993 -0
- codegraph/indexing/__init__.py +24 -0
- codegraph/indexing/classifier.py +564 -0
- codegraph/indexing/indexer.py +2578 -0
- codegraph/indexing/models.py +427 -0
- codegraph/indexing/parser.py +3404 -0
- codegraph/indexing/pool.py +136 -0
- codegraph/indexing/scanner.py +211 -0
- codegraph/indexing/telemetry.py +326 -0
- codegraph/indexing/templates.py +418 -0
- codegraph/indexing/test_framework.py +116 -0
- codegraph/installer.py +1702 -0
- codegraph/interrogation.py +2355 -0
- codegraph/llm/__init__.py +3 -0
- codegraph/llm/base.py +15 -0
- codegraph/llm/context.py +20 -0
- codegraph/mcp/__init__.py +3 -0
- codegraph/mcp/server.py +1476 -0
- codegraph/mcp_diagnostics.py +882 -0
- codegraph/memory/__init__.py +3 -0
- codegraph/memory/store.py +46 -0
- codegraph/models.py +310 -0
- codegraph/monorepo.py +925 -0
- codegraph/observability.py +262 -0
- codegraph/optimizer.py +1014 -0
- codegraph/planner.py +417 -0
- codegraph/process_lifecycle.py +1153 -0
- codegraph/py.typed +1 -0
- codegraph/query_expansion.py +199 -0
- codegraph/ranking.py +408 -0
- codegraph/refactor/__init__.py +36 -0
- codegraph/refactor/models.py +114 -0
- codegraph/refactor/renamer.py +844 -0
- codegraph/refactor/transactions.py +414 -0
- codegraph/resolver.py +3968 -0
- codegraph/resources/__init__.py +46 -0
- codegraph/resources/cache.py +117 -0
- codegraph/resources/coalescer.py +83 -0
- codegraph/resources/debouncer.py +98 -0
- codegraph/resources/governor.py +219 -0
- codegraph/resources/monitor.py +102 -0
- codegraph/resources/policy.py +123 -0
- codegraph/retrieval_policy.py +320 -0
- codegraph/roblox.py +2174 -0
- codegraph/route_composer.py +363 -0
- codegraph/route_topology.py +172 -0
- codegraph/runtime/__init__.py +51 -0
- codegraph/runtime/collector.py +155 -0
- codegraph/runtime/db_watcher.py +191 -0
- codegraph/runtime/ingestor.py +692 -0
- codegraph/runtime/models.py +130 -0
- codegraph/runtime/port_inspector.py +304 -0
- codegraph/runtime/reconciliation.py +492 -0
- codegraph/runtime/runner.py +353 -0
- codegraph/runtime/trace_linker.py +175 -0
- codegraph/search/__init__.py +25 -0
- codegraph/search/hybrid.py +846 -0
- codegraph/search/semantic.py +28 -0
- codegraph/security/__init__.py +51 -0
- codegraph/security/paths.py +189 -0
- codegraph/security/redaction.py +633 -0
- codegraph/semantic_conflicts.py +270 -0
- codegraph/semantic_decorators.py +372 -0
- codegraph/structural_diff.py +542 -0
- codegraph/styles.py +144 -0
- codegraph/symbol_history.py +277 -0
- codegraph/target_resolver.py +348 -0
- codegraph/task.py +637 -0
- codegraph/tool_selection_eval.py +1158 -0
- codegraph/tsconfig.py +236 -0
- codegraph/ui/__init__.py +6 -0
- codegraph/ui/server.py +755 -0
- codegraph/watcher.py +323 -0
- codegraph_engine-3.0.0.dist-info/METADATA +859 -0
- codegraph_engine-3.0.0.dist-info/RECORD +130 -0
- codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
- codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
- codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|