validate-board-config: follow source ${SRC}/config/boards/<foo> inheritance

`Validate changed board configs` (PR #9747 CI run 25258999953) failed
on the three Ayn-Odin2 derivative boards with:

    ERROR: config/boards/ayn-odin2mini.csc: BOARDFAMILY: required, ...
    ERROR: config/boards/ayn-odin2mini.csc: KERNEL_TARGET: required, ...
    (same pair on ayn-odin2portal.csc and ayn-thor.csc)

The validator's header explicitly says it does NOT source the file
("boards have side-effecty function bodies"), and the regex
`parse_assignments` only sees top-level `KEY=value` lines. Boards
that consist of one `source "${SRC}/config/boards/<parent>.csc"` line
plus a handful of overrides (BOARD_NAME, BOARD_VENDOR, BOARD_MAINTAINER,
ARCH) inherit BOARDFAMILY / KERNEL_TARGET / KERNEL_TEST_TARGET from
the parent, so the validator saw them as missing and erred out.

The errors are pre-existing — they would fire on any future change
to those .csc files. PR #9747 just made them visible by being the
first PR to touch an inheriting board since the validator landed.

Add `collect_inherited_assignments`: when a top-level `source` line
points at another file under config/boards/ (matched by a regex
anchored to start-of-line so `source` calls inside function bodies —
which are always indented — aren't followed), parse that file's
top-level assignments and lay them behind the child's own. Child's
explicit values still win via dict.setdefault.

Recursion is guarded by a visited set keyed on resolved paths, so
a self-source or a parent-source-child cycle terminates instead of
looping. Missing source targets are silently skipped — the main
required-field checks will still flag any field that ends up unset
after the merge, so a typo'd source path doesn't mask a real gap.

Verified against the five PR-changed files: 6 errors → 0 errors,
4 warnings → 1 warning (the unmaintained-board warning on
aml-t95z-plus.tvb, which is unrelated to inheritance and was
already there). Self-contained boards (musepipro.conf etc.)
produce identical output to before this change.
This commit is contained in:
Igor Pecovnik
2026-05-02 20:56:34 +02:00
committed by Igor
parent e825695352
commit 3640a9d289
+64
View File
@@ -42,6 +42,16 @@ _ASSIGN_RE = re.compile(
re.MULTILINE, re.MULTILINE,
) )
# Top-level `source ${SRC}/config/boards/<foo>.csc` (or .conf/.tvb/.wip).
# Anchored to start-of-line so a `source` call inside a function body
# (which is always indented) doesn't get followed. Captures the relative
# path under config/boards/ so the validator can resolve it next to the
# child file being validated.
_SOURCE_RE = re.compile(
r'^source\s+["\']?\$\{?SRC\}?/config/boards/([^"\'\s]+\.(?:conf|csc|tvb|wip))["\']?',
re.MULTILINE,
)
@dataclass @dataclass
@@ -83,6 +93,56 @@ def parse_assignments(text: str) -> dict[str, str]:
return out return out
def collect_inherited_assignments(path: Path, _visited: set[Path] | None = None) -> dict[str, str]:
"""Resolve fields a board inherits via `source ${SRC}/config/boards/<foo>`.
Some boards (e.g. ayn-odin2{mini,portal}.csc, ayn-thor.csc) consist
of one `source` line pointing at a base .csc plus a handful of
overrides for the fields they actually change. Fields the child
doesn't redeclare — BOARDFAMILY, KERNEL_TARGET, KERNEL_TEST_TARGET
in the typical case — live in the sourced parent.
Returns a flat dict of effective fields the parent chain provides,
in the same shape parse_assignments() returns. Caller merges this
behind the child's own dict so the child's explicit values still
win.
Cycles (a sources b sources a) are guarded by the _visited set;
missing or unresolvable source targets are silently skipped — the
main validator will still flag any field that ends up unset.
"""
if _visited is None:
_visited = set()
try:
resolved = path.resolve()
except OSError:
return {}
if resolved in _visited:
return {}
_visited.add(resolved)
text = path.read_text(errors="replace")
inherited: dict[str, str] = {}
for m in _SOURCE_RE.finditer(text):
sourced = path.parent / m.group(1)
if not sourced.is_file():
continue
# Recurse first so transitive parents are merged behind the
# immediate parent's fields. Within a single chain, nearest
# parent's value should win over a more distant ancestor's,
# which falls out naturally from setdefault's "skip if set".
ancestors = collect_inherited_assignments(sourced, _visited)
parent_fields = parse_assignments(sourced.read_text(errors="replace"))
# Parent's own assignments win over its ancestors; merge in
# that order, then those become the inheritance pool the
# caller will lay behind the child.
for k, v in parent_fields.items():
inherited.setdefault(k, v)
for k, v in ancestors.items():
inherited.setdefault(k, v)
return inherited
def validate(path: Path) -> list[Finding]: def validate(path: Path) -> list[Finding]:
ext = path.suffix.lstrip(".") ext = path.suffix.lstrip(".")
if ext == "eos": if ext == "eos":
@@ -90,6 +150,10 @@ def validate(path: Path) -> list[Finding]:
text = path.read_text(errors="replace") text = path.read_text(errors="replace")
fields = parse_assignments(text) fields = parse_assignments(text)
# Layer fields the child inherits via `source ${SRC}/config/boards/<...>`
# behind its own — child's explicit value wins, parent fills the gaps.
for k, v in collect_inherited_assignments(path).items():
fields.setdefault(k, v)
findings: list[Finding] = [] findings: list[Finding] = []
fname = str(path) fname = str(path)