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,596 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import configparser
5
+ import re
6
+ import shlex
7
+ import tomllib
8
+ import warnings
9
+ from bisect import bisect_right
10
+ from collections import defaultdict
11
+ from collections.abc import Iterator
12
+ from dataclasses import dataclass
13
+
14
+ from mxready.scanning.indexer import FileIndex, IndexedFile
15
+ from mxready.scanning.path_context import is_demoted
16
+
17
+ _DEPENDENCY_NAME = re.compile(r"\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
18
+ _KNOWN_FLAGS = {
19
+ "imports_apex",
20
+ "imports_tensorrt",
21
+ "invokes_nvcc_directly",
22
+ "invokes_nvidia_smi",
23
+ "references_cuda_home",
24
+ "uses_cmake_cuda_language",
25
+ "uses_cmake_cuda_package",
26
+ "uses_cmake_torch_package",
27
+ "uses_cu_bridge_toolchain",
28
+ "uses_hardcoded_cuda_path",
29
+ "uses_torch_cpp_extension",
30
+ "uses_torch_cpp_extension_load",
31
+ "uses_torch_cuda_extension",
32
+ }
33
+ _MAKEFILE_FILENAMES = {"GNUmakefile", "Makefile", "makefile"}
34
+ # cu-bridge 的入口与必配环境变量,均取自其用户手册;命中任一即说明构建层已接入
35
+ # MXMACA 兼容层。MACA_CLANG_PATH 单独排除——它由 MACA SDK 设置,原生 MACA 项目
36
+ # 也会出现,不能作为 cu-bridge 的证据。
37
+ _CU_BRIDGE_MARKERS = re.compile(
38
+ r"\b(?:make_maca|cmake_maca|cucc)\b|\bCUCC_(?:PATH|CMAKE_ENTRY)\b|cu-bridge",
39
+ re.IGNORECASE,
40
+ )
41
+ _BUILD_SYSTEM_PACKAGES = {
42
+ "flit-core": "flit",
43
+ "hatchling": "hatch",
44
+ "meson-python": "meson",
45
+ "poetry-core": "poetry",
46
+ "scikit-build-core": "scikit-build",
47
+ "setuptools": "setuptools",
48
+ }
49
+ _SETUP_REQUIRE_KEYWORDS = ("install_requires", "setup_requires", "tests_require")
50
+
51
+
52
+ @dataclass(frozen=True, slots=True, order=True)
53
+ class SourceLocation:
54
+ relative_path: str
55
+ line: int
56
+ evidence: str
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class ProjectFacts:
61
+ dependencies: frozenset[str]
62
+ build_systems: frozenset[str]
63
+ flags: dict[str, bool]
64
+ locations: dict[str, tuple[SourceLocation, ...]]
65
+
66
+
67
+ class _FactCollector:
68
+ def __init__(self) -> None:
69
+ self.dependencies: set[str] = set()
70
+ self.build_systems: set[str] = set()
71
+ self.flags = dict.fromkeys(sorted(_KNOWN_FLAGS), False)
72
+ self.locations: defaultdict[str, list[SourceLocation]] = defaultdict(list)
73
+ self._seen_locations: defaultdict[str, set[SourceLocation]] = defaultdict(set)
74
+
75
+ def add_dependency(
76
+ self,
77
+ specification: str,
78
+ location: SourceLocation,
79
+ ) -> None:
80
+ name = _parse_dependency_name(specification)
81
+ if name is None:
82
+ return
83
+ self.dependencies.add(name)
84
+ self._add_location(f"dependency:{name}", location)
85
+
86
+ def add_build_system(self, name: str, location: SourceLocation) -> None:
87
+ normalized = name.casefold()
88
+ self.build_systems.add(normalized)
89
+ self._add_location(f"build_system:{normalized}", location)
90
+
91
+ def set_flag(self, name: str, location: SourceLocation) -> None:
92
+ self.flags[name] = True
93
+ self._add_location(f"fact:{name}", location)
94
+
95
+ def _add_location(self, key: str, location: SourceLocation) -> None:
96
+ seen = self._seen_locations[key]
97
+ if location in seen:
98
+ return
99
+ seen.add(location)
100
+ self.locations[key].append(location)
101
+
102
+ def finish(self) -> ProjectFacts:
103
+ locations = {key: tuple(sorted(value)) for key, value in sorted(self.locations.items())}
104
+ return ProjectFacts(
105
+ dependencies=frozenset(self.dependencies),
106
+ build_systems=frozenset(self.build_systems),
107
+ flags={key: self.flags[key] for key in sorted(self.flags)},
108
+ locations=locations,
109
+ )
110
+
111
+
112
+ def extract_project_facts(index: FileIndex) -> ProjectFacts:
113
+ """Extract conservative facts without importing or executing repository code."""
114
+ collector = _FactCollector()
115
+
116
+ for indexed_file in index.files:
117
+ name = indexed_file.relative_path.rsplit("/", 1)[-1]
118
+ lower_name = name.casefold()
119
+
120
+ if lower_name == "pyproject.toml":
121
+ _extract_pyproject(indexed_file, collector)
122
+ if lower_name == "setup.cfg":
123
+ _extract_setup_cfg(indexed_file, collector)
124
+ if lower_name.startswith("requirements") and lower_name.endswith(".txt"):
125
+ _extract_requirements(indexed_file, collector)
126
+ if lower_name.endswith(".py"):
127
+ _extract_python(indexed_file, collector)
128
+ if name == "CMakeLists.txt" or lower_name.endswith(".cmake"):
129
+ _extract_cmake(indexed_file, collector)
130
+ if lower_name.endswith(".sh") or _is_makefile(name, lower_name):
131
+ # Makefile 与 shell 共享 `#` 注释与"一行一条命令"的形态,
132
+ # 同一套逐行提取规则可以直接复用。
133
+ _extract_shell(indexed_file, collector)
134
+ if _may_reference_toolchain(name, lower_name):
135
+ _detect_cu_bridge(indexed_file, collector)
136
+
137
+ return collector.finish()
138
+
139
+
140
+ def _is_makefile(name: str, lower_name: str) -> bool:
141
+ return name in _MAKEFILE_FILENAMES or lower_name.endswith(".mk")
142
+
143
+
144
+ def _may_reference_toolchain(name: str, lower_name: str) -> bool:
145
+ """只在构建与部署相关文件里找 cu-bridge 痕迹,避免在 .cu 注释里误命中。"""
146
+ return (
147
+ _is_makefile(name, lower_name)
148
+ or lower_name.endswith((".sh", ".py", ".cmake"))
149
+ or name in {"CMakeLists.txt", "Dockerfile"}
150
+ or lower_name.endswith(".dockerfile")
151
+ )
152
+
153
+
154
+ def _detect_cu_bridge(indexed_file: IndexedFile, collector: _FactCollector) -> None:
155
+ if is_demoted(indexed_file.relative_path) or indexed_file.relative_path.casefold().endswith(
156
+ ".py"
157
+ ):
158
+ # Python integration is recognized from command calls by the AST visitor below.
159
+ return
160
+ lines = indexed_file.text.splitlines()
161
+ for line_number, line in enumerate(lines, start=1):
162
+ if _CU_BRIDGE_MARKERS.search(_strip_shell_comment(line)):
163
+ collector.set_flag(
164
+ "uses_cu_bridge_toolchain",
165
+ _location(lines, indexed_file.relative_path, line_number),
166
+ )
167
+ return
168
+
169
+
170
+ def _extract_setup_cfg(indexed_file: IndexedFile, collector: _FactCollector) -> None:
171
+ parser = configparser.ConfigParser(interpolation=None, inline_comment_prefixes=("#",))
172
+ try:
173
+ parser.read_string(indexed_file.text)
174
+ except configparser.Error:
175
+ return
176
+
177
+ searcher = _LineSearch(indexed_file)
178
+ values = [parser.get("options", name, fallback="") for name in _SETUP_REQUIRE_KEYWORDS]
179
+ if parser.has_section("options.extras_require"):
180
+ values.extend(value for _, value in parser.items("options.extras_require"))
181
+ for value in values:
182
+ if value.strip().startswith(("file:", "attr:")):
183
+ continue
184
+ # setuptools splits multiline lists by lines, single-line lists by semicolons.
185
+ specifications = value.splitlines() if "\n" in value else value.split(";")
186
+ for specification in specifications:
187
+ specification = specification.strip()
188
+ if specification and not specification.startswith("#"):
189
+ collector.add_dependency(specification, searcher.locate(specification))
190
+
191
+
192
+ def _extract_pyproject(indexed_file: IndexedFile, collector: _FactCollector) -> None:
193
+ try:
194
+ document = tomllib.loads(indexed_file.text)
195
+ except (tomllib.TOMLDecodeError, TypeError):
196
+ return
197
+
198
+ searcher = _LineSearch(indexed_file)
199
+
200
+ def locate(value: str) -> SourceLocation:
201
+ return searcher.locate(value)
202
+
203
+ project = document.get("project")
204
+ if isinstance(project, dict):
205
+ dependencies = project.get("dependencies")
206
+ if isinstance(dependencies, list):
207
+ for dependency in dependencies:
208
+ if isinstance(dependency, str):
209
+ collector.add_dependency(dependency, locate(dependency))
210
+ optional = project.get("optional-dependencies")
211
+ if isinstance(optional, dict):
212
+ for group in optional.values():
213
+ if isinstance(group, list):
214
+ for dependency in group:
215
+ if isinstance(dependency, str):
216
+ collector.add_dependency(dependency, locate(dependency))
217
+
218
+ build_system = document.get("build-system")
219
+ if isinstance(build_system, dict):
220
+ requirements = build_system.get("requires")
221
+ if isinstance(requirements, list):
222
+ for requirement in requirements:
223
+ if not isinstance(requirement, str):
224
+ continue
225
+ package = _parse_dependency_name(requirement)
226
+ if package in _BUILD_SYSTEM_PACKAGES:
227
+ collector.add_build_system(
228
+ _BUILD_SYSTEM_PACKAGES[package],
229
+ locate(requirement),
230
+ )
231
+
232
+ backend = build_system.get("build-backend")
233
+ if isinstance(backend, str):
234
+ package = _canonicalize_name(backend.split(".", 1)[0])
235
+ if package in _BUILD_SYSTEM_PACKAGES:
236
+ collector.add_build_system(
237
+ _BUILD_SYSTEM_PACKAGES[package],
238
+ locate(backend),
239
+ )
240
+
241
+ tool = document.get("tool")
242
+ if isinstance(tool, dict):
243
+ poetry = tool.get("poetry")
244
+ if isinstance(poetry, dict):
245
+ dependencies = poetry.get("dependencies")
246
+ if isinstance(dependencies, dict):
247
+ for name in dependencies:
248
+ if isinstance(name, str) and name.casefold() != "python":
249
+ collector.add_dependency(
250
+ name,
251
+ locate(name),
252
+ )
253
+
254
+
255
+ def _extract_requirements(
256
+ indexed_file: IndexedFile,
257
+ collector: _FactCollector,
258
+ ) -> None:
259
+ lines = indexed_file.text.splitlines()
260
+ for line_number, line in enumerate(lines, start=1):
261
+ stripped = line.strip()
262
+ if not stripped or stripped.startswith(("#", "-")):
263
+ continue
264
+ if "://" in stripped and " @ " not in stripped:
265
+ continue
266
+ specification = stripped.split(" #", 1)[0].strip()
267
+ collector.add_dependency(
268
+ specification,
269
+ _location(lines, indexed_file.relative_path, line_number),
270
+ )
271
+
272
+
273
+ def _extract_python(indexed_file: IndexedFile, collector: _FactCollector) -> None:
274
+ try:
275
+ with warnings.catch_warnings():
276
+ warnings.simplefilter("ignore", SyntaxWarning)
277
+ warnings.simplefilter("ignore", DeprecationWarning)
278
+ tree = ast.parse(indexed_file.text, filename=indexed_file.relative_path)
279
+ except (SyntaxError, ValueError):
280
+ return
281
+
282
+ visitor = _PythonFactVisitor(indexed_file, collector)
283
+ visitor.visit(tree)
284
+
285
+
286
+ class _PythonFactVisitor(ast.NodeVisitor):
287
+ def __init__(
288
+ self,
289
+ indexed_file: IndexedFile,
290
+ collector: _FactCollector,
291
+ ) -> None:
292
+ self.indexed_file = indexed_file
293
+ self.lines = indexed_file.text.splitlines()
294
+ self.collector = collector
295
+ self.aliases: dict[str, str] = {}
296
+
297
+ def visit_Import(self, node: ast.Import) -> None:
298
+ for alias in node.names:
299
+ local_name = alias.asname or alias.name.split(".", 1)[0]
300
+ self.aliases[local_name] = alias.name if alias.asname else alias.name.split(".", 1)[0]
301
+ self._record_import(alias.name, node.lineno)
302
+ self.generic_visit(node)
303
+
304
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
305
+ module = node.module or ""
306
+ for alias in node.names:
307
+ full_name = f"{module}.{alias.name}" if module else alias.name
308
+ self.aliases[alias.asname or alias.name] = full_name
309
+ self._record_import(full_name, node.lineno)
310
+ self.generic_visit(node)
311
+
312
+ def visit_Assign(self, node: ast.Assign) -> None:
313
+ target_names = {target.id for target in node.targets if isinstance(target, ast.Name)}
314
+ if target_names & {"CUDA_HOME", "CUDA_PATH", "CUDA_ROOT"}:
315
+ self.collector.set_flag(
316
+ "references_cuda_home",
317
+ self._location(node.lineno),
318
+ )
319
+ if _node_contains_string(node.value, "/usr/local/cuda"):
320
+ self.collector.set_flag(
321
+ "uses_hardcoded_cuda_path",
322
+ self._location(node.lineno),
323
+ )
324
+ self.generic_visit(node)
325
+
326
+ def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
327
+ if isinstance(node.target, ast.Name) and node.target.id in {
328
+ "CUDA_HOME",
329
+ "CUDA_PATH",
330
+ "CUDA_ROOT",
331
+ }:
332
+ self.collector.set_flag(
333
+ "references_cuda_home",
334
+ self._location(node.lineno),
335
+ )
336
+ if node.value is not None and _node_contains_string(
337
+ node.value,
338
+ "/usr/local/cuda",
339
+ ):
340
+ self.collector.set_flag(
341
+ "uses_hardcoded_cuda_path",
342
+ self._location(node.lineno),
343
+ )
344
+ self.generic_visit(node)
345
+
346
+ def visit_Call(self, node: ast.Call) -> None:
347
+ function_name = self._resolve_name(node.func)
348
+ location = self._location(node.lineno)
349
+ if (
350
+ not is_demoted(self.indexed_file.relative_path)
351
+ and function_name
352
+ in {
353
+ "subprocess.run",
354
+ "subprocess.Popen",
355
+ "subprocess.call",
356
+ "subprocess.check_call",
357
+ "subprocess.check_output",
358
+ "os.system",
359
+ }
360
+ and node.args
361
+ and _is_cu_bridge_command(node.args[0])
362
+ ):
363
+ self.collector.set_flag("uses_cu_bridge_toolchain", location)
364
+ if function_name == "torch.utils.cpp_extension.CUDAExtension":
365
+ self.collector.set_flag("uses_torch_cuda_extension", location)
366
+ elif function_name == "torch.utils.cpp_extension.CppExtension":
367
+ self.collector.set_flag("uses_torch_cpp_extension", location)
368
+ elif function_name == "torch.utils.cpp_extension.load":
369
+ self.collector.set_flag("uses_torch_cpp_extension_load", location)
370
+
371
+ if function_name in {"setuptools.setup", "distutils.core.setup"}:
372
+ self.collector.add_build_system(
373
+ "setuptools" if function_name.startswith("setuptools") else "distutils",
374
+ location,
375
+ )
376
+ self._extract_setup_dependencies(node)
377
+ self.generic_visit(node)
378
+
379
+ def _extract_setup_dependencies(self, node: ast.Call) -> None:
380
+ """setup.py 是 CUDA 扩展项目最主流的依赖声明面(PyMigBench 语料证实),
381
+ 保守提取字面量字符串列表,不求值任何表达式。"""
382
+ for keyword in node.keywords:
383
+ if keyword.arg in _SETUP_REQUIRE_KEYWORDS:
384
+ for value, line in _string_list_elements(keyword.value):
385
+ self.collector.add_dependency(
386
+ value,
387
+ self._location(line),
388
+ )
389
+ elif keyword.arg == "extras_require":
390
+ self._extract_extras_require(keyword.value)
391
+
392
+ def _extract_extras_require(self, value: ast.expr) -> None:
393
+ if not isinstance(value, ast.Dict):
394
+ return
395
+ for key, group in zip(value.keys, value.values, strict=False):
396
+ if not isinstance(key, ast.Constant) or not isinstance(key.value, str):
397
+ continue
398
+ for spec, line in _string_list_elements(group):
399
+ self.collector.add_dependency(
400
+ spec,
401
+ self._location(line),
402
+ )
403
+
404
+ def _record_import(self, full_name: str, line_number: int) -> None:
405
+ location = self._location(line_number)
406
+ if full_name == "setuptools" or full_name.startswith("setuptools."):
407
+ self.collector.add_build_system("setuptools", location)
408
+ if full_name == "tensorrt" or full_name.startswith("tensorrt."):
409
+ self.collector.set_flag("imports_tensorrt", location)
410
+ if full_name == "apex" or full_name.startswith("apex."):
411
+ self.collector.set_flag("imports_apex", location)
412
+
413
+ def _resolve_name(self, node: ast.expr) -> str:
414
+ dotted = _dotted_name(node)
415
+ if not dotted:
416
+ return ""
417
+ head, separator, tail = dotted.partition(".")
418
+ resolved_head = self.aliases.get(head, head)
419
+ return f"{resolved_head}{separator}{tail}"
420
+
421
+ def _location(self, line_number: int) -> SourceLocation:
422
+ return _location(self.lines, self.indexed_file.relative_path, line_number)
423
+
424
+
425
+ def _string_list_elements(node: ast.expr) -> Iterator[tuple[str, int]]:
426
+ # setup.py 里 `install_requires=("torch",)` 这种元组写法同样常见,只认 List
427
+ # 会整条漏采。仍不求值任何表达式,只取字面量。
428
+ if isinstance(node, (ast.List, ast.Tuple)):
429
+ for element in node.elts:
430
+ if isinstance(element, ast.Constant) and isinstance(element.value, str):
431
+ yield element.value, element.lineno
432
+
433
+
434
+ def _is_cu_bridge_command(node: ast.expr) -> bool:
435
+ if isinstance(node, (ast.List, ast.Tuple)):
436
+ if not node.elts or not isinstance(node.elts[0], ast.Constant):
437
+ return False
438
+ executable = node.elts[0].value
439
+ elif isinstance(node, ast.Constant) and isinstance(node.value, str):
440
+ try:
441
+ arguments = shlex.split(node.value)
442
+ except ValueError:
443
+ return False
444
+ executable = arguments[0] if arguments else ""
445
+ else:
446
+ return False
447
+ return isinstance(executable, str) and executable.rsplit("/", 1)[-1] in {
448
+ "make_maca",
449
+ "cmake_maca",
450
+ "cucc",
451
+ }
452
+
453
+
454
+ def _extract_cmake(indexed_file: IndexedFile, collector: _FactCollector) -> None:
455
+ cmake_source = _strip_cmake_comments(indexed_file.text)
456
+ original_lines = indexed_file.text.splitlines()
457
+ collector.add_build_system("cmake", _location(original_lines, indexed_file.relative_path, 1))
458
+ patterns = {
459
+ "uses_cmake_cuda_language": re.compile(
460
+ r"\b(?:project|enable_language)\s*\([^)]*\bCUDA\b",
461
+ re.IGNORECASE | re.DOTALL,
462
+ ),
463
+ "uses_cmake_cuda_package": re.compile(
464
+ r"\bfind_package\s*\(\s*(?:CUDA|CUDAToolkit)\b",
465
+ re.IGNORECASE,
466
+ ),
467
+ "uses_cmake_torch_package": re.compile(
468
+ r"\bfind_package\s*\(\s*Torch\b",
469
+ re.IGNORECASE,
470
+ ),
471
+ }
472
+ for flag, pattern in patterns.items():
473
+ match = pattern.search(cmake_source)
474
+ if match:
475
+ line_number = cmake_source.count("\n", 0, match.start()) + 1
476
+ collector.set_flag(
477
+ flag,
478
+ _location(original_lines, indexed_file.relative_path, line_number),
479
+ )
480
+
481
+ # 在剥离注释后的文本上检测:`# set(CUDA_HOME ...)` 这类被注掉的写法不算引用。
482
+ # _strip_cmake_comments 保持字符偏移与换行不变,行号可直接对回原文取 evidence。
483
+ for line_number, stripped_line in enumerate(cmake_source.splitlines(), start=1):
484
+ if re.search(r"\b(?:CUDA_HOME|CUDA_PATH|CUDA_ROOT)\b", stripped_line):
485
+ collector.set_flag(
486
+ "references_cuda_home",
487
+ _location(original_lines, indexed_file.relative_path, line_number),
488
+ )
489
+
490
+
491
+ def _strip_cmake_comments(value: str) -> str:
492
+ lines = []
493
+ for line in value.splitlines(keepends=True):
494
+ content, marker, comment = line.partition("#")
495
+ if marker:
496
+ lines.append(content + " " * (len(marker) + len(comment.rstrip("\r\n"))))
497
+ if line.endswith("\r\n"):
498
+ lines.append("\r\n")
499
+ elif line.endswith("\n"):
500
+ lines.append("\n")
501
+ else:
502
+ lines.append(line)
503
+ return "".join(lines)
504
+
505
+
506
+ def _extract_shell(indexed_file: IndexedFile, collector: _FactCollector) -> None:
507
+ lines = indexed_file.text.splitlines()
508
+ for line_number, line in enumerate(lines, start=1):
509
+ location = _location(lines, indexed_file.relative_path, line_number)
510
+ code = _strip_shell_comment(line)
511
+ if re.search(r"\bnvcc\b", code, re.IGNORECASE):
512
+ collector.set_flag("invokes_nvcc_directly", location)
513
+ if re.search(r"\bnvidia-smi\b", code, re.IGNORECASE):
514
+ collector.set_flag("invokes_nvidia_smi", location)
515
+ if re.search(r"\b(?:CUDA_HOME|CUDA_PATH|CUDA_ROOT)\b", code):
516
+ collector.set_flag("references_cuda_home", location)
517
+ if "/usr/local/cuda" in code:
518
+ collector.set_flag("uses_hardcoded_cuda_path", location)
519
+
520
+
521
+ def _strip_shell_comment(value: str) -> str:
522
+ content, marker, _comment = value.partition("#")
523
+ return content if marker else value
524
+
525
+
526
+ def _parse_dependency_name(specification: str) -> str | None:
527
+ match = _DEPENDENCY_NAME.match(specification)
528
+ if match is None:
529
+ return None
530
+ return _canonicalize_name(match.group(1))
531
+
532
+
533
+ def _canonicalize_name(value: str) -> str:
534
+ return re.sub(r"[-_.]+", "-", value).casefold()
535
+
536
+
537
+ def _dotted_name(node: ast.expr) -> str:
538
+ if isinstance(node, ast.Name):
539
+ return node.id
540
+ if isinstance(node, ast.Attribute):
541
+ parent = _dotted_name(node.value)
542
+ return f"{parent}.{node.attr}" if parent else node.attr
543
+ return ""
544
+
545
+
546
+ def _node_contains_string(node: ast.AST, value: str) -> bool:
547
+ return any(
548
+ isinstance(child, ast.Constant) and isinstance(child.value, str) and value in child.value
549
+ for child in ast.walk(node)
550
+ )
551
+
552
+
553
+ class _LineSearch:
554
+ """把"折叠后子串"定位回行号,构建一次、查询多次。
555
+
556
+ 原实现为每个依赖逐行做 `in` 判断,是 O(依赖数 × 行数) 的 Python 级循环:
557
+ 500 条依赖的 pyproject 上单文件耗十几毫秒。改为整篇折叠一次后用 C 层
558
+ `str.find` 定位,再用行首偏移查表,查询降为 O(字符数) + O(log 行数)。
559
+
560
+ 行首偏移取自 `splitlines(keepends=True)`,与 `str.splitlines()` 的行口径
561
+ 严格一致——不像 `\\n` 计数那样在 `\\f`、`\\u2028` 等异体分隔符上错行。
562
+ 折叠是逐码位映射,整篇折叠与逐行折叠等价,因此结果与旧实现逐行扫描相同。
563
+ """
564
+
565
+ __slots__ = ("_folded", "_lines", "_relative_path", "_starts")
566
+
567
+ def __init__(self, indexed_file: IndexedFile) -> None:
568
+ text = indexed_file.text
569
+ self._relative_path = indexed_file.relative_path
570
+ self._lines = text.splitlines()
571
+ self._folded = text.casefold()
572
+ starts: list[int] = []
573
+ offset = 0
574
+ for line in text.splitlines(keepends=True):
575
+ starts.append(offset)
576
+ offset += len(line)
577
+ self._starts = starts
578
+
579
+ def locate(self, value: str) -> SourceLocation:
580
+ position = self._folded.find(value.casefold())
581
+ if position < 0:
582
+ return _location(self._lines, self._relative_path, 1)
583
+ return _location(
584
+ self._lines,
585
+ self._relative_path,
586
+ bisect_right(self._starts, position),
587
+ )
588
+
589
+
590
+ def _location(lines: list[str], relative_path: str, line_number: int) -> SourceLocation:
591
+ evidence = lines[line_number - 1].strip() if 0 < line_number <= len(lines) else ""
592
+ return SourceLocation(
593
+ relative_path=relative_path,
594
+ line=max(1, line_number),
595
+ evidence=evidence[:240],
596
+ )