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.