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.
Files changed (119) hide show
  1. pyossmtool/__init__.py +3 -0
  2. pyossmtool/bundle/catalog/checks/bandit.scan.yaml +13 -0
  3. pyossmtool/bundle/catalog/checks/codespell.spelling.yaml +12 -0
  4. pyossmtool/bundle/catalog/checks/deadcode.scan.yaml +13 -0
  5. pyossmtool/bundle/catalog/checks/gate.acronym-allowlist.yaml +14 -0
  6. pyossmtool/bundle/catalog/checks/gate.folder-breadth.yaml +14 -0
  7. pyossmtool/bundle/catalog/checks/gate.module-private-vars.yaml +14 -0
  8. pyossmtool/bundle/catalog/checks/gate.module-size.yaml +14 -0
  9. pyossmtool/bundle/catalog/checks/gitleaks.secrets.yaml +21 -0
  10. pyossmtool/bundle/catalog/checks/hadolint.dockerfile.yaml +16 -0
  11. pyossmtool/bundle/catalog/checks/jscpd.duplication.yaml +19 -0
  12. pyossmtool/bundle/catalog/checks/markdownlint.check.yaml +17 -0
  13. pyossmtool/bundle/catalog/checks/mdformat.apply.yaml +10 -0
  14. pyossmtool/bundle/catalog/checks/mdformat.format.yaml +14 -0
  15. pyossmtool/bundle/catalog/checks/mutmut.run.yaml +13 -0
  16. pyossmtool/bundle/catalog/checks/pydeps.cycles.yaml +14 -0
  17. pyossmtool/bundle/catalog/checks/pytest.coverage.yaml +19 -0
  18. pyossmtool/bundle/catalog/checks/pytest.test.yaml +14 -0
  19. pyossmtool/bundle/catalog/checks/radon.cc.yaml +13 -0
  20. pyossmtool/bundle/catalog/checks/radon.mi.yaml +13 -0
  21. pyossmtool/bundle/catalog/checks/ruff.format.apply.yaml +11 -0
  22. pyossmtool/bundle/catalog/checks/ruff.format.yaml +14 -0
  23. pyossmtool/bundle/catalog/checks/ruff.lint.yaml +15 -0
  24. pyossmtool/bundle/catalog/checks/ruff.unused.yaml +17 -0
  25. pyossmtool/bundle/catalog/checks/semgrep.scan.yaml +18 -0
  26. pyossmtool/bundle/catalog/checks/shellcheck.yaml +13 -0
  27. pyossmtool/bundle/catalog/checks/shfmt.apply.yaml +11 -0
  28. pyossmtool/bundle/catalog/checks/shfmt.format.yaml +14 -0
  29. pyossmtool/bundle/catalog/checks/sourcery.review.yaml +14 -0
  30. pyossmtool/bundle/catalog/checks/ty.check.yaml +15 -0
  31. pyossmtool/bundle/catalog/checks/vulture.scan.yaml +16 -0
  32. pyossmtool/bundle/catalog/checks/yamlfmt.apply.yaml +10 -0
  33. pyossmtool/bundle/catalog/checks/yamlfmt.format.yaml +14 -0
  34. pyossmtool/bundle/catalog/checks/yamllint.check.yaml +16 -0
  35. pyossmtool/bundle/catalog/tools/bandit.yaml +20 -0
  36. pyossmtool/bundle/catalog/tools/codespell.yaml +17 -0
  37. pyossmtool/bundle/catalog/tools/deadcode.yaml +13 -0
  38. pyossmtool/bundle/catalog/tools/gitleaks.yaml +9 -0
  39. pyossmtool/bundle/catalog/tools/hadolint.yaml +11 -0
  40. pyossmtool/bundle/catalog/tools/jscpd.yaml +15 -0
  41. pyossmtool/bundle/catalog/tools/markdownlint.yaml +18 -0
  42. pyossmtool/bundle/catalog/tools/mdformat.yaml +13 -0
  43. pyossmtool/bundle/catalog/tools/mutmut.yaml +10 -0
  44. pyossmtool/bundle/catalog/tools/pydeps.yaml +10 -0
  45. pyossmtool/bundle/catalog/tools/pytest.yaml +12 -0
  46. pyossmtool/bundle/catalog/tools/radon.yaml +10 -0
  47. pyossmtool/bundle/catalog/tools/ruff.yaml +26 -0
  48. pyossmtool/bundle/catalog/tools/script.yaml +8 -0
  49. pyossmtool/bundle/catalog/tools/semgrep.yaml +21 -0
  50. pyossmtool/bundle/catalog/tools/shellcheck.yaml +10 -0
  51. pyossmtool/bundle/catalog/tools/shfmt.yaml +13 -0
  52. pyossmtool/bundle/catalog/tools/sourcery.yaml +10 -0
  53. pyossmtool/bundle/catalog/tools/ty.yaml +20 -0
  54. pyossmtool/bundle/catalog/tools/vulture.yaml +13 -0
  55. pyossmtool/bundle/catalog/tools/yamlfmt.yaml +24 -0
  56. pyossmtool/bundle/catalog/tools/yamllint.yaml +24 -0
  57. pyossmtool/bundle/defaults/allowlists/acronyms.yaml +10 -0
  58. pyossmtool/bundle/defaults/allowlists/folder-breadth.txt +1 -0
  59. pyossmtool/bundle/defaults/allowlists/module-private-vars.txt +1 -0
  60. pyossmtool/bundle/defaults/allowlists/module-size.txt +1 -0
  61. pyossmtool/bundle/defaults/configs/bandit.yaml +5 -0
  62. pyossmtool/bundle/defaults/configs/gates/acronym-allowlist.yaml +4 -0
  63. pyossmtool/bundle/defaults/configs/gates/folder-breadth.yaml +12 -0
  64. pyossmtool/bundle/defaults/configs/gates/module-private-vars.yaml +4 -0
  65. pyossmtool/bundle/defaults/configs/gates/module-size.yaml +6 -0
  66. pyossmtool/bundle/defaults/configs/markdownlint.json +8 -0
  67. pyossmtool/bundle/defaults/configs/ruff.toml +8 -0
  68. pyossmtool/bundle/defaults/configs/ty.toml +5 -0
  69. pyossmtool/bundle/defaults/configs/yamlfmt.yaml +4 -0
  70. pyossmtool/bundle/defaults/configs/yamllint.yaml +12 -0
  71. pyossmtool/bundle/defaults/gates/README.md +150 -0
  72. pyossmtool/bundle/defaults/gates/acronym-allowlist.sh +15 -0
  73. pyossmtool/bundle/defaults/gates/folder-breadth.sh +22 -0
  74. pyossmtool/bundle/defaults/gates/lib.sh +116 -0
  75. pyossmtool/bundle/defaults/gates/module-private-vars.sh +77 -0
  76. pyossmtool/bundle/defaults/gates/module-size.sh +60 -0
  77. pyossmtool/bundle/defaults/semgrep/python-quality.yml +19 -0
  78. pyossmtool/bundle/suites/demo.yaml +11 -0
  79. pyossmtool/bundle/suites/extended.yaml +34 -0
  80. pyossmtool/bundle/suites/format.yaml +10 -0
  81. pyossmtool/bundle/suites/formatting.yaml +10 -0
  82. pyossmtool/bundle/suites/policy.yaml +11 -0
  83. pyossmtool/bundle/suites/python-quality.yaml +9 -0
  84. pyossmtool/bundle/suites/reslab-parity.yaml +41 -0
  85. pyossmtool/bundle/suites/standard.yaml +45 -0
  86. pyossmtool/cli.py +233 -0
  87. pyossmtool/config_resolver.py +117 -0
  88. pyossmtool/discovery.py +50 -0
  89. pyossmtool/gate_config.py +119 -0
  90. pyossmtool/gates.py +102 -0
  91. pyossmtool/ignore.py +270 -0
  92. pyossmtool/ignore_materialize.py +184 -0
  93. pyossmtool/installer.py +119 -0
  94. pyossmtool/models.py +279 -0
  95. pyossmtool/parsers/__init__.py +76 -0
  96. pyossmtool/parsers/analysis.py +329 -0
  97. pyossmtool/parsers/base.py +29 -0
  98. pyossmtool/parsers/common.py +57 -0
  99. pyossmtool/parsers/format_text.py +67 -0
  100. pyossmtool/parsers/gates.py +92 -0
  101. pyossmtool/parsers/patterns.py +78 -0
  102. pyossmtool/parsers/prose.py +193 -0
  103. pyossmtool/parsers/ruff.py +103 -0
  104. pyossmtool/parsers/shell.py +83 -0
  105. pyossmtool/policy/__init__.py +0 -0
  106. pyossmtool/policy/acronym_allowlist.py +322 -0
  107. pyossmtool/policy/folder_breadth.py +306 -0
  108. pyossmtool/registry.py +72 -0
  109. pyossmtool/reporter.py +87 -0
  110. pyossmtool/resolver.py +146 -0
  111. pyossmtool/runner.py +482 -0
  112. pyossmtool/runner_script.py +131 -0
  113. pyossmtool/target_expand.py +112 -0
  114. pyossmtool-0.0.1.dist-info/METADATA +94 -0
  115. pyossmtool-0.0.1.dist-info/RECORD +119 -0
  116. pyossmtool-0.0.1.dist-info/WHEEL +5 -0
  117. pyossmtool-0.0.1.dist-info/entry_points.txt +2 -0
  118. pyossmtool-0.0.1.dist-info/licenses/LICENSE +21 -0
  119. 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