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