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,493 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from importlib.metadata import version as distribution_version
|
|
7
|
+
from itertools import pairwise
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess # ruff: ignore[suspicious-subprocess-import] -- commands are fixed argv assembled from detected ecosystems.
|
|
14
|
+
import sys
|
|
15
|
+
from typing import (
|
|
16
|
+
TYPE_CHECKING,
|
|
17
|
+
NamedTuple,
|
|
18
|
+
cast, # ruff: ignore[banned-api] -- json.loads is typed Any; establish an object boundary before narrowing.
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from sarj_standards.libs.filesystem import is_link_like
|
|
22
|
+
from sarj_standards.libs.linting import runner
|
|
23
|
+
|
|
24
|
+
from . import manifest, packagemanager, scaffold, transaction
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Iterable
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_PROJECT_SKIP_DIRS = frozenset({".git", ".venv", "build", "dist", "node_modules", "target", "vendor"})
|
|
32
|
+
_SKILL_ARTIFACT_ROOTS = frozenset({".agents", ".claude"})
|
|
33
|
+
_ESLINT_SUFFIXES = frozenset({".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"})
|
|
34
|
+
_COMMAND_TIMEOUT = timedelta(minutes=10)
|
|
35
|
+
_GIT_DISCOVERY_TIMEOUT = timedelta(seconds=5)
|
|
36
|
+
_GIT_SAFE_ENV = frozenset(
|
|
37
|
+
{"HOME", "LANG", "LC_ALL", "LC_CTYPE", "PATH", "SYSTEMDRIVE", "SYSTEMROOT", "TMPDIR", "XDG_CONFIG_HOME"}
|
|
38
|
+
)
|
|
39
|
+
_PRECOMMIT_HOOK_LABEL = "pre-commit hooks"
|
|
40
|
+
_PRECOMMIT_UVX_MARKER = "# sarj-standards: cache-independent pinned pre-commit fallback"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class Command:
|
|
45
|
+
label: str
|
|
46
|
+
argv: Sequence[str]
|
|
47
|
+
cwd: Path
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class Inspection:
|
|
52
|
+
adopted_version: str | None
|
|
53
|
+
profile: manifest.Profile | None
|
|
54
|
+
installed_version: str
|
|
55
|
+
configs: tuple[str, ...]
|
|
56
|
+
python_root: str | None
|
|
57
|
+
typescript_root: str | None
|
|
58
|
+
package_manager: str | None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class EslintSelection(NamedTuple):
|
|
62
|
+
commands: tuple[Command, ...]
|
|
63
|
+
unowned_count: int
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def install_commands(
|
|
67
|
+
root: Path,
|
|
68
|
+
ecosystems: scaffold.Ecosystems,
|
|
69
|
+
*,
|
|
70
|
+
hook_manager: manifest.HookManager = "pre-commit",
|
|
71
|
+
) -> list[Command]:
|
|
72
|
+
commands: list[Command] = []
|
|
73
|
+
if ecosystems.typescript_root is not None:
|
|
74
|
+
install_root = ecosystems.typescript_install_root or ecosystems.typescript_root
|
|
75
|
+
# Setup writes pnpm-workspace.yaml before this command executes because
|
|
76
|
+
# pnpm 11 requires overrides there even for a standalone package.
|
|
77
|
+
is_workspace = (
|
|
78
|
+
ecosystems.client is packagemanager.PackageManager.PNPM
|
|
79
|
+
or install_root != ecosystems.typescript_root
|
|
80
|
+
or (install_root / "pnpm-workspace.yaml").is_file()
|
|
81
|
+
)
|
|
82
|
+
commands.append(
|
|
83
|
+
Command(
|
|
84
|
+
"ESLint peers",
|
|
85
|
+
packagemanager.install_argv(ecosystems.client, workspace=is_workspace, yarn=ecosystems.yarn),
|
|
86
|
+
install_root,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
if ecosystems.python_root is not None and _has_legacy_in_project_bundle(ecosystems.python_root):
|
|
90
|
+
commands.append(
|
|
91
|
+
Command(
|
|
92
|
+
"remove legacy in-project standards",
|
|
93
|
+
("uv", "remove", "--dev", "sarj-standards"),
|
|
94
|
+
ecosystems.python_root,
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
git_metadata = root / ".git"
|
|
98
|
+
if (git_metadata.is_dir() or git_metadata.is_file()) and hook_manager == "pre-commit":
|
|
99
|
+
hook_argv = (
|
|
100
|
+
"uvx",
|
|
101
|
+
"--from",
|
|
102
|
+
f"pre-commit=={distribution_version('pre-commit')}",
|
|
103
|
+
"pre-commit",
|
|
104
|
+
"install",
|
|
105
|
+
"--hook-type",
|
|
106
|
+
"pre-commit",
|
|
107
|
+
)
|
|
108
|
+
commands.append(Command(_PRECOMMIT_HOOK_LABEL, hook_argv, root))
|
|
109
|
+
return commands
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _has_legacy_in_project_bundle(python_root: Path) -> bool:
|
|
113
|
+
pyproject = python_root / "pyproject.toml"
|
|
114
|
+
try:
|
|
115
|
+
text = pyproject.read_text(encoding="utf-8")
|
|
116
|
+
except OSError:
|
|
117
|
+
return False
|
|
118
|
+
return re.search(r"(?i)\bsarj[-_]lint[-_]configs\s*(?:\[[^]]+\])?\s*(?:==|>=|<=|~=|!=|>|<|@)", text) is not None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def verification_commands(ecosystems: scaffold.Ecosystems) -> list[Command]:
|
|
122
|
+
commands: list[Command] = []
|
|
123
|
+
if ecosystems.python_root is not None:
|
|
124
|
+
for project in _python_verification_roots(ecosystems.python_root):
|
|
125
|
+
commands.extend(
|
|
126
|
+
(
|
|
127
|
+
Command("Ruff", (_environment_binary("ruff"), "check", "."), project),
|
|
128
|
+
Command("BasedPyright", (_environment_binary("basedpyright"),), project),
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
if ecosystems.typescript_root is not None:
|
|
132
|
+
commands.append(
|
|
133
|
+
Command(
|
|
134
|
+
"ESLint",
|
|
135
|
+
packagemanager.exec_argv(ecosystems.client, "eslint", "--no-cache", "."),
|
|
136
|
+
ecosystems.typescript_root,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
return commands
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def selected_eslint_commands(root: Path, paths: Iterable[str], *, label: str = "selected") -> list[Command]:
|
|
143
|
+
return list(select_eslint_commands(root, paths, label=label).commands)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def select_eslint_commands(
|
|
147
|
+
root: Path,
|
|
148
|
+
paths: Iterable[str],
|
|
149
|
+
*,
|
|
150
|
+
label: str = "selected",
|
|
151
|
+
fix: bool = False,
|
|
152
|
+
) -> EslintSelection:
|
|
153
|
+
repository = root.resolve()
|
|
154
|
+
fallback_project = _adopted_typescript_project(repository)
|
|
155
|
+
candidates = _selected_eslint_candidates(repository, paths, fallback_project=fallback_project)
|
|
156
|
+
if not candidates:
|
|
157
|
+
return EslintSelection((), 0)
|
|
158
|
+
grouped: dict[Path, set[str]] = {}
|
|
159
|
+
unowned: list[Path] = []
|
|
160
|
+
for candidate in candidates:
|
|
161
|
+
project = _owning_typescript_project(candidate, repository, fallback_project=fallback_project)
|
|
162
|
+
if project is None:
|
|
163
|
+
unowned.append(candidate)
|
|
164
|
+
continue
|
|
165
|
+
grouped.setdefault(project, set()).add(Path(os.path.relpath(candidate, project)).as_posix())
|
|
166
|
+
commands: list[Command] = []
|
|
167
|
+
for project, scoped in sorted(grouped.items(), key=lambda item: str(item[0])):
|
|
168
|
+
install_root = packagemanager.workspace_root(project, repository)
|
|
169
|
+
client = packagemanager.detect(install_root)
|
|
170
|
+
config = _eslint_config(project)
|
|
171
|
+
config_args = () if config is None else ("--config", config.name)
|
|
172
|
+
commands.append(
|
|
173
|
+
Command(
|
|
174
|
+
f"ESLint ({label}: {project.relative_to(repository).as_posix() or '.'})",
|
|
175
|
+
packagemanager.exec_argv(
|
|
176
|
+
client,
|
|
177
|
+
"eslint",
|
|
178
|
+
*config_args,
|
|
179
|
+
*(("--fix",) if fix else ()),
|
|
180
|
+
"--",
|
|
181
|
+
*sorted(scoped),
|
|
182
|
+
),
|
|
183
|
+
project,
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
return EslintSelection(tuple(commands), len(unowned))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _owning_typescript_project(
|
|
190
|
+
candidate: Path,
|
|
191
|
+
repository: Path,
|
|
192
|
+
*,
|
|
193
|
+
fallback_project: Path | None = None,
|
|
194
|
+
) -> Path | None:
|
|
195
|
+
start = candidate if candidate.is_dir() else candidate.parent
|
|
196
|
+
bounded = (start, *(parent for parent in start.parents if parent == repository or repository in parent.parents))
|
|
197
|
+
configured = next((path for path in bounded if _eslint_config(path) is not None), None)
|
|
198
|
+
if configured is not None:
|
|
199
|
+
return configured
|
|
200
|
+
lock_names = tuple(name for name, _client in packagemanager.LOCKFILES)
|
|
201
|
+
locked = next((path for path in bounded if any((path / name).is_file() for name in lock_names)), None)
|
|
202
|
+
if locked is not None:
|
|
203
|
+
return fallback_project if fallback_project is not None and locked == repository else locked
|
|
204
|
+
packaged = next((path for path in bounded if (path / "package.json").is_file()), None)
|
|
205
|
+
if fallback_project is not None and (packaged is None or packaged == repository):
|
|
206
|
+
return fallback_project
|
|
207
|
+
return packaged
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _eslint_config(project: Path) -> Path | None:
|
|
211
|
+
names = ("eslint.config.js", "eslint.config.cjs", "eslint.config.mjs", "eslint.config.ts")
|
|
212
|
+
return next((project / name for name in names if (project / name).is_file()), None)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _adopted_typescript_project(repository: Path) -> Path | None:
|
|
216
|
+
try:
|
|
217
|
+
adopted = manifest.load(repository)
|
|
218
|
+
except OSError, TypeError, ValueError:
|
|
219
|
+
return None
|
|
220
|
+
if adopted is None:
|
|
221
|
+
return None
|
|
222
|
+
project = (repository / adopted.typescript_dest).resolve()
|
|
223
|
+
return project if project.is_dir() and _eslint_config(project) is not None else None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def staged_eslint_commands(root: Path, paths: Iterable[str]) -> list[Command]:
|
|
227
|
+
return selected_eslint_commands(root, paths, label="staged")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _selected_eslint_candidates(
|
|
231
|
+
root: Path,
|
|
232
|
+
paths: Iterable[str],
|
|
233
|
+
*,
|
|
234
|
+
fallback_project: Path | None = None,
|
|
235
|
+
) -> set[Path]:
|
|
236
|
+
candidates: set[Path] = set()
|
|
237
|
+
for raw_path in paths:
|
|
238
|
+
path = Path(raw_path)
|
|
239
|
+
unresolved = path if path.is_absolute() else root / path
|
|
240
|
+
if is_link_like(unresolved):
|
|
241
|
+
continue
|
|
242
|
+
candidate = unresolved.resolve()
|
|
243
|
+
if not candidate.is_relative_to(root):
|
|
244
|
+
continue
|
|
245
|
+
if _is_skill_artifact(candidate, root):
|
|
246
|
+
continue
|
|
247
|
+
if candidate.is_dir() and candidate.name not in _PROJECT_SKIP_DIRS:
|
|
248
|
+
sources = _eslint_sources(candidate)
|
|
249
|
+
owners = {_owning_typescript_project(source, root, fallback_project=fallback_project) for source in sources}
|
|
250
|
+
candidate_owner = _owning_typescript_project(candidate, root, fallback_project=fallback_project)
|
|
251
|
+
if (
|
|
252
|
+
sources
|
|
253
|
+
and owners == {candidate_owner}
|
|
254
|
+
and candidate_owner is not None
|
|
255
|
+
and not _contains_skill_artifacts(candidate)
|
|
256
|
+
):
|
|
257
|
+
candidates.add(candidate)
|
|
258
|
+
else:
|
|
259
|
+
candidates.update(sources)
|
|
260
|
+
elif candidate.suffix.lower() in _ESLINT_SUFFIXES and candidate.is_file():
|
|
261
|
+
candidates.add(candidate)
|
|
262
|
+
return candidates
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _contains_eslint_source(directory: Path) -> bool:
|
|
266
|
+
return next(iter(_eslint_sources(directory)), None) is not None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _eslint_sources(directory: Path) -> set[Path]:
|
|
270
|
+
sources: set[Path] = set()
|
|
271
|
+
for parent, directories, names in os.walk(directory):
|
|
272
|
+
base = Path(parent)
|
|
273
|
+
directories[:] = [
|
|
274
|
+
name
|
|
275
|
+
for name in directories
|
|
276
|
+
if name not in _PROJECT_SKIP_DIRS
|
|
277
|
+
and not (base.name in _SKILL_ARTIFACT_ROOTS and name == "skills")
|
|
278
|
+
and not is_link_like(base / name)
|
|
279
|
+
]
|
|
280
|
+
sources.update(
|
|
281
|
+
base / name
|
|
282
|
+
for name in names
|
|
283
|
+
if Path(name).suffix.lower() in _ESLINT_SUFFIXES and not is_link_like(base / name)
|
|
284
|
+
)
|
|
285
|
+
return sources
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _is_skill_artifact(path: Path, repository: Path) -> bool:
|
|
289
|
+
parts = path.relative_to(repository).parts
|
|
290
|
+
return any(root in _SKILL_ARTIFACT_ROOTS and child == "skills" for root, child in pairwise(parts))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _contains_skill_artifacts(directory: Path) -> bool:
|
|
294
|
+
for parent, directories, _names in os.walk(directory):
|
|
295
|
+
base = Path(parent)
|
|
296
|
+
if base.name in _SKILL_ARTIFACT_ROOTS and "skills" in directories:
|
|
297
|
+
return True
|
|
298
|
+
directories[:] = [
|
|
299
|
+
name for name in directories if name not in _PROJECT_SKIP_DIRS and not is_link_like(base / name)
|
|
300
|
+
]
|
|
301
|
+
return False
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def format_commands(ecosystems: scaffold.Ecosystems) -> list[Command]:
|
|
305
|
+
commands: list[Command] = []
|
|
306
|
+
if ecosystems.python_root is not None:
|
|
307
|
+
for project in _python_verification_roots(ecosystems.python_root):
|
|
308
|
+
commands.extend(
|
|
309
|
+
(
|
|
310
|
+
Command(
|
|
311
|
+
"Ruff format",
|
|
312
|
+
(_environment_binary("ruff"), "format", "."),
|
|
313
|
+
project,
|
|
314
|
+
),
|
|
315
|
+
Command(
|
|
316
|
+
"Ruff fixes",
|
|
317
|
+
(_environment_binary("ruff"), "check", "--fix", "."),
|
|
318
|
+
project,
|
|
319
|
+
),
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
if ecosystems.typescript_root is not None:
|
|
323
|
+
commands.append(
|
|
324
|
+
Command(
|
|
325
|
+
"ESLint fixes",
|
|
326
|
+
packagemanager.exec_argv(ecosystems.client, "eslint", "--fix", "."),
|
|
327
|
+
ecosystems.typescript_root,
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
return commands
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def selected_format_commands(root: Path, paths: Iterable[str]) -> list[Command]:
|
|
334
|
+
repository = root.resolve()
|
|
335
|
+
selected = tuple(sorted(set(paths)))
|
|
336
|
+
python_paths = tuple(
|
|
337
|
+
source_path.resolve().relative_to(repository).as_posix()
|
|
338
|
+
for path in selected
|
|
339
|
+
if (source_path := Path(path)).is_file() and source_path.suffix.lower() in {".py", ".pyi"}
|
|
340
|
+
)
|
|
341
|
+
commands: list[Command] = []
|
|
342
|
+
if python_paths:
|
|
343
|
+
commands.extend(
|
|
344
|
+
(
|
|
345
|
+
Command("Ruff format", (_environment_binary("ruff"), "format", *python_paths), repository),
|
|
346
|
+
Command("Ruff fixes", (_environment_binary("ruff"), "check", "--fix", *python_paths), repository),
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
commands.extend(select_eslint_commands(repository, selected, label="selected", fix=True).commands)
|
|
350
|
+
return commands
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def execute(commands: Iterable[Command]) -> int:
|
|
354
|
+
for command in commands:
|
|
355
|
+
executable = shutil.which(command.argv[0])
|
|
356
|
+
if executable is None:
|
|
357
|
+
sys.stderr.write(f"error: {command.argv[0]} is required for {command.label}\n")
|
|
358
|
+
return 2
|
|
359
|
+
try:
|
|
360
|
+
completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true]
|
|
361
|
+
[executable, *command.argv[1:]],
|
|
362
|
+
cwd=command.cwd,
|
|
363
|
+
check=False,
|
|
364
|
+
env=_command_environment(command),
|
|
365
|
+
timeout=_COMMAND_TIMEOUT.total_seconds(),
|
|
366
|
+
)
|
|
367
|
+
except subprocess.TimeoutExpired:
|
|
368
|
+
sys.stderr.write(f"error: {command.label} exceeded {_COMMAND_TIMEOUT.total_seconds():g}s and was stopped\n")
|
|
369
|
+
return 2
|
|
370
|
+
if completed.returncode:
|
|
371
|
+
return completed.returncode
|
|
372
|
+
if command.label == _PRECOMMIT_HOOK_LABEL:
|
|
373
|
+
try:
|
|
374
|
+
harden_precommit_hook(command.cwd)
|
|
375
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
376
|
+
sys.stderr.write(f"error: installed pre-commit hook is not durable: {exc}\n")
|
|
377
|
+
return 2
|
|
378
|
+
return 0
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def harden_precommit_hook(root: Path) -> None:
|
|
382
|
+
hook = _precommit_hook_path(root)
|
|
383
|
+
text = hook.read_text(encoding="utf-8")
|
|
384
|
+
if _PRECOMMIT_UVX_MARKER in text:
|
|
385
|
+
return
|
|
386
|
+
fallback_point = "elif command -v pre-commit > /dev/null; then"
|
|
387
|
+
if fallback_point not in text:
|
|
388
|
+
msg = f"{hook} does not contain pre-commit's expected launcher fallback"
|
|
389
|
+
raise OSError(msg)
|
|
390
|
+
version = distribution_version("pre-commit")
|
|
391
|
+
fallback = (
|
|
392
|
+
f"{_PRECOMMIT_UVX_MARKER}\n"
|
|
393
|
+
"elif command -v uvx > /dev/null; then\n"
|
|
394
|
+
f' exec uvx --isolated --python 3.14 --from pre-commit=={version} pre-commit "${{ARGS[@]}}"\n'
|
|
395
|
+
)
|
|
396
|
+
transaction.atomic_write_text(hook.parent, hook, text.replace(fallback_point, fallback + fallback_point, 1))
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _precommit_hook_path(root: Path) -> Path:
|
|
400
|
+
git = shutil.which("git")
|
|
401
|
+
if git is None:
|
|
402
|
+
msg = "git is required to resolve the installed pre-commit hook"
|
|
403
|
+
raise OSError(msg)
|
|
404
|
+
completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] -- fixed Git query.
|
|
405
|
+
(git, "rev-parse", "--git-path", "hooks/pre-commit"),
|
|
406
|
+
cwd=root,
|
|
407
|
+
check=True,
|
|
408
|
+
capture_output=True,
|
|
409
|
+
env=_git_environment(),
|
|
410
|
+
text=True,
|
|
411
|
+
timeout=_GIT_DISCOVERY_TIMEOUT.total_seconds(),
|
|
412
|
+
)
|
|
413
|
+
resolved = Path(completed.stdout.strip())
|
|
414
|
+
return resolved if resolved.is_absolute() else root / resolved
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _git_environment() -> dict[str, str]:
|
|
418
|
+
return {
|
|
419
|
+
name: value
|
|
420
|
+
for name, value in os.environ.items() # ruff: ignore[banned-api] -- hook-local Git variables must not redirect discovery.
|
|
421
|
+
if name in _GIT_SAFE_ENV
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _command_environment(command: Command) -> dict[str, str] | None:
|
|
426
|
+
_ = command
|
|
427
|
+
environment = os.environ.copy() # ruff: ignore[banned-api] -- tools must not mistake the isolated runner for the consumer environment.
|
|
428
|
+
environment.pop("VIRTUAL_ENV", None)
|
|
429
|
+
return environment
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def verify_custom_rules(root: Path, *, paths: Iterable[str] = (".",)) -> int:
|
|
433
|
+
return runner.run([str(root / path) for path in paths])
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def inspect(root: Path) -> Inspection:
|
|
437
|
+
adopted = manifest.load(root)
|
|
438
|
+
ecosystems = scaffold.detect(root)
|
|
439
|
+
return Inspection(
|
|
440
|
+
adopted_version=adopted.version if adopted else None,
|
|
441
|
+
profile=adopted.profile if adopted else None,
|
|
442
|
+
installed_version=manifest.adopted_version(),
|
|
443
|
+
configs=adopted.configs if adopted else (),
|
|
444
|
+
python_root=_relative(root, ecosystems.python_root),
|
|
445
|
+
typescript_root=_relative(root, ecosystems.typescript_root),
|
|
446
|
+
package_manager=str(ecosystems.client) if ecosystems.typescript else None,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def inspection_json(root: Path) -> str:
|
|
451
|
+
return json.dumps(asdict(inspect(root)), indent=2) + "\n"
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _environment_binary(name: str) -> str:
|
|
455
|
+
found = shutil.which(name, path=str(Path(sys.executable).parent))
|
|
456
|
+
if found is None:
|
|
457
|
+
msg = f"{name} is missing from the code-standards environment"
|
|
458
|
+
raise OSError(msg)
|
|
459
|
+
return found
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _python_verification_roots(root: Path) -> tuple[Path, ...]:
|
|
463
|
+
nested = sorted(
|
|
464
|
+
{
|
|
465
|
+
path.parent
|
|
466
|
+
for path in root.glob("**/pyrightconfig.json")
|
|
467
|
+
if not any(part in _PROJECT_SKIP_DIRS for part in path.relative_to(root).parts)
|
|
468
|
+
and (path.parent / "pyproject.toml").is_file()
|
|
469
|
+
},
|
|
470
|
+
key=lambda path: path.as_posix(),
|
|
471
|
+
)
|
|
472
|
+
if not nested:
|
|
473
|
+
return (root,)
|
|
474
|
+
root_config = root / "pyrightconfig.json"
|
|
475
|
+
if root in nested and len(nested) > 1 and not _pyright_has_explicit_scope(root_config):
|
|
476
|
+
nested.remove(root)
|
|
477
|
+
return tuple(nested)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _pyright_has_explicit_scope(path: Path) -> bool:
|
|
481
|
+
if not path.is_file():
|
|
482
|
+
return False
|
|
483
|
+
try:
|
|
484
|
+
parsed = cast("object", json.loads(path.read_text(encoding="utf-8")))
|
|
485
|
+
except OSError, json.JSONDecodeError:
|
|
486
|
+
return False
|
|
487
|
+
return isinstance(parsed, dict) and any(key in parsed for key in ("include", "files"))
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _relative(root: Path, path: Path | None) -> str | None:
|
|
491
|
+
if path is None:
|
|
492
|
+
return None
|
|
493
|
+
return path.relative_to(root).as_posix() or "."
|