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,365 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess # ruff: ignore[suspicious-subprocess-import] -- fixed argv, isolated local lint processes only.
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from typing import TYPE_CHECKING, Final
|
|
11
|
+
|
|
12
|
+
from sarj_standards.libs.corpus.snapshot import selected_files, snapshot_inventory, verify_inventory
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import BinaryIO
|
|
18
|
+
|
|
19
|
+
from sarj_standards.libs.corpus import CorpusSource
|
|
20
|
+
|
|
21
|
+
_SAFE_ENVIRONMENT: Final = frozenset(
|
|
22
|
+
{"HOME", "LANG", "LC_ALL", "LC_CTYPE", "PATH", "SYSTEMDRIVE", "SYSTEMROOT", "TMPDIR", "VIRTUAL_ENV"}
|
|
23
|
+
)
|
|
24
|
+
_MAX_BATCH_SIZE = 1_000
|
|
25
|
+
_DEFAULT_MAX_OUTPUT_BYTES = 1_048_576
|
|
26
|
+
_MAX_OUTPUT_BYTES = 8 * 1_048_576
|
|
27
|
+
_MAX_ARGV_BYTES = 64 * 1024
|
|
28
|
+
_DEFAULT_MAX_FILES_PER_CORPUS = 50_000
|
|
29
|
+
_DEFAULT_MAX_BATCHES = 1_000
|
|
30
|
+
_READ_SIZE = 65_536
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class CorpusBatchResult:
|
|
35
|
+
corpus: str
|
|
36
|
+
ordinal: int
|
|
37
|
+
files: int
|
|
38
|
+
returncode: int
|
|
39
|
+
stdout_lines: int
|
|
40
|
+
stderr_lines: int
|
|
41
|
+
elapsed: timedelta
|
|
42
|
+
stdout_bytes: int = 0
|
|
43
|
+
stderr_bytes: int = 0
|
|
44
|
+
stdout_truncated: bool = False
|
|
45
|
+
stderr_truncated: bool = False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class IsolatedCorpusReport:
|
|
50
|
+
batches: tuple[CorpusBatchResult, ...]
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def files(self) -> int:
|
|
54
|
+
return sum(batch.files for batch in self.batches)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def stdout_lines(self) -> int:
|
|
58
|
+
return sum(batch.stdout_lines for batch in self.batches)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def elapsed(self) -> timedelta:
|
|
62
|
+
return sum((batch.elapsed for batch in self.batches), start=timedelta())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CorpusLintError(RuntimeError):
|
|
66
|
+
"""A corpus batch timed out or the linter failed to execute."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class _StreamSummary:
|
|
71
|
+
retained_bytes: int
|
|
72
|
+
total_bytes: int
|
|
73
|
+
lines: int
|
|
74
|
+
truncated: bool
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class _ProcessResult:
|
|
79
|
+
returncode: int
|
|
80
|
+
stdout: _StreamSummary
|
|
81
|
+
stderr: _StreamSummary
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run_isolated_corpora(
|
|
85
|
+
sources: tuple[CorpusSource, ...],
|
|
86
|
+
command: tuple[str, ...],
|
|
87
|
+
*,
|
|
88
|
+
batch_size: int = 250,
|
|
89
|
+
timeout: timedelta = timedelta(minutes=2),
|
|
90
|
+
max_output_bytes: int = _DEFAULT_MAX_OUTPUT_BYTES,
|
|
91
|
+
total_timeout: timedelta = timedelta(minutes=15),
|
|
92
|
+
max_files_per_corpus: int = _DEFAULT_MAX_FILES_PER_CORPUS,
|
|
93
|
+
max_batches: int = _DEFAULT_MAX_BATCHES,
|
|
94
|
+
accepted_returncodes: frozenset[int] = frozenset({0, 1}),
|
|
95
|
+
) -> IsolatedCorpusReport:
|
|
96
|
+
if not sources:
|
|
97
|
+
msg = "isolated corpus evaluation requires at least one corpus"
|
|
98
|
+
raise ValueError(msg)
|
|
99
|
+
if not command or any(not argument for argument in command):
|
|
100
|
+
msg = "isolated corpus evaluation requires a non-empty command"
|
|
101
|
+
raise ValueError(msg)
|
|
102
|
+
if not 1 <= batch_size <= _MAX_BATCH_SIZE:
|
|
103
|
+
msg = f"batch size must be between 1 and {_MAX_BATCH_SIZE}"
|
|
104
|
+
raise ValueError(msg)
|
|
105
|
+
if timeout <= timedelta():
|
|
106
|
+
msg = "corpus batch timeout must be positive"
|
|
107
|
+
raise ValueError(msg)
|
|
108
|
+
if not 1 <= max_output_bytes <= _MAX_OUTPUT_BYTES:
|
|
109
|
+
msg = f"corpus batch output limit must be between 1 and {_MAX_OUTPUT_BYTES} bytes"
|
|
110
|
+
raise ValueError(msg)
|
|
111
|
+
if total_timeout <= timedelta():
|
|
112
|
+
msg = "corpus total timeout must be positive"
|
|
113
|
+
raise ValueError(msg)
|
|
114
|
+
if max_files_per_corpus <= 0 or max_batches <= 0:
|
|
115
|
+
msg = "corpus file and batch limits must be positive"
|
|
116
|
+
raise ValueError(msg)
|
|
117
|
+
if not accepted_returncodes:
|
|
118
|
+
msg = "accepted return codes must not be empty"
|
|
119
|
+
raise ValueError(msg)
|
|
120
|
+
|
|
121
|
+
results: list[CorpusBatchResult] = []
|
|
122
|
+
deadline = time.monotonic() + total_timeout.total_seconds()
|
|
123
|
+
for source in sources:
|
|
124
|
+
verified, files = verify_inventory(source)
|
|
125
|
+
if len(files) > max_files_per_corpus:
|
|
126
|
+
msg = f"corpus {source.report_name} exceeds the {max_files_per_corpus}-file evaluation limit"
|
|
127
|
+
raise CorpusLintError(msg)
|
|
128
|
+
batches = argv_batches(files, command, batch_size=batch_size)
|
|
129
|
+
if len(results) + len(batches) > max_batches:
|
|
130
|
+
msg = f"corpus evaluation exceeds the {max_batches}-batch limit"
|
|
131
|
+
raise CorpusLintError(msg)
|
|
132
|
+
for ordinal, batch in enumerate(batches, start=1):
|
|
133
|
+
remaining = deadline - time.monotonic()
|
|
134
|
+
if remaining <= 0:
|
|
135
|
+
msg = f"corpus evaluation exceeded {total_timeout.total_seconds():g}s total"
|
|
136
|
+
raise CorpusLintError(msg)
|
|
137
|
+
results.append(
|
|
138
|
+
_run_batch(
|
|
139
|
+
source,
|
|
140
|
+
command,
|
|
141
|
+
batch,
|
|
142
|
+
ordinal=ordinal,
|
|
143
|
+
timeout=min(timeout, timedelta(seconds=remaining)),
|
|
144
|
+
max_output_bytes=max_output_bytes,
|
|
145
|
+
accepted_returncodes=accepted_returncodes,
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
try:
|
|
149
|
+
if selected_files(source) != files:
|
|
150
|
+
msg = f"corpus {source.report_name} changed during evaluation"
|
|
151
|
+
raise CorpusLintError(msg)
|
|
152
|
+
after = snapshot_inventory(source, files)
|
|
153
|
+
except (OSError, ValueError) as error:
|
|
154
|
+
msg = f"corpus {source.report_name} changed during evaluation"
|
|
155
|
+
raise CorpusLintError(msg) from error
|
|
156
|
+
if after.digest != verified.digest or after.revision != verified.revision:
|
|
157
|
+
msg = f"corpus {source.report_name} changed during evaluation"
|
|
158
|
+
raise CorpusLintError(msg)
|
|
159
|
+
return IsolatedCorpusReport(tuple(results))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def argv_batches(
|
|
163
|
+
files: tuple[Path, ...],
|
|
164
|
+
command: tuple[str, ...],
|
|
165
|
+
*,
|
|
166
|
+
batch_size: int,
|
|
167
|
+
) -> tuple[tuple[Path, ...], ...]:
|
|
168
|
+
base_bytes = sum(len(os.fsencode(argument)) + 1 for argument in command)
|
|
169
|
+
if base_bytes >= _MAX_ARGV_BYTES:
|
|
170
|
+
msg = "corpus linter command exceeds the argv byte budget"
|
|
171
|
+
raise CorpusLintError(msg)
|
|
172
|
+
batches: list[tuple[Path, ...]] = []
|
|
173
|
+
current: list[Path] = []
|
|
174
|
+
current_bytes = base_bytes
|
|
175
|
+
for path in files:
|
|
176
|
+
argument_bytes = len(os.fsencode(str(path))) + 1
|
|
177
|
+
if current and (len(current) >= batch_size or current_bytes + argument_bytes > _MAX_ARGV_BYTES):
|
|
178
|
+
batches.append(tuple(current))
|
|
179
|
+
current = []
|
|
180
|
+
current_bytes = base_bytes
|
|
181
|
+
if current_bytes + argument_bytes > _MAX_ARGV_BYTES:
|
|
182
|
+
msg = "one corpus path exceeds the argv byte budget"
|
|
183
|
+
raise CorpusLintError(msg)
|
|
184
|
+
current.append(path)
|
|
185
|
+
current_bytes += argument_bytes
|
|
186
|
+
if current:
|
|
187
|
+
batches.append(tuple(current))
|
|
188
|
+
return tuple(batches)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _run_batch(
|
|
192
|
+
source: CorpusSource,
|
|
193
|
+
command: tuple[str, ...],
|
|
194
|
+
files: tuple[Path, ...],
|
|
195
|
+
*,
|
|
196
|
+
ordinal: int,
|
|
197
|
+
timeout: timedelta,
|
|
198
|
+
max_output_bytes: int,
|
|
199
|
+
accepted_returncodes: frozenset[int],
|
|
200
|
+
) -> CorpusBatchResult:
|
|
201
|
+
relative_files = tuple(path.relative_to(source.root).as_posix() for path in files if path.is_file())
|
|
202
|
+
if not relative_files:
|
|
203
|
+
msg = f"corpus {source.report_name} batch {ordinal} contains no existing files"
|
|
204
|
+
raise CorpusLintError(msg)
|
|
205
|
+
environment = {
|
|
206
|
+
name: value
|
|
207
|
+
for name, value in os.environ.items() # ruff: ignore[banned-api] -- intentionally discard hook-local state.
|
|
208
|
+
if name in _SAFE_ENVIRONMENT
|
|
209
|
+
}
|
|
210
|
+
started = time.monotonic()
|
|
211
|
+
try:
|
|
212
|
+
completed = _run_process(
|
|
213
|
+
(*command, *relative_files),
|
|
214
|
+
cwd=source.root,
|
|
215
|
+
env=environment,
|
|
216
|
+
timeout=timeout,
|
|
217
|
+
max_output_bytes=max_output_bytes,
|
|
218
|
+
)
|
|
219
|
+
except subprocess.TimeoutExpired as error:
|
|
220
|
+
msg = f"corpus {source.report_name} batch {ordinal} exceeded {timeout.total_seconds():g}s"
|
|
221
|
+
raise CorpusLintError(msg) from error
|
|
222
|
+
except OSError as error:
|
|
223
|
+
msg = f"corpus {source.report_name} batch {ordinal} failed to execute"
|
|
224
|
+
raise CorpusLintError(msg) from error
|
|
225
|
+
elapsed = timedelta(seconds=time.monotonic() - started)
|
|
226
|
+
if completed.stdout.truncated or completed.stderr.truncated:
|
|
227
|
+
msg = f"corpus {source.report_name} batch {ordinal} exceeded the output limit"
|
|
228
|
+
raise CorpusLintError(msg)
|
|
229
|
+
if completed.returncode not in accepted_returncodes:
|
|
230
|
+
msg = f"corpus {source.report_name} batch {ordinal} exited with {completed.returncode}"
|
|
231
|
+
raise CorpusLintError(msg)
|
|
232
|
+
return CorpusBatchResult(
|
|
233
|
+
corpus=source.report_name,
|
|
234
|
+
ordinal=ordinal,
|
|
235
|
+
files=len(relative_files),
|
|
236
|
+
returncode=completed.returncode,
|
|
237
|
+
stdout_lines=completed.stdout.lines,
|
|
238
|
+
stderr_lines=completed.stderr.lines,
|
|
239
|
+
elapsed=elapsed,
|
|
240
|
+
stdout_bytes=completed.stdout.total_bytes,
|
|
241
|
+
stderr_bytes=completed.stderr.total_bytes,
|
|
242
|
+
stdout_truncated=completed.stdout.truncated,
|
|
243
|
+
stderr_truncated=completed.stderr.truncated,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _run_process(
|
|
248
|
+
argv: tuple[str, ...],
|
|
249
|
+
*,
|
|
250
|
+
cwd: Path,
|
|
251
|
+
env: dict[str, str],
|
|
252
|
+
timeout: timedelta,
|
|
253
|
+
max_output_bytes: int,
|
|
254
|
+
) -> _ProcessResult:
|
|
255
|
+
deadline = time.monotonic() + timeout.total_seconds()
|
|
256
|
+
process = subprocess.Popen( # ruff: ignore[subprocess-without-shell-equals-true] -- argv is never interpreted by a shell.
|
|
257
|
+
argv,
|
|
258
|
+
cwd=cwd,
|
|
259
|
+
env=env,
|
|
260
|
+
shell=False,
|
|
261
|
+
stdout=subprocess.PIPE,
|
|
262
|
+
stderr=subprocess.PIPE,
|
|
263
|
+
start_new_session=os.name == "posix",
|
|
264
|
+
)
|
|
265
|
+
stdout = process.stdout
|
|
266
|
+
stderr = process.stderr
|
|
267
|
+
if stdout is None or stderr is None: # pragma: no cover - PIPE guarantees both streams.
|
|
268
|
+
process.kill()
|
|
269
|
+
msg = "corpus process did not expose output pipes"
|
|
270
|
+
raise OSError(msg)
|
|
271
|
+
|
|
272
|
+
summaries: list[_StreamSummary | None] = [None, None]
|
|
273
|
+
exceeded = threading.Event()
|
|
274
|
+
threads = (
|
|
275
|
+
threading.Thread(target=_drain_stream, args=(stdout, max_output_bytes, exceeded, summaries, 0), daemon=True),
|
|
276
|
+
threading.Thread(target=_drain_stream, args=(stderr, max_output_bytes, exceeded, summaries, 1), daemon=True),
|
|
277
|
+
)
|
|
278
|
+
for thread in threads:
|
|
279
|
+
thread.start()
|
|
280
|
+
try:
|
|
281
|
+
returncode = _wait_for_process(process, exceeded, deadline, argv, timeout)
|
|
282
|
+
except subprocess.TimeoutExpired:
|
|
283
|
+
_terminate_process_group(process)
|
|
284
|
+
_ = process.wait()
|
|
285
|
+
for thread in threads:
|
|
286
|
+
thread.join()
|
|
287
|
+
raise
|
|
288
|
+
|
|
289
|
+
for thread in threads:
|
|
290
|
+
thread.join(max(0.0, deadline - time.monotonic()))
|
|
291
|
+
if any(thread.is_alive() for thread in threads):
|
|
292
|
+
_terminate_process_group(process)
|
|
293
|
+
for thread in threads:
|
|
294
|
+
thread.join()
|
|
295
|
+
raise subprocess.TimeoutExpired(argv, timeout.total_seconds())
|
|
296
|
+
|
|
297
|
+
stdout_summary, stderr_summary = summaries
|
|
298
|
+
if stdout_summary is None or stderr_summary is None: # pragma: no cover - threads always assign on EOF.
|
|
299
|
+
msg = "corpus process output could not be summarized"
|
|
300
|
+
raise OSError(msg)
|
|
301
|
+
return _ProcessResult(returncode, stdout_summary, stderr_summary)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _wait_for_process(
|
|
305
|
+
process: subprocess.Popen[bytes],
|
|
306
|
+
exceeded: threading.Event,
|
|
307
|
+
deadline: float,
|
|
308
|
+
argv: tuple[str, ...],
|
|
309
|
+
timeout: timedelta,
|
|
310
|
+
) -> int:
|
|
311
|
+
while process.poll() is None and not exceeded.is_set():
|
|
312
|
+
remaining = deadline - time.monotonic()
|
|
313
|
+
if remaining <= 0:
|
|
314
|
+
raise subprocess.TimeoutExpired(argv, timeout.total_seconds())
|
|
315
|
+
try:
|
|
316
|
+
_ = process.wait(timeout=min(0.05, remaining))
|
|
317
|
+
except subprocess.TimeoutExpired:
|
|
318
|
+
continue
|
|
319
|
+
if exceeded.is_set() and process.poll() is None:
|
|
320
|
+
_terminate_process_group(process)
|
|
321
|
+
return process.wait()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _drain_stream(
|
|
325
|
+
stream: BinaryIO,
|
|
326
|
+
max_output_bytes: int,
|
|
327
|
+
exceeded: threading.Event,
|
|
328
|
+
summaries: list[_StreamSummary | None],
|
|
329
|
+
index: int,
|
|
330
|
+
) -> None:
|
|
331
|
+
retained_bytes = 0
|
|
332
|
+
total_bytes = 0
|
|
333
|
+
newline_count = 0
|
|
334
|
+
has_trailing_content = False
|
|
335
|
+
truncated = False
|
|
336
|
+
try:
|
|
337
|
+
while chunk := stream.read(_READ_SIZE):
|
|
338
|
+
total_bytes += len(chunk)
|
|
339
|
+
newline_count += chunk.count(b"\n")
|
|
340
|
+
has_trailing_content = not chunk.endswith(b"\n")
|
|
341
|
+
remaining = max_output_bytes - retained_bytes
|
|
342
|
+
if remaining > 0:
|
|
343
|
+
retained_bytes += min(len(chunk), remaining)
|
|
344
|
+
if len(chunk) > remaining:
|
|
345
|
+
truncated = True
|
|
346
|
+
exceeded.set()
|
|
347
|
+
break
|
|
348
|
+
finally:
|
|
349
|
+
stream.close()
|
|
350
|
+
summaries[index] = _StreamSummary(
|
|
351
|
+
retained_bytes,
|
|
352
|
+
total_bytes,
|
|
353
|
+
newline_count + int(has_trailing_content),
|
|
354
|
+
truncated,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _terminate_process_group(process: subprocess.Popen[bytes]) -> None:
|
|
359
|
+
if os.name == "posix":
|
|
360
|
+
try:
|
|
361
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
362
|
+
except ProcessLookupError:
|
|
363
|
+
return
|
|
364
|
+
else: # pragma: no cover - CI exercises POSIX process-group cleanup.
|
|
365
|
+
process.kill()
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import TYPE_CHECKING, Protocol
|
|
7
|
+
|
|
8
|
+
from sarj_standards.libs.corpus import CorpusSnapshot
|
|
9
|
+
|
|
10
|
+
from .contracts import EvaluationCase, ExpectedOutcome, Finding, RuleProblem
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RuleEvaluator(Protocol):
|
|
18
|
+
def __call__(self, case: EvaluationCase, /) -> Sequence[Finding]: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PromotionDecision(StrEnum):
|
|
22
|
+
REJECT = "reject"
|
|
23
|
+
WARN = "warn"
|
|
24
|
+
ERROR = "error"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class EvaluationThresholds:
|
|
29
|
+
max_false_positives: int = 0
|
|
30
|
+
max_false_negatives: int = 0
|
|
31
|
+
minimum_cases_for_error: int = 20
|
|
32
|
+
minimum_positives_for_error: int = 1
|
|
33
|
+
minimum_negatives_for_error: int = 1
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
if (
|
|
37
|
+
min(
|
|
38
|
+
self.max_false_positives,
|
|
39
|
+
self.max_false_negatives,
|
|
40
|
+
self.minimum_cases_for_error,
|
|
41
|
+
self.minimum_positives_for_error,
|
|
42
|
+
self.minimum_negatives_for_error,
|
|
43
|
+
)
|
|
44
|
+
< 0
|
|
45
|
+
):
|
|
46
|
+
msg = "evaluation thresholds must be non-negative"
|
|
47
|
+
raise ValueError(msg)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class CaseEvaluation:
|
|
52
|
+
case_id: str
|
|
53
|
+
path: str
|
|
54
|
+
expected: ExpectedOutcome
|
|
55
|
+
findings: tuple[Finding, ...]
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def matched(self) -> bool:
|
|
59
|
+
return bool(self.findings)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class EvaluationEvidence:
|
|
64
|
+
corpora: tuple[CorpusSnapshot, ...]
|
|
65
|
+
sample_method: str
|
|
66
|
+
elapsed: timedelta
|
|
67
|
+
baseline: timedelta
|
|
68
|
+
maximum_slowdown: float = 1.25
|
|
69
|
+
|
|
70
|
+
def __post_init__(self) -> None:
|
|
71
|
+
if not self.corpora or not all(corpus.verified for corpus in self.corpora) or not self.sample_method.strip():
|
|
72
|
+
msg = "evaluation evidence requires verified corpora and a sampling method"
|
|
73
|
+
raise ValueError(msg)
|
|
74
|
+
if self.elapsed < timedelta() or self.baseline <= timedelta() or self.maximum_slowdown <= 0:
|
|
75
|
+
msg = "evaluation timings require a positive baseline and slowdown budget"
|
|
76
|
+
raise ValueError(msg)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def within_performance_budget(self) -> bool:
|
|
80
|
+
return self.elapsed <= self.baseline * self.maximum_slowdown
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class EvaluationReport:
|
|
85
|
+
problem_key: str
|
|
86
|
+
rule_id: str
|
|
87
|
+
cases: tuple[CaseEvaluation, ...]
|
|
88
|
+
thresholds: EvaluationThresholds
|
|
89
|
+
evidence: EvaluationEvidence | None = None
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def true_positives(self) -> int:
|
|
93
|
+
return sum(case.expected is ExpectedOutcome.MATCH and case.matched for case in self.cases)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def true_negatives(self) -> int:
|
|
97
|
+
return sum(case.expected is ExpectedOutcome.NO_MATCH and not case.matched for case in self.cases)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def false_positives(self) -> int:
|
|
101
|
+
return sum(case.expected is ExpectedOutcome.NO_MATCH and case.matched for case in self.cases)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def false_negatives(self) -> int:
|
|
105
|
+
return sum(case.expected is ExpectedOutcome.MATCH and not case.matched for case in self.cases)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def duplicate_locations(self) -> tuple[tuple[str, int, int], ...]:
|
|
109
|
+
duplicates: list[tuple[str, int, int]] = []
|
|
110
|
+
for case in self.cases:
|
|
111
|
+
seen: set[tuple[int, int]] = set()
|
|
112
|
+
for finding in case.findings:
|
|
113
|
+
location = (finding.line, finding.column)
|
|
114
|
+
if location in seen:
|
|
115
|
+
duplicates.append((case.case_id, *location))
|
|
116
|
+
seen.add(location)
|
|
117
|
+
return tuple(duplicates)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def decision(self) -> PromotionDecision:
|
|
121
|
+
if (
|
|
122
|
+
self.false_positives > self.thresholds.max_false_positives
|
|
123
|
+
or self.false_negatives > self.thresholds.max_false_negatives
|
|
124
|
+
or self.duplicate_locations
|
|
125
|
+
or not self.cases
|
|
126
|
+
):
|
|
127
|
+
return PromotionDecision.REJECT
|
|
128
|
+
if self.false_positives or self.false_negatives:
|
|
129
|
+
return PromotionDecision.WARN
|
|
130
|
+
positives = self.true_positives + self.false_negatives
|
|
131
|
+
negatives = self.true_negatives + self.false_positives
|
|
132
|
+
if (
|
|
133
|
+
len(self.cases) < self.thresholds.minimum_cases_for_error
|
|
134
|
+
or positives < self.thresholds.minimum_positives_for_error
|
|
135
|
+
or negatives < self.thresholds.minimum_negatives_for_error
|
|
136
|
+
or self.evidence is None
|
|
137
|
+
or not self.evidence.within_performance_budget
|
|
138
|
+
):
|
|
139
|
+
return PromotionDecision.WARN
|
|
140
|
+
return PromotionDecision.ERROR
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def evaluate(
|
|
144
|
+
problem: RuleProblem,
|
|
145
|
+
rule_id: str,
|
|
146
|
+
cases: Sequence[EvaluationCase],
|
|
147
|
+
evaluator: RuleEvaluator,
|
|
148
|
+
*,
|
|
149
|
+
thresholds: EvaluationThresholds | None = None,
|
|
150
|
+
evidence: EvaluationEvidence | None = None,
|
|
151
|
+
) -> EvaluationReport:
|
|
152
|
+
unsupported = [case.report_id for case in cases if case.language not in problem.languages]
|
|
153
|
+
if unsupported:
|
|
154
|
+
msg = f"cases use languages outside the problem: {', '.join(unsupported)}"
|
|
155
|
+
raise ValueError(msg)
|
|
156
|
+
outcomes: list[CaseEvaluation] = []
|
|
157
|
+
for index, case in enumerate(cases, start=1):
|
|
158
|
+
findings = tuple(evaluator(case))
|
|
159
|
+
wrong = sorted({finding.rule_id for finding in findings if finding.rule_id != rule_id})
|
|
160
|
+
if wrong:
|
|
161
|
+
msg = f"candidate {rule_id} returned findings for: {', '.join(wrong)}"
|
|
162
|
+
raise ValueError(msg)
|
|
163
|
+
outcomes.append(
|
|
164
|
+
CaseEvaluation(
|
|
165
|
+
f"<private:{index}>" if case.private else case.report_id,
|
|
166
|
+
case.report_path,
|
|
167
|
+
case.expected,
|
|
168
|
+
_report_findings(case, findings),
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
return EvaluationReport(problem.key, rule_id, tuple(outcomes), thresholds or EvaluationThresholds(), evidence)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _report_findings(case: EvaluationCase, findings: Sequence[Finding]) -> tuple[Finding, ...]:
|
|
175
|
+
if not case.private:
|
|
176
|
+
return tuple(findings)
|
|
177
|
+
return tuple(Finding(finding.rule_id, finding.line, finding.column, "<private-finding>") for finding in findings)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from sarj_standards.libs.adoption.lifecycle import Command, execute
|
|
7
|
+
from sarj_standards.libs.repository import hooks
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class SetupPlan:
|
|
12
|
+
root: Path
|
|
13
|
+
commands: tuple[Command, ...]
|
|
14
|
+
install_hooks: bool = True
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def plan_setup(root: Path) -> SetupPlan:
|
|
18
|
+
resolved = root.resolve()
|
|
19
|
+
commands = (
|
|
20
|
+
*(
|
|
21
|
+
Command("Python package", ("uv", "sync", "--frozen"), resolved / "packages" / name)
|
|
22
|
+
for name in ("python", "sql", "iac", "standards")
|
|
23
|
+
),
|
|
24
|
+
Command(
|
|
25
|
+
"TypeScript package",
|
|
26
|
+
("npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund"),
|
|
27
|
+
resolved / "packages" / "typescript",
|
|
28
|
+
),
|
|
29
|
+
Command(
|
|
30
|
+
"Documentation site",
|
|
31
|
+
("npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund"),
|
|
32
|
+
resolved / "apps" / "docs",
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
return SetupPlan(resolved, install_hooks=True, commands=commands)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def apply_setup(plan: SetupPlan) -> int:
|
|
39
|
+
hook_status = hooks.install(plan.root) if plan.install_hooks else 0
|
|
40
|
+
return hook_status or execute(plan.commands)
|
sarj_standards/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
SCHEMAS_DIR = Path(__file__).resolve().parent
|
|
5
|
+
RULE_CATALOG = SCHEMAS_DIR / "rule-catalog.v1.json"
|
|
6
|
+
RULE_CATALOG_SCHEMA = SCHEMAS_DIR / "rule-catalog.v1.schema.json"
|
|
7
|
+
SLACK_AUTOMATIONS_SCHEMA = SCHEMAS_DIR / "slack-automations.v1.schema.json"
|