astoncli 0.1.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.
- aston/__init__.py +0 -0
- aston/axios.py +525 -0
- aston/config.py +88 -0
- aston/docker.py +402 -0
- aston/exceptions.py +49 -0
- aston/ingestion.py +1105 -0
- aston/llm.py +182 -0
- aston/pipeline.py +749 -0
- aston/poller.py +201 -0
- aston/relevance.py +175 -0
- aston/repo_verification.py +243 -0
- aston/rule_validation.py +437 -0
- aston/scanner.py +211 -0
- aston/watcher.py +66 -0
- astoncli-0.1.0.dist-info/METADATA +883 -0
- astoncli-0.1.0.dist-info/RECORD +19 -0
- astoncli-0.1.0.dist-info/WHEEL +5 -0
- astoncli-0.1.0.dist-info/entry_points.txt +2 -0
- astoncli-0.1.0.dist-info/top_level.txt +1 -0
aston/pipeline.py
ADDED
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
from aston.axios import code_patcher, stage0, scraper
|
|
2
|
+
from aston.docker import verify_single_file_in_docker, VerificationResult
|
|
3
|
+
from aston.watcher import watcher
|
|
4
|
+
from aston.scanner import parser
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
from aston.repo_index import repo_map
|
|
10
|
+
from aston.cli.console import rich_success, error, warning, info
|
|
11
|
+
|
|
12
|
+
from aston.rule_validation import (
|
|
13
|
+
validate_rules_against_target,
|
|
14
|
+
validate_all_rules,
|
|
15
|
+
reconcile_report,
|
|
16
|
+
)
|
|
17
|
+
from aston.artifacts.save_attempt import save_code
|
|
18
|
+
from aston.cli_ui.rich import (
|
|
19
|
+
print_rich_aston_banner,
|
|
20
|
+
run_with_animation,
|
|
21
|
+
print_aston_banner,
|
|
22
|
+
)
|
|
23
|
+
from aston.exceptions import ConfigurationError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _safe_read_text(path: Path) -> str:
|
|
27
|
+
"""Read UTF-8 source text and fail clearly if unavailable."""
|
|
28
|
+
return path.read_text(encoding="utf-8")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _write_candidate(path: Path, source: str) -> bool:
|
|
32
|
+
"""Write a candidate only when it contains non-empty source code."""
|
|
33
|
+
if not isinstance(source, str) or not source.strip():
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
path.write_text(source, encoding="utf-8")
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _candidate_path(path: str | Path, run_id: str) -> Path:
|
|
42
|
+
source_path = Path(path).expanduser()
|
|
43
|
+
|
|
44
|
+
return source_path.with_name(
|
|
45
|
+
f".{source_path.stem}.aston_candidate_{run_id}"
|
|
46
|
+
f"{source_path.suffix or '.py'}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _record_failure(path: str, failed_files: list) -> None:
|
|
51
|
+
if path not in failed_files:
|
|
52
|
+
failed_files.append(path)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _record_success(path: str, passed_files: list, failed_files: list) -> None:
|
|
56
|
+
if path in failed_files:
|
|
57
|
+
failed_files.remove(path)
|
|
58
|
+
|
|
59
|
+
if path not in passed_files:
|
|
60
|
+
passed_files.append(path)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _save_successful_migration(
|
|
64
|
+
path: str,
|
|
65
|
+
verified_code: str,
|
|
66
|
+
file_run_dir: Path,
|
|
67
|
+
root_path: str,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Save verified source to artifacts and succ_migrations."""
|
|
70
|
+
|
|
71
|
+
save_code(file_run_dir / "final.py", verified_code)
|
|
72
|
+
|
|
73
|
+
repo_root = Path(root_path).expanduser().resolve()
|
|
74
|
+
source_path = Path(path)
|
|
75
|
+
|
|
76
|
+
if not source_path.is_absolute():
|
|
77
|
+
source_path = repo_root / source_path
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
relative_path = source_path.resolve().relative_to(repo_root)
|
|
81
|
+
except ValueError as exc:
|
|
82
|
+
raise ConfigurationError(
|
|
83
|
+
message=f"Migration source is outside repository root: {source_path}",
|
|
84
|
+
details="Ensure target files reside within the active repository directory."
|
|
85
|
+
) from exc
|
|
86
|
+
|
|
87
|
+
migration_root = Path(__file__).resolve().parent / "succ_migrations"
|
|
88
|
+
migrated_path = migration_root / relative_path
|
|
89
|
+
|
|
90
|
+
migrated_path.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
migrated_path.write_text(verified_code, encoding="utf-8")
|
|
92
|
+
|
|
93
|
+
rich_success(f"Code successfully patched and saved to {migrated_path}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _find_file_entry(repo_index: dict, path: str, root_path: str) -> dict:
|
|
97
|
+
"""
|
|
98
|
+
Find the current file's entry in repo_index.
|
|
99
|
+
|
|
100
|
+
Supports an exact key match and comparisons between relative and absolute
|
|
101
|
+
paths. It deliberately avoids basename-only matching because repositories
|
|
102
|
+
can contain multiple files with the same name.
|
|
103
|
+
"""
|
|
104
|
+
files_dict = repo_index.get("files", {})
|
|
105
|
+
|
|
106
|
+
if not isinstance(files_dict, dict):
|
|
107
|
+
return {}
|
|
108
|
+
|
|
109
|
+
# Fast path: exact key.
|
|
110
|
+
if path in files_dict and isinstance(files_dict[path], dict):
|
|
111
|
+
return files_dict[path]
|
|
112
|
+
|
|
113
|
+
target = Path(path)
|
|
114
|
+
|
|
115
|
+
if not target.is_absolute():
|
|
116
|
+
target = Path(root_path) / target
|
|
117
|
+
|
|
118
|
+
target_resolved = target.resolve()
|
|
119
|
+
|
|
120
|
+
for indexed_path, file_data in files_dict.items():
|
|
121
|
+
if not isinstance(file_data, dict):
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
indexed = Path(indexed_path)
|
|
125
|
+
|
|
126
|
+
if not indexed.is_absolute():
|
|
127
|
+
indexed = Path(root_path) / indexed
|
|
128
|
+
|
|
129
|
+
if indexed.resolve() == target_resolved:
|
|
130
|
+
return file_data
|
|
131
|
+
|
|
132
|
+
return {}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _get_file_usages(
|
|
136
|
+
repo_index: dict,
|
|
137
|
+
path: str,
|
|
138
|
+
root_path: str,
|
|
139
|
+
package_name: str,
|
|
140
|
+
) -> list[dict]:
|
|
141
|
+
"""Return usages for one file and one package, not the entire repository."""
|
|
142
|
+
file_entry = _find_file_entry(repo_index, path, root_path)
|
|
143
|
+
|
|
144
|
+
usages = file_entry.get("usages", [])
|
|
145
|
+
|
|
146
|
+
if not isinstance(usages, list):
|
|
147
|
+
return []
|
|
148
|
+
|
|
149
|
+
info(f"[DEBUG] File: {path}")
|
|
150
|
+
info(f"[DEBUG] Indexed usage count: {len(usages)}")
|
|
151
|
+
info(
|
|
152
|
+
f"[DEBUG] Indexed package values: "
|
|
153
|
+
f"{sorted({str(u.get('package')) for u in usages if isinstance(u, dict)})}"
|
|
154
|
+
)
|
|
155
|
+
info(f"[DEBUG] Requested package: {package_name}")
|
|
156
|
+
|
|
157
|
+
return [
|
|
158
|
+
usage
|
|
159
|
+
for usage in usages
|
|
160
|
+
if isinstance(usage, dict)
|
|
161
|
+
and usage.get("package") == package_name
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _error_key(pr) -> str:
|
|
166
|
+
"""Normalize verifier error type for classification."""
|
|
167
|
+
error_type = getattr(pr, "error_type", None)
|
|
168
|
+
|
|
169
|
+
if error_type is None:
|
|
170
|
+
return ""
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
str(getattr(error_type, "value", error_type))
|
|
174
|
+
.lower()
|
|
175
|
+
.replace("_", "")
|
|
176
|
+
.replace(" ", "")
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _is_repairable_failure(pr) -> bool:
|
|
181
|
+
"""
|
|
182
|
+
Only route likely source-code migration errors to the code repair loop.
|
|
183
|
+
|
|
184
|
+
Path, environment, external-service, and timeout errors should be
|
|
185
|
+
diagnosed separately rather than blindly prompting the model to rewrite
|
|
186
|
+
Python source.
|
|
187
|
+
"""
|
|
188
|
+
if pr.status == VerificationResult.PASS:
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
if pr.status != VerificationResult.MIGRATION_ERROR:
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
error_type = _error_key(pr)
|
|
195
|
+
error_message = str(
|
|
196
|
+
getattr(pr, "error_message", "") or ""
|
|
197
|
+
).lower()
|
|
198
|
+
|
|
199
|
+
non_code_errors = {
|
|
200
|
+
"targetfilenotfound",
|
|
201
|
+
"environmenterror",
|
|
202
|
+
"externalserviceerror",
|
|
203
|
+
"timeout",
|
|
204
|
+
"timeouterror",
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if error_type in non_code_errors:
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
if "target file not found" in error_message:
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
if "no such file or directory" in error_message:
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
return True
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def repair_file(
|
|
220
|
+
path: str,
|
|
221
|
+
temp_req_path: Path,
|
|
222
|
+
context_data: dict,
|
|
223
|
+
root_path: str,
|
|
224
|
+
docker_time,
|
|
225
|
+
initial_result=None,
|
|
226
|
+
max_retries: int = 2,
|
|
227
|
+
passed_files: list = None,
|
|
228
|
+
failed_files: list = None,
|
|
229
|
+
):
|
|
230
|
+
"""Repair a candidate, preserving the original until verification passes."""
|
|
231
|
+
if passed_files is None:
|
|
232
|
+
passed_files = []
|
|
233
|
+
|
|
234
|
+
if failed_files is None:
|
|
235
|
+
failed_files = []
|
|
236
|
+
|
|
237
|
+
retry_count = 0
|
|
238
|
+
previous_attempts = []
|
|
239
|
+
|
|
240
|
+
file_run_dir = Path(context_data["file_run_dir"])
|
|
241
|
+
|
|
242
|
+
all_reports = context_data["all_reports"]
|
|
243
|
+
validated_all_rules_dict = context_data["validated_all_rules_dict"]
|
|
244
|
+
all_tar_vers = context_data["all_tar_vers"]
|
|
245
|
+
all_cur_vers = context_data["all_cur_vers"]
|
|
246
|
+
raw_code_examples = context_data["raw_code_examples"]
|
|
247
|
+
|
|
248
|
+
rules = context_data["rules"]
|
|
249
|
+
symbols = context_data["symbols"]
|
|
250
|
+
relationships = context_data["relationships"]
|
|
251
|
+
dependencies = context_data["dependencies"]
|
|
252
|
+
|
|
253
|
+
candidate_path = Path(path)
|
|
254
|
+
original_path = Path(context_data.get("original_path", path))
|
|
255
|
+
display_path = context_data.get("display_path", str(original_path))
|
|
256
|
+
|
|
257
|
+
rep_con = context_data.get("repair_instructions")
|
|
258
|
+
|
|
259
|
+
if rep_con is None:
|
|
260
|
+
repair_context_path = Path("repair_context.txt")
|
|
261
|
+
|
|
262
|
+
if repair_context_path.exists():
|
|
263
|
+
rep_con = repair_context_path.read_text(encoding="utf-8")
|
|
264
|
+
|
|
265
|
+
# Reuse the initial result instead of verifying the same candidate twice.
|
|
266
|
+
pr = initial_result
|
|
267
|
+
|
|
268
|
+
if pr is None:
|
|
269
|
+
pr = verify_single_file_in_docker(
|
|
270
|
+
str(candidate_path),
|
|
271
|
+
temp_req_path,
|
|
272
|
+
repo_root=root_path,
|
|
273
|
+
timeout=docker_time
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
while pr.status != VerificationResult.PASS and retry_count < max_retries:
|
|
277
|
+
if not _is_repairable_failure(pr):
|
|
278
|
+
warning(
|
|
279
|
+
"\nNot sending this failure to code repair: "
|
|
280
|
+
f"status={pr.status}, "
|
|
281
|
+
f"type={getattr(pr, 'error_type', None)}, "
|
|
282
|
+
f"message={getattr(pr, 'error_message', None)}"
|
|
283
|
+
)
|
|
284
|
+
break
|
|
285
|
+
|
|
286
|
+
retry_count += 1
|
|
287
|
+
|
|
288
|
+
# Always read the candidate being repaired. pr.target_file is
|
|
289
|
+
# diagnostic metadata, not the authoritative source path.
|
|
290
|
+
try:
|
|
291
|
+
failed_source = _safe_read_text(candidate_path)
|
|
292
|
+
except (OSError, UnicodeError) as exc:
|
|
293
|
+
error(f"Cannot read candidate source {candidate_path}: {exc}")
|
|
294
|
+
_record_failure(display_path, failed_files)
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
previous_attempts.append(
|
|
298
|
+
{
|
|
299
|
+
"attempt": retry_count,
|
|
300
|
+
"source_code": failed_source,
|
|
301
|
+
"error_type": getattr(pr, "error_type", None),
|
|
302
|
+
"error_message": getattr(pr, "error_message", None),
|
|
303
|
+
"traceback": getattr(pr, "raw_stderr", None),
|
|
304
|
+
}
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
warning(
|
|
308
|
+
f"\nStage 4 Failed with "
|
|
309
|
+
f"{getattr(pr, 'error_type', None)} "
|
|
310
|
+
f"(Repair Attempt {retry_count}/{max_retries})"
|
|
311
|
+
)
|
|
312
|
+
info("Routing to Stage 5 Self-Healing Repair Loop...")
|
|
313
|
+
|
|
314
|
+
repair_context = {
|
|
315
|
+
"file_name": str(candidate_path),
|
|
316
|
+
"original_path": str(original_path),
|
|
317
|
+
"display_path": display_path,
|
|
318
|
+
"error_type": getattr(pr, "error_type", None),
|
|
319
|
+
"error_message": getattr(pr, "error_message", None),
|
|
320
|
+
"traceback": getattr(pr, "raw_stderr", None),
|
|
321
|
+
"attempted_code": failed_source,
|
|
322
|
+
"previous_attempts": previous_attempts,
|
|
323
|
+
"repair_instructions": rep_con,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
factored_code = code_patcher(
|
|
327
|
+
source_code=failed_source,
|
|
328
|
+
matches=all_reports,
|
|
329
|
+
rules=validated_all_rules_dict,
|
|
330
|
+
target_version=all_tar_vers,
|
|
331
|
+
current_version=all_cur_vers,
|
|
332
|
+
repo_rules=rules,
|
|
333
|
+
symbols=symbols,
|
|
334
|
+
relationships=relationships,
|
|
335
|
+
dependencies=dependencies,
|
|
336
|
+
repair_context=repair_context,
|
|
337
|
+
raw_code_examples=raw_code_examples,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
if not isinstance(factored_code, str) or not factored_code.strip():
|
|
341
|
+
warning("Repair returned empty or invalid source. Stopping.")
|
|
342
|
+
_record_failure(display_path, failed_files)
|
|
343
|
+
return False
|
|
344
|
+
|
|
345
|
+
if factored_code == failed_source:
|
|
346
|
+
warning("Repair returned unchanged source. Stopping.")
|
|
347
|
+
_record_failure(display_path, failed_files)
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
repaired_attempt_path = (
|
|
351
|
+
file_run_dir / f"repaired_attempt_{retry_count}.py"
|
|
352
|
+
)
|
|
353
|
+
save_code(repaired_attempt_path, factored_code)
|
|
354
|
+
|
|
355
|
+
if not _write_candidate(candidate_path, factored_code):
|
|
356
|
+
warning("Could not write repaired candidate. Stopping.")
|
|
357
|
+
_record_failure(display_path, failed_files)
|
|
358
|
+
return False
|
|
359
|
+
|
|
360
|
+
# Verify the newly written candidate; use this result for the next
|
|
361
|
+
# repair decision.
|
|
362
|
+
pr = verify_single_file_in_docker(
|
|
363
|
+
str(candidate_path),
|
|
364
|
+
temp_req_path,
|
|
365
|
+
repo_root=root_path,
|
|
366
|
+
timeout=docker_time
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
if pr.status == VerificationResult.PASS:
|
|
370
|
+
try:
|
|
371
|
+
verified_code = _safe_read_text(candidate_path)
|
|
372
|
+
except (OSError, UnicodeError) as exc:
|
|
373
|
+
error(f"Cannot read verified candidate {candidate_path}: {exc}")
|
|
374
|
+
_record_failure(display_path, failed_files)
|
|
375
|
+
return False
|
|
376
|
+
|
|
377
|
+
# original_path.parent.mkdir(parents=True, exist_ok=True)
|
|
378
|
+
# original_path.write_text(verified_code, encoding="utf-8")
|
|
379
|
+
|
|
380
|
+
_save_successful_migration(
|
|
381
|
+
path,
|
|
382
|
+
verified_code,
|
|
383
|
+
file_run_dir,
|
|
384
|
+
root_path,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
_record_success(display_path, passed_files, failed_files)
|
|
388
|
+
|
|
389
|
+
rich_success("Sandboxed Verification Successful!")
|
|
390
|
+
return True
|
|
391
|
+
|
|
392
|
+
info(
|
|
393
|
+
f"\nVerification stopped after {retry_count} repair attempts. "
|
|
394
|
+
"Original source was preserved."
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
_record_failure(display_path, failed_files)
|
|
398
|
+
return False
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def run_for_repo(
|
|
402
|
+
root_path: str,
|
|
403
|
+
path: str,
|
|
404
|
+
rules,
|
|
405
|
+
symbols,
|
|
406
|
+
relationships,
|
|
407
|
+
dependencies,
|
|
408
|
+
source_code,
|
|
409
|
+
temp_req_path,
|
|
410
|
+
passed_files: list,
|
|
411
|
+
failed_files: list,
|
|
412
|
+
max_tries: int,
|
|
413
|
+
dokcer_file_time
|
|
414
|
+
):
|
|
415
|
+
with open("repo_index.json", "r", encoding="utf-8") as f:
|
|
416
|
+
repo_index = json.load(f)
|
|
417
|
+
|
|
418
|
+
with open("migrations.json", "r", encoding="utf-8") as f:
|
|
419
|
+
migrations = json.load(f)
|
|
420
|
+
|
|
421
|
+
run_id = uuid4().hex[:12]
|
|
422
|
+
run_dir = Path("aston/artifacts") / "runs" / run_id
|
|
423
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
424
|
+
|
|
425
|
+
print("\n==================================================")
|
|
426
|
+
print("--- [Stage 0] Global Repository Scanned Usages ---")
|
|
427
|
+
print("==================================================")
|
|
428
|
+
|
|
429
|
+
all_rules: dict[str, list] = {}
|
|
430
|
+
all_reports: dict[str, list] = {}
|
|
431
|
+
all_cur_vers: dict[str, str] = {}
|
|
432
|
+
all_tar_vers: dict[str, str] = {}
|
|
433
|
+
incomplete_packages: list[str] = []
|
|
434
|
+
|
|
435
|
+
# Stage 0 remains repository-wide. Actual matching in scraper is
|
|
436
|
+
# restricted to the current file.
|
|
437
|
+
for module, mod_data in migrations.items():
|
|
438
|
+
stage0_symbols = stage0(module, repo_index)
|
|
439
|
+
|
|
440
|
+
dep_info = repo_index.get("dependencies", {}).get(module, {})
|
|
441
|
+
declared_version = dep_info.get("declared", {}).get("version")
|
|
442
|
+
target_version = dep_info.get("target_version")
|
|
443
|
+
|
|
444
|
+
if not declared_version or not target_version:
|
|
445
|
+
raise ConfigurationError(
|
|
446
|
+
message=f"Missing version specification for package '{module}'.",
|
|
447
|
+
details=f"declared={declared_version!r}, target={target_version!r}"
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
current_version = declared_version
|
|
451
|
+
|
|
452
|
+
composed_rules = mod_data.get("composed_rules", [])
|
|
453
|
+
doc_text = mod_data.get("doc_text", "")
|
|
454
|
+
|
|
455
|
+
# IMPORTANT: only this file's usages are passed to scraper/man_extract.
|
|
456
|
+
file_usages = _get_file_usages(
|
|
457
|
+
repo_index=repo_index,
|
|
458
|
+
path=path,
|
|
459
|
+
root_path=root_path,
|
|
460
|
+
package_name=module,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
info(
|
|
465
|
+
f"File-specific usages for {path} / {module}: "
|
|
466
|
+
f"{len(file_usages)}"
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
# Do not run relevance/completeness analysis for a package
|
|
470
|
+
# that has no indexed usages in the current file.
|
|
471
|
+
if not file_usages:
|
|
472
|
+
warning(
|
|
473
|
+
f"[SKIP] {path} has no indexed usages of {module}; "
|
|
474
|
+
"skipping per-file audit."
|
|
475
|
+
)
|
|
476
|
+
continue
|
|
477
|
+
|
|
478
|
+
report, relevant_rules, is_complete = run_with_animation(
|
|
479
|
+
f"Evaluating relevance and completeness for {module}...",
|
|
480
|
+
scraper,
|
|
481
|
+
module,
|
|
482
|
+
current_version,
|
|
483
|
+
target_version,
|
|
484
|
+
stage0_symbols,
|
|
485
|
+
file_usages,
|
|
486
|
+
composed_rules,
|
|
487
|
+
doc_text,
|
|
488
|
+
source_file=path,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
if not report:
|
|
493
|
+
warning(
|
|
494
|
+
f"No legacy usages matched in {path}. "
|
|
495
|
+
"Skipping migration for this file."
|
|
496
|
+
)
|
|
497
|
+
continue
|
|
498
|
+
|
|
499
|
+
if not is_complete:
|
|
500
|
+
incomplete_packages.append(module)
|
|
501
|
+
|
|
502
|
+
# Each report now represents matches from the current file only.
|
|
503
|
+
all_rules[module] = relevant_rules
|
|
504
|
+
all_reports[module] = report
|
|
505
|
+
all_cur_vers[module] = current_version
|
|
506
|
+
all_tar_vers[module] = target_version
|
|
507
|
+
|
|
508
|
+
# Flatten rules and ensure every rule has a package identifier.
|
|
509
|
+
flat_all_rules = []
|
|
510
|
+
|
|
511
|
+
for pkg, pkg_rules in all_rules.items():
|
|
512
|
+
for rule in pkg_rules:
|
|
513
|
+
rule = dict(rule)
|
|
514
|
+
rule.setdefault("package", pkg)
|
|
515
|
+
flat_all_rules.append(rule)
|
|
516
|
+
|
|
517
|
+
known_target_versions = {
|
|
518
|
+
pkg: version
|
|
519
|
+
for pkg, version in all_tar_vers.items()
|
|
520
|
+
if version and version != "unknown"
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if flat_all_rules and known_target_versions:
|
|
524
|
+
validated_rules = run_with_animation(
|
|
525
|
+
"Running sandboxed batch validation across target environments...",
|
|
526
|
+
validate_all_rules,
|
|
527
|
+
all_rules=flat_all_rules,
|
|
528
|
+
all_target_versions=known_target_versions,
|
|
529
|
+
req_file=Path(root_path) / "requirements.txt",
|
|
530
|
+
)
|
|
531
|
+
else:
|
|
532
|
+
validated_rules = []
|
|
533
|
+
|
|
534
|
+
if flat_all_rules and not known_target_versions:
|
|
535
|
+
warning(
|
|
536
|
+
"Target versions are unknown; "
|
|
537
|
+
"skipping target-environment batch validation."
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
final_report_dict = reconcile_report(
|
|
541
|
+
extraction_report={"rules": validated_rules},
|
|
542
|
+
validated_rules=validated_rules,
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
flat_filename = path.replace("/", "_")
|
|
546
|
+
output_path = (
|
|
547
|
+
Path("final_reports")
|
|
548
|
+
/ f"{Path(flat_filename).stem}_changes.json"
|
|
549
|
+
)
|
|
550
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
551
|
+
|
|
552
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
553
|
+
json.dump(final_report_dict, f, indent=3)
|
|
554
|
+
|
|
555
|
+
info(f"Canonical audit contract saved to {output_path}")
|
|
556
|
+
|
|
557
|
+
validated_all_rules_dict = {}
|
|
558
|
+
|
|
559
|
+
for rule in validated_rules:
|
|
560
|
+
pkg = rule.get("package")
|
|
561
|
+
|
|
562
|
+
if not pkg:
|
|
563
|
+
continue
|
|
564
|
+
|
|
565
|
+
validated_all_rules_dict.setdefault(pkg, []).append(rule)
|
|
566
|
+
|
|
567
|
+
has_valid_rules = any(
|
|
568
|
+
bool(package_rules)
|
|
569
|
+
for package_rules in validated_all_rules_dict.values()
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
# Reports are now file-specific, so this checks whether this file has
|
|
573
|
+
# actual matched usages instead of merely checking that an audit exists.
|
|
574
|
+
has_valid_matches = any(
|
|
575
|
+
isinstance(report, list) and len(report) > 0
|
|
576
|
+
for report in all_reports.values()
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
if not has_valid_rules or not has_valid_matches:
|
|
580
|
+
info(f"[SKIP] No applicable migration for {path}")
|
|
581
|
+
return True, {}
|
|
582
|
+
|
|
583
|
+
file_slug = path.replace("/", "_").replace(".py", "")
|
|
584
|
+
file_run_dir = run_dir / file_slug
|
|
585
|
+
file_run_dir.mkdir(parents=True, exist_ok=True)
|
|
586
|
+
|
|
587
|
+
save_code(file_run_dir / "original.py", source_code)
|
|
588
|
+
|
|
589
|
+
raw_code_examples = []
|
|
590
|
+
|
|
591
|
+
for mod_data in migrations.values():
|
|
592
|
+
examples = mod_data.get("raw_code_examples", [])
|
|
593
|
+
|
|
594
|
+
if isinstance(examples, list):
|
|
595
|
+
raw_code_examples.extend(examples)
|
|
596
|
+
|
|
597
|
+
# Normalize repository and source paths before creating the candidate.
|
|
598
|
+
root_path = Path(root_path).expanduser().resolve()
|
|
599
|
+
source_path = Path(path).expanduser()
|
|
600
|
+
|
|
601
|
+
if not source_path.is_absolute():
|
|
602
|
+
source_path = root_path / source_path
|
|
603
|
+
|
|
604
|
+
source_path = source_path.resolve()
|
|
605
|
+
|
|
606
|
+
try:
|
|
607
|
+
source_path.relative_to(root_path)
|
|
608
|
+
except ValueError as exc:
|
|
609
|
+
raise ConfigurationError(
|
|
610
|
+
message=f"Source file {source_path} is outside repository root {root_path}.",
|
|
611
|
+
details="All files targeted for migration must be located within the active repository workspace."
|
|
612
|
+
) from exc
|
|
613
|
+
|
|
614
|
+
original_path = source_path
|
|
615
|
+
candidate_path = _candidate_path(source_path, run_id).resolve()
|
|
616
|
+
|
|
617
|
+
print("\n--- CANDIDATE DIAGNOSTICS ---")
|
|
618
|
+
info(f"Repository root: {root_path}")
|
|
619
|
+
info(f"Original path: {original_path}")
|
|
620
|
+
info(f"Candidate path: {candidate_path}")
|
|
621
|
+
info(f"Candidate exists: {candidate_path.is_file()}")
|
|
622
|
+
print("-----------------------------\n")
|
|
623
|
+
|
|
624
|
+
print("\n--- CANDIDATE DIAGNOSTICS ---")
|
|
625
|
+
info(f"Candidate path: {candidate_path}")
|
|
626
|
+
info(f"Candidate exists: {candidate_path.is_file()}")
|
|
627
|
+
|
|
628
|
+
if candidate_path.is_file():
|
|
629
|
+
info(f"Candidate size: {candidate_path.stat().st_size} bytes")
|
|
630
|
+
else:
|
|
631
|
+
error("ERROR: Candidate was not written before verification.")
|
|
632
|
+
|
|
633
|
+
print("-----------------------------\n")
|
|
634
|
+
|
|
635
|
+
if not _write_candidate(candidate_path, source_code):
|
|
636
|
+
error(
|
|
637
|
+
"Original source input is empty or invalid; "
|
|
638
|
+
"refusing to patch."
|
|
639
|
+
)
|
|
640
|
+
_record_failure(path, failed_files)
|
|
641
|
+
return False, {}
|
|
642
|
+
|
|
643
|
+
try:
|
|
644
|
+
factored_code = code_patcher(
|
|
645
|
+
source_code=source_code,
|
|
646
|
+
matches=all_reports,
|
|
647
|
+
rules=validated_all_rules_dict,
|
|
648
|
+
target_version=all_tar_vers,
|
|
649
|
+
current_version=all_cur_vers,
|
|
650
|
+
repo_rules=rules,
|
|
651
|
+
symbols=symbols,
|
|
652
|
+
relationships=relationships,
|
|
653
|
+
dependencies=dependencies,
|
|
654
|
+
repair_context=None,
|
|
655
|
+
raw_code_examples=raw_code_examples,
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
if not isinstance(factored_code, str) or not factored_code.strip():
|
|
659
|
+
warning(
|
|
660
|
+
"Patcher returned empty or invalid source. "
|
|
661
|
+
"Original source preserved."
|
|
662
|
+
)
|
|
663
|
+
_record_failure(path, failed_files)
|
|
664
|
+
return False, {}
|
|
665
|
+
|
|
666
|
+
if factored_code == source_code:
|
|
667
|
+
warning("Patcher returned unchanged source. Skipping verification.")
|
|
668
|
+
_record_success(path, passed_files, failed_files)
|
|
669
|
+
return True, {}
|
|
670
|
+
|
|
671
|
+
save_code(file_run_dir / "first_attempt.py", factored_code)
|
|
672
|
+
|
|
673
|
+
if not _write_candidate(candidate_path, factored_code):
|
|
674
|
+
warning(
|
|
675
|
+
"Could not write candidate source. "
|
|
676
|
+
"Original source preserved."
|
|
677
|
+
)
|
|
678
|
+
_record_failure(path, failed_files)
|
|
679
|
+
return False, {}
|
|
680
|
+
|
|
681
|
+
# Initial verification is performed exactly once.
|
|
682
|
+
pr = verify_single_file_in_docker(
|
|
683
|
+
str(candidate_path),
|
|
684
|
+
temp_req_path,
|
|
685
|
+
repo_root=root_path,
|
|
686
|
+
timeout=dokcer_file_time
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
context_data = {
|
|
690
|
+
"file_run_dir": file_run_dir,
|
|
691
|
+
"all_reports": all_reports,
|
|
692
|
+
"validated_all_rules_dict": validated_all_rules_dict,
|
|
693
|
+
"all_tar_vers": all_tar_vers,
|
|
694
|
+
"all_cur_vers": all_cur_vers,
|
|
695
|
+
"raw_code_examples": raw_code_examples,
|
|
696
|
+
"rules": rules,
|
|
697
|
+
"symbols": symbols,
|
|
698
|
+
"relationships": relationships,
|
|
699
|
+
"dependencies": dependencies,
|
|
700
|
+
"source_code": source_code,
|
|
701
|
+
"original_path": str(original_path),
|
|
702
|
+
"display_path": path,
|
|
703
|
+
"repair_instructions": None,
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if pr.status == VerificationResult.PASS:
|
|
707
|
+
|
|
708
|
+
verified_code = _safe_read_text(candidate_path)
|
|
709
|
+
|
|
710
|
+
# Preserve the original source file.
|
|
711
|
+
# Save the verified migrated code to succ_migrations instead.
|
|
712
|
+
_save_successful_migration(
|
|
713
|
+
path,
|
|
714
|
+
verified_code,
|
|
715
|
+
file_run_dir,
|
|
716
|
+
root_path,
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
# Remove the candidate only after its successful migration is saved.
|
|
720
|
+
candidate_path.unlink(missing_ok=True)
|
|
721
|
+
|
|
722
|
+
_record_success(path, passed_files, failed_files)
|
|
723
|
+
|
|
724
|
+
rich_success("Sandboxed Verification Successful!")
|
|
725
|
+
return True, context_data
|
|
726
|
+
|
|
727
|
+
info(
|
|
728
|
+
f"\nInitial verification failed for {path}. "
|
|
729
|
+
"Starting repair loop..."
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
success = repair_file(
|
|
733
|
+
path=str(candidate_path),
|
|
734
|
+
temp_req_path=temp_req_path,
|
|
735
|
+
context_data=context_data,
|
|
736
|
+
root_path=root_path,
|
|
737
|
+
docker_time=dokcer_file_time,
|
|
738
|
+
initial_result=pr,
|
|
739
|
+
max_retries=max_tries,
|
|
740
|
+
passed_files=passed_files,
|
|
741
|
+
failed_files=failed_files,
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
return success, context_data
|
|
745
|
+
|
|
746
|
+
finally:
|
|
747
|
+
# Remove candidate after success. Keep it after failure for diagnosis.
|
|
748
|
+
if path in passed_files and candidate_path.exists():
|
|
749
|
+
candidate_path.unlink()
|