code-standards 7.0.0__py3-none-any.whl

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