Loader
Rig files and shield templates in, rig model out. Everything this stage rejects is something the files got wrong — a missing reference, an illegal axis, a shield that does not exist, a parameter with no value. It never reads a board.
rigc.loader
The loader proper: rig.yml metadata (qualifier axes), the shield library, the required content file, fragment discovery, and the delta engine with params/config fully wired – assembled here from the loader’s own submodules:
documents.py -- mark-aware YAML, content-filename construction
axes.py -- revisions:/variants: declaration + resolution (the
hwmv2 seam) -- reused unchanged for shield.yml's own
revisions: axis
binding.py -- the invocation's board -> rig.board, and the
SocketBinding seam
fragments.py -- the contributes-nothing check for a selected
non-default axis value
library.py -- the shield library: scan, axes, lazy revision
resolution
params.py -- params:/config: machinery; the per-instance-
parameter vocabulary is the owning DEVICE's own
declared_param_includes, never a rig.yml declaration
delta.py -- base topology + the delta engine, resolving
`shield:` against the REAL library
The shield library is scanned BEFORE rig.yml even opens, so shield-node-name-mismatch and every other scan-time diagnostic precedes every rig-side one.
load() returns (Rig | None, diagnostics) rather than raising on a reject: a load that finds nothing wrong hands its Rig to cli.py, which carries on into the board reader, the analyzer and the emitter.
Three phases: load() itself is just the library scan, three phase calls, and the final Rig assembly – _resolve_metadata (rig.yml’s shell: name, qualifier axes, the invocation’s board – entirely cpp-free), _gather_content (the required content file, the two delta fragments, the contributes-nothing check), _build_topology (stage 0 plus the two delta stages, the per-stage invariant). Each phase returns its OWN small value – never a shared mutable “context” written into across phases. load() concatenates each phase’s diagnostics onto its own running list, in the phases’ own call order – reproducing today’s traversal order byte for byte, since that order is the frozen stderr contract. A LoadError raised partway through must still carry every diagnostic gathered before the raise, or a later caller renders only the fatal finding and silently drops the rest; the boundary that catches LoadError therefore sits at the TOP of load(), wrapping the library scan and all three phases, and re-raises with its own diagnostics-so-far prepended. _build_topology carries its OWN inner instance of the same guard, because a shield revision resolved LAZILY, mid-topology (ShieldLibrary.resolve), can still raise LoadError from inside that one phase call, same as the library scan already does in library.py.
rigc.loader.documents
The document model: mark-aware YAML parsing shared by rig.yml (the METADATA document) and every content/delta document – the base <rigname>.yml and every <rigname>_<variant|rev>.yml fragment all use the SAME flat top-level shape, with no rig: wrapper in either case, so the same parser serves both.
Line-accurate anchors ride on YAML composer marks: a scalar value’s own start line, a nested mapping’s FIRST ENTRY line (one below the key that introduces it), a sequence item’s own line – proven byte-exact against the frozen goldens, which this module’s diagnostics depend on staying that way.
rigc.loader.axes
Qualifier axes: declaration parsing, selection resolution, the constructed-fragment-stem collision check, and revision normalization, matching hwmv2’s own revision semantics (the format/exact/nearest-lower machinery extensions.cmake implements).
The hwmv2 seam: this module is the ONLY place a revision:/ revisions:/variants: declaration’s raw YAML is read (parse_revision_decl/parse_legacy_revision_decl/parse_variant_decl) or a selection resolved against it (resolve_axis_selection): a rig’s own axis resolution (resolve_axis, below) and ShieldLibrary.resolve (loader/library.py) both delegate to that one function rather than each re-deriving the three failure shapes (not-declared-at-all / not-a-member / no-default) with their own wording. normalize_revision applies ONLY at filename construction, and only to a RESOLVED value, never a requested one.
variants: keeps its own {default:, list: []} shape: each entry is either a bare name or a mapping {name:} (only name: is read; any other key is ignored). A revision entry (parse_revision_decl below) must always be a mapping – the two axes’ entry shapes differ, and that difference is deliberate, not an oversight to fix.
A rig.yml revision axis (singular key revision:) takes upstream’s own board.yml block: format: (required, one of letter/number/major.minor. patch/custom), default:, exact: (optional), and a plural revisions: list of {name:} mappings – copying the SHAPE, not a near-miss, so a reviewer can diff our schema against upstream’s board-schema.yaml. Only letter/number/major.minor.patch are implemented; format: custom is a valid declaration (upstream’s schema allows it) but is rejected at resolution time, loudly, since upstream’s custom means the board author supplies their own revision.cmake calling board_check_revision itself – arbitrary cmake feeding a Python resolver is out of scope here.
A shield.yml revision axis does NOT get that shape: it keeps its PRE-hwmv2 one (parse_legacy_revision_decl’s own docstring has the full reason) – the pinned zephyr tree carries its own schema restricting a shield’s revisions: block to exactly {default:, list: []}, enforced by Zephyr’s own list_shields.py on every real configure, whether or not the shield in question is used. resolve_axis_selection reads decl.format is None as “run no hwmv2 machinery, exact membership only” – a property of the DATA, not of which caller (owner_kind) passed it, so the function needs no shield/rig branching and the day that schema changes, nothing here does either.
rigc.loader.fragments
The contributes-nothing check: a selected NON-DEFAULT axis value that contributes NOTHING – the constructed-fragment-file contribution check. A variant declares nothing but its own name, so its only way to contribute is the same fragment-file avenue a revision has.
PURE, deliberately: which files exist is probed by the CALLER – the loader’s IO phase, _gather_content – and arrives here as a FragmentPresence value, so the rule itself decides over values and its tests construct values instead of a tmp directory. The name CONSTRUCTION stays here, in the two *_contribution_names helpers, single-sourced for both the caller’s probes and this module’s own message text, since duplicated stem construction is where variant/revision normalization drift hides.
rigc.loader.delta
Base topology parsing and the delta engine: instances, wires, and the four delta operations (instances:, add-instances:, remove-instances:, add-wires:/remove-wires:), all matched against an in-memory EFFECTIVE topology. Diagnostic code is lang-variant or lang-rev by STAGE.
shield: references resolve against a REAL ShieldLibrary (loader/library.py), and params:/config: are fully applied (loader/params.py). Wire endpoints are checked for label existence and ambiguity (resolve_dotted, via Shield.by_name).
rigc.loader.library
The shield library: scan, axes, lazy revision resolution. resolve() returns a REAL Shield, or diagnostics explaining why not.
- Discovery, per folder under a shield-library root:
no shield.yml -> the name is this folder’s own basename; it is a rig template if and only if <dir>/<name>.shield exists, silently skipped otherwise (a legacy shield, or not a shield at all).
shield.yml present -> one name per shield: (a single mapping) or shields: (a list, the mutually exclusive plural form upstream b836fcdd709 added) entry – NAME comes from the entry’s own name: now, never the folder. An entry is a rig template if and only if it declares template: true AND <dir>/<name>.shield exists; one that declares template: true with no matching file is a loud lang-shield-template finding, not a silent skip (this folder’s authoring intent is known, unlike the yml-less case); one that omits the flag is a legacy shield carrying metadata, discoverable (promote.discover_shields’s wider census) but never a rig template. Never a *.shield glob either way (Kconfig.shield ends in the literal substring and would be mis-globbed).
shield.yml supplies the declared NAME – the folder name no longer is one – and, per template entry, its own revisions: axis, parsed by loader.axes.parse_legacy_revision_decl – its OWN pre-hwmv2 shape ({default:, list: []}), NOT the hwmv2 block rig.yml’s own revision: axis takes. This is a hard external constraint, not a choice: the pinned zephyr tree carries its own schema restricting a shield’s revisions: block to exactly that shape (see parse_legacy_revision_decl’s own docstring). resolve_axis_selection still applies – a shield revision axis with format is None runs exact-membership resolution only, no nearest-lower.
Eager vs lazy: discovery (the folder walk, the <name>.shield presence probe, shield.yml’s own entries and axis reads) is ALWAYS eager – it is cheap, has no subprocess, and is what builds the known-shields census lang-instance-shield prints. Parsing a template – base or revision – never happens at scan time; every discovered shield is recorded as _Pending and its template parses on resolve()’s first reference, whether or not it declares a revisions: axis. Eagerly parsing every discovered shield regardless of use would do needless cpp/dtlib work (a rig referencing 2 of 14 discovered shields has no business preprocessing the other 12) and leak an unreferenced template’s path into dependency data; eagerly combining every declared REVISION of a referenced shield would repeat the same mistake one level down, so that stays deferred to each revision’s own first selection too.
A base parse that fails (its template defines no node matching the folder name) is memoized in ShieldLibrary.failed so a second reference reports nothing new; a lazy re-parse per reference would otherwise re-run cpp and re-report the same defect once per referencing instance.
Diagnostics and dependency data are RETURN values: resolve() never writes into an accumulator handed in from outside. ShieldLibrary.shields IS mutated in place by resolve() – that is the lazy-parse MEMOIZATION cache the whole design requires, a self-contained value the library keeps about itself, not a side channel written into by many unrelated callers.
rigc.loader.binding
A rig’s abstract socket: references resolve through ONE SocketBinding value, applied at exactly one seam (instance construction, loader/delta.py) – the delta engine itself never touches a socket map, only abstract references. rig.yml no longer has a board:/sockets: grammar of its own; resolve_board is the one place the invocation’s –board becomes rig.board, so a later change to how a board reaches this pipeline touches only this module.
rigc.loader.params
Params, config elements, per-instance-parameter vocabulary: shield: references resolve against a real ShieldLibrary, and this is where params:/config: apply fully against the resolved shield. The rig-side key is config:, resolving by DTS label, never by node name.
Every function here takes the NARROW values it needs (a shield, a params: Val, a rig NAME) rather than a whole Rig or Instance – “whole-model inputs where a value would do”: the caller (loader/delta.py) already holds these pieces and assigns the result onto a freshly constructed Instance, matching its own no-mutation discipline.
The vocabulary a param token resolves against is the OWNING DEVICE’s own `declared_param_includes`, never a rig-level declaration – the shield that declares a parameter declares the vocabulary that parameter is drawn from, so check_param_token takes the device’s own header list, not something threaded down from rig.yml.
rigc.loader.shields
Shield parsing: a .shield translation unit -> model.Shield. Loader-side validation done here:
shield,plugs names a known connector type
bus proxy nodes are allowed by the plug binding
position references target one of THIS shield’s plugs and exist in that plug’s connector type
exactly one of reg / shield,addr-from on addressable-bus devices (forgot-vs-deferred: address authority rule)
authored reg matches the unit-address; symbolic unit-addresses are linted against the addr-from target
ONE authored form: N plug nodes, N >= 1, each a child of the template with compatible = “shield,plug” and its own shield,plugs naming that plug’s connector type. The child’s NODE NAME is the slot name (shield-owned); plug is the conventional name for a shield with one, and carries no special meaning. Plurality is a COUNT, never an authored form – which is what every consumer below this module always tested (len(shield.plugs) > 1).
Placement, the same rule at either count:
- bus groups NEST UNDER their owning plug node – that nesting is what
dissolves the sibling-name collision two same-kind buses would otherwise have; a bus-shaped group at template level is rejected.
- plain (non-bus) device groups stay at TEMPLATE level, plug-agnostic –
their devices’ refs each carry their own plug by phandle (“one of this shield’s plugs”); a plain group nested under a plug is rejected. With exactly one plug, such a device is attributed to it; with more, to none.
- pads and config are template-level too, whatever the plug count:
they are shield-level facts. Promotion and routing jumpers are refused above one plug – straps are unaffected (bus-scoped, not plug-scoped). A carrier of any plug count may declare an exposed socket: its gpio-map rows and socket,<bus> properties each resolve through one of the carrier’s plugs, exactly like a device’s own cross-plug refs.
A plug node declares no cell counts. #gpio-cells/#pwm-cells/ #io-channel-cells are refused there: every value the corpus ever gave one was _FUNCTION_DEFAULT_CELLS restated, the node is never emitted so nothing validates it, and a wrong value silently changed a reference’s arity. The _ncells mechanism stays for the nodes that genuinely differ – a routing jumper’s own <1>.
Diagnostics are RETURN values: every parse function below returns (value, diagnostics) rather than writing into a diags parameter handed in from outside – the local list a function builds and returns is not the banned accumulator shape (nothing outside this module ever mutates one), it is composition-by-return exactly like every other rigc module.
The cpp/unit-test seam: everything here operates on a dtlib.DT that ALREADY EXISTS – it never calls cpp itself – so it is unit-testable directly against a synthetic, cpp-free .dts text parsed with dtsio.get_dtlib().DT(path).
rigc.registry
Connector-type registry. A type IS two artifacts: the unified socket+plug binding (board side, edtlib’s job in the real build; shield side, consumed HERE by the loader) and the index header (position single source of truth). The registry is a PREREQUISITE, not a nicety – loader/shields.py checks every shield’s plug against it (lang-shield-type), so an empty or stubbed registry would emit errors on perfectly valid fixture/corpus shields and corrupt every golden’s bytes.
Data source: ONE file per type, dts/bindings/connectors/<type>.yaml – the real socket binding plus the shield-side plug contract folded in as plug,* top-level extension keys (namespaced by the SIDE they describe, never the project) – legal since edtlib treats any top-level binding key containing a comma as an opaque vendor-namespaced extension. Read HERE with a plain yaml.safe_load rather than edtlib.Binding: the plug,* keys are declared inline in every unified binding, so the raw YAML dict already has them.
Resolved ONCE at CLI entry and threaded down as a value – the hardcoded BINDINGS default below is a DEV/TEST convenience only, never the production path: a real build always threads –connector-dir explicitly (cmake/modules/dts.cmake mirrors DTS_ROOT/dts/bindings/connectors for every DTS_ROOT, the same rule this module’s own default encodes), because the connector-type registry is a DIFFERENT consumer from edtlib’s own bindings scan and cannot ride inside a threaded –bindings-dir. BINDINGS being wrong or absent is therefore only ever a standalone-invocation or workspace-layout problem, never a real build’s – see load_types’ own docstring for what happens when it is.