code-standards 7.0.0__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.
- code_standards-7.0.0.dist-info/METADATA +53 -0
- code_standards-7.0.0.dist-info/RECORD +99 -0
- code_standards-7.0.0.dist-info/WHEEL +4 -0
- code_standards-7.0.0.dist-info/entry_points.txt +3 -0
- code_standards-7.0.0.dist-info/licenses/LICENSE +21 -0
- sarj_standards/__init__.py +30 -0
- sarj_standards/__main__.py +5 -0
- sarj_standards/_meta.py +22 -0
- sarj_standards/api.py +890 -0
- sarj_standards/cli/__init__.py +0 -0
- sarj_standards/cli/main.py +2466 -0
- sarj_standards/configs/cli-reference.v1.json +1 -0
- sarj_standards/configs/doctor.config.json +22 -0
- sarj_standards/configs/eslint.application.mjs +1366 -0
- sarj_standards/configs/eslint.peers.json +44 -0
- sarj_standards/configs/eslint.strict.mjs +1060 -0
- sarj_standards/configs/markdownlint.strict.yaml +12 -0
- sarj_standards/configs/pyright.strict.json +96 -0
- sarj_standards/configs/ruff.application.toml +363 -0
- sarj_standards/configs/ruff.strict.toml +338 -0
- sarj_standards/configs/rule-inventory.v1.json +1 -0
- sarj_standards/configs/rule-ledger.json +846 -0
- sarj_standards/configs/rule-warning-levels.v1.json +1 -0
- sarj_standards/configs/taplo.strict.toml +14 -0
- sarj_standards/configs/yamllint.strict.yaml +25 -0
- sarj_standards/libs/__init__.py +0 -0
- sarj_standards/libs/adoption/__init__.py +0 -0
- sarj_standards/libs/adoption/configs.py +36 -0
- sarj_standards/libs/adoption/doctor.py +1346 -0
- sarj_standards/libs/adoption/exclusions.py +66 -0
- sarj_standards/libs/adoption/hooks.py +423 -0
- sarj_standards/libs/adoption/launcher.py +240 -0
- sarj_standards/libs/adoption/lifecycle.py +493 -0
- sarj_standards/libs/adoption/manifest.py +550 -0
- sarj_standards/libs/adoption/packagemanager.py +285 -0
- sarj_standards/libs/adoption/retired_suppressions.py +371 -0
- sarj_standards/libs/adoption/scaffold.py +1660 -0
- sarj_standards/libs/adoption/service.py +441 -0
- sarj_standards/libs/adoption/transaction.py +274 -0
- sarj_standards/libs/adoption/upgrade.py +516 -0
- sarj_standards/libs/adoption/uvtool.py +62 -0
- sarj_standards/libs/catalogs/__init__.py +9 -0
- sarj_standards/libs/catalogs/slack_automations.py +627 -0
- sarj_standards/libs/corpus/__init__.py +25 -0
- sarj_standards/libs/corpus/manifest.py +211 -0
- sarj_standards/libs/corpus/snapshot.py +222 -0
- sarj_standards/libs/diagnostics/__init__.py +65 -0
- sarj_standards/libs/diagnostics/analysis.schema.json +161 -0
- sarj_standards/libs/diagnostics/baseline.py +131 -0
- sarj_standards/libs/diagnostics/models.py +574 -0
- sarj_standards/libs/diagnostics/serialize.py +290 -0
- sarj_standards/libs/diagnostics/source.py +172 -0
- sarj_standards/libs/filesystem.py +11 -0
- sarj_standards/libs/linting/__init__.py +0 -0
- sarj_standards/libs/linting/analysis.py +422 -0
- sarj_standards/libs/linting/external.py +1454 -0
- sarj_standards/libs/linting/library_policy.py +688 -0
- sarj_standards/libs/linting/policy.py +152 -0
- sarj_standards/libs/linting/runner.py +442 -0
- sarj_standards/libs/linting/textlint.py +1605 -0
- sarj_standards/libs/release/__init__.py +98 -0
- sarj_standards/libs/release/_values.py +24 -0
- sarj_standards/libs/release/artifacts.py +191 -0
- sarj_standards/libs/release/causality.py +80 -0
- sarj_standards/libs/release/changes.py +48 -0
- sarj_standards/libs/release/process.py +128 -0
- sarj_standards/libs/release/publish.py +85 -0
- sarj_standards/libs/release/registry.py +271 -0
- sarj_standards/libs/release/release_age.py +218 -0
- sarj_standards/libs/release/rollout.py +1163 -0
- sarj_standards/libs/release/tags.py +373 -0
- sarj_standards/libs/release/typescript.py +191 -0
- sarj_standards/libs/repository/__init__.py +0 -0
- sarj_standards/libs/repository/cli_reference_artifact.py +324 -0
- sarj_standards/libs/repository/comment_corpus.py +536 -0
- sarj_standards/libs/repository/config_generation.py +146 -0
- sarj_standards/libs/repository/docs.py +347 -0
- sarj_standards/libs/repository/hooks.py +118 -0
- sarj_standards/libs/repository/ledger.py +99 -0
- sarj_standards/libs/repository/repository.py +744 -0
- sarj_standards/libs/repository/rule_authoring.py +246 -0
- sarj_standards/libs/repository/rule_catalog_artifact.py +479 -0
- sarj_standards/libs/repository/rule_changes.py +318 -0
- sarj_standards/libs/repository/rule_inventory_artifact.py +142 -0
- sarj_standards/libs/repository/rule_lifecycle.py +167 -0
- sarj_standards/libs/repository/rule_maintenance.py +225 -0
- sarj_standards/libs/rules/__init__.py +74 -0
- sarj_standards/libs/rules/catalog.py +145 -0
- sarj_standards/libs/rules/contracts.py +382 -0
- sarj_standards/libs/rules/corpus_runner.py +365 -0
- sarj_standards/libs/rules/evaluation.py +177 -0
- sarj_standards/libs/setup/__init__.py +4 -0
- sarj_standards/libs/setup/repository.py +40 -0
- sarj_standards/py.typed +0 -0
- sarj_standards/schemas/__init__.py +4 -0
- sarj_standards/schemas/_paths.py +7 -0
- sarj_standards/schemas/rule-catalog.v1.json +1 -0
- sarj_standards/schemas/rule-catalog.v1.schema.json +112 -0
- sarj_standards/schemas/slack-automations.v1.schema.json +1751 -0
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from fnmatch import fnmatch
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess # ruff: ignore[suspicious-subprocess-import] -- fixed-argument git queries are required repository inputs.
|
|
13
|
+
import tomllib
|
|
14
|
+
from typing import Final
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from sarj_standards.libs.adoption.manifest import as_table, list_field, table_field, text_field
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_CONFLICT_RE: Final = re.compile(r"^(?:<<<<<<< |>>>>>>> |\|\|\|\|\|\|\| )", re.MULTILINE)
|
|
22
|
+
_GITHUB_MERGE_SUBJECT_RE: Final = re.compile(r"^Merge pull request #[1-9][0-9]* from [A-Za-z0-9_.-]+/[A-Za-z0-9._/-]+$")
|
|
23
|
+
_PRIVATE_REFS_FILE: Final = ".sarj-private-refs.toml"
|
|
24
|
+
_TEST_COMMAND_RE: Final = re.compile(r"(?:npm test|pytest|make (?:verify|test)\b)")
|
|
25
|
+
_PYPROJECT_VERSION_RE: Final = re.compile(r'^version = "([^"]+)"$', re.MULTILINE)
|
|
26
|
+
_ESLINT_RULE_RE: Final = re.compile(r'^\s*"([a-z0-9-]+)":', re.MULTILINE)
|
|
27
|
+
_ESLINT_MAP_RE: Final = re.compile(r"^const rules = \{$(?P<body>.*?)^\};$", re.MULTILINE | re.DOTALL)
|
|
28
|
+
_MARKDOWN_LOCATIONS: Final = (
|
|
29
|
+
".github/SECURITY.md",
|
|
30
|
+
".github/PULL_REQUEST_TEMPLATE.md",
|
|
31
|
+
"CODE_OF_CONDUCT.md",
|
|
32
|
+
"CONTRIBUTING.md",
|
|
33
|
+
"README.md",
|
|
34
|
+
"CLAUDE.md",
|
|
35
|
+
"packages/*/README.md",
|
|
36
|
+
"plugins/*/commands/*.md",
|
|
37
|
+
"plugins/*/skills/*/SKILL.md",
|
|
38
|
+
"plugins/*/skills/*/references/*.md",
|
|
39
|
+
"plugins/*/README.md",
|
|
40
|
+
"docs/audits/*.md",
|
|
41
|
+
)
|
|
42
|
+
_MANAGED_ROOT_CONFIGS: Final = (
|
|
43
|
+
(".ruff-strict.toml", "ruff.strict.toml"),
|
|
44
|
+
(".pyright-strict.json", "pyright.strict.json"),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class Finding:
|
|
50
|
+
check: str
|
|
51
|
+
where: str
|
|
52
|
+
message: str
|
|
53
|
+
|
|
54
|
+
def render(self) -> str:
|
|
55
|
+
return f"error[{self.check}]: {self.where}: {self.message}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class FilenameRule:
|
|
60
|
+
glob: str
|
|
61
|
+
pattern: re.Pattern[str]
|
|
62
|
+
label: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class RuleFamily:
|
|
67
|
+
name: str
|
|
68
|
+
source: str
|
|
69
|
+
tests: str
|
|
70
|
+
registry: str
|
|
71
|
+
extension: str
|
|
72
|
+
test_pattern: str
|
|
73
|
+
registry_pattern: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class ConfigReference:
|
|
78
|
+
glob: str
|
|
79
|
+
pattern: re.Pattern[str]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class VersionReference:
|
|
84
|
+
path: str
|
|
85
|
+
format: str
|
|
86
|
+
version: str
|
|
87
|
+
selector: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True, slots=True)
|
|
91
|
+
class RepositoryPolicy:
|
|
92
|
+
distinctive: tuple[str, ...]
|
|
93
|
+
contextual: tuple[str, ...]
|
|
94
|
+
private_excludes: tuple[str, ...]
|
|
95
|
+
forbidden_paths: tuple[str, ...]
|
|
96
|
+
filename_rules: tuple[FilenameRule, ...]
|
|
97
|
+
rule_families: tuple[RuleFamily, ...]
|
|
98
|
+
config_references: tuple[ConfigReference, ...]
|
|
99
|
+
version_references: tuple[VersionReference, ...]
|
|
100
|
+
canonical_config_dir: str
|
|
101
|
+
versions: Mapping[str, tuple[str, ...]]
|
|
102
|
+
known_manifests: tuple[str, ...]
|
|
103
|
+
known_locks: tuple[str, ...]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_policy(root: Path, *, private_refs_path: Path | None = None) -> RepositoryPolicy:
|
|
107
|
+
path = root / ".sarj-standards.toml"
|
|
108
|
+
try:
|
|
109
|
+
raw: object = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
110
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
111
|
+
msg = f"cannot load repository policy from {path}: {exc}"
|
|
112
|
+
raise ValueError(msg) from exc
|
|
113
|
+
repository = _table(as_table(raw), "repository", required=True)
|
|
114
|
+
_known_keys(
|
|
115
|
+
repository,
|
|
116
|
+
frozenset(
|
|
117
|
+
{
|
|
118
|
+
"canonical_config_dir",
|
|
119
|
+
"config_references",
|
|
120
|
+
"filename_rules",
|
|
121
|
+
"forbidden_paths",
|
|
122
|
+
"private_refs",
|
|
123
|
+
"rule_families",
|
|
124
|
+
"version_coverage",
|
|
125
|
+
"version_references",
|
|
126
|
+
"versions",
|
|
127
|
+
}
|
|
128
|
+
),
|
|
129
|
+
"repository",
|
|
130
|
+
)
|
|
131
|
+
private = _private_refs(root, _table(repository, "private_refs"), private_refs_path)
|
|
132
|
+
_known_keys(
|
|
133
|
+
private,
|
|
134
|
+
frozenset({"contextual", "distinctive", "exclude"}),
|
|
135
|
+
"private_refs",
|
|
136
|
+
)
|
|
137
|
+
coverage = _table(repository, "version_coverage")
|
|
138
|
+
_known_keys(coverage, frozenset({"locks", "manifests"}), "version_coverage")
|
|
139
|
+
return RepositoryPolicy(
|
|
140
|
+
distinctive=_strings(private, "distinctive"),
|
|
141
|
+
contextual=_strings(private, "contextual"),
|
|
142
|
+
private_excludes=_strings(private, "exclude"),
|
|
143
|
+
forbidden_paths=_strings(repository, "forbidden_paths"),
|
|
144
|
+
filename_rules=tuple(_filename_rules(_objects(repository, "filename_rules"))),
|
|
145
|
+
rule_families=tuple(_rule_families(_objects(repository, "rule_families"))),
|
|
146
|
+
config_references=tuple(_config_references(_objects(repository, "config_references"))),
|
|
147
|
+
version_references=tuple(_version_references(_objects(repository, "version_references"))),
|
|
148
|
+
canonical_config_dir=_optional_text(repository, "canonical_config_dir"),
|
|
149
|
+
versions={
|
|
150
|
+
key: _string_values(value, f"repository.versions.{key}")
|
|
151
|
+
for key, value in _table(repository, "versions").items()
|
|
152
|
+
},
|
|
153
|
+
known_manifests=_strings(coverage, "manifests"),
|
|
154
|
+
known_locks=_strings(coverage, "locks"),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def check(
|
|
159
|
+
root: Path,
|
|
160
|
+
*,
|
|
161
|
+
selected: frozenset[str] = frozenset(),
|
|
162
|
+
commits: str | None = None,
|
|
163
|
+
policy_root: Path | None = None,
|
|
164
|
+
private_refs_path: Path | None = None,
|
|
165
|
+
) -> list[Finding]:
|
|
166
|
+
if policy_root is not None and policy_root.resolve() != root.resolve() and selected != frozenset({"private-refs"}):
|
|
167
|
+
msg = "a separate policy root is restricted to the private-refs check"
|
|
168
|
+
raise ValueError(msg)
|
|
169
|
+
policy = load_policy(policy_root or root, private_refs_path=private_refs_path)
|
|
170
|
+
checks = {
|
|
171
|
+
"ci-history": lambda: check_ci_history(root),
|
|
172
|
+
"file-conventions": lambda: check_file_conventions(root, policy),
|
|
173
|
+
"private-refs": lambda: check_private_refs(root, policy, commits=commits),
|
|
174
|
+
"versions": lambda: check_versions(root, policy),
|
|
175
|
+
}
|
|
176
|
+
unknown = selected.difference(checks)
|
|
177
|
+
if unknown:
|
|
178
|
+
msg = f"unknown repository check(s): {', '.join(sorted(unknown))}"
|
|
179
|
+
raise ValueError(msg)
|
|
180
|
+
findings: list[Finding] = []
|
|
181
|
+
defaults = frozenset(checks).difference({"private-refs"})
|
|
182
|
+
for name, checker in checks.items():
|
|
183
|
+
if name in (selected or defaults):
|
|
184
|
+
findings.extend(checker())
|
|
185
|
+
return sorted(findings, key=lambda item: (item.check, item.where, item.message))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def check_private_refs(root: Path, policy: RepositoryPolicy, *, commits: str | None) -> list[Finding]:
|
|
189
|
+
if not any((policy.distinctive, policy.contextual)):
|
|
190
|
+
msg = "private-reference policy is unavailable"
|
|
191
|
+
raise ValueError(msg)
|
|
192
|
+
broad = _broad_private_pattern(policy.distinctive)
|
|
193
|
+
scoped = _scoped_private_pattern(policy.contextual)
|
|
194
|
+
findings: list[Finding] = []
|
|
195
|
+
for relative in _tracked(root):
|
|
196
|
+
findings.extend(_private_text_findings(relative, relative, broad, scoped))
|
|
197
|
+
if any(fnmatch(relative, pattern) for pattern in policy.private_excludes):
|
|
198
|
+
continue
|
|
199
|
+
text = _tracked_text(root, relative)
|
|
200
|
+
findings.extend(_private_text_findings(relative, text, broad, scoped))
|
|
201
|
+
if commits:
|
|
202
|
+
revisions = _git(root, "rev-list", "--reverse", commits, check=False)
|
|
203
|
+
if revisions.returncode != 0:
|
|
204
|
+
findings.append(Finding("private-refs", commits, "commit range does not resolve; fetch full history"))
|
|
205
|
+
else:
|
|
206
|
+
findings.extend(_commit_findings(root, revisions.stdout.splitlines(), policy, broad, scoped))
|
|
207
|
+
return list(dict.fromkeys(findings))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _commit_findings(
|
|
211
|
+
root: Path,
|
|
212
|
+
revisions: Sequence[str],
|
|
213
|
+
policy: RepositoryPolicy,
|
|
214
|
+
broad: re.Pattern[str] | None,
|
|
215
|
+
scoped: re.Pattern[str] | None,
|
|
216
|
+
) -> list[Finding]:
|
|
217
|
+
findings: list[Finding] = []
|
|
218
|
+
for revision in revisions:
|
|
219
|
+
message = _git(root, "show", "--quiet", "--format=%H%n%s%n%b", revision).stdout
|
|
220
|
+
parents = _git(root, "show", "--quiet", "--format=%P", revision).stdout.split()
|
|
221
|
+
if len(parents) > 1:
|
|
222
|
+
message = _without_generated_merge_subject(message)
|
|
223
|
+
if _matches_private_ref(message, broad, scoped) or _CONFLICT_RE.search(message):
|
|
224
|
+
findings.append(Finding("private-refs", revision, "private reference or conflict marker in commit message"))
|
|
225
|
+
changed = _git(
|
|
226
|
+
root,
|
|
227
|
+
"diff-tree",
|
|
228
|
+
"--root",
|
|
229
|
+
"-m",
|
|
230
|
+
"-r",
|
|
231
|
+
"--no-commit-id",
|
|
232
|
+
"--no-renames",
|
|
233
|
+
"--name-only",
|
|
234
|
+
"-z",
|
|
235
|
+
revision,
|
|
236
|
+
).stdout
|
|
237
|
+
for relative in (path for path in changed.split("\0") if path):
|
|
238
|
+
where = f"{revision}:{relative}"
|
|
239
|
+
findings.extend(_private_text_findings(where, relative, broad, scoped))
|
|
240
|
+
if any(fnmatch(relative, pattern) for pattern in policy.private_excludes):
|
|
241
|
+
continue
|
|
242
|
+
text = _revision_text(root, revision, relative)
|
|
243
|
+
if text is None:
|
|
244
|
+
continue
|
|
245
|
+
findings.extend(_private_text_findings(where, text, broad, scoped))
|
|
246
|
+
return findings
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _without_generated_merge_subject(message: str) -> str:
|
|
250
|
+
commit_hash, separator, remainder = message.partition("\n")
|
|
251
|
+
subject, body_separator, body = remainder.partition("\n")
|
|
252
|
+
if separator and _GITHUB_MERGE_SUBJECT_RE.fullmatch(subject):
|
|
253
|
+
return f"{commit_hash}\n{body}" if body_separator else commit_hash
|
|
254
|
+
return message
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _private_text_findings(
|
|
258
|
+
where: str,
|
|
259
|
+
text: str,
|
|
260
|
+
broad: re.Pattern[str] | None,
|
|
261
|
+
scoped: re.Pattern[str] | None,
|
|
262
|
+
) -> list[Finding]:
|
|
263
|
+
findings: list[Finding] = []
|
|
264
|
+
if _matches_private_ref(text, broad, scoped):
|
|
265
|
+
findings.append(Finding("private-refs", where, "private repository or client reference"))
|
|
266
|
+
if _CONFLICT_RE.search(text):
|
|
267
|
+
findings.append(Finding("private-refs", where, "unresolved conflict marker"))
|
|
268
|
+
return findings
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _revision_text(root: Path, revision: str, relative: str) -> str | None:
|
|
272
|
+
entry = _git(root, "ls-tree", "-z", revision, "--", relative).stdout.rstrip("\0")
|
|
273
|
+
if not entry:
|
|
274
|
+
return None
|
|
275
|
+
metadata, separator, _ = entry.partition("\t")
|
|
276
|
+
if not separator:
|
|
277
|
+
return None
|
|
278
|
+
match metadata.split():
|
|
279
|
+
case [_, "blob", object_id]:
|
|
280
|
+
pass
|
|
281
|
+
case _:
|
|
282
|
+
return None
|
|
283
|
+
text = _git(root, "cat-file", "blob", object_id).stdout
|
|
284
|
+
return "" if "\0" in text else text
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _private_refs(
|
|
288
|
+
root: Path,
|
|
289
|
+
public: Mapping[str, object],
|
|
290
|
+
private_refs_path: Path | None,
|
|
291
|
+
) -> Mapping[str, object]:
|
|
292
|
+
local_path = private_refs_path or root / _PRIVATE_REFS_FILE
|
|
293
|
+
if local_path.is_file():
|
|
294
|
+
try:
|
|
295
|
+
parsed: object = tomllib.loads(local_path.read_text(encoding="utf-8"))
|
|
296
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
297
|
+
msg = f"cannot load private-reference policy from {local_path}: {exc}"
|
|
298
|
+
raise ValueError(msg) from exc
|
|
299
|
+
return {**public, **_table(as_table(parsed), "private_refs", required=True)}
|
|
300
|
+
return public
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _broad_private_pattern(literals: Sequence[str]) -> re.Pattern[str] | None:
|
|
304
|
+
if not literals:
|
|
305
|
+
return None
|
|
306
|
+
alternatives = _private_alternation(literals)
|
|
307
|
+
return re.compile(rf"(^|[^A-Za-z0-9])(?:{alternatives})(?:[^A-Za-z0-9]|$)", re.IGNORECASE)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _scoped_private_pattern(literals: Sequence[str]) -> re.Pattern[str] | None:
|
|
311
|
+
if not literals:
|
|
312
|
+
return None
|
|
313
|
+
alternatives = _private_alternation(literals)
|
|
314
|
+
return re.compile(
|
|
315
|
+
rf"(^|[^A-Za-z0-9])(?:{alternatives})(?:/[A-Za-z0-9_.]|'s[^A-Za-z0-9]|'\s)|"
|
|
316
|
+
rf"^\s*\|\s*(?:{alternatives})\s*\|",
|
|
317
|
+
re.IGNORECASE | re.MULTILINE,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _matches_private_ref(
|
|
322
|
+
text: str,
|
|
323
|
+
broad: re.Pattern[str] | None,
|
|
324
|
+
scoped: re.Pattern[str] | None,
|
|
325
|
+
) -> bool:
|
|
326
|
+
return bool((broad and broad.search(text)) or (scoped and scoped.search(text)))
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def check_ci_history(root: Path) -> list[Finding]:
|
|
330
|
+
findings: list[Finding] = []
|
|
331
|
+
for path in sorted((root / ".github/workflows").glob("*.yml")):
|
|
332
|
+
try:
|
|
333
|
+
document: object = yaml.safe_load(path.read_text(encoding="utf-8")) # pyright: ignore[reportAny]
|
|
334
|
+
except (OSError, yaml.YAMLError) as exc:
|
|
335
|
+
findings.append(Finding("ci-history", str(path.relative_to(root)), f"invalid workflow YAML: {exc}"))
|
|
336
|
+
continue
|
|
337
|
+
jobs = table_field(as_table(document), "jobs")
|
|
338
|
+
for job_name, raw_job in jobs.items():
|
|
339
|
+
job = as_table(raw_job)
|
|
340
|
+
steps = list_field(job, "steps")
|
|
341
|
+
runs_tests = any(_TEST_COMMAND_RE.search(str(as_table(step).get("run", ""))) for step in steps)
|
|
342
|
+
if not runs_tests:
|
|
343
|
+
continue
|
|
344
|
+
full_history = any(_is_full_checkout(as_table(step)) for step in steps)
|
|
345
|
+
if not full_history:
|
|
346
|
+
relative = path.relative_to(root)
|
|
347
|
+
findings.append(
|
|
348
|
+
Finding("ci-history", f"{relative}:{job_name}", "test job needs checkout fetch-depth: 0")
|
|
349
|
+
)
|
|
350
|
+
return findings
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def check_file_conventions(root: Path, policy: RepositoryPolicy) -> list[Finding]:
|
|
354
|
+
tracked = _tracked(root)
|
|
355
|
+
findings = [
|
|
356
|
+
Finding("file-conventions", relative, "path is forbidden by repository policy")
|
|
357
|
+
for relative in tracked
|
|
358
|
+
if any(fnmatch(relative, pattern) for pattern in policy.forbidden_paths)
|
|
359
|
+
]
|
|
360
|
+
for rule in policy.filename_rules:
|
|
361
|
+
findings.extend(
|
|
362
|
+
Finding("file-conventions", relative, rule.label)
|
|
363
|
+
for relative in tracked
|
|
364
|
+
if fnmatch(relative, rule.glob) and not rule.pattern.fullmatch(Path(relative).name)
|
|
365
|
+
)
|
|
366
|
+
findings.extend(_check_markdown_locations(tracked))
|
|
367
|
+
for family in policy.rule_families:
|
|
368
|
+
findings.extend(_check_rule_family(root, family))
|
|
369
|
+
findings.extend(_check_config_copies(root, tracked, policy.canonical_config_dir))
|
|
370
|
+
findings.extend(_check_config_references(root, tracked, policy))
|
|
371
|
+
return findings
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def check_versions(root: Path, policy: RepositoryPolicy) -> list[Finding]:
|
|
375
|
+
versions = {name: _manifest_version(root / paths[0]) for name, paths in policy.versions.items() if paths}
|
|
376
|
+
findings: list[Finding] = []
|
|
377
|
+
for name, paths in policy.versions.items():
|
|
378
|
+
expected = versions.get(name)
|
|
379
|
+
for relative in paths:
|
|
380
|
+
actual = _manifest_version(root / relative)
|
|
381
|
+
if expected is None or actual is None:
|
|
382
|
+
findings.append(Finding("versions", relative, f"cannot read {name} version"))
|
|
383
|
+
elif actual != expected:
|
|
384
|
+
findings.append(Finding("versions", relative, f"version {actual} does not match {expected}"))
|
|
385
|
+
for reference in policy.version_references:
|
|
386
|
+
expected = versions.get(reference.version)
|
|
387
|
+
actual = _reference_version(root / reference.path, reference)
|
|
388
|
+
if expected != actual:
|
|
389
|
+
findings.append(
|
|
390
|
+
Finding(
|
|
391
|
+
"versions",
|
|
392
|
+
reference.path,
|
|
393
|
+
f"{reference.selector} is {actual or 'missing'}, expected {expected}",
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
findings.extend(_check_version_coverage(root, policy))
|
|
397
|
+
return findings
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _reference_version(path: Path, reference: VersionReference) -> str | None:
|
|
401
|
+
if reference.format == "uv-lock":
|
|
402
|
+
return _uv_lock_version(path, reference.selector)
|
|
403
|
+
if reference.format == "exact-pin":
|
|
404
|
+
match = re.search(rf'"{re.escape(reference.selector)}==([^"]+)"', _read_text(path))
|
|
405
|
+
return match.group(1) if match else None
|
|
406
|
+
if reference.format != "json-pointer":
|
|
407
|
+
msg = f"unknown version reference format: {reference.format}"
|
|
408
|
+
raise ValueError(msg)
|
|
409
|
+
try:
|
|
410
|
+
document: object = json.loads(path.read_text(encoding="utf-8")) # pyright: ignore[reportAny]
|
|
411
|
+
except OSError, json.JSONDecodeError:
|
|
412
|
+
return None
|
|
413
|
+
value = document
|
|
414
|
+
for token in reference.selector.removeprefix("/").split("/"):
|
|
415
|
+
value = as_table(value).get(token.replace("~1", "/").replace("~0", "~"))
|
|
416
|
+
return value if isinstance(value, str) else None
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _check_markdown_locations(tracked: Sequence[str]) -> list[Finding]:
|
|
420
|
+
return [
|
|
421
|
+
Finding("file-conventions", path, "Markdown is outside the maintained locations")
|
|
422
|
+
for path in tracked
|
|
423
|
+
if path.endswith(".md") and not any(fnmatch(path, pattern) for pattern in _MARKDOWN_LOCATIONS)
|
|
424
|
+
]
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _check_rule_family(root: Path, family: RuleFamily) -> list[Finding]:
|
|
428
|
+
source = root / family.source
|
|
429
|
+
tests = root / family.tests
|
|
430
|
+
registry = _read_text(root / family.registry)
|
|
431
|
+
findings: list[Finding] = []
|
|
432
|
+
for path in sorted(source.glob(f"*.{family.extension}")):
|
|
433
|
+
if path.stem.startswith("_"):
|
|
434
|
+
continue
|
|
435
|
+
test = tests / family.test_pattern.format(name=path.stem)
|
|
436
|
+
if not test.is_file():
|
|
437
|
+
findings.append(
|
|
438
|
+
Finding("file-conventions", str(path.relative_to(root)), f"missing {test.relative_to(root)}")
|
|
439
|
+
)
|
|
440
|
+
if family.registry_pattern.format(name=path.stem) not in registry:
|
|
441
|
+
findings.append(
|
|
442
|
+
Finding("file-conventions", str(path.relative_to(root)), "rule is absent from its registry")
|
|
443
|
+
)
|
|
444
|
+
test_glob = family.test_pattern.format(name="*")
|
|
445
|
+
prefix, suffix = family.test_pattern.split("{name}", maxsplit=1)
|
|
446
|
+
for path in sorted(tests.glob(test_glob)):
|
|
447
|
+
name = path.name.removeprefix(prefix).removesuffix(suffix)
|
|
448
|
+
if (
|
|
449
|
+
not (source / f"{name}.{family.extension}").is_file()
|
|
450
|
+
and not (source / f"_{name}.{family.extension}").is_file()
|
|
451
|
+
):
|
|
452
|
+
findings.append(Finding("file-conventions", str(path.relative_to(root)), "test names no rule or helper"))
|
|
453
|
+
return findings
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _check_config_copies(root: Path, tracked: Sequence[str], canonical_dir: str) -> list[Finding]:
|
|
457
|
+
canonical_root = root / canonical_dir
|
|
458
|
+
hashes = {_digest(path): path for path in canonical_root.iterdir() if path.is_file()}
|
|
459
|
+
findings: list[Finding] = []
|
|
460
|
+
for relative in tracked:
|
|
461
|
+
path = root / relative
|
|
462
|
+
if path.is_symlink() or not path.is_file() or canonical_root in path.parents:
|
|
463
|
+
continue
|
|
464
|
+
managed_source = _managed_config_source(root, path, canonical_root)
|
|
465
|
+
if managed_source is not None:
|
|
466
|
+
if _digest(path) != _digest(managed_source):
|
|
467
|
+
findings.append(
|
|
468
|
+
Finding(
|
|
469
|
+
"file-conventions",
|
|
470
|
+
relative,
|
|
471
|
+
f"generated config drifted from {managed_source.relative_to(root)}; run `code-standards setup`",
|
|
472
|
+
)
|
|
473
|
+
)
|
|
474
|
+
continue
|
|
475
|
+
original = hashes.get(_digest(path))
|
|
476
|
+
if original is not None:
|
|
477
|
+
findings.append(
|
|
478
|
+
Finding(
|
|
479
|
+
"file-conventions",
|
|
480
|
+
relative,
|
|
481
|
+
f"duplicates {original.relative_to(root)}; remove the unmanaged copy",
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
return findings
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _managed_config_source(root: Path, path: Path, canonical_root: Path) -> Path | None:
|
|
488
|
+
for destination, source in _MANAGED_ROOT_CONFIGS:
|
|
489
|
+
candidate = canonical_root / source
|
|
490
|
+
if path == root / destination and candidate.is_file():
|
|
491
|
+
return candidate
|
|
492
|
+
return None
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _is_full_checkout(step: Mapping[str, object]) -> bool:
|
|
496
|
+
if step.get("uses") is None or not str(step["uses"]).startswith("actions/checkout@"):
|
|
497
|
+
return False
|
|
498
|
+
depth = table_field(step, "with").get("fetch-depth")
|
|
499
|
+
return depth in {0, "0"}
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _manifest_version(path: Path) -> str | None:
|
|
503
|
+
if path.suffix == ".json":
|
|
504
|
+
try:
|
|
505
|
+
value: object = json.loads(path.read_text(encoding="utf-8")) # pyright: ignore[reportAny]
|
|
506
|
+
except OSError, json.JSONDecodeError:
|
|
507
|
+
return None
|
|
508
|
+
return text_field(as_table(value), "version")
|
|
509
|
+
match = _PYPROJECT_VERSION_RE.search(_read_text(path)[:2000])
|
|
510
|
+
return match.group(1) if match else None
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _uv_lock_version(path: Path, distribution: str) -> str | None:
|
|
514
|
+
try:
|
|
515
|
+
document: object = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
516
|
+
except OSError, tomllib.TOMLDecodeError:
|
|
517
|
+
return None
|
|
518
|
+
packages = list_field(as_table(document), "package")
|
|
519
|
+
for raw in packages:
|
|
520
|
+
package = as_table(raw)
|
|
521
|
+
if text_field(package, "name") == distribution:
|
|
522
|
+
return text_field(package, "version")
|
|
523
|
+
return None
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _tracked(root: Path) -> tuple[str, ...]:
|
|
527
|
+
output = _git(root, "ls-files", "-z").stdout
|
|
528
|
+
return tuple(path for path in output.split("\0") if path)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _git(root: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
532
|
+
executable = shutil.which("git")
|
|
533
|
+
if executable is None:
|
|
534
|
+
msg = "git is required for repository checks"
|
|
535
|
+
raise OSError(msg)
|
|
536
|
+
environment = os.environ.copy() # ruff: ignore[banned-api] -- preserve user Git configuration, but not a hook's repository binding.
|
|
537
|
+
local_names = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true]
|
|
538
|
+
[executable, "rev-parse", "--local-env-vars"],
|
|
539
|
+
check=True,
|
|
540
|
+
capture_output=True,
|
|
541
|
+
text=True,
|
|
542
|
+
).stdout.splitlines()
|
|
543
|
+
for name in local_names:
|
|
544
|
+
environment.pop(name, None)
|
|
545
|
+
return subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true]
|
|
546
|
+
[executable, *args],
|
|
547
|
+
cwd=root,
|
|
548
|
+
check=check,
|
|
549
|
+
capture_output=True,
|
|
550
|
+
env=environment,
|
|
551
|
+
errors="replace",
|
|
552
|
+
text=True,
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _read_text(path: Path) -> str:
|
|
557
|
+
content = path.read_bytes() if path.is_file() else b""
|
|
558
|
+
return "" if b"\0" in content else content.decode("utf-8", errors="replace")
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _tracked_text(root: Path, relative: str) -> str:
|
|
562
|
+
path = root / relative
|
|
563
|
+
if path.is_symlink():
|
|
564
|
+
return path.readlink().as_posix()
|
|
565
|
+
resolved_root = root.resolve()
|
|
566
|
+
resolved = path.resolve()
|
|
567
|
+
if resolved != resolved_root and not resolved.is_relative_to(resolved_root):
|
|
568
|
+
msg = f"tracked path escapes repository: {relative}"
|
|
569
|
+
raise ValueError(msg)
|
|
570
|
+
return _read_text(resolved)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _digest(path: Path) -> str:
|
|
574
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _private_alternation(literals: Sequence[str]) -> str:
|
|
578
|
+
return "|".join(map(re.escape, literals))
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _strings(table: Mapping[str, object], key: str) -> tuple[str, ...]:
|
|
582
|
+
if key not in table:
|
|
583
|
+
return ()
|
|
584
|
+
return _string_values(table[key], key)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _string_values(value: object, label: str) -> tuple[str, ...]:
|
|
588
|
+
if not isinstance(value, list):
|
|
589
|
+
msg = f"{label} must be a list of strings"
|
|
590
|
+
raise TypeError(msg)
|
|
591
|
+
items: list[object] = value # pyright: ignore[reportUnknownVariableType]
|
|
592
|
+
strings = tuple(item for item in items if isinstance(item, str))
|
|
593
|
+
if len(strings) != len(items):
|
|
594
|
+
msg = f"{label} must contain only strings"
|
|
595
|
+
raise ValueError(msg)
|
|
596
|
+
return strings
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _objects(table: Mapping[str, object], key: str) -> list[object]:
|
|
600
|
+
if key not in table:
|
|
601
|
+
return []
|
|
602
|
+
value = table[key]
|
|
603
|
+
if not isinstance(value, list):
|
|
604
|
+
msg = f"repository.{key} must be an array of tables"
|
|
605
|
+
raise TypeError(msg)
|
|
606
|
+
return value # pyright: ignore[reportUnknownVariableType]
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _table(table: Mapping[str, object], key: str, *, required: bool = False) -> Mapping[str, object]:
|
|
610
|
+
if key not in table:
|
|
611
|
+
if required:
|
|
612
|
+
msg = f"missing [{key}] table"
|
|
613
|
+
raise ValueError(msg)
|
|
614
|
+
return {}
|
|
615
|
+
value = table[key]
|
|
616
|
+
if not isinstance(value, dict):
|
|
617
|
+
msg = f"{key} must be a table"
|
|
618
|
+
raise TypeError(msg)
|
|
619
|
+
return as_table(value) # pyright: ignore[reportUnknownArgumentType]
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _known_keys(table: Mapping[str, object], allowed: frozenset[str], label: str) -> None:
|
|
623
|
+
unknown = set(table).difference(allowed)
|
|
624
|
+
if unknown:
|
|
625
|
+
msg = f"unknown {label} field(s): {', '.join(sorted(unknown))}"
|
|
626
|
+
raise ValueError(msg)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _optional_text(table: Mapping[str, object], key: str) -> str:
|
|
630
|
+
if key not in table:
|
|
631
|
+
return ""
|
|
632
|
+
value = table[key]
|
|
633
|
+
if not isinstance(value, str):
|
|
634
|
+
msg = f"repository.{key} must be a string"
|
|
635
|
+
raise TypeError(msg)
|
|
636
|
+
return value
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _required_texts(table: Mapping[str, object], keys: tuple[str, ...], label: str) -> tuple[str, ...]:
|
|
640
|
+
values = tuple(text_field(table, key) for key in keys)
|
|
641
|
+
missing = tuple(key for key, value in zip(keys, values, strict=True) if not value)
|
|
642
|
+
if missing:
|
|
643
|
+
msg = f"{label} requires: {', '.join(missing)}"
|
|
644
|
+
raise ValueError(msg)
|
|
645
|
+
return tuple(value or "" for value in values)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _filename_rules(values: Iterable[object]) -> Iterable[FilenameRule]:
|
|
649
|
+
for value in values:
|
|
650
|
+
table = as_table(value)
|
|
651
|
+
_known_keys(table, frozenset({"glob", "label", "pattern"}), "filename rule")
|
|
652
|
+
glob, pattern, label = _required_texts(table, ("glob", "pattern", "label"), "filename rule")
|
|
653
|
+
yield FilenameRule(glob, _compile_policy_regex(pattern, "filename rule"), label)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _rule_families(values: Iterable[object]) -> Iterable[RuleFamily]:
|
|
657
|
+
for value in values:
|
|
658
|
+
table = as_table(value)
|
|
659
|
+
_known_keys(
|
|
660
|
+
table,
|
|
661
|
+
frozenset({"extension", "name", "registry", "registry_pattern", "source", "test_pattern", "tests"}),
|
|
662
|
+
"rule family",
|
|
663
|
+
)
|
|
664
|
+
fields = _required_texts(
|
|
665
|
+
table,
|
|
666
|
+
("name", "source", "tests", "registry", "extension", "test_pattern", "registry_pattern"),
|
|
667
|
+
"rule family",
|
|
668
|
+
)
|
|
669
|
+
name, source, tests, registry, extension, test_pattern, registry_pattern = fields
|
|
670
|
+
yield RuleFamily(name, source, tests, registry, extension, test_pattern, registry_pattern)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _config_references(values: Iterable[object]) -> Iterable[ConfigReference]:
|
|
674
|
+
for value in values:
|
|
675
|
+
table = as_table(value)
|
|
676
|
+
_known_keys(table, frozenset({"glob", "pattern"}), "config reference")
|
|
677
|
+
glob, pattern = _required_texts(table, ("glob", "pattern"), "config reference")
|
|
678
|
+
yield ConfigReference(glob, _compile_policy_regex(pattern, "config reference", re.MULTILINE))
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _version_references(values: Iterable[object]) -> Iterable[VersionReference]:
|
|
682
|
+
for value in values:
|
|
683
|
+
table = as_table(value)
|
|
684
|
+
_known_keys(table, frozenset({"format", "path", "selector", "version"}), "version reference")
|
|
685
|
+
path, format_name, version, selector = _required_texts(
|
|
686
|
+
table, ("path", "format", "version", "selector"), "version reference"
|
|
687
|
+
)
|
|
688
|
+
yield VersionReference(path, format_name, version, selector)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _compile_policy_regex(pattern: str, label: str, flags: re.RegexFlag = re.NOFLAG) -> re.Pattern[str]:
|
|
692
|
+
try:
|
|
693
|
+
return re.compile(pattern, flags)
|
|
694
|
+
except re.PatternError as exc:
|
|
695
|
+
msg = f"invalid {label} regex: {pattern}"
|
|
696
|
+
raise ValueError(msg) from exc
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _check_config_references(root: Path, tracked: Sequence[str], policy: RepositoryPolicy) -> list[Finding]:
|
|
700
|
+
canonical = (root / policy.canonical_config_dir).resolve()
|
|
701
|
+
findings: list[Finding] = []
|
|
702
|
+
for rule in policy.config_references:
|
|
703
|
+
for relative in tracked:
|
|
704
|
+
if not fnmatch(relative, rule.glob):
|
|
705
|
+
continue
|
|
706
|
+
match = rule.pattern.search(_read_text(root / relative))
|
|
707
|
+
if match is None:
|
|
708
|
+
continue
|
|
709
|
+
reference = match.group(1)
|
|
710
|
+
target = root / Path(relative).parent / reference
|
|
711
|
+
if not target.exists():
|
|
712
|
+
findings.append(Finding("file-conventions", relative, f"extended config does not exist: {reference}"))
|
|
713
|
+
elif (
|
|
714
|
+
canonical not in target.resolve().parents
|
|
715
|
+
and _managed_config_source(root, target.resolve(), canonical) is None
|
|
716
|
+
):
|
|
717
|
+
findings.append(
|
|
718
|
+
Finding("file-conventions", relative, f"extended config is outside {policy.canonical_config_dir}")
|
|
719
|
+
)
|
|
720
|
+
return findings
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _check_version_coverage(root: Path, policy: RepositoryPolicy) -> list[Finding]:
|
|
724
|
+
known_manifests = set(policy.known_manifests)
|
|
725
|
+
known_locks = set(policy.known_locks)
|
|
726
|
+
tracked = _tracked(root)
|
|
727
|
+
manifests = {
|
|
728
|
+
path
|
|
729
|
+
for path in tracked
|
|
730
|
+
if fnmatch(path, "packages/*/pyproject.toml") or fnmatch(path, "packages/*/package.json")
|
|
731
|
+
}
|
|
732
|
+
locks = {path for path in tracked if path.endswith(("uv.lock", "package-lock.json"))}
|
|
733
|
+
return [
|
|
734
|
+
Finding("versions", path, "versioned package manifest is absent from version policy")
|
|
735
|
+
for path in sorted(manifests - known_manifests)
|
|
736
|
+
] + [Finding("versions", path, "lockfile is absent from version policy") for path in sorted(locks - known_locks)]
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def eslint_rule_names(root: Path) -> list[str]:
|
|
740
|
+
source = _read_text(root / "packages/typescript/src/index.ts")
|
|
741
|
+
body = _ESLINT_MAP_RE.search(source)
|
|
742
|
+
if body is None:
|
|
743
|
+
return []
|
|
744
|
+
return sorted(_ESLINT_RULE_RE.findall(body.group("body")))
|