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/ingestion.py
ADDED
|
@@ -0,0 +1,1105 @@
|
|
|
1
|
+
"""Discover migration documentation and extract evidence-grounded migration rules.
|
|
2
|
+
Candidates that cannot be safely established are preserved with
|
|
3
|
+
validation_status='needs_review'; they must not be automatically applied.
|
|
4
|
+
"""
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import html
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
import requests
|
|
16
|
+
from dotenv import load_dotenv
|
|
17
|
+
from mistralai.client import Mistral
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
from aston.changes.chain import get_migration_chain, merge_rule_chain
|
|
20
|
+
from aston.cli.console import rich_success, error, warning, info
|
|
21
|
+
from aston.exceptions import ConfigurationError
|
|
22
|
+
from aston.Utils.utility import request_with_backoff
|
|
23
|
+
from aston.llm import call_llm
|
|
24
|
+
from aston.config import load_config
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
config = load_config()
|
|
28
|
+
|
|
29
|
+
llm_cfg = config.get("llm", {})
|
|
30
|
+
provider_name = llm_cfg.get("provider", "mistral")
|
|
31
|
+
model_name = llm_cfg.get("model", "codestral-latest")
|
|
32
|
+
temperature = llm_cfg.get("temperature", 0.0)
|
|
33
|
+
# max_retries = llm_cfg.get("max_retries", 3)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
load_dotenv()
|
|
37
|
+
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
|
|
38
|
+
|
|
39
|
+
API_KEY = os.getenv("MISTRAL_API_KEY")
|
|
40
|
+
client = Mistral(api_key=API_KEY) if API_KEY else None
|
|
41
|
+
|
|
42
|
+
GITHUB_API = "https://api.github.com"
|
|
43
|
+
REQUEST_TIMEOUT = 20
|
|
44
|
+
|
|
45
|
+
EXTRACTION_SCHEMA_VERSION = "v7-discussion-chunked-cache-safe"
|
|
46
|
+
CACHE_DIR = Path(".sma/rule_cache_v7")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MigrationRule(BaseModel):
|
|
50
|
+
package: str
|
|
51
|
+
old_api: str
|
|
52
|
+
new_api: str | None = None
|
|
53
|
+
requires_instantiation: bool | None = None
|
|
54
|
+
migration_type: str | None = None
|
|
55
|
+
description: str | None = None
|
|
56
|
+
from_version: str | None = None
|
|
57
|
+
to_version: str | None = None
|
|
58
|
+
example_before: str | None = None
|
|
59
|
+
example_after: str | None = None
|
|
60
|
+
evidence: str | None = None
|
|
61
|
+
source_url: str | None = None
|
|
62
|
+
validation_status: str | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class MigrationRuleset(BaseModel):
|
|
66
|
+
rules: list[MigrationRule] = Field(default_factory=list)
|
|
67
|
+
raw_example_blocks: list[str] = Field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
prompt = """
|
|
70
|
+
|
|
71
|
+
You are an expert static-analysis and code-migration engineer.
|
|
72
|
+
|
|
73
|
+
Extract migration candidates supported by the supplied documentation only.
|
|
74
|
+
|
|
75
|
+
The caller may supply FOCUS SYMBOLS observed in the user's codebase.
|
|
76
|
+
|
|
77
|
+
For each focus symbol, actively search the supplied documentation for:
|
|
78
|
+
|
|
79
|
+
- explicit removals or deprecations;
|
|
80
|
+
|
|
81
|
+
- replacement APIs and migration examples;
|
|
82
|
+
|
|
83
|
+
- changes to imports, clients, initialization, configuration, and call structure;
|
|
84
|
+
|
|
85
|
+
- whether the symbol remains supported in the target version.
|
|
86
|
+
|
|
87
|
+
Prioritize finding evidence for each focus symbol, but do not invent
|
|
88
|
+
|
|
89
|
+
a replacement if the documentation does not establish one.
|
|
90
|
+
|
|
91
|
+
If no migration evidence exists for a focus symbol in the supplied source,
|
|
92
|
+
|
|
93
|
+
do not fabricate a rule for it. Extract other documented candidates normally.
|
|
94
|
+
|
|
95
|
+
Return each distinct documented API rename, removal, signature change,
|
|
96
|
+
|
|
97
|
+
parameter replacement, configuration change, client-instantiation change,
|
|
98
|
+
|
|
99
|
+
or coordinated migration strategy. Do not invent a replacement API.
|
|
100
|
+
|
|
101
|
+
For every rule:
|
|
102
|
+
|
|
103
|
+
- package: use the package name supplied by the user.
|
|
104
|
+
|
|
105
|
+
- old_api: old fully qualified API symbol when the source supports it.
|
|
106
|
+
|
|
107
|
+
- new_api: documented replacement, or null if no replacement is established.
|
|
108
|
+
|
|
109
|
+
- requires_instantiation: boolean or null.
|
|
110
|
+
|
|
111
|
+
- migration_type: rename, structural, initialization, configuration,
|
|
112
|
+
client_migration, signature_change, removal, manual, or unknown.
|
|
113
|
+
|
|
114
|
+
- description: explain non-trivial transformations and unresolved details.
|
|
115
|
+
|
|
116
|
+
- from_version and to_version: leave null; Python assigns the actual boundary.
|
|
117
|
+
|
|
118
|
+
- evidence: an exact excerpt from the supplied source, not a paraphrase.
|
|
119
|
+
|
|
120
|
+
- source_url: only an exact URL shown in a SOURCE URL marker in the input;
|
|
121
|
+
|
|
122
|
+
otherwise null.
|
|
123
|
+
|
|
124
|
+
- example_before / example_after: minimal snippets explicitly shown in
|
|
125
|
+
|
|
126
|
+
the source for this rule; otherwise null.
|
|
127
|
+
|
|
128
|
+
- validation_status: use needs_review when replacement/evidence/strategy is
|
|
129
|
+
|
|
130
|
+
uncertain; do not claim validation merely because a name looks plausible.
|
|
131
|
+
|
|
132
|
+
Preserve candidates with unknown replacements. Use null for new_api when no
|
|
133
|
+
|
|
134
|
+
replacement is documented. Do not turn a configuration migration into a simple
|
|
135
|
+
|
|
136
|
+
rename without direct supporting evidence. Preserve competing strategies as
|
|
137
|
+
|
|
138
|
+
separate candidates rather than overwriting them.
|
|
139
|
+
|
|
140
|
+
Also return raw_example_blocks: distinct before/after code blocks copied
|
|
141
|
+
|
|
142
|
+
verbatim from the source. Do not invent or complete examples. Return [] when
|
|
143
|
+
|
|
144
|
+
there are no such blocks.
|
|
145
|
+
|
|
146
|
+
Output only data matching the response schema.
|
|
147
|
+
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
RENAME_CONTRADICTION_SIGNALS = (
|
|
151
|
+
"via", "through", "align", "pass to", "passed to", "configure",
|
|
152
|
+
"instantiat", "client", "constructor", "parameter of", "http client",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
ALLOWED_MIGRATION_TYPES = {
|
|
156
|
+
"rename", "structural", "initialization", "configuration",
|
|
157
|
+
"client_migration", "signature_change", "removal", "manual", "unknown",
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
ALLOWED_VALIDATION_STATUSES = {"validated", "needs_review", "rejected"}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class ValidatedRule(BaseModel):
|
|
164
|
+
old_api: str
|
|
165
|
+
new_api: str | None = None
|
|
166
|
+
migration_type: str | None = None
|
|
167
|
+
requires_instantiation: bool | None = None
|
|
168
|
+
description: str | None = None
|
|
169
|
+
from_version: str | None = None
|
|
170
|
+
to_version: str | None = None
|
|
171
|
+
evidence: str | None = None
|
|
172
|
+
source_url: str | None = None
|
|
173
|
+
validation_status: str
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class ValidatedRuleset(BaseModel):
|
|
177
|
+
rules: list[ValidatedRule] = Field(default_factory=list)
|
|
178
|
+
|
|
179
|
+
VALIDATOR_SYSTEM_PROMPT = """
|
|
180
|
+
|
|
181
|
+
You are a Python dependency migration rule validator. Validate every candidate
|
|
182
|
+
|
|
183
|
+
against the supplied source text and original extracted data.
|
|
184
|
+
|
|
185
|
+
Never invent APIs, source URLs, evidence, version boundaries, or examples.
|
|
186
|
+
|
|
187
|
+
Classify migration_type as one of: rename, structural, initialization,
|
|
188
|
+
|
|
189
|
+
configuration, client_migration, signature_change, removal, manual, unknown.
|
|
190
|
+
|
|
191
|
+
Set validation_status to exactly one of: validated, needs_review, rejected.
|
|
192
|
+
|
|
193
|
+
A rename is only a direct symbol substitution with the same calling convention
|
|
194
|
+
|
|
195
|
+
and surrounding behavior. If the description mentions a client, constructor,
|
|
196
|
+
|
|
197
|
+
configuration, passing a value elsewhere, or instantiation, it is not a simple
|
|
198
|
+
|
|
199
|
+
rename. If a rule's type and description contradict each other, mark it
|
|
200
|
+
|
|
201
|
+
needs_review.
|
|
202
|
+
|
|
203
|
+
Evidence must be an exact excerpt appearing in the supplied documentation.
|
|
204
|
+
|
|
205
|
+
Verbatim presence alone is insufficient if the excerpt is speculative, a
|
|
206
|
+
|
|
207
|
+
question, hedged, or an unconfirmed user comment. In those cases use
|
|
208
|
+
|
|
209
|
+
needs_review. A rule is validated only if evidence supports a concrete
|
|
210
|
+
|
|
211
|
+
replacement and the migration strategy is sufficiently established.
|
|
212
|
+
|
|
213
|
+
A missing or unresolved replacement is not grounds for rejection. Preserve
|
|
214
|
+
|
|
215
|
+
removals and configuration changes requiring investigation as needs_review;
|
|
216
|
+
|
|
217
|
+
use null for new_api when there is no documented replacement. Do not label a
|
|
218
|
+
|
|
219
|
+
rule validated solely because the proposed target symbol looks plausible.
|
|
220
|
+
|
|
221
|
+
Return one result per input candidate, preserving old_api and the input order.
|
|
222
|
+
|
|
223
|
+
Do not omit uncertain candidates. Use needs_review when uncertain. A candidate
|
|
224
|
+
|
|
225
|
+
may be rejected only when demonstrably invalid or contradicted by reliable
|
|
226
|
+
|
|
227
|
+
supplied evidence.
|
|
228
|
+
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def check_rename_contradiction(rule: dict) -> bool:
|
|
233
|
+
"""Detect a rename classification whose description implies structural work."""
|
|
234
|
+
if (rule.get("migration_type") or "").strip().lower() != "rename":
|
|
235
|
+
return False
|
|
236
|
+
description = (rule.get("description") or "").lower()
|
|
237
|
+
return any(signal in description for signal in RENAME_CONTRADICTION_SIGNALS)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def normalize_evidence_text(text: str) -> str:
|
|
241
|
+
"""Normalize whitespace and common Markdown formatting for comparison."""
|
|
242
|
+
if not isinstance(text, str):
|
|
243
|
+
return ""
|
|
244
|
+
text = text.replace("\\\\", "")
|
|
245
|
+
text = re.sub(r"`+", "", text)
|
|
246
|
+
text = re.sub(r"\s+", " ", text)
|
|
247
|
+
return text.strip().casefold()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _normalise_api(value: Any) -> str:
|
|
251
|
+
if not isinstance(value, str):
|
|
252
|
+
return ""
|
|
253
|
+
return value.replace("()", "").strip()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _is_unresolved_replacement(value: Any) -> bool:
|
|
257
|
+
if not isinstance(value, str) or not value.strip():
|
|
258
|
+
return True
|
|
259
|
+
normalized = value.strip().casefold()
|
|
260
|
+
return normalized.startswith(("unresolved:", "unknown:", "no replacement"))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _candidate_key(rule: dict) -> tuple[str, str, str]:
|
|
264
|
+
"""Identity includes strategy so competing migrations for one symbol survive."""
|
|
265
|
+
return (
|
|
266
|
+
_normalise_api(rule.get("old_api")).casefold(),
|
|
267
|
+
_normalise_api(rule.get("new_api")).casefold(),
|
|
268
|
+
str(rule.get("migration_type") or "").strip().casefold(),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def validate_and_normalize_rules(
|
|
273
|
+
package: str,
|
|
274
|
+
current_version: str,
|
|
275
|
+
target_version: str,
|
|
276
|
+
rules: list[dict],
|
|
277
|
+
raw_code_examples: list[str],
|
|
278
|
+
source_evidence: str = "",
|
|
279
|
+
) -> list[dict]:
|
|
280
|
+
"""Validate candidates; retain uncertain candidates with needs_review."""
|
|
281
|
+
if not rules:
|
|
282
|
+
warning("Rule validation skipped: no extracted rules.")
|
|
283
|
+
return []
|
|
284
|
+
if not client:
|
|
285
|
+
raise ConfigurationError(
|
|
286
|
+
message="MISTRAL_API_KEY environment variable is missing or invalid.",
|
|
287
|
+
details="Set your MISTRAL_API_KEY in your environment or .env file before running rule validation."
|
|
288
|
+
)
|
|
289
|
+
def preserve_for_review(source_rules: list[dict], reason: str) -> list[dict]:
|
|
290
|
+
preserved = []
|
|
291
|
+
for source_rule in source_rules:
|
|
292
|
+
rule = dict(source_rule)
|
|
293
|
+
# Extraction-stage status is untrusted; only the validator may reject.
|
|
294
|
+
old_api = _normalise_api(rule.get("old_api"))
|
|
295
|
+
if not old_api:
|
|
296
|
+
continue
|
|
297
|
+
new_api = rule.get("new_api")
|
|
298
|
+
if _is_unresolved_replacement(new_api):
|
|
299
|
+
new_api = None
|
|
300
|
+
note = f"Validation could not establish trust: {reason}"
|
|
301
|
+
description = rule.get("description")
|
|
302
|
+
source_url = rule.get("source_url")
|
|
303
|
+
if not isinstance(source_url, str) or source_url not in source_evidence:
|
|
304
|
+
source_url = None
|
|
305
|
+
rule.update({
|
|
306
|
+
"package": package,
|
|
307
|
+
"old_api": old_api,
|
|
308
|
+
"new_api": new_api,
|
|
309
|
+
"from_version": current_version,
|
|
310
|
+
"to_version": target_version,
|
|
311
|
+
"source_url": source_url,
|
|
312
|
+
"validation_status": "needs_review",
|
|
313
|
+
"description": f"{description.strip()} {note}" if isinstance(description, str) and description.strip() else note,
|
|
314
|
+
})
|
|
315
|
+
preserved.append(rule)
|
|
316
|
+
return preserved
|
|
317
|
+
user_prompt = f"""
|
|
318
|
+
PACKAGE: {package}
|
|
319
|
+
CURRENT VERSION: {current_version}
|
|
320
|
+
TARGET VERSION: {target_version}
|
|
321
|
+
ORIGINAL EXTRACTED RULES:
|
|
322
|
+
{json.dumps(rules, indent=2, default=str)}
|
|
323
|
+
RAW CODE EXAMPLES (context only):
|
|
324
|
+
{json.dumps(raw_code_examples or [], indent=2, default=str)}
|
|
325
|
+
SOURCE MIGRATION DOCUMENTATION:
|
|
326
|
+
{source_evidence or "No source documentation supplied."}
|
|
327
|
+
Validate each candidate independently. Preserve source metadata and exact
|
|
328
|
+
version boundary values supplied by the caller. Do not invent evidence or URLs.
|
|
329
|
+
"""
|
|
330
|
+
try:
|
|
331
|
+
response = call_llm(prompt=user_prompt, provider=provider_name, model=model_name, temperature=temperature, system_prompt=VALIDATOR_SYSTEM_PROMPT, response_format=ValidatedRuleset)
|
|
332
|
+
parsed = response.choices[0].message.parsed
|
|
333
|
+
if parsed is None:
|
|
334
|
+
warning("Validator returned no parsed result; preserving candidates as needs_review.")
|
|
335
|
+
return preserve_for_review(rules, "the validator returned no parsed result")
|
|
336
|
+
# Match validator output back to source candidates. If the validator
|
|
337
|
+
# omitted any input, preserve it for review instead of silently losing it.
|
|
338
|
+
unmatched = list(enumerate(rules))
|
|
339
|
+
matched_results: list[tuple[dict, dict]] = []
|
|
340
|
+
for validated_model in parsed.rules:
|
|
341
|
+
validated = validated_model.model_dump()
|
|
342
|
+
key = _candidate_key(validated)
|
|
343
|
+
match_index = next(
|
|
344
|
+
(i for i, (_, source) in enumerate(unmatched) if _candidate_key(source) == key),
|
|
345
|
+
None,
|
|
346
|
+
)
|
|
347
|
+
if match_index is None:
|
|
348
|
+
old_key = _normalise_api(validated.get("old_api")).casefold()
|
|
349
|
+
match_index = next(
|
|
350
|
+
(i for i, (_, source) in enumerate(unmatched)
|
|
351
|
+
if _normalise_api(source.get("old_api")).casefold() == old_key),
|
|
352
|
+
None,
|
|
353
|
+
)
|
|
354
|
+
if match_index is None:
|
|
355
|
+
warning(f"Validator returned an unmatched candidate {validated.get('old_api')!r}; ignoring extra output.")
|
|
356
|
+
continue
|
|
357
|
+
_, source = unmatched.pop(match_index)
|
|
358
|
+
matched_results.append((source, validated))
|
|
359
|
+
output: list[dict] = []
|
|
360
|
+
rejected = 0
|
|
361
|
+
omitted = [source for _, source in unmatched]
|
|
362
|
+
for source, validated in matched_results:
|
|
363
|
+
# Keep identity and source metadata from the original extraction.
|
|
364
|
+
rule = dict(source)
|
|
365
|
+
rule.update({k: v for k, v in validated.items() if v is not None})
|
|
366
|
+
rule["package"] = package
|
|
367
|
+
rule["old_api"] = _normalise_api(source.get("old_api"))
|
|
368
|
+
rule["from_version"] = current_version
|
|
369
|
+
rule["to_version"] = target_version
|
|
370
|
+
status = rule.get("validation_status")
|
|
371
|
+
if status not in ALLOWED_VALIDATION_STATUSES:
|
|
372
|
+
status = "needs_review"
|
|
373
|
+
if status == "rejected":
|
|
374
|
+
rejected += 1
|
|
375
|
+
continue
|
|
376
|
+
# Do not let model output replace known source metadata with invented values.
|
|
377
|
+
source_url = source.get("source_url")
|
|
378
|
+
candidate_url = validated.get("source_url")
|
|
379
|
+
if candidate_url and candidate_url in source_evidence:
|
|
380
|
+
rule["source_url"] = candidate_url
|
|
381
|
+
else:
|
|
382
|
+
rule["source_url"] = source_url if isinstance(source_url, str) and source_url in source_evidence else None
|
|
383
|
+
evidence = rule.get("evidence")
|
|
384
|
+
normalized_evidence = normalize_evidence_text(evidence or "")
|
|
385
|
+
normalized_source = normalize_evidence_text(source_evidence)
|
|
386
|
+
evidence_is_grounded = bool(normalized_evidence and normalized_source and normalized_evidence in normalized_source)
|
|
387
|
+
reasons = []
|
|
388
|
+
if check_rename_contradiction(rule):
|
|
389
|
+
reasons.append("Rename classification conflicts with a structural/configuration description.")
|
|
390
|
+
if not evidence_is_grounded:
|
|
391
|
+
reasons.append("Evidence could not be verified verbatim against the source documentation.")
|
|
392
|
+
if rule.get("migration_type") not in ALLOWED_MIGRATION_TYPES:
|
|
393
|
+
reasons.append("Migration type is missing or outside the supported classifications.")
|
|
394
|
+
if rule.get("migration_type") in {"removal", "manual", "unknown"}:
|
|
395
|
+
reasons.append("This migration strategy requires manual review.")
|
|
396
|
+
if _is_unresolved_replacement(rule.get("new_api")):
|
|
397
|
+
rule["new_api"] = None
|
|
398
|
+
reasons.append("No concrete replacement API has been established.")
|
|
399
|
+
if reasons:
|
|
400
|
+
status = "needs_review"
|
|
401
|
+
rule["validation_status"] = status
|
|
402
|
+
if reasons:
|
|
403
|
+
description = rule.get("description") or ""
|
|
404
|
+
rule["description"] = (description + "\nValidation concerns: " + " ".join(reasons)).strip()
|
|
405
|
+
if rule.get("old_api"):
|
|
406
|
+
# Only copy known fields, preserving the existing schema.
|
|
407
|
+
output.append({
|
|
408
|
+
"package": package,
|
|
409
|
+
"old_api": rule["old_api"],
|
|
410
|
+
"new_api": rule.get("new_api"),
|
|
411
|
+
"migration_type": rule.get("migration_type"),
|
|
412
|
+
"requires_instantiation": rule.get("requires_instantiation"),
|
|
413
|
+
"description": rule.get("description"),
|
|
414
|
+
"from_version": current_version,
|
|
415
|
+
"to_version": target_version,
|
|
416
|
+
"example_before": source.get("example_before"),
|
|
417
|
+
"example_after": source.get("example_after"),
|
|
418
|
+
"evidence": rule.get("evidence"),
|
|
419
|
+
"source_url": rule.get("source_url"),
|
|
420
|
+
"validation_status": status,
|
|
421
|
+
})
|
|
422
|
+
if omitted:
|
|
423
|
+
info(f"Validator omitted {len(omitted)} input candidate(s); preserving them as needs_review.")
|
|
424
|
+
output.extend(preserve_for_review(omitted, "the validator omitted this input candidate"))
|
|
425
|
+
print("\n========== Rule Validation Summary ==========")
|
|
426
|
+
info(f"Candidate rules: {len(rules)}")
|
|
427
|
+
info(f"Validated: {sum(r.get('validation_status') == 'validated' for r in output)}")
|
|
428
|
+
info(f"Needs review: {sum(r.get('validation_status') == 'needs_review' for r in output)}")
|
|
429
|
+
info(f"Rejected: {rejected}")
|
|
430
|
+
print("==============================================\n")
|
|
431
|
+
return output
|
|
432
|
+
except Exception as exc:
|
|
433
|
+
error(f"Rule validation failed: {exc}")
|
|
434
|
+
info("Preserving extracted candidates as needs_review; none will be marked validated by this fallback.")
|
|
435
|
+
return preserve_for_review(rules, f"validator failure: {type(exc).__name__}: {exc}")
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _github_headers() -> dict[str, str]:
|
|
439
|
+
headers = {
|
|
440
|
+
"Accept": "application/vnd.github+json",
|
|
441
|
+
"User-Agent": "Aston-dependency-migration-tool",
|
|
442
|
+
}
|
|
443
|
+
if GITHUB_TOKEN:
|
|
444
|
+
headers["Authorization"] = f"Bearer {GITHUB_TOKEN}"
|
|
445
|
+
return headers
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _cache_path(
|
|
449
|
+
package: str,
|
|
450
|
+
tag: str,
|
|
451
|
+
doc_text: str,
|
|
452
|
+
stage0_symbols: set[str],
|
|
453
|
+
current_version: str = "",
|
|
454
|
+
target_version: str = "",
|
|
455
|
+
) -> Path:
|
|
456
|
+
"""Cache by schema, evidence, symbols, and the exact version transition."""
|
|
457
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
458
|
+
digest_input = "\n".join((
|
|
459
|
+
EXTRACTION_SCHEMA_VERSION,
|
|
460
|
+
f"current={current_version}",
|
|
461
|
+
f"target={target_version}",
|
|
462
|
+
doc_text,
|
|
463
|
+
"--SYMBOLS--",
|
|
464
|
+
"\n".join(sorted(stage0_symbols)),
|
|
465
|
+
)).encode("utf-8", errors="replace")
|
|
466
|
+
digest = hashlib.sha256(digest_input).hexdigest()[:16]
|
|
467
|
+
safe_package = re.sub(r"[^A-Za-z0-9_.-]", "_", package)
|
|
468
|
+
safe_tag = re.sub(r"[^A-Za-z0-9_.-]", "_", tag)
|
|
469
|
+
return CACHE_DIR / f"{safe_package}__{safe_tag}__{digest}.json"
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def should_extract(doc_text: str, stage0_symbols: set[str]) -> bool:
|
|
473
|
+
"""Use specific migration signals or a scanned-symbol match as a cheap gate."""
|
|
474
|
+
if not doc_text or len(doc_text.strip()) < 30:
|
|
475
|
+
return False
|
|
476
|
+
doc_lower = doc_text.lower()
|
|
477
|
+
migration_indicators = (
|
|
478
|
+
"migration", "upgrade guide", "breaking change", "breaking changes",
|
|
479
|
+
"changelog", "deprecated", "deprecation", "no longer supported",
|
|
480
|
+
"removed", "renamed", "signature change", "migration guide",
|
|
481
|
+
)
|
|
482
|
+
if any(indicator in doc_lower for indicator in migration_indicators):
|
|
483
|
+
return True
|
|
484
|
+
if not stage0_symbols:
|
|
485
|
+
return True
|
|
486
|
+
for symbol in stage0_symbols:
|
|
487
|
+
normalized = symbol.strip().lower()
|
|
488
|
+
if normalized and normalized in doc_lower:
|
|
489
|
+
return True
|
|
490
|
+
leaf = normalized.rsplit(".", 1)[-1]
|
|
491
|
+
if len(leaf) >= 4 and re.search(rf"(?\<![a-z0-9_]){re.escape(leaf)}(?![a-z0-9_])", doc_lower):
|
|
492
|
+
return True
|
|
493
|
+
return False
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def normalize_rule_symbols(rules: list[dict]) -> list[dict]:
|
|
497
|
+
normalized = []
|
|
498
|
+
for source in rules:
|
|
499
|
+
rule = dict(source)
|
|
500
|
+
for key in ("old_api", "new_api"):
|
|
501
|
+
value = rule.get(key)
|
|
502
|
+
if isinstance(value, str):
|
|
503
|
+
rule[key] = value.replace("()", "").strip()
|
|
504
|
+
normalized.append(rule)
|
|
505
|
+
return normalized
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _validate_raw_blocks(blocks: list[str], source_text: str) -> list[str]:
|
|
509
|
+
normalized_source = re.sub(r"\s+", " ", source_text).casefold()
|
|
510
|
+
result = []
|
|
511
|
+
seen = set()
|
|
512
|
+
for block in blocks:
|
|
513
|
+
cleaned = block.strip()
|
|
514
|
+
normalized_block = re.sub(r"\s+", " ", cleaned).casefold()
|
|
515
|
+
if cleaned and normalized_block in normalized_source and cleaned not in seen:
|
|
516
|
+
result.append(cleaned)
|
|
517
|
+
seen.add(cleaned)
|
|
518
|
+
return result
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _split_document_chunks(
|
|
522
|
+
doc_text: str,
|
|
523
|
+
max_chars: int = 12000,
|
|
524
|
+
overlap: int = 1200,
|
|
525
|
+
) -> list[tuple[int, str]]:
|
|
526
|
+
"""Split documentation into overlapping chunks without dropping the tail."""
|
|
527
|
+
if not doc_text:
|
|
528
|
+
return []
|
|
529
|
+
max_chars = max(1000, max_chars)
|
|
530
|
+
overlap = max(0, min(overlap, max_chars // 3))
|
|
531
|
+
chunks: list[tuple[int, str]] = []
|
|
532
|
+
start = 0
|
|
533
|
+
index = 0
|
|
534
|
+
text_length = len(doc_text)
|
|
535
|
+
while start < text_length:
|
|
536
|
+
end = min(text_length, start + max_chars)
|
|
537
|
+
if end < text_length:
|
|
538
|
+
# Prefer ending at a paragraph/line boundary, but never make a tiny chunk.
|
|
539
|
+
boundary = max(doc_text.rfind("\n\n", start + max_chars // 2, end),
|
|
540
|
+
doc_text.rfind("\n", start + max_chars // 2, end))
|
|
541
|
+
if boundary > start + max_chars // 2:
|
|
542
|
+
end = boundary
|
|
543
|
+
chunk = doc_text[start:end].strip()
|
|
544
|
+
if chunk:
|
|
545
|
+
chunks.append((index, chunk))
|
|
546
|
+
index += 1
|
|
547
|
+
if end >= text_length:
|
|
548
|
+
break
|
|
549
|
+
next_start = max(start + 1, end - overlap)
|
|
550
|
+
start = next_start
|
|
551
|
+
return chunks
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _chunk_priority(chunk: str, focus_symbols: set[str]) -> int:
|
|
555
|
+
"""Higher scores mean the chunk directly mentions more observed symbols."""
|
|
556
|
+
lower = chunk.casefold()
|
|
557
|
+
return sum(1 for symbol in focus_symbols if symbol.casefold() in lower)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _rule_fallback_detected(rules: list[dict]) -> bool:
|
|
561
|
+
"""Recognize validator fallback candidates so they are returned but not cached."""
|
|
562
|
+
markers = (
|
|
563
|
+
"validation could not establish trust",
|
|
564
|
+
"validator failure:",
|
|
565
|
+
"validator returned no parsed result",
|
|
566
|
+
)
|
|
567
|
+
for rule in rules:
|
|
568
|
+
description = str(rule.get("description") or "").casefold()
|
|
569
|
+
if any(marker in description for marker in markers):
|
|
570
|
+
return True
|
|
571
|
+
return False
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _validation_evidence_for_rules(
|
|
575
|
+
doc_text: str,
|
|
576
|
+
rules: list[dict],
|
|
577
|
+
max_chars: int = 30000,
|
|
578
|
+
) -> str:
|
|
579
|
+
"""Build a bounded evidence bundle for validation without discarding late sources."""
|
|
580
|
+
if not doc_text:
|
|
581
|
+
return ""
|
|
582
|
+
folded = doc_text.casefold()
|
|
583
|
+
source_marker = "=== SOURCE URL:"
|
|
584
|
+
snippets: list[str] = []
|
|
585
|
+
seen: set[str] = set()
|
|
586
|
+
total = 0
|
|
587
|
+
for rule in rules:
|
|
588
|
+
evidence = rule.get("evidence")
|
|
589
|
+
old_api = str(rule.get("old_api") or "").strip()
|
|
590
|
+
position = -1
|
|
591
|
+
span = 0
|
|
592
|
+
if isinstance(evidence, str) and evidence.strip():
|
|
593
|
+
position = folded.find(evidence.strip().casefold())
|
|
594
|
+
if position >= 0:
|
|
595
|
+
span = len(evidence.strip())
|
|
596
|
+
if position < 0 and old_api:
|
|
597
|
+
position = folded.find(old_api.casefold())
|
|
598
|
+
span = len(old_api)
|
|
599
|
+
if position < 0:
|
|
600
|
+
continue
|
|
601
|
+
marker_position = doc_text.rfind(source_marker, 0, position)
|
|
602
|
+
start = marker_position if marker_position >= 0 else max(0, position - 500)
|
|
603
|
+
end = min(len(doc_text), position + max(span, 1) + 500)
|
|
604
|
+
snippet = doc_text[start:end].strip()
|
|
605
|
+
normalized = re.sub(r"\s+", " ", snippet).casefold()
|
|
606
|
+
if not snippet or normalized in seen:
|
|
607
|
+
continue
|
|
608
|
+
if total + len(snippet) > max_chars:
|
|
609
|
+
# Keep evidence atomic; don't truncate an excerpt mid-claim.
|
|
610
|
+
continue
|
|
611
|
+
snippets.append(snippet)
|
|
612
|
+
seen.add(normalized)
|
|
613
|
+
total += len(snippet)
|
|
614
|
+
if not snippets:
|
|
615
|
+
return doc_text[:min(50000, max_chars)]
|
|
616
|
+
return "\n\n--- VALIDATION EVIDENCE EXCERPT ---\n\n".join(snippets)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _github_discussion_markdown(url: str) -> str | None:
|
|
620
|
+
"""Fetch a GitHub Discussion's Markdown body through the GraphQL API."""
|
|
621
|
+
parsed = urlparse(url)
|
|
622
|
+
if parsed.hostname != "github.com" or not GITHUB_TOKEN:
|
|
623
|
+
return None
|
|
624
|
+
match = re.match(r"^/([^/]+)/([^/]+)/discussions/(\d+)(?:/|$)", parsed.path)
|
|
625
|
+
if not match:
|
|
626
|
+
return None
|
|
627
|
+
owner, repository, number = match.groups()
|
|
628
|
+
query = """
|
|
629
|
+
query($owner: String!, $name: String!, $number: Int!) {
|
|
630
|
+
repository(owner: $owner, name: $name) {
|
|
631
|
+
discussion(number: $number) { title body url }
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
"""
|
|
635
|
+
try:
|
|
636
|
+
response = request_with_backoff(
|
|
637
|
+
"POST",
|
|
638
|
+
"https://api.github.com/graphql",
|
|
639
|
+
max_retries=3,
|
|
640
|
+
backoff_factor=2.0,
|
|
641
|
+
headers={**_github_headers(), "Accept": "application/vnd.github+json"},
|
|
642
|
+
json={"query": query, "variables": {
|
|
643
|
+
"owner": owner, "name": repository, "number": int(number)
|
|
644
|
+
}},
|
|
645
|
+
)
|
|
646
|
+
if response.status_code != 200:
|
|
647
|
+
error(f"GitHub GraphQL returned {response.status_code} for {url}")
|
|
648
|
+
return None
|
|
649
|
+
payload = response.json()
|
|
650
|
+
if payload.get("errors"):
|
|
651
|
+
warning(f" GitHub GraphQL could not retrieve discussion {url}: {payload['errors']}")
|
|
652
|
+
return None
|
|
653
|
+
discussion = ((payload.get("data") or {}).get("repository") or {}).get("discussion")
|
|
654
|
+
if not discussion or not isinstance(discussion.get("body"), str):
|
|
655
|
+
return None
|
|
656
|
+
title = (discussion.get("title") or "").strip()
|
|
657
|
+
body = discussion["body"].strip()
|
|
658
|
+
return (f"# {title}\n\n{body}" if title else body) or None
|
|
659
|
+
except (requests.RequestException, ValueError, TypeError) as exc:
|
|
660
|
+
warning(f"GitHub Discussion GraphQL fetch failed for {url}: {exc}")
|
|
661
|
+
return None
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _changelog_section_for_tag(content: str, tag: str) -> str | None:
|
|
665
|
+
"""Return the Markdown heading section corresponding to a release tag."""
|
|
666
|
+
version_text = tag.strip()
|
|
667
|
+
if version_text.startswith(("v", "V")):
|
|
668
|
+
version_text = version_text[1:]
|
|
669
|
+
escaped_version = re.escape(version_text)
|
|
670
|
+
version_pattern = re.compile(
|
|
671
|
+
rf"(?<![A-Za-z0-9])v?{escaped_version}(?![A-Za-z0-9])", re.IGNORECASE
|
|
672
|
+
)
|
|
673
|
+
headings = list(re.finditer(r"(?m)^(#{1,6})[ \t]+(.+?)\s*$", content))
|
|
674
|
+
for position, heading in enumerate(headings):
|
|
675
|
+
heading_text = heading.group(2)
|
|
676
|
+
if not version_pattern.search(heading_text):
|
|
677
|
+
continue
|
|
678
|
+
level = len(heading.group(1))
|
|
679
|
+
section_end = len(content)
|
|
680
|
+
for following in headings[position + 1:]:
|
|
681
|
+
if len(following.group(1)) <= level:
|
|
682
|
+
section_end = following.start()
|
|
683
|
+
break
|
|
684
|
+
section = content[heading.start():section_end].strip()
|
|
685
|
+
return section or None
|
|
686
|
+
return None
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def extract_rules(
|
|
690
|
+
package: str,
|
|
691
|
+
doc_text: str,
|
|
692
|
+
focus_symbols: set[str] | None = None,
|
|
693
|
+
) -> MigrationRuleset:
|
|
694
|
+
"""Extract evidence-grounded rules across the full document in focused chunks."""
|
|
695
|
+
if not client:
|
|
696
|
+
raise ConfigurationError(
|
|
697
|
+
message="MISTRAL_API_KEY environment variable is missing or invalid.",
|
|
698
|
+
details="Set your MISTRAL_API_KEY in your environment or .env file before running rule extraction."
|
|
699
|
+
)
|
|
700
|
+
if not doc_text or len(doc_text.strip()) < 30:
|
|
701
|
+
return MigrationRuleset()
|
|
702
|
+
focus_symbols = {
|
|
703
|
+
symbol.strip()
|
|
704
|
+
for symbol in (focus_symbols or set())
|
|
705
|
+
if isinstance(symbol, str) and symbol.strip()
|
|
706
|
+
}
|
|
707
|
+
chunks = _split_document_chunks(doc_text)
|
|
708
|
+
if focus_symbols:
|
|
709
|
+
# Process the most relevant chunks first, but still process every chunk.
|
|
710
|
+
chunks.sort(key=lambda item: (-_chunk_priority(item[1], focus_symbols), item[0]))
|
|
711
|
+
info(
|
|
712
|
+
f"Extraction input: {len(doc_text)} total characters across "
|
|
713
|
+
f"{len(chunks)} chunks; focus symbols: {len(focus_symbols)}"
|
|
714
|
+
)
|
|
715
|
+
lower_doc = doc_text.casefold()
|
|
716
|
+
for symbol in sorted(focus_symbols):
|
|
717
|
+
position = lower_doc.find(symbol.casefold())
|
|
718
|
+
if position >= 0:
|
|
719
|
+
info(f" Found {symbol!r} in combined docs at character {position}")
|
|
720
|
+
else:
|
|
721
|
+
warning(f"{symbol!r} does not appear literally in combined docs")
|
|
722
|
+
focus_section = (
|
|
723
|
+
"\n".join(f"- {symbol}" for symbol in sorted(focus_symbols))
|
|
724
|
+
if focus_symbols else "(No specific focus symbols supplied.)"
|
|
725
|
+
)
|
|
726
|
+
combined_rules: list[dict] = []
|
|
727
|
+
seen_rule_keys: set[tuple[str, str, str]] = set()
|
|
728
|
+
combined_blocks: list[str] = []
|
|
729
|
+
seen_blocks: set[str] = set()
|
|
730
|
+
for chunk_number, chunk_text in chunks:
|
|
731
|
+
info(f"Extracting chunk {chunk_number + 1}/{len(chunks)} ({len(chunk_text)} chars)")
|
|
732
|
+
|
|
733
|
+
content = (
|
|
734
|
+
f"Package name: {package}\n\n"
|
|
735
|
+
"FOCUS SYMBOLS OBSERVED IN THE CODEBASE:\n"
|
|
736
|
+
f"{focus_section}\n\n"
|
|
737
|
+
f"DOCUMENT CHUNK: {chunk_number + 1} of {len(chunks)}\n"
|
|
738
|
+
"Extract only rules supported by this chunk. Prioritize evidence "
|
|
739
|
+
"relevant to the focus symbols. Version boundaries are assigned "
|
|
740
|
+
"by Python. Do not invent replacements when the source is uncertain.\n\n"
|
|
741
|
+
f"Documentation chunk:\n{chunk_text}"
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
response = call_llm(prompt=content, provider=provider_name, model=model_name, temperature=temperature, system_prompt=prompt, response_format=MigrationRule)
|
|
745
|
+
|
|
746
|
+
parsed = response.choices[0].message.parsed
|
|
747
|
+
if parsed is None:
|
|
748
|
+
raise RuntimeError(
|
|
749
|
+
f"Codestral returned no parsed ruleset for {package}, chunk {chunk_number + 1}"
|
|
750
|
+
)
|
|
751
|
+
normalized = normalize_rule_symbols(
|
|
752
|
+
[rule.model_dump() for rule in parsed.rules]
|
|
753
|
+
)
|
|
754
|
+
for rule in normalized:
|
|
755
|
+
rule["package"] = package
|
|
756
|
+
source_url = rule.get("source_url")
|
|
757
|
+
if not isinstance(source_url, str) or source_url not in chunk_text:
|
|
758
|
+
rule["source_url"] = None
|
|
759
|
+
key = _candidate_key(rule)
|
|
760
|
+
if key not in seen_rule_keys:
|
|
761
|
+
seen_rule_keys.add(key)
|
|
762
|
+
combined_rules.append(rule)
|
|
763
|
+
for block in _validate_raw_blocks(parsed.raw_example_blocks, chunk_text):
|
|
764
|
+
normalized_block = re.sub(r"\s+", " ", block).strip().casefold()
|
|
765
|
+
if normalized_block and normalized_block not in seen_blocks:
|
|
766
|
+
seen_blocks.add(normalized_block)
|
|
767
|
+
combined_blocks.append(block)
|
|
768
|
+
return MigrationRuleset(
|
|
769
|
+
rules=[MigrationRule.model_validate(rule) for rule in combined_rules],
|
|
770
|
+
raw_example_blocks=combined_blocks,
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def extract_rules_cached(
|
|
775
|
+
package: str,
|
|
776
|
+
tag: str,
|
|
777
|
+
doc_text: str,
|
|
778
|
+
stage0_symbols: set[str],
|
|
779
|
+
current_version: str,
|
|
780
|
+
target_version: str,
|
|
781
|
+
) -> MigrationRuleset:
|
|
782
|
+
"""Extract and validate rules; cache only nonempty, non-fallback results."""
|
|
783
|
+
cache_file = _cache_path(
|
|
784
|
+
package, tag, doc_text, stage0_symbols, current_version, target_version
|
|
785
|
+
)
|
|
786
|
+
if cache_file.exists():
|
|
787
|
+
try:
|
|
788
|
+
cached = MigrationRuleset.model_validate_json(
|
|
789
|
+
cache_file.read_text(encoding="utf-8")
|
|
790
|
+
)
|
|
791
|
+
for rule in cached.rules:
|
|
792
|
+
rule.from_version = current_version
|
|
793
|
+
rule.to_version = target_version
|
|
794
|
+
return cached
|
|
795
|
+
except Exception as exc:
|
|
796
|
+
warning(f"Invalid cache for {tag}; re-extracting ({exc})")
|
|
797
|
+
if not should_extract(doc_text, stage0_symbols):
|
|
798
|
+
warning(
|
|
799
|
+
f"{tag}: no relevant migration signal or scanned symbol; "
|
|
800
|
+
"skipping LLM extraction without caching the empty result."
|
|
801
|
+
)
|
|
802
|
+
return MigrationRuleset()
|
|
803
|
+
raw = extract_rules(package, doc_text, focus_symbols=stage0_symbols)
|
|
804
|
+
if not raw.rules:
|
|
805
|
+
warning(f"{tag}: extraction returned no rules; result will not be cached.")
|
|
806
|
+
return raw
|
|
807
|
+
raw_dicts = []
|
|
808
|
+
for model in raw.rules:
|
|
809
|
+
rule = model.model_dump()
|
|
810
|
+
rule["package"] = package
|
|
811
|
+
rule["from_version"] = current_version
|
|
812
|
+
rule["to_version"] = target_version
|
|
813
|
+
raw_dicts.append(rule)
|
|
814
|
+
|
|
815
|
+
validated_dicts = validate_and_normalize_rules(
|
|
816
|
+
package=package,
|
|
817
|
+
current_version=current_version,
|
|
818
|
+
target_version=target_version,
|
|
819
|
+
rules=[r.model_dump() for r in raw.rules],
|
|
820
|
+
raw_code_examples=raw.raw_example_blocks,
|
|
821
|
+
source_evidence=doc_text,
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
result = MigrationRuleset(
|
|
825
|
+
rules=[MigrationRule.model_validate(r) for r in validated_dicts],
|
|
826
|
+
raw_example_blocks=raw.raw_example_blocks,
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
# Only cache if validation succeeded cleanly (no fallback errors or mass needs_review)
|
|
830
|
+
has_fallback_warning = any("Validation could not establish trust" in str(r.get("description")) for r in validated_dicts)
|
|
831
|
+
if not has_fallback_warning:
|
|
832
|
+
cache_file.write_text(result.model_dump_json(indent=2))
|
|
833
|
+
else:
|
|
834
|
+
warning(f"Skipping cache write for {tag} due to validation fallback/concerns.")
|
|
835
|
+
|
|
836
|
+
return result
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def get_installed_version(package_name: str) -> str | None:
|
|
840
|
+
try:
|
|
841
|
+
return version(package_name)
|
|
842
|
+
except PackageNotFoundError:
|
|
843
|
+
return None
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def get_latest_pypi_version(package_name: str) -> str | None:
|
|
847
|
+
try:
|
|
848
|
+
response = request_with_backoff(
|
|
849
|
+
"GET",
|
|
850
|
+
f"https://pypi.org/pypi/{package_name}/json",
|
|
851
|
+
max_retries=3,
|
|
852
|
+
backoff_factor=2.0,
|
|
853
|
+
)
|
|
854
|
+
response.raise_for_status()
|
|
855
|
+
return response.json().get("info", {}).get("version")
|
|
856
|
+
except (requests.RequestException, ValueError) as exc:
|
|
857
|
+
error(f"PyPI lookup failed for {package_name}: {exc}")
|
|
858
|
+
return None
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def resolve_github_repo(package_name: str) -> Optional[str]:
|
|
862
|
+
response = request_with_backoff(
|
|
863
|
+
"GET",
|
|
864
|
+
f"https://pypi.org/pypi/{package_name}/json",
|
|
865
|
+
max_retries=3,
|
|
866
|
+
backoff_factor=2.0,
|
|
867
|
+
)
|
|
868
|
+
if response.status_code != 200:
|
|
869
|
+
return None
|
|
870
|
+
project_urls = response.json().get("info", {}).get("project_urls") or {}
|
|
871
|
+
for target_url in project_urls.values():
|
|
872
|
+
match = re.search(r"github\.com/([^/]+/[^/]+)", target_url)
|
|
873
|
+
if match:
|
|
874
|
+
return match.group(1).removesuffix(".git").rstrip("/")
|
|
875
|
+
return None
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def is_candidate_url(url: str) -> bool:
|
|
879
|
+
"""Permit major migration discussions, changelogs, and upgrade guides,
|
|
880
|
+
while continuing to filter out noisy individual bug reports or PR diffs."""
|
|
881
|
+
lower = url.lower()
|
|
882
|
+
|
|
883
|
+
# Block noisy PRs and issue comments, but KEEP discussions and migration pages
|
|
884
|
+
if any(path in lower for path in ("/issues/", "/pull/")):
|
|
885
|
+
return False
|
|
886
|
+
|
|
887
|
+
return any(
|
|
888
|
+
term in lower for term in ("migration", "upgrade", "breaking", "changelog", "changes", "discussions")
|
|
889
|
+
) or lower.endswith((".md", ".rst"))
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def extract_candidate_urls_from_text(text: str) -> list[str]:
|
|
893
|
+
pattern = r"https?://[^\s<>]+"
|
|
894
|
+
urls = {item.rstrip(".,;:!?)]}") for item in re.findall(pattern, text)}
|
|
895
|
+
return sorted(url for url in urls if is_candidate_url(url))
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def fetch_page_content(url: str) -> str | None:
|
|
899
|
+
"""Fetch a linked page, using raw GitHub Discussion Markdown when possible."""
|
|
900
|
+
parsed = urlparse(url)
|
|
901
|
+
if parsed.hostname == "github.com" and re.search(
|
|
902
|
+
r"/discussions/\d+(?:/|$)", parsed.path
|
|
903
|
+
):
|
|
904
|
+
markdown = _github_discussion_markdown(url)
|
|
905
|
+
if markdown:
|
|
906
|
+
info(f"Retrieved GitHub Discussion Markdown ({len(markdown)} characters)")
|
|
907
|
+
return markdown
|
|
908
|
+
try:
|
|
909
|
+
response = request_with_backoff(
|
|
910
|
+
"GET",
|
|
911
|
+
url,
|
|
912
|
+
max_retries=3,
|
|
913
|
+
backoff_factor=2.0,
|
|
914
|
+
headers={"User-Agent": "Aston-dependency-migration-tool"},
|
|
915
|
+
)
|
|
916
|
+
if response.status_code != 200:
|
|
917
|
+
error(f"HTTP {response.status_code} while fetching {url}")
|
|
918
|
+
return None
|
|
919
|
+
page = response.text
|
|
920
|
+
# GitHub's article contains the useful body; discard page chrome when possible.
|
|
921
|
+
article = re.search(r"(?is)<article\b[^>]*>(.*?)</article>", page)
|
|
922
|
+
if article:
|
|
923
|
+
page = article.group(1)
|
|
924
|
+
page = re.sub(r"(?is)<(script|style|noscript)\b[^>]*>.*?</\1\s*>", " ", page)
|
|
925
|
+
page = re.sub(
|
|
926
|
+
r"(?i)<br\s*/?>|</(p|div|li|pre|h[1-6]|blockquote|tr)\s*>",
|
|
927
|
+
"\n", page,
|
|
928
|
+
)
|
|
929
|
+
page = re.sub(r"<[^>]+>", " ", page)
|
|
930
|
+
page = html.unescape(page)
|
|
931
|
+
lines = [line.strip() for line in page.splitlines() if line.strip()]
|
|
932
|
+
cleaned = "\n".join(lines)
|
|
933
|
+
# This cap is only for HTML fallback; GraphQL Discussion Markdown is not truncated.
|
|
934
|
+
return cleaned[:100000]
|
|
935
|
+
except requests.RequestException as exc:
|
|
936
|
+
warning(f"Failed to fetch {url}: {exc}")
|
|
937
|
+
return None
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def collect_linked_sources(initial_text: str, max_depth: int = 2) -> list[str]:
|
|
941
|
+
"""Collect linked docs with explicit URL markers and a strict depth limit."""
|
|
942
|
+
collected: list[str] = []
|
|
943
|
+
visited: set[str] = set()
|
|
944
|
+
current_level: list[tuple[str | None, str]] = [(None, initial_text)]
|
|
945
|
+
for depth in range(max_depth + 1):
|
|
946
|
+
next_level: list[tuple[str | None, str]] = []
|
|
947
|
+
for source_url, source_text in current_level:
|
|
948
|
+
if source_url:
|
|
949
|
+
collected.append(f"=== SOURCE URL: {source_url} ===\n{source_text}")
|
|
950
|
+
else:
|
|
951
|
+
collected.append(source_text)
|
|
952
|
+
# Do not fetch a level whose content would be outside max_depth.
|
|
953
|
+
if depth >= max_depth:
|
|
954
|
+
continue
|
|
955
|
+
for url in extract_candidate_urls_from_text(source_text):
|
|
956
|
+
if url in visited:
|
|
957
|
+
continue
|
|
958
|
+
visited.add(url)
|
|
959
|
+
info(f"Following migration link (depth {depth + 1}): {url}")
|
|
960
|
+
content = fetch_page_content(url)
|
|
961
|
+
if content and len(content) > 100:
|
|
962
|
+
info(f"Retrieved {len(content)} characters")
|
|
963
|
+
next_level.append((url, content))
|
|
964
|
+
current_level = next_level
|
|
965
|
+
if not current_level:
|
|
966
|
+
break
|
|
967
|
+
return collected
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _fetch_repo_file(repo: str, tag: str, filename: str) -> str | None:
|
|
971
|
+
url = f"{GITHUB_API}/repos/{repo}/contents/{filename}"
|
|
972
|
+
response = request_with_backoff(
|
|
973
|
+
"GET",
|
|
974
|
+
url,
|
|
975
|
+
max_retries=3,
|
|
976
|
+
backoff_factor=2.0,
|
|
977
|
+
params={"ref": tag},
|
|
978
|
+
headers=_github_headers(),
|
|
979
|
+
)
|
|
980
|
+
if response.status_code != 200:
|
|
981
|
+
return None
|
|
982
|
+
data = response.json()
|
|
983
|
+
if data.get("encoding") == "base64" and data.get("content"):
|
|
984
|
+
try:
|
|
985
|
+
return base64.b64decode(data["content"]).decode("utf-8", errors="replace")
|
|
986
|
+
except (ValueError, TypeError):
|
|
987
|
+
return None
|
|
988
|
+
download_url = data.get("download_url")
|
|
989
|
+
if download_url:
|
|
990
|
+
raw = request_with_backoff("GET", download_url, max_retries=3, backoff_factor=2.0)
|
|
991
|
+
if raw.status_code == 200:
|
|
992
|
+
return raw.text
|
|
993
|
+
return None
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def discover_release_doc(repo: str, tag: str) -> str | None:
|
|
997
|
+
"""Retrieve release notes with robust fallback to changelog and migration files."""
|
|
998
|
+
release_url = f"{GITHUB_API}/repos/{repo}/releases/tags/{tag}"
|
|
999
|
+
body = ""
|
|
1000
|
+
try:
|
|
1001
|
+
response = request_with_backoff(
|
|
1002
|
+
"GET",
|
|
1003
|
+
release_url,
|
|
1004
|
+
max_retries=3,
|
|
1005
|
+
backoff_factor=2.0,
|
|
1006
|
+
headers=_github_headers(),
|
|
1007
|
+
)
|
|
1008
|
+
if response.status_code == 200:
|
|
1009
|
+
body = response.json().get("body", "")
|
|
1010
|
+
except requests.RequestException as exc:
|
|
1011
|
+
warning(f"Release lookup failed for {tag}: {exc}")
|
|
1012
|
+
|
|
1013
|
+
# Always check repository files if release body is short or missing comprehensive migration text
|
|
1014
|
+
if not body or len(body.strip()) < 200 or "migration" not in body.lower():
|
|
1015
|
+
info(f"Checking repository migration files for {tag}...")
|
|
1016
|
+
candidates = (
|
|
1017
|
+
"MIGRATION.md", "migration.md", "CHANGELOG.md", "CHANGELOG.rst",
|
|
1018
|
+
"CHANGES.md", "docs/migration.md", "docs/MIGRATION.md", "docs/CHANGELOG.md",
|
|
1019
|
+
)
|
|
1020
|
+
found = []
|
|
1021
|
+
for filename in candidates:
|
|
1022
|
+
try:
|
|
1023
|
+
content = _fetch_repo_file(repo, tag, filename)
|
|
1024
|
+
except Exception:
|
|
1025
|
+
continue
|
|
1026
|
+
if content and len(content.strip()) > 50:
|
|
1027
|
+
found.append(f"=== SOURCE FILE: {filename} @ {tag} ===\n{content}")
|
|
1028
|
+
if found:
|
|
1029
|
+
body = "\n\n".join(found) + "\n\n" + body
|
|
1030
|
+
|
|
1031
|
+
if not body or len(body.strip()) < 20:
|
|
1032
|
+
warning(f"Unresolved boundary: no documentation source found for {tag}")
|
|
1033
|
+
return None
|
|
1034
|
+
|
|
1035
|
+
info(f"Ingested documentation source for tag: {tag}")
|
|
1036
|
+
linked_docs = collect_linked_sources(body, max_depth=2)
|
|
1037
|
+
return "\n\n--- NEXT DOCUMENT IN CHAIN ---\n\n".join(linked_docs)[:50000]
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def run_stage1_pipeline(
|
|
1041
|
+
package_name: str,
|
|
1042
|
+
current_version: str | None = None,
|
|
1043
|
+
target_version: str | None = None,
|
|
1044
|
+
stage0_symbols: set[str] | None = None,
|
|
1045
|
+
) -> tuple[list[dict], str, list[dict]]:
|
|
1046
|
+
"""Return (merged_rules, combined_docs, boundary-tagged raw examples)."""
|
|
1047
|
+
if not current_version or not target_version:
|
|
1048
|
+
raise ValueError("current_version and target_version are required for migration-chain resolution.")
|
|
1049
|
+
repo = resolve_github_repo(package_name)
|
|
1050
|
+
if not repo:
|
|
1051
|
+
warning(f"Failed to resolve GitHub repository for package {package_name}")
|
|
1052
|
+
return [], "", []
|
|
1053
|
+
info(f"Resolving migration chain for '{package_name}' ({current_version} -> {target_version}) @ {repo}")
|
|
1054
|
+
try:
|
|
1055
|
+
tags_in_chain = get_migration_chain(repo, current_version, target_version)
|
|
1056
|
+
except (requests.RequestException, ValueError) as exc:
|
|
1057
|
+
warning(f"Could not resolve migration chain: {exc}")
|
|
1058
|
+
return [], "", []
|
|
1059
|
+
if not tags_in_chain:
|
|
1060
|
+
warning("No version transition to process.")
|
|
1061
|
+
return [], "", []
|
|
1062
|
+
info(f"Target Version Boundaries: {tags_in_chain}")
|
|
1063
|
+
rulesets_in_order: list[list[dict]] = []
|
|
1064
|
+
migration_docs: list[str] = []
|
|
1065
|
+
all_raw_example_blocks: list[dict] = []
|
|
1066
|
+
unresolved_boundaries: list[str] = []
|
|
1067
|
+
boundary_from_version = current_version
|
|
1068
|
+
for tag in tags_in_chain:
|
|
1069
|
+
info(f"Ingesting boundary: {tag}...")
|
|
1070
|
+
doc_content = discover_release_doc(repo, tag)
|
|
1071
|
+
if not doc_content:
|
|
1072
|
+
unresolved_boundaries.append(tag)
|
|
1073
|
+
# Keep chain progression deterministic even if this boundary's docs are missing.
|
|
1074
|
+
boundary_from_version = tag
|
|
1075
|
+
continue
|
|
1076
|
+
migration_docs.append(f"=== MIGRATION BOUNDARY: {boundary_from_version} -> {tag} ===\n{doc_content}")
|
|
1077
|
+
ruleset = extract_rules_cached(
|
|
1078
|
+
package_name,
|
|
1079
|
+
tag,
|
|
1080
|
+
doc_content,
|
|
1081
|
+
stage0_symbols or set(),
|
|
1082
|
+
boundary_from_version,
|
|
1083
|
+
tag,
|
|
1084
|
+
)
|
|
1085
|
+
rulesets_in_order.append([rule.model_dump() for rule in ruleset.rules])
|
|
1086
|
+
for block in ruleset.raw_example_blocks:
|
|
1087
|
+
all_raw_example_blocks.append({
|
|
1088
|
+
"boundary": tag,
|
|
1089
|
+
"source": "release_notes_or_repository_migration_docs",
|
|
1090
|
+
"code": block,
|
|
1091
|
+
})
|
|
1092
|
+
validated_count = sum(rule.validation_status == "validated" for rule in ruleset.rules)
|
|
1093
|
+
review_count = sum(rule.validation_status == "needs_review" for rule in ruleset.rules)
|
|
1094
|
+
info(f"Processed {len(ruleset.rules)} candidates for {tag}: {validated_count} validated, {review_count} needs_review")
|
|
1095
|
+
boundary_from_version = tag
|
|
1096
|
+
if unresolved_boundaries:
|
|
1097
|
+
warning("Unresolved documentation boundaries: " + ", ".join(unresolved_boundaries))
|
|
1098
|
+
warning("Coverage is incomplete until these boundaries have usable sources.")
|
|
1099
|
+
merged_rules = merge_rule_chain(rulesets_in_order)
|
|
1100
|
+
# info("merged_rules", merged_rules)
|
|
1101
|
+
migration_doc_text = "\n\n".join(migration_docs)
|
|
1102
|
+
info(f"Rule Chain Composition Complete. Total Merged Rules: {len(merged_rules)}")
|
|
1103
|
+
# print(f"Combined Migration Documentation: {len(migration_doc_text)} characters")
|
|
1104
|
+
|
|
1105
|
+
return merged_rules, migration_doc_text, all_raw_example_blocks
|