8Step-6 cross-record validation (Plan 142 P1.4, §5.3 step 6).
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.
22- ``validate_cross_record(categorized)`` — run all step-6 checks; raise the
23 first failing ``SourceValidationError`` with its §10 code + offending records.
29 L2 - validation lifecycle
30 L3 - per-check details
31 L4 - graph walk tracing
35- Pure / read-only over the loaded models; raises before any temp-DB insert.
36- Deterministic: checks + offending-record lists are order-stable.
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)
46from __future__
import annotations
50from oc_diagnostics
import _dbg
52from option_c_registry_contracts
import (
55 check_successor_acyclicity,
59 SRC_DANGLING_DEPENDS_ON,
61 SRC_DONE_PARENT_LIVE_CHILD,
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,
70 SRC_VERSION_METADATA_CONFLICT,
75_dbg(
"OC_CHAT.MAIN",
"module",
"loaded", 4)
79_VERSION_AGREE_FIELDS = (
"era",
"date",
"round",
"bump_override",
"notes")
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))
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]]] = {}
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():
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,
114 hint=
"item ids are unique case-insensitively across file ids ∪ nested "
115 "sub_phase ids ∪ promoted",
117 return {item_id
for item_id, _model, _location
in items}
121 edges: list[tuple[str, str]] = []
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:
128 f
"{location}: depends_on references unknown id {predecessor!r}",
129 code=SRC_DANGLING_DEPENDS_ON,
132 edges.append((predecessor, item_id))
134 if cycle
is not None:
137 "dependency cycle (kind-unscoped): " +
" -> ".join([*cycle, cycle[0]]),
138 code=SRC_DEPENDENCY_CYCLE,
144 adjacency: dict[str, list[str]] = {}
145 for source, target
in edges:
146 adjacency.setdefault(source, []).append(target)
147 color: dict[str, int] = {}
148 stack: list[str] = []
150 def _walk(node: str) -> list[str] |
None:
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:
158 if found
is not None:
164 for start
in list(adjacency):
165 if color.get(start)
is None:
167 if found
is not None:
173 holders = [model
for _id, model, _loc
in items
if hasattr(model,
"successor")]
175 check_successor_acyclicity(holders)
176 except ValueError
as error:
179 str(error), code=SRC_SUCCESSOR_CYCLE, records=[]
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":
199 if status_by_id.get(child_id)
not in TERMINAL_STATUSES:
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],
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:
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)],
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:
227 f
"duplicate track_code {track.track_code!r}",
228 code=SRC_DUPLICATE_CODE,
229 records=[track.track_code],
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:
237 f
"duplicate phase_code {phase.id!r}",
238 code=SRC_DUPLICATE_CODE,
241 seen_phase.add(phase.id)
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():
251 for field
in _VERSION_AGREE_FIELDS:
252 present = {getattr(rec, field)
for rec
in records
if getattr(rec, field)
is not None}
256 f
"version {version_id}: conflicting {field} across kinds: {sorted(map(str, present))}",
257 code=SRC_VERSION_METADATA_CONFLICT,
258 records=[version_id, field],
263 """Every ``kind=roadmap`` changelog record must carry a unique positive-int
264 ``extensions.changelog_seq`` (Plan 142 P6 / F1).
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":
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:
281 f
"{location}: kind=roadmap record needs a positive-int "
282 f
"extensions.changelog_seq (got {seq!r})",
283 code=SRC_ROADMAP_CHANGELOG_SEQ,
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",
294 f
"duplicate changelog_seq {seq} on {seen[seq]!r} and {location!r}",
295 code=SRC_ROADMAP_CHANGELOG_SEQ,
296 records=[seen[seq], location],
302 """The ``kind=product`` changelog seq is ALL-OR-NONE, via the shared validator
303 (Plan 142 P2 / SRC_PRODUCT_CHANGELOG_SEQ).
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."""
314 f
"changelog/product/{getattr(version, 'version', '?')}",
315 (getattr(version,
"extensions",
None)
or {}).get(
"changelog_seq"),
317 for version
in categorized.get(
"versions", [])
318 if getattr(version,
"kind",
None) ==
"product"
320 problem = check_changelog_seq(located)
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",
334 """[NEW·V24] Validate every ``framing`` entity's placement anchor (§3.3).
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", [])
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,
362 hint=
"anchor.target_id must resolve to an era id / version slug / "
363 "position sibling id or the top|bottom sentinel",
365 key = (anchor.target_kind, anchor.target_id, anchor.placement, anchor.order)
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",
378 """Run every §5.3 step-6 cross-record check (raises on the first failure)."""
379 func =
"validate_cross_record"
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)