ci: code size comment from stored master snapshots, ESP included (#4055)

- Size pinned boards' examples (ESP-IDF and -DMA variants included) with membrowse 1.2.12 and store per-board snapshots as artifacts.
- Compare a PR against the stored master snapshots and post one sticky comment: each changed TinyUSB file with its min to max Flash/RAM delta, plus what was not compared; the full report goes to the job summary and an artifact.
- Retire the Membrowse PR comment.
- Add tools/code_size.py report/diff for local runs, and the code-size skill.
diff --git a/.claude/skills/code-size/SKILL.md b/.claude/skills/code-size/SKILL.md
index 11df41b..5c78c69 100644
--- a/.claude/skills/code-size/SKILL.md
+++ b/.claude/skills/code-size/SKILL.md
@@ -1,16 +1,11 @@
 ---
 name: code-size
-description: Use when sizing TinyUSB examples per file, section or symbol (report), or diffing them between a base ref (master by default) and the working tree to evaluate the size impact of changes (diff) — one example on one board, all examples on one board, or every CI-pinned board combined — sized by membrowse, linkermap or bloaty.
+description: Use when sizing TinyUSB examples per file, section or symbol (report), diffing them against a base ref to judge a change's size impact (diff), or reading or debugging the PR's "Code size" comment (CI snapshot/compare).
 ---
 
 # Code Size
 
-`tools/code_size.py` has two subcommands:
-
-- **`report`** builds the working tree, uncommitted changes included, and tabulates each elf as linkermap does: a row per file, a column per output section, size and % of that elf's filtered total.
-- **`diff`** builds a base ref (default `master`) in a temporary worktree and the working tree, pairs each base elf with the current elf of the same (board, elf path) and reports per-file deltas per pair: a Flash/RAM table and the same section table as signed deltas, changed rows only.
-
-Sizes are never averaged or summed across examples or boards. Pick the narrowest scope that exercises the change:
+`tools/code_size.py report` sizes the working tree, uncommitted changes included. `diff` builds a base ref (default `master`) in a temporary worktree and the working tree, pairs elfs by board and elf path, and reports per-file deltas. Pick the narrowest scope that exercises the change; sizes are never summed or averaged across examples or boards.
 
 | Scope                             | Command                                                        |
 |-----------------------------------|----------------------------------------------------------------|
@@ -19,41 +14,36 @@
 | all examples, CI-pinned, combined | `python3 tools/code_size.py diff --ci`                         |
 | one tree, no diff                 | `python3 tools/code_size.py report -b BOARD -e device/cdc_msc` |
 
-The base worktree symlinks this checkout's fetched dependencies, so a `tools/get_deps.py` pin bump's own size change is not in the diff.
+The base worktree symlinks this checkout's fetched dependencies, so a `tools/get_deps.py` pin bump's own size change is not in the diff. A local `report` or `diff` cannot size a `-D` variant build.
 
-## Arguments
+## Setup and choices
 
-- **`-b BOARD`**, repeatable. For an iterative check with no board named, pass `-b raspberry_pi_pico`.
-- **`-e <group>/<name>`**, repeatable; omit for all examples.
-- **`--engine`**, membrowse unless asked:
+Flags and syntax: `python3 tools/code_size.py <command> --help`. The choices:
 
-  | Engine                | Per-file sizes from                                                 | Needs                                       |
-  |-----------------------|---------------------------------------------------------------------|---------------------------------------------|
-  | `membrowse` (default) | `membrowse report --json --all-symbols` symbols                     | `pip install membrowse`                     |
-  | `linkermap`           | the GNU ld map's input sections, by object path                     | `python3 tools/get_deps.py tools/linkermap` |
-  | `bloaty`              | `bloaty -d compileunits,sections,symbols` VM sizes, by compile unit | `bloaty` on PATH                            |
+- No board named for an iterative check: `-b raspberry_pi_pico`. `-f` only if asked.
+- `--engine` membrowse unless asked (`pip install membrowse`; `membrowse==1.2.12` matches CI's attribution); linkermap needs `python3 tools/get_deps.py tools/linkermap`, bloaty needs `bloaty` on PATH. Flash/RAM come from the elf's headers for every engine. Membrowse's all-symbol total can overlap aliases and omit padding, so compare whole-elf totals only within one engine.
+- `--symbols` when Flash/RAM cancel: a pair counts as changed when its sections or symbols moved.
+- `--ci` for "all boards" / "CI"; it needs the arm, riscv, msp430 and ft9xx toolchains, and ESP-IDF or docker.
+- An espressif board builds each example `tools/build.py` picks as its own ESP-IDF project: with the exported ESP-IDF (`. "$IDF_PATH/export.sh"`), else in CI's image through docker, as your user (once: `docker pull espressif/idf:v5.5.3 && docker tag espressif/idf:v5.5.3 espressif/idf:tinyusb`, about 8.4 GB).
 
-  Every engine takes flash/RAM from the elf's headers (a section copied from flash counts in both). The whole-elf total counts different things per engine (membrowse's all-symbol sum overlaps aliases and omits padding), so compare it only within one.
-- **`--symbols`**: lists each file's symbols under it (`└ name`); without it the table stops at files. linkermap's rows are input sections (`.text.cdcd_open`), not symbols. A diff counts a pair changed when its sections or, with `--symbols`, symbols moved even if Flash/RAM cancel.
-- **`--json`**: also writes each report's raw sizes (paired, with the base SHA, for diff), filters and failures as `.json` beside the `.md`; symbols only with `--symbols`.
-- **`-f SUBSTRING`**, only if asked: replaces the default filter, each build's absolute `<checkout>/src/` path, which matches TinyUSB code and no vendored `src/`.
-- diff only:
-  - **`--base-branch <ref>`**: any branch, tag or commit.
-  - **`--combined`**: also one report over every `-b` board.
-  - **`--ci`**, for "all boards" / "CI": adds the `.github/ci-pinned-boards.json` boards, covering every dcd/hcd driver not waived there, and implies `--combined`; needs the arm, riscv and msp430 toolchains.
-  - **`--bloaty`**, with `-e` only: also prints bloaty's section and symbol diff to stdout.
+## Results
 
-## Outputs and timing
+Reports go under `cmake-code-size/<board>/`, combined diffs under `cmake-code-size/_combined/`. A local command exits nonzero on a build or report failure or when no TinyUSB file matched; `report` also when it sized no elf, `diff` when it compared no pair. A failed build prints an excerpt on the console; its report records the first compiler, linker or CMake error.
 
-Reports go to `cmake-code-size/<board>/{report,diff}[_<example>].md` and, when combined, `cmake-code-size/_combined/diff.md`. The exit code is nonzero on a failure, when no pair was compared (diff) or no elf of a scope matched a file. The console shows each phase's time, one result line per scope and, for one board and one `-e` example, its changed tables; a failed build prints an excerpt of its output there, and its report and JSON record its first compiler, linker or CMake error, otherwise a fallback message.
+Approximate times: one diff example ~30 s, one board ~1-1.5 min, an espressif board 10+ min, `--ci` 30+ min. A report is about half a diff. A diff rebuilds both trees every run, so ask for `--symbols` or `--bloaty` in the same run. Run a long sweep in the background.
 
-A report builds one tree, about half a diff's time. One diff example ~30 s; one board ~60-90 s; `--ci` ~7-8 min, boards built one after another — run it in the background, it nears the 10-minute command timeout.
+Each report opens with a coverage line. `INCOMPLETE` means a build, measurement or filter match failed, or an elf exists on one side only; those elfs are listed and excluded, never counted as zero. Show the coverage line and the relevant tables, then:
 
-## Reporting results
+- A many-elf report summarizes each elf, a many-pair diff each file; per-elf tables are in the `.md`'s `<details>`. Diff statistics cover compared pairs only: `Changed / present` is the pairs whose Flash or RAM of the file changed over the compared pairs containing it. min > 0 means growth in every such pair, max > 0 in at least one; name the worst-growth pair.
+- A whole-elf Δ with a zero TinyUSB Δ (`filtered` under a custom `-f`) is unfiltered code (inlined headers, example/BSP code) or an attribution difference; check the per-file table for rows that cancel.
+- Corroborate a surprising delta on its own board and example, not the whole sweep: `-b <board> -e <example> --engine linkermap`, plus `--bloaty` if bloaty is on PATH.
 
-Each report opens with a coverage line; `INCOMPLETE` means a build, report or filter match failed, or an elf exists on one side only (listed, outside every statistic). A many-pair diff counts each file whose Flash or RAM changed as `Changed / present` pairs with its min/max Δ naming the pair; a many-elf report lists each elf's totals. Per-elf tables are then in the `.md`'s `<details>` only.
+An ESP-IDF app is sized by its image: initialized IRAM/DRAM counts in both Flash and RAM, `.flash*` NOBITS reservations are not counted, and its TinyUSB files come from DWARF.
 
-Show the coverage line and the relevant tables, then:
-- A filtered delta suggests a TinyUSB size impact in that configuration. A whole-elf Δ with a zero filtered total is outside the filter (inlined headers, example/BSP code); check the per-file table for changes that cancel.
-- min > 0 means growth in every present pair, max > 0 in at least one; name the worst-growth pair.
-- Corroborate a surprising delta on its own board and example, not the whole sweep: `-b <board> -e <example> --engine linkermap` (~30 s; every run rebuilds both trees); add `--bloaty` if bloaty is on PATH to see the sections and symbols behind it.
+## CI comment
+
+CI never rebuilds for sizing. In a code-changing run, each selected pinned `cmake` build leg, and each `hil-build-esp` leg inside the ESP-IDF image, snapshots the boards it built and uploads `code-size-<toolchain>-<leg>`; it tries even after a failed build, which a missing membrowse does not stop it recording. A `-DMA` variant leg reports as a board of its own named by its `--build-name` (`espressif_s3_devkitm-DMA`), in the comment and on the Membrowse dashboard; reproducing its snapshot needs that name and the leg's `-D` defines, which can enable examples. A `code-size-scope` job uploads the legs and examples the run selected. Master push runs keep snapshots 90 days as baselines, PR runs 14.
+
+`pr_comment.yml`'s `code-size-comment` job runs the default branch's scripts. `.github/scripts/code_size_ci.py baseline` takes the master push run of the merge commit's first parent, else its nearest first-parent ancestor with snapshots (up to 30), labelled approximate: master changes after it count as the PR's. `code_size.py compare` produces the report, which the job publishes as its summary and the `code-size-report` artifact; the sticky `code-size` comment is posted only when `code_size_ci.py is-current` finds no newer push, Build run or attempt for the PR. Runs of other PRs on the same commit are ignored; two open PRs from one head branch can still mask each other. A comment that did not update: read that step's reason and any superseding run it names. Neither the job nor `compare` fails on `INCOMPLETE`.
+
+Reading the comment: it lists every changed TinyUSB file with its min to max Flash and RAM Δ across the builds containing it, so a small driver change shows beside a heavy one; per-build tables are in the full report. Its `Not compared:` list is the full report's `INCOMPLETE`, with one-sided elfs named `missing in this PR` and `new in this PR`. CI's `INCOMPLETE` adds a leg or board with no snapshot, a board with no baseline, and snapshots of mismatched commits. Compiler and membrowse differences are noted, not failed; baseline elfs of examples the PR did not build are outside coverage. To reproduce, `gh run download` both runs' `code-size-*` artifacts into two directories and run `python3 tools/code_size.py compare CUR BASE -o OUT --symbols`, adding `--baseline-info INFO` for the baseline annotations.
diff --git a/.github/actions/get_deps/action.yml b/.github/actions/get_deps/action.yml
index 96eee01..66deb77 100644
--- a/.github/actions/get_deps/action.yml
+++ b/.github/actions/get_deps/action.yml
@@ -26,7 +26,7 @@
         NINJA_URL=https://github.com/ninja-build/ninja/releases/download/v1.13.1/ninja-linux.zip
         wget $NINJA_URL -O ninja-linux.zip
         unzip ninja-linux.zip -d ninja-bin
-        pip install --only-binary :all: membrowse==1.2.9
+        pip install --only-binary :all: membrowse==1.2.12
         echo >> $GITHUB_PATH "${{ github.workspace }}/ninja-bin"
       shell: bash
 
diff --git a/.github/ci-pinned-boards.json b/.github/ci-pinned-boards.json
index 0df5738..a6f09f3 100644
--- a/.github/ci-pinned-boards.json
+++ b/.github/ci-pinned-boards.json
@@ -1,34 +1,39 @@
 {
   "boards": [
-    { "board": "adafruit_fruit_jam" },
-    { "board": "ch32v307v_r1_1v0" },
-    { "board": "ea4088_quickstart" },
-    { "board": "ek_tm4c123gxl" },
-    { "board": "feather_nrf52840_express" },
-    { "board": "frdm_kl25z" },
-    { "board": "lpcxpresso11u68" },
-    { "board": "lpcxpresso55s28" },
-    { "board": "max32666fthr" },
-    { "board": "metro_m0_express" },
-    { "board": "metro_m4_express" },
-    { "board": "mimxrt1064_evk" },
-    { "board": "msp_exp430f5529lp" },
-    { "board": "nanoch32v203" },
-    { "board": "ra6m5_ek" },
-    { "board": "raspberry_pi_pico" },
-    { "board": "raspberry_pi_pico2" },
-    { "board": "same70_xplained" },
-    { "board": "samg55_xplained" },
-    { "board": "stm32f407disco" },
-    { "board": "stm32g0b1nucleo" },
-    { "board": "stm32h743eval" },
-    { "board": "stm32l412nucleo" }
+    { "board": "adafruit_fruit_jam", "note": "rp2350 FS + pio-usb host" },
+    { "board": "ch32v307v_r1_1v0", "note": "ch32 usbhs HS" },
+    { "board": "da14695_dk_usb", "note": "da146xx FS" },
+    { "board": "ea4088_quickstart", "note": "lpc17_40 FS, ohci host" },
+    { "board": "ek_tm4c123gxl", "note": "musb FS" },
+    { "board": "espressif_p4_function_ev", "note": "dwc2 4.00a HS, UTMI+, host; built by hil-build-esp with its -DMA variant, not the cmake job" },
+    { "board": "espressif_s3_devkitm", "note": "dwc2 4.00a FS, host; built by hil-build-esp with its -DMA variant, not the cmake job" },
+    { "board": "feather_nrf52840_express", "note": "nrf5x FS" },
+    { "board": "fomu", "note": "eptri FS" },
+    { "board": "frdm_k64f", "note": "ci_fs FS, host" },
+    { "board": "lpcxpresso11u68", "note": "ip3511 FS" },
+    { "board": "lpcxpresso55s28", "note": "ip3511 HS, ohci host" },
+    { "board": "max32666fthr", "note": "musb HS" },
+    { "board": "metro_m0_express", "note": "samd21 FS, host" },
+    { "board": "metro_m4_express", "note": "samd51 FS, host" },
+    { "board": "mimxrt1064_evk", "note": "ci_hs HS, ehci host" },
+    { "board": "mm900evxb", "note": "ft9xx HS" },
+    { "board": "msp_exp430f5529lp", "note": "msp430x5xx FS" },
+    { "board": "nanoch32v203", "note": "ch32 usbfs + fsdev FS, host" },
+    { "board": "ra6m5_ek", "note": "rusb2 HS, host" },
+    { "board": "raspberry_pi_pico", "note": "rp2040 FS, host" },
+    { "board": "raspberry_pi_pico2", "note": "rp2350 FS, host" },
+    { "board": "same70_xplained", "note": "samx7x HS" },
+    { "board": "samg55_xplained", "note": "samg FS" },
+    { "board": "stm32f303disco", "note": "fsdev 512B, 1x16-bit" },
+    { "board": "stm32f407disco", "note": "dwc2 2.81a FS, host" },
+    { "board": "stm32g0b1nucleo", "note": "fsdev 2KB, 32-bit, host" },
+    { "board": "stm32h743eval", "note": "dwc2 3.30a HS, ULPI, host" },
+    { "board": "stm32h7s3nucleo", "note": "dwc2 4.11a HS, UTMI+, host" },
+    { "board": "stm32l412nucleo", "note": "fsdev 1KB, 2x16-bit" },
+    { "board": "stm32n657nucleo", "note": "dwc2 4.11a HS, UTMI+, host" }
   ],
   "uncovered": {
-    "dcd_cxd56": "spresense unpinned - no board on either HIL roster and no other CI board carries dcd_cxd56; build-only size tracking with no hardware behind it",
-    "dcd_da146xx": "da1469x_dk_pro unpinned - no board on either HIL roster and no other CI board carries dcd_da146xx; build-only size tracking with no hardware behind it",
-    "dcd_eptri": "fomu unpinned - no board on either HIL roster and no other CI board carries dcd_eptri; build-only size tracking with no hardware behind it",
-    "dcd_ft9xx": "mm900evxb unpinned - no board on either HIL roster and no other CI board carries dcd_ft9xx; build-only size tracking with no hardware behind it",
+    "dcd_cxd56": "cxd56 has no CI family lane (not in ci_set_matrix.family_list), so no CI board can build it",
     "dcd_mm32f327x_otg": "mm32 unpinned - maintainer trimmed the pinned set",
     "dcd_nuc120": "nuvoton unpinned - maintainer trimmed the pinned set",
     "dcd_nuc121": "nuvoton unpinned - maintainer trimmed the pinned set",
diff --git a/.github/membrowse_pr_message.j2 b/.github/membrowse_pr_message.j2
deleted file mode 100644
index fbba1ee..0000000
--- a/.github/membrowse_pr_message.j2
+++ /dev/null
@@ -1,38 +0,0 @@
-{#- Top 10 targets with biggest memory changes + project dashboard link -#}
-{% set section_columns = ['.text', '.rodata', '.data', '.bss'] -%}
-{#- --- Compute per-target total absolute delta and collect changed targets --- -#}
-{% set changed = [] -%}
-{% for target in targets -%}
-{% if target.has_changes -%}
-{% set ns = namespace(total_delta=0, total_current=0) -%}
-{% for region in target.regions -%}
-{% set ns.total_delta = ns.total_delta + region.delta -%}
-{% set ns.total_current = ns.total_current + region.used_size -%}
-{% endfor -%}
-{% set total_old = ns.total_current - ns.total_delta -%}
-{% set pct = (ns.total_delta / total_old * 100) if total_old > 0 else 0 -%}
-{% set abs_pct = (ns.total_delta | abs) if total_old == 0 else (pct | abs) -%}
-{% set _ = changed.append({'target': target, 'total_current': ns.total_current, 'total_old': total_old, 'total_delta': ns.total_delta, 'pct': pct, 'abs_pct': abs_pct}) -%}
-{% endif -%}
-{% endfor -%}
-{#- --- Sort by absolute percentage change descending and take top 10 --- -#}
-{% set sorted_changed = changed | sort(attribute='abs_pct', reverse=true) -%}
-{% set top10 = sorted_changed[:10] -%}
-{#- --- Render --- -#}
-{% if top10 %}
-### Top {{ top10 | length }} targets by memory change (%) (out of {{ targets | length }} targets) {% if dashboard_url %} [View Project Dashboard →]({{ dashboard_url }}){% endif %}
-
-| target | .text | .rodata | .data | .bss | total | % diff |
-|--------|-------|---------|-------|------|-------|--------|
-{% for info in top10 -%}
-{% set target = info.target -%}
-{% set section_map = {} -%}
-{% for section in target.sections -%}
-{% set _ = section_map.update({section.name: section}) -%}
-{% endfor -%}
-| {% if target.comparison_url %}[{{ target.name }}]({{ target.comparison_url }}){% else %}{{ target.name }}{% endif %} |
-{%- for col in section_columns %} {% if col in section_map %}{{ "{:,}".format(section_map[col].old.size) }} → {{ "{:,}".format(section_map[col].size) }} ({{ section_map[col].delta_str }}){% else %}—{% endif %} |{% endfor %} {{ "{:,}".format(info.total_old) }} → {{ "{:,}".format(info.total_current) }} ({% if info.total_delta >= 0 %}+{{ "{:,}".format(info.total_delta) }}{% else %}{{ "{:,}".format(info.total_delta) }}{% endif %}) | {% if info.total_old > 0 %}{% if info.pct >= 0 %}+{% endif %}{{ "%.1f" | format(info.pct) }}%{% else %}N/A{% endif %} |
-{% endfor %}
-{% else %}
-No memory changes detected across {{ targets | length }} target{{ 's' if targets | length != 1 else '' }}.{% if dashboard_url %} [View Project Dashboard →]({{ dashboard_url }}){% endif %}
-{% endif -%}
diff --git a/.github/scripts/code_size_ci.py b/.github/scripts/code_size_ci.py
new file mode 100644
index 0000000..4514311
--- /dev/null
+++ b/.github/scripts/code_size_ci.py
@@ -0,0 +1,174 @@
+#!/usr/bin/env python3
+"""pr_comment.yml's code-size comment mechanics. Needs `gh` with a token that can read Actions.
+
+  code_size_ci.py baseline --repo OWNER/NAME --current CUR_DIR --info INFO.json
+
+finds the baseline of a PR's Build run: the master push Build run of the commit the PR
+was built against (its merge commit's first parent), else of its nearest first-parent
+ancestor with snapshots, labelled approximate. Never rebuilds; no baseline, a failed
+lookup included, is a result, not an error. CUR_DIR holds the PR run's downloaded
+code-size artifacts; INFO.json gets {sha, url, exact, note, run_id} for `tools/code_size.py
+compare --baseline-info`, sha and run_id None when there is no baseline. The workflow
+downloads run_id's `code-size-*` artifacts.
+
+  code_size_ci.py is-current --repo OWNER/NAME --pr N --run-id ID --attempt A --head-sha SHA
+
+exits 0 when that Build run attempt is still the PR's latest, 1 when a newer push, run
+or attempt supersedes it: its comment would overwrite a newer one.
+"""
+import argparse
+import json
+import os
+import subprocess
+import sys
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'tools'))
+import code_size  # noqa: E402  stdlib-only; its snapshot loader is the one compare uses
+
+WORKFLOW = 'build.yml'
+BRANCH = 'master'
+MAX_DEPTH = 30  # first-parent ancestors tried before giving up
+
+
+def gh(path, paginate=False):
+    """Parsed `gh api` GET of `path`; with `paginate`, the list of every page."""
+    cmd = ['gh', 'api', '-H', 'Accept: application/vnd.github+json'] + (['--paginate', '--slurp'] if paginate else [])
+    ret = subprocess.run(cmd + [path], capture_output=True, text=True, check=False)
+    if ret.returncode != 0:
+        raise RuntimeError(f'gh api {path}: {ret.stderr.strip()}')
+    return json.loads(ret.stdout)
+
+
+def gh_items(path, key):
+    """Every item of a paginated list endpoint whose items are under `key`."""
+    return [item for page in gh(path, paginate=True) for item in page[key]]
+
+
+def current_base_sha(current):
+    """The one base_sha the PR run's legs record, else None."""
+    shas = {code_size._commits(leg)[1] for leg in code_size.load_snapshots(current)['legs'].values()}
+    return shas.pop() if len(shas) == 1 else None
+
+
+def is_master_ancestor(repo, sha):
+    """Whether `sha` is on master's history, so a PR can never pick its own run as baseline."""
+    return gh(f'repos/{repo}/compare/{sha}...{BRANCH}')['status'] in ('ahead', 'identical')
+
+
+def snapshot_run(repo, sha):
+    """The latest completed master push Build run of `sha` with unexpired snapshots, else None."""
+    runs = gh_items(f'repos/{repo}/actions/workflows/{WORKFLOW}/runs?head_sha={sha}&event=push'
+                    f'&branch={BRANCH}&status=completed', 'workflow_runs')
+    for run in sorted(runs, key=lambda r: r['run_number'], reverse=True):
+        if run['head_sha'] != sha or run['head_branch'] != BRANCH or run['event'] != 'push' \
+                or (run.get('head_repository') or {}).get('full_name') != repo:
+            continue
+        names = {a['name'] for a in gh_items(f'repos/{repo}/actions/runs/{run["id"]}/artifacts', 'artifacts')
+                 if a['name'].startswith('code-size-') and not a['expired']}
+        # a docs-only commit uploads a scope that measured nothing: not a baseline
+        if 'code-size-scope' in names and len(names) > 1:
+            return run
+    return None
+
+
+def find_baseline(repo, sha):
+    """(run, depth) of the first first-parent ancestor of `sha`, itself at depth 0, with
+    snapshots, or None within MAX_DEPTH."""
+    for depth in range(MAX_DEPTH):
+        run = snapshot_run(repo, sha)
+        if run:
+            return run, depth
+        parents = gh(f'repos/{repo}/commits/{sha}')['parents']
+        if not parents:
+            return None
+        sha = parents[0]['sha']
+    return None
+
+
+def lookup(repo, current):
+    """The baseline description written to INFO.json."""
+    info = {'sha': None, 'url': None, 'exact': False, 'run_id': None}
+    sha = current_base_sha(current)
+    if sha is None:
+        return {**info, 'note': 'the PR snapshots record no single base commit'}
+    if not is_master_ancestor(repo, sha):
+        return {**info, 'note': f'base {sha[:10]} is not on {BRANCH}'}
+    found = find_baseline(repo, sha)
+    if found is None:
+        return {**info, 'note': f'no {BRANCH} Build run with snapshots within {MAX_DEPTH} commits of {sha[:10]}'}
+    run, depth = found
+    info = {'sha': run['head_sha'], 'url': run['html_url'], 'exact': depth == 0, 'run_id': run['id']}
+    note = (f'{depth} {BRANCH} commits before this build\'s base {sha[:10]}: their changes count as this PR\'s')
+    return {**info, 'note': note} if depth else info
+
+
+def baseline(args):
+    try:
+        info = lookup(args.repo, args.current)
+    except (RuntimeError, KeyError, TypeError, ValueError) as e:  # an API failure or an unexpected reply
+        info = {'sha': None, 'url': None, 'exact': False, 'run_id': None, 'note': f'the baseline lookup failed: {e}'}
+    with open(args.info, 'w') as f:
+        json.dump(info, f, indent=1, sort_keys=True)
+        f.write('\n')
+    print(json.dumps(info))
+    return 0
+
+
+def is_run_of(run, pr, head):
+    """Whether a pull_request Build run may be PR `pr`'s, whose head is `head`: another PR can
+    share the commit. GitHub lists the open same-repo PRs of the run's head in pull_requests and
+    leaves it empty for a fork's, so an unlisted run counts when it built the PR's head branch."""
+    numbers = [p['number'] for p in run.get('pull_requests') or []]
+    if numbers:
+        return pr in numbers
+    return run.get('head_branch') == head.get('ref') and \
+        (run.get('head_repository') or {}).get('full_name') == (head.get('repo') or {}).get('full_name')
+
+
+def run_owner(run):
+    """Run `run`'s id and whose it is, so a stale verdict names what superseded it."""
+    numbers = [p['number'] for p in run.get('pull_requests') or []]
+    owner = ', '.join(f'#{n}' for n in numbers) if numbers else \
+        f"{(run.get('head_repository') or {}).get('full_name')}:{run.get('head_branch')}"
+    return f"run {run['id']} of {owner}"
+
+
+def is_current(args):
+    """Whether run `args.run_id` attempt `args.attempt` of the PR head is still the newest."""
+    why = None
+    head = gh(f'repos/{args.repo}/pulls/{args.pr}')['head']
+    if head['sha'] != args.head_sha:
+        why = 'the PR head moved on'
+    else:
+        runs = [r for r in gh_items(f'repos/{args.repo}/actions/workflows/{WORKFLOW}/runs?head_sha={args.head_sha}'
+                                    f'&event=pull_request', 'workflow_runs') if is_run_of(r, args.pr, head)]
+        latest = max(runs, key=lambda r: r['run_number'], default=None)
+        if latest is None:
+            why = 'no Build run of the PR head found'
+        elif latest['id'] != args.run_id:
+            why = f'a newer Build run exists: {run_owner(latest)}'
+        elif gh(f'repos/{args.repo}/actions/runs/{args.run_id}')['run_attempt'] != args.attempt:
+            why = 'a newer attempt exists'
+    print(f'stale: {why}' if why else 'current')
+    return 1 if why else 0
+
+
+def main():
+    top = argparse.ArgumentParser(description=__doc__.split('\n\n')[0])
+    sub = top.add_subparsers(dest='command', required=True)
+    p = sub.add_parser('baseline', help='find the baseline run of a PR run\'s snapshots')
+    p.add_argument('--repo', required=True, help='OWNER/NAME whose master runs are baselines')
+    p.add_argument('--current', required=True, help='The PR run\'s downloaded code-size artifacts')
+    p.add_argument('--info', required=True, help='Where to write the baseline description JSON')
+    p = sub.add_parser('is-current', help='exit 1 when a newer push, run or attempt supersedes this one')
+    p.add_argument('--repo', required=True)
+    p.add_argument('--pr', required=True, type=int)
+    p.add_argument('--run-id', required=True, type=int)
+    p.add_argument('--attempt', required=True, type=int)
+    p.add_argument('--head-sha', required=True)
+    args = top.parse_args()
+    return baseline(args) if args.command == 'baseline' else is_current(args)
+
+
+if __name__ == '__main__':
+    sys.exit(main())
diff --git a/.github/scripts/hil_ci_set_matrix.py b/.github/scripts/hil_ci_set_matrix.py
index e465c64..f973cc1 100644
--- a/.github/scripts/hil_ci_set_matrix.py
+++ b/.github/scripts/hil_ci_set_matrix.py
@@ -120,6 +120,9 @@
             except ValueError as e:
                 raise SystemExit(f'{config_file}: {e}')
             for v in variants:
+                if toolchain == 'esp-idf' and v['name'] == name and (v['defines'] or v['flags']):
+                    # hil-build-esp sizes and uploads every leg as the board its --build-name names
+                    raise SystemExit(f'{config_file}: {name}: an esp-idf variant needs a name of its own')
                 arg = build_board
                 if v['name'] != name:
                     arg += f' --build-name {v["name"]}'
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index ad975d2..7d4a319 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -42,6 +42,7 @@
               - 'tools/get_deps.py'
               - 'tools/rtt.py'
               - 'tools/membrowse_cli.py'
+              - 'tools/code_size.py'
               - '.github/ci-pinned-boards.json'
               - '.github/actions/**'
               - '.github/workflows/build.yml'
@@ -51,6 +52,7 @@
   set-matrix:
     runs-on: ubuntu-latest
     outputs:
+      cmake_toolchains: ${{ steps.set-matrix-json.outputs.cmake_toolchains }}
       pinned_json: ${{ steps.set-matrix-json.outputs.pinned_matrix }}
       hil_json: ${{ steps.set-matrix-json.outputs.hil_matrix }}
       example_map: ${{ steps.set-matrix-json.outputs.example_map }}
@@ -178,6 +180,9 @@
           echo "pinned_matrix=$PINNED_JSON"
           echo "pinned_matrix=$PINNED_JSON" >> $GITHUB_OUTPUT
           echo "example_map=$EXAMPLE_MAP" >> $GITHUB_OUTPUT
+          # the `cmake` job's toolchains, one list for its matrix and the code-size scope
+          # (arm-clang and esp-idf are not built there: see that job's comment)
+          echo 'cmake_toolchains=["aarch64-gcc", "arm-gcc", "ft9xx-gcc", "msp430-gcc", "riscv-gcc"]' >> $GITHUB_OUTPUT
 
           # HIL matrix (merged from tinyusb + hifiphile configs), scoped on PRs.
           # Scoping is best-effort too: fall back to the unscoped (full) matrix.
@@ -194,6 +199,45 @@
           echo "hil_matrix=$HIL_MATRIX_JSON"
           echo "hil_matrix=$HIL_MATRIX_JSON" >> $GITHUB_OUTPUT
 
+  # The legs this run expects a code-size snapshot from, for pr_comment.yml's compare: a
+  # leg that dies before its snapshot step leaves no artifact, which only this manifest
+  # can tell apart from a leg that was never selected: the cmake job's pinned families and
+  # hil-build-esp's legs (a -DMA variant reports as a board of its own), under that job's own
+  # gate. No legs when no code changed.
+  # Nothing needs it, so it stays off the critical path; measurement only, never red.
+  code-size-scope:
+    needs: [ check-paths, set-matrix ]
+    if: github.event_name == 'pull_request' || github.event_name == 'push'
+    runs-on: ubuntu-latest
+    continue-on-error: true
+    steps:
+      - name: Code size scope
+        env:
+          TOOLCHAINS: ${{ needs.set-matrix.outputs.cmake_toolchains }}
+          PINNED_JSON: ${{ needs.set-matrix.outputs.pinned_json }}
+          EXAMPLE_MAP: ${{ needs.set-matrix.outputs.example_map }}
+          CODE_CHANGED: ${{ needs.check-paths.outputs.code_changed }}
+          HIL_JSON: ${{ needs.set-matrix.outputs.hil_json }}
+          OWNER: ${{ github.repository_owner }}
+        run: |
+          mkdir -p code-size-scope
+          jq -n --argjson tc "$TOOLCHAINS" --argjson pinned "$PINNED_JSON" --argjson ex "$EXAMPLE_MAP" \
+                --argjson hil "$HIL_JSON" --arg changed "$CODE_CHANGED" --arg owner "$OWNER" \
+            '{schema: 1, code_changed: ($changed == "true"), family_examples: $ex,
+              legs: (if $changed == "true" then [$tc[] as $t | ($pinned[$t] // [])[] | {toolchain: $t, arg: .}]
+                       + [if $owner == "hathach" then ($hil["esp-idf"] // [])[] else empty end
+                          | {toolchain: "esp-idf", arg: .}]
+                     else [] end)}' \
+            > code-size-scope/scope.json
+          cat code-size-scope/scope.json
+
+      - name: Upload code size scope
+        uses: actions/upload-artifact@v7
+        with:
+          name: code-size-scope
+          path: code-size-scope/scope.json
+          retention-days: ${{ github.event_name == 'push' && 90 || 14 }}
+
   # ------------------------------------------------------------------------------
   # CMake build: the families with a membrowse CI board, building that board - the only
   # boards this job exists for, since CircleCI builds every board of every family. A
@@ -209,14 +253,8 @@
     strategy:
       fail-fast: false
       matrix:
-        toolchain:
-          - 'aarch64-gcc'
-          #- 'arm-clang'
-          - 'arm-gcc'
-          #- 'esp-idf'
-          - 'ft9xx-gcc'
-          - 'msp430-gcc'
-          - 'riscv-gcc'
+        # arm-clang and esp-idf stay out: esp-idf would double-build hil-build-esp's boards
+        toolchain: ${{ fromJSON(needs.set-matrix.outputs.cmake_toolchains) }}
     with:
       build-system: 'cmake'
       toolchain: ${{ matrix.toolchain }}
@@ -344,9 +382,9 @@
           persist-credentials: false
 
       - name: Install membrowse
-        # pinned and wheel-only: an unpinned sdist would run its setup scripts on
-        # a runner that holds MEMBROWSE_API_KEY
-        run: 'pip install --only-binary :all: membrowse==1.2.9'
+        # this runner holds MEMBROWSE_API_KEY: wheel-only runs no sdist setup script,
+        # pinned runs no new release unreviewed
+        run: 'pip install --only-binary :all: membrowse==1.2.12'
 
       - name: Membrowse identical upload (espressif, no code change)
         continue-on-error: true
@@ -359,15 +397,7 @@
           python3 .github/scripts/hil_ci_set_matrix.py test/hil/tinyusb.json |
           python3 -c 'import json, sys; [print(a) for a in json.load(sys.stdin)["esp-idf"]]' |
           while IFS= read -r arg; do
-            # same variant guard as build_util.yml's Membrowse Upload step: a
-            # -DMA variant leg configures with the same -DBOARD=<name> as the
-            # plain leg, and membrowse's target-name ({board}/{example}) is
-            # derived from the board alone - uploading both would write two
-            # --identical rows under the same target every no-code-change push
-            case " $arg " in
-              *' --build-name '*|*' -D'*|*' --cflag='*)
-                echo "skip membrowse upload: variant build ($arg)"; continue ;;
-            esac
+            # a -DMA variant uploads as <build-name>/<example> (tools/build.py)
             python3 tools/build.py -s cmake --target examples-membrowse-upload -j 1 $arg || true
           done
 
diff --git a/.github/workflows/build_util.yml b/.github/workflows/build_util.yml
index a3f09b7..5dcf92c 100644
--- a/.github/workflows/build_util.yml
+++ b/.github/workflows/build_util.yml
@@ -100,6 +100,7 @@
           echo "EX_ARGS=$EX_ARGS" >> $GITHUB_ENV
 
       - name: Build
+        id: build
         if: ${{ inputs.code-changed }}
         env:
           IAR_LMS_BEARER_TOKEN: ${{ secrets.IAR_LMS_BEARER_TOKEN }}
@@ -116,6 +117,31 @@
           fi
         shell: bash
 
+      - name: Code size snapshot
+        id: code-size
+        # measurement only: it also runs after a failed Build and records that in the
+        # snapshot, and a sizing failure never fails the leg. Before the membrowse upload,
+        # so a membrowse service error cannot cost the local measurement.
+        if: ${{ !cancelled() && inputs.upload-membrowse && inputs.code-changed }}
+        continue-on-error: true
+        env:
+          ARG: ${{ matrix.arg }}
+          BUILD_OUTCOME: ${{ steps.build.outcome }}
+          TOOLCHAIN: ${{ inputs.toolchain }}
+        run: |
+          if [ "$TOOLCHAIN" != esp-idf ]; then
+            python tools/code_size.py snapshot --symbols --build-outcome "$BUILD_OUTCOME" -o code-size $ARG $EX_ARGS
+            exit
+          fi
+          # in the build's image: membrowse reads the /project link commands and linker scripts.
+          # The command is constant (PR-derived values arrive as env), and the container's root
+          # does not own the runner's checkout, which git refuses without safe.directory.
+          docker run --rm -e ARG -e EX_ARGS -e BUILD_OUTCOME -e GITHUB_EVENT_NAME -v "$PWD:/project" -w /project \
+            espressif/idf:tinyusb bash -c 'git config --global --add safe.directory /project &&
+              pip install --only-binary :all: membrowse==1.2.12 >/dev/null &&
+              python tools/code_size.py snapshot --symbols --build-outcome "$BUILD_OUTCOME" -o code-size $ARG $EX_ARGS'
+        shell: bash
+
       - name: Membrowse Upload
         if: inputs.upload-membrowse == true
         continue-on-error: true
@@ -123,18 +149,6 @@
           MEMBROWSE_API_KEY: ${{ secrets.MEMBROWSE_API_KEY }}
           ARG: ${{ matrix.arg }}
         run: |
-          # A HIL variant leg (--build-name, an extra -D define, or --cflag beyond
-          # the bare board) still configures with the same -DBOARD=<name> as the
-          # plain build - membrowse's target-name is <board>/<example>, derived
-          # from the board alone - so uploading it would overwrite the plain
-          # build's (or another variant's) history under that same name. Only a
-          # bare `-b <board> [-e ...]` leg, the plain build, uploads here; a
-          # variant board still gets built (for the rig) by this same job, just
-          # not uploaded from this leg.
-          case " $ARG " in
-            *' --build-name '*|*' -D'*|*' --cflag='*)
-              echo "skip membrowse upload: variant build ($ARG)"; exit 0 ;;
-          esac
           # code-changed false -> no elf -> membrowse uploads with --identical.
           # --ci-pinned-boards-only: families without a CI board were built as compile
           # smoke-checks only and must not upload. -e (in $EX_ARGS or $ARG) only picks
@@ -154,14 +168,14 @@
                         -e GITHUB_EVENT_PATH="$GITHUB_EVENT_PATH")
             DOCKER_MOUNTS=(-v $PWD:/project)
             [ -n "$GITHUB_EVENT_PATH" ] && DOCKER_MOUNTS+=(-v "$GITHUB_EVENT_PATH:$GITHUB_EVENT_PATH:ro")
-            docker run --rm "${DOCKER_ENV[@]}" "${DOCKER_MOUNTS[@]}" -w /project espressif/idf:tinyusb bash -c "pip install --only-binary :all: membrowse==1.2.9 >/dev/null && $CMD"
+            docker run --rm "${DOCKER_ENV[@]}" "${DOCKER_MOUNTS[@]}" -w /project espressif/idf:tinyusb bash -c "pip install --only-binary :all: membrowse==1.2.12 >/dev/null && $CMD"
           else
             $CMD
           fi
         shell: bash
 
       - name: Artifact name
-        if: inputs.upload-artifacts == true
+        if: ${{ !cancelled() && (inputs.upload-artifacts || steps.code-size.outcome == 'success' || steps.code-size.outcome == 'failure') }}
         env:
           ARG: ${{ matrix.arg }}
         run: |
@@ -196,3 +210,15 @@
             cmake-build/cmake-build-*/*/*/config.env
             cmake-build/cmake-build-*/*/*/flash_args
             cmake-build/hw/mcu/**/*.ld
+
+      - name: Upload code size snapshot
+        if: ${{ !cancelled() && (steps.code-size.outcome == 'success' || steps.code-size.outcome == 'failure') }}
+        continue-on-error: true
+        uses: actions/upload-artifact@v7
+        with:
+          # the toolchain too: the same family could be a leg of two toolchains
+          name: code-size-${{ inputs.toolchain }}-${{ env.ARTIFACT_TAG }}
+          path: code-size/*.json
+          # master's snapshots are the baselines PRs compare against
+          retention-days: ${{ github.event_name == 'push' && 90 || 14 }}
+          if-no-files-found: ignore
diff --git a/.github/workflows/membrowse-comment.yml b/.github/workflows/membrowse-comment.yml
deleted file mode 100644
index 952d8ba..0000000
--- a/.github/workflows/membrowse-comment.yml
+++ /dev/null
@@ -1,33 +0,0 @@
-name: Membrowse Comment
-
-on:
-  workflow_run:
-    workflows: ["Build"]
-    types:
-      - completed
-
-jobs:
-  post-comment:
-    runs-on: ubuntu-latest
-    # Run the comment job even if some of the builds fail
-    if: >
-      github.event.workflow_run.event == 'pull_request' &&
-      github.event.workflow_run.conclusion != 'cancelled'
-    permissions:
-      contents: read
-      actions: read
-      pull-requests: write
-    steps:
-      - name: Checkout repository
-        uses: actions/checkout@v6
-
-      - name: Post Membrowse PR comment
-        if: ${{ env.MEMBROWSE_API_KEY != '' }}
-        uses: membrowse/membrowse-action/comment-action@v1
-        with:
-          api_key: ${{ secrets.MEMBROWSE_API_KEY }}
-          commit: ${{ github.event.workflow_run.head_sha }}
-          comment_template: .github/membrowse_pr_message.j2
-        env:
-          MEMBROWSE_API_KEY: ${{ secrets.MEMBROWSE_API_KEY }}
-          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/pr_comment.yml b/.github/workflows/pr_comment.yml
index 1393e13..0f4df77 100644
--- a/.github/workflows/pr_comment.yml
+++ b/.github/workflows/pr_comment.yml
@@ -106,8 +106,106 @@
 
       - name: Post HIL report as sticky PR comment
         if: steps.combine.outputs.found == 'true'
-        uses: marocchino/sticky-pull-request-comment@v2
+        uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
         with:
           header: hil-report
           path: hil_combined.md
           number: ${{ needs.pr_number.outputs.number }}
+
+  # ---------------------------------------
+  # Code size of the PR's CI-pinned boards against the master push run of the commit it was
+  # built against (code_size_ci.py baseline): no base build, only the snapshots both Build
+  # runs uploaded. Artifacts are PR-built data, parsed only by the default branch's scripts
+  # and rendered escaped. Report only: this job never fails the PR.
+  # ---------------------------------------
+  code-size-comment:
+    needs: pr_number
+    if: >
+      github.event.workflow_run.conclusion != 'cancelled' &&
+      needs.pr_number.outputs.number != ''
+    runs-on: ubuntu-latest
+    continue-on-error: true
+    permissions:
+      contents: read
+      actions: read
+      pull-requests: write
+    env:
+      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      REPO: ${{ github.repository }}
+    steps:
+      - name: Checkout default branch scripts
+        uses: actions/checkout@v6
+        with:
+          persist-credentials: false
+
+      # every step up to the freshness check must succeed, even with nothing to compare:
+      # a failed one would skip the post and leave an earlier push's table looking current
+      - name: Download code size snapshots
+        continue-on-error: true
+        uses: actions/download-artifact@v5
+        with:
+          run-id: ${{ github.event.workflow_run.id }}
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          pattern: code-size-*
+          path: ${{ runner.temp }}/code-size/current
+
+      - name: Find the baseline
+        id: baseline
+        run: |
+          CS="$RUNNER_TEMP/code-size"
+          mkdir -p "$CS/current" "$CS/out"
+          # it reports its own failures in the info; a crash still must not skip the post
+          python3 .github/scripts/code_size_ci.py baseline --repo "$REPO" --current "$CS/current" \
+            --info "$CS/baseline.json" || echo "::warning::code_size_ci.py baseline crashed"
+          echo "run_id=$(jq -r '.run_id // empty' "$CS/baseline.json")" >> "$GITHUB_OUTPUT"
+
+      - name: Download baseline snapshots
+        if: steps.baseline.outputs.run_id != ''
+        continue-on-error: true
+        uses: actions/download-artifact@v5
+        with:
+          run-id: ${{ steps.baseline.outputs.run_id }}
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          pattern: code-size-*
+          path: ${{ runner.temp }}/code-size/base
+
+      - name: Compare against the baseline
+        env:
+          RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+        run: |
+          CS="$RUNNER_TEMP/code-size"
+          if ! python3 tools/code_size.py compare "$CS/current" "$CS/base" -o "$CS/out" --symbols \
+                 --baseline-info "$CS/baseline.json"; then
+            printf '## Code size\n\n_The comparison failed._\n' > "$CS/out/comment.md"
+          fi
+          echo "_[Full report]($RUN_URL)_" >> "$CS/out/comment.md"
+          # the job summary caps at 1 MiB
+          if [ -f "$CS/out/code-size.md" ]; then head -c 1000000 "$CS/out/code-size.md" >> "$GITHUB_STEP_SUMMARY"; fi
+
+      - name: Upload full report
+        continue-on-error: true
+        uses: actions/upload-artifact@v7
+        with:
+          name: code-size-report
+          path: ${{ runner.temp }}/code-size/out/code-size.*
+
+      - name: Skip a superseded run
+        id: current
+        env:
+          PR: ${{ needs.pr_number.outputs.number }}
+          RUN_ID: ${{ github.event.workflow_run.id }}
+          ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
+          HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
+        run: |
+          if python3 .github/scripts/code_size_ci.py is-current --repo "$REPO" --pr "$PR" \
+               --run-id "$RUN_ID" --attempt "$ATTEMPT" --head-sha "$HEAD_SHA"; then
+            echo "post=true" >> "$GITHUB_OUTPUT"
+          fi
+
+      - name: Post code size as sticky PR comment
+        if: steps.current.outputs.post == 'true'
+        uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
+        with:
+          header: code-size
+          path: ${{ runner.temp }}/code-size/out/comment.md
+          number: ${{ needs.pr_number.outputs.number }}
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index c67d733..059ec1f 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -106,11 +106,11 @@
 
   - id: drivers-coverage
     name: drivers-coverage
-    files: ^(\.github/ci-pinned-boards\.json$|\.github/scripts/ci_set_matrix\.py$|tools/drivers_coverage_check\.py$|tools/ci_select\.py$|tools/build_utils\.py$|hw/bsp/|src/portable/|examples/|test/hil/test/test_drivers_coverage\.py$|tools/build\.py$|test/hil/test/test_ci_boards\.py$|tools/code_size\.py$|test/hil/test/test_code_size\.py$|tools/membrowse_cli\.py$|test/hil/test/test_membrowse_cli\.py$|test/hil/tinyusb\.json$|test/hil/hfp\.json$)
-    entry: sh -c "cd test/hil/test && python3 -m unittest -q test_drivers_coverage test_ci_boards test_code_size test_membrowse_cli"
+    files: ^(\.github/ci-pinned-boards\.json$|\.github/scripts/ci_set_matrix\.py$|tools/drivers_coverage_check\.py$|tools/ci_select\.py$|tools/build_utils\.py$|hw/bsp/|src/portable/|examples/|test/hil/test/test_drivers_coverage\.py$|tools/build\.py$|test/hil/test/test_ci_boards\.py$|tools/code_size\.py$|test/hil/test/test_code_size\.py$|\.github/scripts/code_size_ci\.py$|test/hil/test/test_code_size_ci\.py$|tools/membrowse_cli\.py$|test/hil/test/test_membrowse_cli\.py$|test/hil/tinyusb\.json$|test/hil/hfp\.json$)
+    entry: sh -c "cd test/hil/test && python3 -m unittest -q test_drivers_coverage test_ci_boards test_code_size test_code_size_ci test_membrowse_cli"
     pass_filenames: false
     language: python
-    additional_dependencies: [pyyaml]
+    additional_dependencies: [pyyaml, pyelftools]
 
 #  - id: build-fuzzer
 #    name: build-fuzzer
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 17769e1..ee189ce 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
@@ -51,9 +51,9 @@
 | 1   | `docs/`, `.claude/`, `*.md`, `*.rst`, `LICENSE`                                                                                                                                                                                                                                                                                   | —                                                               | —                                                                 | —                                                                                                                                |
 | 1b  | `.gitignore`, `.clang-format`, `.agents`, `.codex/**`, `.idea/**`, `test/{fuzz,unit-test}/**`, `test/hil/test/**`, non-build `.github/**`, packaging manifests                                                                                                                                                                      | —                                                               | —                                                                 | —                                                                                                                                |
 | 2   | `test/hil/**` (not `test/hil/test/**`)                                                                                                                                                                                                                                                                                            | —                                                               | —                                                                 | all boards → all tests                                                                                                           |
-| 2b  | `tools/code_size.py`, `tools/drivers_coverage_check.py`                                                                                                                                                                                                                                                                           | — (local-only tooling, no CI build runs it)                     | —                                                                 | — (nothing on the rig runs it)                                                                                                   |
+| 2b  | `tools/drivers_coverage_check.py`                                                                                                                                                                                                                                                                                                 | — (local-only tooling, no CI build runs it)                     | —                                                                 | — (nothing on the rig runs it)                                                                                                   |
 | 2c  | `.github/ci-pinned-boards.json`                                                                                                                                                                                                                                                                                                   | `ALL`                                                           | `ALL`                                                             | — (CI board data; no rig board's behaviour depends on it)                                                                        |
-| 2d  | `tools/membrowse_cli.py`                                                                                                                                                                                                                                                                                                          | `ALL`                                                           | `ALL`                                                             | — (build-time script invoked from family_support.cmake; no rig board runs it)                                                    |
+| 2d  | `tools/membrowse_cli.py`, `tools/code_size.py`                                                                                                                                                                                                                                                                                    | `ALL`                                                           | `ALL`                                                             | — (size scripts the pinned build legs run; no rig board runs them)                                                               |
 | 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                                                                                                 |
diff --git a/hw/bsp/family_support.cmake b/hw/bsp/family_support.cmake
index 215683a..04d12d4 100644
--- a/hw/bsp/family_support.cmake
+++ b/hw/bsp/family_support.cmake
@@ -285,6 +285,10 @@
   else ()
     set(ELF_TARGET ${TARGET})
   endif ()
+  # a variant build (build.py --build-name) reports as a board of its own
+  if (NOT DEFINED MEMBROWSE_BOARD)
+    set(MEMBROWSE_BOARD ${BOARD})
+  endif ()
 
   # For Ninja generator, `tools/membrowse_cli.py report` extracts linker scripts (with
   # INCLUDE resolution) and --defsym symbols from the ninja build graph, then runs
@@ -299,7 +303,7 @@
       --ninja ${CMAKE_MAKE_PROGRAM}
       --elf ${TARGET_ELF_PATH}
       # membrowse history is keyed on this name (pinned by test_membrowse_cli): never rename
-      --target-name ${BOARD}/${TARGET}
+      --target-name ${MEMBROWSE_BOARD}/${TARGET}
       )
     if (DEFINED MEMBROWSE_OPTION)
       list(APPEND MEMBROWSE_ARGS "--option=${MEMBROWSE_OPTION}")
diff --git a/test/hil/test/test_ci_metrics.py b/test/hil/test/test_ci_metrics.py
index 3db5fb6..faf605f 100644
--- a/test/hil/test/test_ci_metrics.py
+++ b/test/hil/test/test_ci_metrics.py
@@ -209,6 +209,122 @@
         # (tools/build.py sets build_examples=None) and compile every example
         self.assertNotIn('--ci-pinned-boards-only', self.jobs['cmake'])
 
+    def test_the_code_size_scope_lists_every_cmake_leg_and_esp_leg(self):
+        # pr_comment.yml's compare expects a snapshot artifact per listed leg: the list
+        # must be the cmake job's matrix, from the one toolchain list both read, plus every
+        # hil-build-esp leg (a -DMA variant is a board of its own) under that job's owner gate
+        import subprocess, tempfile
+        self.assertIn('fromJSON(needs.set-matrix.outputs.cmake_toolchains)', self.jobs['cmake'])
+        self.assertIn("github.repository_owner == 'hathach'", self.jobs['hil-build-esp'])
+        m = re.search(r"echo 'cmake_toolchains=(\[.*\])' >> \$GITHUB_OUTPUT", self.build)
+        toolchains = json.loads(m.group(1))
+        self.assertNotIn('esp-idf', toolchains)
+        i = self.build.index('mkdir -p code-size-scope')
+        i = self.build.rindex('\n', 0, i) + 1
+        j = self.build.index('cat code-size-scope/scope.json', i)
+        block = re.sub(r'^ {10}', '', self.build[i:j], flags=re.M)
+        pinned = {'arm-gcc': ['stm32f4', 'imxrt'], 'riscv-gcc': ['fomu'], 'esp-idf': ['espressif']}
+        hil = {'arm-gcc': ['-b x'], 'esp-idf': [
+            '-b espressif_s3_devkitm -e device/cdc_msc_freertos',
+            '-b espressif_s3_devkitm --build-name espressif_s3_devkitm-DMA --cflag=-DCFG_TUD_DWC2_DMA_ENABLE=1']}
+
+        def scope(changed, owner='hathach'):
+            with tempfile.TemporaryDirectory() as d:
+                subprocess.run(['bash', '-e', '-o', 'pipefail', '-c', block], cwd=d, capture_output=True, check=True,
+                               env={**os.environ, 'TOOLCHAINS': m.group(1), 'PINNED_JSON': json.dumps(pinned),
+                                    'EXAMPLE_MAP': '{"stm32f4": ["device/cdc_msc"]}', 'CODE_CHANGED': changed,
+                                    'HIL_JSON': json.dumps(hil), 'OWNER': owner})
+                with open(os.path.join(d, 'code-size-scope', 'scope.json')) as fh:
+                    return json.load(fh)
+        cmake = [{'toolchain': 'arm-gcc', 'arg': 'stm32f4'}, {'toolchain': 'arm-gcc', 'arg': 'imxrt'},
+                 {'toolchain': 'riscv-gcc', 'arg': 'fomu'}]
+        s = scope('true')
+        self.assertEqual(s['legs'], cmake + [{'toolchain': 'esp-idf', 'arg': a} for a in hil['esp-idf']])
+        self.assertEqual((s['code_changed'], s['family_examples']), (True, {'stm32f4': ['device/cdc_msc']}))
+        self.assertEqual(scope('true', owner='fork')['legs'], cmake)  # hil-build-esp does not run there
+        self.assertEqual(scope('false')['legs'], [])  # no code change: nothing to measure
+
+    def test_the_esp_snapshot_runs_in_the_idf_image_with_the_leg_as_data(self):
+        import subprocess, tempfile
+        with open(os.path.join(REPO, '.github', 'workflows', 'build_util.yml')) as f:
+            text = f.read()
+        def step_script(name):
+            i = text.index('run: |\n', text.index(f'- name: {name}\n')) + len('run: |\n')
+            return re.sub(r'^ {10}', '', text[i:text.index('        shell: bash', i)], flags=re.M)
+        block = step_script('Code size snapshot')
+        with tempfile.TemporaryDirectory() as d:
+            # docker runs its inner command here with the env it was told to pass; git, pip
+            # and python only record their argv
+            stubs = {'docker': 'echo "docker ${*:1:$#-1}" >> "$LOG"; exec bash -c "${!#}"',
+                     'git': 'echo "git $*" >> "$LOG"', 'pip': 'echo "pip $*" >> "$LOG"',
+                     'python': '{ printf "python"; printf " [%s]" "$@"; echo; } >> "$LOG"'}
+            for name, body in stubs.items():
+                with open(os.path.join(d, name), 'w') as f:
+                    f.write(f'#!/bin/bash\n{body}\n')
+                os.chmod(os.path.join(d, name), 0o755)
+            env = {**os.environ, 'PATH': d + os.pathsep + os.environ['PATH'], 'LOG': os.path.join(d, 'log'),
+                   'TOOLCHAIN': 'esp-idf', 'BUILD_OUTCOME': 'success', 'EX_ARGS': '',
+                   'GITHUB_EVENT_NAME': 'pull_request'}
+
+            def run(arg, toolchain='esp-idf'):
+                open(env['LOG'], 'w').close()
+                r = subprocess.run(['bash', '-e', '-o', 'pipefail', '-c', block], cwd=d, capture_output=True,
+                                   text=True, env={**env, 'ARG': arg, 'TOOLCHAIN': toolchain})
+                self.assertEqual(r.returncode, 0, r.stderr)
+                with open(env['LOG']) as f:
+                    return f.read().splitlines()
+            log = run('-b espressif_s3_devkitm $(touch pwned) ;id')
+            self.assertFalse(os.path.exists(os.path.join(d, 'pwned')))
+            self.assertTrue(log[0].startswith('docker run --rm -e ARG -e EX_ARGS -e BUILD_OUTCOME -e GITHUB_EVENT_NAME '
+                                              f'-v {d}:/project -w /project espressif/idf:tinyusb bash -c'))
+            self.assertEqual(log[1:3], ['git config --global --add safe.directory /project',
+                                        'pip install --only-binary :all: membrowse==1.2.12'])
+            self.assertEqual(log[3], 'python [tools/code_size.py] [snapshot] [--symbols] [--build-outcome] [success] '
+                                     '[-o] [code-size] [-b] [espressif_s3_devkitm] [$(touch] [pwned)] [;id]')
+            self.assertEqual(run('stm32f4', toolchain='arm-gcc'),  # every other toolchain sizes on the runner
+                             ['python [tools/code_size.py] [snapshot] [--symbols] [--build-outcome] [success] '
+                              '[-o] [code-size] [stm32f4]'])
+            # a variant leg is sized too, as the board its --build-name names
+            self.assertEqual(run('-b espressif_s3_devkitm --build-name x-DMA --cflag=-DY=1')[-1],
+                             'python [tools/code_size.py] [snapshot] [--symbols] [--build-outcome] [success] '
+                             '[-o] [code-size] [-b] [espressif_s3_devkitm] [--build-name] [x-DMA] [--cflag=-DY=1]')
+
+    def test_the_code_size_comment_is_written_even_without_usable_snapshots(self):
+        # a run without usable snapshots, and a baseline lookup that fails, must still
+        # leave a comment.md, so the post replaces an earlier push's table
+        import subprocess, tempfile
+        with open(os.path.join(REPO, '.github', 'workflows', 'pr_comment.yml')) as f:
+            text = f.read()
+
+        def step_script(name):
+            i = text.index('run: |\n', text.index(f'- name: {name}\n')) + len('run: |\n')
+            j = re.search(r'^\s*$', text[i:], re.M).start() + i  # the block ends at the first blank line
+            return re.sub(r'^ {10}', '', text[i:j], flags=re.M)
+        with tempfile.TemporaryDirectory() as d:
+            env = {**os.environ, 'RUNNER_TEMP': d, 'REPO': 'x/y', 'RUN_URL': 'https://example/run',
+                   'GITHUB_STEP_SUMMARY': os.path.join(d, 'summary.md'), 'GITHUB_OUTPUT': os.path.join(d, 'out'),
+                   'PATH': d + os.pathsep + os.environ['PATH']}
+            with open(os.path.join(d, 'gh'), 'w') as f:  # any API call fails
+                f.write('#!/bin/sh\nexit 1\n')
+            os.chmod(os.path.join(d, 'gh'), 0o755)
+            leg = os.path.join(d, 'code-size', 'current', 'code-size-arm-gcc-x')
+            os.makedirs(leg)
+            with open(os.path.join(leg, 'leg.json'), 'w') as f:
+                json.dump({'schema': 1, 'boards': [], 'examples': None, 'build_outcome': 'success',
+                           'sha': 'a' * 40, 'base_sha': 'a' * 40, 'head_sha': 'a' * 40}, f)
+            for name in ('Find the baseline', 'Compare against the baseline'):
+                r = subprocess.run(['bash', '-e', '-o', 'pipefail', '-c', step_script(name)], cwd=REPO,
+                                   capture_output=True, text=True, env=env)
+                self.assertEqual(r.returncode, 0, f'{name}: {r.stderr}')
+            with open(os.path.join(d, 'out')) as f:
+                self.assertEqual(f.read(), 'run_id=\n')  # no baseline download
+            with open(os.path.join(d, 'code-size', 'out', 'comment.md')) as f:
+                comment = f.read()
+        self.assertTrue(comment.startswith('## Code size'))
+        self.assertIn('Baseline: unavailable - the baseline lookup failed', comment)
+        self.assertIn('no usable scope manifest', comment)
+        self.assertTrue(comment.endswith('_[Full report](https://example/run)_\n'))
+
     def _run_matrix_step(self, sel, fail_pinned=False):
         """Run the whole 'Generate matrix json' step for real, optionally with the
         SCOPED --pinned invocation failing (the unscoped fallback still works, as a
diff --git a/test/hil/test/test_ci_select.py b/test/hil/test/test_ci_select.py
index a5ef682..35e7a8e 100644
--- a/test/hil/test/test_ci_select.py
+++ b/test/hil/test/test_ci_select.py
@@ -1249,6 +1249,7 @@
             'test/hil/test/test_ci_metrics.py',
             'test/hil/test/test_ci_select.py',
             'test/hil/test/test_code_size.py',
+            'test/hil/test/test_code_size_ci.py',
             'test/hil/test/test_drivers_coverage.py',
             'test/hil/test/test_family_json.py',
             'test/hil/test/test_hil_args.py',
@@ -1874,7 +1875,7 @@
                   '.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',
-                  '.claude/skills/build-doc/scripts/gen_doc.py', '.agents'):
+                  '.github/scripts/code_size_ci.py', '.claude/skills/build-doc/scripts/gen_doc.py', '.agents'):
             s = self.b([p])
             self.assertFalse(s['full'], p)
             self.assertEqual(s['families'], [], p)
@@ -1992,9 +1993,8 @@
     whole build matrix plus an exclusive full-rig sweep - where master ran nothing."""
 
     def test_local_tooling_contributes_nothing_on_either_axis(self):
-        # no CI build and no rig board runs any of these (drivers_coverage_check.py is
-        # pre-commit only)
-        for p in ('tools/code_size.py', 'tools/drivers_coverage_check.py'):
+        # no CI build and no rig board runs it (pre-commit only)
+        for p in ('tools/drivers_coverage_check.py',):
             h = sel([p])
             self.assertFalse(h['full'], p)
             self.assertEqual(h['boards'], {}, p)
@@ -2010,13 +2010,14 @@
         self.assertEqual(h['boards'], {}, p)
         self.assertTrue(ci_select.classify_build([p], REPO)['full'], p)
 
-    def test_membrowse_cli_is_a_full_build_matrix_without_hil(self):
-        # run by family_add_membrowse() for every pinned board (rule 2d)
-        p = 'tools/membrowse_cli.py'
-        h = sel([p])
-        self.assertFalse(h['full'], p)
-        self.assertEqual(h['boards'], {}, p)
-        self.assertTrue(ci_select.classify_build([p], REPO)['full'], p)
+    def test_ci_size_scripts_are_a_full_build_matrix_without_hil(self):
+        # run by every pinned build leg (rule 2d): membrowse_cli.py from
+        # family_add_membrowse(), code_size.py as the CI size snapshot
+        for p in ('tools/membrowse_cli.py', 'tools/code_size.py'):
+            h = sel([p])
+            self.assertFalse(h['full'], p)
+            self.assertEqual(h['boards'], {}, p)
+            self.assertTrue(ci_select.classify_build([p], REPO)['full'], p)
 
     def test_typec_example_builds_but_runs_nothing(self):
         # examples/typec is compiled by the build matrix and run by no rig board; the
@@ -2305,6 +2306,25 @@
         self.assertEqual(r.returncode, 0, r.stderr)
         return r.stdout
 
+    def test_an_esp_idf_variant_must_name_itself(self):
+        # hil-build-esp sizes and uploads every leg under its --build-name: an unnamed
+        # esp-idf variant would collide with the plain board; other buckets never upload
+        import tempfile
+        other = {'name': 'metro_m4_express', 'uid': 'm', 'flasher': {'name': 'jlink'},
+                 'variant': [{'name': 'metro_m4_express', 'defines': ['MAX3421_HOST=1']}]}
+        for vname, ok in (('espressif_s3_devkitm', False), ('espressif_s3_devkitm-X', True)):
+            esp = {'name': 'espressif_s3_devkitm', 'uid': 'u', 'flasher': {'name': 'esptool'},
+                   'variant': [{'name': vname, 'defines': ['X=1']}]}
+            with tempfile.NamedTemporaryFile('w', suffix='.json') as f:
+                json.dump({'boards': [esp, other]}, f)
+                f.flush()
+                r = subprocess.run([sys.executable, HIL_SET_MATRIX, f.name], capture_output=True, text=True)
+            self.assertEqual(r.returncode == 0, ok, r.stderr)
+            if ok:
+                self.assertEqual(json.loads(r.stdout)['arm-gcc'], ['-b metro_m4_express -DMAX3421_HOST=1'])
+            else:
+                self.assertIn('an esp-idf variant needs a name of its own', r.stderr)
+
     def test_no_hil_examples_is_byte_identical(self):
         plain = self.run_matrix()
         sel = json.dumps({'full': True, 'boards': {}})
@@ -2437,6 +2457,36 @@
         self.assertEqual(cmd[cmd.index('--elf') + 1],
                          'cmake-build/cmake-build-espressif_s3_devkitc/device/cdc_msc_freertos/cdc_msc_freertos.elf')
 
+    def test_a_variant_is_a_membrowse_board_named_by_its_build_name(self):
+        from unittest import mock
+        calls = []
+        def fake_run(cmd):
+            calls.append(cmd)
+            return types.SimpleNamespace(returncode=0)
+        real_isdir = os.path.isdir
+        no_build_dir = lambda p: False if str(p).startswith('cmake-build/') else real_isdir(p)
+        with mock.patch.object(self.build, 'run_cmd', fake_run), \
+             mock.patch.object(self.build.os.path, 'isdir', no_build_dir):
+            self.build.cmake_board('espressif_s3_devkitc', [], 'espressif_s3_devkitc-DMA', ['-DX=1'], ['all'],
+                                   examples=['device/cdc_msc_freertos'])
+            self.build.cmake_board('espressif_s3_devkitc', [], 'espressif_s3_devkitc-DMA', [],
+                                   ['examples-membrowse-upload'], examples=['device/cdc_msc_freertos'])
+        idf, identical = calls
+        self.assertIn('-DMEMBROWSE_BOARD=espressif_s3_devkitc-DMA', idf)
+        self.assertIn('cmake-build/cmake-build-espressif_s3_devkitc-DMA/device/cdc_msc_freertos', idf)
+        self.assertEqual(identical[identical.index('--target-name') + 1], 'espressif_s3_devkitc-DMA/cdc_msc_freertos')
+
+    def test_an_unnamed_variant_never_uploads_under_the_plain_board(self):
+        from unittest import mock
+        for argv, refused in ((['-b', 'b1', '--cflag=-DX=1'], True), (['-b', 'b1', '-DX=1'], True),
+                              (['-b', 'b1', '--build-name', 'b1-X', '--cflag=-DX=1'], False), (['-b', 'b1'], False)):
+            with mock.patch.object(self.build, 'build_boards_list', return_value=[1, 0, 0]) as build, \
+                 mock.patch.object(sys, 'argv', ['build.py', '-T', 'examples-membrowse-upload'] + argv), \
+                 contextlib.redirect_stdout(io.StringIO()) as out:
+                ret = self.build.main()
+            self.assertEqual((ret, build.called), (1, False) if refused else (0, True), argv)
+            self.assertEqual('requires --build-name' in out.getvalue(), refused)
+
     def test_make_one_example_uses_make_semantics(self):
         # F1 end to end: the make path must ask skip_example with build_system='make',
         # or lpc54's cmake-only FAMILY_MCUS un-skips a host example whose make build
diff --git a/test/hil/test/test_code_size.py b/test/hil/test/test_code_size.py
index 7c9812a..5414d18 100644
--- a/test/hil/test/test_code_size.py
+++ b/test/hil/test/test_code_size.py
@@ -64,6 +64,21 @@
             self.assertEqual(cmd[cmd.index('--def') + 1], 'X=1')
             self.assertEqual(run.call_args.kwargs['cwd'], build)
 
+    def test_an_esp_idf_build_uses_its_generated_scripts(self):
+        with tempfile.TemporaryDirectory() as build:
+            open(os.path.join(build, 'build.ninja'), 'w').close()
+            ld = os.path.join(build, 'esp-idf', 'esp_system', 'ld')
+            os.makedirs(ld)
+            commands = 'cc -T memory.ld -T sections.ld -T esp32s3.rom.ld -Wl,--defsym=X=1 -o x.elf\n'
+            open(os.path.join(ld, 'memory.ld'), 'w').close()
+            with mock.patch.object(sd, 'link_command', return_value=commands), \
+                 self.assertRaisesRegex(RuntimeError, 'lacks one of'):
+                sd._link_settings(os.path.join(build, 'x.elf'))
+            open(os.path.join(ld, 'sections.ld'), 'w').close()
+            with mock.patch.object(sd, 'link_command', return_value=commands):
+                self.assertEqual(sd._link_settings(os.path.join(build, 'x.elf')),
+                                 (build, [os.path.join(ld, 'memory.ld'), os.path.join(ld, 'sections.ld')], ['X=1']))
+
     def test_malformed_output_is_a_runtime_error(self):
         done = subprocess.CompletedProcess([], 0, 'not json', '')
         with mock.patch.object(sd, '_link_settings', return_value=('/b', ['m.ld'], [])), \
@@ -167,7 +182,7 @@
         a, = pair_ids(1)
         md = self.report({a: elf({'x.c': (60, 20), 'y.c': (20, 0)}, all_syms=(500, 40))}, 'membrowse')
         self.assertIn('**Coverage (complete, membrowse):** 1 of 1 elfs sized', md)
-        self.assertIn('filtered Flash 80, RAM 20; all symbols Flash 500, RAM 40', md)
+        self.assertIn('TinyUSB Flash 80, RAM 20; all symbols Flash 500, RAM 40', md)
         self.assertIn('| File | .text | .bss | size | % |', md)
         self.assertLess(md.index('| x.c | 60 | 20 | 80 | 80.0% |'), md.index('| y.c | 20 | 0 | 20 | 20.0% |'))
         self.assertIn('| TOTAL | 80 | 20 | 100 | 100.0% |', md)
@@ -461,6 +476,44 @@
         b = self.buckets([('.init_array', INIT_ARRAY, WA, 0x54a4, 4)], [(0x200, 0x200, 0x6000)])
         self.assertEqual(b['.init_array'], {'flash'})
 
+    def test_an_esp_idf_app_is_split_by_its_flash_mapped_sections(self):
+        # every section runs where it loads; .flash* is flash-mapped, the rest the bootloader
+        # copies from the image into RAM (real S3 names, incl. writable .flash.rodata)
+        sections = [('.flash.appdesc', PROGBITS, A, 0x3c020020, 0x100),
+                    ('.flash.rodata', PROGBITS, WA, 0x3c020120, 0x10),
+                    ('.flash.text', PROGBITS, AX, 0x42000020, 0x10),
+                    ('.flash_rodata_dummy', NOBITS, WA, 0x3c000020, 0x20000),
+                    ('.iram0.text', PROGBITS, AX, 0x40374404, 0x10),
+                    ('.dram0.data', PROGBITS, WA, 0x3fc88000, 0x10),
+                    ('.rtc.force_slow', PROGBITS, WA, 0x50000000, 0x10),
+                    ('.dram0.bss', NOBITS, WA, 0x3fc96d20, 0x10)]
+        with tempfile.TemporaryDirectory() as tmp:
+            elf = os.path.join(tmp, 'x.elf')
+            write_elf(elf, sections, [(0, 0, 0xffffffff)])
+            b = sd.section_buckets(elf, os.path.join(tmp, 'no.map'))  # an IDF app never reads the map
+        self.assertEqual({n: set(v) for n, v in b.items() if n != '.shstrtab'},
+                         {'.flash.appdesc': {'flash'}, '.flash.rodata': {'flash'}, '.flash.text': {'flash'},
+                          '.flash_rodata_dummy': set(), '.iram0.text': {'flash', 'ram'},
+                          '.dram0.data': {'flash', 'ram'}, '.rtc.force_slow': {'flash', 'ram'},
+                          '.dram0.bss': {'ram'}})
+
+    def test_overlapping_flash_and_ram_regions_still_fail_outside_esp_idf(self):
+        regions = MAP_REGIONS.replace('m_text           0x00000000         0x00010000         xr',
+                                      'drom0_0_seg      0x3c000020         0x01ffffe0         r\n'
+                                      'extern_ram_seg   0x3c000020         0x01ffffe0         xrw')
+        with self.assertRaisesRegex(RuntimeError, 'no map region recognized'):
+            self.buckets([('.ext_ram.data', PROGBITS, WA, 0x3c040020, 0x10)],
+                         [(0x3c040020, 0x3c040020, 0x200)], regions)
+
+    def test_an_esp_idf_map_is_named_after_the_project(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            elf = os.path.join(tmp, 'x.elf')
+            self.assertEqual(sd._map_path(elf), elf + '.map')  # neither: the usual name, reported missing
+            open(os.path.join(tmp, 'x.map'), 'w').close()
+            self.assertEqual(sd._map_path(elf), os.path.join(tmp, 'x.map'))
+            open(elf + '.map', 'w').close()
+            self.assertEqual(sd._map_path(elf), elf + '.map')
+
     def test_writable_data_in_place_takes_its_map_region(self):
         # RAM-only image (raspberrypi_zero): .data runs where it loads, in RAM
         b = self.buckets([('.data', PROGBITS, WA, 0x20000000, 0x10)], [(0x20000000, 0x20000000, 0x10)])
@@ -580,6 +633,42 @@
                   [(0x10000000, 0x10000000, 0x400), (0x20000000, 0x10000400, 0x30)])
 
 
+class DwarfSources(unittest.TestCase):
+    @unittest.skipUnless(shutil.which('gcc'), 'needs gcc to build an elf with DWARF')
+    def test_compile_units_are_found_by_basename(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            for rel, code in (('src/device/usbd.c', 'int usbd(void) { return 1; }\n'),
+                              ('main.c', 'int usbd(void);\nint main(void) { return usbd(); }\n')):
+                os.makedirs(os.path.dirname(os.path.join(tmp, rel)), exist_ok=True)
+                with open(os.path.join(tmp, rel), 'w') as f:
+                    f.write(code)
+            subprocess.run(['gcc', '-g', '-o', 'x.elf', 'main.c', 'src/device/usbd.c'], cwd=tmp, check=True)
+            sources = sd._dwarf_sources(os.path.join(tmp, 'x.elf'))
+            self.assertEqual(sources['usbd.c'], {os.path.join(os.path.realpath(tmp), 'src', 'device', 'usbd.c')})
+
+
+class SourcePath(unittest.TestCase):
+    def path(self, sym, sources):
+        with mock.patch.object(sd, '_dwarf_sources', return_value=sources):
+            return sd._source_path(sym, 'x.elf', ['/c/src/'])
+
+    def test_an_object_path_or_full_source_is_kept(self):
+        self.assertEqual(self.path({'object_file': '/c/src/tusb.c.obj', 'source_file': 'tusb.c'}, {}),
+                         '/c/src/tusb.c.obj')
+        self.assertEqual(self.path({'source_file': '/c/src/tusb.c'}, {}), '/c/src/tusb.c')
+
+    def test_a_bare_source_name_takes_its_one_compile_unit(self):
+        # ESP-IDF archive member: membrowse keeps only the basename
+        self.assertEqual(self.path({'source_file': 'usbd.c'}, {'usbd.c': {'/c/src/device/usbd.c'}}),
+                         '/c/src/device/usbd.c')
+
+    def test_an_ambiguous_name_fails_only_when_one_candidate_is_filtered(self):
+        self.assertEqual(self.path({'source_file': 'port.c'}, {'port.c': {'/idf/a/port.c', '/idf/b/port.c'}}),
+                         'port.c')
+        with self.assertRaisesRegex(RuntimeError, 'several compile units'):
+            self.path({'source_file': 'x.c', 'name': 'f'}, {'x.c': {'/c/src/x.c', '/idf/x.c'}})
+
+
 class MembrowseSizes(unittest.TestCase):
     def sizes(self, symbols, filters=('/co/src/',)):
         with tempfile.TemporaryDirectory() as tmp:
@@ -622,6 +711,27 @@
         with self.assertRaisesRegex(RuntimeError, r'section \.gone, not in'):
             self.sizes([{'name': 'g', 'size': 4, 'section': '.gone', 'object_file': 'o/co/src/g.c.obj'}])
 
+    @staticmethod
+    def pins(text):
+        return re.findall(r'pip3? install [^\n]*?\bmembrowse\b(==[^\s\'"]+)?', text.replace('\\\n', ' '))
+
+    def test_pins_reports_unpinned_installs_in_every_shell_form(self):
+        self.assertEqual(self.pins('pip3 install membrowse\n'
+                                   'pip install \\\n  --only-binary :all: \\\n  membrowse\n'
+                                   'python -m pip install membrowse\n'), ['', '', ''])
+
+    def test_every_ci_install_pins_the_version_the_install_hint_names(self):
+        self.assertEqual(self.pins("pip install membrowse==1.2.12rc1 'x'; pip install membrowse >/dev/null"),
+                         ['==1.2.12rc1', ''])
+        installs = []
+        for root, _, files in os.walk(os.path.join(REPO, '.github')):
+            for name in files:
+                with open(os.path.join(root, name), errors='replace') as f:
+                    installs += self.pins(f.read())
+        self.assertGreaterEqual(len(installs), 4)
+        self.assertEqual(set(installs), {f'=={sd.MEMBROWSE_CI_VERSION}'})
+        self.assertIn(f'membrowse=={sd.MEMBROWSE_CI_VERSION}', sd.ENGINES['membrowse'].install)
+
 
 class BloatySizes(unittest.TestCase):
     def sizes(self, csv_text, returncode=0):
@@ -706,6 +816,15 @@
         self.assertIn('another --engine', errors[0][1])
         self.assertEqual(out, '')
 
+    def test_fetched_tools_under_deps_are_not_examples(self):
+        # picotool's enc_bootloader.elf has no linker map, so sizing it fails every rp2 board
+        sizer = mock.Mock(return_value=_elf(7))
+        with tempfile.TemporaryDirectory() as build:
+            _touch(build, 'device/ex/ex.elf', '_deps/picotool/enc_bootloader.elf')
+            with _engine('bloaty', sizer):
+                sizes, errors = sd.generate_sizes(build, ['src/'], engine='bloaty')
+        self.assertEqual((list(sizes), errors), (['device/ex/ex.elf'], []))
+
     def test_the_selected_engine_sizes_each_elf(self):
         bloaty = mock.Mock(return_value=_elf(7))
         with mock.patch('glob.glob', return_value=['/fake/build/ex/ex.elf']), \
@@ -881,6 +1000,105 @@
             sd.build_board(tmp, os.path.join(tmp, 'b'), 'b', 'device/ex', 'build')
         self.assertEqual(run.call_args, mock.call(['ninja', '-C', os.path.join(tmp, 'b'), 'ex'], timeout=600))
 
+    def _esp_src(self, tmp):
+        """A checkout with one espressif board, one example and a dependency linked in two
+        hops, as a base worktree's: src -> checkout -> main checkout."""
+        src = os.path.join(tmp, 'src')
+        os.makedirs(os.path.join(src, 'hw', 'bsp', 'espressif', 'boards', 'esp'))
+        # an ESP-IDF component, which get_examples('espressif') requires
+        _touch(src, 'examples/device/a_freertos/src/CMakeLists.txt')
+        os.makedirs(os.path.join(src, 'tools'))
+        os.makedirs(os.path.join(src, 'lib'))
+        os.makedirs(os.path.join(tmp, 'main', 'lib', 'dep'))
+        os.makedirs(os.path.join(tmp, 'checkout', 'lib'))
+        os.symlink(os.path.join(tmp, 'main', 'lib', 'dep'), os.path.join(tmp, 'checkout', 'lib', 'dep'))
+        os.symlink(os.path.join(tmp, 'checkout', 'lib', 'dep'), os.path.join(src, 'lib', 'dep'))
+        with open(os.path.join(src, 'tools', 'get_deps.py'), 'w') as f:
+            f.write("deps_all = {'lib/dep': None, 'lib/absent': None}\n")
+        return src
+
+    def _build_esp(self, tmp, example='device/a_freertos', which=('idf.py',), inspect=0, rc=0, runs=None):
+        """(build_board's error, its run() calls but the image check, also into `runs`); `rc`
+        an exception class raises it from the build."""
+        runs = [] if runs is None else runs
+        def run(cmd, timeout=None):
+            if cmd[:3] == ['docker', 'image', 'inspect']:
+                return subprocess.CompletedProcess([], inspect, '', '')
+            runs.append((cmd, timeout))
+            if isinstance(rc, type) and 'app' in cmd:
+                raise rc
+            return subprocess.CompletedProcess([], rc, '', 'x')
+        with mock.patch.object(sd, 'run', run), mock.patch.object(sd, 'CODE_SIZE_DIR', tmp), \
+             mock.patch.object(sd.shutil, 'which', lambda name: f'/bin/{name}' if name in which else None), \
+             mock.patch.object(sd.build_utils, 'skip_example', return_value=False), \
+             contextlib.redirect_stdout(io.StringIO()):
+            error = sd.build_board(self._esp_src(tmp), os.path.join(tmp, 'b'), 'esp', example, 'build')
+        return error, runs
+
+    def test_an_espressif_board_builds_each_examples_app_as_an_idf_project(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            error, runs = self._build_esp(tmp)
+        self.assertIsNone(error)
+        self.assertEqual(runs, [(['idf.py', '-C', os.path.join(tmp, 'src', 'examples', 'device', 'a_freertos'),
+                                  '-B', os.path.join(tmp, 'b', 'device', 'a_freertos'), '-GNinja', '-DBOARD=esp',
+                                  'app'], 600)])
+
+    def test_without_an_exported_idf_it_builds_in_cis_image_as_this_user(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            error, runs = self._build_esp(tmp, which=('docker',))
+            dep, cache = os.path.realpath(os.path.join(tmp, 'main', 'lib', 'dep')), os.path.join(tmp, '_ccache')
+            mounts = [f'{os.path.realpath(tmp)}/_ccache:{cache}', f'{os.path.realpath(tmp)}/b:{tmp}/b',
+                      f'{dep}:{tmp}/checkout/lib/dep', f'{dep}:{tmp}/main/lib/dep',
+                      f'{os.path.realpath(tmp)}/src:{tmp}/src']
+            self.assertTrue(os.path.isdir(cache))
+        self.assertIsNone(error)
+        cmd = runs[0][0]
+        self.assertEqual(cmd[:11], ['docker', 'run', '--rm', '--name', f'tinyusb-code-size-{os.getpid()}',
+                                    '--user', f'{os.getuid()}:{os.getgid()}', '-e', 'HOME=/tmp',
+                                    '-e', f'CCACHE_DIR={cache}'])
+        self.assertEqual([cmd[i + 1] for i, a in enumerate(cmd) if a == '-v'], mounts)
+        self.assertEqual(cmd[cmd.index(sd.ESP_IDF_IMAGE):][:3], [sd.ESP_IDF_IMAGE, 'idf.py', '-C'])
+
+    def test_without_idf_or_its_image_the_espressif_build_fails_saying_how_to_get_one(self):
+        for which, inspect in (((), 0), (('docker',), 1)):
+            with tempfile.TemporaryDirectory() as tmp:
+                error, runs = self._build_esp(tmp, which=which, inspect=inspect)
+            self.assertEqual(runs, [])
+            self.assertIn('esp needs ESP-IDF: source $IDF_PATH/export.sh', error)
+
+    def test_without_idf_report_and_diff_refuse_an_espressif_board_before_building(self):
+        pinned = ['stm32f407disco', 'espressif_s3_devkitm']
+        for argv in (['report', '-b', pinned[0], '-b', pinned[1]], ['diff', '-b', pinned[0], '-b', pinned[1]],
+                     ['diff', '--ci']):
+            err = io.StringIO()
+            with mock.patch.object(sys, 'argv', ['code_size.py'] + argv), \
+                 mock.patch.object(sd, 'ci_pinned_boards', return_value=pinned), \
+                 mock.patch.object(sd, 'engine_missing', return_value=False), \
+                 mock.patch.object(sd.shutil, 'which', return_value=None), \
+                 mock.patch.object(sd, 'run') as run, contextlib.redirect_stderr(err):
+                with self.assertRaises(SystemExit):
+                    sd.main()
+            run.assert_not_called()
+            self.assertIn('espressif_s3_devkitm need ESP-IDF: source $IDF_PATH/export.sh', err.getvalue())
+
+    def test_an_example_the_tree_does_not_build_for_espressif_fails_the_build(self):
+        # device/board_test: an espressif example absent from this tree
+        for example in ('device/cdc_msc', 'device/board_test'):
+            with tempfile.TemporaryDirectory() as tmp:
+                error, runs = self._build_esp(tmp, example=example)
+            self.assertEqual((error, runs), (f'esp builds no {example}', []))
+
+    def test_a_timed_out_or_interrupted_docker_build_removes_its_container(self):
+        rm = ['docker', 'rm', '-f', f'tinyusb-code-size-{os.getpid()}']
+        for which, rc, removed in ((('docker',), 124, True), (('docker',), 2, False), (('idf.py',), 124, False)):
+            with tempfile.TemporaryDirectory() as tmp:
+                _error, runs = self._build_esp(tmp, which=which, rc=rc)
+            self.assertEqual(runs[-1][0] == rm, removed, (which, rc))
+        runs = []
+        with tempfile.TemporaryDirectory() as tmp, self.assertRaises(KeyboardInterrupt):
+            self._build_esp(tmp, which=('docker',), rc=KeyboardInterrupt, runs=runs)
+        self.assertEqual(runs[-1][0], rm)
+
     def test_a_timeout_returns_124_and_keeps_the_output_as_text(self):
         ret = sd.run(['sh', '-c', 'printf err >&2; echo out; exec sleep 30'], timeout=1)
         self.assertEqual(ret.returncode, 124)
@@ -1095,7 +1313,7 @@
             with open(os.path.join(tmp, 'b', 'diff.md')) as f:
                 self.assertIn('| x.c | 1 → 3 | +2 | 0 → 0 | 0 |', unpad(f.read()))
             # no -e: the console gets the summary line, the tables stay in the .md
-            self.assertIn('  1 pair, 1 changed; filtered Flash Δ +2, RAM Δ 0\n', out)
+            self.assertIn('  1 pair, 1 changed; TinyUSB Flash Δ +2, RAM Δ 0\n', out)
             self.assertNotIn('| x.c |', out)
 
     def test_a_filter_mismatch_fails_the_size_and_compare_phase(self):
@@ -1192,7 +1410,7 @@
             rc, out = self._run_main(tmp, ['-b', 'b', '-e', 'ex'], build, lambda *_a, **_k: next(side_sizes))
             self.assertEqual(rc, 0)
             self.assertRegex(out, r'^diff master \(c0ffee\) vs working tree \(c0ffee\) · membrowse\n\[1/1\] b / ex\n'
-                                  r'  size and compare… \d+\.\ds\n  1 pair, 1 changed; filtered Flash Δ \+2')
+                                  r'  size and compare… \d+\.\ds\n  1 pair, 1 changed; TinyUSB Flash Δ \+2')
             # indented tables; unpad() folds their indent to one space
             self.assertIn('\n | x.c | 1 → 3 | +2 | 0 → 0 | 0 |', out)
             self.assertIn('\n | x.c | +2 | +2 |', out)
@@ -1223,10 +1441,19 @@
             rc, out = self._main(tmp, sizes=({'ex/ex.elf': _elf(8)}, []),
                                  cur_sizes=({'ex/ex.elf': empty}, []))
             self.assertEqual(rc, 0)
-            self.assertIn('1 pair, 1 changed; filtered Flash Δ -8', out)
+            self.assertIn('1 pair, 1 changed; TinyUSB Flash Δ -8', out)
             with open(os.path.join(tmp, 'b', 'diff.md')) as f:
                 self.assertIn('| x.c | 8 → 0 | -8 | 0 → 0 | 0 |', unpad(f.read()))
 
+    def test_a_custom_filter_is_not_called_tinyusb(self):
+        def build(_src, _build_dir, board, *_args, **_kwargs):
+            os.makedirs(os.path.join(tmp, board), exist_ok=True)
+        side_sizes = iter([({'ex/ex.elf': _elf(1)}, []), ({'ex/ex.elf': _elf(3)}, [])])
+        with tempfile.TemporaryDirectory() as tmp:
+            rc, out = self._run_main(tmp, ['-b', 'b', '-f', 'lib/'], build, lambda *_a, **_k: next(side_sizes))
+        self.assertEqual(rc, 0)
+        self.assertIn('1 pair, 1 changed; filtered Flash Δ +2', out)
+
     def _main_combined(self, tmp, sizes, build_ok=('b1', 'b2'), example=None):
         """main() with -b b1 -b b2 --combined. `sizes` maps
         (board, side) to what generate_sizes() returns; boards not in
@@ -1558,7 +1785,7 @@
             self.assertIn('Coverage (complete, bloaty)', md)
             self.assertIn('| x.c | 4 | 4 | 100.0% |', md)
             self.assertRegex(self.out, r'^report working tree · bloaty\n\[1/1\] b / device/cdc_msc\n'
-                                       r'  size… \d+\.\ds\n  1 of 1 elfs sized; filtered Flash 4, RAM 0\n')
+                                       r'  size… \d+\.\ds\n  1 of 1 elfs sized; TinyUSB Flash 4, RAM 0\n')
             self.assertIn('\n | x.c | 4 | 4 | 100.0% |', self.out)
 
     def test_an_examples_trailing_slash_is_dropped(self):
@@ -1708,6 +1935,530 @@
                                             '"uncovered": []}')
             self.assertEqual(built, ['extra', 'b1', 'b2'])
 
+    def test_ci_builds_pinned_espressif_boards_too(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            built = self._boards_built(tmp, '{"boards": [{"board": "b1"}, '
+                                            '{"board": "espressif_s3_devkitm"}], "uncovered": []}')
+        self.assertEqual(built, ['extra', 'b1', 'espressif_s3_devkitm'])
+
+
+def _touch(root, *rels):
+    for rel in rels:
+        os.makedirs(os.path.dirname(os.path.join(root, rel)), exist_ok=True)
+        open(os.path.join(root, rel), 'w').close()
+
+
+class Snapshot(unittest.TestCase):
+    SHA = [c * 40 for c in 'abc']
+
+    def test_a_missing_build_dir_is_a_build_failure(self):
+        elfs, failures = sd.board_snapshot('b1', '/nonexistent/cmake-build-b1', None, ['src/'])
+        self.assertEqual(elfs, {})
+        self.assertEqual([(f['elf'], f['stage']) for f in failures], [(None, 'build')])
+
+    def test_sizes_every_elf_and_records_failed_ones(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            _touch(tmp, 'device/a/a.elf', 'device/a/loader.elf', 'host/bad/bad.elf')
+
+            def sizes(path, _filters):
+                if 'bad' in path:
+                    raise RuntimeError('membrowse report failed')
+                return _elf(5)
+            with _engine('membrowse', sizes):
+                elfs, failures = sd.board_snapshot('b1', tmp, None, ['src/'])
+        self.assertEqual(sorted(elfs), ['device/a/a.elf', 'device/a/loader.elf'])
+        self.assertEqual([(f['elf'], f['stage']) for f in failures], [('host/bad/bad.elf', 'report')])
+
+    def test_scoped_examples_skip_what_the_board_skips_and_fail_a_missing_one(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            _touch(tmp, 'device/a/a.elf')
+            with _engine('membrowse', lambda _p, _f: _elf(5)), \
+                 mock.patch.object(sd.build_utils, 'skip_example', side_effect=lambda e, b, _d: e == 'device/skipped'):
+                elfs, failures = sd.board_snapshot('b1', tmp, ['device/a', 'device/skipped', 'device/gone'],
+                                                   ['src/'])
+        self.assertEqual(list(elfs), ['device/a/a.elf'])
+        self.assertEqual(len(failures), 1)
+        self.assertEqual(failures[0]['stage'], 'build')
+        self.assertIn('device/gone', failures[0]['message'])
+
+    def test_no_tinyusb_file_in_any_elf_fails_the_filter(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            _touch(tmp, 'device/a/a.elf')
+            with _engine('membrowse', lambda _p, _f: elf({})):
+                elfs, failures = sd.board_snapshot('b1', tmp, None, ['src/'])
+        self.assertEqual(list(elfs), ['device/a/a.elf'])
+        self.assertEqual([f['stage'] for f in failures], ['filter'])
+
+    def test_compiler_comes_from_cmake_not_the_host(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            os.makedirs(os.path.join(tmp, 'CMakeFiles', '4.1.2'))
+            with open(os.path.join(tmp, 'CMakeFiles', '4.1.2', 'CMakeCCompiler.cmake'), 'w') as f:
+                f.write('set(CMAKE_C_COMPILER "/opt/arm/bin/arm-none-eabi-gcc")\n'
+                        'set(CMAKE_C_COMPILER_ID "GNU")\nset(CMAKE_C_COMPILER_VERSION "13.3.1")\n')
+            with open(os.path.join(tmp, 'CMakeCache.txt'), 'w') as f:
+                f.write('CMAKE_BUILD_TYPE:STRING=MinSizeRel\n')
+            self.assertEqual(sd._cmake_compiler(tmp), {'id': 'GNU', 'version': '13.3.1',
+                                                       'name': 'arm-none-eabi-gcc', 'build_type': 'MinSizeRel'})
+        self.assertEqual(sd._cmake_compiler('/nonexistent'), {'id': '', 'version': '', 'name': '', 'build_type': ''})
+
+    def test_an_esp_idf_board_reads_its_first_example_project(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            for ex, ver in (('device/b', '14.2.0'), ('device/a', '14.2.0'), ('device/a/bootloader', '1.0')):
+                os.makedirs(os.path.join(tmp, ex, 'CMakeFiles', '3.30'))
+                with open(os.path.join(tmp, ex, 'CMakeFiles', '3.30', 'CMakeCCompiler.cmake'), 'w') as f:
+                    f.write(f'set(CMAKE_C_COMPILER "/idf/xtensa-esp32s3-elf-gcc")\nset(CMAKE_C_COMPILER_VERSION "{ver}")\n')
+            with open(os.path.join(tmp, 'device', 'a', 'CMakeCache.txt'), 'w') as f:
+                f.write('CMAKE_BUILD_TYPE:STRING=\n')
+            self.assertEqual(sd._cmake_compiler(tmp), {'id': '', 'version': '14.2.0',
+                                                       'name': 'xtensa-esp32s3-elf-gcc', 'build_type': ''})
+
+    def test_a_pull_request_reads_base_and_head_from_the_merge_commit(self):
+        ret = subprocess.CompletedProcess([], 0, '\n'.join(self.SHA) + '\n', '')
+        with mock.patch.object(sd, 'run', return_value=ret) as run:
+            self.assertEqual(sd._git_shas('pull_request'), tuple(self.SHA))
+        self.assertEqual(run.call_args[0][0][-3:], ['HEAD', 'HEAD^1', 'HEAD^2'])
+        with mock.patch.object(sd, 'run', return_value=subprocess.CompletedProcess([], 0, self.SHA[0], '')):
+            self.assertEqual(sd._git_shas('push'), (self.SHA[0],) * 3)
+
+    def test_unresolvable_commits_raise(self):
+        # a shallow pull_request checkout has no HEAD^2: never a snapshot with a guessed base
+        for ret in (subprocess.CompletedProcess([], 128, '', 'unknown revision HEAD^2'),
+                    subprocess.CompletedProcess([], 0, 'HEAD^2\n', '')):
+            with mock.patch.object(sd, 'run', return_value=ret), self.assertRaises(RuntimeError):
+                sd._git_shas('pull_request')
+
+    def test_boards_are_the_pinned_ones_of_the_leg(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            pinned = os.path.join(tmp, 'pinned.json')
+            with open(pinned, 'w') as f:
+                f.write('{"boards": [{"board": "p1"}, {"board": "p2"}]}')
+            import build
+            with mock.patch.object(sd, 'CI_PINNED_BOARDS', pinned), \
+                 mock.patch.object(build, 'builds_any', return_value=True), \
+                 mock.patch.object(build, 'resolve_ci_boards', return_value=['p2']) as resolve:
+                boards = sd.snapshot_boards(['fam'], ['p1', 'notpinned', 'p2'], ['device/a'])
+        self.assertEqual(boards, ['p1', 'p2'])
+        resolve.assert_called_once_with(pinned, 'fam', True, ['device/a'], extra_defines=())
+
+    def test_a_pinned_board_that_builds_none_of_the_examples_is_not_expected(self):
+        # build.py skips it before configuring: no build dir, and no failure either
+        with tempfile.TemporaryDirectory() as tmp:
+            pinned = os.path.join(tmp, 'pinned.json')
+            with open(pinned, 'w') as f:
+                f.write('{"boards": [{"board": "p1"}, {"board": "p2"}]}')
+            import build
+            with mock.patch.object(sd, 'CI_PINNED_BOARDS', pinned), \
+                 mock.patch.object(build, 'builds_any', side_effect=lambda b, _e, _d: b == 'p2'):
+                self.assertEqual(sd.snapshot_boards([], ['p1', 'p2'], ['host/device_info']), ['p2'])
+
+    def test_non_integer_sizes_fail_the_elf(self):
+        for bad in (1.5, True):
+            sizes = _elf(5)
+            sizes['files']['x.c']['flash'] = bad
+            with tempfile.TemporaryDirectory() as tmp:
+                _touch(tmp, 'device/a/a.elf')
+                with _engine('membrowse', lambda _p, _f, s=sizes: s):
+                    elfs, failures = sd.board_snapshot('b1', tmp, None, ['src/'])
+            self.assertEqual(elfs, {})
+            self.assertEqual([(f['elf'], f['stage']) for f in failures], [('device/a/a.elf', 'report')])
+
+    def test_a_repeated_example_is_sized_once(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            _touch(tmp, 'device/a/a.elf')
+            sizer = mock.Mock(return_value=_elf(5))
+            with _engine('membrowse', sizer), mock.patch.object(sd.build_utils, 'skip_example', return_value=False):
+                elfs, failures = sd.board_snapshot('b1', tmp, ['device/a', 'device/a'], ['src/'])
+        self.assertEqual((list(elfs), failures, sizer.call_count), (['device/a/a.elf'], [], 1))
+
+    def test_writes_one_json_per_board_symbols_only_on_request(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            build_root, out = os.path.join(tmp, 'cmake-build'), os.path.join(tmp, 'out')
+            _touch(build_root, 'cmake-build-b1/device/a/a.elf')
+            for symbols in (False, True):
+                args = mock.Mock(event='push', families=[], board=['b1'], example=None, output=out,
+                                 build_root=build_root, build_outcome='failure', symbols=symbols, filter=None,
+                                 build_name=None, define_symbol=[])
+                with _engine('membrowse', lambda _p, _f: _elf(5)), \
+                     mock.patch.object(sd, '_git_shas', return_value=(self.SHA[0],) * 3), \
+                     mock.patch.object(sd, 'snapshot_boards', return_value=['b1']), \
+                     contextlib.redirect_stdout(io.StringIO()):
+                    self.assertEqual(sd.run_snapshot(args), 0)
+                with open(os.path.join(out, 'code-size-b1.json')) as f:
+                    data = json.load(f)
+                self.assertEqual((data['schema'], data['board']), (sd.SNAPSHOT_SCHEMA, 'b1'))
+                self.assertEqual(data['elfs']['device/a/a.elf']['files'], {'x.c': {'flash': 5, 'ram': 0}})
+                self.assertEqual('symbols' in data['elfs']['device/a/a.elf'], symbols)
+                with open(os.path.join(out, 'leg.json')) as f:
+                    leg = json.load(f)
+                self.assertEqual((leg['boards'], leg['sha'], leg['build_outcome']), (['b1'], self.SHA[0], 'failure'))
+
+    def test_a_leg_without_pinned_boards_still_leaves_its_marker(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            args = mock.Mock(event='push', families=['fam'], board=[], example=['device/a'], output=tmp,
+                             build_root=tmp, build_outcome='success', symbols=False, filter=None, build_name=None,
+                             define_symbol=[])
+            with mock.patch.object(sd, '_git_shas', return_value=(self.SHA[0],) * 3), \
+                 mock.patch.object(sd, 'snapshot_boards', return_value=[]), \
+                 contextlib.redirect_stdout(io.StringIO()):
+                self.assertEqual(sd.run_snapshot(args), 0)
+            self.assertEqual(os.listdir(tmp), ['leg.json'])
+            with open(os.path.join(tmp, 'leg.json')) as f:
+                leg = json.load(f)
+        self.assertEqual((leg['boards'], leg['examples']), ([], ['device/a']))
+
+    def test_a_variant_reports_its_board_under_the_build_name(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            build_root, out = os.path.join(tmp, 'cmake-build'), os.path.join(tmp, 'out')
+            _touch(build_root, 'cmake-build-b1/device/a/a.elf', 'cmake-build-b1-DMA/device/a/a.elf')
+            args = mock.Mock(event='push', families=[], board=['b1'], example=['device/a'], output=out,
+                             build_root=build_root, build_outcome='success', symbols=False, filter=None,
+                             build_name='b1-DMA', define_symbol=['MAX3421_HOST=1'])
+            sized = []
+            with _engine('membrowse', lambda p, _f: sized.append(p) or _elf(5)), \
+                 mock.patch.object(sd, '_git_shas', return_value=(self.SHA[0],) * 3), \
+                 mock.patch.object(sd, 'snapshot_boards', return_value=['b1']) as boards, \
+                 mock.patch.object(sd.build_utils, 'skip_example', return_value=False) as skip, \
+                 contextlib.redirect_stdout(io.StringIO()):
+                self.assertEqual(sd.run_snapshot(args), 0)
+            self.assertEqual(sorted(os.listdir(out)), ['code-size-b1-DMA.json', 'leg.json'])
+            with open(os.path.join(out, 'leg.json')) as f:
+                self.assertEqual(json.load(f)['boards'], ['b1-DMA'])
+            with open(os.path.join(out, 'code-size-b1-DMA.json')) as f:
+                self.assertEqual(json.load(f)['board'], 'b1-DMA')
+        self.assertEqual([os.path.relpath(p, build_root) for p in sized], ['cmake-build-b1-DMA/device/a/a.elf'])
+        # the board's own skip rules, with the leg's defines as build.py applied them
+        boards.assert_called_once_with([], ['b1'], ['device/a'], ('MAX3421_HOST=1',))
+        skip.assert_called_with('device/a', 'b1', ('MAX3421_HOST=1',))
+
+    def test_a_variant_needs_one_board_and_a_build_name(self):
+        for argv in (['-b', 'b1', '-b', 'b2', '--build-name', 'x'], ['fam', '--build-name', 'x'],
+                     ['-b', 'b1', '--cflag=-DX=1'], ['-b', 'b1', '-DX=1'], ['-b', 'b1', '--build-name', '../x']):
+            with mock.patch.object(sd, 'engine_missing', return_value=False), \
+                 mock.patch.object(sd, 'run_snapshot') as run, \
+                 mock.patch.object(sys, 'argv', ['code_size.py', 'snapshot', '-o', 'out'] + argv), \
+                 contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
+                sd.main()
+            run.assert_not_called()
+
+    def test_a_variant_takes_build_py_defines(self):
+        argv = ['-b', 'b1', '--build-name', 'b1-X', '-DX=1', '--define-symbol', 'Y=2', '--cflag=-DZ=3']
+        with mock.patch.object(sd, 'engine_missing', return_value=False), \
+             mock.patch.object(sd, 'run_snapshot', return_value=0) as run, \
+             mock.patch.object(sys, 'argv', ['code_size.py', 'snapshot', '-o', 'out'] + argv):
+            self.assertEqual(sd.main(), 0)
+        self.assertEqual((run.call_args[0][0].define_symbol, run.call_args[0][0].cflag), (['X=1', 'Y=2'], ['-DZ=3']))
+
+
+def _shard(board, elfs, sha='a', base='b', head='c', failures=(), examples=None, compiler='gcc 14'):
+    return {'schema': 1, 'board': board, 'engine': 'membrowse', 'membrowse_version': '1.2.9',
+            'compiler': compiler, 'sha': sha * 40, 'base_sha': base * 40, 'head_sha': head * 40,
+            'build_outcome': 'success', 'examples': examples, 'elfs': elfs, 'failures': list(failures)}
+
+
+def _write(root, rel, data):
+    os.makedirs(os.path.dirname(os.path.join(root, rel)), exist_ok=True)
+    with open(os.path.join(root, rel), 'w') as f:
+        json.dump(data, f)
+
+
+LEG_KEYS = ('sha', 'base_sha', 'head_sha', 'examples', 'build_outcome')
+
+
+def _run_dir(root, shards, legs=None, scope=None):
+    """A downloaded run: one artifact dir per leg ({artifact: [boards]}), each leg taking its
+    commits, examples and build outcome from its first board's _shard(), and the shards,
+    without those, spread over them."""
+    legs = legs if legs is not None else {'code-size-arm-gcc-fam': [s['board'] for s in shards]}
+    by_board = {s['board']: s for s in shards}
+    for artifact, boards in legs.items():
+        first = by_board.get(boards[0], _shard('x', {})) if boards else _shard('x', {})
+        _write(root, f'{artifact}/leg.json', {'schema': 1, 'boards': boards, **{k: first[k] for k in LEG_KEYS}})
+        for b in boards:
+            if b in by_board:
+                _write(root, f'{artifact}/code-size-{b}.json',
+                       {k: v for k, v in by_board[b].items() if k not in LEG_KEYS})
+    if scope is not None:
+        _write(root, 'code-size-scope/scope.json', {'schema': 1, **scope})
+    return sd.load_snapshots(root)
+
+
+class Compare(unittest.TestCase):
+    SCOPE = {'code_changed': True, 'legs': [{'toolchain': 'arm-gcc', 'arg': 'fam'}]}
+
+    def compare(self, base_shards, cur_shards, cur_legs=None, scope=SCOPE, baseline=None, symbols=False):
+        with tempfile.TemporaryDirectory() as b, tempfile.TemporaryDirectory() as c:
+            return sd.compare_runs(_run_dir(b, base_shards, scope=self.SCOPE),
+                                   _run_dir(c, cur_shards, cur_legs, scope), baseline, symbols)
+
+    def failed(self, data):
+        return {(f['board'], f['side'], f['stage']) for f in data['failures']}
+
+    def test_pairs_boards_and_reports_the_change(self):
+        md, comment, data = self.compare([_shard('b1', {'device/a/a.elf': _elf(10)}, sha='b', head='b')],
+                                         [_shard('b1', {'device/a/a.elf': _elf(14)})])
+        self.assertEqual(data['status'], 'complete')
+        self.assertIn('+4', md)
+        self.assertTrue(comment.startswith('## Code size'))
+        self.assertIn('bbbbbbbbbb', md)  # sides labelled by the merge commit's parents
+
+    def test_base_elfs_of_examples_the_pr_did_not_build_are_outside_coverage(self):
+        base = _shard('b1', {'device/a/a.elf': _elf(10), 'device/z/z.elf': _elf(3)})
+        cur = _shard('b1', {'device/a/a.elf': _elf(10)}, examples=['device/a'])
+        scope = {**self.SCOPE, 'family_examples': {'fam': ['device/a']}}
+        _md, _c, data = self.compare([base], [cur], scope=scope)
+        self.assertEqual(data['status'], 'complete')
+        self.assertEqual(data['outside'], 1)
+        self.assertEqual(data['base_only'], [])
+
+    def test_a_leg_measuring_other_examples_than_the_scope_fails(self):
+        cur = _shard('b1', {'device/z/z.elf': _elf(1)}, examples=['device/z'])
+        scope = {**self.SCOPE, 'family_examples': {'fam': ['device/a']}}
+        _md, _c, data = self.compare([_shard('b1', {'device/z/z.elf': _elf(1)})], [cur], scope=scope)
+        self.assertIn(('code-size-arm-gcc-fam', 'current', 'scope'), self.failed(data))
+
+    def test_an_esp_leg_matches_its_artifact_and_its_own_examples(self):
+        arg = '-b espressif_s3_devkitm -e device/cdc_msc_freertos'
+        scope = {'code_changed': True, 'legs': [{'toolchain': 'esp-idf', 'arg': arg}]}
+        legs = {'code-size-esp-idf--b espressif_s3_devkitm': ['espressif_s3_devkitm']}
+        elfs = {'device/cdc_msc_freertos/cdc_msc_freertos.elf': _elf(1)}
+        _md, _c, data = self.compare([_shard('espressif_s3_devkitm', elfs)],
+                                     [_shard('espressif_s3_devkitm', elfs, examples=['device/cdc_msc_freertos'])],
+                                     cur_legs=legs, scope=scope)
+        self.assertEqual(data['status'], 'complete')
+        _md, _c, data = self.compare([], [_shard('espressif_s3_devkitm', elfs, examples=['device/other'])],
+                                     cur_legs=legs, scope=scope)
+        self.assertIn(('code-size-esp-idf--b espressif_s3_devkitm', 'current', 'scope'), self.failed(data))
+
+    def test_a_leg_outside_the_scope_and_a_shard_outside_its_leg_fail(self):
+        shards = [_shard('b1', {'device/a/a.elf': _elf(1)}), _shard('b2', {'device/a/a.elf': _elf(1)})]
+        with tempfile.TemporaryDirectory() as b, tempfile.TemporaryDirectory() as c:
+            cur = _run_dir(c, shards, {'code-size-arm-gcc-fam': ['b1'], 'code-size-arm-gcc-extra': ['b2']},
+                           self.SCOPE)
+            _write(c, 'code-size-arm-gcc-fam/code-size-b3.json', _shard('b3', {'device/a/a.elf': _elf(1)}))
+            _md, _c, data = sd.compare_runs(_run_dir(b, shards + [_shard('b3', {})]), sd.load_snapshots(c))
+        self.assertIn(('code-size-arm-gcc-extra', 'current', 'scope'), self.failed(data))
+        self.assertIn(('b3', 'current', 'snapshot'), self.failed(data))
+        self.assertEqual(data['boards'], ['b1', 'b2'])
+
+    def test_a_new_elf_is_current_only(self):
+        md, comment, data = self.compare([_shard('b1', {'device/a/a.elf': _elf(10), 'device/o/o.elf': _elf(1)})],
+                                         [_shard('b1', {'device/a/a.elf': _elf(10), 'device/n/n.elf': _elf(1)})])
+        self.assertEqual(data['current_only'], [{'board': 'b1', 'elf': 'device/n/n.elf'}])
+        self.assertEqual(data['status'], 'INCOMPLETE')
+        self.assertIn('- missing in this PR: `b1: device/o/o.elf`\n'
+                      '- new in this PR (no master size yet): `b1: device/n/n.elf`\n', comment)
+        self.assertIn('current-only: `b1: device/n/n.elf`', md)  # the full report keeps its terms
+
+    def test_a_board_without_baseline_leaves_coverage_incomplete(self):
+        md, comment, data = self.compare([_shard('b1', {'device/a/a.elf': _elf(10)})],
+                                         [_shard('b1', {'device/a/a.elf': _elf(10)}), _shard('b2', {'device/a/a.elf': _elf(1)})])
+        self.assertEqual(data['no_baseline'], ['b2'])
+        self.assertEqual(data['status'], 'INCOMPLETE')
+        self.assertIn('FAILED `b2` base snapshot: no baseline snapshot', md)
+        self.assertEqual(len(data['pairs']), 1)
+        self.assertIn('1 builds on 1 boards compared, 0 changed\n\nNot compared:\n\n'
+                      '- FAILED `b2` master snapshot: no baseline snapshot\n', comment)
+        self.assertNotIn('INCOMPLETE', comment)
+
+    def test_a_missing_scope_or_a_failed_build_leaves_coverage_incomplete(self):
+        broken = {**_shard('b1', {'device/a/a.elf': _elf(1)}), 'build_outcome': 'failure'}
+        _md, _c, data = self.compare([_shard('b1', {'device/a/a.elf': _elf(1)})], [broken], scope=None)
+        self.assertEqual(self.failed(data), {('scope', 'current', 'snapshot'), ('b1', 'current', 'build')})
+
+    def test_a_leg_or_board_that_left_no_snapshot_fails(self):
+        scope = {'code_changed': True, 'legs': [{'toolchain': 'arm-gcc', 'arg': 'fam -e device/a'},
+                                               {'toolchain': 'riscv-gcc', 'arg': 'other'}]}
+        _md, _c, data = self.compare([_shard('b1', {'device/a/a.elf': _elf(1)})],
+                                     [_shard('b1', {'device/a/a.elf': _elf(1)}, examples=['device/a'])],
+                                     cur_legs={'code-size-arm-gcc-fam': ['b1', 'b2']}, scope=scope)
+        self.assertEqual(self.failed(data), {('code-size-riscv-gcc-other', 'current', 'snapshot'),
+                                             ('b2', 'current', 'snapshot')})
+
+    def test_a_failed_baseline_elf_is_a_base_failure_not_a_new_elf(self):
+        base = _shard('b1', {'device/a/a.elf': _elf(1)},
+                      failures=[{'elf': 'device/n/n.elf', 'stage': 'report', 'message': 'boom'}])
+        _md, _c, data = self.compare([base], [_shard('b1', {'device/a/a.elf': _elf(1), 'device/n/n.elf': _elf(2)})])
+        self.assertEqual(data['current_only'], [])
+        self.assertEqual([(f['elf'], f['side']) for f in data['failures']], [('device/n/n.elf', 'base')])
+
+    def test_a_compiler_mismatch_is_a_warning(self):
+        md, _c, data = self.compare([_shard('b1', {'device/a/a.elf': _elf(1)}, compiler='gcc 13')],
+                                    [_shard('b1', {'device/a/a.elf': _elf(1)})])
+        self.assertEqual(data['status'], 'complete')
+        self.assertIn('compiler differs', md)
+
+    def test_current_snapshots_of_different_commits_are_not_compared(self):
+        _md, _c, data = self.compare([_shard('b1', {'device/a/a.elf': _elf(1)}), _shard('b2', {'device/a/a.elf': _elf(1)})],
+                                     [_shard('b1', {'device/a/a.elf': _elf(1)}),
+                                      _shard('b2', {'device/a/a.elf': _elf(1)}, head='d')],
+                                     cur_legs={'code-size-arm-gcc-fam': ['b1'], 'code-size-arm-gcc-x': ['b2']},
+                                     scope={'code_changed': True, 'legs': [{'toolchain': 'arm-gcc', 'arg': 'fam'},
+                                                                           {'toolchain': 'arm-gcc', 'arg': 'x'}]})
+        self.assertIn(('commits', 'current', 'snapshot'), self.failed(data))
+        self.assertEqual(data['pairs'], [])
+
+    def test_baseline_snapshots_of_another_commit_than_the_selected_run_are_dropped(self):
+        _md, _c, data = self.compare([_shard('b1', {'device/a/a.elf': _elf(1)}, sha='d')],
+                                     [_shard('b1', {'device/a/a.elf': _elf(1)})],
+                                     baseline={'sha': 'b' * 40, 'url': 'u', 'exact': True})
+        self.assertIn(('commits', 'base', 'snapshot'), self.failed(data))
+        self.assertEqual(data['pairs'], [])
+
+    def test_a_run_with_no_leg_to_measure_says_so(self):
+        for changed in (False, True):  # e.g. a HIL-harness-only PR selects no pinned leg
+            md, comment, data = self.compare([], [], cur_legs={}, scope={'code_changed': changed, 'legs': []})
+            self.assertEqual(data, {'status': 'nothing measured'})
+            self.assertTrue(comment.startswith('## Code size\n\nNothing to measure'))
+
+    def test_snapshots_a_scope_without_legs_did_not_expect_are_reported(self):
+        _md, _c, data = self.compare([_shard('b1', {'device/a/a.elf': _elf(1)})],
+                                     [_shard('b1', {'device/a/a.elf': _elf(41)})],
+                                     scope={'code_changed': True, 'legs': []})
+        self.assertEqual(data['status'], 'INCOMPLETE')
+        self.assertIn(('code-size-arm-gcc-fam', 'current', 'scope'), self.failed(data))
+
+    def test_the_comment_has_a_row_per_changed_file_however_small(self):
+        def msc(n, ram=0):
+            return elf({'class/msc/msc_device.c': (n, 0), 'device/usbd.c': (500, ram)})
+        base = [_shard('b1', {'device/msc/msc.elf': msc(4000), 'device/hid/hid.elf': elf({'class/hid/hid_device.c': (1200, 128)}),
+                              'device/n/n.elf': elf({'device/usbd.c': (500, 0)}, all_syms=(900, 9))}),
+                _shard('b2', {'device/msc/msc.elf': msc(3800)})]
+        cur = [_shard('b1', {'device/msc/msc.elf': msc(4200, 4), 'device/hid/hid.elf': elf({'class/hid/hid_device.c': (1224, 136)}),
+                             'device/n/n.elf': elf({'device/usbd.c': (500, 0)}, all_syms=(904, 9))}),
+               _shard('b2', {'device/msc/msc.elf': msc(3980)})]
+        md, comment, _data = self.compare(base, cur)
+        self.assertIn('4 builds on 2 boards compared, 4 changed. Whole firmware¹ Flash Δ +4 → +200, '
+                      'RAM Δ 0 → +8', comment)
+        rows = [line for line in comment.splitlines() if line.startswith('| ') and 'File' not in line]
+        self.assertEqual([[c.strip() for c in r.strip('|').split('|')] for r in rows],
+                         [['class/msc/msc_device.c', '+180 → +200', '0'],
+                          ['class/hid/hid_device.c', '+24', '+8'],
+                          ['device/usbd.c', '0', '0 → +4']])
+        self.assertIn('1 other build changed without a TinyUSB file-size change.', comment)
+        self.assertNotIn('boards: ', comment)
+        self.assertNotIn('Not compared', comment)
+        self.assertNotIn('| b1: ', comment)
+        self.assertNotIn('<details>', comment)
+        self.assertIn('<details>', md)
+        self.assertIn('| b1: device/hid/hid.elf ', md)  # the full report keeps every pair
+
+    def test_a_comment_over_the_limit_is_cut_at_a_line(self):
+        base = _shard('b1', {'device/a/a.elf': elf({f'f{j}.c': (10, 0) for j in range(50)})})
+        cur = _shard('b1', {'device/a/a.elf': elf({f'f{j}.c': (11, 0) for j in range(50)})})
+        with mock.patch.object(sd, 'COMMENT_LIMIT', 800):
+            _md, comment, _data = self.compare([base], [cur])
+        self.assertLessEqual(len(comment), 800)
+        self.assertIn('Whole firmware¹', comment)
+        self.assertTrue(comment.endswith('_Truncated: see the full report._\n\n'
+                                         '¹ Sum of all symbols measured by membrowse, not the exact image size.\n'))
+
+    def test_a_symbol_only_change_counts_under_symbols(self):
+        sizes = lambda a, b: elf({'x.c': (10, 0)}, symbols={'x.c': {'.text': {'f': a, 'g': b}}})  # noqa: E731
+        for symbols, head in ((False, '1 builds on 1 boards compared, 0 changed'),
+                              (True, '1 builds on 1 boards compared, 1 changed')):
+            _md, comment, _data = self.compare([_shard('b1', {'device/a/a.elf': sizes(4, 6)})],
+                                               [_shard('b1', {'device/a/a.elf': sizes(6, 4)})], symbols=symbols)
+            self.assertIn(head, comment)
+        self.assertIn('1 other build changed without a TinyUSB file-size change.', comment)
+
+    def test_pr_controlled_names_cannot_inject_markdown(self):
+        evil = 'x`|<img src=x>@team\n\n# FORGED\r\n'
+        sizes = lambda n: elf({f'{evil}.c': (n, 0)}, symbols={f'{evil}.c': {evil: {evil: n}}})  # noqa: E731
+        fail = [{'elf': None, 'stage': 'build', 'message': '<script>@maintainers `x`'}]
+        base = _shard('b1', {'device/a/a.elf': sizes(1)})
+        with tempfile.TemporaryDirectory() as b, tempfile.TemporaryDirectory() as c:
+            md, comment, _data = sd.compare_runs(
+                _run_dir(b, [base]), _run_dir(c, [_shard('b1', {'device/a/a.elf': sizes(2)}, failures=fail)],
+                                              scope=self.SCOPE), symbols=True)
+        for text in (md, comment):
+            self.assertNotIn('<img', text)
+            self.assertNotIn('<script', text)
+            self.assertIsNone(re.search(r'@\w', text))
+            self.assertNotIn('`|', text)
+            self.assertIsNone(re.search(r'^# FORGED', text, re.M))  # stays inside its cell
+        self.assertIn('&#60;img', md)  # escaped, still shown
+
+    def test_names_that_escape_alike_stay_distinct(self):
+        files = lambda a, b: elf({'x@.c': (a, 0), 'x#.c': (b, 0)})  # noqa: E731
+        _md, _c, data = self.compare([_shard('b1', {'device/a/a.elf': files(1, 2)})],
+                                     [_shard('b1', {'device/a/a.elf': files(1, 5)})])
+        self.assertEqual(sorted(data['pairs'][0]['current']['files']), ['x#.c', 'x@.c'])
+
+    def test_invalid_shards_are_errors_not_data(self):
+        good = _shard('b0', {})
+        bad = [_shard('b1', {'../x.elf': _elf(1)}), _shard('b2', {'device/a/a.elf': {'files': {}}}),
+               {**_shard('b3', {}), 'engine': 'bloaty'}, {**_shard('b4', {}), 'schema': 2},
+               _shard('b5', {}, failures=[1]),
+               _shard('b7', {'device/a/a.elf': {**_elf(1), 'files': 1}}),
+               _shard('b8', {'device/a/a.elf': {**_elf(1), 'sections': {'x.c': {'.text': True}}}}),
+               _shard('b10', {}, failures=[{'stage': 'build', 'message': 'no elf key'}])]
+        with tempfile.TemporaryDirectory() as c:
+            run = _run_dir(c, [good] + bad)
+            _write(c, 'code-size-scope/scope.json', {'schema': 1, 'code_changed': True, 'legs': None})
+            run = sd.load_snapshots(c)
+            sd.compare_runs(run, run)  # malformed input never crashes
+        self.assertEqual(list(run['shards']), ['b0'])
+        self.assertIsNone(run['scope'])
+        self.assertEqual(len(run['errors']), len(bad) + 1)
+
+    def test_invalid_legs_are_errors_not_data(self):
+        good = {'schema': 1, 'boards': [], 'examples': None, 'build_outcome': 'success',
+                'sha': 'a' * 40, 'base_sha': 'a' * 40, 'head_sha': 'a' * 40}
+        bad = [{k: v for k, v in good.items() if k != 'examples'}, {**good, 'examples': 1},
+               {**good, 'sha': 'nothex'}, {**good, 'boards': None}, {**good, 'build_outcome': None}]
+        with tempfile.TemporaryDirectory() as c:
+            for i, leg in enumerate([good] + bad):
+                _write(c, f'code-size-arm-gcc-f{i}/leg.json', leg)
+            run = sd.load_snapshots(c)
+        self.assertEqual(list(run['legs']), ['code-size-arm-gcc-f0'])
+        self.assertEqual(len(run['errors']), len(bad))
+
+    def test_an_invalid_baseline_shard_of_a_board_the_pr_did_not_build_is_ignored(self):
+        with tempfile.TemporaryDirectory() as b, tempfile.TemporaryDirectory() as c:
+            base = _run_dir(b, [_shard('b1', {'device/a/a.elf': _elf(1)})])
+            _write(b, 'code-size-arm-gcc-fam/code-size-other.json', {'schema': 1})
+            _md, _c, data = sd.compare_runs(sd.load_snapshots(b), _run_dir(
+                c, [_shard('b1', {'device/a/a.elf': _elf(1)})], scope=self.SCOPE))
+        self.assertEqual(data['status'], 'complete')
+
+    def test_symbols_are_omitted_when_a_snapshot_lacks_them(self):
+        bare = {k: v for k, v in _elf(1).items() if k != 'symbols'}
+        with tempfile.TemporaryDirectory() as b, tempfile.TemporaryDirectory() as c:
+            md, _c, _d = sd.compare_runs(_run_dir(b, [_shard('b1', {'device/a/a.elf': bare})]),
+                                         _run_dir(c, [_shard('b1', {'device/a/a.elf': _elf(2)})], scope=self.SCOPE),
+                                         symbols=True)
+        self.assertIn('symbols omitted', md)
+
+    def test_a_board_in_two_artifacts_is_rejected(self):
+        with tempfile.TemporaryDirectory() as c:
+            run = _run_dir(c, [_shard('b1', {})], legs={'code-size-arm-gcc-x': ['b1'], 'code-size-riscv-gcc-y': ['b1']})
+        self.assertEqual(list(run['shards']), ['b1'])
+        self.assertEqual(len(run['errors']), 1)
+
+    def test_an_approximate_baseline_is_labelled_with_its_note(self):
+        md, _c, _d = self.compare([_shard('b1', {'device/a/a.elf': _elf(1)}, sha='d')],
+                                  [_shard('b1', {'device/a/a.elf': _elf(1)})],
+                                  baseline={'sha': 'd' * 40, 'url': 'https://github.com/x/y/actions/runs/1',
+                                            'exact': False, 'note': '2 master commits before this build'})
+        self.assertIn('Baseline: dddddddddd (approximate) https://github.com/x/y/actions/runs/1 - 2 master '
+                      'commits before this build', md)
+
+    def test_an_unavailable_baseline_is_said_so(self):
+        md, _c, data = self.compare([], [_shard('b1', {'device/a/a.elf': _elf(1)})],
+                                    baseline={'sha': None, 'url': None, 'exact': False, 'note': 'base x is not on master'})
+        self.assertIn('Baseline: unavailable - base x is not on master', md)
+        self.assertEqual(data['no_baseline'], ['b1'])
+
+    def test_cli_writes_the_three_reports(self):
+        with tempfile.TemporaryDirectory() as b, tempfile.TemporaryDirectory() as c, \
+             tempfile.TemporaryDirectory() as out:
+            _run_dir(b, [_shard('b1', {'device/a/a.elf': _elf(1)})])
+            _run_dir(c, [_shard('b1', {'device/a/a.elf': _elf(2)})], scope=self.SCOPE)
+            with mock.patch.object(sys, 'argv', ['code_size.py', 'compare', c, b, '-o', out]), \
+                 contextlib.redirect_stdout(io.StringIO()):
+                self.assertEqual(sd.main(), 0)
+            self.assertEqual(sorted(os.listdir(out)), ['code-size.json', 'code-size.md', 'comment.md'])
+
 
 class SymlinkDeps(unittest.TestCase):
     def test_links_each_dep_path_of_the_worktrees_own_manifest(self):
diff --git a/test/hil/test/test_code_size_ci.py b/test/hil/test/test_code_size_ci.py
new file mode 100644
index 0000000..7398010
--- /dev/null
+++ b/test/hil/test/test_code_size_ci.py
@@ -0,0 +1,186 @@
+#!/usr/bin/env python3
+"""Tests for .github/scripts/code_size_ci.py, against a stubbed GitHub API."""
+import contextlib
+import io
+import json
+import os
+import re
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
+sys.path.insert(0, os.path.join(REPO, '.github', 'scripts'))
+import code_size_ci as ci  # noqa: E402
+
+NAME = 'hathach/tinyusb'
+
+
+def sha(c):
+    return c * 40
+
+
+def run(run_id, commit, number=None, event='push', branch='master', repo=NAME):
+    return {'id': run_id, 'run_number': number or run_id, 'head_sha': commit, 'head_branch': branch,
+            'event': event, 'head_repository': {'full_name': repo},
+            'html_url': f'https://github.com/{NAME}/actions/runs/{run_id}'}
+
+
+def artifact(art_id, name, expired=False):
+    return {'id': art_id, 'name': name, 'expired': expired}
+
+
+class FakeGh:
+    """The API paths code_size_ci.py reads: first-parent history, runs per commit,
+    artifacts per run, the ancestry compare, a PR and a run. A paginated call gets
+    everything as one page."""
+
+    def __init__(self, parents, runs=None, artifacts=None, status='ahead'):
+        self.parents, self.runs, self.artifacts, self.status = parents, runs or {}, artifacts or {}, status
+        self.pr_head, self.pr_repo, self.attempts = None, NAME, {}
+
+    def __call__(self, path, paginate=False):
+        reply = self.reply(path)
+        return [reply] if paginate else reply
+
+    def reply(self, path):
+        if m := re.match(rf'repos/{NAME}/actions/workflows/build\.yml/runs\?head_sha=(\w+)&', path):
+            return {'workflow_runs': self.runs.get(m.group(1), [])}
+        if m := re.fullmatch(rf'repos/{NAME}/actions/runs/(\d+)/artifacts', path):
+            return {'artifacts': self.artifacts.get(int(m.group(1)), [])}
+        if m := re.fullmatch(rf'repos/{NAME}/actions/runs/(\d+)', path):
+            return {'run_attempt': self.attempts[int(m.group(1))]}
+        if m := re.fullmatch(rf'repos/{NAME}/commits/(\w+)', path):
+            parent = self.parents.get(m.group(1))
+            return {'parents': [{'sha': parent}] if parent else []}
+        if re.fullmatch(rf'repos/{NAME}/pulls/\d+', path):
+            return {'head': {'sha': self.pr_head, 'ref': 'feature', 'repo': {'full_name': self.pr_repo}}}
+        if re.fullmatch(rf'repos/{NAME}/compare/\w+\.\.\.master', path):
+            return {'status': self.status}
+        raise AssertionError(f'unexpected API call {path}')
+
+
+SNAPSHOTS = [artifact(1, 'code-size-scope'), artifact(2, 'code-size-arm-gcc-stm32f4')]
+
+
+def leg(base):
+    return {'schema': 1, 'boards': [], 'examples': None, 'build_outcome': 'success',
+            'sha': sha('e'), 'base_sha': base, 'head_sha': sha('f')}
+
+
+class Baseline(unittest.TestCase):
+    def lookup(self, fake, base=sha('b'), legs=None):
+        with tempfile.TemporaryDirectory() as tmp:
+            cur, info = os.path.join(tmp, 'cur'), os.path.join(tmp, 'info.json')
+            for i, data in enumerate(legs or [leg(base)]):
+                os.makedirs(os.path.join(cur, f'code-size-leg{i}'))
+                with open(os.path.join(cur, f'code-size-leg{i}', 'leg.json'), 'w') as f:
+                    json.dump(data, f)
+            args = ['code_size_ci.py', 'baseline', '--repo', NAME, '--current', cur, '--info', info]
+            with mock.patch.object(ci, 'gh', fake), mock.patch.object(sys, 'argv', args), \
+                 contextlib.redirect_stdout(io.StringIO()):
+                self.assertEqual(ci.main(), 0)
+            with open(info) as f:
+                return json.load(f)
+
+    def test_the_base_commits_own_run_is_exact(self):
+        fake = FakeGh({}, {sha('b'): [run(7, sha('b'))]}, {7: SNAPSHOTS})
+        self.assertEqual(self.lookup(fake), {'sha': sha('b'), 'url': f'https://github.com/{NAME}/actions/runs/7',
+                                             'exact': True, 'run_id': 7})
+
+    def test_a_commit_without_snapshots_falls_back_to_its_first_parent_as_approximate(self):
+        docs_only = [artifact(3, 'code-size-scope'), artifact(4, 'binaries-arm-gcc-x')]
+        fake = FakeGh({sha('b'): sha('a')}, {sha('b'): [run(8, sha('b'))], sha('a'): [run(7, sha('a'))]},
+                      {8: docs_only, 7: SNAPSHOTS})
+        info = self.lookup(fake)
+        self.assertEqual((info['sha'], info['exact'], info['run_id']), (sha('a'), False, 7))
+        self.assertIn('1 master commits before', info['note'])
+
+    def test_expired_artifacts_do_not_count(self):
+        expired = [artifact(1, 'code-size-scope'), artifact(2, 'code-size-arm-gcc-x', expired=True)]
+        fake = FakeGh({sha('b'): sha('a')}, {sha('b'): [run(8, sha('b'))], sha('a'): [run(7, sha('a'))]},
+                      {8: expired, 7: SNAPSHOTS})
+        self.assertEqual(self.lookup(fake)['sha'], sha('a'))
+
+    def test_the_latest_run_of_a_commit_wins(self):
+        fake = FakeGh({}, {sha('b'): [run(7, sha('b')), run(8, sha('b'))]}, {7: SNAPSHOTS, 8: SNAPSHOTS})
+        self.assertEqual(self.lookup(fake)['run_id'], 8)
+
+    def test_runs_of_other_events_branches_or_repos_are_not_baselines(self):
+        others = [run(5, sha('b'), event='pull_request'), run(6, sha('b'), branch='dev'),
+                  run(7, sha('b'), repo='fork/tinyusb')]
+        fake = FakeGh({}, {sha('b'): others}, {5: SNAPSHOTS, 6: SNAPSHOTS, 7: SNAPSHOTS})
+        self.assertIsNone(self.lookup(fake)['run_id'])
+
+    def test_a_base_off_master_has_no_baseline(self):
+        fake = FakeGh({}, {sha('b'): [run(7, sha('b'))]}, {7: SNAPSHOTS}, status='diverged')
+        info = self.lookup(fake)
+        self.assertIsNone(info['run_id'])
+        self.assertIn('not on master', info['note'])
+
+    def test_the_search_stops_at_the_depth_cap(self):
+        history = [f'{i:040x}' for i in range(ci.MAX_DEPTH + 5)]
+        fake = FakeGh(dict(zip(history, history[1:])), {history[-1]: [run(7, history[-1])]}, {7: SNAPSHOTS})
+        info = self.lookup(fake, base=history[0])
+        self.assertIsNone(info['sha'])
+        self.assertIn(f'within {ci.MAX_DEPTH} commits', info['note'])
+
+    def test_legs_that_disagree_on_their_base_or_are_invalid_have_no_baseline(self):
+        for legs in ([leg(sha('b')), leg(sha('c'))], [leg(['x'])], [{'base_sha': sha('b')}]):
+            info = self.lookup(FakeGh({}), legs=legs)
+            self.assertIsNone(info['sha'])
+            self.assertIn('no single base commit', info['note'])
+
+    def test_a_failed_lookup_is_reported_not_raised(self):
+        def broken(path, paginate=False):
+            raise RuntimeError('gh api: HTTP 502')
+        info = self.lookup(broken)
+        self.assertEqual((info['sha'], info['run_id']), (None, None))
+        self.assertIn('the baseline lookup failed: gh api: HTTP 502', info['note'])
+
+
+def pr_run(run_id, prs=(5,), branch='feature', repo=NAME):
+    return {**run(run_id, sha('c'), event='pull_request', branch=branch, repo=repo),
+            'pull_requests': [{'number': n} for n in prs]}
+
+
+class IsCurrent(unittest.TestCase):
+    def check(self, pr_head=sha('c'), runs=(pr_run(7),), attempts=None, pr_repo=NAME):
+        fake = FakeGh({}, {sha('c'): list(runs)})
+        fake.pr_head, fake.pr_repo, fake.attempts = pr_head, pr_repo, attempts or {7: 1}
+        args = ['code_size_ci.py', 'is-current', '--repo', NAME, '--pr', '5', '--run-id', '7',
+                '--attempt', '1', '--head-sha', sha('c')]
+        out = io.StringIO()
+        with mock.patch.object(ci, 'gh', fake), mock.patch.object(sys, 'argv', args), \
+             contextlib.redirect_stdout(out):
+            return ci.main(), out.getvalue().strip()
+
+    def test_the_latest_attempt_of_the_latest_run_of_the_head_is_current(self):
+        self.assertEqual(self.check(), (0, 'current'))
+
+    def test_a_moved_head_a_newer_run_or_attempt_is_stale(self):
+        self.assertEqual(self.check(pr_head=sha('d')), (1, 'stale: the PR head moved on'))
+        self.assertEqual(self.check(runs=(pr_run(7), pr_run(8))), (1, 'stale: a newer Build run exists: run 8 of #5'))
+        self.assertEqual(self.check(attempts={7: 2}), (1, 'stale: a newer attempt exists'))
+        self.assertEqual(self.check(runs=()), (1, 'stale: no Build run of the PR head found'))
+
+    def test_a_newer_run_of_another_pr_sharing_the_head_does_not_supersede(self):
+        self.assertEqual(self.check(runs=(pr_run(7), pr_run(8, prs=(6,)))), (0, 'current'))
+        self.assertEqual(self.check(runs=(pr_run(7), pr_run(8, prs=(5, 6)))),
+                         (1, 'stale: a newer Build run exists: run 8 of #5, #6'))
+
+    def test_a_fork_prs_unlisted_runs_count_by_their_head_branch_and_repo(self):
+        fork = 'someone/tinyusb'
+        own = pr_run(7, prs=(), repo=fork)
+        self.assertEqual(self.check(runs=(own,), pr_repo=fork), (0, 'current'))
+        self.assertEqual(self.check(runs=(own, pr_run(8, prs=(), repo=fork)), pr_repo=fork),
+                         (1, 'stale: a newer Build run exists: run 8 of someone/tinyusb:feature'))
+        self.assertEqual(self.check(runs=(own, pr_run(8, prs=(), branch='other', repo=fork)), pr_repo=fork),
+                         (0, 'current'))
+        self.assertEqual(self.check(runs=(own, pr_run(8, prs=(), repo='other/tinyusb')), pr_repo=fork),
+                         (0, 'current'))
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/test/hil/test/test_membrowse_cli.py b/test/hil/test/test_membrowse_cli.py
index 7d7d61e..a752716 100644
--- a/test/hil/test/test_membrowse_cli.py
+++ b/test/hil/test/test_membrowse_cli.py
@@ -259,11 +259,12 @@
         self.assertEqual(report.call_args[0][0].option, '--all-symbols')
 
     def test_ci_uploads_use_the_same_target_name(self):
-        # membrowse history is keyed on it: a rename orphans every series
+        # membrowse history is keyed on it: a rename orphans every series. A plain build
+        # is <board>/<example>; only build.py --build-name sets MEMBROWSE_BOARD.
         with open(os.path.join(REPO, 'hw', 'bsp', 'family_support.cmake')) as f:
             args = [line.split('#', 1)[0].strip() for line in f]
         self.assertEqual([a for a in args if a.startswith('--target-name')],
-                         ['--target-name ${BOARD}/${TARGET}'])
+                         ['--target-name ${MEMBROWSE_BOARD}/${TARGET}'])
 
     def test_paths_and_build_script_are_repo_root_relative(self):
         cmd = cli.compose('stm32f407disco', 'device/cdc_msc', 30, False, 'k', [])
diff --git a/tools/build.py b/tools/build.py
index 13690d2..f5fc8a8 100755
--- a/tools/build.py
+++ b/tools/build.py
@@ -148,6 +148,8 @@
     build_flags = []
     if build_cflags:
         build_flags.append('-DCFLAGS_CLI=' + ' '.join(build_cflags))
+    if build_name:  # a variant is a membrowse board of its own (family_add_membrowse)
+        build_flags.append(f'-DMEMBROWSE_BOARD={build_name}')
 
     family = find_family(board)
     if family == 'espressif':
@@ -182,7 +184,7 @@
                     sys.executable, os.path.join(os.path.dirname(__file__), 'membrowse_cli.py'), 'report',
                     '--build-dir', example_build_dir, '--ninja', 'ninja',
                     '--elf', f'{example_build_dir}/{name}.elf',
-                    '--target-name', f'{board}/{name}', '--upload',
+                    '--target-name', f'{build_name or board}/{name}', '--upload',
                 ])
                 ret[0 if rcmd.returncode == 0 else 1] += 1
             elif not os.path.isdir(example_build_dir):
@@ -413,7 +415,7 @@
     with open(boards_path) as f:
         data = json.load(f)
     # membership in this family's board dir, not find_family() per entry: that walks
-    # every hw/bsp family for each of the 23 pinned boards to answer one family's question
+    # every hw/bsp family for each pinned board to answer one family's question
     family_boards = {e.name for e in os.scandir(f'hw/bsp/{family}/boards') if e.is_dir()}
     ci_boards = [t['board'] for t in data['boards']
                  if t['board'] in family_boards
@@ -492,7 +494,8 @@
     # that existed before the configure); -e only selects targets, the cmake tree configures whole.
     # The command line is only half of it - canonical_row() refuses the flags CFLAGS and
     # its siblings add to a configure this test cannot see
-    canonical = build_system == 'cmake' and not build_defines and not build_cflags and toolchain == 'gcc'
+    variant = bool(build_defines or build_cflags)
+    canonical = build_system == 'cmake' and not variant and toolchain == 'gcc'
 
     for e in args.example:
         if not EXAMPLE_RE.fullmatch(e):
@@ -520,6 +523,12 @@
         print("--build-name requires exactly one board (-b) and no families")
         return 1
 
+    # membrowse names an upload <board>/<example>: an unnamed variant would overwrite
+    # the plain build's history (family_add_membrowse)
+    if variant and not build_name and 'examples-membrowse-upload' in build_targets:
+        print("examples-membrowse-upload of a -D/--cflag variant requires --build-name")
+        return 1
+
     print(build_separator)
     print(build_format.format('Board', 'Target', '\033[39mResult\033[0m', 'Time'))
     total_time = time.monotonic()
diff --git a/tools/ci_select.py b/tools/ci_select.py
index 17dfd89..e7e989e 100755
--- a/tools/ci_select.py
+++ b/tools/ci_select.py
@@ -25,9 +25,9 @@
 | 1 | `docs/`, `.claude/`, `*.md`, `*.rst`, `LICENSE` | — | — | — |
 | 1b | `.gitignore`, `.clang-format`, `.agents`, `.codex/**`, `.idea/**`, `test/{fuzz,unit-test}/**`, `test/hil/test/**`, non-build `.github/**`, packaging manifests | — | — | — |
 | 2 | `test/hil/**` (not `test/hil/test/**`) | — | — | all boards → all tests |
-| 2b | `tools/code_size.py`, `tools/drivers_coverage_check.py` | — (local-only tooling, no CI build runs it) | — | — (nothing on the rig runs it) |
+| 2b | `tools/drivers_coverage_check.py` | — (local-only tooling, no CI build runs it) | — | — (nothing on the rig runs it) |
 | 2c | `.github/ci-pinned-boards.json` | `ALL` | `ALL` | — (CI board data; no rig board's behaviour depends on it) |
-| 2d | `tools/membrowse_cli.py` | `ALL` | `ALL` | — (build-time script invoked from family_support.cmake; no rig board runs it) |
+| 2d | `tools/membrowse_cli.py`, `tools/code_size.py` | `ALL` | `ALL` | — (size scripts the pinned build legs run; no rig board runs them) |
 | 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 |
@@ -98,7 +98,8 @@
 #
 # 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".
+# "does any Build step read this file", not "is it source": code_size_ci.py is read by
+# pr_comment.yml only, so it is metadata.
 #
 # 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.
@@ -119,25 +120,25 @@
     # The harness itself stays under _FULL_RE's test/hil/ prefix.
     r'test/(fuzz|unit-test)/|test/hil/test/|'
     # .github, minus the build machinery named in _FULL_RE and _CI_BOARDS_RE
-    r'\.github/(FUNDING\.yml$|labeler\.yml$|membrowse_pr_message\.j2$|'
-    r'ISSUE_TEMPLATE/|'
-    r'workflows/(cifuzz|claude|claude-code-review|labeler|membrowse-comment|'
+    r'\.github/(FUNDING\.yml$|labeler\.yml$|'
+    r'ISSUE_TEMPLATE/|scripts/code_size_ci\.py$|'
+    r'workflows/(cifuzz|claude|claude-code-review|labeler|'
     r'pr_comment|pre-commit|static_analysis|trigger)\.yml$)|'
     # tools/ scripts no build invokes (tools/build*.py and local tooling are handled separately)
     r'tools/(check_example_pids|file2carray|iar_gen|mksunxi|pcapng_to_corpus)\.py$|'
     r'tools/iar_template\.ipcf$'
     r')')
-# Local-only size/coverage tooling: no CI build and no rig board runs it (the
+# Local-only coverage tooling: no CI build and no rig board runs it (the
 # drivers-coverage checker is pre-commit only), so no contribution on either axis.
-_LOCAL_TOOLING_RE = re.compile(
-    r'^tools/(code_size|drivers_coverage_check)\.py$')
+_LOCAL_TOOLING_RE = re.compile(r'^tools/drivers_coverage_check\.py$')
 # CI board data (tools/build.py --ci-pinned-boards): it decides which boards a family's
 # build legs compile, so a bad edit can silently drop a family - full build matrix. No
 # rig board depends on it.
 _CI_BOARDS_RE = re.compile(r'^\.github/ci-pinned-boards\.json$')
-# Run by family_add_membrowse() for every family with a pinned board: which family it
-# breaks is data, not code, so full build matrix as rule 2c. No rig board runs it.
-_MEMBROWSE_SCRIPT_RE = re.compile(r'^tools/membrowse_cli\.py$')
+# Size scripts every pinned build leg runs (membrowse_cli.py from family_add_membrowse(),
+# code_size.py's CI snapshot): which family they break is data, not code, so full build
+# matrix as rule 2c. No rig board runs them.
+_MEMBROWSE_SCRIPT_RE = re.compile(r'^tools/(membrowse_cli|code_size)\.py$')
 _FULL_RE = re.compile(
     r'^(src/common/|src/osal/|src/tusb\.c$|src/tusb\.h$|src/tusb_option\.h$|'
     # tools/rtt.py is part of the harness, not a standalone tool: hil_util imports it
@@ -659,13 +660,13 @@
         s.reasons.append(f'{path}: non-code, no contribution')
         return
     if _LOCAL_TOOLING_RE.match(path):                                   # rule 2b
-        s.reasons.append(f'{path}: local size/coverage tooling, no HIL contribution')
+        s.reasons.append(f'{path}: local coverage tooling, no HIL contribution')
         return
     if _CI_BOARDS_RE.match(path):                                 # rule 2c
         s.reasons.append(f'{path}: CI board data, no HIL contribution')
         return
     if _MEMBROWSE_SCRIPT_RE.match(path):                          # rule 2d
-        s.reasons.append(f'{path}: membrowse build-time script, no HIL contribution')
+        s.reasons.append(f'{path}: CI size script, no HIL contribution')
         return
     if _FULL_RE.match(path):
         s.force_full(f'{path}: core/infra -> full matrix')
@@ -1181,13 +1182,13 @@
         s.add(all_bsp_families(repo_root), exs, f'{path}: lib {lib} -> {sorted(exs)}')
         return
     if _LOCAL_TOOLING_RE.match(path):                                   # rule 2b
-        s.reasons.append(f'{path}: local size/coverage tooling, no build contribution')
+        s.reasons.append(f'{path}: local coverage tooling, no build contribution')
         return
     if _CI_BOARDS_RE.match(path):                                 # rule 2c
         s.force_full(f'{path}: CI board data changes which boards build -> full build matrix')
         return
     if _MEMBROWSE_SCRIPT_RE.match(path):                          # rule 2d
-        s.force_full(f'{path}: membrowse build-time script -> full build matrix')
+        s.force_full(f'{path}: CI size script -> full build matrix')
         return
     if _FULL_RE.match(path):                                      # rules 15-16
         # attribution, not behaviour: these already reached `full` through the
diff --git a/tools/code_size.py b/tools/code_size.py
index bebc69f..798c749 100644
--- a/tools/code_size.py
+++ b/tools/code_size.py
@@ -35,6 +35,7 @@
 import argparse
 import collections
 import concurrent.futures
+import contextlib
 import csv
 import functools
 import glob
@@ -53,7 +54,7 @@
 import time
 
 import build_utils
-from membrowse_cli import extract_ld_scripts, extract_defsyms, link_command, report_inputs
+from membrowse_cli import IDF_LD_SCRIPTS, extract_ld_scripts, extract_defsyms, link_command, report_inputs
 
 # resolved like tinyusb_src_filter(), so a symlinked checkout still matches its filter
 TINYUSB_ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
@@ -84,6 +85,12 @@
         raise RuntimeError(f'no build.ninja found above {elf_path} - cannot '
                             f'determine its linker scripts')
     commands = link_command('ninja', build_dir, elf_path)
+    # ESP-IDF links its generated scripts by bare name via -L
+    idf_ld = [os.path.join(build_dir, p) for p in IDF_LD_SCRIPTS]
+    if any(map(os.path.isfile, idf_ld)):
+        if not all(map(os.path.isfile, idf_ld)):
+            raise RuntimeError(f'ESP-IDF build {build_dir} lacks one of {", ".join(idf_ld)}')
+        return build_dir, idf_ld, extract_defsyms(commands)
     ld_scripts = extract_ld_scripts(commands)
     if not ld_scripts:
         raise RuntimeError(f'no linker script found in the ninja build graph '
@@ -175,6 +182,12 @@
     return [(name(h[0]), h[1], h[2], h[3], h[5]) for h in headers[1:]], loads
 
 
+def _map_path(elf):
+    """The elf's GNU ld map: `<elf>.map`, or ESP-IDF's `<name>.map` beside it."""
+    idf = os.path.splitext(elf)[0] + '.map'
+    return idf if not os.path.isfile(elf + '.map') and os.path.isfile(idf) else elf + '.map'
+
+
 def map_regions(map_path):
     """[(name, origin, length)] of a GNU ld map's Memory Configuration."""
     if not os.path.isfile(map_path):
@@ -199,6 +212,8 @@
     run location; one running where it loads is flash unless writable, when the
     map region holding it decides. Raises RuntimeError rather than guess."""
     sections, loads = elf_layout(elf)
+    if any(s[0] == '.flash.appdesc' for s in sections):
+        return _esp_idf_buckets(sections)
     regions = map_regions(map_path)
     buckets = {}
     for name, sh_type, flags, addr, size in sections:
@@ -230,6 +245,24 @@
     return buckets
 
 
+def _esp_idf_buckets(sections):
+    """section_buckets() of an ESP-IDF app (it has `.flash.appdesc`), whose ELF runs every
+    section where it loads. IDF's sections.ld names each output section in a flash-mapped region
+    `.flash*`; esptool's elf2image stores the rest in the image too, for the bootloader to load
+    into RAM (bin_image.py is_flash_addr())."""
+    buckets = {}
+    for name, sh_type, flags, _addr, _size in sections:
+        if not flags & SHF_ALLOC or (sh_type == SHT_NOBITS and name.startswith('.flash')):
+            buckets[name] = _NOT_COUNTED  # reserved flash-window address space, not storage
+        elif sh_type == SHT_NOBITS:
+            buckets[name] = frozenset({'ram'})
+        elif name.startswith('.flash'):
+            buckets[name] = frozenset({'flash'})
+        else:
+            buckets[name] = frozenset({'flash', 'ram'})
+    return buckets
+
+
 class _Sizes:
     """Accumulates one elf's filtered per-file flash/RAM, section and symbol sizes,
     and its total flash/RAM."""
@@ -254,10 +287,47 @@
                 'symbols': self.symbols}
 
 
+@functools.lru_cache(maxsize=None)
+def _dwarf_sources(elf):
+    """{basename: {full source path}} of the elf's DWARF compile units."""
+    try:
+        from elftools.elf.elffile import ELFFile  # pyelftools, a membrowse dependency
+    except ImportError as e:
+        raise RuntimeError(f'pyelftools is needed to resolve {elf}\'s source paths ({e})')
+    sources = {}
+    with open(elf, 'rb') as f:
+        elffile = ELFFile(f)
+        if not elffile.has_dwarf_info():
+            return sources
+        for cu in elffile.get_dwarf_info().iter_CUs():
+            top = cu.get_top_DIE().attributes
+            if 'DW_AT_name' in top:
+                comp_dir = top['DW_AT_comp_dir'].value.decode() if 'DW_AT_comp_dir' in top else ''
+                path = os.path.normpath(os.path.join(comp_dir, top['DW_AT_name'].value.decode()))
+                sources.setdefault(os.path.basename(path), set()).add(path)
+    return sources
+
+
+def _source_path(sym, elf, filters):
+    """A symbol's source path. An archive member has no object path and membrowse 1.2.9
+    keeps only the basename of its source, so the DWARF compile unit of that name gives it
+    back (ESP-IDF links TinyUSB from an archive)."""
+    path = sym.get('object_file') or sym.get('source_file')
+    if not path or '/' in path:
+        return path
+    candidates = _dwarf_sources(elf).get(path, set())
+    if len(candidates) == 1:
+        return next(iter(candidates))
+    if any(_relative_key(c, filters) for c in candidates):
+        raise RuntimeError(f'{elf}: {path} names several compile units ({", ".join(sorted(candidates))}), '
+                           f'one of them filtered; cannot tell which holds {sym.get("name")}')
+    return path
+
+
 def membrowse_sizes(elf, filters):
     """Symbols of `membrowse report`; linker-defined symbols without a section
     (`__StackLimit`) are not counted."""
-    buckets = section_buckets(elf, elf + '.map')
+    buckets = section_buckets(elf, _map_path(elf))
     sizes = _Sizes(filters)
     for sym in report_for_elf(elf).get('symbols', []):
         section = sym.get('section')
@@ -265,8 +335,7 @@
             continue
         if section not in buckets:
             raise RuntimeError(f'membrowse symbol {sym.get("name")} is in section {section}, not in {elf}')
-        sizes.add(sym.get('object_file') or sym.get('source_file'), section, buckets[section], sym['size'],
-                  sym.get('name'))
+        sizes.add(_source_path(sym, elf, filters), section, buckets[section], sym['size'], sym.get('name'))
     return sizes.result()
 
 
@@ -282,11 +351,11 @@
 
 
 def linkermap_sizes(elf, filters):
-    """Input sections of `<elf>.map` by full object path; analyze_map() is not used,
+    """Input sections of the elf's map by full object path; analyze_map() is not used,
     it keeps only four sections. An archive member
     (`lib.a(x.o)`) has no source dir, so it counts in 'all' only. The symbols are the
     input sections (`.text.foo`), not the labels inside one."""
-    map_path = elf + '.map'
+    map_path = _map_path(elf)
     buckets = section_buckets(elf, map_path)
     with open(map_path, encoding='utf-8', errors='replace') as f:
         sections = _linkermap().parseSections(f)
@@ -313,7 +382,7 @@
     rows = csv.DictReader(io.StringIO(r.stdout))
     if not {'compileunits', 'sections', 'symbols', 'vmsize'} <= set(rows.fieldnames or ()):
         raise RuntimeError(f'unexpected bloaty csv columns for {elf}: {rows.fieldnames}')
-    buckets = section_buckets(elf, elf + '.map')
+    buckets = section_buckets(elf, _map_path(elf))
     sizes = _Sizes(filters)
     for row in rows:
         section = row['sections']
@@ -333,8 +402,9 @@
 # all_label: what the engine's 'all' total sums (totals of different engines are
 # not comparable); install: how to get its tool when `sizes` raises FileNotFoundError
 Engine = collections.namedtuple('Engine', 'sizes all_label install')
+MEMBROWSE_CI_VERSION = '1.2.12'  # every CI install pins this; attribution can differ across versions
 ENGINES = {
-    'membrowse': Engine(membrowse_sizes, 'all symbols', '`pip install membrowse`'),
+    'membrowse': Engine(membrowse_sizes, 'all symbols', f'`pip install membrowse=={MEMBROWSE_CI_VERSION}`'),
     'linkermap': Engine(linkermap_sizes, 'all input sections',
                         '`python3 tools/get_deps.py tools/linkermap`'),
     'bloaty': Engine(bloaty_sizes, 'all accounted sections',
@@ -362,10 +432,22 @@
     return f'+{delta}' if delta > 0 else str(delta)
 
 
+_MD_CELL = str.maketrans({**{c: f'&#{ord(c)};' for c in '&<>|`\\[]\r\n'}, '@': '@\u200b'})
+
+
+def _md_cell(text):
+    """A table cell safe from names that would open HTML, a link or code span, end or split
+    the row or @mention someone (a zero-width space breaks the mention)."""
+    return text.translate(_MD_CELL)
+
+
 def md_table(header, rows, total=None):
     """Markdown table padded so its columns also line up as plain text: the first
     column left-aligned, the others right-aligned. Cells are strings; a `total` row
     follows the rows under a plain rule."""
+    header = [_md_cell(c) for c in header]
+    rows = [[_md_cell(c) for c in r] for r in rows]
+    total = [_md_cell(c) for c in total] if total else total
     body = rows + [total] if total else rows
     widths = [max(len(r[i]) for r in [header] + body) for i in range(len(header))]
 
@@ -459,10 +541,11 @@
     return md_table([_row_label(symbols, engine)] + cols + ['size', '%'], lines, total_row) + '\n'
 
 
-def render_report(sizes, engine, failures=(), boards=(), symbols=False):
+def render_report(sizes, engine, failures=(), boards=(), symbols=False, files_label='TinyUSB'):
     """Markdown report of one tree's elfs keyed by (board, elf path), None for a
     failed one; `failures` are (elf id, stage, message). One elf is inline, several
-    get a summary table and each its own table in <details>."""
+    get a summary table and each its own table in <details>; `files_label` names the
+    filtered files' totals."""
     sized = {i: s for i, s in sizes.items() if s is not None}
     status = 'INCOMPLETE' if failures or not sized else 'complete'
     all_label = ENGINES[engine].all_label
@@ -477,13 +560,13 @@
 
     def totals(s):
         src = _files_total(s['files'])
-        return (f'filtered Flash {src["flash"]}, RAM {src["ram"]}; '
+        return (f'{files_label} Flash {src["flash"]}, RAM {src["ram"]}; '
                 f'{all_label} Flash {s["all"]["flash"]}, RAM {s["all"]["ram"]}')
 
     if len(sized) == 1:
         (elf_id, s), = sized.items()
         return '\n'.join(lines + [f'`{_label(elf_id)}` {totals(s)}', '', size_table(s, symbols, engine)])
-    lines.append(md_table(['Elf', 'filtered Flash', 'filtered RAM', f'{all_label} Flash', f'{all_label} RAM'],
+    lines.append(md_table(['Elf', f'{files_label} Flash', f'{files_label} RAM', f'{all_label} Flash', f'{all_label} RAM'],
                           [[_label(i)] + [str(t[k]) for t in (_files_total(s['files']), s['all']) for k in ('flash', 'ram')]
                            for i, s in sized.items()]))
     for elf_id, s in sized.items():
@@ -581,8 +664,60 @@
             or bool(_changed_files(b, c, symbols)))
 
 
+def _range(lo, hi):
+    return _fmt(lo) if lo == hi else f'{_fmt(lo)} → {_fmt(hi)}'
+
+
+COMMENT_TERMS = {'base-only': 'missing in this PR', 'current-only': 'new in this PR (no master size yet)',
+                 'base': 'master', 'current': 'PR'}
+
+
+def _problems(base_only, cur_only, failures, terms=None):
+    """Report lines for one-sided elfs and failures, each id named; `terms` rewords the report's words."""
+    word = lambda w: (terms or {}).get(w, w)  # noqa: E731
+    lines = [f'- {word(label)}: ' + ', '.join(f'`{_label(i)}`' for i in ids)
+             for label, ids in (('base-only', base_only), ('current-only', cur_only)) if ids]
+    return lines + [f'- FAILED `{_label(elf_id)}` {word(side)} {stage}: {_md_escape(message)}'
+                    for elf_id, side, stage, message in failures]
+
+
+def _changed_paths(stats):
+    """The changed files of _file_stats(), largest |Δ| first."""
+    return sorted((p for p, s in stats.items() if s['changed']),
+                  key=lambda p: (-max(abs(v[0]) for k in ('flash', 'ram') for v in stats[p][k]), p))
+
+
+def render_comment(pairs, engine, base_only=(), cur_only=(), failures=(), symbols=False, files_label='TinyUSB'):
+    """The PR comment's view as (body, footnote): a row per changed file with its min to max
+    Δ over the builds containing it, so each changed driver shows however heavy the others
+    are. The footnote explains the whole-firmware totals, when the body has them."""
+    file_deltas = {i: _file_deltas(b, c) for i, (b, c) in pairs.items()}
+    changed = [i for i, (b, c) in pairs.items() if _pair_changed(b, c, symbols)]
+    head = (f'{len(pairs)} builds on {len({board for board, _elf in pairs})} boards compared, '
+            f'{len(changed)} changed')
+    footnote = ''
+    if changed:
+        flash, ram = zip(*(_delta(pairs[i][0]['all'], pairs[i][1]['all']) for i in changed))
+        head += f'. Whole firmware¹ Flash Δ {_range(min(flash), max(flash))}, RAM Δ {_range(min(ram), max(ram))}'
+        footnote = f'\n¹ Sum of {ENGINES[engine].all_label} measured by {engine}, not the exact image size.\n'
+    problems = _problems(base_only, cur_only, failures, COMMENT_TERMS)
+    lines = [head, ''] + (['Not compared:', '', *problems, ''] if problems else [])
+    if not changed:
+        return '\n'.join(lines + ['_no changes_' if pairs else '_no comparable pairs_', '']), footnote
+    stats = _file_stats(file_deltas)
+    rows = [[p] + [_range(*(v[0] for v in stats[p][k])) for k in ('flash', 'ram')] for p in _changed_paths(stats)]
+    if rows:
+        lines += [f'{files_label} file size: min to max change across builds containing the file.', '',
+                  md_table(['File', 'Flash Δ', 'RAM Δ'], rows)]
+    other = sum(not any(any(d) for d in file_deltas[i].values()) for i in changed)
+    if other:
+        lines += ['', f'{other} other build{"" if other == 1 else "s"} changed without a '
+                      f'{files_label} file-size change.']
+    return '\n'.join(lines) + '\n', footnote
+
+
 def render_pairs(pairs, matched, engine, base_only=(), cur_only=(), failures=(), boards=(), symbols=False,
-                 labels=SIDE_LABELS):
+                 labels=SIDE_LABELS, files_label='TinyUSB'):
     """Markdown report over paired elfs keyed by (board, elf path).
 
     Every statistic is over per-pair deltas, sized by `engine`. `matched` counts
@@ -591,7 +726,7 @@
     board-level failure. Failures or unmatched elfs mark the report INCOMPLETE.
     `boards` lists the requested boards, so an unchanged or failed one is named.
     `symbols` adds each file's changed symbols to each pair's section table; `labels`
-    name the base and current sides.
+    name the base and current sides; `files_label` names the filtered files' totals.
     """
     file_deltas = {i: _file_deltas(b, c) for i, (b, c) in pairs.items()}
     changed = [i for i, (b, c) in pairs.items() if _pair_changed(b, c, symbols)]
@@ -601,12 +736,7 @@
              f'compared, {len(changed)} changed']
     if boards:
         lines.append('- boards: ' + ', '.join(f'`{b}`' for b in boards))
-    for label, ids in (('base-only', base_only), ('current-only', cur_only)):
-        if ids:
-            lines.append(f'- {label}: ' + ', '.join(f'`{_label(i)}`' for i in ids))
-    for elf_id, side, stage, message in failures:
-        lines.append(f'- FAILED `{_label(elf_id)}` {side} {stage}: {_md_escape(message)}')
-    lines.append('')
+    lines += _problems(base_only, cur_only, failures) + ['']
 
     if len(pairs) == 1:
         (elf_id, (b, c)), = pairs.items()
@@ -619,18 +749,14 @@
     if not changed:
         return '\n'.join(lines + ['_no changes_', ''])
 
-    pair_rows = []
-    for elf_id in changed:
-        b, c = pairs[elf_id]
-        src = _delta(_files_total(b['files']), _files_total(c['files']))
-        syms = _delta(b['all'], c['all'])
-        pair_rows.append([_label(elf_id)] + [_fmt(d) for d in src + syms])
-    lines.append(md_table(['Pair', 'filtered Flash Δ', 'filtered RAM Δ', f'{all_label} Flash Δ',
+    src = {i: _delta(_files_total(pairs[i][0]['files']), _files_total(pairs[i][1]['files'])) for i in changed}
+    pair_rows = [[_label(i)] + [_fmt(d) for d in src[i] + _delta(pairs[i][0]['all'], pairs[i][1]['all'])]
+                 for i in changed]
+    lines.append(md_table(['Pair', f'{files_label} Flash Δ', f'{files_label} RAM Δ', f'{all_label} Flash Δ',
                            f'{all_label} RAM Δ'], pair_rows))
 
     stats = _file_stats(file_deltas)
-    rows = sorted((p for p, s in stats.items() if s['changed']),
-                  key=lambda p: (-max(abs(v[0]) for k in ('flash', 'ram') for v in stats[p][k]), p))
+    rows = _changed_paths(stats)
     lines += ['', '_Changed / present: pairs where the file\'s Flash or RAM total changed / compared pairs '
               'that contain it._', '',
               md_table(['File', 'Changed / present', 'Flash Δ min', 'Flash Δ max', 'RAM Δ min', 'RAM Δ max'],
@@ -663,7 +789,8 @@
             f'{engine} output broke its parsing (try another --engine to isolate)')
 
 
-def compare_sides(base, cur, engine, failures=(), boards=(), scope=None, symbols=False, labels=SIDE_LABELS):
+def compare_sides(base, cur, engine, failures=(), boards=(), scope=None, symbols=False, labels=SIDE_LABELS,
+                  files_label='TinyUSB'):
     """Pair two sides and render them. Returns (md, failures, ok, data).
 
     `failures` comes back with a filter failure for `scope` added when no
@@ -671,13 +798,14 @@
     broke its parsing); pass `scope=None` when each scope was already checked.
     `ok` is False on any failure or when nothing was compared. `data` is the
     report's raw paired sizes, for JSON, symbols included only when `symbols` is set.
+    `files_label` is render_pairs()'.
     """
     pairs, base_only, cur_only = pair_elfs(base, cur)
     failures = list(failures)
     if scope and pairs and not any(b['files'] or c['files'] for b, c in pairs.values()):
         failures.append((scope, 'both', 'filter', _filter_failure(engine)))
     md = render_pairs(pairs, len(base.keys() & cur.keys()), engine, base_only, cur_only, failures, boards,
-                      symbols, labels)
+                      symbols, labels, files_label)
     data = {
         'engine': engine,
         'boards': list(boards),
@@ -817,24 +945,117 @@
     return error.replace(src_dir.rstrip(os.sep) + os.sep, '')
 
 
+ESP_IDF_IMAGE = 'espressif/idf:tinyusb'  # .github/actions/setup_toolchain/espressif's tag
+ESP_IDF_MISSING = (f'ESP-IDF: source $IDF_PATH/export.sh, or docker with {ESP_IDF_IMAGE} '
+                   f'(docker tag espressif/idf:v5.5.3 {ESP_IDF_IMAGE}, as CI does)')
+
+
+def _esp_examples(src_dir, board, example):
+    """The examples tools/build.py builds for espressif `board` (`example` alone when given):
+    those get_examples('espressif') lists that `src_dir` has, less skip_example's."""
+    import build  # tools/build.py; its import has no side effects
+    with contextlib.chdir(src_dir):  # build.py and build_utils read examples/ and hw/bsp from the cwd
+        return [e for e in build.get_examples('espressif')
+                if example in (None, e) and os.path.isdir(os.path.join('examples', e))
+                and not build_utils.skip_example(e, board)]
+
+
+def _link_hops(path):
+    """The paths a symlink resolves through, its real path last."""
+    hops = []
+    while os.path.islink(path):
+        path = os.path.normpath(os.path.join(os.path.dirname(path), os.readlink(path)))
+        hops.append(path)
+    return hops
+
+
+def is_espressif(board, src_dir=TINYUSB_ROOT):
+    return os.path.isdir(os.path.join(src_dir, 'hw', 'bsp', 'espressif', 'boards', board))
+
+
+def _idf_image():
+    return bool(shutil.which('docker')) and run(['docker', 'image', 'inspect', ESP_IDF_IMAGE]).returncode == 0
+
+
+def esp_without_idf(boards):
+    """The error for espressif `boards` no exported ESP-IDF or CI's image can build, else None."""
+    esp = [b for b in boards if is_espressif(b)]
+    if esp and not (shutil.which('idf.py') or _idf_image()):
+        return f'{", ".join(esp)} need {ESP_IDF_MISSING}'
+    return None
+
+
+def _idf_command(src_dir, build_dir, name):
+    """The argv that runs idf.py on `src_dir` into `build_dir`: an exported ESP-IDF, else CI's
+    image as this user in a container `name`, with both mounted at their own paths so the
+    elfs' DWARF matches the filters, and each symlinked dependency's real dir at every path
+    its link goes through (a base worktree's links point at the checkout's, which may point
+    at the main checkout's). None when neither is available."""
+    if shutil.which('idf.py'):
+        return ['idf.py']
+    if not _idf_image():
+        return None
+    # the image enables ccache; kept here it serves the next run (its default hash_dir keeps
+    # one tree's DWARF paths out of the other's objects)
+    cache = os.path.join(CODE_SIZE_DIR, '_ccache')
+    os.makedirs(cache, exist_ok=True)
+    mounts = {p: os.path.realpath(p) for p in map(os.path.abspath, (src_dir, build_dir, cache))}
+    for dep in runpy.run_path(os.path.join(src_dir, 'tools', 'get_deps.py'))['deps_all']:
+        path = os.path.join(src_dir, dep)
+        mounts.update((hop, os.path.realpath(path)) for hop in _link_hops(path))
+    cmd = ['docker', 'run', '--rm', '--name', name, '--user', f'{os.getuid()}:{os.getgid()}',
+           '-e', 'HOME=/tmp', '-e', f'CCACHE_DIR={os.path.abspath(cache)}']
+    for target, source in sorted(mounts.items()):
+        cmd += ['-v', f'{source}:{target}']
+    return cmd + [ESP_IDF_IMAGE, 'idf.py']
+
+
+def _build_idf(src_dir, build_dir, board, example):
+    """Build each ESP-IDF example project's app, as tools/build.py does (its bootloader is
+    not sized); the first failure stops."""
+    examples = _esp_examples(src_dir, board, example)
+    if not examples:
+        return subprocess.CompletedProcess([], 1, '', f'{board} builds no {example or "example"}')
+    container = f'tinyusb-code-size-{os.getpid()}'
+    idf = _idf_command(src_dir, build_dir, container)
+    if idf is None:
+        return subprocess.CompletedProcess([], 1, '', f'{board} needs {ESP_IDF_MISSING}')
+    for ex in examples:
+        ret = None
+        try:
+            ret = run(idf + ['-C', os.path.join(src_dir, 'examples', ex), '-B', os.path.join(build_dir, ex),
+                             '-GNinja', f'-DBOARD={board}', 'app'], timeout=600)
+        finally:
+            # stopping the docker CLI leaves its container building; --rm only removes an exited one
+            if idf[0] == 'docker' and (ret is None or ret.returncode == 124):
+                run(['docker', 'rm', '-f', container])
+        if ret.returncode != 0:
+            break
+    return ret
+
+
 def build_board(src_dir, build_dir, board, example, label):
     """Configure and build examples for a board as a `label` progress phase, printing
     an excerpt of the output on failure. Returns None on success, else build_error().
 
     When `example` is given, only that target is built (`ninja -C DIR NAME`),
-    keeping single-example workflows fast.
+    keeping single-example workflows fast. An espressif board builds each example as
+    its own ESP-IDF project.
     """
     phase = Phase(label)
     os.makedirs(build_dir, exist_ok=True)
-    ret = run(['cmake', '-B', build_dir, '-G', 'Ninja',
-               f'-DBOARD={board}', '-DCMAKE_BUILD_TYPE=MinSizeRel',
-               os.path.join(src_dir, 'examples')])
-    if ret.returncode == 0:
-        # ninja itself, not `cmake --build`: cmake does not pass a timeout's SIGTERM on
-        cmd = ['ninja', '-C', build_dir]
-        if example:
-            cmd.append(os.path.basename(example))
-        ret = run(cmd, timeout=600)
+    if is_espressif(board, src_dir):
+        ret = _build_idf(src_dir, build_dir, board, example)
+    else:
+        ret = run(['cmake', '-B', build_dir, '-G', 'Ninja',
+                   f'-DBOARD={board}', '-DCMAKE_BUILD_TYPE=MinSizeRel',
+                   os.path.join(src_dir, 'examples')])
+        if ret.returncode == 0:
+            # ninja itself, not `cmake --build`: cmake does not pass a timeout's SIGTERM on
+            cmd = ['ninja', '-C', build_dir]
+            if example:
+                cmd.append(os.path.basename(example))
+            ret = run(cmd, timeout=600)
     failed = ret.returncode != 0
     phase.done(failed=failed)
     if not failed:
@@ -872,8 +1093,10 @@
     """
     # escape the dir, not the wildcards: a checkout path is a path, not a pattern
     root = glob.escape(build_dir)
-    # <role>/<example>/*.elf: deeper elfs are helpers, e.g. pico-sdk's bs2_default.elf
-    elfs = sorted(glob.glob(f'{root}/{example or "*/*"}/*.elf'))
+    # <role>/<example>/*.elf: deeper elfs are helpers, e.g. pico-sdk's bs2_default.elf, and
+    # cmake's _deps/ holds fetched tools, e.g. picotool's enc_bootloader.elf
+    elfs = sorted(e for e in glob.glob(f'{root}/{example or "*/*"}/*.elf')
+                  if not os.path.relpath(e, build_dir).startswith('_deps' + os.sep))
     if not elfs:
         return {}, [(None, f'no .elf files in {build_dir}')]
 
@@ -901,7 +1124,7 @@
     return sizes, errors
 
 
-def diff_summary(data, symbols):
+def diff_summary(data, symbols, files_label='TinyUSB'):
     """Console lines of one diff from its JSON-shaped `data`: coverage, the single
     pair's filtered Δ, unmatched elfs and failures."""
     pairs = {(p['board'], p['elf']): (p['base'], p['current']) for p in data['pairs']}
@@ -910,7 +1133,7 @@
     if len(pairs) == 1:
         (b, c), = pairs.values()
         df, dr = _delta(_files_total(b['files']), _files_total(c['files']))
-        line += f'; filtered Flash Δ {_fmt(df)}, RAM Δ {_fmt(dr)}'
+        line += f'; {files_label} Flash Δ {_fmt(df)}, RAM Δ {_fmt(dr)}'
     lines = [line if data['status'] == 'complete' else f'INCOMPLETE: {line}']
     for key in ('base_only', 'current_only'):
         if data[key]:
@@ -920,13 +1143,13 @@
     return lines
 
 
-def report_summary(sizes, failures, ok):
+def report_summary(sizes, failures, ok, files_label='TinyUSB'):
     """Console lines of one report: coverage, the single elf's filtered total, failures."""
     sized = [s for s in sizes.values() if s is not None]
     line = f'{len(sized)} of {len(sizes)} elfs sized'
     if len(sized) == 1:
         src = _files_total(sized[0]['files'])
-        line += f'; filtered Flash {src["flash"]}, RAM {src["ram"]}'
+        line += f'; {files_label} Flash {src["flash"]}, RAM {src["ram"]}'
     lines = [line if ok else f'INCOMPLETE: {line}']
     return lines + [f'FAILED {_label(i)} {stage}: {message}' for i, stage, message in failures]
 
@@ -991,6 +1214,7 @@
 def run_report(args):
     """`report`: build and size the working tree, one report per board and example."""
     filters = args.filter or [tinyusb_src_filter(TINYUSB_ROOT)]
+    files_label = 'filtered' if args.filter else 'TinyUSB'
     examples = args.example or [None]
     drop_stale_reports(args.board, examples, 'report')
     focused = _focused(args.board, examples)
@@ -1017,7 +1241,7 @@
                 if sized and not any(s['files'] for s in sized):
                     failures.append(((board, None), 'filter', _filter_failure(args.engine)))
                 phase.done(failed=bool(failures))
-            md = render_report(sizes, args.engine, failures, [board], args.symbols)
+            md = render_report(sizes, args.engine, failures, [board], args.symbols, files_label)
             ok = not failures and any(s is not None for s in sizes.values())
             failed |= not ok
             data = None
@@ -1030,11 +1254,409 @@
                                      for i, stage, message in failures]}
             tables = _labelled({i: [size_table(s, args.symbols, args.engine)] for i, s in sizes.items() if s},
                                len(sizes)) if focused else ()
-            print_result(report_summary(sizes, failures, ok), f'{example}: ' if scope else '', tables)
+            print_result(report_summary(sizes, failures, ok, files_label), f'{example}: ' if scope else '',
+                         tables)
             write_report(report_path(board, example, 'report'), md, data)
     return 1 if failed else 0
 
 
+SNAPSHOT_SCHEMA = 1
+_SHA_RE = re.compile(r'[0-9a-f]{40}')
+
+
+def _cmake_compiler(build_dir):
+    """{'id', 'version', 'name', 'build_type'} of a configured build dir, from CMake's own
+    compiler detection (not the host gcc), '' where CMake recorded none."""
+    info = {'id': '', 'version': '', 'name': '', 'build_type': ''}
+    found = glob.glob(os.path.join(glob.escape(build_dir), 'CMakeFiles', '*', 'CMakeCCompiler.cmake'))
+    if not found:  # ESP-IDF: one project per <role>/<example>, all on the board's one toolchain
+        projects = sorted(glob.glob(os.path.join(glob.escape(build_dir), '*', '*', 'CMakeFiles')))
+        return _cmake_compiler(os.path.dirname(projects[0])) if projects else info
+    for path in found:
+        with open(path) as f:
+            text = f.read()
+        for key, var in (('id', 'CMAKE_C_COMPILER_ID'), ('version', 'CMAKE_C_COMPILER_VERSION'),
+                         ('name', 'CMAKE_C_COMPILER')):
+            m = re.search(rf'^set\({var} "([^"]*)"\)', text, re.M)
+            if m:
+                info[key] = os.path.basename(m.group(1)) if key == 'name' else m.group(1)
+    cache = os.path.join(build_dir, 'CMakeCache.txt')
+    if os.path.isfile(cache):
+        with open(cache) as f:
+            m = re.search(r'^CMAKE_BUILD_TYPE:\w+=(.*)$', f.read(), re.M)
+        info['build_type'] = m.group(1) if m else ''
+    return info
+
+
+def _git_shas(event):
+    """(sha, base_sha, head_sha) of the checkout: a pull_request build checks out GitHub's
+    merge commit, whose first parent is the base branch tip it was built against and
+    second parent the PR head; any other build is its own base and head."""
+    ret = run(['git', '-C', TINYUSB_ROOT, 'rev-parse', 'HEAD', 'HEAD^1', 'HEAD^2']
+              if event == 'pull_request' else ['git', '-C', TINYUSB_ROOT, 'rev-parse', 'HEAD'])
+    shas = ret.stdout.split() if ret.returncode == 0 else []
+    if not shas or not all(_SHA_RE.fullmatch(s) for s in shas):
+        raise RuntimeError(f'cannot resolve the checkout commits for a {event} build: {ret.stderr.strip()}')
+    return (shas[0], shas[1], shas[2]) if len(shas) == 3 else (shas[0], shas[0], shas[0])
+
+
+def snapshot_boards(families, boards, examples, defines=()):
+    """The CI-pinned boards a build leg produced, as the Membrowse upload step picks them:
+    each family's pinned boards that build one of `examples` (build.py's
+    resolve_ci_boards, boards-only), plus each `-b` board that is pinned. `defines` are
+    the leg's -D tokens, which can enable examples (MAX3421_HOST=1)."""
+    import build  # tools/build.py; its import has no side effects
+    pinned = ci_pinned_boards()
+    # build.py skips a -b board that builds none of the examples, before configuring it
+    picked = [b for b in boards if b in pinned and build.builds_any(b, examples, defines)]
+    for family in families:
+        picked += build.resolve_ci_boards(CI_PINNED_BOARDS, family, True, examples, extra_defines=defines)
+    return list(dict.fromkeys(picked))
+
+
+def board_snapshot(board, build_dir, examples, filters, defines=()):
+    """(elfs, failures) of one board's build dir: {elf path: sizes} of every sized elf,
+    and [{'elf', 'stage', 'message'}]. A missing build dir, an example of the scope the
+    board builds but has no elf for, or an elf that failed to size is a failure, never a
+    zero; a board whose elfs all match no TinyUSB file fails the filter check."""
+    if not os.path.isdir(build_dir):
+        return {}, [{'elf': None, 'stage': 'build', 'message': f'no build dir {_shown(build_dir)}'}]
+    elfs, failures = {}, []
+    scopes = [e for e in dict.fromkeys(examples)
+              if not build_utils.skip_example(e, board, defines)] if examples else [None]
+    for example in scopes:
+        sizes, errors = generate_sizes(build_dir, filters, example)
+        for rel, s in sizes.items():
+            if s is not None and not _valid_sizes(s):
+                errors.append((rel, 'sizes are not integer byte counts of files, sections and symbols'))
+            elif s is not None:
+                elfs[rel] = s
+        failures += [{'elf': rel, 'stage': 'build' if rel is None else 'report',
+                      'message': f'{example}: {msg}' if example and rel is None else msg}
+                     for rel, msg in errors]
+    if elfs and not any(s['files'] for s in elfs.values()):
+        failures.append({'elf': None, 'stage': 'filter', 'message': _filter_failure('membrowse')})
+    return elfs, failures
+
+
+def run_snapshot(args):
+    """`snapshot`: size the CI-pinned boards a build leg already built, one
+    code-size-<board>.json each, for `compare` against another run's snapshots."""
+    os.chdir(TINYUSB_ROOT)  # build_utils and build.py resolve hw/bsp and examples from the cwd
+    sha, base_sha, head_sha = _git_shas(args.event)
+    try:
+        from importlib.metadata import version
+        membrowse_version = version('membrowse')
+    except Exception:
+        membrowse_version = ''
+    filters = args.filter or [tinyusb_src_filter(TINYUSB_ROOT)]
+    defines = tuple(args.define_symbol)
+    boards = snapshot_boards(args.families, args.board, args.example, defines)
+    # a variant leg (--build-name) reports its one board under the build name
+    names = {args.build_name or b: b for b in boards}
+    os.makedirs(args.output, exist_ok=True)
+    # written even with no board: `compare` tells a leg that ran from one that never did
+    with open(os.path.join(args.output, 'leg.json'), 'w') as f:
+        json.dump({'schema': SNAPSHOT_SCHEMA, 'examples': args.example, 'boards': list(names),
+                   'build_outcome': args.build_outcome, 'sha': sha, 'base_sha': base_sha, 'head_sha': head_sha},
+                  f, sort_keys=True)
+    if not boards:
+        print('snapshot: no CI-pinned board in this leg')
+        return 0
+    failed = False
+    for board, base_board in names.items():
+        build_dir = os.path.join(args.build_root, f'cmake-build-{board}')
+        elfs, failures = board_snapshot(base_board, build_dir, args.example, filters, defines)
+        # the leg's commits, examples and build outcome are in its leg.json
+        data = {'schema': SNAPSHOT_SCHEMA, 'board': board, 'engine': 'membrowse',
+                'membrowse_version': membrowse_version, 'compiler': _cmake_compiler(build_dir),
+                'elfs': {rel: _json_sizes(s, args.symbols) for rel, s in sorted(elfs.items())},
+                'failures': failures}
+        path = os.path.join(args.output, f'code-size-{board}.json')
+        with open(path, 'w') as f:
+            json.dump(data, f, sort_keys=True, separators=(',', ':'))
+        failed |= bool(failures)
+        print(f'  {board}: {len(elfs)} elfs sized, {len(failures)} failures -> {_shown(path)}')
+        for fail in failures:
+            print(f'    FAILED {fail["elf"] or board} {fail["stage"]}: {fail["message"]}')
+    return 1 if failed else 0
+
+
+_NAME_RE = re.compile(r'[A-Za-z0-9_.+/-]+')
+COMMENT_LIMIT = 60000  # GitHub caps a comment at 65536 characters
+
+
+def _clean(text, limit=300):
+    """Snapshot text for a report: a snapshot is PR-built data, so nothing in it may open
+    Markdown, HTML or an @mention."""
+    return re.sub(r'[^A-Za-z0-9_.,:;=()/+ -]', '?', str(text))[:limit]
+
+
+def _is_count(value):
+    return isinstance(value, int) and not isinstance(value, bool)
+
+
+def _region(value):
+    return isinstance(value, dict) and set(value) == {'flash', 'ram'} and all(map(_is_count, value.values()))
+
+
+def _int_map(value, depth):
+    """Whether `value` is `depth` levels of str-keyed dicts over byte counts."""
+    return isinstance(value, dict) and all(
+        isinstance(k, str) and (_int_map(v, depth - 1) if depth > 1 else _is_count(v)) for k, v in value.items())
+
+
+def _valid_sizes(s):
+    return (isinstance(s, dict) and {'files', 'all', 'sections'} <= set(s) <= {'files', 'all', 'sections', 'symbols'}
+            and _region(s['all']) and isinstance(s['files'], dict)
+            and all(isinstance(k, str) and _region(v) for k, v in s['files'].items())
+            and _int_map(s['sections'], 2) and ('symbols' not in s or _int_map(s['symbols'], 3)))
+
+
+def _is_name(value):
+    return isinstance(value, str) and bool(_NAME_RE.fullmatch(value)) and '..' not in value.split('/')
+
+
+def _names(value, none_ok=False):
+    return (none_ok and value is None) or (isinstance(value, list) and all(map(_is_name, value)))
+
+
+def _commits(data):
+    """(sha, base_sha, head_sha), or None unless all are 40-hex."""
+    shas = tuple(data.get(k) for k in ('sha', 'base_sha', 'head_sha'))
+    return shas if all(isinstance(s, str) and _SHA_RE.fullmatch(s) for s in shas) else None
+
+
+def _scope_error(data):
+    legs, fam = data.get('legs'), data.get('family_examples', {})
+    if not isinstance(data.get('code_changed'), bool):
+        return 'code_changed is not a boolean'
+    if not (isinstance(legs, list) and all(isinstance(leg, dict) and isinstance(leg.get('toolchain'), str)
+                                           and isinstance(leg.get('arg'), str) for leg in legs)):
+        return 'legs are not {toolchain, arg} strings'
+    if not (isinstance(fam, dict) and all(map(_names, fam.values()))):
+        return 'family_examples is not a map of example lists'
+    return None
+
+
+def _leg_error(data):
+    if not isinstance(data.get('build_outcome'), str):
+        return 'build_outcome is not a string'
+    if not _names(data.get('boards')):
+        return 'boards is not a list of board names'
+    if 'examples' not in data or not _names(data['examples'], none_ok=True):
+        return 'examples is not a list of example names'
+    return None if _commits(data) else 'commit hashes are not 40-hex'
+
+
+def _shard_error(data, filename):
+    board = data.get('board')
+    if not (_is_name(board) and filename == f'code-size-{board}.json'):
+        return 'board name does not match the file'
+    if data.get('engine') != 'membrowse':
+        return 'unsupported engine'
+    elfs, failures = data.get('elfs'), data.get('failures')
+    if not isinstance(elfs, dict) or not isinstance(failures, list):
+        return 'no elfs/failures'
+    for elf, sizes in elfs.items():
+        if not _is_name(elf):
+            return 'invalid elf path'
+        if not _valid_sizes(sizes):
+            return f'{elf}: sizes are not integer byte counts'
+    if not all(isinstance(f, dict) and 'elf' in f and (f['elf'] is None or _is_name(f['elf']))
+               and isinstance(f.get('stage'), str) and isinstance(f.get('message'), str) for f in failures):
+        return 'failures are not {elf, stage, message} records'
+    return None
+
+
+def load_snapshots(root):
+    """Every snapshot file under `root` (one directory per downloaded artifact):
+    {'scope': scope.json or None, 'legs': {artifact dir: leg.json}, 'shards': {board: shard},
+    'errors': [(file, why)]}. A shard records its artifact dir as '_leg'. An unusable file
+    is an error, never guessed at."""
+    run = {'scope': None, 'legs': {}, 'shards': {}, 'errors': []}
+    for dirpath, _dirs, files in sorted(os.walk(root)):
+        artifact = os.path.basename(dirpath)
+        for name in sorted(f for f in files if f.endswith('.json')):
+            rel = os.path.relpath(os.path.join(dirpath, name), root)
+            try:
+                with open(os.path.join(dirpath, name)) as f:
+                    data = json.load(f)
+            except (OSError, ValueError):
+                run['errors'].append((rel, 'unreadable JSON'))
+                continue
+            if not isinstance(data, dict) or data.get('schema') != SNAPSHOT_SCHEMA:
+                why = 'unsupported snapshot schema'
+            elif name == 'scope.json':
+                why = _scope_error(data)
+                run['scope'] = None if why else data
+            elif name == 'leg.json':
+                why = _leg_error(data)
+                if not why:
+                    run['legs'][artifact] = data
+            elif name.startswith('code-size-'):
+                why = _shard_error(data, name)
+                if not why and data['board'] in run['shards']:
+                    why = f'second snapshot of {data["board"]}'
+                if not why:
+                    run['shards'][data['board']] = {**data, '_leg': artifact}
+            else:
+                why = 'not a snapshot file'
+            if why:
+                run['errors'].append((rel, why))
+    return run
+
+
+def _leg_examples(scope, arg):
+    """The examples build_util.yml builds for a scope leg: its arg's own -e plus the scope's
+    family map entry for that arg, None for all."""
+    return sorted(set(re.findall(r' -e ([^ ]+)', arg) + scope.get('family_examples', {}).get(arg, []))) or None
+
+
+def _in_scope(elf, examples):
+    """Whether an elf path (<role>/<example>/<name>.elf) is one of `examples`, all when None."""
+    return examples is None or '/'.join(elf.split('/')[:2]) in examples
+
+
+def _usable_shards(run, side, failures, boards=None):
+    """`run`'s shards listed in the boards of their own leg, whose build succeeded, each given
+    its leg's examples as '_examples'. Returns ({board: shard}, {leg commits}); every
+    rejection is added to `failures`, only of `boards` when given."""
+    shards, commits = {}, {_commits(leg) for leg in run['legs'].values()}
+    for rel, why in run['errors']:
+        board = re.fullmatch(r'code-size-(.+)\.json', os.path.basename(rel))
+        if boards is None or not board or board.group(1) in boards:
+            failures.append(((_clean(rel), None), side, 'snapshot', _clean(why)))
+    for board, shard in sorted(run['shards'].items()):
+        if boards is not None and board not in boards:
+            continue
+        leg = run['legs'].get(shard['_leg'])
+        if leg is None or board not in leg['boards']:
+            failures.append(((board, None), side, 'snapshot', 'not a board of its leg\'s build'))
+        elif leg['build_outcome'] != 'success':
+            failures.append(((board, None), side, 'build', f'the build step ended {_clean(leg["build_outcome"])}'))
+        else:
+            shards[board] = {**shard, '_examples': leg['examples']}
+    for _artifact, leg in sorted(run['legs'].items()):
+        failures += [((board, None), side, 'snapshot', 'the leg left no snapshot of it') for board in leg['boards']
+                     if board not in run['shards'] and (boards is None or board in boards)]
+    return shards, commits
+
+
+def _scope_failures(scope, legs):
+    """Legs of the scope that left no leg.json, or measured other examples than it selected,
+    and legs that are not of it."""
+    failures, expected = [], set()
+    for leg in scope['legs']:
+        tag = re.sub(r' -e [^ ]+', '', leg['arg'])  # build_util.yml's ARTIFACT_TAG
+        artifact = f'code-size-{leg["toolchain"]}-{tag}'
+        expected.add(artifact)
+        if artifact not in legs:
+            failures.append(((_clean(artifact), None), 'current', 'snapshot', 'the build leg left no snapshot'))
+        elif (legs[artifact]['examples'] is not None  # all examples covers any selection
+              and sorted(set(legs[artifact]['examples'])) != _leg_examples(scope, leg['arg'])):
+            failures.append(((_clean(artifact), None), 'current', 'scope', 'measured other examples than selected'))
+    failures += [((_clean(a), None), 'current', 'scope', 'not a leg of this run') for a in sorted(set(legs) - expected)]
+    return failures
+
+
+def compare_runs(base, cur, baseline=None, symbols=False):
+    """Compare two load_snapshots() runs, `cur` defining the scope. Returns
+    (full Markdown, comment Markdown, JSON data). `baseline` is code_size_ci.py's
+    description of the base run ({'sha', 'url', 'exact', 'note'}), or None. Anything that
+    leaves coverage short is a failure; a comparable difference is a note."""
+    notes, failures = [], []
+    scope = cur['scope']
+    if scope is None:
+        failures.append((('scope', None), 'current', 'snapshot', 'no usable scope manifest'))
+    elif not (scope['legs'] or cur['legs'] or cur['shards'] or cur['errors']):
+        md = 'Nothing to measure: no code change, or no CI-pinned build leg selected.\n'
+        return md, f'## Code size\n\n{md}', {'status': 'nothing measured'}
+    else:
+        failures += _scope_failures(scope, cur['legs'])
+    cur_shards, cur_commits = _usable_shards(cur, 'current', failures)
+    base_shards, base_commits = _usable_shards(base, 'base', failures, set(cur_shards))
+    labels = SIDE_LABELS
+    if len(cur_commits) > 1:
+        failures.append((('commits', None), 'current', 'snapshot', 'the snapshots are of different commits'))
+        cur_shards = {}
+    elif cur_commits:
+        (_sha, base_sha, head_sha), = cur_commits
+        labels = (base_sha[:10], head_sha[:10])
+    base_shas = {c[0] for c in base_commits}
+    if len(base_shas) > 1 or (baseline and base_shas and base_shas != {baseline.get('sha')}):
+        failures.append((('commits', None), 'base', 'snapshot', 'the snapshots are not of the selected baseline commit'))
+        base_shards = {}
+
+    sides, no_baseline, outside = {'base': {}, 'current': {}}, [], 0
+    for board, shard in sorted(cur_shards.items()):
+        failures += [((board, f['elf']), 'current', _clean(f['stage']), _clean(f['message'])) for f in shard['failures']]
+        b = base_shards.get(board)
+        if b is None:
+            no_baseline.append(board)
+            failures.append(((board, None), 'base', 'snapshot', 'no baseline snapshot'))
+            continue
+        for key in ('membrowse_version', 'compiler'):
+            if b.get(key) != shard.get(key):
+                notes.append(f'{board}: {key} differs from the baseline ({_clean(b.get(key))} -> '
+                             f'{_clean(shard.get(key))}): comparable, not exact')
+        examples = shard['_examples']
+        # a baseline failure is the baseline's, never this PR's: its elf is not new here
+        base_failed = set()
+        for f in b['failures']:
+            if f['elf'] is None or _in_scope(f['elf'], examples):
+                failures.append(((board, f['elf']), 'base', _clean(f['stage']), _clean(f['message'])))
+                base_failed.add(f['elf'])
+        for elf, sizes in b['elfs'].items():
+            if _in_scope(elf, examples):
+                sides['base'][(board, elf)] = sizes
+            else:
+                outside += 1
+        sides['current'].update({(board, elf): s for elf, s in shard['elfs'].items() if elf not in base_failed})
+    if outside:
+        notes.append(f'{outside} baseline elfs are of examples this PR did not build (outside coverage)')
+
+    if symbols and not all('symbols' in s for side in sides.values() for s in side.values()):
+        symbols = False
+        notes.append('symbols omitted: some snapshots were taken without them')
+    head = []
+    if baseline:
+        kind = 'exact' if baseline.get('exact') else 'approximate'
+        head.append((f'Baseline: {_clean(baseline["sha"][:10])} ({kind}) {_clean(baseline.get("url"), 200)}'
+                     if baseline.get('sha') else 'Baseline: unavailable')
+                    + (f' - {_clean(baseline["note"])}' if baseline.get('note') else ''))
+    head += [f'- {n}' for n in notes]
+    boards = sorted(cur_shards)
+    full, _fails, _ok, data = compare_sides(sides['base'], sides['current'], 'membrowse', failures, boards,
+                                            symbols=symbols, labels=labels)
+    pairs, base_only, cur_only = pair_elfs(sides['base'], sides['current'])
+    body, footnote = render_comment(pairs, 'membrowse', base_only, cur_only, failures, symbols)
+    preface = '\n'.join(head) + ('\n\n' if head else '')
+    comment = f'## Code size\n\n{preface}{body}'
+    truncated = '\n\n_Truncated: see the full report._\n'
+    if len(comment) + len(footnote) > COMMENT_LIMIT:
+        comment = comment[:COMMENT_LIMIT - len(footnote) - len(truncated)].rsplit('\n', 1)[0] + truncated
+    comment += footnote
+    data.update({'notes': notes, 'baseline': baseline, 'no_baseline': no_baseline, 'outside': outside})
+    return preface + full, comment, data
+
+
+def run_compare(args):
+    """`compare`: compare a run's snapshots against a baseline run's, no build."""
+    baseline = None
+    if args.baseline_info:
+        with open(args.baseline_info) as f:
+            baseline = json.load(f)
+    base = load_snapshots(args.base)  # os.walk of a missing directory yields nothing
+    full, comment, data = compare_runs(base, load_snapshots(args.current), baseline, args.symbols)
+    os.makedirs(args.output, exist_ok=True)
+    write_report(os.path.join(args.output, 'code-size'), full, data)
+    with open(os.path.join(args.output, 'comment.md'), 'w') as f:
+        f.write(comment)
+    print(f'  comment: {_shown(os.path.join(args.output, "comment.md"))}')
+    return 0
+
+
 def example_arg(value):
     example = value.rstrip('/')
     if not example:
@@ -1086,8 +1708,54 @@
     parser.add_argument('--combined', action='store_true',
                         help='Also write one comparison over every board '
                              '(cmake-code-size/_combined/diff.md), in addition to per-board.')
+    snap = sub.add_parser('snapshot', help='size the CI-pinned boards a CI build leg already built, '
+                                           'one code-size-<board>.json each (no build)')
+    snap.add_argument('families', nargs='*', default=[], help='Families the leg built (as tools/build.py)')
+    snap.add_argument('-b', '--board', action='append', default=[], help='Board the leg built (repeatable)')
+    snap.add_argument('-e', '--example', action='append', default=None, type=example_arg,
+                      help='Examples the leg built (repeatable); omit when it built all')
+    snap.add_argument('--build-name', help='A variant leg\'s build name (as tools/build.py): its build dir, and '
+                                          'the board it reports as')
+    snap.add_argument('-D', '--define-symbol', action='append', default=[],
+                      help='A variant leg\'s build-system define (as tools/build.py); it needs --build-name')
+    snap.add_argument('--cflag', action='append', default=[],
+                      help='A variant leg\'s compiler flag (as tools/build.py); it needs --build-name')
+    snap.add_argument('-o', '--output', required=True, help='Directory for the code-size-<board>.json files')
+    snap.add_argument('--build-root', default=os.path.join(TINYUSB_ROOT, 'cmake-build'),
+                      help='Where tools/build.py put cmake-build-<board> (default: cmake-build)')
+    snap.add_argument('--event', default=os.environ.get('GITHUB_EVENT_NAME', 'push'),
+                      help='CI event; pull_request reads base/head from the merge commit '
+                           '(default: $GITHUB_EVENT_NAME, else push)')
+    snap.add_argument('--build-outcome', default='success', help='The Build step\'s outcome, recorded as is')
+    snap.add_argument('-f', '--filter', action='append', default=None,
+                      help='Path-substring filter (repeatable); default: this checkout\'s <checkout>/src/')
+    snap.add_argument('--symbols', action='store_true', help='Include each file\'s symbols')
+    snap.add_argument('-v', '--verbose', action='store_true', help='Print commands')
+    cmp = sub.add_parser('compare', help='compare a CI run\'s snapshots against a baseline run\'s '
+                                         '(code-size.md, code-size.json, comment.md; no build)')
+    cmp.add_argument('current', help='The run\'s downloaded artifacts, one directory each')
+    cmp.add_argument('base', help='The baseline run\'s downloaded artifacts; a missing directory is no baseline')
+    cmp.add_argument('-o', '--output', required=True, help='Directory for the reports')
+    cmp.add_argument('--baseline-info', help='JSON describing the baseline run: {sha, url, exact, note}')
+    cmp.add_argument('--symbols', action='store_true', help='Show each changed file\'s symbols in the full report')
     args = top.parse_args()
-    verbose = args.verbose
+    verbose = getattr(args, 'verbose', False)
+
+    if args.command == 'compare':
+        return run_compare(args)
+
+    if args.command == 'snapshot':
+        # a leg whose build did not succeed may have skipped the membrowse install too;
+        # its snapshot still records that outcome, and any elf it left fails to size
+        if engine_missing('membrowse') and args.build_outcome == 'success':
+            snap.error(f'membrowse not found - install {ENGINES["membrowse"].install}')
+        if invalid := invalid_boards(args.board + ([args.build_name] if args.build_name else [])):
+            snap.error(f'invalid board name: {", ".join(invalid)}')
+        if args.build_name and (args.families or len(args.board) != 1):
+            snap.error('--build-name names exactly one -b board and no families')
+        if (args.define_symbol or args.cflag) and not args.build_name:
+            snap.error('-D or --cflag makes a variant, which needs --build-name to report under')
+        return run_snapshot(args)
 
     if engine_missing(args.engine):
         sub.choices[args.command].error(f'{args.engine} not found - install {ENGINES[args.engine].install}, '
@@ -1098,6 +1766,8 @@
             report_parser.error('at least one -b BOARD is required')
         if invalid := invalid_boards(args.board):
             report_parser.error(f'invalid board name: {", ".join(invalid)}')
+        if error := esp_without_idf(args.board):
+            report_parser.error(error)
         return run_report(args)
 
     if args.bloaty and not args.example:
@@ -1113,8 +1783,12 @@
         parser.error('at least one -b BOARD is required (or pass --ci)')
     if invalid := invalid_boards(args.board):
         parser.error(f'invalid board name: {", ".join(invalid)}')
+    # before any board builds: a --ci run would otherwise fail its espressif boards last
+    if error := esp_without_idf(args.board):
+        parser.error(error)
 
     worktree_dir = os.path.join(CODE_SIZE_DIR, '_worktree')
+    files_label = 'filtered' if args.filter else 'TinyUSB'
 
     # Per-side filters: when no override is given, each build uses its own
     # absolute <checkout>/src/ path so we only match TinyUSB stack code from that
@@ -1202,7 +1876,7 @@
 
                 md, failures, ok, data = compare_sides(
                     sides['base'], sides['current'], args.engine, failures, [board], scope=(board, None),
-                    symbols=args.symbols, labels=labels)
+                    symbols=args.symbols, labels=labels, files_label=files_label)
                 if not build_failure:
                     phase.done(failed=not ok)
                 failed |= not ok
@@ -1214,7 +1888,8 @@
                                                                          args.engine, labels)
                                     for p in data['pairs'] if _pair_changed(p['base'], p['current'], args.symbols)},
                                    len(data['pairs'])) if focused else ()
-                print_result(diff_summary(data, args.symbols), f'{example}: ' if scope else '', tables)
+                print_result(diff_summary(data, args.symbols, files_label), f'{example}: ' if scope else '',
+                             tables)
                 write_report(report_path(board, example), md, report_data(data))
 
                 if args.bloaty and example and not build_failure:
@@ -1247,9 +1922,9 @@
             # every scope was filter-checked above, and its failures carried over
             md, _failures, ok, data = compare_sides(
                 combined_sides['base'], combined_sides['current'], args.engine,
-                combined_failures, args.board, symbols=args.symbols, labels=labels)
+                combined_failures, args.board, symbols=args.symbols, labels=labels, files_label=files_label)
             failed |= not ok
-            print_result(diff_summary(data, args.symbols))
+            print_result(diff_summary(data, args.symbols, files_label))
             write_report(os.path.join(combined_dir, 'diff'), md, report_data(data))
     finally:
         # an add stopped by a signal leaves it too, locked `initializing`
diff --git a/tools/membrowse_cli.py b/tools/membrowse_cli.py
index fe147d2..12b06f7 100644
--- a/tools/membrowse_cli.py
+++ b/tools/membrowse_cli.py
@@ -13,6 +13,8 @@
 
 # `--defsym=SYM=VAL` or `--defsym,SYM=VAL` from a link line
 DEFSYM_RE = re.compile(r'--defsym[=,](\S+)')
+# ESP-IDF's generated scripts, relative to the build dir (family_support.cmake's MEMBROWSE_LD_OVERRIDE)
+IDF_LD_SCRIPTS = ('esp-idf/esp_system/ld/memory.ld', 'esp-idf/esp_system/ld/sections.ld')
 
 
 def link_command(ninja, build_dir, elf):
@@ -129,8 +131,7 @@
         build = (f'idf.py -C {shlex.quote(f"examples/{example}")} -B {quoted_build_dir} '
                  f'-GNinja {shlex.quote(f"-DBOARD={board}")} build')
         elf_path = f'{build_dir}/{basename}.elf'
-        shim_scripts = ('esp-idf/esp_system/ld/memory.ld',
-                        'esp-idf/esp_system/ld/sections.ld')
+        shim_scripts = IDF_LD_SCRIPTS
     else:
         configure = (f'cmake -S examples -B {quoted_build_dir} '
                      f'{shlex.quote(f"-DBOARD={board}")} -G Ninja '