From 03a329eeda09e073d7de9be84df55d65392e4013 Mon Sep 17 00:00:00 2001 From: hathach Date: Fri, 21 Aug 2026 22:17:51 +0700 Subject: ci_select: classify the 254 files that were reaching rule 17 Rule 17 (unclassified -> full on both axes) is the fail-open net for paths nobody anticipated, and 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, and 254 tracked files did. The cost was real. PR #3842 changed a skill, a README and .gitignore; .gitignore matched no rule, so both axes went full and 74 cmake legs span up runners to do checkout + toolchain + get_deps before skipping the build, plus the whole 30-board rig. Three changes, none of which touch rule 17 itself: 1. _META_RE - repo metadata and tooling no Build step reads: .gitignore, .gitattributes, .clang-format, .codespellrc, .pre-commit-config.yaml, .readthedocs.yaml, .PVS-Studio/, .idea/, sonar-project.properties, the packaging manifests, CMakePresets, udev rules, test/{fuzz,unit-test} (their own jobs build those), the non-build .github/ files, and the tools/*.py scripts no build invokes. Deliberately NOT included, and still full: .circleci/**, .github/workflows/build*.yml, .github/actions/**, .github/scripts/**. The line is "does a Build step read this", not "is it source". 2. Rules 15 and 16 now match what they already claimed. Row 15 names examples//CMakeLists.txt and the regex never had it; row 16 says tools/build*.py but anchored tools/build\.py$. Both got the right answer only because rule 17 caught them on the way past. Also names their siblings - family_support.mk, family_rules.mk, src/CMakeLists.txt, src/tinyusb.mk - and .circleci/**, which generates the whole CircleCI matrix and was in no row at all. 3. src/typec/** gets row 12b. It is listed unconditionally by both build systems but its body is `#if CFG_TUC_ENABLED`, which only examples/typec/power_delivery sets - the same shape as the class rule, so the same answer: the examples that enable it (stm32g4 and stm32u5 after the buildability prune), and nothing on the rig, which runs no typec test. It was force-fulling 82 families and all 30 boards. TestNoTrackedFileIsUnclassified walks every tracked file and asserts none reaches rule 17, on both axes - 254 -> 0. Verified it fails when a new unclassified path appears. That turns 17 into what it should be: unreachable for anything in the tree, so it fires only for genuinely new shapes, and the author is told to write the row rather than letting the fall-through pick an answer for them. test_full_paths used sonar-project.properties as its stand-in for "unclassified"; that is now metadata, so the case moved to the new test_repo_metadata_is_not_a_build_input, with test_the_build_machinery_is_still_full pinning the other side of the line. --- test/hil/test/test_ci_select.py | 62 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) (limited to 'test') diff --git a/test/hil/test/test_ci_select.py b/test/hil/test/test_ci_select.py index 8f1841531..66b20b2e4 100644 --- a/test/hil/test/test_ci_select.py +++ b/test/hil/test/test_ci_select.py @@ -840,6 +840,45 @@ class TestRostersDoNotOverlap(unittest.TestCase): seen[b['name']] = b.get('tests') +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//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 +1280,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): -- cgit v1.3.1 From 6ff0ef97702c0e6b6d17b7a8fe856b31164efe57 Mon Sep 17 00:00:00 2001 From: hathach Date: Fri, 21 Aug 2026 23:14:25 +0700 Subject: build_utils: key the caches on the tree, not just the arguments The eight lru_cache layers take repo-RELATIVE paths - 'hw/bsp/', 'examples//skip.txt', the literal 'hw/bsp' glob - while ci_select._in_repo() chdirs around every call so one process can classify more than one tree. With no cwd in the key the second tree gets the first tree's answers. Reproduced: skip_example('host/bare_api','metro_m0_express') is False at the repo root and STILL False after chdir into a tree where that board does not exist; only cache_clear() gave the right answer. It bites the code-size skill's base-vs-branch worktree compare, /pre-pr, and the first test that points classify_build at a fixture tree. Master had no caching here, so the hazard arrived with it. _cwd_cache puts os.getcwd() in the key. The 199-test suite passed before only because every test happens to pass the real REPO; the new TestCachesAreKeyedOnTheTree crosses trees deliberately. Also adds the drift guard the class rule was missing. Ports, hw/mcu, get_deps tokens and bsp families each have one; the class rule had only a comment claiming vendor_host.c was the sole "enabled by no example config" case until its removal - which src/class/bth falsifies today. TestClassesWithNoEnablingExample pins the set to {bth}, so a class added before its first example, or an example config flipped to 0, fails here instead of silently selecting nothing on both axes. Verified it fires by adding a class dir nothing enables. --- test/hil/test/test_ci_select.py | 63 +++++++++++++++++++++++++++++++++++++++-- tools/build_utils.py | 40 ++++++++++++++++++++------ 2 files changed, 92 insertions(+), 11 deletions(-) (limited to 'test') diff --git a/test/hil/test/test_ci_select.py b/test/hil/test/test_ci_select.py index 66b20b2e4..9b2c61ef2 100644 --- a/test/hil/test/test_ci_select.py +++ b/test/hil/test/test_ci_select.py @@ -840,6 +840,62 @@ class TestRostersDoNotOverlap(unittest.TestCase): seen[b['name']] = b.get('tests') +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 @@ -1373,11 +1429,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*\)') -@functools.lru_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/', + 'examples//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 -@functools.lru_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 -@functools.lru_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) -@functools.lru_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 -@functools.lru_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 -@functools.lru_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) -@functools.lru_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 -@functools.lru_cache(maxsize=None) +@_cwd_cache def _skip_example(example, board, extra_defines, build_system): if build_system == 'make': return _skip_example_make(example, board) -- cgit v1.3.1 From 050595d64f9f130783853a2342eb1114d32199e8 Mon Sep 17 00:00:00 2001 From: hathach Date: Sat, 22 Aug 2026 23:10:14 +0700 Subject: ci_select: address Copilot review - anchor _META_RE, cover rule 12b Anchor the .github file alternatives. FUNDING.yml, labeler.yml and membrowse_pr_message.j2 sat inside a group whose only `$` belonged to the workflows/ branch, so they matched as prefixes: .github/labeler.yml.bak and .github/FUNDING.yml.old were classified as metadata and would have selected nothing. No such file exists today - the workflows/ alternative was already anchored and ISSUE_TEMPLATE/ is a directory prefix on purpose. Rule 12b had no test of its own: TestNoTrackedFileIsUnclassified only proved src/typec no longer reaches rule 17, not that the answer is right. TestTypecRule pins it - non-full, every selected example under typec/, all four src/typec files answering alike, no rig board, and the set derived from CFG_TUC_ENABLED rather than hardcoded, so it follows a new typec example on its own. Verified all four fail with rule 12b removed. --- test/hil/test/test_ci_select.py | 42 +++++++++++++++++++++++++++++++++++++++++ tools/ci_select.py | 2 +- 2 files changed, 43 insertions(+), 1 deletion(-) (limited to 'test') diff --git a/test/hil/test/test_ci_select.py b/test/hil/test/test_ci_select.py index 9b2c61ef2..a19392bde 100644 --- a/test/hil/test/test_ci_select.py +++ b/test/hil/test/test_ci_select.py @@ -840,6 +840,48 @@ 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 diff --git a/tools/ci_select.py b/tools/ci_select.py index cf5a0da5b..89a0d214c 100755 --- a/tools/ci_select.py +++ b/tools/ci_select.py @@ -75,7 +75,7 @@ _META_RE = re.compile( 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'\.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) -- cgit v1.3.1