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
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+
8
+ from pydantic import ValidationError
9
+
10
+ from mxready.errors import MxReadyError
11
+ from mxready.models import ScanReport
12
+ from mxready.verification.bundle import build_verification_bundle
13
+
14
+
15
+ def build_bundle_from_report(report_path: Path, output_path: Path) -> None:
16
+ report = ScanReport.model_validate_json(Path(report_path).read_text(encoding="utf-8-sig"))
17
+ output_path = Path(output_path)
18
+ output_path.parent.mkdir(parents=True, exist_ok=True)
19
+ output_path.write_bytes(build_verification_bundle(report))
20
+
21
+
22
+ def main(argv: Sequence[str] | None = None) -> int:
23
+ arguments = _build_parser().parse_args(argv)
24
+ try:
25
+ build_bundle_from_report(arguments.report, arguments.output)
26
+ except (MxReadyError, OSError, UnicodeError, ValidationError, ValueError) as error:
27
+ print(f"MXReady bundle error: {error}", file=sys.stderr)
28
+ return 1
29
+
30
+ print(f"MXReady: wrote verification bundle to {arguments.output}")
31
+ return 0
32
+
33
+
34
+ def _build_parser() -> argparse.ArgumentParser:
35
+ parser = argparse.ArgumentParser(
36
+ prog="mxready-build-bundle",
37
+ description="Build a safe-default MXReady verification ZIP from a JSON report.",
38
+ )
39
+ parser.add_argument("report", type=Path, help="MXReady JSON report")
40
+ parser.add_argument("--output", type=Path, required=True, help="Output ZIP path")
41
+ return parser
42
+
43
+
44
+ if __name__ == "__main__":
45
+ raise SystemExit(main())
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import re
5
+ import sys
6
+ import tempfile
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+ from uuid import NAMESPACE_URL, uuid5
10
+
11
+ from mxready.config import Settings
12
+ from mxready.errors import MxReadyError
13
+ from mxready.models import ScanReport, StaticStatus
14
+ from mxready.reporting.markdown import render_markdown
15
+ from mxready.reporting.sarif import render_sarif
16
+ from mxready.reporting.terminal import render_terminal_summary
17
+ from mxready.repository.git_client import GitClient
18
+ from mxready.repository.github_archive import GitHubArchiveClient
19
+ from mxready.repository.identity import parse_repository_url
20
+ from mxready.scanning.analyzer import ScanAnalyzer
21
+ from mxready.scanning.rule_loader import load_rule_catalog
22
+
23
+ _LABEL_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
24
+
25
+
26
+ def generate_public_report(
27
+ repository_url: str,
28
+ *,
29
+ label: str,
30
+ output_dir: Path,
31
+ work_dir: Path,
32
+ requested_ref: str | None = None,
33
+ pin_output_to_commit: bool = False,
34
+ git_client: GitClient | None = None,
35
+ archive_client: GitHubArchiveClient | None = None,
36
+ ) -> ScanReport:
37
+ """Safely clone a public repository and write commit-pinned evidence."""
38
+ if _LABEL_PATTERN.fullmatch(label) is None:
39
+ raise ValueError("Label must contain lowercase letters, numbers, and hyphens only.")
40
+
41
+ identity = parse_repository_url(repository_url)
42
+ output_dir = Path(output_dir)
43
+ work_dir = Path(work_dir)
44
+ output_dir.mkdir(parents=True, exist_ok=True)
45
+ work_dir.mkdir(parents=True, exist_ok=True)
46
+
47
+ client = git_client or GitClient()
48
+ with tempfile.TemporaryDirectory(prefix="mxready-evidence-", dir=work_dir) as temporary:
49
+ repository_root = Path(temporary) / "git-repository"
50
+ try:
51
+ commit = client.clone(identity, requested_ref, repository_root)
52
+ except MxReadyError as error:
53
+ if identity.provider != "github" or error.code not in {
54
+ "CLONE_TIMEOUT",
55
+ "SCAN_INTERNAL_ERROR",
56
+ }:
57
+ raise
58
+ repository_root = Path(temporary) / "archive-repository"
59
+ commit = (archive_client or GitHubArchiveClient()).clone(
60
+ identity,
61
+ requested_ref,
62
+ repository_root,
63
+ )
64
+ settings = Settings.from_env()
65
+ report = ScanAnalyzer(
66
+ load_rule_catalog(
67
+ settings.rules_dir,
68
+ extra_directory=settings.extra_rules_dir,
69
+ ),
70
+ enable_prefilter=settings.prefilter_enabled,
71
+ ).analyze(
72
+ repository_root,
73
+ repository_url=identity.clone_url,
74
+ commit=commit,
75
+ scan_id=uuid5(NAMESPACE_URL, f"{identity.clone_url}@{commit}"),
76
+ stage_callback=lambda status: None,
77
+ )
78
+
79
+ stem = f"{label}-{commit[:12]}" if pin_output_to_commit else label
80
+ (output_dir / f"{stem}.json").write_text(
81
+ report.model_dump_json(indent=2) + "\n",
82
+ encoding="utf-8",
83
+ )
84
+ (output_dir / f"{stem}.md").write_text(
85
+ render_markdown(report),
86
+ encoding="utf-8",
87
+ )
88
+ (output_dir / f"{stem}.sarif").write_text(render_sarif(report), encoding="utf-8")
89
+ return report
90
+
91
+
92
+ def main(argv: Sequence[str] | None = None) -> int:
93
+ arguments = _build_parser().parse_args(argv)
94
+ try:
95
+ report = generate_public_report(
96
+ arguments.repository_url,
97
+ label=arguments.label,
98
+ output_dir=Path(arguments.output),
99
+ work_dir=Path(arguments.work_dir),
100
+ requested_ref=arguments.ref,
101
+ )
102
+ except (MxReadyError, OSError, ValueError) as error:
103
+ print(f"MXReady public scan error: {error}", file=sys.stderr)
104
+ return 1
105
+
106
+ print(
107
+ render_terminal_summary(
108
+ report,
109
+ output_note=f"{arguments.label}.json / {arguments.label}.md",
110
+ )
111
+ )
112
+ return 2 if report.static_status is StaticStatus.BLOCKED else 0
113
+
114
+
115
+ def _build_parser() -> argparse.ArgumentParser:
116
+ parser = argparse.ArgumentParser(
117
+ prog="mxready-scan-public",
118
+ description=(
119
+ "Safely clone a public GitHub/Gitee repository and generate "
120
+ "commit-pinned MXReady evidence."
121
+ ),
122
+ )
123
+ parser.add_argument("repository_url", help="Public GitHub/Gitee HTTPS URL")
124
+ parser.add_argument(
125
+ "--label",
126
+ required=True,
127
+ help="Lowercase output label containing only letters, numbers, and hyphens",
128
+ )
129
+ parser.add_argument("--ref", help="Optional branch, tag, or 40-character commit")
130
+ parser.add_argument("--output", required=True, help="Directory for JSON and Markdown")
131
+ parser.add_argument(
132
+ "--work-dir",
133
+ default="data/public-scan-work",
134
+ help="Temporary clone parent (clones are removed after each scan)",
135
+ )
136
+ return parser
137
+
138
+
139
+ if __name__ == "__main__":
140
+ raise SystemExit(main())
@@ -0,0 +1,368 @@
1
+ """批量评估多个公开仓库:逐仓复用安全获取与报告管线,输出汇总总表。
2
+
3
+ 清单格式:每行一个仓库,`<https URL> [分支|标签|40位提交]`,`#` 开头为注释。
4
+ 来源参数既可以是清单文件路径,也可以直接传仓库 URL。工具只服务开发者本机,
5
+ 与 `mxready-scan-public` 共享同一套主机白名单、Git 限制和只读分析器。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import csv
12
+ import io
13
+ import re
14
+ import sys
15
+ from collections.abc import Callable, Sequence
16
+ from dataclasses import dataclass
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+
20
+ from mxready.cli.generate_public_report import generate_public_report
21
+ from mxready.errors import MxReadyError
22
+ from mxready.models import ScanReport, Severity
23
+ from mxready.repository.identity import parse_repository_url
24
+
25
+ _TOP_RULE_LIMIT = 3
26
+ _LABEL_SANITIZE = re.compile(r"[^a-z0-9]+")
27
+
28
+ ScanOne = Callable[..., ScanReport]
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class BatchEntry:
33
+ repository_url: str
34
+ requested_ref: str | None
35
+ source_line: int
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class BatchOutcome:
40
+ display: str
41
+ label: str
42
+ entry: BatchEntry
43
+ report: ScanReport | None
44
+ error: str | None
45
+
46
+
47
+ def load_batch_entries(source: str) -> list[BatchEntry]:
48
+ """解析一个批量来源:直接 URL 视为单项清单,否则按文件逐行读取。"""
49
+ if source.startswith("https://"):
50
+ return [_entry_from_line(source, 1)]
51
+
52
+ path = Path(source)
53
+ if not path.is_file():
54
+ raise ValueError(f"批量清单文件不存在: {path}")
55
+ entries: list[BatchEntry] = []
56
+ seen: set[tuple[str, str | None]] = set()
57
+ for line_number, raw in enumerate(
58
+ path.read_text(encoding="utf-8-sig").splitlines(),
59
+ start=1,
60
+ ):
61
+ stripped = raw.strip()
62
+ if not stripped or stripped.startswith("#"):
63
+ continue
64
+ entry = _entry_from_line(stripped.split(" #", 1)[0].strip(), line_number)
65
+ key = (entry.repository_url, entry.requested_ref)
66
+ if key in seen:
67
+ continue
68
+ seen.add(key)
69
+ entries.append(entry)
70
+ return entries
71
+
72
+
73
+ def _entry_from_line(line: str, line_number: int) -> BatchEntry:
74
+ parts = line.split()
75
+ if not 1 <= len(parts) <= 2:
76
+ raise ValueError(f"批量清单第 {line_number} 行格式无效: {line!r}")
77
+ repository_url, requested_ref = parts[0], parts[1] if len(parts) == 2 else None
78
+ try:
79
+ parse_repository_url(repository_url)
80
+ except MxReadyError as error:
81
+ raise ValueError(f"批量清单第 {line_number} 行仓库地址无效: {error.message}") from error
82
+ return BatchEntry(
83
+ repository_url=repository_url,
84
+ requested_ref=requested_ref,
85
+ source_line=line_number,
86
+ )
87
+
88
+
89
+ def scan_batch(
90
+ entries: Sequence[BatchEntry],
91
+ *,
92
+ output_dir: Path,
93
+ work_dir: Path,
94
+ scan_one: ScanOne | None = None,
95
+ progress: Callable[[BatchOutcome], None] | None = None,
96
+ ) -> list[BatchOutcome]:
97
+ """逐仓扫描并收集结果;单仓失败不中断批量,错误记录进汇总。"""
98
+ scan_fn: ScanOne = scan_one if scan_one is not None else generate_public_report
99
+ outcomes: list[BatchOutcome] = []
100
+ for entry in entries:
101
+ try:
102
+ identity = parse_repository_url(entry.repository_url)
103
+ except MxReadyError as error:
104
+ outcome = BatchOutcome(
105
+ display=entry.repository_url,
106
+ label="",
107
+ entry=entry,
108
+ report=None,
109
+ error=f"{error.code}: {error.message}",
110
+ )
111
+ outcomes.append(outcome)
112
+ if progress is not None:
113
+ progress(outcome)
114
+ continue
115
+
116
+ base = _LABEL_SANITIZE.sub("-", f"{identity.owner}-{identity.name}".casefold()).strip("-")
117
+ label_base = base[:48] or "repository"
118
+ try:
119
+ report = scan_fn(
120
+ entry.repository_url,
121
+ label=label_base,
122
+ output_dir=output_dir,
123
+ work_dir=work_dir,
124
+ requested_ref=entry.requested_ref,
125
+ pin_output_to_commit=True,
126
+ )
127
+ outcome = BatchOutcome(
128
+ display=f"{identity.owner}/{identity.name}",
129
+ label=f"{label_base}-{report.repository.commit[:12]}",
130
+ entry=entry,
131
+ report=report,
132
+ error=None,
133
+ )
134
+ except (MxReadyError, OSError, ValueError) as error:
135
+ code = error.code if isinstance(error, MxReadyError) else "OPERATIONAL_ERROR"
136
+ message = error.message if isinstance(error, MxReadyError) else str(error)
137
+ outcome = BatchOutcome(
138
+ display=f"{identity.owner}/{identity.name}",
139
+ label="",
140
+ entry=entry,
141
+ report=None,
142
+ error=f"{code}: {message}",
143
+ )
144
+ outcomes.append(outcome)
145
+ if progress is not None:
146
+ progress(outcome)
147
+ return outcomes
148
+
149
+
150
+ _CSV_HEADERS = [
151
+ "repository",
152
+ "ref",
153
+ "commit",
154
+ "status",
155
+ "blocker",
156
+ "warning",
157
+ "info",
158
+ "total",
159
+ "top_rules",
160
+ "report",
161
+ "error",
162
+ ]
163
+
164
+
165
+ def render_batch_csv(outcomes: Sequence[BatchOutcome]) -> str:
166
+ buffer = io.StringIO()
167
+ writer = csv.writer(buffer, lineterminator="\n")
168
+ writer.writerow(_CSV_HEADERS)
169
+ for outcome in outcomes:
170
+ writer.writerow(_csv_row(outcome))
171
+ return buffer.getvalue()
172
+
173
+
174
+ def _csv_row(outcome: BatchOutcome) -> list[str]:
175
+ report = outcome.report
176
+ if report is None:
177
+ return [
178
+ outcome.display,
179
+ outcome.entry.requested_ref or "",
180
+ "",
181
+ "failed",
182
+ "",
183
+ "",
184
+ "",
185
+ "",
186
+ "",
187
+ "",
188
+ outcome.error or "",
189
+ ]
190
+ summary = report.summary
191
+ return [
192
+ report.repository.url,
193
+ outcome.entry.requested_ref or "",
194
+ report.repository.commit,
195
+ report.static_status.value,
196
+ str(summary.blocker_count),
197
+ str(summary.warning_count),
198
+ str(summary.info_count),
199
+ str(summary.total_count),
200
+ "; ".join(_top_rules(report)),
201
+ f"{outcome.label}.md",
202
+ "",
203
+ ]
204
+
205
+
206
+ def _top_rules(report: ScanReport) -> list[str]:
207
+ rules: list[str] = []
208
+ for finding in report.findings:
209
+ if finding.severity not in {Severity.BLOCKER, Severity.WARNING}:
210
+ continue
211
+ if finding.rule_id not in rules:
212
+ rules.append(finding.rule_id)
213
+ if len(rules) >= _TOP_RULE_LIMIT:
214
+ break
215
+ return rules
216
+
217
+
218
+ def render_batch_markdown(
219
+ outcomes: Sequence[BatchOutcome],
220
+ *,
221
+ ruleset_version: str | None = None,
222
+ ruleset_digest: str | None = None,
223
+ ) -> str:
224
+ succeeded = [outcome for outcome in outcomes if outcome.report is not None]
225
+ blocked = sum(
226
+ 1
227
+ for outcome in succeeded
228
+ if outcome.report is not None and outcome.report.static_status.value == "blocked"
229
+ )
230
+ lines = [
231
+ "# MXReady 批量迁移体检汇总",
232
+ "",
233
+ f"- 生成时间: {datetime.now(UTC).isoformat(timespec='seconds')}",
234
+ f"- 仓库数: {len(outcomes)}(成功 {len(succeeded)} · 失败 {len(outcomes) - len(succeeded)}"
235
+ f" · 其中阻塞 {blocked})",
236
+ ]
237
+ if ruleset_version is not None:
238
+ digest_note = f",规则集摘要 {ruleset_digest}" if ruleset_digest else ""
239
+ lines.append(f"- 规则集版本: {ruleset_version}{digest_note}")
240
+ lines.extend(
241
+ [
242
+ "",
243
+ "| 仓库 | 提交 | 状态 | 阻断 | 警告 | 提示 | 主要规则 |",
244
+ "| --- | --- | --- | --- | --- | --- | --- |",
245
+ ]
246
+ )
247
+ for outcome in outcomes:
248
+ lines.append(_markdown_row(outcome))
249
+ return "\n".join(lines) + "\n"
250
+
251
+
252
+ def _markdown_row(outcome: BatchOutcome) -> str:
253
+ report = outcome.report
254
+ if report is None:
255
+ reason = (outcome.error or "未知错误").replace("|", "\\|")
256
+ return f"| {outcome.display} | - | 失败({reason}) | - | - | - | - |"
257
+ summary = report.summary
258
+ commit = report.repository.commit[:12]
259
+ rules = "、".join(_top_rules(report)) or "-"
260
+ return (
261
+ f"| [{outcome.display}]({report.repository.url}) "
262
+ f"| `{commit}` | {report.static_status.value} "
263
+ f"| {summary.blocker_count} | {summary.warning_count} | {summary.info_count} | {rules} |"
264
+ )
265
+
266
+
267
+ def format_progress_line(index: int, total: int, outcome: BatchOutcome) -> str:
268
+ if outcome.report is None:
269
+ return f"[{index}/{total}] {outcome.display} → 失败: {outcome.error}"
270
+ summary = outcome.report.summary
271
+ counts = (
272
+ f"阻断 {summary.blocker_count} · 警告 {summary.warning_count} · 提示 {summary.info_count}"
273
+ )
274
+ return f"[{index}/{total}] {outcome.display} → {outcome.report.static_status.value}({counts})"
275
+
276
+
277
+ def main(argv: Sequence[str] | None = None) -> int:
278
+ arguments = _build_parser().parse_args(argv)
279
+ try:
280
+ entries: list[BatchEntry] = []
281
+ for source in arguments.sources:
282
+ entries.extend(load_batch_entries(source))
283
+ except ValueError as error:
284
+ print(f"MXReady batch error: {error}", file=sys.stderr)
285
+ return 1
286
+ if not entries:
287
+ print("MXReady batch error: 清单为空。", file=sys.stderr)
288
+ return 1
289
+
290
+ output_dir = Path(arguments.output)
291
+ work_dir = Path(arguments.work_dir)
292
+ total = len(entries)
293
+ counter = {"index": 0}
294
+
295
+ def show(outcome: BatchOutcome) -> None:
296
+ counter["index"] += 1
297
+ print(format_progress_line(counter["index"], total, outcome))
298
+
299
+ try:
300
+ outcomes = scan_batch(
301
+ entries,
302
+ output_dir=output_dir,
303
+ work_dir=work_dir,
304
+ progress=show,
305
+ )
306
+ output_dir.mkdir(parents=True, exist_ok=True)
307
+ (output_dir / "batch-summary.csv").write_text(
308
+ render_batch_csv(outcomes),
309
+ encoding="utf-8-sig",
310
+ )
311
+ succeeded = [outcome.report for outcome in outcomes if outcome.report is not None]
312
+ (output_dir / "batch-summary.md").write_text(
313
+ render_batch_markdown(
314
+ outcomes,
315
+ ruleset_version=succeeded[0].ruleset_version if succeeded else None,
316
+ ruleset_digest=succeeded[0].ruleset_digest if succeeded else None,
317
+ ),
318
+ encoding="utf-8",
319
+ )
320
+ except OSError as error:
321
+ print(f"MXReady batch error: {error}", file=sys.stderr)
322
+ return 1
323
+
324
+ succeeded_count = sum(1 for outcome in outcomes if outcome.report is not None)
325
+ blocked_count = sum(
326
+ 1
327
+ for outcome in outcomes
328
+ if outcome.report is not None and outcome.report.static_status.value == "blocked"
329
+ )
330
+ print(
331
+ f"汇总: 成功 {succeeded_count} · 失败 {total - succeeded_count}(其中阻塞 {blocked_count})"
332
+ )
333
+ print(f"报告: {output_dir / 'batch-summary.csv'} 与 {output_dir / 'batch-summary.md'}")
334
+ if succeeded_count < total:
335
+ return 1
336
+ if blocked_count:
337
+ return 2
338
+ return 0
339
+
340
+
341
+ def _build_parser() -> argparse.ArgumentParser:
342
+ parser = argparse.ArgumentParser(
343
+ prog="mxready-scan-batch",
344
+ description=(
345
+ "Batch-scan multiple public GitHub/Gitee repositories and write "
346
+ "a CSV/Markdown readiness summary."
347
+ ),
348
+ )
349
+ parser.add_argument(
350
+ "sources",
351
+ nargs="+",
352
+ help="清单文件路径或直接传一个仓库 HTTPS URL(可混合多个来源)",
353
+ )
354
+ parser.add_argument(
355
+ "--output",
356
+ default="data/batch",
357
+ help="每仓报告与汇总文件的输出目录(默认 data/batch)",
358
+ )
359
+ parser.add_argument(
360
+ "--work-dir",
361
+ default="data/public-scan-work",
362
+ help="临时克隆父目录,扫描后自动清理(默认 data/public-scan-work)",
363
+ )
364
+ return parser
365
+
366
+
367
+ if __name__ == "__main__":
368
+ raise SystemExit(main())