oc_chat
Loading...
Searching...
No Matches
cross_record.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/regenerate/cross_record.py
4
5# OCT-LINT: disable=no_hardcoded_secrets reason="Field names and SRC_* codes are schema constants"
6# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/regenerate/test_cross_record.py"
7"""
8Step-6 cross-record validation (Plan 142 P1.4, §5.3 step 6).
9
10Purpose
11-------
12Validate the resolved id index (file ids ∪ nested sub_phase ids ∪ promoted ids)
13and every cross-record invariant the direct-insert rebuild bypasses (§5.3.1 R
14rows): id-uniqueness, dangling ``depends_on``, kind-unscoped dependency
15acyclicity, successor acyclicity, ``done``-parent⇒children-terminal,
16era/track ``order``/code uniqueness, same-version changelog metadata
17agreement (omit-defers-to-present), and roadmap ``changelog_seq``
18presence/uniqueness — each with its durable ``SRC_*`` code.
19
20Responsibilities
21----------------
22- ``validate_cross_record(categorized)`` — run all step-6 checks; raise the
23 first failing ``SourceValidationError`` with its §10 code + offending records.
24
25Diagnostics
26-----------
27Domain: OC_CHAT.MAIN
28Levels:
29 L2 - validation lifecycle
30 L3 - per-check details
31 L4 - graph walk tracing
32
33Contracts
34---------
35- Pure / read-only over the loaded models; raises before any temp-DB insert.
36- Deterministic: checks + offending-record lists are order-stable.
37
38Dependencies
39------------
40- oc_diagnostics (structured debug logging via _dbg)
41- option_c_registry_contracts (TERMINAL_STATUSES, check_successor_acyclicity)
42- oc_chat.registry.changelog_seq (check_changelog_seq for the product changelog seq check)
43- oc_chat.registry.regenerate.envelope (the SRC_* codes each failing check raises with)
44- oc_chat.registry.source.errors (SourceValidationError raised by the first failing check)
45"""
46from __future__ import annotations
47
48from typing import Any
49
50from oc_diagnostics import _dbg
51
52from option_c_registry_contracts import (
53 TERMINAL_STATUSES,
54 DependencyEdge,
55 check_successor_acyclicity,
56)
57
59 SRC_DANGLING_DEPENDS_ON,
60 SRC_DEPENDENCY_CYCLE,
61 SRC_DONE_PARENT_LIVE_CHILD,
62 SRC_DUPLICATE_CODE,
63 SRC_DUPLICATE_ORDER,
64 SRC_FRAMING_DANGLING_ANCHOR,
65 SRC_FRAMING_DUPLICATE_ANCHOR,
66 SRC_ITEM_ID_COLLISION,
67 SRC_PRODUCT_CHANGELOG_SEQ,
68 SRC_ROADMAP_CHANGELOG_SEQ,
69 SRC_SUCCESSOR_CYCLE,
70 SRC_VERSION_METADATA_CONFLICT,
71)
72from oc_chat.registry.changelog_seq import check_changelog_seq
73from oc_chat.registry.source.errors import SourceValidationError
74
75_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
76
77# The store-projected version-metadata fields that must agree on a same-version
78# roadmap+product pair (§3.2 / AC §8.38).
79_VERSION_AGREE_FIELDS = ("era", "date", "round", "bump_override", "notes")
80
81
82def _item_models(categorized: dict[str, list]) -> list[tuple[str, Any, str]]:
83 """Return ``(item_id, model, location)`` for every item-bearing record."""
84 out: list[tuple[str, Any, str]] = []
85 for phase, _body in categorized.get("phases", []):
86 out.append((phase.id, phase, f"phases/{phase.id}.md"))
87 for sub in phase.sub_phases:
88 out.append((sub.id, sub, f"phases/{phase.id}.md#{sub.id}"))
89 for item, _body in categorized.get("items", []):
90 out.append((item.id, item, f"items/{item.track_kind}/{item.id}.md"))
91 for scanned, item, _body in categorized.get("promoted", []):
92 out.append((item.id, item, scanned.rel_path))
93 return out
94
95
96def _check_id_uniqueness(items: list[tuple[str, Any, str]]) -> set[str]:
97 """Item ids are unique **case-insensitively** across the union (file ids ∪
98 nested sub_phase ids ∪ promoted) — Plan 142j R7 (VC-0007, layer 3 semantic
99 identity). A collision (exact OR case-only, §3.2) raises the distinct
100 ``SRC_ITEM_ID_COLLISION`` (split out of ``SRC_DUP_OR_STEM_MISMATCH``)."""
101 groups: dict[str, list[tuple[str, str]]] = {} # casefold-id -> [(orig_id, location)]
102 for item_id, _model, location in items:
103 groups.setdefault(item_id.casefold(), []).append((item_id, location))
104 for _key, members in groups.items():
105 if len(members) > 1:
106 # @invariant: inv.item_id_unique (§5.3.1 R — AC §8.39 / SRC_ITEM_ID_COLLISION,
107 # case-insensitive item-id-union split — Plan 142j R7)
108 ids = sorted({mid for mid, _loc in members})
109 locations = sorted({loc for _mid, loc in members})
111 f"item id collision (case-insensitive) across {ids}: {locations}",
112 code=SRC_ITEM_ID_COLLISION,
113 records=locations,
114 hint="item ids are unique case-insensitively across file ids ∪ nested "
115 "sub_phase ids ∪ promoted",
116 )
117 return {item_id for item_id, _model, _location in items}
118
119
120def _check_depends_on_and_cycle(items: list[tuple[str, Any, str]], id_index: set[str]) -> None:
121 edges: list[tuple[str, str]] = [] # (from=predecessor, to=dependent)
122 for item_id, model, location in items:
123 for edge in getattr(model, "depends_on", []) or []:
124 predecessor = edge.id if isinstance(edge, DependencyEdge) else edge
125 if predecessor not in id_index:
126 # @invariant: inv.depends_on_resolves (§5.3.1 R — AC §8.3 / SRC_DANGLING_DEPENDS_ON)
128 f"{location}: depends_on references unknown id {predecessor!r}",
129 code=SRC_DANGLING_DEPENDS_ON,
130 records=[location],
131 )
132 edges.append((predecessor, item_id))
133 cycle = _find_graph_cycle(edges)
134 if cycle is not None:
135 # @invariant: inv.dependency_acyclic (§5.3.1 R — AC §8.47 / SRC_DEPENDENCY_CYCLE, kind-unscoped OQ-R-07)
137 "dependency cycle (kind-unscoped): " + " -> ".join([*cycle, cycle[0]]),
138 code=SRC_DEPENDENCY_CYCLE,
139 records=list(cycle),
140 )
141
142
143def _find_graph_cycle(edges: list[tuple[str, str]]) -> list[str] | None:
144 adjacency: dict[str, list[str]] = {}
145 for source, target in edges:
146 adjacency.setdefault(source, []).append(target)
147 color: dict[str, int] = {} # 0 visiting, 1 done
148 stack: list[str] = []
149
150 def _walk(node: str) -> list[str] | None:
151 color[node] = 0
152 stack.append(node)
153 for nxt in adjacency.get(node, []):
154 if color.get(nxt) == 0:
155 return stack[stack.index(nxt):]
156 if color.get(nxt) is None:
157 found = _walk(nxt)
158 if found is not None:
159 return found
160 stack.pop()
161 color[node] = 1
162 return None
163
164 for start in list(adjacency):
165 if color.get(start) is None:
166 found = _walk(start)
167 if found is not None:
168 return found
169 return None
170
171
172def _check_successor_acyclicity(items: list[tuple[str, Any, str]]) -> None:
173 holders = [model for _id, model, _loc in items if hasattr(model, "successor")]
174 try:
175 check_successor_acyclicity(holders)
176 except ValueError as error:
177 # @invariant: inv.successor_acyclic (§5.3.1 R — AC §8.42 / SRC_SUCCESSOR_CYCLE)
179 str(error), code=SRC_SUCCESSOR_CYCLE, records=[]
180 ) from error
181
182
183def _check_done_parent_terminal_children(categorized: dict[str, list]) -> None:
184 status_by_id: dict[str, Any] = {}
185 parent_of: dict[str, str] = {}
186 for phase, _body in categorized.get("phases", []):
187 status_by_id[phase.id] = phase.status
188 for sub in phase.sub_phases:
189 status_by_id[sub.id] = sub.status
190 parent_of[sub.id] = phase.id
191 for item, _body in categorized.get("items", []):
192 status_by_id[item.id] = item.status
193 for scanned, item, _body in categorized.get("promoted", []):
194 status_by_id[item.id] = item.status
195 parent_of[item.id] = scanned.phase_code
196 for child_id, parent_id in parent_of.items():
197 if status_by_id.get(parent_id) != "done":
198 continue
199 if status_by_id.get(child_id) not in TERMINAL_STATUSES:
200 # @invariant: inv.done_parent_terminal_children (§5.3.1 R — AC §8.50 / SRC_DONE_PARENT_LIVE_CHILD)
202 f"done parent {parent_id!r} has non-terminal child {child_id!r} "
203 f"(status={status_by_id.get(child_id)!r})",
204 code=SRC_DONE_PARENT_LIVE_CHILD,
205 records=[parent_id, child_id],
206 )
207
208
209def _check_order_and_code_uniqueness(categorized: dict[str, list]) -> None:
210 for kind in ("eras", "tracks"):
211 seen_order: dict[int, str] = {}
212 for model in categorized.get(kind, []):
213 code = getattr(model, "id", None) or getattr(model, "track_code", None)
214 if model.order in seen_order:
215 # @invariant: inv.era_track_order_unique (§5.3.1 R — AC §8.56 / SRC_DUPLICATE_ORDER)
217 f"{kind}: duplicate order {model.order} ({seen_order[model.order]}, {code})",
218 code=SRC_DUPLICATE_ORDER,
219 records=[seen_order[model.order], str(code)],
220 )
221 seen_order[model.order] = str(code)
222 seen_track: set[str] = set()
223 for track in categorized.get("tracks", []):
224 if track.track_code in seen_track:
225 # @invariant: inv.phase_track_code_unique (§5.3.1 R — AC §8.56 / SRC_DUPLICATE_CODE)
227 f"duplicate track_code {track.track_code!r}",
228 code=SRC_DUPLICATE_CODE,
229 records=[track.track_code],
230 )
231 seen_track.add(track.track_code)
232 seen_phase: set[str] = set()
233 for phase, _body in categorized.get("phases", []):
234 if phase.id in seen_phase:
235 # @invariant: inv.phase_track_code_unique (§5.3.1 R — AC §8.56 / SRC_DUPLICATE_CODE)
237 f"duplicate phase_code {phase.id!r}",
238 code=SRC_DUPLICATE_CODE,
239 records=[phase.id],
240 )
241 seen_phase.add(phase.id)
242
243
244def _check_version_metadata_agreement(categorized: dict[str, list]) -> None:
245 by_version: dict[str, list] = {}
246 for version in categorized.get("versions", []):
247 by_version.setdefault(version.version, []).append(version)
248 for version_id, records in by_version.items():
249 if len(records) < 2:
250 continue
251 for field in _VERSION_AGREE_FIELDS:
252 present = {getattr(rec, field) for rec in records if getattr(rec, field) is not None}
253 if len(present) > 1:
254 # @invariant: inv.version_metadata_agreement (§5.3.1 R — AC §8.38 / SRC_VERSION_METADATA_CONFLICT)
256 f"version {version_id}: conflicting {field} across kinds: {sorted(map(str, present))}",
257 code=SRC_VERSION_METADATA_CONFLICT,
258 records=[version_id, field],
259 )
260
261
262def _check_roadmap_changelog_seq(categorized: dict[str, list]) -> None:
263 """Every ``kind=roadmap`` changelog record must carry a unique positive-int
264 ``extensions.changelog_seq`` (Plan 142 P6 / F1).
265
266 The roadmap changelog is rendered by tiling the verbatim record bodies in seq
267 order (byte-faithful to the authoritative history). A missing / non-int /
268 non-positive / duplicate seq is **fail-closed** here so the renderer can never
269 silently drop a record. ``kind=product`` records are unaffected (they render
270 structured, ordered by version)."""
271 seen: dict[int, str] = {}
272 for version in categorized.get("versions", []):
273 if getattr(version, "kind", None) != "roadmap":
274 continue
275 location = f"changelog/roadmap/{getattr(version, 'version', '?')}"
276 seq = (getattr(version, "extensions", None) or {}).get("changelog_seq")
277 if isinstance(seq, bool) or not isinstance(seq, int) or seq <= 0:
278 # P6/F1 render-protection (NOT a §5.3.1 DDL-preservation @invariant —
279 # changelog_seq lives in the open extensions dict; SRC_ROADMAP_CHANGELOG_SEQ).
281 f"{location}: kind=roadmap record needs a positive-int "
282 f"extensions.changelog_seq (got {seq!r})",
283 code=SRC_ROADMAP_CHANGELOG_SEQ,
284 records=[location],
285 hint="every roadmap changelog record is a verbatim sequenced segment; "
286 "append with max(changelog_seq)+10 per kind-dir (highest = newest, "
287 "render descending); a mid-history entry uses the spec §3.5 "
288 "mid-insert/renumber",
289 )
290 if seq in seen:
291 # P6/F1 render-protection (NOT a §5.3.1 DDL-preservation @invariant —
292 # changelog_seq lives in the open extensions dict; SRC_ROADMAP_CHANGELOG_SEQ).
294 f"duplicate changelog_seq {seq} on {seen[seq]!r} and {location!r}",
295 code=SRC_ROADMAP_CHANGELOG_SEQ,
296 records=[seen[seq], location],
297 )
298 seen[seq] = location
299
300
301def _check_product_changelog_seq(categorized: dict[str, list]) -> None:
302 """The ``kind=product`` changelog seq is ALL-OR-NONE, via the shared validator
303 (Plan 142 P2 / SRC_PRODUCT_CHANGELOG_SEQ).
304
305 No product record carries a seq → the structured version-ordered fallback (OK).
306 Any product record carries a seq → **every** selected product record must carry a
307 unique positive-int ``extensions.changelog_seq`` (``bool`` rejected before
308 ``int``); a missing / non-int / non-positive / duplicate seq in a sequenced set is
309 **fail-closed** here so the verbatim render can never silently drop a record. The
310 render path (``render_root_changelog``) calls the SAME ``check_changelog_seq``
311 validator, so the regenerate gate and the renderer never drift."""
312 located = [
313 (
314 f"changelog/product/{getattr(version, 'version', '?')}",
315 (getattr(version, "extensions", None) or {}).get("changelog_seq"),
316 )
317 for version in categorized.get("versions", [])
318 if getattr(version, "kind", None) == "product"
319 ]
320 problem = check_changelog_seq(located)
321 if problem:
322 # P2 render-protection (NOT a §5.3.1 DDL-preservation @invariant —
323 # changelog_seq lives in the open extensions dict; SRC_PRODUCT_CHANGELOG_SEQ).
325 problem,
326 code=SRC_PRODUCT_CHANGELOG_SEQ,
327 records=[loc for loc, _seq in located],
328 hint="kind=product changelog seq is all-or-none; give every sequenced "
329 "product record a unique positive-int extensions.changelog_seq",
330 )
331
332
333def _check_framing_anchors(categorized: dict[str, list]) -> None:
334 """[NEW·V24] Validate every ``framing`` entity's placement anchor (§3.3).
335
336 Each anchor must resolve: ``era`` → a known era id; ``version`` → a known
337 changelog ``slug``; ``position`` → the ``top``/``bottom`` sentinel or a sibling
338 record id (a version slug or another framing id). An unresolved target is
339 ``SRC_FRAMING_DANGLING_ANCHOR``. Two framings sharing the same
340 ``(target_kind, target_id, placement, order)`` — **including both order-unset**
341 (``None`` is a value here) — is ``SRC_FRAMING_DUPLICATE_ANCHOR`` (§3.3, no
342 further id tiebreak). These are source-only (framing has no store rows), so
343 the check runs over the loaded models."""
344 framings = categorized.get("framing", [])
345 if not framings:
346 return
347 era_ids = {era.id for era in categorized.get("eras", [])}
348 version_slugs = {version.slug for version in categorized.get("versions", [])}
349 framing_ids = {framing.id for framing in framings}
350 position_ids = {"top", "bottom"} | version_slugs | framing_ids
351 resolvable = {"era": era_ids, "version": version_slugs, "position": position_ids}
352 seen: dict[tuple, str] = {}
353 for framing in framings:
354 anchor = framing.anchor
355 location = f"framing/{framing.id}.md"
356 if anchor.target_id not in resolvable[anchor.target_kind]:
358 f"{location}: framing anchor references unknown "
359 f"{anchor.target_kind} target {anchor.target_id!r}",
360 code=SRC_FRAMING_DANGLING_ANCHOR,
361 records=[location],
362 hint="anchor.target_id must resolve to an era id / version slug / "
363 "position sibling id or the top|bottom sentinel",
364 )
365 key = (anchor.target_kind, anchor.target_id, anchor.placement, anchor.order)
366 if key in seen:
368 f"duplicate framing anchor {key} on {seen[key]!r} and {location!r}",
369 code=SRC_FRAMING_DUPLICATE_ANCHOR,
370 records=[seen[key], location],
371 hint="two framings share the same (target, placement, order) — "
372 "give one an explicit distinct order",
373 )
374 seen[key] = location
375
376
377def validate_cross_record(categorized: dict[str, list]) -> None:
378 """Run every §5.3 step-6 cross-record check (raises on the first failure)."""
379 func = "validate_cross_record"
380 items = _item_models(categorized)
381 id_index = _check_id_uniqueness(items)
382 _check_depends_on_and_cycle(items, id_index)
389 _check_framing_anchors(categorized)
390 _dbg("OC_CHAT.MAIN", func, f"cross-record ok for {len(items)} items", 3)
None validate_cross_record(dict[str, list] categorized)
None _check_version_metadata_agreement(dict[str, list] categorized)
None _check_depends_on_and_cycle(list[tuple[str, Any, str]] items, set[str] id_index)
None _check_done_parent_terminal_children(dict[str, list] categorized)
None _check_roadmap_changelog_seq(dict[str, list] categorized)
None _check_successor_acyclicity(list[tuple[str, Any, str]] items)
list[str]|None _find_graph_cycle(list[tuple[str, str]] edges)
None _check_order_and_code_uniqueness(dict[str, list] categorized)
list[tuple[str, Any, str]] _item_models(dict[str, list] categorized)
None _check_product_changelog_seq(dict[str, list] categorized)
set[str] _check_id_uniqueness(list[tuple[str, Any, str]] items)
None _check_framing_anchors(dict[str, list] categorized)