pyossmtool 0.0.1__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.
- pyossmtool/__init__.py +3 -0
- pyossmtool/bundle/catalog/checks/bandit.scan.yaml +13 -0
- pyossmtool/bundle/catalog/checks/codespell.spelling.yaml +12 -0
- pyossmtool/bundle/catalog/checks/deadcode.scan.yaml +13 -0
- pyossmtool/bundle/catalog/checks/gate.acronym-allowlist.yaml +14 -0
- pyossmtool/bundle/catalog/checks/gate.folder-breadth.yaml +14 -0
- pyossmtool/bundle/catalog/checks/gate.module-private-vars.yaml +14 -0
- pyossmtool/bundle/catalog/checks/gate.module-size.yaml +14 -0
- pyossmtool/bundle/catalog/checks/gitleaks.secrets.yaml +21 -0
- pyossmtool/bundle/catalog/checks/hadolint.dockerfile.yaml +16 -0
- pyossmtool/bundle/catalog/checks/jscpd.duplication.yaml +19 -0
- pyossmtool/bundle/catalog/checks/markdownlint.check.yaml +17 -0
- pyossmtool/bundle/catalog/checks/mdformat.apply.yaml +10 -0
- pyossmtool/bundle/catalog/checks/mdformat.format.yaml +14 -0
- pyossmtool/bundle/catalog/checks/mutmut.run.yaml +13 -0
- pyossmtool/bundle/catalog/checks/pydeps.cycles.yaml +14 -0
- pyossmtool/bundle/catalog/checks/pytest.coverage.yaml +19 -0
- pyossmtool/bundle/catalog/checks/pytest.test.yaml +14 -0
- pyossmtool/bundle/catalog/checks/radon.cc.yaml +13 -0
- pyossmtool/bundle/catalog/checks/radon.mi.yaml +13 -0
- pyossmtool/bundle/catalog/checks/ruff.format.apply.yaml +11 -0
- pyossmtool/bundle/catalog/checks/ruff.format.yaml +14 -0
- pyossmtool/bundle/catalog/checks/ruff.lint.yaml +15 -0
- pyossmtool/bundle/catalog/checks/ruff.unused.yaml +17 -0
- pyossmtool/bundle/catalog/checks/semgrep.scan.yaml +18 -0
- pyossmtool/bundle/catalog/checks/shellcheck.yaml +13 -0
- pyossmtool/bundle/catalog/checks/shfmt.apply.yaml +11 -0
- pyossmtool/bundle/catalog/checks/shfmt.format.yaml +14 -0
- pyossmtool/bundle/catalog/checks/sourcery.review.yaml +14 -0
- pyossmtool/bundle/catalog/checks/ty.check.yaml +15 -0
- pyossmtool/bundle/catalog/checks/vulture.scan.yaml +16 -0
- pyossmtool/bundle/catalog/checks/yamlfmt.apply.yaml +10 -0
- pyossmtool/bundle/catalog/checks/yamlfmt.format.yaml +14 -0
- pyossmtool/bundle/catalog/checks/yamllint.check.yaml +16 -0
- pyossmtool/bundle/catalog/tools/bandit.yaml +20 -0
- pyossmtool/bundle/catalog/tools/codespell.yaml +17 -0
- pyossmtool/bundle/catalog/tools/deadcode.yaml +13 -0
- pyossmtool/bundle/catalog/tools/gitleaks.yaml +9 -0
- pyossmtool/bundle/catalog/tools/hadolint.yaml +11 -0
- pyossmtool/bundle/catalog/tools/jscpd.yaml +15 -0
- pyossmtool/bundle/catalog/tools/markdownlint.yaml +18 -0
- pyossmtool/bundle/catalog/tools/mdformat.yaml +13 -0
- pyossmtool/bundle/catalog/tools/mutmut.yaml +10 -0
- pyossmtool/bundle/catalog/tools/pydeps.yaml +10 -0
- pyossmtool/bundle/catalog/tools/pytest.yaml +12 -0
- pyossmtool/bundle/catalog/tools/radon.yaml +10 -0
- pyossmtool/bundle/catalog/tools/ruff.yaml +26 -0
- pyossmtool/bundle/catalog/tools/script.yaml +8 -0
- pyossmtool/bundle/catalog/tools/semgrep.yaml +21 -0
- pyossmtool/bundle/catalog/tools/shellcheck.yaml +10 -0
- pyossmtool/bundle/catalog/tools/shfmt.yaml +13 -0
- pyossmtool/bundle/catalog/tools/sourcery.yaml +10 -0
- pyossmtool/bundle/catalog/tools/ty.yaml +20 -0
- pyossmtool/bundle/catalog/tools/vulture.yaml +13 -0
- pyossmtool/bundle/catalog/tools/yamlfmt.yaml +24 -0
- pyossmtool/bundle/catalog/tools/yamllint.yaml +24 -0
- pyossmtool/bundle/defaults/allowlists/acronyms.yaml +10 -0
- pyossmtool/bundle/defaults/allowlists/folder-breadth.txt +1 -0
- pyossmtool/bundle/defaults/allowlists/module-private-vars.txt +1 -0
- pyossmtool/bundle/defaults/allowlists/module-size.txt +1 -0
- pyossmtool/bundle/defaults/configs/bandit.yaml +5 -0
- pyossmtool/bundle/defaults/configs/gates/acronym-allowlist.yaml +4 -0
- pyossmtool/bundle/defaults/configs/gates/folder-breadth.yaml +12 -0
- pyossmtool/bundle/defaults/configs/gates/module-private-vars.yaml +4 -0
- pyossmtool/bundle/defaults/configs/gates/module-size.yaml +6 -0
- pyossmtool/bundle/defaults/configs/markdownlint.json +8 -0
- pyossmtool/bundle/defaults/configs/ruff.toml +8 -0
- pyossmtool/bundle/defaults/configs/ty.toml +5 -0
- pyossmtool/bundle/defaults/configs/yamlfmt.yaml +4 -0
- pyossmtool/bundle/defaults/configs/yamllint.yaml +12 -0
- pyossmtool/bundle/defaults/gates/README.md +150 -0
- pyossmtool/bundle/defaults/gates/acronym-allowlist.sh +15 -0
- pyossmtool/bundle/defaults/gates/folder-breadth.sh +22 -0
- pyossmtool/bundle/defaults/gates/lib.sh +116 -0
- pyossmtool/bundle/defaults/gates/module-private-vars.sh +77 -0
- pyossmtool/bundle/defaults/gates/module-size.sh +60 -0
- pyossmtool/bundle/defaults/semgrep/python-quality.yml +19 -0
- pyossmtool/bundle/suites/demo.yaml +11 -0
- pyossmtool/bundle/suites/extended.yaml +34 -0
- pyossmtool/bundle/suites/format.yaml +10 -0
- pyossmtool/bundle/suites/formatting.yaml +10 -0
- pyossmtool/bundle/suites/policy.yaml +11 -0
- pyossmtool/bundle/suites/python-quality.yaml +9 -0
- pyossmtool/bundle/suites/reslab-parity.yaml +41 -0
- pyossmtool/bundle/suites/standard.yaml +45 -0
- pyossmtool/cli.py +233 -0
- pyossmtool/config_resolver.py +117 -0
- pyossmtool/discovery.py +50 -0
- pyossmtool/gate_config.py +119 -0
- pyossmtool/gates.py +102 -0
- pyossmtool/ignore.py +270 -0
- pyossmtool/ignore_materialize.py +184 -0
- pyossmtool/installer.py +119 -0
- pyossmtool/models.py +279 -0
- pyossmtool/parsers/__init__.py +76 -0
- pyossmtool/parsers/analysis.py +329 -0
- pyossmtool/parsers/base.py +29 -0
- pyossmtool/parsers/common.py +57 -0
- pyossmtool/parsers/format_text.py +67 -0
- pyossmtool/parsers/gates.py +92 -0
- pyossmtool/parsers/patterns.py +78 -0
- pyossmtool/parsers/prose.py +193 -0
- pyossmtool/parsers/ruff.py +103 -0
- pyossmtool/parsers/shell.py +83 -0
- pyossmtool/policy/__init__.py +0 -0
- pyossmtool/policy/acronym_allowlist.py +322 -0
- pyossmtool/policy/folder_breadth.py +306 -0
- pyossmtool/registry.py +72 -0
- pyossmtool/reporter.py +87 -0
- pyossmtool/resolver.py +146 -0
- pyossmtool/runner.py +482 -0
- pyossmtool/runner_script.py +131 -0
- pyossmtool/target_expand.py +112 -0
- pyossmtool-0.0.1.dist-info/METADATA +94 -0
- pyossmtool-0.0.1.dist-info/RECORD +119 -0
- pyossmtool-0.0.1.dist-info/WHEEL +5 -0
- pyossmtool-0.0.1.dist-info/entry_points.txt +2 -0
- pyossmtool-0.0.1.dist-info/licenses/LICENSE +21 -0
- pyossmtool-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Folder breadth policy gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pyossmtool.ignore import EffectiveIgnores, ignores_from_env
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class DirBreadthViolation:
|
|
17
|
+
path: str
|
|
18
|
+
count: int
|
|
19
|
+
max_allowed: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class FolderBreadthReport:
|
|
24
|
+
max_allowed: int
|
|
25
|
+
scan_roots: tuple[str, ...]
|
|
26
|
+
extensions: tuple[str, ...]
|
|
27
|
+
leaf_dirs_scanned: int
|
|
28
|
+
leaf_dirs_over_max: int
|
|
29
|
+
worst_leaf_dir: str
|
|
30
|
+
worst_leaf_count: int
|
|
31
|
+
violations: tuple[DirBreadthViolation, ...]
|
|
32
|
+
|
|
33
|
+
def report_metadata(self) -> dict[str, int | str]:
|
|
34
|
+
return {
|
|
35
|
+
"leaf_dirs_scanned": self.leaf_dirs_scanned,
|
|
36
|
+
"worst_leaf_dir": self.worst_leaf_dir,
|
|
37
|
+
"worst_leaf_count": self.worst_leaf_count,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
SKIP_DIR_NAMES = frozenset({"__pycache__"})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_allowlist(path: Path) -> set[str]:
|
|
45
|
+
if not path.is_file():
|
|
46
|
+
return set()
|
|
47
|
+
entries: set[str] = set()
|
|
48
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
49
|
+
stripped = line.split("#", 1)[0].strip()
|
|
50
|
+
if stripped:
|
|
51
|
+
entries.add(stripped.rstrip("/"))
|
|
52
|
+
return entries
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def settings_from_config(
|
|
56
|
+
config: dict[str, Any],
|
|
57
|
+
) -> tuple[int, tuple[str, ...], tuple[str, ...], bool]:
|
|
58
|
+
max_allowed = int(config.get("max_allowed", 12))
|
|
59
|
+
scan_roots = tuple(str(item) for item in config.get("scan_roots", ["src/"]))
|
|
60
|
+
extensions_raw = config.get("extensions", [".py", ".md", ".yaml", ".sh"])
|
|
61
|
+
extensions: list[str] = []
|
|
62
|
+
for part in extensions_raw:
|
|
63
|
+
stripped = str(part).strip()
|
|
64
|
+
if not stripped:
|
|
65
|
+
continue
|
|
66
|
+
extensions.append(stripped if stripped.startswith(".") else f".{stripped}")
|
|
67
|
+
strict = bool(config.get("strict", True))
|
|
68
|
+
return max_allowed, scan_roots, tuple(extensions), strict
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def iter_scan_directories(base: Path) -> list[Path]:
|
|
72
|
+
if not base.is_dir():
|
|
73
|
+
return []
|
|
74
|
+
directories = [base]
|
|
75
|
+
for path in sorted(base.rglob("*")):
|
|
76
|
+
if path.is_dir() and path.name not in SKIP_DIR_NAMES:
|
|
77
|
+
directories.append(path)
|
|
78
|
+
return directories
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def count_direct_files(directory: Path, extensions: tuple[str, ...]) -> int:
|
|
82
|
+
count = 0
|
|
83
|
+
for child in directory.iterdir():
|
|
84
|
+
if not child.is_file():
|
|
85
|
+
continue
|
|
86
|
+
if child.name == "__init__.py":
|
|
87
|
+
continue
|
|
88
|
+
if child.suffix in extensions:
|
|
89
|
+
count += 1
|
|
90
|
+
return count
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def is_allowlisted(rel_posix: str, allowlist: set[str]) -> bool:
|
|
94
|
+
normalized = rel_posix.rstrip("/")
|
|
95
|
+
if normalized in allowlist:
|
|
96
|
+
return True
|
|
97
|
+
return any(normalized.startswith(f"{entry}/") for entry in allowlist)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def scan_folder_breadth(
|
|
101
|
+
root: Path,
|
|
102
|
+
*,
|
|
103
|
+
max_allowed: int,
|
|
104
|
+
scan_roots: tuple[str, ...],
|
|
105
|
+
extensions: tuple[str, ...],
|
|
106
|
+
allowlist: set[str],
|
|
107
|
+
ignores: EffectiveIgnores | None = None,
|
|
108
|
+
) -> FolderBreadthReport:
|
|
109
|
+
violations: list[DirBreadthViolation] = []
|
|
110
|
+
dirs_scanned = 0
|
|
111
|
+
worst_path = ""
|
|
112
|
+
worst_count = 0
|
|
113
|
+
|
|
114
|
+
for scan_root in scan_roots:
|
|
115
|
+
scanned, worst_path, worst_count, root_violations = _scan_root_breadth(
|
|
116
|
+
root,
|
|
117
|
+
scan_root,
|
|
118
|
+
max_allowed=max_allowed,
|
|
119
|
+
extensions=extensions,
|
|
120
|
+
allowlist=allowlist,
|
|
121
|
+
ignores=ignores,
|
|
122
|
+
worst_path=worst_path,
|
|
123
|
+
worst_count=worst_count,
|
|
124
|
+
)
|
|
125
|
+
dirs_scanned += scanned
|
|
126
|
+
violations.extend(root_violations)
|
|
127
|
+
|
|
128
|
+
violations.sort(key=lambda item: (-item.count, item.path))
|
|
129
|
+
return FolderBreadthReport(
|
|
130
|
+
max_allowed=max_allowed,
|
|
131
|
+
scan_roots=scan_roots,
|
|
132
|
+
extensions=extensions,
|
|
133
|
+
leaf_dirs_scanned=dirs_scanned,
|
|
134
|
+
leaf_dirs_over_max=len(violations),
|
|
135
|
+
worst_leaf_dir=worst_path,
|
|
136
|
+
worst_leaf_count=worst_count,
|
|
137
|
+
violations=tuple(violations),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _scan_root_breadth(
|
|
142
|
+
root: Path,
|
|
143
|
+
scan_root: str,
|
|
144
|
+
*,
|
|
145
|
+
max_allowed: int,
|
|
146
|
+
extensions: tuple[str, ...],
|
|
147
|
+
allowlist: set[str],
|
|
148
|
+
ignores: EffectiveIgnores | None,
|
|
149
|
+
worst_path: str,
|
|
150
|
+
worst_count: int,
|
|
151
|
+
) -> tuple[int, str, int, list[DirBreadthViolation]]:
|
|
152
|
+
violations: list[DirBreadthViolation] = []
|
|
153
|
+
dirs_scanned = 0
|
|
154
|
+
base = root / scan_root
|
|
155
|
+
for directory in iter_scan_directories(base):
|
|
156
|
+
outcome = _directory_breadth_outcome(
|
|
157
|
+
directory,
|
|
158
|
+
root,
|
|
159
|
+
max_allowed=max_allowed,
|
|
160
|
+
extensions=extensions,
|
|
161
|
+
allowlist=allowlist,
|
|
162
|
+
ignores=ignores,
|
|
163
|
+
)
|
|
164
|
+
if outcome is None:
|
|
165
|
+
continue
|
|
166
|
+
violation, file_count, rel = outcome
|
|
167
|
+
dirs_scanned += 1
|
|
168
|
+
if file_count > worst_count:
|
|
169
|
+
worst_count = file_count
|
|
170
|
+
worst_path = rel
|
|
171
|
+
if violation is not None:
|
|
172
|
+
violations.append(violation)
|
|
173
|
+
return dirs_scanned, worst_path, worst_count, violations
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _directory_breadth_outcome(
|
|
177
|
+
directory: Path,
|
|
178
|
+
root: Path,
|
|
179
|
+
*,
|
|
180
|
+
max_allowed: int,
|
|
181
|
+
extensions: tuple[str, ...],
|
|
182
|
+
allowlist: set[str],
|
|
183
|
+
ignores: EffectiveIgnores | None,
|
|
184
|
+
) -> tuple[DirBreadthViolation | None, int, str] | None:
|
|
185
|
+
rel = directory.relative_to(root).as_posix()
|
|
186
|
+
if _should_skip_directory(rel, allowlist, ignores):
|
|
187
|
+
return None
|
|
188
|
+
file_count = count_direct_files(directory, extensions)
|
|
189
|
+
if file_count == 0:
|
|
190
|
+
return None
|
|
191
|
+
violation = _breadth_violation(rel, file_count, max_allowed)
|
|
192
|
+
return violation, file_count, rel
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _should_skip_directory(rel: str, allowlist: set[str], ignores: EffectiveIgnores | None) -> bool:
|
|
196
|
+
if is_allowlisted(rel, allowlist):
|
|
197
|
+
return True
|
|
198
|
+
return bool(ignores and ignores.is_ignored(rel))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _breadth_violation(rel: str, file_count: int, max_allowed: int) -> DirBreadthViolation | None:
|
|
202
|
+
if file_count <= max_allowed:
|
|
203
|
+
return None
|
|
204
|
+
return DirBreadthViolation(path=rel, count=file_count, max_allowed=max_allowed)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def findings_from_report(report: FolderBreadthReport) -> list[dict[str, Any]]:
|
|
208
|
+
findings: list[dict[str, Any]] = []
|
|
209
|
+
for violation in report.violations:
|
|
210
|
+
findings.append(
|
|
211
|
+
{
|
|
212
|
+
"rule_id": "folder-breadth",
|
|
213
|
+
"severity": "error",
|
|
214
|
+
"message": (f"{violation.path} has {violation.count} sibling files (max {violation.max_allowed})"),
|
|
215
|
+
"location": {"file": violation.path},
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
return findings
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def run_gate(
|
|
222
|
+
*,
|
|
223
|
+
root: Path,
|
|
224
|
+
config: dict[str, Any],
|
|
225
|
+
allowlist_path: Path | None,
|
|
226
|
+
report_path: Path | None,
|
|
227
|
+
strict: bool | None = None,
|
|
228
|
+
) -> int:
|
|
229
|
+
max_allowed, scan_roots, extensions, config_strict = settings_from_config(config)
|
|
230
|
+
enforcing = config_strict if strict is None else strict
|
|
231
|
+
report = scan_folder_breadth(
|
|
232
|
+
root,
|
|
233
|
+
max_allowed=max_allowed,
|
|
234
|
+
scan_roots=scan_roots,
|
|
235
|
+
extensions=extensions,
|
|
236
|
+
allowlist=load_allowlist(allowlist_path or Path()),
|
|
237
|
+
ignores=ignores_from_env(root),
|
|
238
|
+
)
|
|
239
|
+
_write_folder_breadth_report(report, report_path)
|
|
240
|
+
return _folder_breadth_exit(report, enforcing)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _write_folder_breadth_report(report: FolderBreadthReport, report_path: Path | None) -> None:
|
|
244
|
+
if not report_path:
|
|
245
|
+
return
|
|
246
|
+
report_path.parent.mkdir(parents=True, exist_ok=True)
|
|
247
|
+
payload = {"findings": findings_from_report(report), **report.report_metadata()}
|
|
248
|
+
report_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _folder_breadth_exit(report: FolderBreadthReport, enforcing: bool) -> int:
|
|
252
|
+
if not enforcing or report.leaf_dirs_over_max == 0:
|
|
253
|
+
return 0
|
|
254
|
+
for violation in report.violations:
|
|
255
|
+
print(
|
|
256
|
+
f"FAIL folder-breadth: {violation.path} has {violation.count} files (max {violation.max_allowed})",
|
|
257
|
+
file=sys.stderr,
|
|
258
|
+
)
|
|
259
|
+
return 1
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def main(argv: list[str] | None = None) -> int:
|
|
263
|
+
args = _parse_folder_breadth_args(argv)
|
|
264
|
+
root = args.root.resolve()
|
|
265
|
+
config = _load_gate_mapping(args.config)
|
|
266
|
+
return run_gate(
|
|
267
|
+
root=root,
|
|
268
|
+
config=config,
|
|
269
|
+
allowlist_path=_resolve_config_allowlist(root, config),
|
|
270
|
+
report_path=args.report,
|
|
271
|
+
strict=False if args.advisory else args.strict,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _parse_folder_breadth_args(argv: list[str] | None):
|
|
276
|
+
parser = argparse.ArgumentParser(description="Folder breadth gate.")
|
|
277
|
+
parser.add_argument("--root", type=Path, default=Path.cwd())
|
|
278
|
+
parser.add_argument("--config", type=Path, required=True)
|
|
279
|
+
parser.add_argument("--report", type=Path, default=None)
|
|
280
|
+
parser.add_argument("--strict", action="store_true", default=None)
|
|
281
|
+
parser.add_argument("--advisory", action="store_true", default=False)
|
|
282
|
+
return parser.parse_args(argv)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _load_gate_mapping(config_path: Path) -> dict[str, Any]:
|
|
286
|
+
import yaml
|
|
287
|
+
|
|
288
|
+
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
|
289
|
+
if not isinstance(config, dict):
|
|
290
|
+
msg = f"Gate config must be a mapping: {config_path}"
|
|
291
|
+
raise ValueError(msg)
|
|
292
|
+
return config
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _resolve_config_allowlist(root: Path, config: dict[str, Any]) -> Path | None:
|
|
296
|
+
allowlist_file = config.get("allowlist_file")
|
|
297
|
+
if not allowlist_file:
|
|
298
|
+
return None
|
|
299
|
+
allowlist_path = Path(str(allowlist_file))
|
|
300
|
+
if not allowlist_path.is_absolute():
|
|
301
|
+
allowlist_path = root / allowlist_path
|
|
302
|
+
return allowlist_path
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == "__main__":
|
|
306
|
+
raise SystemExit(main())
|
pyossmtool/registry.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Load and validate catalog definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from pyossmtool.models import CheckDef, ProjectConfig, SuiteDef, ToolDef
|
|
10
|
+
|
|
11
|
+
BUNDLE_ROOT = Path(__file__).resolve().parent / "bundle"
|
|
12
|
+
PROJECT_CATALOG = ".pyossmtool/catalog"
|
|
13
|
+
PROJECT_SUITES = ".pyossmtool/suites"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Registry:
|
|
17
|
+
def __init__(self, root: Path | None = None, project_root: Path | None = None) -> None:
|
|
18
|
+
self.root = root or BUNDLE_ROOT
|
|
19
|
+
self.project_root = (project_root or Path.cwd()).resolve()
|
|
20
|
+
self.catalog_root = self.root / "catalog"
|
|
21
|
+
self.suites_root = self.root / "suites"
|
|
22
|
+
self.tools: dict[str, ToolDef] = {}
|
|
23
|
+
self.checks: dict[str, CheckDef] = {}
|
|
24
|
+
self.suites: dict[str, SuiteDef] = {}
|
|
25
|
+
self._load()
|
|
26
|
+
|
|
27
|
+
def _load_yaml_dir(self, directory: Path) -> dict[str, dict]:
|
|
28
|
+
items: dict[str, dict] = {}
|
|
29
|
+
if not directory.exists():
|
|
30
|
+
return items
|
|
31
|
+
for path in sorted(directory.glob("*.yaml")):
|
|
32
|
+
with path.open(encoding="utf-8") as handle:
|
|
33
|
+
data = yaml.safe_load(handle) or {}
|
|
34
|
+
items[data["id"]] = data
|
|
35
|
+
return items
|
|
36
|
+
|
|
37
|
+
def _load(self) -> None:
|
|
38
|
+
self._load_catalog_tree(self.catalog_root, self.suites_root)
|
|
39
|
+
project_catalog = self.project_root / PROJECT_CATALOG
|
|
40
|
+
self._load_catalog_tree(project_catalog, self.project_root / PROJECT_SUITES)
|
|
41
|
+
|
|
42
|
+
def _load_catalog_tree(self, catalog_root: Path, suites_root: Path) -> None:
|
|
43
|
+
for tool_id, data in self._load_yaml_dir(catalog_root / "tools").items():
|
|
44
|
+
self.tools[tool_id] = ToolDef.model_validate(data)
|
|
45
|
+
for check_id, data in self._load_yaml_dir(catalog_root / "checks").items():
|
|
46
|
+
self.checks[check_id] = CheckDef.model_validate(data)
|
|
47
|
+
for suite_id, data in self._load_yaml_dir(suites_root).items():
|
|
48
|
+
self.suites[suite_id] = SuiteDef.model_validate(data)
|
|
49
|
+
|
|
50
|
+
def get_tool(self, tool_id: str) -> ToolDef:
|
|
51
|
+
if tool_id not in self.tools:
|
|
52
|
+
raise KeyError(f"Unknown tool: {tool_id}")
|
|
53
|
+
return self.tools[tool_id]
|
|
54
|
+
|
|
55
|
+
def get_check(self, check_id: str) -> CheckDef:
|
|
56
|
+
if check_id not in self.checks:
|
|
57
|
+
raise KeyError(f"Unknown check: {check_id}")
|
|
58
|
+
return self.checks[check_id]
|
|
59
|
+
|
|
60
|
+
def get_suite(self, suite_id: str) -> SuiteDef:
|
|
61
|
+
if suite_id not in self.suites:
|
|
62
|
+
raise KeyError(f"Unknown suite: {suite_id}")
|
|
63
|
+
return self.suites[suite_id]
|
|
64
|
+
|
|
65
|
+
def load_project_config(self, project_root: Path | None = None) -> ProjectConfig | None:
|
|
66
|
+
root = project_root or self.project_root
|
|
67
|
+
config_path = root / "pyossmtool.yaml"
|
|
68
|
+
if not config_path.exists():
|
|
69
|
+
return None
|
|
70
|
+
with config_path.open(encoding="utf-8") as handle:
|
|
71
|
+
data = yaml.safe_load(handle) or {}
|
|
72
|
+
return ProjectConfig.model_validate(data)
|
pyossmtool/reporter.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Write normalized failure reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pyossmtool.models import (
|
|
10
|
+
CheckDef,
|
|
11
|
+
FailureArtifacts,
|
|
12
|
+
FailureReport,
|
|
13
|
+
Finding,
|
|
14
|
+
Remediation,
|
|
15
|
+
ToolDef,
|
|
16
|
+
summarize_findings,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Reporter:
|
|
21
|
+
def __init__(self, project_root: Path | None = None) -> None:
|
|
22
|
+
self.project_root = (project_root or Path.cwd()).resolve()
|
|
23
|
+
self.failures_dir = self.project_root / "reports" / "failures"
|
|
24
|
+
|
|
25
|
+
def write_failure(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
check: CheckDef,
|
|
29
|
+
tool: ToolDef,
|
|
30
|
+
suite_id: str | None,
|
|
31
|
+
target: str,
|
|
32
|
+
started_at: datetime,
|
|
33
|
+
duration_ms: int,
|
|
34
|
+
findings: list[Finding],
|
|
35
|
+
stdout: str,
|
|
36
|
+
stderr: str,
|
|
37
|
+
) -> Path:
|
|
38
|
+
self.failures_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
timestamp = started_at.strftime("%Y%m%dT%H%M%SZ")
|
|
40
|
+
report_dir = self.failures_dir / f"{check.id}-{timestamp}"
|
|
41
|
+
report_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
|
|
43
|
+
raw_stdout_path = report_dir / "raw.stdout.txt"
|
|
44
|
+
raw_stderr_path = report_dir / "raw.stderr.txt"
|
|
45
|
+
raw_stdout_path.write_text(stdout, encoding="utf-8")
|
|
46
|
+
raw_stderr_path.write_text(stderr, encoding="utf-8")
|
|
47
|
+
|
|
48
|
+
cov_target = check.policy.coverage_source if check.policy and check.policy.coverage_source else "src/"
|
|
49
|
+
remediation = Remediation(
|
|
50
|
+
docs_url=tool.documentation_url,
|
|
51
|
+
suggested_commands=[
|
|
52
|
+
cmd.format(target=target, cov=cov_target, binary=tool.binary)
|
|
53
|
+
for cmd in check.remediation.get("suggested_commands", [])
|
|
54
|
+
],
|
|
55
|
+
)
|
|
56
|
+
if not remediation.suggested_commands:
|
|
57
|
+
remediation.suggested_commands = self._fallback_commands(check, tool, target)
|
|
58
|
+
|
|
59
|
+
report = FailureReport(
|
|
60
|
+
check_id=check.id,
|
|
61
|
+
tool_id=tool.id,
|
|
62
|
+
suite_id=suite_id,
|
|
63
|
+
target=target,
|
|
64
|
+
started_at=started_at,
|
|
65
|
+
duration_ms=duration_ms,
|
|
66
|
+
summary=summarize_findings(findings),
|
|
67
|
+
findings=findings,
|
|
68
|
+
remediation=remediation,
|
|
69
|
+
artifacts=FailureArtifacts(
|
|
70
|
+
raw_stdout=str(raw_stdout_path.relative_to(self.project_root)),
|
|
71
|
+
raw_stderr=str(raw_stderr_path.relative_to(self.project_root)),
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
report_path = report_dir / "report.json"
|
|
76
|
+
report_path.write_text(
|
|
77
|
+
json.dumps(report.model_dump(mode="json"), indent=2) + "\n",
|
|
78
|
+
encoding="utf-8",
|
|
79
|
+
)
|
|
80
|
+
return report_path
|
|
81
|
+
|
|
82
|
+
def _fallback_commands(self, check: CheckDef, tool: ToolDef, target: str) -> list[str]:
|
|
83
|
+
if check.tool == "script" and check.script:
|
|
84
|
+
if check.script.startswith("bundled:"):
|
|
85
|
+
return [f"pyossmtool run --check {check.id} --target {target}"]
|
|
86
|
+
return [f"./{check.script}"]
|
|
87
|
+
return [f"{tool.binary} {target}"]
|
pyossmtool/resolver.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Hybrid binary resolution for quality tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import tomllib
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from pyossmtool.models import EnvMode, ToolDef
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BinaryResolver:
|
|
14
|
+
def __init__(self, project_root: Path | None = None) -> None:
|
|
15
|
+
self.project_root = (project_root or Path.cwd()).resolve()
|
|
16
|
+
self.cache_root = Path.home() / ".cache" / "pyossmtool" / "tools"
|
|
17
|
+
self.local_tools_root = self.project_root / ".pyossmtool" / "tools"
|
|
18
|
+
|
|
19
|
+
def resolve_env_mode(self, mode: EnvMode) -> EnvMode:
|
|
20
|
+
if mode != EnvMode.AUTO:
|
|
21
|
+
return mode
|
|
22
|
+
if self._project_venv_exists():
|
|
23
|
+
return EnvMode.PROJECT
|
|
24
|
+
return EnvMode.MANAGED
|
|
25
|
+
|
|
26
|
+
def _project_venv_exists(self) -> bool:
|
|
27
|
+
return (self.project_root / "pyproject.toml").exists() and (
|
|
28
|
+
(self.project_root / ".venv").exists() or (self.project_root / "venv").exists()
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def _venv_bin_dirs(self) -> list[Path]:
|
|
32
|
+
candidates = [
|
|
33
|
+
self.project_root / ".pyossmtool" / "tools" / "venv" / "bin",
|
|
34
|
+
self.project_root / ".pyossmtool" / "tools" / "npm" / "node_modules" / ".bin",
|
|
35
|
+
self.project_root / ".venv" / "bin",
|
|
36
|
+
self.project_root / "venv" / "bin",
|
|
37
|
+
self.local_tools_root / "bin",
|
|
38
|
+
self.cache_root / "bin",
|
|
39
|
+
]
|
|
40
|
+
return [path for path in candidates if path.is_dir()]
|
|
41
|
+
|
|
42
|
+
def _tool_in_project_deps(self, tool: ToolDef) -> bool:
|
|
43
|
+
pyproject = self.project_root / "pyproject.toml"
|
|
44
|
+
if not pyproject.exists() or tool.install.package is None:
|
|
45
|
+
return False
|
|
46
|
+
package_name = tool.install.package.split(">=")[0].split("==")[0].strip()
|
|
47
|
+
return package_name in self._project_dependency_text(pyproject)
|
|
48
|
+
|
|
49
|
+
def _project_dependency_text(self, pyproject: Path) -> str:
|
|
50
|
+
text = pyproject.read_text(encoding="utf-8")
|
|
51
|
+
try:
|
|
52
|
+
data = tomllib.loads(text)
|
|
53
|
+
except tomllib.TOMLDecodeError:
|
|
54
|
+
return text
|
|
55
|
+
deps = list(data.get("project", {}).get("dependencies", []))
|
|
56
|
+
for group in data.get("dependency-groups", {}).values():
|
|
57
|
+
if isinstance(group, list):
|
|
58
|
+
deps.extend(group)
|
|
59
|
+
return " ".join(deps)
|
|
60
|
+
|
|
61
|
+
def resolve(self, tool: ToolDef, mode: EnvMode) -> Path:
|
|
62
|
+
resolved_mode = self.resolve_env_mode(mode)
|
|
63
|
+
for candidate in self._resolution_candidates(tool, resolved_mode):
|
|
64
|
+
if candidate is not None:
|
|
65
|
+
return candidate
|
|
66
|
+
raise FileNotFoundError(
|
|
67
|
+
f"Could not resolve binary '{tool.binary}' for tool '{tool.id}' (mode={resolved_mode.value})"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def _resolution_candidates(self, tool: ToolDef, mode: EnvMode):
|
|
71
|
+
yield self._resolve_for_mode(tool, mode)
|
|
72
|
+
yield self._resolve_managed(tool)
|
|
73
|
+
yield self._resolve_which(tool.binary)
|
|
74
|
+
if tool.install.method.value == "system":
|
|
75
|
+
yield self._resolve_system(tool.binary)
|
|
76
|
+
|
|
77
|
+
def _resolve_for_mode(self, tool: ToolDef, mode: EnvMode) -> Path | None:
|
|
78
|
+
if mode == EnvMode.PROJECT:
|
|
79
|
+
return self._resolve_project_paths(tool)
|
|
80
|
+
if mode == EnvMode.AUTO and self._tool_in_project_deps(tool):
|
|
81
|
+
return self._resolve_from_path(tool.binary)
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
def _resolve_project_paths(self, tool: ToolDef) -> Path | None:
|
|
85
|
+
binary = self._resolve_from_path(tool.binary)
|
|
86
|
+
if binary is not None:
|
|
87
|
+
return binary
|
|
88
|
+
if tool.install.method.value == "npm":
|
|
89
|
+
return self._resolve_npm(tool.binary)
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
def _resolve_which(self, binary_name: str) -> Path | None:
|
|
93
|
+
found = shutil.which(binary_name)
|
|
94
|
+
return Path(found) if found else None
|
|
95
|
+
|
|
96
|
+
def _resolve_from_path(self, binary_name: str) -> Path | None:
|
|
97
|
+
for bin_dir in self._venv_bin_dirs():
|
|
98
|
+
candidate = bin_dir / binary_name
|
|
99
|
+
if candidate.exists():
|
|
100
|
+
return candidate
|
|
101
|
+
found = shutil.which(binary_name)
|
|
102
|
+
if found:
|
|
103
|
+
return Path(found)
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
def _resolve_npm(self, binary_name: str) -> Path | None:
|
|
107
|
+
managed = self.project_root / ".pyossmtool" / "tools" / "npm" / "node_modules" / ".bin" / binary_name
|
|
108
|
+
if managed.exists():
|
|
109
|
+
return managed
|
|
110
|
+
local = self.project_root / "node_modules" / ".bin" / binary_name
|
|
111
|
+
if local.exists():
|
|
112
|
+
return local
|
|
113
|
+
return self._resolve_from_path(binary_name)
|
|
114
|
+
|
|
115
|
+
def _resolve_managed(self, tool: ToolDef) -> Path | None:
|
|
116
|
+
managed_venv_bin = self.project_root / ".pyossmtool" / "tools" / "venv" / "bin"
|
|
117
|
+
if managed_venv_bin.is_dir():
|
|
118
|
+
candidate = managed_venv_bin / tool.binary
|
|
119
|
+
if candidate.exists():
|
|
120
|
+
return candidate
|
|
121
|
+
if tool.install.method.value == "npm":
|
|
122
|
+
npm_bin = self._resolve_npm(tool.binary)
|
|
123
|
+
if npm_bin is not None:
|
|
124
|
+
return npm_bin
|
|
125
|
+
for bin_dir in [self.local_tools_root / "bin", self.cache_root / "bin"]:
|
|
126
|
+
candidate = bin_dir / tool.binary
|
|
127
|
+
if candidate.exists():
|
|
128
|
+
return candidate
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
def _resolve_system(self, binary_name: str) -> Path | None:
|
|
132
|
+
found = shutil.which(binary_name)
|
|
133
|
+
if found:
|
|
134
|
+
return Path(found)
|
|
135
|
+
trunk_glob = Path.home() / ".cache" / "trunk" / "repos"
|
|
136
|
+
if trunk_glob.exists():
|
|
137
|
+
matches = sorted(trunk_glob.glob(f"*/tools/{binary_name}"))
|
|
138
|
+
if matches:
|
|
139
|
+
return matches[-1]
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
def prepend_managed_path(self, env: dict[str, str] | None = None) -> dict[str, str]:
|
|
143
|
+
merged = dict(env or os.environ)
|
|
144
|
+
paths = [str(path) for path in self._venv_bin_dirs()]
|
|
145
|
+
merged["PATH"] = os.pathsep.join(paths + [merged.get("PATH", "")])
|
|
146
|
+
return merged
|