code-standards 7.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_standards-7.0.0.dist-info/METADATA +53 -0
- code_standards-7.0.0.dist-info/RECORD +99 -0
- code_standards-7.0.0.dist-info/WHEEL +4 -0
- code_standards-7.0.0.dist-info/entry_points.txt +3 -0
- code_standards-7.0.0.dist-info/licenses/LICENSE +21 -0
- sarj_standards/__init__.py +30 -0
- sarj_standards/__main__.py +5 -0
- sarj_standards/_meta.py +22 -0
- sarj_standards/api.py +890 -0
- sarj_standards/cli/__init__.py +0 -0
- sarj_standards/cli/main.py +2466 -0
- sarj_standards/configs/cli-reference.v1.json +1 -0
- sarj_standards/configs/doctor.config.json +22 -0
- sarj_standards/configs/eslint.application.mjs +1366 -0
- sarj_standards/configs/eslint.peers.json +44 -0
- sarj_standards/configs/eslint.strict.mjs +1060 -0
- sarj_standards/configs/markdownlint.strict.yaml +12 -0
- sarj_standards/configs/pyright.strict.json +96 -0
- sarj_standards/configs/ruff.application.toml +363 -0
- sarj_standards/configs/ruff.strict.toml +338 -0
- sarj_standards/configs/rule-inventory.v1.json +1 -0
- sarj_standards/configs/rule-ledger.json +846 -0
- sarj_standards/configs/rule-warning-levels.v1.json +1 -0
- sarj_standards/configs/taplo.strict.toml +14 -0
- sarj_standards/configs/yamllint.strict.yaml +25 -0
- sarj_standards/libs/__init__.py +0 -0
- sarj_standards/libs/adoption/__init__.py +0 -0
- sarj_standards/libs/adoption/configs.py +36 -0
- sarj_standards/libs/adoption/doctor.py +1346 -0
- sarj_standards/libs/adoption/exclusions.py +66 -0
- sarj_standards/libs/adoption/hooks.py +423 -0
- sarj_standards/libs/adoption/launcher.py +240 -0
- sarj_standards/libs/adoption/lifecycle.py +493 -0
- sarj_standards/libs/adoption/manifest.py +550 -0
- sarj_standards/libs/adoption/packagemanager.py +285 -0
- sarj_standards/libs/adoption/retired_suppressions.py +371 -0
- sarj_standards/libs/adoption/scaffold.py +1660 -0
- sarj_standards/libs/adoption/service.py +441 -0
- sarj_standards/libs/adoption/transaction.py +274 -0
- sarj_standards/libs/adoption/upgrade.py +516 -0
- sarj_standards/libs/adoption/uvtool.py +62 -0
- sarj_standards/libs/catalogs/__init__.py +9 -0
- sarj_standards/libs/catalogs/slack_automations.py +627 -0
- sarj_standards/libs/corpus/__init__.py +25 -0
- sarj_standards/libs/corpus/manifest.py +211 -0
- sarj_standards/libs/corpus/snapshot.py +222 -0
- sarj_standards/libs/diagnostics/__init__.py +65 -0
- sarj_standards/libs/diagnostics/analysis.schema.json +161 -0
- sarj_standards/libs/diagnostics/baseline.py +131 -0
- sarj_standards/libs/diagnostics/models.py +574 -0
- sarj_standards/libs/diagnostics/serialize.py +290 -0
- sarj_standards/libs/diagnostics/source.py +172 -0
- sarj_standards/libs/filesystem.py +11 -0
- sarj_standards/libs/linting/__init__.py +0 -0
- sarj_standards/libs/linting/analysis.py +422 -0
- sarj_standards/libs/linting/external.py +1454 -0
- sarj_standards/libs/linting/library_policy.py +688 -0
- sarj_standards/libs/linting/policy.py +152 -0
- sarj_standards/libs/linting/runner.py +442 -0
- sarj_standards/libs/linting/textlint.py +1605 -0
- sarj_standards/libs/release/__init__.py +98 -0
- sarj_standards/libs/release/_values.py +24 -0
- sarj_standards/libs/release/artifacts.py +191 -0
- sarj_standards/libs/release/causality.py +80 -0
- sarj_standards/libs/release/changes.py +48 -0
- sarj_standards/libs/release/process.py +128 -0
- sarj_standards/libs/release/publish.py +85 -0
- sarj_standards/libs/release/registry.py +271 -0
- sarj_standards/libs/release/release_age.py +218 -0
- sarj_standards/libs/release/rollout.py +1163 -0
- sarj_standards/libs/release/tags.py +373 -0
- sarj_standards/libs/release/typescript.py +191 -0
- sarj_standards/libs/repository/__init__.py +0 -0
- sarj_standards/libs/repository/cli_reference_artifact.py +324 -0
- sarj_standards/libs/repository/comment_corpus.py +536 -0
- sarj_standards/libs/repository/config_generation.py +146 -0
- sarj_standards/libs/repository/docs.py +347 -0
- sarj_standards/libs/repository/hooks.py +118 -0
- sarj_standards/libs/repository/ledger.py +99 -0
- sarj_standards/libs/repository/repository.py +744 -0
- sarj_standards/libs/repository/rule_authoring.py +246 -0
- sarj_standards/libs/repository/rule_catalog_artifact.py +479 -0
- sarj_standards/libs/repository/rule_changes.py +318 -0
- sarj_standards/libs/repository/rule_inventory_artifact.py +142 -0
- sarj_standards/libs/repository/rule_lifecycle.py +167 -0
- sarj_standards/libs/repository/rule_maintenance.py +225 -0
- sarj_standards/libs/rules/__init__.py +74 -0
- sarj_standards/libs/rules/catalog.py +145 -0
- sarj_standards/libs/rules/contracts.py +382 -0
- sarj_standards/libs/rules/corpus_runner.py +365 -0
- sarj_standards/libs/rules/evaluation.py +177 -0
- sarj_standards/libs/setup/__init__.py +4 -0
- sarj_standards/libs/setup/repository.py +40 -0
- sarj_standards/py.typed +0 -0
- sarj_standards/schemas/__init__.py +4 -0
- sarj_standards/schemas/_paths.py +7 -0
- sarj_standards/schemas/rule-catalog.v1.json +1 -0
- sarj_standards/schemas/rule-catalog.v1.schema.json +112 -0
- sarj_standards/schemas/slack-automations.v1.schema.json +1751 -0
|
@@ -0,0 +1,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]
|