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,329 @@
1
+ """Analysis-tool parsers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import ClassVar
8
+
9
+ from pyossmtool.models import CheckDef, Finding, Location, Severity
10
+ from pyossmtool.parsers.base import Parser, register
11
+ from pyossmtool.parsers.common import RANK_ORDER
12
+ from pyossmtool.parsers.patterns import JsonListParser, LineRegexParser, PolicyJsonParser
13
+
14
+
15
+ def _policy_max_rank_value(check: CheckDef) -> int:
16
+ max_rank = (check.policy.max_complexity_rank if check.policy else "A") or "A"
17
+ return RANK_ORDER.get(max_rank, 1)
18
+
19
+
20
+ @register
21
+ class BanditParser(JsonListParser):
22
+ """Bandit emits ``{\"results\": [...]}`` rather than a bare JSON array."""
23
+
24
+ id = "bandit_json"
25
+
26
+ def parse(self, stdout: str, stderr: str = "", *, check: CheckDef | None = None) -> list[Finding]:
27
+ if not stdout.strip():
28
+ return []
29
+ payload = json.loads(stdout)
30
+ findings: list[Finding] = []
31
+ for item in payload.get("results", []):
32
+ finding = self.map_item(item)
33
+ if finding is not None:
34
+ findings.append(finding)
35
+ return findings
36
+
37
+ def map_item(self, item: dict) -> Finding:
38
+ return Finding(
39
+ rule_id=item.get("test_id", "bandit"),
40
+ severity=Severity.ERROR if item.get("issue_severity") == "HIGH" else Severity.WARNING,
41
+ message=item.get("issue_text", "bandit finding"),
42
+ location=Location(
43
+ file=item.get("filename", ""),
44
+ line=item.get("line_number"),
45
+ ),
46
+ )
47
+
48
+
49
+ @register
50
+ class TyParser(LineRegexParser):
51
+ id = "ty_concise"
52
+ pattern: ClassVar[re.Pattern[str]] = re.compile(
53
+ r"^(?P<file>[^:]+):(?P<line>\d+):(?P<col>\d+):\s*(?P<kind>\w+):\s*(?P<message>.+)$"
54
+ )
55
+
56
+ def parse(self, stdout: str, stderr: str = "", *, check: CheckDef | None = None) -> list[Finding]:
57
+ findings = super().parse(stdout, stderr, check=check)
58
+ if findings:
59
+ return findings
60
+ return self._summary_findings(stdout or stderr)
61
+
62
+ def map_match(self, match: re.Match[str]) -> Finding:
63
+ return Finding(
64
+ rule_id=match.group("kind"),
65
+ severity=Severity.ERROR,
66
+ message=match.group("message"),
67
+ location=Location(
68
+ file=match.group("file"),
69
+ line=int(match.group("line")),
70
+ column=int(match.group("col")),
71
+ ),
72
+ )
73
+
74
+ def _summary_findings(self, text: str) -> list[Finding]:
75
+ if not re.search(r"Found \d+ diagnostics", text):
76
+ return []
77
+ return [
78
+ Finding(
79
+ rule_id="ty",
80
+ severity=Severity.ERROR,
81
+ message=text.strip().splitlines()[-1],
82
+ )
83
+ ]
84
+
85
+
86
+ @register
87
+ class RadonCcParser(PolicyJsonParser):
88
+ id = "radon_cc_json"
89
+
90
+ def parse_payload(self, payload: dict, check: CheckDef) -> list[Finding]:
91
+ max_value = _policy_max_rank_value(check)
92
+ findings: list[Finding] = []
93
+ for file_path, blocks in payload.items():
94
+ findings.extend(self._file_findings(file_path, blocks, max_value))
95
+ return findings
96
+
97
+ def _file_findings(self, file_path: str, blocks: list[dict], max_value: int) -> list[Finding]:
98
+ findings: list[Finding] = []
99
+ for block in blocks:
100
+ finding = self._block_finding(file_path, block, max_value)
101
+ if finding is not None:
102
+ findings.append(finding)
103
+ return findings
104
+
105
+ def _block_finding(self, file_path: str, block: dict, max_value: int) -> Finding | None:
106
+ rank = block.get("rank", "A")
107
+ if RANK_ORDER.get(rank, 99) <= max_value:
108
+ return None
109
+ return Finding(
110
+ rule_id="complexity",
111
+ severity=Severity.ERROR,
112
+ message=f"{block.get('type')} {block.get('name')} complexity rank {rank}",
113
+ location=Location(file=file_path, line=block.get("lineno")),
114
+ )
115
+
116
+
117
+ @register
118
+ class RadonMiParser(PolicyJsonParser):
119
+ id = "radon_mi_json"
120
+
121
+ def parse_payload(self, payload: dict, check: CheckDef) -> list[Finding]:
122
+ max_value = _policy_max_rank_value(check)
123
+ findings: list[Finding] = []
124
+ for file_path, item in payload.items():
125
+ finding = self._file_finding(file_path, item, max_value)
126
+ if finding is not None:
127
+ findings.append(finding)
128
+ return findings
129
+
130
+ def _file_finding(self, file_path: str, item: dict, max_value: int) -> Finding | None:
131
+ rank = item.get("rank", "A")
132
+ if RANK_ORDER.get(rank, 99) <= max_value:
133
+ return None
134
+ return Finding(
135
+ rule_id="maintainability",
136
+ severity=Severity.ERROR,
137
+ message=f"Maintainability index rank {rank} (mi={item.get('mi')})",
138
+ location=Location(file=file_path),
139
+ )
140
+
141
+
142
+ @register
143
+ class JscpdParser(PolicyJsonParser):
144
+ id = "jscpd_json"
145
+
146
+ def parse_payload(self, payload: dict, check: CheckDef) -> list[Finding]:
147
+ threshold = check.policy.max_duplication_percent if check.policy else None
148
+ if threshold is not None:
149
+ return self._threshold_findings(payload, threshold)
150
+ return self._duplicate_findings(payload)
151
+
152
+ def _threshold_findings(self, payload: dict, threshold: float) -> list[Finding]:
153
+ percentage = payload.get("statistics", {}).get("total", {}).get("percentage", 0)
154
+ if percentage <= threshold:
155
+ return []
156
+ return [
157
+ Finding(
158
+ rule_id="duplication",
159
+ severity=Severity.ERROR,
160
+ message=f"Duplication {percentage}% exceeds threshold {threshold}%",
161
+ )
162
+ ]
163
+
164
+ def _duplicate_findings(self, payload: dict) -> list[Finding]:
165
+ findings: list[Finding] = []
166
+ for duplicate in payload.get("duplicates", []):
167
+ first = duplicate.get("firstFile", {})
168
+ second = duplicate.get("secondFile", {})
169
+ findings.append(
170
+ Finding(
171
+ rule_id="duplicate-block",
172
+ severity=Severity.WARNING,
173
+ message=(f"Duplicated block ({duplicate.get('lines', 0)} lines, {duplicate.get('format', '')})"),
174
+ location=Location(file=first.get("name", ""), line=first.get("startLoc", {}).get("line")),
175
+ snippet=f"also in {second.get('name', '')}",
176
+ )
177
+ )
178
+ return findings
179
+
180
+
181
+ @register
182
+ class SemgrepParser(Parser):
183
+ id = "semgrep_json"
184
+
185
+ def parse(self, stdout: str, stderr: str = "", *, check: CheckDef | None = None) -> list[Finding]:
186
+ if not stdout.strip():
187
+ return []
188
+ payload = json.loads(stdout)
189
+ findings: list[Finding] = []
190
+ for item in payload.get("results", []):
191
+ findings.append(self._item_finding(item))
192
+ return findings
193
+
194
+ def _item_finding(self, item: dict) -> Finding:
195
+ extra = item.get("extra", {})
196
+ metadata = extra.get("metadata", {})
197
+ return Finding(
198
+ rule_id=item.get("check_id", "semgrep"),
199
+ severity=Severity.ERROR if extra.get("severity", "").upper() in {"ERROR", "HIGH"} else Severity.WARNING,
200
+ message=extra.get("message", metadata.get("message", "semgrep finding")),
201
+ location=Location(
202
+ file=item.get("path", ""),
203
+ line=item.get("start", {}).get("line"),
204
+ column=item.get("start", {}).get("col"),
205
+ end_line=item.get("end", {}).get("line"),
206
+ end_column=item.get("end", {}).get("col"),
207
+ ),
208
+ snippet=extra.get("lines"),
209
+ )
210
+
211
+
212
+ @register
213
+ class DeadcodeParser(Parser):
214
+ id = "deadcode_text"
215
+ _pattern = re.compile(r"(?P<file>[^:]+):(?P<line>\d+):\s*(?P<code>DC\d+):\s*(?P<message>.+)")
216
+
217
+ def parse(self, stdout: str, stderr: str = "", *, check: CheckDef | None = None) -> list[Finding]:
218
+ text = stdout or stderr
219
+ findings = [finding for line in text.splitlines() if (finding := self._line_finding(line)) is not None]
220
+ if findings:
221
+ return findings
222
+ return self._fallback(text)
223
+
224
+ def _line_finding(self, line: str) -> Finding | None:
225
+ match = self._pattern.search(line)
226
+ if not match:
227
+ return None
228
+ return Finding(
229
+ rule_id=match.group("code"),
230
+ severity=Severity.ERROR,
231
+ message=match.group("message").strip(),
232
+ location=Location(file=match.group("file"), line=int(match.group("line"))),
233
+ )
234
+
235
+ def _fallback(self, text: str) -> list[Finding]:
236
+ if not re.search(r"DC\d+", text):
237
+ return []
238
+ return [Finding(rule_id="deadcode", severity=Severity.ERROR, message=text.strip()[:500])]
239
+
240
+
241
+ @register
242
+ class VultureParser(LineRegexParser):
243
+ id = "vulture_text"
244
+ pattern: ClassVar[re.Pattern[str]] = re.compile(
245
+ r"^(?P<file>[^:]+):(?P<line>\d+):\s*(?P<confidence>\d+)%\s*confidence:\s*(?P<message>.+)$"
246
+ )
247
+
248
+ def map_match(self, match: re.Match[str]) -> Finding:
249
+ return Finding(
250
+ rule_id="vulture",
251
+ severity=Severity.ERROR,
252
+ message=match.group("message").strip(),
253
+ location=Location(
254
+ file=match.group("file"),
255
+ line=int(match.group("line")),
256
+ ),
257
+ )
258
+
259
+
260
+ @register
261
+ class PydepsCyclesParser(Parser):
262
+ id = "pydeps_cycles_text"
263
+
264
+ def parse(self, stdout: str, stderr: str = "", *, check: CheckDef | None = None) -> list[Finding]:
265
+ text = (stdout if stdout else stderr).strip()
266
+ if not text:
267
+ return []
268
+ if "No import cycles detected" in text:
269
+ return []
270
+ return self._cycle_findings(text)
271
+
272
+ def _cycle_findings(self, text: str) -> list[Finding]:
273
+ findings: list[Finding] = []
274
+ for line in text.splitlines():
275
+ message = self._cycle_message(line)
276
+ if message is None:
277
+ continue
278
+ findings.append(Finding(rule_id="import-cycle", severity=Severity.ERROR, message=message))
279
+ return findings
280
+
281
+ def _cycle_message(self, line: str) -> str | None:
282
+ stripped = line.strip()
283
+ if not stripped:
284
+ return None
285
+ if "No import cycles" in stripped:
286
+ return None
287
+ return stripped
288
+
289
+
290
+ @register
291
+ class PytestParser(Parser):
292
+ id = "pytest_text"
293
+ _fail_pattern = re.compile(r"^(?P<file>[^\s]+):(?P<line>\d+):\s*(?P<message>.+)$")
294
+
295
+ def parse(self, stdout: str, stderr: str = "", *, check: CheckDef | None = None) -> list[Finding]:
296
+ text = (stdout or stderr).strip()
297
+ if not text:
298
+ return []
299
+ findings = self._line_findings(text)
300
+ if findings:
301
+ return findings
302
+ return self._summary_findings(text)
303
+
304
+ def _line_findings(self, text: str) -> list[Finding]:
305
+ return [finding for line in text.splitlines() if (finding := self._line_finding(line)) is not None]
306
+
307
+ def _line_finding(self, line: str) -> Finding | None:
308
+ if line.startswith("FAILED ") or " FAILED" in line:
309
+ return Finding(rule_id="pytest", severity=Severity.ERROR, message=line.strip())
310
+ return self._location_finding(line)
311
+
312
+ def _location_finding(self, line: str) -> Finding | None:
313
+ match = self._fail_pattern.match(line.strip())
314
+ if not match:
315
+ return None
316
+ message = match.group("message")
317
+ lowered = message.lower()
318
+ if "error" not in lowered and "failed" not in lowered:
319
+ return None
320
+ return Finding(
321
+ rule_id="pytest",
322
+ severity=Severity.ERROR,
323
+ message=message.strip(),
324
+ location=Location(file=match.group("file"), line=int(match.group("line"))),
325
+ )
326
+
327
+ def _summary_findings(self, text: str) -> list[Finding]:
328
+ summary = [line for line in text.splitlines() if "failed" in line.lower() or "error" in line.lower()]
329
+ return [Finding(rule_id="pytest", severity=Severity.ERROR, message=line.strip()) for line in summary[-3:]]
@@ -0,0 +1,29 @@
1
+ """Parser ABC and registration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import ClassVar
7
+
8
+ from pyossmtool.models import CheckDef, Finding
9
+
10
+
11
+ class Parser(ABC):
12
+ id: ClassVar[str]
13
+ needs_check: ClassVar[bool] = False
14
+
15
+ @abstractmethod
16
+ def parse(self, stdout: str, stderr: str = "", *, check: CheckDef | None = None) -> list[Finding]:
17
+ raise NotImplementedError
18
+
19
+
20
+ REGISTRY: dict[str, type[Parser]] = {}
21
+
22
+
23
+ def register(cls: type[Parser]) -> type[Parser]:
24
+ if not getattr(cls, "id", None):
25
+ raise ValueError(f"Parser {cls.__name__} missing id")
26
+ if cls.id in REGISTRY:
27
+ raise ValueError(f"Duplicate parser id: {cls.id}")
28
+ REGISTRY[cls.id] = cls
29
+ return cls
@@ -0,0 +1,57 @@
1
+ """Shared parser helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from pyossmtool.models import Finding, FixHint, Location, Severity
8
+
9
+ RANK_ORDER = {"A": 1, "B": 2, "C": 3, "D": 4, "E": 5, "F": 6}
10
+
11
+ _SEVERITY_BY_NAME = {
12
+ "warning": Severity.WARNING,
13
+ "info": Severity.INFO,
14
+ }
15
+
16
+
17
+ def strip_ansi(text: str) -> str:
18
+ return re.sub(r"\x1b\[[0-9;]*m", "", text)
19
+
20
+
21
+ def finding_from_dict(item: dict) -> Finding:
22
+ return Finding(
23
+ rule_id=item.get("rule_id", "gate"),
24
+ severity=_severity_from_dict(item),
25
+ message=item.get("message", "script gate finding"),
26
+ location=_location_from_dict(item),
27
+ snippet=item.get("snippet"),
28
+ fix=_fix_from_dict(item),
29
+ )
30
+
31
+
32
+ def _location_from_dict(item: dict) -> Location | None:
33
+ loc = item.get("location")
34
+ if not isinstance(loc, dict) or not loc.get("file"):
35
+ return None
36
+ return Location(
37
+ file=loc.get("file", ""),
38
+ line=loc.get("line"),
39
+ column=loc.get("column"),
40
+ end_line=loc.get("end_line"),
41
+ end_column=loc.get("end_column"),
42
+ )
43
+
44
+
45
+ def _severity_from_dict(item: dict) -> Severity:
46
+ severity_raw = str(item.get("severity", "error")).lower()
47
+ return _SEVERITY_BY_NAME.get(severity_raw, Severity.ERROR)
48
+
49
+
50
+ def _fix_from_dict(item: dict) -> FixHint | None:
51
+ fix_data = item.get("fix")
52
+ if not isinstance(fix_data, dict):
53
+ return None
54
+ return FixHint(
55
+ description=fix_data.get("description"),
56
+ command=fix_data.get("command"),
57
+ )
@@ -0,0 +1,67 @@
1
+ """Markdown and YAML format parsers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pyossmtool.models import CheckDef, Finding, FixHint, Severity
6
+ from pyossmtool.parsers.base import Parser, register
7
+ from pyossmtool.parsers.patterns import FallbackTextParser
8
+
9
+
10
+ @register
11
+ class MdformatParser(FallbackTextParser):
12
+ id = "mdformat_text"
13
+
14
+ def parse(self, stdout: str, stderr: str = "", *, check: CheckDef | None = None) -> list[Finding]:
15
+ text = (stdout or stderr).strip()
16
+ if not text:
17
+ return []
18
+ findings = self._line_findings(text)
19
+ if findings:
20
+ return findings
21
+ return [self._fallback_finding(text)]
22
+
23
+ def _line_findings(self, text: str) -> list[Finding]:
24
+ return [self._line_finding(line) for line in text.splitlines() if self._line_match(line)]
25
+
26
+ def _line_match(self, line: str) -> bool:
27
+ lowered = line.lower()
28
+ if "would be reformatted" in lowered:
29
+ return True
30
+ if "failed" in lowered:
31
+ return True
32
+ return line.endswith(".md")
33
+
34
+ def _line_finding(self, line: str) -> Finding:
35
+ return Finding(
36
+ rule_id="format",
37
+ severity=Severity.ERROR,
38
+ message=line.strip() or "Markdown would be reformatted",
39
+ fix=FixHint(description="Run mdformat", command="mdformat ."),
40
+ )
41
+
42
+ def _fallback_finding(self, text: str) -> Finding:
43
+ message = text.splitlines()[-1] if text else "Markdown formatting check failed"
44
+ return Finding(rule_id="format", severity=Severity.ERROR, message=message)
45
+
46
+
47
+ @register
48
+ class YamlfmtParser(Parser):
49
+ id = "yamlfmt_text"
50
+
51
+ def parse(self, stdout: str, stderr: str = "", *, check: CheckDef | None = None) -> list[Finding]:
52
+ text = (stdout or stderr).strip()
53
+ if not text:
54
+ return []
55
+ return self._findings(text)
56
+
57
+ def _findings(self, text: str) -> list[Finding]:
58
+ return [
59
+ Finding(
60
+ rule_id="format",
61
+ severity=Severity.ERROR,
62
+ message=line.strip(),
63
+ fix=FixHint(description="Run yamlfmt", command="yamlfmt ."),
64
+ )
65
+ for line in text.splitlines()
66
+ if line.strip()
67
+ ]
@@ -0,0 +1,92 @@
1
+ """Gate / script / CLI text parsers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+
8
+ from pyossmtool.models import CheckDef, Finding, Severity
9
+ from pyossmtool.parsers.base import Parser, register
10
+ from pyossmtool.parsers.common import finding_from_dict
11
+
12
+
13
+ @register
14
+ class CliTextParser(Parser):
15
+ id = "cli_text"
16
+
17
+ def parse(self, stdout: str, stderr: str = "", *, check: CheckDef | None = None) -> list[Finding]:
18
+ text = (stdout or stderr).strip()
19
+ if not text:
20
+ return []
21
+ return self._findings(text)
22
+
23
+ def _findings(self, text: str) -> list[Finding]:
24
+ return [
25
+ Finding(rule_id="check", severity=Severity.ERROR, message=line.strip())
26
+ for line in text.splitlines()
27
+ if line.strip()
28
+ ]
29
+
30
+
31
+ @register
32
+ class ScriptTextParser(Parser):
33
+ id = "script_text"
34
+
35
+ def parse(self, stdout: str, stderr: str = "", *, check: CheckDef | None = None) -> list[Finding]:
36
+ findings: list[Finding] = []
37
+ for line in (stdout or stderr).splitlines():
38
+ finding = self._fail_finding(line)
39
+ if finding is not None:
40
+ findings.append(finding)
41
+ return findings
42
+
43
+ def _fail_finding(self, line: str) -> Finding | None:
44
+ stripped = line.strip()
45
+ if not stripped:
46
+ return None
47
+ message = self._fail_message(stripped)
48
+ if message is None:
49
+ return None
50
+ return Finding(rule_id="gate", severity=Severity.ERROR, message=message)
51
+
52
+ def _fail_message(self, stripped: str) -> str | None:
53
+ if stripped.startswith("FAIL "):
54
+ return stripped.removeprefix("FAIL ").strip()
55
+ if stripped.startswith("FAIL:"):
56
+ return stripped.removeprefix("FAIL:").strip()
57
+ if re.match(r"^FAIL\b", stripped):
58
+ return stripped
59
+ return None
60
+
61
+
62
+ @register
63
+ class GateJsonParser(Parser):
64
+ id = "gate_json"
65
+
66
+ def parse(self, stdout: str, stderr: str = "", *, check: CheckDef | None = None) -> list[Finding]:
67
+ text = stdout.strip()
68
+ if not text:
69
+ return ScriptTextParser().parse(stdout, stderr)
70
+ return self._from_text(text, stdout, stderr)
71
+
72
+ def _from_text(self, text: str, stdout: str, stderr: str) -> list[Finding]:
73
+ try:
74
+ payload = json.loads(text)
75
+ except json.JSONDecodeError:
76
+ return ScriptTextParser().parse(stdout, stderr)
77
+ return [finding_from_dict(item) for item in self._items(payload) if isinstance(item, dict)]
78
+
79
+ def _items(self, payload) -> list:
80
+ if isinstance(payload, dict) and "findings" in payload:
81
+ return payload["findings"]
82
+ if isinstance(payload, list):
83
+ return payload
84
+ return []
85
+
86
+
87
+ @register
88
+ class NoopParser(Parser):
89
+ id = "noop"
90
+
91
+ def parse(self, stdout: str, stderr: str = "", *, check: CheckDef | None = None) -> list[Finding]:
92
+ return []
@@ -0,0 +1,78 @@
1
+ """Shared parser pattern bases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from abc import abstractmethod
8
+ from typing import Any, ClassVar
9
+
10
+ from pyossmtool.models import CheckDef, Finding
11
+ from pyossmtool.parsers.base import Parser
12
+
13
+
14
+ class JsonListParser(Parser):
15
+ """JSON array in stdout (or stderr if stdout empty) → map_item per entry."""
16
+
17
+ def parse(self, stdout: str, stderr: str = "", *, check: CheckDef | None = None) -> list[Finding]:
18
+ payload_text = stdout.strip() or stderr.strip()
19
+ if not payload_text:
20
+ return []
21
+ return self._map_items(json.loads(payload_text))
22
+
23
+ def _map_items(self, payload: list) -> list[Finding]:
24
+ findings: list[Finding] = []
25
+ for item in payload:
26
+ finding = self.map_item(item)
27
+ if finding is not None:
28
+ findings.append(finding)
29
+ return findings
30
+
31
+ @abstractmethod
32
+ def map_item(self, item: Any) -> Finding | None:
33
+ raise NotImplementedError
34
+
35
+
36
+ class LineRegexParser(Parser):
37
+ pattern: ClassVar[re.Pattern[str]]
38
+
39
+ def parse(self, stdout: str, stderr: str = "", *, check: CheckDef | None = None) -> list[Finding]:
40
+ return self._map_lines(stdout or stderr)
41
+
42
+ def _map_lines(self, text: str) -> list[Finding]:
43
+ findings: list[Finding] = []
44
+ for line in text.splitlines():
45
+ match = self.pattern.match(line.strip())
46
+ if not match:
47
+ continue
48
+ finding = self.map_match(match)
49
+ if finding is not None:
50
+ findings.append(finding)
51
+ return findings
52
+
53
+ @abstractmethod
54
+ def map_match(self, match: re.Match[str]) -> Finding | None:
55
+ raise NotImplementedError
56
+
57
+
58
+ class DiffTextParser(Parser):
59
+ """Subclasses implement format-diff scanning in parse()."""
60
+
61
+
62
+ class PolicyJsonParser(Parser):
63
+ needs_check: ClassVar[bool] = True
64
+
65
+ def parse(self, stdout: str, stderr: str = "", *, check: CheckDef | None = None) -> list[Finding]:
66
+ if check is None:
67
+ raise ValueError(f"{self.id} requires check")
68
+ if not stdout.strip():
69
+ return []
70
+ return self.parse_payload(json.loads(stdout), check)
71
+
72
+ @abstractmethod
73
+ def parse_payload(self, payload: dict, check: CheckDef) -> list[Finding]:
74
+ raise NotImplementedError
75
+
76
+
77
+ class FallbackTextParser(Parser):
78
+ """Subclasses implement free-form text parsing in parse()."""