8Canonical YAML dumper + record loader for the AuthoredSource format.
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).
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.
29 L2 - format/load lifecycle
30 L3 - per-record canonicalisation
31 L4 - YAML node emission details
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}``).
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)
52from __future__
import annotations
54from typing
import Any, Optional
57from pydantic
import ValidationError
58from pydantic_core
import PydanticUndefined
60from oc_diagnostics
import _dbg
62from option_c_registry_contracts
import (
71 SRC_DUP_OR_STEM_MISMATCH,
74 SourceValidationError,
79_dbg(
"OC_CHAT.MAIN",
"module",
"loaded", 4)
81_STR_TAG =
"tag:yaml.org,2002:str"
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)}
95 """A mapping rendered inline (flow style) — used for ``depends_on`` edges."""
99 """SafeDumper that double-quotes string values and emits plain keys."""
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."""
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
129 return dumper.represent_scalar(_STR_TAG, data, style=
"|")
130 return dumper.represent_scalar(_STR_TAG, data, style=
'"')
134 return dumper.represent_mapping(
"tag:yaml.org,2002:map", data, flow_style=
True)
137CanonicalDumper.add_representer(str, _represent_str)
138CanonicalDumper.add_representer(_FlowMap, _represent_flowmap)
142 if field_info.default_factory
is not None:
143 return field_info.default_factory()
144 if field_info.default
is not PydanticUndefined:
145 return field_info.default
146 return PydanticUndefined
150 if isinstance(value, RegistryModel):
152 if isinstance(value, list):
154 if isinstance(value, dict):
155 return {key:
_to_yaml_obj(item)
for key, item
in value.items()}
160 """Canonical ``depends_on`` emission (§3.2 / AC §8.36)."""
161 if isinstance(edge, DependencyEdge):
162 if edge.kind ==
"blocks":
164 return _FlowMap({
"id": edge.id,
"kind": edge.kind})
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)
176 if isinstance(value, (list, dict))
and not value:
180 default
is not PydanticUndefined
181 and not isinstance(value, (list, dict))
185 if name ==
"depends_on":
193 """Serialize a frontmatter mapping to canonical YAML text (LF, no BOM)."""
196 Dumper=CanonicalDumper,
197 default_flow_style=
False,
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")
211 """Return the canonical bytes (text) of a record: frontmatter + body."""
215 return f
"---\n{frontmatter}---\n\n{clean_body}\n"
216 return f
"---\n{frontmatter}---\n"
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",
227 hint=
"every source record starts with a '---' frontmatter block",
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 '---')",
241 """Reject anchors/aliases/merge/custom-tags (§3.2) → SRC_BAD_YAML_FEATURE."""
243 events = list(yaml.parse(frontmatter, Loader=yaml.SafeLoader))
244 except yaml.YAMLError
as error:
246 f
"{rel_path}: unparseable YAML frontmatter: {error}",
251 if isinstance(event, yaml.AliasEvent):
253 f
"{rel_path}: YAML aliases/merge keys are forbidden in frontmatter",
254 code=SRC_BAD_YAML_FEATURE,
257 if getattr(event,
"anchor",
None):
259 f
"{rel_path}: YAML anchors are forbidden in frontmatter",
260 code=SRC_BAD_YAML_FEATURE,
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,
273 if scanned.kind
in SOURCE_ENTITIES:
274 return SOURCE_ENTITIES[scanned.kind]
275 if scanned.kind ==
"promoted_item":
281 f
"{scanned.rel_path}: no source model for kind {scanned.kind!r}",
282 code=SRC_STRICT_TYPE,
283 records=[scanned.rel_path],
287def load_record(scanned: ScannedRecord) -> tuple[RegistryModel, str]:
288 """Load + strict-validate a scanned record into its P1.1 model + body."""
290 raw = scanned.path.read_text(encoding=
"utf-8")
291 except UnicodeDecodeError
as error:
293 f
"{scanned.rel_path}: not valid UTF-8: {error}",
295 records=[scanned.rel_path],
300 data = yaml.safe_load(frontmatter)
or {}
301 except yaml.YAMLError
as error:
303 f
"{scanned.rel_path}: unparseable YAML frontmatter: {error}",
305 records=[scanned.rel_path],
307 if not isinstance(data, dict):
309 f
"{scanned.rel_path}: frontmatter must be a mapping",
311 records=[scanned.rel_path],
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],
327 """Filename stem == frontmatter id (§4); items/<kind>/ matches track_kind.
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.
334 if scanned.stem.split(
".", 1)[0].casefold()
in _RESERVED_DEVICE_STEMS:
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)",
344 getattr(model,
"id",
None)
345 or getattr(model,
"track_code",
None)
346 or getattr(model,
"slug",
None)
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],
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],
367 """Return the canonical text for a scanned record (load + re-emit)."""
373 records: list[ScannedRecord], *, check: bool =
False
375 """Format (or, with ``check=True``, verify) every formattable record.
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
383 changed: list[str] = []
384 for scanned
in records:
386 current = scanned.path.read_text(encoding=
"utf-8")
387 if current == canonical:
389 changed.append(scanned.rel_path)
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,
397 hint=
"run 'oct agent roadmap source format' to canonicalise",
403 """The canonical UTF-8 bytes for a record (source-tree-hash preimage input)."""
yaml.MappingNode represent_mapping(self, str tag, dict[str, Any] mapping, Optional[bool] flow_style=None)
str canonicalize_file(ScannedRecord scanned)
yaml.MappingNode _represent_flowmap(yaml.SafeDumper dumper, _FlowMap data)
tuple[RegistryModel, str] load_record(ScannedRecord scanned)
dict[str, Any] model_to_frontmatter(RegistryModel model)
tuple[str, str] split_record_text(str text)
str _normalize_body(str body)
str dump_frontmatter(dict[str, Any] mapping)
Any _model_for(ScannedRecord scanned)
None _assert_no_yaml_features(str frontmatter, str rel_path)
list[str] format_source_tree(list[ScannedRecord] records, *, bool check=False)
yaml.ScalarNode _represent_str(yaml.SafeDumper dumper, str data)
bytes canonical_record_bytes(ScannedRecord scanned)
Any _depends_on_to_yaml(Any edge)
Any _to_yaml_obj(Any value)
None _check_identity(ScannedRecord scanned, RegistryModel model)
str format_record_text(RegistryModel model, str body="")
Any _field_default(Any field_info)