Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
efd4b04f2This file is pinned to the selected release. Download the original to open it in your design tool.
# Copyright (c) 2026 TiaC Systems
# SPDX-License-Identifier: Apache-2.0
"""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.
"""
from __future__ import annotations
import re
from ..diag import Diagnostic, SourceRef, error
from ..model import AxisDecl
from .documents import Val, require
#: hwmv2's own per-format id shapes (extensions.cmake:1048 family, ported
#: unchanged): `letter` is a single uppercase letter, `number` is digits
#: only, `major.minor.patch` is three dot-separated non-negative integers
#: with no leading zero (except the literal "0" component itself).
_REVISION_PATTERNS = {
"letter": re.compile(r"^[A-Z]$"),
"number": re.compile(r"^\d+$"),
"major.minor.patch": re.compile(r"^((0|[1-9][0-9]*)(\.[0-9]+)(\.[0-9]+))$"),
}
#: Every format upstream's board-schema.yaml accepts, for the SCHEMA-level
#: check (declaring `format: custom` is legal YAML, just not an
#: implemented resolution -- rejected instead in `resolve_axis_selection`,
#: where "this axis is actually used" is decided).
_ALL_REVISION_FORMATS = ("letter", "number", "major.minor.patch", "custom")
_FORMAT_DESCRIPTIONS = {
"letter": "a single uppercase letter (A-Z)",
"number": "digits only",
"major.minor.patch": "major.minor.patch, e.g. 1.2.3",
}
def normalize_revision(rev: str) -> str:
"""hwmv2's own revision normalization (zephyr_build_string,
extensions.cmake:1772): a dotted revision id becomes underscores in
the CONSTRUCTED filename only (1.2 -> 1_2). Applies to the RESOLVED
value only -- the value a selection actually resolved to, nearest-lower
match included -- never to a declared name or the raw requested
string."""
return rev.replace(".", "_")
def variant_fragment_name(rig_name: str, variant: str) -> str:
"""<rigname>_<variant>.yml -- the variant delta-fragment stem, from
the RAW selected value: normalization is a REVISION concept (hwmv2
dots-in-ids) only, never applied to variants -- the two axes
construct their stems differently. Lives beside normalize_revision
because stem construction IS the hwmv2 seam: the collision
enumerator below builds the same stems."""
return f"{rig_name}_{variant}.yml"
def revision_fragment_name(rig_name: str, revision: str) -> str:
"""<rigname>_<norm(rev)>.yml -- the revision delta-fragment stem,
normalized in exactly ONE place (normalize_revision above). `revision`
must already be the RESOLVED value (nearest-lower match applied) --
callers never pass the raw requested string here."""
return f"{rig_name}_{normalize_revision(revision)}.yml"
def _zero_append(fmt: str, value: str) -> str:
"""hwmv2's loose typing for major.minor.patch (extensions.cmake:
1092-1103): a REQUESTED value with fewer than three dot-separated
components gets the missing ones padded with zero, BEFORE the format's
own pattern is matched -- "1" becomes "1.0.0", "1.2" becomes "1.2.0".
A no-op for every other format and for a value that already has three
or more components. Declared names are never passed through this --
they must already be full triples (extensions.cmake never rewrites
VALID_REVISIONS, only BOARD_REVISION)."""
if fmt != "major.minor.patch":
return value
parts = value.split(".")
while len(parts) < 3:
parts.append("0")
return ".".join(parts)
def _format_matches(fmt: str, value: str) -> bool:
pattern = _REVISION_PATTERNS.get(fmt)
return pattern is None or bool(pattern.match(value))
def _revision_sort_key(fmt: str, value: str) -> tuple[int, ...]:
"""The per-format ordering hwmv2's nearest-lower match compares by
(extensions.cmake:1133-1152's VERSION_/STRGREATER/GREATER split):
`number` and `major.minor.patch` compare numerically component by
component; `letter` is a single character, so its ordinal already
matches STRGREATER's lexicographic order. Callers only ever pass an
already format-validated value."""
if fmt == "number":
return (int(value),)
if fmt == "major.minor.patch":
return tuple(int(p) for p in value.split("."))
return (ord(value),)
def _nearest_lower(fmt: str, declared: list[str], candidate: str) -> str | None:
"""The highest declared revision <= candidate, per-format compared --
hwmv2's nearest-lower match (extensions.cmake:1133-1152). None when
every declared revision is greater than candidate (no fallback
exists); declared values are read-only."""
candidate_key = _revision_sort_key(fmt, candidate)
best: str | None = None
best_key: tuple[int, ...] | None = None
for value in declared:
key = _revision_sort_key(fmt, value)
if key <= candidate_key and (best_key is None or key > best_key):
best, best_key = value, key
return best
def parse_variant_decl(
container_v: Val,
key: str = "variants",
owner: str = "rig",
) -> tuple[AxisDecl | None, list[Diagnostic]]:
"""A rig's `variants:` declaration block: {default:, list: []}.
Absent key -> no axis declared (None, no diagnostics). `list:` must be
non-empty and `default:` (if given) must be one of its own members --
both lang-schema, since they are defects of the declaring FILE, not of
a particular selection.
Each list entry is either a bare name, or a mapping {name:} -- ONLY
name: is read off a mapping entry. A stray board:/sockets: key on an
entry is silently ignored, the same as an unrecognized key anywhere
else in this grammar: rig.yml has had no board:/sockets: grammar
since the board left it entirely, and nothing downstream has anywhere
to put such a value.
Returns (decl, diagnostics): the parsed declaration, or None when the
key is absent or its shape was rejected -- the diagnostics distinguish
the two."""
axis_v = container_v.value.get(key)
if axis_v is None:
return None, []
diags: list[Diagnostic] = []
axis_map = axis_v.value if isinstance(axis_v.value, dict) else {}
list_v = axis_map.get("list")
values: list[str] = []
for item_v in list_v.value if list_v is not None else []:
if isinstance(item_v.value, dict):
name_v, d = require(item_v, "name", f"{owner} {key} entry")
diags += d
if name_v is None:
continue
values.append(str(name_v.value))
else:
values.append(str(item_v.value))
if not values:
diags.append(
error("lang-schema", f"{owner} {key}: 'list' must be a non-empty list", (axis_v.src,))
)
return None, diags
default_v = axis_map.get("default")
if default_v is None:
return AxisDecl(values=values), diags
default = str(default_v.value)
if default not in values:
diags.append(
error(
"lang-schema",
f"{owner} {key}: default '{default}' is not one of the declared "
f"values ({', '.join(values)})",
(default_v.src,),
)
)
return None, diags
return AxisDecl(values=values, default=default), diags
def _parse_revision_entries(
list_v: Val | None, fmt: str, key: str, owner: str
) -> tuple[list[str], list[Diagnostic]]:
"""Each `revisions:` list entry: a mapping {name:} whose name: is a
quoted string matching `fmt`'s own pattern. An unquoted numeric-
looking id is rejected rather than coerced (it is exactly the value
a `major.minor.patch` axis invites an author to type, and YAML would
silently read it as a number); a name that does not match `fmt` is
likewise rejected here, since a malformed DECLARED name is this
file's own defect.
Returns (values, diagnostics): only the entries that passed every
check, in list order."""
diags: list[Diagnostic] = []
values: list[str] = []
for item_v in list_v.value if list_v is not None else []:
if not isinstance(item_v.value, dict):
diags.append(
error(
"lang-schema",
f"{owner} {key}: a revisions: entry must be a mapping "
"{name:} -- this axis takes no bare scalar entries",
(item_v.src,),
)
)
continue
name_v, d = require(item_v, "name", f"{owner} {key} entry")
diags += d
if name_v is None:
continue
if not isinstance(name_v.value, str):
diags.append(
error(
"lang-schema",
f"{owner} {key}: revision id {name_v.value!r} must be a "
"quoted string -- an unquoted id can parse as a YAML "
"number and silently change value",
(name_v.src,),
)
)
continue
name = name_v.value
if not _format_matches(fmt, name):
diags.append(
error(
"lang-schema",
f"{owner} {key}: revision '{name}' does not match format "
f"{fmt!r} (expected {_FORMAT_DESCRIPTIONS[fmt]})",
(name_v.src,),
)
)
continue
values.append(name)
return values, diags
def parse_revision_decl(
container_v: Val,
key: str = "revision",
owner: str = "rig",
) -> tuple[AxisDecl | None, list[Diagnostic]]:
"""A rig.yml `revision:` declaration block (singular key): upstream's
own board.yml shape -- `format:` (required, one of letter/number/
major.minor.patch/custom), `default:`, optional `exact:`, and a
plural `revisions:` list of `{name:}` mappings. Absent key -> no axis
declared (None, no diagnostics). NOT used for shield.yml -- see
`parse_legacy_revision_decl`.
Every declared id must be a quoted STRING (upstream's `name:
{type: string}`) -- a non-string is rejected, never coerced, since an
unquoted numeric-looking id is exactly the value a `major.minor.patch`
axis invites an author to type. Each id is additionally validated
against `format:`'s own pattern here (board-schema.yaml's own
conditional block does the same for declared names, independent of
any particular selection) -- a malformed DECLARED name is a defect of
this file, `lang-schema`; a malformed REQUESTED value is instead
`resolve_axis_selection`'s concern, since it is a defect of the
SELECTION, not the declaration.
`format: custom` is accepted here (it is valid upstream YAML) and
rejected only in `resolve_axis_selection`, once the axis is actually
used -- see this module's own docstring.
Returns (decl, diagnostics): the parsed declaration, or None when the
key is absent or its shape was rejected -- the diagnostics distinguish
the two."""
axis_v = container_v.value.get(key)
if axis_v is None:
return None, []
diags: list[Diagnostic] = []
axis_map = axis_v.value if isinstance(axis_v.value, dict) else {}
format_v = axis_map.get("format")
if format_v is None:
diags.append(
error(
"lang-schema",
f"{owner} {key}: 'format' is required -- one of {', '.join(_ALL_REVISION_FORMATS)}",
(axis_v.src,),
)
)
return None, diags
fmt = format_v.value
if not isinstance(fmt, str) or fmt not in _ALL_REVISION_FORMATS:
diags.append(
error(
"lang-schema",
f"{owner} {key}: 'format' must be one of "
f"{', '.join(_ALL_REVISION_FORMATS)} (got {fmt!r})",
(format_v.src,),
)
)
return None, diags
list_v = axis_map.get("revisions")
values, entry_diags = _parse_revision_entries(list_v, fmt, key, owner)
diags += entry_diags
if not values:
diags.append(
error(
"lang-schema", f"{owner} {key}: 'revisions' must be a non-empty list", (axis_v.src,)
)
)
return None, diags
exact_v = axis_map.get("exact")
exact = bool(exact_v.value) if exact_v is not None else False
default_v = axis_map.get("default")
if default_v is None:
return AxisDecl(values=values, format=fmt, exact=exact), diags
if not isinstance(default_v.value, str):
diags.append(
error(
"lang-schema", f"{owner} {key}: 'default' must be a quoted string", (default_v.src,)
)
)
return None, diags
default = default_v.value
if default not in values:
diags.append(
error(
"lang-schema",
f"{owner} {key}: default '{default}' is not one of the declared "
f"revisions ({', '.join(values)})",
(default_v.src,),
)
)
return None, diags
return AxisDecl(values=values, default=default, format=fmt, exact=exact), diags
def parse_legacy_revision_decl(
container_v: Val,
key: str = "revisions",
owner: str = "rig",
) -> tuple[AxisDecl | None, list[Diagnostic]]:
"""shield.yml's `revisions:` declaration, kept in its PRE-hwmv2 shape
(`{default:, list: []}`, bare scalar ids only) -- NOT the hwmv2
`revision:` block `parse_revision_decl` gives rig.yml.
This is a hard external constraint, discovered by running a REAL
cmake configure against a migrated shield.yml, not a choice: the
PINNED zephyr tree carries its own schema for shield.yml
(`zephyr/scripts/schemas/shield-schema.yaml`, commit `8da5b3a0f60`,
"schemas: shield: allow a revisions: block in shield.yml"), which
`additionalProperties: false`-restricts a shield's `revisions:` block
to exactly `default:`/`list:` -- no `format:`, no `exact:`, no plural
`revisions:` sub-list. Zephyr's own `list_shields.py` validates EVERY
shield.yml under EVERY board root against that schema at ordinary
`find_package(Zephyr)` time, for every configure, whether or not the
shield in question is ever referenced -- so a `revision:`/`format:`
key on any shield.yml fails EVERY real build in the workspace, not
just ones naming that shield. zephyr/ is a separate module and not
ours to patch (a schema change there is its own, out-of-scope,
ruling), so shield.yml keeps this shape until it does.
Returns (decl, diagnostics): the parsed declaration (`format`/`exact`
always at their defaults, None/False -- `resolve_axis_selection`
reads `format is None` as "run no hwmv2 machinery, exact membership
only"), or None when the key is absent or its shape was rejected."""
axis_v = container_v.value.get(key)
if axis_v is None:
return None, []
diags: list[Diagnostic] = []
axis_map = axis_v.value if isinstance(axis_v.value, dict) else {}
list_v = axis_map.get("list")
values: list[str] = []
for item_v in list_v.value if list_v is not None else []:
if isinstance(item_v.value, dict):
diags.append(
error(
"lang-schema",
f"{owner} {key}: a mapping entry (name:) is legal only in "
"a rig's variants: list -- this axis takes bare names",
(item_v.src,),
)
)
continue
values.append(str(item_v.value))
if not values:
diags.append(
error("lang-schema", f"{owner} {key}: 'list' must be a non-empty list", (axis_v.src,))
)
return None, diags
default_v = axis_map.get("default")
if default_v is None:
return AxisDecl(values=values), diags
default = str(default_v.value)
if default not in values:
diags.append(
error(
"lang-schema",
f"{owner} {key}: default '{default}' is not one of the declared "
f"values ({', '.join(values)})",
(default_v.src,),
)
)
return None, diags
return AxisDecl(values=values, default=default), diags
def check_axis_collision(
rig_name: str, variants: AxisDecl | None, revisions: AxisDecl | None, src: SourceRef
) -> list[Diagnostic]:
"""The fragment-stem collision check: no two distinct (variant,
revision) SELECTIONS may construct the same fragment stem.
Enumerates every stem the declared axes could ever construct -- each
axis alone, plus every combined (variant, revision) pair -- and
reports every collision; a variant name equal to a revision id is
the single-axis case of this same collision.
Returns the collision findings, possibly empty; the declarations
are read-only."""
variant_values = variants.values if variants is not None else []
revision_values = revisions.values if revisions is not None else []
origins: dict[str, list[str]] = {}
def note(stem: str, origin: str) -> None:
origins.setdefault(stem, []).append(origin)
for v in variant_values:
note(f"{rig_name}_{v}", f"variant '{v}'")
for r in revision_values:
note(f"{rig_name}_{normalize_revision(r)}", f"revision '{r}'")
for v in variant_values:
for r in revision_values:
note(f"{rig_name}_{v}_{normalize_revision(r)}", f"variant '{v}' + revision '{r}'")
diags: list[Diagnostic] = []
for stem in sorted(origins):
stem_origins = origins[stem]
if len(stem_origins) > 1:
diags.append(
error(
"lang-variant",
f"rig '{rig_name}': {' and '.join(stem_origins)} all "
f"construct the same fragment stem '{stem}' -- the "
"constructed filenames would be ambiguous about which "
"selection a fragment belongs to",
(src,),
)
)
return diags
def _resolve_revision_selection(
owner_kind: str,
owner_name: str,
axis_kind: str,
code: str,
decl: AxisDecl,
selected: str | None,
src: SourceRef,
) -> tuple[str | None, list[Diagnostic]]:
"""The hwmv2 revision-resolution machinery (extensions.cmake:1048
family), reached only once `decl.format is not None`: `format:
custom` is rejected loudly the moment the axis is used at all, even
via its default, naming the three formats rigc implements. A
`selected` value gets hwmv2's own loose typing for
`major.minor.patch` (missing trailing components zero-appended)
before format validation and membership are checked; a value that
does not match the declared format at all is rejected. A value that
IS a declared member resolves to itself; otherwise, unless
`decl.exact`, hwmv2's nearest-lower match resolves DOWN to the
highest declared revision <= the requested one. An unselected axis
takes the declared default exactly like a non-hwmv2 axis does.
Returns (value, diagnostics): the RESOLVED axis value (nearest-lower
already applied where it applies), or None either legitimately (no
default, nothing selected -- reported) or after a reported failure.
`decl` is read-only."""
if decl.format not in _REVISION_PATTERNS:
supported = ", ".join(_REVISION_PATTERNS)
return None, [
error(
code,
f"{owner_kind} '{owner_name}' declares revision format "
f"{decl.format!r} -- rigc supports {supported} only "
"(format: custom is not implemented)",
(src,),
)
]
if selected is None:
if decl.default is not None:
return decl.default, []
return None, [
error(
code,
f"{owner_kind} '{owner_name}': no {axis_kind} selected, and "
f"this {owner_kind} declares no default {axis_kind} -- choose "
f"one of: {', '.join(decl.values)}",
(src,),
)
]
assert decl.format is not None # narrowed by the check above
candidate = _zero_append(decl.format, selected)
if not _format_matches(decl.format, candidate):
return None, [
error(
code,
f"{owner_kind} '{owner_name}': revision '{selected}' does "
f"not match this axis's declared format {decl.format!r} -- "
f"expected {_FORMAT_DESCRIPTIONS[decl.format]}",
(src,),
)
]
if candidate in decl.values:
return candidate, []
if not decl.exact:
lower = _nearest_lower(decl.format, decl.values, candidate)
if lower is not None:
return lower, []
return None, [
error(
code,
f"{owner_kind} '{owner_name}': revision '{candidate}' is not "
f"declared -- known revisions: {', '.join(decl.values)}",
(src,),
)
]
def resolve_axis_selection(
owner_kind: str,
owner_name: str,
axis_kind: str,
decl_key: str,
decl: AxisDecl | None,
selected: str | None,
src: SourceRef,
) -> tuple[str | None, list[Diagnostic]]:
"""The shared decision a qualifier-axis resolution makes, over values
-- shared by a rig's own axis resolution (`resolve_axis`, below) and
`ShieldLibrary.resolve` (`loader/library.py`).
A `selected` value naming an axis its owner does not declare AT ALL
says so by name ("this rig declares no revision:" / "this shield
declares no revision:") rather than the generic not-a-member wording
-- it points the author at the right place. A bare (unselected) axis
takes the declared default; if the axis is declared but has none,
that is an error.
For a REVISION axis (`axis_kind == "revision"`), `decl.format` governs
the rest of resolution, hwmv2-ported (extensions.cmake:1048 family):
`format: custom` is rejected loudly, naming the three formats rigc
implements, whenever this axis is used at all (even a bare reference
to its default) -- a declared-but-never-selected custom axis is still
"used" the moment a rig/shield resolves ITS OWN axis. A `selected`
value gets hwmv2's own loose typing for `major.minor.patch` (missing
trailing components zero-appended) BEFORE format validation and
membership are checked; a value that doesn't match the declared
format at all is rejected. A value that IS a declared member resolves
to itself. Otherwise, unless `decl.exact` is set, hwmv2's
nearest-lower match resolves DOWN to the highest declared revision
<= the requested one (per-format compared); `exact: true` disables
that, reproducing this axis's pre-hwmv2 behaviour (an undeclared
value is unconditionally fatal) bit for bit. A VARIANT axis
(`axis_kind == "variant"`) takes none of this: a selected value must
be an exact declared member.
`owner_kind` ("rig" or "shield") constructs both the quoted-name
prefix and the possessive ("this rig" / "this shield") -- the two
callers' wording differs in exactly that one word, nowhere else.
`axis_kind` ("revision" or "variant") and `decl_key` (the declaring
YAML key: "revision" or "variants") construct the rest; a shield
always passes ("shield", name, "revision", "revision"), since a
shield declares only that one axis. This function does no IO and
triggers no parsing -- callers that need to act on the RESOLVED value
(constructing a fragment filename, parsing a shield revision's
template) do that themselves, around this decision.
A revision axis whose declaration carries NO `format` at all
(`decl.format is None`) runs NONE of the hwmv2 machinery above --
exact membership only, same as a variant axis. This is not a
shortcut; it is what `parse_legacy_revision_decl` (this module)
always produces, and every shield.yml revision axis is parsed by
THAT function, never `parse_revision_decl` -- see its own docstring
for why. `decl.format` is therefore the discriminator this function
reads, not `owner_kind`: the day a shield.yml CAN carry `format:`,
nothing here needs to change.
Returns (value, diagnostics): the RESOLVED axis value (nearest-lower
already applied, when hwmv2 applies at all), or None either
legitimately (no axis declared and nothing selected) or after a
reported failure -- an error in the list is what distinguishes them.
`decl` is read-only to this function."""
code = "lang-rev" if axis_kind == "revision" else "lang-variant"
hwmv2 = axis_kind == "revision" and decl is not None and decl.format is not None
if hwmv2:
assert decl is not None
return _resolve_revision_selection(
owner_kind, owner_name, axis_kind, code, decl, selected, src
)
if selected is not None:
if decl is None:
return None, [
error(
code,
f"{owner_kind} '{owner_name}' names a {axis_kind} "
f"({selected!r}), but this {owner_kind} declares no "
f"{decl_key}: at all",
(src,),
)
]
if selected not in decl.values:
return None, [
error(
code,
f"{owner_kind} '{owner_name}': {axis_kind} '{selected}' "
f"is not declared -- known {axis_kind}s: "
f"{', '.join(decl.values)}",
(src,),
)
]
return selected, []
if decl is None:
return None, []
if decl.default is not None:
return decl.default, []
return None, [
error(
code,
f"{owner_kind} '{owner_name}': no {axis_kind} selected, and this "
f"{owner_kind} declares no default {axis_kind} -- choose one of: "
f"{', '.join(decl.values)}",
(src,),
)
]
def resolve_axis(
rig_name: str,
axis_kind: str,
decl_key: str,
decl: AxisDecl | None,
selected: str | None,
src: SourceRef,
) -> tuple[str | None, list[Diagnostic]]:
"""Resolve ONE of a rig's own qualifier axes (`revision` or
`variant`) to its final RESOLVED value -- the rig-owned instance of
`resolve_axis_selection`'s shared decision (`owner_kind="rig"`). For
a revision axis this may differ from `selected` (nearest-lower match,
or hwmv2's major.minor.patch zero-append) -- callers that need the
raw requested value for provenance keep it themselves (it is exactly
what they already passed in as `selected`).
Returns (value, diagnostics): the selected axis value, or None
either legitimately (no axis declared and nothing selected) or
after a reported failure -- an error in the list is what
distinguishes them."""
return resolve_axis_selection("rig", rig_name, axis_kind, decl_key, decl, selected, src)