From 48c6948ae8c97707597ea6513b5f5a10611f5900 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:39:16 -0500 Subject: [PATCH 1/2] Guard empty compile commands, refuse a compile DB with no ESPHome TU, share the launcher rejection --- esphome/build_helpers/idedata.py | 43 +++++++++++++------ esphome/espidf/clang_tidy.py | 7 ++- script/determine-jobs.py | 1 - tests/script/test_determine_jobs.py | 2 +- .../unit_tests/build_helpers/test_idedata.py | 24 +++++++---- 5 files changed, 51 insertions(+), 26 deletions(-) diff --git a/esphome/build_helpers/idedata.py b/esphome/build_helpers/idedata.py index efee461a0a..04f190952b 100644 --- a/esphome/build_helpers/idedata.py +++ b/esphome/build_helpers/idedata.py @@ -161,6 +161,10 @@ def parse_entry( raw = os.path.normpath(directory / raw) return raw.replace("\\", "/") + if not tokens: + # _split_command("") is [] by design; fail like _pick_entry does + # instead of an IndexError traceback + raise ValueError(f"empty compile command for {entry.get('file')}") # A launcher-wrapped command ("ccache g++ ...") names the compiler second if launcher is not None and tokens[0] == launcher: tokens = tokens[1:] @@ -290,6 +294,19 @@ def load_or_build_idedata( return data +def reject_launcher_compiler(cxx_path: str) -> None: + """Reject a compile DB that names a launcher (ccache) as the compiler. + + Reject before the toolchain probe, which would fail opaquely on a + launcher; the unusable compile DB must never be cached or consumed. + """ + if _is_launcher(cxx_path): + raise EsphomeError( + f"compile_commands.json names the launcher {cxx_path} as the " + "compiler; the compile database is unusable" + ) + + def idedata_from_build(compile_commands: Path, launcher: str | None = None) -> dict: """Parse compile_commands.json into the idedata fields consumers expect. @@ -304,17 +321,12 @@ def idedata_from_build(compile_commands: Path, launcher: str | None = None) -> d representative = _pick_entry(entries) cxx_path, defines, rep_includes, cxx_flags = parse_entry(representative, launcher) - if _is_launcher(cxx_path): - # Reject before the toolchain probe, which would fail opaquely on - # a launcher; never cache the unusable compile DB - raise EsphomeError( - f"compile_commands.json names the launcher {cxx_path} as the " - "compiler; the compile database is unusable" - ) + reject_launcher_compiler(cxx_path) # Seed with the representative's includes so it is not parsed twice + has_esphome_tu = _is_esphome_src(representative["file"]) build_includes: dict[str, None] = dict.fromkeys( - rep_includes if _is_esphome_src(representative["file"]) else () + rep_includes if has_esphome_tu else () ) def _shape(entry: dict) -> str: @@ -331,18 +343,21 @@ def idedata_from_build(compile_commands: Path, launcher: str | None = None) -> d for entry in entries: if entry is representative or not _is_esphome_src(entry["file"]): continue + has_esphome_tu = True if (shape := _shape(entry)) in seen_shapes: continue seen_shapes.add(shape) for inc in parse_entry(entry, launcher)[2]: build_includes.setdefault(inc, None) - if not build_includes: - # No ESPHome translation unit contributed includes: idedata with an - # empty build include set breaks clang-tidy/IDE consumers silently - _LOGGER.warning( - "No ESPHome source includes found in %s; idedata will be incomplete", - compile_commands, + if not has_esphome_tu: + # _pick_entry fell back to an arbitrary C++ entry: idedata built + # from it breaks clang-tidy/IDE consumers, and a one-time warning + # would be cached into permanence. The best-effort call sites + # downgrade this to a build warning. + raise EsphomeError( + f"No ESPHome translation unit found in {compile_commands}; " + "refusing to cache unusable idedata" ) return { diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index 61ce94fc9d..9a70f1a99a 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -23,7 +23,11 @@ from dataclasses import dataclass import os from pathlib import Path -from esphome.build_helpers.idedata import get_toolchain_includes, parse_entry +from esphome.build_helpers.idedata import ( + get_toolchain_includes, + parse_entry, + reject_launcher_compiler, +) TIDY_PROJECT_NAME = "esphome_tidy" @@ -422,6 +426,7 @@ def _idedata_from_tidy_project(compile_commands: Path) -> dict: if entry is None: raise RuntimeError(f"tidy.cpp not found in {compile_commands}") cxx_path, defines, includes, cxx_flags = parse_entry(entry) + reject_launcher_compiler(cxx_path) return { "cxx_path": cxx_path, diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 3e11deeb9a..722e2370fe 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -537,7 +537,6 @@ ESP_IDF_INFRA_TRIGGER_FILES = frozenset( "esphome/build_gen/espidf.py", "esphome/framework_helpers.py", "esphome/platformio/library.py", - "esphome/platformio/extra_script.py", } ) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 297752b3ac..859e320a2d 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1123,7 +1123,7 @@ def test_should_run_esp32_platformio_with_branch() -> None: # Shared native-build modules the IDF build imports -> trigger (["esphome/build_helpers/idedata.py"], True), (["esphome/platformio/library.py"], True), - (["esphome/platformio/extra_script.py"], True), + (["esphome/framework_helpers.py"], True), # PlatformIO build gen, its toolchain, and the esp32 component are # NOT IDF-infra triggers (["esphome/platformio/toolchain.py"], False), diff --git a/tests/unit_tests/build_helpers/test_idedata.py b/tests/unit_tests/build_helpers/test_idedata.py index 2998d46ec3..4294d2e6ab 100644 --- a/tests/unit_tests/build_helpers/test_idedata.py +++ b/tests/unit_tests/build_helpers/test_idedata.py @@ -151,11 +151,16 @@ def test_is_esphome_src_handles_backslash_paths() -> None: assert not idedata._is_esphome_src(r"C:\b\src\esphome\core\app.h") -def test_idedata_from_build_empty_includes_warns( - tmp_path: Path, caplog: pytest.LogCaptureFixture -) -> None: - """A compile DB with no ESPHome TU yields no build includes; that is - never a usable idedata, so it must be diagnosable.""" +def test_parse_entry_empty_command_raises() -> None: + """A blank command fails with a named ValueError, not an IndexError.""" + entry = {"directory": "/b", "file": "/b/src/x.cpp", "command": ""} + with pytest.raises(ValueError, match="empty compile command"): + idedata.parse_entry(entry) + + +def test_idedata_from_build_empty_includes_raises(tmp_path: Path) -> None: + """A compile DB with no ESPHome TU is never usable idedata and must + not be cached (call sites downgrade the raise to a build warning).""" compile_commands = tmp_path / "compile_commands.json" compile_commands.write_text( json.dumps( @@ -168,10 +173,11 @@ def test_idedata_from_build_empty_includes_warns( ] ) ) - with patch.object(idedata, "get_toolchain_includes", return_value=[]): - data = idedata.idedata_from_build(compile_commands) - assert data["includes"]["build"] == [] - assert "idedata will be incomplete" in caplog.text + with ( + patch.object(idedata, "get_toolchain_includes", return_value=[]), + pytest.raises(EsphomeError, match="No ESPHome translation unit found"), + ): + idedata.idedata_from_build(compile_commands) def test_idedata_from_build_dedupes_identical_command_shapes( From b08026e413ee77aaaad4c83c3f87995869f8865c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:39:34 -0500 Subject: [PATCH 2/2] Trigger the IDF infra job on the extraScript shim now that it exists --- script/determine-jobs.py | 1 + tests/script/test_determine_jobs.py | 1 + 2 files changed, 2 insertions(+) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 722e2370fe..3e11deeb9a 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -537,6 +537,7 @@ ESP_IDF_INFRA_TRIGGER_FILES = frozenset( "esphome/build_gen/espidf.py", "esphome/framework_helpers.py", "esphome/platformio/library.py", + "esphome/platformio/extra_script.py", } ) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 859e320a2d..e67029f210 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1124,6 +1124,7 @@ def test_should_run_esp32_platformio_with_branch() -> None: (["esphome/build_helpers/idedata.py"], True), (["esphome/platformio/library.py"], True), (["esphome/framework_helpers.py"], True), + (["esphome/platformio/extra_script.py"], True), # PlatformIO build gen, its toolchain, and the esp32 component are # NOT IDF-infra triggers (["esphome/platformio/toolchain.py"], False),