code-standards 7.0.0__py3-none-any.whl

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