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