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,131 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from operator import itemgetter
5
+ from pathlib import Path
6
+ import re
7
+ from typing import TYPE_CHECKING, TypeIs
8
+
9
+ from sarj_standards.libs.filesystem import is_link_like
10
+
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Iterable
14
+
15
+ from .models import Diagnostic
16
+
17
+
18
+ SCHEMA_VERSION = 1
19
+ _MAX_BYTES = 16 * 1024 * 1024
20
+ _FINGERPRINT = re.compile(r"[0-9a-f]{64}")
21
+ _NON_BASELINEABLE_SOURCES = frozenset({"react-doctor"})
22
+
23
+
24
+ def is_baselineable(diagnostic: Diagnostic) -> bool:
25
+ return diagnostic.source not in _NON_BASELINEABLE_SOURCES
26
+
27
+
28
+ def load(path: Path) -> dict[str, int]:
29
+ if not path.is_file() or is_link_like(path):
30
+ msg = f"diagnostic baseline must be a regular file: {path}"
31
+ raise ValueError(msg)
32
+ if path.stat().st_size > _MAX_BYTES:
33
+ msg = f"diagnostic baseline exceeds {_MAX_BYTES} bytes: {path}"
34
+ raise ValueError(msg)
35
+ try:
36
+ parsed: object = json.loads( # pyright: ignore[reportAny] -- untyped stdlib boundary narrowed below
37
+ path.read_text(encoding="utf-8")
38
+ )
39
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
40
+ msg = f"cannot read diagnostic baseline {path}: {exc}"
41
+ raise ValueError(msg) from exc
42
+ root = _string_object_dict(parsed, label="diagnostic baseline")
43
+ if root.get("schemaVersion") != SCHEMA_VERSION:
44
+ msg = f"diagnostic baseline schemaVersion must equal {SCHEMA_VERSION}"
45
+ raise ValueError(msg)
46
+ entries = root.get("diagnostics")
47
+ if not _is_object_list(entries):
48
+ msg = "diagnostic baseline diagnostics must be a list"
49
+ raise TypeError(msg)
50
+ fingerprints: dict[str, int] = {}
51
+ for index, value in enumerate(entries):
52
+ entry = _string_object_dict(value, label=f"diagnostic baseline entry {index}")
53
+ fingerprint = entry.get("fingerprint")
54
+ if not isinstance(fingerprint, str) or _FINGERPRINT.fullmatch(fingerprint) is None:
55
+ msg = f"diagnostic baseline entry {index} has an invalid fingerprint"
56
+ raise ValueError(msg)
57
+ _ = _required_text(entry, "source", index)
58
+ _ = _required_text(entry, "ruleId", index)
59
+ relative = _required_text(entry, "path", index)
60
+ count = entry.get("count")
61
+ if type(count) is not int or count < 1:
62
+ msg = f"diagnostic baseline entry {index} count must be a positive integer"
63
+ raise ValueError(msg)
64
+ candidate = Path(relative)
65
+ if candidate.is_absolute() or ".." in candidate.parts:
66
+ msg = f"diagnostic baseline entry {index} path must be repository-relative"
67
+ raise ValueError(msg)
68
+ if fingerprint in fingerprints:
69
+ msg = f"diagnostic baseline repeats fingerprint: {fingerprint}"
70
+ raise ValueError(msg)
71
+ fingerprints[fingerprint] = count
72
+ return fingerprints
73
+
74
+
75
+ def _required_text(entry: dict[str, object], key: str, index: int) -> str:
76
+ value = entry.get(key)
77
+ if not isinstance(value, str) or not value:
78
+ msg = f"diagnostic baseline entry {index} requires a non-empty {key}"
79
+ raise TypeError(msg)
80
+ return value
81
+
82
+
83
+ def render(diagnostics: Iterable[Diagnostic]) -> str:
84
+ entries: dict[str, dict[str, object]] = {}
85
+ for item in diagnostics:
86
+ if item.fingerprint is None or not is_baselineable(item):
87
+ continue
88
+ entry = entries.setdefault(
89
+ item.fingerprint,
90
+ {
91
+ "fingerprint": item.fingerprint,
92
+ "source": item.source,
93
+ "ruleId": item.rule_id or item.code,
94
+ "path": item.location.path,
95
+ "count": 0,
96
+ },
97
+ )
98
+ count = entry["count"]
99
+ if not isinstance(count, int):
100
+ msg = "diagnostic baseline count has an invalid internal type"
101
+ raise TypeError(msg)
102
+ entry["count"] = count + 1
103
+ ordered = sorted(
104
+ entries.values(),
105
+ key=itemgetter("source", "ruleId", "path", "fingerprint"),
106
+ )
107
+ return json.dumps({"schemaVersion": SCHEMA_VERSION, "diagnostics": ordered}, indent=2) + "\n"
108
+
109
+
110
+ def _string_object_dict(value: object, *, label: str) -> dict[str, object]:
111
+ if not _is_object_dict(value):
112
+ msg = f"{label} must be an object"
113
+ raise TypeError(msg)
114
+ result: dict[str, object] = {}
115
+ for key, item in value.items():
116
+ if not isinstance(key, str):
117
+ msg = f"{label} contains a non-string key"
118
+ raise TypeError(msg)
119
+ result[key] = item
120
+ return result
121
+
122
+
123
+ def _is_object_dict(value: object) -> TypeIs[dict[object, object]]:
124
+ return isinstance(value, dict)
125
+
126
+
127
+ def _is_object_list(value: object) -> TypeIs[list[object]]:
128
+ return isinstance(value, list)
129
+
130
+
131
+ __all__ = ["SCHEMA_VERSION", "load", "render"]
@@ -0,0 +1,574 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+ from hashlib import sha256
6
+ from pathlib import Path, PureWindowsPath
7
+ from typing import Final, NewType, TypedDict
8
+ from urllib.parse import urlparse
9
+
10
+
11
+ SCHEMA_VERSION: Final = 1
12
+ SCHEMA_URI: Final = "https://standards.sarj.ai/schemas/analysis/v1"
13
+ ANALYSIS_SCHEMA: Final = Path(__file__).with_name("analysis.schema.json")
14
+ FINGERPRINT_VERSION: Final = 1
15
+ _SHA256_HEX_LENGTH: Final = 64
16
+ AnalyzerId = NewType("AnalyzerId", str)
17
+ InvocationId = NewType("InvocationId", str)
18
+
19
+
20
+ class Severity(StrEnum):
21
+ ERROR = "error"
22
+ WARNING = "warning"
23
+ INFO = "info"
24
+
25
+
26
+ class Completion(StrEnum):
27
+ COMPLETE = "complete"
28
+ PARTIAL = "partial"
29
+ FAILED = "failed"
30
+
31
+
32
+ class Conclusion(StrEnum):
33
+ PASSED = "passed"
34
+ FINDINGS = "findings"
35
+ INCONCLUSIVE = "inconclusive"
36
+
37
+
38
+ class TrustMode(StrEnum):
39
+ SAFE = "safe"
40
+ TRUSTED = "trusted"
41
+
42
+
43
+ class CoverageDisposition(StrEnum):
44
+ FAILED = "failed"
45
+ UNSUPPORTED = "unsupported"
46
+ EXCLUDED = "excluded"
47
+ NOT_REQUESTED = "not-requested"
48
+
49
+
50
+ class FixSafety(StrEnum):
51
+ SAFE = "safe"
52
+ UNSAFE = "unsafe"
53
+
54
+
55
+ class CacheStatus(StrEnum):
56
+ DISABLED = "disabled"
57
+ HIT = "hit"
58
+ MISS = "miss"
59
+
60
+
61
+ class _CoverageNotice(TypedDict):
62
+ source: str
63
+ reason: str
64
+ fileCount: int
65
+ disposition: str
66
+
67
+
68
+ _AnalysisReport = TypedDict(
69
+ "_AnalysisReport",
70
+ {
71
+ "$schema": str,
72
+ "schemaVersion": int,
73
+ "root": str,
74
+ "completion": str,
75
+ "conclusion": str,
76
+ "exitCode": int,
77
+ "rules": list[dict[str, object]],
78
+ "diagnostics": list[dict[str, object]],
79
+ "issues": list[dict[str, object]],
80
+ "tools": list[dict[str, object]],
81
+ "coverage": list[_CoverageNotice],
82
+ },
83
+ )
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class CoverageNotice:
88
+ source: str
89
+ reason: str
90
+ file_count: int
91
+ disposition: CoverageDisposition = CoverageDisposition.FAILED
92
+
93
+ def __post_init__(self) -> None:
94
+ _require_text(self.source, "coverage source")
95
+ _require_text(self.reason, "coverage reason")
96
+ _require_int(self.file_count, "coverage file count")
97
+ if self.file_count < 1:
98
+ msg = "coverage notice must describe at least one file"
99
+ raise ValueError(msg)
100
+ _require_instance(self.disposition, CoverageDisposition, "coverage disposition")
101
+
102
+ @property
103
+ def blocking(self) -> bool:
104
+ return self.disposition is CoverageDisposition.FAILED
105
+
106
+ def as_dict(self) -> _CoverageNotice:
107
+ return {
108
+ "source": self.source,
109
+ "reason": self.reason,
110
+ "fileCount": self.file_count,
111
+ "disposition": self.disposition.value,
112
+ }
113
+
114
+
115
+ @dataclass(frozen=True, slots=True)
116
+ class Position:
117
+ line: int
118
+ character: int
119
+ byte_offset: int
120
+
121
+ def __post_init__(self) -> None:
122
+ _require_int(self.line, "position line")
123
+ _require_int(self.character, "position character")
124
+ _require_int(self.byte_offset, "position byte offset")
125
+ if min(self.line, self.character, self.byte_offset) < 0:
126
+ msg = "source positions cannot be negative"
127
+ raise ValueError(msg)
128
+
129
+ def as_dict(self) -> dict[str, int]:
130
+ return {"line": self.line, "character": self.character}
131
+
132
+
133
+ @dataclass(frozen=True, slots=True)
134
+ class Region:
135
+ start: Position
136
+ end: Position
137
+
138
+ def __post_init__(self) -> None:
139
+ _require_instance(self.start, Position, "source region start")
140
+ _require_instance(self.end, Position, "source region end")
141
+ if self.end.byte_offset < self.start.byte_offset:
142
+ msg = "source region end precedes its start"
143
+ raise ValueError(msg)
144
+ if (self.end.line, self.end.character) < (self.start.line, self.start.character):
145
+ msg = "source region coordinates run backwards"
146
+ raise ValueError(msg)
147
+
148
+ def as_dict(self) -> dict[str, dict[str, int]]:
149
+ return {"start": self.start.as_dict(), "end": self.end.as_dict()}
150
+
151
+
152
+ @dataclass(frozen=True, slots=True)
153
+ class Location:
154
+ path: str
155
+ position: Position | None = None
156
+ region: Region | None = None
157
+
158
+ def __post_init__(self) -> None:
159
+ _require_text(self.path, "location path")
160
+ path = Path(self.path)
161
+ windows = PureWindowsPath(self.path)
162
+ if path.is_absolute() or windows.is_absolute() or windows.drive or ".." in path.parts or "\\" in self.path:
163
+ msg = "diagnostic location must be a repository-relative path"
164
+ raise ValueError(msg)
165
+ if self.position is not None and self.region is not None:
166
+ msg = "a diagnostic location cannot carry both a point and a region"
167
+ raise ValueError(msg)
168
+ if self.position is not None:
169
+ _require_instance(self.position, Position, "diagnostic position")
170
+ if self.region is not None:
171
+ _require_instance(self.region, Region, "diagnostic region")
172
+
173
+ def as_dict(self) -> dict[str, object]:
174
+ result: dict[str, object] = {"path": self.path}
175
+ if self.region is not None:
176
+ result["range"] = self.region.as_dict()
177
+ elif self.position is not None:
178
+ result["position"] = self.position.as_dict()
179
+ return result
180
+
181
+
182
+ @dataclass(frozen=True, slots=True)
183
+ class RelatedLocation:
184
+ label: str
185
+ location: Location
186
+
187
+ def __post_init__(self) -> None:
188
+ _require_text(self.label, "related location label")
189
+ _require_instance(self.location, Location, "related location")
190
+
191
+ def as_dict(self) -> dict[str, object]:
192
+ return {"label": self.label, "location": self.location.as_dict()}
193
+
194
+
195
+ @dataclass(frozen=True, slots=True)
196
+ class TextEdit:
197
+ location: Location
198
+ replacement: str
199
+ expected_text_hash: str | None = None
200
+
201
+ def __post_init__(self) -> None:
202
+ _require_instance(self.location, Location, "text edit location")
203
+ if self.location.region is None:
204
+ msg = "text edit location must contain an exact range"
205
+ raise ValueError(msg)
206
+ _require_text(self.replacement, "text edit replacement", allow_empty=True)
207
+ if self.expected_text_hash is not None:
208
+ _require_text(self.expected_text_hash, "expected text hash")
209
+ if len(self.expected_text_hash) != _SHA256_HEX_LENGTH or any(
210
+ char not in "0123456789abcdef" for char in self.expected_text_hash
211
+ ):
212
+ msg = "expected text hash must be a lowercase SHA-256 digest"
213
+ raise ValueError(msg)
214
+
215
+ def as_dict(self) -> dict[str, object]:
216
+ result: dict[str, object] = {"location": self.location.as_dict(), "replacement": self.replacement}
217
+ if self.expected_text_hash is not None:
218
+ result["expectedTextHash"] = self.expected_text_hash
219
+ return result
220
+
221
+
222
+ @dataclass(frozen=True, slots=True)
223
+ class Fix:
224
+ title: str
225
+ safety: FixSafety
226
+ edits: tuple[TextEdit, ...]
227
+
228
+ def __post_init__(self) -> None:
229
+ _require_text(self.title, "fix title")
230
+ _require_instance(self.safety, FixSafety, "fix safety")
231
+ _require_tuple_items(self.edits, TextEdit, "fix edits")
232
+ if not self.edits:
233
+ msg = "fix must contain at least one edit"
234
+ raise ValueError(msg)
235
+ ordered = sorted(self.edits, key=_edit_key)
236
+ if tuple(ordered) != self.edits:
237
+ msg = "fix edits must be sorted by path and range"
238
+ raise ValueError(msg)
239
+ for previous, current in zip(self.edits, self.edits[1:], strict=False):
240
+ if previous.location.path != current.location.path:
241
+ continue
242
+ previous_region = previous.location.region
243
+ current_region = current.location.region
244
+ if (
245
+ previous_region is not None
246
+ and current_region is not None
247
+ and (previous_region.end.byte_offset > current_region.start.byte_offset)
248
+ ):
249
+ msg = "fix edits must not overlap"
250
+ raise ValueError(msg)
251
+
252
+ def as_dict(self) -> dict[str, object]:
253
+ return {"title": self.title, "safety": self.safety.value, "edits": [edit.as_dict() for edit in self.edits]}
254
+
255
+
256
+ @dataclass(frozen=True, slots=True)
257
+ class RuleDescriptor:
258
+ key: str
259
+ name: str
260
+ summary: str
261
+ help_url: str | None = None
262
+ tags: tuple[str, ...] = ()
263
+
264
+ def __post_init__(self) -> None:
265
+ _require_text(self.key, "rule key")
266
+ _require_text(self.name, "rule name")
267
+ _require_text(self.summary, "rule summary")
268
+ _require_tuple_text(self.tags, "rule tags")
269
+ _require_unique_text(self.tags, "rule tags")
270
+ _validate_help_url(self.help_url)
271
+
272
+ def as_dict(self) -> dict[str, object]:
273
+ result: dict[str, object] = {"key": self.key, "name": self.name, "summary": self.summary}
274
+ if self.help_url is not None:
275
+ result["helpUrl"] = self.help_url
276
+ if self.tags:
277
+ result["tags"] = list(self.tags)
278
+ return result
279
+
280
+
281
+ @dataclass(frozen=True, slots=True)
282
+ class Diagnostic:
283
+ code: str
284
+ message: str
285
+ severity: Severity
286
+ source: str
287
+ location: Location
288
+ rule_id: str | None = None
289
+ help: str | None = None
290
+ help_url: str | None = None
291
+ related: tuple[RelatedLocation, ...] = ()
292
+ notes: tuple[str, ...] = ()
293
+ fixes: tuple[Fix, ...] = ()
294
+ tags: tuple[str, ...] = ()
295
+ fingerprint: str | None = None
296
+
297
+ def __post_init__(self) -> None:
298
+ _require_text(self.code, "diagnostic code")
299
+ _require_text(self.message, "diagnostic message", allow_empty=True)
300
+ _require_text(self.source, "diagnostic source")
301
+ _require_instance(self.severity, Severity, "diagnostic severity")
302
+ _require_instance(self.location, Location, "diagnostic location")
303
+ if self.rule_id is not None:
304
+ _require_text(self.rule_id, "diagnostic rule id")
305
+ if self.help is not None:
306
+ _require_text(self.help, "diagnostic help", allow_empty=True)
307
+ _validate_help_url(self.help_url)
308
+ _require_tuple_items(self.related, RelatedLocation, "diagnostic related locations")
309
+ _require_tuple_text(self.notes, "diagnostic notes")
310
+ _require_tuple_items(self.fixes, Fix, "diagnostic fixes")
311
+ _require_tuple_text(self.tags, "diagnostic tags")
312
+ _require_unique_text(self.tags, "diagnostic tags")
313
+ if self.fingerprint is not None:
314
+ _require_text(self.fingerprint, "diagnostic fingerprint")
315
+
316
+ def as_dict(self) -> dict[str, object]:
317
+ result: dict[str, object] = {
318
+ "code": self.code,
319
+ "message": self.message,
320
+ "severity": self.severity.value,
321
+ "source": self.source,
322
+ "location": self.location.as_dict(),
323
+ }
324
+ if self.rule_id is not None:
325
+ result["ruleId"] = self.rule_id
326
+ if self.help is not None:
327
+ result["help"] = self.help
328
+ if self.help_url is not None:
329
+ result["helpUrl"] = self.help_url
330
+ result["ruleKey"] = f"{self.source}:{self.rule_id or self.code}"
331
+ if self.related:
332
+ result["relatedLocations"] = [item.as_dict() for item in self.related]
333
+ if self.notes:
334
+ result["notes"] = list(self.notes)
335
+ if self.fixes:
336
+ result["fixes"] = [item.as_dict() for item in self.fixes]
337
+ if self.tags:
338
+ result["tags"] = list(self.tags)
339
+ if self.fingerprint is not None:
340
+ result["fingerprint"] = {"algorithm": FINGERPRINT_VERSION, "value": self.fingerprint}
341
+ return result
342
+
343
+
344
+ @dataclass(frozen=True, slots=True)
345
+ class ExecutionIssue:
346
+ source: str
347
+ kind: str
348
+ message: str
349
+ exit_code: int | None = None
350
+
351
+ def __post_init__(self) -> None:
352
+ _require_text(self.source, "execution issue source")
353
+ _require_text(self.kind, "execution issue kind")
354
+ _require_text(self.message, "execution issue message", allow_empty=True)
355
+ if self.exit_code is not None:
356
+ _require_int(self.exit_code, "execution issue exit code")
357
+
358
+ def as_dict(self) -> dict[str, object]:
359
+ result: dict[str, object] = {"source": self.source, "kind": self.kind, "message": self.message}
360
+ if self.exit_code is not None:
361
+ result["exitCode"] = self.exit_code
362
+ return result
363
+
364
+
365
+ @dataclass(frozen=True, slots=True)
366
+ class ToolReport:
367
+ name: str
368
+ completion: Completion
369
+ diagnostics: tuple[Diagnostic, ...] = ()
370
+ issues: tuple[ExecutionIssue, ...] = ()
371
+ analyzer_id: AnalyzerId | None = None
372
+ invocation_id: InvocationId | None = None
373
+ version: str | None = None
374
+ duration_ms: int | None = None # sarj-noqa: SARJ014 — protocol durations are integer milliseconds.
375
+ file_count: int | None = None
376
+ cache_status: CacheStatus = CacheStatus.DISABLED
377
+
378
+ def __post_init__(self) -> None:
379
+ _require_text(self.name, "tool name")
380
+ _require_instance(self.completion, Completion, "tool completion")
381
+ _require_tuple_items(self.diagnostics, Diagnostic, "tool diagnostics")
382
+ _require_tuple_items(self.issues, ExecutionIssue, "tool issues")
383
+ if self.completion is Completion.COMPLETE and self.issues:
384
+ msg = "a complete tool report cannot contain execution issues"
385
+ raise ValueError(msg)
386
+ if self.completion is not Completion.COMPLETE and not self.issues:
387
+ msg = "an incomplete tool report must explain its execution issue"
388
+ raise ValueError(msg)
389
+ for value, label in (
390
+ (self.analyzer_id, "analyzer id"),
391
+ (self.invocation_id, "invocation id"),
392
+ (self.version, "tool version"),
393
+ ):
394
+ if value is not None:
395
+ _require_text(value, label)
396
+ for value, label in ((self.duration_ms, "duration"), (self.file_count, "file count")):
397
+ if value is not None:
398
+ _require_int(value, label)
399
+ if value < 0:
400
+ msg = f"{label} cannot be negative"
401
+ raise ValueError(msg)
402
+ _require_instance(self.cache_status, CacheStatus, "cache status")
403
+
404
+ def as_dict(self) -> dict[str, object]:
405
+ result: dict[str, object] = {
406
+ "name": self.name,
407
+ "completion": self.completion.value,
408
+ "diagnosticCount": len(self.diagnostics),
409
+ "issueCount": len(self.issues),
410
+ }
411
+ result["analyzerId"] = self.analyzer_id or self.name
412
+ result["invocationId"] = self.invocation_id or self.name
413
+ result["cache"] = self.cache_status.value
414
+ if self.version is not None:
415
+ result["version"] = self.version
416
+ if self.file_count is not None:
417
+ result["fileCount"] = self.file_count
418
+ return result
419
+
420
+
421
+ @dataclass(frozen=True, slots=True)
422
+ class AnalysisReport:
423
+ root: Path
424
+ completion: Completion
425
+ conclusion: Conclusion
426
+ tools: tuple[ToolReport, ...]
427
+ coverage: tuple[CoverageNotice, ...] = ()
428
+
429
+ def __post_init__(self) -> None:
430
+ _require_instance(self.root, Path, "analysis root")
431
+ _require_instance(self.completion, Completion, "analysis completion")
432
+ _require_instance(self.conclusion, Conclusion, "analysis conclusion")
433
+ _require_tuple_items(self.tools, ToolReport, "analysis tools")
434
+ _require_tuple_items(self.coverage, CoverageNotice, "analysis coverage")
435
+ has_issues = any(tool.issues for tool in self.tools)
436
+ has_findings = any(tool.diagnostics for tool in self.tools)
437
+ is_incomplete = has_issues or any(item.blocking for item in self.coverage)
438
+ expected_completion = (
439
+ Completion.FAILED
440
+ if self.tools and all(tool.completion is Completion.FAILED for tool in self.tools)
441
+ else Completion.COMPLETE
442
+ if not is_incomplete and all(tool.completion is Completion.COMPLETE for tool in self.tools)
443
+ else Completion.PARTIAL
444
+ )
445
+ if self.completion is not expected_completion:
446
+ msg = f"analysis completion must be {expected_completion.value} for its tool and coverage states"
447
+ raise ValueError(msg)
448
+ expected = (
449
+ Conclusion.FINDINGS if has_findings else Conclusion.INCONCLUSIVE if is_incomplete else Conclusion.PASSED
450
+ )
451
+ if self.conclusion is not expected:
452
+ msg = f"analysis conclusion must be {expected.value} for its findings and issues"
453
+ raise ValueError(msg)
454
+
455
+ @property
456
+ def diagnostics(self) -> tuple[Diagnostic, ...]:
457
+ return tuple(diagnostic for tool in self.tools for diagnostic in tool.diagnostics)
458
+
459
+ @property
460
+ def issues(self) -> tuple[ExecutionIssue, ...]:
461
+ return tuple(issue for tool in self.tools for issue in tool.issues)
462
+
463
+ @property
464
+ def exit_code(self) -> int:
465
+ if self.issues or any(item.blocking for item in self.coverage):
466
+ return 2
467
+ return 1 if any(item.severity is Severity.ERROR for item in self.diagnostics) else 0
468
+
469
+ @property
470
+ def ok(self) -> bool:
471
+ return self.exit_code == 0
472
+
473
+ def as_dict(self) -> _AnalysisReport:
474
+ rules: dict[str, RuleDescriptor] = {}
475
+ for item in self.diagnostics:
476
+ key = f"{item.source}:{item.rule_id or item.code}"
477
+ rules.setdefault(
478
+ key,
479
+ RuleDescriptor(
480
+ key, item.rule_id or item.code, item.help or item.rule_id or item.code, item.help_url, item.tags
481
+ ),
482
+ )
483
+ return {
484
+ "$schema": SCHEMA_URI,
485
+ "schemaVersion": SCHEMA_VERSION,
486
+ "root": ".",
487
+ "completion": self.completion.value,
488
+ "conclusion": self.conclusion.value,
489
+ "exitCode": self.exit_code,
490
+ "rules": [rules[key].as_dict() for key in sorted(rules)],
491
+ "diagnostics": [item.as_dict() for item in self.diagnostics],
492
+ "issues": [item.as_dict() for item in self.issues],
493
+ "tools": [item.as_dict() for item in self.tools],
494
+ "coverage": [item.as_dict() for item in self.coverage],
495
+ }
496
+
497
+
498
+ def diagnostic_fingerprint(diagnostic: Diagnostic, *, anchor: str) -> str:
499
+ _require_text(anchor, "diagnostic fingerprint anchor")
500
+ identity = "\0".join(
501
+ (
502
+ str(FINGERPRINT_VERSION),
503
+ diagnostic.source,
504
+ diagnostic.rule_id or diagnostic.code,
505
+ diagnostic.location.path,
506
+ anchor,
507
+ )
508
+ )
509
+ return sha256(identity.encode("utf-8")).hexdigest()
510
+
511
+
512
+ def _edit_key(edit: TextEdit) -> tuple[object, ...]:
513
+ region = edit.location.region
514
+ if region is None:
515
+ msg = "text edit location must contain an exact range"
516
+ raise ValueError(msg)
517
+ return (edit.location.path, region.start.byte_offset, region.end.byte_offset)
518
+
519
+
520
+ def _validate_help_url(value: str | None) -> None:
521
+ if value is None:
522
+ return
523
+ _require_text(value, "diagnostic help URL")
524
+ parsed = urlparse(value)
525
+ if parsed.scheme != "https" or not parsed.netloc or any(char.isspace() for char in value):
526
+ msg = "diagnostic help URL must be an absolute HTTPS URI"
527
+ raise ValueError(msg)
528
+
529
+
530
+ def _require_tuple_text(value: object, label: str) -> None:
531
+ if not isinstance(value, tuple):
532
+ msg = f"{label} must be a tuple of non-empty strings"
533
+ raise TypeError(msg)
534
+ for item in value: # pyright: ignore[reportUnknownVariableType]
535
+ if not isinstance(item, str) or not item:
536
+ msg = f"{label} must be a tuple of non-empty strings"
537
+ raise TypeError(msg)
538
+
539
+
540
+ def _require_unique_text(value: tuple[str, ...], label: str) -> None:
541
+ if len(value) != len(set(value)):
542
+ msg = f"{label} must not contain duplicates"
543
+ raise ValueError(msg)
544
+
545
+
546
+ def _require_text(value: object, label: str, *, allow_empty: bool = False) -> None:
547
+ if type(value) is not str or (not allow_empty and not value):
548
+ msg = f"{label} must not be empty"
549
+ raise ValueError(msg)
550
+
551
+
552
+ def _require_int(value: int, label: str) -> None:
553
+ if type(value) is not int:
554
+ msg = f"{label} must be an integer"
555
+ raise TypeError(msg)
556
+
557
+
558
+ def _require_instance(value: object, expected: type[object], label: str) -> None:
559
+ if not isinstance(value, expected):
560
+ msg = f"{label} must be {expected.__name__}"
561
+ raise TypeError(msg)
562
+
563
+
564
+ def _require_tuple_items(value: object, expected: type[object], label: str) -> None:
565
+ if not isinstance(value, tuple):
566
+ invalid = True
567
+ else:
568
+ invalid = type(value) is not tuple or any( # pyright: ignore[reportUnknownArgumentType] -- validated below.
569
+ not isinstance(item, expected)
570
+ for item in value # pyright: ignore[reportUnknownVariableType] -- tuple elements are validated here.
571
+ )
572
+ if invalid:
573
+ msg = f"{label} must be a tuple of {expected.__name__} values"
574
+ raise TypeError(msg)