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/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