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,299 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from array import array
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from mxready.errors import MxReadyError
|
|
12
|
+
from mxready.models import ScanCoverage
|
|
13
|
+
|
|
14
|
+
_ALLOWED_EXTENSIONS = {
|
|
15
|
+
".c",
|
|
16
|
+
".cc",
|
|
17
|
+
".cfg",
|
|
18
|
+
".cmake",
|
|
19
|
+
".cpp",
|
|
20
|
+
".cu",
|
|
21
|
+
".cuh",
|
|
22
|
+
".dockerfile",
|
|
23
|
+
".h",
|
|
24
|
+
".hpp",
|
|
25
|
+
".md",
|
|
26
|
+
".mk",
|
|
27
|
+
".py",
|
|
28
|
+
".sh",
|
|
29
|
+
".toml",
|
|
30
|
+
".txt",
|
|
31
|
+
".yaml",
|
|
32
|
+
".yml",
|
|
33
|
+
}
|
|
34
|
+
# Makefile 三种常见拼写都要收:CUDA 侧大量项目(含 cuda-samples)用 Make 构建,
|
|
35
|
+
# cu-bridge 的官方入口 make_maca 本身就是 make 包装器,漏掉它等于漏掉整条构建链。
|
|
36
|
+
_ALLOWED_FILENAMES = {
|
|
37
|
+
"CMakeLists.txt",
|
|
38
|
+
"Dockerfile",
|
|
39
|
+
"GNUmakefile",
|
|
40
|
+
"Makefile",
|
|
41
|
+
"makefile",
|
|
42
|
+
}
|
|
43
|
+
_EXCLUDED_DIRECTORIES = {
|
|
44
|
+
".git",
|
|
45
|
+
".venv",
|
|
46
|
+
"__pycache__",
|
|
47
|
+
"build",
|
|
48
|
+
"dist",
|
|
49
|
+
"node_modules",
|
|
50
|
+
"target",
|
|
51
|
+
"vendor",
|
|
52
|
+
"venv",
|
|
53
|
+
}
|
|
54
|
+
_HIDDEN_CACHE_DIRECTORIES = {
|
|
55
|
+
".cache",
|
|
56
|
+
".hypothesis",
|
|
57
|
+
".mypy_cache",
|
|
58
|
+
".nox",
|
|
59
|
+
".pytest_cache",
|
|
60
|
+
".ruff_cache",
|
|
61
|
+
".tox",
|
|
62
|
+
}
|
|
63
|
+
# 测试运行器与基准框架的临时目录:内容是夹具的运行时副本,不属于仓库内容,
|
|
64
|
+
# 每次运行还会漂移(pytest 的 `--basetemp=.pytest-temp` 即属此类)。
|
|
65
|
+
# 扫描它们只会产生随时间变化、无法复核的噪音命中,因此整目录排除。
|
|
66
|
+
_SCRATCH_DIRECTORY_PREFIXES = (".benchmarks", ".pytest")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class IndexedFile:
|
|
71
|
+
relative_path: str
|
|
72
|
+
size: int
|
|
73
|
+
sha256: str
|
|
74
|
+
text: str
|
|
75
|
+
line_offsets: array[int] = field(init=False, repr=False)
|
|
76
|
+
|
|
77
|
+
def __post_init__(self) -> None:
|
|
78
|
+
offsets = array("I", [0])
|
|
79
|
+
offsets.extend(match.end() for match in re.finditer("\n", self.text))
|
|
80
|
+
object.__setattr__(self, "line_offsets", offsets)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class IndexWarning:
|
|
85
|
+
code: str
|
|
86
|
+
relative_path: str
|
|
87
|
+
message: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True, slots=True)
|
|
91
|
+
class FileIndex:
|
|
92
|
+
files: tuple[IndexedFile, ...]
|
|
93
|
+
warnings: tuple[IndexWarning, ...]
|
|
94
|
+
coverage: ScanCoverage | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_file_index(
|
|
98
|
+
root: Path,
|
|
99
|
+
*,
|
|
100
|
+
max_files: int = 10_000,
|
|
101
|
+
max_file_bytes: int = 1_048_576,
|
|
102
|
+
) -> FileIndex:
|
|
103
|
+
"""Build a deterministic text-only index without following symlinks."""
|
|
104
|
+
if max_files <= 0 or max_file_bytes <= 0:
|
|
105
|
+
raise ValueError("file index limits must be positive")
|
|
106
|
+
|
|
107
|
+
root = Path(root)
|
|
108
|
+
if not root.is_dir():
|
|
109
|
+
raise MxReadyError(
|
|
110
|
+
"SCAN_INTERNAL_ERROR",
|
|
111
|
+
"待扫描的仓库目录不存在或不可读取。",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
indexed_files: list[IndexedFile] = []
|
|
115
|
+
warnings: list[IndexWarning] = []
|
|
116
|
+
candidate_count = 0
|
|
117
|
+
skipped: Counter[str] = Counter()
|
|
118
|
+
|
|
119
|
+
for path, relative_path in _walk_candidate_files(root, warnings, skipped):
|
|
120
|
+
candidate_count += 1
|
|
121
|
+
if candidate_count > max_files:
|
|
122
|
+
raise MxReadyError(
|
|
123
|
+
"TOO_MANY_FILES",
|
|
124
|
+
f"可分析源码文件超过 {max_files} 个限制。",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
size = path.stat(follow_symlinks=False).st_size
|
|
129
|
+
except OSError:
|
|
130
|
+
warnings.append(
|
|
131
|
+
IndexWarning(
|
|
132
|
+
code="FILE_READ_ERROR",
|
|
133
|
+
relative_path=relative_path,
|
|
134
|
+
message="无法读取该文件的元数据,已跳过。",
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
if size > max_file_bytes:
|
|
140
|
+
warnings.append(
|
|
141
|
+
IndexWarning(
|
|
142
|
+
code="FILE_TOO_LARGE",
|
|
143
|
+
relative_path=relative_path,
|
|
144
|
+
message=f"文件超过 {max_file_bytes} 字节限制,已跳过。",
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
content = path.read_bytes()
|
|
151
|
+
except OSError:
|
|
152
|
+
warnings.append(
|
|
153
|
+
IndexWarning(
|
|
154
|
+
code="FILE_READ_ERROR",
|
|
155
|
+
relative_path=relative_path,
|
|
156
|
+
message="无法安全读取该文件,已跳过。",
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
if len(content) > max_file_bytes:
|
|
162
|
+
warnings.append(
|
|
163
|
+
IndexWarning(
|
|
164
|
+
code="FILE_TOO_LARGE",
|
|
165
|
+
relative_path=relative_path,
|
|
166
|
+
message=f"文件超过 {max_file_bytes} 字节限制,已跳过。",
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
continue
|
|
170
|
+
if _looks_binary(content):
|
|
171
|
+
warnings.append(
|
|
172
|
+
IndexWarning(
|
|
173
|
+
code="BINARY_FILE_SKIPPED",
|
|
174
|
+
relative_path=relative_path,
|
|
175
|
+
message="文件包含二进制内容,已跳过。",
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
text = _decode_text(content)
|
|
181
|
+
if text is None:
|
|
182
|
+
warnings.append(
|
|
183
|
+
IndexWarning(
|
|
184
|
+
code="UNSUPPORTED_TEXT_ENCODING",
|
|
185
|
+
relative_path=relative_path,
|
|
186
|
+
message="文件不是 UTF-8 或 GB18030 文本,已跳过。",
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
indexed_files.append(
|
|
192
|
+
IndexedFile(
|
|
193
|
+
relative_path=relative_path,
|
|
194
|
+
size=len(content),
|
|
195
|
+
sha256=hashlib.sha256(content).hexdigest(),
|
|
196
|
+
text=text,
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
skipped.update(w.code for w in warnings if w.code != "DIRECTORY_READ_ERROR")
|
|
201
|
+
return FileIndex(
|
|
202
|
+
coverage=ScanCoverage(
|
|
203
|
+
analyzed_files=len(indexed_files),
|
|
204
|
+
analyzed_bytes=sum(f.size for f in indexed_files),
|
|
205
|
+
files_by_type=dict(
|
|
206
|
+
sorted(
|
|
207
|
+
Counter(
|
|
208
|
+
Path(f.relative_path).suffix.lower() or Path(f.relative_path).name
|
|
209
|
+
for f in indexed_files
|
|
210
|
+
).items()
|
|
211
|
+
)
|
|
212
|
+
),
|
|
213
|
+
excluded_directories=skipped.pop("EXCLUDED_DIRECTORY", 0),
|
|
214
|
+
skipped_by_reason=dict(sorted(skipped.items())),
|
|
215
|
+
),
|
|
216
|
+
files=tuple(sorted(indexed_files, key=lambda item: item.relative_path)),
|
|
217
|
+
warnings=tuple(sorted(warnings, key=lambda item: (item.relative_path, item.code))),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _walk_candidate_files(
|
|
222
|
+
root: Path,
|
|
223
|
+
warnings: list[IndexWarning],
|
|
224
|
+
skipped: Counter[str],
|
|
225
|
+
):
|
|
226
|
+
pending = [root]
|
|
227
|
+
while pending:
|
|
228
|
+
directory = pending.pop()
|
|
229
|
+
try:
|
|
230
|
+
with os.scandir(directory) as entries:
|
|
231
|
+
ordered_entries = sorted(entries, key=lambda item: item.name)
|
|
232
|
+
except OSError:
|
|
233
|
+
relative_path = _relative_path(directory, root)
|
|
234
|
+
warnings.append(
|
|
235
|
+
IndexWarning(
|
|
236
|
+
code="DIRECTORY_READ_ERROR",
|
|
237
|
+
relative_path=relative_path,
|
|
238
|
+
message="无法读取该目录,已跳过。",
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
child_directories: list[Path] = []
|
|
244
|
+
for entry in ordered_entries:
|
|
245
|
+
try:
|
|
246
|
+
if entry.is_symlink():
|
|
247
|
+
skipped["SYMLINK"] += 1
|
|
248
|
+
continue
|
|
249
|
+
if entry.is_dir(follow_symlinks=False):
|
|
250
|
+
if not _is_excluded_directory(entry.name):
|
|
251
|
+
child_directories.append(Path(entry.path))
|
|
252
|
+
else:
|
|
253
|
+
skipped["EXCLUDED_DIRECTORY"] += 1
|
|
254
|
+
continue
|
|
255
|
+
if not entry.is_file(follow_symlinks=False):
|
|
256
|
+
continue
|
|
257
|
+
except OSError:
|
|
258
|
+
skipped["ENTRY_READ_ERROR"] += 1
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
path = Path(entry.path)
|
|
262
|
+
if _is_allowed_file(path):
|
|
263
|
+
yield path, _relative_path(path, root)
|
|
264
|
+
else:
|
|
265
|
+
skipped["UNSUPPORTED_FILE_TYPE"] += 1
|
|
266
|
+
|
|
267
|
+
pending.extend(reversed(child_directories))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _is_excluded_directory(name: str) -> bool:
|
|
271
|
+
return (
|
|
272
|
+
name in _EXCLUDED_DIRECTORIES
|
|
273
|
+
or name in _HIDDEN_CACHE_DIRECTORIES
|
|
274
|
+
or name.startswith(_SCRATCH_DIRECTORY_PREFIXES)
|
|
275
|
+
or name.startswith(".")
|
|
276
|
+
and "cache" in name.casefold()
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _is_allowed_file(path: Path) -> bool:
|
|
281
|
+
return path.name in _ALLOWED_FILENAMES or path.suffix.casefold() in _ALLOWED_EXTENSIONS
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _relative_path(path: Path, root: Path) -> str:
|
|
285
|
+
relative = path.relative_to(root).as_posix()
|
|
286
|
+
return relative or "."
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _looks_binary(content: bytes) -> bool:
|
|
290
|
+
return b"\x00" in content
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _decode_text(content: bytes) -> str | None:
|
|
294
|
+
for encoding in ("utf-8-sig", "gb18030"):
|
|
295
|
+
try:
|
|
296
|
+
return content.decode(encoding)
|
|
297
|
+
except UnicodeDecodeError:
|
|
298
|
+
continue
|
|
299
|
+
return None
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""按路径判定文件角色:非交付路径上的命中只作提示。
|
|
2
|
+
|
|
3
|
+
静态规则看不到"这段代码是交付产物还是验证材料"。仓库的 `tests/`、`examples/`、
|
|
4
|
+
`docs/` 里常常放着 `.cu` 夹具、构建脚本与代码片段——它们的写法与真实 kernel 一样,
|
|
5
|
+
但**不是**迁移对象。按原级别上报会让报告整体失真:本仓库自扫描一度出现 22 条
|
|
6
|
+
blocker,全部出自测试夹具,真实用户第一次运行就会看到"工具判定自己是阻塞状态"。
|
|
7
|
+
|
|
8
|
+
处理原则:
|
|
9
|
+
|
|
10
|
+
1. 不排除这些文件。仍然索引、仍然评估、仍然出现在报告里——"看不见"比"级别低"
|
|
11
|
+
更危险,漏掉一条真实风险比看到一条弱提示代价大得多。
|
|
12
|
+
2. 只把**交付路径之外**的命中封顶为 `info`,不改变交付路径的任何结论。
|
|
13
|
+
3. 降级必须留痕。与工具链画像(`scanning/toolchain.py`)同一套约定:
|
|
14
|
+
`Finding.message` 末尾追加原因,并往 `analysis_warnings` 写一条可追溯记录,
|
|
15
|
+
含角色、规则 ID 与文件数。
|
|
16
|
+
|
|
17
|
+
路径判定按**路径段精确匹配**,不做子串匹配:`contest/`、`attestation/`、
|
|
18
|
+
`latest/` 都不是测试目录。
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from enum import StrEnum
|
|
24
|
+
|
|
25
|
+
from mxready.models import AnalysisWarning, Finding, Severity
|
|
26
|
+
|
|
27
|
+
PATH_CONTEXT_WARNING_CODE = "PATH_CONTEXT_DEMOTION"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PathRole(StrEnum):
|
|
31
|
+
SOURCE = "source"
|
|
32
|
+
TEST = "test"
|
|
33
|
+
EXAMPLE = "example"
|
|
34
|
+
DOC = "doc"
|
|
35
|
+
BENCHMARK = "benchmark"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# 判定顺序即优先级:`docs/examples/x.cu` 归为示例,而不是文档。
|
|
39
|
+
_ROLE_SEGMENTS: tuple[tuple[PathRole, frozenset[str]], ...] = (
|
|
40
|
+
(
|
|
41
|
+
PathRole.TEST,
|
|
42
|
+
frozenset(
|
|
43
|
+
{
|
|
44
|
+
"__tests__",
|
|
45
|
+
"fixture",
|
|
46
|
+
"fixtures",
|
|
47
|
+
"test",
|
|
48
|
+
"test-data",
|
|
49
|
+
"test_data",
|
|
50
|
+
"testdata",
|
|
51
|
+
"tests",
|
|
52
|
+
"testing",
|
|
53
|
+
}
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
(
|
|
57
|
+
PathRole.BENCHMARK,
|
|
58
|
+
frozenset({"bench", "benches", "benchmark", "benchmarks"}),
|
|
59
|
+
),
|
|
60
|
+
(
|
|
61
|
+
PathRole.EXAMPLE,
|
|
62
|
+
frozenset({"demo", "demos", "example", "examples", "sample", "samples"}),
|
|
63
|
+
),
|
|
64
|
+
(
|
|
65
|
+
PathRole.DOC,
|
|
66
|
+
frozenset({"doc", "docs", "documentation"}),
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# 只对这两档降级:info 本来就是提示,再写一遍原因只是噪音。
|
|
71
|
+
_DEMOTABLE = frozenset({Severity.BLOCKER, Severity.WARNING})
|
|
72
|
+
|
|
73
|
+
_ROLE_LABELS = {
|
|
74
|
+
PathRole.TEST: "测试/夹具",
|
|
75
|
+
PathRole.BENCHMARK: "基准测试",
|
|
76
|
+
PathRole.EXAMPLE: "示例",
|
|
77
|
+
PathRole.DOC: "文档",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_ROLE_NOTES = {
|
|
81
|
+
PathRole.TEST: (
|
|
82
|
+
"(该命中位于测试或夹具路径:规则命中的是测试代码本身,通常不代表交付产物的"
|
|
83
|
+
"迁移风险,已从 {declared} 降为提示;若该夹具描述的正是交付行为,请另行复核。)"
|
|
84
|
+
),
|
|
85
|
+
PathRole.BENCHMARK: (
|
|
86
|
+
"(该命中位于基准测试路径:基准代码一般不随交付构建,已从 {declared} 降为提示。)"
|
|
87
|
+
),
|
|
88
|
+
PathRole.EXAMPLE: (
|
|
89
|
+
"(该命中位于示例路径:示例代码一般不随交付构建,已从 {declared} 降为提示。)"
|
|
90
|
+
),
|
|
91
|
+
PathRole.DOC: ("(该命中位于文档路径:命中的是文档中的代码片段,已从 {declared} 降为提示。)"),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
_SEVERITY_LABELS = {
|
|
95
|
+
Severity.BLOCKER: "blocker",
|
|
96
|
+
Severity.WARNING: "warning",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def classify_path(relative_path: str) -> PathRole:
|
|
101
|
+
"""按路径段给出文件角色;文件名本身只有 `test_*` / `*_test` 参与判定。"""
|
|
102
|
+
segments = [segment.casefold() for segment in relative_path.split("/") if segment]
|
|
103
|
+
if not segments:
|
|
104
|
+
return PathRole.SOURCE
|
|
105
|
+
|
|
106
|
+
directories = segments[:-1]
|
|
107
|
+
for role, names in _ROLE_SEGMENTS:
|
|
108
|
+
if any(segment in names for segment in directories):
|
|
109
|
+
return role
|
|
110
|
+
|
|
111
|
+
if _is_test_filename(segments[-1]):
|
|
112
|
+
return PathRole.TEST
|
|
113
|
+
return PathRole.SOURCE
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def is_demoted(relative_path: str) -> bool:
|
|
117
|
+
return classify_path(relative_path) is not PathRole.SOURCE
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def apply_path_context(
|
|
121
|
+
findings: list[Finding],
|
|
122
|
+
) -> tuple[list[Finding], list[AnalysisWarning]]:
|
|
123
|
+
"""把非交付路径上的 blocker/warning 封顶为 info,并返回需要写入报告的告警。"""
|
|
124
|
+
adjusted: list[Finding] = []
|
|
125
|
+
demoted: dict[PathRole, dict[str, set[str]]] = {}
|
|
126
|
+
|
|
127
|
+
for finding in findings:
|
|
128
|
+
role = classify_path(finding.relative_path)
|
|
129
|
+
if role is PathRole.SOURCE or finding.severity not in _DEMOTABLE:
|
|
130
|
+
adjusted.append(finding)
|
|
131
|
+
continue
|
|
132
|
+
demoted.setdefault(role, {}).setdefault(finding.rule_id, set()).add(finding.relative_path)
|
|
133
|
+
adjusted.append(
|
|
134
|
+
finding.model_copy(
|
|
135
|
+
update={
|
|
136
|
+
"severity": Severity.INFO,
|
|
137
|
+
"message": _append_note(finding.message, role, finding.severity),
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if not demoted:
|
|
143
|
+
return adjusted, []
|
|
144
|
+
|
|
145
|
+
warnings = [
|
|
146
|
+
AnalysisWarning(
|
|
147
|
+
code=PATH_CONTEXT_WARNING_CODE,
|
|
148
|
+
relative_path=None,
|
|
149
|
+
message=(
|
|
150
|
+
f"{_ROLE_LABELS[role]}路径上的 {_describe_rules(rules)} 已封顶为提示,"
|
|
151
|
+
"不参与静态状态判定;文件仍被扫描,结论可逐条复核。"
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
for role, rules in sorted(demoted.items(), key=lambda item: item[0].value)
|
|
155
|
+
]
|
|
156
|
+
return adjusted, warnings
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _is_test_filename(name: str) -> bool:
|
|
160
|
+
stem = name.rsplit(".", 1)[0]
|
|
161
|
+
return stem.startswith("test_") or stem.endswith(("_test", "-test"))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _append_note(message: str, role: PathRole, declared: Severity) -> str:
|
|
165
|
+
note = _ROLE_NOTES[role].format(declared=_SEVERITY_LABELS[declared])
|
|
166
|
+
if note in message:
|
|
167
|
+
return message
|
|
168
|
+
return f"{message}{note}"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _describe_rules(rules: dict[str, set[str]]) -> str:
|
|
172
|
+
return ";".join(
|
|
173
|
+
f"{rule_id}({len(paths)} 个文件)" for rule_id, paths in sorted(rules.items())
|
|
174
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""识别 Python 文档字符串占用的精确字符区间。
|
|
2
|
+
|
|
3
|
+
静态规则按词法匹配,分不清"可执行代码"与"文档里对代码的描述"。本仓库自身就有
|
|
4
|
+
反例:`scanning/toolchain.py` 的模块文档字符串写了
|
|
5
|
+
`subprocess.run(["nvcc", ...])` 作为反例说明,于是被 `MXR-TOOLCHAIN-001`
|
|
6
|
+
判成 blocker——一条描述规则的文字被当成了违反规则的行为。
|
|
7
|
+
|
|
8
|
+
这里的判定刻意收得很窄,只处理**文档字符串**:
|
|
9
|
+
|
|
10
|
+
- 只有模块、类、函数的首条语句、且是字符串常量,才算文档字符串;
|
|
11
|
+
- 普通字符串字面量**不**豁免。`os.system("nvcc main.cu")` 命令就在字符串里,
|
|
12
|
+
豁免它会制造真实的漏报,而漏报比多报危险得多;
|
|
13
|
+
- 解析失败(Python 2 源码、生成代码、语法错误)一律返回空区间,即不豁免。
|
|
14
|
+
|
|
15
|
+
因此这是"去掉文档噪音",不是"忽略字符串"。
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import ast
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def docstring_spans(text: str) -> tuple[tuple[int, int], ...]:
|
|
25
|
+
"""返回半开字符偏移区间,与正则 match.span() 使用同一坐标。"""
|
|
26
|
+
try:
|
|
27
|
+
tree = ast.parse(text)
|
|
28
|
+
except (SyntaxError, ValueError):
|
|
29
|
+
return ()
|
|
30
|
+
|
|
31
|
+
line_starts = [0, *(match.end() for match in re.finditer(r"\r\n?|\n", text))]
|
|
32
|
+
|
|
33
|
+
def offset(row: int, byte_column: int) -> int:
|
|
34
|
+
start = line_starts[row - 1]
|
|
35
|
+
end = line_starts[row] if row < len(line_starts) else len(text)
|
|
36
|
+
# AST columns count UTF-8 bytes; regex offsets count Unicode characters.
|
|
37
|
+
return start + len(text[start:end].encode("utf-8")[:byte_column].decode("utf-8"))
|
|
38
|
+
|
|
39
|
+
spans: list[tuple[int, int]] = []
|
|
40
|
+
for node in ast.walk(tree):
|
|
41
|
+
if not isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
42
|
+
continue
|
|
43
|
+
if not node.body:
|
|
44
|
+
continue
|
|
45
|
+
first = node.body[0]
|
|
46
|
+
if not isinstance(first, ast.Expr):
|
|
47
|
+
continue
|
|
48
|
+
value = first.value
|
|
49
|
+
if not isinstance(value, ast.Constant) or not isinstance(value.value, str):
|
|
50
|
+
continue
|
|
51
|
+
if value.end_lineno is not None and value.end_col_offset is not None:
|
|
52
|
+
spans.append(
|
|
53
|
+
(
|
|
54
|
+
offset(value.lineno, value.col_offset),
|
|
55
|
+
offset(value.end_lineno, value.end_col_offset),
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
return tuple(spans)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_inside_docstring(
|
|
62
|
+
spans: tuple[tuple[int, int], ...],
|
|
63
|
+
offset_start: int,
|
|
64
|
+
offset_end: int,
|
|
65
|
+
) -> bool:
|
|
66
|
+
"""只有命中的字符范围完整落在文档字符串内才豁免。"""
|
|
67
|
+
return any(start <= offset_start and offset_end <= end for start, end in spans)
|