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,1660 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import re
8
+ import shlex
9
+ import textwrap
10
+ import tomllib
11
+ from typing import (
12
+ TYPE_CHECKING,
13
+ Final,
14
+ NamedTuple,
15
+ cast, # ruff: ignore[banned-api] -- narrow untyped YAML at one boundary.
16
+ )
17
+
18
+ from packaging.specifiers import InvalidSpecifier, SpecifierSet
19
+ from packaging.version import InvalidVersion, Version
20
+ import yaml
21
+
22
+ from sarj_standards.libs.filesystem import is_link_like
23
+
24
+ from . import hooks, launcher, manifest, packagemanager, uvtool
25
+ from .packagemanager import LOCKFILES, Overrides, PackageManager, YarnVariant
26
+
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import Mapping, Sequence
30
+
31
+
32
+ class _JsonObjectResult(NamedTuple):
33
+ document: dict[str, object] | None
34
+ error: str | None
35
+
36
+
37
+ class _HookMigration(NamedTuple):
38
+ migrated: str | None
39
+ error: str | None
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Ecosystems:
44
+ python: bool
45
+ typescript: bool
46
+ python_root: Path | None = None
47
+ typescript_root: Path | None = None
48
+ typescript_install_root: Path | None = None
49
+ client: PackageManager = PackageManager.NPM
50
+ yarn: YarnVariant = YarnVariant.CLASSIC
51
+
52
+ @property
53
+ def any(self) -> bool:
54
+ """Whether anything at all was detected."""
55
+ return self.python or self.typescript
56
+
57
+
58
+ @dataclass
59
+ class Plan:
60
+ ecosystems: Ecosystems
61
+ root: Path | None = None
62
+ profile: manifest.Profile = "standard"
63
+ configs: tuple[str, ...] = ()
64
+ hook_manager: manifest.HookManager = "pre-commit"
65
+ writes: list[tuple[Path, str]] = field(default_factory=list)
66
+ edits: list[tuple[Path, str]] = field(default_factory=list)
67
+ deletes: list[Path] = field(default_factory=list)
68
+ skips: list[tuple[Path, str]] = field(default_factory=list)
69
+ notes: list[str] = field(default_factory=list)
70
+ errors: list[str] = field(default_factory=list)
71
+
72
+
73
+ _ESLINT_CONFIG: Final = "eslint.config.mjs"
74
+ _ESLINT_CONFIG_NAMES: Final = (
75
+ "eslint.config.js",
76
+ "eslint.config.mjs",
77
+ "eslint.config.cjs",
78
+ "eslint.config.ts",
79
+ "eslint.config.mts",
80
+ "eslint.config.cts",
81
+ )
82
+ _PYRIGHT_CONFIG: Final = "pyrightconfig.json"
83
+ _STANDALONE_RUFF_CONFIG_NAMES: Final = (".ruff.toml", "ruff.toml")
84
+ _PRECOMMIT_CONFIG_NAMES: Final = (".pre-commit-config.yaml", ".pre-commit-config.yml")
85
+ _ECOSYSTEM_CONFIGS: Final = frozenset((*manifest.PYTHON_CONFIGS, *manifest.TYPESCRIPT_CONFIGS))
86
+ _CUSTOM_HOOK_SCOPE_KEYS: Final = frozenset({"args", "exclude", "exclude_types", "types", "types_or"})
87
+ _RUFF_REDUNDANT_SELECT_ALL: Final = re.compile(r"(?m)^[ \t]*select\s*=\s*\[\s*['\"]ALL['\"]\s*\]\s*(?:#.*)?\r?\n?")
88
+ _PYTHON_MAJOR: Final = 3
89
+ _LEGACY_WORKFLOW_VERIFY: Final = re.compile(
90
+ r"(?P<command>(?:[^\s\"']*/)?sarj-standards(?:\s+--root\s+[^\s;&|]+)?)\s+verify\b"
91
+ )
92
+ _SCHEMA_LESS_VERSION_LINE: Final = re.compile(r'(?m)^[ \t]*version\s*=\s*"[^"]*"\s*$')
93
+ _SCHEMA_LESS_CONFIGS_START: Final = re.compile(r"^[ \t]*configs\s*=")
94
+ _FIRST_TOML_TABLE: Final = re.compile(r"(?m)^\s*\[")
95
+
96
+ _RUFF_EXTEND = re.compile(r"^[ \t]*\[tool\.ruff\][ \t]*$", re.MULTILINE)
97
+ _RUFF_LINT_SECTION = re.compile(
98
+ r"(?ms)^(?P<header>[ \t]*\[tool\.ruff\.lint\][ \t]*(?:#[^\n]*)?\n)"
99
+ r"(?P<body>.*?)(?=^[ \t]*\[|\Z)"
100
+ )
101
+ _RUFF_REPLACEMENT_KEY = re.compile(r"(?m)^(?P<indent>[ \t]*)(?P<key>select|ignore)(?P<equals>[ \t]*=)")
102
+
103
+ #: Directories a detection walk must not descend into: an installed dependency
104
+ #: carries thousands of `package.json` files and a vendored tree carries the
105
+ #: pyproject of something this repo did not write.
106
+ _SKIP_DIRS: Final = frozenset(
107
+ {
108
+ ".git",
109
+ ".agents",
110
+ ".cache",
111
+ ".claude",
112
+ ".next",
113
+ ".open-next",
114
+ ".turbo",
115
+ ".uv-cache",
116
+ ".wrangler",
117
+ ".yarn",
118
+ ".mypy_cache",
119
+ ".pytest_cache",
120
+ ".ruff_cache",
121
+ "build",
122
+ "coverage",
123
+ ".tox",
124
+ ".venv",
125
+ "dist",
126
+ "node_modules",
127
+ "out",
128
+ "target",
129
+ "vendor",
130
+ }
131
+ )
132
+
133
+
134
+ def detect(
135
+ root: Path,
136
+ *,
137
+ python_dest: str | None = None,
138
+ typescript_dest: str | None = None,
139
+ ) -> Ecosystems:
140
+ python_root = _override(root, python_dest) or _python_root(root)
141
+ typescript_root = _override(root, typescript_dest) or _typescript_root(root)
142
+ install_root = packagemanager.workspace_root(typescript_root, root) if typescript_root else None
143
+ client = packagemanager.detect(install_root) if install_root else PackageManager.NPM
144
+ return Ecosystems(
145
+ python=python_root is not None,
146
+ typescript=typescript_root is not None,
147
+ python_root=python_root,
148
+ typescript_root=typescript_root,
149
+ typescript_install_root=install_root,
150
+ client=client,
151
+ yarn=(
152
+ packagemanager.yarn_variant(install_root)
153
+ if install_root is not None and client is PackageManager.YARN
154
+ else YarnVariant.CLASSIC
155
+ ),
156
+ )
157
+
158
+
159
+ def detect_adopted(root: Path, adopted: manifest.Manifest) -> Ecosystems:
160
+ python = bool({"ruff", "pyright"}.intersection(adopted.configs))
161
+ typescript = "eslint" in adopted.configs
162
+ detected = detect(
163
+ root,
164
+ python_dest=adopted.python_dest if python else None,
165
+ typescript_dest=adopted.typescript_dest if typescript else None,
166
+ )
167
+ return Ecosystems(
168
+ python=python,
169
+ typescript=typescript,
170
+ python_root=detected.python_root if python else None,
171
+ typescript_root=detected.typescript_root if typescript else None,
172
+ typescript_install_root=detected.typescript_install_root if typescript else None,
173
+ client=detected.client,
174
+ yarn=detected.yarn,
175
+ )
176
+
177
+
178
+ def _override(root: Path, dest: str | None) -> Path | None:
179
+ if dest is None:
180
+ return None
181
+ lexical = root
182
+ for part in Path(dest).parts:
183
+ lexical /= part
184
+ if is_link_like(lexical):
185
+ msg = f"destination {dest!r} traverses a symlink or junction: {lexical}"
186
+ raise ValueError(msg)
187
+ resolved = (root / dest).resolve()
188
+ try:
189
+ resolved.relative_to(root.resolve())
190
+ except ValueError as exc:
191
+ msg = f"destination {dest!r} escapes repository root {root}"
192
+ raise ValueError(msg) from exc
193
+ if not resolved.is_dir():
194
+ msg = f"destination {dest!r} is not a directory"
195
+ raise ValueError(msg)
196
+ return resolved
197
+
198
+
199
+ def _python_root(root: Path) -> Path | None:
200
+ return _shallowest(root, ("pyproject.toml",))
201
+
202
+
203
+ def _typescript_root(root: Path) -> Path | None:
204
+ lockfiles = tuple(name for name, _ in LOCKFILES)
205
+ return _shallowest(root, lockfiles) or _shallowest(root, ("package.json",))
206
+
207
+
208
+ def _shallowest(root: Path, names: Sequence[str]) -> Path | None:
209
+ if any((root / name).is_file() for name in names):
210
+ return root
211
+ wanted = frozenset(names)
212
+ found: list[Path] = []
213
+ for parent, directories, filenames in os.walk(root, topdown=True, followlinks=False):
214
+ directories[:] = sorted(name for name in directories if name not in _SKIP_DIRS)
215
+ if wanted.intersection(filenames):
216
+ found.append(Path(parent))
217
+ if not found:
218
+ return None
219
+ return min(found, key=lambda path: (len(path.relative_to(root).parts), str(path)))
220
+
221
+
222
+ def _all_roots(root: Path, names: Sequence[str]) -> list[Path]:
223
+ wanted = frozenset(names)
224
+ found: list[Path] = []
225
+ for parent, directories, filenames in os.walk(root, topdown=True, followlinks=False):
226
+ directories[:] = sorted(name for name in directories if name not in _SKIP_DIRS)
227
+ if wanted.intersection(filenames):
228
+ found.append(Path(parent))
229
+ return found
230
+
231
+
232
+ def build_plan(
233
+ root: Path,
234
+ *,
235
+ force: bool,
236
+ update_manifest: bool = False,
237
+ configs: Sequence[str] | None = None,
238
+ python_dest: str | None = None,
239
+ typescript_dest: str | None = None,
240
+ profile: manifest.Profile = "standard",
241
+ hook_manager: manifest.HookManager | None = None,
242
+ allow_existing_nested_eslint: bool = False,
243
+ ) -> Plan:
244
+ ecosystems = detect(root, python_dest=python_dest, typescript_dest=typescript_dest)
245
+ selected = (
246
+ tuple(configs)
247
+ if configs is not None
248
+ else manifest.default_configs(has_python=ecosystems.python, has_typescript=ecosystems.typescript)
249
+ )
250
+ selected_hook_manager: manifest.HookManager = hook_manager or hooks.detect_manager(root)
251
+ plan = Plan(
252
+ ecosystems=ecosystems,
253
+ root=root,
254
+ profile=profile,
255
+ configs=selected,
256
+ hook_manager=selected_hook_manager,
257
+ )
258
+
259
+ if python_dest is None and ecosystems.python_root is not None:
260
+ _report_independent_roots(root, ecosystems.python_root, ("pyproject.toml",), "Python", plan)
261
+ if typescript_dest is None and ecosystems.typescript_root is not None:
262
+ lockfiles = tuple(name for name, _client in LOCKFILES)
263
+ candidates = lockfiles if _all_roots(root, lockfiles) else ("package.json",)
264
+ _report_independent_roots(root, ecosystems.typescript_root, candidates, "TypeScript", plan)
265
+ if not ecosystems.any:
266
+ if configs is None:
267
+ plan.notes.append("no pyproject.toml and no package.json found -- pass --config to scaffold anyway")
268
+ return plan
269
+ unsupported = tuple(name for name in selected if name in _ECOSYSTEM_CONFIGS)
270
+ if unsupported:
271
+ names = ", ".join(unsupported)
272
+ plan.errors.append(
273
+ f"cannot scaffold ecosystem-specific config(s) without an owning project: {names}; "
274
+ "add pyproject.toml/package.json or select only markdownlint, taplo, and yamllint"
275
+ )
276
+ return plan
277
+ plan.notes.append("no Python or TypeScript project found; adopting repository-wide shared configs only")
278
+
279
+ _plan_manifest(root, plan, force=force, update_existing=update_manifest)
280
+ _plan_retired_repository_launcher(root, plan)
281
+ if (
282
+ ecosystems.python
283
+ and ecosystems.python_root is not None
284
+ and any(name in selected for name in manifest.PYTHON_CONFIGS)
285
+ ):
286
+ _plan_python(ecosystems.python_root, plan, force=force)
287
+ if (
288
+ ecosystems.typescript
289
+ and ecosystems.typescript_root is not None
290
+ and any(name in selected for name in manifest.TYPESCRIPT_CONFIGS)
291
+ ):
292
+ _plan_typescript(ecosystems.typescript_root, plan, force=force)
293
+ # Nested configs are only Standards' concern when ESLint was actually
294
+ # selected. At this point eslint.strict.mjs is either present or a
295
+ # target in the config sync plan built by ``plan_init``.
296
+ if "eslint" in selected and not allow_existing_nested_eslint:
297
+ _report_unwired_nested_eslint_configs(root, ecosystems.typescript_root, plan)
298
+ if plan.hook_manager == "pre-commit":
299
+ _plan_precommit(root, plan, force=force)
300
+ elif plan.hook_manager == "lefthook":
301
+ _plan_retire_precommit_staged_check(root, plan)
302
+ if hooks.lefthook_config(root) is None:
303
+ plan.errors.append("--hooks lefthook requires lefthook.yml or lefthook.yaml")
304
+ elif not hooks.lefthook_runs_staged_check(root):
305
+ try:
306
+ plan.writes.append(hooks.wire_lefthook_staged_check(root))
307
+ except ValueError as exc:
308
+ plan.errors.append(str(exc))
309
+ else:
310
+ plan.notes.append("added the canonical staged check to the existing Lefthook configuration")
311
+ else:
312
+ plan.notes.append("preserving validated Lefthook management; no pre-commit config was generated")
313
+ else:
314
+ plan.notes.append(f"preserving {plan.hook_manager} hook management; no pre-commit config was generated")
315
+ workflow = root / ".github" / "workflows" / "standards.yml"
316
+ workflow_contents = github_ci_workflow(root)
317
+ existing_gates = standards_check_workflows(root)
318
+ if workflow.is_file() and _is_managed_workflow(workflow):
319
+ if workflow.read_text(encoding="utf-8") == workflow_contents:
320
+ plan.skips.append((workflow, "already runs the canonical manifest-driven Standards gate"))
321
+ else:
322
+ plan.writes.append((workflow, workflow_contents))
323
+ elif existing_gates:
324
+ names = ", ".join(path.relative_to(root).as_posix() for path in existing_gates)
325
+ plan.skips.append((workflow, f"existing workflow already runs the canonical Standards check: {names}"))
326
+ elif workflow.is_file() and (migrated := _migrate_legacy_workflow_gate(workflow)) is not None:
327
+ plan.writes.append((workflow, migrated))
328
+ plan.notes.append("migrated the removed Standards `verify` CI command to the canonical check")
329
+ elif workflow.is_file() and workflow.read_text(encoding="utf-8") == workflow_contents:
330
+ plan.skips.append((workflow, "already runs the canonical manifest-driven Standards gate"))
331
+ else:
332
+ _record(
333
+ plan,
334
+ workflow,
335
+ workflow_contents,
336
+ force=force,
337
+ reason=(
338
+ "exists; preserve repository-specific CI changes or regenerate explicitly with "
339
+ "`code-standards show ci --output .github/workflows/standards.yml`"
340
+ ),
341
+ )
342
+ _note_subproject_destinations(root, plan)
343
+ return plan
344
+
345
+
346
+ def _is_managed_workflow(path: Path) -> bool:
347
+ try:
348
+ first_line = path.read_text(encoding="utf-8").splitlines()[0]
349
+ except OSError, IndexError:
350
+ return False
351
+ return (
352
+ re.fullmatch(
353
+ r"# Managed by (?:code-standards|sarj-standards)(?: [0-9]+\.[0-9]+\.[0-9]+)?; regenerate with "
354
+ r"`code-standards show ci --output \.github/workflows/standards\.yml`\.",
355
+ first_line,
356
+ )
357
+ is not None
358
+ )
359
+
360
+
361
+ def _report_independent_roots(
362
+ repository: Path,
363
+ selected: Path,
364
+ names: Sequence[str],
365
+ label: str,
366
+ plan: Plan,
367
+ ) -> None:
368
+ independent = [path for path in _all_roots(repository, names) if not path.is_relative_to(selected)]
369
+ if not independent:
370
+ return
371
+ roots = ", ".join(path.relative_to(repository).as_posix() or "." for path in (selected, *independent))
372
+ option = "--python-dest" if label == "Python" else "--typescript-dest"
373
+ plan.errors.append(
374
+ f"multiple independent {label} roots detected: {roots}; run setup in each independent project"
375
+ f" or select one with {option}"
376
+ )
377
+
378
+
379
+ def _report_unwired_nested_eslint_configs(repository: Path, selected: Path, plan: Plan) -> None:
380
+ for config_root in _all_roots(repository, _ESLINT_CONFIG_NAMES):
381
+ if config_root == selected:
382
+ continue
383
+ configs = tuple(config_root / name for name in _ESLINT_CONFIG_NAMES if (config_root / name).is_file())
384
+ strict = selected / "eslint.strict.mjs"
385
+ if not configs or any(
386
+ _eslint_wiring_reaches_strict(path, repository, planned_strict=strict) for path in configs
387
+ ):
388
+ continue
389
+ relative = config_root.relative_to(repository).as_posix()
390
+ if len(configs) == 1 and (wired := _wire_nested_eslint(configs[0], strict)) is not None:
391
+ plan.writes.append((configs[0], wired))
392
+ plan.notes.append(f"wired nested ESLint policy in {relative}")
393
+ continue
394
+ plan.errors.append(
395
+ f"nested ESLint config in {relative} would shadow Standards and cannot be merged safely; "
396
+ f"run setup with --typescript-dest {shlex.quote(relative)} or wire that config to eslint.strict.mjs"
397
+ )
398
+
399
+
400
+ _NAMED_ESLINT_EXPORT = re.compile(r"(?m)^\s*export\s+default\s+(?P<name>[A-Za-z_$][\w$]*)\s*;?\s*$")
401
+
402
+
403
+ def _wire_nested_eslint(path: Path, strict: Path) -> str | None:
404
+ text = path.read_text(encoding="utf-8")
405
+ exported = _NAMED_ESLINT_EXPORT.search(text)
406
+ if exported is None:
407
+ return None
408
+ name = re.escape(exported.group("name"))
409
+ if (
410
+ re.search(
411
+ rf"(?m)^\s*(?:const|let)\s+{name}(?:\s*:[^=\n]+)?\s*=\s*(?:defineConfig\s*\(\s*)?\[",
412
+ text[: exported.start()],
413
+ )
414
+ is None
415
+ ):
416
+ # An imported identifier, function result, or object is not known to be
417
+ # iterable. Spreading it could make ESLint crash after setup.
418
+ return None
419
+ relative = os.path.relpath(strict, path.parent).replace(os.sep, "/")
420
+ specifier = relative if relative.startswith(".") else f"./{relative}"
421
+ prefix = f'import sarjStrict from "{specifier}";\n\n'
422
+ replacement = f"export default [...sarjStrict, ...{exported.group('name')}];"
423
+ return f"{prefix}{text[: exported.start()]}{replacement}{text[exported.end() :]}"
424
+
425
+
426
+ def dest_of(root: Path, subdirectory: Path | None) -> str:
427
+ if subdirectory is None:
428
+ return "."
429
+ return subdirectory.relative_to(root).as_posix() or "."
430
+
431
+
432
+ def _note_subproject_destinations(root: Path, plan: Plan) -> None:
433
+ for label, subdirectory in (
434
+ ("python", plan.ecosystems.python_root),
435
+ ("typescript", plan.ecosystems.typescript_root),
436
+ ):
437
+ dest = dest_of(root, subdirectory)
438
+ if dest != ".":
439
+ plan.notes.append(
440
+ f"the {label} project is {dest}/, not the repo root, so its configs"
441
+ f" were written there. Future setup and update runs read the same destinations from"
442
+ f" {manifest.MANIFEST_NAME}."
443
+ )
444
+
445
+
446
+ def _plan_manifest(root: Path, plan: Plan, *, force: bool, update_existing: bool) -> None:
447
+ path = manifest.manifest_path(root)
448
+ current = manifest.load_for_setup(root) if path.is_file() else None
449
+ detected_generated = _generated_python_exclusions(root, plan.ecosystems.python_root)
450
+ existing_exclusions = () if current is None else current.excluded_paths
451
+ desired = manifest.Manifest(
452
+ version=manifest.adopted_version(),
453
+ configs=plan.configs,
454
+ python_dest=dest_of(root, plan.ecosystems.python_root),
455
+ typescript_dest=dest_of(root, plan.ecosystems.typescript_root),
456
+ profile=plan.profile,
457
+ hook_manager=plan.hook_manager,
458
+ verify_paths=(".",) if current is None else current.verify_paths,
459
+ excluded_paths=tuple(dict.fromkeys((*existing_exclusions, *detected_generated))),
460
+ excluded_rules=() if current is None else current.excluded_rules,
461
+ exclusion_overrides=() if current is None else current.exclusion_overrides,
462
+ durable_artifacts=manifest.DEFAULT_DURABLE_ARTIFACTS if current is None else current.durable_artifacts,
463
+ text_excluded_paths=() if current is None else current.text_excluded_paths,
464
+ doctor_excluded_paths=() if current is None else current.doctor_excluded_paths,
465
+ diagnostic_baseline=None if current is None else current.diagnostic_baseline,
466
+ ci_bootstrap=() if current is None else current.ci_bootstrap,
467
+ )
468
+ contents = desired.render()
469
+ if current is not None:
470
+ try:
471
+ strict = manifest.load(root)
472
+ except ValueError:
473
+ strict = None
474
+ if strict is None:
475
+ legacy_text = path.read_text(encoding="utf-8")
476
+ plan.writes.append((path, _migrate_schema_less_manifest(legacy_text, desired)))
477
+ plan.notes.append("migrated the legacy manifest to the current schema")
478
+ return
479
+ if strict != desired:
480
+ if not force and not update_existing:
481
+ plan.skips.append((path, "exists; preserve repository-specific adoption settings"))
482
+ return
483
+ plan.writes.append((path, contents))
484
+ plan.notes.append("updated the manifest to match the requested capabilities and profile")
485
+ return
486
+ _record(plan, path, contents, force=force, reason="already declares an adopted version")
487
+
488
+
489
+ def _plan_retired_repository_launcher(root: Path, plan: Plan) -> None:
490
+ path = root / launcher.RETIRED_REPOSITORY_LAUNCHER
491
+ if not path.exists():
492
+ return
493
+ if not path.is_file():
494
+ plan.errors.append(f"retired launcher target must be a regular file: {path}")
495
+ return
496
+ if path.read_bytes() != launcher.retired_repository_script().encode():
497
+ plan.errors.append(f"refusing to remove customized retired launcher: {path}")
498
+ return
499
+ plan.deletes.append(path)
500
+
501
+
502
+ def _migrate_schema_less_manifest(text: str, desired: manifest.Manifest) -> str:
503
+ version_line = _SCHEMA_LESS_VERSION_LINE.search(text)
504
+ if version_line is None: # The legacy loader proves this before planning.
505
+ return text
506
+ prefix = f'schema = {manifest.MANIFEST_SCHEMA}\nbundle = "{desired.version}"\nrule_profile = "all"\n'
507
+ migrated = f"{text[: version_line.start()]}{prefix}{text[version_line.end() :]}"
508
+ migrated = _without_schema_less_configs(migrated)
509
+ disabled = tuple(name for name in manifest.ALL_CONFIGS if name not in desired.configs)
510
+ disabled_text = ", ".join(f'"{name}"' for name in disabled)
511
+ policy = f"\n[capabilities]\ndisable = [{disabled_text}]\n"
512
+ table = _FIRST_TOML_TABLE.search(migrated)
513
+ if table is None:
514
+ return f"{migrated.rstrip()}\n{policy}"
515
+ return f"{migrated[: table.start()].rstrip()}\n{policy}\n{migrated[table.start() :]}"
516
+
517
+
518
+ def _without_schema_less_configs(text: str) -> str:
519
+ lines = text.splitlines(keepends=True)
520
+ kept: list[str] = []
521
+ skipping = False
522
+ depth = 0
523
+ for line in lines:
524
+ if not skipping and _SCHEMA_LESS_CONFIGS_START.match(line):
525
+ skipping = True
526
+ if skipping:
527
+ depth += line.count("[") - line.count("]")
528
+ if depth <= 0:
529
+ skipping = False
530
+ continue
531
+ kept.append(line)
532
+ return "".join(kept)
533
+
534
+
535
+ def _generated_python_exclusions(repository: Path, python_root: Path | None) -> tuple[str, ...]:
536
+ if python_root is None:
537
+ return ()
538
+ exclusions: list[str] = []
539
+ for project in _all_roots(python_root, ("pyproject.toml",)):
540
+ if project == repository:
541
+ continue
542
+ if _is_speakeasy_project(project):
543
+ exclusions.append(f"{project.relative_to(repository).as_posix()}/**")
544
+ continue
545
+ package = _openapi_python_client_package(project)
546
+ if package is not None:
547
+ exclusions.append(f"{package.relative_to(repository).as_posix()}/**")
548
+ return tuple(sorted(set(exclusions)))
549
+
550
+
551
+ def _is_speakeasy_project(project: Path) -> bool:
552
+ if not (project / ".speakeasy" / "gen.yaml").is_file():
553
+ return False
554
+ source = project / "src"
555
+ if not source.is_dir():
556
+ return False
557
+ marker = "Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT."
558
+ for candidate in sorted(source.rglob("*.py"))[:8]:
559
+ try:
560
+ if marker in candidate.read_text(encoding="utf-8", errors="replace")[:512]:
561
+ return True
562
+ except OSError:
563
+ continue
564
+ return False
565
+
566
+
567
+ def _openapi_python_client_package(project: Path) -> Path | None:
568
+ generator = project / "generate.py"
569
+ pyproject = project / "pyproject.toml"
570
+ if not (project / "codegen.config.yml").is_file() or not generator.is_file():
571
+ return None
572
+ try:
573
+ generator_text = generator.read_text(encoding="utf-8", errors="replace")
574
+ parsed: object = tomllib.loads(pyproject.read_text(encoding="utf-8"))
575
+ except OSError, tomllib.TOMLDecodeError:
576
+ return None
577
+ data = manifest.as_table(parsed)
578
+ description = manifest.text_field(manifest.table_field(data, "project"), "description") or ""
579
+ if not description.casefold().startswith("generated ") or "openapi-python-client" not in generator_text:
580
+ return None
581
+ tool = manifest.table_field(data, "tool")
582
+ hatch = manifest.table_field(tool, "hatch")
583
+ build = manifest.table_field(hatch, "build")
584
+ targets = manifest.table_field(build, "targets")
585
+ wheel = manifest.table_field(targets, "wheel")
586
+ packages = manifest.list_field(wheel, "packages")
587
+ if len(packages) != 1 or not isinstance(packages[0], str):
588
+ return None
589
+ package = (project / packages[0]).resolve()
590
+ if package.parent != project.resolve() or not package.is_dir() or is_link_like(package):
591
+ return None
592
+ return package
593
+
594
+
595
+ def _plan_python( # ruff: ignore[too-many-locals] -- one TOML boundary preserves consumer policy while wiring bases.
596
+ root: Path, plan: Plan, *, force: bool
597
+ ) -> None:
598
+ standalone_ruff = [root / name for name in _STANDALONE_RUFF_CONFIG_NAMES if (root / name).is_file()]
599
+ if standalone_ruff:
600
+ names = ", ".join(path.name for path in standalone_ruff)
601
+ plan.errors.append(
602
+ f"cannot safely adopt Ruff while standalone config(s) are active in {root}: {names}; "
603
+ "consolidate their settings into pyproject.toml, remove them, and rerun setup"
604
+ )
605
+ return
606
+ pyproject = root / "pyproject.toml"
607
+ python_target: str | None = None
608
+ if pyproject.is_file():
609
+ text = pyproject.read_text(encoding="utf-8")
610
+ try:
611
+ parsed: object = tomllib.loads(text)
612
+ except tomllib.TOMLDecodeError as exc:
613
+ plan.errors.append(f"cannot safely wire {pyproject}: {exc}")
614
+ return
615
+ document = manifest.as_table(parsed)
616
+ python_target = _python_target(document)
617
+ tool = manifest.as_table(document.get("tool"))
618
+ pyright_tables = tuple(name for name in ("pyright", "basedpyright") if name in tool)
619
+ if pyright_tables:
620
+ tables = " and ".join(f"[tool.{name}]" for name in pyright_tables)
621
+ plan.errors.append(
622
+ f"cannot safely wire {pyproject}: {tables} cannot inherit the canonical JSON configuration; "
623
+ "move those settings to pyrightconfig.json, remove the TOML table, then rerun setup"
624
+ )
625
+ return
626
+ ruff = manifest.as_table(tool.get("ruff"))
627
+ lint = manifest.as_table(ruff.get("lint"))
628
+ conflicts = tuple(
629
+ (key, f"extend-{key}")
630
+ for key in ("select", "ignore")
631
+ if key in lint and f"extend-{key}" in lint and not (key == "select" and lint.get("select") == ["ALL"])
632
+ )
633
+ if conflicts:
634
+ rendered = ", ".join(f"{first}/{second}" for first, second in conflicts)
635
+ plan.errors.append(
636
+ f"cannot safely wire {pyproject}: [tool.ruff.lint] defines both {rendered}; "
637
+ "combine each pair under the extend-* key, then rerun setup"
638
+ )
639
+ return
640
+ existing_extend = ruff.get("extend")
641
+ if existing_extend is not None and existing_extend != ".ruff-strict.toml":
642
+ plan.errors.append(
643
+ f"cannot safely wire {pyproject}: [tool.ruff] already extends {existing_extend!r}; "
644
+ "preserve that config chain manually before adding .ruff-strict.toml"
645
+ )
646
+ return
647
+ updated = _extend_ruff_replacement_policy(text)
648
+ if ruff.get("extend") == ".ruff-strict.toml" and updated != text:
649
+ plan.writes.append((pyproject, updated))
650
+ elif ruff.get("extend") == ".ruff-strict.toml":
651
+ plan.skips.append((pyproject, "already extends .ruff-strict.toml"))
652
+ elif _RUFF_EXTEND.search(updated):
653
+ wired = _RUFF_EXTEND.sub('[tool.ruff]\nextend = ".ruff-strict.toml"', updated, count=1)
654
+ plan.writes.append((pyproject, wired))
655
+ elif updated != text:
656
+ plan.writes.append((pyproject, f'{updated}\n[tool.ruff]\nextend = ".ruff-strict.toml"\n'))
657
+ else:
658
+ plan.edits.append((pyproject, '\n[tool.ruff]\nextend = ".ruff-strict.toml"\n'))
659
+
660
+ pyright = root / _PYRIGHT_CONFIG
661
+ pyright_jsonc = root / "pyrightconfig.jsonc"
662
+ if pyright_jsonc.is_file():
663
+ competing = f" alongside {pyright}" if pyright.is_file() else ""
664
+ plan.errors.append(
665
+ f"cannot safely wire {pyright_jsonc}{competing}; Pyright JSONC may contain comments and only one "
666
+ "extends parent, so compose .pyright-strict.json manually before rerunning setup"
667
+ )
668
+ return
669
+ if pyright.is_file():
670
+ document, error = _json_object(pyright)
671
+ if error is not None:
672
+ plan.errors.append(f"cannot safely wire {pyright}: {error}")
673
+ elif document is not None:
674
+ existing_pyright_extend = document.get("extends")
675
+ if existing_pyright_extend is not None and existing_pyright_extend != ".pyright-strict.json":
676
+ plan.errors.append(
677
+ f"cannot safely wire {pyright}: it already extends {existing_pyright_extend!r}; "
678
+ "Pyright supports one parent, so preserve that config chain manually before adding "
679
+ ".pyright-strict.json"
680
+ )
681
+ return
682
+ changed = existing_pyright_extend != ".pyright-strict.json"
683
+ document["extends"] = ".pyright-strict.json"
684
+ if python_target is not None and "pythonVersion" not in document:
685
+ document["pythonVersion"] = python_target
686
+ changed = True
687
+ if changed:
688
+ plan.writes.append((pyright, json.dumps(document, indent=_indent_of(pyright.read_text())) + "\n"))
689
+ else:
690
+ plan.skips.append((pyright, "already extends .pyright-strict.json"))
691
+ else:
692
+ generated_document: dict[str, object] = {"extends": ".pyright-strict.json"}
693
+ if python_target is not None:
694
+ generated_document["pythonVersion"] = python_target
695
+ _record(
696
+ plan,
697
+ pyright,
698
+ json.dumps(generated_document, indent=2) + "\n",
699
+ force=force,
700
+ reason='exists; add `"extends": ".pyright-strict.json"` yourself',
701
+ )
702
+
703
+
704
+ def _python_target(document: Mapping[str, object]) -> str | None:
705
+ requires_python = manifest.table_field(document, "project").get("requires-python")
706
+ if not isinstance(requires_python, str):
707
+ return None
708
+ try:
709
+ specifiers = SpecifierSet(requires_python)
710
+ except InvalidSpecifier:
711
+ return None
712
+ boundary_versions: list[Version] = []
713
+ for specifier in specifiers:
714
+ try:
715
+ boundary_versions.append(Version(specifier.version.rstrip(".*")))
716
+ except InvalidVersion:
717
+ continue
718
+ for minor in range(8, 15):
719
+ candidates = [Version(f"3.{minor}.0"), Version(f"3.{minor}.999"), *boundary_versions]
720
+ if any(
721
+ candidate.major == _PYTHON_MAJOR and candidate.minor == minor and candidate in specifiers
722
+ for candidate in candidates
723
+ ):
724
+ return f"3.{minor}"
725
+ return None
726
+
727
+
728
+ def _extend_ruff_replacement_policy(text: str) -> str:
729
+
730
+ def rewrite_section(section: re.Match[str]) -> str:
731
+ def rewrite_key(match: re.Match[str]) -> str:
732
+ replacement = "extend-select" if match.group("key") == "select" else "extend-ignore"
733
+ return f"{match.group('indent')}{replacement}{match.group('equals')}"
734
+
735
+ body = section.group("body")
736
+ if _RUFF_REPLACEMENT_KEY.search(body) and re.search(r"(?m)^\s*extend-select\s*=", body):
737
+ body = _RUFF_REDUNDANT_SELECT_ALL.sub("", body)
738
+ body = _RUFF_REPLACEMENT_KEY.sub(rewrite_key, body)
739
+ return f"{section.group('header')}{body}"
740
+
741
+ return _RUFF_LINT_SECTION.sub(rewrite_section, text)
742
+
743
+
744
+ def _json_object(path: Path) -> _JsonObjectResult:
745
+ try:
746
+ parsed: object = json.loads( # pyright: ignore[reportAny] -- untyped stdlib boundary
747
+ path.read_text(encoding="utf-8")
748
+ )
749
+ except (OSError, ValueError) as exc:
750
+ return _JsonObjectResult(None, str(exc))
751
+ if not isinstance(parsed, dict):
752
+ return _JsonObjectResult(None, "expected a JSON object")
753
+ document = manifest.as_table(parsed) # pyright: ignore[reportUnknownArgumentType] -- isinstance establishes the JSON object boundary; as_table narrows its leaves
754
+ return _JsonObjectResult(document, None)
755
+
756
+
757
+ def _plan_typescript(root: Path, plan: Plan, *, force: bool) -> None:
758
+ existing_configs = [root / name for name in _ESLINT_CONFIG_NAMES if (root / name).is_file()]
759
+ if len(existing_configs) > 1:
760
+ names = ", ".join(path.name for path in existing_configs)
761
+ plan.errors.append(f"multiple active ESLint flat configs in {root}: {names}; keep one before running setup")
762
+ return
763
+ eslint = existing_configs[0] if existing_configs else root / _ESLINT_CONFIG
764
+ if eslint.is_file():
765
+ text = eslint.read_text(encoding="utf-8")
766
+ if _eslint_wiring_reaches_strict(eslint, root, planned_strict=root / "eslint.strict.mjs"):
767
+ plan.skips.append((eslint, "already imports eslint.strict.mjs"))
768
+ elif re.search(r"(?m)^[ \t]*export\s+default\s+defineConfig\s*\(\s*\[", text):
769
+ wired = f'import strict from "./eslint.strict.mjs";\n\n{
770
+ re.sub(
771
+ r"(?m)^[ \t]*export\s+default\s+defineConfig\s*\(\s*\[",
772
+ "export default defineConfig([\n ...strict,",
773
+ text,
774
+ count=1,
775
+ )
776
+ }'
777
+ plan.writes.append((eslint, wired))
778
+ elif re.search(r"(?m)^[ \t]*export\s+default\s*\[", text):
779
+ wired = f'import strict from "./eslint.strict.mjs";\n\n{
780
+ re.sub(
781
+ r"(?m)^[ \t]*export\s+default\s*\[",
782
+ "export default [\n ...strict,",
783
+ text,
784
+ count=1,
785
+ )
786
+ }'
787
+ plan.writes.append((eslint, wired))
788
+ else:
789
+ plan.errors.append(
790
+ f"cannot safely wire {eslint}; import `./eslint.strict.mjs` and spread it in the exported flat config"
791
+ )
792
+ else:
793
+ _record(plan, eslint, _eslint_entrypoint(), force=force, reason="exists; import ./eslint.strict.mjs from it")
794
+ client = plan.ecosystems.client
795
+ install_root = plan.ecosystems.typescript_install_root or root
796
+ _plan_npm_overrides(install_root, plan, client)
797
+ typescript_root = plan.ecosystems.typescript_root
798
+ if (
799
+ client is PackageManager.YARN
800
+ and typescript_root is not None
801
+ and typescript_root.resolve() != install_root.resolve()
802
+ ):
803
+ _plan_yarn_workspace_peers(typescript_root, plan)
804
+ # pnpm 11 reads overrides from pnpm-workspace.yaml even for a standalone
805
+ # package. Setup creates that policy file below, so the ensuing install is
806
+ # always a workspace install for pnpm.
807
+ is_workspace = (
808
+ client is PackageManager.PNPM or install_root != root or (install_root / "pnpm-workspace.yaml").is_file()
809
+ )
810
+ plan.notes.append(
811
+ f"detected {client} -- install the tested ESLint peer set:\n"
812
+ f" {packagemanager.install_command(client, workspace=is_workspace, yarn=plan.ecosystems.yarn)}"
813
+ )
814
+ caveat = packagemanager.install_note(client, yarn=plan.ecosystems.yarn)
815
+ if caveat is not None:
816
+ plan.notes.append(caveat)
817
+
818
+
819
+ _LOCAL_MODULE = re.compile(
820
+ r"(?m)^\s*(?:import\b[^;\n]*?\bfrom\s+|import\s*|export\b[^;\n]*?\bfrom\s+)"
821
+ r"[\"'](?P<path>\.[^\"']+)[\"']"
822
+ )
823
+
824
+
825
+ def _eslint_wiring_reaches_strict(
826
+ path: Path,
827
+ root: Path,
828
+ seen: set[Path] | None = None,
829
+ *,
830
+ planned_strict: Path | None = None,
831
+ ) -> bool:
832
+ visited: set[Path] = set() if seen is None else seen
833
+ resolved = path.resolve()
834
+ if resolved in visited or not resolved.is_file():
835
+ return False
836
+ try:
837
+ resolved.relative_to(root.resolve())
838
+ except ValueError:
839
+ return False
840
+ visited.add(resolved)
841
+ text = resolved.read_text(encoding="utf-8", errors="replace")
842
+ for match in _LOCAL_MODULE.finditer(text):
843
+ target = (resolved.parent / match.group("path")).resolve()
844
+ if target.name == "eslint.strict.mjs" and (
845
+ target.is_file() or (planned_strict is not None and target == planned_strict.resolve())
846
+ ):
847
+ return True
848
+ candidates = (target, *(target.with_suffix(suffix) for suffix in (".js", ".mjs", ".cjs", ".ts")))
849
+ if any(
850
+ _eslint_wiring_reaches_strict(candidate, root, visited, planned_strict=planned_strict)
851
+ for candidate in candidates
852
+ ):
853
+ return True
854
+ return False
855
+
856
+
857
+ def _plan_npm_overrides(root: Path, plan: Plan, client: PackageManager) -> None:
858
+ overrides = packagemanager.overrides_for(client)
859
+ pnpm_workspace = root / "pnpm-workspace.yaml"
860
+ package_overrides: Overrides | None = overrides
861
+ if client is PackageManager.PNPM:
862
+ current = pnpm_workspace.read_text(encoding="utf-8") if pnpm_workspace.is_file() else ""
863
+ try:
864
+ merged = _merged_pnpm_workspace(current, overrides.entries)
865
+ except ValueError as exc:
866
+ plan.errors.append(f"cannot safely merge pnpm overrides into {pnpm_workspace}: {exc}")
867
+ return
868
+ if merged == current and pnpm_workspace.is_file():
869
+ plan.skips.append((pnpm_workspace, "already carries the pnpm peer overrides"))
870
+ else:
871
+ plan.writes.append((pnpm_workspace, merged))
872
+ package_overrides = None
873
+ override_target = package_json = root / "package.json"
874
+ if client is PackageManager.PNPM:
875
+ override_target = pnpm_workspace
876
+ rendered_overrides = "".join(
877
+ f" {json.dumps(key)}: {json.dumps(value)}\n" for key, value in overrides.entries.items()
878
+ ).rstrip()
879
+ printed = f" overrides:\n{rendered_overrides}"
880
+ else:
881
+ printed = textwrap.indent(json.dumps(overrides.as_document(), indent=2), " ")
882
+ if not package_json.is_file():
883
+ plan.errors.append(
884
+ f"cannot adopt TypeScript in {root}: no package.json exists at the detected install root, so the "
885
+ f"tested ESLint peers and {client} overrides cannot be installed; select the correct workspace root"
886
+ )
887
+ return
888
+ try:
889
+ merged = _merged_npm_overrides(package_json.read_text(encoding="utf-8"), package_overrides, client=client)
890
+ except (TypeError, ValueError) as exc:
891
+ plan.errors.append(f"cannot safely merge tested ESLint peers into {package_json}: {exc}")
892
+ return
893
+ if merged is None:
894
+ plan.skips.append((package_json, f"already pins the tested ESLint peers and {client} overrides"))
895
+ return
896
+ plan.writes.append((package_json, merged))
897
+ plan.notes.append(
898
+ f"pinned the tested ESLint peers in {package_json} and merged the {client} overrides into "
899
+ f"{override_target}:\n{printed}\n"
900
+ f" {client} cannot resolve the tree without them -- eslint-plugin-react"
901
+ " peers eslint <=9.7 and the unicorn floor needs >=10.4."
902
+ )
903
+
904
+
905
+ def _plan_yarn_workspace_peers(typescript_root: Path, plan: Plan) -> None:
906
+ package_json = typescript_root / "package.json"
907
+ if not package_json.is_file():
908
+ plan.errors.append(f"cannot adopt TypeScript in {typescript_root}: package.json is missing")
909
+ return
910
+ try:
911
+ merged = _merged_npm_overrides(package_json.read_text(encoding="utf-8"), None, client=PackageManager.YARN)
912
+ except (TypeError, ValueError) as exc:
913
+ plan.errors.append(f"cannot safely merge tested ESLint peers into {package_json}: {exc}")
914
+ return
915
+ if merged is None:
916
+ plan.skips.append((package_json, "Yarn workspace already pins the tested ESLint peers"))
917
+ return
918
+ plan.writes.append((package_json, merged))
919
+ plan.notes.append(
920
+ f"pinned the tested ESLint peers in Yarn workspace {package_json};"
921
+ " Plug'n'Play resolves config imports from that workspace rather than its install root"
922
+ )
923
+
924
+
925
+ def _merged_pnpm_workspace(text: str, entries: Mapping[str, object]) -> str:
926
+ if re.search(r"""(?m)^(?:overrides|"overrides"|'overrides'):[ \t]*[^\s#]""", text):
927
+ msg = "flow-style `overrides` is unsupported; convert it to a YAML block mapping and rerun setup"
928
+ raise ValueError(msg)
929
+ current = packagemanager.pnpm_workspace_values(text)
930
+ for key, value in entries.items():
931
+ if key not in current or current[key] == str(value):
932
+ continue
933
+ pattern = re.compile(rf"(?m)^(?P<indent>\s*)(?:{re.escape(json.dumps(key))}|{re.escape(key)}):[^\n]*$")
934
+ text = pattern.sub(rf"\g<indent>{json.dumps(key)}: {json.dumps(value)}", text, count=1)
935
+ current = packagemanager.pnpm_workspace_values(text)
936
+ missing = [(key, value) for key, value in entries.items() if current.get(key) != str(value)]
937
+ if not missing:
938
+ return text
939
+ rendered = "".join(f" {json.dumps(key)}: {json.dumps(value)}\n" for key, value in missing)
940
+ heading = re.search(r"(?m)^overrides:\s*$", text)
941
+ if heading is None:
942
+ prefix = "" if not text or text.endswith("\n") else "\n"
943
+ return f"{text}{prefix}overrides:\n{rendered}"
944
+ insertion = heading.end() + (1 if text[heading.end() :].startswith("\n") else 0)
945
+ return text[:insertion] + rendered + text[insertion:]
946
+
947
+
948
+ def _merged_npm_overrides( # ruff: ignore[too-many-locals] -- explicit JSON merge state preserves consumer fields.
949
+ text: str, overrides: Overrides | None, *, client: PackageManager
950
+ ) -> str | None:
951
+ parsed: object = json.loads(text) # pyright: ignore[reportAny] -- untyped stdlib boundary
952
+ data = manifest.as_table(parsed)
953
+ if not data:
954
+ msg = "package.json must contain a non-empty JSON object"
955
+ raise TypeError(msg)
956
+ changed = False
957
+ runtime_dependencies = manifest.table_field(data, "dependencies")
958
+ existing_peers = manifest.table_field(data, "devDependencies")
959
+ updated_runtime = dict(runtime_dependencies)
960
+ updated_peers = dict(existing_peers)
961
+ for name, peer_version in manifest.eslint_peers().items():
962
+ # Preserve dependency-section ownership while repairing duplicate peers.
963
+ if name in runtime_dependencies:
964
+ current = runtime_dependencies[name]
965
+ current_major = _semver_major(current)
966
+ required_major = _semver_major(peer_version)
967
+ if current != peer_version and (current_major is None or current_major != required_major):
968
+ msg = (
969
+ f"{name} is an application runtime dependency at {current!r}, but Standards requires "
970
+ f"{peer_version!r}; setup will not silently change its major version. Move lint tooling to "
971
+ "devDependencies or upgrade the runtime dependency explicitly, then rerun setup"
972
+ )
973
+ raise ValueError(msg)
974
+ updated_runtime[name] = peer_version
975
+ updated_peers.pop(name, None)
976
+ else:
977
+ updated_peers[name] = peer_version
978
+ if updated_runtime != runtime_dependencies:
979
+ data["dependencies"] = updated_runtime
980
+ changed = True
981
+ if updated_peers != existing_peers:
982
+ data["devDependencies"] = updated_peers
983
+ changed = True
984
+ if overrides is not None:
985
+ *outer, final = overrides.key_path
986
+ container = data
987
+ for key in outer:
988
+ container = manifest.table_field(container, key)
989
+ existing = manifest.table_field(container, final)
990
+ updated = dict(existing)
991
+ for name, value in overrides.entries.items():
992
+ # A consumer may already override the same package for a different
993
+ # reason, so merge the inner table rather than replacing it.
994
+ current_value = updated.get(name)
995
+ current_entry = manifest.table_field(updated, name)
996
+ new_entry = manifest.as_table(value)
997
+ if new_entry and current_value is not None and not isinstance(current_value, dict):
998
+ current_entry = {".": current_value}
999
+ updated[name] = {**current_entry, **new_entry} if new_entry else value
1000
+ if client is PackageManager.NPM:
1001
+ _align_npm_direct_dependency_overrides(updated)
1002
+ if updated != existing or not _has_path(data, overrides.key_path):
1003
+ _set_path(data, overrides.key_path, updated)
1004
+ changed = True
1005
+ if not changed:
1006
+ return None
1007
+ rendered = json.dumps(data, indent=_indent_of(text), ensure_ascii=False)
1008
+ return rendered + "\n" if text.endswith("\n") else rendered
1009
+
1010
+
1011
+ def _align_npm_direct_dependency_overrides(overrides: dict[str, object]) -> None:
1012
+ for name, pinned in manifest.eslint_peers().items():
1013
+ current = overrides.get(name)
1014
+ if isinstance(current, str):
1015
+ if current not in {pinned, f"${name}"}:
1016
+ overrides[name] = f"${name}"
1017
+ continue
1018
+ current_table = manifest.as_table(current)
1019
+ root_spec = current_table.get(".")
1020
+ if root_spec is not None and root_spec not in {pinned, f"${name}"}:
1021
+ overrides[name] = {**current_table, ".": f"${name}"}
1022
+
1023
+
1024
+ def _semver_major(value: object) -> int | None:
1025
+ if not isinstance(value, str):
1026
+ return None
1027
+ match = re.match(r"^\s*(?:[~^]|>=?|<=?|=)?\s*v?(?P<major>\d+)(?:\.|\s|$)", value)
1028
+ return int(match.group("major")) if match is not None else None
1029
+
1030
+
1031
+ def _has_path(data: Mapping[str, object], key_path: Sequence[str]) -> bool:
1032
+ table: Mapping[str, object] = data
1033
+ for key in key_path[:-1]:
1034
+ table = manifest.table_field(table, key)
1035
+ return key_path[-1] in table
1036
+
1037
+
1038
+ def _set_path(data: dict[str, object], key_path: Sequence[str], value: object) -> None:
1039
+ table = data
1040
+ for key in key_path[:-1]:
1041
+ nested = manifest.table_field(table, key)
1042
+ table[key] = nested
1043
+ table = nested
1044
+ table[key_path[-1]] = value
1045
+
1046
+
1047
+ def _indent_of(text: str) -> int | str:
1048
+ match = re.search(r"\n(?P<indent>[ \t]+)\S", text)
1049
+ return match.group("indent") if match else 2
1050
+
1051
+
1052
+ def _eslint_entrypoint() -> str:
1053
+ return """// Flat config entrypoint. `eslint.strict.mjs` next to this file is SYNCED --
1054
+ // `code-standards setup` overwrites it, and `setup --dry-run` fails CI if
1055
+ // you edit it. Put every repo-specific decision HERE instead, in the override
1056
+ // block below: later entries win, so you can relax a rule, add a framework
1057
+ // exemption, or scope one to a directory without forking the canonical file.
1058
+ import strict from "./eslint.strict.mjs";
1059
+
1060
+ export default [
1061
+ ...strict,
1062
+
1063
+ // --- repo-specific overrides -------------------------------------------
1064
+ // Example: your router generates bracketed filenames that unicorn rejects.
1065
+ //
1066
+ // {
1067
+ // files: ["src/routes/**/*.tsx"],
1068
+ // rules: {
1069
+ // "unicorn/filename-case": ["error", {
1070
+ // cases: { kebabCase: true },
1071
+ // ignore: [String.raw`^\\[`],
1072
+ // }],
1073
+ // },
1074
+ // },
1075
+ ];
1076
+ """
1077
+
1078
+
1079
+ def _plan_precommit(root: Path, plan: Plan, *, force: bool) -> None:
1080
+ existing = [root / name for name in _PRECOMMIT_CONFIG_NAMES if (root / name).is_file()]
1081
+ if len(existing) > 1:
1082
+ plan.errors.append(
1083
+ "multiple pre-commit configurations are active: "
1084
+ + ", ".join(path.name for path in existing)
1085
+ + "; keep one before running setup"
1086
+ )
1087
+ return
1088
+ path = existing[0] if existing else root / _PRECOMMIT_CONFIG_NAMES[0]
1089
+ block = precommit_block()
1090
+ if path.is_file():
1091
+ text = path.read_text(encoding="utf-8")
1092
+ runner_prefix = launcher.repository_command()
1093
+ migrated, migration_error = _migrate_official_remote_hook(text, runner_prefix)
1094
+ if migration_error is not None:
1095
+ plan.errors.append(f"cannot safely migrate {path}: {migration_error}")
1096
+ return
1097
+ if migrated is not None:
1098
+ plan.writes.append((path, migrated))
1099
+ return
1100
+ custom_legacy = re.search(r"(?m)^\s*-\s+id:\s+['\"]?sarj-standards['\"]?\s*$", text) is not None
1101
+ owned_hook = _has_owned_hooks(text)
1102
+ if custom_legacy:
1103
+ plan.skips.append((path, "preserving a custom legacy sarj-standards hook"))
1104
+ elif owned_hook:
1105
+ canonical = _canonicalize_owned_hooks(text, runner_prefix)
1106
+ if canonical == text:
1107
+ plan.skips.append((path, "already runs the canonical sarj-standards hook"))
1108
+ else:
1109
+ plan.writes.append((path, canonical))
1110
+ elif inline := re.search(r"(?m)^repos:\s*\[\s*\]\s*(?P<comment>#.*)?$", text):
1111
+ comment = inline.group("comment")
1112
+ opened = "repos:" if comment is None else f"repos: {comment}"
1113
+ text = f"{text[: inline.start()]}{opened}{text[inline.end() :]}"
1114
+ missing = _precommit_check_block(runner_prefix, item_indent=_precommit_item_indent(text))
1115
+ addition = missing if text.endswith("\n") else "\n" + missing
1116
+ plan.writes.append((path, text + addition))
1117
+ elif re.search(r"(?m)^repos:\s*(?:#.*)?$", text):
1118
+ missing = _precommit_check_block(runner_prefix, item_indent=_precommit_item_indent(text))
1119
+ addition = missing if text.endswith("\n") else "\n" + missing
1120
+ plan.edits.append((path, addition))
1121
+ else:
1122
+ plan.errors.append(f"cannot safely merge hooks into {path}; add this block under `repos:`:\n{block}")
1123
+ return
1124
+ _record(plan, path, f"repos:\n{block}", force=force, reason="exists")
1125
+
1126
+
1127
+ def _plan_retire_precommit_staged_check(root: Path, plan: Plan) -> None:
1128
+ existing = [root / name for name in _PRECOMMIT_CONFIG_NAMES if (root / name).is_file()]
1129
+ if len(existing) > 1:
1130
+ plan.errors.append(
1131
+ "multiple pre-commit configurations are active: "
1132
+ + ", ".join(path.name for path in existing)
1133
+ + "; keep one before switching hook managers"
1134
+ )
1135
+ return
1136
+ if not existing:
1137
+ return
1138
+ path = existing[0]
1139
+ text = path.read_text(encoding="utf-8")
1140
+ if not _has_owned_hooks(text):
1141
+ return
1142
+ try:
1143
+ updated = _remove_owned_precommit_hooks(text)
1144
+ except ValueError as exc:
1145
+ plan.errors.append(f"cannot safely retire the Standards pre-commit hook in {path}: {exc}")
1146
+ return
1147
+ plan.writes.append((path, updated))
1148
+ plan.notes.append("removed the generated Standards pre-commit hook because Lefthook is authoritative")
1149
+
1150
+
1151
+ def _migrate_official_remote_hook(text: str, runner_prefix: str) -> _HookMigration:
1152
+ official = tuple(
1153
+ block for block in hooks.precommit_repo_blocks(text) if hooks.is_official_standards_repo(block.repository)
1154
+ )
1155
+ if not official:
1156
+ return _HookMigration(None, None)
1157
+ for block in official:
1158
+ try:
1159
+ parsed: object = yaml.safe_load(f"repos:\n{block.text}") # pyright: ignore[reportAny] -- narrowed below.
1160
+ except yaml.YAMLError as exc:
1161
+ return _HookMigration(None, f"official Standards hook contains invalid YAML: {exc}")
1162
+ repos = manifest.list_field(manifest.as_table(parsed), "repos")
1163
+ if len(repos) != 1:
1164
+ return _HookMigration(None, "official Standards repository block is not a single YAML list item")
1165
+ repository = manifest.as_table(repos[0])
1166
+ hook_values = manifest.list_field(repository, "hooks")
1167
+ if not hook_values:
1168
+ return _HookMigration(None, "official Standards repository block has no hooks")
1169
+ for hook_value in hook_values:
1170
+ hook = manifest.as_table(hook_value)
1171
+ hook_id = hook.get("id")
1172
+ custom_keys = sorted(set(hook) - {"id"})
1173
+ is_owned = isinstance(hook_id, str) and (hook_id == "sarj-standards" or hook_id.startswith("sarj-"))
1174
+ if not is_owned or custom_keys:
1175
+ detail = (
1176
+ f"hook {hook_id!r} has custom keys {custom_keys}"
1177
+ if custom_keys
1178
+ else f"hook {hook_id!r} is not owned by Standards"
1179
+ )
1180
+ return _HookMigration(None, f"{detail}; preserve its scope manually before replacing the remote block")
1181
+ first = official[0].start
1182
+ removed = text
1183
+ for block in reversed(official):
1184
+ removed = removed[: block.start] + removed[block.end :]
1185
+ item_indents = {block.indent for block in official}
1186
+ if len(item_indents) != 1:
1187
+ return _HookMigration(None, "official Standards repository blocks use inconsistent indentation")
1188
+ insertion = _precommit_check_block(runner_prefix, item_indent=item_indents.pop())
1189
+ migrated = removed[:first] + insertion + removed[first:]
1190
+ return _HookMigration(_canonicalize_owned_hooks(migrated, runner_prefix), None)
1191
+
1192
+
1193
+ def _precommit_item_indent(text: str) -> int:
1194
+ blocks = hooks.precommit_repo_blocks(text)
1195
+ return blocks[0].indent if blocks else 2
1196
+
1197
+
1198
+ def _canonicalize_owned_hooks(text: str, runner_prefix: str) -> str:
1199
+ local_blocks = tuple(
1200
+ block
1201
+ for block in hooks.precommit_repo_blocks(text)
1202
+ if block.repository == "local" and _has_owned_hook_in_block(block.text)
1203
+ )
1204
+ if not local_blocks:
1205
+ return text
1206
+ for block in local_blocks:
1207
+ custom_keys = _owned_hook_custom_keys(block.text)
1208
+ if custom_keys:
1209
+ names = ", ".join(sorted(custom_keys))
1210
+ msg = (
1211
+ f"cannot replace a customized local Sarj hook ({names}); remove those keys or migrate their scope "
1212
+ "to the canonical umbrella hook explicitly"
1213
+ )
1214
+ raise ValueError(msg)
1215
+ keeper = local_blocks[0]
1216
+ canonical = text
1217
+ for block in reversed(local_blocks):
1218
+ replacement = _canonicalize_local_hook_block(
1219
+ block.text,
1220
+ runner_prefix,
1221
+ item_indent=block.indent,
1222
+ insert_canonical=block.start == keeper.start,
1223
+ )
1224
+ canonical = canonical[: block.start] + replacement + canonical[block.end :]
1225
+ return canonical
1226
+
1227
+
1228
+ def _remove_owned_precommit_hooks(text: str) -> str:
1229
+ local_blocks = tuple(
1230
+ block
1231
+ for block in hooks.precommit_repo_blocks(text)
1232
+ if block.repository == "local" and _has_owned_hook_in_block(block.text)
1233
+ )
1234
+ updated = text
1235
+ for block in reversed(local_blocks):
1236
+ custom_keys = _owned_hook_custom_keys(block.text)
1237
+ if custom_keys:
1238
+ names = ", ".join(sorted(custom_keys))
1239
+ msg = f"customized local Sarj hook has consumer-owned keys: {names}"
1240
+ raise ValueError(msg)
1241
+ replacement = _canonicalize_local_hook_block(
1242
+ block.text,
1243
+ "",
1244
+ item_indent=block.indent,
1245
+ insert_canonical=False,
1246
+ )
1247
+ try:
1248
+ parsed = cast("object", yaml.safe_load(f"repos:\n{replacement}"))
1249
+ except yaml.YAMLError as exc:
1250
+ msg = "generated local hook block is not valid YAML"
1251
+ raise ValueError(msg) from exc
1252
+ repositories = manifest.list_field(manifest.as_table(parsed), "repos")
1253
+ repository = manifest.as_table(repositories[0]) if repositories else {}
1254
+ if not manifest.list_field(repository, "hooks"):
1255
+ replacement = ""
1256
+ updated = updated[: block.start] + replacement + updated[block.end :]
1257
+ return updated
1258
+
1259
+
1260
+ def _has_owned_hooks(text: str) -> bool:
1261
+ return any(
1262
+ _has_owned_hook_in_block(block.text)
1263
+ for block in hooks.precommit_repo_blocks(text)
1264
+ if block.repository == "local"
1265
+ )
1266
+
1267
+
1268
+ def _has_owned_hook_in_block(text: str) -> bool:
1269
+ return (
1270
+ re.search(
1271
+ r"(?m)^\s*-\s+id:\s+['\"]?sarj-standards-(?:check|drift)['\"]?\s*(?:#.*)?$",
1272
+ text,
1273
+ )
1274
+ is not None
1275
+ )
1276
+
1277
+
1278
+ def _owned_hook_custom_keys(text: str) -> frozenset[str]:
1279
+ lines = text.splitlines(keepends=True)
1280
+ owned = {"sarj-standards-check", "sarj-standards-drift"}
1281
+ found: set[str] = set()
1282
+ for index, line in enumerate(lines):
1283
+ match = re.match(
1284
+ r"^(?P<indent>\s*)-\s+id:\s+['\"]?(?P<id>[^\s'\"#]+)['\"]?\s*(?:#.*)?$",
1285
+ line.rstrip("\r\n"),
1286
+ )
1287
+ if match is None or match["id"] not in owned:
1288
+ continue
1289
+ end = hooks.yaml_list_item_end(lines, index, len(match["indent"]))
1290
+ for property_line in lines[index + 1 : end]:
1291
+ if (key_match := re.match(r"^\s+(?P<key>[a-z_][a-z0-9_-]*):", property_line)) and key_match[
1292
+ "key"
1293
+ ] in _CUSTOM_HOOK_SCOPE_KEYS:
1294
+ found.add(key_match["key"])
1295
+ return frozenset(found)
1296
+
1297
+
1298
+ def _canonicalize_local_hook_block(
1299
+ text: str,
1300
+ runner_prefix: str,
1301
+ *,
1302
+ item_indent: int,
1303
+ insert_canonical: bool,
1304
+ ) -> str:
1305
+ lines = text.splitlines(keepends=True)
1306
+ owned = {"sarj-standards-check", "sarj-standards-drift"}
1307
+ spans: list[tuple[int, int]] = []
1308
+ for index, line in enumerate(lines):
1309
+ match = re.match(
1310
+ r"^(?P<indent>\s*)-\s+id:\s+['\"]?(?P<id>[^\s'\"#]+)['\"]?\s*(?:#.*)?$",
1311
+ line.rstrip("\r\n"),
1312
+ )
1313
+ if match is None or match["id"] not in owned:
1314
+ continue
1315
+ indent = len(match["indent"])
1316
+ end = hooks.yaml_list_item_end(lines, index, indent)
1317
+ spans.append((index, end))
1318
+ if not spans:
1319
+ return text
1320
+ first = spans[0][0]
1321
+ removed = {index for start, end in spans for index in range(start, end)}
1322
+ output: list[str] = []
1323
+ for index, line in enumerate(lines):
1324
+ if insert_canonical and index == first:
1325
+ output.append(_precommit_hook(runner_prefix, hook_indent=item_indent + 4))
1326
+ if index not in removed:
1327
+ output.append(line)
1328
+ return "".join(output)
1329
+
1330
+
1331
+ def precommit_block() -> str:
1332
+ return _precommit_check_block(launcher.repository_command())
1333
+
1334
+
1335
+ def _precommit_check_block(runner_prefix: str, *, item_indent: int = 2) -> str:
1336
+ repo_indent = " " * item_indent
1337
+ return (
1338
+ f"{repo_indent}- repo: local\n"
1339
+ f"{repo_indent} hooks:\n"
1340
+ f"{_precommit_hook(runner_prefix, hook_indent=item_indent + 4)}"
1341
+ )
1342
+
1343
+
1344
+ def _precommit_hook(runner_prefix: str, *, hook_indent: int = 6) -> str:
1345
+ item = " " * hook_indent
1346
+ field = " " * (hook_indent + 2)
1347
+ return (
1348
+ f"{item}- id: sarj-standards-check\n"
1349
+ f"{field}name: sarj standards -- staged checks\n"
1350
+ f"{field}entry: {runner_prefix} check --staged --trust-repository-code --\n"
1351
+ f"{field}language: system\n"
1352
+ f"{field}always_run: true\n"
1353
+ f"{field}pass_filenames: true\n"
1354
+ f"{field}require_serial: true\n"
1355
+ f"{field}files: '{hooks.PRECOMMIT_FILES_PATTERN}'\n"
1356
+ f"{field}stages: [pre-commit]\n"
1357
+ )
1358
+
1359
+
1360
+ def _record(plan: Plan, path: Path, contents: str, *, force: bool, reason: str) -> None:
1361
+ if path.exists() and not force:
1362
+ plan.skips.append((path, reason))
1363
+ return
1364
+ plan.writes.append((path, contents))
1365
+
1366
+
1367
+ def apply(plan: Plan, *, preconditions: Mapping[Path, bytes | None] | None = None) -> None:
1368
+ from . import transaction # ruff: ignore[import-outside-top-level] -- avoid a scaffold/transaction import cycle
1369
+
1370
+ if plan.root is None:
1371
+ msg = "scaffold plan has no repository root"
1372
+ raise OSError(msg)
1373
+ transaction.validate_targets(
1374
+ plan.root,
1375
+ tuple(path for path, _contents in (*plan.writes, *plan.edits)) + tuple(plan.deletes),
1376
+ )
1377
+ for path, contents in plan.writes:
1378
+ if preconditions is not None and path in preconditions:
1379
+ transaction.assert_expected(plan.root, path, preconditions[path])
1380
+ transaction.atomic_write_text(plan.root, path, contents)
1381
+ for path, addition in plan.edits:
1382
+ if preconditions is not None and path in preconditions:
1383
+ transaction.assert_expected(plan.root, path, preconditions[path])
1384
+ current = path.read_text(encoding="utf-8")
1385
+ transaction.atomic_write_text(plan.root, path, current + addition)
1386
+ for path in plan.deletes:
1387
+ if preconditions is not None and path in preconditions:
1388
+ transaction.assert_expected(plan.root, path, preconditions[path])
1389
+ transaction.remove_file(plan.root, path)
1390
+
1391
+
1392
+ def ci_snippet() -> str:
1393
+ lines = [
1394
+ " - name: sarj standards",
1395
+ f" run: {launcher.repository_command()} check --trust-repository-code",
1396
+ ]
1397
+ return "\n".join(lines) + "\n"
1398
+
1399
+
1400
+ def github_ci_workflow(root: Path) -> str:
1401
+ root = root.resolve()
1402
+ adopted = manifest.load_for_setup(root)
1403
+ python_dest = "." if adopted is None else adopted.python_dest
1404
+ python_override = (
1405
+ None
1406
+ if adopted is None or not any(name in adopted.configs for name in manifest.PYTHON_CONFIGS)
1407
+ else adopted.python_dest
1408
+ )
1409
+ typescript_override = (
1410
+ None
1411
+ if adopted is None or not any(name in adopted.configs for name in manifest.TYPESCRIPT_CONFIGS)
1412
+ else adopted.typescript_dest
1413
+ )
1414
+ ecosystems = detect(root, python_dest=python_override, typescript_dest=typescript_override)
1415
+ install_root = ecosystems.typescript_install_root or ecosystems.typescript_root
1416
+ runner = launcher.repository_command()
1417
+ lines = [
1418
+ "# Managed by code-standards; regenerate with `code-standards show ci --output .github/workflows/standards.yml`.",
1419
+ "name: Standards",
1420
+ "",
1421
+ "on:",
1422
+ " pull_request:",
1423
+ " push:",
1424
+ " branches: [main]",
1425
+ "",
1426
+ "permissions:",
1427
+ " contents: read",
1428
+ "",
1429
+ "concurrency:",
1430
+ " group: standards-${{ github.workflow }}-${{ github.ref }}",
1431
+ " cancel-in-progress: true",
1432
+ "",
1433
+ "jobs:",
1434
+ " standards:",
1435
+ " runs-on: ubuntu-latest",
1436
+ " timeout-minutes: 15",
1437
+ " steps:",
1438
+ " - name: Harden the runner",
1439
+ " uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1",
1440
+ " with:",
1441
+ " egress-policy: audit",
1442
+ " - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7",
1443
+ " with:",
1444
+ " fetch-depth: 0",
1445
+ " persist-credentials: false",
1446
+ " - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0",
1447
+ " with:",
1448
+ _setup_uv_version(root, ecosystems.python_root),
1449
+ " enable-cache: true",
1450
+ " cache-dependency-glob: |",
1451
+ " .sarj-standards.toml",
1452
+ " **/uv.lock",
1453
+ ]
1454
+ if ecosystems.typescript:
1455
+ if ecosystems.client is PackageManager.BUN:
1456
+ lines.append(" - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2")
1457
+ else:
1458
+ lines.extend(
1459
+ (
1460
+ " - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7",
1461
+ " with:",
1462
+ " node-version: 24",
1463
+ )
1464
+ )
1465
+ if (
1466
+ ecosystems.client is PackageManager.NPM
1467
+ and install_root is not None
1468
+ and (npm_version := packagemanager.declared_version(install_root, PackageManager.NPM)) is not None
1469
+ ):
1470
+ lines.extend(
1471
+ (
1472
+ " - name: Activate declared npm version",
1473
+ f" run: npm install --global npm@{npm_version} --ignore-scripts",
1474
+ )
1475
+ )
1476
+ javascript_command = _ci_javascript_install(ecosystems.client, ecosystems.yarn)
1477
+ if ecosystems.client in {PackageManager.PNPM, PackageManager.YARN}:
1478
+ javascript_command = f"corepack enable && {javascript_command}"
1479
+ lines.extend((" - name: Install JavaScript dependencies", f" run: {javascript_command}"))
1480
+ if install_root is not None and install_root != root:
1481
+ relative_install_root = install_root.relative_to(root).as_posix()
1482
+ lines.append(f" working-directory: {json.dumps(relative_install_root)}")
1483
+ if ecosystems.python:
1484
+ python_install = python_ci_install_argv(root, python_dest)
1485
+ if python_install:
1486
+ lines.extend((" - name: Install Python dependencies", f" run: {shlex.join(python_install)}"))
1487
+ for index, command in enumerate(() if adopted is None else adopted.ci_bootstrap, start=1):
1488
+ label = "Bootstrap analysis inputs" if index == 1 else f"Bootstrap analysis inputs ({index})"
1489
+ lines.extend((f" - name: {label}", f" run: {json.dumps(command)}"))
1490
+ lines.extend(
1491
+ (
1492
+ " - name: Run standards",
1493
+ " env:",
1494
+ " SARJ_REACT_DOCTOR_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}",
1495
+ f" run: {runner} check --trust-repository-code --format github",
1496
+ )
1497
+ )
1498
+ return "\n".join(lines) + "\n"
1499
+
1500
+
1501
+ def _setup_uv_version(root: Path, python_root: Path | None) -> str:
1502
+ source = uvtool.version_file(python_root)
1503
+ if source is None:
1504
+ return " version: '0.12.5'"
1505
+ return f" version-file: {json.dumps(source.relative_to(root).as_posix())}"
1506
+
1507
+
1508
+ def python_ci_install_argv(root: Path, python_dest: str) -> tuple[str, ...]:
1509
+ python_root = root / python_dest
1510
+ if not (python_root / "uv.lock").is_file():
1511
+ return ()
1512
+ project = () if python_dest == "." else ("--project", python_dest)
1513
+ workspace = ("--all-packages",) if _is_uv_workspace(python_root) else ()
1514
+ return ("uv", "sync", "--locked", *project, *workspace)
1515
+
1516
+
1517
+ def _is_uv_workspace(project: Path) -> bool:
1518
+ pyproject = project / "pyproject.toml"
1519
+ try:
1520
+ parsed: object = tomllib.loads(pyproject.read_text(encoding="utf-8"))
1521
+ except OSError, tomllib.TOMLDecodeError:
1522
+ return False
1523
+ tool = manifest.table_field(manifest.as_table(parsed), "tool")
1524
+ uv = manifest.table_field(tool, "uv")
1525
+ return bool(manifest.table_field(uv, "workspace"))
1526
+
1527
+
1528
+ def standards_check_workflows(root: Path) -> tuple[Path, ...]:
1529
+ directory = root / ".github" / "workflows"
1530
+ if not directory.is_dir():
1531
+ return ()
1532
+ source_checkout = (root / "packages" / "standards" / "src" / "sarj_standards").resolve() == Path(__file__).parents[
1533
+ 2
1534
+ ]
1535
+ return tuple(
1536
+ path
1537
+ for path in sorted((*directory.glob("*.yml"), *directory.glob("*.yaml")))
1538
+ if _workflow_runs_standards_check(path, source_checkout=source_checkout)
1539
+ )
1540
+
1541
+
1542
+ def _workflow_runs_standards_check(path: Path, *, source_checkout: bool) -> bool:
1543
+ try:
1544
+ parsed = cast("object", yaml.safe_load(path.read_text(encoding="utf-8")))
1545
+ except OSError, yaml.YAMLError:
1546
+ return False
1547
+ return any(
1548
+ _run_value_executes_standards_check(command, source_checkout=source_checkout)
1549
+ for command in _workflow_run_commands(parsed)
1550
+ )
1551
+
1552
+
1553
+ def _run_value_executes_standards_check(command: str, *, source_checkout: bool) -> bool:
1554
+ logical = command.replace("\\\n", " ")
1555
+ for line in logical.splitlines():
1556
+ lexer = shlex.shlex(line, posix=True, punctuation_chars=";&|")
1557
+ lexer.whitespace_split = True
1558
+ lexer.commenters = "#"
1559
+ try:
1560
+ tokens = tuple(lexer)
1561
+ except ValueError:
1562
+ continue
1563
+ if _tokens_execute_standards_check(tokens, source_checkout=source_checkout):
1564
+ return True
1565
+ return False
1566
+
1567
+
1568
+ def _tokens_execute_standards_check(tokens: tuple[str, ...], *, source_checkout: bool) -> bool:
1569
+ if not tokens or any(token in {";", "&&", "||", "|", "&"} for token in tokens):
1570
+ return False
1571
+ prefix = launcher.repository_argv()
1572
+ if tokens[: len(prefix)] == prefix:
1573
+ arguments = tokens[len(prefix) :]
1574
+ return bool(arguments) and arguments[0] == "check"
1575
+ if not source_checkout:
1576
+ return False
1577
+ try:
1578
+ executable_index = next(
1579
+ index for index, token in enumerate(tokens) if Path(token).name in {"code-standards", "sarj-standards"}
1580
+ )
1581
+ except StopIteration:
1582
+ return False
1583
+ command_prefix = tokens[:executable_index]
1584
+ if command_prefix and not (
1585
+ Path(command_prefix[0]).name == "uv"
1586
+ and command_prefix[1:2] == ("run",)
1587
+ and _launcher_options_are_valid(
1588
+ command_prefix[2:],
1589
+ flags=frozenset({"--frozen", "--isolated", "--no-config", "--no-project", "--no-sync"}),
1590
+ valued=frozenset({"--directory", "--project", "--python", "--with"}),
1591
+ )
1592
+ ):
1593
+ return False
1594
+ arguments = tokens[executable_index + 1 :]
1595
+ while arguments and (arguments[0] == "--root" or arguments[0].startswith("--root=")):
1596
+ arguments = arguments[2:] if arguments[0] == "--root" else arguments[1:]
1597
+ return bool(arguments) and arguments[0] == "check"
1598
+
1599
+
1600
+ def _launcher_options_are_valid(
1601
+ tokens: tuple[str, ...],
1602
+ *,
1603
+ flags: frozenset[str],
1604
+ valued: frozenset[str],
1605
+ ) -> bool:
1606
+ index = 0
1607
+ while index < len(tokens):
1608
+ token = tokens[index]
1609
+ if token in flags:
1610
+ index += 1
1611
+ continue
1612
+ if token in valued and index + 1 < len(tokens):
1613
+ index += 2
1614
+ continue
1615
+ if any(token.startswith(f"{option}=") for option in valued):
1616
+ index += 1
1617
+ continue
1618
+ return False
1619
+ return True
1620
+
1621
+
1622
+ def _migrate_legacy_workflow_gate(path: Path) -> str | None:
1623
+ try:
1624
+ text = path.read_text(encoding="utf-8")
1625
+ except OSError:
1626
+ return None
1627
+ migrated, count = _LEGACY_WORKFLOW_VERIFY.subn(r"\g<command> check --trust-repository-code", text)
1628
+ return migrated if count else None
1629
+
1630
+
1631
+ def _workflow_run_commands(value: object) -> tuple[str, ...]:
1632
+ match value:
1633
+ case dict():
1634
+ commands: list[str] = []
1635
+ table = cast("dict[object, object]", value)
1636
+ for key, item in table.items():
1637
+ if key == "run" and isinstance(item, str):
1638
+ commands.append(item)
1639
+ else:
1640
+ commands.extend(_workflow_run_commands(item))
1641
+ return tuple(commands)
1642
+ case list():
1643
+ items = cast("list[object]", value)
1644
+ return tuple(command for item in items for command in _workflow_run_commands(item))
1645
+ case _:
1646
+ return ()
1647
+
1648
+
1649
+ def _ci_javascript_install(client: PackageManager, yarn: YarnVariant) -> str:
1650
+ if client is PackageManager.YARN:
1651
+ return (
1652
+ "yarn install --immutable --mode=skip-build"
1653
+ if yarn is YarnVariant.BERRY
1654
+ else "yarn install --frozen-lockfile --ignore-scripts"
1655
+ )
1656
+ return {
1657
+ PackageManager.NPM: "npm ci --no-audit --no-fund --ignore-scripts",
1658
+ PackageManager.PNPM: "pnpm install --frozen-lockfile --ignore-scripts",
1659
+ PackageManager.BUN: "bun install --frozen-lockfile --ignore-scripts",
1660
+ }[client]