code-standards 7.0.0__py3-none-any.whl

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