code-standards 7.0.0__py3-none-any.whl

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