summaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rwxr-xr-xtools/build.py175
-rwxr-xr-xtools/build_utils.py329
-rwxr-xr-xtools/ci_select.py1123
-rwxr-xr-xtools/get_deps.py8
-rw-r--r--tools/iar_template.ipcf2
-rw-r--r--tools/metrics.py41
6 files changed, 1639 insertions, 39 deletions
diff --git a/tools/build.py b/tools/build.py
index 51d3d0f70..eeefca22d 100755
--- a/tools/build.py
+++ b/tools/build.py
@@ -2,6 +2,7 @@
import argparse
import random
import os
+import re
import sys
import time
import subprocess
@@ -99,6 +100,53 @@ def get_examples(family):
return all_examples
+def resolve_example_target_groups(build_targets, examples, board, extra_defines=()):
+ """Map generic targets onto per-example targets for a filtered build (-e), as ONE
+ GROUP PER REQUESTED TARGET: 'all' -> the example executables, anything else (e.g.
+ tinyusb_metrics) passes through as its own single-entry group.
+
+ Grouped rather than flattened because each group becomes one `cmake --build
+ --target a b c` invocation: the examples of a group build in parallel (flattening
+ them into one target per invocation serialises the whole leg - measured +39% at
+ -j4 and +220% at -j32 on stm32f407disco), while separate groups stay ordered, so a
+ target that must run after the examples still does.
+
+ extra_defines are this build's -D tokens: MAX3421_HOST=1 there decides
+ only.txt for the max3421 examples (see build_utils.skip_example).
+ Returns None when no requested example is buildable on this board."""
+ buildable = [e for e in examples
+ if not build_utils.skip_example(e, board, extra_defines)]
+ if not buildable:
+ return None
+ names = list(dict.fromkeys(e.split('/', 1)[1] for e in buildable))
+ return [list(names) if t == 'all' else [t] for t in build_targets]
+
+
+_TARGET_HELP_RE = re.compile(r'^([A-Za-z0-9_.+-]+):')
+# role/name, the only shape resolve_example_target_groups and the CMake target names accept
+EXAMPLE_RE = re.compile(r'[A-Za-z0-9_]+/[A-Za-z0-9_]+')
+
+
+def parse_target_help(text):
+ """Bare target names out of `cmake --build <dir> --target help`; the Ninja
+ generator prints one '<name>: phony' line per target. Names containing '/' are
+ per-directory utility targets (device/edit_cache) or absolute CMakeFiles paths,
+ never an example target."""
+ return {m.group(1) for m in map(_TARGET_HELP_RE.match, text.splitlines()) if m}
+
+
+def cmake_registered_targets(build_dir):
+ """The targets CMake actually created in build_dir, or None when that cannot be
+ read. Ground truth: skip.txt/only.txt only mirrors family_filter, so an example
+ the role CMakeLists never lists (or a stale -e name) still looks buildable to it
+ and `cmake --build --target <it>` hard-fails. None keeps the mirror's answer."""
+ r = subprocess.run(['cmake', '--build', build_dir, '--target', 'help'],
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+ if r.returncode != 0:
+ return None
+ return parse_target_help(r.stdout.decode('utf-8', 'replace')) or None
+
+
def print_build_result(board, build_target, status, duration):
if isinstance(duration, (int, float)):
duration = "{:.2f}s".format(duration)
@@ -107,7 +155,7 @@ def print_build_result(board, build_target, status, duration):
# -----------------------------
# CMake
# -----------------------------
-def cmake_board(board, build_args, build_name, build_cflags, build_targets):
+def cmake_board(board, build_args, build_name, build_cflags, build_targets, examples=None, defines=()):
ret = [0, 0, 0]
start_time = time.monotonic()
@@ -120,8 +168,13 @@ def cmake_board(board, build_args, build_name, build_cflags, build_targets):
if family == 'espressif':
# for espressif, we have to build example individually
all_examples = get_examples(family)
+ if examples is not None:
+ all_examples = [e for e in all_examples if e in examples]
+ if not all_examples:
+ print_build_result(board, 'examples (PR filter)', 2, '-')
+ return [0, 0, 1]
for example in all_examples:
- if build_utils.skip_example(example, board):
+ if build_utils.skip_example(example, board, defines):
ret[2] += 1
else:
rcmd = run_cmd([
@@ -130,13 +183,40 @@ def cmake_board(board, build_args, build_name, build_cflags, build_targets):
])
ret[0 if rcmd.returncode == 0 else 1] += 1
else:
+ # the skip.txt/only.txt prefilter reads no configure output: answer it first,
+ # so a selection this board builds nothing of costs no cmake run at all
+ if examples is not None:
+ examples = [e for e in examples
+ if not build_utils.skip_example(e, board, defines)]
+ if not examples:
+ print_build_result(board, 'examples (PR filter)', 2, '-')
+ return [0, 0, 1]
rcmd = run_cmd(['cmake', 'examples', '-B', build_dir, '-GNinja',
f'-DBOARD={board}', '-DCMAKE_BUILD_TYPE=MinSizeRel', '-DLINKERMAP_OPTION=-q -f tinyusb/src',
*build_args, *build_flags])
if rcmd.returncode == 0:
+ target_groups = [[t] for t in build_targets]
+ if examples is not None:
+ registered = cmake_registered_targets(build_dir)
+ if registered is not None:
+ kept = [e for e in examples if e.split('/', 1)[1] in registered]
+ for e in examples:
+ if e not in kept:
+ print_build_result(board, f'{e} (no such target)', 2, '-')
+ examples = kept
+ if not examples:
+ print_build_result(board, 'examples (no such target)', 2, '-')
+ return [0, 0, 1]
+ target_groups = resolve_example_target_groups(build_targets, examples, board, defines)
+ if registered is None:
+ # ground truth unavailable, so nothing checked these names against
+ # what CMake created. ninja validates a whole invocation up front:
+ # one unknown name in the batch builds NOTHING, where a target each
+ # builds everything up to it. Give up the parallelism, not the work.
+ target_groups = [[t] for g in target_groups for t in g]
cmd = ["cmake", "--build", build_dir, '--parallel', str(parallel_jobs)]
- for target in build_targets:
- rcmd = run_cmd(cmd + ['--target', target])
+ for group in target_groups:
+ rcmd = run_cmd(cmd + ['--target'] + group)
if rcmd.returncode != 0:
break
ret[0 if rcmd.returncode == 0 else 1] += 1
@@ -148,9 +228,10 @@ def cmake_board(board, build_args, build_name, build_cflags, build_targets):
# -----------------------------
# Make
# -----------------------------
-def make_one_example(example, board, make_option, build_targets):
- # Check if board is skipped
- if build_utils.skip_example(example, board):
+def make_one_example(example, board, make_option, build_targets, defines=()):
+ # Check if board is skipped. Make semantics: family.mk decides, not the
+ # family.cmake MCU list (see build_utils.skip_example).
+ if build_utils.skip_example(example, board, defines, build_system='make'):
print_build_result(board, example, 2, '-')
r = 2
else:
@@ -171,10 +252,15 @@ def make_one_example(example, board, make_option, build_targets):
return ret
-def make_board(board, build_args, build_targets):
+def make_board(board, build_args, build_targets, examples=None, defines=()):
print(build_separator)
family = find_family(board);
all_examples = get_examples(family)
+ if examples is not None:
+ all_examples = [e for e in all_examples if e in examples]
+ if not all_examples:
+ print_build_result(board, 'examples (PR filter)', 2, '-')
+ return [0, 0, 1]
start_time = time.monotonic()
ret = [0, 0, 0]
if family == 'espressif' or family == 'rp2040':
@@ -182,7 +268,7 @@ def make_board(board, build_args, build_targets):
final_status = 2
else:
with Pool(processes=os.cpu_count()) as pool:
- pool_args = list((map(lambda e, b=board, o=f"{build_args}", t=build_targets: [e, b, o, t], all_examples)))
+ pool_args = list((map(lambda e, b=board, o=f"{build_args}", t=build_targets, d=defines: [e, b, o, t, d], all_examples)))
r = pool.starmap(make_one_example, pool_args)
# sum all element of same index (column sum)
ret = list(map(sum, list(zip(*r))))
@@ -194,36 +280,58 @@ def make_board(board, build_args, build_targets):
# -----------------------------
# Build Family
# -----------------------------
-def build_boards_list(boards, build_defines, build_system, build_name, build_cflags, build_targets):
+def build_boards_list(boards, build_defines, build_system, build_name, build_cflags, build_targets, examples=None):
ret = [0, 0, 0]
+ # the -D tokens are part of the skip.txt/only.txt answer (MAX3421_HOST=1), so
+ # the -e filter has to see them too; sorted+tuple keeps skip_example cacheable
+ defines = tuple(sorted(build_defines))
for b in boards:
r = [0, 0, 0]
if build_system == 'cmake':
build_args = [f'-D{d}' for d in build_defines]
- r = cmake_board(b, build_args, build_name, build_cflags, build_targets)
+ r = cmake_board(b, build_args, build_name, build_cflags, build_targets, examples, defines)
elif build_system == 'make':
build_args = ' '.join(f'{d}' for d in build_defines)
- r = make_board(b, build_args, build_targets)
+ r = make_board(b, build_args, build_targets, examples, defines)
ret[0] += r[0]
ret[1] += r[1]
ret[2] += r[2]
return ret
-def get_family_boards(family, one_random, one_first):
+def get_family_boards(family, one_random, one_first, examples=None, build_system='cmake',
+ extra_defines=(), ci=None):
"""Get list of boards for a family.
Args:
family: Family name
one_random: If True, return only one random board
one_first: If True, return only the first board (alphabetical)
+ examples: PR example filter (-e). The one-board pick then prefers a board that
+ can build at least one of them: the family is in the matrix BECAUSE some
+ board of it builds these examples (ci_select._prune_buildable asks about
+ every board, since CircleCI builds every board), but GHA builds one. Without
+ this, lpc54 selected for host/msc_file_explorer picks lpcxpresso54114 -
+ which every one of those examples skips - and the leg runs to green having
+ compiled nothing and uploaded no metrics.
+ build_system: which skip answer to ask for; the two differ (build_utils)
+ extra_defines: this build's -D tokens, so a board whose only.txt match comes
+ from -DMAX3421_HOST=1 is not judged unbuildable here and buildable in
+ cmake_board
+ ci: force the ci_skip_boards / ci_preferred_boards lists on or off. Default
+ None reads the environment, which is right for a build but NOT for a caller
+ asking what CI would do: ci_select must answer the same on a laptop as on a
+ runner, or /pre-pr and the code-size skill report a family list CI will not
+ reproduce.
Returns:
List of board names
"""
+ if ci is None:
+ ci = bool(os.getenv('GITHUB_ACTIONS') or os.getenv('CIRCLECI'))
skip_list = []
preferred_list = []
- if os.getenv('GITHUB_ACTIONS') or os.getenv('CIRCLECI'):
+ if ci:
skip_list = ci_skip_boards.get(family, [])
preferred_list = ci_preferred_boards.get(family, [])
@@ -238,12 +346,26 @@ def get_family_boards(family, one_random, one_first):
# If only-one flags are set, honor select list first, then pick first or random
if one_first or one_random:
- if preferred_list:
+ def buildable(board):
+ # no filter, or nothing in the filter is buildable anywhere: keep today's
+ # answer rather than inventing a different board
+ return examples is None or any(
+ not build_utils.skip_example(e, board, extra_defines, build_system)
+ for e in examples)
+
+ # the WHOLE preferred list, in order - stopping at entry one would abandon a
+ # curated list for the raw alphabetical order the moment its first board cannot
+ # build the filter, which also moves the board the metrics baseline is keyed on
+ for b in preferred_list:
+ if buildable(b):
+ return [b]
+ if preferred_list and examples is None:
return [preferred_list[0]]
+ candidates = [b for b in all_boards if buildable(b)] or all_boards
if one_first:
- return [all_boards[0]]
+ return [candidates[0]]
if one_random:
- return [random.choice(all_boards)]
+ return [random.choice(candidates)]
return all_boards
@@ -272,6 +394,8 @@ def main():
parser.add_argument('-j', '--jobs', type=int, default=os.cpu_count(), help='Number of jobs to run in parallel')
parser.add_argument('-T', '--target', action='append', default=[],
help='Build target to use, may be specified multiple times (default: all)')
+ parser.add_argument('-e', '--example', action='append', default=[],
+ help='Only build these examples (role/name, repeatable). Default: all examples')
parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output')
args = parser.parse_args()
@@ -285,9 +409,20 @@ def main():
one_random = args.one_random
one_first = args.one_first
build_targets = args.target if args.target else ['all']
+ examples = args.example or None
verbose = args.verbose
parallel_jobs = args.jobs
+ for e in args.example:
+ if not EXAMPLE_RE.fullmatch(e):
+ parser.error(f"-e/--example takes 'role/name' (e.g. device/cdc_msc), got '{e}'")
+ # a name no example dir answers to would silently build nothing on every board
+ # and still exit 0 (every row is a Skipped, and main() returns the FAILED count).
+ # The -e lists are generated - from ci_select's example map and from HIL roster
+ # test names - so a stale one must be loud, not green
+ if not os.path.isdir(os.path.join('examples', e)):
+ parser.error(f"-e/--example '{e}': no such example directory examples/{e}")
+
build_defines.append(f'TOOLCHAIN={toolchain}')
if len(families) == 0 and len(boards) == 0:
@@ -317,10 +452,12 @@ def main():
# get boards from families and append to boards list
all_boards = list(boards)
for f in all_families:
- all_boards.extend(get_family_boards(f, one_random, one_first))
+ all_boards.extend(get_family_boards(f, one_random, one_first, examples,
+ build_system, tuple(build_defines)))
# build all boards
- result = build_boards_list(all_boards, build_defines, build_system, build_name, build_cflags, build_targets)
+ result = build_boards_list(all_boards, build_defines, build_system, build_name, build_cflags, build_targets,
+ examples)
total_time = time.monotonic() - total_time
print(build_separator)
diff --git a/tools/build_utils.py b/tools/build_utils.py
index d80ceea7c..1eeef0269 100755
--- a/tools/build_utils.py
+++ b/tools/build_utils.py
@@ -1,4 +1,5 @@
#!/usr/bin/env python3
+import functools
import subprocess
import pathlib
import re
@@ -10,32 +11,190 @@ FAILED = "\033[31mfailed\033[0m"
SKIPPED = "\033[33mskipped\033[0m"
-def skip_example(example, board):
- ex_dir = pathlib.Path('examples/') / example
- bsp = pathlib.Path("hw/bsp")
+# Every read here is a source file, not user text: decode it the same way on every
+# machine. Without this the reads take the locale's encoding, and one of the eight
+# tracked non-ASCII files this now touches (hw/bsp/nrf/boards/nrf54lm20dk/board.cmake
+# among them) raises UnicodeDecodeError under LC_ALL=C - a ValueError, which sails
+# straight through the `except OSError` fail-opens.
+_TEXT = {'encoding': 'utf-8', 'errors': 'replace'}
- # board within family
- board_dir = list(bsp.glob("*/boards/" + board))
- if not board_dir:
- # Skip unknown boards
- return True
+_FAMILY_MCUS_RE = re.compile(r'set\s*\(\s*FAMILY_MCUS\s+([^)]*)\)')
+_CMAKE_SET_RE = re.compile(r'set\s*\(\s*([A-Za-z_]\w*)\s+([^)\s]+)')
+_CMAKE_VAR_RE = re.compile(r'\$\{([A-Za-z_]\w*)\}')
+_CMAKE_CASE_RE = re.compile(r'string\s*\(\s*(TOUPPER|TOLOWER)\s+(\S+)\s+([A-Za-z_]\w*)\s*\)')
- board_dir = list(board_dir)[0]
- family_dir = board_dir.parent.parent
- family = family_dir.name
- # family.mk
[email protected]_cache(maxsize=None)
+def _cmake_sets(path):
+ """One cmake file's variable assignments as NAME -> first definition seen, as
+ either a literal value or an ('TOUPPER'|'TOLOWER', source) pair. Only used to
+ expand ${...} tokens; never mutate the cached dict.
+
+ string(TOUPPER ...) is not decoration: hw/bsp/maxim derives its ONLY FAMILY_MCUS
+ entry that way (`string(TOUPPER ${MAX_DEVICE} MAX_DEVICE_UPPER)`), as do the eight
+ at32 families, so dropping those lines left nine families with an empty MCU set."""
+ try:
+ text = pathlib.Path(path).read_text(**_TEXT)
+ except OSError:
+ return {}
+ out = {}
+ for line in text.splitlines():
+ line = line.strip()
+ if line.startswith('#'):
+ continue
+ m = _CMAKE_CASE_RE.match(line)
+ if m:
+ # strip quotes like the set() branch below: string(TOUPPER "${VAR}" DST) is
+ # idiomatic cmake, and keeping them yields a '"NAME"' token that can never
+ # equal a mcu: entry
+ out.setdefault(m.group(3), (m.group(1), m.group(2).strip('"')))
+ continue
+ m = _CMAKE_SET_RE.match(line)
+ if m:
+ out.setdefault(m.group(1), m.group(2).strip('"'))
+ return out
+
+
+def _cmake_expand(value, files, depth=0):
+ """`value` with every ${VAR} replaced, resolving each name against `files` in
+ order, or None when any name resolves nowhere OR the result still carries a `${`.
+ That last case is the one _CMAKE_VAR_RE cannot see - a hyphen in the name, a nested
+ ${${X}}, an unterminated brace - where the loop below finds nothing to substitute
+ and would otherwise hand the raw text back as if it were a resolved MCU name.
+ Bounded depth: a cmake file may define a var in terms of another one, and a
+ self-referential set() must not recurse forever."""
+ if depth > 4:
+ return None
+ out = value
+ for name in set(_CMAKE_VAR_RE.findall(value)):
+ val = None
+ for f in files:
+ val = _cmake_sets(f).get(name)
+ if val is not None:
+ break
+ if val is None:
+ return None
+ if isinstance(val, tuple): # string(TOUPPER src DST)
+ src = _cmake_expand(val[1], files, depth + 1)
+ if src is None:
+ return None
+ val = src.upper() if val[0] == 'TOUPPER' else src.lower()
+ else:
+ val = _cmake_expand(val, files, depth + 1)
+ if val is None:
+ return None
+ out = out.replace('${' + name + '}', val)
+ return None if '${' in out else out
+
+
[email protected]_cache(maxsize=None)
+def _board_dirs(board):
+ """(board_dir, family_dir) for a board name, or (None, None). Cached: skip_example
+ is asked (board x example) times - 566k lstat calls per selector run without this,
+ since the glob rescans every hw/bsp/*/boards for each example."""
+ hits = list(pathlib.Path("hw/bsp").glob("*/boards/" + board))
+ if not hits:
+ return None, None
+ return hits[0], hits[0].parent.parent
+
+
[email protected]_cache(maxsize=None)
+def _family_mcus(family_dir, board_dir):
+ """The MCU names CMake's family_filter iterates. family_support.cmake:176/190
+ loop `foreach(MCU IN LISTS FAMILY_MCUS)`, so a family-wide list (broadcom_64bit
+ sets "BCM2711 BCM2835") makes ANY of its entries decide skip.txt/only.txt -- not
+ just the one CFG_TUSB_MCU the configured board names.
+
+ ${...} tokens are expanded from `set(VAR value)` and `string(TOUPPER src VAR)` in
+ the board's board.cmake first, then in family.cmake: hw/bsp/ra sets
+ `FAMILY_MCUS RAXXX ${MCU_VARIANT}` and ra6m5_ek/board.cmake sets MCU_VARIANT ra6m5,
+ which is the token dual/host_info_to_device_cdc/only.txt actually spells; hw/bsp/maxim
+ sets `FAMILY_MCUS ${MAX_DEVICE_UPPER}`, upper-cased from the board's MAX_DEVICE. A
+ token resolving nowhere is dropped (nothing can be said about it).
+
+ A family that never spells `set(FAMILY_MCUS ...)` at all gets one more chance: the
+ name is resolved as a variable, which covers the derived form hw/bsp/espressif uses
+ (`string(TOUPPER ${IDF_TARGET} FAMILY_MCUS)`).
+
+ Only unconditional set() calls count: nrf and mcx pick FAMILY_MCUS per board
+ inside if() blocks this does not evaluate, so for those two families the whole
+ cmake-side MCU set is whatever the CFG_TUSB_MCU scrape in _board_mcu finds.
+
+ nrf: the scrape reads the FIRST CFG_TUSB_MCU token of hw/bsp/nrf/family.mk, so
+ every nrf board answers NRF54, the NRF5X ones included. Harmless only because no
+ skip.txt/only.txt names an nrf token today.
+
+ mcx: load-bearing, not academic -- mcu:MCXA15 is live in six examples' skip.txt
+ (device/{cdc_msc,audio_test,hid_composite,audio_4_channel_mic,midi_test}_freertos
+ and device/net_lwip_webserver). Those answers come out right only because the
+ scrape falls through to each board's make-only board.mk, which still spells the
+ token; an mcx board carrying board.cmake alone (MCU_VARIANT and no CFG_TUSB_MCU)
+ would scrape 'NONE' and skip EVERY example on it, silently. TestFamilyMcusFallback
+ fails the day such a board lands. The fix then is to evaluate the
+ if(MCU_VARIANT STREQUAL ...) branches, not to add another scrape.
+ """
+ fam_cmake = pathlib.Path(family_dir) / "family.cmake"
+ try:
+ text = fam_cmake.read_text(**_TEXT)
+ except OSError:
+ return frozenset()
+ board_cmake = pathlib.Path(board_dir) / "board.cmake"
+ out = set()
+ depth = 0
+ any_set = False
+ for line in text.splitlines():
+ line = line.strip()
+ m = _FAMILY_MCUS_RE.match(line)
+ if m:
+ any_set = True
+ if m and depth == 0:
+ files = (str(board_cmake), str(fam_cmake))
+ for tok in m.group(1).split():
+ if tok in ("CACHE", "INTERNAL") or tok.startswith('"'):
+ continue
+ val = _cmake_expand(tok, files)
+ if val:
+ out.add(val)
+ if re.match(r'if\s*\(', line):
+ depth += 1
+ elif re.match(r'endif\s*\(', line):
+ depth = max(0, depth - 1)
+ if not out and not any_set:
+ # FAMILY_MCUS can also be produced rather than set: hw/bsp/espressif derives it
+ # with `string(TOUPPER ${IDF_TARGET} FAMILY_MCUS)`, which _FAMILY_MCUS_RE cannot
+ # see, leaving espressif's whole cmake answer resting on the IDF_TARGET scrape.
+ #
+ # `not any_set` is load-bearing: _cmake_sets is if()-blind and keeps the FIRST
+ # definition, so on a family that sets FAMILY_MCUS only inside conditionals
+ # (mcx, nrf) this would leak branch one's value onto every board - mcx/frdm_mcxn947
+ # answered MCXA15, which six examples' skip.txt names, dropping 12 firmware
+ # images CMake actually builds. Those families keep the CFG_TUSB_MCU scrape.
+ val = _cmake_expand('${FAMILY_MCUS}', (str(board_cmake), str(fam_cmake)))
+ if val:
+ out.add(val)
+ return frozenset(out)
+
+
[email protected]_cache(maxsize=None)
+def _scrape_mcu(family_dir, board_dir, family):
+ """(CFG_TUSB_MCU token of this board, the text it was read from), master's
+ algorithm verbatim: family.mk (family.cmake when there is none) first, falling
+ back to the board's board.mk (board.cmake when there is none) only when the
+ family file names no token at all. espressif spells its MCU as
+ `set(IDF_TARGET "...")` instead. The text comes back with it because the make
+ path reads MAX3421_HOST out of that same single file - which file that is IS
+ part of master's answer, so it cannot be re-derived by the caller."""
family_mk = family_dir / "family.mk"
if not family_mk.exists():
family_mk = family_dir / "family.cmake"
- mk_contents = family_mk.read_text()
+ mk_contents = family_mk.read_text(**_TEXT)
# Find the mcu, first in family mk then board mk
if "CFG_TUSB_MCU=OPT_MCU_" not in mk_contents:
board_mk = board_dir / "board.mk"
if not board_mk.exists():
board_mk = board_dir / "board.cmake"
- mk_contents = board_mk.read_text()
+ mk_contents = board_mk.read_text(**_TEXT)
mcu = "NONE"
if family == "espressif":
@@ -53,6 +212,95 @@ def skip_example(example, board):
mcu = opt_mcu[len("OPT_MCU_"):]
if mcu != "NONE":
break
+ return mcu, mk_contents
+
+
[email protected]_cache(maxsize=None)
+def _board_mcu(board_dir, family_dir, family):
+ """(CFG_TUSB_MCU of this board, MAX3421_HOST enabled by its cmake BSP).
+
+ MAX3421_HOST is read from family.cmake AND board.cmake rather than only the file
+ the MCU token came from: feather_rp2040_max3421 sets it in its board.cmake while
+ its MCU token comes from rp2040's family file, and family_support.cmake:940
+ appends MAX3421 to FAMILY_MCUS for it. board.mk is deliberately not read - a
+ make-only option compiles nothing in a cmake build (and the make path answers
+ with master's own single-file scrape, see _skip_example_make)."""
+ family_dir = pathlib.Path(family_dir)
+ board_dir = pathlib.Path(board_dir)
+ mcu, _ = _scrape_mcu(family_dir, board_dir, family)
+ if "${" in mcu:
+ # the scrape is textual, so a computed token comes back verbatim
+ # (tm4c board.cmake spells OPT_MCU_TM4C${MCU_SUB_VARIANT}, maxim
+ # OPT_MCU_${MAX_DEVICE_UPPER}). Expand it the same way FAMILY_MCUS tokens are;
+ # what still will not resolve stays as-is and _skip_example treats it as
+ # "MCU unknown" rather than silently matching no mcu: token at all.
+ mcu = _cmake_expand(mcu, (str(board_dir / "board.cmake"),
+ str(family_dir / "family.cmake"))) or mcu
+
+ max3421_enabled = False
+ for f in (family_dir / "family.cmake", board_dir / "board.cmake"):
+ try:
+ text = f.read_text(**_TEXT)
+ except OSError:
+ continue
+ # a commented-out `# set(MAX3421_HOST 1)` (feather_nrf52840_express) enables
+ # nothing; master never hit one because it only read the MCU token's file
+ if any(not l.lstrip().startswith('#') and
+ ("MAX3421_HOST=1" in l or 'MAX3421_HOST 1' in l)
+ for l in text.splitlines()):
+ max3421_enabled = True
+ break
+
+ return mcu, max3421_enabled
+
+
[email protected]_cache(maxsize=None)
+def _filter_tokens(path):
+ """skip.txt / only.txt as a token set, or None when the file does not exist."""
+ f = pathlib.Path(path)
+ return frozenset(f.read_text(**_TEXT).split()) if f.exists() else None
+
+
+def skip_example(example, board, extra_defines=(), build_system='cmake'):
+ """Is this example unbuildable on this board, for this build system?
+
+ The two build systems ask DIFFERENT questions and must not share an answer:
+
+ 'cmake' mirrors CMake's family_filter (hw/bsp/family_support.cmake:171-207),
+ including the whole FAMILY_MCUS list the family.cmake sets.
+
+ 'make' is master's original algorithm, unchanged. family.mk and family.cmake are
+ not the same build: hw/bsp/lpc54/family.cmake sets FAMILY_MCUS LPC54 and wires the
+ ohci host sources, while family.mk builds OPT_MCU_LPC54XXX and compiles no HCD
+ source at all -- feeding the cmake MCU union to a make build un-skips the host
+ examples only.txt gates on mcu:LPC54 and they fail to link (undefined hcd_init).
+
+ extra_defines: NAME=VALUE tokens the build passes on the command line
+ (build.py -D). MAX3421_HOST=1 there enables the max3421 host controller
+ exactly like a BSP that sets it, and family_support.cmake:940 appends MAX3421
+ to FAMILY_MCUS for it -- so a roster board whose MAX3421 comes from the build
+ args (metro_m4_express) must resolve its only.txt the same way. cmake only:
+ master's make algorithm never looked at them.
+ """
+ return _skip_example(example, board, tuple(extra_defines), build_system)
+
+
[email protected]_cache(maxsize=None)
+def _skip_example_make(example, board):
+ """master's skip_example, verbatim (tools/build_utils.py @ 9c202e8c6): the
+ make build's own answer, derived from family.mk/board.mk with the single
+ CFG_TUSB_MCU token that file names. Do not "improve" it -- it is the mirror of
+ what `make BOARD=... all` actually compiles."""
+ ex_dir = pathlib.Path('examples/') / example
+
+ # board within family
+ board_dir, family_dir = _board_dirs(board)
+ if board_dir is None:
+ # Skip unknown boards
+ return True
+ family = family_dir.name
+
+ mcu, mk_contents = _scrape_mcu(family_dir, board_dir, family)
# Skip all OPT_MCU_NONE these are WIP port
if mcu == "NONE":
@@ -68,14 +316,14 @@ def skip_example(example, board):
only_file = ex_dir / "only.txt"
if skip_file.exists():
- skips = skip_file.read_text().split()
+ skips = skip_file.read_text(**_TEXT).split()
if ("mcu:" + mcu in skips or
"board:" + board in skips or
"family:" + family in skips):
return True
if only_file.exists():
- onlys = only_file.read_text().split()
+ onlys = only_file.read_text(**_TEXT).split()
if not ("mcu:" + mcu in onlys or
("mcu:MAX3421" in onlys and max3421_enabled) or
"board:" + board in onlys or
@@ -85,6 +333,55 @@ def skip_example(example, board):
return False
[email protected]_cache(maxsize=None)
+def _skip_example(example, board, extra_defines, build_system):
+ if build_system == 'make':
+ return _skip_example_make(example, board)
+
+ ex_dir = pathlib.Path('examples/') / example
+
+ # board within family
+ board_dir, family_dir = _board_dirs(board)
+ if board_dir is None:
+ # Skip unknown boards
+ return True
+ family = family_dir.name
+
+ mcu, max3421_enabled = _board_mcu(str(board_dir), str(family_dir), family)
+
+ # Skip all OPT_MCU_NONE these are WIP port
+ if mcu == "NONE":
+ return True
+
+ if any(t.strip().strip('"') == "MAX3421_HOST=1" for t in extra_defines):
+ max3421_enabled = True
+
+ mcus = set(_family_mcus(str(family_dir), str(board_dir)))
+ if "${" not in mcu:
+ mcus.add(mcu)
+ if not mcus:
+ # nothing resolved: neither FAMILY_MCUS nor the scraped CFG_TUSB_MCU token
+ # yielded a name. Answering "skip" here would silently drop EVERY example on
+ # the board (an only.txt can then never match), so say "buildable" and let
+ # the real filter decide - build.py checks the targets CMake actually
+ # registered, and CMake itself is the authority on the make/cmake legs.
+ return False
+ if max3421_enabled:
+ mcus.add("MAX3421") # family_support.cmake:940
+
+ keys = {"board:" + board, "family:" + family} | {"mcu:" + m for m in mcus}
+
+ skips = _filter_tokens(str(ex_dir / "skip.txt"))
+ if skips is not None and (skips & keys):
+ return True
+
+ onlys = _filter_tokens(str(ex_dir / "only.txt"))
+ if onlys is not None and not (onlys & keys):
+ return True
+
+ return False
+
+
def build_size(make_cmd):
size_output = subprocess.run(make_cmd + ' size', shell=True, stdout=subprocess.PIPE).stdout.decode("utf-8").splitlines()
for i, l in enumerate(size_output):
diff --git a/tools/ci_select.py b/tools/ci_select.py
new file mode 100755
index 000000000..ced3bbbc0
--- /dev/null
+++ b/tools/ci_select.py
@@ -0,0 +1,1123 @@
+#!/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 such as /pre-pr
+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).
+"""
+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(
+ r'^(docs/|\.claude/|.*\.(md|rst)$|LICENSE)')
+# Build-size metrics tooling. HIL axis ONLY: nothing on the rig runs any of it, and
+# without this rule these paths are unclassified, so a metrics-only PR booked an
+# exclusive full 30-board sweep to validate a script no board executes.
+# The BUILD axis deliberately keeps its full-matrix answer: `tinyusb_metrics` runs
+# tools/metrics.py as a build target (examples/CMakeLists.txt), and build_util.yml adds
+# `--target tinyusb_metrics` to every metrics leg - a break in it fails the build, so a
+# build has to exercise it.
+_METRICS_RE = re.compile(
+ r'^(tools/metrics[^/]*\.py$|\.github/scripts/metrics_[^/]*\.py$)')
+_FULL_RE = re.compile(
+ r'^(src/common/|src/osal/|src/tusb\.c$|src/tusb\.h$|src/tusb_option\.h$|'
+ r'test/hil/|\.github/workflows/build.*\.yml$|\.github/actions/|\.github/scripts/|'
+ r'tools/build\.py$|tools/cmake/|'
+ r'hw/bsp/(family_support\.cmake|board_api\.h|board\.c|ansi_escape\.h)$|'
+ r'examples/build_system/|examples/CMakeLists\.txt$|'
+ # 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', [])]
+
+
+# cached: called per changed file x roster board, and the tree doesn't change mid-run
[email protected]_cache(maxsize=None)
+def board_family(board_name: str, repo_root: str):
+ hits = glob.glob(os.path.join(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')
+
+
[email protected]_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
[email protected]_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
+
+
[email protected]_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)}
+
+
[email protected]_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')
+ out = []
+ for f in sorted(glob.glob(os.path.join(bsp_root, '*/family.cmake')) +
+ glob.glob(os.path.join(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/([^/"<>]+)/([^"<>]+)[">]')
+
+
[email protected]_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(os.path.join(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'}
+
+
+def _config_enables(cfg_path: str, macros) -> bool:
+ try:
+ with open(cfg_path, encoding='utf-8', errors='replace') as f:
+ text = f.read()
+ except OSError:
+ 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
[email protected]_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):
+ for f in sorted(glob.glob(os.path.join(repo_root, 'examples', ex, '**', '*'),
+ recursive=True)):
+ if os.path.basename(f) not in ('CMakeLists.txt', 'Makefile'):
+ continue
+ 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):
+ s.reasons.append(f'{path}: non-code, no contribution')
+ return
+ if _METRICS_RE.match(path):
+ s.reasons.append(f'{path}: build-size metrics tooling, 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 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
+
+ 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)
+ print(json.dumps(s))
+
+
+# -------------------------------------------------------------
+# 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))
+
+
[email protected]_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(os.path.join(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}
+
+
[email protected]_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): # rule 1
+ 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: # get_deps rule
+ 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',))
+ 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
+ m = re.match(r'lib/([^/]+)/', path)
+ if m: # lib rule
+ 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 build
+ # compiles it (lib/SEGGER_RTT is only reached through LOGGER=rtt, which
+ # no CI build sets)
+ 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
+ s.force_full(f'{path}: unclassified -> full build matrix') # rules 15-17
+
+
+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, with the same CI skip lists the build jobs apply.
+
+ 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:
+ # ci=True unconditionally: this answers "what will CI build", so it must
+ # not change with GITHUB_ACTIONS/CIRCLECI being set. Locally the lists
+ # are off by default, and rp2040 would keep feather_rp2040_max3421 -
+ # the only board satisfying the max3421 only.txt files - giving a
+ # developer a family list the runner will not reproduce.
+ boards = build_py.get_family_boards(fam, False, False, ci=True)
+ 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))
+ try:
+ buildable = [e for e in allex if e in pool and
+ any(not build_utils.skip_example(e, b) or
+ not build_utils.skip_example(e, b, (), 'make')
+ for b in boards)]
+ 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
+ want = fam_ex.get(fam)
+ have = set(buildable)
+ kept = buildable if want is None else [e for e in want if e in have]
+ if not kept:
+ continue # this diff builds nothing for this family
+ out_fams.append(fam)
+ if set(kept) != set(buildable):
+ 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()
diff --git a/tools/get_deps.py b/tools/get_deps.py
index baaf3761f..12bec4861 100755
--- a/tools/get_deps.py
+++ b/tools/get_deps.py
@@ -33,7 +33,7 @@ deps_mandatory = {
deps_optional = {
'hw/mcu/allwinner': ['https://github.com/hathach/allwinner_driver.git',
'8e5e89e8e132c0fd90e72d5422e5d3d68232b756',
- 'fc100s'],
+ 'f1c100s'],
'hw/mcu/analog/msdk' : ['https://github.com/analogdevicesinc/msdk.git',
'b20b398d3e5e2007594e54a74ba3d2a2e50ddd75',
'maxim'],
@@ -108,7 +108,7 @@ deps_optional = {
'efm32'],
'hw/mcu/sony/cxd56/spresense-exported-sdk': ['https://github.com/sonydevworld/spresense-exported-sdk.git',
'2ec2a1538362696118dc3fdf56f33dacaf8f4067',
- 'spresense'],
+ 'cxd56'],
'hw/mcu/st/cmsis_device_c0': ['https://github.com/STMicroelectronics/cmsis_device_c0.git',
'517611273f835ffe95318947647bc1408f69120d',
'stm32c0'],
@@ -386,6 +386,10 @@ def main():
parser.add_argument('-f1', '--build-flags-on', action='append', default=[], help='Have no effect')
parser.add_argument('--build-name', default=None, help='Have no effect')
parser.add_argument('--cflag', action='append', default=[], help='Have no effect')
+ # build-matrix entries carry -e for tools/build.py; they reach get_deps.py
+ # verbatim (.github/actions/get_deps, build.yml's hil-hfp-iar) and an
+ # argparse error here reds the Get Dependencies step of every scoped PR
+ parser.add_argument('-e', '--example', action='append', default=[], help='Have no effect')
args = parser.parse_args()
families = args.families
diff --git a/tools/iar_template.ipcf b/tools/iar_template.ipcf
index 035e40b94..922b22426 100644
--- a/tools/iar_template.ipcf
+++ b/tools/iar_template.ipcf
@@ -81,9 +81,7 @@
</group>
<group name="src/class/vendor">
<path>$TUSB_DIR$/src/class/vendor/vendor_device.c</path>
- <path>$TUSB_DIR$/src/class/vendor/vendor_host.c</path>
<path>$TUSB_DIR$/src/class/vendor/vendor_device.h</path>
- <path>$TUSB_DIR$/src/class/vendor/vendor_host.h</path>
</group>
<group name="src/class/video">
<path>$TUSB_DIR$/src/class/video/video_device.c</path>
diff --git a/tools/metrics.py b/tools/metrics.py
index 0e29fc1ab..27c995954 100644
--- a/tools/metrics.py
+++ b/tools/metrics.py
@@ -98,6 +98,24 @@ def combine_files(input_files, filters=None):
if fin.endswith(".json"):
with open(fin, "r", encoding="utf-8") as f:
json_data = json.load(f)
+ if fin.endswith('_by_example.json') and isinstance(json_data, dict) and \
+ all(isinstance(v, dict) and 'files' in v for v in json_data.values()):
+ # a metrics_by_example.json: one data entry per example. Keyed on
+ # the filename, which IS the contract (write_by_example, the CMake
+ # rule and metrics_pair_compare all spell that suffix) - a shape
+ # sniff would silently reroute any coincidentally-shaped JSON.
+ for ex in sorted(json_data):
+ # same TOTAL scrub the shared path below applies: this branch
+ # `continue`s past it, so do it here or a by-example input keeps
+ # the fake TOTAL rows an ordinary input has stripped
+ sub = {'files': [f for f in json_data[ex]['files']
+ if str(f.get('file', '')).upper() != 'TOTAL']}
+ if filters:
+ sub['files'] = [f for f in sub['files']
+ if f.get('path') and any(x in f['path'] for x in filters)]
+ all_json_data['file_list'].append(f'{fin}:{ex}')
+ all_json_data['data'].append(sub)
+ continue
if filters:
json_data["files"] = [
f
@@ -316,6 +334,25 @@ def write_json_output(json_data, path):
json.dump(json_data, outf, indent=2)
+def write_by_example(all_json_data, path):
+ """{<role>/<example>: {files: [...]}} from the data combine_files already parsed
+ - re-reading and re-parsing every input a second time bought nothing.
+
+ Inputs are map.json files laid out as <build>/<role>/<example>/<name>.map.json
+ (examples/CMakeLists.txt's pattern), so the example name is the last two path
+ components; a metrics_by_example.json input already carries its own name in the
+ file_list entry ('<file>.json:<role>/<name>')."""
+ out = {}
+ for fin, data in zip(all_json_data["file_list"], all_json_data["data"]):
+ _, sep, ex = fin.partition('.json:')
+ if not sep:
+ d = os.path.dirname(os.path.abspath(fin))
+ ex = f'{os.path.basename(os.path.dirname(d))}/{os.path.basename(d)}'
+ out.setdefault(ex, {'files': []})['files'] += data.get('files', [])
+ with open(path, 'w', encoding='utf-8') as f:
+ json.dump(out, f)
+
+
def render_combine_table(json_data, sort_order='name+'):
"""Render averaged sizes as markdown table lines (no title)."""
files = json_data.get("files", [])
@@ -594,6 +631,8 @@ def cmd_combine(args):
if args.markdown_out:
write_combine_markdown(json_average, args.out + '.md', sort_order=args.sort,
title="TinyUSB Average Code Size Metrics")
+ if args.by_example:
+ write_by_example(all_json_data, args.out + '_by_example.json')
def cmd_compare(args):
@@ -633,6 +672,8 @@ def main(argv=None):
combine_parser.add_argument('-S', '--sort', dest='sort', default='size-',
choices=['size', 'size-', 'size+', 'name', 'name-', 'name+'],
help='Sort order: size/size- (descending), size+ (ascending), name/name+ (ascending), name- (descending). Default: size-')
+ combine_parser.add_argument('--by-example', dest='by_example', action='store_true',
+ help='Also write <out>_by_example.json: per-example file lists keyed by role/example')
# Compare subcommand
compare_parser = subparsers.add_parser('compare', help='Compare two metrics inputs (bloaty CSV or metrics JSON)')