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,382 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import PurePosixPath
|
|
7
|
+
import re
|
|
8
|
+
from typing import TYPE_CHECKING, Final, NewType, Self
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_DEFAULT_CASE_PATH = PurePosixPath("case.txt")
|
|
16
|
+
_KEBAB_CASE: Final = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
17
|
+
_MAX_SUMMARY_LENGTH: Final = 160
|
|
18
|
+
_PUBLIC_PAIR_SIZE: Final = 2
|
|
19
|
+
|
|
20
|
+
RuleId = NewType("RuleId", str)
|
|
21
|
+
MessageId = NewType("MessageId", str)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Language(StrEnum):
|
|
25
|
+
PYTHON = "python"
|
|
26
|
+
TYPESCRIPT = "typescript"
|
|
27
|
+
MARKDOWN = "markdown"
|
|
28
|
+
SQL = "sql"
|
|
29
|
+
IAC = "iac"
|
|
30
|
+
CONFIG = "config"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RuleOrigin(StrEnum):
|
|
34
|
+
UPSTREAM = "upstream"
|
|
35
|
+
SARJ = "sarj"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RuleEngine(StrEnum):
|
|
39
|
+
ESLINT = "eslint"
|
|
40
|
+
IAC = "iac"
|
|
41
|
+
PYTHON = "python"
|
|
42
|
+
SQL = "sql"
|
|
43
|
+
TEXT = "text"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True, order=True)
|
|
47
|
+
class RuleSelector:
|
|
48
|
+
engine: RuleEngine
|
|
49
|
+
rule_id: RuleId
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
if not _KEBAB_CASE.fullmatch(self.rule_id):
|
|
53
|
+
msg = "rule ID must be non-empty lowercase kebab-case"
|
|
54
|
+
raise ValueError(msg)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def parse(cls, value: str) -> Self:
|
|
58
|
+
engine_text, separator, rule_text = value.partition(":")
|
|
59
|
+
if not separator or ":" in rule_text:
|
|
60
|
+
msg = "rule selector must use canonical ENGINE:ID form"
|
|
61
|
+
raise ValueError(msg)
|
|
62
|
+
try:
|
|
63
|
+
engine = RuleEngine(engine_text)
|
|
64
|
+
except ValueError as exc:
|
|
65
|
+
msg = f"unknown custom-rule engine: {engine_text}"
|
|
66
|
+
raise ValueError(msg) from exc
|
|
67
|
+
return cls(engine, RuleId(rule_text))
|
|
68
|
+
|
|
69
|
+
def __str__(self) -> str:
|
|
70
|
+
return f"{self.engine.value}:{self.rule_id}"
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def native_rule_id(self) -> str:
|
|
74
|
+
if self.engine is RuleEngine.ESLINT:
|
|
75
|
+
return f"@sarj/{self.rule_id}"
|
|
76
|
+
return str(self.rule_id)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class RuleSelection:
|
|
81
|
+
selectors: frozenset[RuleSelector]
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_values(cls, values: Iterable[str | RuleSelector]) -> Self:
|
|
85
|
+
if isinstance(values, str):
|
|
86
|
+
msg = "rule selection must be an iterable of selector values, not one string"
|
|
87
|
+
raise TypeError(msg)
|
|
88
|
+
parsed: set[RuleSelector] = set()
|
|
89
|
+
for value in values:
|
|
90
|
+
if isinstance(value, RuleSelector):
|
|
91
|
+
parsed.add(value)
|
|
92
|
+
else:
|
|
93
|
+
parsed.add(RuleSelector.parse(value))
|
|
94
|
+
return cls(frozenset(parsed))
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def engines(self) -> frozenset[RuleEngine]:
|
|
98
|
+
return frozenset(selector.engine for selector in self.selectors)
|
|
99
|
+
|
|
100
|
+
def ids_for(self, engine: RuleEngine) -> frozenset[RuleId]:
|
|
101
|
+
return frozenset(selector.rule_id for selector in self.selectors if selector.engine is engine)
|
|
102
|
+
|
|
103
|
+
def native_ids_for(self, engine: RuleEngine) -> frozenset[str]:
|
|
104
|
+
return frozenset(selector.native_rule_id for selector in self.selectors if selector.engine is engine)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class RuleCategory(StrEnum):
|
|
108
|
+
ARCHITECTURE = "architecture"
|
|
109
|
+
CORRECTNESS = "correctness"
|
|
110
|
+
MAINTAINABILITY = "maintainability"
|
|
111
|
+
PERFORMANCE = "performance"
|
|
112
|
+
SECURITY = "security"
|
|
113
|
+
STYLE = "style"
|
|
114
|
+
TESTING = "testing"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class DefaultLevel(StrEnum):
|
|
118
|
+
ERROR = "error"
|
|
119
|
+
OFF = "off"
|
|
120
|
+
WARNING = "warning"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class RuleStatus(StrEnum):
|
|
124
|
+
ACTIVE = "active"
|
|
125
|
+
RENAMED = "renamed"
|
|
126
|
+
RETIRED = "retired"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class AutofixPolicy(StrEnum):
|
|
130
|
+
NONE = "none"
|
|
131
|
+
SUGGESTION = "suggestion"
|
|
132
|
+
SAFE = "safe"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ExpectedOutcome(StrEnum):
|
|
136
|
+
MATCH = "match"
|
|
137
|
+
NO_MATCH = "no-match"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True, slots=True)
|
|
141
|
+
class ExampleFile:
|
|
142
|
+
path: PurePosixPath
|
|
143
|
+
source: str = field(repr=False)
|
|
144
|
+
|
|
145
|
+
def __post_init__(self) -> None:
|
|
146
|
+
if self.path.is_absolute() or ".." in self.path.parts or not self.path.name or "\\" in self.path.as_posix():
|
|
147
|
+
msg = "example file paths must be safe relative paths"
|
|
148
|
+
raise ValueError(msg)
|
|
149
|
+
if not self.source:
|
|
150
|
+
msg = "example file source must not be empty"
|
|
151
|
+
raise ValueError(msg)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True, slots=True)
|
|
155
|
+
class RuleExample:
|
|
156
|
+
example_id: str
|
|
157
|
+
outcome: ExpectedOutcome
|
|
158
|
+
files: tuple[ExampleFile, ...]
|
|
159
|
+
focus_path: PurePosixPath
|
|
160
|
+
expected_count: int
|
|
161
|
+
title: str
|
|
162
|
+
public: bool = False
|
|
163
|
+
fixed_files: tuple[ExampleFile, ...] = ()
|
|
164
|
+
scenario: str = "primary"
|
|
165
|
+
|
|
166
|
+
def __post_init__(self) -> None:
|
|
167
|
+
if not _KEBAB_CASE.fullmatch(self.example_id):
|
|
168
|
+
msg = "example ID must be non-empty lowercase kebab-case"
|
|
169
|
+
raise ValueError(msg)
|
|
170
|
+
if not _KEBAB_CASE.fullmatch(self.scenario):
|
|
171
|
+
msg = "example scenario must be non-empty lowercase kebab-case"
|
|
172
|
+
raise ValueError(msg)
|
|
173
|
+
if not self.title.strip():
|
|
174
|
+
msg = "example title must not be empty"
|
|
175
|
+
raise ValueError(msg)
|
|
176
|
+
paths = tuple(item.path for item in self.files)
|
|
177
|
+
if not paths or len(paths) != len(set(paths)):
|
|
178
|
+
msg = "example files must have unique paths"
|
|
179
|
+
raise ValueError(msg)
|
|
180
|
+
fixed_paths = tuple(item.path for item in self.fixed_files)
|
|
181
|
+
if len(fixed_paths) != len(set(fixed_paths)):
|
|
182
|
+
msg = "fixed example files must have unique paths"
|
|
183
|
+
raise ValueError(msg)
|
|
184
|
+
if self.focus_path not in paths:
|
|
185
|
+
msg = "example focus path must name one example file"
|
|
186
|
+
raise ValueError(msg)
|
|
187
|
+
if self.expected_count < 0:
|
|
188
|
+
msg = "example expected count must not be negative"
|
|
189
|
+
raise ValueError(msg)
|
|
190
|
+
if self.outcome is ExpectedOutcome.MATCH and self.expected_count < 1:
|
|
191
|
+
msg = "matching examples must expect at least one diagnostic"
|
|
192
|
+
raise ValueError(msg)
|
|
193
|
+
if self.outcome is ExpectedOutcome.NO_MATCH and self.expected_count != 0:
|
|
194
|
+
msg = "non-matching examples must expect zero diagnostics"
|
|
195
|
+
raise ValueError(msg)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass(frozen=True, slots=True)
|
|
199
|
+
class RuleSpec:
|
|
200
|
+
engine: RuleEngine
|
|
201
|
+
rule_id: RuleId
|
|
202
|
+
code: str | None
|
|
203
|
+
summary: str
|
|
204
|
+
rationale: str
|
|
205
|
+
remediation: str
|
|
206
|
+
category: RuleCategory
|
|
207
|
+
languages: frozenset[Language]
|
|
208
|
+
autofix: AutofixPolicy = AutofixPolicy.NONE
|
|
209
|
+
aliases: tuple[str, ...] = ()
|
|
210
|
+
examples: tuple[RuleExample, ...] = ()
|
|
211
|
+
limitations: tuple[str, ...] = ()
|
|
212
|
+
file_patterns: tuple[str, ...] = ()
|
|
213
|
+
message_ids: tuple[MessageId, ...] = ()
|
|
214
|
+
options_schema: str | None = None
|
|
215
|
+
references: tuple[str, ...] = ()
|
|
216
|
+
since: str | None = None
|
|
217
|
+
|
|
218
|
+
def __post_init__(self) -> None:
|
|
219
|
+
if not _KEBAB_CASE.fullmatch(self.rule_id):
|
|
220
|
+
msg = "rule ID must be non-empty lowercase kebab-case"
|
|
221
|
+
raise ValueError(msg)
|
|
222
|
+
if self.code is not None and not self.code.strip():
|
|
223
|
+
msg = "rule code must be non-empty when present"
|
|
224
|
+
raise ValueError(msg)
|
|
225
|
+
for label, value in (
|
|
226
|
+
("summary", self.summary),
|
|
227
|
+
("rationale", self.rationale),
|
|
228
|
+
("remediation", self.remediation),
|
|
229
|
+
):
|
|
230
|
+
if not value.strip():
|
|
231
|
+
msg = f"rule {label} must not be empty"
|
|
232
|
+
raise ValueError(msg)
|
|
233
|
+
if len(self.summary) > _MAX_SUMMARY_LENGTH or "\n" in self.summary:
|
|
234
|
+
msg = "rule summary must be one concise line of at most 160 characters"
|
|
235
|
+
raise ValueError(msg)
|
|
236
|
+
if not self.languages:
|
|
237
|
+
msg = "rule must name at least one language"
|
|
238
|
+
raise ValueError(msg)
|
|
239
|
+
if len(self.aliases) != len(set(self.aliases)) or any(
|
|
240
|
+
not _KEBAB_CASE.fullmatch(alias) or alias == self.rule_id for alias in self.aliases
|
|
241
|
+
):
|
|
242
|
+
msg = "rule aliases must be unique historical lowercase kebab-case IDs"
|
|
243
|
+
raise ValueError(msg)
|
|
244
|
+
example_ids = tuple(example.example_id for example in self.examples)
|
|
245
|
+
if len(example_ids) != len(set(example_ids)):
|
|
246
|
+
msg = "rule example IDs must be unique"
|
|
247
|
+
raise ValueError(msg)
|
|
248
|
+
if self.autofix is AutofixPolicy.NONE and any(example.fixed_files for example in self.examples):
|
|
249
|
+
msg = "rules without autofix must not publish fixed example files"
|
|
250
|
+
raise ValueError(msg)
|
|
251
|
+
if len(self.message_ids) != len(set(self.message_ids)) or any(not value.strip() for value in self.message_ids):
|
|
252
|
+
msg = "rule message IDs must be unique and non-empty"
|
|
253
|
+
raise ValueError(msg)
|
|
254
|
+
if len(self.file_patterns) != len(set(self.file_patterns)) or any(
|
|
255
|
+
not pattern.strip() for pattern in self.file_patterns
|
|
256
|
+
):
|
|
257
|
+
msg = "rule file patterns must be unique and non-empty"
|
|
258
|
+
raise ValueError(msg)
|
|
259
|
+
if any(not limitation.strip() for limitation in self.limitations):
|
|
260
|
+
msg = "rule limitations must not be empty"
|
|
261
|
+
raise ValueError(msg)
|
|
262
|
+
if any(not reference.startswith("https://") for reference in self.references):
|
|
263
|
+
msg = "rule references must use https"
|
|
264
|
+
raise ValueError(msg)
|
|
265
|
+
if self.since is not None and not self.since.strip():
|
|
266
|
+
msg = "rule since version must not be empty when present"
|
|
267
|
+
raise ValueError(msg)
|
|
268
|
+
if self.options_schema is not None:
|
|
269
|
+
try:
|
|
270
|
+
parsed_schema: object = json.loads(self.options_schema) # pyright: ignore[reportAny]
|
|
271
|
+
except json.JSONDecodeError as exc:
|
|
272
|
+
msg = "rule options schema must be valid JSON"
|
|
273
|
+
raise ValueError(msg) from exc
|
|
274
|
+
if not isinstance(parsed_schema, dict):
|
|
275
|
+
msg = "rule options schema must be a JSON object"
|
|
276
|
+
raise ValueError(msg)
|
|
277
|
+
public_scenarios = {example.scenario for example in self.examples if example.public}
|
|
278
|
+
for scenario in public_scenarios:
|
|
279
|
+
pair = tuple(example for example in self.examples if example.public and example.scenario == scenario)
|
|
280
|
+
if len(pair) != _PUBLIC_PAIR_SIZE or {example.outcome for example in pair} != {
|
|
281
|
+
ExpectedOutcome.MATCH,
|
|
282
|
+
ExpectedOutcome.NO_MATCH,
|
|
283
|
+
}:
|
|
284
|
+
msg = f"published example scenario {scenario!r} must contain both matching and non-matching cases exactly once"
|
|
285
|
+
raise ValueError(msg)
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
def key(self) -> str:
|
|
289
|
+
"""Return the collision-free, engine-qualified rule identity."""
|
|
290
|
+
return f"{self.engine.value}:{self.rule_id}"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@dataclass(frozen=True, slots=True)
|
|
294
|
+
class RuleProblem:
|
|
295
|
+
key: str
|
|
296
|
+
summary: str
|
|
297
|
+
harm: str
|
|
298
|
+
languages: frozenset[Language]
|
|
299
|
+
bad_examples: tuple[str, ...]
|
|
300
|
+
good_examples: tuple[str, ...]
|
|
301
|
+
exclusions: tuple[str, ...] = ()
|
|
302
|
+
autofix: AutofixPolicy = AutofixPolicy.NONE
|
|
303
|
+
|
|
304
|
+
def __post_init__(self) -> None:
|
|
305
|
+
if not self.key or not self.key.replace("-", "").isalnum() or self.key != self.key.lower():
|
|
306
|
+
msg = "problem key must be non-empty lowercase kebab-case"
|
|
307
|
+
raise ValueError(msg)
|
|
308
|
+
for label, value in (("summary", self.summary), ("harm", self.harm)):
|
|
309
|
+
if not value.strip():
|
|
310
|
+
msg = f"problem {label} must not be empty"
|
|
311
|
+
raise ValueError(msg)
|
|
312
|
+
if not self.languages:
|
|
313
|
+
msg = "problem must name at least one language"
|
|
314
|
+
raise ValueError(msg)
|
|
315
|
+
if not self.bad_examples or not self.good_examples:
|
|
316
|
+
msg = "problem must contain both bad and good examples"
|
|
317
|
+
raise ValueError(msg)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@dataclass(frozen=True, slots=True)
|
|
321
|
+
class CatalogRule:
|
|
322
|
+
identifier: str
|
|
323
|
+
summary: str
|
|
324
|
+
origin: RuleOrigin
|
|
325
|
+
languages: frozenset[Language]
|
|
326
|
+
configurable: bool = False
|
|
327
|
+
documentation: str | None = None
|
|
328
|
+
|
|
329
|
+
def __post_init__(self) -> None:
|
|
330
|
+
if not self.identifier.strip() or not self.summary.strip():
|
|
331
|
+
msg = "catalog rule identifier and summary must not be empty"
|
|
332
|
+
raise ValueError(msg)
|
|
333
|
+
if not self.languages:
|
|
334
|
+
msg = "catalog rule must name at least one language"
|
|
335
|
+
raise ValueError(msg)
|
|
336
|
+
if self.documentation is not None and not self.documentation.startswith("https://"):
|
|
337
|
+
msg = "catalog documentation must use https"
|
|
338
|
+
raise ValueError(msg)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@dataclass(frozen=True, slots=True)
|
|
342
|
+
class Finding:
|
|
343
|
+
rule_id: str
|
|
344
|
+
line: int
|
|
345
|
+
column: int
|
|
346
|
+
message: str
|
|
347
|
+
|
|
348
|
+
def __post_init__(self) -> None:
|
|
349
|
+
if not self.rule_id.strip() or not self.message.strip():
|
|
350
|
+
msg = "finding rule ID and message must not be empty"
|
|
351
|
+
raise ValueError(msg)
|
|
352
|
+
if self.line < 1 or self.column < 1:
|
|
353
|
+
msg = "finding locations are one-based"
|
|
354
|
+
raise ValueError(msg)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@dataclass(frozen=True, slots=True)
|
|
358
|
+
class EvaluationCase:
|
|
359
|
+
case_id: str
|
|
360
|
+
language: Language
|
|
361
|
+
source: str = field(repr=False)
|
|
362
|
+
expected: ExpectedOutcome = ExpectedOutcome.NO_MATCH
|
|
363
|
+
path: PurePosixPath = _DEFAULT_CASE_PATH
|
|
364
|
+
private: bool = False
|
|
365
|
+
|
|
366
|
+
def __post_init__(self) -> None:
|
|
367
|
+
if not self.case_id.strip() or not self.source:
|
|
368
|
+
msg = "evaluation case ID and source must not be empty"
|
|
369
|
+
raise ValueError(msg)
|
|
370
|
+
if self.path.is_absolute() or ".." in self.path.parts:
|
|
371
|
+
msg = "evaluation case paths must be safe relative paths"
|
|
372
|
+
raise ValueError(msg)
|
|
373
|
+
|
|
374
|
+
@property
|
|
375
|
+
def report_path(self) -> str:
|
|
376
|
+
"""Return a stable path that never reveals a private corpus layout."""
|
|
377
|
+
return "<private>" if self.private else self.path.as_posix()
|
|
378
|
+
|
|
379
|
+
@property
|
|
380
|
+
def report_id(self) -> str:
|
|
381
|
+
"""Redact private case labels before they enter a report."""
|
|
382
|
+
return "<private>" if self.private else self.case_id
|