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.
@@ -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()