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/rule_validation.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""Validate extracted migration rules against a target dependency environment.
|
|
2
|
+
Documentation evidence and runtime API probing are deliberately tracked
|
|
3
|
+
separately: finding an attribute in the target package does not prove that a
|
|
4
|
+
migration rule is semantically correct.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import json
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _mark_unprobed(rule: dict[str, Any], reason: str) -> dict[str, Any]:
|
|
17
|
+
"""Mark a rule as not runtime-validated without discarding its evidence."""
|
|
18
|
+
rule["target_api_status"] = "unprobed"
|
|
19
|
+
rule["validation_status"] = "not_validated"
|
|
20
|
+
rule["resolution_reason"] = reason
|
|
21
|
+
return rule
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _mark_pending_as_failed(
|
|
25
|
+
rules: list[dict[str, Any]],
|
|
26
|
+
reason: str,
|
|
27
|
+
*,
|
|
28
|
+
status: str = "probe_failed",
|
|
29
|
+
) -> list[dict[str, Any]]:
|
|
30
|
+
"""Give every rule whose API probe did not finish an explicit outcome."""
|
|
31
|
+
for rule in rules:
|
|
32
|
+
if rule.get("target_api_status") == "pending":
|
|
33
|
+
rule["target_api_status"] = status
|
|
34
|
+
rule["validation_status"] = "unverifiable"
|
|
35
|
+
rule["resolution_reason"] = reason
|
|
36
|
+
return rules
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_all_rules(
|
|
40
|
+
all_rules: list[dict[str, Any]],
|
|
41
|
+
all_target_versions: dict[str, str],
|
|
42
|
+
req_file: str | Path,
|
|
43
|
+
distribution_names: dict[str, str] | None = None,
|
|
44
|
+
*,
|
|
45
|
+
python_image: str = "python:3.11-slim",
|
|
46
|
+
timeout: int = 600,
|
|
47
|
+
) -> list[dict[str, Any]]:
|
|
48
|
+
"""Validate shared rules once per import-package/target-version group.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
all_rules: Extracted migration rules. Each rule should include
|
|
52
|
+
``package`` (the Python import name), ``old_api`` and ``new_api``.
|
|
53
|
+
all_target_versions: Target versions keyed by Python import name.
|
|
54
|
+
req_file: Requirements file used to build the validation environment.
|
|
55
|
+
distribution_names: Optional mapping from Python import names to pip
|
|
56
|
+
distribution names, e.g. ``{"yaml": "PyYAML"}``.
|
|
57
|
+
python_image: Docker image used for validation.
|
|
58
|
+
timeout: Maximum seconds allowed for each Docker validation run.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
A new list of rules with runtime-probe results attached. Input rule
|
|
62
|
+
dictionaries are not mutated in place.
|
|
63
|
+
"""
|
|
64
|
+
distribution_names = distribution_names or {}
|
|
65
|
+
req_path = Path(req_file).expanduser().resolve()
|
|
66
|
+
groups: dict[tuple[str, str], list[tuple[int, dict[str, Any]]]] = defaultdict(list)
|
|
67
|
+
result_by_index: dict[int, dict[str, Any]] = {}
|
|
68
|
+
|
|
69
|
+
for index, original in enumerate(all_rules):
|
|
70
|
+
rule = dict(original)
|
|
71
|
+
# Keep the original documentation/rule assessment separate from the
|
|
72
|
+
# result of probing whether the destination API exists at runtime.
|
|
73
|
+
rule.setdefault("evidence_status", "unknown")
|
|
74
|
+
rule.setdefault("chain_status", "unresolved")
|
|
75
|
+
rule.setdefault("verification_status", "not_run")
|
|
76
|
+
rule["documentation_validation_status"] = rule.get(
|
|
77
|
+
"documentation_validation_status",
|
|
78
|
+
rule.get("validation_status", "not_validated"),
|
|
79
|
+
)
|
|
80
|
+
rule.setdefault("target_api_status", "unprobed")
|
|
81
|
+
|
|
82
|
+
package = rule.get("package")
|
|
83
|
+
if not package:
|
|
84
|
+
result_by_index[index] = _mark_unprobed(
|
|
85
|
+
rule, "Rule has no package identifier"
|
|
86
|
+
)
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
target_version = all_target_versions.get(package)
|
|
90
|
+
if not target_version:
|
|
91
|
+
result_by_index[index] = _mark_unprobed(
|
|
92
|
+
rule, f"No target version configured for import package {package}"
|
|
93
|
+
)
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
groups[(package, str(target_version))].append((index, rule))
|
|
97
|
+
|
|
98
|
+
for (package, target_version), indexed_rules in groups.items():
|
|
99
|
+
validated = validate_rules_against_target(
|
|
100
|
+
rules=[rule for _, rule in indexed_rules],
|
|
101
|
+
pkg_name=package,
|
|
102
|
+
target_version=target_version,
|
|
103
|
+
all_target_versions=all_target_versions,
|
|
104
|
+
req_file=req_path,
|
|
105
|
+
distribution_name=distribution_names.get(package),
|
|
106
|
+
distribution_names=distribution_names,
|
|
107
|
+
python_image=python_image,
|
|
108
|
+
timeout=timeout,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if len(validated) != len(indexed_rules):
|
|
112
|
+
raise RuntimeError(
|
|
113
|
+
f"Rule validator returned {len(validated)} rules for "
|
|
114
|
+
f"{len(indexed_rules)} inputs for {package}=={target_version}. "
|
|
115
|
+
"Refusing to silently pass unvalidated rules downstream."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
for (original_index, _), validated_rule in zip(indexed_rules, validated):
|
|
119
|
+
result_by_index[original_index] = validated_rule
|
|
120
|
+
|
|
121
|
+
# Preserve original ordering, including rules that could not be grouped.
|
|
122
|
+
return [result_by_index[index] for index in range(len(all_rules))]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def reconcile_report(
|
|
126
|
+
extraction_report: dict[str, Any],
|
|
127
|
+
validated_rules: list[dict[str, Any]],
|
|
128
|
+
) -> dict[str, Any]:
|
|
129
|
+
"""Return an extraction report whose rules and counts match validation."""
|
|
130
|
+
report = dict(extraction_report)
|
|
131
|
+
report["rules"] = validated_rules
|
|
132
|
+
report["total_rules"] = len(validated_rules)
|
|
133
|
+
|
|
134
|
+
report["target_api_exists"] = sum(
|
|
135
|
+
rule.get("target_api_status") == "target_api_exists"
|
|
136
|
+
for rule in validated_rules
|
|
137
|
+
)
|
|
138
|
+
report["target_api_missing"] = sum(
|
|
139
|
+
rule.get("target_api_status") == "target_api_missing"
|
|
140
|
+
for rule in validated_rules
|
|
141
|
+
)
|
|
142
|
+
report["unverifiable_rules"] = sum(
|
|
143
|
+
rule.get("target_api_status") in {
|
|
144
|
+
"unverifiable",
|
|
145
|
+
"probe_failed",
|
|
146
|
+
"unprobed",
|
|
147
|
+
}
|
|
148
|
+
for rule in validated_rules
|
|
149
|
+
)
|
|
150
|
+
report["not_probed_rules"] = sum(
|
|
151
|
+
rule.get("target_api_status") in {"pending", "unprobed"}
|
|
152
|
+
for rule in validated_rules
|
|
153
|
+
)
|
|
154
|
+
report["documentation_validated_rules"] = sum(
|
|
155
|
+
rule.get("documentation_validation_status") == "validated"
|
|
156
|
+
for rule in validated_rules
|
|
157
|
+
)
|
|
158
|
+
return report
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def validate_rules_against_target(
|
|
162
|
+
rules: list[dict[str, Any]],
|
|
163
|
+
pkg_name: str,
|
|
164
|
+
target_version: str,
|
|
165
|
+
all_target_versions: dict[str, str] | None = None,
|
|
166
|
+
req_file: str | Path = "temp_requirements.txt",
|
|
167
|
+
distribution_name: str | None = None,
|
|
168
|
+
distribution_names: dict[str, str] | None = None,
|
|
169
|
+
python_image: str = "python:3.11-slim",
|
|
170
|
+
timeout: int = 600,
|
|
171
|
+
) -> list[dict[str, Any]]:
|
|
172
|
+
"""Probe candidate destination APIs in an isolated target-version env.
|
|
173
|
+
|
|
174
|
+
``pkg_name`` and rule ``package`` values are Python import names.
|
|
175
|
+
``distribution_names`` maps those import names to pip distribution names.
|
|
176
|
+
"""
|
|
177
|
+
req_path = Path(req_file).expanduser().resolve()
|
|
178
|
+
distribution_names = dict(distribution_names or {})
|
|
179
|
+
if distribution_name:
|
|
180
|
+
distribution_names.setdefault(pkg_name, distribution_name)
|
|
181
|
+
all_target_versions = dict(all_target_versions or {pkg_name: target_version})
|
|
182
|
+
|
|
183
|
+
prepared_rules: list[dict[str, Any]] = []
|
|
184
|
+
for original in rules:
|
|
185
|
+
rule = dict(original)
|
|
186
|
+
rule.setdefault("evidence_status", "unknown")
|
|
187
|
+
rule.setdefault("chain_status", "unresolved")
|
|
188
|
+
rule.setdefault("verification_status", "not_run")
|
|
189
|
+
rule["documentation_validation_status"] = rule.get(
|
|
190
|
+
"documentation_validation_status",
|
|
191
|
+
rule.get("validation_status", "not_validated"),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
candidate = rule.get("candidate_new_api") or rule.get("new_api")
|
|
195
|
+
rule["candidate_new_api"] = candidate
|
|
196
|
+
|
|
197
|
+
if not isinstance(candidate, str) or not candidate.strip() or "UNRESOLVED" in candidate.upper():
|
|
198
|
+
rule["target_api_status"] = "unverifiable"
|
|
199
|
+
rule["validation_status"] = "not_validated"
|
|
200
|
+
rule["resolution_reason"] = (
|
|
201
|
+
rule.get("resolution_reason") or "No documented replacement API"
|
|
202
|
+
)
|
|
203
|
+
else:
|
|
204
|
+
rule["target_api_status"] = "pending"
|
|
205
|
+
# Do not overwrite documentation_validation_status here.
|
|
206
|
+
rule["validation_status"] = "pending"
|
|
207
|
+
prepared_rules.append(rule)
|
|
208
|
+
|
|
209
|
+
if not prepared_rules:
|
|
210
|
+
return []
|
|
211
|
+
|
|
212
|
+
if not req_path.is_file():
|
|
213
|
+
return _mark_pending_as_failed(
|
|
214
|
+
prepared_rules,
|
|
215
|
+
f"Requirements file not found: {req_path}",
|
|
216
|
+
status="unprobed",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# The worker installs distribution names but imports and probes Python
|
|
220
|
+
# import names. The version map sent to it is keyed by distribution name.
|
|
221
|
+
install_version_map: dict[str, str] = {}
|
|
222
|
+
for import_name, version in all_target_versions.items():
|
|
223
|
+
dist_name = distribution_names.get(import_name, import_name)
|
|
224
|
+
if dist_name in install_version_map and install_version_map[dist_name] != version:
|
|
225
|
+
raise ValueError(
|
|
226
|
+
f"Conflicting target versions for distribution {dist_name!r}: "
|
|
227
|
+
f"{install_version_map[dist_name]!r} and {version!r}"
|
|
228
|
+
)
|
|
229
|
+
install_version_map[dist_name] = str(version)
|
|
230
|
+
|
|
231
|
+
worker_script = r'''\
|
|
232
|
+
import importlib
|
|
233
|
+
import json
|
|
234
|
+
import subprocess
|
|
235
|
+
import sys
|
|
236
|
+
|
|
237
|
+
requirements_path, import_name, version_map_json, rules_path = sys.argv[1:5]
|
|
238
|
+
version_map = json.loads(version_map_json)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def install(args):
|
|
242
|
+
result = subprocess.run(
|
|
243
|
+
[sys.executable, "-m", "pip", "install", *args],
|
|
244
|
+
capture_output=True,
|
|
245
|
+
text=True,
|
|
246
|
+
)
|
|
247
|
+
if result.returncode:
|
|
248
|
+
raise RuntimeError("pip install failed:\\n" + result.stderr[-12000:])
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def resolve_attribute(root, path):
|
|
252
|
+
current = root
|
|
253
|
+
for part in path.split("."):
|
|
254
|
+
if not part:
|
|
255
|
+
raise AttributeError("Empty attribute in API path")
|
|
256
|
+
current = getattr(current, part)
|
|
257
|
+
return current
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def probe_api(module, api):
|
|
261
|
+
if not isinstance(api, str) or not api.strip():
|
|
262
|
+
return "unverifiable", "No usable candidate API"
|
|
263
|
+
api = api.strip()
|
|
264
|
+
|
|
265
|
+
# Generic probing cannot instantiate arbitrary client objects safely.
|
|
266
|
+
if api.startswith("client."):
|
|
267
|
+
return "unverifiable", "Instance-based API requires a package-specific adapter"
|
|
268
|
+
|
|
269
|
+
if api == import_name:
|
|
270
|
+
path = ""
|
|
271
|
+
elif api.startswith(import_name + "."):
|
|
272
|
+
path = api[len(import_name) + 1:]
|
|
273
|
+
else:
|
|
274
|
+
path = api
|
|
275
|
+
|
|
276
|
+
if not path:
|
|
277
|
+
return "exists", "Package module imported successfully"
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
resolve_attribute(module, path)
|
|
281
|
+
return "exists", "Destination attribute resolved"
|
|
282
|
+
except (AttributeError, ImportError, ModuleNotFoundError) as exc:
|
|
283
|
+
return "missing", f"{type(exc).__name__}: {exc}"
|
|
284
|
+
except Exception as exc:
|
|
285
|
+
return "unverifiable", f"{type(exc).__name__}: {exc}"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
# Install the project's base environment, then pin all target distributions.
|
|
290
|
+
install(["-r", requirements_path])
|
|
291
|
+
upgrade_args = ["--upgrade", "--upgrade-strategy", "only-if-needed"]
|
|
292
|
+
for dist_name, version in version_map.items():
|
|
293
|
+
upgrade_args.append(f"{dist_name}=={version}")
|
|
294
|
+
install(upgrade_args)
|
|
295
|
+
|
|
296
|
+
check = subprocess.run(
|
|
297
|
+
[sys.executable, "-m", "pip", "check"],
|
|
298
|
+
capture_output=True,
|
|
299
|
+
text=True,
|
|
300
|
+
)
|
|
301
|
+
if check.returncode:
|
|
302
|
+
print(json.dumps({
|
|
303
|
+
"environment_status": "dependency_conflict",
|
|
304
|
+
"details": (check.stdout + "\\n" + check.stderr)[-12000:],
|
|
305
|
+
"rules": [],
|
|
306
|
+
}))
|
|
307
|
+
sys.exit(0)
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
module = importlib.import_module(import_name)
|
|
311
|
+
except Exception as exc:
|
|
312
|
+
print(json.dumps({
|
|
313
|
+
"environment_status": "import_failed",
|
|
314
|
+
"details": f"{type(exc).__name__}: {exc}",
|
|
315
|
+
"rules": [],
|
|
316
|
+
}))
|
|
317
|
+
sys.exit(0)
|
|
318
|
+
|
|
319
|
+
with open(rules_path, encoding="utf-8") as file:
|
|
320
|
+
rules = json.load(file)
|
|
321
|
+
|
|
322
|
+
results = []
|
|
323
|
+
for index, rule in enumerate(rules):
|
|
324
|
+
candidate = rule.get("candidate_new_api") or rule.get("new_api")
|
|
325
|
+
if not isinstance(candidate, str) or not candidate.strip() or "UNRESOLVED" in candidate.upper():
|
|
326
|
+
results.append({
|
|
327
|
+
"index": index,
|
|
328
|
+
"status": "unverifiable",
|
|
329
|
+
"reason": "No documented replacement API",
|
|
330
|
+
})
|
|
331
|
+
continue
|
|
332
|
+
status, reason = probe_api(module, candidate)
|
|
333
|
+
results.append({"index": index, "status": status, "reason": reason})
|
|
334
|
+
|
|
335
|
+
print(json.dumps({"environment_status": "ready", "rules": results}))
|
|
336
|
+
except Exception as exc:
|
|
337
|
+
print(json.dumps({
|
|
338
|
+
"environment_status": "install_failed",
|
|
339
|
+
"details": f"{type(exc).__name__}: {exc}",
|
|
340
|
+
"rules": [],
|
|
341
|
+
}))
|
|
342
|
+
'''
|
|
343
|
+
|
|
344
|
+
with tempfile.TemporaryDirectory(prefix="aston-validation-") as tmp:
|
|
345
|
+
temp_dir = Path(tmp)
|
|
346
|
+
script_path = temp_dir / "validate.py"
|
|
347
|
+
rules_path = temp_dir / "rules.json"
|
|
348
|
+
script_path.write_text(worker_script, encoding="utf-8")
|
|
349
|
+
rules_path.write_text(json.dumps(prepared_rules), encoding="utf-8")
|
|
350
|
+
|
|
351
|
+
command = [
|
|
352
|
+
"docker", "run", "--rm",
|
|
353
|
+
"-v", f"{req_path.parent}:/project:ro",
|
|
354
|
+
"-v", f"{temp_dir}:/work:ro",
|
|
355
|
+
"-w", "/work",
|
|
356
|
+
python_image,
|
|
357
|
+
"python", "/work/validate.py",
|
|
358
|
+
f"/project/{req_path.name}",
|
|
359
|
+
pkg_name,
|
|
360
|
+
json.dumps(install_version_map),
|
|
361
|
+
"/work/rules.json",
|
|
362
|
+
]
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
result = subprocess.run(
|
|
366
|
+
command,
|
|
367
|
+
capture_output=True,
|
|
368
|
+
text=True,
|
|
369
|
+
timeout=timeout,
|
|
370
|
+
)
|
|
371
|
+
except subprocess.TimeoutExpired:
|
|
372
|
+
return _mark_pending_as_failed(
|
|
373
|
+
prepared_rules, f"Validation timed out after {timeout} seconds"
|
|
374
|
+
)
|
|
375
|
+
except OSError as exc:
|
|
376
|
+
return _mark_pending_as_failed(
|
|
377
|
+
prepared_rules, f"Could not start Docker validation: {exc}"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
if result.returncode != 0:
|
|
381
|
+
details = (result.stderr or result.stdout or "No Docker output")[-4000:]
|
|
382
|
+
return _mark_pending_as_failed(
|
|
383
|
+
prepared_rules, f"Docker validation failed: {details}"
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
report = json.loads(result.stdout.strip().splitlines()[-1])
|
|
388
|
+
except (IndexError, json.JSONDecodeError):
|
|
389
|
+
return _mark_pending_as_failed(
|
|
390
|
+
prepared_rules, "Could not parse Docker validation report"
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
if report.get("environment_status") != "ready":
|
|
394
|
+
details = report.get("details", report.get("environment_status", "unknown error"))
|
|
395
|
+
return _mark_pending_as_failed(prepared_rules, str(details))
|
|
396
|
+
|
|
397
|
+
seen_indices: set[int] = set()
|
|
398
|
+
for item in report.get("rules", []):
|
|
399
|
+
index = item.get("index")
|
|
400
|
+
if not isinstance(index, int) or not 0 <= index < len(prepared_rules):
|
|
401
|
+
continue
|
|
402
|
+
seen_indices.add(index)
|
|
403
|
+
rule = prepared_rules[index]
|
|
404
|
+
status = item.get("status", "unverifiable")
|
|
405
|
+
reason = str(item.get("reason", "No probe explanation supplied"))
|
|
406
|
+
documentation_status = rule.get(
|
|
407
|
+
"documentation_validation_status", "not_validated"
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
if status == "exists":
|
|
411
|
+
rule["target_api_status"] = "target_api_exists"
|
|
412
|
+
# Runtime existence is necessary but not sufficient for rule validity.
|
|
413
|
+
rule["validation_status"] = (
|
|
414
|
+
"validated" if documentation_status == "validated"
|
|
415
|
+
else documentation_status
|
|
416
|
+
)
|
|
417
|
+
elif status == "missing":
|
|
418
|
+
rule["target_api_status"] = "target_api_missing"
|
|
419
|
+
rule["validation_status"] = "needs_review"
|
|
420
|
+
else:
|
|
421
|
+
rule["target_api_status"] = "unverifiable"
|
|
422
|
+
rule["validation_status"] = (
|
|
423
|
+
"needs_review" if documentation_status == "validated"
|
|
424
|
+
else documentation_status
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
rule["resolution_reason"] = reason
|
|
428
|
+
rule["environment_status"] = "ready"
|
|
429
|
+
|
|
430
|
+
# A malformed/partial worker report must not leave any rule pending.
|
|
431
|
+
for index, rule in enumerate(prepared_rules):
|
|
432
|
+
if index not in seen_indices and rule.get("target_api_status") == "pending":
|
|
433
|
+
rule["target_api_status"] = "probe_failed"
|
|
434
|
+
rule["validation_status"] = "unverifiable"
|
|
435
|
+
rule["resolution_reason"] = "Rule missing from Docker validation report"
|
|
436
|
+
|
|
437
|
+
return prepared_rules
|
aston/scanner.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import libcst as cst
|
|
3
|
+
from libcst.display import dump
|
|
4
|
+
from libcst.metadata import VisitorMetadataProvider, MetadataWrapper, PositionProvider, ParentNodeProvider
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from libcst import matchers as m
|
|
7
|
+
import json
|
|
8
|
+
import jedi
|
|
9
|
+
from typing import Sequence
|
|
10
|
+
import hashlib
|
|
11
|
+
|
|
12
|
+
class Tracker(VisitorMetadataProvider):
|
|
13
|
+
METADATA_DEPENDENCIES = (PositionProvider, ParentNodeProvider)
|
|
14
|
+
|
|
15
|
+
def __init__(self, relative_path: str, code_text: str, file_path: Path | None = None):
|
|
16
|
+
super().__init__()
|
|
17
|
+
self.relative_path = relative_path
|
|
18
|
+
self.code_text = code_text
|
|
19
|
+
self.code_lines = code_text.splitlines()
|
|
20
|
+
self.file_path = file_path
|
|
21
|
+
self._jedi_script = None
|
|
22
|
+
|
|
23
|
+
self.usages = []
|
|
24
|
+
self.symbols = {}
|
|
25
|
+
self.imports = []
|
|
26
|
+
self.local_calls = []
|
|
27
|
+
self.scope_stack = []
|
|
28
|
+
self.local_imports = {}
|
|
29
|
+
self.current_func_params = set()
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def jedi_script(self):
|
|
33
|
+
if self._jedi_script is None:
|
|
34
|
+
if self.file_path and self.file_path.exists():
|
|
35
|
+
self._jedi_script = jedi.Script(path=str(self.file_path))
|
|
36
|
+
else:
|
|
37
|
+
self._jedi_script = jedi.Script(self.code_text)
|
|
38
|
+
return self._jedi_script
|
|
39
|
+
|
|
40
|
+
def visit_ImportFrom(self, node: cst.ImportFrom) -> None:
|
|
41
|
+
if node.module and not isinstance(node.names, cst.ImportStar):
|
|
42
|
+
pkg = cst.Module([]).code_for_node(node.module).strip()
|
|
43
|
+
for alias in node.names:
|
|
44
|
+
imported_name = alias.name.value
|
|
45
|
+
local_alias = alias.asname.name.value if alias.asname else imported_name
|
|
46
|
+
self.local_imports[local_alias] = {"package": pkg, "imported": imported_name}
|
|
47
|
+
|
|
48
|
+
self.imports.append({
|
|
49
|
+
"module": pkg,
|
|
50
|
+
"imported_name": imported_name,
|
|
51
|
+
"alias": alias.asname.name.value if alias.asname else None,
|
|
52
|
+
"line": self.get_metadata(PositionProvider, node).start.line,
|
|
53
|
+
"import_type": "from_import"
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
def visit_Import(self, node: cst.Import) -> None:
|
|
57
|
+
for alias in node.names:
|
|
58
|
+
pkg = cst.Module([]).code_for_node(alias.name).strip()
|
|
59
|
+
local_alias = alias.asname.name.value if alias.asname else pkg
|
|
60
|
+
self.local_imports[local_alias] = {"package": pkg, "imported": pkg}
|
|
61
|
+
|
|
62
|
+
self.imports.append({
|
|
63
|
+
"module": pkg,
|
|
64
|
+
"imported_name": None,
|
|
65
|
+
"alias": alias.asname.name.value if alias.asname else None,
|
|
66
|
+
"line": self.get_metadata(PositionProvider, node).start.line,
|
|
67
|
+
"import_type": "module"
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
def visit_FunctionDef(self, node: cst.FunctionDef) -> None:
|
|
71
|
+
func_name = node.name.value
|
|
72
|
+
pos = self.get_metadata(PositionProvider, node)
|
|
73
|
+
params = [param.name.value for param in node.params.params]
|
|
74
|
+
|
|
75
|
+
symbol_key = f"{self.relative_path}::{func_name}"
|
|
76
|
+
self.symbols[symbol_key] = {
|
|
77
|
+
"file": self.relative_path,
|
|
78
|
+
"qualified_name": func_name,
|
|
79
|
+
"kind": "function",
|
|
80
|
+
"line_start": pos.start.line,
|
|
81
|
+
"line_end": pos.end.line,
|
|
82
|
+
"parameters": params,
|
|
83
|
+
"reach": "unknown",
|
|
84
|
+
"watched_usage_ids": []
|
|
85
|
+
}
|
|
86
|
+
self.scope_stack.append(func_name)
|
|
87
|
+
self.current_func_params = set(params)
|
|
88
|
+
|
|
89
|
+
def leave_FunctionDef(self, node: cst.FunctionDef) -> None:
|
|
90
|
+
if self.scope_stack:
|
|
91
|
+
self.scope_stack.pop()
|
|
92
|
+
self.current_func_params = set()
|
|
93
|
+
|
|
94
|
+
def _get_result_use(self, node: cst.Call) -> str:
|
|
95
|
+
current = node
|
|
96
|
+
while True:
|
|
97
|
+
parent = self.get_metadata(ParentNodeProvider, current)
|
|
98
|
+
if parent is None:
|
|
99
|
+
return "discarded"
|
|
100
|
+
if isinstance(parent, cst.Return):
|
|
101
|
+
return "returned"
|
|
102
|
+
if isinstance(parent, cst.Assign):
|
|
103
|
+
return "assigned"
|
|
104
|
+
if isinstance(parent, cst.Arg):
|
|
105
|
+
return "argument"
|
|
106
|
+
if isinstance(parent, cst.Expr):
|
|
107
|
+
return "discarded"
|
|
108
|
+
current = parent
|
|
109
|
+
|
|
110
|
+
def visit_Call(self, node: cst.Call) -> None:
|
|
111
|
+
pos = self.get_metadata(PositionProvider, node)
|
|
112
|
+
line = pos.start.line
|
|
113
|
+
col = pos.start.column
|
|
114
|
+
end_line = pos.end.line
|
|
115
|
+
end_col = pos.end.column
|
|
116
|
+
|
|
117
|
+
call_expr = cst.Module([]).code_for_node(node.func).strip()
|
|
118
|
+
enclosing_symbol = self.scope_stack[-1] if self.scope_stack else None
|
|
119
|
+
|
|
120
|
+
# 1. Capture local / parameter call sites (strictly Name nodes like ask_assistant() or postprocess())
|
|
121
|
+
if enclosing_symbol and isinstance(node.func, cst.Name):
|
|
122
|
+
callee_name = node.func.value
|
|
123
|
+
self.local_calls.append({
|
|
124
|
+
"source_file": self.relative_path,
|
|
125
|
+
"source_symbol": enclosing_symbol,
|
|
126
|
+
"callee_name": callee_name,
|
|
127
|
+
"line": line,
|
|
128
|
+
"column": col,
|
|
129
|
+
"end_line": end_line,
|
|
130
|
+
"end_column": end_col,
|
|
131
|
+
"is_parameter": callee_name in self.current_func_params
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
# 2. Target external API usage tracking (Attribute nodes like openai.ChatCompletion.create)
|
|
135
|
+
if not isinstance(node.func, cst.Attribute):
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
root_name = call_expr.split(".")[0]
|
|
139
|
+
package = None
|
|
140
|
+
imported = None
|
|
141
|
+
|
|
142
|
+
if root_name in self.local_imports:
|
|
143
|
+
package = self.local_imports[root_name]["package"]
|
|
144
|
+
imported = self.local_imports[root_name]["imported"]
|
|
145
|
+
|
|
146
|
+
if not package:
|
|
147
|
+
try:
|
|
148
|
+
defs = self.jedi_script.goto(line=line, column=col) or self.jedi_script.infer(line=line, column=col)
|
|
149
|
+
if defs:
|
|
150
|
+
target = defs[0]
|
|
151
|
+
if target.module_name and target.module_name not in ("builtins", "__main__"):
|
|
152
|
+
package = target.module_name
|
|
153
|
+
imported = target.name
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
if package and ("openai" in package or "openai" in call_expr):
|
|
158
|
+
usage_id = f"{self.relative_path}:{line}:{col}"
|
|
159
|
+
result_use = self._get_result_use(node)
|
|
160
|
+
|
|
161
|
+
kwargs = {}
|
|
162
|
+
for arg in node.args:
|
|
163
|
+
if arg.keyword:
|
|
164
|
+
kwargs[arg.keyword.value] = cst.Module([]).code_for_node(arg.value).strip()
|
|
165
|
+
|
|
166
|
+
self.usages.append({
|
|
167
|
+
"id": usage_id,
|
|
168
|
+
"package": package,
|
|
169
|
+
"imported": imported,
|
|
170
|
+
"local_alias": root_name,
|
|
171
|
+
"api_usage": call_expr,
|
|
172
|
+
"result_use": result_use,
|
|
173
|
+
"enclosing_symbol": enclosing_symbol,
|
|
174
|
+
"line": line,
|
|
175
|
+
"column": col,
|
|
176
|
+
"end_line": end_line,
|
|
177
|
+
"end_column": end_col,
|
|
178
|
+
"usage_kind": "call",
|
|
179
|
+
"kwargs": kwargs
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def parser(path: str | Path, relative_path: str, project: jedi.Project | None = None):
|
|
185
|
+
|
|
186
|
+
path = Path(path)
|
|
187
|
+
if not path.exists():
|
|
188
|
+
path = Path("before.py")
|
|
189
|
+
|
|
190
|
+
code_text = path.read_text(encoding="utf-8")
|
|
191
|
+
file_hash = hashlib.sha256(code_text.encode("utf-8")).hexdigest()
|
|
192
|
+
|
|
193
|
+
module = cst.parse_module(code_text)
|
|
194
|
+
visitor = Tracker(relative_path=relative_path, code_text=code_text, file_path=path)
|
|
195
|
+
wrapper = cst.metadata.MetadataWrapper(module)
|
|
196
|
+
wrapper.visit(visitor)
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
"file_meta": {
|
|
200
|
+
"path": relative_path,
|
|
201
|
+
"sha256": file_hash,
|
|
202
|
+
"parse_status": "success"
|
|
203
|
+
},
|
|
204
|
+
"imports": visitor.imports,
|
|
205
|
+
"usages": visitor.usages,
|
|
206
|
+
"symbols": visitor.symbols,
|
|
207
|
+
"local_calls": visitor.local_calls,
|
|
208
|
+
"diagnostics": []
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
# parser()
|