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.
Files changed (99) hide show
  1. code_standards-7.0.0.dist-info/METADATA +53 -0
  2. code_standards-7.0.0.dist-info/RECORD +99 -0
  3. code_standards-7.0.0.dist-info/WHEEL +4 -0
  4. code_standards-7.0.0.dist-info/entry_points.txt +3 -0
  5. code_standards-7.0.0.dist-info/licenses/LICENSE +21 -0
  6. sarj_standards/__init__.py +30 -0
  7. sarj_standards/__main__.py +5 -0
  8. sarj_standards/_meta.py +22 -0
  9. sarj_standards/api.py +890 -0
  10. sarj_standards/cli/__init__.py +0 -0
  11. sarj_standards/cli/main.py +2466 -0
  12. sarj_standards/configs/cli-reference.v1.json +1 -0
  13. sarj_standards/configs/doctor.config.json +22 -0
  14. sarj_standards/configs/eslint.application.mjs +1366 -0
  15. sarj_standards/configs/eslint.peers.json +44 -0
  16. sarj_standards/configs/eslint.strict.mjs +1060 -0
  17. sarj_standards/configs/markdownlint.strict.yaml +12 -0
  18. sarj_standards/configs/pyright.strict.json +96 -0
  19. sarj_standards/configs/ruff.application.toml +363 -0
  20. sarj_standards/configs/ruff.strict.toml +338 -0
  21. sarj_standards/configs/rule-inventory.v1.json +1 -0
  22. sarj_standards/configs/rule-ledger.json +846 -0
  23. sarj_standards/configs/rule-warning-levels.v1.json +1 -0
  24. sarj_standards/configs/taplo.strict.toml +14 -0
  25. sarj_standards/configs/yamllint.strict.yaml +25 -0
  26. sarj_standards/libs/__init__.py +0 -0
  27. sarj_standards/libs/adoption/__init__.py +0 -0
  28. sarj_standards/libs/adoption/configs.py +36 -0
  29. sarj_standards/libs/adoption/doctor.py +1346 -0
  30. sarj_standards/libs/adoption/exclusions.py +66 -0
  31. sarj_standards/libs/adoption/hooks.py +423 -0
  32. sarj_standards/libs/adoption/launcher.py +240 -0
  33. sarj_standards/libs/adoption/lifecycle.py +493 -0
  34. sarj_standards/libs/adoption/manifest.py +550 -0
  35. sarj_standards/libs/adoption/packagemanager.py +285 -0
  36. sarj_standards/libs/adoption/retired_suppressions.py +371 -0
  37. sarj_standards/libs/adoption/scaffold.py +1660 -0
  38. sarj_standards/libs/adoption/service.py +441 -0
  39. sarj_standards/libs/adoption/transaction.py +274 -0
  40. sarj_standards/libs/adoption/upgrade.py +516 -0
  41. sarj_standards/libs/adoption/uvtool.py +62 -0
  42. sarj_standards/libs/catalogs/__init__.py +9 -0
  43. sarj_standards/libs/catalogs/slack_automations.py +627 -0
  44. sarj_standards/libs/corpus/__init__.py +25 -0
  45. sarj_standards/libs/corpus/manifest.py +211 -0
  46. sarj_standards/libs/corpus/snapshot.py +222 -0
  47. sarj_standards/libs/diagnostics/__init__.py +65 -0
  48. sarj_standards/libs/diagnostics/analysis.schema.json +161 -0
  49. sarj_standards/libs/diagnostics/baseline.py +131 -0
  50. sarj_standards/libs/diagnostics/models.py +574 -0
  51. sarj_standards/libs/diagnostics/serialize.py +290 -0
  52. sarj_standards/libs/diagnostics/source.py +172 -0
  53. sarj_standards/libs/filesystem.py +11 -0
  54. sarj_standards/libs/linting/__init__.py +0 -0
  55. sarj_standards/libs/linting/analysis.py +422 -0
  56. sarj_standards/libs/linting/external.py +1454 -0
  57. sarj_standards/libs/linting/library_policy.py +688 -0
  58. sarj_standards/libs/linting/policy.py +152 -0
  59. sarj_standards/libs/linting/runner.py +442 -0
  60. sarj_standards/libs/linting/textlint.py +1605 -0
  61. sarj_standards/libs/release/__init__.py +98 -0
  62. sarj_standards/libs/release/_values.py +24 -0
  63. sarj_standards/libs/release/artifacts.py +191 -0
  64. sarj_standards/libs/release/causality.py +80 -0
  65. sarj_standards/libs/release/changes.py +48 -0
  66. sarj_standards/libs/release/process.py +128 -0
  67. sarj_standards/libs/release/publish.py +85 -0
  68. sarj_standards/libs/release/registry.py +271 -0
  69. sarj_standards/libs/release/release_age.py +218 -0
  70. sarj_standards/libs/release/rollout.py +1163 -0
  71. sarj_standards/libs/release/tags.py +373 -0
  72. sarj_standards/libs/release/typescript.py +191 -0
  73. sarj_standards/libs/repository/__init__.py +0 -0
  74. sarj_standards/libs/repository/cli_reference_artifact.py +324 -0
  75. sarj_standards/libs/repository/comment_corpus.py +536 -0
  76. sarj_standards/libs/repository/config_generation.py +146 -0
  77. sarj_standards/libs/repository/docs.py +347 -0
  78. sarj_standards/libs/repository/hooks.py +118 -0
  79. sarj_standards/libs/repository/ledger.py +99 -0
  80. sarj_standards/libs/repository/repository.py +744 -0
  81. sarj_standards/libs/repository/rule_authoring.py +246 -0
  82. sarj_standards/libs/repository/rule_catalog_artifact.py +479 -0
  83. sarj_standards/libs/repository/rule_changes.py +318 -0
  84. sarj_standards/libs/repository/rule_inventory_artifact.py +142 -0
  85. sarj_standards/libs/repository/rule_lifecycle.py +167 -0
  86. sarj_standards/libs/repository/rule_maintenance.py +225 -0
  87. sarj_standards/libs/rules/__init__.py +74 -0
  88. sarj_standards/libs/rules/catalog.py +145 -0
  89. sarj_standards/libs/rules/contracts.py +382 -0
  90. sarj_standards/libs/rules/corpus_runner.py +365 -0
  91. sarj_standards/libs/rules/evaluation.py +177 -0
  92. sarj_standards/libs/setup/__init__.py +4 -0
  93. sarj_standards/libs/setup/repository.py +40 -0
  94. sarj_standards/py.typed +0 -0
  95. sarj_standards/schemas/__init__.py +4 -0
  96. sarj_standards/schemas/_paths.py +7 -0
  97. sarj_standards/schemas/rule-catalog.v1.json +1 -0
  98. sarj_standards/schemas/rule-catalog.v1.schema.json +112 -0
  99. 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
+ )