mxready 1.0.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.
Files changed (71) hide show
  1. mxready/__init__.py +1 -0
  2. mxready/api/__init__.py +4 -0
  3. mxready/api/middleware.py +149 -0
  4. mxready/api/rules.py +10 -0
  5. mxready/api/scans.py +164 -0
  6. mxready/api/workspace.py +156 -0
  7. mxready/app.py +179 -0
  8. mxready/cli/__init__.py +1 -0
  9. mxready/cli/build_verification_bundle.py +45 -0
  10. mxready/cli/generate_public_report.py +140 -0
  11. mxready/cli/scan_batch.py +368 -0
  12. mxready/cli/scan_repository.py +212 -0
  13. mxready/cli/serve.py +55 -0
  14. mxready/config.py +117 -0
  15. mxready/errors.py +11 -0
  16. mxready/logging_setup.py +88 -0
  17. mxready/mcp/__init__.py +5 -0
  18. mxready/mcp/server.py +433 -0
  19. mxready/models.py +382 -0
  20. mxready/persistence/__init__.py +1 -0
  21. mxready/persistence/sqlite.py +581 -0
  22. mxready/py.typed +0 -0
  23. mxready/reporting/__init__.py +4 -0
  24. mxready/reporting/badge.py +40 -0
  25. mxready/reporting/comparison.py +33 -0
  26. mxready/reporting/markdown.py +157 -0
  27. mxready/reporting/sarif.py +94 -0
  28. mxready/reporting/terminal.py +75 -0
  29. mxready/repository/__init__.py +14 -0
  30. mxready/repository/git_client.py +275 -0
  31. mxready/repository/github_archive.py +566 -0
  32. mxready/repository/identity.py +140 -0
  33. mxready/resources/__init__.py +19 -0
  34. mxready/resources/frontend/assets/index-BxJEwNUm.css +1 -0
  35. mxready/resources/frontend/assets/index-lzTCdWAt.js +9 -0
  36. mxready/resources/frontend/assets/mx-chip-C0HgFQyE.webp +0 -0
  37. mxready/resources/frontend/index.html +17 -0
  38. mxready/resources/rules/v1/core.yml +634 -0
  39. mxready/resources/rules/v1/manifest.yml +4 -0
  40. mxready/resources/schemas/verification-result-v1.json +259 -0
  41. mxready/scanning/__init__.py +17 -0
  42. mxready/scanning/analyzer.py +183 -0
  43. mxready/scanning/facts.py +596 -0
  44. mxready/scanning/indexer.py +299 -0
  45. mxready/scanning/path_context.py +174 -0
  46. mxready/scanning/python_source.py +67 -0
  47. mxready/scanning/rule_engine.py +595 -0
  48. mxready/scanning/rule_loader.py +282 -0
  49. mxready/scanning/toolchain.py +100 -0
  50. mxready/services/__init__.py +3 -0
  51. mxready/services/scans.py +239 -0
  52. mxready/services/worker.py +38 -0
  53. mxready/verification/__init__.py +11 -0
  54. mxready/verification/bundle.py +137 -0
  55. mxready/verification/validation.py +101 -0
  56. mxready-1.0.0.dist-info/METADATA +526 -0
  57. mxready-1.0.0.dist-info/RECORD +71 -0
  58. mxready-1.0.0.dist-info/WHEEL +5 -0
  59. mxready-1.0.0.dist-info/entry_points.txt +8 -0
  60. mxready-1.0.0.dist-info/licenses/LICENSE +201 -0
  61. mxready-1.0.0.dist-info/top_level.txt +2 -0
  62. mxready_runner/__init__.py +1 -0
  63. mxready_runner/__main__.py +3 -0
  64. mxready_runner/cli.py +88 -0
  65. mxready_runner/contracts.py +26 -0
  66. mxready_runner/execute.py +185 -0
  67. mxready_runner/inspect.py +79 -0
  68. mxready_runner/process.py +103 -0
  69. mxready_runner/py.typed +0 -0
  70. mxready_runner/redact.py +36 -0
  71. mxready_runner/schema.py +326 -0
mxready/mcp/server.py ADDED
@@ -0,0 +1,433 @@
1
+ """Model Context Protocol front end for the MXReady static readiness pipeline.
2
+
3
+ The HTTP application and this server build the same scan pipeline; this module
4
+ exists so an agent can ask MXReady for migration facts instead of guessing about
5
+ CUDA to MXMACA compatibility.
6
+
7
+ Two constraints shape the implementation:
8
+
9
+ * stdout is the stdio transport channel, so logging must never reach it;
10
+ * the ``mcp`` SDK is an optional dependency, so nothing outside this module may
11
+ import it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import atexit
18
+ import base64
19
+ import json
20
+ import logging
21
+ import sys
22
+ from threading import Lock
23
+ from time import monotonic, sleep
24
+ from typing import Any
25
+ from uuid import UUID
26
+
27
+ from mcp.server.fastmcp import FastMCP
28
+
29
+ from mxready import __version__
30
+ from mxready.config import Settings
31
+ from mxready.errors import MxReadyError
32
+ from mxready.models import Finding, ScanJob, ScanReport, ScanStatus
33
+ from mxready.persistence.sqlite import SQLiteStore
34
+ from mxready.repository.git_client import GitClient
35
+ from mxready.repository.identity import build_providers
36
+ from mxready.scanning.analyzer import ScanAnalyzer
37
+ from mxready.scanning.rule_loader import RuleCatalog, load_rule_catalog
38
+ from mxready.services.scans import ScanService
39
+ from mxready.services.worker import ScanWorker
40
+
41
+ _LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s %(message)s"
42
+ _SEVERITY_ORDER = {"blocker": 0, "warning": 1, "info": 2}
43
+ _DEFAULT_TIMEOUT_SECONDS = 300
44
+ _MIN_TIMEOUT_SECONDS = 30
45
+ _MAX_TIMEOUT_SECONDS = 1800
46
+ _MAX_FINDINGS_CEILING = 500
47
+ _POLL_SECONDS = 0.5
48
+
49
+ _INSTRUCTIONS = (
50
+ "MXReady returns static, evidence-backed readiness findings for PyTorch CUDA "
51
+ "extension repositories that are being considered for migration to MetaX MXMACA. "
52
+ "Every finding carries a rule id, a file and line, the matched evidence, and a "
53
+ "recommendation, so a migration plan can cite verifiable facts instead of a guessed "
54
+ "compatibility verdict. Findings are advisory: a cleared static check is not a "
55
+ "hardware verification result, and verification_status only becomes 'verified' "
56
+ "after a runner result produced on a MetaX GPU is uploaded for the same commit."
57
+ )
58
+
59
+
60
+ class _ScanRuntime:
61
+ """Owns the durable queue, the analyzer and the worker threads for one session."""
62
+
63
+ def __init__(self, settings: Settings) -> None:
64
+ settings.data_dir.mkdir(parents=True, exist_ok=True)
65
+ settings.temp_dir.mkdir(parents=True, exist_ok=True)
66
+ self.catalog: RuleCatalog = load_rule_catalog(
67
+ settings.rules_dir,
68
+ extra_directory=settings.extra_rules_dir,
69
+ )
70
+ self._store = SQLiteStore(settings.data_dir / "mxready.db")
71
+ self._store.initialize()
72
+ providers = build_providers(settings.allowed_hosts)
73
+ self._service = ScanService(
74
+ self._store,
75
+ GitClient(),
76
+ ScanAnalyzer(
77
+ self.catalog,
78
+ providers=providers,
79
+ enable_prefilter=settings.prefilter_enabled,
80
+ ),
81
+ settings,
82
+ providers=providers,
83
+ )
84
+ self._worker = ScanWorker(self._service)
85
+
86
+ def start(self) -> None:
87
+ self._worker.start()
88
+
89
+ def stop(self) -> None:
90
+ self._worker.stop()
91
+
92
+ def scan_and_wait(
93
+ self,
94
+ repo_url: str,
95
+ ref: str | None,
96
+ timeout_seconds: int,
97
+ ) -> ScanReport:
98
+ job = self._service.create_scan(repo_url, ref)
99
+ finished = self._wait_for(job, timeout_seconds)
100
+ if finished.status is ScanStatus.FAILED:
101
+ raise MxReadyError(
102
+ finished.failure_code or "SCAN_FAILED",
103
+ finished.failure_message or "The scan failed.",
104
+ {"scan_id": str(finished.id)},
105
+ )
106
+ return self._service.get_report(finished.id)
107
+
108
+ def get_report(self, scan_id: UUID) -> ScanReport:
109
+ return self._service.get_report(scan_id)
110
+
111
+ def _wait_for(self, job: ScanJob, timeout_seconds: int) -> ScanJob:
112
+ deadline = monotonic() + timeout_seconds
113
+ current = job
114
+ while current.status not in (ScanStatus.COMPLETED, ScanStatus.FAILED):
115
+ if monotonic() >= deadline:
116
+ raise MxReadyError(
117
+ "SCAN_TIMEOUT",
118
+ f"The scan did not finish within {timeout_seconds} seconds.",
119
+ {"scan_id": str(job.id), "status": str(current.status)},
120
+ )
121
+ sleep(_POLL_SECONDS)
122
+ current = self._service.get_scan(job.id)
123
+ return current
124
+
125
+
126
+ def _configure_stderr_logging() -> None:
127
+ """Send MXReady logs to stderr; stdout belongs to the stdio transport."""
128
+ target = logging.getLogger("mxready")
129
+ if target.handlers:
130
+ return
131
+ target.setLevel(logging.INFO)
132
+ handler = logging.StreamHandler(sys.stderr)
133
+ handler.setFormatter(logging.Formatter(_LOG_FORMAT))
134
+ target.addHandler(handler)
135
+ target.propagate = False
136
+
137
+
138
+ def _bounded_timeout(value: int) -> int:
139
+ return max(_MIN_TIMEOUT_SECONDS, min(value, _MAX_TIMEOUT_SECONDS))
140
+
141
+
142
+ def _bounded_findings(value: int) -> int:
143
+ return max(1, min(value, _MAX_FINDINGS_CEILING))
144
+
145
+
146
+ def _finding_payload(finding: Finding) -> dict[str, Any]:
147
+ return {
148
+ "rule_id": finding.rule_id,
149
+ "severity": str(finding.severity),
150
+ "category": finding.category,
151
+ "title": finding.title,
152
+ "file": finding.relative_path,
153
+ "line_start": finding.line_start,
154
+ "line_end": finding.line_end,
155
+ "occurrences": finding.count,
156
+ "evidence": finding.evidence,
157
+ "message": finding.message,
158
+ "recommendation": finding.recommendation,
159
+ "references": [{"title": item.title, "url": item.url} for item in finding.references],
160
+ }
161
+
162
+
163
+ def _report_payload(
164
+ report: ScanReport,
165
+ max_findings: int,
166
+ *,
167
+ offset: int = 0,
168
+ severity: str | None = None,
169
+ file: str | None = None,
170
+ ) -> dict[str, Any]:
171
+ ordered = sorted(
172
+ report.findings,
173
+ key=lambda finding: (
174
+ _SEVERITY_ORDER.get(str(finding.severity), len(_SEVERITY_ORDER)),
175
+ finding.relative_path,
176
+ finding.line_start,
177
+ finding.rule_id,
178
+ ),
179
+ )
180
+ ordered = [
181
+ item
182
+ for item in ordered
183
+ if (not severity or item.severity == severity) and (not file or item.relative_path == file)
184
+ ]
185
+ selected = ordered[offset : offset + max_findings]
186
+ next_offset = offset + len(selected)
187
+ cursor = (
188
+ base64.urlsafe_b64encode(
189
+ json.dumps(
190
+ {
191
+ "scan_id": str(report.scan_id),
192
+ "offset": next_offset,
193
+ "severity": severity,
194
+ "file": file,
195
+ }
196
+ ).encode()
197
+ ).decode()
198
+ if next_offset < len(ordered)
199
+ else None
200
+ )
201
+ return {
202
+ "scan_id": str(report.scan_id),
203
+ "repository": {
204
+ "url": report.repository.url,
205
+ "provider": report.repository.provider,
206
+ "commit": report.repository.commit,
207
+ },
208
+ "scanned_at": report.scanned_at.isoformat(),
209
+ "tool_version": report.tool_version,
210
+ "ruleset_version": report.ruleset_version,
211
+ "static_status": str(report.static_status),
212
+ "verification_status": str(report.verification_status),
213
+ "summary": report.summary.model_dump(mode="json"),
214
+ "findings": [_finding_payload(item) for item in selected],
215
+ "returned_findings": len(selected),
216
+ "total_findings": len(report.findings),
217
+ "matched_findings": len(ordered),
218
+ "next_cursor": cursor,
219
+ "findings_truncated": len(report.findings) > len(selected),
220
+ "migration_checklist": [
221
+ item.model_dump(mode="json") for item in report.migration_checklist
222
+ ],
223
+ "analysis_warnings": [item.model_dump(mode="json") for item in report.analysis_warnings],
224
+ }
225
+
226
+
227
+ def _page_options(
228
+ scan_id: str, cursor: str | None, severity: str | None, file: str | None
229
+ ) -> tuple[int, str | None, str | None]:
230
+ offset = 0
231
+ if cursor:
232
+ try:
233
+ if len(cursor) > 4096:
234
+ raise ValueError("cursor too long")
235
+ page = json.loads(base64.b64decode(cursor, altchars=b"-_", validate=True))
236
+ if set(page) != {"scan_id", "offset", "severity", "file"} or page["scan_id"] != scan_id:
237
+ raise ValueError("cursor belongs to a different query")
238
+ offset = page["offset"]
239
+ if type(offset) is not int or not 0 <= offset <= 10_000:
240
+ raise ValueError("invalid offset")
241
+ if severity is not None and severity != page["severity"]:
242
+ raise ValueError("severity changed")
243
+ if file is not None and file != page["file"]:
244
+ raise ValueError("file changed")
245
+ severity, file = page["severity"], page["file"]
246
+ if severity is not None and not isinstance(severity, str):
247
+ raise ValueError("invalid severity")
248
+ if file is not None and (not isinstance(file, str) or len(file) > 1024):
249
+ raise ValueError("invalid file")
250
+ except (ValueError, TypeError, KeyError) as error:
251
+ raise MxReadyError(
252
+ "INVALID_CURSOR", "Use next_cursor from the same report query."
253
+ ) from error
254
+ if severity is not None and severity not in _SEVERITY_ORDER:
255
+ raise MxReadyError("INVALID_FILTER", "severity must be blocker, warning or info.")
256
+ if file is not None and len(file) > 1024:
257
+ raise MxReadyError("INVALID_FILTER", "file filter is too long.")
258
+ return offset, severity, file
259
+
260
+
261
+ def _error_payload(error: Exception) -> dict[str, Any]:
262
+ if isinstance(error, MxReadyError):
263
+ return {
264
+ "error": {
265
+ "code": error.code,
266
+ "message": error.message,
267
+ "details": error.details,
268
+ }
269
+ }
270
+ return {"error": {"code": type(error).__name__, "message": str(error), "details": {}}}
271
+
272
+
273
+ _runtime_lock = Lock()
274
+ _runtime_instance: _ScanRuntime | None = None
275
+
276
+
277
+ def _runtime() -> _ScanRuntime:
278
+ global _runtime_instance
279
+ with _runtime_lock:
280
+ if _runtime_instance is None:
281
+ runtime = _ScanRuntime(Settings.from_env())
282
+ runtime.start()
283
+ atexit.register(runtime.stop)
284
+ _runtime_instance = runtime
285
+ return _runtime_instance
286
+
287
+
288
+ def build_server() -> FastMCP:
289
+ """Register the MXReady tools on a fresh FastMCP server."""
290
+ server = FastMCP("MXReady", instructions=_INSTRUCTIONS)
291
+ # The SDK reports its own package version in the initialize result by
292
+ # default; surface the MXReady version so a client can tell which build
293
+ # answered. FastMCP exposes no constructor argument for this.
294
+ server._mcp_server.version = __version__
295
+
296
+ @server.tool()
297
+ async def scan_repository(
298
+ repo_url: str,
299
+ ref: str | None = None,
300
+ timeout_seconds: int = _DEFAULT_TIMEOUT_SECONDS,
301
+ max_findings: int = 20,
302
+ ) -> dict[str, Any]:
303
+ """Scan a public GitHub or Gitee repository and return readiness findings.
304
+
305
+ Blocks until the scan finishes or the timeout expires. The report is bound
306
+ to the resolved commit, so the same findings can be reproduced later.
307
+
308
+ Args:
309
+ repo_url: Public HTTPS repository URL on an allowlisted host.
310
+ ref: Optional branch, tag, or 40-character commit to pin.
311
+ timeout_seconds: Wall-clock budget, clamped to 30..1800.
312
+ max_findings: Maximum findings returned, ordered by severity.
313
+ """
314
+ try:
315
+
316
+ def scan() -> ScanReport:
317
+ return _runtime().scan_and_wait(repo_url, ref, _bounded_timeout(timeout_seconds))
318
+
319
+ report = await asyncio.to_thread(scan)
320
+ return _report_payload(report, _bounded_findings(max_findings))
321
+ except (MxReadyError, OSError, ValueError) as error:
322
+ return _error_payload(error)
323
+
324
+ @server.tool()
325
+ async def get_report(
326
+ scan_id: str,
327
+ max_findings: int = 50,
328
+ cursor: str | None = None,
329
+ severity: str | None = None,
330
+ file: str | None = None,
331
+ ) -> dict[str, Any]:
332
+ """Return a previously completed MXReady report by scan id.
333
+
334
+ Args:
335
+ scan_id: UUID returned by scan_repository.
336
+ max_findings: Maximum findings returned, ordered by severity.
337
+ cursor: Opaque next_cursor returned by the preceding page of this query.
338
+ severity: Optional blocker, warning or info filter.
339
+ file: Optional exact repository-relative file path filter.
340
+ """
341
+ try:
342
+ normalized_id = str(UUID(scan_id))
343
+ offset, severity, file = _page_options(normalized_id, cursor, severity, file)
344
+ report = await asyncio.to_thread(lambda: _runtime().get_report(UUID(normalized_id)))
345
+ return _report_payload(
346
+ report, _bounded_findings(max_findings), offset=offset, severity=severity, file=file
347
+ )
348
+ except (MxReadyError, ValueError) as error:
349
+ return _error_payload(error)
350
+
351
+ @server.tool()
352
+ async def get_scan(scan_id: str) -> dict[str, Any]:
353
+ """Read scan progress or failure after a scan timeout; never creates a new scan."""
354
+ try:
355
+ job = await asyncio.to_thread(lambda: _runtime()._service.get_scan(UUID(scan_id)))
356
+ return job.model_dump(mode="json")
357
+ except (MxReadyError, ValueError) as error:
358
+ return _error_payload(error)
359
+
360
+ @server.tool()
361
+ async def list_rules(
362
+ category: str | None = None,
363
+ severity: str | None = None,
364
+ ) -> dict[str, Any]:
365
+ """List the bundled MXMACA migration rules, optionally filtered.
366
+
367
+ Args:
368
+ category: Case-insensitive category filter, for example "toolchain".
369
+ severity: One of "blocker", "warning", or "info".
370
+ """
371
+ catalog = await asyncio.to_thread(lambda: _runtime().catalog)
372
+ rules = catalog.rules
373
+ if category:
374
+ rules = [rule for rule in rules if rule.category.casefold() == category.casefold()]
375
+ if severity:
376
+ rules = [rule for rule in rules if str(rule.severity).casefold() == severity.casefold()]
377
+ return {
378
+ "tool_version": __version__,
379
+ "ruleset_version": catalog.version,
380
+ "total": len(rules),
381
+ "rules": [
382
+ {
383
+ "id": rule.id,
384
+ "title": rule.title,
385
+ "severity": str(rule.severity),
386
+ "category": rule.category,
387
+ }
388
+ for rule in rules
389
+ ],
390
+ }
391
+
392
+ @server.tool()
393
+ async def explain_rule(rule_id: str) -> dict[str, Any]:
394
+ """Return one rule with its rationale, recommendation, and sources.
395
+
396
+ Args:
397
+ rule_id: Exact rule identifier, for example "MXR-WARP-001".
398
+ """
399
+ catalog = await asyncio.to_thread(lambda: _runtime().catalog)
400
+ for rule in catalog.rules:
401
+ if rule.id == rule_id:
402
+ return {
403
+ "id": rule.id,
404
+ "version": rule.version,
405
+ "title": rule.title,
406
+ "severity": str(rule.severity),
407
+ "category": rule.category,
408
+ "file_globs": list(rule.file_globs),
409
+ "message": rule.message,
410
+ "recommendation": rule.recommendation,
411
+ "confidence": rule.confidence,
412
+ "references": [
413
+ {"title": item.title, "url": item.url} for item in rule.references
414
+ ],
415
+ }
416
+ return {
417
+ "error": {
418
+ "code": "RULE_NOT_FOUND",
419
+ "message": f"No rule with id {rule_id}.",
420
+ "details": {},
421
+ }
422
+ }
423
+
424
+ return server
425
+
426
+
427
+ def main() -> None:
428
+ _configure_stderr_logging()
429
+ build_server().run()
430
+
431
+
432
+ if __name__ == "__main__":
433
+ main()