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/__init__.py
ADDED
|
File without changes
|
aston/axios.py
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
from mistralai.client import Mistral
|
|
7
|
+
from aston.rule_validation import validate_rules_against_target
|
|
8
|
+
from aston.scanner import parser
|
|
9
|
+
from aston.ingestion import run_stage1_pipeline, get_latest_pypi_version
|
|
10
|
+
from aston.relevance import evaluate_relevance_and_completeness, canonicalize_usage
|
|
11
|
+
from aston.docker import verify_single_file_in_docker, VerificationResult
|
|
12
|
+
from aston.relevance import normalize_api_symbol
|
|
13
|
+
from aston.cli.console import rich_success, error, warning, info
|
|
14
|
+
from aston.exceptions import ConfigurationError, LLMError
|
|
15
|
+
import time
|
|
16
|
+
from aston.llm import call_llm
|
|
17
|
+
from aston.config import load_config
|
|
18
|
+
|
|
19
|
+
load_dotenv()
|
|
20
|
+
config = load_config()
|
|
21
|
+
|
|
22
|
+
llm_cfg = config.get("llm", {})
|
|
23
|
+
provider_name = llm_cfg.get("provider", "mistral")
|
|
24
|
+
model_name = llm_cfg.get("model", "codestral-latest")
|
|
25
|
+
temperature = llm_cfg.get("temperature", 0.0)
|
|
26
|
+
max_retries = llm_cfg.get("max_retries", 3)
|
|
27
|
+
|
|
28
|
+
API_KEY = os.environ.get("MISTRAL_API_KEY")
|
|
29
|
+
client = Mistral(api_key=API_KEY) if API_KEY else None
|
|
30
|
+
|
|
31
|
+
with open("patcher_prompt.txt", "r", encoding="utf-8") as f:
|
|
32
|
+
prompt = f.read()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def contains_items(items) -> bool:
|
|
36
|
+
"""Check whether a list or nested dictionary contains rule/match records."""
|
|
37
|
+
if isinstance(items, dict):
|
|
38
|
+
return any(contains_items(value) for value in items.values())
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
isinstance(items, list)
|
|
42
|
+
and len(items) > 0
|
|
43
|
+
and any(isinstance(item, dict) for item in items)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def clean_mistral_code(raw_response: str) -> str:
|
|
48
|
+
"""Remove markdown code fences from the LLM response."""
|
|
49
|
+
if not isinstance(raw_response, str):
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
lines = raw_response.strip().splitlines()
|
|
53
|
+
|
|
54
|
+
if lines and lines[0].startswith("```python"):
|
|
55
|
+
lines.pop(0)
|
|
56
|
+
elif lines and lines[0].startswith("```"):
|
|
57
|
+
lines.pop(0)
|
|
58
|
+
|
|
59
|
+
if lines and lines[-1].strip() == "```":
|
|
60
|
+
lines.pop()
|
|
61
|
+
|
|
62
|
+
return "\n".join(lines).strip()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def stage0(package_name: str, repo_index: dict) -> set[str]:
|
|
66
|
+
"""
|
|
67
|
+
Extract canonical usages for a package across the entire repository.
|
|
68
|
+
|
|
69
|
+
This remains global because Stage 0 discovers the package APIs used
|
|
70
|
+
anywhere in the repository.
|
|
71
|
+
"""
|
|
72
|
+
flattened_usages = []
|
|
73
|
+
|
|
74
|
+
files_dict = repo_index.get("files", {})
|
|
75
|
+
|
|
76
|
+
for file_path, file_data in files_dict.items():
|
|
77
|
+
if isinstance(file_data, dict):
|
|
78
|
+
usages = file_data.get("usages", [])
|
|
79
|
+
|
|
80
|
+
if isinstance(usages, list):
|
|
81
|
+
flattened_usages.extend(
|
|
82
|
+
usage
|
|
83
|
+
for usage in usages
|
|
84
|
+
if isinstance(usage, dict)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
stage0_records = [
|
|
88
|
+
record
|
|
89
|
+
for record in flattened_usages
|
|
90
|
+
if record.get("package") == package_name
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
dynamic_stage0_symbols = {
|
|
94
|
+
canonicalize_usage(record)
|
|
95
|
+
for record in stage0_records
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
info(
|
|
99
|
+
f"Dynamic AST Scanned Usages for {package_name}: "
|
|
100
|
+
f"{dynamic_stage0_symbols}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return dynamic_stage0_symbols
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def man_extract(
|
|
107
|
+
rules: list,
|
|
108
|
+
parses: list[dict],
|
|
109
|
+
source_file: str,
|
|
110
|
+
) -> list[dict]:
|
|
111
|
+
"""Match AST usages to migration candidates using longest-prefix matching."""
|
|
112
|
+
from collections import defaultdict
|
|
113
|
+
|
|
114
|
+
extracted = defaultdict(list)
|
|
115
|
+
|
|
116
|
+
for rule in rules:
|
|
117
|
+
package = rule.get("package")
|
|
118
|
+
old_api = normalize_api_symbol(rule.get("old_api", ""))
|
|
119
|
+
|
|
120
|
+
if not package or not old_api:
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
extracted[(package, old_api)].append(dict(rule))
|
|
124
|
+
|
|
125
|
+
report = []
|
|
126
|
+
filename = Path(source_file).as_posix()
|
|
127
|
+
|
|
128
|
+
rule_keys_by_pkg = defaultdict(list)
|
|
129
|
+
|
|
130
|
+
for package, old_api in extracted.keys():
|
|
131
|
+
rule_keys_by_pkg[package].append(old_api)
|
|
132
|
+
|
|
133
|
+
for package in rule_keys_by_pkg:
|
|
134
|
+
rule_keys_by_pkg[package].sort(
|
|
135
|
+
key=len,
|
|
136
|
+
reverse=True,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
for parse in parses:
|
|
140
|
+
if not isinstance(parse, dict):
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
package = parse.get("package")
|
|
144
|
+
canonical_usage = canonicalize_usage(parse)
|
|
145
|
+
normalized_usage = normalize_api_symbol(canonical_usage)
|
|
146
|
+
|
|
147
|
+
matched_old_api = None
|
|
148
|
+
|
|
149
|
+
for old_api in rule_keys_by_pkg.get(package, []):
|
|
150
|
+
if (
|
|
151
|
+
normalized_usage == old_api
|
|
152
|
+
or normalized_usage.startswith(old_api + ".")
|
|
153
|
+
):
|
|
154
|
+
matched_old_api = old_api
|
|
155
|
+
break
|
|
156
|
+
|
|
157
|
+
if not matched_old_api:
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
candidates = extracted.get((package, matched_old_api))
|
|
161
|
+
|
|
162
|
+
if not candidates:
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
report.append(
|
|
166
|
+
{
|
|
167
|
+
"file_name": filename,
|
|
168
|
+
"Line": parse.get("line"),
|
|
169
|
+
"alias": parse.get("local_alias"),
|
|
170
|
+
"package": package,
|
|
171
|
+
"detected_usage": parse.get("api_usage"),
|
|
172
|
+
"canonical_usage": canonical_usage,
|
|
173
|
+
"instance": parse.get("instance"),
|
|
174
|
+
"kwargs": parse.get("kwargs"),
|
|
175
|
+
"change": {
|
|
176
|
+
"candidates": candidates,
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
info(
|
|
182
|
+
f"\nman_extract: {filename} | "
|
|
183
|
+
f"input usages={len(parses)} | matched usages={len(report)}"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return report
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def code_patcher(
|
|
190
|
+
source_code: str,
|
|
191
|
+
matches: list,
|
|
192
|
+
rules: list,
|
|
193
|
+
target_version: list,
|
|
194
|
+
current_version: list,
|
|
195
|
+
repo_rules,
|
|
196
|
+
symbols,
|
|
197
|
+
relationships,
|
|
198
|
+
dependencies,
|
|
199
|
+
repair_context: dict | None = None,
|
|
200
|
+
raw_code_examples: list[str] | None = None,
|
|
201
|
+
):
|
|
202
|
+
"""Stage 3 migration and Stage 5 verification-guided repair."""
|
|
203
|
+
|
|
204
|
+
if not client:
|
|
205
|
+
raise ConfigurationError(
|
|
206
|
+
message="MISTRAL_API_KEY environment variable is missing or invalid.",
|
|
207
|
+
details="Set your MISTRAL_API_KEY in your environment or .env file before running code patching."
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# CHANGE: Require rules and matches for a normal migration, but don't
|
|
211
|
+
# block repair mode. A verifier error can still need repair even when
|
|
212
|
+
# the original migration evidence is incomplete.
|
|
213
|
+
if not repair_context and (
|
|
214
|
+
not contains_items(rules)
|
|
215
|
+
or not contains_items(matches)
|
|
216
|
+
):
|
|
217
|
+
warning(
|
|
218
|
+
"No valid migration rules or matching usages. "
|
|
219
|
+
"Retaining original code unmodified."
|
|
220
|
+
)
|
|
221
|
+
return source_code
|
|
222
|
+
|
|
223
|
+
system_prompt = prompt
|
|
224
|
+
|
|
225
|
+
if repair_context:
|
|
226
|
+
repair_instructions = repair_context.get(
|
|
227
|
+
"repair_instructions",
|
|
228
|
+
"",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
system_prompt += """
|
|
232
|
+
|
|
233
|
+
REPAIR MODE — OVERRIDES CONFLICTING MIGRATION INSTRUCTIONS
|
|
234
|
+
|
|
235
|
+
The current source has already failed target-environment verification.
|
|
236
|
+
|
|
237
|
+
Treat the current error and all previous attempts as evidence.
|
|
238
|
+
Trace the failing expression to the migration decision that produced it.
|
|
239
|
+
|
|
240
|
+
Never repeat an expression that a previous verification attempt
|
|
241
|
+
demonstrated to be invalid unless new, specific evidence explains
|
|
242
|
+
why it should now work.
|
|
243
|
+
|
|
244
|
+
Do not alternate between previously failed replacements.
|
|
245
|
+
Do not assume a migration rule is a valid literal substitution.
|
|
246
|
+
Inspect surrounding configuration, initialization, imports, and
|
|
247
|
+
object ownership before changing the failing code.
|
|
248
|
+
|
|
249
|
+
Preserve successful changes and unrelated code.
|
|
250
|
+
Do not invent APIs, attributes, constructor arguments, or configuration.
|
|
251
|
+
If the supplied evidence is insufficient for a safe replacement,
|
|
252
|
+
do not fabricate one.
|
|
253
|
+
|
|
254
|
+
Return the complete source file only.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
if repair_instructions:
|
|
258
|
+
system_prompt += (
|
|
259
|
+
"\n\nPROJECT-SPECIFIC REPAIR INSTRUCTIONS:\n"
|
|
260
|
+
+ repair_instructions
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
cont = f"""
|
|
264
|
+
MIGRATION_RULES_FOR_EACH_DEPENDENCY:
|
|
265
|
+
{json.dumps(rules, indent=2, default=str)}
|
|
266
|
+
|
|
267
|
+
REPOSITORY_FILE_USAGES:
|
|
268
|
+
{json.dumps(repo_rules, indent=2, default=str)}
|
|
269
|
+
|
|
270
|
+
AST_SYMBOLS_INDEX:
|
|
271
|
+
{json.dumps(symbols, indent=2, default=str)}
|
|
272
|
+
|
|
273
|
+
CROSS_FILE_RELATIONSHIPS:
|
|
274
|
+
{json.dumps(relationships, indent=2, default=str)}
|
|
275
|
+
|
|
276
|
+
DEPENDENCIES_MAP:
|
|
277
|
+
{json.dumps(dependencies, indent=2, default=str)}
|
|
278
|
+
|
|
279
|
+
MATCHES_AND_AFFECTED_USAGES:
|
|
280
|
+
{json.dumps(matches, indent=2, default=str)}
|
|
281
|
+
|
|
282
|
+
CURRENT_DEPENDENCY_VERSIONS:
|
|
283
|
+
{json.dumps(current_version, indent=2, default=str)}
|
|
284
|
+
|
|
285
|
+
TARGET_DEPENDENCY_VERSIONS:
|
|
286
|
+
{json.dumps(target_version, indent=2, default=str)}
|
|
287
|
+
|
|
288
|
+
RAW_CODE_EXAMPLES_FOR_CONTEXT_NOT_AUTHORITATIVE_RULES:
|
|
289
|
+
{json.dumps(raw_code_examples, indent=2, default=str)}
|
|
290
|
+
|
|
291
|
+
REPAIR_CONTEXT:
|
|
292
|
+
{json.dumps(repair_context, indent=2, default=str) if repair_context else "None"}
|
|
293
|
+
|
|
294
|
+
CURRENT SOURCE CODE TO MIGRATE OR REPAIR:
|
|
295
|
+
{source_code}
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
delay = 2.0
|
|
300
|
+
response = None
|
|
301
|
+
|
|
302
|
+
for attempt in range(1, max_retries + 1):
|
|
303
|
+
try:
|
|
304
|
+
response = call_llm(prompt=cont, provider=provider_name, model=model_name, temperature=temperature, system_prompt=system_prompt)
|
|
305
|
+
break
|
|
306
|
+
except Exception as exc:
|
|
307
|
+
if attempt == max_retries:
|
|
308
|
+
raise LLMError(
|
|
309
|
+
message=f"Mistral LLM call failed after {max_retries} attempts.",
|
|
310
|
+
details=str(exc)
|
|
311
|
+
) from exc
|
|
312
|
+
warning(f"Mistral API call failed ({exc}). Retrying in {delay}s ({attempt}/{max_retries})...")
|
|
313
|
+
time.sleep(delay)
|
|
314
|
+
delay *= 2.0
|
|
315
|
+
|
|
316
|
+
patched = response.choices[0].message.content
|
|
317
|
+
clean = clean_mistral_code(patched)
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
ast.parse(clean)
|
|
321
|
+
except SyntaxError as se:
|
|
322
|
+
error(
|
|
323
|
+
"Patcher returned syntactically invalid Python code "
|
|
324
|
+
f"({se}); rejecting candidate."
|
|
325
|
+
)
|
|
326
|
+
return source_code
|
|
327
|
+
|
|
328
|
+
if repair_context:
|
|
329
|
+
failed_sources = {
|
|
330
|
+
attempt.get("source_code")
|
|
331
|
+
for attempt in repair_context.get("previous_attempts", [])
|
|
332
|
+
if attempt.get("source_code")
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if clean == source_code:
|
|
336
|
+
warning(
|
|
337
|
+
"Repair returned unchanged source; "
|
|
338
|
+
"rejecting candidate."
|
|
339
|
+
)
|
|
340
|
+
return source_code
|
|
341
|
+
|
|
342
|
+
if clean in failed_sources:
|
|
343
|
+
warning(
|
|
344
|
+
"Repair repeated a previously failed source; "
|
|
345
|
+
"rejecting candidate."
|
|
346
|
+
)
|
|
347
|
+
return source_code
|
|
348
|
+
|
|
349
|
+
return clean
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def scraper(
|
|
353
|
+
pkg_name: str,
|
|
354
|
+
current_version: str,
|
|
355
|
+
target_version: str,
|
|
356
|
+
stage0_symbols: set[str],
|
|
357
|
+
parses: list[dict],
|
|
358
|
+
composed_rules: list[dict],
|
|
359
|
+
doc_text: str,
|
|
360
|
+
source_file: str,
|
|
361
|
+
):
|
|
362
|
+
"""Run migration-rule coverage auditing, targeted extraction, and matching."""
|
|
363
|
+
print("\n==================================================")
|
|
364
|
+
print("--- [Stage 1] Multi-Boundary Chain Ingestion ---")
|
|
365
|
+
print("==================================================")
|
|
366
|
+
|
|
367
|
+
def normalize_set(values) -> set[str]:
|
|
368
|
+
if not values:
|
|
369
|
+
return set()
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
normalize_api_symbol(value)
|
|
373
|
+
for value in values
|
|
374
|
+
if (
|
|
375
|
+
isinstance(value, str)
|
|
376
|
+
and normalize_api_symbol(value)
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
def rule_key(rule: dict) -> tuple:
|
|
381
|
+
return (
|
|
382
|
+
rule.get("package"),
|
|
383
|
+
normalize_api_symbol(rule.get("old_api") or ""),
|
|
384
|
+
normalize_api_symbol(rule.get("new_api") or ""),
|
|
385
|
+
rule.get("from_version"),
|
|
386
|
+
rule.get("to_version"),
|
|
387
|
+
rule.get("migration_type"),
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
working_rules = [
|
|
391
|
+
dict(rule)
|
|
392
|
+
for rule in (composed_rules or [])
|
|
393
|
+
if isinstance(rule, dict)
|
|
394
|
+
]
|
|
395
|
+
|
|
396
|
+
working_doc_text = doc_text or ""
|
|
397
|
+
|
|
398
|
+
print("\n========== STAGE 2: INITIAL AUDIT ==========")
|
|
399
|
+
info(f"\nSTAGE 0 SYMBOLS: {sorted(stage0_symbols)}")
|
|
400
|
+
info(f"INITIAL RULE COUNT: {len(working_rules)}")
|
|
401
|
+
|
|
402
|
+
audit = evaluate_relevance_and_completeness(
|
|
403
|
+
stage0_symbols,
|
|
404
|
+
working_rules,
|
|
405
|
+
working_doc_text,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
observed_symbols = normalize_set(stage0_symbols)
|
|
409
|
+
directly_covered = normalize_set(
|
|
410
|
+
audit.get("directly_covered_symbols", [])
|
|
411
|
+
)
|
|
412
|
+
already_target = normalize_set(
|
|
413
|
+
audit.get("already_target_symbols", [])
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
targeted_symbols = set()
|
|
417
|
+
|
|
418
|
+
for field in ("missing_symbols", "uncovered_symbols"):
|
|
419
|
+
targeted_symbols.update(
|
|
420
|
+
normalize_set(audit.get(field, []))
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
targeted_symbols.update(
|
|
424
|
+
observed_symbols - directly_covered - already_target
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
if targeted_symbols:
|
|
428
|
+
print("\n==================================================")
|
|
429
|
+
print("--- [Stage 2.5] Targeted Rule Extraction ---")
|
|
430
|
+
print("==================================================")
|
|
431
|
+
info(f"FOCUS SYMBOLS: {sorted(targeted_symbols)}")
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
extra_rules, extra_doc_text, extra_examples = (
|
|
435
|
+
run_stage1_pipeline(
|
|
436
|
+
pkg_name,
|
|
437
|
+
current_version=current_version,
|
|
438
|
+
target_version=target_version,
|
|
439
|
+
stage0_symbols=targeted_symbols,
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
extra_rules = extra_rules or []
|
|
444
|
+
existing_keys = {
|
|
445
|
+
rule_key(rule)
|
|
446
|
+
for rule in working_rules
|
|
447
|
+
}
|
|
448
|
+
added_count = 0
|
|
449
|
+
|
|
450
|
+
for rule in extra_rules:
|
|
451
|
+
if not isinstance(rule, dict):
|
|
452
|
+
continue
|
|
453
|
+
|
|
454
|
+
candidate = dict(rule)
|
|
455
|
+
key = rule_key(candidate)
|
|
456
|
+
|
|
457
|
+
if key not in existing_keys:
|
|
458
|
+
working_rules.append(candidate)
|
|
459
|
+
existing_keys.add(key)
|
|
460
|
+
added_count += 1
|
|
461
|
+
|
|
462
|
+
if extra_doc_text:
|
|
463
|
+
working_doc_text = (
|
|
464
|
+
working_doc_text + "\n\n" + extra_doc_text
|
|
465
|
+
).strip()
|
|
466
|
+
|
|
467
|
+
info(f"TARGETED CANDIDATES RETURNED: {len(extra_rules)}")
|
|
468
|
+
info(f"NEW DISTINCT RULES ADDED: {added_count}")
|
|
469
|
+
|
|
470
|
+
except Exception as exc:
|
|
471
|
+
error(
|
|
472
|
+
"⚠️ Targeted extraction failed:",
|
|
473
|
+
repr(exc),
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
audit = evaluate_relevance_and_completeness(
|
|
477
|
+
stage0_symbols,
|
|
478
|
+
working_rules,
|
|
479
|
+
working_doc_text,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
print("\n==================================================")
|
|
483
|
+
print("--- [Stage 2] Final Relevance & Completeness Audit ---")
|
|
484
|
+
print("==================================================")
|
|
485
|
+
print()
|
|
486
|
+
info(f"OBSERVED: {sorted(observed_symbols)}")
|
|
487
|
+
info(f"TOTAL RULES: {len(working_rules)}")
|
|
488
|
+
info("FINAL AUDIT RESULT:")
|
|
489
|
+
info(json.dumps(audit, indent=2, default=str))
|
|
490
|
+
|
|
491
|
+
relevant_rules = audit.get("relevant_records", [])
|
|
492
|
+
missing = normalize_set(audit.get("missing_symbols", []))
|
|
493
|
+
uncovered = normalize_set(audit.get("uncovered_symbols", []))
|
|
494
|
+
|
|
495
|
+
is_genuinely_complete = (
|
|
496
|
+
bool(audit.get("is_complete", False))
|
|
497
|
+
and not missing
|
|
498
|
+
and not uncovered
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
print("\n========== FINAL COVERAGE STATUS ==========")
|
|
502
|
+
info(f"\nComplete: {is_genuinely_complete}")
|
|
503
|
+
|
|
504
|
+
with open("changes.json", "w", encoding="utf-8") as f:
|
|
505
|
+
json.dump(relevant_rules, f, indent=2, default=str)
|
|
506
|
+
|
|
507
|
+
info("Canonical contract saved in changes.json")
|
|
508
|
+
|
|
509
|
+
print("\n==================================================")
|
|
510
|
+
print("--- [Stage 3] Matching Usages ---")
|
|
511
|
+
print("==================================================")
|
|
512
|
+
|
|
513
|
+
# The pipeline must pass usages from the current file here.
|
|
514
|
+
# Stage 0 remains global; this matching operation is file-specific.
|
|
515
|
+
report = man_extract(
|
|
516
|
+
relevant_rules,
|
|
517
|
+
parses,
|
|
518
|
+
source_file,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
info(f"MATCHED USAGES: {len(report)}")
|
|
522
|
+
info(f"RELEVANT RULES: {len(relevant_rules)}")
|
|
523
|
+
|
|
524
|
+
return report, relevant_rules, is_genuinely_complete
|
|
525
|
+
|
aston/config.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import yaml
|
|
4
|
+
from aston.cli.console import error, info, rich_success, warning
|
|
5
|
+
from aston.exceptions import ConfigurationError
|
|
6
|
+
|
|
7
|
+
CONFIG_DIR = Path.home() / ".config" / "aston"
|
|
8
|
+
CONFIG_FILE = CONFIG_DIR / "config.yaml"
|
|
9
|
+
LOGS_DIR = CONFIG_DIR / "logs"
|
|
10
|
+
|
|
11
|
+
DEFAULT_CONFIG = {
|
|
12
|
+
"llm": {
|
|
13
|
+
"provider": "mistral", # Default LLM provider
|
|
14
|
+
"model": "codestral-latest",
|
|
15
|
+
"temperature": 0.0,
|
|
16
|
+
"max_retries": 3,
|
|
17
|
+
},
|
|
18
|
+
"migration": {
|
|
19
|
+
"per_file_repair_retries": 3, # LLM repair loops per individual file
|
|
20
|
+
"failed_files_sweep_passes": 2, # Outer pipeline runs to retry remaining failed files
|
|
21
|
+
"docker_file_timeout_seconds": 300, # Timeout for single-file sandbox checks
|
|
22
|
+
"docker_repo_timeout_seconds": 300,# Timeout for repo-wide test suites
|
|
23
|
+
},
|
|
24
|
+
"github": {
|
|
25
|
+
"default_base_branch": "main",
|
|
26
|
+
"draft_prs": False,
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_config_dir() -> Path:
|
|
32
|
+
"""Ensure and return the user config directory (~/.config/aston/)."""
|
|
33
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
return CONFIG_DIR
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_config() -> dict:
|
|
39
|
+
"""
|
|
40
|
+
Load configuration from ~/.config/aston/config.yaml,
|
|
41
|
+
falling back to defaults and applying environment variable overrides.
|
|
42
|
+
"""
|
|
43
|
+
config = DEFAULT_CONFIG.copy()
|
|
44
|
+
|
|
45
|
+
if CONFIG_FILE.is_file():
|
|
46
|
+
try:
|
|
47
|
+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
48
|
+
user_config = yaml.safe_load(f)
|
|
49
|
+
if isinstance(user_config, dict):
|
|
50
|
+
# Deep merge user config into defaults
|
|
51
|
+
for section, values in user_config.items():
|
|
52
|
+
if section in config and isinstance(values, dict):
|
|
53
|
+
config[section].update(values)
|
|
54
|
+
else:
|
|
55
|
+
config[section] = values
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
warning(f"Failed to parse {CONFIG_FILE}: {exc}. Using default configuration.")
|
|
58
|
+
|
|
59
|
+
# Environment variable overrides
|
|
60
|
+
if os.getenv("ASTON_MODEL"):
|
|
61
|
+
config["llm"]["model"] = os.getenv("ASTON_MODEL")
|
|
62
|
+
|
|
63
|
+
return config
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def save_default_config() -> Path:
|
|
67
|
+
"""Initialize ~/.config/aston/config.yaml with default settings."""
|
|
68
|
+
get_config_dir()
|
|
69
|
+
if not CONFIG_FILE.exists():
|
|
70
|
+
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
|
71
|
+
yaml.safe_dump(DEFAULT_CONFIG, f, sort_keys=False)
|
|
72
|
+
rich_success(f"Initialized default configuration at: {CONFIG_FILE}")
|
|
73
|
+
else:
|
|
74
|
+
info(f"Configuration file already exists at: {CONFIG_FILE}")
|
|
75
|
+
return CONFIG_FILE
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def redact_secrets(data: any) -> any:
|
|
79
|
+
"""Recursively redact sensitive keys for safe display."""
|
|
80
|
+
sensitive_keys = {"api_key", "token", "secret", "password", "github_token"}
|
|
81
|
+
if isinstance(data, dict):
|
|
82
|
+
return {
|
|
83
|
+
k: ("********" if k.lower() in sensitive_keys else redact_secrets(v))
|
|
84
|
+
for k, v in data.items()
|
|
85
|
+
}
|
|
86
|
+
elif isinstance(data, list):
|
|
87
|
+
return [redact_secrets(item) for item in data]
|
|
88
|
+
return data
|