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,1454 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, replace
4
+ from datetime import timedelta
5
+ from enum import StrEnum
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import re
10
+ import shutil
11
+ import signal
12
+ import subprocess # ruff: ignore[suspicious-subprocess-import] -- fixed argv, no shell, bounded timeout.
13
+ import sys
14
+ import threading
15
+ import time
16
+ import tomllib
17
+ from typing import TYPE_CHECKING, ClassVar, Literal, NamedTuple, Protocol
18
+
19
+ from pathspec import PathSpec
20
+ from pydantic import BaseModel, ConfigDict, Field
21
+
22
+ from sarj_standards.libs.adoption import manifest, packagemanager
23
+ from sarj_standards.libs.adoption.lifecycle import select_eslint_commands
24
+ from sarj_standards.libs.diagnostics import (
25
+ AnalyzerId,
26
+ Completion,
27
+ Diagnostic,
28
+ ExecutionIssue,
29
+ InvocationId,
30
+ Location,
31
+ Position,
32
+ Region,
33
+ Severity,
34
+ SourceDocument,
35
+ ToolReport,
36
+ TrustMode,
37
+ )
38
+
39
+ from .runner import GroupedPaths, group_paths
40
+
41
+
42
+ if TYPE_CHECKING:
43
+ from collections.abc import Sequence
44
+ from typing import BinaryIO
45
+
46
+ from .policy import Policy
47
+
48
+
49
+ class _ReactDoctorSelection(NamedTuple):
50
+ project: Path
51
+ projects: tuple[Path, ...]
52
+
53
+
54
+ class _PreparedInputs(NamedTuple):
55
+ repository: Path
56
+ selected: tuple[str, ...]
57
+ grouped: GroupedPaths
58
+
59
+
60
+ _TIMEOUT = timedelta(seconds=120)
61
+ _ESLINT_ERROR = 2
62
+ _MAX_STDOUT_BYTES = 16 * 1024 * 1024
63
+ _MAX_STDERR_BYTES = 64 * 1024
64
+ _READ_BYTES = 64 * 1024
65
+ _MAX_ESLINT_PROJECTS = 32
66
+ _MAX_PYTHON_PROJECTS = 32
67
+ _ANALYSIS_DEADLINE = timedelta(seconds=300)
68
+ _REACT_DOCTOR_MAX_DURATION = timedelta(seconds=60)
69
+ _REACT_RUNTIME_PACKAGES = frozenset(
70
+ {
71
+ "@astrojs/react",
72
+ "@vitejs/plugin-react",
73
+ "@vitejs/plugin-react-swc",
74
+ "expo",
75
+ "next",
76
+ "preact",
77
+ "react",
78
+ "react-dom",
79
+ "react-native",
80
+ }
81
+ )
82
+ _JAVASCRIPT_SCAN_SKIP_DIRS = frozenset(
83
+ {
84
+ ".git",
85
+ ".next",
86
+ ".open-next",
87
+ ".turbo",
88
+ ".yarn",
89
+ "build",
90
+ "coverage",
91
+ "dist",
92
+ "node_modules",
93
+ "out",
94
+ "vendor",
95
+ }
96
+ )
97
+ _SAFE_ENVIRONMENT_KEYS = frozenset(
98
+ {
99
+ "HOME",
100
+ "LANG",
101
+ "PATH",
102
+ "SHELL",
103
+ "SYSTEMROOT",
104
+ "TEMP",
105
+ "TMP",
106
+ "TMPDIR",
107
+ "WINDIR",
108
+ }
109
+ )
110
+
111
+
112
+ class _ReactDoctorProtocolModel(BaseModel):
113
+ """Strictly type every React Doctor field consumed by the adapter."""
114
+
115
+ model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore", strict=True)
116
+
117
+
118
+ class _ReactDoctorFailure(_ReactDoctorProtocolModel):
119
+ message: str = Field(min_length=1)
120
+
121
+
122
+ class _ReactDoctorDiagnostic(_ReactDoctorProtocolModel):
123
+ file_path: str = Field(alias="filePath", min_length=1)
124
+ plugin: str = Field(min_length=1)
125
+ rule: str = Field(min_length=1)
126
+ severity: Literal["error", "warning"]
127
+ message: str = Field(min_length=1)
128
+ line: int = Field(ge=0)
129
+ column: int = Field(ge=0)
130
+ end_line: int | None = Field(default=None, alias="endLine", ge=0)
131
+ end_column: int | None = Field(default=None, alias="endColumn", ge=0)
132
+ url: str | None = None
133
+
134
+
135
+ class _ReactDoctorProject(_ReactDoctorProtocolModel):
136
+ directory: str = Field(min_length=1)
137
+ complete: bool
138
+ diagnostics: tuple[_ReactDoctorDiagnostic, ...] = ()
139
+
140
+
141
+ class _ReactDoctorReport(_ReactDoctorProtocolModel):
142
+ schema_version: Literal[3] = Field(alias="schemaVersion")
143
+ version: str = Field(min_length=1)
144
+ ok: bool
145
+ projects: tuple[_ReactDoctorProject, ...]
146
+ skipped_projects: tuple[object, ...] = Field(default=(), alias="skippedProjects")
147
+ error: _ReactDoctorFailure | None
148
+
149
+
150
+ class _BasedPyrightPosition(BaseModel):
151
+ model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore", strict=True)
152
+
153
+ line: int = Field(ge=0)
154
+ character: int = Field(ge=0)
155
+
156
+
157
+ class _BasedPyrightRange(BaseModel):
158
+ model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore", strict=True)
159
+
160
+ start: _BasedPyrightPosition
161
+ end: _BasedPyrightPosition
162
+
163
+
164
+ class _BasedPyrightDiagnostic(BaseModel):
165
+ model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore", strict=True)
166
+
167
+ file: str = Field(min_length=1)
168
+ severity: str = Field(min_length=1)
169
+ message: str = Field(min_length=1)
170
+ rule: str | None = None
171
+ range: _BasedPyrightRange
172
+
173
+
174
+ class _BasedPyrightReport(BaseModel):
175
+ model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore", strict=True)
176
+
177
+ general_diagnostics: tuple[_BasedPyrightDiagnostic, ...] = Field(alias="generalDiagnostics")
178
+
179
+
180
+ class _ExternalSeverity(StrEnum):
181
+ ERROR = "error"
182
+ WARNING = "warning"
183
+ INFORMATION = "information"
184
+
185
+
186
+ @dataclass(frozen=True, slots=True)
187
+ class ProcessOutput:
188
+ returncode: int
189
+ stdout: str
190
+ stderr: str
191
+
192
+
193
+ class OutputLimitError(OSError):
194
+ """An analyzer exceeded the memory-safe structured-output contract."""
195
+
196
+
197
+ class ProcessRunner(Protocol):
198
+ def __call__(self, argv: Sequence[str], *, cwd: Path) -> ProcessOutput: ...
199
+
200
+
201
+ def analyze_external(
202
+ files: Sequence[str],
203
+ *,
204
+ root: Path,
205
+ trust: TrustMode | str,
206
+ runner: ProcessRunner | None = None,
207
+ policy: Policy | None = None,
208
+ capabilities: frozenset[str] | None = None,
209
+ grouped: GroupedPaths | None = None,
210
+ include_react_doctor: bool = False,
211
+ react_doctor_staged: bool = False,
212
+ ) -> tuple[ToolReport, ...]:
213
+ execute = run_process if runner is None else runner
214
+ try:
215
+ normalized_trust = TrustMode(trust)
216
+ root, _contained, routed = _prepare_inputs(files, root, policy=policy, grouped=grouped)
217
+ except (OSError, ValueError) as exc:
218
+ issue = ExecutionIssue("external", "invalid-input", str(exc))
219
+ return (ToolReport("external", Completion.FAILED, issues=(issue,)),)
220
+ reports: list[ToolReport] = []
221
+ if routed.python:
222
+ if capabilities is None or "ruff" in capabilities:
223
+ reports.extend(
224
+ _invoke_ruff_projects(
225
+ routed.python,
226
+ root=root,
227
+ runner=execute,
228
+ )
229
+ )
230
+ if capabilities is None or "pyright" in capabilities:
231
+ reports.extend(
232
+ _invoke_python_projects(
233
+ "basedpyright",
234
+ routed.python,
235
+ root=root,
236
+ runner=execute,
237
+ parser=parse_basedpyright,
238
+ )
239
+ )
240
+ if capabilities is not None and "eslint" not in capabilities:
241
+ eslint_commands = ()
242
+ unowned_eslint = 0
243
+ else:
244
+ try:
245
+ eslint_commands, unowned_eslint = select_eslint_commands(root, routed.typescript, label="analysis")
246
+ except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
247
+ message = _redact_message(f"{type(exc).__name__}: {exc}", root)
248
+ issue = ExecutionIssue("eslint", "configuration-failure", message)
249
+ reports.append(ToolReport("eslint", Completion.FAILED, issues=(issue,)))
250
+ return tuple(reports)
251
+ if unowned_eslint:
252
+ issue = ExecutionIssue(
253
+ "eslint",
254
+ "coverage-missing",
255
+ f"no TypeScript project accepts {unowned_eslint} selected JavaScript/TypeScript path(s)",
256
+ )
257
+ reports.append(
258
+ ToolReport(
259
+ "eslint",
260
+ Completion.FAILED,
261
+ issues=(issue,),
262
+ analyzer_id=AnalyzerId("eslint"),
263
+ invocation_id=InvocationId("eslint:unowned"),
264
+ file_count=unowned_eslint,
265
+ )
266
+ )
267
+ if len(eslint_commands) > _MAX_ESLINT_PROJECTS:
268
+ issue = ExecutionIssue(
269
+ "eslint",
270
+ "project-limit",
271
+ f"selected {len(eslint_commands)} ESLint projects; maximum is {_MAX_ESLINT_PROJECTS}",
272
+ )
273
+ reports.append(ToolReport("eslint", Completion.FAILED, issues=(issue,)))
274
+ eslint_commands = ()
275
+ analysis_started = time.monotonic()
276
+ for command in eslint_commands:
277
+ if time.monotonic() - analysis_started >= _ANALYSIS_DEADLINE.total_seconds():
278
+ issue = ExecutionIssue("eslint", "aggregate-timeout", "ESLint aggregate analysis exceeded 300 seconds")
279
+ reports.append(ToolReport("eslint", Completion.FAILED, issues=(issue,)))
280
+ break
281
+ if normalized_trust is TrustMode.SAFE:
282
+ issue = ExecutionIssue(
283
+ "eslint",
284
+ "trust-required",
285
+ "ESLint config is executable repository code; retry with TrustMode.TRUSTED",
286
+ )
287
+ reports.append(ToolReport("eslint", Completion.FAILED, issues=(issue,)))
288
+ continue
289
+ if runner is None and (issue := _missing_eslint_issue(command.cwd, root)) is not None:
290
+ reports.append(
291
+ ToolReport(
292
+ "eslint",
293
+ Completion.FAILED,
294
+ issues=(issue,),
295
+ analyzer_id=AnalyzerId("eslint"),
296
+ invocation_id=InvocationId(f"eslint:{command.cwd.relative_to(root).as_posix() or '.'}"),
297
+ file_count=_argv_file_count(command.argv),
298
+ )
299
+ )
300
+ continue
301
+ reports.append(
302
+ _invoke(
303
+ "eslint",
304
+ _local_eslint_argv(_eslint_json_argv(command.argv), command.cwd, root)
305
+ if runner is None
306
+ else _eslint_json_argv(command.argv),
307
+ cwd=command.cwd,
308
+ root=root,
309
+ runner=execute,
310
+ parser=parse_eslint,
311
+ invocation_id=command.cwd.relative_to(root).as_posix() or ".",
312
+ file_count=_argv_file_count(command.argv),
313
+ )
314
+ )
315
+ react_selection = _selected_react_doctor_projects(
316
+ root,
317
+ enabled=include_react_doctor,
318
+ has_typescript=bool(routed.typescript),
319
+ capabilities=capabilities,
320
+ )
321
+ if react_selection is not None:
322
+ react_root, react_projects = react_selection
323
+ reports.append(
324
+ _invoke_react_doctor(
325
+ react_root,
326
+ projects=react_projects,
327
+ root=root,
328
+ runner=execute,
329
+ use_local_binary=runner is None,
330
+ file_count=len(routed.typescript),
331
+ staged=react_doctor_staged,
332
+ )
333
+ )
334
+ if policy is None:
335
+ return tuple(reports)
336
+ return tuple(
337
+ ToolReport(
338
+ report.name,
339
+ report.completion,
340
+ diagnostics=policy.filter_diagnostics(report.diagnostics),
341
+ issues=report.issues,
342
+ analyzer_id=report.analyzer_id,
343
+ invocation_id=report.invocation_id,
344
+ version=report.version,
345
+ duration_ms=report.duration_ms,
346
+ file_count=report.file_count,
347
+ cache_status=report.cache_status,
348
+ )
349
+ for report in reports
350
+ )
351
+
352
+
353
+ def _missing_eslint_issue(project: Path, root: Path) -> ExecutionIssue | None:
354
+ current = project.resolve()
355
+ repository = root.resolve()
356
+ while True:
357
+ if (current / ".pnp.cjs").is_file() or (current / ".pnp.loader.mjs").is_file():
358
+ return None
359
+ binaries = current / "node_modules" / ".bin"
360
+ if (binaries / "eslint").is_file() or (binaries / "eslint.cmd").is_file():
361
+ return None
362
+ if current.parent == current:
363
+ break
364
+ current = current.parent
365
+ relative = project.resolve().relative_to(repository).as_posix() or "."
366
+ message = (
367
+ f"ESLint is not installed locally for {relative}; node_modules/.bin/eslint is missing. "
368
+ "Run the repository's locked package install or rerun `code-standards setup`, then retry."
369
+ )
370
+ return ExecutionIssue("eslint", "missing-dependency", message)
371
+
372
+
373
+ def _local_eslint_argv(argv: Sequence[str], project: Path, root: Path) -> tuple[str, ...]:
374
+ current = project.resolve()
375
+ while True:
376
+ if (current / ".pnp.cjs").is_file() or (current / ".pnp.loader.mjs").is_file():
377
+ return tuple(argv)
378
+ binaries = current / "node_modules" / ".bin"
379
+ binary = binaries / ("eslint.cmd" if os.name == "nt" else "eslint")
380
+ if binary.is_file():
381
+ try:
382
+ tail = tuple(argv[argv.index("eslint") + 1 :])
383
+ except ValueError as exc:
384
+ msg = "ESLint command does not contain an eslint executable"
385
+ raise ValueError(msg) from exc
386
+ if os.name == "nt":
387
+ return ("cmd.exe", "/d", "/s", "/c", str(binary), *tail)
388
+ return (str(binary), *tail)
389
+ if current.parent == current:
390
+ break
391
+ current = current.parent
392
+ # The preflight owns the user-facing missing-dependency error. Reaching
393
+ # this branch means the filesystem changed between preflight and launch.
394
+ relative = project.resolve().relative_to(root.resolve()).as_posix() or "."
395
+ msg = f"local ESLint disappeared before execution for {relative}"
396
+ raise OSError(msg)
397
+
398
+
399
+ def _selected_react_doctor_projects(
400
+ root: Path,
401
+ *,
402
+ enabled: bool,
403
+ has_typescript: bool,
404
+ capabilities: frozenset[str] | None,
405
+ ) -> _ReactDoctorSelection | None:
406
+ if not enabled or not has_typescript or (capabilities is not None and "eslint" not in capabilities):
407
+ return None
408
+ adopted = manifest.load(root)
409
+ project = _react_doctor_root(root, adopted=adopted)
410
+ excluded = () if adopted is None else adopted.doctor_excluded_paths
411
+ if project is None or not (projects := _react_project_roots(project, repository=root, excluded=excluded)):
412
+ return None
413
+ return _ReactDoctorSelection(project, projects)
414
+
415
+
416
+ def _react_doctor_root(root: Path, *, adopted: manifest.Manifest | None = None) -> Path | None:
417
+ if adopted is None:
418
+ adopted = manifest.load(root)
419
+ candidate = root if adopted is None else (root / adopted.typescript_dest).resolve()
420
+ return candidate if candidate.is_dir() else None
421
+
422
+
423
+ def _react_project_roots(
424
+ root: Path,
425
+ *,
426
+ repository: Path | None = None,
427
+ excluded: Sequence[str] = (),
428
+ ) -> tuple[Path, ...]:
429
+ repository_root = root.resolve() if repository is None else repository.resolve()
430
+ exclusions = PathSpec.from_lines("gitignore", excluded)
431
+ projects: list[Path] = []
432
+ for parent, directories, filenames in os.walk(root, topdown=True, followlinks=False):
433
+ directory = Path(parent).resolve()
434
+ relative = directory.relative_to(repository_root).as_posix()
435
+ if relative and exclusions.match_file(relative):
436
+ directories[:] = []
437
+ continue
438
+ directories[:] = sorted(name for name in directories if name not in _JAVASCRIPT_SCAN_SKIP_DIRS)
439
+ if "package.json" not in filenames:
440
+ continue
441
+ package_json = directory / "package.json"
442
+ try:
443
+ parsed: object = json.loads(package_json.read_text(encoding="utf-8")) # pyright: ignore[reportAny]
444
+ except OSError, ValueError:
445
+ continue
446
+ document = manifest.as_table(parsed)
447
+ declared: set[str] = set()
448
+ for field in ("dependencies", "devDependencies", "peerDependencies"):
449
+ declared.update(manifest.table_field(document, field))
450
+ if declared.intersection(_REACT_RUNTIME_PACKAGES):
451
+ projects.append(directory)
452
+ return tuple(projects)
453
+
454
+
455
+ def _invoke_react_doctor(
456
+ project: Path,
457
+ *,
458
+ projects: Sequence[Path],
459
+ root: Path,
460
+ runner: ProcessRunner,
461
+ use_local_binary: bool,
462
+ file_count: int,
463
+ staged: bool,
464
+ ) -> ToolReport:
465
+ name = "react-doctor"
466
+ if use_local_binary and (issue := _missing_local_binary_issue(name, project, root)) is not None:
467
+ return ToolReport(
468
+ name,
469
+ Completion.FAILED,
470
+ issues=(issue,),
471
+ analyzer_id=AnalyzerId(name),
472
+ invocation_id=InvocationId(name),
473
+ file_count=file_count,
474
+ )
475
+ install_root = packagemanager.workspace_root(project, root)
476
+ client = packagemanager.detect(install_root)
477
+ # React Doctor is introduced as a no-baseline ratchet: hooks inspect the
478
+ # index, while whole-repository Standards runs block only diagnostics that
479
+ # are new relative to the detected merge base. A standalone React Doctor
480
+ # full scan remains available for deliberate debt cleanup.
481
+ scope_args = _react_doctor_scope_args(staged=staged)
482
+ argv = packagemanager.exec_argv(
483
+ client,
484
+ name,
485
+ ".",
486
+ "--project",
487
+ ",".join(item.relative_to(project).as_posix() or "." for item in projects),
488
+ *scope_args,
489
+ "--blocking",
490
+ "warning",
491
+ "--no-dead-code",
492
+ "--no-supply-chain",
493
+ "--no-score",
494
+ "--no-cache",
495
+ "--max-duration",
496
+ str(int(_REACT_DOCTOR_MAX_DURATION.total_seconds())),
497
+ "--json",
498
+ "--json-compact",
499
+ "--no-color",
500
+ )
501
+ report = _invoke(
502
+ name,
503
+ _local_node_binary_argv(name, argv, project, root) if use_local_binary else argv,
504
+ cwd=project,
505
+ root=root,
506
+ runner=runner,
507
+ parser=parse_react_doctor,
508
+ invocation_id=project.relative_to(root).as_posix() or None,
509
+ file_count=file_count,
510
+ )
511
+ return _filter_react_doctor_to_changed_paths(report, root=root, runner=runner, staged=staged)
512
+
513
+
514
+ def _filter_react_doctor_to_changed_paths(
515
+ report: ToolReport,
516
+ *,
517
+ root: Path,
518
+ runner: ProcessRunner,
519
+ staged: bool,
520
+ ) -> ToolReport:
521
+ if staged:
522
+ return report
523
+ base = change_scope_base()
524
+ if not base:
525
+ return report
526
+ changed = runner(
527
+ ("git", "diff", "--name-only", "--diff-filter=ACMR", "-z", f"{base}...HEAD", "--"),
528
+ cwd=root,
529
+ )
530
+ if changed.returncode != 0:
531
+ return report
532
+ changed_paths = frozenset(path for path in changed.stdout.split("\0") if path)
533
+ return replace(
534
+ report,
535
+ diagnostics=tuple(item for item in report.diagnostics if item.location.path in changed_paths),
536
+ )
537
+
538
+
539
+ def _react_doctor_scope_args(*, staged: bool) -> tuple[str, ...]:
540
+ if staged:
541
+ return ("--staged",)
542
+ base = change_scope_base()
543
+ if base:
544
+ return ("--scope", "changed", "--base", base)
545
+ return ("--scope", "changed")
546
+
547
+
548
+ def change_scope_base() -> str:
549
+ explicit = os.environ.get( # ruff: ignore[banned-api] -- explicit CI workflow boundary, not application settings.
550
+ "SARJ_REACT_DOCTOR_BASE", ""
551
+ ).strip()
552
+ if explicit:
553
+ return explicit
554
+ event_path = os.environ.get( # ruff: ignore[banned-api] -- GitHub owns this path in Actions.
555
+ "GITHUB_EVENT_PATH", ""
556
+ ).strip()
557
+ if not event_path:
558
+ return ""
559
+ try:
560
+ payload: object = json.loads(Path(event_path).read_text(encoding="utf-8")) # pyright: ignore[reportAny]
561
+ except OSError, json.JSONDecodeError:
562
+ return ""
563
+ pull_request = manifest.as_table(manifest.as_table(payload).get("pull_request"))
564
+ base = manifest.as_table(pull_request.get("base"))
565
+ sha = manifest.text_field(base, "sha")
566
+ return sha if sha is not None and re.fullmatch(r"[0-9a-f]{40}", sha) else ""
567
+
568
+
569
+ def is_non_default_github_push() -> bool:
570
+ event_name = os.environ.get( # ruff: ignore[banned-api] -- GitHub owns this value in Actions.
571
+ "GITHUB_EVENT_NAME", ""
572
+ ).strip()
573
+ if event_name != "push":
574
+ return False
575
+ event_path = os.environ.get( # ruff: ignore[banned-api] -- GitHub owns this path in Actions.
576
+ "GITHUB_EVENT_PATH", ""
577
+ ).strip()
578
+ if not event_path:
579
+ return False
580
+ try:
581
+ payload: object = json.loads(Path(event_path).read_text(encoding="utf-8")) # pyright: ignore[reportAny]
582
+ except OSError, json.JSONDecodeError:
583
+ return False
584
+ event = manifest.as_table(payload)
585
+ if manifest.as_table(event.get("pull_request")):
586
+ return False
587
+ ref = manifest.text_field(event, "ref")
588
+ repository = manifest.as_table(event.get("repository"))
589
+ default_branch = manifest.text_field(repository, "default_branch")
590
+ prefix = "refs/heads/"
591
+ return bool(ref and default_branch and ref.startswith(prefix) and ref.removeprefix(prefix) != default_branch)
592
+
593
+
594
+ def _missing_local_binary_issue(name: str, project: Path, root: Path) -> ExecutionIssue | None:
595
+ current = project.resolve()
596
+ repository = root.resolve()
597
+ while current.is_relative_to(repository):
598
+ if (current / ".pnp.cjs").is_file() or (current / ".pnp.loader.mjs").is_file():
599
+ return None
600
+ binaries = current / "node_modules" / ".bin"
601
+ if (binaries / name).is_file() or (binaries / f"{name}.cmd").is_file():
602
+ return None
603
+ if current == repository:
604
+ break
605
+ current = current.parent
606
+ relative = project.relative_to(repository).as_posix() or "."
607
+ message = (
608
+ f"{name} is not installed locally for {relative}; node_modules/.bin/{name} is missing. "
609
+ "Run the repository's locked package install or rerun `code-standards setup`, then retry."
610
+ )
611
+ return ExecutionIssue(name, "missing-dependency", message)
612
+
613
+
614
+ def _local_node_binary_argv(name: str, argv: Sequence[str], project: Path, root: Path) -> tuple[str, ...]:
615
+ current = project.resolve()
616
+ repository = root.resolve()
617
+ while current.is_relative_to(repository):
618
+ if (current / ".pnp.cjs").is_file() or (current / ".pnp.loader.mjs").is_file():
619
+ return tuple(argv)
620
+ binary = current / "node_modules" / ".bin" / (f"{name}.cmd" if os.name == "nt" else name)
621
+ if binary.is_file():
622
+ try:
623
+ tail = tuple(argv[argv.index(name) + 1 :])
624
+ except ValueError as exc:
625
+ msg = f"analyzer command does not contain {name!r}"
626
+ raise ValueError(msg) from exc
627
+ if os.name == "nt":
628
+ return ("cmd.exe", "/d", "/s", "/c", str(binary), *tail)
629
+ return (str(binary), *tail)
630
+ if current == repository:
631
+ break
632
+ current = current.parent
633
+ msg = f"local {name} disappeared before execution"
634
+ raise OSError(msg)
635
+
636
+
637
+ def _invoke_python_projects(
638
+ name: str,
639
+ files: Sequence[str],
640
+ *,
641
+ root: Path,
642
+ runner: ProcessRunner,
643
+ parser: ProtocolParser,
644
+ ) -> tuple[ToolReport, ...]:
645
+ projects = _group_python_projects(files, root)
646
+ if len(projects) > _MAX_PYTHON_PROJECTS:
647
+ issue = ExecutionIssue(
648
+ name,
649
+ "project-limit",
650
+ f"selected {len(projects)} Python projects; maximum is {_MAX_PYTHON_PROJECTS}",
651
+ )
652
+ return (ToolReport(name, Completion.FAILED, issues=(issue,), analyzer_id=AnalyzerId(name)),)
653
+ reports: list[ToolReport] = []
654
+ for project, scoped_files in projects:
655
+ argv = (_project_analyzer(project, "basedpyright"), "--outputjson")
656
+ project_id = project.relative_to(root).as_posix() or None
657
+ report = _invoke(
658
+ name,
659
+ argv,
660
+ cwd=project,
661
+ root=root,
662
+ runner=runner,
663
+ parser=parser,
664
+ invocation_id=project_id,
665
+ file_count=len(scoped_files),
666
+ )
667
+ selected = frozenset(_relative(Path(path), root) for path in scoped_files)
668
+ reports.append(
669
+ ToolReport(
670
+ report.name,
671
+ report.completion,
672
+ diagnostics=tuple(
673
+ diagnostic for diagnostic in report.diagnostics if diagnostic.location.path in selected
674
+ ),
675
+ issues=report.issues,
676
+ analyzer_id=report.analyzer_id,
677
+ invocation_id=report.invocation_id,
678
+ version=report.version,
679
+ duration_ms=report.duration_ms,
680
+ file_count=report.file_count,
681
+ cache_status=report.cache_status,
682
+ )
683
+ )
684
+ return tuple(reports)
685
+
686
+
687
+ def _invoke_ruff_projects(
688
+ files: Sequence[str],
689
+ *,
690
+ root: Path,
691
+ runner: ProcessRunner,
692
+ ) -> tuple[ToolReport, ...]:
693
+ reports: list[ToolReport] = []
694
+ for project, config, scoped_files in _group_ruff_projects(files, root):
695
+ project_id = None if project == root else project.relative_to(root).as_posix()
696
+ reports.append(
697
+ _invoke(
698
+ "ruff",
699
+ _ruff_argv(scoped_files, config=config),
700
+ cwd=project,
701
+ root=root,
702
+ runner=runner,
703
+ parser=parse_ruff,
704
+ invocation_id=project_id,
705
+ file_count=len(scoped_files),
706
+ )
707
+ )
708
+ return tuple(reports)
709
+
710
+
711
+ def _project_analyzer(project: Path, name: str) -> str:
712
+ candidates = (
713
+ project / ".venv" / "bin" / name,
714
+ project / ".venv" / "Scripts" / f"{name}.exe",
715
+ )
716
+ return str(next((candidate for candidate in candidates if candidate.is_file()), name))
717
+
718
+
719
+ def _group_python_projects(files: Sequence[str], root: Path) -> tuple[tuple[Path, tuple[str, ...]], ...]:
720
+ fallback = _adopted_python_project(root)
721
+ grouped: dict[Path, list[str]] = {}
722
+ for raw_file in files:
723
+ path = Path(raw_file).resolve()
724
+ project = _nearest_analyzer_project(path.parent, root, fallback=fallback)
725
+ grouped.setdefault(project, []).append(str(path))
726
+ return tuple(
727
+ (project, tuple(sorted(scoped_files)))
728
+ for project, scoped_files in sorted(grouped.items(), key=lambda item: str(item[0]))
729
+ )
730
+
731
+
732
+ def _nearest_analyzer_project(start: Path, root: Path, *, fallback: Path | None = None) -> Path:
733
+ configured = _nearest_configured_python_project(start, root, names=("pyright", "basedpyright"))
734
+ if configured is not None:
735
+ return configured
736
+ current = start
737
+ while current.is_relative_to(root):
738
+ if any(
739
+ candidate.is_file()
740
+ for candidate in (
741
+ current / ".venv" / "bin" / "basedpyright",
742
+ current / ".venv" / "Scripts" / "basedpyright.exe",
743
+ )
744
+ ):
745
+ return current
746
+ if current == root:
747
+ break
748
+ current = current.parent
749
+ return fallback or _nearest_project(start, root, ("pyproject.toml",))
750
+
751
+
752
+ def _group_ruff_projects(files: Sequence[str], root: Path) -> tuple[tuple[Path, Path | None, tuple[str, ...]], ...]:
753
+ fallback = _adopted_python_config(root)
754
+ grouped: dict[tuple[Path, Path | None], list[str]] = {}
755
+ for raw_file in files:
756
+ path = Path(raw_file).resolve()
757
+ config = _nearest_ruff_config(path.parent, root) or fallback
758
+ project = root if config is None else config.parent
759
+ grouped.setdefault((project, config), []).append(str(path))
760
+ return tuple(
761
+ (project, config, tuple(sorted(scoped_files)))
762
+ for (project, config), scoped_files in sorted(grouped.items(), key=lambda item: str(item[0][0]))
763
+ )
764
+
765
+
766
+ def _nearest_ruff_config(start: Path, root: Path) -> Path | None:
767
+ current = start
768
+ while current.is_relative_to(root):
769
+ for name in (".ruff.toml", "ruff.toml"):
770
+ candidate = current / name
771
+ if candidate.is_file():
772
+ return candidate
773
+ pyproject = current / "pyproject.toml"
774
+ if pyproject.is_file() and _pyproject_has_tool(pyproject, ("ruff",)):
775
+ return pyproject
776
+ if current == root:
777
+ break
778
+ current = current.parent
779
+ return None
780
+
781
+
782
+ def _adopted_python_config(root: Path) -> Path | None:
783
+ project = _adopted_python_project(root)
784
+ return None if project is None else _nearest_ruff_config(project, root)
785
+
786
+
787
+ def _adopted_python_project(root: Path) -> Path | None:
788
+ try:
789
+ adopted = manifest.load(root)
790
+ except OSError, TypeError, ValueError:
791
+ return None
792
+ if adopted is None:
793
+ return None
794
+ project = (root / adopted.python_dest).resolve()
795
+ return project if project.is_dir() else None
796
+
797
+
798
+ def _nearest_configured_python_project(start: Path, root: Path, *, names: Sequence[str]) -> Path | None:
799
+ current = start
800
+ while current.is_relative_to(root):
801
+ if (current / "pyrightconfig.json").is_file() or (current / "pyrightconfig.jsonc").is_file():
802
+ return current
803
+ pyproject = current / "pyproject.toml"
804
+ if pyproject.is_file() and _pyproject_has_tool(pyproject, names):
805
+ return current
806
+ if current == root:
807
+ break
808
+ current = current.parent
809
+ return None
810
+
811
+
812
+ def _pyproject_has_tool(path: Path, names: Sequence[str]) -> bool:
813
+ try:
814
+ parsed: object = tomllib.loads(path.read_text(encoding="utf-8"))
815
+ except OSError, tomllib.TOMLDecodeError:
816
+ return False
817
+ document = manifest.as_table(parsed)
818
+ tool = manifest.as_table(document.get("tool"))
819
+ return any(isinstance(tool.get(name), dict) for name in names)
820
+
821
+
822
+ def _nearest_project(start: Path, root: Path, markers: Sequence[str]) -> Path:
823
+ current = start
824
+ while current.is_relative_to(root):
825
+ if any((current / marker).is_file() for marker in markers):
826
+ return current
827
+ if current == root:
828
+ break
829
+ current = current.parent
830
+ return root
831
+
832
+
833
+ def run_process(argv: Sequence[str], *, cwd: Path) -> ProcessOutput:
834
+ executable = _analyzer_executable(argv[0])
835
+ if executable is None:
836
+ msg = f"required analyzer executable is missing: {argv[0]}"
837
+ raise FileNotFoundError(msg)
838
+ process = subprocess.Popen( # ruff: ignore[subprocess-without-shell-equals-true] -- fixed argv and shell stays disabled.
839
+ [executable, *argv[1:]],
840
+ cwd=cwd,
841
+ shell=False,
842
+ stdout=subprocess.PIPE,
843
+ stderr=subprocess.PIPE,
844
+ start_new_session=os.name == "posix",
845
+ env=_analysis_environment(),
846
+ )
847
+ stdout = process.stdout
848
+ stderr = process.stderr
849
+ if stdout is None or stderr is None: # pragma: no cover - PIPE guarantees streams.
850
+ process.kill()
851
+ msg = "analyzer process did not expose output pipes"
852
+ raise OSError(msg)
853
+ exceeded = threading.Event()
854
+ captures: list[bytes | None] = [None, None]
855
+ threads = (
856
+ threading.Thread(target=_capture_stream, args=(stdout, _MAX_STDOUT_BYTES, exceeded, captures, 0), daemon=True),
857
+ threading.Thread(target=_capture_stream, args=(stderr, _MAX_STDERR_BYTES, exceeded, captures, 1), daemon=True),
858
+ )
859
+ try:
860
+ _start_capture_threads(threads)
861
+ returncode = _wait_for_process(process, threads, exceeded, argv)
862
+ except BaseException:
863
+ if process.poll() is None:
864
+ _terminate_process(process)
865
+ try:
866
+ _ = process.wait(timeout=5)
867
+ except OSError, subprocess.SubprocessError:
868
+ process.kill()
869
+ for thread in threads:
870
+ if thread.ident is not None:
871
+ thread.join(timeout=5)
872
+ raise
873
+ if exceeded.is_set():
874
+ msg = "analyzer output exceeded the 16 MiB stdout or 64 KiB stderr limit"
875
+ raise OutputLimitError(msg)
876
+ stdout_bytes, stderr_bytes = captures
877
+ if stdout_bytes is None or stderr_bytes is None: # pragma: no cover - drain threads always assign.
878
+ msg = "analyzer process output could not be captured"
879
+ raise OSError(msg)
880
+ return ProcessOutput(
881
+ returncode,
882
+ stdout_bytes.decode("utf-8", errors="replace"),
883
+ stderr_bytes.decode("utf-8", errors="replace"),
884
+ )
885
+
886
+
887
+ def _analyzer_executable(name: str) -> str | None:
888
+ environment_bin = str(Path(sys.executable).parent)
889
+ return shutil.which(name, path=environment_bin) or shutil.which(name)
890
+
891
+
892
+ def _analysis_environment() -> dict[str, str]:
893
+ return {
894
+ key: value
895
+ for key, value in os.environ.items() # ruff: ignore[banned-api] -- deliberately reduce inherited environment.
896
+ if key in _SAFE_ENVIRONMENT_KEYS or key.startswith("LC_")
897
+ }
898
+
899
+
900
+ def _start_capture_threads(threads: Sequence[threading.Thread]) -> None:
901
+ for thread in threads:
902
+ thread.start()
903
+
904
+
905
+ def _wait_for_process(
906
+ process: subprocess.Popen[bytes],
907
+ threads: Sequence[threading.Thread],
908
+ exceeded: threading.Event,
909
+ argv: Sequence[str],
910
+ ) -> int:
911
+ deadline = time.monotonic() + _TIMEOUT.total_seconds()
912
+ while process.poll() is None and not exceeded.is_set():
913
+ if time.monotonic() >= deadline:
914
+ _terminate_process(process)
915
+ _ = process.wait(timeout=5)
916
+ _join_capture_threads(threads)
917
+ raise subprocess.TimeoutExpired(argv, _TIMEOUT.total_seconds())
918
+ time.sleep(0.01)
919
+ if exceeded.is_set():
920
+ _terminate_process(process)
921
+ returncode = process.wait(timeout=5 if exceeded.is_set() else None)
922
+ _join_capture_threads(threads)
923
+ return returncode
924
+
925
+
926
+ def _capture_stream(
927
+ stream: BinaryIO,
928
+ limit: int,
929
+ exceeded: threading.Event,
930
+ captures: list[bytes | None],
931
+ index: int,
932
+ ) -> None:
933
+ data = bytearray()
934
+ try:
935
+ while chunk := stream.read(_READ_BYTES):
936
+ remaining = limit - len(data)
937
+ if remaining > 0:
938
+ data.extend(chunk[:remaining])
939
+ if len(chunk) > remaining:
940
+ exceeded.set()
941
+ break
942
+ finally:
943
+ stream.close()
944
+ captures[index] = bytes(data)
945
+
946
+
947
+ def _terminate_process(process: subprocess.Popen[bytes]) -> None:
948
+ if os.name == "posix":
949
+ try:
950
+ os.killpg(process.pid, signal.SIGKILL)
951
+ except ProcessLookupError:
952
+ return
953
+ else: # pragma: no cover - Windows CI covers the process-tree strategy.
954
+ try:
955
+ taskkill = shutil.which("taskkill")
956
+ if taskkill is None:
957
+ msg = "taskkill is unavailable"
958
+ raise FileNotFoundError(msg)
959
+ subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] -- resolved system binary, fixed argv.
960
+ (taskkill, "/PID", str(process.pid), "/T", "/F"),
961
+ check=False,
962
+ capture_output=True,
963
+ shell=False,
964
+ timeout=5,
965
+ )
966
+ except OSError, subprocess.SubprocessError:
967
+ process.kill()
968
+ if process.poll() is None:
969
+ process.kill()
970
+
971
+
972
+ def _join_capture_threads(threads: Sequence[threading.Thread]) -> None:
973
+ for thread in threads:
974
+ thread.join(timeout=5)
975
+ if any(thread.is_alive() for thread in threads):
976
+ msg = "analyzer output streams did not close after process termination"
977
+ raise OSError(msg)
978
+
979
+
980
+ def _invoke(
981
+ name: str,
982
+ argv: Sequence[str],
983
+ *,
984
+ cwd: Path,
985
+ root: Path,
986
+ runner: ProcessRunner,
987
+ parser: ProtocolParser,
988
+ invocation_id: str | None = None,
989
+ file_count: int,
990
+ ) -> ToolReport:
991
+ started = time.monotonic()
992
+ try:
993
+ report = _invoke_unchecked(name, argv, cwd=cwd, root=root, runner=runner, parser=parser)
994
+ return ToolReport(
995
+ report.name,
996
+ report.completion,
997
+ diagnostics=report.diagnostics,
998
+ issues=report.issues,
999
+ analyzer_id=AnalyzerId(name),
1000
+ invocation_id=InvocationId(name if invocation_id is None else f"{name}:{invocation_id}"),
1001
+ duration_ms=round((time.monotonic() - started) * 1_000),
1002
+ file_count=file_count,
1003
+ )
1004
+ except (OSError, TypeError, ValueError, RecursionError, json.JSONDecodeError, subprocess.SubprocessError) as exc:
1005
+ message = _redact_message(f"{type(exc).__name__}: {exc}", root)
1006
+ issue = ExecutionIssue(name, "tool-failure", message)
1007
+ return ToolReport(
1008
+ name,
1009
+ Completion.FAILED,
1010
+ issues=(issue,),
1011
+ analyzer_id=AnalyzerId(name),
1012
+ invocation_id=InvocationId(name if invocation_id is None else f"{name}:{invocation_id}"),
1013
+ duration_ms=round((time.monotonic() - started) * 1_000),
1014
+ file_count=file_count,
1015
+ )
1016
+
1017
+
1018
+ def _invoke_unchecked(
1019
+ name: str,
1020
+ argv: Sequence[str],
1021
+ *,
1022
+ cwd: Path,
1023
+ root: Path,
1024
+ runner: ProcessRunner,
1025
+ parser: ProtocolParser,
1026
+ ) -> ToolReport:
1027
+ output = runner(argv, cwd=cwd)
1028
+ if output.returncode not in {0, 1}:
1029
+ message = _redact_message(output.stderr.strip() or f"{name} exited {output.returncode}", root)
1030
+ issue = ExecutionIssue(name, "tool-failure", message, output.returncode)
1031
+ return ToolReport(name, Completion.FAILED, issues=(issue,))
1032
+ if not output.stdout.strip():
1033
+ stderr = output.stderr.strip()
1034
+ kind = "tool-failure" if stderr else "protocol-mismatch"
1035
+ message = _redact_message(stderr or f"{name} returned empty structured output", root)
1036
+ issue = ExecutionIssue(name, kind, message, output.returncode)
1037
+ return ToolReport(name, Completion.FAILED, issues=(issue,))
1038
+ try:
1039
+ diagnostics = parser(output.stdout, root=root)
1040
+ except json.JSONDecodeError as exc:
1041
+ stderr = output.stderr.strip()
1042
+ message = stderr or f"{name} returned invalid structured JSON at line {exc.lineno}, column {exc.colno}"
1043
+ issue = ExecutionIssue(name, "protocol-mismatch", _redact_message(message, root), output.returncode)
1044
+ return ToolReport(name, Completion.FAILED, issues=(issue,))
1045
+ if output.returncode == 1 and not diagnostics:
1046
+ message = _redact_message(output.stderr.strip() or f"{name} exited 1 but reported no diagnostics", root)
1047
+ issue = ExecutionIssue(name, "protocol-mismatch", message, output.returncode)
1048
+ return ToolReport(name, Completion.FAILED, issues=(issue,))
1049
+ return ToolReport(name, Completion.COMPLETE, diagnostics=diagnostics)
1050
+
1051
+
1052
+ class ProtocolParser(Protocol):
1053
+ def __call__(self, payload: str, *, root: Path) -> tuple[Diagnostic, ...]: ...
1054
+
1055
+
1056
+ def parse_ruff(payload: str, *, root: Path) -> tuple[Diagnostic, ...]:
1057
+ values = _array(_loads(payload), "Ruff output")
1058
+ documents: dict[Path, SourceDocument | None] = {}
1059
+ diagnostics: list[Diagnostic] = []
1060
+ for value in values:
1061
+ item = _table(value, "Ruff diagnostic")
1062
+ path = _path(item, "filename", root)
1063
+ start = _one_based_position(_table(item.get("location"), "Ruff location"), path, documents)
1064
+ end_value = item.get("end_location")
1065
+ end = _one_based_position(_table(end_value, "Ruff end location"), path, documents)
1066
+ code = _text(item, "code")
1067
+ url_value = item.get("url")
1068
+ help_url = url_value if isinstance(url_value, str) else None
1069
+ diagnostics.append(
1070
+ Diagnostic(
1071
+ code,
1072
+ _redact_message(_text(item, "message"), root),
1073
+ Severity.ERROR,
1074
+ "ruff",
1075
+ Location(_relative(path, root), region=Region(start, end)),
1076
+ rule_id=code,
1077
+ help_url=help_url,
1078
+ )
1079
+ )
1080
+ return tuple(diagnostics)
1081
+
1082
+
1083
+ def parse_basedpyright(payload: str, *, root: Path) -> tuple[Diagnostic, ...]:
1084
+ report = _BasedPyrightReport.model_validate_json(payload)
1085
+ documents: dict[Path, SourceDocument | None] = {}
1086
+ diagnostics: list[Diagnostic] = []
1087
+ for item in report.general_diagnostics:
1088
+ path = _reported_path(item.file, root)
1089
+ start = _basedpyright_position(item.range.start, path, documents)
1090
+ end = _basedpyright_position(item.range.end, path, documents)
1091
+ rule = item.rule or "basedpyright"
1092
+ diagnostics.append(
1093
+ Diagnostic(
1094
+ rule,
1095
+ _redact_message(item.message, root),
1096
+ _severity_text(item.severity),
1097
+ "basedpyright",
1098
+ Location(_relative(path, root), region=Region(start, end)),
1099
+ rule_id=rule,
1100
+ )
1101
+ )
1102
+ return tuple(diagnostics)
1103
+
1104
+
1105
+ def parse_eslint( # ruff: ignore[too-many-locals] -- protocol normalization keeps each ESLint field explicit.
1106
+ payload: str, *, root: Path
1107
+ ) -> tuple[Diagnostic, ...]:
1108
+ values = _array(_loads(payload), "ESLint output")
1109
+ documents: dict[Path, SourceDocument | None] = {}
1110
+ diagnostics: list[Diagnostic] = []
1111
+ for value in values:
1112
+ result = _table(value, "ESLint file result")
1113
+ path = _path(result, "filePath", root)
1114
+ for raw_message in _array(result.get("messages"), "ESLint messages"):
1115
+ item = _table(raw_message, "ESLint diagnostic")
1116
+ if item.get("fatal") is True:
1117
+ detail = _text(item, "message")
1118
+ msg = f"ESLint fatal parser/configuration failure: {detail}"
1119
+ raise ValueError(msg)
1120
+ start = _eslint_start_position(item, path, documents)
1121
+ end = (
1122
+ None
1123
+ if start is None
1124
+ else _eslint_position(item, path, documents, line_key="endLine", column_key="endColumn")
1125
+ )
1126
+ rule_value = item.get("ruleId")
1127
+ rule = rule_value if isinstance(rule_value, str) else "eslint/file"
1128
+ relative_path = _relative(path, root)
1129
+ if start is None:
1130
+ location = Location(relative_path)
1131
+ elif end is not None:
1132
+ location = Location(relative_path, region=Region(start, end))
1133
+ else:
1134
+ location = Location(relative_path, position=start)
1135
+ severity_value = item.get("severity")
1136
+ if type(severity_value) is int and severity_value == _ESLINT_ERROR:
1137
+ severity = Severity.ERROR
1138
+ elif type(severity_value) is int and severity_value == 1:
1139
+ severity = Severity.WARNING
1140
+ else:
1141
+ msg = f"unsupported ESLint severity: {severity_value!r}"
1142
+ raise ValueError(msg)
1143
+ diagnostics.append(
1144
+ Diagnostic(
1145
+ rule,
1146
+ _redact_message(_text(item, "message"), root),
1147
+ severity,
1148
+ "eslint",
1149
+ location,
1150
+ rule_id=rule,
1151
+ )
1152
+ )
1153
+ return tuple(diagnostics)
1154
+
1155
+
1156
+ def parse_react_doctor(payload: str, *, root: Path) -> tuple[Diagnostic, ...]:
1157
+ report = _ReactDoctorReport.model_validate_json(payload)
1158
+ expected_version = manifest.eslint_peers()["react-doctor"]
1159
+ if report.version != expected_version:
1160
+ msg = f"React Doctor reported version {report.version!r}; expected {expected_version!r}"
1161
+ raise ValueError(msg)
1162
+ if report.error is not None:
1163
+ msg = f"React Doctor scan failed: {report.error.message}"
1164
+ raise ValueError(msg)
1165
+ if not report.ok:
1166
+ msg = "React Doctor report did not complete successfully"
1167
+ raise ValueError(msg)
1168
+ if report.skipped_projects:
1169
+ msg = f"React Doctor skipped {len(report.skipped_projects)} project(s) before analysis"
1170
+ raise ValueError(msg)
1171
+
1172
+ documents: dict[Path, SourceDocument | None] = {}
1173
+ diagnostics: list[Diagnostic] = []
1174
+ for project in report.projects:
1175
+ if not project.complete:
1176
+ msg = f"React Doctor project did not complete: {project.directory!r}"
1177
+ raise ValueError(msg)
1178
+ directory = _contained_report_directory(project, root)
1179
+ for item in project.diagnostics:
1180
+ path = _react_doctor_path(item, directory, root)
1181
+ location = _react_doctor_location(item, path, root, documents)
1182
+ rule = f"{item.plugin}/{item.rule}"
1183
+ diagnostics.append(
1184
+ Diagnostic(
1185
+ rule,
1186
+ _redact_message(item.message, root),
1187
+ Severity.ERROR,
1188
+ "react-doctor",
1189
+ location,
1190
+ rule_id=rule,
1191
+ help_url=item.url,
1192
+ )
1193
+ )
1194
+ return tuple(diagnostics)
1195
+
1196
+
1197
+ def _contained_report_directory(project: _ReactDoctorProject, root: Path) -> Path:
1198
+ directory = Path(project.directory)
1199
+ resolved = (directory if directory.is_absolute() else root / directory).resolve()
1200
+ try:
1201
+ resolved.relative_to(root.resolve())
1202
+ except ValueError as exc:
1203
+ msg = "React Doctor reported a project outside the repository root"
1204
+ raise ValueError(msg) from exc
1205
+ return resolved
1206
+
1207
+
1208
+ def _react_doctor_path(item: _ReactDoctorDiagnostic, directory: Path, root: Path) -> Path:
1209
+ raw_path = Path(item.file_path)
1210
+ resolved = (raw_path if raw_path.is_absolute() else directory / raw_path).resolve()
1211
+ try:
1212
+ resolved.relative_to(root.resolve())
1213
+ except ValueError as exc:
1214
+ msg = "React Doctor reported a path outside the repository root"
1215
+ raise ValueError(msg) from exc
1216
+ return resolved
1217
+
1218
+
1219
+ def _react_doctor_location(
1220
+ item: _ReactDoctorDiagnostic,
1221
+ path: Path,
1222
+ root: Path,
1223
+ documents: dict[Path, SourceDocument | None],
1224
+ ) -> Location:
1225
+ relative_path = _relative(path, root)
1226
+ # React Doctor uses the protocol sentinel (0, 0) for project-level
1227
+ # diagnostics that truthfully identify a file but no source position.
1228
+ if item.line == 0 or item.column == 0:
1229
+ return Location(relative_path)
1230
+ start = _one_based_position(
1231
+ {"row": item.line, "column": item.column},
1232
+ path,
1233
+ documents,
1234
+ )
1235
+ if item.end_line is not None and item.end_column is not None:
1236
+ try:
1237
+ end = _one_based_position({"row": item.end_line, "column": item.end_column}, path, documents)
1238
+ except ValueError:
1239
+ end = None
1240
+ if end is not None and (end.line, end.character) >= (start.line, start.character):
1241
+ return Location(relative_path, region=Region(start, end))
1242
+ return Location(relative_path, position=start)
1243
+
1244
+
1245
+ def _ruff_argv(files: Sequence[str], *, config: Path | None = None) -> tuple[str, ...]:
1246
+ config_args = () if config is None else ("--config", str(config))
1247
+ return ("ruff", "check", "--output-format", "json", *config_args, "--", *files)
1248
+
1249
+
1250
+ def _eslint_json_argv(argv: Sequence[str]) -> tuple[str, ...]:
1251
+ values = list(argv)
1252
+ try:
1253
+ index = values.index("eslint") + 1
1254
+ except ValueError as exc:
1255
+ msg = "ESLint command does not contain an eslint executable"
1256
+ raise ValueError(msg) from exc
1257
+ values[index:index] = ["--format", "json", "--no-warn-ignored", "--no-cache"]
1258
+ return tuple(values)
1259
+
1260
+
1261
+ def _argv_file_count(argv: Sequence[str]) -> int:
1262
+ if "--" not in argv:
1263
+ return 0
1264
+ # npm has both a package-manager delimiter and ESLint's file delimiter.
1265
+ # The final delimiter is the analyzer boundary for every supported client.
1266
+ index = max(position for position, value in enumerate(argv) if value == "--")
1267
+ return len(argv) - index - 1
1268
+
1269
+
1270
+ def _loads(payload: str) -> object:
1271
+ if not payload.strip():
1272
+ msg = "analyzer returned empty structured output"
1273
+ raise ValueError(msg)
1274
+ return json.loads(payload) # pyright: ignore[reportAny] -- narrowed immediately.
1275
+
1276
+
1277
+ def _prepare_inputs(
1278
+ files: Sequence[str],
1279
+ root: Path,
1280
+ *,
1281
+ policy: Policy | None = None,
1282
+ grouped: GroupedPaths | None = None,
1283
+ ) -> _PreparedInputs:
1284
+ repository = root.resolve()
1285
+ contained = tuple(_contained_path(item, repository) for item in files)
1286
+ selected = contained if policy is None else policy.filter_paths(contained)
1287
+ return _PreparedInputs(
1288
+ repository, selected, grouped if grouped is not None else group_paths(selected, policy=policy)
1289
+ )
1290
+
1291
+
1292
+ def _contained_path(value: str, root: Path) -> str:
1293
+ path = Path(value)
1294
+ resolved = (path if path.is_absolute() else root / path).resolve()
1295
+ try:
1296
+ resolved.relative_to(root)
1297
+ except ValueError as exc:
1298
+ msg = "analysis path is outside the repository root"
1299
+ raise ValueError(msg) from exc
1300
+ return str(resolved)
1301
+
1302
+
1303
+ def _table(value: object, label: str) -> dict[str, object]:
1304
+ if not isinstance(value, dict):
1305
+ msg = f"{label} must be an object"
1306
+ raise TypeError(msg)
1307
+ table: dict[str, object] = {}
1308
+ for key, item in value.items(): # pyright: ignore[reportUnknownVariableType] -- dynamic JSON narrowed here.
1309
+ if not isinstance(key, str):
1310
+ msg = f"{label} contains a non-string key"
1311
+ raise TypeError(msg)
1312
+ table[key] = item
1313
+ return table
1314
+
1315
+
1316
+ def _array(value: object, label: str) -> list[object]:
1317
+ if not isinstance(value, list):
1318
+ msg = f"{label} must be an array"
1319
+ raise TypeError(msg)
1320
+ return list(value) # pyright: ignore[reportUnknownArgumentType] -- elements stay opaque.
1321
+
1322
+
1323
+ def _text(table: dict[str, object], key: str) -> str:
1324
+ value = table.get(key)
1325
+ if not isinstance(value, str) or not value:
1326
+ msg = f"{key} must be a non-empty string"
1327
+ raise TypeError(msg)
1328
+ return value
1329
+
1330
+
1331
+ def _integer(table: dict[str, object], key: str) -> int:
1332
+ value = table.get(key)
1333
+ if type(value) is not int:
1334
+ msg = f"{key} must be an integer"
1335
+ raise TypeError(msg)
1336
+ return value
1337
+
1338
+
1339
+ def _path(table: dict[str, object], key: str, root: Path) -> Path:
1340
+ return _reported_path(_text(table, key), root)
1341
+
1342
+
1343
+ def _reported_path(value: str, root: Path) -> Path:
1344
+ path = Path(value)
1345
+ resolved = (path if path.is_absolute() else root / path).resolve()
1346
+ try:
1347
+ resolved.relative_to(root.resolve())
1348
+ except ValueError as exc:
1349
+ msg = "analyzer reported a path outside the repository root"
1350
+ raise ValueError(msg) from exc
1351
+ return resolved
1352
+
1353
+
1354
+ def _document(path: Path, cache: dict[Path, SourceDocument | None]) -> SourceDocument:
1355
+ resolved = path.resolve()
1356
+ if resolved not in cache:
1357
+ cache[resolved] = SourceDocument.read(resolved)
1358
+ document = cache[resolved]
1359
+ if document is None:
1360
+ msg = "cannot read analyzer source"
1361
+ raise OSError(msg)
1362
+ return document
1363
+
1364
+
1365
+ def _one_based_position(value: dict[str, object], path: Path, cache: dict[Path, SourceDocument | None]) -> Position:
1366
+ position = _document(path, cache).point(line=_integer(value, "row"), column=_integer(value, "column"))
1367
+ if position is None:
1368
+ msg = "analyzer position is outside source"
1369
+ raise ValueError(msg)
1370
+ return position
1371
+
1372
+
1373
+ def _basedpyright_position(
1374
+ value: _BasedPyrightPosition, path: Path, cache: dict[Path, SourceDocument | None]
1375
+ ) -> Position:
1376
+ position = _document(path, cache).utf16_point(line=value.line, character=value.character)
1377
+ if position is None:
1378
+ msg = "analyzer position is outside source"
1379
+ raise ValueError(msg)
1380
+ return position
1381
+
1382
+
1383
+ def _eslint_position(
1384
+ value: dict[str, object],
1385
+ path: Path,
1386
+ cache: dict[Path, SourceDocument | None],
1387
+ *,
1388
+ line_key: str,
1389
+ column_key: str,
1390
+ ) -> Position | None:
1391
+ line = value.get(line_key)
1392
+ column = value.get(column_key)
1393
+ if type(line) is not int or type(column) is not int:
1394
+ return None
1395
+ try:
1396
+ return _zero_based_position({"line": line - 1, "character": column - 1}, path, cache)
1397
+ except ValueError:
1398
+ return None
1399
+
1400
+
1401
+ def _eslint_start_position(
1402
+ value: dict[str, object], path: Path, cache: dict[Path, SourceDocument | None]
1403
+ ) -> Position | None:
1404
+ if isinstance(value.get("line"), bool) or isinstance(value.get("column"), bool):
1405
+ msg = "ESLint diagnostic has invalid boolean coordinates"
1406
+ raise TypeError(msg)
1407
+ if value.get("line") == 0:
1408
+ return None
1409
+ return _eslint_position(value, path, cache, line_key="line", column_key="column")
1410
+
1411
+
1412
+ def _zero_based_position(value: dict[str, object], path: Path, cache: dict[Path, SourceDocument | None]) -> Position:
1413
+ position = _document(path, cache).utf16_point(line=_integer(value, "line"), character=_integer(value, "character"))
1414
+ if position is None:
1415
+ msg = "analyzer position is outside source"
1416
+ raise ValueError(msg)
1417
+ return position
1418
+
1419
+
1420
+ def _severity_text(value: str) -> Severity:
1421
+ try:
1422
+ severity = _ExternalSeverity(value)
1423
+ except ValueError as exc:
1424
+ msg = f"unsupported BasedPyright severity: {value!r}"
1425
+ raise ValueError(msg) from exc
1426
+ match severity:
1427
+ case _ExternalSeverity.ERROR:
1428
+ return Severity.ERROR
1429
+ case _ExternalSeverity.WARNING:
1430
+ return Severity.WARNING
1431
+ case _ExternalSeverity.INFORMATION:
1432
+ return Severity.INFO
1433
+
1434
+
1435
+ def _relative(path: Path, root: Path) -> str:
1436
+ try:
1437
+ return path.resolve().relative_to(root.resolve()).as_posix()
1438
+ except ValueError as exc:
1439
+ msg = "analyzer reported a path outside the repository root"
1440
+ raise ValueError(msg) from exc
1441
+
1442
+
1443
+ def _redact_message(value: str, root: Path) -> str:
1444
+ message = value.replace(str(root), ".")
1445
+ message = re.sub(r"(?i)\b(token|secret|password|api[_-]?key)=\S+", r"\1=<redacted>", message)
1446
+ message = re.sub(r"(?i)\b(authorization\s*:\s*bearer)\s+\S+", r"\1 <redacted>", message)
1447
+ message = re.sub(
1448
+ r"(?i)\b((?:aws|azure|gcp|github)?[_-]?(?:access[_-]?key|secret[_-]?access[_-]?key))\s+\S+",
1449
+ r"\1 <redacted>",
1450
+ message,
1451
+ )
1452
+ message = re.sub(r"(?<![\w:./])/(?:[^\s:]+/?)+", "<path>", message)
1453
+ message = re.sub(r"\b[A-Za-z]:\\[^\s]+", "<path>", message)
1454
+ return message[:1024]