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,318 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from operator import itemgetter
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
from typing import TYPE_CHECKING, Final, Literal, TypedDict
|
|
8
|
+
|
|
9
|
+
from sarj_standards.libs.release.process import ProcessRunner, run_process
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SCHEMA_VERSION: Final = 1
|
|
17
|
+
_INVENTORY_PATH: Final = "packages/standards/src/sarj_standards/configs/rule-inventory.v1.json"
|
|
18
|
+
_CATALOG_PATH: Final = "packages/standards/src/sarj_standards/schemas/rule-catalog.v1.json"
|
|
19
|
+
_ENGINE_BY_FAMILY: Final = MappingProxyType(
|
|
20
|
+
{
|
|
21
|
+
"typescript": "eslint",
|
|
22
|
+
"iac": "iac",
|
|
23
|
+
"python": "python",
|
|
24
|
+
"sql": "sql",
|
|
25
|
+
"text": "text",
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
_RELEASE_TARGET_BY_ENGINE: Final = MappingProxyType(
|
|
29
|
+
{
|
|
30
|
+
"eslint": "typescript",
|
|
31
|
+
"iac": "iac",
|
|
32
|
+
"python": "python",
|
|
33
|
+
"sql": "sql",
|
|
34
|
+
"text": "standards",
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
_POLICY_FIELDS: Final = frozenset({"defaultLevel", "optionsSchema"})
|
|
38
|
+
_INVENTORY_ENTRY_FIELDS: Final = frozenset({"code", "family", "id", "source", "test"})
|
|
39
|
+
_GIT_SHA_LENGTH: Final = 40
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RuleDescriptorV1(TypedDict):
|
|
43
|
+
key: str
|
|
44
|
+
engine: str
|
|
45
|
+
family: str
|
|
46
|
+
id: str
|
|
47
|
+
code: str | None
|
|
48
|
+
defaultLevel: str
|
|
49
|
+
releaseTarget: str
|
|
50
|
+
source: str
|
|
51
|
+
test: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
type ChangeKind = Literal["added", "removed", "implementation-changed", "policy-changed"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RuleChangeV1(TypedDict):
|
|
58
|
+
kind: ChangeKind
|
|
59
|
+
key: str
|
|
60
|
+
releaseTarget: str
|
|
61
|
+
before: RuleDescriptorV1 | None
|
|
62
|
+
after: RuleDescriptorV1 | None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RuleChangeSetV1(TypedDict):
|
|
66
|
+
schemaVersion: int
|
|
67
|
+
beforeSha: str
|
|
68
|
+
afterSha: str
|
|
69
|
+
changedSelectors: list[str]
|
|
70
|
+
changeSetDigest: str
|
|
71
|
+
changes: list[RuleChangeV1]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _RevisionRules(TypedDict):
|
|
75
|
+
descriptors: dict[str, RuleDescriptorV1]
|
|
76
|
+
catalog: dict[str, dict[str, object]]
|
|
77
|
+
implementation_blobs: dict[str, tuple[str, str]]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def compare(
|
|
81
|
+
root: Path,
|
|
82
|
+
*,
|
|
83
|
+
before: str,
|
|
84
|
+
after: str,
|
|
85
|
+
runner: ProcessRunner = run_process,
|
|
86
|
+
) -> RuleChangeSetV1:
|
|
87
|
+
resolved = root.resolve()
|
|
88
|
+
before_sha = _resolve_revision(resolved, before, runner=runner)
|
|
89
|
+
after_sha = _resolve_revision(resolved, after, runner=runner)
|
|
90
|
+
old = _load_revision(resolved, before_sha, runner=runner)
|
|
91
|
+
new = _load_revision(resolved, after_sha, runner=runner)
|
|
92
|
+
changes: list[RuleChangeV1] = []
|
|
93
|
+
for key in sorted(old["descriptors"].keys() | new["descriptors"].keys()):
|
|
94
|
+
old_descriptor = old["descriptors"].get(key)
|
|
95
|
+
new_descriptor = new["descriptors"].get(key)
|
|
96
|
+
if old_descriptor is None:
|
|
97
|
+
changes.append(_change("added", key, None, new_descriptor))
|
|
98
|
+
continue
|
|
99
|
+
if new_descriptor is None:
|
|
100
|
+
changes.append(_change("removed", key, old_descriptor, None))
|
|
101
|
+
continue
|
|
102
|
+
old_catalog = old["catalog"][key]
|
|
103
|
+
new_catalog = new["catalog"][key]
|
|
104
|
+
if any(old_catalog.get(field) != new_catalog.get(field) for field in _POLICY_FIELDS):
|
|
105
|
+
changes.append(_change("policy-changed", key, old_descriptor, new_descriptor))
|
|
106
|
+
if (
|
|
107
|
+
_implementation_projection(old_catalog) != _implementation_projection(new_catalog)
|
|
108
|
+
or old["implementation_blobs"][key] != new["implementation_blobs"][key]
|
|
109
|
+
):
|
|
110
|
+
changes.append(_change("implementation-changed", key, old_descriptor, new_descriptor))
|
|
111
|
+
changes.sort(key=itemgetter("key", "kind"))
|
|
112
|
+
changed_selectors = sorted({change["key"] for change in changes})
|
|
113
|
+
identity: dict[str, object] = {
|
|
114
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
115
|
+
"beforeSha": before_sha,
|
|
116
|
+
"afterSha": after_sha,
|
|
117
|
+
"changedSelectors": changed_selectors,
|
|
118
|
+
"changes": changes,
|
|
119
|
+
}
|
|
120
|
+
digest = hashlib.sha256(
|
|
121
|
+
json.dumps(identity, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode()
|
|
122
|
+
).hexdigest()
|
|
123
|
+
return {
|
|
124
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
125
|
+
"beforeSha": before_sha,
|
|
126
|
+
"afterSha": after_sha,
|
|
127
|
+
"changedSelectors": changed_selectors,
|
|
128
|
+
"changeSetDigest": digest,
|
|
129
|
+
"changes": changes,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _change(
|
|
134
|
+
kind: ChangeKind,
|
|
135
|
+
key: str,
|
|
136
|
+
before: RuleDescriptorV1 | None,
|
|
137
|
+
after: RuleDescriptorV1 | None,
|
|
138
|
+
) -> RuleChangeV1:
|
|
139
|
+
current = after if after is not None else before
|
|
140
|
+
if current is None: # pragma: no cover - compare only emits changes with at least one descriptor
|
|
141
|
+
msg = "a rule change must contain a before or after descriptor"
|
|
142
|
+
raise ValueError(msg)
|
|
143
|
+
return {
|
|
144
|
+
"kind": kind,
|
|
145
|
+
"key": key,
|
|
146
|
+
"releaseTarget": current["releaseTarget"],
|
|
147
|
+
"before": before,
|
|
148
|
+
"after": after,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _implementation_projection(rule: dict[str, object]) -> dict[str, object]:
|
|
153
|
+
return {key: value for key, value in rule.items() if key not in _POLICY_FIELDS}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _resolve_revision(root: Path, revision: str, *, runner: ProcessRunner) -> str:
|
|
157
|
+
if not revision:
|
|
158
|
+
msg = "rule comparison revisions must not be empty"
|
|
159
|
+
raise ValueError(msg)
|
|
160
|
+
result = runner(("git", "rev-parse", "--verify", f"{revision}^{{commit}}"), cwd=root, capture_output=True)
|
|
161
|
+
sha = result.stdout.strip()
|
|
162
|
+
if len(sha) != _GIT_SHA_LENGTH or any(character not in "0123456789abcdef" for character in sha):
|
|
163
|
+
msg = f"git did not resolve {revision!r} to a full lowercase commit SHA"
|
|
164
|
+
raise ValueError(msg)
|
|
165
|
+
return sha
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _load_revision( # ruff: ignore[too-many-locals] -- validates and joins two generated wire artifacts.
|
|
169
|
+
root: Path,
|
|
170
|
+
sha: str,
|
|
171
|
+
*,
|
|
172
|
+
runner: ProcessRunner,
|
|
173
|
+
) -> _RevisionRules:
|
|
174
|
+
inventory = _git_json(root, sha, _INVENTORY_PATH, runner=runner)
|
|
175
|
+
catalog = _git_json(root, sha, _CATALOG_PATH, runner=runner)
|
|
176
|
+
inventory_entries = _rules_array(inventory, label="rule inventory")
|
|
177
|
+
catalog_entries = _rules_array(catalog, label="rule catalog")
|
|
178
|
+
|
|
179
|
+
inventory_by_key: dict[str, dict[str, object]] = {}
|
|
180
|
+
for index, raw in enumerate(inventory_entries, start=1):
|
|
181
|
+
entry = _object(raw, label=f"rule inventory entry {index}")
|
|
182
|
+
if frozenset(entry) != _INVENTORY_ENTRY_FIELDS:
|
|
183
|
+
msg = f"rule inventory entry {index} has unexpected or missing fields"
|
|
184
|
+
raise ValueError(msg)
|
|
185
|
+
family = _string(entry, "family")
|
|
186
|
+
try:
|
|
187
|
+
engine = _ENGINE_BY_FAMILY[family]
|
|
188
|
+
except KeyError as exc:
|
|
189
|
+
msg = f"rule inventory entry {index} has unknown family {family!r}"
|
|
190
|
+
raise ValueError(msg) from exc
|
|
191
|
+
rule_id = _string(entry, "id")
|
|
192
|
+
key = f"{engine}:{rule_id}"
|
|
193
|
+
if key in inventory_by_key:
|
|
194
|
+
msg = f"rule inventory repeats {key}"
|
|
195
|
+
raise ValueError(msg)
|
|
196
|
+
inventory_by_key[key] = entry
|
|
197
|
+
|
|
198
|
+
catalog_by_key: dict[str, dict[str, object]] = {}
|
|
199
|
+
for index, raw in enumerate(catalog_entries, start=1):
|
|
200
|
+
entry = _object(raw, label=f"rule catalog entry {index}")
|
|
201
|
+
key = _string(entry, "key")
|
|
202
|
+
engine = _string(entry, "engine")
|
|
203
|
+
rule_id = _string(entry, "id")
|
|
204
|
+
if key != f"{engine}:{rule_id}" or engine not in _RELEASE_TARGET_BY_ENGINE:
|
|
205
|
+
msg = f"rule catalog entry {index} has inconsistent key/engine/id"
|
|
206
|
+
raise ValueError(msg)
|
|
207
|
+
if key in catalog_by_key:
|
|
208
|
+
msg = f"rule catalog repeats {key}"
|
|
209
|
+
raise ValueError(msg)
|
|
210
|
+
catalog_by_key[key] = entry
|
|
211
|
+
|
|
212
|
+
if inventory_by_key.keys() != catalog_by_key.keys():
|
|
213
|
+
missing_catalog = sorted(inventory_by_key.keys() - catalog_by_key.keys())
|
|
214
|
+
missing_inventory = sorted(catalog_by_key.keys() - inventory_by_key.keys())
|
|
215
|
+
msg = (
|
|
216
|
+
"rule inventory/catalog disagreement"
|
|
217
|
+
f"; missing catalog: {', '.join(missing_catalog) or '-'}"
|
|
218
|
+
f"; missing inventory: {', '.join(missing_inventory) or '-'}"
|
|
219
|
+
)
|
|
220
|
+
raise ValueError(msg)
|
|
221
|
+
|
|
222
|
+
descriptors: dict[str, RuleDescriptorV1] = {}
|
|
223
|
+
implementation_blobs: dict[str, tuple[str, str]] = {}
|
|
224
|
+
for key in sorted(inventory_by_key):
|
|
225
|
+
inventory_entry = inventory_by_key[key]
|
|
226
|
+
catalog_entry = catalog_by_key[key]
|
|
227
|
+
family = _string(inventory_entry, "family")
|
|
228
|
+
engine = _ENGINE_BY_FAMILY[family]
|
|
229
|
+
catalog_code = catalog_entry.get("code")
|
|
230
|
+
if catalog_code is not None and not isinstance(catalog_code, str):
|
|
231
|
+
msg = f"rule catalog {key} has invalid code"
|
|
232
|
+
raise TypeError(msg)
|
|
233
|
+
inventory_code = _string(inventory_entry, "code")
|
|
234
|
+
if catalog_code is not None and inventory_code != catalog_code:
|
|
235
|
+
msg = f"rule inventory/catalog disagreement for {key}: code differs"
|
|
236
|
+
raise ValueError(msg)
|
|
237
|
+
descriptors[key] = {
|
|
238
|
+
"key": key,
|
|
239
|
+
"engine": engine,
|
|
240
|
+
"family": family,
|
|
241
|
+
"id": _string(inventory_entry, "id"),
|
|
242
|
+
"code": catalog_code,
|
|
243
|
+
"defaultLevel": _string(catalog_entry, "defaultLevel"),
|
|
244
|
+
"releaseTarget": _RELEASE_TARGET_BY_ENGINE[engine],
|
|
245
|
+
"source": _string(inventory_entry, "source"),
|
|
246
|
+
"test": _string(inventory_entry, "test"),
|
|
247
|
+
}
|
|
248
|
+
implementation_blobs[key] = (
|
|
249
|
+
_git_blob_oid(root, sha, descriptors[key]["source"], runner=runner),
|
|
250
|
+
_git_blob_oid(root, sha, descriptors[key]["test"], runner=runner),
|
|
251
|
+
)
|
|
252
|
+
return {
|
|
253
|
+
"descriptors": descriptors,
|
|
254
|
+
"catalog": catalog_by_key,
|
|
255
|
+
"implementation_blobs": implementation_blobs,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _git_blob_oid(root: Path, sha: str, path: str, *, runner: ProcessRunner) -> str:
|
|
260
|
+
if path.startswith("/") or ".." in path.split("/"):
|
|
261
|
+
msg = f"rule implementation path must be repository-relative: {path!r}"
|
|
262
|
+
raise ValueError(msg)
|
|
263
|
+
result = runner(("git", "rev-parse", "--verify", f"{sha}:{path}"), cwd=root, capture_output=True)
|
|
264
|
+
oid = result.stdout.strip()
|
|
265
|
+
if len(oid) != _GIT_SHA_LENGTH or any(character not in "0123456789abcdef" for character in oid):
|
|
266
|
+
msg = f"git did not resolve rule implementation {path!r} at {sha} to a blob"
|
|
267
|
+
raise ValueError(msg)
|
|
268
|
+
return oid
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _git_json(root: Path, sha: str, path: str, *, runner: ProcessRunner) -> dict[str, object]:
|
|
272
|
+
result = runner(("git", "show", f"{sha}:{path}"), cwd=root, capture_output=True)
|
|
273
|
+
try:
|
|
274
|
+
payload: object = json.loads(result.stdout) # pyright: ignore[reportAny]
|
|
275
|
+
except json.JSONDecodeError as exc:
|
|
276
|
+
msg = f"{path} at {sha} is not valid JSON"
|
|
277
|
+
raise ValueError(msg) from exc
|
|
278
|
+
return _object(payload, label=path)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _rules_array(document: dict[str, object], *, label: str) -> list[object]:
|
|
282
|
+
if document.get("schemaVersion") != 1 or set(document) != {"schemaVersion", "rules"}:
|
|
283
|
+
msg = f"{label} must contain exactly schemaVersion 1 and rules"
|
|
284
|
+
raise ValueError(msg)
|
|
285
|
+
rules = document["rules"]
|
|
286
|
+
if not isinstance(rules, list):
|
|
287
|
+
msg = f"{label} rules must be an array"
|
|
288
|
+
raise TypeError(msg)
|
|
289
|
+
return rules # pyright: ignore[reportUnknownVariableType]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _object(value: object, *, label: str) -> dict[str, object]:
|
|
293
|
+
if not isinstance(value, dict):
|
|
294
|
+
msg = f"{label} must be an object"
|
|
295
|
+
raise TypeError(msg)
|
|
296
|
+
if not all(isinstance(key, str) for key in value): # pyright: ignore[reportUnknownVariableType]
|
|
297
|
+
msg = f"{label} must have string keys"
|
|
298
|
+
raise TypeError(msg)
|
|
299
|
+
return value # pyright: ignore[reportUnknownVariableType]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _string(value: dict[str, object], key: str) -> str:
|
|
303
|
+
item = value.get(key)
|
|
304
|
+
if not isinstance(item, str) or not item:
|
|
305
|
+
msg = f"rule field {key} must be a non-empty string"
|
|
306
|
+
raise TypeError(msg)
|
|
307
|
+
return item
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def render_text(result: RuleChangeSetV1) -> str:
|
|
311
|
+
lines = [f"rules {result['beforeSha']}..{result['afterSha']}"]
|
|
312
|
+
lines.extend(f"{item['kind']}: {item['key']}" for item in result["changes"])
|
|
313
|
+
if not result["changes"]:
|
|
314
|
+
lines.append("no rule changes")
|
|
315
|
+
return "\n".join(lines)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
__all__ = ["RuleChangeSetV1", "RuleChangeV1", "RuleDescriptorV1", "compare", "render_text"]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path, PurePosixPath
|
|
6
|
+
from typing import Final, TypedDict, TypeGuard
|
|
7
|
+
|
|
8
|
+
from sarj_standards._meta import CONFIGS_DIR
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
SCHEMA_VERSION: Final = 1
|
|
12
|
+
RULE_INVENTORY_PATH: Final = CONFIGS_DIR / "rule-inventory.v1.json"
|
|
13
|
+
_REPOSITORY_INVENTORY_PATH: Final = Path("packages/standards/src/sarj_standards/configs/rule-inventory.v1.json")
|
|
14
|
+
_RULE_FIELDS: Final = frozenset({"family", "id", "code", "source", "test"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class InventorySyncResult:
|
|
19
|
+
status: int
|
|
20
|
+
message: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RuleInventoryEntry(TypedDict):
|
|
24
|
+
family: str
|
|
25
|
+
id: str
|
|
26
|
+
code: str
|
|
27
|
+
source: str
|
|
28
|
+
test: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RuleInventory(TypedDict):
|
|
32
|
+
schemaVersion: int
|
|
33
|
+
rules: list[RuleInventoryEntry]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_object(value: object) -> TypeGuard[dict[str, object]]:
|
|
37
|
+
return isinstance(value, dict)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def validate(value: object) -> RuleInventory:
|
|
41
|
+
if not _is_object(value) or frozenset(value) != frozenset({"schemaVersion", "rules"}):
|
|
42
|
+
msg = "rule inventory must contain exactly schemaVersion and rules"
|
|
43
|
+
raise ValueError(msg)
|
|
44
|
+
if value["schemaVersion"] != SCHEMA_VERSION:
|
|
45
|
+
msg = f"unsupported rule inventory schemaVersion: {value['schemaVersion']!r}; expected {SCHEMA_VERSION}"
|
|
46
|
+
raise ValueError(msg)
|
|
47
|
+
raw_rules = value["rules"]
|
|
48
|
+
if not _is_array(raw_rules):
|
|
49
|
+
msg = "rule inventory rules must be an array"
|
|
50
|
+
raise ValueError(msg)
|
|
51
|
+
|
|
52
|
+
rules = [_validated_rule(rule, index=index) for index, rule in enumerate(raw_rules, start=1)]
|
|
53
|
+
keys = [(rule["family"], rule["id"]) for rule in rules]
|
|
54
|
+
if keys != sorted(keys):
|
|
55
|
+
msg = "rule inventory entries must be sorted by family and id"
|
|
56
|
+
raise ValueError(msg)
|
|
57
|
+
if len(keys) != len(set(keys)):
|
|
58
|
+
msg = "rule inventory contains duplicate family/id entries"
|
|
59
|
+
raise ValueError(msg)
|
|
60
|
+
return {"schemaVersion": SCHEMA_VERSION, "rules": rules}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_array(value: object) -> TypeGuard[list[object]]:
|
|
64
|
+
return isinstance(value, list)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _validated_rule(value: object, *, index: int) -> RuleInventoryEntry:
|
|
68
|
+
if not _is_object(value) or frozenset(value) != _RULE_FIELDS:
|
|
69
|
+
msg = f"rule inventory entry {index} must contain exactly: {', '.join(sorted(_RULE_FIELDS))}"
|
|
70
|
+
raise ValueError(msg)
|
|
71
|
+
|
|
72
|
+
fields: dict[str, str] = {}
|
|
73
|
+
for field in _RULE_FIELDS:
|
|
74
|
+
item = value[field]
|
|
75
|
+
if not isinstance(item, str) or not item:
|
|
76
|
+
msg = f"rule inventory entry {index} has invalid {field}"
|
|
77
|
+
raise ValueError(msg)
|
|
78
|
+
fields[field] = item
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
"family": fields["family"],
|
|
82
|
+
"id": fields["id"],
|
|
83
|
+
"code": fields["code"],
|
|
84
|
+
"source": _relative_repository_path(fields["source"], field="source", index=index),
|
|
85
|
+
"test": _relative_repository_path(fields["test"], field="test", index=index),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _relative_repository_path(value: str, *, field: str, index: int) -> str:
|
|
90
|
+
path = PurePosixPath(value)
|
|
91
|
+
if not value or "\\" in value or path.is_absolute() or ".." in path.parts or path.as_posix() != value:
|
|
92
|
+
msg = f"rule inventory entry {index} has invalid {field}: {value!r}"
|
|
93
|
+
raise ValueError(msg)
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load(path: Path = RULE_INVENTORY_PATH) -> RuleInventory:
|
|
98
|
+
try:
|
|
99
|
+
payload: object = json.loads(path.read_text(encoding="utf-8")) # pyright: ignore[reportAny]
|
|
100
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
101
|
+
msg = f"cannot load shipped rule inventory {path}: {exc}"
|
|
102
|
+
raise ValueError(msg) from exc
|
|
103
|
+
return validate(payload)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def build(root: Path) -> RuleInventory:
|
|
107
|
+
from sarj_standards.libs.repository import rule_maintenance # ruff: ignore[import-outside-top-level]
|
|
108
|
+
|
|
109
|
+
return validate({"schemaVersion": SCHEMA_VERSION, "rules": rule_maintenance.inventory(root)})
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def render(root: Path) -> str:
|
|
113
|
+
return (
|
|
114
|
+
json.dumps(
|
|
115
|
+
build(root),
|
|
116
|
+
ensure_ascii=False,
|
|
117
|
+
separators=(",", ":"),
|
|
118
|
+
sort_keys=True,
|
|
119
|
+
)
|
|
120
|
+
+ "\n"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def sync(root: Path, *, check: bool) -> InventorySyncResult:
|
|
125
|
+
from sarj_standards.libs.adoption import transaction # ruff: ignore[import-outside-top-level]
|
|
126
|
+
|
|
127
|
+
destination = root.resolve() / _REPOSITORY_INVENTORY_PATH
|
|
128
|
+
expected = render(root)
|
|
129
|
+
try:
|
|
130
|
+
current = destination.read_text(encoding="utf-8")
|
|
131
|
+
except FileNotFoundError:
|
|
132
|
+
current = ""
|
|
133
|
+
|
|
134
|
+
if current == expected:
|
|
135
|
+
return InventorySyncResult(0, "ok: rule-inventory.v1.json matches live registries")
|
|
136
|
+
if check:
|
|
137
|
+
return InventorySyncResult(
|
|
138
|
+
1,
|
|
139
|
+
"drift: rule-inventory.v1.json differs from live registries; run `code-standards maintain rules sync`",
|
|
140
|
+
)
|
|
141
|
+
transaction.atomic_write_text(root.resolve(), destination, expected)
|
|
142
|
+
return InventorySyncResult(0, "updated: rule-inventory.v1.json")
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from difflib import get_close_matches
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Final, TypeGuard
|
|
9
|
+
|
|
10
|
+
from sarj_standards.libs.adoption import transaction
|
|
11
|
+
from sarj_standards.libs.repository import rule_catalog_artifact, rule_inventory_artifact, rule_maintenance
|
|
12
|
+
from sarj_standards.libs.rules import RuleEngine, RuleId, RuleSelector
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_WARNING_PATH: Final = Path("packages/standards/src/sarj_standards/configs/rule-warning-levels.v1.json")
|
|
16
|
+
_INVENTORY_PATH: Final = Path("packages/standards/src/sarj_standards/configs/rule-inventory.v1.json")
|
|
17
|
+
_CATALOG_PATH: Final = Path("packages/standards/src/sarj_standards/schemas/rule-catalog.v1.json")
|
|
18
|
+
_LEDGER_PATH: Final = Path("packages/standards/src/sarj_standards/configs/rule-ledger.json")
|
|
19
|
+
_ENGINE_BY_FAMILY: Final = MappingProxyType(
|
|
20
|
+
{
|
|
21
|
+
"typescript": RuleEngine.ESLINT,
|
|
22
|
+
"iac": RuleEngine.IAC,
|
|
23
|
+
"python": RuleEngine.PYTHON,
|
|
24
|
+
"sql": RuleEngine.SQL,
|
|
25
|
+
"text": RuleEngine.TEXT,
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class StageResult:
|
|
32
|
+
status: int
|
|
33
|
+
changed: bool
|
|
34
|
+
message: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def stage_warning(root: Path, selector: RuleSelector, *, check: bool = False) -> StageResult:
|
|
38
|
+
repository = root.resolve()
|
|
39
|
+
inventory = rule_inventory_artifact.build(repository)
|
|
40
|
+
known = {
|
|
41
|
+
RuleSelector(_ENGINE_BY_FAMILY[entry["family"]], RuleId(entry["id"]))
|
|
42
|
+
for entry in inventory["rules"]
|
|
43
|
+
if entry["family"] in _ENGINE_BY_FAMILY
|
|
44
|
+
}
|
|
45
|
+
if selector not in known:
|
|
46
|
+
raise ValueError(_unknown_selector_message(selector, known))
|
|
47
|
+
|
|
48
|
+
# Building before mutation proves source-owned metadata/examples are complete.
|
|
49
|
+
_ = rule_catalog_artifact.build(repository)
|
|
50
|
+
warning_path = repository / _WARNING_PATH
|
|
51
|
+
selected = set(_load(warning_path))
|
|
52
|
+
already_staged = selector in selected
|
|
53
|
+
selected.add(selector)
|
|
54
|
+
rendered = _render(selected)
|
|
55
|
+
warning_current = warning_path.read_text(encoding="utf-8") == rendered
|
|
56
|
+
derived_current = _derived_current(repository) if already_staged and warning_current else False
|
|
57
|
+
if already_staged and warning_current and derived_current:
|
|
58
|
+
return StageResult(status=0, changed=False, message=f"ok: {selector} is already warning-stage")
|
|
59
|
+
if check:
|
|
60
|
+
return StageResult(
|
|
61
|
+
status=1,
|
|
62
|
+
changed=False,
|
|
63
|
+
message=(
|
|
64
|
+
f"drift: synchronize derived artifacts for warning-stage {selector}"
|
|
65
|
+
if already_staged
|
|
66
|
+
else f"drift: stage {selector} as warning before publication"
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
managed = (_WARNING_PATH, _INVENTORY_PATH, _CATALOG_PATH, _LEDGER_PATH)
|
|
71
|
+
paths = tuple(repository / path for path in managed)
|
|
72
|
+
mutation = transaction.FileTransaction.capture(repository, paths)
|
|
73
|
+
try:
|
|
74
|
+
if not warning_current:
|
|
75
|
+
transaction.atomic_write_text(repository, warning_path, rendered)
|
|
76
|
+
mutation.mark_written(warning_path)
|
|
77
|
+
_synchronize(repository, mutation)
|
|
78
|
+
except BaseException:
|
|
79
|
+
rollback = mutation.rollback()
|
|
80
|
+
if not rollback.ok:
|
|
81
|
+
msg = rollback.render() or "rule lifecycle rollback was incomplete"
|
|
82
|
+
raise RuntimeError(msg) from None
|
|
83
|
+
raise
|
|
84
|
+
return StageResult(
|
|
85
|
+
status=0,
|
|
86
|
+
changed=True,
|
|
87
|
+
message=(
|
|
88
|
+
f"synchronized: warning lifecycle and derived artifacts for {selector}"
|
|
89
|
+
if already_staged
|
|
90
|
+
else f"staged: {selector} will ship at warning level"
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _render(selectors: set[RuleSelector]) -> str:
|
|
96
|
+
return (
|
|
97
|
+
json.dumps(
|
|
98
|
+
{"schemaVersion": 1, "rules": sorted(str(item) for item in selectors)},
|
|
99
|
+
ensure_ascii=False,
|
|
100
|
+
separators=(",", ":"),
|
|
101
|
+
sort_keys=True,
|
|
102
|
+
)
|
|
103
|
+
+ "\n"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _unknown_selector_message(selector: RuleSelector, known: set[RuleSelector]) -> str:
|
|
108
|
+
requested = str(selector)
|
|
109
|
+
suggestion = get_close_matches(requested, (str(item) for item in known), n=1, cutoff=0.6)
|
|
110
|
+
if suggestion:
|
|
111
|
+
return f"unknown live rule selector: {requested}; did you mean {suggestion[0]}?"
|
|
112
|
+
return f"unknown live rule selector: {requested}; run `code-standards maintain rules manifest` to list selectors"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _derived_current(repository: Path) -> bool:
|
|
116
|
+
results = (
|
|
117
|
+
rule_inventory_artifact.sync(repository, check=True),
|
|
118
|
+
rule_maintenance.sync_ledger(repository, check=True),
|
|
119
|
+
rule_catalog_artifact.sync(repository, check=True),
|
|
120
|
+
)
|
|
121
|
+
return all(result.status == 0 for result in results)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _synchronize(repository: Path, mutation: transaction.FileTransaction) -> None:
|
|
125
|
+
operations = (
|
|
126
|
+
(rule_maintenance.sync_ledger, repository / _LEDGER_PATH),
|
|
127
|
+
(rule_inventory_artifact.sync, repository / _INVENTORY_PATH),
|
|
128
|
+
(rule_catalog_artifact.sync, repository / _CATALOG_PATH),
|
|
129
|
+
)
|
|
130
|
+
for operation, path in operations:
|
|
131
|
+
result = operation(repository, check=False)
|
|
132
|
+
mutation.mark_written(path)
|
|
133
|
+
if result.status != 0:
|
|
134
|
+
msg = f"could not synchronize derived rule artifact: {path}"
|
|
135
|
+
raise RuntimeError(msg)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _load(path: Path) -> tuple[RuleSelector, ...]:
|
|
139
|
+
payload: object = json.loads(path.read_text(encoding="utf-8")) # pyright: ignore[reportAny]
|
|
140
|
+
if not _is_object(payload):
|
|
141
|
+
msg = "rule warning lifecycle must contain exactly schemaVersion and rules"
|
|
142
|
+
raise TypeError(msg)
|
|
143
|
+
if set(payload) != {"schemaVersion", "rules"}:
|
|
144
|
+
msg = "rule warning lifecycle must contain exactly schemaVersion and rules"
|
|
145
|
+
raise ValueError(msg)
|
|
146
|
+
rules = payload.get("rules")
|
|
147
|
+
if payload.get("schemaVersion") != 1 or not _is_array(rules):
|
|
148
|
+
msg = "rule warning lifecycle must use schemaVersion 1 and a rules array"
|
|
149
|
+
raise ValueError(msg)
|
|
150
|
+
values = rules
|
|
151
|
+
if any(not isinstance(value, str) or not value for value in values):
|
|
152
|
+
msg = "rule warning lifecycle must contain unique non-empty selectors"
|
|
153
|
+
raise ValueError(msg)
|
|
154
|
+
selectors = tuple(RuleSelector.parse(value) for value in values if isinstance(value, str))
|
|
155
|
+
if len(set(selectors)) != len(selectors):
|
|
156
|
+
msg = "rule warning lifecycle must contain unique non-empty selectors"
|
|
157
|
+
raise ValueError(msg)
|
|
158
|
+
return selectors
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _is_object(value: object) -> TypeGuard[dict[str, object]]:
|
|
162
|
+
# JSON object keys are strings by definition.
|
|
163
|
+
return isinstance(value, dict)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _is_array(value: object) -> TypeGuard[list[object]]:
|
|
167
|
+
return isinstance(value, list)
|