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,193 @@
|
|
|
1
|
+
"""Prose and misc linter parsers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any, ClassVar
|
|
8
|
+
|
|
9
|
+
from pyossmtool.models import CheckDef, Finding, Location, Severity
|
|
10
|
+
from pyossmtool.parsers.base import Parser, register
|
|
11
|
+
from pyossmtool.parsers.gates import CliTextParser
|
|
12
|
+
from pyossmtool.parsers.patterns import FallbackTextParser, LineRegexParser
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _load_json_or_none(payload_text: str) -> Any:
|
|
16
|
+
try:
|
|
17
|
+
return json.loads(payload_text)
|
|
18
|
+
except json.JSONDecodeError:
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@register
|
|
23
|
+
class GitleaksParser(Parser):
|
|
24
|
+
id = "gitleaks_json"
|
|
25
|
+
|
|
26
|
+
def parse(self, stdout: str, stderr: str = "", *, check: CheckDef | None = None) -> list[Finding]:
|
|
27
|
+
payload_text = stdout.strip() or stderr.strip()
|
|
28
|
+
if not payload_text:
|
|
29
|
+
return []
|
|
30
|
+
try:
|
|
31
|
+
payload = json.loads(payload_text)
|
|
32
|
+
except json.JSONDecodeError:
|
|
33
|
+
return CliTextParser().parse(stdout, stderr)
|
|
34
|
+
return [self.map_item(item) for item in payload]
|
|
35
|
+
|
|
36
|
+
def map_item(self, item: dict) -> Finding:
|
|
37
|
+
return Finding(
|
|
38
|
+
rule_id=item.get("RuleID", "gitleaks"),
|
|
39
|
+
severity=Severity.ERROR,
|
|
40
|
+
message=f"Secret detected: {item.get('Description', 'potential leak')}",
|
|
41
|
+
location=Location(
|
|
42
|
+
file=item.get("File", ""),
|
|
43
|
+
line=item.get("StartLine"),
|
|
44
|
+
end_line=item.get("EndLine"),
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@register
|
|
50
|
+
class CodespellParser(LineRegexParser):
|
|
51
|
+
id = "codespell_text"
|
|
52
|
+
pattern: ClassVar[re.Pattern[str]] = re.compile(
|
|
53
|
+
r"^(?P<file>[^:]+):(?P<line>\d+):\s*(?P<wrong>[^=]+)\s*==>\s*(?P<right>[^\s]+)"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def map_match(self, match: re.Match[str]) -> Finding:
|
|
57
|
+
return Finding(
|
|
58
|
+
rule_id="spelling",
|
|
59
|
+
severity=Severity.ERROR,
|
|
60
|
+
message=f"{match.group('wrong').strip()} -> {match.group('right').strip()}",
|
|
61
|
+
location=Location(file=match.group("file"), line=int(match.group("line"))),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@register
|
|
66
|
+
class MarkdownlintParser(Parser):
|
|
67
|
+
id = "markdownlint_json"
|
|
68
|
+
|
|
69
|
+
def parse(self, stdout: str, stderr: str = "", *, check: CheckDef | None = None) -> list[Finding]:
|
|
70
|
+
payload_text = stdout.strip() or stderr.strip()
|
|
71
|
+
if not payload_text:
|
|
72
|
+
return []
|
|
73
|
+
payload = _load_json_or_none(payload_text)
|
|
74
|
+
if payload is None:
|
|
75
|
+
return CliTextParser().parse(stdout, stderr)
|
|
76
|
+
return self._from_payload(payload)
|
|
77
|
+
|
|
78
|
+
def _from_payload(self, payload: Any) -> list[Finding]:
|
|
79
|
+
if isinstance(payload, list):
|
|
80
|
+
return self._list_findings(payload)
|
|
81
|
+
if isinstance(payload, dict):
|
|
82
|
+
return self._dict_findings(payload)
|
|
83
|
+
return []
|
|
84
|
+
|
|
85
|
+
def _list_findings(self, payload: list) -> list[Finding]:
|
|
86
|
+
findings: list[Finding] = []
|
|
87
|
+
for item in payload:
|
|
88
|
+
if isinstance(item, dict):
|
|
89
|
+
file_path = item.get("fileName", item.get("file", ""))
|
|
90
|
+
findings.append(self._item_finding(item, file_path))
|
|
91
|
+
return findings
|
|
92
|
+
|
|
93
|
+
def _dict_findings(self, payload: dict) -> list[Finding]:
|
|
94
|
+
findings: list[Finding] = []
|
|
95
|
+
for file_path, items in payload.items():
|
|
96
|
+
for item in items:
|
|
97
|
+
findings.append(self._item_finding(item, file_path))
|
|
98
|
+
return findings
|
|
99
|
+
|
|
100
|
+
def _item_finding(self, item: dict, file_path: str) -> Finding:
|
|
101
|
+
rule_names = item.get("ruleNames")
|
|
102
|
+
rule_id = rule_names[0] if rule_names else "markdownlint"
|
|
103
|
+
return Finding(
|
|
104
|
+
rule_id=rule_id,
|
|
105
|
+
severity=Severity.ERROR,
|
|
106
|
+
message=item.get("ruleDescription", item.get("ruleInformation", "markdownlint finding")),
|
|
107
|
+
location=Location(
|
|
108
|
+
file=file_path,
|
|
109
|
+
line=item.get("lineNumber"),
|
|
110
|
+
column=item.get("columnNumber"),
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@register
|
|
116
|
+
class YamllintParser(LineRegexParser):
|
|
117
|
+
id = "yamllint_text"
|
|
118
|
+
pattern: ClassVar[re.Pattern[str]] = re.compile(
|
|
119
|
+
r"^(?P<file>[^:]+):(?P<line>\d+):(?P<col>\d+):\s*"
|
|
120
|
+
r"\[(?P<severity>error|warning)\]\s*(?P<message>.+)$"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def map_match(self, match: re.Match[str]) -> Finding | None:
|
|
124
|
+
if match.group("severity") != "error":
|
|
125
|
+
return None
|
|
126
|
+
return Finding(
|
|
127
|
+
rule_id="yamllint",
|
|
128
|
+
severity=Severity.ERROR,
|
|
129
|
+
message=match.group("message").strip(),
|
|
130
|
+
location=Location(
|
|
131
|
+
file=match.group("file"),
|
|
132
|
+
line=int(match.group("line")),
|
|
133
|
+
column=int(match.group("col")),
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@register
|
|
139
|
+
class HadolintParser(Parser):
|
|
140
|
+
id = "hadolint_json"
|
|
141
|
+
|
|
142
|
+
def parse(self, stdout: str, stderr: str = "", *, check: CheckDef | None = None) -> list[Finding]:
|
|
143
|
+
payload_text = stdout.strip() or stderr.strip()
|
|
144
|
+
if not payload_text:
|
|
145
|
+
return []
|
|
146
|
+
payload = _load_json_or_none(payload_text)
|
|
147
|
+
if payload is None:
|
|
148
|
+
return CliTextParser().parse(stdout, stderr)
|
|
149
|
+
return [self.map_item(item) for item in payload]
|
|
150
|
+
|
|
151
|
+
def map_item(self, item: dict) -> Finding:
|
|
152
|
+
return Finding(
|
|
153
|
+
rule_id=item.get("code", "hadolint"),
|
|
154
|
+
severity=Severity.ERROR if item.get("level") == "error" else Severity.WARNING,
|
|
155
|
+
message=item.get("message", "hadolint finding"),
|
|
156
|
+
location=Location(file=item.get("file", ""), line=item.get("line")),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@register
|
|
161
|
+
class MutmutParser(FallbackTextParser):
|
|
162
|
+
id = "mutmut_text"
|
|
163
|
+
|
|
164
|
+
def parse(self, stdout: str, stderr: str = "", *, check: CheckDef | None = None) -> list[Finding]:
|
|
165
|
+
text = (stdout or stderr).strip()
|
|
166
|
+
if not text or self._all_passed(text):
|
|
167
|
+
return []
|
|
168
|
+
return CliTextParser().parse(stdout, stderr)
|
|
169
|
+
|
|
170
|
+
def _all_passed(self, text: str) -> bool:
|
|
171
|
+
lowered = text.lower()
|
|
172
|
+
return "passed" in lowered and "failed" not in lowered
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@register
|
|
176
|
+
class SourceryParser(FallbackTextParser):
|
|
177
|
+
id = "sourcery_text"
|
|
178
|
+
|
|
179
|
+
def parse(self, stdout: str, stderr: str = "", *, check: CheckDef | None = None) -> list[Finding]:
|
|
180
|
+
text = (stdout or stderr).strip()
|
|
181
|
+
if not text:
|
|
182
|
+
return []
|
|
183
|
+
return [
|
|
184
|
+
Finding(rule_id="sourcery", severity=Severity.WARNING, message=message)
|
|
185
|
+
for line in text.splitlines()
|
|
186
|
+
if (message := self._message(line)) is not None
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
def _message(self, line: str) -> str | None:
|
|
190
|
+
stripped = line.strip()
|
|
191
|
+
if not stripped or stripped.lower().startswith("reviewing"):
|
|
192
|
+
return None
|
|
193
|
+
return stripped
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Ruff parsers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pyossmtool.models import CheckDef, Finding, FixHint, Location, Severity
|
|
6
|
+
from pyossmtool.parsers.base import register
|
|
7
|
+
from pyossmtool.parsers.common import strip_ansi
|
|
8
|
+
from pyossmtool.parsers.patterns import DiffTextParser, JsonListParser
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@register
|
|
12
|
+
class RuffJsonParser(JsonListParser):
|
|
13
|
+
id = "ruff_json"
|
|
14
|
+
|
|
15
|
+
def map_item(self, item: dict) -> Finding:
|
|
16
|
+
filename = item.get("filename")
|
|
17
|
+
location = None
|
|
18
|
+
if filename:
|
|
19
|
+
location = Location(
|
|
20
|
+
file=filename,
|
|
21
|
+
line=item.get("location", {}).get("row"),
|
|
22
|
+
column=item.get("location", {}).get("column"),
|
|
23
|
+
end_line=item.get("end_location", {}).get("row"),
|
|
24
|
+
end_column=item.get("end_location", {}).get("column"),
|
|
25
|
+
)
|
|
26
|
+
code = item.get("code", "ruff")
|
|
27
|
+
return Finding(
|
|
28
|
+
rule_id=code,
|
|
29
|
+
severity=Severity.ERROR if str(code).startswith("E") else Severity.WARNING,
|
|
30
|
+
message=item.get("message", "ruff finding"),
|
|
31
|
+
location=location,
|
|
32
|
+
snippet=item.get("message"),
|
|
33
|
+
fix=FixHint(
|
|
34
|
+
description="Apply ruff auto-fix",
|
|
35
|
+
command=f"ruff check --fix {filename}",
|
|
36
|
+
)
|
|
37
|
+
if filename
|
|
38
|
+
else None,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@register
|
|
43
|
+
class RuffFormatParser(DiffTextParser):
|
|
44
|
+
id = "ruff_format_text"
|
|
45
|
+
|
|
46
|
+
def parse(self, stdout: str, stderr: str = "", *, check: CheckDef | None = None) -> list[Finding]:
|
|
47
|
+
text = strip_ansi(stdout or stderr)
|
|
48
|
+
if not text.strip():
|
|
49
|
+
return []
|
|
50
|
+
findings, _current_file = self._scan_lines(text.splitlines())
|
|
51
|
+
if findings:
|
|
52
|
+
return findings
|
|
53
|
+
if "reformatted" not in text.lower():
|
|
54
|
+
return []
|
|
55
|
+
return [
|
|
56
|
+
Finding(
|
|
57
|
+
rule_id="format",
|
|
58
|
+
severity=Severity.ERROR,
|
|
59
|
+
message="Formatting differences detected",
|
|
60
|
+
snippet=text[:500],
|
|
61
|
+
fix=FixHint(description="Run ruff format", command="ruff format"),
|
|
62
|
+
)
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
def _scan_lines(self, lines: list[str]) -> tuple[list[Finding], str | None]:
|
|
66
|
+
findings: list[Finding] = []
|
|
67
|
+
current_file: str | None = None
|
|
68
|
+
for line in lines:
|
|
69
|
+
current_file = self._file_from_line(line, current_file)
|
|
70
|
+
finding = self._finding_from_line(line, current_file)
|
|
71
|
+
if finding is not None:
|
|
72
|
+
findings.append(finding)
|
|
73
|
+
continue
|
|
74
|
+
self._append_diff(findings, line, current_file)
|
|
75
|
+
return findings, current_file
|
|
76
|
+
|
|
77
|
+
def _append_diff(self, findings: list[Finding], line: str, current_file: str | None) -> None:
|
|
78
|
+
if not line.startswith(("---", "+++", "@@")):
|
|
79
|
+
return
|
|
80
|
+
if not findings or not current_file:
|
|
81
|
+
return
|
|
82
|
+
findings[-1].snippet = (findings[-1].snippet or "") + line + "\n"
|
|
83
|
+
|
|
84
|
+
def _file_from_line(self, line: str, current_file: str | None) -> str | None:
|
|
85
|
+
if line.endswith(".py") or line.endswith(".py would be reformatted"):
|
|
86
|
+
return strip_ansi(line.split()[0].rstrip(":"))
|
|
87
|
+
if line.startswith("Would reformat:"):
|
|
88
|
+
return line.split(":", 1)[1].strip()
|
|
89
|
+
return current_file
|
|
90
|
+
|
|
91
|
+
def _finding_from_line(self, line: str, current_file: str | None) -> Finding | None:
|
|
92
|
+
if not line.startswith("Would reformat:"):
|
|
93
|
+
return None
|
|
94
|
+
return Finding(
|
|
95
|
+
rule_id="format",
|
|
96
|
+
severity=Severity.ERROR,
|
|
97
|
+
message="File would be reformatted",
|
|
98
|
+
location=Location(file=current_file) if current_file else None,
|
|
99
|
+
fix=FixHint(
|
|
100
|
+
description="Run ruff format",
|
|
101
|
+
command=f"ruff format {current_file}" if current_file else "ruff format",
|
|
102
|
+
),
|
|
103
|
+
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Shell tool parsers (shellcheck, shfmt)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pyossmtool.models import CheckDef, Finding, FixHint, Location, Severity
|
|
6
|
+
from pyossmtool.parsers.base import register
|
|
7
|
+
from pyossmtool.parsers.common import strip_ansi
|
|
8
|
+
from pyossmtool.parsers.patterns import DiffTextParser, JsonListParser
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@register
|
|
12
|
+
class ShellcheckParser(JsonListParser):
|
|
13
|
+
id = "shellcheck_json"
|
|
14
|
+
|
|
15
|
+
def map_item(self, item: dict) -> Finding:
|
|
16
|
+
level = item.get("level", "warning")
|
|
17
|
+
severity = Severity.ERROR if level in {"error", "warning"} else Severity.INFO
|
|
18
|
+
return Finding(
|
|
19
|
+
rule_id=f"SC{item.get('code', 0)}",
|
|
20
|
+
severity=severity,
|
|
21
|
+
message=item.get("message", "shellcheck finding"),
|
|
22
|
+
location=Location(
|
|
23
|
+
file=item.get("file", ""),
|
|
24
|
+
line=item.get("line"),
|
|
25
|
+
column=item.get("column"),
|
|
26
|
+
end_line=item.get("endLine"),
|
|
27
|
+
end_column=item.get("endColumn"),
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@register
|
|
33
|
+
class ShfmtDiffParser(DiffTextParser):
|
|
34
|
+
id = "shfmt_diff"
|
|
35
|
+
|
|
36
|
+
def parse(self, stdout: str, stderr: str = "", *, check: CheckDef | None = None) -> list[Finding]:
|
|
37
|
+
text = strip_ansi(stdout or stderr)
|
|
38
|
+
if not text.strip():
|
|
39
|
+
return []
|
|
40
|
+
findings = self._collect_findings(text.splitlines())
|
|
41
|
+
if findings:
|
|
42
|
+
return findings
|
|
43
|
+
return [
|
|
44
|
+
Finding(
|
|
45
|
+
rule_id="format",
|
|
46
|
+
severity=Severity.ERROR,
|
|
47
|
+
message="Shell formatting differences detected",
|
|
48
|
+
snippet=text[:500],
|
|
49
|
+
)
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
def _collect_findings(self, lines: list[str]) -> list[Finding]:
|
|
53
|
+
findings: list[Finding] = []
|
|
54
|
+
current_file: str | None = None
|
|
55
|
+
for line in lines:
|
|
56
|
+
current_file = self._file_from_diff_line(line, current_file)
|
|
57
|
+
finding = self._diff_finding(line, current_file)
|
|
58
|
+
if finding is not None:
|
|
59
|
+
findings.append(finding)
|
|
60
|
+
return findings
|
|
61
|
+
|
|
62
|
+
def _file_from_diff_line(self, line: str, current_file: str | None) -> str | None:
|
|
63
|
+
if not line.startswith(("--- ", "+++ ")):
|
|
64
|
+
return current_file
|
|
65
|
+
marker = line[4:].split("\t")[0].strip()
|
|
66
|
+
if marker and marker != "/dev/null":
|
|
67
|
+
return marker
|
|
68
|
+
return current_file
|
|
69
|
+
|
|
70
|
+
def _diff_finding(self, line: str, current_file: str | None) -> Finding | None:
|
|
71
|
+
if line.startswith("@@") or not line.startswith(("-", "+")):
|
|
72
|
+
return None
|
|
73
|
+
return Finding(
|
|
74
|
+
rule_id="format",
|
|
75
|
+
severity=Severity.ERROR,
|
|
76
|
+
message="Shell formatting difference",
|
|
77
|
+
location=Location(file=current_file) if current_file else None,
|
|
78
|
+
snippet=line[:200],
|
|
79
|
+
fix=FixHint(
|
|
80
|
+
description="Run shfmt -w",
|
|
81
|
+
command=f"shfmt -w {current_file}" if current_file else "shfmt -w",
|
|
82
|
+
),
|
|
83
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Documented-acronym policy gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from pyossmtool.ignore import EffectiveIgnores, ignores_from_env
|
|
16
|
+
|
|
17
|
+
ACRONYM_RE = re.compile(r"\b[A-Z]{2,}\b")
|
|
18
|
+
FENCED_CODE_BLOCK_RE = re.compile(r"(`{3,})[\s\S]*?\1", re.DOTALL)
|
|
19
|
+
INLINE_CODE_RE = re.compile(r"`[^`]+`")
|
|
20
|
+
|
|
21
|
+
BUILTIN_EXEMPT: frozenset[str] = frozenset(
|
|
22
|
+
{
|
|
23
|
+
"AA",
|
|
24
|
+
"AGENTS",
|
|
25
|
+
"AMC",
|
|
26
|
+
"AND",
|
|
27
|
+
"API",
|
|
28
|
+
"ASGI",
|
|
29
|
+
"AST",
|
|
30
|
+
"AVOIDANCE",
|
|
31
|
+
"BIGCO",
|
|
32
|
+
"BLUE",
|
|
33
|
+
"BMO",
|
|
34
|
+
"BUMPY",
|
|
35
|
+
"CALM",
|
|
36
|
+
"CHEAP",
|
|
37
|
+
"CI",
|
|
38
|
+
"CLI",
|
|
39
|
+
"CM",
|
|
40
|
+
"CORS",
|
|
41
|
+
"CPU",
|
|
42
|
+
"CRUD",
|
|
43
|
+
"CSRF",
|
|
44
|
+
"CSS",
|
|
45
|
+
"CSV",
|
|
46
|
+
"DD",
|
|
47
|
+
"DEAR",
|
|
48
|
+
"DELETE",
|
|
49
|
+
"DO",
|
|
50
|
+
"DTO",
|
|
51
|
+
"DX",
|
|
52
|
+
"ECHO",
|
|
53
|
+
"EPSILON",
|
|
54
|
+
"ESG",
|
|
55
|
+
"FAIL",
|
|
56
|
+
"FAILED",
|
|
57
|
+
"FIFO",
|
|
58
|
+
"FLAT",
|
|
59
|
+
"FK",
|
|
60
|
+
"GB",
|
|
61
|
+
"GET",
|
|
62
|
+
"GICS",
|
|
63
|
+
"GPL",
|
|
64
|
+
"GREEN",
|
|
65
|
+
"HEAD",
|
|
66
|
+
"HEREDOC",
|
|
67
|
+
"HIGH",
|
|
68
|
+
"HOW",
|
|
69
|
+
"HTML",
|
|
70
|
+
"HTTP",
|
|
71
|
+
"HTTPS",
|
|
72
|
+
"IA",
|
|
73
|
+
"IDE",
|
|
74
|
+
"ID",
|
|
75
|
+
"II",
|
|
76
|
+
"III",
|
|
77
|
+
"INFY",
|
|
78
|
+
"IO",
|
|
79
|
+
"IP",
|
|
80
|
+
"IPO",
|
|
81
|
+
"ISO",
|
|
82
|
+
"IST",
|
|
83
|
+
"IT",
|
|
84
|
+
"JSON",
|
|
85
|
+
"JSONB",
|
|
86
|
+
"JSCPD",
|
|
87
|
+
"JWT",
|
|
88
|
+
"KNOWLEDGE",
|
|
89
|
+
"LOC",
|
|
90
|
+
"LOW",
|
|
91
|
+
"MA",
|
|
92
|
+
"MB",
|
|
93
|
+
"MEDIUM",
|
|
94
|
+
"MID",
|
|
95
|
+
"MINT",
|
|
96
|
+
"MM",
|
|
97
|
+
"MOTHERSON",
|
|
98
|
+
"MOTHERSUMI",
|
|
99
|
+
"NO",
|
|
100
|
+
"NOT",
|
|
101
|
+
"NOVA",
|
|
102
|
+
"NULL",
|
|
103
|
+
"OK",
|
|
104
|
+
"ON",
|
|
105
|
+
"OOM",
|
|
106
|
+
"OPAL",
|
|
107
|
+
"OPTIONS",
|
|
108
|
+
"PARTIAL",
|
|
109
|
+
"PASS",
|
|
110
|
+
"PATCH",
|
|
111
|
+
"PATH",
|
|
112
|
+
"PDF",
|
|
113
|
+
"PEM",
|
|
114
|
+
"PG",
|
|
115
|
+
"PINE",
|
|
116
|
+
"PK",
|
|
117
|
+
"POST",
|
|
118
|
+
"PUT",
|
|
119
|
+
"QUILL",
|
|
120
|
+
"RAM",
|
|
121
|
+
"README",
|
|
122
|
+
"RED",
|
|
123
|
+
"RELIANCE",
|
|
124
|
+
"REST",
|
|
125
|
+
"ROSE",
|
|
126
|
+
"RUN",
|
|
127
|
+
"SCOPE",
|
|
128
|
+
"SHA",
|
|
129
|
+
"SIGINT",
|
|
130
|
+
"SIGTERM",
|
|
131
|
+
"SKILL",
|
|
132
|
+
"SLO",
|
|
133
|
+
"SMALLB",
|
|
134
|
+
"SMOOTH",
|
|
135
|
+
"SPA",
|
|
136
|
+
"SQL",
|
|
137
|
+
"SSG",
|
|
138
|
+
"SSR",
|
|
139
|
+
"SSH",
|
|
140
|
+
"SVG",
|
|
141
|
+
"SURVIVAL",
|
|
142
|
+
"TBD",
|
|
143
|
+
"TEMPORAL",
|
|
144
|
+
"TEXT",
|
|
145
|
+
"TIMESTAMPTZ",
|
|
146
|
+
"TODO",
|
|
147
|
+
"TS",
|
|
148
|
+
"UPDATE",
|
|
149
|
+
"UP",
|
|
150
|
+
"URL",
|
|
151
|
+
"UTC",
|
|
152
|
+
"UUID",
|
|
153
|
+
"VM",
|
|
154
|
+
"VPN",
|
|
155
|
+
"WCAG",
|
|
156
|
+
"WHAT",
|
|
157
|
+
"WILD",
|
|
158
|
+
"WORKDIR",
|
|
159
|
+
"WSL",
|
|
160
|
+
"XML",
|
|
161
|
+
"YAML",
|
|
162
|
+
"YYYY",
|
|
163
|
+
"ZIP",
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True, slots=True)
|
|
169
|
+
class AcronymViolation:
|
|
170
|
+
path: str
|
|
171
|
+
line: int
|
|
172
|
+
token: str
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def strip_markdown_code(text: str) -> str:
|
|
176
|
+
without_fences = FENCED_CODE_BLOCK_RE.sub("", text)
|
|
177
|
+
return INLINE_CODE_RE.sub("", without_fences)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def load_allowlist(path: Path) -> set[str]:
|
|
181
|
+
if not path.is_file():
|
|
182
|
+
return set()
|
|
183
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
184
|
+
if not isinstance(raw, dict):
|
|
185
|
+
msg = f"acronym allowlist must be a YAML mapping: {path}"
|
|
186
|
+
raise ValueError(msg)
|
|
187
|
+
return {str(key) for key in raw}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def find_violations_in_line(line: str, *, allowlisted: set[str]) -> list[str]:
|
|
191
|
+
tokens: list[str] = []
|
|
192
|
+
for match in ACRONYM_RE.finditer(line):
|
|
193
|
+
token = match.group(0)
|
|
194
|
+
if token in BUILTIN_EXEMPT or token in allowlisted:
|
|
195
|
+
continue
|
|
196
|
+
tokens.append(token)
|
|
197
|
+
return tokens
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def find_violations_in_text(text: str, *, path: str, allowlisted: set[str]) -> list[AcronymViolation]:
|
|
201
|
+
prose = strip_markdown_code(text)
|
|
202
|
+
violations: list[AcronymViolation] = []
|
|
203
|
+
for line_no, line in enumerate(prose.splitlines(), start=1):
|
|
204
|
+
for token in find_violations_in_line(line, allowlisted=allowlisted):
|
|
205
|
+
violations.append(AcronymViolation(path=path, line=line_no, token=token))
|
|
206
|
+
return violations
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def iter_markdown_files(scan_roots: tuple[str, ...], repo_root: Path) -> list[Path]:
|
|
210
|
+
files: list[Path] = []
|
|
211
|
+
for root in scan_roots:
|
|
212
|
+
candidate = repo_root / root
|
|
213
|
+
if candidate.is_file():
|
|
214
|
+
files.append(candidate)
|
|
215
|
+
continue
|
|
216
|
+
if candidate.is_dir():
|
|
217
|
+
files.extend(sorted(candidate.rglob("*.md")))
|
|
218
|
+
return files
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def scan_paths(
|
|
222
|
+
*,
|
|
223
|
+
repo_root: Path,
|
|
224
|
+
allowlist_path: Path,
|
|
225
|
+
scan_roots: tuple[str, ...],
|
|
226
|
+
ignores: EffectiveIgnores | None = None,
|
|
227
|
+
) -> list[AcronymViolation]:
|
|
228
|
+
allowlisted = load_allowlist(allowlist_path)
|
|
229
|
+
violations: list[AcronymViolation] = []
|
|
230
|
+
for file_path in iter_markdown_files(scan_roots, repo_root):
|
|
231
|
+
rel = file_path.relative_to(repo_root).as_posix()
|
|
232
|
+
if ignores and ignores.is_ignored(rel):
|
|
233
|
+
continue
|
|
234
|
+
text = file_path.read_text(encoding="utf-8")
|
|
235
|
+
violations.extend(find_violations_in_text(text, path=rel, allowlisted=allowlisted))
|
|
236
|
+
return violations
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def findings_from_violations(violations: list[AcronymViolation]) -> list[dict[str, Any]]:
|
|
240
|
+
findings: list[dict[str, Any]] = []
|
|
241
|
+
for item in violations:
|
|
242
|
+
findings.append(
|
|
243
|
+
{
|
|
244
|
+
"rule_id": "undocumented-acronym",
|
|
245
|
+
"severity": "error",
|
|
246
|
+
"message": f"undocumented acronym {item.token!r}",
|
|
247
|
+
"location": {"file": item.path, "line": item.line},
|
|
248
|
+
}
|
|
249
|
+
)
|
|
250
|
+
return findings
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def settings_from_config(config: dict[str, Any]) -> tuple[tuple[str, ...], Path | None]:
|
|
254
|
+
scan_roots = tuple(str(item) for item in config.get("scan_roots", ["docs/", "AGENTS.md"]))
|
|
255
|
+
allowlist_file = config.get("allowlist_file")
|
|
256
|
+
allowlist_path = Path(str(allowlist_file)) if allowlist_file else None
|
|
257
|
+
return scan_roots, allowlist_path
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def run_gate(
|
|
261
|
+
*,
|
|
262
|
+
root: Path,
|
|
263
|
+
config: dict[str, Any],
|
|
264
|
+
report_path: Path | None,
|
|
265
|
+
) -> int:
|
|
266
|
+
scan_roots, allowlist_path = settings_from_config(config)
|
|
267
|
+
allowlist_path = _require_allowlist_path(root, allowlist_path)
|
|
268
|
+
violations = scan_paths(
|
|
269
|
+
repo_root=root,
|
|
270
|
+
allowlist_path=allowlist_path,
|
|
271
|
+
scan_roots=scan_roots,
|
|
272
|
+
ignores=ignores_from_env(root),
|
|
273
|
+
)
|
|
274
|
+
_write_acronym_report(violations, report_path)
|
|
275
|
+
return _acronym_exit(violations)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _require_allowlist_path(root: Path, allowlist_path: Path | None) -> Path:
|
|
279
|
+
if allowlist_path is None:
|
|
280
|
+
msg = "acronym allowlist_file is required in gate config"
|
|
281
|
+
raise ValueError(msg)
|
|
282
|
+
if not allowlist_path.is_absolute():
|
|
283
|
+
return root / allowlist_path
|
|
284
|
+
return allowlist_path
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _write_acronym_report(violations, report_path: Path | None) -> None:
|
|
288
|
+
if not report_path:
|
|
289
|
+
return
|
|
290
|
+
report_path.parent.mkdir(parents=True, exist_ok=True)
|
|
291
|
+
payload = {"findings": findings_from_violations(violations)}
|
|
292
|
+
report_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _acronym_exit(violations) -> int:
|
|
296
|
+
if not violations:
|
|
297
|
+
return 0
|
|
298
|
+
for item in violations:
|
|
299
|
+
print(
|
|
300
|
+
f"FAIL acronym: {item.path}:{item.line}: undocumented acronym {item.token!r}",
|
|
301
|
+
file=sys.stderr,
|
|
302
|
+
)
|
|
303
|
+
return 1
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def main(argv: list[str] | None = None) -> int:
|
|
307
|
+
parser = argparse.ArgumentParser(description="Documented-acronym gate.")
|
|
308
|
+
parser.add_argument("--root", type=Path, default=Path.cwd())
|
|
309
|
+
parser.add_argument("--config", type=Path, required=True)
|
|
310
|
+
parser.add_argument("--report", type=Path, default=None)
|
|
311
|
+
args = parser.parse_args(argv)
|
|
312
|
+
|
|
313
|
+
root = args.root.resolve()
|
|
314
|
+
config = yaml.safe_load(args.config.read_text(encoding="utf-8")) or {}
|
|
315
|
+
if not isinstance(config, dict):
|
|
316
|
+
msg = f"Gate config must be a mapping: {args.config}"
|
|
317
|
+
raise ValueError(msg)
|
|
318
|
+
return run_gate(root=root, config=config, report_path=args.report)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
if __name__ == "__main__":
|
|
322
|
+
raise SystemExit(main())
|