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,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()."""
|