oc_chat
Loading...
Searching...
No Matches
canonical.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/source/canonical.py
4
5# OCT-LINT: disable=no_hardcoded_secrets reason="YAML tag URIs and field names are format constants"
6# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/source/test_canonical.py"
7"""
8Canonical YAML dumper + record loader for the AuthoredSource format.
9
10Purpose
11-------
12Read a source record (``yaml.safe_load``) and re-emit byte-canonical frontmatter
13with a custom ``yaml.SafeDumper`` subclass, per Registry Source & Storage Spec
14V0.22 §3.2: scalar string values double-quoted, plain keys, field-declaration
15key order, pydantic defaults + empty containers omitted, ``depends_on``
16canonical emission, no anchors/aliases/tags, LF / UTF-8 / no-BOM / one trailing
17newline. The formatter is idempotent (AC §8.4).
18
19Responsibilities
20----------------
21- ``format_record_text(model, body)`` — canonical bytes for a record.
22- ``load_record(scanned)`` — parse + strict-validate against the P1.1 model.
23- ``canonicalize_file`` / ``format_source_tree`` — format or ``--check`` a tree.
24
25Diagnostics
26-----------
27Domain: OC_CHAT.MAIN
28Levels:
29 L2 - format/load lifecycle
30 L3 - per-record canonicalisation
31 L4 - YAML node emission details
32
33Contracts
34---------
35- Idempotent: ``format(format(x)) == format(x)``.
36- Round-trip safe: a canonical record re-parses to an equal model.
37- All string scalar VALUES double-quoted (round-trips any single-line content);
38 keys emitted plain. ``depends_on`` blocks → bare quoted id; enables/gates →
39 inline ``{id: "..", kind: ..}`` (never ``{id, kind: blocks}``).
40
41Dependencies
42------------
43- oc_diagnostics (structured debug logging via _dbg)
44- yaml (safe_load + custom SafeDumper)
45- option_c_registry_contracts (P1.1 entity models)
46- oc_chat.registry.source.errors (SRC_* codes)
47- oc_chat.registry.source.promoted_item (PromotedItem - the model for promoted_item records)
48- oc_chat.registry.source.scanner (ScannedRecord - the scanned record every loader and formatter takes)
49- pydantic (ValidationError - reported as an SRC_STRICT_TYPE failure)
50- pydantic_core (PydanticUndefined - telling a field with no default apart)
51"""
52from __future__ import annotations
53
54from typing import Any, Optional
55
56import yaml
57from pydantic import ValidationError
58from pydantic_core import PydanticUndefined
59
60from oc_diagnostics import _dbg
61
62from option_c_registry_contracts import (
63 DependencyEdge,
64 RegistryModel,
65 SOURCE_ENTITIES,
66)
67
69 SRC_BAD_YAML,
70 SRC_BAD_YAML_FEATURE,
71 SRC_DUP_OR_STEM_MISMATCH,
72 SRC_NOT_CANONICAL,
73 SRC_STRICT_TYPE,
74 SourceValidationError,
75)
76from oc_chat.registry.source.promoted_item import PromotedItem
77from oc_chat.registry.source.scanner import ScannedRecord
78
79_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
80
81_STR_TAG = "tag:yaml.org,2002:str"
82
83#: Reserved Windows device stems (§3.2) — a record whose stem's first dot-segment
84#: is one of these cannot be checked out on Windows. Rejected per-record on load
85#: (Plan 142j R7 / VC-0007, layer 2). Reuses SRC_DUP_OR_STEM_MISMATCH (a stem
86#: validity failure; SRC_UNSAFE_PATH is a TARGET code not in ALL_CODES).
87_RESERVED_DEVICE_STEMS: frozenset[str] = frozenset(
88 {"con", "prn", "aux", "nul"}
89 | {f"com{i}" for i in range(1, 10)}
90 | {f"lpt{i}" for i in range(1, 10)}
91)
92
93
94class _FlowMap(dict):
95 """A mapping rendered inline (flow style) — used for ``depends_on`` edges."""
96
97
98class CanonicalDumper(yaml.SafeDumper):
99 """SafeDumper that double-quotes string values and emits plain keys."""
100
102 self,
103 tag: str,
104 mapping: dict[str, Any],
105 flow_style: Optional[bool] = None,
106 ) -> yaml.MappingNode:
107 """Emit a mapping with plain keys + (recursively) quoted scalar values."""
108 value = []
109 node = yaml.MappingNode(tag, value, flow_style=flow_style)
110 if self.alias_key is not None:
111 self.represented_objects[self.alias_key] = node
112 for item_key, item_value in mapping.items():
113 node_key = self.represent_scalar(_STR_TAG, str(item_key), style="")
114 node_value = self.represent_data(item_value)
115 value.append((node_key, node_value))
116 if flow_style is None:
117 node.flow_style = False
118 return node
119
120
121def _represent_str(dumper: yaml.SafeDumper, data: str) -> yaml.ScalarNode:
122 # §3.2: a multi-line string value (the [NEW·V24] era.banner / framing.text
123 # verbatim prose) is emitted as a literal block scalar (``|``); a single-line
124 # value stays double-quoted (round-trips any single-line content). PyYAML
125 # falls back to a quoted style automatically if a block scalar cannot
126 # represent the content (e.g. trailing whitespace) — the canonicaliser
127 # normalises those away, so block scalars stay clean + idempotent.
128 if "\n" in data:
129 return dumper.represent_scalar(_STR_TAG, data, style="|")
130 return dumper.represent_scalar(_STR_TAG, data, style='"')
131
132
133def _represent_flowmap(dumper: yaml.SafeDumper, data: _FlowMap) -> yaml.MappingNode:
134 return dumper.represent_mapping("tag:yaml.org,2002:map", data, flow_style=True)
135
136
137CanonicalDumper.add_representer(str, _represent_str)
138CanonicalDumper.add_representer(_FlowMap, _represent_flowmap)
139
140
141def _field_default(field_info: Any) -> Any:
142 if field_info.default_factory is not None: # type: ignore[truthy-function]
143 return field_info.default_factory()
144 if field_info.default is not PydanticUndefined:
145 return field_info.default
146 return PydanticUndefined
147
148
149def _to_yaml_obj(value: Any) -> Any:
150 if isinstance(value, RegistryModel):
151 return model_to_frontmatter(value)
152 if isinstance(value, list):
153 return [_to_yaml_obj(item) for item in value]
154 if isinstance(value, dict):
155 return {key: _to_yaml_obj(item) for key, item in value.items()}
156 return value
157
158
159def _depends_on_to_yaml(edge: Any) -> Any:
160 """Canonical ``depends_on`` emission (§3.2 / AC §8.36)."""
161 if isinstance(edge, DependencyEdge):
162 if edge.kind == "blocks":
163 return edge.id # bare quoted id — never {id, kind: blocks}
164 return _FlowMap({"id": edge.id, "kind": edge.kind})
165 return edge # already a bare id str (kind defaults to blocks)
166
167
168def model_to_frontmatter(model: RegistryModel) -> dict[str, Any]:
169 """Build the frontmatter mapping in field-declaration order, omitting
170 pydantic defaults and empty containers (§3.2)."""
171 out: dict[str, Any] = {}
172 for name, field_info in type(model).model_fields.items():
173 value = getattr(model, name)
174 if value is None:
175 continue
176 if isinstance(value, (list, dict)) and not value:
177 continue
178 default = _field_default(field_info)
179 if (
180 default is not PydanticUndefined
181 and not isinstance(value, (list, dict))
182 and value == default
183 ):
184 continue
185 if name == "depends_on":
186 out[name] = [_depends_on_to_yaml(edge) for edge in value]
187 else:
188 out[name] = _to_yaml_obj(value)
189 return out
190
191
192def dump_frontmatter(mapping: dict[str, Any]) -> str:
193 """Serialize a frontmatter mapping to canonical YAML text (LF, no BOM)."""
194 return yaml.dump(
195 mapping,
196 Dumper=CanonicalDumper,
197 default_flow_style=False,
198 sort_keys=False,
199 allow_unicode=True,
200 width=4096,
201 )
202
203
204def _normalize_body(body: str) -> str:
205 body = body.replace("\r\n", "\n").replace("\r", "\n")
206 body = body.strip("\n")
207 return "\n".join(line.rstrip() for line in body.split("\n")).strip("\n")
208
209
210def format_record_text(model: RegistryModel, body: str = "") -> str:
211 """Return the canonical bytes (text) of a record: frontmatter + body."""
212 frontmatter = dump_frontmatter(model_to_frontmatter(model))
213 clean_body = _normalize_body(body)
214 if clean_body:
215 return f"---\n{frontmatter}---\n\n{clean_body}\n"
216 return f"---\n{frontmatter}---\n"
217
218
219def split_record_text(text: str) -> tuple[str, str]:
220 """Split a record file into (frontmatter_text, body); LF-normalised."""
221 text = text.replace("\r\n", "\n").replace("\r", "\n")
222 lines = text.split("\n")
223 if not lines or lines[0].strip() != "---":
225 "record has no '---' YAML frontmatter delimiter",
226 code=SRC_BAD_YAML,
227 hint="every source record starts with a '---' frontmatter block",
228 )
229 for index in range(1, len(lines)):
230 if lines[index].strip() == "---":
231 frontmatter = "\n".join(lines[1:index]) + "\n"
232 body = "\n".join(lines[index + 1 :])
233 return frontmatter, body
235 "unterminated YAML frontmatter (missing closing '---')",
236 code=SRC_BAD_YAML,
237 )
238
239
240def _assert_no_yaml_features(frontmatter: str, rel_path: str) -> None:
241 """Reject anchors/aliases/merge/custom-tags (§3.2) → SRC_BAD_YAML_FEATURE."""
242 try:
243 events = list(yaml.parse(frontmatter, Loader=yaml.SafeLoader))
244 except yaml.YAMLError as error:
246 f"{rel_path}: unparseable YAML frontmatter: {error}",
247 code=SRC_BAD_YAML,
248 records=[rel_path],
249 ) from error
250 for event in events:
251 if isinstance(event, yaml.AliasEvent):
253 f"{rel_path}: YAML aliases/merge keys are forbidden in frontmatter",
254 code=SRC_BAD_YAML_FEATURE,
255 records=[rel_path],
256 )
257 if getattr(event, "anchor", None):
259 f"{rel_path}: YAML anchors are forbidden in frontmatter",
260 code=SRC_BAD_YAML_FEATURE,
261 records=[rel_path],
262 )
263 tag = getattr(event, "tag", None)
264 if tag and isinstance(tag, str) and tag.startswith("!"):
266 f"{rel_path}: explicit YAML tags are forbidden in frontmatter",
267 code=SRC_BAD_YAML_FEATURE,
268 records=[rel_path],
269 )
270
271
272def _model_for(scanned: ScannedRecord) -> Any:
273 if scanned.kind in SOURCE_ENTITIES:
274 return SOURCE_ENTITIES[scanned.kind]
275 if scanned.kind == "promoted_item":
276 # [OP-5] promoted parity: resolve the relocated PromotedItem model so
277 # promoted records canonicalise / format / hash like every other entity.
278 # (Not in SOURCE_ENTITIES — that closed dict lives in the contracts pkg.)
279 return PromotedItem
281 f"{scanned.rel_path}: no source model for kind {scanned.kind!r}",
282 code=SRC_STRICT_TYPE,
283 records=[scanned.rel_path],
284 )
285
286
287def load_record(scanned: ScannedRecord) -> tuple[RegistryModel, str]:
288 """Load + strict-validate a scanned record into its P1.1 model + body."""
289 try:
290 raw = scanned.path.read_text(encoding="utf-8")
291 except UnicodeDecodeError as error:
293 f"{scanned.rel_path}: not valid UTF-8: {error}",
294 code=SRC_BAD_YAML,
295 records=[scanned.rel_path],
296 ) from error
297 frontmatter, body = split_record_text(raw)
298 _assert_no_yaml_features(frontmatter, scanned.rel_path)
299 try:
300 data = yaml.safe_load(frontmatter) or {}
301 except yaml.YAMLError as error:
303 f"{scanned.rel_path}: unparseable YAML frontmatter: {error}",
304 code=SRC_BAD_YAML,
305 records=[scanned.rel_path],
306 ) from error
307 if not isinstance(data, dict):
309 f"{scanned.rel_path}: frontmatter must be a mapping",
310 code=SRC_BAD_YAML,
311 records=[scanned.rel_path],
312 )
313 model_cls = _model_for(scanned)
314 try:
315 model = model_cls.model_validate(data)
316 except ValidationError as error:
318 f"{scanned.rel_path}: strict validation failed: {error}",
319 code=SRC_STRICT_TYPE,
320 records=[scanned.rel_path],
321 ) from error
322 _check_identity(scanned, model)
323 return model, _normalize_body(body)
324
325
326def _check_identity(scanned: ScannedRecord, model: RegistryModel) -> None:
327 """Filename stem == frontmatter id (§4); items/<kind>/ matches track_kind.
328
329 [NEW·V24] A changelog ``version`` record's identity is its ``slug`` field
330 (§3.5 / §4 item 6), so its stem is now checked against ``slug`` (the earlier
331 ``version``-skip is removed); ``framing`` records check stem == ``id`` via the
332 generic path. Only ``manifest`` (which has no id) is exempt.
333 """
334 if scanned.stem.split(".", 1)[0].casefold() in _RESERVED_DEVICE_STEMS:
335 # Plan 142j R7 (VC-0007, layer 2): per-record reserved Windows device stem.
337 f"{scanned.rel_path}: reserved Windows device stem {scanned.stem!r} "
338 "cannot be checked out on Windows",
339 code=SRC_DUP_OR_STEM_MISMATCH,
340 records=[scanned.rel_path],
341 hint="rename the record — CON/PRN/AUX/NUL/COM1-9/LPT1-9 are reserved (§3.2)",
342 )
343 record_id = (
344 getattr(model, "id", None)
345 or getattr(model, "track_code", None)
346 or getattr(model, "slug", None)
347 )
348 if scanned.kind != "manifest" and record_id is not None:
349 if record_id != scanned.stem:
351 f"{scanned.rel_path}: filename stem {scanned.stem!r} != id {record_id!r}",
352 code=SRC_DUP_OR_STEM_MISMATCH,
353 records=[scanned.rel_path],
354 )
355 if scanned.kind == "roadmap_item":
356 track_kind = getattr(model, "track_kind", None)
357 if track_kind != scanned.track_kind:
359 f"{scanned.rel_path}: track_kind {track_kind!r} != directory "
360 f"items/{scanned.track_kind}/",
361 code=SRC_STRICT_TYPE,
362 records=[scanned.rel_path],
363 )
364
365
366def canonicalize_file(scanned: ScannedRecord) -> str:
367 """Return the canonical text for a scanned record (load + re-emit)."""
368 model, body = load_record(scanned)
369 return format_record_text(model, body)
370
371
373 records: list[ScannedRecord], *, check: bool = False
374) -> list[str]:
375 """Format (or, with ``check=True``, verify) every formattable record.
376
377 Returns the list of relative paths that were rewritten (or, in check mode,
378 the non-canonical paths). [OP-5] promoted parity: promoted items are
379 canonicalised + ``--check``-verified like every other entity (the earlier
380 model-gap skip is removed) so ``format --check`` catches a non-canonical
381 promoted file.
382 """
383 changed: list[str] = []
384 for scanned in records:
385 canonical = canonicalize_file(scanned)
386 current = scanned.path.read_text(encoding="utf-8")
387 if current == canonical:
388 continue
389 changed.append(scanned.rel_path)
390 if not check:
391 scanned.path.write_text(canonical, encoding="utf-8", newline="")
392 if check and changed:
394 f"{len(changed)} record(s) are not canonical: {changed}",
395 code=SRC_NOT_CANONICAL,
396 records=changed,
397 hint="run 'oct agent roadmap source format' to canonicalise",
398 )
399 return changed
400
401
402def canonical_record_bytes(scanned: ScannedRecord) -> bytes:
403 """The canonical UTF-8 bytes for a record (source-tree-hash preimage input)."""
404 return canonicalize_file(scanned).encode("utf-8")
yaml.MappingNode represent_mapping(self, str tag, dict[str, Any] mapping, Optional[bool] flow_style=None)
Definition canonical.py:106
str canonicalize_file(ScannedRecord scanned)
Definition canonical.py:366
yaml.MappingNode _represent_flowmap(yaml.SafeDumper dumper, _FlowMap data)
Definition canonical.py:133
tuple[RegistryModel, str] load_record(ScannedRecord scanned)
Definition canonical.py:287
dict[str, Any] model_to_frontmatter(RegistryModel model)
Definition canonical.py:168
tuple[str, str] split_record_text(str text)
Definition canonical.py:219
str dump_frontmatter(dict[str, Any] mapping)
Definition canonical.py:192
Any _model_for(ScannedRecord scanned)
Definition canonical.py:272
None _assert_no_yaml_features(str frontmatter, str rel_path)
Definition canonical.py:240
list[str] format_source_tree(list[ScannedRecord] records, *, bool check=False)
Definition canonical.py:374
yaml.ScalarNode _represent_str(yaml.SafeDumper dumper, str data)
Definition canonical.py:121
bytes canonical_record_bytes(ScannedRecord scanned)
Definition canonical.py:402
None _check_identity(ScannedRecord scanned, RegistryModel model)
Definition canonical.py:326
str format_record_text(RegistryModel model, str body="")
Definition canonical.py:210
Any _field_default(Any field_info)
Definition canonical.py:141