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,2466 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import shlex
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess # ruff: ignore[suspicious-subprocess-import] -- repository commands report failures from fixed-argument child processes.
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
from typing import TYPE_CHECKING, NoReturn
|
|
15
|
+
|
|
16
|
+
from packaging.version import InvalidVersion, Version
|
|
17
|
+
|
|
18
|
+
from sarj_standards import __version__
|
|
19
|
+
from sarj_standards._meta import CONFIGS_DIR
|
|
20
|
+
from sarj_standards.libs.adoption import manifest
|
|
21
|
+
from sarj_standards.libs.adoption.configs import (
|
|
22
|
+
APPLICATION_CONFIG_NAMES,
|
|
23
|
+
CONFIG_NAMES,
|
|
24
|
+
)
|
|
25
|
+
from sarj_standards.libs.filesystem import is_link_like
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from collections.abc import Iterable, Sequence
|
|
30
|
+
|
|
31
|
+
from sarj_standards.libs.adoption import service
|
|
32
|
+
from sarj_standards.libs.rules import RuleSelector
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_NEXT_STEPS = (
|
|
36
|
+
"\nnext: in your pyproject.toml, add:\n"
|
|
37
|
+
" [tool.ruff]\n"
|
|
38
|
+
' extend = ".ruff-strict.toml"\n'
|
|
39
|
+
"\n(or run `code-standards setup`, which writes that and the rest of the wiring)\n"
|
|
40
|
+
)
|
|
41
|
+
_BOOTSTRAP_TIMEOUT = timedelta(seconds=120)
|
|
42
|
+
_GIT_SAFE_ENV = frozenset(
|
|
43
|
+
{"HOME", "LANG", "LC_ALL", "LC_CTYPE", "PATH", "SYSTEMDRIVE", "SYSTEMROOT", "TMPDIR", "XDG_CONFIG_HOME"}
|
|
44
|
+
)
|
|
45
|
+
_INVALID_DOCTOR_IDS = frozenset(
|
|
46
|
+
{
|
|
47
|
+
"doctor.manifest.invalid",
|
|
48
|
+
"doctor.manifest.destination",
|
|
49
|
+
"doctor.config.unknown",
|
|
50
|
+
"doctor.package-json.invalid",
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _EvaluationScope(StrEnum):
|
|
56
|
+
CORPUS = "corpus"
|
|
57
|
+
EFFECTIVE = "effective"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _Args(argparse.Namespace):
|
|
61
|
+
cmd: str = ""
|
|
62
|
+
dest: str = "."
|
|
63
|
+
only: list[str]
|
|
64
|
+
force: bool = False
|
|
65
|
+
check: bool = False
|
|
66
|
+
dry_run: bool = False
|
|
67
|
+
python_dest: str | None = None
|
|
68
|
+
typescript_dest: str | None = None
|
|
69
|
+
configs: list[str]
|
|
70
|
+
name: str = ""
|
|
71
|
+
files: list[str]
|
|
72
|
+
repo_cmd: str = ""
|
|
73
|
+
repo_only: list[str]
|
|
74
|
+
commits: str | None = None
|
|
75
|
+
policy_dest: str | None = None
|
|
76
|
+
private_refs_file: str | None = None
|
|
77
|
+
quiet: bool = False
|
|
78
|
+
roots: list[Path]
|
|
79
|
+
include_text: Path | None = None
|
|
80
|
+
rules_cmd: str = ""
|
|
81
|
+
rule_category: str = ""
|
|
82
|
+
rule_summary: str = ""
|
|
83
|
+
apply_rule: bool = False
|
|
84
|
+
reference_cmd: str = ""
|
|
85
|
+
docs_cmd: str = ""
|
|
86
|
+
hooks_cmd: str = ""
|
|
87
|
+
no_install: bool = False
|
|
88
|
+
repair: bool = False
|
|
89
|
+
profile: manifest.Profile | None = None
|
|
90
|
+
output_format: str = "text"
|
|
91
|
+
offline: bool = False
|
|
92
|
+
target_version: str | None = None
|
|
93
|
+
release_cmd: str = ""
|
|
94
|
+
release_mode: str = ""
|
|
95
|
+
tag: str = ""
|
|
96
|
+
lockfile: Path | None = None
|
|
97
|
+
minimum_age: timedelta | None = None
|
|
98
|
+
release_exclude: list[str]
|
|
99
|
+
release_exclude_file: list[Path]
|
|
100
|
+
output: Path | None = None
|
|
101
|
+
external: bool = False
|
|
102
|
+
trust: str = "safe"
|
|
103
|
+
trust_repository_code: bool = False
|
|
104
|
+
before: str = ""
|
|
105
|
+
after: str = ""
|
|
106
|
+
github_output: Path | None = None
|
|
107
|
+
release_target: str = ""
|
|
108
|
+
release_targets: list[str]
|
|
109
|
+
wheels: list[Path]
|
|
110
|
+
hooks: manifest.HookManager | None = None
|
|
111
|
+
show_cmd: str = ""
|
|
112
|
+
catalog_cmd: str = ""
|
|
113
|
+
exclude_cmd: str = ""
|
|
114
|
+
exclude_kind: str = ""
|
|
115
|
+
value: str = ""
|
|
116
|
+
staged: bool = False
|
|
117
|
+
release_commit: str = ""
|
|
118
|
+
max_annotations_per_level: int = 10
|
|
119
|
+
analysis_mode: str = "policy"
|
|
120
|
+
attempts: int = 6
|
|
121
|
+
delay_seconds: timedelta = timedelta(seconds=10)
|
|
122
|
+
ratchet_cmd: str = ""
|
|
123
|
+
baseline_cmd: str = ""
|
|
124
|
+
selected_rules: list[RuleSelector]
|
|
125
|
+
selector: RuleSelector | None = None
|
|
126
|
+
evaluation_scope: _EvaluationScope = _EvaluationScope.CORPUS
|
|
127
|
+
baseline: Path | None = None
|
|
128
|
+
package: list[str]
|
|
129
|
+
exclude_subtree: list[str]
|
|
130
|
+
allow_increase: bool = False
|
|
131
|
+
slack_catalog: Path = Path()
|
|
132
|
+
|
|
133
|
+
def __init__(self) -> None:
|
|
134
|
+
super().__init__()
|
|
135
|
+
# `None` and `[]` mean the same thing for a list of choices, so these
|
|
136
|
+
# default to empty rather than nullable; argparse replaces them when the
|
|
137
|
+
# flag is given.
|
|
138
|
+
self.files = []
|
|
139
|
+
self.only = []
|
|
140
|
+
self.configs = []
|
|
141
|
+
self.repo_only = []
|
|
142
|
+
self.roots = []
|
|
143
|
+
self.release_exclude = []
|
|
144
|
+
self.release_exclude_file = []
|
|
145
|
+
self.release_targets = []
|
|
146
|
+
self.wheels = []
|
|
147
|
+
self.package = []
|
|
148
|
+
self.exclude_subtree = []
|
|
149
|
+
self.selected_rules = []
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def cmd_sync(args: _Args, *, next_steps: bool = True) -> int:
|
|
153
|
+
from sarj_standards.libs.adoption import service # ruff: ignore[import-outside-top-level] -- lazy route
|
|
154
|
+
|
|
155
|
+
root = _resolve_dest(args.dest)
|
|
156
|
+
try:
|
|
157
|
+
plan = service.plan_sync(
|
|
158
|
+
root,
|
|
159
|
+
configs=args.only or None,
|
|
160
|
+
python_dest=args.python_dest,
|
|
161
|
+
typescript_dest=args.typescript_dest,
|
|
162
|
+
profile=args.profile,
|
|
163
|
+
)
|
|
164
|
+
except ValueError as exc:
|
|
165
|
+
_user_error(str(exc))
|
|
166
|
+
result = service.apply_sync(plan, force=args.force, check=args.check)
|
|
167
|
+
_render_sync(result)
|
|
168
|
+
if (
|
|
169
|
+
result.count(service.SyncOutcome.WRITTEN)
|
|
170
|
+
and next_steps
|
|
171
|
+
and any(target.name == "ruff" for target in plan.targets)
|
|
172
|
+
):
|
|
173
|
+
print(_NEXT_STEPS)
|
|
174
|
+
return result.status
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _resolve_dest(dest_arg: str) -> Path:
|
|
178
|
+
dest = Path(dest_arg).absolute().resolve()
|
|
179
|
+
if not dest.is_dir():
|
|
180
|
+
_user_error(f"--root {dest} is not a directory")
|
|
181
|
+
return dest
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _user_error(message: str) -> NoReturn:
|
|
185
|
+
print(f"error: {message}", file=sys.stderr)
|
|
186
|
+
raise SystemExit(2)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _parse_rule_selector(value: str) -> RuleSelector:
|
|
190
|
+
from sarj_standards.libs.rules import ( # ruff: ignore[import-outside-top-level] -- parser startup stays lazy
|
|
191
|
+
RuleSelector,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
return RuleSelector.parse(value)
|
|
196
|
+
except ValueError as exc:
|
|
197
|
+
raise argparse.ArgumentTypeError(str(exc)) from exc
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _render_sync(result: service.SyncResult) -> None:
|
|
201
|
+
from sarj_standards.libs.adoption import service # ruff: ignore[import-outside-top-level] -- typed lazy route
|
|
202
|
+
|
|
203
|
+
for record in result.records:
|
|
204
|
+
destination = record.target.destination
|
|
205
|
+
match record.outcome:
|
|
206
|
+
case service.SyncOutcome.INVALID:
|
|
207
|
+
print(f"invalid: {destination} (destination must be a regular file)")
|
|
208
|
+
case service.SyncOutcome.DRIFT:
|
|
209
|
+
print(f"drift: {destination}")
|
|
210
|
+
case service.SyncOutcome.OK:
|
|
211
|
+
print(f"ok: {destination}")
|
|
212
|
+
case service.SyncOutcome.SKIPPED:
|
|
213
|
+
print(f"skip: {destination} (exists; pass --force to overwrite)")
|
|
214
|
+
case service.SyncOutcome.WRITTEN:
|
|
215
|
+
print(f"wrote: {destination}")
|
|
216
|
+
invalid = result.count(service.SyncOutcome.INVALID)
|
|
217
|
+
if result.check:
|
|
218
|
+
drift = result.count(service.SyncOutcome.DRIFT)
|
|
219
|
+
suffix = f"; {invalid} invalid" if invalid else ""
|
|
220
|
+
print(f"\nchecked {len(result.records)} config(s); {drift} drifted{suffix}.")
|
|
221
|
+
return
|
|
222
|
+
written = result.count(service.SyncOutcome.WRITTEN)
|
|
223
|
+
skipped = result.count(service.SyncOutcome.SKIPPED)
|
|
224
|
+
suffix = f"; {invalid} invalid" if invalid else ""
|
|
225
|
+
print(f"\nsynced {written}/{len(result.records)} config(s); {skipped} skipped{suffix}.")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def cmd_list() -> int:
|
|
229
|
+
for name, (src, dst) in CONFIG_NAMES.items():
|
|
230
|
+
full = CONFIGS_DIR / src
|
|
231
|
+
size = full.stat().st_size if full.exists() else 0
|
|
232
|
+
print(f"{name:8s} {src:25s} -> {dst:25s} ({size:>5d} bytes)")
|
|
233
|
+
return 0
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def cmd_path(args: _Args) -> int:
|
|
237
|
+
standard_src_name, _ = CONFIG_NAMES[args.name]
|
|
238
|
+
src_name = (
|
|
239
|
+
APPLICATION_CONFIG_NAMES.get(args.name, standard_src_name)
|
|
240
|
+
if args.profile == "application"
|
|
241
|
+
else standard_src_name
|
|
242
|
+
)
|
|
243
|
+
print(CONFIGS_DIR / src_name)
|
|
244
|
+
return 0
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def cmd_peers(args: _Args) -> int:
|
|
248
|
+
from sarj_standards.libs.adoption import packagemanager, scaffold # ruff: ignore[import-outside-top-level]
|
|
249
|
+
|
|
250
|
+
peers = manifest.eslint_peers()
|
|
251
|
+
for name, pin in sorted(peers.items()):
|
|
252
|
+
print(f"{name:50s} {pin}")
|
|
253
|
+
root = _resolve_dest(args.dest)
|
|
254
|
+
adopted = manifest.load(root)
|
|
255
|
+
detected = scaffold.detect(root, typescript_dest=adopted.typescript_dest if adopted is not None else None)
|
|
256
|
+
install_root = detected.typescript_install_root or detected.typescript_root or root
|
|
257
|
+
client = packagemanager.detect(install_root)
|
|
258
|
+
overrides = packagemanager.overrides_for(client)
|
|
259
|
+
workspace = (
|
|
260
|
+
client is packagemanager.PackageManager.PNPM
|
|
261
|
+
or install_root != (detected.typescript_root or root)
|
|
262
|
+
or (install_root / "pnpm-workspace.yaml").is_file()
|
|
263
|
+
)
|
|
264
|
+
yarn = packagemanager.yarn_variant(install_root)
|
|
265
|
+
print(
|
|
266
|
+
f"\ndetected {client} at {install_root}; install with:\n"
|
|
267
|
+
f"{packagemanager.install_command(client, workspace=workspace, yarn=yarn)}"
|
|
268
|
+
)
|
|
269
|
+
if client is packagemanager.PackageManager.PNPM:
|
|
270
|
+
rendered = "\n".join(f" {json.dumps(key)}: {json.dumps(value)}" for key, value in overrides.entries.items())
|
|
271
|
+
print(f"\n{client} also needs this in pnpm-workspace.yaml:\noverrides:\n{rendered}")
|
|
272
|
+
else:
|
|
273
|
+
print(
|
|
274
|
+
f"\n{client} also needs this in package.json, or the tree does not resolve:\n"
|
|
275
|
+
f"{json.dumps(overrides.as_document(), indent=2)}"
|
|
276
|
+
)
|
|
277
|
+
return 0
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def cmd_doctor(args: _Args) -> int: # ruff: ignore[too-many-locals] -- one command renders repair and diagnosis state.
|
|
281
|
+
from sarj_standards.libs.adoption import doctor, upgrade # ruff: ignore[import-outside-top-level]
|
|
282
|
+
|
|
283
|
+
root = _resolve_dest(args.dest)
|
|
284
|
+
repair = args.repair
|
|
285
|
+
no_install: bool = args.no_install
|
|
286
|
+
repair_status = 0
|
|
287
|
+
if repair:
|
|
288
|
+
try:
|
|
289
|
+
adopted = manifest.load(root)
|
|
290
|
+
except (OSError, TypeError, ValueError) as exc:
|
|
291
|
+
try:
|
|
292
|
+
adopted = _repair_legacy_manifest(root, install=not no_install)
|
|
293
|
+
except (OSError, TypeError, ValueError) as migration_error:
|
|
294
|
+
details = str(exc)
|
|
295
|
+
migration_details = str(migration_error)
|
|
296
|
+
if migration_details != details:
|
|
297
|
+
details = f"{details}; {migration_details}"
|
|
298
|
+
print(f"error: cannot repair invalid adoption manifest: {details}", file=sys.stderr)
|
|
299
|
+
return 2
|
|
300
|
+
if adopted is None:
|
|
301
|
+
print("error: repository is not adopted; run `code-standards setup`", file=sys.stderr)
|
|
302
|
+
return 2
|
|
303
|
+
plan = upgrade.build_plan(root)
|
|
304
|
+
blockers = upgrade.unsafe_retired_findings(plan)
|
|
305
|
+
if blockers:
|
|
306
|
+
print("warning: automatic repair cannot migrate these retired rule references:", file=sys.stderr)
|
|
307
|
+
for finding in blockers:
|
|
308
|
+
print(f"warning: {finding.where} -- {finding.detail}", file=sys.stderr)
|
|
309
|
+
current_drift = [finding for finding in doctor.diagnose(root) if finding.level is doctor.Level.DRIFT]
|
|
310
|
+
repair_status = (
|
|
311
|
+
0
|
|
312
|
+
if not plan.changes and not current_drift
|
|
313
|
+
else upgrade.apply(
|
|
314
|
+
plan,
|
|
315
|
+
install=not no_install,
|
|
316
|
+
allow_retired_debt=bool(blockers),
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
if repair_status > 1:
|
|
320
|
+
print(
|
|
321
|
+
"error: automatic repair did not converge; tracked configuration changes were restored",
|
|
322
|
+
file=sys.stderr,
|
|
323
|
+
)
|
|
324
|
+
findings = doctor.diagnose(root)
|
|
325
|
+
if repair and no_install:
|
|
326
|
+
findings = [
|
|
327
|
+
doctor.Finding(
|
|
328
|
+
doctor.Level.WARN,
|
|
329
|
+
finding.where,
|
|
330
|
+
f"{finding.detail}; installation intentionally skipped",
|
|
331
|
+
finding.id,
|
|
332
|
+
finding.remediation,
|
|
333
|
+
)
|
|
334
|
+
if finding.level is doctor.Level.DRIFT and upgrade.is_install_remediable(finding)
|
|
335
|
+
else finding
|
|
336
|
+
for finding in findings
|
|
337
|
+
]
|
|
338
|
+
drifted = sum(1 for finding in findings if finding.level is doctor.Level.DRIFT)
|
|
339
|
+
warned = sum(1 for finding in findings if finding.level is doctor.Level.WARN)
|
|
340
|
+
invalid = sum(finding.id in _INVALID_DOCTOR_IDS for finding in findings)
|
|
341
|
+
unadopted = any(finding.id == "doctor.manifest.absent" for finding in findings)
|
|
342
|
+
if args.output_format == "json":
|
|
343
|
+
print(
|
|
344
|
+
json.dumps(
|
|
345
|
+
{
|
|
346
|
+
"schema": 1,
|
|
347
|
+
"root": str(root),
|
|
348
|
+
"summary": {
|
|
349
|
+
"checked": len(findings),
|
|
350
|
+
"drifted": drifted,
|
|
351
|
+
"warnings": warned,
|
|
352
|
+
"invalid": invalid,
|
|
353
|
+
},
|
|
354
|
+
"findings": [finding.as_dict() for finding in findings],
|
|
355
|
+
},
|
|
356
|
+
indent=2,
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
else:
|
|
360
|
+
print(f"root: {root}")
|
|
361
|
+
for finding in findings:
|
|
362
|
+
print(f"{finding.level.value:6s} {finding.id} {finding.where} -- {finding.detail}")
|
|
363
|
+
print(f"\nchecked {len(findings)} configuration site(s); {drifted} drifted; {warned} warning(s).")
|
|
364
|
+
remediations = (
|
|
365
|
+
["run `code-standards setup`"]
|
|
366
|
+
if unadopted
|
|
367
|
+
else list(
|
|
368
|
+
dict.fromkeys(
|
|
369
|
+
finding.remediation
|
|
370
|
+
for finding in findings
|
|
371
|
+
if finding.level is doctor.Level.DRIFT and finding.remediation
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
)
|
|
375
|
+
for remediation in remediations:
|
|
376
|
+
print(f"fix: {remediation}")
|
|
377
|
+
if invalid:
|
|
378
|
+
return max(repair_status, 2)
|
|
379
|
+
return max(repair_status, 1 if drifted or unadopted else 0)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _repair_legacy_manifest(root: Path, *, install: bool) -> manifest.Manifest:
|
|
383
|
+
from sarj_standards.libs.adoption import service # ruff: ignore[import-outside-top-level]
|
|
384
|
+
|
|
385
|
+
legacy = manifest.load_for_setup(root)
|
|
386
|
+
if legacy is None:
|
|
387
|
+
msg = "repository is not adopted; run `code-standards setup`"
|
|
388
|
+
raise ValueError(msg)
|
|
389
|
+
migration = service.plan_init(
|
|
390
|
+
root,
|
|
391
|
+
configs=legacy.configs,
|
|
392
|
+
python_dest=legacy.python_dest,
|
|
393
|
+
typescript_dest=legacy.typescript_dest,
|
|
394
|
+
profile=legacy.profile,
|
|
395
|
+
hook_manager=legacy.hook_manager,
|
|
396
|
+
)
|
|
397
|
+
if migration.scaffold.errors or migration.sync is None:
|
|
398
|
+
detail = "; ".join(migration.scaffold.errors) or "setup plan is not applicable"
|
|
399
|
+
msg = f"cannot repair legacy adoption: {detail}"
|
|
400
|
+
raise ValueError(msg)
|
|
401
|
+
migrated = service.apply_init(migration, install=install)
|
|
402
|
+
if migrated.status:
|
|
403
|
+
msg = f"cannot repair legacy adoption: {migrated.error}"
|
|
404
|
+
raise ValueError(msg)
|
|
405
|
+
adopted = manifest.load(root)
|
|
406
|
+
if adopted is None:
|
|
407
|
+
msg = "legacy manifest migration did not produce an adopted repository"
|
|
408
|
+
raise ValueError(msg)
|
|
409
|
+
return adopted
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def cmd_update(args: _Args) -> int: # ruff: ignore[too-many-locals] -- one command preserves preview/apply state.
|
|
413
|
+
from sarj_standards.libs.adoption import doctor, lifecycle, upgrade # ruff: ignore[import-outside-top-level]
|
|
414
|
+
|
|
415
|
+
target_version: str | None = None
|
|
416
|
+
if args.target_version is not None:
|
|
417
|
+
try:
|
|
418
|
+
target_version = str(Version(args.target_version))
|
|
419
|
+
except InvalidVersion:
|
|
420
|
+
print(f"error: invalid standards version: {args.target_version}", file=sys.stderr)
|
|
421
|
+
return 2
|
|
422
|
+
if target_version != args.target_version:
|
|
423
|
+
print(
|
|
424
|
+
f"error: standards version must be canonical ({target_version}), got {args.target_version}",
|
|
425
|
+
file=sys.stderr,
|
|
426
|
+
)
|
|
427
|
+
return 2
|
|
428
|
+
|
|
429
|
+
bootstrapped = (
|
|
430
|
+
os.environ.get( # ruff: ignore[banned-api] -- private recursion sentinel, not application settings
|
|
431
|
+
"SARJ_STANDARDS_BOOTSTRAPPED"
|
|
432
|
+
)
|
|
433
|
+
== "1"
|
|
434
|
+
)
|
|
435
|
+
if (
|
|
436
|
+
target_version is not None
|
|
437
|
+
and (bootstrapped or args.offline)
|
|
438
|
+
and Version(__version__) != Version(target_version)
|
|
439
|
+
):
|
|
440
|
+
print(
|
|
441
|
+
f"error: exact update requested standards {target_version}, but the executing bundle is {__version__}",
|
|
442
|
+
file=sys.stderr,
|
|
443
|
+
)
|
|
444
|
+
return 2
|
|
445
|
+
|
|
446
|
+
if not args.offline and not bootstrapped:
|
|
447
|
+
executable = shutil.which("uvx")
|
|
448
|
+
if executable is None:
|
|
449
|
+
print(
|
|
450
|
+
"error: uvx is required to resolve the latest standards release; install uv and retry "
|
|
451
|
+
"(--offline only reconverges the executing bundle)",
|
|
452
|
+
file=sys.stderr,
|
|
453
|
+
)
|
|
454
|
+
return 2
|
|
455
|
+
from sarj_standards.libs.adoption import launcher # ruff: ignore[import-outside-top-level] -- lazy route
|
|
456
|
+
|
|
457
|
+
command = [
|
|
458
|
+
*launcher.argv(executable=executable, version=target_version, refresh=True),
|
|
459
|
+
"--root",
|
|
460
|
+
str(_resolve_dest(args.dest)),
|
|
461
|
+
"update",
|
|
462
|
+
]
|
|
463
|
+
if target_version is None:
|
|
464
|
+
command.append("--offline")
|
|
465
|
+
else:
|
|
466
|
+
command.extend(("--to", target_version))
|
|
467
|
+
if args.check:
|
|
468
|
+
command.append("--check")
|
|
469
|
+
if args.no_install:
|
|
470
|
+
command.append("--no-install")
|
|
471
|
+
environment = dict(os.environ) # ruff: ignore[banned-api] -- preserve the caller environment for uvx
|
|
472
|
+
environment["SARJ_STANDARDS_BOOTSTRAPPED"] = "1"
|
|
473
|
+
try:
|
|
474
|
+
return subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] -- fixed executable and argv
|
|
475
|
+
command,
|
|
476
|
+
check=False,
|
|
477
|
+
env=environment,
|
|
478
|
+
timeout=_BOOTSTRAP_TIMEOUT.total_seconds(),
|
|
479
|
+
).returncode
|
|
480
|
+
except subprocess.TimeoutExpired:
|
|
481
|
+
print(
|
|
482
|
+
"error: resolving the requested standards release timed out; check the network and retry "
|
|
483
|
+
"(--offline only reconverges the executing bundle)",
|
|
484
|
+
file=sys.stderr,
|
|
485
|
+
)
|
|
486
|
+
return 2
|
|
487
|
+
|
|
488
|
+
if args.offline:
|
|
489
|
+
args.no_install = True
|
|
490
|
+
root = _resolve_dest(args.dest)
|
|
491
|
+
try:
|
|
492
|
+
_ = manifest.load(root)
|
|
493
|
+
except (OSError, TypeError, ValueError) as exc:
|
|
494
|
+
migration_error: OSError | TypeError | ValueError | None = None
|
|
495
|
+
try:
|
|
496
|
+
legacy = manifest.load_for_setup(root)
|
|
497
|
+
except (OSError, TypeError, ValueError) as legacy_error:
|
|
498
|
+
legacy = None
|
|
499
|
+
migration_error = legacy_error
|
|
500
|
+
if legacy is None:
|
|
501
|
+
print(f"error: cannot plan upgrade: {migration_error or exc}", file=sys.stderr)
|
|
502
|
+
return 2
|
|
503
|
+
if args.check:
|
|
504
|
+
print(
|
|
505
|
+
"error: the adoption manifest needs a one-way migration; run "
|
|
506
|
+
"`code-standards doctor --repair --no-install`, then retry update",
|
|
507
|
+
file=sys.stderr,
|
|
508
|
+
)
|
|
509
|
+
return 2
|
|
510
|
+
try:
|
|
511
|
+
_ = _repair_legacy_manifest(root, install=not args.no_install)
|
|
512
|
+
except (OSError, TypeError, ValueError) as migration_error:
|
|
513
|
+
print(f"error: cannot migrate legacy adoption before update: {migration_error}", file=sys.stderr)
|
|
514
|
+
return 2
|
|
515
|
+
print("migrated: legacy adoption manifest")
|
|
516
|
+
try:
|
|
517
|
+
plan = upgrade.build_plan(root)
|
|
518
|
+
except (OSError, TypeError, ValueError) as exc:
|
|
519
|
+
print(f"error: cannot plan upgrade: {exc}", file=sys.stderr)
|
|
520
|
+
return 2
|
|
521
|
+
preflight_findings = doctor.diagnose(root)
|
|
522
|
+
invalid = [finding for finding in preflight_findings if finding.id in _INVALID_DOCTOR_IDS]
|
|
523
|
+
if invalid:
|
|
524
|
+
for finding in invalid:
|
|
525
|
+
print(f"error: {finding.id} {finding.where} -- {finding.detail}", file=sys.stderr)
|
|
526
|
+
if finding.remediation:
|
|
527
|
+
print(f"fix: {finding.remediation}", file=sys.stderr)
|
|
528
|
+
return 2
|
|
529
|
+
blockers = upgrade.unsafe_retired_findings(plan)
|
|
530
|
+
if blockers:
|
|
531
|
+
for finding in blockers:
|
|
532
|
+
print(f"error: {finding.where} -- {finding.detail}", file=sys.stderr)
|
|
533
|
+
return 2
|
|
534
|
+
preview = upgrade.render(plan.changes)
|
|
535
|
+
if args.check:
|
|
536
|
+
drifted = [finding for finding in preflight_findings if finding.level is doctor.Level.DRIFT]
|
|
537
|
+
if preview:
|
|
538
|
+
print(preview)
|
|
539
|
+
elif drifted:
|
|
540
|
+
print(
|
|
541
|
+
f"bundle current: {root} has standards {__version__},"
|
|
542
|
+
f" but doctor found {len(drifted)} configuration drift(s)"
|
|
543
|
+
)
|
|
544
|
+
else:
|
|
545
|
+
print(f"current: {root} already matches standards {__version__}")
|
|
546
|
+
for finding in drifted:
|
|
547
|
+
print(f"drift: {finding.id} {finding.where} -- {finding.detail}")
|
|
548
|
+
remediations = list(dict.fromkeys(finding.remediation for finding in drifted if finding.remediation))
|
|
549
|
+
for remediation in remediations:
|
|
550
|
+
print(f"fix: {remediation}")
|
|
551
|
+
return 1 if plan.changes or drifted else 0
|
|
552
|
+
current_drift = [finding for finding in preflight_findings if finding.level is doctor.Level.DRIFT]
|
|
553
|
+
skipped_commands = (
|
|
554
|
+
lifecycle.install_commands(root, plan.ecosystems, hook_manager=plan.adopted.hook_manager)
|
|
555
|
+
if args.no_install
|
|
556
|
+
else []
|
|
557
|
+
)
|
|
558
|
+
if not preview and not current_drift and not skipped_commands:
|
|
559
|
+
print(f"current: {root} already matches standards {__version__}")
|
|
560
|
+
return 0
|
|
561
|
+
print(preview or f"current: {root} already matches standards {__version__}")
|
|
562
|
+
status = upgrade.apply(plan, install=not args.no_install)
|
|
563
|
+
if status:
|
|
564
|
+
print("error: update failed; tracked configuration files were restored", file=sys.stderr)
|
|
565
|
+
remaining = [finding for finding in doctor.diagnose(root) if finding.level is doctor.Level.DRIFT]
|
|
566
|
+
for finding in remaining:
|
|
567
|
+
print(f"error: {finding.id} {finding.where} -- {finding.detail}", file=sys.stderr)
|
|
568
|
+
for remediation in dict.fromkeys(finding.remediation for finding in remaining if finding.remediation):
|
|
569
|
+
print(f"fix: {remediation}", file=sys.stderr)
|
|
570
|
+
return status
|
|
571
|
+
postflight_findings = doctor.diagnose(root)
|
|
572
|
+
invalid = [finding for finding in postflight_findings if finding.id in _INVALID_DOCTOR_IDS]
|
|
573
|
+
if invalid:
|
|
574
|
+
for finding in invalid:
|
|
575
|
+
print(f"error: {finding.id} {finding.where} -- {finding.detail}", file=sys.stderr)
|
|
576
|
+
return 2
|
|
577
|
+
pending = (
|
|
578
|
+
[
|
|
579
|
+
finding
|
|
580
|
+
for finding in postflight_findings
|
|
581
|
+
if finding.level is doctor.Level.DRIFT and upgrade.is_install_remediable(finding)
|
|
582
|
+
]
|
|
583
|
+
if args.no_install
|
|
584
|
+
else []
|
|
585
|
+
)
|
|
586
|
+
if pending or skipped_commands:
|
|
587
|
+
print(
|
|
588
|
+
f"updated configuration: {root} now uses standards {__version__};"
|
|
589
|
+
f" setup is incomplete ({len(skipped_commands)} setup command(s) skipped;"
|
|
590
|
+
f" {len(pending)} finding(s) pending)"
|
|
591
|
+
)
|
|
592
|
+
for finding in pending:
|
|
593
|
+
print(f"pending: {finding.id} {finding.where} -- {finding.detail}")
|
|
594
|
+
print("next: run the skipped setup command(s), then `code-standards doctor`:")
|
|
595
|
+
for command in skipped_commands:
|
|
596
|
+
print(f" {shlex.join(command.argv)} (in {command.cwd})")
|
|
597
|
+
return 0
|
|
598
|
+
print(f"updated: {root} now uses standards {__version__}")
|
|
599
|
+
print("next: run `code-standards check --trust-repository-code` and review every new finding")
|
|
600
|
+
return 0
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def cmd_setup(args: _Args) -> int:
|
|
604
|
+
from sarj_standards.libs.adoption import scaffold, service # ruff: ignore[import-outside-top-level] -- lazy route
|
|
605
|
+
|
|
606
|
+
root = _resolve_dest(args.dest)
|
|
607
|
+
selected_configs = tuple(dict.fromkeys((*args.configs, *args.only)))
|
|
608
|
+
try:
|
|
609
|
+
init_plan = service.plan_init(
|
|
610
|
+
root,
|
|
611
|
+
force=args.force,
|
|
612
|
+
configs=selected_configs or None,
|
|
613
|
+
python_dest=args.python_dest,
|
|
614
|
+
typescript_dest=args.typescript_dest,
|
|
615
|
+
profile=args.profile,
|
|
616
|
+
hook_manager=args.hooks,
|
|
617
|
+
)
|
|
618
|
+
except ValueError as exc:
|
|
619
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
620
|
+
return 2
|
|
621
|
+
except OSError as exc:
|
|
622
|
+
print(f"error: unsafe initialization plan: {exc}", file=sys.stderr)
|
|
623
|
+
return 2
|
|
624
|
+
plan = init_plan.scaffold
|
|
625
|
+
if plan.errors:
|
|
626
|
+
for error in plan.errors:
|
|
627
|
+
print(f"error: {error}", file=sys.stderr)
|
|
628
|
+
print("setup made no changes; resolve the wiring errors above and rerun", file=sys.stderr)
|
|
629
|
+
return 2
|
|
630
|
+
|
|
631
|
+
detected = [
|
|
632
|
+
name
|
|
633
|
+
for name, present in (("python", plan.ecosystems.python), ("typescript", plan.ecosystems.typescript))
|
|
634
|
+
if present
|
|
635
|
+
]
|
|
636
|
+
print(f"detected: {', '.join(detected) or 'nothing'}")
|
|
637
|
+
if not plan.ecosystems.any and not selected_configs:
|
|
638
|
+
for note in plan.notes:
|
|
639
|
+
print(f"note: {note}")
|
|
640
|
+
return 1
|
|
641
|
+
|
|
642
|
+
print(f"configs: {', '.join(plan.configs)}")
|
|
643
|
+
if args.dry_run:
|
|
644
|
+
print("\n-- dry run; nothing is written --")
|
|
645
|
+
if init_plan.sync is not None:
|
|
646
|
+
pending_sync = 0
|
|
647
|
+
for target in init_plan.sync.targets:
|
|
648
|
+
if not target.destination.is_file() or target.destination.read_bytes() != target.source.read_bytes():
|
|
649
|
+
print(f"would sync: {target.destination}")
|
|
650
|
+
pending_sync += 1
|
|
651
|
+
if not pending_sync:
|
|
652
|
+
print("configs are current")
|
|
653
|
+
if not args.no_install:
|
|
654
|
+
for command in init_plan.install_commands:
|
|
655
|
+
print(f"would run: {shlex.join(command.argv)} (in {command.cwd})")
|
|
656
|
+
else:
|
|
657
|
+
result = service.apply_init(init_plan, install=not args.no_install)
|
|
658
|
+
if result.status:
|
|
659
|
+
event = "interrupted" if result.failure is service.InitFailure.INTERRUPTED else "failed"
|
|
660
|
+
print(
|
|
661
|
+
f"error: initialization {event}; rollback and generated-environment cleanup were attempted",
|
|
662
|
+
file=sys.stderr,
|
|
663
|
+
)
|
|
664
|
+
if result.error:
|
|
665
|
+
print(f"detail: {result.error}", file=sys.stderr)
|
|
666
|
+
return result.status
|
|
667
|
+
if result.sync is not None:
|
|
668
|
+
_render_sync(result.sync)
|
|
669
|
+
|
|
670
|
+
verb_write = "would write" if args.dry_run else "wrote"
|
|
671
|
+
verb_edit = "would append to" if args.dry_run else "appended to"
|
|
672
|
+
verb_delete = "would remove" if args.dry_run else "removed"
|
|
673
|
+
for path, _contents in plan.writes:
|
|
674
|
+
print(f"{verb_write}: {path}")
|
|
675
|
+
for path, _addition in plan.edits:
|
|
676
|
+
print(f"{verb_edit}: {path}")
|
|
677
|
+
for path in plan.deletes:
|
|
678
|
+
print(f"{verb_delete}: {path}")
|
|
679
|
+
for path, reason in plan.skips:
|
|
680
|
+
print(f"skip: {path} ({reason})")
|
|
681
|
+
|
|
682
|
+
for note in plan.notes:
|
|
683
|
+
print(f"\nnote: {note}")
|
|
684
|
+
if args.no_install and init_plan.install_commands:
|
|
685
|
+
print("\nnext: dependency and hook installation was skipped; run:")
|
|
686
|
+
for command in init_plan.install_commands:
|
|
687
|
+
print(f" {shlex.join(command.argv)} (in {command.cwd})")
|
|
688
|
+
workflows = scaffold.standards_check_workflows(root)
|
|
689
|
+
if workflows:
|
|
690
|
+
rendered = ", ".join(path.relative_to(root).as_posix() for path in workflows)
|
|
691
|
+
print(f"\nCI: {rendered} runs the pinned quality gate")
|
|
692
|
+
else:
|
|
693
|
+
print("\nCI: would write .github/workflows/standards.yml")
|
|
694
|
+
return 0
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def cmd_verify(args: _Args) -> int:
|
|
698
|
+
root = _resolve_dest(args.dest)
|
|
699
|
+
doctor_status = cmd_doctor(args)
|
|
700
|
+
if doctor_status:
|
|
701
|
+
return doctor_status
|
|
702
|
+
sync_args = _Args()
|
|
703
|
+
sync_args.dest = str(root)
|
|
704
|
+
sync_args.check = True
|
|
705
|
+
sync_status = cmd_sync(sync_args, next_steps=False)
|
|
706
|
+
if sync_status:
|
|
707
|
+
return sync_status
|
|
708
|
+
adopted = _declared_manifest(args)
|
|
709
|
+
return _run_canonical_check(
|
|
710
|
+
root,
|
|
711
|
+
None if adopted is None else adopted.verify_paths,
|
|
712
|
+
raw=adopted is None,
|
|
713
|
+
trusted=args.trust_repository_code,
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _declared_manifest(args: _Args) -> manifest.Manifest | None:
|
|
718
|
+
try:
|
|
719
|
+
return manifest.load(_resolve_dest(args.dest))
|
|
720
|
+
except TypeError, ValueError, SystemExit:
|
|
721
|
+
return None
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def cmd_library_policy(args: _Args, *, selected_paths: Iterable[str] | None = None) -> int:
|
|
725
|
+
from sarj_standards.libs.linting import library_policy # ruff: ignore[import-outside-top-level]
|
|
726
|
+
|
|
727
|
+
root = _resolve_dest(args.dest)
|
|
728
|
+
try:
|
|
729
|
+
adopted = manifest.load(root)
|
|
730
|
+
except (OSError, TypeError, ValueError) as exc:
|
|
731
|
+
print(f"error: invalid standards manifest: {exc}", file=sys.stderr)
|
|
732
|
+
return 2
|
|
733
|
+
profile = args.profile or (adopted.profile if adopted is not None else "standard")
|
|
734
|
+
if profile != "application":
|
|
735
|
+
if args.output_format == "json":
|
|
736
|
+
print(json.dumps({"profile": profile, "findings": []}))
|
|
737
|
+
elif not args.quiet:
|
|
738
|
+
print("library policy skipped (standard profile)")
|
|
739
|
+
return 0
|
|
740
|
+
try:
|
|
741
|
+
findings = (
|
|
742
|
+
library_policy.scan(root) if selected_paths is None else library_policy.scan_paths(root, selected_paths)
|
|
743
|
+
)
|
|
744
|
+
except library_policy.ManifestPolicyError as exc:
|
|
745
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
746
|
+
return 2
|
|
747
|
+
if args.output_format == "json":
|
|
748
|
+
print(
|
|
749
|
+
json.dumps(
|
|
750
|
+
{
|
|
751
|
+
"profile": profile,
|
|
752
|
+
"findings": [
|
|
753
|
+
{
|
|
754
|
+
"id": finding.id,
|
|
755
|
+
"path": str(finding.path),
|
|
756
|
+
"line": finding.line,
|
|
757
|
+
"column": finding.column,
|
|
758
|
+
"package": finding.package,
|
|
759
|
+
"replacement": finding.replacement,
|
|
760
|
+
"message": finding.message,
|
|
761
|
+
}
|
|
762
|
+
for finding in findings
|
|
763
|
+
],
|
|
764
|
+
},
|
|
765
|
+
indent=2,
|
|
766
|
+
)
|
|
767
|
+
)
|
|
768
|
+
elif findings or not args.quiet:
|
|
769
|
+
print("\n".join(finding.render() for finding in findings) or "library policy ✓")
|
|
770
|
+
return 1 if findings else 0
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def cmd_check(args: _Args) -> int:
|
|
774
|
+
from sarj_standards.libs.linting import external, runner # ruff: ignore[import-outside-top-level]
|
|
775
|
+
|
|
776
|
+
root = _resolve_dest(args.dest)
|
|
777
|
+
catalog_status = _check_conventional_slack_catalog(args, root)
|
|
778
|
+
if catalog_status:
|
|
779
|
+
return catalog_status
|
|
780
|
+
repository_wide = not args.files
|
|
781
|
+
pull_request_scoped = False
|
|
782
|
+
if args.staged:
|
|
783
|
+
try:
|
|
784
|
+
staged_names = _staged_file_names(root)
|
|
785
|
+
staged = _safe_staged_paths(root, staged_names)
|
|
786
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
787
|
+
if args.output_format != "text":
|
|
788
|
+
return _emit_analysis_report(args, root, _machine_input_error(root, f"cannot read staged files: {exc}"))
|
|
789
|
+
print(f"error: cannot read staged files: {exc}", file=sys.stderr)
|
|
790
|
+
return 2
|
|
791
|
+
if args.files:
|
|
792
|
+
requested = frozenset(_repository_relative_names(root, args.files))
|
|
793
|
+
selected_staged_names = [name for name in staged_names if name in requested]
|
|
794
|
+
staged_set = frozenset(staged)
|
|
795
|
+
args.files = [path for path in _safe_staged_paths(root, args.files) if path in staged_set]
|
|
796
|
+
else:
|
|
797
|
+
selected_staged_names = staged_names
|
|
798
|
+
args.files = staged
|
|
799
|
+
drifted = _unstaged_versions(root, selected_staged_names)
|
|
800
|
+
if drifted:
|
|
801
|
+
message = (
|
|
802
|
+
"--staged found files with unstaged content; run through pre-commit "
|
|
803
|
+
"(which safely stashes it) or stage the intended versions: " + ", ".join(drifted)
|
|
804
|
+
)
|
|
805
|
+
if args.output_format != "text":
|
|
806
|
+
return _emit_analysis_report(args, root, _machine_input_error(root, message))
|
|
807
|
+
print(f"error: {message}", file=sys.stderr)
|
|
808
|
+
return 2
|
|
809
|
+
elif args.files:
|
|
810
|
+
try:
|
|
811
|
+
args.files = _selected_paths(root, args.files)
|
|
812
|
+
except ValueError as exc:
|
|
813
|
+
if args.output_format != "text":
|
|
814
|
+
return _emit_analysis_report(args, root, _machine_input_error(root, str(exc)))
|
|
815
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
816
|
+
return 2
|
|
817
|
+
elif (root / ".git").exists() and external.is_non_default_github_push():
|
|
818
|
+
args.files = []
|
|
819
|
+
pull_request_scoped = True
|
|
820
|
+
elif (root / ".git").exists() and (base := external.change_scope_base()):
|
|
821
|
+
try:
|
|
822
|
+
args.files = [path for path in _changed_file_paths(root, base) if runner.accepts_hook_path(Path(path))]
|
|
823
|
+
pull_request_scoped = True
|
|
824
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
825
|
+
if args.output_format != "text":
|
|
826
|
+
return _emit_analysis_report(
|
|
827
|
+
args,
|
|
828
|
+
root,
|
|
829
|
+
_machine_input_error(root, f"cannot read pull-request changes: {exc}"),
|
|
830
|
+
)
|
|
831
|
+
print(f"error: cannot read pull-request changes: {exc}", file=sys.stderr)
|
|
832
|
+
return 2
|
|
833
|
+
if len(args.files) == 1 and Path(args.files[0]).resolve() == root:
|
|
834
|
+
args.files = []
|
|
835
|
+
repository_wide = True
|
|
836
|
+
if args.staged:
|
|
837
|
+
health_status = _check_staged_adoption_health(root, args.files, args=args)
|
|
838
|
+
if health_status:
|
|
839
|
+
return health_status
|
|
840
|
+
args.files = [path for path in args.files if runner.accepts_hook_path(Path(path))]
|
|
841
|
+
if not args.files:
|
|
842
|
+
return 0
|
|
843
|
+
if args.output_format != "text":
|
|
844
|
+
if _validate_analysis_output(args, root):
|
|
845
|
+
return 2
|
|
846
|
+
args.external = True
|
|
847
|
+
args.trust = "trusted" if args.trust_repository_code else "safe"
|
|
848
|
+
args.analysis_mode = "policy"
|
|
849
|
+
if repository_wide:
|
|
850
|
+
adoption_report = _machine_adoption_gate(root)
|
|
851
|
+
if adoption_report is not None:
|
|
852
|
+
return _emit_analysis_report(args, root, adoption_report)
|
|
853
|
+
if pull_request_scoped and not args.files:
|
|
854
|
+
from sarj_standards.api import ( # ruff: ignore[import-outside-top-level]
|
|
855
|
+
AnalysisMode,
|
|
856
|
+
Standards,
|
|
857
|
+
TrustMode,
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
report = Standards(root).analyze(
|
|
861
|
+
(),
|
|
862
|
+
external=True,
|
|
863
|
+
trust=TrustMode.TRUSTED if args.trust_repository_code else TrustMode.SAFE,
|
|
864
|
+
mode=AnalysisMode.POLICY,
|
|
865
|
+
)
|
|
866
|
+
return _emit_analysis_report(args, root, report)
|
|
867
|
+
return cmd_analyze(args)
|
|
868
|
+
if pull_request_scoped:
|
|
869
|
+
adoption_report = _machine_adoption_gate(root)
|
|
870
|
+
if adoption_report is not None:
|
|
871
|
+
return _emit_analysis_report(args, root, adoption_report)
|
|
872
|
+
if not args.files:
|
|
873
|
+
return _run_canonical_check(root, (), trusted=args.trust_repository_code)
|
|
874
|
+
if not args.files:
|
|
875
|
+
return cmd_verify(args)
|
|
876
|
+
return _run_canonical_check(root, list(args.files), trusted=args.trust_repository_code, staged=args.staged)
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def cmd_validate_slack_automations(args: _Args) -> int:
|
|
880
|
+
root = _resolve_dest(args.dest)
|
|
881
|
+
path = _catalog_path(root, args.slack_catalog)
|
|
882
|
+
return _render_slack_catalog_findings(args, root, path)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _check_conventional_slack_catalog(args: _Args, root: Path) -> int:
|
|
886
|
+
from sarj_standards.libs.catalogs import CONVENTIONAL_PATH # ruff: ignore[import-outside-top-level]
|
|
887
|
+
|
|
888
|
+
path = root / CONVENTIONAL_PATH
|
|
889
|
+
return _render_slack_catalog_findings(args, root, path) if path.exists() or path.is_symlink() else 0
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _catalog_path(root: Path, value: Path) -> Path:
|
|
893
|
+
path = value if value.is_absolute() else root / value
|
|
894
|
+
if not path.resolve().is_relative_to(root):
|
|
895
|
+
_user_error(f"catalog path escapes repository root: {value}")
|
|
896
|
+
if not path.is_file():
|
|
897
|
+
_user_error(f"catalog path is not a file: {value}")
|
|
898
|
+
return path
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _render_slack_catalog_findings(args: _Args, root: Path, path: Path) -> int:
|
|
902
|
+
from sarj_standards.libs.catalogs import validate_catalog # ruff: ignore[import-outside-top-level]
|
|
903
|
+
|
|
904
|
+
findings = validate_catalog(path, root=root)
|
|
905
|
+
if not findings:
|
|
906
|
+
if args.cmd == "validate-slack-automations":
|
|
907
|
+
print(f"Slack automation catalog ✓ ({path.relative_to(root).as_posix()})")
|
|
908
|
+
return 0
|
|
909
|
+
relative = path.relative_to(root)
|
|
910
|
+
if args.output_format == "text":
|
|
911
|
+
print("\n".join(finding.render(relative) for finding in findings))
|
|
912
|
+
return 1
|
|
913
|
+
from sarj_standards.libs.diagnostics import ( # ruff: ignore[import-outside-top-level]
|
|
914
|
+
Completion,
|
|
915
|
+
Diagnostic,
|
|
916
|
+
Location,
|
|
917
|
+
Severity,
|
|
918
|
+
ToolReport,
|
|
919
|
+
)
|
|
920
|
+
from sarj_standards.libs.linting.analysis import report_from_tools # ruff: ignore[import-outside-top-level]
|
|
921
|
+
|
|
922
|
+
diagnostics = tuple(
|
|
923
|
+
Diagnostic(
|
|
924
|
+
"slack-automations.invalid",
|
|
925
|
+
f"{finding.location}: {finding.message}",
|
|
926
|
+
Severity.ERROR,
|
|
927
|
+
"sarj-standards-slack-automations",
|
|
928
|
+
Location(relative.as_posix()),
|
|
929
|
+
rule_id="slack-automations.invalid",
|
|
930
|
+
help="run `code-standards validate-slack-automations catalog/slack-automations.v1.json`",
|
|
931
|
+
)
|
|
932
|
+
for finding in findings
|
|
933
|
+
)
|
|
934
|
+
report = report_from_tools(
|
|
935
|
+
root,
|
|
936
|
+
(ToolReport("sarj-standards-slack-automations", Completion.COMPLETE, diagnostics=diagnostics),),
|
|
937
|
+
)
|
|
938
|
+
return _emit_analysis_report(args, root, report)
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _run_canonical_check(
|
|
942
|
+
root: Path,
|
|
943
|
+
paths: Sequence[str] | None,
|
|
944
|
+
*,
|
|
945
|
+
raw: bool = False,
|
|
946
|
+
trusted: bool = False,
|
|
947
|
+
staged: bool = False,
|
|
948
|
+
) -> int:
|
|
949
|
+
from sarj_standards.api import AnalysisMode, Standards, TrustMode # ruff: ignore[import-outside-top-level]
|
|
950
|
+
from sarj_standards.libs.diagnostics import to_text # ruff: ignore[import-outside-top-level]
|
|
951
|
+
|
|
952
|
+
report = Standards(root).analyze(
|
|
953
|
+
paths,
|
|
954
|
+
external=True,
|
|
955
|
+
trust=TrustMode.TRUSTED if trusted else TrustMode.SAFE,
|
|
956
|
+
mode=AnalysisMode.RAW if raw else AnalysisMode.POLICY,
|
|
957
|
+
staged=staged,
|
|
958
|
+
)
|
|
959
|
+
rendered = to_text(report)
|
|
960
|
+
if rendered:
|
|
961
|
+
print(rendered, end="" if rendered.endswith("\n") else "\n")
|
|
962
|
+
return report.exit_code
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def cmd_analyze(args: _Args) -> int:
|
|
966
|
+
from sarj_standards.api import ( # ruff: ignore[import-outside-top-level] -- keep CLI startup cheap
|
|
967
|
+
AnalysisMode,
|
|
968
|
+
Standards,
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
root = _resolve_dest(args.dest)
|
|
972
|
+
if _validate_analysis_output(args, root):
|
|
973
|
+
return 2
|
|
974
|
+
report = Standards(root).analyze(
|
|
975
|
+
args.files or None,
|
|
976
|
+
external=args.external,
|
|
977
|
+
trust=args.trust,
|
|
978
|
+
mode=AnalysisMode(args.analysis_mode),
|
|
979
|
+
staged=args.staged,
|
|
980
|
+
)
|
|
981
|
+
return _emit_analysis_report(args, root, report)
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def cmd_rule_evaluate(args: _Args) -> int:
|
|
985
|
+
from sarj_standards.api import AnalysisMode, Standards, TrustMode # ruff: ignore[import-outside-top-level]
|
|
986
|
+
|
|
987
|
+
root = _resolve_dest(args.dest)
|
|
988
|
+
if _validate_analysis_output(args, root):
|
|
989
|
+
return 2
|
|
990
|
+
report = Standards(root).analyze(
|
|
991
|
+
args.files or None,
|
|
992
|
+
external=any(selector.engine.value == "eslint" for selector in args.selected_rules),
|
|
993
|
+
trust=TrustMode.TRUSTED if args.trust_repository_code else TrustMode.SAFE,
|
|
994
|
+
mode=(AnalysisMode.CORPUS if args.evaluation_scope is _EvaluationScope.CORPUS else AnalysisMode.POLICY),
|
|
995
|
+
rules=args.selected_rules,
|
|
996
|
+
)
|
|
997
|
+
status = _emit_analysis_report(args, root, report)
|
|
998
|
+
if args.output_format == "text" and not report.issues:
|
|
999
|
+
print(_rule_evaluation_summary(report, args.selected_rules))
|
|
1000
|
+
return status
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def cmd_observe(args: _Args) -> int:
|
|
1004
|
+
from sarj_standards.api import AnalysisMode, Standards, TrustMode # ruff: ignore[import-outside-top-level]
|
|
1005
|
+
from sarj_standards.libs.linting.policy import warning_selectors # ruff: ignore[import-outside-top-level]
|
|
1006
|
+
|
|
1007
|
+
root = _resolve_dest(args.dest)
|
|
1008
|
+
if _validate_analysis_output(args, root):
|
|
1009
|
+
return 2
|
|
1010
|
+
warning_rules = warning_selectors()
|
|
1011
|
+
invalid = sorted(set(args.selected_rules) - warning_rules)
|
|
1012
|
+
if invalid:
|
|
1013
|
+
rendered = ", ".join(map(str, invalid))
|
|
1014
|
+
print(
|
|
1015
|
+
f"error: observe accepts warning-stage rules only: {rendered}\n"
|
|
1016
|
+
f"next: code-standards maintain rules stage-warning {invalid[0]}",
|
|
1017
|
+
file=sys.stderr,
|
|
1018
|
+
)
|
|
1019
|
+
return 2
|
|
1020
|
+
report = Standards(root).analyze(
|
|
1021
|
+
args.files or None,
|
|
1022
|
+
external=any(selector.engine.value == "eslint" for selector in args.selected_rules),
|
|
1023
|
+
trust=TrustMode.TRUSTED if args.trust_repository_code else TrustMode.SAFE,
|
|
1024
|
+
mode=AnalysisMode.OBSERVE,
|
|
1025
|
+
rules=args.selected_rules,
|
|
1026
|
+
)
|
|
1027
|
+
return _emit_analysis_report(args, root, report)
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _rule_evaluation_summary(report: object, selectors: Sequence[RuleSelector]) -> str:
|
|
1031
|
+
from sarj_standards.libs.diagnostics import AnalysisReport # ruff: ignore[import-outside-top-level]
|
|
1032
|
+
from sarj_standards.libs.rules import RuleEngine # ruff: ignore[import-outside-top-level]
|
|
1033
|
+
|
|
1034
|
+
if not isinstance(report, AnalysisReport):
|
|
1035
|
+
msg = "rule evaluation report has an invalid internal type"
|
|
1036
|
+
raise TypeError(msg)
|
|
1037
|
+
sources = {
|
|
1038
|
+
RuleEngine.ESLINT: frozenset(("eslint",)),
|
|
1039
|
+
RuleEngine.IAC: frozenset(("iac", "sarj-iac-lint")),
|
|
1040
|
+
RuleEngine.PYTHON: frozenset(("python", "sarj-python-lint")),
|
|
1041
|
+
RuleEngine.SQL: frozenset(("sql", "sarj-sql-lint")),
|
|
1042
|
+
RuleEngine.TEXT: frozenset(("text", "sarj-text-lint")),
|
|
1043
|
+
}
|
|
1044
|
+
lines = ["calibration summary:"]
|
|
1045
|
+
for selector in sorted(set(selectors)):
|
|
1046
|
+
count = sum(
|
|
1047
|
+
item.source in sources[selector.engine] and (item.rule_id or item.code) == selector.native_rule_id
|
|
1048
|
+
for item in report.diagnostics
|
|
1049
|
+
)
|
|
1050
|
+
noun = "finding" if count == 1 else "findings"
|
|
1051
|
+
lines.append(f" {selector}: {count} {noun}")
|
|
1052
|
+
lines.extend(
|
|
1053
|
+
(
|
|
1054
|
+
"next: review these findings for false positives; stage an approved rule with:",
|
|
1055
|
+
f" code-standards maintain rules stage-warning {min(selectors)}",
|
|
1056
|
+
)
|
|
1057
|
+
)
|
|
1058
|
+
return "\n".join(lines)
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def _emit_analysis_report(args: _Args, root: Path, report: object) -> int:
|
|
1062
|
+
from sarj_standards.libs.diagnostics import ( # ruff: ignore[import-outside-top-level]
|
|
1063
|
+
AnalysisReport,
|
|
1064
|
+
to_github,
|
|
1065
|
+
to_json,
|
|
1066
|
+
to_sarif,
|
|
1067
|
+
to_text,
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
if not isinstance(report, AnalysisReport):
|
|
1071
|
+
msg = "analysis report has an invalid internal type"
|
|
1072
|
+
raise TypeError(msg)
|
|
1073
|
+
if args.output is not None and str(args.output) != "-" and args.output_format not in {"json", "sarif"}:
|
|
1074
|
+
print("error: --output is supported only with --format json or sarif", file=sys.stderr)
|
|
1075
|
+
return 2
|
|
1076
|
+
if args.output is not None and str(args.output) != "-":
|
|
1077
|
+
try:
|
|
1078
|
+
_prepare_report_parent(root, args.output)
|
|
1079
|
+
_report_destination(root, args.output, output_format=args.output_format)
|
|
1080
|
+
except OSError as exc:
|
|
1081
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
1082
|
+
return 2
|
|
1083
|
+
if args.output_format == "github":
|
|
1084
|
+
payload = to_github(report, max_annotations_per_level=args.max_annotations_per_level)
|
|
1085
|
+
elif args.output_format == "json":
|
|
1086
|
+
payload = to_json(report)
|
|
1087
|
+
else:
|
|
1088
|
+
payload = {"sarif": to_sarif, "text": to_text}[args.output_format](report)
|
|
1089
|
+
if args.output is None or str(args.output) == "-":
|
|
1090
|
+
print(payload, end="")
|
|
1091
|
+
else:
|
|
1092
|
+
_write_report(root, args.output, payload, output_format=args.output_format)
|
|
1093
|
+
return report.exit_code
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def _validate_analysis_output(args: _Args, root: Path) -> bool:
|
|
1097
|
+
if args.output is None or str(args.output) == "-":
|
|
1098
|
+
return False
|
|
1099
|
+
if args.output_format not in {"json", "sarif"}:
|
|
1100
|
+
print("error: --output is supported only with --format json or sarif", file=sys.stderr)
|
|
1101
|
+
return True
|
|
1102
|
+
try:
|
|
1103
|
+
_prepare_report_parent(root, args.output)
|
|
1104
|
+
_report_destination(root, args.output, output_format=args.output_format)
|
|
1105
|
+
except OSError as exc:
|
|
1106
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
1107
|
+
return True
|
|
1108
|
+
return False
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def _machine_adoption_gate(root: Path) -> object | None:
|
|
1112
|
+
from sarj_standards.libs.adoption import doctor, service # ruff: ignore[import-outside-top-level]
|
|
1113
|
+
from sarj_standards.libs.diagnostics import ( # ruff: ignore[import-outside-top-level]
|
|
1114
|
+
Completion,
|
|
1115
|
+
Diagnostic,
|
|
1116
|
+
ExecutionIssue,
|
|
1117
|
+
Location,
|
|
1118
|
+
Severity,
|
|
1119
|
+
ToolReport,
|
|
1120
|
+
)
|
|
1121
|
+
from sarj_standards.libs.linting.analysis import report_from_tools # ruff: ignore[import-outside-top-level]
|
|
1122
|
+
|
|
1123
|
+
diagnosed = doctor.diagnose(root)
|
|
1124
|
+
absent = next((finding for finding in diagnosed if finding.id == "doctor.manifest.absent"), None)
|
|
1125
|
+
if absent is not None:
|
|
1126
|
+
diagnostic = Diagnostic(
|
|
1127
|
+
absent.id,
|
|
1128
|
+
absent.detail,
|
|
1129
|
+
Severity.ERROR,
|
|
1130
|
+
"sarj-standards-doctor",
|
|
1131
|
+
Location(manifest.MANIFEST_NAME),
|
|
1132
|
+
rule_id=absent.id,
|
|
1133
|
+
help=absent.remediation or "run `code-standards setup`",
|
|
1134
|
+
)
|
|
1135
|
+
return report_from_tools(
|
|
1136
|
+
root,
|
|
1137
|
+
(ToolReport("sarj-standards-adoption", Completion.COMPLETE, diagnostics=(diagnostic,)),),
|
|
1138
|
+
)
|
|
1139
|
+
drifted = [finding for finding in diagnosed if finding.level is doctor.Level.DRIFT]
|
|
1140
|
+
invalid_ids = _INVALID_DOCTOR_IDS
|
|
1141
|
+
issues = tuple(
|
|
1142
|
+
ExecutionIssue("sarj-standards-doctor", finding.id, f"{finding.where}: {finding.detail}", exit_code=2)
|
|
1143
|
+
for finding in drifted
|
|
1144
|
+
if finding.id in invalid_ids
|
|
1145
|
+
)
|
|
1146
|
+
diagnostics = tuple(
|
|
1147
|
+
Diagnostic(
|
|
1148
|
+
finding.id,
|
|
1149
|
+
finding.detail,
|
|
1150
|
+
Severity.ERROR,
|
|
1151
|
+
"sarj-standards-doctor",
|
|
1152
|
+
Location(_doctor_location(root, finding.where)),
|
|
1153
|
+
rule_id=finding.id,
|
|
1154
|
+
help=finding.remediation,
|
|
1155
|
+
)
|
|
1156
|
+
for finding in drifted
|
|
1157
|
+
if finding.id not in invalid_ids
|
|
1158
|
+
)
|
|
1159
|
+
try:
|
|
1160
|
+
sync = service.apply_sync(service.plan_sync(root), check=True)
|
|
1161
|
+
except (OSError, TypeError, ValueError) as exc:
|
|
1162
|
+
issues = (*issues, ExecutionIssue("sarj-standards-config", "config-sync-invalid", str(exc), exit_code=2))
|
|
1163
|
+
else:
|
|
1164
|
+
doctor_paths = {diagnostic.location.path for diagnostic in diagnostics}
|
|
1165
|
+
sync_diagnostics_list: list[Diagnostic] = []
|
|
1166
|
+
for record in sync.records:
|
|
1167
|
+
relative = record.target.destination.relative_to(root).as_posix()
|
|
1168
|
+
if record.outcome is not service.SyncOutcome.DRIFT or relative in doctor_paths:
|
|
1169
|
+
continue
|
|
1170
|
+
sync_diagnostics_list.append(
|
|
1171
|
+
Diagnostic(
|
|
1172
|
+
"standards.config.sync",
|
|
1173
|
+
"generated configuration differs from the installed Standards version",
|
|
1174
|
+
Severity.ERROR,
|
|
1175
|
+
"sarj-standards-config",
|
|
1176
|
+
Location(relative),
|
|
1177
|
+
rule_id="standards.config.sync",
|
|
1178
|
+
help="run `code-standards update --offline`",
|
|
1179
|
+
)
|
|
1180
|
+
)
|
|
1181
|
+
sync_diagnostics = tuple(sync_diagnostics_list)
|
|
1182
|
+
diagnostics = (*diagnostics, *sync_diagnostics)
|
|
1183
|
+
if any(record.outcome is service.SyncOutcome.INVALID for record in sync.records):
|
|
1184
|
+
issues = (
|
|
1185
|
+
*issues,
|
|
1186
|
+
ExecutionIssue(
|
|
1187
|
+
"sarj-standards-config",
|
|
1188
|
+
"config-sync-invalid",
|
|
1189
|
+
"a generated configuration destination is not a regular file",
|
|
1190
|
+
exit_code=2,
|
|
1191
|
+
),
|
|
1192
|
+
)
|
|
1193
|
+
if not diagnostics and not issues:
|
|
1194
|
+
return None
|
|
1195
|
+
completion = Completion.FAILED if issues else Completion.COMPLETE
|
|
1196
|
+
tool = ToolReport("sarj-standards-adoption", completion, diagnostics=diagnostics, issues=issues)
|
|
1197
|
+
return report_from_tools(root, (tool,))
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def _machine_input_error(root: Path, message: str) -> object:
|
|
1201
|
+
from sarj_standards.libs.diagnostics import ( # ruff: ignore[import-outside-top-level] -- machine formats stay lazy
|
|
1202
|
+
Completion,
|
|
1203
|
+
ExecutionIssue,
|
|
1204
|
+
ToolReport,
|
|
1205
|
+
)
|
|
1206
|
+
from sarj_standards.libs.linting.analysis import ( # ruff: ignore[import-outside-top-level] -- machine formats stay lazy
|
|
1207
|
+
report_from_tools,
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
issue = ExecutionIssue("sarj-standards", "invalid-input", message, exit_code=2)
|
|
1211
|
+
return report_from_tools(root, (ToolReport("sarj-standards-input", Completion.FAILED, issues=(issue,)),))
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _doctor_location(root: Path, where: str) -> str:
|
|
1215
|
+
rendered = where.split(":", 1)[0]
|
|
1216
|
+
candidate = Path(rendered)
|
|
1217
|
+
if not candidate.is_absolute() and ".." not in candidate.parts and (root / candidate).exists():
|
|
1218
|
+
return candidate.as_posix()
|
|
1219
|
+
return manifest.MANIFEST_NAME
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def _report_destination(root: Path, output: Path, *, output_format: str) -> Path:
|
|
1223
|
+
candidate = output if output.is_absolute() else root / output
|
|
1224
|
+
lexical = Path(os.path.abspath(candidate)) # ruff: ignore[os-path-abspath] -- preserve symlink components for rejection
|
|
1225
|
+
try:
|
|
1226
|
+
relative = lexical.relative_to(root)
|
|
1227
|
+
except ValueError as exc:
|
|
1228
|
+
msg = f"report output must stay inside repository root: {output}"
|
|
1229
|
+
raise OSError(msg) from exc
|
|
1230
|
+
current = root
|
|
1231
|
+
for part in relative.parent.parts:
|
|
1232
|
+
current /= part
|
|
1233
|
+
if is_link_like(current):
|
|
1234
|
+
msg = f"report output parent must not traverse a symlink: {current}"
|
|
1235
|
+
raise OSError(msg)
|
|
1236
|
+
destination = lexical.resolve(strict=False)
|
|
1237
|
+
try:
|
|
1238
|
+
destination.relative_to(root)
|
|
1239
|
+
except ValueError as exc:
|
|
1240
|
+
msg = f"report output must stay inside repository root: {output}"
|
|
1241
|
+
raise OSError(msg) from exc
|
|
1242
|
+
expected_suffix = destination.name.endswith(".sarif") or destination.name.endswith(".sarif.json")
|
|
1243
|
+
extension_matches = {
|
|
1244
|
+
"json": destination.suffix == ".json",
|
|
1245
|
+
"sarif": expected_suffix,
|
|
1246
|
+
}[output_format]
|
|
1247
|
+
if not extension_matches:
|
|
1248
|
+
msg = f"report output extension does not match {output_format}: {output}"
|
|
1249
|
+
raise OSError(msg)
|
|
1250
|
+
parent = destination.parent
|
|
1251
|
+
if not parent.is_dir():
|
|
1252
|
+
msg = f"report output parent does not exist: {parent}"
|
|
1253
|
+
raise OSError(msg)
|
|
1254
|
+
if destination.is_dir() or is_link_like(destination):
|
|
1255
|
+
msg = f"report output must be a regular file: {destination}"
|
|
1256
|
+
raise OSError(msg)
|
|
1257
|
+
return destination
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def _prepare_report_parent(root: Path, output: Path) -> None:
|
|
1261
|
+
candidate = output if output.is_absolute() else root / output
|
|
1262
|
+
lexical = Path(os.path.abspath(candidate)) # ruff: ignore[os-path-abspath] -- inspect lexical parent components.
|
|
1263
|
+
try:
|
|
1264
|
+
relative = lexical.relative_to(root)
|
|
1265
|
+
except ValueError as exc:
|
|
1266
|
+
msg = f"report output must stay inside repository root: {output}"
|
|
1267
|
+
raise OSError(msg) from exc
|
|
1268
|
+
current = root
|
|
1269
|
+
for part in relative.parent.parts:
|
|
1270
|
+
current /= part
|
|
1271
|
+
if is_link_like(current):
|
|
1272
|
+
msg = f"report output parent must not traverse a symlink: {current}"
|
|
1273
|
+
raise OSError(msg)
|
|
1274
|
+
if current.exists() and not current.is_dir():
|
|
1275
|
+
msg = f"report output parent must be a directory: {current}"
|
|
1276
|
+
raise OSError(msg)
|
|
1277
|
+
current.mkdir(exist_ok=True)
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
def _write_report(root: Path, output: Path, payload: str, *, output_format: str) -> None:
|
|
1281
|
+
# Revalidate immediately before the write as a defense against a parent path
|
|
1282
|
+
# being replaced after the pre-analysis check.
|
|
1283
|
+
destination = _report_destination(root, output, output_format=output_format)
|
|
1284
|
+
parent = destination.parent
|
|
1285
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{destination.name}.", suffix=".tmp", dir=parent)
|
|
1286
|
+
temporary = Path(temporary_name)
|
|
1287
|
+
try:
|
|
1288
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
1289
|
+
_ = handle.write(payload)
|
|
1290
|
+
handle.flush()
|
|
1291
|
+
os.fsync(handle.fileno())
|
|
1292
|
+
temporary.replace(destination)
|
|
1293
|
+
except BaseException:
|
|
1294
|
+
temporary.unlink(missing_ok=True)
|
|
1295
|
+
raise
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
def _check_staged_adoption_health(
|
|
1299
|
+
root: Path,
|
|
1300
|
+
staged_paths: Iterable[str] = (),
|
|
1301
|
+
*,
|
|
1302
|
+
args: _Args,
|
|
1303
|
+
) -> int:
|
|
1304
|
+
from sarj_standards.libs.adoption import doctor # ruff: ignore[import-outside-top-level]
|
|
1305
|
+
|
|
1306
|
+
selected = tuple(Path(path) for path in staged_paths)
|
|
1307
|
+
drifted = [
|
|
1308
|
+
finding for finding in doctor.diagnose_adoption_health(root, selected) if finding.level is doctor.Level.DRIFT
|
|
1309
|
+
]
|
|
1310
|
+
invalid = any(finding.id in {"doctor.manifest.invalid", "doctor.package-json.invalid"} for finding in drifted)
|
|
1311
|
+
status = 2 if invalid else 1 if drifted else 0
|
|
1312
|
+
if args.output_format != "text" and status:
|
|
1313
|
+
from sarj_standards.libs.diagnostics import ( # ruff: ignore[import-outside-top-level]
|
|
1314
|
+
Completion,
|
|
1315
|
+
Diagnostic,
|
|
1316
|
+
ExecutionIssue,
|
|
1317
|
+
Location,
|
|
1318
|
+
Severity,
|
|
1319
|
+
ToolReport,
|
|
1320
|
+
)
|
|
1321
|
+
from sarj_standards.libs.linting.analysis import ( # ruff: ignore[import-outside-top-level]
|
|
1322
|
+
report_from_tools,
|
|
1323
|
+
)
|
|
1324
|
+
|
|
1325
|
+
issues = tuple(
|
|
1326
|
+
ExecutionIssue("sarj-standards-doctor", finding.id, finding.detail, exit_code=2)
|
|
1327
|
+
for finding in drifted
|
|
1328
|
+
if finding.id in _INVALID_DOCTOR_IDS
|
|
1329
|
+
)
|
|
1330
|
+
diagnostics = tuple(
|
|
1331
|
+
Diagnostic(
|
|
1332
|
+
finding.id,
|
|
1333
|
+
finding.detail,
|
|
1334
|
+
Severity.ERROR,
|
|
1335
|
+
"sarj-standards-doctor",
|
|
1336
|
+
Location(_doctor_location(root, finding.where)),
|
|
1337
|
+
rule_id=finding.id,
|
|
1338
|
+
help=finding.remediation,
|
|
1339
|
+
)
|
|
1340
|
+
for finding in drifted
|
|
1341
|
+
if finding.id not in _INVALID_DOCTOR_IDS
|
|
1342
|
+
)
|
|
1343
|
+
completion = Completion.FAILED if issues else Completion.COMPLETE
|
|
1344
|
+
report = report_from_tools(
|
|
1345
|
+
root,
|
|
1346
|
+
(ToolReport("sarj-standards-adoption", completion, diagnostics=diagnostics, issues=issues),),
|
|
1347
|
+
)
|
|
1348
|
+
return _emit_analysis_report(args, root, report)
|
|
1349
|
+
for finding in drifted:
|
|
1350
|
+
print(f"drift: {finding.id} {finding.where} -- {finding.detail}")
|
|
1351
|
+
remediations = list(dict.fromkeys(finding.remediation for finding in drifted if finding.remediation))
|
|
1352
|
+
for remediation in remediations:
|
|
1353
|
+
print(f"fix: {remediation}")
|
|
1354
|
+
if invalid:
|
|
1355
|
+
return 2
|
|
1356
|
+
return 1 if drifted else 0
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def _staged_file_names(root: Path) -> list[str]:
|
|
1360
|
+
git = shutil.which("git")
|
|
1361
|
+
if git is None:
|
|
1362
|
+
msg = "git is required for --staged"
|
|
1363
|
+
raise OSError(msg)
|
|
1364
|
+
completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] -- fixed argv, no shell.
|
|
1365
|
+
[git, "diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z"],
|
|
1366
|
+
cwd=root,
|
|
1367
|
+
check=True,
|
|
1368
|
+
capture_output=True,
|
|
1369
|
+
env=_git_environment(),
|
|
1370
|
+
)
|
|
1371
|
+
names = [part.decode("utf-8", errors="surrogateescape") for part in completed.stdout.split(b"\0") if part]
|
|
1372
|
+
return list(dict.fromkeys(names))
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
def _changed_file_paths(root: Path, base: str) -> list[str]:
|
|
1376
|
+
git = shutil.which("git")
|
|
1377
|
+
if git is None:
|
|
1378
|
+
msg = "git is required for pull-request change scoping"
|
|
1379
|
+
raise OSError(msg)
|
|
1380
|
+
completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] -- fixed argv, no shell.
|
|
1381
|
+
[git, "diff", "--name-only", "--diff-filter=ACMR", "-z", f"{base}...HEAD", "--"],
|
|
1382
|
+
cwd=root,
|
|
1383
|
+
check=True,
|
|
1384
|
+
capture_output=True,
|
|
1385
|
+
env=_git_environment(),
|
|
1386
|
+
)
|
|
1387
|
+
names = [part.decode("utf-8", errors="surrogateescape") for part in completed.stdout.split(b"\0") if part]
|
|
1388
|
+
return _safe_staged_paths(root, names)
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
def _safe_staged_paths(root: Path, paths: Iterable[str]) -> list[str]:
|
|
1392
|
+
repository = root.resolve()
|
|
1393
|
+
safe: list[str] = []
|
|
1394
|
+
for raw in paths:
|
|
1395
|
+
if not raw:
|
|
1396
|
+
continue
|
|
1397
|
+
supplied = Path(raw)
|
|
1398
|
+
lexical = Path(
|
|
1399
|
+
os.path.abspath( # ruff: ignore[os-path-abspath] -- preserve lexical symlink components before resolve.
|
|
1400
|
+
supplied if supplied.is_absolute() else repository / supplied
|
|
1401
|
+
)
|
|
1402
|
+
)
|
|
1403
|
+
try:
|
|
1404
|
+
relative = lexical.relative_to(repository)
|
|
1405
|
+
except ValueError:
|
|
1406
|
+
continue
|
|
1407
|
+
cursor = repository
|
|
1408
|
+
if any(is_link_like(cursor := cursor / part) for part in relative.parts):
|
|
1409
|
+
continue
|
|
1410
|
+
resolved = lexical.resolve()
|
|
1411
|
+
if resolved.is_relative_to(repository) and resolved.is_file():
|
|
1412
|
+
safe.append(str(resolved))
|
|
1413
|
+
return list(dict.fromkeys(safe))
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def _unstaged_versions(root: Path, staged_paths: Iterable[str]) -> tuple[str, ...]:
|
|
1417
|
+
if not (root / ".git").exists():
|
|
1418
|
+
return ()
|
|
1419
|
+
git = shutil.which("git")
|
|
1420
|
+
if git is None:
|
|
1421
|
+
msg = "git is required for --staged"
|
|
1422
|
+
raise OSError(msg)
|
|
1423
|
+
completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] -- fixed argv, no shell.
|
|
1424
|
+
[git, "diff", "--name-only", "-z"],
|
|
1425
|
+
cwd=root,
|
|
1426
|
+
check=True,
|
|
1427
|
+
capture_output=True,
|
|
1428
|
+
env=_git_environment(),
|
|
1429
|
+
)
|
|
1430
|
+
unstaged = {part.decode("utf-8", errors="surrogateescape") for part in completed.stdout.split(b"\0") if part}
|
|
1431
|
+
selected = set(_repository_relative_names(root, staged_paths))
|
|
1432
|
+
return tuple(sorted(unstaged & selected))
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
def _repository_relative_names(root: Path, paths: Iterable[str]) -> list[str]:
|
|
1436
|
+
repository = root.resolve()
|
|
1437
|
+
relative_names: list[str] = []
|
|
1438
|
+
for raw in paths:
|
|
1439
|
+
supplied = Path(raw)
|
|
1440
|
+
lexical = Path(
|
|
1441
|
+
os.path.abspath( # ruff: ignore[os-path-abspath] -- preserve a missing worktree path lexically.
|
|
1442
|
+
supplied if supplied.is_absolute() else repository / supplied
|
|
1443
|
+
)
|
|
1444
|
+
)
|
|
1445
|
+
try:
|
|
1446
|
+
relative_names.append(lexical.relative_to(repository).as_posix())
|
|
1447
|
+
except ValueError:
|
|
1448
|
+
continue
|
|
1449
|
+
return list(dict.fromkeys(relative_names))
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
def _git_environment() -> dict[str, str]:
|
|
1453
|
+
return {
|
|
1454
|
+
name: value
|
|
1455
|
+
for name, value in os.environ.items() # ruff: ignore[banned-api] -- intentionally sanitize Git hook routing.
|
|
1456
|
+
if name in _GIT_SAFE_ENV
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def _selected_paths(root: Path, paths: Iterable[str]) -> list[str]:
|
|
1461
|
+
repository = root.resolve()
|
|
1462
|
+
selected: list[str] = []
|
|
1463
|
+
for raw in paths:
|
|
1464
|
+
supplied = Path(raw)
|
|
1465
|
+
lexical = Path(
|
|
1466
|
+
os.path.abspath( # ruff: ignore[os-path-abspath] -- inspect lexical symlink components before resolve.
|
|
1467
|
+
supplied if supplied.is_absolute() else repository / supplied
|
|
1468
|
+
)
|
|
1469
|
+
)
|
|
1470
|
+
try:
|
|
1471
|
+
relative = lexical.relative_to(repository)
|
|
1472
|
+
except ValueError as exc:
|
|
1473
|
+
msg = f"input escapes repository root: {raw}"
|
|
1474
|
+
raise ValueError(msg) from exc
|
|
1475
|
+
cursor = repository
|
|
1476
|
+
if any(is_link_like(cursor := cursor / part) for part in relative.parts):
|
|
1477
|
+
msg = f"refusing symlink input: {raw}"
|
|
1478
|
+
raise ValueError(msg)
|
|
1479
|
+
resolved = lexical.resolve()
|
|
1480
|
+
if not resolved.is_relative_to(repository):
|
|
1481
|
+
msg = f"input escapes repository root: {raw}"
|
|
1482
|
+
raise ValueError(msg)
|
|
1483
|
+
if not resolved.exists():
|
|
1484
|
+
msg = f"input does not exist: {raw}"
|
|
1485
|
+
raise ValueError(msg)
|
|
1486
|
+
selected.append(str(resolved))
|
|
1487
|
+
return list(dict.fromkeys(selected))
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
def cmd_format(args: _Args) -> int:
|
|
1491
|
+
from sarj_standards.libs.adoption import doctor, lifecycle, scaffold # ruff: ignore[import-outside-top-level]
|
|
1492
|
+
|
|
1493
|
+
root = _resolve_dest(args.dest)
|
|
1494
|
+
diagnosed = doctor.diagnose(root)
|
|
1495
|
+
if any(finding.id == "doctor.manifest.absent" for finding in diagnosed):
|
|
1496
|
+
print("error: repository is not adopted; run `code-standards setup`", file=sys.stderr)
|
|
1497
|
+
return 2
|
|
1498
|
+
drifted = [finding for finding in diagnosed if finding.level is doctor.Level.DRIFT]
|
|
1499
|
+
if drifted:
|
|
1500
|
+
for finding in drifted:
|
|
1501
|
+
print(f"error: {finding.id} {finding.where} -- {finding.detail}", file=sys.stderr)
|
|
1502
|
+
print("fix: run `code-standards doctor --repair`, then retry", file=sys.stderr)
|
|
1503
|
+
return 2 if any(finding.id in _INVALID_DOCTOR_IDS for finding in drifted) else 1
|
|
1504
|
+
if args.staged:
|
|
1505
|
+
try:
|
|
1506
|
+
args.files = _staged_files(root)
|
|
1507
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
1508
|
+
print(f"error: cannot read staged files: {exc}", file=sys.stderr)
|
|
1509
|
+
return 2
|
|
1510
|
+
elif args.files:
|
|
1511
|
+
try:
|
|
1512
|
+
args.files = _selected_paths(root, args.files)
|
|
1513
|
+
except ValueError as exc:
|
|
1514
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
1515
|
+
return 2
|
|
1516
|
+
adopted = _declared_manifest(args)
|
|
1517
|
+
ecosystems = scaffold.detect(root) if adopted is None else scaffold.detect_adopted(root, adopted)
|
|
1518
|
+
commands = (
|
|
1519
|
+
lifecycle.selected_format_commands(root, args.files) if args.files else lifecycle.format_commands(ecosystems)
|
|
1520
|
+
)
|
|
1521
|
+
return lifecycle.execute(commands)
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
def _staged_files(root: Path) -> list[str]:
|
|
1525
|
+
return _safe_staged_paths(root, _staged_file_names(root))
|
|
1526
|
+
|
|
1527
|
+
|
|
1528
|
+
def cmd_inspect(args: _Args) -> int:
|
|
1529
|
+
from sarj_standards.libs.adoption import lifecycle # ruff: ignore[import-outside-top-level]
|
|
1530
|
+
|
|
1531
|
+
sys.stdout.write(lifecycle.inspection_json(_resolve_dest(args.dest)))
|
|
1532
|
+
return 0
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
def cmd_show(args: _Args) -> int:
|
|
1536
|
+
match args.show_cmd:
|
|
1537
|
+
case "state":
|
|
1538
|
+
return cmd_inspect(args)
|
|
1539
|
+
case "configs":
|
|
1540
|
+
return cmd_list()
|
|
1541
|
+
case "config":
|
|
1542
|
+
return cmd_path(args)
|
|
1543
|
+
case "peers":
|
|
1544
|
+
return cmd_peers(args)
|
|
1545
|
+
case "rules":
|
|
1546
|
+
from sarj_standards.libs.repository import rule_catalog_artifact # ruff: ignore[import-outside-top-level]
|
|
1547
|
+
|
|
1548
|
+
print(json.dumps(rule_catalog_artifact.load(), indent=2))
|
|
1549
|
+
return 0
|
|
1550
|
+
case "ci":
|
|
1551
|
+
from sarj_standards.libs.adoption import scaffold # ruff: ignore[import-outside-top-level]
|
|
1552
|
+
|
|
1553
|
+
root = _resolve_dest(args.dest)
|
|
1554
|
+
rendered = scaffold.github_ci_workflow(root)
|
|
1555
|
+
if args.output is None:
|
|
1556
|
+
print(rendered, end="")
|
|
1557
|
+
else:
|
|
1558
|
+
from sarj_standards.libs.adoption import transaction # ruff: ignore[import-outside-top-level]
|
|
1559
|
+
|
|
1560
|
+
output = args.output if args.output.is_absolute() else root / args.output
|
|
1561
|
+
transaction.atomic_write_text(root, output, rendered)
|
|
1562
|
+
print(f"wrote: {output}")
|
|
1563
|
+
return 0
|
|
1564
|
+
case _:
|
|
1565
|
+
return 2
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
def cmd_exclude(args: _Args) -> int:
|
|
1569
|
+
from sarj_standards.libs.adoption import exclusions # ruff: ignore[import-outside-top-level]
|
|
1570
|
+
|
|
1571
|
+
root = _resolve_dest(args.dest)
|
|
1572
|
+
if args.exclude_cmd == "list":
|
|
1573
|
+
adopted = exclusions.read(root)
|
|
1574
|
+
if not adopted.excluded_paths and not adopted.excluded_rules:
|
|
1575
|
+
print("no exclusions; all rules apply to all paths")
|
|
1576
|
+
return 0
|
|
1577
|
+
for value in adopted.excluded_paths:
|
|
1578
|
+
print(f"path {value}")
|
|
1579
|
+
for value in adopted.excluded_rules:
|
|
1580
|
+
print(f"rule {value}")
|
|
1581
|
+
return 0
|
|
1582
|
+
|
|
1583
|
+
kind: exclusions.ExclusionKind = "path" if args.exclude_kind == "path" else "rule"
|
|
1584
|
+
result = (
|
|
1585
|
+
exclusions.add(root, kind, args.value)
|
|
1586
|
+
if args.exclude_cmd == "add"
|
|
1587
|
+
else exclusions.remove(root, kind, args.value)
|
|
1588
|
+
)
|
|
1589
|
+
if result.changed:
|
|
1590
|
+
print(f"{'excluded' if result.added else 'included'} {result.kind}: {result.value}")
|
|
1591
|
+
else:
|
|
1592
|
+
print(f"already {'excluded' if result.added else 'included'} {result.kind}: {result.value}")
|
|
1593
|
+
return 0
|
|
1594
|
+
|
|
1595
|
+
|
|
1596
|
+
def cmd_ratchet(args: _Args) -> int:
|
|
1597
|
+
from sarj_python_lint import run_ratchet # ruff: ignore[import-outside-top-level]
|
|
1598
|
+
|
|
1599
|
+
root = _resolve_dest(args.dest)
|
|
1600
|
+
baseline = args.baseline if args.baseline is not None else root / "suppression-baseline.json"
|
|
1601
|
+
if args.ratchet_cmd == "init" and baseline.exists():
|
|
1602
|
+
print(
|
|
1603
|
+
f"error: suppression budget already exists: {baseline}; use `code-standards ratchet update`",
|
|
1604
|
+
file=sys.stderr,
|
|
1605
|
+
)
|
|
1606
|
+
return 2
|
|
1607
|
+
argv = [str(root), "--baseline", str(baseline)]
|
|
1608
|
+
for package in args.package:
|
|
1609
|
+
argv.extend(("--package", package))
|
|
1610
|
+
for subtree in args.exclude_subtree:
|
|
1611
|
+
argv.extend(("--exclude-subtree", subtree))
|
|
1612
|
+
if args.ratchet_cmd in {"init", "update"}:
|
|
1613
|
+
argv.append("--update")
|
|
1614
|
+
if args.allow_increase:
|
|
1615
|
+
argv.append("--allow-increase")
|
|
1616
|
+
return run_ratchet(argv)
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
_DEFAULT_DIAGNOSTIC_BASELINE = "diagnostic-baseline.json"
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
def cmd_baseline(args: _Args) -> int:
|
|
1623
|
+
from sarj_standards.api import AnalysisMode, Standards, TrustMode # ruff: ignore[import-outside-top-level]
|
|
1624
|
+
from sarj_standards.libs.diagnostics import baseline # ruff: ignore[import-outside-top-level]
|
|
1625
|
+
|
|
1626
|
+
root = _resolve_dest(args.dest)
|
|
1627
|
+
output = args.output if args.output is not None else root / _DEFAULT_DIAGNOSTIC_BASELINE
|
|
1628
|
+
if args.baseline_cmd == "init" and output.exists():
|
|
1629
|
+
print(
|
|
1630
|
+
f"error: diagnostic baseline already exists: {output}; use `code-standards baseline update`",
|
|
1631
|
+
file=sys.stderr,
|
|
1632
|
+
)
|
|
1633
|
+
return 2
|
|
1634
|
+
try:
|
|
1635
|
+
# `analyze` rejects anything outside the repository, so normalize the paths a
|
|
1636
|
+
# caller naturally types (absolute, or relative to the shell) before handing over.
|
|
1637
|
+
selected = _selected_paths(root, args.files) if args.files else None
|
|
1638
|
+
except ValueError as exc:
|
|
1639
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
1640
|
+
return 2
|
|
1641
|
+
report = Standards(root).analyze(
|
|
1642
|
+
selected,
|
|
1643
|
+
external=True,
|
|
1644
|
+
trust=TrustMode.TRUSTED if args.trust_repository_code else TrustMode.SAFE,
|
|
1645
|
+
mode=AnalysisMode.RAW,
|
|
1646
|
+
)
|
|
1647
|
+
blocked = [issue for issue in report.issues if issue.kind != "baseline-failure"]
|
|
1648
|
+
if blocked:
|
|
1649
|
+
for issue in blocked:
|
|
1650
|
+
print(f"error: {issue.kind}: {issue.message}", file=sys.stderr)
|
|
1651
|
+
return 2
|
|
1652
|
+
eligible = tuple(item for item in report.diagnostics if baseline.is_baselineable(item))
|
|
1653
|
+
rendered = baseline.render(eligible)
|
|
1654
|
+
try:
|
|
1655
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
1656
|
+
output.write_text(rendered, encoding="utf-8")
|
|
1657
|
+
except OSError as exc:
|
|
1658
|
+
print(f"error: cannot write diagnostic baseline {output}: {exc}", file=sys.stderr)
|
|
1659
|
+
return 2
|
|
1660
|
+
recorded = len(eligible)
|
|
1661
|
+
print(f"baseline written: {output} ({recorded} diagnostic(s) recorded)")
|
|
1662
|
+
adopted = manifest.load(root)
|
|
1663
|
+
if adopted is None or adopted.diagnostic_baseline is None:
|
|
1664
|
+
relative = output.relative_to(root) if output.is_relative_to(root) else output
|
|
1665
|
+
print(f'point the manifest at it: [baseline] diagnostics = "{relative}"')
|
|
1666
|
+
return 0
|
|
1667
|
+
|
|
1668
|
+
|
|
1669
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1670
|
+
raw_argv = _root_option_first(sys.argv[1:] if argv is None else argv)
|
|
1671
|
+
args = build_parser().parse_args(raw_argv, namespace=_Args())
|
|
1672
|
+
try:
|
|
1673
|
+
return _dispatch(args)
|
|
1674
|
+
except KeyboardInterrupt:
|
|
1675
|
+
print("error: interrupted", file=sys.stderr)
|
|
1676
|
+
return 130
|
|
1677
|
+
except (OSError, TypeError, ValueError) as exc:
|
|
1678
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
1679
|
+
return 2
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
def _root_option_first(argv: list[str]) -> list[str]:
|
|
1683
|
+
equals_positions = [index for index, value in enumerate(argv) if value.startswith("--root=")]
|
|
1684
|
+
if equals_positions:
|
|
1685
|
+
if len(equals_positions) != 1:
|
|
1686
|
+
return argv
|
|
1687
|
+
index = equals_positions[0]
|
|
1688
|
+
value = argv[index].partition("=")[2]
|
|
1689
|
+
return ["--root", value, *argv[:index], *argv[index + 1 :]]
|
|
1690
|
+
positions = [index for index, value in enumerate(argv) if value == "--root"]
|
|
1691
|
+
if not positions or positions == [0] or len(positions) != 1:
|
|
1692
|
+
return argv
|
|
1693
|
+
index = positions[0]
|
|
1694
|
+
if index + 1 >= len(argv):
|
|
1695
|
+
return argv
|
|
1696
|
+
return ["--root", argv[index + 1], *argv[:index], *argv[index + 2 :]]
|
|
1697
|
+
|
|
1698
|
+
|
|
1699
|
+
def _dispatch(args: _Args) -> int:
|
|
1700
|
+
match args.cmd:
|
|
1701
|
+
case "doctor":
|
|
1702
|
+
return cmd_doctor(args)
|
|
1703
|
+
case "update":
|
|
1704
|
+
return cmd_update(args)
|
|
1705
|
+
case "setup":
|
|
1706
|
+
return cmd_setup(args)
|
|
1707
|
+
case "fix":
|
|
1708
|
+
return cmd_format(args)
|
|
1709
|
+
case "check":
|
|
1710
|
+
return cmd_check(args)
|
|
1711
|
+
case "validate-slack-automations":
|
|
1712
|
+
return cmd_validate_slack_automations(args)
|
|
1713
|
+
case "observe":
|
|
1714
|
+
return cmd_observe(args)
|
|
1715
|
+
case "show":
|
|
1716
|
+
return cmd_show(args)
|
|
1717
|
+
case "exclude":
|
|
1718
|
+
return cmd_exclude(args)
|
|
1719
|
+
case "ratchet":
|
|
1720
|
+
return cmd_ratchet(args)
|
|
1721
|
+
case "baseline":
|
|
1722
|
+
return cmd_baseline(args)
|
|
1723
|
+
case "maintain":
|
|
1724
|
+
return _cmd_repo(args)
|
|
1725
|
+
case _: # argparse enforces `required=True`, so this is unreachable
|
|
1726
|
+
return 2
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
def build_parser() -> argparse.ArgumentParser: # ruff: ignore[too-many-locals] -- parser sections mirror public verbs.
|
|
1730
|
+
parser = argparse.ArgumentParser(
|
|
1731
|
+
prog="code-standards",
|
|
1732
|
+
description=f"Adopt, check, fix, diagnose, and update sarj-ai standards (v{__version__}).",
|
|
1733
|
+
epilog="Start with `code-standards setup`, then use `code-standards check`.",
|
|
1734
|
+
)
|
|
1735
|
+
parser.add_argument("--version", action="version", version=f"code-standards {__version__}")
|
|
1736
|
+
parser.add_argument(
|
|
1737
|
+
"--root",
|
|
1738
|
+
dest="dest",
|
|
1739
|
+
default=".",
|
|
1740
|
+
help="repository root shared by the selected command (default: current directory)",
|
|
1741
|
+
)
|
|
1742
|
+
sub = parser.add_subparsers(
|
|
1743
|
+
dest="cmd",
|
|
1744
|
+
required=True,
|
|
1745
|
+
metavar=("{setup,check,validate-slack-automations,observe,fix,doctor,update,ratchet,exclude,show,maintain}"),
|
|
1746
|
+
title="commands",
|
|
1747
|
+
)
|
|
1748
|
+
|
|
1749
|
+
p_doctor = sub.add_parser(
|
|
1750
|
+
"doctor",
|
|
1751
|
+
help="diagnose adoption health and optionally repair safe drift",
|
|
1752
|
+
)
|
|
1753
|
+
p_doctor.add_argument(
|
|
1754
|
+
"--format",
|
|
1755
|
+
dest="output_format",
|
|
1756
|
+
choices=("text", "json"),
|
|
1757
|
+
default="text",
|
|
1758
|
+
help="output format (default: text)",
|
|
1759
|
+
)
|
|
1760
|
+
p_doctor.add_argument(
|
|
1761
|
+
"--repair",
|
|
1762
|
+
action="store_true",
|
|
1763
|
+
help="transactionally repair safe drift with the executing bundle, then re-diagnose",
|
|
1764
|
+
)
|
|
1765
|
+
p_doctor.add_argument(
|
|
1766
|
+
"--no-install",
|
|
1767
|
+
action="store_true",
|
|
1768
|
+
help="with --repair, update configuration without installing dependencies or hooks",
|
|
1769
|
+
)
|
|
1770
|
+
|
|
1771
|
+
setup = sub.add_parser("setup", help="adopt or converge the repository in one idempotent operation")
|
|
1772
|
+
setup.add_argument(
|
|
1773
|
+
"--hooks",
|
|
1774
|
+
choices=manifest.HOOK_MANAGERS,
|
|
1775
|
+
help="hook manager (default: detect Lefthook, otherwise pre-commit)",
|
|
1776
|
+
)
|
|
1777
|
+
setup.add_argument(
|
|
1778
|
+
"--python-dest",
|
|
1779
|
+
help="the directory that owns pyproject.toml (default: detected)",
|
|
1780
|
+
)
|
|
1781
|
+
setup.add_argument(
|
|
1782
|
+
"--typescript-dest",
|
|
1783
|
+
help="the directory that owns the npm lockfile (default: detected)",
|
|
1784
|
+
)
|
|
1785
|
+
setup.add_argument("--dry-run", action="store_true", help="print the complete plan without writing")
|
|
1786
|
+
setup.add_argument(
|
|
1787
|
+
"--force",
|
|
1788
|
+
action="store_true",
|
|
1789
|
+
help="replace conflicting generated lint configuration after review",
|
|
1790
|
+
)
|
|
1791
|
+
setup.add_argument(
|
|
1792
|
+
"--profile",
|
|
1793
|
+
choices=manifest.PROFILES,
|
|
1794
|
+
help="policy profile to adopt (default: existing value, otherwise standard)",
|
|
1795
|
+
)
|
|
1796
|
+
setup.add_argument(
|
|
1797
|
+
"--no-install", action="store_true", help="write wiring without installing dependencies or hooks"
|
|
1798
|
+
)
|
|
1799
|
+
setup.add_argument(
|
|
1800
|
+
"--config",
|
|
1801
|
+
dest="only",
|
|
1802
|
+
action="append",
|
|
1803
|
+
choices=sorted(CONFIG_NAMES),
|
|
1804
|
+
default=[],
|
|
1805
|
+
help="select one config explicitly (repeatable)",
|
|
1806
|
+
)
|
|
1807
|
+
|
|
1808
|
+
p_check = sub.add_parser(
|
|
1809
|
+
"check",
|
|
1810
|
+
help="run the complete quality gate or check selected paths",
|
|
1811
|
+
)
|
|
1812
|
+
p_check.add_argument(
|
|
1813
|
+
"--trust-repository-code",
|
|
1814
|
+
action="store_true",
|
|
1815
|
+
help="allow executable repository ESLint configuration (generated hooks and CI set this explicitly)",
|
|
1816
|
+
)
|
|
1817
|
+
p_check.add_argument(
|
|
1818
|
+
"--staged",
|
|
1819
|
+
action="store_true",
|
|
1820
|
+
help="run custom rules on hook-supplied paths, or discover staged files when none are supplied",
|
|
1821
|
+
)
|
|
1822
|
+
p_check.add_argument(
|
|
1823
|
+
"--format",
|
|
1824
|
+
dest="output_format",
|
|
1825
|
+
choices=("text", "json", "sarif", "github"),
|
|
1826
|
+
default="text",
|
|
1827
|
+
)
|
|
1828
|
+
p_check.add_argument("--output", type=Path, help="write JSON or SARIF atomically to PATH")
|
|
1829
|
+
p_check.add_argument(
|
|
1830
|
+
"--max-annotations-per-level",
|
|
1831
|
+
dest="max_annotations_per_level",
|
|
1832
|
+
type=int,
|
|
1833
|
+
choices=range(11),
|
|
1834
|
+
default=10,
|
|
1835
|
+
)
|
|
1836
|
+
p_check.add_argument(
|
|
1837
|
+
"files",
|
|
1838
|
+
nargs="*",
|
|
1839
|
+
help="selected paths; when omitted, check the complete repository",
|
|
1840
|
+
)
|
|
1841
|
+
|
|
1842
|
+
validate_slack = sub.add_parser(
|
|
1843
|
+
"validate-slack-automations",
|
|
1844
|
+
help="validate a versioned Slack automation catalog",
|
|
1845
|
+
)
|
|
1846
|
+
validate_slack.add_argument("slack_catalog", type=Path, metavar="PATH")
|
|
1847
|
+
|
|
1848
|
+
observe = sub.add_parser(
|
|
1849
|
+
"observe",
|
|
1850
|
+
help="report warning-stage findings with exit 0; invalid input or execution still exits 2",
|
|
1851
|
+
description="Report selected warning-stage findings with exit 0; invalid input or execution still exits 2.",
|
|
1852
|
+
)
|
|
1853
|
+
observe.add_argument(
|
|
1854
|
+
"--rule",
|
|
1855
|
+
dest="selected_rules",
|
|
1856
|
+
action="append",
|
|
1857
|
+
type=_parse_rule_selector,
|
|
1858
|
+
required=True,
|
|
1859
|
+
help="canonical ENGINE:ID selector (repeatable)",
|
|
1860
|
+
)
|
|
1861
|
+
observe.add_argument(
|
|
1862
|
+
"--format",
|
|
1863
|
+
dest="output_format",
|
|
1864
|
+
choices=("text", "json", "sarif", "github"),
|
|
1865
|
+
default="text",
|
|
1866
|
+
)
|
|
1867
|
+
observe.add_argument("--output", type=Path)
|
|
1868
|
+
observe.add_argument(
|
|
1869
|
+
"--trust-repository-code",
|
|
1870
|
+
action="store_true",
|
|
1871
|
+
help="allow repository ESLint configuration to execute",
|
|
1872
|
+
)
|
|
1873
|
+
observe.add_argument(
|
|
1874
|
+
"--max-annotations-per-level",
|
|
1875
|
+
type=int,
|
|
1876
|
+
default=10,
|
|
1877
|
+
)
|
|
1878
|
+
observe.add_argument("files", nargs="*", help="selected paths; defaults to adopted verification paths")
|
|
1879
|
+
|
|
1880
|
+
fix = sub.add_parser("fix", help="apply safe formatting and lint fixes")
|
|
1881
|
+
fix.add_argument("--staged", action="store_true", help="fix only files staged in Git")
|
|
1882
|
+
fix.add_argument("files", nargs="*", help="selected paths; when omitted, fix the complete repository")
|
|
1883
|
+
|
|
1884
|
+
update = sub.add_parser("update", help="upgrade to the latest published coherent Standards bundle")
|
|
1885
|
+
update.add_argument("--check", action="store_true", help="preview without writing; exit 1 when changes exist")
|
|
1886
|
+
update.add_argument(
|
|
1887
|
+
"--offline",
|
|
1888
|
+
action="store_true",
|
|
1889
|
+
help="reconverge the executing bundle and skip every network-dependent install; does not resolve latest",
|
|
1890
|
+
)
|
|
1891
|
+
update.add_argument(
|
|
1892
|
+
"--to",
|
|
1893
|
+
dest="target_version",
|
|
1894
|
+
metavar="VERSION",
|
|
1895
|
+
help="resolve and apply exactly this immutable coherent bundle version",
|
|
1896
|
+
)
|
|
1897
|
+
update.add_argument("--no-install", action="store_true", help="do not install dependencies or hooks")
|
|
1898
|
+
|
|
1899
|
+
baseline_parser = sub.add_parser(
|
|
1900
|
+
"baseline",
|
|
1901
|
+
help="grandfather today's findings so only new ones fail",
|
|
1902
|
+
)
|
|
1903
|
+
baseline_commands = baseline_parser.add_subparsers(dest="baseline_cmd", required=True)
|
|
1904
|
+
for name, help_text in (
|
|
1905
|
+
("init", "record the first diagnostic baseline"),
|
|
1906
|
+
("update", "re-record the diagnostic baseline after reviewed cleanup"),
|
|
1907
|
+
):
|
|
1908
|
+
command = baseline_commands.add_parser(name, help=help_text)
|
|
1909
|
+
command.add_argument(
|
|
1910
|
+
"--output",
|
|
1911
|
+
type=Path,
|
|
1912
|
+
help=f"baseline JSON (default: {_DEFAULT_DIAGNOSTIC_BASELINE})",
|
|
1913
|
+
)
|
|
1914
|
+
command.add_argument(
|
|
1915
|
+
"--trust-repository-code",
|
|
1916
|
+
action="store_true",
|
|
1917
|
+
help="run repository-local analyzers that execute project code",
|
|
1918
|
+
)
|
|
1919
|
+
command.add_argument("files", nargs="*", help="paths to analyze (default: the whole repository)")
|
|
1920
|
+
|
|
1921
|
+
ratchet = sub.add_parser("ratchet", help="keep the Python suppression budget from growing")
|
|
1922
|
+
ratchet_commands = ratchet.add_subparsers(dest="ratchet_cmd", required=True)
|
|
1923
|
+
for name, help_text in (
|
|
1924
|
+
("init", "create the first suppression budget"),
|
|
1925
|
+
("check", "fail when suppression debt grows"),
|
|
1926
|
+
("status", "show current suppression debt and available reductions"),
|
|
1927
|
+
("update", "lock in reviewed suppression-budget changes"),
|
|
1928
|
+
):
|
|
1929
|
+
command = ratchet_commands.add_parser(name, help=help_text)
|
|
1930
|
+
command.add_argument("--baseline", type=Path, help="budget JSON (default: suppression-baseline.json)")
|
|
1931
|
+
command.add_argument("--package", action="append", default=[], help="Python package root (repeatable)")
|
|
1932
|
+
command.add_argument(
|
|
1933
|
+
"--exclude-subtree",
|
|
1934
|
+
action="append",
|
|
1935
|
+
default=[],
|
|
1936
|
+
help="generated or vendored subtree to persistently exclude (repeatable)",
|
|
1937
|
+
)
|
|
1938
|
+
if name == "update":
|
|
1939
|
+
command.add_argument(
|
|
1940
|
+
"--allow-increase",
|
|
1941
|
+
action="store_true",
|
|
1942
|
+
help="permit a reviewed increase in suppression debt",
|
|
1943
|
+
)
|
|
1944
|
+
|
|
1945
|
+
exclude = sub.add_parser("exclude", help="inspect or change explicit path and rule exclusions")
|
|
1946
|
+
exclude_commands = exclude.add_subparsers(dest="exclude_cmd", required=True)
|
|
1947
|
+
exclude_commands.add_parser("list", help="list the complete denylist")
|
|
1948
|
+
for action in ("add", "remove"):
|
|
1949
|
+
mutation = exclude_commands.add_parser(action, help=f"{action} one exact denylist entry")
|
|
1950
|
+
mutation.add_argument("exclude_kind", choices=("path", "rule"), metavar="{path,rule}")
|
|
1951
|
+
mutation.add_argument("value", help="repository-relative glob or canonical engine:rule selector")
|
|
1952
|
+
|
|
1953
|
+
show = sub.add_parser("show", help="print read-only package and adoption information")
|
|
1954
|
+
show_commands = show.add_subparsers(dest="show_cmd", required=True)
|
|
1955
|
+
show_commands.add_parser("state", help="print detected adoption state as JSON")
|
|
1956
|
+
show_commands.add_parser("configs", help="list bundled configurations")
|
|
1957
|
+
config = show_commands.add_parser("config", help="print one bundled configuration path")
|
|
1958
|
+
config.add_argument("name", choices=sorted(CONFIG_NAMES))
|
|
1959
|
+
config.add_argument("--profile", choices=manifest.PROFILES, default="standard")
|
|
1960
|
+
show_commands.add_parser("peers", help="show tested ESLint peer dependencies and install command")
|
|
1961
|
+
show_commands.add_parser("rules", help="print the machine-readable custom-rule inventory")
|
|
1962
|
+
ci = show_commands.add_parser("ci", help="print a complete pinned GitHub Actions standards workflow")
|
|
1963
|
+
ci.add_argument("--output", type=Path, help="write a managed workflow inside the repository")
|
|
1964
|
+
|
|
1965
|
+
_add_repo_parsers(sub.add_parser("maintain", help="repository policy, hooks, rule ledgers, and releases"))
|
|
1966
|
+
|
|
1967
|
+
return parser
|
|
1968
|
+
|
|
1969
|
+
|
|
1970
|
+
def _cmd_repo(args: _Args) -> int:
|
|
1971
|
+
try:
|
|
1972
|
+
return _run_repo(args)
|
|
1973
|
+
except (OSError, RuntimeError, TypeError, ValueError, subprocess.SubprocessError) as exc:
|
|
1974
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
1975
|
+
return 2
|
|
1976
|
+
|
|
1977
|
+
|
|
1978
|
+
def _run_repo(args: _Args) -> int: # ruff: ignore[too-many-locals] -- one lazy CLI router covers independent repository subcommands.
|
|
1979
|
+
if args.repo_cmd == "setup":
|
|
1980
|
+
from sarj_standards.libs.setup import apply_setup, plan_setup # ruff: ignore[import-outside-top-level]
|
|
1981
|
+
|
|
1982
|
+
plan = plan_setup(_resolve_dest(args.dest))
|
|
1983
|
+
if args.check:
|
|
1984
|
+
if plan.install_hooks:
|
|
1985
|
+
print(f"would install: Lefthook repository hooks (in {plan.root})")
|
|
1986
|
+
for command in plan.commands:
|
|
1987
|
+
print(f"would run: {shlex.join(command.argv)} (in {command.cwd})")
|
|
1988
|
+
return 0
|
|
1989
|
+
return apply_setup(plan)
|
|
1990
|
+
if args.repo_cmd == "release":
|
|
1991
|
+
from sarj_standards.libs import release # ruff: ignore[import-outside-top-level] -- lazy route
|
|
1992
|
+
|
|
1993
|
+
root = _resolve_dest(args.dest)
|
|
1994
|
+
if args.release_cmd == "check-tag":
|
|
1995
|
+
validated = release.validate_release_tag(args.tag, root)
|
|
1996
|
+
print(f"{validated.tag} exactly matches {validated.manifest}")
|
|
1997
|
+
return 0
|
|
1998
|
+
if args.release_cmd == "verify-tags":
|
|
1999
|
+
missing = (
|
|
2000
|
+
release.verify_remote_release_tags(root, commit=args.release_commit)
|
|
2001
|
+
if args.release_commit
|
|
2002
|
+
else release.missing_remote_release_tags(root)
|
|
2003
|
+
)
|
|
2004
|
+
if missing:
|
|
2005
|
+
for tag_name in missing:
|
|
2006
|
+
print(f"missing release tag: {tag_name}")
|
|
2007
|
+
return 1
|
|
2008
|
+
print("all current package versions have release tags")
|
|
2009
|
+
return 0
|
|
2010
|
+
if args.release_cmd == "create-tags":
|
|
2011
|
+
result = release.create_release_tags(
|
|
2012
|
+
root,
|
|
2013
|
+
tuple(args.release_targets),
|
|
2014
|
+
commit=args.release_commit,
|
|
2015
|
+
attempts=args.attempts,
|
|
2016
|
+
delay=args.delay_seconds,
|
|
2017
|
+
)
|
|
2018
|
+
for tag_name in result.existing:
|
|
2019
|
+
print(f"release tag already exists: {tag_name}")
|
|
2020
|
+
for tag_name in result.created:
|
|
2021
|
+
print(f"created release tag: {tag_name}")
|
|
2022
|
+
return 0
|
|
2023
|
+
if args.release_cmd == "changes" and args.github_output is not None:
|
|
2024
|
+
changed = release.pending_release_targets(root, before=args.before, after=args.after)
|
|
2025
|
+
with args.github_output.open("a", encoding="utf-8") as output:
|
|
2026
|
+
for target, value in changed.items():
|
|
2027
|
+
_ = output.write(f"{target}={'true' if value else 'false'}\n")
|
|
2028
|
+
return 0
|
|
2029
|
+
if args.release_cmd == "causality":
|
|
2030
|
+
report = release.check_release_causality(root, before=args.before, after=args.after)
|
|
2031
|
+
if report.violations:
|
|
2032
|
+
print("\n".join(violation.render() for violation in report.violations))
|
|
2033
|
+
return 1
|
|
2034
|
+
changed = ", ".join(report.changed_targets) or "none"
|
|
2035
|
+
print(f"release causality ✓ (publishable targets changed: {changed})")
|
|
2036
|
+
return 0
|
|
2037
|
+
if args.release_cmd == "lock-age" and args.lockfile is not None:
|
|
2038
|
+
environment_policy = release.ReleaseAgePolicy.from_strings(
|
|
2039
|
+
os.environ.get("MIN_RELEASE_AGE_DAYS"), # ruff: ignore[banned-api] -- compatibility with the retired release script.
|
|
2040
|
+
os.environ.get("MIN_RELEASE_AGE_EXCLUDE"), # ruff: ignore[banned-api] -- compatibility with the retired release script.
|
|
2041
|
+
)
|
|
2042
|
+
policy = release.ReleaseAgePolicy(
|
|
2043
|
+
args.minimum_age if args.minimum_age is not None else environment_policy.minimum_age,
|
|
2044
|
+
environment_policy.exclusions
|
|
2045
|
+
| frozenset(args.release_exclude)
|
|
2046
|
+
| frozenset(
|
|
2047
|
+
exclusion
|
|
2048
|
+
for exclusion_file in args.release_exclude_file
|
|
2049
|
+
for exclusion in release.load_exact_exclusions((root / exclusion_file).resolve())
|
|
2050
|
+
),
|
|
2051
|
+
)
|
|
2052
|
+
report = release.check_lockfile_release_age((root / args.lockfile).resolve(), policy)
|
|
2053
|
+
if report.failures:
|
|
2054
|
+
print("\n".join(str(failure) for failure in report.failures))
|
|
2055
|
+
return 1
|
|
2056
|
+
print(f"release-age policy ✓ ({len(report.checked)} package versions checked)")
|
|
2057
|
+
return 0
|
|
2058
|
+
if args.release_cmd == "typescript":
|
|
2059
|
+
mode = args.release_mode
|
|
2060
|
+
if mode == "check":
|
|
2061
|
+
release_mode = "check"
|
|
2062
|
+
elif mode == "pack":
|
|
2063
|
+
release_mode = "pack"
|
|
2064
|
+
elif mode == "publish":
|
|
2065
|
+
release_mode = "publish"
|
|
2066
|
+
else:
|
|
2067
|
+
return 2
|
|
2068
|
+
artifact = release.run_typescript_release(
|
|
2069
|
+
release_mode,
|
|
2070
|
+
root / "packages" / "typescript",
|
|
2071
|
+
destination=args.output,
|
|
2072
|
+
)
|
|
2073
|
+
if artifact is not None:
|
|
2074
|
+
print(f"packed and verified {artifact.path}")
|
|
2075
|
+
return 0
|
|
2076
|
+
if args.release_cmd == "verify-wheel":
|
|
2077
|
+
for wheel in args.wheels:
|
|
2078
|
+
release.verify_python_wheel_license(wheel.resolve())
|
|
2079
|
+
print(f"verified wheel license: {wheel}")
|
|
2080
|
+
return 0
|
|
2081
|
+
if args.release_cmd == "verify-publications":
|
|
2082
|
+
from sarj_standards.libs.release import registry # ruff: ignore[import-outside-top-level]
|
|
2083
|
+
|
|
2084
|
+
return registry.main(
|
|
2085
|
+
[
|
|
2086
|
+
"--root",
|
|
2087
|
+
str(root),
|
|
2088
|
+
"--attempts",
|
|
2089
|
+
str(args.attempts),
|
|
2090
|
+
"--delay-seconds",
|
|
2091
|
+
str(args.delay_seconds.total_seconds()),
|
|
2092
|
+
]
|
|
2093
|
+
)
|
|
2094
|
+
if args.release_cmd == "publish":
|
|
2095
|
+
target = args.release_target
|
|
2096
|
+
if target == "typescript":
|
|
2097
|
+
publish_target = "typescript"
|
|
2098
|
+
elif target == "bootstrap":
|
|
2099
|
+
publish_target = "bootstrap"
|
|
2100
|
+
elif target == "python":
|
|
2101
|
+
publish_target = "python"
|
|
2102
|
+
elif target == "sql":
|
|
2103
|
+
publish_target = "sql"
|
|
2104
|
+
elif target == "iac":
|
|
2105
|
+
publish_target = "iac"
|
|
2106
|
+
elif target == "standards":
|
|
2107
|
+
publish_target = "standards"
|
|
2108
|
+
elif target == "tsconfig":
|
|
2109
|
+
publish_target = "tsconfig"
|
|
2110
|
+
elif target == "docs-ui":
|
|
2111
|
+
publish_target = "docs-ui"
|
|
2112
|
+
else:
|
|
2113
|
+
return 2
|
|
2114
|
+
release.publish_target(root, publish_target)
|
|
2115
|
+
return 0
|
|
2116
|
+
return 2
|
|
2117
|
+
if args.repo_cmd == "check":
|
|
2118
|
+
from sarj_standards.libs.repository import repository # ruff: ignore[import-outside-top-level]
|
|
2119
|
+
|
|
2120
|
+
findings = repository.check(
|
|
2121
|
+
_resolve_dest(args.dest),
|
|
2122
|
+
selected=frozenset(args.repo_only),
|
|
2123
|
+
commits=args.commits,
|
|
2124
|
+
policy_root=_resolve_dest(args.policy_dest) if args.policy_dest else None,
|
|
2125
|
+
private_refs_path=Path(args.private_refs_file).resolve() if args.private_refs_file else None,
|
|
2126
|
+
)
|
|
2127
|
+
if args.quiet:
|
|
2128
|
+
print("repository policy failed" if findings else "repository policy ✓")
|
|
2129
|
+
else:
|
|
2130
|
+
print("\n".join(finding.render() for finding in findings) or "repository policy ✓")
|
|
2131
|
+
return 1 if findings else 0
|
|
2132
|
+
if args.repo_cmd == "sync-ledger":
|
|
2133
|
+
from sarj_standards.libs.repository import rule_maintenance # ruff: ignore[import-outside-top-level]
|
|
2134
|
+
|
|
2135
|
+
result = rule_maintenance.sync_ledger(_resolve_dest(args.dest), check=args.check)
|
|
2136
|
+
print(result.message)
|
|
2137
|
+
return result.status
|
|
2138
|
+
if args.repo_cmd == "docs":
|
|
2139
|
+
from sarj_standards.libs.repository import docs # ruff: ignore[import-outside-top-level]
|
|
2140
|
+
|
|
2141
|
+
root = _resolve_dest(args.dest)
|
|
2142
|
+
if args.docs_cmd == "check":
|
|
2143
|
+
result = docs.check(root)
|
|
2144
|
+
for path in result.changed:
|
|
2145
|
+
print(f"drift: {path.relative_to(root)}")
|
|
2146
|
+
print("documentation is current" if not result.changed else "run `code-standards maintain docs sync`")
|
|
2147
|
+
return result.status
|
|
2148
|
+
result = docs.sync(root)
|
|
2149
|
+
for path in result.changed:
|
|
2150
|
+
print(f"wrote: {path.relative_to(root)}")
|
|
2151
|
+
return 0
|
|
2152
|
+
if args.repo_cmd == "comment-corpus":
|
|
2153
|
+
from sarj_standards.libs.repository import comment_corpus # ruff: ignore[import-outside-top-level]
|
|
2154
|
+
|
|
2155
|
+
if args.include_text is not None:
|
|
2156
|
+
return comment_corpus.write_records(args.roots, args.include_text)
|
|
2157
|
+
return comment_corpus.emit_summary(args.roots, sys.stdout)
|
|
2158
|
+
if args.repo_cmd == "hooks" and args.hooks_cmd == "install":
|
|
2159
|
+
from sarj_standards.libs.repository import hooks # ruff: ignore[import-outside-top-level]
|
|
2160
|
+
|
|
2161
|
+
return hooks.install(_resolve_dest(args.dest))
|
|
2162
|
+
if args.repo_cmd == "rules":
|
|
2163
|
+
from sarj_standards.libs.repository import rule_inventory_artifact # ruff: ignore[import-outside-top-level]
|
|
2164
|
+
|
|
2165
|
+
if args.rules_cmd == "manifest":
|
|
2166
|
+
print(json.dumps(rule_inventory_artifact.load(), indent=2))
|
|
2167
|
+
return 0
|
|
2168
|
+
if args.rules_cmd == "changes":
|
|
2169
|
+
from sarj_standards.libs.release.process import ( # ruff: ignore[import-outside-top-level]
|
|
2170
|
+
ProcessFailureError,
|
|
2171
|
+
)
|
|
2172
|
+
from sarj_standards.libs.repository import rule_changes # ruff: ignore[import-outside-top-level]
|
|
2173
|
+
|
|
2174
|
+
try:
|
|
2175
|
+
comparison = rule_changes.compare(
|
|
2176
|
+
_resolve_dest(args.dest),
|
|
2177
|
+
before=args.before,
|
|
2178
|
+
after=args.after,
|
|
2179
|
+
)
|
|
2180
|
+
except (OSError, TypeError, ValueError, ProcessFailureError) as exc:
|
|
2181
|
+
print(f"error: cannot compare rule revisions: {exc}", file=sys.stderr)
|
|
2182
|
+
return 2
|
|
2183
|
+
print(
|
|
2184
|
+
json.dumps(comparison, indent=2)
|
|
2185
|
+
if args.output_format == "json"
|
|
2186
|
+
else rule_changes.render_text(comparison)
|
|
2187
|
+
)
|
|
2188
|
+
return 0
|
|
2189
|
+
if args.rules_cmd == "evaluate":
|
|
2190
|
+
return cmd_rule_evaluate(args)
|
|
2191
|
+
if args.rules_cmd == "new":
|
|
2192
|
+
from sarj_standards.libs.repository import rule_authoring # ruff: ignore[import-outside-top-level]
|
|
2193
|
+
|
|
2194
|
+
if args.selector is None: # pragma: no cover - argparse requires the positional value
|
|
2195
|
+
msg = "new requires a rule selector"
|
|
2196
|
+
raise TypeError(msg)
|
|
2197
|
+
try:
|
|
2198
|
+
plan = rule_authoring.plan_new(
|
|
2199
|
+
_resolve_dest(args.dest), args.selector, category=args.rule_category, summary=args.rule_summary
|
|
2200
|
+
)
|
|
2201
|
+
if args.apply_rule:
|
|
2202
|
+
rule_authoring.apply(plan, _resolve_dest(args.dest))
|
|
2203
|
+
except (OSError, TypeError, ValueError) as exc:
|
|
2204
|
+
print(f"error: cannot scaffold rule: {exc}", file=sys.stderr)
|
|
2205
|
+
return 2
|
|
2206
|
+
print(plan.render(_resolve_dest(args.dest)))
|
|
2207
|
+
return 0
|
|
2208
|
+
if args.rules_cmd == "verify":
|
|
2209
|
+
from sarj_standards.libs.repository import rule_authoring # ruff: ignore[import-outside-top-level]
|
|
2210
|
+
|
|
2211
|
+
if args.selector is None: # pragma: no cover - argparse requires the positional value
|
|
2212
|
+
msg = "verify requires a rule selector"
|
|
2213
|
+
raise TypeError(msg)
|
|
2214
|
+
try:
|
|
2215
|
+
result = rule_authoring.verify(_resolve_dest(args.dest), args.selector)
|
|
2216
|
+
except (OSError, TypeError, ValueError, RuntimeError) as exc:
|
|
2217
|
+
print(f"error: cannot verify rule: {exc}", file=sys.stderr)
|
|
2218
|
+
return 2
|
|
2219
|
+
print(result.message)
|
|
2220
|
+
return result.status
|
|
2221
|
+
if args.rules_cmd in {"stage-warning", "prepare"}:
|
|
2222
|
+
from sarj_standards.libs.repository import rule_lifecycle # ruff: ignore[import-outside-top-level]
|
|
2223
|
+
|
|
2224
|
+
if args.selector is None: # pragma: no cover - argparse requires the positional value
|
|
2225
|
+
msg = f"{args.rules_cmd} requires a rule selector"
|
|
2226
|
+
raise TypeError(msg)
|
|
2227
|
+
if args.rules_cmd == "prepare":
|
|
2228
|
+
from sarj_standards.libs.repository import ( # ruff: ignore[import-outside-top-level]
|
|
2229
|
+
rule_authoring,
|
|
2230
|
+
)
|
|
2231
|
+
|
|
2232
|
+
try:
|
|
2233
|
+
verified = rule_authoring.verify(_resolve_dest(args.dest), args.selector)
|
|
2234
|
+
except (OSError, TypeError, ValueError, RuntimeError) as exc:
|
|
2235
|
+
print(f"error: cannot verify rule before preparation: {exc}", file=sys.stderr)
|
|
2236
|
+
return 2
|
|
2237
|
+
if verified.status != 0:
|
|
2238
|
+
print(verified.message)
|
|
2239
|
+
return verified.status
|
|
2240
|
+
try:
|
|
2241
|
+
result = rule_lifecycle.stage_warning(_resolve_dest(args.dest), args.selector, check=args.check)
|
|
2242
|
+
except (OSError, TypeError, ValueError, RuntimeError) as exc:
|
|
2243
|
+
print(f"error: cannot stage warning rule: {exc}", file=sys.stderr)
|
|
2244
|
+
return 2
|
|
2245
|
+
print(result.message)
|
|
2246
|
+
if result.status == 0:
|
|
2247
|
+
print(_rule_author_next_steps(args.selector))
|
|
2248
|
+
return result.status
|
|
2249
|
+
result = rule_inventory_artifact.sync(_resolve_dest(args.dest), check=args.rules_cmd == "check")
|
|
2250
|
+
print(result.message)
|
|
2251
|
+
return result.status
|
|
2252
|
+
if args.repo_cmd == "catalog":
|
|
2253
|
+
from sarj_standards.libs.repository import rule_catalog_artifact # ruff: ignore[import-outside-top-level]
|
|
2254
|
+
|
|
2255
|
+
result = rule_catalog_artifact.sync(_resolve_dest(args.dest), check=args.catalog_cmd == "check")
|
|
2256
|
+
print(result.message)
|
|
2257
|
+
return result.status
|
|
2258
|
+
if args.repo_cmd == "cli-reference":
|
|
2259
|
+
from sarj_standards.libs.repository import cli_reference_artifact # ruff: ignore[import-outside-top-level]
|
|
2260
|
+
|
|
2261
|
+
result = cli_reference_artifact.sync(
|
|
2262
|
+
_resolve_dest(args.dest), build_parser(), check=args.reference_cmd == "check"
|
|
2263
|
+
)
|
|
2264
|
+
print(result.message)
|
|
2265
|
+
return result.status
|
|
2266
|
+
return 2
|
|
2267
|
+
|
|
2268
|
+
|
|
2269
|
+
def _rule_author_next_steps(selector: RuleSelector) -> str:
|
|
2270
|
+
return (
|
|
2271
|
+
"next: validate the staged rule locally\n"
|
|
2272
|
+
f" code-standards --root . maintain rules evaluate --rule {selector} --scope corpus\n"
|
|
2273
|
+
" make verify\n"
|
|
2274
|
+
"after committing the result:\n"
|
|
2275
|
+
" code-standards --root . maintain rules changes --before origin/main --after HEAD"
|
|
2276
|
+
)
|
|
2277
|
+
|
|
2278
|
+
|
|
2279
|
+
def _add_repo_parsers(repo: argparse.ArgumentParser) -> None: # ruff: ignore[too-many-locals] -- argparse requires one variable per subparser.
|
|
2280
|
+
commands = repo.add_subparsers(dest="repo_cmd", required=True)
|
|
2281
|
+
setup = commands.add_parser("setup", help="install every standards development environment and repository hook")
|
|
2282
|
+
setup.add_argument("--check", action="store_true", help="print the deterministic setup plan without executing it")
|
|
2283
|
+
release = commands.add_parser("release", help="validate and build publishable release artifacts")
|
|
2284
|
+
release_commands = release.add_subparsers(dest="release_cmd", required=True)
|
|
2285
|
+
tag = release_commands.add_parser("check-tag", help="require a release tag to match its package manifest")
|
|
2286
|
+
tag.add_argument("tag")
|
|
2287
|
+
verify_tags = release_commands.add_parser("verify-tags", help="verify all manifest release tags on origin")
|
|
2288
|
+
verify_tags.add_argument(
|
|
2289
|
+
"--commit",
|
|
2290
|
+
dest="release_commit",
|
|
2291
|
+
help="also require existing tags to match this publishing commit or an unchanged package tree",
|
|
2292
|
+
)
|
|
2293
|
+
create_tags = release_commands.add_parser("create-tags", help="create and push manifest release tags")
|
|
2294
|
+
create_tags.add_argument(
|
|
2295
|
+
"release_targets",
|
|
2296
|
+
nargs="+",
|
|
2297
|
+
choices=("typescript", "bootstrap", "python", "sql", "iac", "standards", "tsconfig", "docs-ui"),
|
|
2298
|
+
)
|
|
2299
|
+
create_tags.add_argument("--commit", dest="release_commit", required=True, help="exact commit that was published")
|
|
2300
|
+
create_tags.add_argument("--attempts", type=int, default=6)
|
|
2301
|
+
create_tags.add_argument(
|
|
2302
|
+
"--delay-seconds",
|
|
2303
|
+
type=lambda value: timedelta(seconds=float(value)),
|
|
2304
|
+
default=timedelta(seconds=10),
|
|
2305
|
+
)
|
|
2306
|
+
changes = release_commands.add_parser("changes", help="emit package version changes between Git revisions")
|
|
2307
|
+
changes.add_argument("--before", required=True)
|
|
2308
|
+
changes.add_argument("--after", required=True)
|
|
2309
|
+
changes.add_argument("--github-output", type=Path, required=True)
|
|
2310
|
+
causality = release_commands.add_parser(
|
|
2311
|
+
"causality",
|
|
2312
|
+
help="require every publishable package change to bump its version",
|
|
2313
|
+
)
|
|
2314
|
+
causality.add_argument("--before", required=True)
|
|
2315
|
+
causality.add_argument("--after", required=True)
|
|
2316
|
+
age = release_commands.add_parser("lock-age", help="enforce npm lockfile minimum release age")
|
|
2317
|
+
age.add_argument("lockfile", type=Path)
|
|
2318
|
+
age.add_argument("--minimum-days", dest="minimum_age", type=lambda value: timedelta(days=int(value)))
|
|
2319
|
+
age.add_argument("--exclude", dest="release_exclude", action="append", default=[])
|
|
2320
|
+
age.add_argument(
|
|
2321
|
+
"--exclude-file",
|
|
2322
|
+
dest="release_exclude_file",
|
|
2323
|
+
action="append",
|
|
2324
|
+
type=Path,
|
|
2325
|
+
default=[],
|
|
2326
|
+
help="line-oriented exact package@version exceptions (repeatable)",
|
|
2327
|
+
)
|
|
2328
|
+
typescript = release_commands.add_parser("typescript", help="check, pack, or publish the TypeScript package")
|
|
2329
|
+
typescript.add_argument("release_mode", choices=("check", "pack", "publish"))
|
|
2330
|
+
typescript.add_argument("--output", type=Path, help="artifact directory (required for pack)")
|
|
2331
|
+
verify_wheel = release_commands.add_parser(
|
|
2332
|
+
"verify-wheel", help="require non-empty license text in built Python wheels"
|
|
2333
|
+
)
|
|
2334
|
+
verify_wheel.add_argument("wheels", nargs="+", type=Path)
|
|
2335
|
+
publications = release_commands.add_parser(
|
|
2336
|
+
"verify-publications", help="wait for every exact sibling publication required by Standards"
|
|
2337
|
+
)
|
|
2338
|
+
publications.add_argument("--attempts", type=int, default=6)
|
|
2339
|
+
publications.add_argument(
|
|
2340
|
+
"--delay-seconds",
|
|
2341
|
+
type=lambda value: timedelta(seconds=float(value)),
|
|
2342
|
+
default=timedelta(seconds=10),
|
|
2343
|
+
)
|
|
2344
|
+
publish = release_commands.add_parser("publish", help="build and publish one package through its native client")
|
|
2345
|
+
publish.add_argument(
|
|
2346
|
+
"release_target",
|
|
2347
|
+
choices=("typescript", "bootstrap", "python", "sql", "iac", "standards", "tsconfig", "docs-ui"),
|
|
2348
|
+
)
|
|
2349
|
+
check = commands.add_parser("check", help="run repository policy gates")
|
|
2350
|
+
check.add_argument(
|
|
2351
|
+
"--only",
|
|
2352
|
+
dest="repo_only",
|
|
2353
|
+
action="append",
|
|
2354
|
+
choices=("ci-history", "file-conventions", "private-refs", "versions"),
|
|
2355
|
+
default=[],
|
|
2356
|
+
)
|
|
2357
|
+
check.add_argument("--commits", help="also inspect commit messages in this revision range")
|
|
2358
|
+
check.add_argument(
|
|
2359
|
+
"--policy-root",
|
|
2360
|
+
dest="policy_dest",
|
|
2361
|
+
help="trusted repository policy root (default: --root)",
|
|
2362
|
+
)
|
|
2363
|
+
check.add_argument("--private-refs-file", help="private-reference TOML outside the scanned repository")
|
|
2364
|
+
check.add_argument("--quiet", action="store_true", help="hide finding details")
|
|
2365
|
+
ledger = commands.add_parser("sync-ledger", help="synchronize the rule compatibility ledger")
|
|
2366
|
+
ledger.add_argument("--check", action="store_true", help="report drift without writing")
|
|
2367
|
+
docs = commands.add_parser("docs", help="check or synchronize source-derived documentation")
|
|
2368
|
+
docs_commands = docs.add_subparsers(dest="docs_cmd", required=True)
|
|
2369
|
+
for action in ("check", "sync"):
|
|
2370
|
+
docs_commands.add_parser(action)
|
|
2371
|
+
corpus = commands.add_parser("comment-corpus", help="extract comments for calibration")
|
|
2372
|
+
corpus.add_argument("roots", nargs="+", type=Path)
|
|
2373
|
+
corpus.add_argument(
|
|
2374
|
+
"--include-text",
|
|
2375
|
+
type=Path,
|
|
2376
|
+
metavar="PRIVATE_JSONL",
|
|
2377
|
+
help="write sensitive comment text to a new owner-readable file",
|
|
2378
|
+
)
|
|
2379
|
+
hook_commands = commands.add_parser("hooks", help="manage the pinned repository hooks").add_subparsers(
|
|
2380
|
+
dest="hooks_cmd", required=True
|
|
2381
|
+
)
|
|
2382
|
+
hook_commands.add_parser("install", help="install Lefthook")
|
|
2383
|
+
rule_commands = commands.add_parser("rules", help="inspect live custom rules").add_subparsers(
|
|
2384
|
+
dest="rules_cmd", required=True
|
|
2385
|
+
)
|
|
2386
|
+
rule_commands.add_parser("manifest", help="print the shipped rule inventory")
|
|
2387
|
+
rule_commands.add_parser("check", help="verify the shipped rule inventory matches live registries")
|
|
2388
|
+
rule_commands.add_parser("sync", help="update the shipped rule inventory from live registries")
|
|
2389
|
+
rule_new = rule_commands.add_parser("new", help="plan or create author-owned rule and test skeletons")
|
|
2390
|
+
rule_new.add_argument("selector", type=_parse_rule_selector, help="canonical ENGINE:ID selector")
|
|
2391
|
+
rule_new.add_argument(
|
|
2392
|
+
"--category",
|
|
2393
|
+
dest="rule_category",
|
|
2394
|
+
choices=("architecture", "correctness", "maintainability", "performance", "security", "style", "testing"),
|
|
2395
|
+
required=True,
|
|
2396
|
+
)
|
|
2397
|
+
rule_new.add_argument("--summary", dest="rule_summary", required=True)
|
|
2398
|
+
rule_new.add_argument("--apply", dest="apply_rule", action="store_true", help="create the planned files")
|
|
2399
|
+
stage_warning = rule_commands.add_parser(
|
|
2400
|
+
"stage-warning", help="prepare one registered rule for warning-first publication"
|
|
2401
|
+
)
|
|
2402
|
+
stage_warning.add_argument("selector", type=_parse_rule_selector, help="canonical ENGINE:ID selector")
|
|
2403
|
+
stage_warning.add_argument("--check", action="store_true", help="report required staging without writing")
|
|
2404
|
+
prepare_rule = rule_commands.add_parser(
|
|
2405
|
+
"prepare", help="validate and prepare one registered rule for warning-first publication"
|
|
2406
|
+
)
|
|
2407
|
+
prepare_rule.add_argument("selector", type=_parse_rule_selector, help="canonical ENGINE:ID selector")
|
|
2408
|
+
prepare_rule.add_argument("--check", action="store_true", help="report required preparation without writing")
|
|
2409
|
+
verify_rule = rule_commands.add_parser(
|
|
2410
|
+
"verify", help="validate one registered rule's authored files and public examples"
|
|
2411
|
+
)
|
|
2412
|
+
verify_rule.add_argument("selector", type=_parse_rule_selector, help="canonical ENGINE:ID selector")
|
|
2413
|
+
rule_changes = rule_commands.add_parser(
|
|
2414
|
+
"changes", help="compare rule inventory and policy between two Git revisions"
|
|
2415
|
+
)
|
|
2416
|
+
rule_changes.add_argument("--before", required=True)
|
|
2417
|
+
rule_changes.add_argument("--after", required=True)
|
|
2418
|
+
rule_changes.add_argument("--format", dest="output_format", choices=("json", "text"), default="text")
|
|
2419
|
+
rule_evaluate = rule_commands.add_parser(
|
|
2420
|
+
"evaluate",
|
|
2421
|
+
help="calibrate selected custom rules; findings exit 1 and invalid input or execution exits 2",
|
|
2422
|
+
description="Calibrate selected custom rules; findings exit 1 and invalid input or execution exits 2.",
|
|
2423
|
+
)
|
|
2424
|
+
rule_evaluate.add_argument(
|
|
2425
|
+
"--rule",
|
|
2426
|
+
dest="selected_rules",
|
|
2427
|
+
action="append",
|
|
2428
|
+
type=_parse_rule_selector,
|
|
2429
|
+
required=True,
|
|
2430
|
+
help="canonical ENGINE:ID selector (repeatable)",
|
|
2431
|
+
)
|
|
2432
|
+
rule_evaluate.add_argument(
|
|
2433
|
+
"--scope",
|
|
2434
|
+
dest="evaluation_scope",
|
|
2435
|
+
type=_EvaluationScope,
|
|
2436
|
+
choices=tuple(_EvaluationScope),
|
|
2437
|
+
default=_EvaluationScope.CORPUS,
|
|
2438
|
+
help="corpus ignores baselines/rule exclusions; effective applies adopted repository policy",
|
|
2439
|
+
)
|
|
2440
|
+
rule_evaluate.add_argument(
|
|
2441
|
+
"--format",
|
|
2442
|
+
dest="output_format",
|
|
2443
|
+
choices=("json", "text"),
|
|
2444
|
+
default="json",
|
|
2445
|
+
)
|
|
2446
|
+
rule_evaluate.add_argument("--output", type=Path)
|
|
2447
|
+
rule_evaluate.add_argument(
|
|
2448
|
+
"--trust-repository-code",
|
|
2449
|
+
action="store_true",
|
|
2450
|
+
help="allow executable repository ESLint configuration",
|
|
2451
|
+
)
|
|
2452
|
+
rule_evaluate.add_argument("files", nargs="*")
|
|
2453
|
+
catalog_commands = commands.add_parser(
|
|
2454
|
+
"catalog", help="maintain the source-derived public rule catalog"
|
|
2455
|
+
).add_subparsers(dest="catalog_cmd", required=True)
|
|
2456
|
+
catalog_commands.add_parser("check", help="verify the public catalog matches every live rule")
|
|
2457
|
+
catalog_commands.add_parser("sync", help="update the public catalog from source-owned rule metadata")
|
|
2458
|
+
reference_commands = commands.add_parser(
|
|
2459
|
+
"cli-reference", help="maintain the source-derived CLI reference"
|
|
2460
|
+
).add_subparsers(dest="reference_cmd", required=True)
|
|
2461
|
+
reference_commands.add_parser("check", help="verify the shipped reference matches the parser graph")
|
|
2462
|
+
reference_commands.add_parser("sync", help="update the shipped reference from the parser graph")
|
|
2463
|
+
|
|
2464
|
+
|
|
2465
|
+
if __name__ == "__main__":
|
|
2466
|
+
raise SystemExit(main())
|