sidegraph 0.1.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.
- sidegraph/__init__.py +37 -0
- sidegraph/anchoring.py +246 -0
- sidegraph/bootstrap/__init__.py +49 -0
- sidegraph/bootstrap/apply.py +603 -0
- sidegraph/bootstrap/catalog.py +92 -0
- sidegraph/bootstrap/cli.py +827 -0
- sidegraph/bootstrap/integrations.py +184 -0
- sidegraph/bootstrap/model.py +277 -0
- sidegraph/bootstrap/planner.py +400 -0
- sidegraph/bootstrap/proof.py +103 -0
- sidegraph/bootstrap/review.py +331 -0
- sidegraph/bootstrap/scan.py +289 -0
- sidegraph/capture.py +1794 -0
- sidegraph/cli.py +1902 -0
- sidegraph/config.py +148 -0
- sidegraph/doc_import.py +2099 -0
- sidegraph/doctor.py +1429 -0
- sidegraph/domains.py +902 -0
- sidegraph/engine/__init__.py +7 -0
- sidegraph/engine/reader.py +353 -0
- sidegraph/gitio.py +572 -0
- sidegraph/host/__init__.py +7 -0
- sidegraph/host/hooks.py +770 -0
- sidegraph/importer.py +239 -0
- sidegraph/okf.py +471 -0
- sidegraph/profiles.py +459 -0
- sidegraph/retrieval.py +1657 -0
- sidegraph/schema.py +386 -0
- sidegraph/server.py +2608 -0
- sidegraph/store.py +3363 -0
- sidegraph/sync.py +885 -0
- sidegraph/verify.py +1040 -0
- sidegraph/viz/__init__.py +4 -0
- sidegraph/viz/assets/vis-network.min.js +33 -0
- sidegraph/viz/model.py +248 -0
- sidegraph/viz/render.py +110 -0
- sidegraph/viz/template.html +131 -0
- sidegraph-0.1.0.dist-info/METADATA +392 -0
- sidegraph-0.1.0.dist-info/RECORD +42 -0
- sidegraph-0.1.0.dist-info/WHEEL +4 -0
- sidegraph-0.1.0.dist-info/entry_points.txt +18 -0
- sidegraph-0.1.0.dist-info/licenses/LICENSE +201 -0
sidegraph/verify.py
ADDED
|
@@ -0,0 +1,1040 @@
|
|
|
1
|
+
"""Integrity lint for the decision store — ``sidegraph-verify``.
|
|
2
|
+
|
|
3
|
+
See design/superpowers/specs/2026-07-11-ci-integrity-design.md ruling 2. Two layers:
|
|
4
|
+
|
|
5
|
+
- **Snapshot layer** (:func:`verify_snapshot`, always runs): every hot/archived file
|
|
6
|
+
parses against its schema and the store's referential invariants — see this section's
|
|
7
|
+
docstring below.
|
|
8
|
+
- **Transition layer** (:func:`verify_against`, ``--against <git-ref>``, CI mode): for
|
|
9
|
+
each store file that CHANGED vs a git ref, classify the modification against the
|
|
10
|
+
store's OWN write rules — append-only as a checkable contract, not a blanket "committed
|
|
11
|
+
files never change" lint (which would be wrong: supersede legally closes a predecessor,
|
|
12
|
+
ratify legally flips statuses, sync legally refreshes engine-mapping fields, compact
|
|
13
|
+
legally deletes a hot file once archived). See the "transition layer" section near the
|
|
14
|
+
bottom of this module for the mutable-field tables (derived from ``store.py``'s write
|
|
15
|
+
methods, not invented) and :func:`classify_transition`/:func:`verify_against`.
|
|
16
|
+
|
|
17
|
+
**PURE READ.** :func:`verify_snapshot` opens the canonical files under a store directory
|
|
18
|
+
directly (``json.loads`` + ``Model.model_validate``) and never constructs a
|
|
19
|
+
:class:`sidegraph.store.Store`. Opening a ``Store`` is NOT a read-only operation — its
|
|
20
|
+
``__init__`` runs ``_refresh_freshness``, which can WRITE: a stale/missing digest triggers
|
|
21
|
+
``_reload_index_from_canonical`` (rewrites ``index.db``'s digest + ``schema_version``
|
|
22
|
+
meta), and an unmigrated legacy single-file store gets migrated in place. A lint command
|
|
23
|
+
must never mutate the very thing it is inspecting.
|
|
24
|
+
|
|
25
|
+
Reading the canonical files directly (rather than the derived ``index.db``) is also *the
|
|
26
|
+
point*, not just a side constraint: the index's own reload path is deliberately tolerant of
|
|
27
|
+
several on-disk shapes this module exists specifically to flag —
|
|
28
|
+
:func:`~sidegraph.store.Store._reload_index_from_canonical` silently treats a hot file that
|
|
29
|
+
duplicates an already-archived record as harmless "crash-window debris" whenever the two
|
|
30
|
+
copies are byte-identical (see
|
|
31
|
+
``docs/reference/store-format.md#archive-segments-sidegraph-compact``), and only warns (never
|
|
32
|
+
fails, never surfaces in any report) on a genuine hot/archive mismatch. ``verify_snapshot`` is
|
|
33
|
+
stricter on purpose, with exactly ONE sanctioned exception (design amendment, Task-2 review):
|
|
34
|
+
two or more archive segments carrying byte-IDENTICAL payloads for the same id are the store's
|
|
35
|
+
own tolerated cross-branch merge shape (independent ``sidegraph-compact`` runs on two
|
|
36
|
+
branches, later merged — see ``store._archived_records``'s "guaranteed byte-identical by
|
|
37
|
+
design") and are exempt from :data:`DUPLICATE_ULID`. Every other multi-location shape is
|
|
38
|
+
flagged: two+ hot files sharing an internal id (the store's file-per-record model allows at
|
|
39
|
+
most one), a hot file coexisting with ANY archive copy, or archive segments whose payloads for
|
|
40
|
+
the same id actually differ. A hot file whose *filename* doesn't match its own internal id is
|
|
41
|
+
a separate finding, :data:`FILENAME_ID_MISMATCH` — the store always writes
|
|
42
|
+
``<record's own id>.json``, so a mismatch is never legitimate.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import json
|
|
48
|
+
import subprocess
|
|
49
|
+
from dataclasses import dataclass, replace
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
|
|
52
|
+
from pydantic import BaseModel, ValidationError
|
|
53
|
+
|
|
54
|
+
from .schema import SCHEMA_VERSION, AnchorBinding, Decision, Domain, Entity, Fact, Initiative
|
|
55
|
+
from .store import (
|
|
56
|
+
_FORMAT_MARKER_NAME,
|
|
57
|
+
_FORMAT_MARKER_PREFIX,
|
|
58
|
+
_MIGRATABLE_SCHEMA_VERSIONS,
|
|
59
|
+
_RELOADABLE_SCHEMA_VERSIONS,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Violation codes — pinned constants (design ruling 2). The CLI's --json output and every
|
|
63
|
+
# test assert against these exact strings, never a free-form message.
|
|
64
|
+
PARSE_ERROR = "parse-error"
|
|
65
|
+
UNKNOWN_SCHEMA_VERSION = "unknown-schema-version"
|
|
66
|
+
BAD_VALIDITY_WINDOW = "bad-validity-window"
|
|
67
|
+
SUPERSEDED_WITHOUT_SUCCESSOR = "superseded-without-successor"
|
|
68
|
+
DANGLING_SUPERSEDES = "dangling-supersedes"
|
|
69
|
+
DANGLING_BINDING_ENTITY = "dangling-binding-entity"
|
|
70
|
+
DANGLING_FACT_SUPPORT = "dangling-fact-support"
|
|
71
|
+
DUPLICATE_ULID = "duplicate-ulid"
|
|
72
|
+
BAD_ARCHIVE_SEGMENT = "bad-archive-segment"
|
|
73
|
+
FILENAME_ID_MISMATCH = "filename-id-mismatch"
|
|
74
|
+
|
|
75
|
+
# Transition-layer codes (design ruling 2, "Transition layer" — see that section below).
|
|
76
|
+
ILLEGAL_FIELD_CHANGE = "illegal-field-change"
|
|
77
|
+
ILLEGAL_STATUS_JUMP = "illegal-status-jump"
|
|
78
|
+
VALID_TO_UNSET = "valid-to-unset"
|
|
79
|
+
VALID_TO_CHANGED = "valid-to-changed"
|
|
80
|
+
ILLEGAL_DELETION = "illegal-deletion"
|
|
81
|
+
|
|
82
|
+
# A store's committed ``format`` marker (see ``store._ensure_format_marker``) is only ever
|
|
83
|
+
# stamped with the running code's CURRENT ``SCHEMA_VERSION`` at write time — it is never
|
|
84
|
+
# rewritten by a mere reload. A store this code can actually OPEN (see
|
|
85
|
+
# ``store._RELOADABLE_SCHEMA_VERSIONS``/``_MIGRATABLE_SCHEMA_VERSIONS``) may therefore still
|
|
86
|
+
# carry an older marker; all three sets together are exactly "known to this running code",
|
|
87
|
+
# mirroring what ``Store.__init__`` itself tolerates without raising.
|
|
88
|
+
_KNOWN_SCHEMA_VERSIONS = frozenset(
|
|
89
|
+
{SCHEMA_VERSION} | _RELOADABLE_SCHEMA_VERSIONS | _MIGRATABLE_SCHEMA_VERSIONS
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Substring pydantic's wrapped ``ValueError`` carries for the ``Decision``/``Fact``
|
|
93
|
+
# validity-window ``model_validator`` (see schema.py) — see ``_check_temporal_dir``.
|
|
94
|
+
_VALIDITY_WINDOW_MSG = "valid_to must be >= valid_from"
|
|
95
|
+
|
|
96
|
+
# Both DecisionStatus.SUPERSEDED and DomainStatus.SUPERSEDED serialize to this same literal
|
|
97
|
+
# (see schema.py) — the supersedes-chain check is generic across decisions/facts/domains, so
|
|
98
|
+
# it compares against the raw string rather than importing either enum.
|
|
99
|
+
_SUPERSEDED = "superseded"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class Violation:
|
|
104
|
+
"""One integrity finding. ``path`` is always a string (a file path, or a synthetic
|
|
105
|
+
``dir/<id>.json`` for an id that only exists inside an archive segment)."""
|
|
106
|
+
|
|
107
|
+
code: str
|
|
108
|
+
path: str
|
|
109
|
+
detail: str
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _violation(code: str, path: Path | str, detail: str) -> Violation:
|
|
113
|
+
return Violation(code=code, path=str(path), detail=detail)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# -- generic file loading -----------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _iter_json_files(dir_path: Path) -> list[Path]:
|
|
120
|
+
if not dir_path.is_dir():
|
|
121
|
+
return []
|
|
122
|
+
return sorted(dir_path.glob("*.json"))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _load_raw_json(path: Path) -> tuple[object | None, Violation | None]:
|
|
126
|
+
"""Read+parse one file as JSON. Never raises — a decode/read failure becomes a
|
|
127
|
+
:data:`PARSE_ERROR` violation instead, so one corrupt file never aborts the whole
|
|
128
|
+
snapshot pass."""
|
|
129
|
+
try:
|
|
130
|
+
text = path.read_text(encoding="utf-8")
|
|
131
|
+
except OSError as e:
|
|
132
|
+
return None, _violation(PARSE_ERROR, path, f"unreadable: {e}")
|
|
133
|
+
try:
|
|
134
|
+
return json.loads(text), None
|
|
135
|
+
except json.JSONDecodeError as e:
|
|
136
|
+
return None, _violation(PARSE_ERROR, path, f"invalid JSON: {e}")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _check_filename_matches_id(path: Path, rid: str) -> Violation | None:
|
|
140
|
+
"""The store always names a canonical record file after its OWN internal id
|
|
141
|
+
(``_write_decision_canonical`` et al. — ``<id>.json``, never anything else). A mismatch
|
|
142
|
+
is never legitimate: either a hand-rename, or a hand-copy that also needs its own
|
|
143
|
+
:data:`DUPLICATE_ULID` finding (see :func:`_check_duplicate_ulids`) — the two codes are
|
|
144
|
+
independent and can both fire for the same file."""
|
|
145
|
+
if path.stem != rid:
|
|
146
|
+
return _violation(
|
|
147
|
+
FILENAME_ID_MISMATCH, path, f"filename {path.stem!r} does not match record id {rid!r}"
|
|
148
|
+
)
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# -- per-directory checks ------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _check_temporal_dir(
|
|
156
|
+
dir_path: Path, model: type[BaseModel]
|
|
157
|
+
) -> tuple[dict[str, dict], dict[str, Path], dict[str, list[Path]], list[Violation]]:
|
|
158
|
+
"""``decisions/`` or ``facts/``: both carry the ``valid_to >= valid_from``
|
|
159
|
+
``model_validator`` (schema.py), which this function reports under its OWN code
|
|
160
|
+
(:data:`BAD_VALIDITY_WINDOW`) rather than lumping it into :data:`PARSE_ERROR` — pydantic
|
|
161
|
+
wraps that check into a ``ValidationError`` indistinguishable, by error TYPE, from a
|
|
162
|
+
missing/mistyped field, so the error MESSAGE is what disambiguates the two: a hand-edited
|
|
163
|
+
``valid_from``/``valid_to`` produces exactly one error, and only that one, from a
|
|
164
|
+
model-level (not field-level) validator.
|
|
165
|
+
|
|
166
|
+
Returns ``(raw_by_id, path_by_id, all_paths_by_id, violations)``. ``path_by_id`` keeps
|
|
167
|
+
only the FIRST hot file seen for a given internal id — used by the cross-reference checks,
|
|
168
|
+
which need exactly one representative payload per id. ``all_paths_by_id`` keeps EVERY hot
|
|
169
|
+
file carrying that id, because a second hot file for the same id is itself a finding
|
|
170
|
+
(:data:`DUPLICATE_ULID` — see :func:`_check_duplicate_ulids`), never something to silently
|
|
171
|
+
overwrite. A record stays in ``raw_by_id`` even when it fails full model validation (either
|
|
172
|
+
code) as long as its JSON decodes and carries an ``id`` — its
|
|
173
|
+
``status``/``supersedes``/``supports`` are still real, checkable data; only a file that
|
|
174
|
+
fails to decode at all contributes no id anywhere.
|
|
175
|
+
"""
|
|
176
|
+
raw_by_id: dict[str, dict] = {}
|
|
177
|
+
path_by_id: dict[str, Path] = {}
|
|
178
|
+
all_paths_by_id: dict[str, list[Path]] = {}
|
|
179
|
+
violations: list[Violation] = []
|
|
180
|
+
for path in _iter_json_files(dir_path):
|
|
181
|
+
raw, err = _load_raw_json(path)
|
|
182
|
+
if err is not None:
|
|
183
|
+
violations.append(err)
|
|
184
|
+
continue
|
|
185
|
+
if not isinstance(raw, dict) or "id" not in raw:
|
|
186
|
+
violations.append(_violation(PARSE_ERROR, path, "not a record object (missing 'id')"))
|
|
187
|
+
continue
|
|
188
|
+
rid = raw["id"]
|
|
189
|
+
all_paths_by_id.setdefault(rid, []).append(path)
|
|
190
|
+
mismatch = _check_filename_matches_id(path, rid)
|
|
191
|
+
if mismatch is not None:
|
|
192
|
+
violations.append(mismatch)
|
|
193
|
+
if rid not in raw_by_id:
|
|
194
|
+
raw_by_id[rid] = raw
|
|
195
|
+
path_by_id[rid] = path
|
|
196
|
+
try:
|
|
197
|
+
model.model_validate(raw)
|
|
198
|
+
except ValidationError as e:
|
|
199
|
+
errors = e.errors()
|
|
200
|
+
if len(errors) == 1 and _VALIDITY_WINDOW_MSG in errors[0]["msg"]:
|
|
201
|
+
violations.append(_violation(BAD_VALIDITY_WINDOW, path, errors[0]["msg"]))
|
|
202
|
+
else:
|
|
203
|
+
violations.append(_violation(PARSE_ERROR, path, str(e)))
|
|
204
|
+
return raw_by_id, path_by_id, all_paths_by_id, violations
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _check_plain_dir(
|
|
208
|
+
dir_path: Path, model: type[BaseModel], id_field: str
|
|
209
|
+
) -> tuple[dict[str, dict], dict[str, Path], dict[str, list[Path]], list[Violation]]:
|
|
210
|
+
"""``domains/``, ``entities/``, ``initiatives/`` — no validity window, straightforward
|
|
211
|
+
schema validation. ``id_field`` is ``"domain_id"``/``"entity_id"``/``"id"``. See
|
|
212
|
+
:func:`_check_temporal_dir` for the ``path_by_id`` vs. ``all_paths_by_id`` split."""
|
|
213
|
+
raw_by_id: dict[str, dict] = {}
|
|
214
|
+
path_by_id: dict[str, Path] = {}
|
|
215
|
+
all_paths_by_id: dict[str, list[Path]] = {}
|
|
216
|
+
violations: list[Violation] = []
|
|
217
|
+
for path in _iter_json_files(dir_path):
|
|
218
|
+
raw, err = _load_raw_json(path)
|
|
219
|
+
if err is not None:
|
|
220
|
+
violations.append(err)
|
|
221
|
+
continue
|
|
222
|
+
if not isinstance(raw, dict) or id_field not in raw:
|
|
223
|
+
violations.append(
|
|
224
|
+
_violation(PARSE_ERROR, path, f"not a record object (missing {id_field!r})")
|
|
225
|
+
)
|
|
226
|
+
continue
|
|
227
|
+
rid = raw[id_field]
|
|
228
|
+
all_paths_by_id.setdefault(rid, []).append(path)
|
|
229
|
+
mismatch = _check_filename_matches_id(path, rid)
|
|
230
|
+
if mismatch is not None:
|
|
231
|
+
violations.append(mismatch)
|
|
232
|
+
if rid not in raw_by_id:
|
|
233
|
+
raw_by_id[rid] = raw
|
|
234
|
+
path_by_id[rid] = path
|
|
235
|
+
try:
|
|
236
|
+
model.model_validate(raw)
|
|
237
|
+
except ValidationError as e:
|
|
238
|
+
violations.append(_violation(PARSE_ERROR, path, str(e)))
|
|
239
|
+
return raw_by_id, path_by_id, all_paths_by_id, violations
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _check_bindings_dir(
|
|
243
|
+
dir_path: Path,
|
|
244
|
+
) -> tuple[dict[str, list[dict]], dict[str, Path], list[Violation]]:
|
|
245
|
+
"""``bindings/<record_id>.json``: a JSON list of ``{entity_id, tier, relation, weight}``
|
|
246
|
+
(see ``store._binding_identity_payload``) — ``record_id`` itself is implied by the
|
|
247
|
+
filename, not a key inside each item, so there is no internal id to compare it against
|
|
248
|
+
(no :data:`FILENAME_ID_MISMATCH` check here)."""
|
|
249
|
+
by_record: dict[str, list[dict]] = {}
|
|
250
|
+
path_by_record: dict[str, Path] = {}
|
|
251
|
+
violations: list[Violation] = []
|
|
252
|
+
for path in _iter_json_files(dir_path):
|
|
253
|
+
raw, err = _load_raw_json(path)
|
|
254
|
+
if err is not None:
|
|
255
|
+
violations.append(err)
|
|
256
|
+
continue
|
|
257
|
+
if not isinstance(raw, list):
|
|
258
|
+
violations.append(_violation(PARSE_ERROR, path, "bindings file must be a JSON list"))
|
|
259
|
+
continue
|
|
260
|
+
record_id = path.stem
|
|
261
|
+
items: list[dict] = []
|
|
262
|
+
for i, item in enumerate(raw):
|
|
263
|
+
payload = item if isinstance(item, dict) else {}
|
|
264
|
+
try:
|
|
265
|
+
AnchorBinding.model_validate({**payload, "record_id": record_id})
|
|
266
|
+
except ValidationError as e:
|
|
267
|
+
violations.append(_violation(PARSE_ERROR, f"{path}#{i}", str(e)))
|
|
268
|
+
continue
|
|
269
|
+
items.append(payload)
|
|
270
|
+
by_record[record_id] = items
|
|
271
|
+
path_by_record[record_id] = path
|
|
272
|
+
return by_record, path_by_record, violations
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@dataclass
|
|
276
|
+
class _ArchiveIndex:
|
|
277
|
+
"""Everything read out of ``archive/*.jsonl``. ``*_entries`` keeps EVERY occurrence
|
|
278
|
+
(path + payload) an id was seen in (unlike ``store._archived_records``'s dedup-to-first),
|
|
279
|
+
because :func:`_check_duplicate_ulids` needs to compare payloads across occurrences, not
|
|
280
|
+
just count them — see that function's docstring for the byte-identical exemption."""
|
|
281
|
+
|
|
282
|
+
decisions: dict[str, dict]
|
|
283
|
+
domains: dict[str, dict]
|
|
284
|
+
decision_entries: dict[str, list[tuple[Path, dict]]]
|
|
285
|
+
domain_entries: dict[str, list[tuple[Path, dict]]]
|
|
286
|
+
violations: list[Violation]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _check_archive_dir(dir_path: Path) -> _ArchiveIndex:
|
|
290
|
+
"""``archive/*.jsonl`` — one JSON object per line (see
|
|
291
|
+
``docs/reference/store-format.md#archive-segments-sidegraph-compact`` and
|
|
292
|
+
``store._archive_record_line``). A line that fails to parse is
|
|
293
|
+
:data:`BAD_ARCHIVE_SEGMENT`, scoped separately from :data:`PARSE_ERROR` (which is
|
|
294
|
+
reserved for the one-file-per-record canonical directories) since "this JSONL segment has
|
|
295
|
+
a bad line" and "this record file is corrupt" are different failure shapes worth
|
|
296
|
+
distinguishing in a report."""
|
|
297
|
+
decisions: dict[str, dict] = {}
|
|
298
|
+
domains: dict[str, dict] = {}
|
|
299
|
+
decision_entries: dict[str, list[tuple[Path, dict]]] = {}
|
|
300
|
+
domain_entries: dict[str, list[tuple[Path, dict]]] = {}
|
|
301
|
+
violations: list[Violation] = []
|
|
302
|
+
if not dir_path.is_dir():
|
|
303
|
+
return _ArchiveIndex(decisions, domains, decision_entries, domain_entries, violations)
|
|
304
|
+
for path in sorted(dir_path.glob("*.jsonl")):
|
|
305
|
+
try:
|
|
306
|
+
text = path.read_text(encoding="utf-8")
|
|
307
|
+
except OSError as e:
|
|
308
|
+
violations.append(_violation(BAD_ARCHIVE_SEGMENT, path, f"unreadable: {e}"))
|
|
309
|
+
continue
|
|
310
|
+
for lineno, raw_line in enumerate(text.splitlines(), start=1):
|
|
311
|
+
line = raw_line.strip()
|
|
312
|
+
if not line:
|
|
313
|
+
continue
|
|
314
|
+
try:
|
|
315
|
+
obj = json.loads(line)
|
|
316
|
+
except json.JSONDecodeError as e:
|
|
317
|
+
violations.append(_violation(BAD_ARCHIVE_SEGMENT, path, f"line {lineno}: {e}"))
|
|
318
|
+
continue
|
|
319
|
+
if not isinstance(obj, dict):
|
|
320
|
+
violations.append(
|
|
321
|
+
_violation(BAD_ARCHIVE_SEGMENT, path, f"line {lineno}: not a JSON object")
|
|
322
|
+
)
|
|
323
|
+
continue
|
|
324
|
+
record_type = obj.get("record_type")
|
|
325
|
+
payload = {k: v for k, v in obj.items() if k != "record_type"}
|
|
326
|
+
if record_type == "decision" and "id" in payload:
|
|
327
|
+
decisions.setdefault(payload["id"], payload)
|
|
328
|
+
decision_entries.setdefault(payload["id"], []).append((path, payload))
|
|
329
|
+
elif record_type == "domain" and "domain_id" in payload:
|
|
330
|
+
domains.setdefault(payload["domain_id"], payload)
|
|
331
|
+
domain_entries.setdefault(payload["domain_id"], []).append((path, payload))
|
|
332
|
+
# An unrecognized record_type is silently skipped -- forward-compat with a future
|
|
333
|
+
# record kind this version doesn't know how to load, mirroring
|
|
334
|
+
# store.py's own ``_archived_records`` tolerance. The line still parsed as JSON,
|
|
335
|
+
# so it is not a bad-archive-segment finding.
|
|
336
|
+
return _ArchiveIndex(decisions, domains, decision_entries, domain_entries, violations)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _check_format_marker(store_dir: Path) -> list[Violation]:
|
|
340
|
+
"""``<store_dir>/format`` — a single committed line, ``sidegraph-store <version>`` (see
|
|
341
|
+
``store._ensure_format_marker``). Missing, malformed, or an unrecognized version are all
|
|
342
|
+
reported the same way: :data:`UNKNOWN_SCHEMA_VERSION`."""
|
|
343
|
+
marker = store_dir / _FORMAT_MARKER_NAME
|
|
344
|
+
if not marker.is_file():
|
|
345
|
+
return [_violation(UNKNOWN_SCHEMA_VERSION, marker, "format marker missing")]
|
|
346
|
+
try:
|
|
347
|
+
text = marker.read_text(encoding="utf-8").strip()
|
|
348
|
+
except OSError as e:
|
|
349
|
+
return [_violation(UNKNOWN_SCHEMA_VERSION, marker, f"unreadable: {e}")]
|
|
350
|
+
if not text.startswith(_FORMAT_MARKER_PREFIX):
|
|
351
|
+
return [_violation(UNKNOWN_SCHEMA_VERSION, marker, f"unrecognized format marker {text!r}")]
|
|
352
|
+
version = text[len(_FORMAT_MARKER_PREFIX) :]
|
|
353
|
+
if version not in _KNOWN_SCHEMA_VERSIONS:
|
|
354
|
+
return [_violation(UNKNOWN_SCHEMA_VERSION, marker, f"unknown schema_version {version!r}")]
|
|
355
|
+
return []
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# -- cross-reference checks -----------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _check_supersedes(combined: dict[str, dict], path_by_id: dict[str, Path]) -> list[Violation]:
|
|
362
|
+
"""Generic across decisions/facts/domains — mirrors the real write-path rule
|
|
363
|
+
(``store.add_decision``/``add_fact``/``supersede_domain``): a successor's ``supersedes``
|
|
364
|
+
must reference a record that exists (:data:`DANGLING_SUPERSEDES`), and a record whose
|
|
365
|
+
``status`` is ``superseded`` must be the target of some OTHER record's ``supersedes``
|
|
366
|
+
(:data:`SUPERSEDED_WITHOUT_SUCCESSOR`) — the same "close + link, atomically" guarantee
|
|
367
|
+
``add_decision``'s docstring describes. ``combined`` is the union of hot and archived
|
|
368
|
+
payloads for one record type (a superseded record's successor may itself have since been
|
|
369
|
+
compacted, so both pools count as "exists")."""
|
|
370
|
+
violations: list[Violation] = []
|
|
371
|
+
successors = {
|
|
372
|
+
payload["supersedes"] for payload in combined.values() if payload.get("supersedes")
|
|
373
|
+
}
|
|
374
|
+
for rid, payload in combined.items():
|
|
375
|
+
supersedes = payload.get("supersedes")
|
|
376
|
+
if supersedes and supersedes not in combined:
|
|
377
|
+
violations.append(
|
|
378
|
+
_violation(
|
|
379
|
+
DANGLING_SUPERSEDES,
|
|
380
|
+
path_by_id.get(rid, rid),
|
|
381
|
+
f"supersedes unknown record {supersedes!r}",
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
if payload.get("status") == _SUPERSEDED and rid not in successors:
|
|
385
|
+
violations.append(
|
|
386
|
+
_violation(
|
|
387
|
+
SUPERSEDED_WITHOUT_SUCCESSOR,
|
|
388
|
+
path_by_id.get(rid, rid),
|
|
389
|
+
f"status is superseded but no record's supersedes references {rid!r}",
|
|
390
|
+
)
|
|
391
|
+
)
|
|
392
|
+
return violations
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _check_binding_entities(
|
|
396
|
+
bindings_by_record: dict[str, list[dict]],
|
|
397
|
+
path_by_record: dict[str, Path],
|
|
398
|
+
entity_ids: set[str],
|
|
399
|
+
) -> list[Violation]:
|
|
400
|
+
"""Every ``bindings/<record_id>.json`` entry's ``entity_id`` must resolve to a real
|
|
401
|
+
``entities/<id>.json`` (mirrors ``store.add_binding``'s own existence check)."""
|
|
402
|
+
violations: list[Violation] = []
|
|
403
|
+
for record_id, items in bindings_by_record.items():
|
|
404
|
+
path = path_by_record[record_id]
|
|
405
|
+
for item in items:
|
|
406
|
+
entity_id = item.get("entity_id")
|
|
407
|
+
if entity_id not in entity_ids:
|
|
408
|
+
violations.append(
|
|
409
|
+
_violation(
|
|
410
|
+
DANGLING_BINDING_ENTITY,
|
|
411
|
+
path,
|
|
412
|
+
f"binding references unknown entity {entity_id!r}",
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
return violations
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _check_fact_supports(
|
|
419
|
+
facts_raw: dict[str, dict], path_by_id: dict[str, Path], decision_ids: set[str]
|
|
420
|
+
) -> list[Violation]:
|
|
421
|
+
"""Every id in a ``Fact.supports`` list must resolve to a real decision (hot or archived)
|
|
422
|
+
— mirrors ``store.add_fact``'s own existence check."""
|
|
423
|
+
violations: list[Violation] = []
|
|
424
|
+
for fid, payload in facts_raw.items():
|
|
425
|
+
for did in payload.get("supports") or []:
|
|
426
|
+
if did not in decision_ids:
|
|
427
|
+
violations.append(
|
|
428
|
+
_violation(
|
|
429
|
+
DANGLING_FACT_SUPPORT, path_by_id[fid], f"supports unknown decision {did!r}"
|
|
430
|
+
)
|
|
431
|
+
)
|
|
432
|
+
return violations
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _check_duplicate_ulids(
|
|
436
|
+
hot_paths_by_id: dict[str, list[Path]],
|
|
437
|
+
archive_entries_by_id: dict[str, list[tuple[Path, dict]]],
|
|
438
|
+
) -> list[Violation]:
|
|
439
|
+
"""An id's canonical locations must be unique, with exactly ONE sanctioned exception
|
|
440
|
+
(design amendment, Task-2 review): two or more archive segments carrying byte-IDENTICAL
|
|
441
|
+
payloads for the same id are the store's own tolerated cross-branch merge shape
|
|
442
|
+
(independent ``sidegraph-compact`` runs on two branches, later merged — see
|
|
443
|
+
``store._archived_records``'s "guaranteed byte-identical by design") and are exempt.
|
|
444
|
+
Every other multi-location shape is :data:`DUPLICATE_ULID`: two+ hot files sharing an id
|
|
445
|
+
(the store's file-per-record model allows at most one — ``hot_paths_by_id`` tracks every
|
|
446
|
+
occurrence precisely so this can't be masked by a silent last-write-wins dict merge), a
|
|
447
|
+
hot file coexisting with ANY archive copy, or archive segments whose payloads for the same
|
|
448
|
+
id actually DIFFER (corruption-shaped, not a legitimate merge, regardless of how it arose).
|
|
449
|
+
The exemption is byte-equality (payload-dict equality, mirroring
|
|
450
|
+
``store._reload_index_from_canonical``'s own ``json.loads(...) != payload`` comparison),
|
|
451
|
+
never id-equality alone.
|
|
452
|
+
"""
|
|
453
|
+
violations: list[Violation] = []
|
|
454
|
+
all_ids = set(hot_paths_by_id) | set(archive_entries_by_id)
|
|
455
|
+
for rid in sorted(all_ids):
|
|
456
|
+
hot_locs = hot_paths_by_id.get(rid, [])
|
|
457
|
+
archive_locs = archive_entries_by_id.get(rid, [])
|
|
458
|
+
if len(hot_locs) + len(archive_locs) <= 1:
|
|
459
|
+
continue
|
|
460
|
+
if not hot_locs:
|
|
461
|
+
payloads = [payload for _, payload in archive_locs]
|
|
462
|
+
if all(payload == payloads[0] for payload in payloads):
|
|
463
|
+
continue # sanctioned cross-branch archive merge -- exempt
|
|
464
|
+
locations = list(hot_locs) + [path for path, _ in archive_locs]
|
|
465
|
+
detail = "present in " + "; ".join(str(p) for p in locations)
|
|
466
|
+
violations.append(_violation(DUPLICATE_ULID, locations[0], detail))
|
|
467
|
+
return violations
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# -- entry point ------------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def verify_snapshot(store_dir: str | Path) -> list[Violation]:
|
|
474
|
+
"""Lint every canonical file under ``store_dir`` against the invariants ``store.py``'s
|
|
475
|
+
write API enforces at write time (see this module's docstring for why this reads the
|
|
476
|
+
files directly rather than opening a ``Store``). Returns ``[]`` for a clean store.
|
|
477
|
+
|
|
478
|
+
Raises ``NotADirectoryError`` if ``store_dir`` does not exist or is not a directory —
|
|
479
|
+
the CLI (``verify_main``) treats that as the operational-error exit path (1), distinct
|
|
480
|
+
from a violation (exit 2): an unreadable store dir is not itself an integrity finding
|
|
481
|
+
about the store's contents.
|
|
482
|
+
"""
|
|
483
|
+
store_dir = Path(store_dir)
|
|
484
|
+
if not store_dir.is_dir():
|
|
485
|
+
raise NotADirectoryError(f"store directory not found: {store_dir}")
|
|
486
|
+
|
|
487
|
+
violations: list[Violation] = []
|
|
488
|
+
violations += _check_format_marker(store_dir)
|
|
489
|
+
|
|
490
|
+
decisions_raw, decision_paths, decision_all_paths, v = _check_temporal_dir(
|
|
491
|
+
store_dir / "decisions", Decision
|
|
492
|
+
)
|
|
493
|
+
violations += v
|
|
494
|
+
facts_raw, fact_paths, fact_all_paths, v = _check_temporal_dir(store_dir / "facts", Fact)
|
|
495
|
+
violations += v
|
|
496
|
+
domains_raw, domain_paths, domain_all_paths, v = _check_plain_dir(
|
|
497
|
+
store_dir / "domains", Domain, "domain_id"
|
|
498
|
+
)
|
|
499
|
+
violations += v
|
|
500
|
+
entities_raw, _entity_paths, entity_all_paths, v = _check_plain_dir(
|
|
501
|
+
store_dir / "entities", Entity, "entity_id"
|
|
502
|
+
)
|
|
503
|
+
violations += v
|
|
504
|
+
_initiatives_raw, _initiative_paths, initiative_all_paths, v = _check_plain_dir(
|
|
505
|
+
store_dir / "initiatives", Initiative, "id"
|
|
506
|
+
)
|
|
507
|
+
violations += v
|
|
508
|
+
bindings_by_record, binding_paths, v = _check_bindings_dir(store_dir / "bindings")
|
|
509
|
+
violations += v
|
|
510
|
+
archive = _check_archive_dir(store_dir / "archive")
|
|
511
|
+
violations += archive.violations
|
|
512
|
+
|
|
513
|
+
entity_ids = set(entities_raw)
|
|
514
|
+
|
|
515
|
+
# Decisions/domains: a superseded record's successor (or the dangling-supersedes target
|
|
516
|
+
# itself) may live only in the archive, so the cross-reference checks run against the
|
|
517
|
+
# union of hot + archived payloads; hot wins on an id present in both (mirrors
|
|
518
|
+
# ``store._reload_index_from_canonical``'s own hot-always-wins precedent) -- the
|
|
519
|
+
# id itself is ALSO reported separately as ``duplicate-ulid`` below.
|
|
520
|
+
combined_decisions = {**archive.decisions, **decisions_raw}
|
|
521
|
+
decision_path_by_id = {
|
|
522
|
+
**{did: entries[0][0] for did, entries in archive.decision_entries.items()},
|
|
523
|
+
**decision_paths,
|
|
524
|
+
}
|
|
525
|
+
violations += _check_supersedes(combined_decisions, decision_path_by_id)
|
|
526
|
+
|
|
527
|
+
# Facts are never archived (see docs/reference/store-format.md) -- hot only.
|
|
528
|
+
violations += _check_supersedes(facts_raw, fact_paths)
|
|
529
|
+
|
|
530
|
+
combined_domains = {**archive.domains, **domains_raw}
|
|
531
|
+
domain_path_by_id = {
|
|
532
|
+
**{dmid: entries[0][0] for dmid, entries in archive.domain_entries.items()},
|
|
533
|
+
**domain_paths,
|
|
534
|
+
}
|
|
535
|
+
violations += _check_supersedes(combined_domains, domain_path_by_id)
|
|
536
|
+
|
|
537
|
+
violations += _check_binding_entities(bindings_by_record, binding_paths, entity_ids)
|
|
538
|
+
violations += _check_fact_supports(facts_raw, fact_paths, set(combined_decisions))
|
|
539
|
+
|
|
540
|
+
# duplicate-ulid: decisions/domains compare hot against BOTH archive pools (the only two
|
|
541
|
+
# archived record types); facts/entities/initiatives are never archived, so only hot-hot
|
|
542
|
+
# duplication is possible for them.
|
|
543
|
+
violations += _check_duplicate_ulids(decision_all_paths, archive.decision_entries)
|
|
544
|
+
violations += _check_duplicate_ulids(domain_all_paths, archive.domain_entries)
|
|
545
|
+
violations += _check_duplicate_ulids(fact_all_paths, {})
|
|
546
|
+
violations += _check_duplicate_ulids(entity_all_paths, {})
|
|
547
|
+
violations += _check_duplicate_ulids(initiative_all_paths, {})
|
|
548
|
+
|
|
549
|
+
return violations
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
# -- transition layer (--against <git-ref>) ------------------------------------------------
|
|
553
|
+
#
|
|
554
|
+
# design/superpowers/specs/2026-07-11-ci-integrity-design.md ruling 2, "Transition layer":
|
|
555
|
+
# for each store file changed vs a git ref, classify the modification against the STORE'S
|
|
556
|
+
# OWN write rules rather than a blanket "committed files never change" lint (which would be
|
|
557
|
+
# wrong -- supersede legally closes a predecessor, ratify legally flips statuses, sync
|
|
558
|
+
# legally refreshes entity/domain engine-mapping fields, compact legally deletes a hot file
|
|
559
|
+
# once its content is durably archived). The mutable-field tables below are DERIVED from
|
|
560
|
+
# store.py's write methods, not invented -- see each table's own comment for exactly which
|
|
561
|
+
# write path was read. Two disagreements surfaced between the design ruling's plan and the
|
|
562
|
+
# actual code while deriving these tables (code wins, per the task brief):
|
|
563
|
+
#
|
|
564
|
+
# 1. ``Domain`` has NO ``valid_to`` field at all (see schema.py's ``Domain`` -- only
|
|
565
|
+
# ``Decision``/``Fact`` carry temporal validity). The design ruling's domain table lists
|
|
566
|
+
# ``valid_to`` as mutable; dropped here -- there is no such key to ever diff.
|
|
567
|
+
# 2. The design ruling's domain table names ``parent_slug`` as an immutable field; the
|
|
568
|
+
# actual schema field is ``parent_id`` (``parent_slug`` is an MCP-tool PARAMETER name in
|
|
569
|
+
# ``server.supersede_domain``/``_add_domain_impl``, resolved to ``parent_id`` before a
|
|
570
|
+
# ``Domain`` is ever constructed). Corrected here — immutable by omission either way
|
|
571
|
+
# (this module classifies via a mutable allow-list, so the exact immutable spelling only
|
|
572
|
+
# matters for this comment, not for behavior), but worth flagging as a real inaccuracy.
|
|
573
|
+
#
|
|
574
|
+
# Two further store.py facts worth flagging even though they don't change the tables below:
|
|
575
|
+
#
|
|
576
|
+
# - ``Entity.last_seen_node_id``/``last_seen_graph_version``/``last_seen_community`` and
|
|
577
|
+
# ``Domain.communities`` are *index-only* — ``_entity_identity_payload``/
|
|
578
|
+
# ``_domain_canonical_payload`` (store.py) strip them before a canonical file is ever
|
|
579
|
+
# written, so sync's routine refresh of these fields never touches a committed file at all
|
|
580
|
+
# (design §1: "sync never writes a committed file again" — confirmed by inspection: a
|
|
581
|
+
# freshly upserted entity's ``entities/<id>.json`` contains only ``entity_id``/
|
|
582
|
+
# ``canonical_name``/``kind``/``descriptor``; a domain's ``domains/<id>.json`` never
|
|
583
|
+
# contains ``communities``). They stay in the MUTABLE tables below anyway, matching the
|
|
584
|
+
# design ruling's intent (these are engine-mapping, never content, so a hand-edit touching
|
|
585
|
+
# them should be tolerated, not flagged) — but in practice no LEGITIMATE diff ever
|
|
586
|
+
# exercises that branch: a real committed file never carries these keys to begin with, so
|
|
587
|
+
# classifying them mutable is inert bookkeeping, not a load-bearing behavior.
|
|
588
|
+
# - ``Initiative`` has NO append-only guard at all (``Store.upsert_initiative`` is a bare,
|
|
589
|
+
# unconditional upsert — see store.py) — so field-level mutation is never checked for
|
|
590
|
+
# initiatives here (:func:`classify_transition` has no ``"initiative"`` kind;
|
|
591
|
+
# :func:`verify_against` handles the type directly, deletion-only — see its docstring).
|
|
592
|
+
|
|
593
|
+
# Decisions/facts (store.py ``add_decision`` :1261-1289, ``add_fact`` :1405-1435, ``ratify``
|
|
594
|
+
# :1962-1987, ``drop`` :1998-2014, ``ratify_fact``/``drop_fact`` :2026-2047): the only fields
|
|
595
|
+
# any write path ever changes on an EXISTING record are ``status`` (along a real transition)
|
|
596
|
+
# and ``valid_to`` (null -> a value, once, when a record closes). Everything else is written
|
|
597
|
+
# once, at creation, and never touched again by any of those methods.
|
|
598
|
+
_DECISION_STATUS_TRANSITIONS = frozenset(
|
|
599
|
+
{
|
|
600
|
+
("proposed", "accepted"), # ratify
|
|
601
|
+
("proposed", "rejected"), # drop
|
|
602
|
+
("proposed", "superseded"), # add_decision/ratify closing a still-proposed predecessor
|
|
603
|
+
("accepted", "superseded"), # add_decision/ratify closing an accepted predecessor
|
|
604
|
+
# No live write path sets DEPRECATED today -- store.py's own comment on
|
|
605
|
+
# _TERMINAL_DECISION_STATUSES calls it a "dead enum value ... included for
|
|
606
|
+
# forward-compat rather than silently left hot forever the day something does start
|
|
607
|
+
# setting it." Allowed here for that same documented forward-compat reason, not a
|
|
608
|
+
# gap this module papers over.
|
|
609
|
+
("accepted", "deprecated"),
|
|
610
|
+
}
|
|
611
|
+
)
|
|
612
|
+
_FACT_STATUS_TRANSITIONS = frozenset(
|
|
613
|
+
{
|
|
614
|
+
("proposed", "accepted"), # ratify_fact, or ratify's decision-supports cascade
|
|
615
|
+
("proposed", "rejected"), # drop_fact, or drop's all-supporters-rejected cascade
|
|
616
|
+
("proposed", "superseded"), # add_fact closing a still-proposed predecessor
|
|
617
|
+
("accepted", "superseded"), # add_fact closing an accepted predecessor
|
|
618
|
+
# schema.py: "DEPRECATED unused for facts" -- no forward-compat carve-out here,
|
|
619
|
+
# unlike decisions above.
|
|
620
|
+
}
|
|
621
|
+
)
|
|
622
|
+
DECISION_MUTABLE_FIELDS = frozenset({"status", "valid_to"})
|
|
623
|
+
FACT_MUTABLE_FIELDS = frozenset({"status", "valid_to"})
|
|
624
|
+
|
|
625
|
+
# Domains (store.py ``add_domain`` :1626-1632 [creation only], ``supersede_domain``
|
|
626
|
+
# :1659-1678, ``ratify_domains`` :1880-1910, ``refresh_domain_communities`` :1822-1831):
|
|
627
|
+
# status moves along ratify_domains'/supersede_domain's transitions; ``communities`` is
|
|
628
|
+
# sync's engine-mapping refresh (see the module-level note above -- never actually present
|
|
629
|
+
# in the canonical file).
|
|
630
|
+
_DOMAIN_STATUS_TRANSITIONS = frozenset(
|
|
631
|
+
{
|
|
632
|
+
("proposed", "accepted"), # ratify_domains(accept=...)
|
|
633
|
+
("proposed", "dropped"), # ratify_domains(drop=...)
|
|
634
|
+
("accepted", "dropped"), # ratify_domains(drop=...) -- extended beyond proposed-only
|
|
635
|
+
# (review round 3, design §6: cross-branch slug-conflict resolution)
|
|
636
|
+
("proposed", "superseded"), # supersede_domain
|
|
637
|
+
("accepted", "superseded"), # supersede_domain
|
|
638
|
+
# Fix-round review (Important-1): supersede_domain (store.py :1661-1674) flips
|
|
639
|
+
# old.status to SUPERSEDED with NO gate on old's current status at all -- unlike
|
|
640
|
+
# ratify_domains, it never checks old is proposed/accepted first. find_domain_by_slug
|
|
641
|
+
# deliberately keeps a DROPPED domain resolvable by slug ("accepted > proposed >
|
|
642
|
+
# dropped" preference order, store.py's own docstring) specifically so a dropped
|
|
643
|
+
# domain can still be superseded/revived later. Reviewer reproduced dropped ->
|
|
644
|
+
# superseded via the real MCP entry points (ratify drop, then supersede_domain) --
|
|
645
|
+
# code wins over my earlier {proposed,accepted}-only restriction.
|
|
646
|
+
("dropped", "superseded"), # supersede_domain, from a previously-dropped domain
|
|
647
|
+
}
|
|
648
|
+
)
|
|
649
|
+
DOMAIN_MUTABLE_FIELDS = frozenset({"status", "communities"})
|
|
650
|
+
|
|
651
|
+
# Entities (store.py ``upsert_entity`` :1123-1150, ``_entity_identity_payload`` :295-303):
|
|
652
|
+
# the canonical file holds ONLY entity_id/canonical_name/kind/descriptor -- upsert_entity
|
|
653
|
+
# rewrites the file solely when the IDENTITY payload changes (i.e. ``descriptor`` -- a
|
|
654
|
+
# "moved" rebind, see ``sync._adopt``). ``entity_id``/``canonical_name``/``kind`` are minted
|
|
655
|
+
# once (or, for ``kind``, fixed at construction) and never rewritten by any write path.
|
|
656
|
+
ENTITY_MUTABLE_FIELDS = frozenset(
|
|
657
|
+
{"descriptor", "last_seen_node_id", "last_seen_graph_version", "last_seen_community"}
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
_KIND_DIRS = {
|
|
661
|
+
"decision": "decisions",
|
|
662
|
+
"fact": "facts",
|
|
663
|
+
"domain": "domains",
|
|
664
|
+
"entity": "entities",
|
|
665
|
+
"binding": "bindings",
|
|
666
|
+
}
|
|
667
|
+
_KIND_ID_FIELDS = {"decision": "id", "fact": "id", "domain": "domain_id", "entity": "entity_id"}
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _synthetic_path(kind: str, payload: dict) -> str:
|
|
671
|
+
"""A best-guess ``<dir>/<id>.json`` path built from the payload alone —
|
|
672
|
+
:func:`classify_transition` is pure and never receives the real filesystem/git path.
|
|
673
|
+
In every legitimate case this matches the real path exactly (the store always names a
|
|
674
|
+
file after its own id — see :data:`FILENAME_ID_MISMATCH`); :func:`verify_against`
|
|
675
|
+
overwrites it with the actual changed git path regardless, so a mismatch here is never
|
|
676
|
+
user-visible."""
|
|
677
|
+
rid = payload.get(_KIND_ID_FIELDS[kind], "?")
|
|
678
|
+
return f"{_KIND_DIRS[kind]}/{rid}.json"
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _check_status_transition(
|
|
682
|
+
old: dict, new: dict, legal: frozenset[tuple[str, str]], path: str
|
|
683
|
+
) -> list[Violation]:
|
|
684
|
+
old_status, new_status = old.get("status"), new.get("status")
|
|
685
|
+
if old_status == new_status or (old_status, new_status) in legal:
|
|
686
|
+
return []
|
|
687
|
+
return [
|
|
688
|
+
_violation(
|
|
689
|
+
ILLEGAL_STATUS_JUMP,
|
|
690
|
+
path,
|
|
691
|
+
f"status {old_status!r} -> {new_status!r} is not a real transition",
|
|
692
|
+
)
|
|
693
|
+
]
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _check_valid_to_transition(old: dict, new: dict, path: str) -> list[Violation]:
|
|
697
|
+
"""``valid_to``: null -> a value, exactly once, is the only legal move — mirrors every
|
|
698
|
+
``add_decision``/``add_fact``/``ratify``/``drop`` call site's own
|
|
699
|
+
``predecessor.valid_to = predecessor.valid_to or max(...)`` idiom (store.py): it is
|
|
700
|
+
ALWAYS an or-assignment onto a currently-``None`` field, never onto an already-set one.
|
|
701
|
+
"""
|
|
702
|
+
old_vt, new_vt = old.get("valid_to"), new.get("valid_to")
|
|
703
|
+
if old_vt == new_vt:
|
|
704
|
+
return []
|
|
705
|
+
if old_vt is None:
|
|
706
|
+
return [] # null -> value: legal
|
|
707
|
+
if new_vt is None:
|
|
708
|
+
return [_violation(VALID_TO_UNSET, path, "valid_to reverted from a value to null")]
|
|
709
|
+
return [_violation(VALID_TO_CHANGED, path, f"valid_to changed {old_vt!r} -> {new_vt!r}")]
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _check_immutable_fields(
|
|
713
|
+
old: dict, new: dict, mutable: frozenset[str], path: str
|
|
714
|
+
) -> list[Violation]:
|
|
715
|
+
"""Every key present in ``old`` or ``new`` but not in ``mutable`` must be identical
|
|
716
|
+
across the two — covers a changed value, a field that disappeared, AND a field that
|
|
717
|
+
newly appeared (all three are "this immutable field changed"). One violation per
|
|
718
|
+
offending field (design ruling 2: "detail names the field"), in sorted key order for a
|
|
719
|
+
deterministic report."""
|
|
720
|
+
violations: list[Violation] = []
|
|
721
|
+
for key in sorted(set(old) | set(new)):
|
|
722
|
+
if key in mutable:
|
|
723
|
+
continue
|
|
724
|
+
if old.get(key) != new.get(key):
|
|
725
|
+
violations.append(_violation(ILLEGAL_FIELD_CHANGE, path, f"field {key!r} changed"))
|
|
726
|
+
return violations
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def classify_transition(kind: str, old: dict | None, new: dict | None) -> list[Violation]:
|
|
730
|
+
"""Classify one record's before/after payload against the store's OWN write-path rules
|
|
731
|
+
(design ruling 2, "Transition layer"). Pure: no filesystem/git access, so every case is
|
|
732
|
+
directly unit-testable without a git repo — :func:`verify_against` is the thin,
|
|
733
|
+
I/O-doing wrapper that feeds this real ``(old, new)`` pairs pulled from ``git show``/the
|
|
734
|
+
working tree.
|
|
735
|
+
|
|
736
|
+
``kind`` is one of ``{"decision", "fact", "domain", "entity", "binding"}``.
|
|
737
|
+
|
|
738
|
+
``old=None`` means the file was ADDED — always legal (a brand-new record can carry any
|
|
739
|
+
content; every write-path invariant on ITS shape is the snapshot layer's job, not this
|
|
740
|
+
one's).
|
|
741
|
+
|
|
742
|
+
``new=None`` means the file was DELETED:
|
|
743
|
+
|
|
744
|
+
- ``kind == "binding"``: always legal, unconditionally (see the ``kind == "binding"``
|
|
745
|
+
branch below — bindings are never classified here at all, added/modified/deleted
|
|
746
|
+
alike; referential integrity of the new state is the snapshot layer's job).
|
|
747
|
+
- every other kind: this function ALONE always reports :data:`ILLEGAL_DELETION` — it
|
|
748
|
+
has no I/O access to check whether the id is safely archived. The one sanctioned
|
|
749
|
+
exception (a decision/domain compacted into an archive segment present in the new
|
|
750
|
+
tree) is applied by :func:`verify_against` as a post-hoc filter over this function's
|
|
751
|
+
raw output, never by this function itself. A pure test exercising ``new=None``
|
|
752
|
+
directly is therefore always the "id absent from any archive" scenario, by
|
|
753
|
+
construction.
|
|
754
|
+
|
|
755
|
+
Neither payload is ever a real filesystem/git path, so a returned :class:`Violation`'s
|
|
756
|
+
``path`` is a best-effort ``<dir>/<id>.json`` guess (:func:`_synthetic_path`) that
|
|
757
|
+
:func:`verify_against` overwrites with the actual changed path.
|
|
758
|
+
"""
|
|
759
|
+
if kind not in _KIND_DIRS:
|
|
760
|
+
raise ValueError(f"unknown kind {kind!r}")
|
|
761
|
+
|
|
762
|
+
if kind == "binding":
|
|
763
|
+
# Referential-only, machine-managed payload (design ruling 2): a dropped entry or a
|
|
764
|
+
# deleted bindings/<record_id>.json file is derived-community decay or record
|
|
765
|
+
# compaction, never flagged here regardless of shape.
|
|
766
|
+
return []
|
|
767
|
+
|
|
768
|
+
if old is None:
|
|
769
|
+
return [] # added -- always legal
|
|
770
|
+
|
|
771
|
+
if new is None:
|
|
772
|
+
return [
|
|
773
|
+
_violation(
|
|
774
|
+
ILLEGAL_DELETION,
|
|
775
|
+
_synthetic_path(kind, old),
|
|
776
|
+
"record deleted (no archive segment consulted at this pure layer)",
|
|
777
|
+
)
|
|
778
|
+
]
|
|
779
|
+
|
|
780
|
+
path = _synthetic_path(kind, new)
|
|
781
|
+
if kind in ("decision", "fact"):
|
|
782
|
+
legal = _DECISION_STATUS_TRANSITIONS if kind == "decision" else _FACT_STATUS_TRANSITIONS
|
|
783
|
+
mutable = DECISION_MUTABLE_FIELDS if kind == "decision" else FACT_MUTABLE_FIELDS
|
|
784
|
+
return (
|
|
785
|
+
_check_status_transition(old, new, legal, path)
|
|
786
|
+
+ _check_valid_to_transition(old, new, path)
|
|
787
|
+
+ _check_immutable_fields(old, new, mutable, path)
|
|
788
|
+
)
|
|
789
|
+
if kind == "domain":
|
|
790
|
+
return _check_status_transition(
|
|
791
|
+
old, new, _DOMAIN_STATUS_TRANSITIONS, path
|
|
792
|
+
) + _check_immutable_fields(old, new, DOMAIN_MUTABLE_FIELDS, path)
|
|
793
|
+
# entity
|
|
794
|
+
return _check_immutable_fields(old, new, ENTITY_MUTABLE_FIELDS, path)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
class _UnparsableContent(Exception):
|
|
798
|
+
"""One side of a changed file's diff failed to parse as a JSON object. Caught by
|
|
799
|
+
:func:`verify_against` to skip just that one file's transition check — the ALWAYS-ON
|
|
800
|
+
snapshot layer already reports a corrupt hot file as :data:`PARSE_ERROR`; the
|
|
801
|
+
transition layer piling on a second, less specific finding for the same root cause
|
|
802
|
+
would be noise, and one unparsable file must never abort the whole ``--against`` pass."""
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _run_git(
|
|
806
|
+
args: list[str], cwd: Path, *, timeout: float | None = None
|
|
807
|
+
) -> subprocess.CompletedProcess[str]:
|
|
808
|
+
"""``timeout`` (drift→supersede D1) bounds the subprocess for hook-path callers; the
|
|
809
|
+
default ``None`` is byte-for-byte today's behavior for every existing call site. A
|
|
810
|
+
``TimeoutExpired`` maps onto the same ``ValueError`` contract as ``OSError`` — every
|
|
811
|
+
caller already treats that as "git unavailable"."""
|
|
812
|
+
try:
|
|
813
|
+
return subprocess.run(
|
|
814
|
+
["git", *args], cwd=cwd, capture_output=True, text=True, timeout=timeout
|
|
815
|
+
)
|
|
816
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
817
|
+
raise ValueError(f"could not run git ({' '.join(args)}): {e}") from e
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _find_repo_root(store_dir: Path) -> Path:
|
|
821
|
+
"""The git repository containing ``store_dir`` — may be an ancestor of it, not
|
|
822
|
+
``store_dir`` itself. Raises ``ValueError`` (the CLI's operational-error exit path, 1 —
|
|
823
|
+
never a violation, design ruling 2: "Not-a-git-repo ... -> operational error") when
|
|
824
|
+
``store_dir`` isn't inside any git working tree."""
|
|
825
|
+
result = _run_git(["rev-parse", "--show-toplevel"], cwd=store_dir)
|
|
826
|
+
if result.returncode != 0:
|
|
827
|
+
raise ValueError(f"{store_dir} is not inside a git repository: {result.stderr.strip()}")
|
|
828
|
+
return Path(result.stdout.strip()).resolve()
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _git_diff_name_status(repo_root: Path, ref: str, store_dir: Path) -> list[tuple[str, str]]:
|
|
832
|
+
"""``git diff --no-renames --name-status <ref> -- <store_dir>``, run from ``repo_root``
|
|
833
|
+
(design ruling 2: "git plumbing ... ``git diff --name-status <ref> -- <store-dir>``").
|
|
834
|
+
|
|
835
|
+
``--no-renames`` is deliberate: a renamed-but-identical record file would otherwise
|
|
836
|
+
report as one ``R100`` line instead of a delete+add pair — but the store's own
|
|
837
|
+
invariant is that a file is always named after its own id (:data:`FILENAME_ID_MISMATCH`),
|
|
838
|
+
so a genuine rename is never legitimate, and surfacing it as a deletion (illegal, absent
|
|
839
|
+
from any archive) alongside an unrelated addition is the MORE informative shape, not a
|
|
840
|
+
loss of information.
|
|
841
|
+
|
|
842
|
+
Compares ``ref`` against the CURRENT WORKING TREE (git's default for a single-ref
|
|
843
|
+
``diff`` — no second ref, no ``--cached``), matching the CI use case this exists for:
|
|
844
|
+
``origin/main`` vs. whatever the PR branch has checked out right now. Untracked files
|
|
845
|
+
never appear in ``git diff`` output at all (git diff semantics, not a bug here) — a
|
|
846
|
+
normal CI checkout has none.
|
|
847
|
+
"""
|
|
848
|
+
result = _run_git(
|
|
849
|
+
["diff", "--no-renames", "--name-status", ref, "--", str(store_dir)], cwd=repo_root
|
|
850
|
+
)
|
|
851
|
+
if result.returncode != 0:
|
|
852
|
+
raise ValueError(f"git diff against {ref!r} failed: {result.stderr.strip()}")
|
|
853
|
+
changed: list[tuple[str, str]] = []
|
|
854
|
+
for line in result.stdout.splitlines():
|
|
855
|
+
if not line.strip():
|
|
856
|
+
continue
|
|
857
|
+
status, _, path = line.partition("\t")
|
|
858
|
+
changed.append((status[:1], path))
|
|
859
|
+
return changed
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _git_show_json(repo_root: Path, ref: str, repo_path: str) -> dict:
|
|
863
|
+
result = _run_git(["show", f"{ref}:{repo_path}"], cwd=repo_root)
|
|
864
|
+
if result.returncode != 0:
|
|
865
|
+
raise ValueError(f"git show {ref}:{repo_path} failed: {result.stderr.strip()}")
|
|
866
|
+
try:
|
|
867
|
+
obj = json.loads(result.stdout)
|
|
868
|
+
except json.JSONDecodeError as e:
|
|
869
|
+
raise _UnparsableContent(str(e)) from e
|
|
870
|
+
if not isinstance(obj, dict):
|
|
871
|
+
raise _UnparsableContent(f"{repo_path}@{ref} is not a JSON object")
|
|
872
|
+
return obj
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _read_json_object(path: Path) -> dict:
|
|
876
|
+
try:
|
|
877
|
+
obj = json.loads(path.read_text(encoding="utf-8"))
|
|
878
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
879
|
+
raise _UnparsableContent(str(e)) from e
|
|
880
|
+
if not isinstance(obj, dict):
|
|
881
|
+
raise _UnparsableContent(f"{path} is not a JSON object")
|
|
882
|
+
return obj
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
_DIR_TO_KIND = {
|
|
886
|
+
"decisions": "decision",
|
|
887
|
+
"facts": "fact",
|
|
888
|
+
"domains": "domain",
|
|
889
|
+
"entities": "entity",
|
|
890
|
+
"bindings": "binding",
|
|
891
|
+
"initiatives": "initiative",
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _classify_store_relative_path(store_relative: str) -> str | None:
|
|
896
|
+
"""``<dir>/<id>.json`` (a path already relative to the store dir) -> a
|
|
897
|
+
:func:`classify_transition` ``kind``, or one of two kinds :func:`verify_against` handles
|
|
898
|
+
directly rather than through :func:`classify_transition` (see its docstring):
|
|
899
|
+
``"initiative"`` (no append-only guard in store.py at all -- deletion-only check) and
|
|
900
|
+
``"archive"`` (``archive/*.jsonl`` segments — write-once by design, see
|
|
901
|
+
``store._write_archive_segment``'s docstring "Segments are write-once ... and this
|
|
902
|
+
always creates a brand-new file" and ``docs/reference/store-format.md``'s "immutable
|
|
903
|
+
segments"; a segment is a multi-record file, not a one-record-per-file canonical shape,
|
|
904
|
+
so it is never diffed field-by-field like a decision/fact/domain/entity — any change to
|
|
905
|
+
an already-published segment's bytes at all is illegal, full stop).
|
|
906
|
+
|
|
907
|
+
``None`` for anything else changed under the store dir this layer doesn't classify: the
|
|
908
|
+
``format`` marker, the gitignored ``index.db``, or any unexpected nesting."""
|
|
909
|
+
parts = store_relative.split("/")
|
|
910
|
+
if len(parts) == 2 and parts[0] == "archive" and parts[1].endswith(".jsonl"):
|
|
911
|
+
return "archive"
|
|
912
|
+
if len(parts) != 2 or not parts[1].endswith(".json"):
|
|
913
|
+
return None
|
|
914
|
+
return _DIR_TO_KIND.get(parts[0])
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def verify_against(store_dir: str | Path, ref: str) -> list[Violation]:
|
|
918
|
+
"""Classify every store file that differs between ``ref`` and the current working tree
|
|
919
|
+
against the store's OWN write-path rules (design ruling 2, "Transition layer") — the
|
|
920
|
+
CI-consumable half of ``sidegraph-verify --against <git-ref>``. Runs alongside
|
|
921
|
+
:func:`verify_snapshot` (which always runs — see ``cli.verify_main``); this function
|
|
922
|
+
alone never checks a single snapshot in isolation for schema/referential correctness,
|
|
923
|
+
only whether what CHANGED was legal.
|
|
924
|
+
|
|
925
|
+
Raises ``NotADirectoryError`` for a missing ``store_dir`` (mirrors
|
|
926
|
+
:func:`verify_snapshot`) and ``ValueError`` for every other operational failure —
|
|
927
|
+
``store_dir`` not inside a git working tree, an unknown/unresolvable ``ref``, or ``git``
|
|
928
|
+
itself unavailable. All of these are the CLI's exit-1 path, never a violation (design
|
|
929
|
+
ruling 2: "Not-a-git-repo / unknown ref -> operational error").
|
|
930
|
+
|
|
931
|
+
Deletion legality (design ruling 2's "Deletions" bullet): :func:`classify_transition`
|
|
932
|
+
alone always reports a decision/domain deletion as illegal (it has no I/O access to the
|
|
933
|
+
archive). This function applies the one sanctioned exception — a deleted record whose id
|
|
934
|
+
appears in an ``archive/*.jsonl`` segment PRESENT IN THE NEW TREE (i.e. on disk right
|
|
935
|
+
now, under ``store_dir/archive`` — a legitimate ``sidegraph-compact``) — by dropping
|
|
936
|
+
that specific :data:`ILLEGAL_DELETION` finding after the fact. Entity/initiative
|
|
937
|
+
deletions have no such exception (no compact path exists for either kind) and stay
|
|
938
|
+
illegal unconditionally. Binding-file changes of any shape, including deletion, are
|
|
939
|
+
never classified at all here, matching :func:`classify_transition`. An already-
|
|
940
|
+
published ``archive/*.jsonl`` segment is write-once: a NEW segment is legal, but any
|
|
941
|
+
modification or deletion of an existing one is always illegal (see
|
|
942
|
+
:func:`_classify_store_relative_path`'s docstring) — checked directly here, not via
|
|
943
|
+
:func:`classify_transition` (a segment is a multi-record file, not a per-record shape).
|
|
944
|
+
|
|
945
|
+
``Violation.path`` here is the changed file's path RELATIVE TO THE REPO ROOT (exactly as
|
|
946
|
+
``git diff`` itself reports it) — not the ``<dir>/<id>.json``-relative-to-store guess
|
|
947
|
+
:func:`classify_transition` produces internally, and not the absolute filesystem path
|
|
948
|
+
:func:`verify_snapshot` uses; this function always corrects it to the real changed path.
|
|
949
|
+
"""
|
|
950
|
+
store_dir = Path(store_dir)
|
|
951
|
+
if not store_dir.is_dir():
|
|
952
|
+
raise NotADirectoryError(f"store directory not found: {store_dir}")
|
|
953
|
+
|
|
954
|
+
repo_root = _find_repo_root(store_dir)
|
|
955
|
+
store_dir_abs = store_dir.resolve()
|
|
956
|
+
store_rel_path = store_dir_abs.relative_to(repo_root)
|
|
957
|
+
store_rel = "" if str(store_rel_path) == "." else store_rel_path.as_posix()
|
|
958
|
+
|
|
959
|
+
# Fix-round review (Important-2): the git subprocess always runs with cwd=repo_root
|
|
960
|
+
# (see _find_repo_root/_git_diff_name_status), NOT the caller's process cwd -- a
|
|
961
|
+
# pathspec built from a store_dir that was itself relative to the CALLER's cwd (e.g.
|
|
962
|
+
# the caller is running from a repo subdirectory and passed a relative --db) would be
|
|
963
|
+
# silently re-interpreted relative to repo_root instead, mis-scoping the diff to a path
|
|
964
|
+
# that doesn't exist and yielding an EMPTY diff -- a silently "clean" verdict over real
|
|
965
|
+
# tampering, the worst possible failure mode for a CI lint. git accepts an absolute
|
|
966
|
+
# pathspec regardless of cwd, so always pass the already-resolved absolute path here.
|
|
967
|
+
changed = _git_diff_name_status(repo_root, ref, store_dir_abs)
|
|
968
|
+
|
|
969
|
+
# The NEW tree's archive contents (on-disk, right now) -- what deletion legality
|
|
970
|
+
# consults. Only the id sets matter here; any BAD_ARCHIVE_SEGMENT-shaped finding is the
|
|
971
|
+
# always-on snapshot layer's job, not re-reported by this pass.
|
|
972
|
+
new_tree_archive = _check_archive_dir(store_dir_abs / "archive")
|
|
973
|
+
archived_ids = {
|
|
974
|
+
"decision": set(new_tree_archive.decisions),
|
|
975
|
+
"domain": set(new_tree_archive.domains),
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
violations: list[Violation] = []
|
|
979
|
+
for status, repo_path in changed:
|
|
980
|
+
if store_rel:
|
|
981
|
+
if not (repo_path == store_rel or repo_path.startswith(store_rel + "/")):
|
|
982
|
+
continue # defensive: git's own pathspec already scopes this
|
|
983
|
+
inner = repo_path[len(store_rel) + 1 :]
|
|
984
|
+
else:
|
|
985
|
+
inner = repo_path
|
|
986
|
+
kind = _classify_store_relative_path(inner)
|
|
987
|
+
if kind is None or kind == "binding":
|
|
988
|
+
continue
|
|
989
|
+
|
|
990
|
+
if kind == "initiative":
|
|
991
|
+
if status == "D":
|
|
992
|
+
violations.append(
|
|
993
|
+
_violation(
|
|
994
|
+
ILLEGAL_DELETION,
|
|
995
|
+
repo_path,
|
|
996
|
+
"initiative deleted (no compaction/archive path exists for initiatives)",
|
|
997
|
+
)
|
|
998
|
+
)
|
|
999
|
+
continue
|
|
1000
|
+
|
|
1001
|
+
if kind == "archive":
|
|
1002
|
+
# Segments are write-once (store._write_archive_segment: "Segments are
|
|
1003
|
+
# write-once ... and this always creates a brand-new file"). A new segment
|
|
1004
|
+
# (status "A") is the only legal shape; any other change to an already-
|
|
1005
|
+
# published segment's bytes is illegal -- deleted (no pruning path exists, so
|
|
1006
|
+
# this is never a legitimate compaction shape the way a hot record's deletion
|
|
1007
|
+
# can be) or modified (content diffed at the byte level, not field-by-field --
|
|
1008
|
+
# a segment is a multi-record file, not this layer's per-record comparison).
|
|
1009
|
+
if status == "D":
|
|
1010
|
+
violations.append(
|
|
1011
|
+
_violation(
|
|
1012
|
+
ILLEGAL_DELETION,
|
|
1013
|
+
repo_path,
|
|
1014
|
+
"archive segment deleted (write-once; no pruning path exists)",
|
|
1015
|
+
)
|
|
1016
|
+
)
|
|
1017
|
+
elif status != "A":
|
|
1018
|
+
violations.append(
|
|
1019
|
+
_violation(
|
|
1020
|
+
ILLEGAL_FIELD_CHANGE,
|
|
1021
|
+
repo_path,
|
|
1022
|
+
"write-once archive segment modified",
|
|
1023
|
+
)
|
|
1024
|
+
)
|
|
1025
|
+
continue
|
|
1026
|
+
|
|
1027
|
+
try:
|
|
1028
|
+
old = None if status == "A" else _git_show_json(repo_root, ref, repo_path)
|
|
1029
|
+
new = None if status == "D" else _read_json_object(repo_root / repo_path)
|
|
1030
|
+
except _UnparsableContent:
|
|
1031
|
+
continue # the always-on snapshot layer already reports this as parse-error
|
|
1032
|
+
|
|
1033
|
+
found = classify_transition(kind, old, new)
|
|
1034
|
+
if new is None and found and kind in ("decision", "domain"):
|
|
1035
|
+
rid_field = "id" if kind == "decision" else "domain_id"
|
|
1036
|
+
if old is not None and old.get(rid_field) in archived_ids[kind]:
|
|
1037
|
+
found = [] # legitimate sidegraph-compact -- archived in the new tree
|
|
1038
|
+
violations.extend(replace(v, path=repo_path) for v in found)
|
|
1039
|
+
|
|
1040
|
+
return violations
|