summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.circleci/config2.yml18
-rw-r--r--.github/workflows/build.yml24
-rw-r--r--.github/workflows/build_util.yml16
-rw-r--r--docs/superpowers/specs/2026-08-19-ci-build-family-filter-design.md16
-rw-r--r--test/hil/test/test_ci_metrics.py35
-rw-r--r--test/hil/test/test_ci_select.py24
-rwxr-xr-xtools/build.py29
-rwxr-xr-xtools/build_utils.py14
-rwxr-xr-xtools/ci_select.py73
-rw-r--r--tools/metrics.py15
10 files changed, 198 insertions, 66 deletions
diff --git a/.circleci/config2.yml b/.circleci/config2.yml
index 899cbe24a..2e69588ae 100644
--- a/.circleci/config2.yml
+++ b/.circleci/config2.yml
@@ -125,6 +125,15 @@ commands:
# shell-text interpolation (unsafe characters); family is a job
# parameter with charset [a-z0-9_], safe to interpolate directly.
EX_ARGS=$(printf '%s' "$EXAMPLE_MAP" | jq -r --arg fam "<< parameters.family >>" '(.[$fam] // []) | map("-e " + .) | join(" ")' 2>/dev/null) || EX_ARGS=''
+ # same screen as build_util.yml's: the values are example dir names from the
+ # PR checkout and $EX_ARGS is used unquoted below, so a glob metacharacter
+ # would pathname-expand against the build cwd. Dropping the filter builds
+ # everything - the safe direction, and what GHA does for the same input.
+ case "$EX_ARGS" in
+ *[!-A-Za-z0-9_/\ ]*)
+ echo "warning: unexpected characters in the example filter - building all examples"
+ EX_ARGS='' ;;
+ esac
if [ << parameters.toolchain >> == esp-idf ]; then
docker run --rm -v $PWD:/project -w /project espressif/idf:v5.5.3 python tools/build.py << parameters.build-args >> --target all $EX_ARGS << parameters.family >>
@@ -253,8 +262,13 @@ jobs:
if ls /tmp/metrics/*/*.json 1> /dev/null 2>&1; then
python tools/metrics.py combine -j -m -f tinyusb/src /tmp/metrics/*/*.json
else
- echo "No metrics files found"
- exit 1
+ # A scoped PR can legitimately build no metrics leg at all (every selected
+ # family empty, or none of them on a metrics toolchain), so this is not an
+ # error any more - it was, when the matrix was always the full 64 families.
+ # An empty file keeps store_artifacts and the compare step below honest:
+ # both would otherwise act on a missing path.
+ echo "No metrics files found - PR selection built no metrics leg"
+ echo '{"files": []}' > metrics.json
fi
- store_artifacts:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 2ee124cb3..39a4e7afd 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -68,9 +68,14 @@ jobs:
with:
fetch-depth: 0
+ # The `ci-full` PR label turns the scoping off for one PR: no selection file is
+ # written, so both matrices and every rig job fall back to the unscoped behaviour.
+ # An escape hatch is the point - a selector bug under-selects SILENTLY, and without
+ # a label the only routes back to a full matrix are accidental (touch an
+ # unclassified path, or break the selector badly enough that it falls open).
- name: CI selection (PR only)
id: hil-select
- if: github.event_name == 'pull_request'
+ if: github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'ci-full')
env:
BASE_REF: ${{ github.base_ref }}
run: |
@@ -166,8 +171,6 @@ jobs:
fi
fi
[ -z "$MATRIX_JSON" ] && MATRIX_JSON=$(python .github/scripts/ci_set_matrix.py)
- echo "matrix=$MATRIX_JSON"
- echo "matrix=$MATRIX_JSON" >> $GITHUB_OUTPUT
# Build-axis extras: the per-family example map rides as a side channel
# (a value inside matrix entries would break CircleCI's family parameter
@@ -188,12 +191,23 @@ jobs:
# silently match another family's baseline
case "$FAM_REGEX" in
*[!-A-Za-z0-9_\|]*)
- echo "::warning::unexpected characters in the family list - unscoped metrics"
+ echo "::warning::unexpected characters in the family list - dropping the scoping"
FAM_REGEX='' ;;
esac
- [ -z "$FAM_REGEX" ] && BUILD_FILTERED='false'
+ if [ -z "$FAM_REGEX" ]; then
+ # all three drop together, as CircleCI's fall-open does. Resetting only
+ # build_filtered leaves the build scoped while code-metrics takes the
+ # UNSCOPED branch, diffing a 1-family run against the full averaged
+ # baseline and publishing that as the PR's code-size impact.
+ BUILD_FILTERED='false'
+ EXAMPLE_MAP='{}'
+ MATRIX_JSON=$(python .github/scripts/ci_set_matrix.py)
+ fi
fi
fi
+ # emitted once, after every path that can still change it
+ echo "matrix=$MATRIX_JSON"
+ echo "matrix=$MATRIX_JSON" >> $GITHUB_OUTPUT
echo "example_map=$EXAMPLE_MAP" >> $GITHUB_OUTPUT
echo "build_filtered=$BUILD_FILTERED" >> $GITHUB_OUTPUT
echo "build_families_regex=$FAM_REGEX" >> $GITHUB_OUTPUT
diff --git a/.github/workflows/build_util.yml b/.github/workflows/build_util.yml
index dfbd83ee2..52999616d 100644
--- a/.github/workflows/build_util.yml
+++ b/.github/workflows/build_util.yml
@@ -126,14 +126,16 @@ jobs:
MEMBROWSE_API_KEY: ${{ secrets.MEMBROWSE_API_KEY }}
run: |
# if code-changed is false --> there is no elf -> membrowse target upload with --identical flag
- # Deliberately NOT scoped by $EX_ARGS: <TARGET>-membrowse-upload has no
- # DEPENDS (hw/bsp/family_support.cmake), so the aggregate rebuilds nothing -
- # it just records every example, reporting the ones with an elf and
- # --identical for the rest. Filtering it here would drop the excluded
- # examples from the dataset membrowse-comment.yml reports against, instead
- # of recording them as unchanged.
+ # $EX_ARGS is passed for the BOARD it picks, not to scope the targets:
+ # --one-first now chooses a board that can build the -e set (tools/build.py),
+ # so omitting it here would configure a DIFFERENT, empty build dir and upload
+ # --identical for a board that was never compiled. The target list is not
+ # scoped by it - `examples-membrowse-upload` is not `all`, so it passes
+ # through as the aggregate, which has no DEPENDS (hw/bsp/family_support.cmake):
+ # it rebuilds nothing and still records every example, --identical for the
+ # ones without an elf.
BUILD_PY_ARGS="-s ${{ inputs.build-system }} ${{ steps.setup-toolchain.outputs.build_option }} ${{ inputs.build-options }}"
- python tools/build.py $BUILD_PY_ARGS --target examples-membrowse-upload -j 1 ${{ matrix.arg }}
+ python tools/build.py $BUILD_PY_ARGS --target examples-membrowse-upload -j 1 ${{ matrix.arg }} $EX_ARGS
shell: bash
- name: Upload Artifacts for Metrics
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 9fa358bee..8f77dc50a 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
@@ -211,16 +211,16 @@ It falls open to the full matrix whenever the entries are not the whole answer:
* the file will not parse;
* there is no base content: `--diff-file` mode has no git, so no merge-base blob;
* a changed entry carries a family token that names no `hw/bsp/<dir>` and is not one of the
- eight known aliases. "Changed but unmappable" is not "nothing changed": reading it as the
+ known aliases. "Changed but unmappable" is not "nothing changed": reading it as the
latter empties the whole build matrix for a dep bump.
-The eight known aliases (`sam3x`, `samd21`, `samd51`, `same5x`, `fc100s`, `spresense`,
-`stm32l1`, `stm32l5`) are pinned in `_DEPS_ALIAS_TOKENS` and select nothing. `get_deps` matches
-a token against a requested family name verbatim (`f in entry[2].split()`), so these tokens
-match nothing there either — four are pre-rename spellings listed beside the current name in
-the same entry, two point at a differently-named family dir (`fc100s`→`f1c100s`,
-`spresense`→`cxd56`, both unreachable in `get_deps` itself), and two name no family in the tree.
-A ninth appearing fails `TestOrphanInvariant`.
+The six known aliases (`sam3x`, `samd21`, `samd51`, `same5x`, `stm32l1`, `stm32l5`) are
+pinned in `_DEPS_ALIAS_TOKENS` and select nothing. `get_deps` matches a token against a
+requested family name verbatim (`f in entry[2].split()`), so these tokens match nothing
+there either — four are pre-rename spellings listed beside the current name in the same
+entry, and two name no family in the tree. (`fc100s` and `spresense` were on this list
+until they were corrected in `get_deps.py`; those two were the only ones that left a
+real dep unreachable for its own family.) A seventh appearing fails `TestOrphanInvariant`.
## Component: `tools/ci_select.py`
diff --git a/test/hil/test/test_ci_metrics.py b/test/hil/test/test_ci_metrics.py
index 89d03aaae..6c236e827 100644
--- a/test/hil/test/test_ci_metrics.py
+++ b/test/hil/test/test_ci_metrics.py
@@ -58,13 +58,16 @@ class TestByExample(unittest.TestCase):
'-o', out, os.path.join(td, '*', '*', '*.map.json')], check=True)
out2 = os.path.join(td, 'sub')
r = subprocess.run([sys.executable, METRICS, 'combine', '-q', '-j',
- '--only-examples', 'device/cdc_msc',
'-o', out2, out + '_by_example.json'],
capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stderr)
sub = json.load(open(out2 + '.json'))
names = {f['file'] for f in sub['files']}
- self.assertEqual(names, {'usbd.c', 'cdc_device.c'}) # bare_api filtered out
+ # one data entry per example, not one blob: reading it as an ordinary
+ # metrics.json would double-count every file
+ self.assertIn('usbd.c', names)
+ self.assertIn('cdc_device.c', names)
+ self.assertNotIn('TOTAL', {n.upper() for n in names})
def test_by_example_expansion_is_keyed_on_the_filename(self):
# the '_by_example.json' suffix IS the contract (write_by_example, the CMake
@@ -339,9 +342,15 @@ class TestWorkflowSelectionHandOff(unittest.TestCase):
# with secrets - and for run_*, flips which rig jobs execute
for name in ('EX_ARGS', 'ARTIFACT_TAG'):
self.assertIn(f'echo "{name}=', self.util)
- self.assertEqual(self.util.count('case "$EX_ARGS" in') +
- self.util.count('case "$TAG" in'), 2,
- 'both GITHUB_ENV writes must screen their value first')
+ # per guard, not a sum: `count(a) + count(b) == 2` stays green when one guard is
+ # deleted and the other duplicated
+ for guard in ('case "$EX_ARGS" in', 'case "$TAG" in'):
+ self.assertEqual(self.util.count(guard), 1,
+ f'{guard}: each GITHUB_ENV write screens its value exactly once')
+ # CircleCI builds from the same PR-derived map and uses $EX_ARGS unquoted
+ cci = open(os.path.join(CIRCLECI, 'config2.yml')).read()
+ self.assertIn('case "$EX_ARGS" in', cci,
+ 'the CircleCI copy of the example filter needs the same screen')
self.assertIn('case "$BUILD_ARGS" in', self.build)
self.assertIn('unexpected characters in the " + key', self.build,
'the args_*/run_* emitter must screen each board filter')
@@ -429,12 +438,16 @@ class TestWorkflowSelectionHandOff(unittest.TestCase):
self.assertEqual(matrix.count('ci_set_matrix: UNSCOPED'), 2,
'every fall-open path must print the marker build.yml greps for')
- def test_membrowse_upload_is_not_scoped(self):
- # <TARGET>-membrowse-upload has no DEPENDS, so the aggregate rebuilds nothing -
- # it records every example, --identical for the ones without an elf. Scoping it
- # drops the excluded examples from the dataset instead of marking them unchanged.
- upload = self.util[self.util.index('--target examples-membrowse-upload'):]
- self.assertNotIn('$EX_ARGS', upload.split('\n')[0])
+ def test_membrowse_upload_sees_the_same_board_as_the_build(self):
+ # $EX_ARGS is passed for the BOARD it selects: --one-first picks a board that can
+ # build the -e set, so without it membrowse configures a different, empty build
+ # dir and uploads --identical for a board that was never compiled. It does NOT
+ # scope the targets - `examples-membrowse-upload` is not `all`, so it passes
+ # through as the aggregate, which has no DEPENDS and still records every example.
+ line = [l for l in self.util.splitlines()
+ if '--target examples-membrowse-upload' in l][0]
+ self.assertIn('$EX_ARGS', line)
+ self.assertNotIn('-e ', line.replace('$EX_ARGS', ''))
if __name__ == '__main__':
diff --git a/test/hil/test/test_ci_select.py b/test/hil/test/test_ci_select.py
index 74e5f48e6..031e8e287 100644
--- a/test/hil/test/test_ci_select.py
+++ b/test/hil/test/test_ci_select.py
@@ -777,6 +777,24 @@ class TestOrphanInvariant(unittest.TestCase):
for v in vendors:
self.assertTrue(ci_select.mcu_families(v + '/x.c', REPO), f'{v}: resolves to no family')
+ # hw/bsp families ci_set_matrix's family_list does not map to any toolchain. Before
+ # scoping these were harmless - the matrix was always every family in family_list,
+ # so a PR touching one of them still compiled the other 64. Now the selection
+ # intersects to nothing and every leg skips, so a family landing here by accident is
+ # a silent hole. espressif is deliberate: its boards are built by hil-build-esp,
+ # keyed on board name rather than family.
+ UNBUILT_FAMILIES = {'cxd56', 'efm32', 'espressif', 'f1c100s', 'pic32mz', 'py32f0',
+ 'same7x'}
+
+ def test_every_bsp_family_is_in_the_ci_matrix(self):
+ sys.path.insert(0, os.path.join(REPO, '.github/scripts'))
+ import ci_set_matrix
+ fams = set(ci_select.all_bsp_families(REPO))
+ self.assertEqual(fams - set(ci_set_matrix.family_list), self.UNBUILT_FAMILIES,
+ 'a hw/bsp family that no toolchain in ci_set_matrix.family_list '
+ 'builds: a PR touching only it now selects zero build legs. Wire '
+ 'it into family_list, or add it here with a reason.')
+
def test_every_get_deps_family_token_resolves_or_is_a_known_alias(self):
"""Same drift guard, dep side. A token naming no hw/bsp dir makes the entry
unreachable for its family in get_deps.py itself (`f in entry[2].split()`), and
@@ -1132,8 +1150,14 @@ class TestBuildClassifier(unittest.TestCase):
# real feather_rp2040_max3421 board) and espressif's component CMakeLists also
# references it — so the raw (unpruned) scan legitimately finds both; Task 4's
# buildability post-filter is what may later prune either away
+ # non-empty FIRST: a subset assertion is satisfied by set(), and since ports are
+ # now empty-means-empty (fail-closed) an unnoticed regression to zero families
+ # would select no build leg at all and merge an uncompiled HCD
+ self.assertTrue(s['families'], 'a host-port change must select some family')
self.assertLessEqual(set(s['families']), {'espressif', 'rp2040'})
+ self.assertTrue(s['family_examples'], 'and must name the examples for them')
for exs in s['family_examples'].values():
+ self.assertTrue(exs)
self.assertFalse(any(e.startswith(('device/', 'typec/')) for e in exs))
def test_port_shared_file_selects_all_examples(self): # rule 5
diff --git a/tools/build.py b/tools/build.py
index e7ca1c839..eeefca22d 100755
--- a/tools/build.py
+++ b/tools/build.py
@@ -299,7 +299,8 @@ def build_boards_list(boards, build_defines, build_system, build_name, build_cfl
return ret
-def get_family_boards(family, one_random, one_first, examples=None, build_system='cmake'):
+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:
@@ -314,13 +315,23 @@ def get_family_boards(family, one_random, one_first, examples=None, build_system
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, [])
@@ -339,9 +350,16 @@ def get_family_boards(family, one_random, one_first, examples=None, build_system
# 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, (), build_system) for e in examples)
+ not build_utils.skip_example(e, board, extra_defines, build_system)
+ for e in examples)
- if preferred_list and buildable(preferred_list[0]):
+ # 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:
@@ -434,7 +452,8 @@ 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, examples, build_system))
+ 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,
diff --git a/tools/build_utils.py b/tools/build_utils.py
index 2af8fd624..1eeef0269 100755
--- a/tools/build_utils.py
+++ b/tools/build_utils.py
@@ -141,9 +141,12 @@ def _family_mcus(family_dir, board_dir):
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():
@@ -156,16 +159,23 @@ def _family_mcus(family_dir, board_dir):
depth += 1
elif re.match(r'endif\s*\(', line):
depth = max(0, depth - 1)
- if not out:
+ 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
+ # 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
diff --git a/tools/ci_select.py b/tools/ci_select.py
index d253f8c01..cd63899c1 100755
--- a/tools/ci_select.py
+++ b/tools/ci_select.py
@@ -42,6 +42,7 @@ 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
@@ -211,17 +212,25 @@ def path_families(rel_dir: str, repo_root: str) -> set:
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/...'."""
- fams = set()
- bsp_root = os.path.join(repo_root, 'hw/bsp')
pat = re.compile(re.escape(rel_dir) + r'(?=[/\s"\')}]|$)', re.M)
- for f in glob.glob(os.path.join(bsp_root, '*/family.cmake')) + \
- glob.glob(os.path.join(bsp_root, '*/components/*/CMakeLists.txt')):
+ 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:
- if pat.search(_read(f)):
- fams.add(os.path.relpath(f, bsp_root).split(os.sep, 1)[0])
+ out.append((os.path.relpath(f, bsp_root).split(os.sep, 1)[0], _read(f)))
except OSError:
pass
- return fams
+ return tuple(out)
def port_families(port_dir: str, repo_root: str) -> set:
@@ -348,10 +357,14 @@ def class_include_edges(repo_root: str) -> dict:
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 only (it splits DFU from DFU_RUNTIME per file); pass '' for
- a class reached through an include edge, where the widest set is correct."""
+ `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':
@@ -360,7 +373,18 @@ def class_macros(cls: str, base: str, prefix: str) -> list:
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']
- return [f'CFG_{prefix}_{cls.upper()}']
+ 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
@@ -407,7 +431,7 @@ def _class_roles(base: str) -> set:
def _config_enables(cfg_path: str, macros) -> bool:
try:
- with open(cfg_path) as f:
+ with open(cfg_path, encoding='utf-8', errors='replace') as f:
text = f.read()
except OSError:
return False
@@ -435,15 +459,23 @@ def lib_examples(lib_name: str, repo_root: str) -> set:
'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, so a family-file scan
- would wrongly narrow it to three families instead of answering 'nobody'."""
+ 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 ('CMakeLists.txt', 'Makefile'):
+ 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:
- with open(os.path.join(repo_root, 'examples', ex, f)) as fh:
- text = fh.read()
+ text = _read(f)
except OSError:
continue
if pat.search(text):
@@ -804,7 +836,7 @@ def main():
repo_root = _REPO_ROOT
rosters = []
for c in a.configs:
- with open(c) as f:
+ 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
@@ -1029,7 +1061,12 @@ def _prune_buildable(fams, fam_ex, repo_root):
reasons.append(f'{fam}: family dir gone from tree, dropped')
continue
try:
- boards = build_py.get_family_boards(fam, False, False)
+ # 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
diff --git a/tools/metrics.py b/tools/metrics.py
index b97b2b206..27c995954 100644
--- a/tools/metrics.py
+++ b/tools/metrics.py
@@ -83,7 +83,7 @@ def parse_bloaty_csv(csv_text, filters=None):
return {"files": files, "TOTAL": total_all}
-def combine_files(input_files, filters=None, only_examples=None):
+def combine_files(input_files, filters=None):
"""Combine multiple metrics inputs (bloaty CSV or metrics JSON) into a single data set."""
filters = filters or []
@@ -105,9 +105,11 @@ def combine_files(input_files, filters=None, only_examples=None):
# 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):
- if only_examples and ex not in only_examples:
- continue
- sub = {'files': list(json_data[ex]['files'])}
+ # 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)]
@@ -614,8 +616,7 @@ def render_compare_table(rows, include_sum):
def cmd_combine(args):
"""Handle combine subcommand."""
input_files = expand_files(args.files)
- only_examples = set(args.only_examples.split(',')) if args.only_examples else None
- all_json_data = combine_files(input_files, args.filters, only_examples=only_examples)
+ all_json_data = combine_files(input_files, args.filters)
json_average = compute_avg(all_json_data)
if json_average is None:
@@ -673,8 +674,6 @@ def main(argv=None):
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')
- combine_parser.add_argument('--only-examples', dest='only_examples', default='',
- help='Comma-separated role/example ids to keep when reading by-example JSON inputs')
# Compare subcommand
compare_parser = subparsers.add_parser('compare', help='Compare two metrics inputs (bloaty CSV or metrics JSON)')