blob: 2c1ffc74491532b138c141cefcff035477a2a6a9 [file] [edit]
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""PR-diff -> CI selection: which rig boards and which tests a change can affect.
Lives in tools/ so it can serve both HIL selection and, from Task 3, build-family
selection. Stdlib-only (runs on bare CI runners; imports hil_util for the example
rosters, never hil_test/pyserial — test_hil_util.BottomLayer enforces the stdlib
closure). Fail-open: any file no rule classifies forces the full matrix. See
docs/superpowers/specs/2026-07-29-hil-pr-scoped-selection-design.md and
docs/superpowers/specs/2026-08-19-ci-build-family-filter-design.md.
JSON: full, boards (name -> 'all' | [tests]), families (bsp families the diff
touches, including ones with no rig board - build-only consumers
sample from these), args (hil_test.py args per config) and args_flasher (the same
args split by each board's flasher, for CI legs that split one rig by flasher).
THE RULE TABLE. First match wins; answers union per family (build) and per board
(HIL). A CARBON COPY of the table in the design spec above - edit both, or
TestRuleTableIsCarbonOfTheSpec fails. `FAM` = the families whose family.cmake
references the changed path (CMake only; make follows it). `DEV`/`HOST`/`DUAL`/
`TYPEC`/`ALL` are the example role sets. The Build families column is PRE-PRUNE:
_prune_buildable then intersects each family with what it can actually build.
| # | Changed path | Build families | Build examples | HIL boards → tests |
| 1 | `docs/`, `.claude/`, `*.md`, `*.rst`, `LICENSE` | — | — | — |
| 1b | `.gitignore`, `.clang-format`, `.agents`, `.codex/**`, `.idea/**`, `test/{fuzz,unit-test}/**`, `test/hil/test/**`, non-build `.github/**`, packaging manifests | — | — | — |
| 2 | `test/hil/**` (not `test/hil/test/**`) | — | — | all boards → all tests |
| 2b | `tools/metrics.py`, `tools/membrowse_compare.py`, `tools/membrowse_onboard.py`, `tools/drivers_coverage_check.py` | — (local-only tooling, no CI build runs it) | — | — (nothing on the rig runs it) |
| 2c | `.github/ci-pinned-boards.json` | `ALL` | `ALL` | — (CI board data; no rig board's behaviour depends on it) |
| 2d | `tools/membrowse_report.py` | `ALL` | `ALL` | — (build-time script invoked from family_support.cmake; no rig board runs it) |
| 3 | `src/portable/<port>/dcd_*`, `*_device.[ch]` | `FAM` | `DEV`+`DUAL` | `FAM`'s device-role boards → device+dual tests |
| 4 | `src/portable/<port>/hcd_*`, `*_host.[ch]` | `FAM` | `HOST`+`DUAL` | `FAM`'s host-role boards → host+dual tests |
| 5 | `src/portable/<port>/**` (anything else) | `FAM` | `ALL` | `FAM`'s boards → all their tests |
| 5b | `src/portable/<port>/**` where `FAM` is empty | — | — | — (empty resolves to nothing on BOTH axes) |
| 6 | `hw/bsp/<family>/**` | that family | `ALL` | that family's boards → all tests (a `boards/<board>/` path narrows to that board) |
| 7 | `hw/mcu/<vendor>/**` | `FAM` — empty resolves to nothing (maintainer ruling) | `ALL` | `FAM`'s boards → all tests; empty resolves to nothing (maintainer ruling) ⚠ *see below* |
| 8 | `src/class/<cls>/*_device.[ch]` | `ALL` | examples enabling `CFG_TUD_<CLS>` | device-role boards → HIL tests enabling `CFG_TUD_<CLS>` |
| 9 | `src/class/<cls>/*_host.[ch]` | `ALL` | examples enabling `CFG_TUH_<CLS>` | host-role boards → HIL tests enabling `CFG_TUH_<CLS>` |
| 10 | `src/class/<cls>/**` (shared header) | `ALL` | either, **plus include-edge classes** | both roles → same, plus include-edge classes |
| 11 | `src/device/**` | `ALL` | `DEV`+`DUAL` | device-role boards → device+dual tests |
| 12 | `src/host/**` | `ALL` | `HOST`+`DUAL` | host-role boards → host+dual tests |
| 12b | `src/typec/**` | `ALL` | examples enabling `CFG_TUC_ENABLED` | — (no rig board runs a typec test) |
| 13 | `examples/<role>/<name>/**` | `ALL` | just `<name>` | if `<name>` is a HIL test: all boards → that test; else nothing |
| 14 | `examples/device/board_test/**` | `ALL` | just `board_test` | all boards → all tests (HIL parking firmware) |
| 15 | `examples/build_system/**`, `examples/CMakeLists.txt`, `examples/<role>/CMakeLists.txt` | `ALL` | `ALL` | all boards → all tests |
| 16 | `src/common/`, `src/osal/`, `src/tusb.[ch]`, `src/tusb_option.h`, `tools/{build,build_utils,ci_select,family_json}.py`, `tools/cmake/**`, `src/CMakeLists.txt`, `src/tinyusb.mk`, `hw/bsp/{family_support.{cmake,mk},family_rules.mk,zephyr_board_aliases.cmake,board.c,board_api.h,ansi_escape.h}`, `.github/**`, `.circleci/**` | `ALL` | `ALL` | all boards → all tests |
| 16a | `lib/<name>/**` | `ALL` | examples whose own `CMakeLists.txt`/`Makefile` names `lib/<name>` | those examples that are HIL tests, on all boards; empty resolves to nothing |
| 16b | `tools/get_deps.py` | families whose `deps_mandatory`/`deps_optional` entries changed | `ALL` | those families' boards → all tests; a logic change, an `'all'` entry, no base content or a changed token naming no family → full |
| 17 | anything unclassified (no tracked file reaches this — TestNoTrackedFileIsUnclassified) | `ALL` | `ALL` | all boards → all tests (fail-open) |
"""
import argparse
import ast
import contextlib
import functools
import glob
import io
import json
import os
import re
import subprocess
import sys
# tools/ -> repo root is ONE level up. Guarded by TestModuleMove.test_repo_root_guard:
# a wrong parent count here silently re-points every repo-relative glob (it happened
# at the helper/ move).
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(_REPO_ROOT, 'test', 'hil')) # for `from helper...`
from helper.hil_util import device_tests, dual_tests, host_test
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # tools/, for build helpers
import build_utils
import build as build_py
ALL_TESTS = {'device': device_tests, 'dual': dual_tests, 'host': host_test}
# class dir -> config macro suffix exceptions (rule 3); dfu is per-file, handled inline
NET_MACROS = ('ECM_RNDIS', 'NCM')
def _read(path: str) -> str:
"""Read a source file with a fixed encoding. The locale's is not it: several tracked
sources carry non-ASCII bytes, and under LC_ALL=C the decode raises UnicodeDecodeError
- a ValueError, which every `except OSError` fail-open below would let through as a
traceback instead of a full matrix."""
with open(path, encoding='utf-8', errors='replace') as f:
return f.read()
_NONCODE_RE = re.compile(
# LICENSE is anchored and LICENSES/ named separately: a bare `LICENSE` alternative
# also swallowed anything merely STARTING with it (a future LICENSE_extra.c),
# which is the silent-under-selection direction
r'^(docs/|\.claude/|.*\.(md|rst)$|LICENSE$|LICENSES/)')
# Repo metadata and tooling that no CI build reads. Enumerated rather than left to
# rule 17, which widens BOTH axes: a PR touching only .gitignore and a README was
# creating 74 cmake legs (each a runner doing checkout + toolchain + get_deps before
# skipping the build) and booking the whole 30-board rig.
#
# Deliberately NOT here, and still full: .circleci/**, .github/workflows/build*.yml,
# .github/actions/**, .github/scripts/** - those decide what gets built. The line is
# "does any Build step read this file", not "is it source".
#
# test/{fuzz,unit-test} have their own jobs (cifuzz.yml, the unit-test pre-commit hook
# and workflow); the Build matrix never compiles them, and test/hil is rule 2.
_META_RE = re.compile(
r'^('
r'\.(gitignore|gitattributes|clang-format|codespellrc|readthedocs\.yaml)$|'
r'\.pre-commit-config\.yaml$|\.PVS-Studio/|\.agents$|\.codex/|\.idea/|\.vscode/|'
r'sonar-project\.properties$|library\.json$|pkg\.yml$|repository\.yml$|'
r'version\.yml$|SConscript$|'
r'.*CMakePresets\.json$|hw/bsp/BoardPresets\.json$|examples/west\.yml$|'
# what each board's default configure compiles: the build skill reads it and
# tools/build.py rewrites a row, no firmware build depends on it
r'hw/bsp/family\.json$|'
r'.*/[0-9]+-tinyusb[^/]*\.rules$|tools/usb_drivers/|tools/codespell/|'
# test/hil/test/ holds the harness's own unit tests, not the harness: nothing on
# the rig runs them (pre-commit does, and build.yml runs test_ci_select.py as the
# gate before trusting a selection), so they cannot change what the rig does.
# The harness itself stays under _FULL_RE's test/hil/ prefix.
r'test/(fuzz|unit-test)/|test/hil/test/|'
# .github, minus the build machinery named in _FULL_RE and _CI_BOARDS_RE
r'\.github/(FUNDING\.yml$|labeler\.yml$|membrowse_pr_message\.j2$|'
r'ISSUE_TEMPLATE/|'
r'workflows/(cifuzz|claude|claude-code-review|labeler|membrowse-comment|'
r'pr_comment|pre-commit|static_analysis|trigger)\.yml$)|'
# tools/ scripts no build invokes (tools/build*.py and metrics are handled above)
r'tools/(check_example_pids|file2carray|iar_gen|mksunxi|pcapng_to_corpus)\.py$|'
r'tools/iar_template\.ipcf$'
r')')
# Local-only size/coverage tooling: no CI build and no rig board runs it (the
# drivers-coverage checker is pre-commit only), so no contribution on either axis.
_METRICS_RE = re.compile(
r'^tools/(metrics[^/]*|membrowse_compare|membrowse_onboard|drivers_coverage_check)\.py$')
# CI board data (tools/build.py --ci-pinned-boards): it decides which boards a family's
# build legs compile, so a bad edit can silently drop a family - full build matrix. No
# rig board depends on it.
_CI_BOARDS_RE = re.compile(r'^\.github/ci-pinned-boards\.json$')
# Run by family_add_membrowse() for every family with a pinned board: which family it
# breaks is data, not code, so full build matrix as rule 2c. No rig board runs it.
_MEMBROWSE_SCRIPT_RE = re.compile(r'^tools/membrowse_report\.py$')
_FULL_RE = re.compile(
r'^(src/common/|src/osal/|src/tusb\.c$|src/tusb\.h$|src/tusb_option\.h$|'
# tools/rtt.py is part of the harness, not a standalone tool: hil_util imports it
# at module load, so a break in it breaks every rig run the same way a test/hil/
# edit can (the pre-commit hil-test hook runs its unit tests for the same reason)
r'test/hil/|tools/rtt\.py$|'
r'\.github/workflows/build.*\.yml$|\.github/actions/|\.github/scripts/|'
# generates the whole CircleCI matrix, same authority as .github/**
r'\.circleci/|'
# rule 16 says `tools/build*.py`; name the siblings the glob implies. build_utils
# and ci_select decide what gets built, so neither can be trusted to narrow its own
# change; family_json runs inside build.py after every default configure, so a
# break in it fails the build the same way
r'tools/(build|build_utils|ci_select|family_json)\.py$|tools/cmake/|'
# the make twins of family_support.cmake are the same authority for the make legs
r'hw/bsp/(family_support\.(cmake|mk)|family_rules\.mk|zephyr_board_aliases\.cmake|'
r'board_api\.h|board\.c|ansi_escape\.h)$|'
# rule 15 lists examples/<role>/CMakeLists.txt - it registers every target in that
# role, so it was only ever reaching `full` through rule 17's fall-through
r'examples/build_system/|examples/CMakeLists\.txt$|'
r'examples/[^/]+/CMakeLists\.txt$|'
# every firmware compiles these unconditionally (src/CMakeLists.txt, src/tinyusb.mk)
r'src/CMakeLists\.txt$|src/tinyusb\.mk$|'
# board_test is HIL infrastructure, not a test: hil_test.py flashes it to park
# every board (variant boundary + end-of-board teardown), so every board depends on it
r'examples/device/board_test/)')
# --no-renames: with rename detection git reports only a rename's destination, so code
# moved out of an HIL-relevant path would be classified by its new path alone
GIT_DIFF_ARGV = ['git', 'diff', '--no-renames', '--name-only']
def test_role(test: str) -> str:
return test.split('/', 1)[0] # 'device' | 'dual' | 'host'
def board_roles(board: dict) -> set:
t = board.get('tests', {})
roles = set()
if t.get('device'):
roles.add('device')
if t.get('host'):
roles.add('host')
if t.get('dual'):
roles.update(('device', 'host'))
for only in t.get('only', []):
r = test_role(only)
roles.update(('device', 'host') if r == 'dual' else (r,))
return roles
def board_tests(board: dict) -> list:
"""Every test this board would run today (mirrors hil_test.test_board's default)."""
t = board.get('tests', {})
if 'only' in t:
run = list(t['only'])
else:
run = []
if t.get('device'):
run += device_tests
if t.get('dual'):
run += dual_tests
if t.get('host'):
run += host_test
return [x for x in run if x not in t.get('skip', [])]
def _rg(repo_root: str, *parts: str) -> str:
"""A glob pattern rooted at repo_root, with the ROOT escaped and the parts left as
patterns. The root is a filesystem path, not a pattern: a checkout at
/w/pr[1]/tinyusb (a worktree named after a PR, a CI workspace with brackets) makes
an unescaped '[1]' a character class that matches nothing, and every lookup below
then resolves to zero - families=0 instead of 30, i.e. the selector fails CLOSED
and the whole matrix compiles nothing while reporting green."""
return os.path.join(glob.escape(repo_root), *parts)
# cached: called per changed file x roster board, and the tree doesn't change mid-run
@functools.lru_cache(maxsize=None)
def board_family(board_name: str, repo_root: str):
hits = glob.glob(_rg(repo_root, 'hw/bsp/*/boards', board_name))
return os.path.basename(os.path.dirname(os.path.dirname(hits[0]))) if hits else None
# `if (OPTION STREQUAL "1")` guards in family_support.cmake, and the option tokens
# a roster entry passes to the build (NAME=VALUE / -DNAME=VALUE)
_CM_IF_RE = re.compile(r'if\s*\(')
_CM_ELSE_RE = re.compile(r'else(if)?\s*\(')
_CM_ENDIF_RE = re.compile(r'endif\s*\(')
_CM_OPT_RE = re.compile(r'if\s*\(\s*\$?\{?([A-Za-z_]\w*)\}?\s+STREQUAL\s+"?1"?\s*\)')
_CM_PORT_RE = re.compile(r'src/portable/((?:[^/\s]+/)?[^/\s]+)/')
_FALSY = ('', '0', 'off', 'false', 'no')
@functools.lru_cache(maxsize=None)
def port_option_gates(repo_root: str) -> dict:
"""port dir -> build options that compile it regardless of the board's family
file, e.g. {'analog/max3421': {'MAX3421_HOST'}} from family_support.cmake."""
gates = {}
try:
text = _read(os.path.join(repo_root, 'hw/bsp/family_support.cmake'))
except OSError:
return gates
stack = [] # one entry per open if(): its option, or None
for line in text.splitlines():
line = line.strip()
if _CM_IF_RE.match(line):
m = _CM_OPT_RE.match(line)
stack.append(m.group(1) if m else None)
elif _CM_ELSE_RE.match(line):
if stack:
stack[-1] = None # the guard doesn't hold in this branch
elif _CM_ENDIF_RE.match(line):
if stack:
stack.pop()
opts = {o for o in stack if o}
m = _CM_PORT_RE.search(line)
if opts and m:
gates.setdefault(m.group(1), set()).update(opts)
return gates
_CM_SET_RE = re.compile(r'set\s*\(\s*([A-Za-z_]\w*)\s+([^)\s]+)\s*\)')
# cached: called per changed portable file x roster board
@functools.lru_cache(maxsize=None)
def bsp_board_options(board_name: str, repo_root: str) -> frozenset:
"""Build options a board turns on in its own BSP: `set(<OPT> <value>)` in
hw/bsp/<family>/boards/<board>/board.cmake, e.g. MAX3421_HOST on the espressif
and rp2040 max3421 boards. CMake only - HIL CI builds nothing with Make, so a
board.mk-only option (e.g. nrf5340dk's MAX3421_HOST) compiles no port here."""
fam = board_family(board_name, repo_root)
if not fam:
return frozenset()
path = os.path.join(repo_root, 'hw/bsp', fam, 'boards', board_name, 'board.cmake')
try:
text = _read(path)
except OSError:
return frozenset()
out = set()
for line in text.splitlines():
line = line.strip()
if line.startswith('#'):
continue
m = _CM_SET_RE.match(line)
if m and m.group(2).strip('"').lower() not in _FALSY:
out.add(m.group(1))
return frozenset(out)
def board_options(board: dict, repo_root: str) -> set:
"""Build options a board has truthy: each variant's defines (NAME=VALUE) and raw
CFLAGS (-DNAME=VALUE), plus whatever its own board.cmake sets (a board can enable a
gated port without the roster saying so). A board whose option is always on carries
a single variant named after itself - metro_m4_express and MAX3421_HOST=1, which is
what makes it the one rig board that compiles hcd_max3421.c."""
toks = []
for v in board.get('variant', []):
toks += list(v.get('defines', []))
toks += v.get('flags', '').split()
out = set(bsp_board_options(board['name'], repo_root))
for t in toks:
name, _, val = (t[2:] if t.startswith('-D') else t).partition('=')
if name and val.strip().strip('"').lower() not in _FALSY:
out.add(name.strip())
return out
@functools.lru_cache(maxsize=None)
def path_families(rel_dir: str, repo_root: str) -> set:
"""Board families whose family.cmake (or espressif component CMakeLists)
references rel_dir at a directory boundary. CMake only, on every axis: CMake
is the first-class build system and Make follows it, so family.mk is never
read - a port wired up in family.mk alone (microchip/pic32mz) is built by no
CI job and resolves to nothing. Boundary = '/', whitespace, quote, paren,
brace or end: `${TOP}/hw/mcu/nordic/nrfx` has no trailing slash, while bare
'microchip/pic' must not match '.../microchip/pic32mz/...'."""
pat = re.compile(re.escape(rel_dir) + r'(?=[/\s"\')}]|$)', re.M)
return {fam for fam, text in _family_file_texts(repo_root) if pat.search(text)}
@functools.lru_cache(maxsize=None)
def _family_file_texts(repo_root: str) -> tuple:
"""((family, text), ...) for every family.cmake and espressif component
CMakeLists.txt, read once. path_families is called per distinct directory in the
diff and its own cache only helps repeats: a 6,000-file hw/mcu dep bump re-read
these 84 files 99,892 times (2.2 s) before this."""
bsp_root = os.path.join(repo_root, 'hw/bsp') # escaped by _rg below
out = []
for f in sorted(glob.glob(_rg(bsp_root, '*/family.cmake')) +
glob.glob(_rg(bsp_root, '*/components/*/CMakeLists.txt'))):
try:
out.append((os.path.relpath(f, bsp_root).split(os.sep, 1)[0], _read(f)))
except OSError:
pass
return tuple(out)
def port_families(port_dir: str, repo_root: str) -> set:
# 'portable/', not 'src/portable/': family.cmake always spells the full literal
# path ('${TOP}/src/portable/...'), but espressif's component CMakeLists.txt
# assigns 'src' into a ${tusb_src} variable first (`${tusb_src}/portable/...`),
# so a leading 'src/' in the needle would never match there and silently drop
# espressif boards (see TestRealRosterPortFamilies).
return path_families('portable/' + port_dir, repo_root)
def mcu_families(path: str, repo_root: str) -> set:
"""Families referencing a changed hw/mcu path: longest resolving dir prefix,
hw/mcu/<vendor>/<sub>/... down to hw/mcu/<vendor>."""
parts = path.split('/')
for n in range(len(parts) - 1, 2, -1):
fams = path_families('/'.join(parts[:n]), repo_root)
if fams:
return fams
return set()
GET_DEPS_PATH = 'tools/get_deps.py'
_DEPS_DICTS = ('deps_mandatory', 'deps_optional')
def _deps_split(text: str):
"""(module dump with the two dep-dict assigns removed, {dict name: entries}).
Parsed with ast, never exec'd: this runs on PR content."""
mod = ast.parse(text)
dicts, rest = {}, []
for node in mod.body:
if (isinstance(node, ast.Assign) and len(node.targets) == 1 and
isinstance(node.targets[0], ast.Name) and
node.targets[0].id in _DEPS_DICTS and isinstance(node.value, ast.Dict)):
dicts[node.targets[0].id] = ast.literal_eval(node.value)
else:
rest.append(node)
mod.body = rest
# annotate_fields=False keeps the dump readable-length; line numbers are not
# included unless asked for, so reformatting alone never reads as a logic change
return ast.dump(mod, annotate_fields=False), dicts
# Family tokens in tools/get_deps.py that name no hw/bsp directory. get_deps matches a
# token against a requested family name verbatim (`f in deps_optional[d][2].split()`),
# so a token like these matches nothing - a stale spelling in get_deps.py, not a
# selector bug, and out of scope to change here. Pinned so that any OTHER unresolvable
# token (real drift) falls open to the full matrix instead of silently selecting
# nothing, and so TestOrphanInvariant fails the day one is fixed or a new one appears.
# sam3x, samd21, samd51, same5x -> pre-rename spellings, listed alongside the current
# samd2x_l2x / samd5x_e5x / same7x in the same entry
# stm32l1, stm32l5 -> no hw/bsp family in the tree at all
_DEPS_ALIAS_TOKENS = frozenset({'sam3x', 'samd21', 'samd51', 'same5x',
'stm32l1', 'stm32l5'})
def get_deps_changed_families(base_text: str, head_text: str, repo_root: str):
"""Families whose tools/get_deps.py dep entries changed between two versions of
the file, or None meaning 'cannot tell - use the full matrix'.
None on: anything outside deps_mandatory/deps_optional differing (a logic change
to get_deps affects every family), a mandatory `'all'` entry changing, a token
that resolves to no family and is not a known alias, or text that will not parse.
Callers with no base content at all - `--diff-file` mode has no git and therefore
no merge-base blob - pass None themselves.
An entry that is added, removed or edited contributes the family tokens of BOTH
sides (a removed entry has only a base side). The two dicts are diffed SEPARATELY:
merging them first would hide a move between deps_mandatory and deps_optional,
which changes which families fetch the dep even though the value is untouched."""
try:
base_rest, base_d = _deps_split(base_text)
head_rest, head_d = _deps_split(head_text)
except (SyntaxError, ValueError, TypeError):
return None
if base_rest != head_rest:
return None
toks = set()
for name in _DEPS_DICTS:
base_x, head_x = base_d.get(name, {}), head_d.get(name, {})
for key in set(base_x) | set(head_x):
if base_x.get(key) == head_x.get(key):
continue
for entry in (base_x.get(key), head_x.get(key)):
if entry and len(entry) > 2:
toks.update(str(entry[2]).split())
if 'all' in toks:
return None
fams = set(all_bsp_families(repo_root))
if toks - fams - _DEPS_ALIAS_TOKENS:
# a changed entry we cannot map to a family. "changed but unmappable" is NOT
# "nothing changed": reading it as the latter empties the entire build matrix
# for a dep bump, so fall open instead
return None
return toks & fams
_CLS_INC_RE = re.compile(r'#\s*include\s*[<"]class/([^/"<>]+)/([^"<>]+)[">]')
@functools.lru_cache(maxsize=None)
def class_include_edges(repo_root: str) -> dict:
"""'<class>/<header>' -> the other class dirs that include it. A class header
pulled in by a second class ships in every firmware enabling that second class:
src/class/midi/midi{,2}_{device,host}.h include class/audio/audio.h, and
net_device.h includes class/cdc/cdc.h. The class rule derives macros from the
directory name alone, so without this edge a change to the included header
selects only its own class's examples - and on a board that skips those (e.g.
metro_m4_express skips audio_test_freertos), nothing at all.
Derived from the actual #include lines rather than a hand-written table so it
cannot rot when a class picks up or drops a cross-class include."""
edges = {}
for f in sorted(glob.glob(_rg(repo_root, 'src/class/*/*.[ch]'))):
cls = os.path.basename(os.path.dirname(f))
try:
text = _read(f)
except OSError:
continue
for inc_cls, inc_hdr in _CLS_INC_RE.findall(text):
if inc_cls != cls:
edges.setdefault(f'{inc_cls}/{inc_hdr}', set()).add(cls)
return edges
_CLS_STEM_RE = re.compile(r'(.*?)(?:_(?:device|host))?\.[ch]$')
def class_macros(cls: str, base: str, prefix: str) -> list:
"""Config macros that compile a class dir's code, for role prefix TUD/TUH.
`base` refines dfu (it splits DFU from DFU_RUNTIME per file) and adds the file's
own macro where that differs from the directory's; pass '' for a class reached
through an include edge, where the widest set is correct."""
if cls == 'net':
return [f'CFG_{prefix}_{m}' for m in NET_MACROS]
if cls == 'dfu':
if base.startswith('dfu_rt'):
return [f'CFG_{prefix}_DFU_RUNTIME']
if base.startswith('dfu_device') or base.startswith('dfu_host'):
return [f'CFG_{prefix}_DFU']
return [f'CFG_{prefix}_DFU', f'CFG_{prefix}_DFU_RUNTIME']
out = [f'CFG_{prefix}_{cls.upper()}']
# A class directory can hold more than one class. src/class/midi ships MIDI 1.0
# AND MIDI 2.0: midi2_device.c is `#if CFG_TUD_ENABLED && CFG_TUD_MIDI2`, and
# examples/device/midi2_device is the only example that enables it - so the
# directory macro alone selected the midi_test examples, which do not compile the
# changed file, and none of the ones that do. Union, never replace: the file may
# still be pulled in by the directory's own macro, and over-selecting costs a build
# while under-selecting merges a break.
m = _CLS_STEM_RE.match(base)
if m and m.group(1) and m.group(1) != cls:
out.append(f'CFG_{prefix}_{m.group(1).upper()}')
return out
# A define is OFF only when its value is a literal zero (0, 00, (0)), optionally
# followed by a comment. Anything else counts as ON - including a value this cannot
# evaluate, e.g. `#define CFG_TUH_MIDI CFG_TUH_DEVICE_MAX` (examples/host/midi_rx).
# Fail-open: reading such a define as OFF made midi_host.c select zero families and
# let a compile break merge green.
#
# A macro defined more than once is ON if ANY of its defines is non-zero, because
# the preprocessor branches are not evaluated here: uac2_speaker_fb defines
# CFG_TUD_HID 1 under `#if CFG_AUDIO_DEBUG` and 0 in the #else, and the default
# build (CFG_AUDIO_DEBUG defaults to 1) compiles the HID class in. Deciding on the
# LAST/only match found made that example invisible to CFG_TUD_HID changes.
_DEF_VALUE = r'^[ \t]*#[ \t]*define[ \t]+{}[ \t]+(\S[^\n]*?)[ \t]*$'
_DEF_ZERO_VALUE = re.compile(r'\(?\s*0+\s*\)?\s*(?://.*|/\*.*)?')
# Shared rule-recognition primitives. The two classifiers walk the same diff with
# different answers, but they must RECOGNISE the same things: one copy each, so a
# new naming convention cannot land in one walk and be missed by the other.
_PORT_PATH_RE = re.compile(r'src/portable/((?:[^/]+/)?[^/]+)/')
def _port_roles(base: str) -> set:
"""Which USB role a src/portable file serves, from its name: dcd_*/ *_device is
the device-controller side, hcd_*/ *_host the host side, anything else (shared
headers, glue) both."""
if re.match(r'(dcd_|.*_device)', base):
return {'device'}
if re.match(r'(hcd_|.*_host)', base):
return {'host'}
return {'device', 'host'}
def _class_roles(base: str) -> set:
"""Same question for a src/class file: <cls>_device.[ch] / <cls>_host.[ch],
else both - the class's shared header ships in either role."""
if re.search(r'_device\.[ch]$', base):
return {'device'}
if re.search(r'_host\.[ch]$', base):
return {'host'}
return {'device', 'host'}
@functools.lru_cache(maxsize=None)
def _config_text(cfg_path: str) -> str:
"""An example's tusb_config.h, read once. Every class path re-asks the same 46
configs on both axes, so the reads go up with the diff: 4,240 of the same 46 files
for a diff touching all of src/class (0.48s -> 0.13s), and they cannot change
mid-run. Cached here rather than on _config_enables so the macros argument stays an
ordinary list at every call site."""
try:
with open(cfg_path, encoding='utf-8', errors='replace') as f:
return f.read()
except OSError:
return ''
def _config_enables(cfg_path: str, macros) -> bool:
text = _config_text(cfg_path)
if not text:
return False
for m in macros:
for value in re.findall(_DEF_VALUE.format(m), text, re.M):
if not _DEF_ZERO_VALUE.fullmatch(value):
return True
return False
def examples_enabling(pool, macros, repo_root: str) -> set:
"""The 'role/name' entries of `pool` whose src/tusb_config.h turns any of
`macros` on. The pool differs per classifier (HIL test lists vs every example),
the question does not."""
return {ex for ex in pool
if _config_enables(os.path.join(repo_root, 'examples', ex, 'src',
'tusb_config.h'), macros)}
# cached: called per changed lib file, and the tree doesn't change mid-run
@functools.lru_cache(maxsize=None)
def lib_examples(lib_name: str, repo_root: str) -> set:
"""Examples whose OWN examples/<role>/<name>/{CMakeLists.txt,Makefile} references
lib/<lib_name> at a directory boundary (same boundary rule as path_families, so
'lib/net' cannot inherit lib/networking's example).
Per-example on purpose: lib/SEGGER_RTT is named by family_support.cmake's
LOGGER=rtt plumbing, which no CI example build turns on (all three references -
family_support.cmake, family_support.mk, rp2040/family.cmake - sit inside a
LOGGER=rtt guard), so a family-file scan would wrongly narrow it to three families
instead of answering 'nobody'.
The whole example TREE is scanned, not just its top-level files: examples/host/
msc_file_explorer_freertos/src/CMakeLists.txt names lib/embedded-cli, and that
example survived only because its top-level file happens to name it too."""
pat = re.compile(re.escape('lib/' + lib_name) + r'(?=[/\s"\')}]|$)', re.M)
out = set()
for ex in all_examples(repo_root):
# the two filenames directly: '**/*' enumerated 489 entries per lib against a
# clean tree to use 107, and grows without bound once `make BOARD=... all` has
# written examples/<role>/<name>/_build/ - which is where a local build runs
for f in sorted(glob.glob(_rg(repo_root, 'examples', ex, '**', 'CMakeLists.txt'),
recursive=True) +
glob.glob(_rg(repo_root, 'examples', ex, '**', 'Makefile'),
recursive=True)):
try:
text = _read(f)
except OSError:
continue
if pat.search(text):
out.add(ex)
break
return out
def roster_only_tests(all_boards) -> set:
"""Test paths that only appear in a roster board's tests.only list (e.g.
espressif boards), not in the shared device/dual/host_test lists."""
out = set()
for b in all_boards:
out.update(b.get('tests', {}).get('only', []))
return out
def class_examples(macros, role: str, repo_root: str, extra_tests: set) -> set:
"""Tests (from role's + dual lists, plus roster-only-list tests of that role)
whose example config enables any macro."""
return examples_enabling(role_tests({role}, extra_tests), macros, repo_root)
def role_tests(roles: set, extras: set) -> set:
"""Every test for the given role(s): each role's own list + dual tests,
plus roster-only-list tests (extras) matching those roles or 'dual'."""
pool = set(dual_tests)
for r in roles:
pool |= set(ALL_TESTS[r])
pool |= {t for t in extras if test_role(t) in roles or test_role(t) == 'dual'}
return pool
class _Sel:
"""Accumulates contributions. board->set(tests) plus 'all-board' markers."""
def __init__(self):
self.full = False
self.by_board = {} # name -> set of tests, or 'all'
self.roles = set() # roles touched by any contribution
self.families = set() # bsp families touched (incl. off-rig ones: build-only consumers)
self.reasons = []
def add(self, boards, tests, reason):
"""tests: 'all' or iterable of test paths."""
self.reasons.append(reason)
for b in boards:
cur = self.by_board.get(b)
if tests == 'all' or cur == 'all':
self.by_board[b] = 'all'
else:
self.by_board[b] = (cur or set()) | set(tests)
def force_full(self, reason):
self.full = True
self.reasons.append(reason)
def _classify_one(path, repo_root, roster_boards, extras: set, s: _Sel,
get_deps_families=None):
base = os.path.basename(path)
if _NONCODE_RE.match(path) or _META_RE.match(path):
s.reasons.append(f'{path}: non-code, no contribution')
return
if _METRICS_RE.match(path): # rule 2b
s.reasons.append(f'{path}: build-size metrics tooling, no HIL contribution')
return
if _CI_BOARDS_RE.match(path): # rule 2c
s.reasons.append(f'{path}: CI board data, no HIL contribution')
return
if _MEMBROWSE_SCRIPT_RE.match(path): # rule 2d
s.reasons.append(f'{path}: membrowse build-time script, no HIL contribution')
return
if _FULL_RE.match(path):
s.force_full(f'{path}: core/infra -> full matrix')
return
if path == GET_DEPS_PATH:
if get_deps_families is None:
s.force_full(f'{path}: dep changes not resolvable -> full matrix')
return
if not get_deps_families:
s.reasons.append(f'{path}: no dep entry changed, no contribution')
return
fams = sorted(get_deps_families)
s.families.update(fams)
boards = [b['name'] for b in roster_boards
if board_family(b['name'], repo_root) in get_deps_families]
s.roles.update(('device', 'host'))
s.add(boards, 'all', f'{path}: dep entries changed -> families {fams} -> '
f'boards {boards}')
return
m = _PORT_PATH_RE.match(path)
if m:
port = m.group(1)
roles = _port_roles(base)
fams = port_families(port, repo_root)
if not fams:
# empty means empty (maintainer ruling), same reading as hw/mcu and as the
# build walk: no family's build references this port, so nothing compiles it
# and there is nothing to run. Forcing the full 30-board rig here bought no
# coverage at all - the build side selected zero families for the same path.
# Live for src/portable/template and the two microchip pic ports;
# TestPortFamiliesCoverage is the drift guard for a port that stops resolving.
s.reasons.append(f'{path}: port {port} maps to no board family, no contribution')
return
s.families.update(fams)
# a board can also pull the port in through a build option (e.g. MAX3421_HOST=1
# from the roster on metro_m4_express, or from its own board.cmake), which its
# family file never names
gates = port_option_gates(repo_root).get(port, set())
boards = [b['name'] for b in roster_boards
if (board_family(b['name'], repo_root) in fams or
(gates and board_options(b, repo_root) & gates)) and (board_roles(b) & roles)]
tests = role_tests(roles, extras)
s.roles.update(roles)
why = f'{path}: port {port} -> families {sorted(fams)}'
if gates:
why += f' + option {sorted(gates)}'
s.add(boards, tests, f'{why} -> boards {boards} ({"/".join(sorted(roles))})')
return
m = re.match(r'src/class/([^/]+)/', path)
if m:
cls = m.group(1)
roles = _class_roles(base)
# this file's own class, plus any class whose headers include it
via = sorted(class_include_edges(repo_root).get(f'{cls}/{base}', ()))
def macros(prefix):
return (class_macros(cls, base, prefix) +
[m2 for c in via for m2 in class_macros(c, '', prefix)])
tests = set()
if 'device' in roles:
tests |= class_examples(macros('TUD'), 'device', repo_root, extras)
if 'host' in roles:
tests |= class_examples(macros('TUH'), 'host', repo_root, extras)
boards = [b['name'] for b in roster_boards if board_roles(b) & roles]
s.roles.update(roles)
why = f'{path}: class {cls}' + (f' (+ included by {via})' if via else '')
s.add(boards, tests, f'{why} -> {sorted(tests)} ({"/".join(sorted(roles))})')
return
m = re.match(r'src/(device|host)/', path)
if m:
role = m.group(1)
boards = [b['name'] for b in roster_boards if role in board_roles(b)]
s.roles.add(role)
s.add(boards, role_tests({role}, extras), f'{path}: core {role} stack -> all {role} tests')
return
m = re.match(r'hw/bsp/([^/]+)/(?:boards/([^/]+)/)?', path)
if m:
fam, brd = m.group(1), m.group(2)
s.families.add(fam)
if brd:
boards = [b['name'] for b in roster_boards if b['name'] == brd]
why = f'{path}: bsp board {brd}'
else:
boards = [b['name'] for b in roster_boards
if board_family(b['name'], repo_root) == fam]
why = f'{path}: bsp family {fam}'
s.roles.update(('device', 'host'))
s.add(boards, 'all', f'{why} -> boards {boards}')
return
if re.match(r'hw/mcu/', path):
fams = mcu_families(path, repo_root)
if not fams:
# empty means empty (maintainer ruling): if no family's build references
# the path, no build consumes the change - there is nothing to compile,
# so there is nothing to run either. TestOrphanInvariant's
# test_tracked_mcu_vendors_resolve is the drift guard: a real vendor dir
# that stops resolving fails pre-commit instead of silently vanishing
s.reasons.append(f'{path}: hw/mcu path resolves to no family, no contribution')
return
s.families.update(fams)
boards = [b['name'] for b in roster_boards
if board_family(b['name'], repo_root) in fams]
s.roles.update(('device', 'host'))
s.add(boards, 'all', f'{path}: mcu dir -> families {sorted(fams)} -> boards {boards}')
return
m = re.match(r'lib/([^/]+)/', path)
if m:
lib = m.group(1)
# only the tests whose example builds the lib, and only those the rig runs
tests = {e for e in lib_examples(lib, repo_root)
if any(e in pool for pool in ALL_TESTS.values()) or e in extras}
if lib == 'SEGGER_RTT':
# no example names this lib, but a board whose roster entry says
# "logger": "rtt" (variant defines LOGGER=rtt) reads EVERY test's console
# through it -- a break here silently breaks all of that board's rows
rtt_boards = [b['name'] for b in roster_boards if b.get('logger') == 'rtt']
if rtt_boards:
s.roles.update(('device', 'host'))
s.add(rtt_boards, 'all',
f'{path}: SEGGER_RTT is the rtt console on {rtt_boards} -> all tests')
return
if not tests:
s.reasons.append(f'{path}: lib {lib} used by no HIL test, no contribution')
return
roles = set()
for test in tests:
r = test_role(test)
roles.update(('device', 'host') if r == 'dual' else (r,))
boards = [b['name'] for b in roster_boards]
s.roles.update(roles)
s.add(boards, sorted(tests), f'{path}: lib {lib} -> {sorted(tests)} on all boards')
return
if re.match(r'src/typec/', path):
# only examples/typec enables CFG_TUC_ENABLED, and no rig board runs a typec
# test (see _HIL_EX_ROLES) - so the build axis covers it and the rig cannot
s.reasons.append(f'{path}: typec, no HIL contribution')
return
m = _BUILD_EX_RE.match(path)
if m:
if m.group(1) not in _HIL_EX_ROLES:
# examples/typec: the build matrix compiles it, nothing on the rig runs it
s.reasons.append(f'{path}: {m.group(1)} example, no HIL contribution')
return
test = f'{m.group(1)}/{m.group(2)}'
known = any(test in pool for pool in ALL_TESTS.values()) or test in extras
if known:
boards = [b['name'] for b in roster_boards]
role = test_role(test)
s.roles.update(('device', 'host') if role == 'dual' else (role,))
s.add(boards, [test], f'{path}: example -> {test} on all boards')
else:
s.reasons.append(f'{path}: example not in HIL lists, no contribution')
return
s.force_full(f'{path}: unclassified -> full matrix')
def classify(changed_files, repo_root, rosters, get_deps_families=None):
all_boards = []
seen = set()
for _, boards in rosters:
for b in boards:
if b['name'] not in seen:
seen.add(b['name'])
all_boards.append(b)
extras = roster_only_tests(all_boards)
s = _Sel()
# no early exit once full: keep classifying so `families` still reports every
# family the diff touches (build-only consumers need it). Nothing after the first
# force_full can change full/boards/args - the full branch below ignores by_board.
for path in changed_files:
_classify_one(path, repo_root, all_boards, extras, s, get_deps_families)
if s.full:
return {'full': True, 'boards': {b['name']: 'all' for b in all_boards},
'families': sorted(s.families), 'reasons': s.reasons}
# role pruning: single-role selections drop the other role's tests and boards
by_name = {b['name']: b for b in all_boards}
out = {}
for name, tests in s.by_board.items():
allowed = board_tests(by_name[name])
if tests == 'all':
kept = list(allowed)
else:
kept = [t for t in allowed if t in tests]
if s.roles and s.roles != {'device', 'host'}:
role = next(iter(s.roles))
kept = [t for t in kept if test_role(t) in (role, 'dual')]
if kept:
out[name] = 'all' if set(kept) == set(allowed) else sorted(kept)
return {'full': False, 'boards': out, 'families': sorted(s.families),
'reasons': s.reasons}
def _board_args(name, chosen) -> list:
parts = [f'-b {name}']
if chosen != 'all':
parts.append(f'-bt {name}:{",".join(chosen)}')
return parts
def hil_examples(sel, rosters):
"""{board: examples hil-build must produce}: the board's selected tests plus
device/board_test, which hil_test.py flashes to park at every variant
boundary and at end-of-board teardown. Emitted for full selections too - the
HIL example universe is a fraction of the tree regardless of the diff."""
by_name = {}
for _, boards in rosters:
for b in boards:
by_name.setdefault(b['name'], []).append(b)
if sel['full']:
chosen = {n: 'all' for n in by_name}
else:
chosen = sel['boards']
out = {}
for name, tests in chosen.items():
if tests == 'all':
# a board named by two rosters (rig migration, or shared between rigs)
# may run different tests on each: union them. Superset firmware costs a
# build; a missing image fails the run on whichever rig lost the toss.
run = set().union(*(board_tests(b) for b in by_name[name]))
else:
run = set(tests)
out[name] = sorted(run | {'device/board_test'})
return out
def selection_args(sel, rosters):
"""hil_test.py args per config. Empty means either 'full matrix' or 'nothing
selected' - callers must read sel['full'] to tell them apart."""
args = {}
for cfg_path, boards in rosters:
parts = []
if not sel['full']:
for b in boards:
chosen = sel['boards'].get(b['name'])
if chosen is not None:
parts += _board_args(b['name'], chosen)
args[os.path.basename(cfg_path)] = ' '.join(parts)
return args
def selection_args_by_flasher(sel, rosters):
"""{config: {flasher name: args}}. CI runs one rig as several jobs split by
flasher (esptool vs the rest); each must gate on its own subset, otherwise the
other leg runs a filter matching zero boards and reports a vacuous green."""
out = {}
for cfg_path, boards in rosters:
per = {}
if not sel['full']:
for b in boards:
chosen = sel['boards'].get(b['name'])
if chosen is None:
continue
per.setdefault(b.get('flasher', {}).get('name', ''), []).extend(
_board_args(b['name'], chosen))
out[os.path.basename(cfg_path)] = {f: ' '.join(p) for f, p in per.items()}
return out
def merge_base(base, repo_root):
return subprocess.run(['git', 'merge-base', 'HEAD', base], cwd=repo_root,
capture_output=True, text=True, check=True).stdout.strip()
def git_show(spec, repo_root):
return subprocess.run(['git', 'show', spec], cwd=repo_root,
capture_output=True, text=True, check=True).stdout
def changed_files_from_git(base, repo_root):
diff = subprocess.run(GIT_DIFF_ARGV + [f'{merge_base(base, repo_root)}..HEAD'],
cwd=repo_root, capture_output=True, text=True, check=True).stdout
return [l for l in diff.splitlines() if l.strip()]
def get_deps_families_from_git(base, repo_root):
"""The changed dep entries' families for a --base run, or None (-> full matrix)
if git cannot produce both sides of tools/get_deps.py."""
try:
mb = merge_base(base, repo_root)
return get_deps_changed_families(git_show(f'{mb}:{GET_DEPS_PATH}', repo_root),
git_show(f'HEAD:{GET_DEPS_PATH}', repo_root),
repo_root)
except (subprocess.CalledProcessError, OSError) as e:
print(f'ci_select: {GET_DEPS_PATH}: base content unreadable ({e})', file=sys.stderr)
return None
def main():
ap = argparse.ArgumentParser(description=__doc__)
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument('--base', help='git ref to diff against (merge-base..HEAD)')
g.add_argument('--diff-file', help='newline-separated changed-file list')
ap.add_argument('configs', nargs='*', help='rig roster JSON file(s); omit for the build view alone')
a = ap.parse_args()
repo_root = _REPO_ROOT
rosters = []
for c in a.configs:
with open(c, encoding='utf-8', errors='replace') as f:
rosters.append((c, json.load(f)['boards']))
files = (_read(a.diff_file).splitlines() if a.diff_file
else changed_files_from_git(a.base, repo_root))
files = [f for f in files if f.strip()]
# --diff-file has no git and so no base content: the rule falls open to full
gd = (get_deps_families_from_git(a.base, repo_root)
if a.base and GET_DEPS_PATH in files else None)
s = classify(files, repo_root, rosters, gd)
s['args'] = selection_args(s, rosters)
s['args_flasher'] = selection_args_by_flasher(s, rosters)
if rosters:
s['hil_examples'] = hil_examples(s, rosters)
s['build'] = classify_build(files, repo_root, gd)
for r in s['build']['reasons']:
print(f'ci_select[build]: {r}', file=sys.stderr)
for r in s['reasons']:
print(f'ci_select: {r}', file=sys.stderr)
# reasons go to stderr ONLY - they are a human diagnostic and no consumer reads them
# back. They are also ~97% of the payload (a whole-tree diff: 453 KB -> 12 KB), which
# build.yml re-parses with ci_set_matrix, hil_ci_set_matrix, an inline python and
# three jq calls. The in-process dicts still carry them, for the log and the tests.
out = {k: v for k, v in s.items() if k != 'reasons'}
out['build'] = {k: v for k, v in s['build'].items() if k != 'reasons'}
print(json.dumps(out))
# -------------------------------------------------------------
# Build-axis classifier (spec rule table, docs/superpowers/specs/
# 2026-08-19-ci-build-family-filter-design.md). Independent of the HIL
# classifier: same diff, second walk, its own fail-open.
# -------------------------------------------------------------
# Both walks recognise an example path with the SAME regex, so a role can never be
# known to one walk and unclassified (-> full matrix) to the other. What differs is the
# answer: the rig runs device/host/dual tests, while the build matrix also compiles
# examples/typec, which nothing on the rig runs.
_EX_ROLES = ('device', 'dual', 'host', 'typec')
_HIL_EX_ROLES = ('device', 'host', 'dual')
_BUILD_EX_RE = re.compile(r'examples/(%s)/([^/]+)/' % '|'.join(_EX_ROLES))
@functools.lru_cache(maxsize=None)
def all_examples(repo_root: str) -> tuple:
"""Every examples/<role>/<name> with a CMakeLists.txt, as 'role/name'."""
out = []
for role in _EX_ROLES:
for d in sorted(glob.glob(_rg(repo_root, 'examples', role, '*/'))):
if os.path.isfile(os.path.join(d, 'CMakeLists.txt')):
out.append(f'{role}/{os.path.basename(d.rstrip(os.sep))}')
return tuple(out)
def role_examples(repo_root: str, roles) -> set:
want = set(roles)
return {e for e in all_examples(repo_root) if e.split('/', 1)[0] in want}
@functools.lru_cache(maxsize=None)
def all_bsp_families(repo_root: str) -> tuple:
return tuple(sorted(d for d in os.listdir(os.path.join(repo_root, 'hw/bsp'))
if os.path.isdir(os.path.join(repo_root, 'hw/bsp', d))))
def _build_class_examples(cls: str, base: str, roles: set, repo_root: str) -> set:
"""Examples (all 46, not the HIL lists) whose tusb_config.h enables the class's
macros for the given roles, plus classes that #include the changed header."""
via = sorted(class_include_edges(repo_root).get(f'{cls}/{base}', ()))
out = set()
for prefix, role in (('TUD', 'device'), ('TUH', 'host')):
if role not in roles:
continue
macros = class_macros(cls, base, prefix) + \
[m for c in via for m in class_macros(c, '', prefix)]
out |= examples_enabling(all_examples(repo_root), macros, repo_root)
return out
class _BSel:
"""family -> set(examples) | 'all', unioned per family."""
def __init__(self):
self.full = False
self.fam_ex = {}
self.reasons = []
def add(self, fams, examples, reason):
self.reasons.append(reason)
for f in fams:
cur = self.fam_ex.get(f)
if examples == 'all' or cur == 'all':
self.fam_ex[f] = 'all'
else:
self.fam_ex[f] = (cur or set()) | set(examples)
def force_full(self, reason):
self.full = True
self.reasons.append(reason)
def _classify_build_one(path, repo_root, s: _BSel, get_deps_families=None):
base = os.path.basename(path)
if _NONCODE_RE.match(path) or _META_RE.match(path): # rules 1, 1b
s.reasons.append(f'{path}: non-code, no build contribution')
return
if re.match(r'test/hil/', path): # rule 2
s.reasons.append(f'{path}: HIL harness, no build contribution')
return
if path == GET_DEPS_PATH: # rule 16b
if get_deps_families is None:
s.force_full(f'{path}: dep changes not resolvable -> full build matrix')
return
if not get_deps_families:
s.reasons.append(f'{path}: no dep entry changed, no contribution')
return
fams = sorted(get_deps_families)
s.add(fams, 'all', f'{path}: dep entries changed -> families {fams}')
return
m = _PORT_PATH_RE.match(path)
if m: # rules 3-5
port = m.group(1)
fams = port_families(port, repo_root)
roles = _port_roles(base)
exs = 'all' if roles == {'device', 'host'} else \
role_examples(repo_root, tuple(roles) + ('dual',))
# rule 5b: fams empty -> s.add iterates nothing -> no contribution
s.add(fams, exs, f'{path}: port {port} -> families {sorted(fams)}')
return
if re.match(r'hw/bsp/[^/]+/', path): # rule 6
fam = path.split('/')[2]
s.add({fam}, 'all', f'{path}: bsp family {fam}')
return
if re.match(r'hw/mcu/', path): # rule 7
fams = mcu_families(path, repo_root)
if not fams:
# empty means empty, same reading as the HIL walk: no family's build
# references the path, so no build compiles it
s.reasons.append(f'{path}: hw/mcu path resolves to no family, no contribution')
return
s.add(fams, 'all', f'{path}: mcu -> families {sorted(fams)}')
return
m = re.match(r'src/class/([^/]+)/', path)
if m: # rules 8-10
cls = m.group(1)
roles = _class_roles(base)
exs = _build_class_examples(cls, base, roles, repo_root)
if not exs:
# Empty means empty - maintainer decision. No example config enables this
# class, so no build exercises it and
# nothing is selected. The file IS still parsed by every full build
# (src/CMakeLists.txt, src/tinyusb.mk list class sources unconditionally,
# the CFG_ guard sits inside), so a break outside the guard surfaces on the
# next master push - the accepted safety net.
s.reasons.append(f'{path}: class {cls} enabled by no example config, '
f'no contribution')
return
s.add(all_bsp_families(repo_root), exs,
f'{path}: class {cls} -> {sorted(exs)}')
return
m = re.match(r'src/(device|host)/', path)
if m: # rules 11-12
role = m.group(1)
s.add(all_bsp_families(repo_root), role_examples(repo_root, (role, 'dual')),
f'{path}: core {role} stack')
return
m = _BUILD_EX_RE.match(path)
if m: # rules 13-14
ex = f'{m.group(1)}/{m.group(2)}'
if ex in all_examples(repo_root):
s.add(all_bsp_families(repo_root), {ex}, f'{path}: example {ex}')
else:
# a deleted example builds nothing; removing it from the role
# CMakeLists (rule 15) is what forces the full matrix
s.reasons.append(f'{path}: not an example dir, no build contribution')
return
if re.match(r'src/typec/', path): # rule 12b
# listed unconditionally by src/CMakeLists.txt and src/tinyusb.mk, but the whole
# body is `#if CFG_TUC_ENABLED` - so it is PARSED by every build and COMPILED
# only for examples that enable it. Same shape as the class rule, same answer:
# the examples whose tusb_config.h turns it on, and empty means empty.
exs = examples_enabling(role_examples(repo_root, ('typec',)),
('CFG_TUC_ENABLED',), repo_root)
if not exs:
s.reasons.append(f'{path}: typec enabled by no example config, no contribution')
return
s.add(all_bsp_families(repo_root), exs, f'{path}: typec -> {sorted(exs)}')
return
m = re.match(r'lib/([^/]+)/', path)
if m: # rule 16a
lib = m.group(1)
exs = lib_examples(lib, repo_root)
if not exs:
# empty means empty: no example's build pulls this lib in, so no MAIN-
# matrix build compiles it. (lib/SEGGER_RTT is reached through LOGGER=rtt,
# which the main matrix never sets; the hil-build legs set it only for
# roster boards whose variant defines carry it, via the HIL SEGGER_RTT rule.
# No committed CI roster has such a board yet, so a SEGGER_RTT edit is
# currently neither built nor HIL-tested by CI -- verify vendor bumps
# manually until a rig board adopts "logger": "rtt".)
s.reasons.append(f'{path}: lib {lib} built by no example, no contribution')
return
s.add(all_bsp_families(repo_root), exs, f'{path}: lib {lib} -> {sorted(exs)}')
return
if _METRICS_RE.match(path): # rule 2b
s.reasons.append(f'{path}: build-size metrics tooling, no build contribution')
return
if _CI_BOARDS_RE.match(path): # rule 2c
s.force_full(f'{path}: CI board data changes which boards build -> full build matrix')
return
if _MEMBROWSE_SCRIPT_RE.match(path): # rule 2d
s.force_full(f'{path}: membrowse build-time script -> full build matrix')
return
if _FULL_RE.match(path): # rules 15-16
# attribution, not behaviour: these already reached `full` through the
# fall-through below. Naming them means a future narrowing of rule 17 cannot
# silently change what they do. Deliberately last, so every earlier rule keeps
# priority - examples/device/board_test is rule 14 (just board_test), not ALL.
s.force_full(f'{path}: core/infra -> full build matrix')
return
s.force_full(f'{path}: unclassified -> full build matrix') # rule 17
@contextlib.contextmanager
def _in_repo(repo_root):
"""build_utils/build.py use repo-relative paths; scope a chdir around them.
get_family_boards also prints on an empty family - swallow stdout so the
selector's machine-read JSON stays clean (diagnostics belong on stderr)."""
old = os.getcwd()
os.chdir(repo_root)
try:
with contextlib.redirect_stdout(io.StringIO()):
yield
finally:
os.chdir(old)
def _prune_buildable(fams, fam_ex, repo_root):
"""Intersect each family's selection with what the family can build at all
(build_utils.skip_example - the same skip.txt/only.txt data CMake's
family_filter reads).
ANY board of the family counts, not just the one GHA's --one-first picks:
CircleCI's cmake legs build every board of a family, so an example gated to a
single board (only.txt board:mimxrt1060_evk) would otherwise lose ALL compile
coverage exactly when a PR touches it. get_family_boards(.., False, False) is
that full list.
EITHER build system counts too. This one list gates CircleCI's make legs as well
as its cmake ones, and the two answer different questions (build_utils.skip_example):
examples/device/dfu carries `mcu:BCM2835` in skip.txt, which the cmake FAMILY_MCUS
union applies to every broadcom_64bit board while the make scrape applies it to
none - asking cmake alone drops the only aarch64-gcc family in the matrix and
`build-make-aarch64-gcc` stops compiling dfu at all."""
out_fams, out_ex, reasons = [], {}, []
allex = list(all_examples(repo_root))
with _in_repo(repo_root):
for fam in fams:
if not os.path.isdir(os.path.join(repo_root, 'hw/bsp', fam, 'boards')):
# a PR that deletes or renames hw/bsp/<fam> still names it in the
# diff (rule 6); the family builds nothing now, and get_family_boards
# would raise FileNotFoundError out of the whole selector
reasons.append(f'{fam}: family dir gone from tree, dropped')
continue
try:
boards = build_py.get_family_boards(fam, False, False)
except OSError as e: # belt and braces: never traceback here
reasons.append(f'{fam}: boards unreadable ({e}), dropped')
continue
if not boards:
out_fams.append(fam) # unknown layout: keep unfiltered
continue
# what this family's build path can even see, asked the same way for
# every family. build.py's espressif branch builds get_examples('espressif')
# only (the *_freertos examples plus a short extra list); keeping the family
# for anything else spins up CI's most expensive leg to skip every example
# it was given. Identical to the unfiltered list on all 81 other families.
pool = set(build_py.get_examples(fam))
# asked per example instead of materialising the family's whole buildable
# list: skip_example is by far the hottest call in the selector, and every
# question below short-circuits (one cdc_device.c diff: 6,883 calls -> 1,889)
def can_build(ex):
# EITHER build system: this one list gates CircleCI's make legs too, and
# the two answer differently (build_utils.skip_example)
return ex in pool and any(
not build_utils.skip_example(ex, b) or
not build_utils.skip_example(ex, b, (), 'make') for b in boards)
want = fam_ex.get(fam)
try:
if want is None:
kept = None if any(can_build(e) for e in allex) else []
else:
kept = [e for e in want if can_build(e)]
if kept and not any(can_build(e) for e in allex if e not in want):
kept = None # already everything the family can build
except OSError as e:
# a family mid-bring-up (boards/ but no family.cmake/family.mk yet)
# reads as unbuildable to the scrape; keep it rather than tracebacking
# out of the selector and losing the scoping for the whole PR
reasons.append(f'{fam}: mcu scrape unreadable ({e}), kept unfiltered')
out_fams.append(fam)
continue
if kept == []:
continue # this diff builds nothing for this family
out_fams.append(fam)
if kept is not None:
out_ex[fam] = kept
return out_fams, out_ex, reasons
def classify_build(changed_files, repo_root, get_deps_families=None):
s = _BSel()
for p in changed_files:
_classify_build_one(p, repo_root, s, get_deps_families)
if s.full:
return {'full': True, 'families': list(all_bsp_families(repo_root)),
'family_examples': {}, 'reasons': s.reasons}
fams = sorted(s.fam_ex)
fam_ex = {f: sorted(e) for f, e in s.fam_ex.items() if e != 'all'}
fams, fam_ex, pruned = _prune_buildable(fams, fam_ex, repo_root)
s.reasons += pruned
return {'full': False, 'families': fams, 'family_examples': fam_ex,
'reasons': s.reasons}
if __name__ == '__main__':
main()