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