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,347 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import subprocess # ruff: ignore[suspicious-subprocess-import] -- validates fixed local CLI examples.
|
|
10
|
+
import sys
|
|
11
|
+
import tomllib
|
|
12
|
+
from typing import Final, TypeGuard
|
|
13
|
+
|
|
14
|
+
from sarj_standards.libs.adoption import transaction
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_GENERATED_SENTINEL: Final = "<!-- Generated by `code-standards maintain docs sync`; do not edit. -->"
|
|
18
|
+
_LOCAL_LINK: Final = re.compile(r"\[[^]]+\]\((?!https?://|mailto:)([^)#]*)(?:#([^)]+))?\)")
|
|
19
|
+
_HEADING: Final = re.compile(r"^#{1,6}\s+(.+?)\s*#*\s*$", re.MULTILINE)
|
|
20
|
+
_STANDARDS_COMMAND: Final = re.compile(r"^\s*(code-standards(?:\s+.+)?)\s*$", re.MULTILINE)
|
|
21
|
+
_DOCUMENT_SUFFIXES: Final = frozenset({".md", ".mdx", ".rst"})
|
|
22
|
+
_WALK_EXCLUDES: Final = frozenset(
|
|
23
|
+
{".git", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".venv", "build", "coverage", "dist", "node_modules"}
|
|
24
|
+
)
|
|
25
|
+
_GENERATED_READMES: Final = (
|
|
26
|
+
Path("README.md"),
|
|
27
|
+
Path("packages/bootstrap/README.md"),
|
|
28
|
+
Path("packages/standards/README.md"),
|
|
29
|
+
Path("packages/standards-compat/README.md"),
|
|
30
|
+
Path("packages/python/README.md"),
|
|
31
|
+
Path("packages/sql/README.md"),
|
|
32
|
+
Path("packages/iac/README.md"),
|
|
33
|
+
Path("packages/typescript/README.md"),
|
|
34
|
+
Path("packages/tsconfig/README.md"),
|
|
35
|
+
Path("packages/docs-ui/README.md"),
|
|
36
|
+
Path("plugins/sarj-audit/README.md"),
|
|
37
|
+
)
|
|
38
|
+
_EXECUTABLE_OR_LEGAL_DOCUMENTS: Final = (Path("CLAUDE.md"),)
|
|
39
|
+
_AUTHORED_DOCUMENTS: Final = (Path("docs/audits/rule-usefulness-audit.md"),)
|
|
40
|
+
_PACKAGE_DEFINITIONS: Final = (
|
|
41
|
+
("packages/standards/pyproject.toml", "PyPI", "text"),
|
|
42
|
+
("packages/standards-compat/pyproject.toml", "PyPI", None),
|
|
43
|
+
("packages/bootstrap/pyproject.toml", "PyPI", None),
|
|
44
|
+
("packages/python/pyproject.toml", "PyPI", "python"),
|
|
45
|
+
("packages/sql/pyproject.toml", "PyPI", "sql"),
|
|
46
|
+
("packages/iac/pyproject.toml", "PyPI", "iac"),
|
|
47
|
+
("packages/typescript/package.json", "npm", "eslint"),
|
|
48
|
+
("packages/tsconfig/package.json", "npm", None),
|
|
49
|
+
("packages/docs-ui/package.json", "npm", None),
|
|
50
|
+
)
|
|
51
|
+
_DOCUMENTATION_URL: Final = "https://code-standards.sarj.ai/"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class DocumentationResult:
|
|
56
|
+
changed: tuple[Path, ...]
|
|
57
|
+
checked: tuple[Path, ...]
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def status(self) -> int:
|
|
61
|
+
"""Return a process-compatible status: one means generated drift."""
|
|
62
|
+
return int(bool(self.changed))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def check(root: Path) -> DocumentationResult:
|
|
66
|
+
return _update(root.resolve(), write=False)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def sync(root: Path) -> DocumentationResult:
|
|
70
|
+
return _update(root.resolve(), write=True)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _update(root: Path, *, write: bool) -> DocumentationResult:
|
|
74
|
+
rendered = _render_readmes(root)
|
|
75
|
+
_validate_markdown_allowlist(root)
|
|
76
|
+
changed = tuple(
|
|
77
|
+
path for path, content in rendered.items() if not path.is_file() or path.read_text(encoding="utf-8") != content
|
|
78
|
+
)
|
|
79
|
+
checked = _documentation_paths(root)
|
|
80
|
+
_validate_documents(
|
|
81
|
+
{**{path: path.read_text(encoding="utf-8") for path in checked if path not in rendered}, **rendered}
|
|
82
|
+
)
|
|
83
|
+
if write:
|
|
84
|
+
for path in changed:
|
|
85
|
+
transaction.atomic_write_text(root, path, rendered[path])
|
|
86
|
+
return DocumentationResult(changed=changed, checked=checked)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _render_readmes(root: Path) -> dict[Path, str]:
|
|
90
|
+
packages = [
|
|
91
|
+
(root / relative, registry, engine, _manifest(root / relative))
|
|
92
|
+
for relative, registry, engine in _PACKAGE_DEFINITIONS
|
|
93
|
+
]
|
|
94
|
+
rendered = {root / "README.md": _root_readme(root, packages)}
|
|
95
|
+
for manifest_path, registry, engine, metadata in packages:
|
|
96
|
+
rendered[manifest_path.parent / "README.md"] = _package_readme(root, manifest_path, registry, engine, metadata)
|
|
97
|
+
rendered[root / "plugins/sarj-audit/README.md"] = _plugin_readme(root)
|
|
98
|
+
return dict(sorted(rendered.items()))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _root_readme(
|
|
102
|
+
root: Path,
|
|
103
|
+
packages: list[tuple[Path, str, str | None, dict[str, object]]],
|
|
104
|
+
) -> str:
|
|
105
|
+
del root
|
|
106
|
+
standards = packages[0][3]
|
|
107
|
+
title = _title(_string(standards, "name"))
|
|
108
|
+
sections = [
|
|
109
|
+
_GENERATED_SENTINEL,
|
|
110
|
+
f"# {title}",
|
|
111
|
+
_string(standards, "description"),
|
|
112
|
+
"```bash\nuv tool install --python 3.14 code-standards\n```",
|
|
113
|
+
(
|
|
114
|
+
"## Contributing\n\n"
|
|
115
|
+
"Install uv 0.12.5, Python 3.14, Node 24.19, and GNU Make. Then bootstrap a fresh checkout:\n\n"
|
|
116
|
+
"```bash\nmake setup\nmake verify\n```"
|
|
117
|
+
"\n\nOnce a new rule and its tests are registered, stage it as a warning and validate it locally:\n\n"
|
|
118
|
+
"```bash\n"
|
|
119
|
+
"code-standards --root . maintain rules stage-warning python:no-string-concat-in-loop\n"
|
|
120
|
+
"code-standards --root . maintain rules evaluate --rule python:no-string-concat-in-loop --scope corpus\n"
|
|
121
|
+
"make verify\n"
|
|
122
|
+
"```\n\nAfter committing the resulting changes, review the complete rule diff:\n\n"
|
|
123
|
+
"```bash\n"
|
|
124
|
+
"code-standards --root . maintain rules changes --before origin/main --after HEAD\n"
|
|
125
|
+
"```\n\n"
|
|
126
|
+
"Fleet calibration and downstream PR creation run automatically after review and release."
|
|
127
|
+
),
|
|
128
|
+
f"[Documentation]({_DOCUMENTATION_URL}) · [Source]({_source_url(standards)})",
|
|
129
|
+
]
|
|
130
|
+
return "\n\n".join(sections) + "\n"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _package_readme(
|
|
134
|
+
root: Path,
|
|
135
|
+
manifest_path: Path,
|
|
136
|
+
registry: str,
|
|
137
|
+
engine: str | None,
|
|
138
|
+
metadata: dict[str, object],
|
|
139
|
+
) -> str:
|
|
140
|
+
del root, manifest_path
|
|
141
|
+
name = _string(metadata, "name")
|
|
142
|
+
sections = [
|
|
143
|
+
_GENERATED_SENTINEL,
|
|
144
|
+
f"# {name}",
|
|
145
|
+
_string(metadata, "description"),
|
|
146
|
+
f"```bash\n{_install_command(name, registry)}\n```",
|
|
147
|
+
_package_usage(name, engine, version=_string(metadata, "version")),
|
|
148
|
+
f"[Documentation]({_homepage(metadata)}) · [Source]({_source_url(metadata)})",
|
|
149
|
+
]
|
|
150
|
+
return "\n\n".join(sections) + "\n"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _package_usage(name: str, engine: str | None, *, version: str) -> str:
|
|
154
|
+
if name == "code-standards":
|
|
155
|
+
return (
|
|
156
|
+
"Use it from pre-commit with a coding agent so violations are flagged and fixed before commit.\n\n"
|
|
157
|
+
"```bash\n"
|
|
158
|
+
"code-standards setup\n"
|
|
159
|
+
"code-standards check\n"
|
|
160
|
+
"code-standards fix\n"
|
|
161
|
+
"code-standards doctor\n"
|
|
162
|
+
"code-standards update\n"
|
|
163
|
+
"```"
|
|
164
|
+
)
|
|
165
|
+
if name == "sarj-standards":
|
|
166
|
+
return (
|
|
167
|
+
"Compatibility bridge for existing exact launchers after the distribution was renamed to "
|
|
168
|
+
"`code-standards`. New installations should use `code-standards` directly."
|
|
169
|
+
)
|
|
170
|
+
if name == "sarj-standards-bootstrap":
|
|
171
|
+
return (
|
|
172
|
+
"Generated CI and hooks pin this protocol package exactly; ordinary Standards upgrades change only "
|
|
173
|
+
"`.sarj-standards.toml`.\n\n"
|
|
174
|
+
"```bash\n"
|
|
175
|
+
"uvx --no-config --isolated --python 3.14 --from "
|
|
176
|
+
f"sarj-standards-bootstrap=={version} code-standards check\n"
|
|
177
|
+
"```\n\n"
|
|
178
|
+
"The bootstrap deliberately inherits UV/PIP registry, proxy, certificate, cache, and offline environment "
|
|
179
|
+
"policy. `--no-config --isolated` prevents consumer project configuration and installed tools from "
|
|
180
|
+
"changing the selected bootstrap or Standards bundle."
|
|
181
|
+
)
|
|
182
|
+
executable = (
|
|
183
|
+
None
|
|
184
|
+
if engine is None
|
|
185
|
+
else {
|
|
186
|
+
"python": "sarj-python-lint",
|
|
187
|
+
"sql": "sarj-sql-lint",
|
|
188
|
+
"iac": "sarj-iac-lint",
|
|
189
|
+
}.get(engine)
|
|
190
|
+
)
|
|
191
|
+
if executable is None:
|
|
192
|
+
return "Rules and configuration are documented in the generated rule directory."
|
|
193
|
+
return f"```bash\n{executable} --help\n```"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _plugin_readme(root: Path) -> str:
|
|
197
|
+
manifest_path = root / "plugins/sarj-audit/.claude-plugin/plugin.json"
|
|
198
|
+
metadata = _manifest(manifest_path)
|
|
199
|
+
sections = [
|
|
200
|
+
_GENERATED_SENTINEL,
|
|
201
|
+
f"# {_title(_string(metadata, 'name'))}",
|
|
202
|
+
_string(metadata, "description"),
|
|
203
|
+
f"[Documentation]({_DOCUMENTATION_URL}) · [Source](https://github.com/sarj-ai/code-standards/tree/main/plugins/sarj-audit)",
|
|
204
|
+
]
|
|
205
|
+
return "\n\n".join(sections) + "\n"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _install_command(name: str, registry: str) -> str:
|
|
209
|
+
if registry == "npm":
|
|
210
|
+
return f"npm install --save-dev {name}"
|
|
211
|
+
python = " --python 3.14" if name == "code-standards" else ""
|
|
212
|
+
return f"uv tool install{python} {name}"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _homepage(metadata: dict[str, object]) -> str:
|
|
216
|
+
urls = metadata.get("urls")
|
|
217
|
+
value = urls.get("Homepage") if _is_object_table(urls) else metadata.get("homepage")
|
|
218
|
+
return value if isinstance(value, str) and value else _DOCUMENTATION_URL
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _source_url(metadata: dict[str, object]) -> str:
|
|
222
|
+
urls = metadata.get("urls")
|
|
223
|
+
value: object = urls.get("Repository") if _is_object_table(urls) else metadata.get("repository")
|
|
224
|
+
if _is_object_table(value):
|
|
225
|
+
value = value.get("url")
|
|
226
|
+
if not isinstance(value, str) or not value:
|
|
227
|
+
return "https://github.com/sarj-ai/code-standards"
|
|
228
|
+
return value.removeprefix("git+").removesuffix(".git")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _validate_markdown_allowlist(root: Path) -> None:
|
|
232
|
+
rejected: list[Path] = []
|
|
233
|
+
for parent, directories, names in os.walk(root):
|
|
234
|
+
directories[:] = sorted(name for name in directories if name not in _WALK_EXCLUDES)
|
|
235
|
+
for name in sorted(names):
|
|
236
|
+
path = Path(parent) / name
|
|
237
|
+
if path.suffix.lower() in _DOCUMENT_SUFFIXES and not _allowed_document(path.relative_to(root)):
|
|
238
|
+
rejected.append(path.relative_to(root))
|
|
239
|
+
if rejected:
|
|
240
|
+
msg = "documentation is not generated or executable-policy allowlisted: " + ", ".join(map(str, rejected))
|
|
241
|
+
raise ValueError(msg)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _allowed_document(relative: Path) -> bool:
|
|
245
|
+
if relative in _GENERATED_READMES or relative in _EXECUTABLE_OR_LEGAL_DOCUMENTS or relative in _AUTHORED_DOCUMENTS:
|
|
246
|
+
return True
|
|
247
|
+
match relative.parts:
|
|
248
|
+
case ("plugins", _plugin, "commands", filename):
|
|
249
|
+
return filename.endswith(".md")
|
|
250
|
+
case ("plugins", _plugin, "skills", _skill, "SKILL.md"):
|
|
251
|
+
return True
|
|
252
|
+
case ("plugins", _plugin, "skills", _skill, "references", filename):
|
|
253
|
+
return filename.endswith(".md")
|
|
254
|
+
case _:
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _documentation_paths(root: Path) -> tuple[Path, ...]:
|
|
259
|
+
required = {
|
|
260
|
+
*(root / path for path in _GENERATED_READMES),
|
|
261
|
+
*(root / path for path in _EXECUTABLE_OR_LEGAL_DOCUMENTS),
|
|
262
|
+
}
|
|
263
|
+
maintained = {
|
|
264
|
+
*(root / path for path in _AUTHORED_DOCUMENTS if (root / path).is_file()),
|
|
265
|
+
*root.glob("plugins/*/commands/*.md"),
|
|
266
|
+
*root.glob("plugins/*/skills/*/SKILL.md"),
|
|
267
|
+
*root.glob("plugins/*/skills/*/references/*.md"),
|
|
268
|
+
}
|
|
269
|
+
missing = [
|
|
270
|
+
path for path in sorted(required) if not path.is_file() and path.relative_to(root) not in _GENERATED_READMES
|
|
271
|
+
]
|
|
272
|
+
if missing:
|
|
273
|
+
msg = f"required documentation is missing: {', '.join(str(path) for path in missing)}"
|
|
274
|
+
raise ValueError(msg)
|
|
275
|
+
return tuple(sorted(required | maintained))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _validate_documents(documents: dict[Path, str]) -> None:
|
|
279
|
+
for path, source in documents.items():
|
|
280
|
+
_validate_local_links(path, source, documents)
|
|
281
|
+
_validate_cli_examples(path, source)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _validate_local_links(path: Path, source: str, documents: dict[Path, str]) -> None:
|
|
285
|
+
for match in _LOCAL_LINK.finditer(source):
|
|
286
|
+
relative, anchor = match.groups()
|
|
287
|
+
target = (path.parent / relative).resolve() if relative else path
|
|
288
|
+
generated_target = documents.get(target)
|
|
289
|
+
if generated_target is None and not target.exists():
|
|
290
|
+
msg = f"{path} links to missing local target {relative!r}"
|
|
291
|
+
raise ValueError(msg)
|
|
292
|
+
if anchor and target.suffix.lower() == ".md":
|
|
293
|
+
target_source = generated_target if generated_target is not None else target.read_text(encoding="utf-8")
|
|
294
|
+
headings = {_heading_slug(heading.group(1)) for heading in _HEADING.finditer(target_source)}
|
|
295
|
+
if anchor not in headings:
|
|
296
|
+
msg = f"{path} links to missing Markdown heading {anchor!r} in {relative or path.name!r}"
|
|
297
|
+
raise ValueError(msg)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _heading_slug(heading: str) -> str:
|
|
301
|
+
normalized = re.sub(r"[^a-z0-9 -]", "", heading.lower()).replace(" ", "-")
|
|
302
|
+
return re.sub(r"-+", "-", normalized)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _validate_cli_examples(path: Path, source: str) -> None:
|
|
306
|
+
for match in _STANDARDS_COMMAND.finditer(source):
|
|
307
|
+
command = shlex.split(match.group(1))
|
|
308
|
+
completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] -- fixed interpreter and argv.
|
|
309
|
+
[sys.executable, "-m", "sarj_standards", *command[1:], "--help"],
|
|
310
|
+
check=False,
|
|
311
|
+
capture_output=True,
|
|
312
|
+
text=True,
|
|
313
|
+
)
|
|
314
|
+
if completed.returncode != 0:
|
|
315
|
+
msg = f"{path} contains an invalid command example: {match.group(1)!r}"
|
|
316
|
+
raise ValueError(msg)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _manifest(path: Path) -> dict[str, object]:
|
|
320
|
+
if path.suffix == ".toml":
|
|
321
|
+
document = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
322
|
+
project: object = document.get("project")
|
|
323
|
+
if not _is_object_table(project):
|
|
324
|
+
msg = f"{path} has no project table"
|
|
325
|
+
raise ValueError(msg)
|
|
326
|
+
return project
|
|
327
|
+
document: object = json.loads(path.read_text(encoding="utf-8")) # pyright: ignore[reportAny]
|
|
328
|
+
if not _is_object_table(document):
|
|
329
|
+
msg = f"{path} is not an object"
|
|
330
|
+
raise TypeError(msg)
|
|
331
|
+
return document
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _string(table: dict[str, object], key: str) -> str:
|
|
335
|
+
value = table.get(key)
|
|
336
|
+
if not isinstance(value, str) or not value:
|
|
337
|
+
msg = f"manifest or catalog entry does not declare {key!r}"
|
|
338
|
+
raise ValueError(msg)
|
|
339
|
+
return value
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _title(name: str) -> str:
|
|
343
|
+
return " ".join(part.capitalize() for part in name.replace("@", "").replace("/", " ").replace("-", " ").split())
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _is_object_table(value: object) -> TypeGuard[dict[str, object]]:
|
|
347
|
+
return isinstance(value, dict)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import platform
|
|
5
|
+
import shlex
|
|
6
|
+
import shutil
|
|
7
|
+
import stat
|
|
8
|
+
import subprocess # ruff: ignore[suspicious-subprocess-import] -- fixed-argument git and Lefthook calls are required.
|
|
9
|
+
import sys
|
|
10
|
+
import sysconfig
|
|
11
|
+
from types import MappingProxyType
|
|
12
|
+
from typing import Final
|
|
13
|
+
|
|
14
|
+
from sarj_standards.libs.adoption import transaction
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_VERSION: Final = "2.1.10"
|
|
18
|
+
_ARCHITECTURES: Final = MappingProxyType({"aarch64": "arm64", "amd64": "x86_64"})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def install(root: Path) -> int:
|
|
22
|
+
if not any((root / name).is_file() for name in ("lefthook.yml", "lefthook.yaml")):
|
|
23
|
+
msg = f"no Lefthook configuration found in {root}"
|
|
24
|
+
raise ValueError(msg)
|
|
25
|
+
binary = _binary("lefthook")
|
|
26
|
+
native_binary = _native_binary()
|
|
27
|
+
durable_binary = _hook_path(root, "pre-commit").parent / f".sarj-lefthook{native_binary.suffix}"
|
|
28
|
+
transaction.validate_targets(durable_binary.parent, (durable_binary,))
|
|
29
|
+
subprocess.run([str(binary), "install", "-f"], cwd=root, check=True) # ruff: ignore[subprocess-without-shell-equals-true]
|
|
30
|
+
hook_paths = _hook_paths(root)
|
|
31
|
+
if hook_paths:
|
|
32
|
+
transaction.atomic_write_bytes(
|
|
33
|
+
durable_binary.parent,
|
|
34
|
+
durable_binary,
|
|
35
|
+
native_binary.read_bytes(),
|
|
36
|
+
mode=stat.S_IMODE(native_binary.stat(follow_symlinks=False).st_mode),
|
|
37
|
+
)
|
|
38
|
+
marker = f"export LEFTHOOK_BIN={shlex.quote(durable_binary.as_posix())}"
|
|
39
|
+
for hook_path in hook_paths:
|
|
40
|
+
lines = [line for line in hook_path.read_text(encoding="utf-8").splitlines() if "LEFTHOOK_BIN=" not in line]
|
|
41
|
+
lines.insert(1, marker)
|
|
42
|
+
transaction.atomic_write_text(hook_path.parent, hook_path, "\n".join(lines) + "\n")
|
|
43
|
+
subprocess.run([str(binary), "validate"], cwd=root, check=True) # ruff: ignore[subprocess-without-shell-equals-true]
|
|
44
|
+
subprocess.run([str(binary), "check-install"], cwd=root, check=True) # ruff: ignore[subprocess-without-shell-equals-true]
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run(argv: list[str] | None = None) -> int:
|
|
49
|
+
try:
|
|
50
|
+
return _run(list(sys.argv[1:] if argv is None else argv))
|
|
51
|
+
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
|
|
52
|
+
sys.stderr.write(f"error: {exc}\n")
|
|
53
|
+
return 2
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _run(args: list[str]) -> int:
|
|
57
|
+
root = Path(_git(Path.cwd(), "rev-parse", "--show-toplevel").strip())
|
|
58
|
+
binary = _binary("lefthook")
|
|
59
|
+
if _installed_version(binary) != _VERSION:
|
|
60
|
+
msg = f"lefthook {_VERSION} is required; reinstall code-standards"
|
|
61
|
+
raise RuntimeError(msg)
|
|
62
|
+
return subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true]
|
|
63
|
+
[str(binary), *args], cwd=root, check=False
|
|
64
|
+
).returncode
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _binary(name: str) -> Path:
|
|
68
|
+
executable = shutil.which(name, path=str(Path(sys.executable).parent))
|
|
69
|
+
if executable is None:
|
|
70
|
+
msg = f"{name} is missing from the code-standards environment"
|
|
71
|
+
raise OSError(msg)
|
|
72
|
+
return Path(executable)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _native_binary() -> Path:
|
|
76
|
+
system = platform.system().lower()
|
|
77
|
+
machine = platform.machine().lower()
|
|
78
|
+
architecture = _ARCHITECTURES.get(machine, machine)
|
|
79
|
+
suffix = ".exe" if system == "windows" else ""
|
|
80
|
+
binary = (
|
|
81
|
+
Path(sysconfig.get_path("purelib"))
|
|
82
|
+
/ "lefthook"
|
|
83
|
+
/ "bin"
|
|
84
|
+
/ f"lefthook-{system}-{architecture}"
|
|
85
|
+
/ f"lefthook{suffix}"
|
|
86
|
+
)
|
|
87
|
+
if not binary.is_file():
|
|
88
|
+
msg = f"native Lefthook binary is missing from the code-standards environment: {binary}"
|
|
89
|
+
raise OSError(msg)
|
|
90
|
+
return binary
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _hook_paths(root: Path) -> list[Path]:
|
|
94
|
+
return [path for hook_name in ("pre-commit", "pre-push") if (path := _hook_path(root, hook_name)).is_file()]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _hook_path(root: Path, hook_name: str) -> Path:
|
|
98
|
+
path = Path(_git(root, "rev-parse", "--git-path", f"hooks/{hook_name}").strip())
|
|
99
|
+
return path if path.is_absolute() else root / path
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _installed_version(binary: Path) -> str | None:
|
|
103
|
+
if not binary.is_file():
|
|
104
|
+
return None
|
|
105
|
+
completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true]
|
|
106
|
+
[str(binary), "version"], check=False, capture_output=True, text=True
|
|
107
|
+
)
|
|
108
|
+
return completed.stdout.strip() if completed.returncode == 0 else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _git(root: Path, *args: str) -> str:
|
|
112
|
+
executable = shutil.which("git")
|
|
113
|
+
if executable is None:
|
|
114
|
+
msg = "git is required to manage repository hooks"
|
|
115
|
+
raise OSError(msg)
|
|
116
|
+
return subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true]
|
|
117
|
+
[executable, *args], cwd=root, check=True, capture_output=True, text=True
|
|
118
|
+
).stdout
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from typing import TYPE_CHECKING, Final
|
|
9
|
+
|
|
10
|
+
from sarj_standards._meta import CONFIGS_DIR
|
|
11
|
+
from sarj_standards.libs.adoption.manifest import as_table, list_field, text_field
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
LEDGER_JSON: Final = CONFIGS_DIR / "rule-ledger.json"
|
|
19
|
+
|
|
20
|
+
#: The `kind` values that name an ESLint rule and a bare `SARJnnn` code; the rest
|
|
21
|
+
#: (`python`, `sql`, `iac`) are rule ids, which double as pre-commit hook ids.
|
|
22
|
+
ESLINT: Final = "eslint"
|
|
23
|
+
CODE: Final = "code"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Status(StrEnum):
|
|
27
|
+
REMOVED = "removed"
|
|
28
|
+
RENAMED = "renamed"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Retired:
|
|
33
|
+
id: str
|
|
34
|
+
kind: str
|
|
35
|
+
status: Status
|
|
36
|
+
replacement: str | None
|
|
37
|
+
note: str
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def pattern(self) -> re.Pattern[str]:
|
|
41
|
+
if self.kind == ESLINT:
|
|
42
|
+
return re.compile(rf"(?<![\w/-]){re.escape(self.id)}(?![\w-])")
|
|
43
|
+
if self.kind == CODE:
|
|
44
|
+
return re.compile(rf"\b{re.escape(self.id)}\b")
|
|
45
|
+
return re.compile(rf"(?<![\w-])(?:sarj-{re.escape(self.id)}|--rule[ =]{re.escape(self.id)})(?![\w-])")
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def advice(self) -> str:
|
|
49
|
+
if self.status is Status.RENAMED and self.replacement is not None:
|
|
50
|
+
return f"renamed to {self.replacement} -- {self.note}"
|
|
51
|
+
return f"no longer exists -- {self.note}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Ledger:
|
|
56
|
+
rules: Mapping[str, tuple[str, ...]]
|
|
57
|
+
codes: Mapping[str, tuple[str, ...]]
|
|
58
|
+
retired: tuple[Retired, ...]
|
|
59
|
+
|
|
60
|
+
def active_ids(self) -> frozenset[str]:
|
|
61
|
+
live = {code for family in self.codes.values() for code in family}
|
|
62
|
+
for family, names in self.rules.items():
|
|
63
|
+
prefix = "@sarj/" if family == ESLINT else ""
|
|
64
|
+
live.update(f"{prefix}{name}" for name in names)
|
|
65
|
+
return frozenset(live)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load() -> Ledger:
|
|
69
|
+
parsed: object = json.loads( # pyright: ignore[reportAny] -- json.loads is an untyped stdlib boundary; the shape is narrowed below
|
|
70
|
+
LEDGER_JSON.read_text(encoding="utf-8")
|
|
71
|
+
)
|
|
72
|
+
data = as_table(parsed)
|
|
73
|
+
return Ledger(
|
|
74
|
+
rules=_families(data, "rules"),
|
|
75
|
+
codes=_families(data, "codes"),
|
|
76
|
+
retired=tuple(_retired(data)),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _families(data: Mapping[str, object], key: str) -> dict[str, tuple[str, ...]]:
|
|
81
|
+
table = as_table(data.get(key))
|
|
82
|
+
return {family: tuple(name for name in list_field(table, family) if isinstance(name, str)) for family in table}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _retired(data: Mapping[str, object]) -> Iterator[Retired]:
|
|
86
|
+
for entry in list_field(data, "retired"):
|
|
87
|
+
row = as_table(entry)
|
|
88
|
+
identifier = text_field(row, "id")
|
|
89
|
+
kind = text_field(row, "kind")
|
|
90
|
+
status = text_field(row, "status")
|
|
91
|
+
if identifier is None or kind is None or status not in tuple(Status):
|
|
92
|
+
continue
|
|
93
|
+
yield Retired(
|
|
94
|
+
id=identifier,
|
|
95
|
+
kind=kind,
|
|
96
|
+
status=Status(status),
|
|
97
|
+
replacement=text_field(row, "replacement"),
|
|
98
|
+
note=text_field(row, "note") or "",
|
|
99
|
+
)
|