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