diff --git a/backend/app/services/espidf_compiler.py b/backend/app/services/espidf_compiler.py index 8328e5b3..deaf8225 100644 --- a/backend/app/services/espidf_compiler.py +++ b/backend/app/services/espidf_compiler.py @@ -1520,6 +1520,27 @@ class ESPIDFCompiler: 'WCharacter.h', 'binary.h', 'pins_arduino.h', 'Esp.h', }) + # Config headers the PROJECT is meant to supply, never a library. + # + # LVGL's sources do `#include "lv_conf.h"` behind `#if !LV_CONF_SKIP`. + # The include scan is textual and does not evaluate that guard, so the + # header is queued even for a build that defines LV_CONF_SKIP=1 and will + # never read it. Resolving it against the shared library dir then finds + # whichever installed library vendors its own copy -- Adafruit LvGL Glue + # ships one written for LVGL v7 -- and merges that ENTIRE library. Its + # sources fail against the LVGL v8 that the sketch actually uses + # ("lv_fs_drv_t has no member named 'user_data'", "'file_t' was not + # declared"), so a sketch whose only LVGL include is dies with a + # wall of errors naming a library the user never asked for. Same failure + # shape as the Hash.h -> stemihexapod case above. Reported 2026-08-28. + # + # A project that DOES ship its own lv_conf.h is unaffected: sketch files + # are copied into the build's own source dir and found there, not through + # this library scan. + _PROJECT_SUPPLIED_HEADERS: frozenset[str] = frozenset({ + 'lv_conf.h', 'lv_drv_conf.h', + }) + # Basenames of C/C++ standard headers. A user library that ships a private # header with one of these names (e.g. LovyanGFX's src/lgfx/internal/ # limits.h, algorithm.h, memory.h, alloca.h) must NOT put that directory on @@ -1858,6 +1879,16 @@ class ESPIDFCompiler: ) continue + # Project-supplied config headers (lv_conf.h ...). See + # _PROJECT_SUPPLIED_HEADERS: resolving these globally drags in + # whatever unrelated library happens to vendor a copy. + if header in self._PROJECT_SUPPLIED_HEADERS: + logger.info( + f'[espidf] <{header}> is a project-supplied config header ' + f'— never resolving against user libraries' + ) + continue + # P2 manifest scope. When a manifest is supplied, resolve the header # to the DECLARED library that provides it — not the first- # alphabetical lib in the shared dir. Several installed libs may diff --git a/test/backend/unit/test_espidf_library_ranking.py b/test/backend/unit/test_espidf_library_ranking.py index d96be989..390a3f49 100644 --- a/test/backend/unit/test_espidf_library_ranking.py +++ b/test/backend/unit/test_espidf_library_ranking.py @@ -144,5 +144,72 @@ class TestMergedLibraryReport(unittest.TestCase): self.assertEqual(hdr2comp.get("A.h"), "user_libs_all") +class TestProjectSuppliedHeaders(unittest.TestCase): + """A config header the PROJECT owns must never drag in a library. + + The live failure (2026-08-28, an LVGL clock on a XIAO ESP32-S3): the + sketch included only , but LVGL's sources carry + `#include "lv_conf.h"` behind `#if !LV_CONF_SKIP`. The include scan is + textual, so lv_conf.h got queued even though the build defined + LV_CONF_SKIP=1, resolved to Adafruit LvGL Glue (which vendors its own + copy for LVGL v7), and merged that whole library. Its sources then failed + against LVGL v8 and the build died naming a library the user never asked + for. + """ + + def setUp(self) -> None: + self.tmp = Path(tempfile.mkdtemp()) + self.libs = self.tmp / "libraries" + self.out = self.tmp / "project" / "user_libs" + self.out.mkdir(parents=True) + self.c = ESPIDFCompiler() + self.c.arduino_path = "" + self.c._core_headers_cache = None + + def tearDown(self) -> None: + shutil.rmtree(self.tmp, ignore_errors=True) + + def _resolve(self, headers, merged): + return self.c._resolve_library_components( + headers, arduino_libs=self.libs, esp32_libs=None, + arduino_comp_name="arduino-esp32", user_libs_dir=self.out, + merged_libs=merged, + ) + + def _make_lvgl_world(self) -> None: + # LVGL itself: lvgl.h pulls lv_conf.h the way the real sources do. + _mk(self.libs / "lvgl@8.3.11-0d103ab1" / "src" / "lvgl.h", + '#include "lv_conf.h"\n') + _mk(self.libs / "lvgl@8.3.11-0d103ab1" / "library.properties", + "name=lvgl\narchitectures=*\n") + # The unrelated library that happens to vendor an lv_conf.h. + _mk(self.libs / "adafruitlittlevglgluelibrary@2.1.7-896f753c" / "src" / "lv_conf.h") + _mk(self.libs / "adafruitlittlevglgluelibrary@2.1.7-896f753c" / "src" + / "Adafruit_LvGL_Glue.h") + _mk(self.libs / "adafruitlittlevglgluelibrary@2.1.7-896f753c" / "library.properties", + "name=Adafruit LvGL Glue Library\narchitectures=*\n") + + def test_lv_conf_does_not_drag_in_the_glue_library(self): + self._make_lvgl_world() + merged: dict[str, str] = {} + self._resolve(["lvgl.h"], merged) + self.assertEqual(merged.get("lvgl.h"), "lvgl") + self.assertNotIn("lv_conf.h", merged) + self.assertNotIn( + "Adafruit LvGL Glue Library", merged.values(), + "a sketch that only includes must not merge the glue library", + ) + + def test_a_library_that_owns_lv_conf_is_still_reachable_by_its_own_header(self): + # The guard is scoped to the config header, not to the library: a + # project that really wants the glue can still include its header. + self._make_lvgl_world() + merged: dict[str, str] = {} + self._resolve(["Adafruit_LvGL_Glue.h"], merged) + self.assertEqual( + merged.get("Adafruit_LvGL_Glue.h"), "Adafruit LvGL Glue Library" + ) + + if __name__ == "__main__": unittest.main()