[ci] Restore memory impact detail for ESP-IDF builds (#17587)

This commit is contained in:
J. Nick Koston
2026-07-16 08:00:19 -04:00
committed by GitHub
parent 2333e6eef5
commit 14e71e190c
9 changed files with 459 additions and 63 deletions
+8 -27
View File
@@ -20,6 +20,7 @@ from . import (
RAM_SECTIONS,
MemoryAnalyzer,
)
from .toolchain import find_elf_path, find_idedata_path, idedata_candidates
if TYPE_CHECKING:
from . import ComponentMemory
@@ -759,45 +760,25 @@ def main():
print(f"Error: {build_path} is not a directory", file=sys.stderr)
sys.exit(1)
# Find firmware.elf
elf_file = None
for elf_candidate in [
build_path / "firmware.elf",
build_path / ".pioenvs" / build_path.name / "firmware.elf",
]:
if elf_candidate.exists():
elf_file = str(elf_candidate)
break
if not elf_file:
print(f"Error: firmware.elf not found in {build_dir}", file=sys.stderr)
elf_path = find_elf_path(build_path)
if not elf_path:
print(f"Error: no firmware ELF found in {build_dir}", file=sys.stderr)
sys.exit(1)
# Find idedata.json - check current directory first, then home
device_name = build_path.name
idedata_candidates = [
Path.cwd() / ".esphome" / "idedata" / f"{device_name}.json",
Path.home() / ".esphome" / "idedata" / f"{device_name}.json",
]
elf_file = str(elf_path)
idedata = None
for idedata_path in idedata_candidates:
if not idedata_path.exists():
continue
if idedata_path := find_idedata_path(build_path):
try:
with idedata_path.open(encoding="utf-8") as f:
raw_data = json.load(f)
idedata = IDEData(raw_data)
print(f"Loaded idedata from: {idedata_path}", file=sys.stderr)
break
except (json.JSONDecodeError, OSError) as e:
print(f"Warning: Failed to load idedata: {e}", file=sys.stderr)
if not idedata:
print(
f"Warning: idedata not found (searched {idedata_candidates[0]} and {idedata_candidates[1]})",
file=sys.stderr,
)
searched = "\n ".join(str(p) for p in idedata_candidates(build_path))
print(f"Warning: idedata not found, searched:\n {searched}", file=sys.stderr)
analyzer = MemoryAnalyzerCLI(elf_file, idedata=idedata)
analyzer.analyze()
+72
View File
@@ -23,6 +23,78 @@ TOOLCHAIN_PREFIXES = [
]
def find_elf_path(build_path: Path) -> Path | None:
"""Locate the firmware ELF inside an ESPHome build directory.
The layout depends on the toolchain that produced the build, so try each
known one in turn.
Args:
build_path: Path to an ESPHome build directory
Returns:
Path to the ELF file, or None if no known layout matches
"""
name = build_path.name
for candidate in (
# Native ESP-IDF: idf.py writes build/<name>.elf, which ESPHome copies
# to build/firmware.elf (see espidf.toolchain.create_elf_copy)
build_path / "build" / "firmware.elf",
# PlatformIO
build_path / "firmware.elf",
build_path / ".pioenvs" / name / "firmware.elf",
# LibreTiny uses raw_firmware.elf
build_path / "raw_firmware.elf",
build_path / ".pioenvs" / name / "raw_firmware.elf",
# Zephyr (nRF52); the SDK nests the artifacts one level deeper from 2.9.2
build_path / ".pioenvs" / name / "zephyr" / "zephyr" / "zephyr.elf",
build_path / ".pioenvs" / name / "zephyr" / "zephyr.elf",
):
if candidate.is_file():
return candidate
return None
def idedata_candidates(build_path: Path) -> list[Path]:
"""Return the idedata locations searched for a build directory, in order.
Exposed so a caller reporting "not found" can name the paths it tried
without keeping its own copy of the list.
Args:
build_path: Path to an ESPHome build directory
Returns:
The candidate idedata JSON paths, most specific first
"""
name = build_path.name
return [
# In .pioenvs for test builds
build_path / ".pioenvs" / name / "idedata.json",
# Both toolchains cache it in the data dir, which holds this build dir:
# <data_dir>/idedata/<name>.json next to <data_dir>/build/<name>
build_path.parent.parent / "idedata" / f"{name}.json",
# Regular builds, invoked from the config dir or from anywhere
Path.cwd() / ".esphome" / "idedata" / f"{name}.json",
Path.home() / ".esphome" / "idedata" / f"{name}.json",
]
def find_idedata_path(build_path: Path) -> Path | None:
"""Locate the idedata JSON belonging to an ESPHome build directory.
Args:
build_path: Path to an ESPHome build directory
Returns:
Path to the idedata JSON, or None if it was not found
"""
for candidate in idedata_candidates(build_path):
if candidate.is_file():
return candidate
return None
def _find_in_platformio_packages(tool_name: str) -> str | None:
"""Search for a tool in PlatformIO package directories.
+24 -1
View File
@@ -6,7 +6,7 @@ toolchain has no such command, but its CMake build emits
turns that file into the same fields consumers (IDE integration, clang-tidy)
expect:
{cxx_path, cxx_flags, defines, includes: {build, toolchain}}
{cc_path, cxx_path, cxx_flags, defines, includes: {build, toolchain}}
"""
from __future__ import annotations
@@ -197,6 +197,28 @@ def _get_toolchain_includes(cxx_path: str) -> list[str]:
return includes
def _cc_path_from_cxx(cxx_path: str) -> str:
"""Derive the C compiler path from the C++ compiler path.
compile_commands.json only names the C++ compiler, but consumers reach the
rest of the toolchain (objdump, readelf, addr2line) by rewriting the tail of
``cc_path``, so they need the ``gcc``-suffixed name.
"""
stem, suffix = (
(cxx_path[: -len(".exe")], ".exe")
if cxx_path.endswith(".exe")
else (cxx_path, "")
)
# Rewrite the program name only when it is g++ itself, or a toolchain
# prefixed one such as xtensa-esp32-elf-g++ -> xtensa-esp32-elf-gcc.
# Requiring a separator before the "g++" keeps names that merely end in
# those three characters intact: "clang++" must not become "clangcc".
head = stem[: -len("g++")]
if stem.endswith("g++") and (not head or head.endswith(("-", "/", "\\"))):
stem = f"{head}gcc"
return f"{stem}{suffix}"
def idedata_from_build(compile_commands: Path) -> dict:
"""Parse compile_commands.json into the idedata fields consumers expect.
@@ -218,6 +240,7 @@ def idedata_from_build(compile_commands: Path) -> dict:
build_includes.setdefault(inc, None)
return {
"cc_path": _cc_path_from_cxx(cxx_path),
"cxx_path": cxx_path,
"cxx_flags": cxx_flags,
"defines": defines,
+8 -1
View File
@@ -467,9 +467,16 @@ def get_idedata() -> dict | None:
cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json")
if cache.is_file() and cache.stat().st_mtime >= compile_commands.stat().st_mtime:
try:
return json.loads(cache.read_text(encoding="utf-8"))
cached = json.loads(cache.read_text(encoding="utf-8"))
except ValueError:
pass
else:
# Caches written before cc_path was emitted stay newer than
# compile_commands.json forever, so rebuild them on the field rather
# than on the timestamp. Check the type too: a corrupted cache can
# still be valid JSON, and "in" would match a substring of a string.
if isinstance(cached, dict) and "cc_path" in cached:
return cached
data = idedata_from_build(compile_commands)
data["prog_path"] = str(get_elf_path())
+28 -32
View File
@@ -33,6 +33,11 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
# pylint: disable=wrong-import-position
from esphome.analyze_memory import MemoryAnalyzer
from esphome.analyze_memory.toolchain import (
find_elf_path,
find_idedata_path,
idedata_candidates,
)
from esphome.platformio.toolchain import IDEData
from script.ci_helpers import write_github_output
@@ -130,53 +135,31 @@ def run_detailed_analysis(build_dir: str) -> dict | None:
print(f"Build directory not found: {build_dir}", file=sys.stderr)
return None
# Find firmware.elf (or raw_firmware.elf for LibreTiny)
elf_path = None
for elf_candidate in [
build_path / "firmware.elf",
build_path / ".pioenvs" / build_path.name / "firmware.elf",
# LibreTiny uses raw_firmware.elf
build_path / "raw_firmware.elf",
build_path / ".pioenvs" / build_path.name / "raw_firmware.elf",
]:
if elf_candidate.exists():
elf_path = str(elf_candidate)
break
elf_path = find_elf_path(build_path)
if not elf_path:
print(
f"firmware.elf/raw_firmware.elf not found in {build_dir}", file=sys.stderr
)
print(f"No firmware ELF found in {build_dir}", file=sys.stderr)
return None
# Find idedata.json - check multiple locations
device_name = build_path.name
idedata_candidates = [
# In .pioenvs for test builds
build_path / ".pioenvs" / device_name / "idedata.json",
# In .esphome/idedata for regular builds
Path.home() / ".esphome" / "idedata" / f"{device_name}.json",
# Check parent directories for .esphome/idedata (for test_build_components)
build_path.parent.parent.parent / "idedata" / f"{device_name}.json",
]
idedata = None
for idedata_path in idedata_candidates:
if not idedata_path.exists():
continue
if idedata_path := find_idedata_path(build_path):
try:
with idedata_path.open(encoding="utf-8") as f:
raw_data = json.load(f)
idedata = IDEData(raw_data)
print(f"Loaded idedata from: {idedata_path}", file=sys.stderr)
break
except (json.JSONDecodeError, OSError) as e:
print(
f"Warning: Failed to load idedata from {idedata_path}: {e}",
file=sys.stderr,
)
else:
# Without idedata the analyzer falls back to whatever binutils are on
# PATH, which are the wrong architecture for a cross build, so say where
# we looked rather than let the results quietly get worse.
searched = "\n ".join(str(p) for p in idedata_candidates(build_path))
print(f"Warning: idedata not found, searched:\n {searched}", file=sys.stderr)
analyzer = MemoryAnalyzer(elf_path, idedata=idedata)
analyzer = MemoryAnalyzer(str(elf_path), idedata=idedata)
components = analyzer.analyze()
# Convert to JSON-serializable format
@@ -320,6 +303,19 @@ def main() -> int:
else:
print(f"{ram_bytes},{flash_bytes}")
# The build produced usable totals, so a missing detailed analysis means the
# build layout moved out from under this script rather than a broken build.
# Fail loudly: the comment would otherwise silently drop the component
# breakdown and the symbol tables, which is easy to miss for a long time.
if detailed_analysis is None:
print(
"::error::Detailed memory analysis unavailable even though the build "
f"succeeded (build directory: {build_dir or 'not detected'}). The PR "
"comment would be missing its component breakdown and symbol changes.",
file=sys.stderr,
)
return 1
return 0
+50
View File
@@ -2993,3 +2993,53 @@ def test_main_force_all_off_uses_detection(
assert output["component_test_count"] == 0
mock_determine_integration_tests.assert_called_once()
mock_should_run_clang_tidy.assert_called_once()
# Every platform the memory impact analysis can select must produce an ELF that
# find_elf_path knows how to locate. The analysis fails the job when it cannot
# find one, so a platform with an unknown layout would turn a clean build red.
_MEMORY_IMPACT_ELF_LAYOUTS = {
# Native ESP-IDF toolchain (the esp32 default): <build>/build/firmware.elf
"esp32-c6-idf": "build/firmware.elf",
"esp32-idf": "build/firmware.elf",
"esp32-c3-idf": "build/firmware.elf",
"esp32-s2-idf": "build/firmware.elf",
"esp32-s3-idf": "build/firmware.elf",
# PlatformIO: <build>/.pioenvs/<name>/firmware.elf
"esp8266-ard": ".pioenvs/{name}/firmware.elf",
"rp2040-ard": ".pioenvs/{name}/firmware.elf",
"rp2350-ard": ".pioenvs/{name}/firmware.elf",
# LibreTiny: <build>/.pioenvs/<name>/raw_firmware.elf
"bk72xx-ard": ".pioenvs/{name}/raw_firmware.elf",
"rtl87xx-ard": ".pioenvs/{name}/raw_firmware.elf",
"ln882x-ard": ".pioenvs/{name}/raw_firmware.elf",
# Zephyr: <build>/.pioenvs/<name>/zephyr/[zephyr/]zephyr.elf
"nrf52-adafruit": ".pioenvs/{name}/zephyr/zephyr/zephyr.elf",
}
def test_memory_impact_platforms_have_known_elf_layout() -> None:
"""Every selectable memory impact platform has a documented ELF layout.
Adding a platform to the preference list without teaching find_elf_path
where its ELF lands would fail the memory impact job on a clean build.
"""
selectable = {
platform.value for platform in determine_jobs.MEMORY_IMPACT_PLATFORM_PREFERENCE
}
selectable.add(determine_jobs.MEMORY_IMPACT_FALLBACK_PLATFORM.value)
assert selectable == set(_MEMORY_IMPACT_ELF_LAYOUTS)
def test_memory_impact_elf_layouts_are_found(tmp_path: Path) -> None:
"""find_elf_path locates the ELF each memory impact platform produces."""
from esphome.analyze_memory.toolchain import find_elf_path
for platform, layout in _MEMORY_IMPACT_ELF_LAYOUTS.items():
build_path = tmp_path / platform / ".esphome" / "build" / "mydevice"
elf = build_path / layout.format(name=build_path.name)
elf.parent.mkdir(parents=True)
elf.write_text("")
assert find_elf_path(build_path) == elf, f"{platform} ELF not found"
@@ -0,0 +1,157 @@
"""Tests for locating build artifacts across the supported toolchain layouts."""
from pathlib import Path
import pytest
from esphome.analyze_memory.toolchain import (
find_elf_path,
find_idedata_path,
idedata_candidates,
)
from esphome.espidf.idedata import _cc_path_from_cxx
from esphome.platformio.toolchain import IDEData
def _make_build_dir(tmp_path: Path, name: str = "mydevice") -> Path:
"""Create <tmp_path>/.esphome/build/<name>, mirroring a real data dir."""
build_path = tmp_path / ".esphome" / "build" / name
build_path.mkdir(parents=True)
return build_path
def _touch(path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("")
return path
def test_find_elf_path_native_esp_idf(tmp_path: Path) -> None:
"""The native ESP-IDF toolchain writes the ELF under build/."""
build_path = _make_build_dir(tmp_path)
elf = _touch(build_path / "build" / "firmware.elf")
assert find_elf_path(build_path) == elf
def test_find_elf_path_platformio(tmp_path: Path) -> None:
"""The PlatformIO toolchain writes the ELF under .pioenvs/<name>/."""
build_path = _make_build_dir(tmp_path)
elf = _touch(build_path / ".pioenvs" / build_path.name / "firmware.elf")
assert find_elf_path(build_path) == elf
def test_find_elf_path_libretiny(tmp_path: Path) -> None:
"""The LibreTiny toolchain names the unwrapped ELF raw_firmware.elf."""
build_path = _make_build_dir(tmp_path)
elf = _touch(build_path / ".pioenvs" / build_path.name / "raw_firmware.elf")
assert find_elf_path(build_path) == elf
@pytest.mark.parametrize(
"relative_elf",
[
# SDK < 2.9.2
"zephyr/zephyr.elf",
# SDK >= 2.9.2 nests the artifacts one level deeper
"zephyr/zephyr/zephyr.elf",
],
)
def test_find_elf_path_zephyr(tmp_path: Path, relative_elf: str) -> None:
"""Zephyr (nRF52) keeps the ELF under .pioenvs/<name>/zephyr/."""
build_path = _make_build_dir(tmp_path)
elf = _touch(build_path / ".pioenvs" / build_path.name / relative_elf)
assert find_elf_path(build_path) == elf
def test_find_elf_path_missing(tmp_path: Path) -> None:
"""An unknown layout resolves to None rather than a bogus path."""
assert find_elf_path(_make_build_dir(tmp_path)) is None
def test_find_idedata_path_in_data_dir(tmp_path: Path) -> None:
"""The idedata cache sits in the data dir that holds the build dir."""
build_path = _make_build_dir(tmp_path)
idedata = _touch(tmp_path / ".esphome" / "idedata" / f"{build_path.name}.json")
assert find_idedata_path(build_path) == idedata
def test_find_idedata_path_in_pioenvs(tmp_path: Path) -> None:
"""Test builds may keep idedata alongside the PlatformIO env."""
build_path = _make_build_dir(tmp_path)
idedata = _touch(build_path / ".pioenvs" / build_path.name / "idedata.json")
assert find_idedata_path(build_path) == idedata
def test_find_idedata_path_missing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A missing idedata resolves to None."""
# Keep the cwd/home fallbacks from finding an unrelated file on this machine
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
assert find_idedata_path(_make_build_dir(tmp_path)) is None
def test_idedata_candidates_are_what_find_probes(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Every advertised candidate is one find_idedata_path actually accepts.
The candidates are reported to the user when idedata is missing, so a list
that drifts from the lookup would send someone hunting in the wrong place.
"""
# Two candidates are relative to the cwd and to home; keep the test from
# writing into the real ones.
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
build_path = _make_build_dir(tmp_path)
candidates = idedata_candidates(build_path)
assert candidates, "no candidates advertised"
for candidate in candidates:
_touch(candidate)
assert find_idedata_path(build_path) == candidate
candidate.unlink()
@pytest.mark.parametrize(
("cxx_path", "expected"),
[
("/tools/bin/xtensa-esp32-elf-g++", "/tools/bin/xtensa-esp32-elf-gcc"),
("/tools/bin/riscv32-esp-elf-g++", "/tools/bin/riscv32-esp-elf-gcc"),
(
r"C:\tools\bin\xtensa-esp32-elf-g++.exe",
r"C:\tools\bin\xtensa-esp32-elf-gcc.exe",
),
# Nothing to rewrite; leave the path alone
("/tools/bin/clang++", "/tools/bin/clang++"),
],
)
def test_cc_path_from_cxx(cxx_path: str, expected: str) -> None:
"""cc_path is derived from the C++ compiler that compile_commands.json names."""
assert _cc_path_from_cxx(cxx_path) == expected
def test_native_idedata_resolves_toolchain_tools() -> None:
"""The binutils paths are derived from the native ESP-IDF cc_path.
Without cc_path, IDEData.objdump_path raises KeyError and the memory
analysis silently degrades to no component or symbol detail.
"""
idedata = IDEData(
{
"cc_path": _cc_path_from_cxx("/tools/bin/xtensa-esp32-elf-g++"),
"cxx_path": "/tools/bin/xtensa-esp32-elf-g++",
}
)
assert idedata.objdump_path == "/tools/bin/xtensa-esp32-elf-objdump"
assert idedata.readelf_path == "/tools/bin/xtensa-esp32-elf-readelf"
@@ -0,0 +1,57 @@
"""Tests for script/ci_memory_impact_extract.py."""
import io
from pathlib import Path
import sys
import pytest
# Add script directory to path so we can import the module
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "script"))
from ci_memory_impact_extract import main # noqa: E402
_COMPILE_OUTPUT = (
"RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes)\n"
"Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes)\n"
)
@pytest.fixture(autouse=True)
def _no_github_output(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
def _run(monkeypatch: pytest.MonkeyPatch, compile_output: str, argv: list[str]) -> int:
monkeypatch.setattr(sys, "stdin", io.StringIO(compile_output))
monkeypatch.setattr(sys, "argv", ["ci_memory_impact_extract.py", *argv])
return main()
def test_missing_detailed_analysis_fails(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A build with no usable ELF fails instead of posting a comment without detail."""
build_dir = tmp_path / ".esphome" / "build" / "mydevice"
build_dir.mkdir(parents=True)
out_json = tmp_path / "analysis.json"
rc = _run(
monkeypatch,
_COMPILE_OUTPUT,
["--build-dir", str(build_dir), "--output-json", str(out_json)],
)
assert rc == 1
# The totals are still written so the failure can be diagnosed from the artifact
assert out_json.is_file()
def test_undetected_build_dir_fails(monkeypatch: pytest.MonkeyPatch) -> None:
"""Compile output without a build path cannot be analyzed, so it fails."""
assert _run(monkeypatch, _COMPILE_OUTPUT, []) == 1
def test_unparseable_output_fails(monkeypatch: pytest.MonkeyPatch) -> None:
"""Output with no memory totals at all is still a failure."""
assert _run(monkeypatch, "nothing useful here\n", []) == 1
+55 -2
View File
@@ -7,6 +7,8 @@ import os
from pathlib import Path
from unittest.mock import patch
import pytest
from esphome.const import CONF_FRAMEWORK, CONF_SOURCE
from esphome.core import CORE
from esphome.espidf import toolchain
@@ -100,7 +102,7 @@ def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None:
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text('{"cxx_path": "cached"}')
cache.write_text('{"cc_path": "cached-gcc", "cxx_path": "cached"}')
cc_mtime = compile_commands.stat().st_mtime
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
@@ -108,7 +110,31 @@ def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None:
result = toolchain.get_idedata()
mock_transform.assert_not_called()
assert result == {"cxx_path": "cached"}
assert result == {"cc_path": "cached-gcc", "cxx_path": "cached"}
def test_get_idedata_regenerates_cache_without_cc_path(setup_core: Path) -> None:
"""A cache predating cc_path is rebuilt even though it is newer.
Such a cache stays newer than the compile DB forever, so consumers that
derive the binutils paths from cc_path would keep failing on it.
"""
compile_commands, cache = _setup_build(setup_core)
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text('{"cxx_path": "cached"}')
cc_mtime = compile_commands.stat().st_mtime
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
with patch(
"esphome.espidf.idedata.idedata_from_build",
return_value={"cc_path": "gcc", "cxx_path": "g++"},
) as mock_transform:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert result["cc_path"] == "gcc"
def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -> None:
@@ -131,6 +157,33 @@ def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -
assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())}
@pytest.mark.parametrize("cached", ['"cc_path is a string"', "[]", "42"])
def test_get_idedata_regenerates_on_non_dict_cache(
setup_core: Path, cached: str
) -> None:
"""A newer cache holding valid JSON that is not an object is regenerated.
A bare string would otherwise pass the cc_path check by substring and be
handed to consumers expecting a dict.
"""
compile_commands, cache = _setup_build(setup_core)
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(cached)
cc_mtime = compile_commands.stat().st_mtime
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
with patch(
"esphome.espidf.idedata.idedata_from_build",
return_value={"cc_path": "gcc", "cxx_path": "g++"},
) as mock_transform:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert isinstance(result, dict)
def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
"""An unparseable (but newer) cache falls back to regeneration."""
compile_commands, cache = _setup_build(setup_core)