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,285 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from typing import TYPE_CHECKING, Final
|
|
8
|
+
|
|
9
|
+
from . import manifest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PackageManager(StrEnum):
|
|
18
|
+
NPM = "npm"
|
|
19
|
+
PNPM = "pnpm"
|
|
20
|
+
YARN = "yarn"
|
|
21
|
+
BUN = "bun"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class YarnVariant(StrEnum):
|
|
25
|
+
CLASSIC = "classic"
|
|
26
|
+
BERRY = "berry"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
#: Lockfiles in deterministic package-manager precedence order.
|
|
30
|
+
LOCKFILES: Final[tuple[tuple[str, PackageManager], ...]] = (
|
|
31
|
+
("pnpm-lock.yaml", PackageManager.PNPM),
|
|
32
|
+
("yarn.lock", PackageManager.YARN),
|
|
33
|
+
("bun.lock", PackageManager.BUN),
|
|
34
|
+
("bun.lockb", PackageManager.BUN),
|
|
35
|
+
("package-lock.json", PackageManager.NPM),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_ESLINT: Final = "eslint"
|
|
39
|
+
_YARN_BERRY_MINIMUM_MAJOR: Final = 2
|
|
40
|
+
_YAML_ENTRY = re.compile(r'^\s*(?P<key>"[^"]+"|\'[^\']+\'|[^:#]+):\s*(?P<value>[^#\n]+?)\s*(?:#.*)?$')
|
|
41
|
+
_EXACT_VERSION = re.compile(
|
|
42
|
+
r"^(?P<version>(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)"
|
|
43
|
+
r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)"
|
|
44
|
+
r"(?:\+sha(?:224|256|384|512)\.[0-9a-f]+)?$"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def detect(root: Path) -> PackageManager:
|
|
49
|
+
declared = _declared_manager(root / "package.json")
|
|
50
|
+
if declared is not None:
|
|
51
|
+
return declared
|
|
52
|
+
detected = {client for name, client in LOCKFILES if (root / name).is_file()}
|
|
53
|
+
if len(detected) > 1:
|
|
54
|
+
names = ", ".join(sorted(str(client) for client in detected))
|
|
55
|
+
msg = f"conflicting package-manager lockfiles in {root}: {names}"
|
|
56
|
+
raise ValueError(msg)
|
|
57
|
+
if detected:
|
|
58
|
+
return next(iter(detected))
|
|
59
|
+
return PackageManager.NPM
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def workspace_root(project_root: Path, repository_root: Path) -> Path:
|
|
63
|
+
repository = repository_root.resolve()
|
|
64
|
+
project = project_root.resolve()
|
|
65
|
+
try:
|
|
66
|
+
project.relative_to(repository)
|
|
67
|
+
except ValueError as exc:
|
|
68
|
+
msg = f"TypeScript project {project} is outside repository {repository}"
|
|
69
|
+
raise ValueError(msg) from exc
|
|
70
|
+
candidates = (project, *project.parents)
|
|
71
|
+
bounded = [path for path in candidates if path == repository or repository in path.parents]
|
|
72
|
+
pnpm = next((path for path in bounded if (path / "pnpm-workspace.yaml").is_file()), None)
|
|
73
|
+
if pnpm is not None:
|
|
74
|
+
return pnpm
|
|
75
|
+
roots = [path for path in bounded if _declared_manager(path / "package.json") is not None or _has_lock(path)]
|
|
76
|
+
return roots[0] if roots else project
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _has_lock(root: Path) -> bool:
|
|
80
|
+
return any((root / name).is_file() for name, _client in LOCKFILES)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def yarn_variant(root: Path) -> YarnVariant:
|
|
84
|
+
declared = _declared_manager_spec(root / "package.json")
|
|
85
|
+
if declared is not None and declared.split("@", 1)[0] == PackageManager.YARN:
|
|
86
|
+
major = declared.partition("@")[2].partition(".")[0]
|
|
87
|
+
if major.isdigit():
|
|
88
|
+
return YarnVariant.CLASSIC if int(major) < _YARN_BERRY_MINIMUM_MAJOR else YarnVariant.BERRY
|
|
89
|
+
if (root / ".yarnrc.yml").is_file():
|
|
90
|
+
return YarnVariant.BERRY
|
|
91
|
+
return YarnVariant.CLASSIC
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def declared_version(root: Path, client: PackageManager) -> str | None:
|
|
95
|
+
declared = _declared_manager_spec(root / "package.json")
|
|
96
|
+
if declared is None:
|
|
97
|
+
return None
|
|
98
|
+
name, separator, raw_version = declared.partition("@")
|
|
99
|
+
if name != client or not separator:
|
|
100
|
+
return None
|
|
101
|
+
match = _EXACT_VERSION.fullmatch(raw_version)
|
|
102
|
+
if match is None:
|
|
103
|
+
msg = f"packageManager {declared!r} must pin an exact semantic version"
|
|
104
|
+
raise ValueError(msg)
|
|
105
|
+
return match.group("version")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _declared_manager_spec(package_json: Path) -> str | None:
|
|
109
|
+
if not package_json.is_file():
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
parsed: object = json.loads( # pyright: ignore[reportAny] -- json.loads is an untyped stdlib boundary; the shape is narrowed below
|
|
113
|
+
package_json.read_text(encoding="utf-8")
|
|
114
|
+
)
|
|
115
|
+
except OSError, ValueError:
|
|
116
|
+
return None
|
|
117
|
+
return manifest.text_field(manifest.as_table(parsed), "packageManager")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _declared_manager(package_json: Path) -> PackageManager | None:
|
|
121
|
+
declared = _declared_manager_spec(package_json)
|
|
122
|
+
if declared is None:
|
|
123
|
+
return None
|
|
124
|
+
name = declared.split("@", 1)[0]
|
|
125
|
+
selected = next((client for client in PackageManager if client == name), None)
|
|
126
|
+
if selected is None:
|
|
127
|
+
supported = ", ".join(str(client) for client in PackageManager)
|
|
128
|
+
msg = f"unsupported packageManager {declared!r} in {package_json}; supported managers: {supported}"
|
|
129
|
+
raise ValueError(msg)
|
|
130
|
+
return selected
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class Overrides:
|
|
135
|
+
#: The package-manager policy document key path, outermost first.
|
|
136
|
+
key_path: tuple[str, ...]
|
|
137
|
+
entries: dict[str, object]
|
|
138
|
+
|
|
139
|
+
def as_document(self) -> dict[str, object]:
|
|
140
|
+
document: dict[str, object] = dict(self.entries)
|
|
141
|
+
for key in reversed(self.key_path):
|
|
142
|
+
document = {key: document}
|
|
143
|
+
return document
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def overrides_for(client: PackageManager) -> Overrides:
|
|
147
|
+
npm_entries = manifest.eslint_overrides()
|
|
148
|
+
match client:
|
|
149
|
+
case PackageManager.NPM:
|
|
150
|
+
return Overrides(("overrides",), {name: _resolved_tree(value) for name, value in npm_entries.items()})
|
|
151
|
+
case PackageManager.PNPM:
|
|
152
|
+
return Overrides(("overrides",), dict(_flatten(npm_entries, ">")))
|
|
153
|
+
case PackageManager.YARN:
|
|
154
|
+
return Overrides(("resolutions",), dict(_flatten(npm_entries, "/")))
|
|
155
|
+
case PackageManager.BUN:
|
|
156
|
+
# Bun ignores nested npm overrides, so pin ESLint at the root.
|
|
157
|
+
return Overrides(("overrides",), {_ESLINT: manifest.eslint_peers()[_ESLINT]})
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def pnpm_workspace_values(text: str) -> dict[str, str]:
|
|
161
|
+
values: dict[str, str] = {}
|
|
162
|
+
in_overrides = False
|
|
163
|
+
for line in text.splitlines():
|
|
164
|
+
if re.match(r"^overrides:\s*(?:#.*)?$", line):
|
|
165
|
+
in_overrides = True
|
|
166
|
+
continue
|
|
167
|
+
if in_overrides and line and not line[0].isspace():
|
|
168
|
+
break
|
|
169
|
+
if not in_overrides or (match := _YAML_ENTRY.match(line)) is None:
|
|
170
|
+
continue
|
|
171
|
+
key = match.group("key").strip().strip("\"'")
|
|
172
|
+
value = match.group("value").strip().strip("\"'")
|
|
173
|
+
values[key] = value
|
|
174
|
+
return values
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _flatten(entries: Mapping[str, object], separator: str) -> Iterator[tuple[str, str]]:
|
|
178
|
+
peers = manifest.eslint_peers()
|
|
179
|
+
for parent, value in entries.items():
|
|
180
|
+
nested = manifest.as_table(value)
|
|
181
|
+
if not nested:
|
|
182
|
+
yield parent, _resolved(value, peers)
|
|
183
|
+
continue
|
|
184
|
+
for child, pin in nested.items():
|
|
185
|
+
yield f"{parent}{separator}{child}", _resolved(pin, peers)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _resolved(value: object, peers: Mapping[str, str]) -> str:
|
|
189
|
+
if not isinstance(value, str):
|
|
190
|
+
return str(value)
|
|
191
|
+
if not value.startswith("$"):
|
|
192
|
+
return value
|
|
193
|
+
return peers.get(value.removeprefix("$"), value)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _resolved_tree(value: object) -> object:
|
|
197
|
+
nested = manifest.as_table(value)
|
|
198
|
+
if nested:
|
|
199
|
+
return {name: _resolved_tree(pin) for name, pin in nested.items()}
|
|
200
|
+
return _resolved(value, manifest.eslint_peers())
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def install_command(
|
|
204
|
+
client: PackageManager,
|
|
205
|
+
*,
|
|
206
|
+
workspace: bool = False,
|
|
207
|
+
yarn: YarnVariant = YarnVariant.CLASSIC,
|
|
208
|
+
) -> str:
|
|
209
|
+
match client:
|
|
210
|
+
case PackageManager.NPM:
|
|
211
|
+
return "npm install --ignore-scripts --no-audit --no-fund"
|
|
212
|
+
case PackageManager.PNPM:
|
|
213
|
+
suffix = "" if workspace else " --ignore-workspace"
|
|
214
|
+
return f"pnpm install --no-frozen-lockfile --ignore-scripts{suffix}"
|
|
215
|
+
case PackageManager.YARN:
|
|
216
|
+
if yarn is YarnVariant.BERRY:
|
|
217
|
+
return "yarn install --no-immutable --mode=skip-build"
|
|
218
|
+
return "yarn install --ignore-scripts"
|
|
219
|
+
case PackageManager.BUN:
|
|
220
|
+
return "bun install --ignore-scripts"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def install_argv(
|
|
224
|
+
client: PackageManager,
|
|
225
|
+
*,
|
|
226
|
+
workspace: bool = False,
|
|
227
|
+
yarn: YarnVariant = YarnVariant.CLASSIC,
|
|
228
|
+
) -> Sequence[str]:
|
|
229
|
+
return tuple(install_command(client, workspace=workspace, yarn=yarn).split())
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def frozen_install_argv(
|
|
233
|
+
client: PackageManager,
|
|
234
|
+
*,
|
|
235
|
+
yarn: YarnVariant = YarnVariant.CLASSIC,
|
|
236
|
+
) -> Sequence[str]:
|
|
237
|
+
match client:
|
|
238
|
+
case PackageManager.NPM:
|
|
239
|
+
return ("npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund")
|
|
240
|
+
case PackageManager.PNPM:
|
|
241
|
+
return ("pnpm", "install", "--frozen-lockfile", "--ignore-scripts")
|
|
242
|
+
case PackageManager.YARN:
|
|
243
|
+
if yarn is YarnVariant.BERRY:
|
|
244
|
+
return ("yarn", "install", "--immutable", "--mode=skip-build")
|
|
245
|
+
return ("yarn", "install", "--frozen-lockfile", "--ignore-scripts")
|
|
246
|
+
case PackageManager.BUN:
|
|
247
|
+
return ("bun", "install", "--frozen-lockfile", "--ignore-scripts")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def exec_argv(client: PackageManager, *command: str) -> Sequence[str]:
|
|
251
|
+
match client:
|
|
252
|
+
case PackageManager.NPM:
|
|
253
|
+
return ("npm", "exec", "--offline", "--", *command)
|
|
254
|
+
case PackageManager.PNPM:
|
|
255
|
+
# `pnpm exec` only resolves binaries from the installed dependency
|
|
256
|
+
# tree. Unlike `pnpm dlx`, it never downloads a missing package, so
|
|
257
|
+
# an `--offline` flag is both unnecessary and invalid on pnpm 11.
|
|
258
|
+
return ("pnpm", "exec", *command)
|
|
259
|
+
case PackageManager.YARN:
|
|
260
|
+
return ("yarn", "exec", *command)
|
|
261
|
+
case PackageManager.BUN:
|
|
262
|
+
return ("bunx", "--bun", "--no-install", *command)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def install_note(client: PackageManager, *, yarn: YarnVariant = YarnVariant.CLASSIC) -> str | None:
|
|
266
|
+
if client is PackageManager.YARN:
|
|
267
|
+
note = (
|
|
268
|
+
"Yarn resolves `resolutions` at install time, so re-run `yarn install`"
|
|
269
|
+
f" after the block is written -- and note Yarn pins {_ESLINT} for"
|
|
270
|
+
" eslint-plugin-react to an exact version rather than tracking your own."
|
|
271
|
+
)
|
|
272
|
+
if yarn is YarnVariant.BERRY:
|
|
273
|
+
note += (
|
|
274
|
+
" Yarn 4.15+ also refuses a package published within its minimum release"
|
|
275
|
+
" age (`All versions satisfying ... are quarantined`); if a fresh"
|
|
276
|
+
" @sarj/eslint-plugin trips that, set `npmMinimalAgeGate: 0` in"
|
|
277
|
+
" .yarnrc.yml or wait it out."
|
|
278
|
+
)
|
|
279
|
+
return note
|
|
280
|
+
if client is PackageManager.PNPM:
|
|
281
|
+
return (
|
|
282
|
+
"Keep pnpm overrides in pnpm-workspace.yaml at the detected install root;"
|
|
283
|
+
" pnpm 11 ignores package.json#pnpm.overrides even for standalone packages."
|
|
284
|
+
)
|
|
285
|
+
return None
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum, auto
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import tokenize
|
|
10
|
+
from typing import TYPE_CHECKING, Final
|
|
11
|
+
|
|
12
|
+
from sarj_standards.libs.repository import ledger
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Callable, Iterable
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_SOURCE_SUFFIXES: Final = frozenset({".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".py", ".pyi", ".ts", ".tsx"})
|
|
20
|
+
_ESLINT_DIRECTIVE: Final = re.compile(
|
|
21
|
+
r"(?P<intro>(?://|/\*)\s*eslint-(?:disable(?:-next-line|-line)?|enable)\s+)"
|
|
22
|
+
r"(?P<body>.*?)(?P<close>\s*\*/)?$"
|
|
23
|
+
)
|
|
24
|
+
_PYTHON_DIRECTIVE: Final = re.compile(r"(?P<intro>#\s*sarj-noqa:\s*)(?P<body>.*?)$")
|
|
25
|
+
_REASON: Final = re.compile(r"(?P<rules>.*?)(?P<reason>\s+(?:--|[–—])\s+.*)?$")
|
|
26
|
+
_ESLINT_SEGMENT = r"[A-Za-z0-9][A-Za-z0-9_-]*"
|
|
27
|
+
_ESLINT_ID: Final = re.compile(rf"^(?:{_ESLINT_SEGMENT}|@?{_ESLINT_SEGMENT}(?:/{_ESLINT_SEGMENT})+)$")
|
|
28
|
+
_SARJ_CODE: Final = re.compile(r"^SARJ\d+$")
|
|
29
|
+
_ESLINT_SUPPRESSIONS: Final = "eslint-suppressions.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class Rewrite:
|
|
34
|
+
path: Path
|
|
35
|
+
contents: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _DirectiveState(Enum):
|
|
39
|
+
NONE = auto()
|
|
40
|
+
VALID = auto()
|
|
41
|
+
AMBIGUOUS = auto()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class _CommentSpan:
|
|
46
|
+
start: int
|
|
47
|
+
end: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class _Directive:
|
|
52
|
+
state: _DirectiveState
|
|
53
|
+
tokens: tuple[str, ...] = ()
|
|
54
|
+
match: re.Match[str] | None = None
|
|
55
|
+
reason: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def supports(path: Path) -> bool:
|
|
59
|
+
return path.suffix.lower() in _SOURCE_SUFFIXES
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def plan(files: Iterable[Path]) -> tuple[Rewrite, ...]:
|
|
63
|
+
shipped = ledger.load()
|
|
64
|
+
retired = shipped.retired
|
|
65
|
+
active = shipped.active_ids()
|
|
66
|
+
eslint = {entry.id: _replacement(entry, active) for entry in retired if entry.kind == ledger.ESLINT}
|
|
67
|
+
codes = {entry.id: _replacement(entry, active) for entry in retired if entry.kind == ledger.CODE}
|
|
68
|
+
rewrites: list[Rewrite] = []
|
|
69
|
+
for path in files:
|
|
70
|
+
if not supports(path) and path.name != _ESLINT_SUPPRESSIONS:
|
|
71
|
+
continue
|
|
72
|
+
try:
|
|
73
|
+
original = path.read_bytes().decode("utf-8")
|
|
74
|
+
except OSError, UnicodeDecodeError:
|
|
75
|
+
continue
|
|
76
|
+
if "sarj-doctor-ignore-retired-rules" in original:
|
|
77
|
+
continue
|
|
78
|
+
migrated = (
|
|
79
|
+
_rewrite_eslint_suppressions(original, eslint)
|
|
80
|
+
if path.name == _ESLINT_SUPPRESSIONS
|
|
81
|
+
else _rewrite(path, original, eslint, codes)
|
|
82
|
+
)
|
|
83
|
+
if migrated != original:
|
|
84
|
+
rewrites.append(Rewrite(path, migrated))
|
|
85
|
+
return tuple(rewrites)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def reference_counts(path: Path, text: str) -> dict[str, int]:
|
|
89
|
+
if "sarj-doctor-ignore-retired-rules" in text:
|
|
90
|
+
return {}
|
|
91
|
+
retired = ledger.load().retired
|
|
92
|
+
entries = {entry.id: entry for entry in retired}
|
|
93
|
+
counts: dict[str, int] = {}
|
|
94
|
+
spans = _comment_spans(path, text)
|
|
95
|
+
for span in spans:
|
|
96
|
+
comment = text[span.start : span.end]
|
|
97
|
+
directive = _classify_directive(path, comment)
|
|
98
|
+
if directive.state is _DirectiveState.VALID:
|
|
99
|
+
for token in directive.tokens:
|
|
100
|
+
if token in entries:
|
|
101
|
+
counts[token] = counts.get(token, 0) + 1
|
|
102
|
+
elif directive.state is _DirectiveState.AMBIGUOUS:
|
|
103
|
+
_add_pattern_hits(counts, retired, comment)
|
|
104
|
+
|
|
105
|
+
# Config objects and fixture strings can be reference sites too. Mask real
|
|
106
|
+
# comments first so ordinary prose and already-classified directives are not
|
|
107
|
+
# counted a second time.
|
|
108
|
+
uncomments = _mask_spans(text, spans)
|
|
109
|
+
for line in uncomments.splitlines():
|
|
110
|
+
if not _looks_like_ambiguous_reference(line, path):
|
|
111
|
+
continue
|
|
112
|
+
_add_pattern_hits(counts, retired, line)
|
|
113
|
+
return counts
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _add_pattern_hits(counts: dict[str, int], retired: tuple[ledger.Retired, ...], text: str) -> None:
|
|
117
|
+
for entry in retired:
|
|
118
|
+
hits = len(entry.pattern.findall(text))
|
|
119
|
+
if hits:
|
|
120
|
+
counts[entry.id] = counts.get(entry.id, 0) + hits
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _replacement(entry: ledger.Retired, active: frozenset[str]) -> str | None:
|
|
124
|
+
if entry.status is ledger.Status.REMOVED:
|
|
125
|
+
return None
|
|
126
|
+
replacement = entry.replacement
|
|
127
|
+
if replacement is None or replacement not in active:
|
|
128
|
+
return entry.id
|
|
129
|
+
if entry.kind == ledger.ESLINT and not replacement.startswith("@sarj/"):
|
|
130
|
+
return entry.id
|
|
131
|
+
if entry.kind == ledger.CODE and _SARJ_CODE.fullmatch(replacement) is None:
|
|
132
|
+
return entry.id
|
|
133
|
+
return replacement
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _rewrite_eslint_suppressions(text: str, retired: dict[str, str | None]) -> str:
|
|
137
|
+
bom = "\ufeff" if text.startswith("\ufeff") else ""
|
|
138
|
+
payload = text.removeprefix("\ufeff")
|
|
139
|
+
try:
|
|
140
|
+
parsed: object = json.loads( # pyright: ignore[reportAny] -- untyped stdlib boundary
|
|
141
|
+
payload,
|
|
142
|
+
object_pairs_hook=_unique_object,
|
|
143
|
+
)
|
|
144
|
+
except _DuplicateKeyError, json.JSONDecodeError:
|
|
145
|
+
return text
|
|
146
|
+
if not isinstance(parsed, _JsonObject):
|
|
147
|
+
return text
|
|
148
|
+
document: dict[str, dict[str, dict[str, int]]] = {}
|
|
149
|
+
changed = False
|
|
150
|
+
for file_name, raw_rules in parsed.values.items():
|
|
151
|
+
if not isinstance(raw_rules, _JsonObject):
|
|
152
|
+
return text
|
|
153
|
+
rules: dict[str, dict[str, int]] = {}
|
|
154
|
+
for rule_id, raw_budget in raw_rules.values.items():
|
|
155
|
+
if (count := _suppression_count(raw_budget)) is None:
|
|
156
|
+
return text
|
|
157
|
+
target = retired.get(rule_id, rule_id)
|
|
158
|
+
changed |= target != rule_id
|
|
159
|
+
if target is None:
|
|
160
|
+
continue
|
|
161
|
+
existing = rules.get(target)
|
|
162
|
+
rules[target] = {"count": count if existing is None else max(count, existing["count"])}
|
|
163
|
+
document[file_name] = rules
|
|
164
|
+
if not changed:
|
|
165
|
+
return text
|
|
166
|
+
line_ending = "\r\n" if "\r\n" in payload else "\n"
|
|
167
|
+
trailing = line_ending if payload.endswith(("\n", "\r")) else ""
|
|
168
|
+
rendered = json.dumps(document, ensure_ascii=False, indent=2).replace("\n", line_ending)
|
|
169
|
+
return f"{bom}{rendered}{trailing}"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class _DuplicateKeyError(ValueError):
|
|
173
|
+
"""A JSON object repeated a key and therefore has no lossless object model."""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True, slots=True)
|
|
177
|
+
class _JsonObject:
|
|
178
|
+
values: dict[str, object]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _unique_object(pairs: list[tuple[str, object]]) -> _JsonObject:
|
|
182
|
+
result: dict[str, object] = {}
|
|
183
|
+
for key, value in pairs:
|
|
184
|
+
if key in result:
|
|
185
|
+
raise _DuplicateKeyError(key)
|
|
186
|
+
result[key] = value
|
|
187
|
+
return _JsonObject(result)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _suppression_count(value: object) -> int | None:
|
|
191
|
+
if not isinstance(value, _JsonObject) or set(value.values) != {"count"}:
|
|
192
|
+
return None
|
|
193
|
+
count = value.values.get("count")
|
|
194
|
+
return count if isinstance(count, int) and not isinstance(count, bool) and count >= 0 else None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _rewrite(path: Path, text: str, eslint: dict[str, str | None], codes: dict[str, str | None]) -> str:
|
|
198
|
+
rewritten = text
|
|
199
|
+
for span in reversed(_comment_spans(path, text)):
|
|
200
|
+
comment = text[span.start : span.end]
|
|
201
|
+
if _is_jsx_comment_wrapper(path, text, span, comment):
|
|
202
|
+
continue
|
|
203
|
+
directive = _classify_directive(path, comment)
|
|
204
|
+
if directive.state is not _DirectiveState.VALID:
|
|
205
|
+
continue
|
|
206
|
+
retired = codes if path.suffix.lower() in {".py", ".pyi"} else eslint
|
|
207
|
+
if not any(token in retired for token in directive.tokens):
|
|
208
|
+
continue
|
|
209
|
+
replacement = _rewrite_valid_directive(directive, retired)
|
|
210
|
+
start = span.start
|
|
211
|
+
if not replacement:
|
|
212
|
+
line_start = rewritten.rfind("\n", 0, start) + 1
|
|
213
|
+
prefix = rewritten[line_start:start]
|
|
214
|
+
trimmed = prefix.rstrip(" \t")
|
|
215
|
+
end = span.end
|
|
216
|
+
if not trimmed:
|
|
217
|
+
if rewritten.startswith("\r\n", end):
|
|
218
|
+
end += 2
|
|
219
|
+
elif rewritten.startswith("\n", end):
|
|
220
|
+
end += 1
|
|
221
|
+
rewritten = f"{rewritten[:line_start]}{trimmed}{rewritten[end:]}"
|
|
222
|
+
else:
|
|
223
|
+
rewritten = f"{rewritten[:start]}{replacement}{rewritten[span.end :]}"
|
|
224
|
+
return rewritten
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _is_jsx_comment_wrapper(path: Path, text: str, span: _CommentSpan, comment: str) -> bool:
|
|
228
|
+
if path.suffix.lower() not in {".jsx", ".tsx"} or not comment.startswith("/*"):
|
|
229
|
+
return False
|
|
230
|
+
line_start = text.rfind("\n", 0, span.start) + 1
|
|
231
|
+
line_end = text.find("\n", span.end)
|
|
232
|
+
if line_end < 0:
|
|
233
|
+
line_end = len(text)
|
|
234
|
+
return text[line_start : span.start].rstrip().endswith("{") and text[span.end : line_end].lstrip().startswith("}")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _classify_directive(path: Path, comment: str) -> _Directive:
|
|
238
|
+
python = path.suffix.lower() in {".py", ".pyi"}
|
|
239
|
+
pattern = _PYTHON_DIRECTIVE if python else _ESLINT_DIRECTIVE
|
|
240
|
+
marker = "sarj-noqa" if python else "eslint-"
|
|
241
|
+
valid = _SARJ_CODE.fullmatch if python else _ESLINT_ID.fullmatch
|
|
242
|
+
match = pattern.fullmatch(comment)
|
|
243
|
+
if match is None:
|
|
244
|
+
state = _DirectiveState.AMBIGUOUS if marker in comment.lower() else _DirectiveState.NONE
|
|
245
|
+
return _Directive(state)
|
|
246
|
+
reason_match = _REASON.fullmatch(match.group("body"))
|
|
247
|
+
if reason_match is None:
|
|
248
|
+
return _Directive(_DirectiveState.AMBIGUOUS)
|
|
249
|
+
tokens = _comma_delimited_tokens(reason_match.group("rules"), valid)
|
|
250
|
+
if not tokens:
|
|
251
|
+
return _Directive(_DirectiveState.AMBIGUOUS)
|
|
252
|
+
return _Directive(_DirectiveState.VALID, tokens, match, reason_match.group("reason") or "")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _comma_delimited_tokens(body: str, valid: Callable[[str], object | None]) -> tuple[str, ...]:
|
|
256
|
+
raw = tuple(part.strip() for part in body.strip().split(","))
|
|
257
|
+
if not raw or any(not token or valid(token) is None for token in raw):
|
|
258
|
+
return ()
|
|
259
|
+
return raw
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _comment_spans(path: Path, text: str) -> tuple[_CommentSpan, ...]:
|
|
263
|
+
return _python_comment_spans(text) if path.suffix.lower() in {".py", ".pyi"} else _javascript_comment_spans(text)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _python_comment_spans(text: str) -> tuple[_CommentSpan, ...]:
|
|
267
|
+
try:
|
|
268
|
+
tokens = tokenize.generate_tokens(io.StringIO(text).readline)
|
|
269
|
+
offsets = _line_offsets(text)
|
|
270
|
+
return tuple(
|
|
271
|
+
_CommentSpan(offsets[token.start[0] - 1] + token.start[1], offsets[token.end[0] - 1] + token.end[1])
|
|
272
|
+
for token in tokens
|
|
273
|
+
if token.type == tokenize.COMMENT
|
|
274
|
+
)
|
|
275
|
+
except IndentationError, SyntaxError, tokenize.TokenError:
|
|
276
|
+
return ()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _javascript_comment_spans(text: str) -> tuple[_CommentSpan, ...]:
|
|
280
|
+
spans: list[_CommentSpan] = []
|
|
281
|
+
index = 0
|
|
282
|
+
quote: str | None = None
|
|
283
|
+
while index < len(text):
|
|
284
|
+
char = text[index]
|
|
285
|
+
if char == "\n":
|
|
286
|
+
if quote in {"'", '"'}:
|
|
287
|
+
quote = None
|
|
288
|
+
index += 1
|
|
289
|
+
continue
|
|
290
|
+
if quote is not None:
|
|
291
|
+
if char == quote and not _escaped(text, index):
|
|
292
|
+
quote = None
|
|
293
|
+
index += 1
|
|
294
|
+
continue
|
|
295
|
+
if char in {"'", '"', "`"}:
|
|
296
|
+
quote = char
|
|
297
|
+
index += 1
|
|
298
|
+
continue
|
|
299
|
+
if char == "/" and index + 1 < len(text) and not _escaped(text, index):
|
|
300
|
+
following = text[index + 1]
|
|
301
|
+
if following == "/":
|
|
302
|
+
newline = text.find("\n", index + 2)
|
|
303
|
+
end = len(text) if newline < 0 else newline - int(newline > 0 and text[newline - 1] == "\r")
|
|
304
|
+
spans.append(_CommentSpan(index, end))
|
|
305
|
+
index = len(text) if newline < 0 else newline
|
|
306
|
+
continue
|
|
307
|
+
if following == "*":
|
|
308
|
+
closing = text.find("*/", index + 2)
|
|
309
|
+
if closing < 0:
|
|
310
|
+
spans.append(_CommentSpan(index, len(text)))
|
|
311
|
+
return tuple(spans)
|
|
312
|
+
spans.append(_CommentSpan(index, closing + 2))
|
|
313
|
+
index = closing + 2
|
|
314
|
+
continue
|
|
315
|
+
index += 1
|
|
316
|
+
return tuple(spans)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _line_offsets(text: str) -> tuple[int, ...]:
|
|
320
|
+
offsets = [0]
|
|
321
|
+
offsets.extend(index + 1 for index, char in enumerate(text) if char == "\n")
|
|
322
|
+
return tuple(offsets)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _mask_spans(text: str, spans: tuple[_CommentSpan, ...]) -> str:
|
|
326
|
+
masked = list(text)
|
|
327
|
+
for span in spans:
|
|
328
|
+
for index in range(span.start, span.end):
|
|
329
|
+
if masked[index] not in {"\r", "\n"}:
|
|
330
|
+
masked[index] = " "
|
|
331
|
+
return "".join(masked)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _escaped(text: str, index: int) -> bool:
|
|
335
|
+
backslashes = 0
|
|
336
|
+
cursor = index - 1
|
|
337
|
+
while cursor >= 0 and text[cursor] == "\\":
|
|
338
|
+
backslashes += 1
|
|
339
|
+
cursor -= 1
|
|
340
|
+
return backslashes % 2 == 1
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _looks_like_ambiguous_reference(line: str, path: Path) -> bool:
|
|
344
|
+
normalized = line.lower()
|
|
345
|
+
if "sarj" not in normalized:
|
|
346
|
+
return False
|
|
347
|
+
if "baseline" in path.name.lower():
|
|
348
|
+
return True
|
|
349
|
+
return (
|
|
350
|
+
"eslint-disable" in normalized
|
|
351
|
+
or "eslint-enable" in normalized
|
|
352
|
+
or "sarj-noqa" in normalized
|
|
353
|
+
or "--rule" in normalized
|
|
354
|
+
or re.search(r"[\"']@sarj/[^\"']+[\"']\s*:", line) is not None
|
|
355
|
+
or re.search(r"^\s*(?:-\s*)?(?:id|entry)\s*:\s*.*sarj", line, re.IGNORECASE) is not None
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _rewrite_valid_directive(directive: _Directive, retired: dict[str, str | None]) -> str:
|
|
360
|
+
match = directive.match
|
|
361
|
+
if match is None:
|
|
362
|
+
return ""
|
|
363
|
+
migrated = tuple(
|
|
364
|
+
dict.fromkeys(
|
|
365
|
+
replacement for token in directive.tokens if (replacement := retired.get(token, token)) is not None
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
if migrated:
|
|
369
|
+
close = match.groupdict().get("close") or ""
|
|
370
|
+
return f"{match.group('intro')}{', '.join(migrated)}{directive.reason}{close}"
|
|
371
|
+
return ""
|