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,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)]
|