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,1346 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import timedelta
5
+ from enum import StrEnum
6
+ from fnmatch import fnmatch
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] -- git enumerates authored files without executing repository code.
14
+ import tomllib
15
+ from types import MappingProxyType
16
+ from typing import TYPE_CHECKING, Final, NamedTuple
17
+
18
+ from sarj_standards._meta import CONFIGS_DIR
19
+ from sarj_standards.libs.filesystem import is_link_like
20
+ from sarj_standards.libs.repository import ledger
21
+
22
+ from . import hooks, launcher, manifest, packagemanager, retired_suppressions, scaffold
23
+
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Iterator, Mapping, Sequence
27
+ from typing import TypeGuard
28
+
29
+
30
+ class Level(StrEnum):
31
+ OK = "ok"
32
+ WARN = "warn"
33
+ DRIFT = "drift"
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Finding:
38
+ level: Level
39
+ where: str
40
+ detail: str
41
+ id: str = "doctor.unknown"
42
+ remediation: str | None = None
43
+
44
+ def as_dict(self) -> dict[str, str | None]:
45
+ return {
46
+ "id": self.id,
47
+ "level": self.level.value,
48
+ "where": self.where,
49
+ "detail": self.detail,
50
+ "remediation": self.remediation,
51
+ }
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class VersionPinUpdate:
56
+ path: Path
57
+ contents: str
58
+ packages: tuple[str, ...]
59
+
60
+
61
+ class VersionPinRewrite(NamedTuple):
62
+ contents: str
63
+ packages: tuple[str, ...]
64
+
65
+
66
+ class _PackageEslintPinRewrite(NamedTuple):
67
+ contents: str
68
+ changed: bool
69
+
70
+
71
+ #: `sarj-python-lint==0.25.0`, `"code-standards>=0.9"`, `--from sarj-sql-lint==1.2.3`.
72
+ _PIN = re.compile(
73
+ r"(?P<name>sarj-(?:python|sql|iac)-lint|(?:code|sarj)-standards)\s*(?P<op>==|>=|~=)\s*"
74
+ r"(?P<version>[0-9][0-9A-Za-z._+\-]*)"
75
+ )
76
+ _PREAPPROVED_ESLINT = re.compile(
77
+ r"(?m)^(?P<prefix>[ \t]*(?:npmPreapprovedPackages|minimumReleaseAgeExclude):[^\n]*\n"
78
+ r'(?:[ \t]+-[^\n]*\n)*?[ \t]+-\s*["\']?@sarj/eslint-plugin@)'
79
+ r'(?P<version>[0-9][0-9A-Za-z._+\-]*)(?P<suffix>["\']?\s*(?:#.*)?)$'
80
+ )
81
+ _PACKAGE_DEPENDENCY_SECTION = re.compile(
82
+ r'(?P<prefix>"(?:dependencies|devDependencies)"\s*:\s*\{)(?P<body>[^{}]*)(?P<suffix>\})',
83
+ re.DOTALL,
84
+ )
85
+ _PACKAGE_ESLINT_PIN = re.compile(
86
+ r'(?P<prefix>"@sarj/eslint-plugin"\s*:\s*")'
87
+ r"(?P<version>[0-9][0-9A-Za-z._+\-]*)"
88
+ r'(?P<suffix>")'
89
+ )
90
+
91
+ #: Standards must not inherit a consumer repository's ``uv.toml`` policy. In
92
+ #: particular, ``exclude-newer`` can make a just-published exact bundle appear
93
+ #: unavailable in CI for days. Keep custom pin-bearing launchers isolated too.
94
+ _UVX_STANDARDS = re.compile(r"\buvx(?P<args>[^\n]*?--from\s+(?:code|sarj)-standards(?:==[^\s]+)?)")
95
+ _PERSISTED_CREDENTIALS_OFF = re.compile(r"(?m)^(?P<indent>[ \t]*)persist-credentials:\s*false\s*$")
96
+
97
+ #: `rev: python-v0.19.0`, `rev: "standards-v0.10.0"`, `rev: 9d073e83b2...`.
98
+ #:
99
+ #: Raw commit pins can silently become stale, so report them as unverifiable.
100
+ _REV = re.compile(r"""rev:\s*['"]?(?P<rev>[a-z-]+-v[0-9][0-9A-Za-z.\-]*|[0-9a-f]{7,40})['"]?""")
101
+ _HOOK_ID = re.compile(r"(?m)^\s*-\s+id:\s*(?P<id>[^\s#]+)")
102
+
103
+ #: A `rev:` that is a raw commit, not a release tag.
104
+ _SHA_REV = re.compile(r"^[0-9a-f]{7,40}$")
105
+
106
+ _ESLINT_PLUGIN: Final = "@sarj/eslint-plugin"
107
+ _ESLINT_CONFIG_NAMES: Final = (
108
+ "eslint.config.js",
109
+ "eslint.config.mjs",
110
+ "eslint.config.cjs",
111
+ "eslint.config.ts",
112
+ "eslint.config.mts",
113
+ "eslint.config.cts",
114
+ )
115
+ _LOCAL_SPECIFIERS: Final = ("file:", "link:", "workspace:", "portal:")
116
+ _PYRIGHT_CONFIG_NAMES: Final = frozenset(
117
+ {".pyright-strict.json", "pyright.strict.json", "pyrightconfig.json", "pyrightconfig.jsonc", "pyproject.toml"}
118
+ )
119
+ _PYRIGHT_REPORT_DEPRECATED = re.compile(
120
+ r"^\s*[\"']?reportDeprecated[\"']?\s*(?::|=)\s*(?P<value>[^,#/\n]+)", re.MULTILINE
121
+ )
122
+ _RUFF_CONFIG_NAMES: Final = frozenset({".ruff.toml", "ruff.toml", "pyproject.toml"})
123
+ _STANDALONE_RUFF_CONFIG_NAMES: Final = (".ruff.toml", "ruff.toml")
124
+ _RUFF_REPLACEMENT_KEYS: Final = frozenset({"ignore", "select"})
125
+ _CONFIG_TARGETS: Final = MappingProxyType(
126
+ {
127
+ "ruff": ("ruff.strict.toml", "ruff.application.toml", ".ruff-strict.toml", "python"),
128
+ "pyright": ("pyright.strict.json", "pyright.strict.json", ".pyright-strict.json", "python"),
129
+ "eslint": ("eslint.strict.mjs", "eslint.application.mjs", "eslint.strict.mjs", "typescript"),
130
+ "markdownlint": ("markdownlint.strict.yaml", "markdownlint.strict.yaml", ".markdownlint.yaml", "root"),
131
+ "taplo": ("taplo.strict.toml", "taplo.strict.toml", ".taplo.toml", "root"),
132
+ "yamllint": ("yamllint.strict.yaml", "yamllint.strict.yaml", ".yamllint.yaml", "root"),
133
+ }
134
+ )
135
+
136
+ #: Where a rule identifier can be written: configs and suppression baselines, but
137
+ #: also ordinary source, because an `eslint-disable-next-line @sarj/<rule>` for a
138
+ #: rule that no longer exists is its own error under the shipped strict config's
139
+ #: `reportUnusedDisableDirectives: "error"`, and a `sarj-noqa: SARJnnn` comment
140
+ #: outlives the code it named.
141
+ _REFERENCE_SUFFIXES: Final = (
142
+ ".cjs",
143
+ ".cts",
144
+ ".js",
145
+ ".json",
146
+ ".jsx",
147
+ ".mjs",
148
+ ".mts",
149
+ ".py",
150
+ ".pyi",
151
+ ".toml",
152
+ ".ts",
153
+ ".tsx",
154
+ ".yaml",
155
+ ".yml",
156
+ )
157
+ _RULE_MAPPING_REFERENCE = re.compile(r"^\s*(?:-\s*)?(?:id|entry)\s*:\s*.*sarj", re.IGNORECASE)
158
+ _ESLINT_RULE_REFERENCE = re.compile(r"[\"']@sarj/[^\"']+[\"']\s*:")
159
+ _IGNORE_RETIRED_RULE_REFERENCES = "sarj-doctor-ignore-retired-rules"
160
+
161
+ _SKIP_DIRS: Final = frozenset(
162
+ {
163
+ ".git",
164
+ ".mypy_cache",
165
+ ".playwright-mcp",
166
+ ".pytest_cache",
167
+ ".ruff_cache",
168
+ ".tox",
169
+ ".uv-cache",
170
+ ".venv",
171
+ ".next",
172
+ ".open-next",
173
+ ".turbo",
174
+ ".wrangler",
175
+ ".yarn",
176
+ "build",
177
+ "coverage",
178
+ "dist",
179
+ "node_modules",
180
+ "out",
181
+ "target",
182
+ "vendor",
183
+ }
184
+ )
185
+ _SKILL_ARTIFACT_ROOTS: Final = frozenset({".agents", ".claude"})
186
+ _GIT_SAFE_ENV: Final = frozenset(
187
+ {"HOME", "LANG", "LC_ALL", "LC_CTYPE", "PATH", "SYSTEMDRIVE", "SYSTEMROOT", "TMPDIR", "XDG_CONFIG_HOME"}
188
+ )
189
+ _GIT_DISCOVERY_TIMEOUT: Final = timedelta(seconds=5)
190
+
191
+
192
+ def _git_environment() -> dict[str, str]:
193
+ return {
194
+ name: value
195
+ for name, value in os.environ.items() # ruff: ignore[banned-api] -- Git hook variables must not redirect child commands.
196
+ if name in _GIT_SAFE_ENV
197
+ }
198
+
199
+
200
+ def diagnose(root: Path) -> list[Finding]:
201
+ installed = manifest.installed_versions()
202
+ installed[_ESLINT_PLUGIN] = manifest.eslint_peers()[_ESLINT_PLUGIN]
203
+ files = authored_files(root)
204
+ findings = [*_check_manifest(root)]
205
+ findings.extend(_check_repository_launcher(root))
206
+ findings.extend(_check_hook_manager(root))
207
+ findings.extend(_check_pin_files(root, files, installed))
208
+ findings.extend(_check_legacy_in_project_launcher(root))
209
+ if not _has_adopted_eslint(root):
210
+ findings.extend(_check_eslint_plugin(root, files))
211
+ findings.extend(check_retired_rules(root, files))
212
+ findings.extend(check_pyright_deprecated(root, files))
213
+ findings.extend(check_ruff_policy_authority(root, files))
214
+ findings.extend(_check_adoption_wiring(root))
215
+ findings.extend(_check_ci_gate(root))
216
+ unique = dict.fromkeys(findings)
217
+ return sorted(unique, key=lambda finding: (finding.where, finding.id, finding.detail))
218
+
219
+
220
+ def authored_files(root: Path) -> tuple[Path, ...]:
221
+ exclusions = _doctor_exclusions(root)
222
+ return tuple(
223
+ path
224
+ for path in _walk(root)
225
+ if not any(fnmatch(path.relative_to(root).as_posix(), pattern) for pattern in exclusions)
226
+ )
227
+
228
+
229
+ def diagnose_adoption_health(root: Path, selected: Sequence[Path] = ()) -> list[Finding]:
230
+ installed = manifest.installed_versions()
231
+ installed[_ESLINT_PLUGIN] = manifest.eslint_peers()[_ESLINT_PLUGIN]
232
+ files = _adoption_health_files(root, selected)
233
+ findings = [*_check_manifest(root)]
234
+ findings.extend(_check_repository_launcher(root))
235
+ findings.extend(_check_hook_manager(root))
236
+ findings.extend(_check_pin_files(root, files, installed))
237
+ findings.extend(_check_legacy_in_project_launcher(root))
238
+ if not _has_adopted_eslint(root):
239
+ findings.extend(_check_eslint_plugin(root, files))
240
+ findings.extend(check_retired_rules(root, files))
241
+ findings.extend(check_pyright_deprecated(root, files))
242
+ findings.extend(check_ruff_policy_authority(root, files))
243
+ findings.extend(_check_adoption_wiring(root))
244
+ findings.extend(_check_ci_gate(root))
245
+ return sorted(dict.fromkeys(findings), key=lambda finding: (finding.where, finding.id, finding.detail))
246
+
247
+
248
+ def _check_repository_launcher(root: Path) -> Iterator[Finding]:
249
+ try:
250
+ adopted = manifest.load(root)
251
+ except OSError, TypeError, ValueError:
252
+ return
253
+ if adopted is None:
254
+ return
255
+ path = root / launcher.RETIRED_REPOSITORY_LAUNCHER
256
+ if not path.exists():
257
+ return
258
+ yield Finding(
259
+ Level.DRIFT,
260
+ launcher.RETIRED_REPOSITORY_LAUNCHER.as_posix(),
261
+ "repository-local launcher protocol 1 is retired; immutable bootstrap owns repository dispatch",
262
+ "doctor.launcher.retired",
263
+ "run `code-standards setup`",
264
+ )
265
+
266
+
267
+ def _adoption_health_files(root: Path, selected: Sequence[Path]) -> tuple[Path, ...]:
268
+ candidates = [
269
+ *(path if path.is_absolute() else root / path for path in selected),
270
+ manifest.manifest_path(root),
271
+ ]
272
+ candidates.extend(
273
+ root / name for name in (*hooks.PRECOMMIT_NAMES, "pyproject.toml", "package.json", "pyrightconfig.json")
274
+ )
275
+ candidates.extend(root.glob("requirements*.txt"))
276
+ candidates.extend(root.glob("requirements*.in"))
277
+ candidates.extend(root.glob("*/pyproject.toml"))
278
+ candidates.extend(root.glob("*/*/pyproject.toml"))
279
+ candidates.extend((root / ".github" / "workflows").glob("*.yml"))
280
+ candidates.extend((root / ".github" / "workflows").glob("*.yaml"))
281
+ try:
282
+ adopted = manifest.load(root)
283
+ except OSError, TypeError, ValueError:
284
+ adopted = None
285
+ if adopted is not None:
286
+ for destination in (adopted.python_dest, adopted.typescript_dest):
287
+ base = _manifest_destination(root, destination)
288
+ if base is not None:
289
+ candidates.extend(base / name for name in ("pyproject.toml", "package.json", "pyrightconfig.json"))
290
+ repository = root.resolve()
291
+ contained: list[Path] = []
292
+ for path in candidates:
293
+ if not path.is_file():
294
+ continue
295
+ resolved = path.resolve()
296
+ if resolved.is_relative_to(repository):
297
+ contained.append(resolved)
298
+ return tuple(dict.fromkeys(contained))
299
+
300
+
301
+ def _check_hook_manager(root: Path) -> Iterator[Finding]:
302
+ try:
303
+ adopted = manifest.load(root)
304
+ except OSError, TypeError, ValueError:
305
+ return
306
+ if adopted is None or adopted.hook_manager == "none":
307
+ return
308
+ configured = {
309
+ manager
310
+ for manager, active in (
311
+ ("pre-commit", hooks.precommit_runs_staged_check(root)),
312
+ ("lefthook", hooks.lefthook_runs_staged_check(root)),
313
+ )
314
+ if active
315
+ }
316
+ unexpected_configured = configured - {adopted.hook_manager}
317
+ if unexpected_configured:
318
+ names = ", ".join(sorted(unexpected_configured))
319
+ yield Finding(
320
+ Level.DRIFT,
321
+ manifest.MANIFEST_NAME,
322
+ f"declares {adopted.hook_manager}, but a canonical {names} Standards hook is also active",
323
+ "doctor.hooks.manager-conflict",
324
+ f"rerun `code-standards setup --hooks {adopted.hook_manager}` to keep one hook owner",
325
+ )
326
+ installed = _installed_hook_managers(root) if _git_worktree(root) else frozenset[str]()
327
+ unexpected_installed = installed - {adopted.hook_manager}
328
+ if unexpected_installed:
329
+ names = ", ".join(sorted(unexpected_installed))
330
+ yield Finding(
331
+ Level.DRIFT,
332
+ ".git/hooks/pre-commit",
333
+ f"installed hook chain includes {names}, but the manifest selects {adopted.hook_manager}",
334
+ "doctor.hooks.manager-conflict",
335
+ (
336
+ "run `code-standards maintain hooks install`"
337
+ if adopted.hook_manager == "lefthook"
338
+ else f"reinstall the selected manager with `code-standards setup --hooks {adopted.hook_manager}`"
339
+ ),
340
+ )
341
+ if adopted.hook_manager == "pre-commit":
342
+ if hooks.precommit_runs_staged_check(root):
343
+ yield Finding(
344
+ Level.OK,
345
+ ".pre-commit-config.yaml",
346
+ "runs exactly one canonical staged check",
347
+ "doctor.hooks.precommit",
348
+ )
349
+ if _git_worktree(root) and "pre-commit" not in installed:
350
+ yield Finding(
351
+ Level.WARN,
352
+ ".git/hooks/pre-commit",
353
+ "the configuration is healthy, but this checkout has no installed commit hook",
354
+ "doctor.hooks.precommit-install",
355
+ "run `code-standards doctor --repair`",
356
+ )
357
+ return
358
+ yield Finding(
359
+ Level.DRIFT,
360
+ ".pre-commit-config.yaml",
361
+ "pre-commit does not run exactly one canonical `code-standards check --staged` hook",
362
+ "doctor.hooks.precommit",
363
+ "run `code-standards update --offline`",
364
+ )
365
+ return
366
+ path = hooks.lefthook_config(root)
367
+ if path is not None and hooks.lefthook_runs_staged_check(root):
368
+ yield Finding(Level.OK, path.name, "runs the canonical staged check", "doctor.hooks.lefthook")
369
+ if _git_worktree(root) and "lefthook" not in installed:
370
+ yield Finding(
371
+ Level.WARN,
372
+ ".git/hooks/pre-commit",
373
+ "the configuration is healthy, but this checkout has no installed Lefthook commit hook",
374
+ "doctor.hooks.lefthook-install",
375
+ "run `code-standards maintain hooks install`",
376
+ )
377
+ return
378
+ yield Finding(
379
+ Level.DRIFT,
380
+ "lefthook.yml",
381
+ "Lefthook does not run `code-standards check --staged` during pre-commit",
382
+ "doctor.hooks.lefthook",
383
+ "add a Lefthook pre-commit command that runs `code-standards check --staged`",
384
+ )
385
+
386
+
387
+ def _git_worktree(root: Path) -> bool:
388
+ git = shutil.which("git")
389
+ if git is None:
390
+ return False
391
+ try:
392
+ completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] -- fixed executable and argv.
393
+ (git, "rev-parse", "--is-inside-work-tree"),
394
+ cwd=root,
395
+ check=False,
396
+ capture_output=True,
397
+ env=_git_environment(),
398
+ text=True,
399
+ timeout=_GIT_DISCOVERY_TIMEOUT.total_seconds(),
400
+ )
401
+ except OSError, subprocess.TimeoutExpired:
402
+ return False
403
+ return completed.returncode == 0 and completed.stdout.strip() == "true"
404
+
405
+
406
+ def _installed_hook_managers(root: Path) -> frozenset[str]:
407
+ git = shutil.which("git")
408
+ if git is None:
409
+ return frozenset()
410
+ try:
411
+ completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] -- fixed executable and argv.
412
+ (git, "rev-parse", "--git-path", "hooks/pre-commit"),
413
+ cwd=root,
414
+ check=False,
415
+ capture_output=True,
416
+ env=_git_environment(),
417
+ text=True,
418
+ timeout=_GIT_DISCOVERY_TIMEOUT.total_seconds(),
419
+ )
420
+ except OSError, subprocess.TimeoutExpired:
421
+ return frozenset()
422
+ if completed.returncode:
423
+ return frozenset()
424
+ hook = Path(completed.stdout.strip())
425
+ path = hook if hook.is_absolute() else root / hook
426
+ managers: set[str] = set()
427
+ for candidate in (path, path.with_name(f"{path.name}.legacy")):
428
+ if not candidate.is_file():
429
+ continue
430
+ try:
431
+ contents = candidate.read_text(encoding="utf-8", errors="replace")
432
+ except OSError:
433
+ continue
434
+ if "LEFTHOOK_BIN=" in contents or "lefthook" in contents.lower():
435
+ managers.add("lefthook")
436
+ if "hook-type=pre-commit" in contents or "pre_commit" in contents:
437
+ managers.add("pre-commit")
438
+ return frozenset(managers)
439
+
440
+
441
+ def _has_adopted_eslint(root: Path) -> bool:
442
+ try:
443
+ adopted = manifest.load(root)
444
+ except OSError, TypeError, ValueError:
445
+ return False
446
+ return adopted is not None and "eslint" in adopted.configs
447
+
448
+
449
+ def _doctor_exclusions(root: Path) -> tuple[str, ...]:
450
+ path = manifest.manifest_path(root)
451
+ if not path.is_file():
452
+ return ()
453
+ try:
454
+ parsed: object = tomllib.loads(path.read_text(encoding="utf-8"))
455
+ except OSError, tomllib.TOMLDecodeError:
456
+ return ()
457
+ values = manifest.list_field(manifest.table_field(manifest.as_table(parsed), "doctor"), "exclude")
458
+ return tuple(value for value in values if isinstance(value, str))
459
+
460
+
461
+ def check_retired_rules(root: Path, files: Sequence[Path] | None = None) -> Iterator[Finding]:
462
+ retired = ledger.load().retired
463
+ if not retired:
464
+ return
465
+ for path in _candidate_files(files if files is not None else _walk(root), _REFERENCE_SUFFIXES):
466
+ if path.name in {"rule-ledger.json", "code_ledger.json"}:
467
+ continue
468
+ counts = retired_rule_counts(path, _read(path))
469
+ for entry in retired:
470
+ hits = counts.get(entry.id, 0)
471
+ if hits:
472
+ where = f"{path.relative_to(root)}: {entry.id} x{hits}"
473
+ yield Finding(
474
+ Level.DRIFT,
475
+ where,
476
+ entry.advice,
477
+ "doctor.rule.retired",
478
+ entry.advice,
479
+ )
480
+
481
+
482
+ def retired_rule_references(path: Path, text: str) -> frozenset[str]:
483
+ return frozenset(retired_rule_counts(path, text))
484
+
485
+
486
+ def retired_rule_counts(path: Path, text: str) -> dict[str, int]:
487
+ if retired_suppressions.supports(path):
488
+ return retired_suppressions.reference_counts(path, text)
489
+ references = _reference_text(path, text)
490
+ if "sarj" not in references.lower():
491
+ return {}
492
+ return {entry.id: hits for entry in ledger.load().retired if (hits := len(entry.pattern.findall(references)))}
493
+
494
+
495
+ def _reference_text(path: Path, text: str) -> str:
496
+ if _IGNORE_RETIRED_RULE_REFERENCES in text:
497
+ return ""
498
+ lowered = path.name.lower()
499
+ if "baseline" in lowered:
500
+ return text
501
+ if "sarj" not in text.lower():
502
+ return ""
503
+ lines: list[str] = []
504
+ for line in text.splitlines():
505
+ normalized = line.lower()
506
+ if "sarj" not in normalized:
507
+ continue
508
+ if (
509
+ "sarj-noqa" in normalized
510
+ or "eslint-disable" in normalized
511
+ or "--rule" in normalized
512
+ or _RULE_MAPPING_REFERENCE.search(line)
513
+ or _ESLINT_RULE_REFERENCE.search(line)
514
+ ):
515
+ lines.append(line)
516
+ return "\n".join(lines)
517
+
518
+
519
+ def check_pyright_deprecated(root: Path, files: Sequence[Path] | None = None) -> Iterator[Finding]:
520
+ for path in files if files is not None else _walk(root):
521
+ if path.name not in _PYRIGHT_CONFIG_NAMES:
522
+ continue
523
+ for match in _PYRIGHT_REPORT_DEPRECATED.finditer(_read(path)):
524
+ value = match.group("value").strip()
525
+ if value.strip("\"'") == "error":
526
+ continue
527
+ where = f"{path.relative_to(root)}: reportDeprecated = {value}"
528
+ yield Finding(
529
+ Level.DRIFT,
530
+ where,
531
+ 'sets `reportDeprecated` away from "error"; restore it to keep deprecated APIs visible',
532
+ "doctor.pyright.report-deprecated",
533
+ 'set `reportDeprecated` to "error"',
534
+ )
535
+
536
+
537
+ def check_ruff_policy_authority(root: Path, files: Sequence[Path] | None = None) -> Iterator[Finding]:
538
+ for path in files if files is not None else _walk(root):
539
+ if path.name not in _RUFF_CONFIG_NAMES:
540
+ continue
541
+ try:
542
+ parsed: object = tomllib.loads(_read(path))
543
+ except tomllib.TOMLDecodeError:
544
+ continue
545
+ document = manifest.as_table(parsed)
546
+ ruff = manifest.as_table(manifest.as_table(document.get("tool")).get("ruff"))
547
+ if not ruff and path.name != "pyproject.toml":
548
+ ruff = document
549
+ if not ruff:
550
+ continue
551
+ extended = ruff.get("extend")
552
+ if not isinstance(extended, str):
553
+ continue
554
+ if not _ruff_extend_reaches_canonical(root, path, extended):
555
+ yield Finding(
556
+ Level.DRIFT,
557
+ f"{path.relative_to(root)}: Ruff config",
558
+ f"extends another project config ({extended}) instead of the canonical .ruff-strict.toml",
559
+ "doctor.ruff.authority",
560
+ "extend the project directly from `.ruff-strict.toml`",
561
+ )
562
+ lint = manifest.as_table(ruff.get("lint"))
563
+ table = "tool.ruff.lint" if path.name == "pyproject.toml" else "lint"
564
+ for key in sorted(_RUFF_REPLACEMENT_KEYS.intersection(lint)):
565
+ yield Finding(
566
+ Level.DRIFT,
567
+ f"{path.relative_to(root)}: [{table}].{key}",
568
+ f"replaces inherited Ruff policy; use `extend-{key}` so the canonical config remains authoritative",
569
+ "doctor.ruff.replaces-policy",
570
+ f"replace `{key}` with `extend-{key}`",
571
+ )
572
+
573
+
574
+ def _ruff_extend_reaches_canonical(root: Path, source: Path, extended: str) -> bool:
575
+ current = source
576
+ value = extended
577
+ seen: set[Path] = set()
578
+ canonical = (root / ".ruff-strict.toml").resolve()
579
+ while True:
580
+ requested = current.parent / value
581
+ target = requested.resolve()
582
+ # The installed config is commonly a symlink, so resolving it changes
583
+ # the basename from `.ruff-strict.toml` to `ruff.strict.toml`.
584
+ if requested.name == ".ruff-strict.toml" or target == canonical:
585
+ try:
586
+ target.relative_to(root.resolve())
587
+ except ValueError:
588
+ return False
589
+ return True
590
+ try:
591
+ target.relative_to(root.resolve())
592
+ except ValueError:
593
+ return False
594
+ if target in seen or not target.is_file():
595
+ return False
596
+ seen.add(target)
597
+ try:
598
+ parsed: object = tomllib.loads(_read(target))
599
+ except tomllib.TOMLDecodeError:
600
+ return False
601
+ document = manifest.as_table(parsed)
602
+ ruff = manifest.as_table(manifest.as_table(document.get("tool")).get("ruff"))
603
+ if not ruff and target.name != "pyproject.toml":
604
+ ruff = document
605
+ next_value = ruff.get("extend")
606
+ if not isinstance(next_value, str):
607
+ return False
608
+ current = target
609
+ value = next_value
610
+
611
+
612
+ def _check_manifest(root: Path) -> Iterator[Finding]:
613
+ try:
614
+ found = manifest.load(root)
615
+ except (OSError, TypeError, ValueError) as exc:
616
+ yield Finding(
617
+ Level.DRIFT,
618
+ manifest.MANIFEST_NAME,
619
+ str(exc),
620
+ "doctor.manifest.invalid",
621
+ "repair the named manifest field, then run `code-standards doctor` again",
622
+ )
623
+ return
624
+
625
+ if found is None:
626
+ yield Finding(
627
+ Level.WARN,
628
+ manifest.MANIFEST_NAME,
629
+ "absent -- run `code-standards setup` so the adopted version has one home",
630
+ "doctor.manifest.absent",
631
+ "run `code-standards setup`",
632
+ )
633
+ return
634
+
635
+ current = manifest.adopted_version()
636
+ if found.version == current:
637
+ yield Finding(Level.OK, manifest.MANIFEST_NAME, f"version {found.version}", "doctor.manifest.version")
638
+ return
639
+ yield Finding(
640
+ Level.DRIFT,
641
+ manifest.MANIFEST_NAME,
642
+ f"declares {found.version} but the installed wheel is {current}"
643
+ " -- run `code-standards update` so every owned site moves together",
644
+ "doctor.manifest.version",
645
+ "run `code-standards update`",
646
+ )
647
+
648
+
649
+ def _check_pin_files(root: Path, files: Sequence[Path], installed: Mapping[str, str]) -> Iterator[Finding]:
650
+ candidates = (path for path in files if _is_pin_site(path))
651
+ for path in candidates:
652
+ for match in _PIN.finditer(_read(path)):
653
+ name = match.group("name")
654
+ pinned = match.group("version")
655
+ canonical = "code-standards" if name == "sarj-standards" else name
656
+ current = installed.get(canonical)
657
+ where = f"{path.relative_to(root)}: {name}{match.group('op')}{pinned}"
658
+ if current is None:
659
+ yield Finding(
660
+ Level.WARN,
661
+ where,
662
+ f"{name} is not installed here, so the pin is unverified",
663
+ "doctor.version.unverified",
664
+ )
665
+ elif name == canonical and pinned == current and match.group("op") == "==":
666
+ yield Finding(Level.OK, where, "matches the installed wheel", "doctor.version.pin")
667
+ else:
668
+ yield Finding(
669
+ Level.DRIFT,
670
+ where,
671
+ f"installed {canonical} is {current}; Sarj toolchain dependencies must use the canonical name "
672
+ "and exact `==` pins",
673
+ "doctor.version.pin",
674
+ "run `code-standards update`",
675
+ )
676
+ for match in _PREAPPROVED_ESLINT.finditer(_read(path)):
677
+ pinned = match.group("version")
678
+ current = installed.get(_ESLINT_PLUGIN)
679
+ where = f"{path.relative_to(root)}: {_ESLINT_PLUGIN}@{pinned}"
680
+ if current is None:
681
+ yield Finding(
682
+ Level.WARN,
683
+ where,
684
+ "the preapproved internal package version is unverified",
685
+ "doctor.version.unverified",
686
+ )
687
+ elif pinned == current:
688
+ yield Finding(Level.OK, where, "matches the tested peer set", "doctor.version.pin")
689
+ else:
690
+ yield Finding(
691
+ Level.DRIFT,
692
+ where,
693
+ f"the tested internal plugin is {_ESLINT_PLUGIN}@{current}",
694
+ "doctor.version.pin",
695
+ "run `code-standards update`",
696
+ )
697
+
698
+
699
+ def _check_legacy_in_project_launcher(root: Path) -> Iterator[Finding]:
700
+ try:
701
+ adopted = manifest.load(root)
702
+ except OSError, TypeError, ValueError:
703
+ return
704
+ if adopted is None:
705
+ return
706
+ python_root = _manifest_destination(root, adopted.python_dest)
707
+ if python_root is None:
708
+ return
709
+ pyproject = python_root / "pyproject.toml"
710
+ text = _read(pyproject) if pyproject.is_file() else ""
711
+ installed_names = {
712
+ name for match in _PIN.finditer(text) if (name := match.group("name")) in {"code-standards", "sarj-standards"}
713
+ }
714
+ if not installed_names:
715
+ return
716
+ removal = " ".join(sorted(installed_names))
717
+ where = str(pyproject.relative_to(root))
718
+ yield Finding(
719
+ Level.DRIFT,
720
+ where,
721
+ "code-standards is installed inside the consumer project; the isolated launcher owns the tool runtime",
722
+ "doctor.python.legacy-in-project-tool",
723
+ f"run `uv remove --dev {removal}` in {python_root.relative_to(root).as_posix() or '.'}",
724
+ )
725
+
726
+
727
+ def _is_pin_site(path: Path) -> bool:
728
+ name = path.name.lower()
729
+ if name in {
730
+ "pyproject.toml",
731
+ ".pre-commit-config.yaml",
732
+ ".pre-commit-config.yml",
733
+ "package.json",
734
+ "makefile",
735
+ "gnumakefile",
736
+ "lefthook.yml",
737
+ "lefthook.yaml",
738
+ ".yarnrc.yml",
739
+ ".yarnrc.yaml",
740
+ "pnpm-workspace.yaml",
741
+ }:
742
+ return True
743
+ if name.startswith("requirements") and path.suffix.lower() in {"", ".in", ".txt"}:
744
+ return True
745
+ if "scripts" in path.parts and path.suffix.lower() in {".py", ".sh"}:
746
+ return True
747
+ return ".github" in path.parts and "workflows" in path.parts and path.suffix.lower() in {".yml", ".yaml"}
748
+
749
+
750
+ def rewrite_version_pins(text: str, installed: Mapping[str, str]) -> VersionPinRewrite:
751
+ changed: set[str] = set()
752
+ text, migrated_launchers = launcher.rewrite_legacy_repository_invocations(text)
753
+ if migrated_launchers:
754
+ changed.add("code-standards")
755
+
756
+ def isolate_launcher(match: re.Match[str]) -> str:
757
+ if "--no-config" in match.group("args").split():
758
+ return match.group(0)
759
+ changed.add("code-standards")
760
+ return f"uvx --no-config{match.group('args')}"
761
+
762
+ def replacement(match: re.Match[str]) -> str:
763
+ name = match.group("name")
764
+ canonical = "code-standards" if name == "sarj-standards" else name
765
+ current = installed.get(canonical) or installed.get(name)
766
+ if current is None or (name == canonical and match.group("version") == current and match.group("op") == "=="):
767
+ return match.group(0)
768
+ changed.add(canonical)
769
+ relative_end = match.end("version") - match.start()
770
+ return f"{canonical}=={current}{match.group(0)[relative_end:]}"
771
+
772
+ def preapproved_eslint(match: re.Match[str]) -> str:
773
+ current = installed.get(_ESLINT_PLUGIN)
774
+ if current is None or match.group("version") == current:
775
+ return match.group(0)
776
+ changed.add(_ESLINT_PLUGIN)
777
+ return f"{match.group('prefix')}{current}{match.group('suffix')}"
778
+
779
+ isolated = _UVX_STANDARDS.sub(isolate_launcher, text)
780
+ if (
781
+ any(name in isolated for name in ("code-standards", "sarj-standards"))
782
+ and "actions/checkout@" in isolated
783
+ and "fetch-depth:" not in isolated
784
+ ):
785
+ migrated = _PERSISTED_CREDENTIALS_OFF.sub(
786
+ r"\g<0>\n\g<indent>fetch-depth: 0",
787
+ isolated,
788
+ count=1,
789
+ )
790
+ if migrated != isolated:
791
+ changed.add("code-standards")
792
+ isolated = migrated
793
+ pinned = _PIN.sub(replacement, isolated)
794
+ return VersionPinRewrite(_PREAPPROVED_ESLINT.sub(preapproved_eslint, pinned), tuple(sorted(changed)))
795
+
796
+
797
+ def plan_version_pin_updates(
798
+ root: Path,
799
+ installed: Mapping[str, str] | None = None,
800
+ ) -> tuple[VersionPinUpdate, ...]:
801
+ versions = dict(manifest.installed_versions() if installed is None else installed)
802
+ versions.setdefault(_ESLINT_PLUGIN, manifest.eslint_peers()[_ESLINT_PLUGIN])
803
+ exclusions = _doctor_exclusions(root)
804
+ updates: list[VersionPinUpdate] = []
805
+ for path in _walk(root):
806
+ if not _is_pin_site(path):
807
+ continue
808
+ relative = path.relative_to(root).as_posix()
809
+ if any(fnmatch(relative, pattern) for pattern in exclusions):
810
+ continue
811
+ original = _read(path)
812
+ contents, packages = rewrite_version_pins(original, versions)
813
+ if path.name == "package.json":
814
+ contents, plugin_changed = _rewrite_package_eslint_pins(
815
+ contents,
816
+ versions[_ESLINT_PLUGIN],
817
+ )
818
+ if plugin_changed:
819
+ packages = tuple(sorted({*packages, _ESLINT_PLUGIN}))
820
+ if packages:
821
+ updates.append(VersionPinUpdate(path, contents, packages))
822
+ return tuple(updates)
823
+
824
+
825
+ def _rewrite_package_eslint_pins(text: str, version: str) -> _PackageEslintPinRewrite:
826
+ changed = False
827
+
828
+ def dependency_section(match: re.Match[str]) -> str:
829
+ nonlocal changed
830
+
831
+ def plugin_pin(pin: re.Match[str]) -> str:
832
+ nonlocal changed
833
+ if pin.group("version") == version:
834
+ return pin.group(0)
835
+ changed = True
836
+ return f"{pin.group('prefix')}{version}{pin.group('suffix')}"
837
+
838
+ body = _PACKAGE_ESLINT_PIN.sub(plugin_pin, match.group("body"))
839
+ return f"{match.group('prefix')}{body}{match.group('suffix')}"
840
+
841
+ return _PackageEslintPinRewrite(_PACKAGE_DEPENDENCY_SECTION.sub(dependency_section, text), changed)
842
+
843
+
844
+ def _check_eslint_plugin(root: Path, files: Sequence[Path]) -> Iterator[Finding]:
845
+ # A missing peer manifest is a packaging defect and must fail loudly.
846
+ floor = manifest.eslint_peers()[_ESLINT_PLUGIN]
847
+ for path in _candidate_files(files, (".json",)):
848
+ if path.name != "package.json":
849
+ continue
850
+ text = _read(path)
851
+ try:
852
+ pinned = _package_json_pin_text(text)
853
+ except json.JSONDecodeError as exc:
854
+ if path != root / "package.json" and _ESLINT_PLUGIN not in text:
855
+ continue
856
+ yield Finding(
857
+ Level.DRIFT,
858
+ str(path.relative_to(root)),
859
+ f"invalid package.json at line {exc.lineno}, column {exc.colno}: {exc.msg}",
860
+ "doctor.package-json.invalid",
861
+ "repair package.json, then rerun doctor",
862
+ )
863
+ continue
864
+ except RecursionError:
865
+ yield Finding(
866
+ Level.DRIFT,
867
+ str(path.relative_to(root)),
868
+ "invalid package.json: document nesting is too deep",
869
+ "doctor.package-json.invalid",
870
+ "repair package.json, then rerun doctor",
871
+ )
872
+ continue
873
+ if pinned is None:
874
+ continue
875
+ where = f"{path.relative_to(root)}: {_ESLINT_PLUGIN}@{pinned}"
876
+ if pinned.startswith("file:") and _local_eslint_plugin_matches(root, path, pinned, floor):
877
+ yield Finding(
878
+ Level.OK,
879
+ where,
880
+ "local plugin package matches the tested peer version",
881
+ "doctor.eslint.plugin",
882
+ )
883
+ continue
884
+ if pinned.startswith(_LOCAL_SPECIFIERS):
885
+ yield Finding(
886
+ Level.WARN,
887
+ f"{path.relative_to(root)}: {_ESLINT_PLUGIN}@{pinned}",
888
+ "local/workspace plugin source cannot prove the published tested version",
889
+ "doctor.eslint.plugin-unverified",
890
+ "use the exact published peer outside local plugin development",
891
+ )
892
+ continue
893
+ if _is_exact_pin(pinned, floor):
894
+ yield Finding(Level.OK, where, "matches the tested peer set", "doctor.eslint.plugin")
895
+ else:
896
+ yield Finding(
897
+ Level.DRIFT,
898
+ where,
899
+ f"the bundled eslint.strict.mjs is tested against {floor};"
900
+ " see `code-standards show peers` for the whole resolvable set",
901
+ "doctor.eslint.plugin",
902
+ "run `code-standards update`",
903
+ )
904
+
905
+
906
+ def _local_eslint_plugin_matches(root: Path, manifest_path: Path, pinned: str, floor: str) -> bool:
907
+ candidate = (manifest_path.parent / pinned.removeprefix("file:")).resolve()
908
+ repository = root.resolve()
909
+ if not candidate.is_relative_to(repository):
910
+ return False
911
+ try:
912
+ raw: object = json.loads((candidate / "package.json").read_text(encoding="utf-8")) # pyright: ignore[reportAny]
913
+ except OSError, json.JSONDecodeError:
914
+ return False
915
+ return _is_object_table(raw) and raw.get("name") == _ESLINT_PLUGIN and raw.get("version") == floor
916
+
917
+
918
+ def _is_object_table(value: object) -> TypeGuard[dict[str, object]]:
919
+ return isinstance(value, dict)
920
+
921
+
922
+ def _check_adoption_wiring(root: Path) -> Iterator[Finding]: # ruff: ignore[too-many-locals] -- validates each declared adoption site once
923
+ try:
924
+ adopted = manifest.load(root)
925
+ except OSError, TypeError, ValueError:
926
+ return
927
+ if adopted is None:
928
+ return
929
+
930
+ destinations: dict[str, Path | None] = {"root": root}
931
+ for kind, value in (("python", adopted.python_dest), ("typescript", adopted.typescript_dest)):
932
+ destinations[kind] = _manifest_destination(root, value)
933
+ if destinations[kind] is None:
934
+ yield Finding(
935
+ Level.DRIFT,
936
+ f"{manifest.MANIFEST_NAME}: dest.{kind}",
937
+ f"destination {value!r} is missing, not a directory, or escapes the repository root",
938
+ "doctor.manifest.destination",
939
+ f"set `dest.{kind}` to an existing directory inside the repository",
940
+ )
941
+ for name in adopted.configs:
942
+ spec = _CONFIG_TARGETS.get(name)
943
+ if spec is None:
944
+ yield Finding(
945
+ Level.DRIFT,
946
+ manifest.MANIFEST_NAME,
947
+ f"declares unknown config {name!r}",
948
+ "doctor.config.unknown",
949
+ "remove or correct the unknown config name in the adoption manifest",
950
+ )
951
+ continue
952
+ standard_source, application_source, target_name, kind = spec
953
+ destination = destinations[kind]
954
+ if destination is None:
955
+ continue
956
+ target = destination / target_name
957
+ source_name = application_source if adopted.profile == "application" else standard_source
958
+ expected = CONFIGS_DIR / source_name
959
+ if not target.is_file():
960
+ yield Finding(
961
+ Level.DRIFT,
962
+ str(target.relative_to(root)),
963
+ f"declared {name} config is missing",
964
+ "doctor.config.missing",
965
+ "run `code-standards update`",
966
+ )
967
+ elif target.read_bytes() != expected.read_bytes():
968
+ if is_link_like(target):
969
+ linked = target.resolve(strict=False)
970
+ yield Finding(
971
+ Level.DRIFT,
972
+ str(target.relative_to(root)),
973
+ f"declared {name} config is a source-controlled link to {linked.relative_to(root) if linked.is_relative_to(root) else linked} and differs from the executing bundle",
974
+ "doctor.config.source-drift",
975
+ "update or rebase the Standards source checkout; automatic repair will not replace a source-controlled link",
976
+ )
977
+ continue
978
+ yield Finding(
979
+ Level.DRIFT,
980
+ str(target.relative_to(root)),
981
+ f"declared {name} config differs from the installed bundle",
982
+ "doctor.config.drift",
983
+ "run `code-standards update`",
984
+ )
985
+ else:
986
+ yield Finding(Level.OK, str(target.relative_to(root)), f"{name} config is current", "doctor.config.current")
987
+
988
+ python_root = destinations["python"]
989
+ if python_root is not None:
990
+ if "ruff" in adopted.configs:
991
+ competing = [path for name in _STANDALONE_RUFF_CONFIG_NAMES if (path := python_root / name).is_file()]
992
+ if competing:
993
+ rendered = ", ".join(path.name for path in competing)
994
+ yield Finding(
995
+ Level.DRIFT,
996
+ str(python_root.relative_to(root) or "."),
997
+ f"standalone Ruff config(s) bypass pyproject.toml and the adopted chain: {rendered}",
998
+ "doctor.ruff.ambiguous-config",
999
+ "consolidate the standalone Ruff settings into pyproject.toml, remove them, then rerun doctor",
1000
+ )
1001
+ yield from _check_text_wiring(
1002
+ root,
1003
+ python_root / "pyproject.toml",
1004
+ ".ruff-strict.toml",
1005
+ "doctor.ruff.wiring",
1006
+ 'add `extend = ".ruff-strict.toml"` under `[tool.ruff]`',
1007
+ )
1008
+ if "pyright" in adopted.configs:
1009
+ configs = (python_root / "pyrightconfig.json", python_root / "pyrightconfig.jsonc")
1010
+ active = next((path for path in configs if path.is_file()), configs[0])
1011
+ yield from _check_text_wiring(
1012
+ root,
1013
+ active,
1014
+ ".pyright-strict.json",
1015
+ "doctor.pyright.wiring",
1016
+ "set `extends` to `.pyright-strict.json`",
1017
+ )
1018
+
1019
+ typescript_root = destinations["typescript"]
1020
+ if typescript_root is not None and "eslint" in adopted.configs:
1021
+ entrypoints = [typescript_root / name for name in _ESLINT_CONFIG_NAMES if (typescript_root / name).is_file()]
1022
+ if len(entrypoints) > 1:
1023
+ yield Finding(
1024
+ Level.DRIFT,
1025
+ str(typescript_root.relative_to(root)),
1026
+ f"multiple ESLint flat configs are active: {', '.join(path.name for path in entrypoints)}",
1027
+ "doctor.eslint.ambiguous-config",
1028
+ "keep one ESLint flat config and remove the shadowed duplicates",
1029
+ )
1030
+ active_entrypoint = entrypoints[0] if entrypoints else typescript_root / "eslint.config.mjs"
1031
+ if _eslint_wiring_reaches_strict(active_entrypoint, typescript_root):
1032
+ yield Finding(
1033
+ Level.OK,
1034
+ str(active_entrypoint.relative_to(root)),
1035
+ "references eslint.strict.mjs",
1036
+ "doctor.eslint.wiring",
1037
+ )
1038
+ else:
1039
+ yield Finding(
1040
+ Level.DRIFT,
1041
+ str(active_entrypoint.relative_to(root)),
1042
+ "does not reference eslint.strict.mjs directly or through a local config",
1043
+ "doctor.eslint.wiring",
1044
+ "import and spread `./eslint.strict.mjs` from the active ESLint config chain",
1045
+ )
1046
+ shadowing = _nested_eslint_configs(typescript_root, active_entrypoint)
1047
+ if shadowing:
1048
+ rendered = ", ".join(path.relative_to(root).as_posix() for path in shadowing)
1049
+ yield Finding(
1050
+ Level.DRIFT,
1051
+ str(typescript_root.relative_to(root)),
1052
+ f"package-local ESLint configs can bypass the adopted config: {rendered}",
1053
+ "doctor.eslint.shadowed-config",
1054
+ "make each package config import the adopted eslint.strict.mjs chain, or remove the shadowing config",
1055
+ )
1056
+ yield from _check_eslint_peer_set(root, typescript_root)
1057
+
1058
+
1059
+ def _check_ci_gate(root: Path) -> Iterator[Finding]:
1060
+ try:
1061
+ adopted = manifest.load(root)
1062
+ except OSError, TypeError, ValueError:
1063
+ return
1064
+ if adopted is None:
1065
+ return
1066
+ workflows = scaffold.standards_check_workflows(root)
1067
+ if workflows:
1068
+ rendered = ", ".join(path.relative_to(root).as_posix() for path in workflows)
1069
+ yield Finding(Level.OK, rendered, "runs the canonical Standards check", "doctor.ci.gate")
1070
+ return
1071
+ yield Finding(
1072
+ Level.DRIFT,
1073
+ ".github/workflows",
1074
+ "no executable workflow step runs `code-standards ... check`",
1075
+ "doctor.ci.gate",
1076
+ "run `code-standards show ci --output .github/workflows/standards.yml`",
1077
+ )
1078
+
1079
+
1080
+ def _nested_eslint_configs(typescript_root: Path, active_entrypoint: Path) -> tuple[Path, ...]:
1081
+ names = {*_ESLINT_CONFIG_NAMES, ".eslintrc", ".eslintrc.json", ".eslintrc.js", ".eslintrc.cjs"}
1082
+ found: list[Path] = []
1083
+ for path in _walk(typescript_root):
1084
+ if path == active_entrypoint or path.name not in names:
1085
+ continue
1086
+ if path.parent == typescript_root and path.name == "eslint.strict.mjs":
1087
+ continue
1088
+ if _eslint_wiring_reaches_strict(path, typescript_root):
1089
+ continue
1090
+ found.append(path)
1091
+ return tuple(sorted(found))
1092
+
1093
+
1094
+ def _manifest_destination(root: Path, value: str) -> Path | None:
1095
+ try:
1096
+ destination = (root / value).resolve()
1097
+ destination.relative_to(root.resolve())
1098
+ except OSError, ValueError:
1099
+ destination = None
1100
+ if destination is None or not destination.is_dir():
1101
+ # This helper cannot yield, so the caller gets the finding through a
1102
+ # sentinel file check below; keep the path invalid rather than escaping.
1103
+ return None
1104
+ return destination
1105
+
1106
+
1107
+ def _check_text_wiring(
1108
+ root: Path,
1109
+ path: Path,
1110
+ needle: str,
1111
+ finding_id: str,
1112
+ remediation: str,
1113
+ ) -> Iterator[Finding]:
1114
+ if not path.is_file():
1115
+ yield Finding(Level.DRIFT, str(path.relative_to(root)), "wiring file is missing", finding_id, remediation)
1116
+ return
1117
+ text = _read(path)
1118
+ if needle not in text:
1119
+ yield Finding(Level.DRIFT, str(path.relative_to(root)), f"does not reference {needle}", finding_id, remediation)
1120
+ else:
1121
+ yield Finding(Level.OK, str(path.relative_to(root)), f"references {needle}", finding_id)
1122
+
1123
+
1124
+ _LOCAL_MODULE = re.compile(
1125
+ r"(?m)^\s*(?:import\b[^;\n]*?\bfrom\s+|import\s*|export\b[^;\n]*?\bfrom\s+)"
1126
+ r"[\"'](?P<path>\.[^\"']+)[\"']"
1127
+ )
1128
+
1129
+
1130
+ def _eslint_wiring_reaches_strict(path: Path, root: Path, seen: set[Path] | None = None) -> bool:
1131
+ visited: set[Path] = set() if seen is None else seen
1132
+ resolved = path.resolve()
1133
+ if resolved in visited or not resolved.is_file():
1134
+ return False
1135
+ try:
1136
+ resolved.relative_to(root.resolve())
1137
+ except ValueError:
1138
+ return False
1139
+ visited.add(resolved)
1140
+ text = _read(resolved)
1141
+ for match in _LOCAL_MODULE.finditer(text):
1142
+ target = (resolved.parent / match.group("path")).resolve()
1143
+ if target.name == "eslint.strict.mjs" and target.is_file():
1144
+ return True
1145
+ candidates = (target, *(target.with_suffix(suffix) for suffix in (".js", ".mjs", ".cjs", ".ts")))
1146
+ if any(_eslint_wiring_reaches_strict(candidate, root, visited) for candidate in candidates):
1147
+ return True
1148
+ return False
1149
+
1150
+
1151
+ def _check_eslint_peer_set(root: Path, typescript_root: Path) -> Iterator[Finding]:
1152
+ package_root = packagemanager.workspace_root(typescript_root, root)
1153
+ package_json = package_root / "package.json"
1154
+ if not package_json.is_file():
1155
+ yield Finding(
1156
+ Level.DRIFT,
1157
+ str(package_json.relative_to(root)),
1158
+ "package.json is missing for the declared TypeScript project",
1159
+ "doctor.eslint.package",
1160
+ "restore package.json or correct dest.typescript",
1161
+ )
1162
+ return
1163
+ try:
1164
+ parsed: object = json.loads(_read(package_json)) # pyright: ignore[reportAny] -- untyped stdlib boundary
1165
+ except json.JSONDecodeError as exc:
1166
+ yield Finding(
1167
+ Level.DRIFT,
1168
+ str(package_json.relative_to(root)),
1169
+ f"invalid package.json at line {exc.lineno}, column {exc.colno}: {exc.msg}",
1170
+ "doctor.package-json.invalid",
1171
+ "repair package.json, then rerun doctor",
1172
+ )
1173
+ return
1174
+ except RecursionError:
1175
+ yield Finding(
1176
+ Level.DRIFT,
1177
+ str(package_json.relative_to(root)),
1178
+ "invalid package.json: document nesting is too deep",
1179
+ "doctor.package-json.invalid",
1180
+ "repair package.json, then rerun doctor",
1181
+ )
1182
+ return
1183
+ document = manifest.as_table(parsed)
1184
+ if not isinstance(parsed, dict):
1185
+ yield Finding(
1186
+ Level.DRIFT,
1187
+ str(package_json.relative_to(root)),
1188
+ f"package.json must contain a JSON object, found {type(parsed).__name__}",
1189
+ "doctor.package-json.invalid",
1190
+ "repair package.json, then rerun doctor",
1191
+ )
1192
+ return
1193
+ declared: dict[str, object] = {}
1194
+ for key in ("dependencies", "devDependencies"):
1195
+ declared.update(manifest.table_field(document, key))
1196
+ for name, expected in sorted(manifest.eslint_peers().items()):
1197
+ actual = declared.get(name)
1198
+ if isinstance(actual, str) and _is_exact_pin(actual, expected):
1199
+ continue
1200
+ yield Finding(
1201
+ Level.DRIFT,
1202
+ f"{package_json.relative_to(root)}: {name}",
1203
+ f"expected exact tested peer {expected}, found {actual!r}",
1204
+ "doctor.eslint.peer",
1205
+ "run `code-standards update`",
1206
+ )
1207
+
1208
+ client = packagemanager.detect(package_root)
1209
+ overrides = packagemanager.overrides_for(client)
1210
+ pnpm_workspace = package_root / "pnpm-workspace.yaml"
1211
+ if client is packagemanager.PackageManager.PNPM:
1212
+ if not pnpm_workspace.is_file():
1213
+ yield Finding(
1214
+ Level.DRIFT,
1215
+ str(pnpm_workspace.relative_to(root)),
1216
+ "required pnpm 11 workspace policy is missing",
1217
+ "doctor.eslint.override",
1218
+ "run `code-standards update`",
1219
+ )
1220
+ return
1221
+ workspace_text = _read(pnpm_workspace)
1222
+ values = packagemanager.pnpm_workspace_values(workspace_text)
1223
+ if all(values.get(key) == str(value) for key, value in overrides.entries.items()):
1224
+ return
1225
+ yield Finding(
1226
+ Level.DRIFT,
1227
+ str(pnpm_workspace.relative_to(root)),
1228
+ "required pnpm peer override is missing",
1229
+ "doctor.eslint.override",
1230
+ "run `code-standards update`",
1231
+ )
1232
+ return
1233
+ table: Mapping[str, object] = document
1234
+ for key in overrides.key_path:
1235
+ table = manifest.table_field(table, key)
1236
+ if not _contains_expected_mapping(table, overrides.entries):
1237
+ yield Finding(
1238
+ Level.DRIFT,
1239
+ str(package_json.relative_to(root)),
1240
+ f"required {client} peer override is missing",
1241
+ "doctor.eslint.override",
1242
+ "run `code-standards update`",
1243
+ )
1244
+
1245
+
1246
+ def _contains_expected_mapping(actual: Mapping[str, object], expected: Mapping[str, object]) -> bool:
1247
+ for key, expected_value in expected.items():
1248
+ actual_value = actual.get(key)
1249
+ expected_table = manifest.as_table(expected_value)
1250
+ if expected_table:
1251
+ actual_table = manifest.as_table(actual_value)
1252
+ if not actual_table or not _contains_expected_mapping(actual_table, expected_table):
1253
+ return False
1254
+ elif actual_value != expected_value:
1255
+ return False
1256
+ return True
1257
+
1258
+
1259
+ def _is_exact_pin(pinned: str, expected: str) -> bool:
1260
+ normalized = pinned.strip()
1261
+ if normalized.startswith("=="):
1262
+ normalized = normalized[2:].strip()
1263
+ elif normalized.startswith("="):
1264
+ normalized = normalized[1:].strip()
1265
+ return normalized == expected
1266
+
1267
+
1268
+ def _package_json_pin_text(text: str) -> str | None:
1269
+ parsed: object = json.loads(text) # pyright: ignore[reportAny] — json.loads is an untyped stdlib boundary; the shape is narrowed below
1270
+ package_json = manifest.as_table(parsed)
1271
+ for field in ("dependencies", "devDependencies"):
1272
+ pinned = manifest.as_table(package_json.get(field)).get(_ESLINT_PLUGIN)
1273
+ if isinstance(pinned, str):
1274
+ return pinned
1275
+ return None
1276
+
1277
+
1278
+ def _candidate_files(files: Sequence[Path], suffixes: Sequence[str]) -> Iterator[Path]:
1279
+ wanted = frozenset(suffixes)
1280
+ for path in files:
1281
+ if path.suffix.lower() in wanted:
1282
+ yield path
1283
+
1284
+
1285
+ def _walk(root: Path) -> tuple[Path, ...]:
1286
+ git = shutil.which("git")
1287
+ try:
1288
+ completed = (
1289
+ subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] -- fixed Git executable and argv.
1290
+ (git, "-C", str(root), "ls-files", "--cached", "--others", "--exclude-standard", "-z"),
1291
+ check=False,
1292
+ capture_output=True,
1293
+ env=_git_environment(),
1294
+ shell=False,
1295
+ timeout=_GIT_DISCOVERY_TIMEOUT.total_seconds(),
1296
+ )
1297
+ if git is not None
1298
+ else None
1299
+ )
1300
+ except OSError, subprocess.TimeoutExpired:
1301
+ completed = None
1302
+ if completed is not None and completed.returncode == 0:
1303
+ found = []
1304
+ for raw in completed.stdout.split(b"\0"):
1305
+ if not raw:
1306
+ continue
1307
+ path = root / raw.decode("utf-8", errors="surrogateescape")
1308
+ relative = path.relative_to(root)
1309
+ if (
1310
+ not any(part in _SKIP_DIRS for part in relative.parts)
1311
+ and not _is_skill_artifact(relative)
1312
+ and not path.is_symlink()
1313
+ and path.is_file()
1314
+ ):
1315
+ found.append(path)
1316
+ return tuple(sorted(found))
1317
+
1318
+ found: list[Path] = []
1319
+ for parent, directories, names in os.walk(root):
1320
+ here = Path(parent)
1321
+ directories[:] = sorted(
1322
+ name
1323
+ for name in directories
1324
+ if name not in _SKIP_DIRS and not (here.name in _SKILL_ARTIFACT_ROOTS and name == "skills")
1325
+ )
1326
+ found.extend(path for name in sorted(names) if not (path := here / name).is_symlink() and path.is_file())
1327
+ return tuple(found)
1328
+
1329
+
1330
+ def _is_skill_artifact(path: Path) -> bool:
1331
+ return any(root in _SKILL_ARTIFACT_ROOTS and child == "skills" for root, child in pairwise(path.parts))
1332
+
1333
+
1334
+ def _read(path: Path) -> str:
1335
+ try:
1336
+ return path.read_bytes().decode("utf-8", errors="replace")
1337
+ except OSError:
1338
+ return ""
1339
+
1340
+
1341
+ def parse_pins(text: str) -> dict[str, str]:
1342
+ return {match.group("name"): match.group("version") for match in _PIN.finditer(text)}
1343
+
1344
+
1345
+ def parse_revs(text: str) -> list[str]:
1346
+ return [match.group("rev") for match in _REV.finditer(text)]