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.
- mxready/__init__.py +1 -0
- mxready/api/__init__.py +4 -0
- mxready/api/middleware.py +149 -0
- mxready/api/rules.py +10 -0
- mxready/api/scans.py +164 -0
- mxready/api/workspace.py +156 -0
- mxready/app.py +179 -0
- mxready/cli/__init__.py +1 -0
- mxready/cli/build_verification_bundle.py +45 -0
- mxready/cli/generate_public_report.py +140 -0
- mxready/cli/scan_batch.py +368 -0
- mxready/cli/scan_repository.py +212 -0
- mxready/cli/serve.py +55 -0
- mxready/config.py +117 -0
- mxready/errors.py +11 -0
- mxready/logging_setup.py +88 -0
- mxready/mcp/__init__.py +5 -0
- mxready/mcp/server.py +433 -0
- mxready/models.py +382 -0
- mxready/persistence/__init__.py +1 -0
- mxready/persistence/sqlite.py +581 -0
- mxready/py.typed +0 -0
- mxready/reporting/__init__.py +4 -0
- mxready/reporting/badge.py +40 -0
- mxready/reporting/comparison.py +33 -0
- mxready/reporting/markdown.py +157 -0
- mxready/reporting/sarif.py +94 -0
- mxready/reporting/terminal.py +75 -0
- mxready/repository/__init__.py +14 -0
- mxready/repository/git_client.py +275 -0
- mxready/repository/github_archive.py +566 -0
- mxready/repository/identity.py +140 -0
- mxready/resources/__init__.py +19 -0
- mxready/resources/frontend/assets/index-BxJEwNUm.css +1 -0
- mxready/resources/frontend/assets/index-lzTCdWAt.js +9 -0
- mxready/resources/frontend/assets/mx-chip-C0HgFQyE.webp +0 -0
- mxready/resources/frontend/index.html +17 -0
- mxready/resources/rules/v1/core.yml +634 -0
- mxready/resources/rules/v1/manifest.yml +4 -0
- mxready/resources/schemas/verification-result-v1.json +259 -0
- mxready/scanning/__init__.py +17 -0
- mxready/scanning/analyzer.py +183 -0
- mxready/scanning/facts.py +596 -0
- mxready/scanning/indexer.py +299 -0
- mxready/scanning/path_context.py +174 -0
- mxready/scanning/python_source.py +67 -0
- mxready/scanning/rule_engine.py +595 -0
- mxready/scanning/rule_loader.py +282 -0
- mxready/scanning/toolchain.py +100 -0
- mxready/services/__init__.py +3 -0
- mxready/services/scans.py +239 -0
- mxready/services/worker.py +38 -0
- mxready/verification/__init__.py +11 -0
- mxready/verification/bundle.py +137 -0
- mxready/verification/validation.py +101 -0
- mxready-1.0.0.dist-info/METADATA +526 -0
- mxready-1.0.0.dist-info/RECORD +71 -0
- mxready-1.0.0.dist-info/WHEEL +5 -0
- mxready-1.0.0.dist-info/entry_points.txt +8 -0
- mxready-1.0.0.dist-info/licenses/LICENSE +201 -0
- mxready-1.0.0.dist-info/top_level.txt +2 -0
- mxready_runner/__init__.py +1 -0
- mxready_runner/__main__.py +3 -0
- mxready_runner/cli.py +88 -0
- mxready_runner/contracts.py +26 -0
- mxready_runner/execute.py +185 -0
- mxready_runner/inspect.py +79 -0
- mxready_runner/process.py +103 -0
- mxready_runner/py.typed +0 -0
- mxready_runner/redact.py +36 -0
- 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())
|