summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHa Thach <[email protected]>2026-08-23 10:42:53 +0700
committerGitHub <[email protected]>2026-08-23 10:42:53 +0700
commitaa0f4a40b59012e6ab421949c153088ca4984df4 (patch)
tree4f4e06b386205185ea49e9af9586b6c7206c625a
parentfd70160a2f5fd23de1abfbaefb6399746a90b588 (diff)
parent050595d64f9f130783853a2342eb1114d32199e8 (diff)
Merge pull request #3843 from hathach/claude/ci-select-rule17HEADmaster
ci_select: classify the files that were reaching rule 17
-rw-r--r--docs/superpowers/specs/2026-08-19-ci-build-family-filter-design.md48
-rw-r--r--test/hil/test/test_ci_select.py167
-rwxr-xr-xtools/build_utils.py40
-rwxr-xr-xtools/ci_select.py79
4 files changed, 294 insertions, 40 deletions
diff --git a/docs/superpowers/specs/2026-08-19-ci-build-family-filter-design.md b/docs/superpowers/specs/2026-08-19-ci-build-family-filter-design.md
index 8f77dc50a..b10f5b4ae 100644
--- a/docs/superpowers/specs/2026-08-19-ci-build-family-filter-design.md
+++ b/docs/superpowers/specs/2026-08-19-ci-build-family-filter-design.md
@@ -46,29 +46,31 @@ never inflates one axis with another's breadth.
`FAM` = the families whose `family.cmake` references the changed path (CMake only — see below).
"roster boards" = boards on `test/hil/{tinyusb,hfp}.json`.
-| # | Changed path | Build families | Build examples | HIL boards → tests |
-| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
-| 1 | `docs/`, `.claude/`, `*.md`, `*.rst`, `LICENSE` | — | — | — |
-| 2 | `test/hil/**` | — | — | all boards → all tests |
-| 2b | `tools/metrics.py`, `.github/scripts/metrics_*.py` | `ALL` (unchanged — `tinyusb_metrics` runs `metrics.py` as a build target) | `ALL` | — (nothing on the rig 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 |
-| 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*.py`, `tools/cmake/**`, `hw/bsp/{family_support.cmake,board.c,board_api.h,ansi_escape.h}`, `.github/**` | `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 | `ALL` | `ALL` | all boards → all tests (fail-open) |
+| # | Changed path | Build families | Build examples | HIL boards → tests |
+| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
+| 1 | `docs/`, `.claude/`, `*.md`, `*.rst`, `LICENSE` | — | — | — |
+| 1b | `.gitignore`, `.clang-format`, `.idea/**`, `test/{fuzz,unit-test}/**`, non-build `.github/**`, packaging manifests | — | — | — |
+| 2 | `test/hil/**` | — | — | all boards → all tests |
+| 2b | `tools/metrics.py`, `.github/scripts/metrics_*.py` | `ALL` (unchanged — `tinyusb_metrics` runs `metrics.py` as a build target) | `ALL` | — (nothing on the rig 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}.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) |
**Rule 2 is deliberately asymmetric.** A `test/hil/**` change is invisible to the family matrix
but is exactly what the rig exercises, so it builds nothing and runs everything.
diff --git a/test/hil/test/test_ci_select.py b/test/hil/test/test_ci_select.py
index 8f1841531..a19392bde 100644
--- a/test/hil/test/test_ci_select.py
+++ b/test/hil/test/test_ci_select.py
@@ -840,6 +840,143 @@ class TestRostersDoNotOverlap(unittest.TestCase):
seen[b['name']] = b.get('tests')
+class TestTypecRule(unittest.TestCase):
+ """Rule 12b. src/typec/usbc.c is listed unconditionally by src/CMakeLists.txt and
+ src/tinyusb.mk, but its whole body is `#if CFG_TUC_ENABLED`, which only
+ examples/typec/power_delivery sets - so it is parsed by every build and compiled by
+ one. Same shape as the class rule, same answer. Before this rule it matched nothing
+ and force-fulled 82 families and all 30 rig boards."""
+
+ def test_build_axis_selects_only_the_typec_examples(self):
+ s = ci_select.classify_build(['src/typec/usbc.c'], REPO)
+ self.assertFalse(s['full'])
+ self.assertTrue(s['families'], 'typec must be compiled somewhere')
+ self.assertTrue(s['family_examples'], 'and the examples must be named')
+ for fam, exs in s['family_examples'].items():
+ self.assertTrue(exs, fam)
+ for e in exs:
+ self.assertTrue(e.startswith('typec/'), f'{fam}: {e} is not a typec example')
+
+ def test_every_typec_file_answers_the_same(self):
+ for f in ('src/typec/usbc.c', 'src/typec/usbc.h', 'src/typec/tcd.h',
+ 'src/typec/pd_types.h'):
+ s = ci_select.classify_build([f], REPO)
+ self.assertFalse(s['full'], f)
+ self.assertTrue(s['families'], f)
+
+ def test_no_rig_board_runs_typec(self):
+ # typec is not a HIL role, so the rig cannot exercise it whatever it selects
+ s = sel(['src/typec/usbc.c'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards'], {})
+
+ def test_it_tracks_the_enabling_config_rather_than_a_hardcoded_list(self):
+ # the answer must come from CFG_TUC_ENABLED in the example configs, so it
+ # follows a new typec example (or an old one switched off) on its own
+ want = ci_select.examples_enabling(
+ ci_select.role_examples(REPO, ('typec',)), ('CFG_TUC_ENABLED',), REPO)
+ self.assertTrue(want, 'no example enables CFG_TUC_ENABLED - rule 12b is dead')
+ got = set()
+ for exs in ci_select.classify_build(['src/typec/usbc.c'], REPO)['family_examples'].values():
+ got |= set(exs)
+ self.assertEqual(got, want)
+
+
+class TestCachesAreKeyedOnTheTree(unittest.TestCase):
+ """build_utils caches on repo-RELATIVE paths while ci_select._in_repo() chdirs
+ between trees, so the cwd has to be part of every cache key. Without it a second
+ tree gets the first tree's skip.txt/only.txt and FAMILY_MCUS - which is exactly the
+ base-vs-branch comparison the code-size skill does in one process."""
+
+ def test_a_second_tree_is_not_answered_from_the_first(self):
+ import build_utils, tempfile
+ old = os.getcwd()
+ try:
+ os.chdir(REPO)
+ self.assertFalse(build_utils.skip_example('host/bare_api', 'metro_m0_express'))
+ with tempfile.TemporaryDirectory() as d:
+ os.makedirs(os.path.join(d, 'hw/bsp'), exist_ok=True)
+ os.chdir(d)
+ # the board does not exist in this tree at all -> unknown board -> skip
+ self.assertTrue(build_utils.skip_example('host/bare_api', 'metro_m0_express'),
+ 'the empty tree was answered from the repo tree cache')
+ os.chdir(REPO)
+ self.assertFalse(build_utils.skip_example('host/bare_api', 'metro_m0_express'),
+ 'and the repo answer must survive the excursion')
+ finally:
+ os.chdir(old)
+
+
+class TestClassesWithNoEnablingExample(unittest.TestCase):
+ """The class rule is the one rule with no drift guard: ports, hw/mcu, get_deps
+ tokens and bsp families all have one. A class dir that no example config enables
+ selects NOTHING on both axes (the maintainer's empty-means-empty ruling), which is
+ right - but it must be a listed state, not a surprise, or a class added before its
+ first example silently stops being built."""
+
+ # class dirs no example's tusb_config.h turns on, for either role. Must only shrink:
+ # a new entry means a class nothing compiles, so a break in it reaches master.
+ NO_EXAMPLE = {'bth'}
+
+ def test_only_the_known_classes_select_nothing(self):
+ import glob as _glob
+ dead = set()
+ for d in sorted(_glob.glob(os.path.join(REPO, 'src/class/*'))):
+ if not os.path.isdir(d):
+ continue
+ cls = os.path.basename(d)
+ hit = False
+ for base in sorted(os.path.basename(f) for f in _glob.glob(os.path.join(d, '*.[ch]'))):
+ roles = ci_select._class_roles(base)
+ if ci_select._build_class_examples(cls, base, roles, REPO):
+ hit = True
+ break
+ if not hit:
+ dead.add(cls)
+ self.assertEqual(dead, self.NO_EXAMPLE,
+ 'a class dir enabled by no example config: it selects nothing on '
+ 'both axes, so nothing compiles it until the next master push')
+
+
+class TestNoTrackedFileIsUnclassified(unittest.TestCase):
+ """Rule 17 (unclassified -> full on both axes) is the fail-open net for paths nobody
+ anticipated. It must stay that way - a wrong `full` costs runner minutes and is
+ visible in the run, a wrong `empty` costs a merged regression and is invisible - but
+ nothing in the tree should REACH it. Every tracked file is classified by a rule, so
+ 17 fires only for genuinely new shapes, and this test is what tells the author to
+ write the row instead of letting the fall-through pick an answer for them.
+
+ Before this guard, 254 tracked files reached 17: .gitignore took a docs-only PR to
+ 74 cmake legs and the whole rig, while examples/<role>/CMakeLists.txt got the RIGHT
+ answer from the wrong rule - row 15 names it, the regex never matched it."""
+
+ def _unclassified(self, axis):
+ import subprocess as sp
+ r = sp.run(['git', 'ls-files'], cwd=REPO, capture_output=True, text=True)
+ if r.returncode != 0:
+ self.skipTest('not a git checkout')
+ files = r.stdout.split()
+ self.assertGreater(len(files), 1000, 'suspiciously few tracked files')
+ out = []
+ for f in files:
+ s = (ci_select.classify_build([f], REPO) if axis == 'build'
+ else ci_select.classify([f], REPO, real_rosters()))
+ if any('unclassified' in why for why in s['reasons']):
+ out.append(f)
+ return out
+
+ def test_build_axis(self):
+ left = self._unclassified('build')
+ self.assertEqual(left, [], f'{len(left)} tracked files fall through to rule 17 on '
+ f'the build axis, e.g. {left[:5]} - classify them, or '
+ f'add the pattern to _META_RE if no build reads them')
+
+ def test_hil_axis(self):
+ left = self._unclassified('hil')
+ self.assertEqual(left, [], f'{len(left)} tracked files fall through to rule 17 on '
+ f'the HIL axis, e.g. {left[:5]}')
+
+
class TestLibRule(unittest.TestCase):
"""lib/** is not a full-matrix path: only the examples that build the lib need it."""
@@ -1241,7 +1378,28 @@ class TestBuildClassifier(unittest.TestCase):
'tools/build.py', 'tools/cmake/cpu/cortex-m4.cmake',
'examples/CMakeLists.txt', 'examples/device/CMakeLists.txt',
'examples/build_system/cmake/cpu.cmake', '.github/workflows/build.yml',
- 'sonar-project.properties', 'some/unknown/path.c'):
+ '.circleci/config.yml', 'src/CMakeLists.txt', 'src/tinyusb.mk',
+ 'hw/bsp/family_support.mk', 'tools/build_utils.py',
+ 'some/unknown/path.c'):
+ self.assertTrue(self.b([p])['full'], p)
+
+ def test_repo_metadata_is_not_a_build_input(self):
+ # these used to reach `full` through rule 17: a PR touching only .gitignore and a
+ # README created 74 cmake legs and booked the whole rig. No Build step reads them.
+ for p in ('sonar-project.properties', '.gitignore', '.gitattributes',
+ '.clang-format', '.idea/misc.xml', 'version.yml', 'library.json',
+ 'examples/CMakePresets.json', 'test/fuzz/fuzz.cc',
+ 'test/unit-test/project.yml', '.github/workflows/pr_comment.yml',
+ 'tools/gen_doc.py'):
+ s = self.b([p])
+ self.assertFalse(s['full'], p)
+ self.assertEqual(s['families'], [], p)
+
+ def test_the_build_machinery_is_still_full(self):
+ # the other side of the same line: these DECIDE what gets built
+ for p in ('.circleci/config.yml', '.github/workflows/build.yml',
+ '.github/scripts/ci_set_matrix.py', 'tools/ci_select.py',
+ 'tools/build_utils.py', 'tools/metrics.py'):
self.assertTrue(self.b([p])['full'], p)
def test_mixed_diff_unions_per_family(self):
@@ -1313,11 +1471,12 @@ class TestBuildPostFilter(unittest.TestCase):
self.assertTrue(any('gone from tree' in r for r in s['reasons']), s['reasons'])
def test_class_source_selecting_nothing_selects_nothing(self):
- # synthetic class-with-no-enabling-config case (vendor_host.c was the live
- # instance until its removal): no config enables CFG_TUH_VENDOR, so
+ # a class-with-no-enabling-config case: no config enables CFG_TUH_VENDOR, so
# nothing exercises it and nothing builds - empty means empty (maintainer
# decision; the file is still parsed by every full master-push build, which is
- # the accepted net for a break outside its #if guard)
+ # the accepted net for a break outside its #if guard). src/class/bth is the
+ # live instance of this state today; TestClassesWithNoEnablingExample pins the
+ # whole set, so a new one cannot appear unnoticed.
s = ci_select.classify_build(['src/class/vendor/vendor_host.c'], REPO)
self.assertFalse(s['full'])
self.assertEqual(s['families'], [])
diff --git a/tools/build_utils.py b/tools/build_utils.py
index 1eeef0269..1b81335e0 100755
--- a/tools/build_utils.py
+++ b/tools/build_utils.py
@@ -1,5 +1,6 @@
#!/usr/bin/env python3
import functools
+import os
import subprocess
import pathlib
import re
@@ -24,7 +25,30 @@ _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*\)')
[email protected]_cache(maxsize=None)
+
+def _cwd_cache(fn):
+ """lru_cache, keyed on the working directory as well as the arguments.
+
+ Every cached helper below takes repo-RELATIVE paths ('hw/bsp/<fam>',
+ 'examples/<ex>/skip.txt', or the literal 'hw/bsp' glob), while ci_select._in_repo()
+ chdirs around each call so one process can classify more than one tree - the
+ code-size skill's base-vs-branch worktrees, /pre-pr, a test pointing at a fixture.
+ Without the cwd in the key the second tree silently gets the first tree's
+ skip.txt/only.txt and FAMILY_MCUS answers. Master had no caching here, so this
+ hazard arrived with it."""
+ cache = {}
+
+ @functools.wraps(fn)
+ def wrapper(*args):
+ key = (os.getcwd(), args)
+ if key not in cache:
+ cache[key] = fn(*args)
+ return cache[key]
+
+ wrapper.cache_clear = cache.clear
+ return wrapper
+
+@_cwd_cache
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
@@ -87,7 +111,7 @@ def _cmake_expand(value, files, depth=0):
return None if '${' in out else out
[email protected]_cache(maxsize=None)
+@_cwd_cache
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,
@@ -98,7 +122,7 @@ def _board_dirs(board):
return hits[0], hits[0].parent.parent
[email protected]_cache(maxsize=None)
+@_cwd_cache
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
@@ -175,7 +199,7 @@ def _family_mcus(family_dir, board_dir):
return frozenset(out)
[email protected]_cache(maxsize=None)
+@_cwd_cache
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
@@ -215,7 +239,7 @@ def _scrape_mcu(family_dir, board_dir, family):
return mcu, mk_contents
[email protected]_cache(maxsize=None)
+@_cwd_cache
def _board_mcu(board_dir, family_dir, family):
"""(CFG_TUSB_MCU of this board, MAX3421_HOST enabled by its cmake BSP).
@@ -254,7 +278,7 @@ def _board_mcu(board_dir, family_dir, family):
return mcu, max3421_enabled
[email protected]_cache(maxsize=None)
+@_cwd_cache
def _filter_tokens(path):
"""skip.txt / only.txt as a token set, or None when the file does not exist."""
f = pathlib.Path(path)
@@ -285,7 +309,7 @@ def skip_example(example, board, extra_defines=(), build_system='cmake'):
return _skip_example(example, board, tuple(extra_defines), build_system)
[email protected]_cache(maxsize=None)
+@_cwd_cache
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
@@ -333,7 +357,7 @@ def _skip_example_make(example, board):
return False
[email protected]_cache(maxsize=None)
+@_cwd_cache
def _skip_example(example, board, extra_defines, build_system):
if build_system == 'make':
return _skip_example_make(example, board)
diff --git a/tools/ci_select.py b/tools/ci_select.py
index ced3bbbc0..89a0d214c 100755
--- a/tools/ci_select.py
+++ b/tools/ci_select.py
@@ -54,6 +54,34 @@ def _read(path: str) -> str:
_NONCODE_RE = re.compile(
r'^(docs/|\.claude/|.*\.(md|rst)$|LICENSE)')
+# 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/|\.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$|'
+ r'.*/[0-9]+-tinyusb[^/]*\.rules$|tools/usb_drivers/|tools/codespell/|'
+ r'test/(fuzz|unit-test)/|'
+ # .github, minus the build machinery named in _FULL_RE
+ r'\.github/(FUNDING\.yml$|labeler\.yml$|membrowse_pr_message\.j2$|ISSUE_TEMPLATE/|'
+ r'workflows/(cifuzz|claude|claude-code-review|labeler|membrowse-comment|'
+ r'membrowse-onboard|pr_comment|pre-commit|static_analysis|trigger)\.yml$)|'
+ # tools/ scripts no build invokes (tools/build*.py and metrics are handled above)
+ r'tools/(build_doc|check_example_pids|file2carray|gen_doc|gen_presets|iar_gen|'
+ r'make_release|mksunxi|pcapng_to_corpus)\.py$|tools/iar_template\.ipcf$'
+ r')')
# 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.
@@ -66,9 +94,20 @@ _METRICS_RE = re.compile(
_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)$|'
+ # generates the whole CircleCI matrix, same authority as .github/**
+ r'\.circleci/|'
+ # rule 16 says `tools/build*.py`; name the two siblings the glob implies. Both
+ # decide what gets built, so neither can be trusted to narrow its own change.
+ r'tools/(build|build_utils|ci_select)\.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/)')
@@ -538,7 +577,7 @@ class _Sel:
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):
+ 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):
@@ -673,6 +712,11 @@ def _classify_one(path, repo_root, roster_boards, extras: set, s: _Sel,
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:
@@ -935,7 +979,8 @@ class _BSel:
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
+ if _NONCODE_RE.match(path) or _META_RE.match(path): # rule 1
+ 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')
@@ -1006,6 +1051,18 @@ def _classify_build_one(path, repo_root, s: _BSel, get_deps_families=None):
# 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: # lib rule
lib = m.group(1)
@@ -1018,7 +1075,19 @@ def _classify_build_one(path, repo_root, s: _BSel, get_deps_families=None):
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
+ if _METRICS_RE.match(path):
+ # HIL-suppressed above; on this axis they stay full - tools/metrics.py runs as
+ # the `tinyusb_metrics` build target, so a break in it fails the build
+ s.force_full(f'{path}: metrics tooling runs in the build -> 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