From a92c59ee5045cd72729c2e0c570aae3581dc44cf Mon Sep 17 00:00:00 2001 From: David Montero Date: Sat, 29 Aug 2026 00:08:32 +0200 Subject: [PATCH] fix(espidf): no build-config header may drag in a library Generalises the lv_conf.h fix, because it was never an LVGL problem. One live compile resolved SIX config headers against the shared library dir in a single build (sdkconfig.h, lv_conf.h, lv_conf_kconfig.h, lv_rt_thread_conf.h, User_Config.h, zconf.h): six chances to merge a library nobody asked for, and one of them did exactly that. The rule is now about the shape of the name (*conf.h, *config.h, *_setup.h, plus the known ones) and applies only to headers reached TRANSITIVELY -- a config header the sketch includes itself is intent and still resolves. Why skipping the lookup is safe rather than merely convenient: once a library is merged its own headers are on the include path, so a config header shipped BY the library that asks for it resolves at compile time with no lookup at all. The lookup can only ever pull in a DIFFERENT library, which for a config header is never what the project meant. A genuinely missing header now fails where it should -- named, in the compiler output -- instead of inside the sources of a library the sketch never mentioned. Tests cover all six shapes, the direct-include exception and an ordinary transitive dependency (Adafruit GFX -> BusIO) that must keep merging. --- backend/app/services/espidf_compiler.py | 47 +++++++++++++++--- .../unit/test_espidf_library_ranking.py | 48 +++++++++++++++++++ 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/backend/app/services/espidf_compiler.py b/backend/app/services/espidf_compiler.py index deaf8225..1358a1ed 100644 --- a/backend/app/services/espidf_compiler.py +++ b/backend/app/services/espidf_compiler.py @@ -1538,9 +1538,36 @@ class ESPIDFCompiler: # 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', + 'lv_conf.h', 'lv_drv_conf.h', 'user_config.h', 'user_setup.h', + 'sdkconfig.h', 'zconf.h', }) + # ...and the general shape of one. Config headers are named by convention + # across the whole Arduino ecosystem, and one live build showed SIX of + # them being resolved against the shared library dir in a single compile + # (sdkconfig.h, lv_conf.h, lv_conf_kconfig.h, lv_rt_thread_conf.h, + # User_Config.h, zconf.h) — six chances to merge a library nobody asked + # for. + _CONFIG_HEADER_SUFFIXES: tuple[str, ...] = ('conf.h', 'config.h', '_setup.h') + + @classmethod + def _is_project_config_header(cls, header: str) -> bool: + """Is this a build-configuration header rather than a library API? + + Why skipping the library scan for these is SAFE rather than merely + convenient: once a library is merged, its own headers are on the + include path, so a config header shipped BY the library that asks for + it resolves at compile time without any lookup. The lookup can + therefore only ever pull in a DIFFERENT library — which for a config + header is never what the project meant. If the header really is + missing the compiler says so, naming it, instead of dying inside the + sources of a library the sketch never mentioned. + """ + name = Path(header).name.lower() + return name in cls._PROJECT_SUPPLIED_HEADERS or name.endswith( + cls._CONFIG_HEADER_SUFFIXES + ) + # 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 @@ -1849,6 +1876,10 @@ class ESPIDFCompiler: used_prefixes: set[str] = set() headers_to_resolve: list[str] = list(ext_headers) + # Headers the SKETCH itself included. The config-header guard below + # applies only to transitively-discovered ones: a direct include is an + # explicit statement of intent and stays resolvable. + direct_headers: set[str] = set(ext_headers) # Headers the SKETCH itself includes (vs transitive pulls found by # re-scanning copied library headers). The architecture guard is # advisory for these: the user explicitly asked for the library, and @@ -1879,13 +1910,15 @@ 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: + # Build-configuration headers pulled in TRANSITIVELY. See + # _is_project_config_header: resolving these against the shared + # library dir drags in whatever unrelated library happens to + # vendor a copy, and that library's sources then fail on their own + # API in a build that never asked for them. + if header not in direct_headers and self._is_project_config_header(header): logger.info( - f'[espidf] <{header}> is a project-supplied config header ' - f'— never resolving against user libraries' + f'[espidf] <{header}> is a build-config header pulled in ' + f'transitively — not resolving it against user libraries' ) continue diff --git a/test/backend/unit/test_espidf_library_ranking.py b/test/backend/unit/test_espidf_library_ranking.py index 390a3f49..67c2fc7e 100644 --- a/test/backend/unit/test_espidf_library_ranking.py +++ b/test/backend/unit/test_espidf_library_ranking.py @@ -200,6 +200,54 @@ class TestProjectSuppliedHeaders(unittest.TestCase): "a sketch that only includes must not merge the glue library", ) + def test_the_guard_is_generic_not_an_lvgl_special_case(self): + """Every config-header shape, not a hardcoded list of two names. + + One live compile resolved six of these against the shared library dir + in a single build, so the rule has to be about the shape of the name. + """ + for cfg in ( + "lv_conf.h", "lv_conf_kconfig.h", "lv_rt_thread_conf.h", + "User_Config.h", "sdkconfig.h", "zconf.h", "User_Setup.h", + ): + with self.subTest(header=cfg): + shutil.rmtree(self.libs, ignore_errors=True) + _mk(self.libs / "toplib" / "src" / "Top.h", f'#include "{cfg}"\n') + _mk(self.libs / "toplib" / "library.properties", "name=TopLib\n") + # An unrelated library that happens to vendor that config header. + _mk(self.libs / "zzz-stray" / "src" / cfg) + _mk(self.libs / "zzz-stray" / "library.properties", "name=Stray\n") + merged: dict[str, str] = {} + self._resolve(["Top.h"], merged) + self.assertEqual(merged.get("Top.h"), "TopLib") + self.assertNotIn( + "Stray", merged.values(), + f"<{cfg}> must not drag in an unrelated library", + ) + + def test_a_direct_sketch_include_is_still_resolved(self): + """The guard is for transitive pulls only. + + A sketch that includes a config header itself is stating intent, and + that path keeps working exactly as before. + """ + _mk(self.libs / "somelib" / "src" / "my_config.h") + _mk(self.libs / "somelib" / "library.properties", "name=SomeLib\n") + merged: dict[str, str] = {} + self._resolve(["my_config.h"], merged) + self.assertEqual(merged.get("my_config.h"), "SomeLib") + + def test_ordinary_transitive_dependencies_still_merge(self): + """The guard must not touch normal library-to-library deps.""" + _mk(self.libs / "adafruitgfx" / "src" / "Adafruit_GFX.h", + '#include \n') + _mk(self.libs / "adafruitgfx" / "library.properties", "name=Adafruit GFX Library\n") + _mk(self.libs / "adafruitbusio" / "src" / "Adafruit_I2CDevice.h") + _mk(self.libs / "adafruitbusio" / "library.properties", "name=Adafruit BusIO\n") + merged: dict[str, str] = {} + self._resolve(["Adafruit_GFX.h"], merged) + self.assertEqual(merged.get("Adafruit_I2CDevice.h"), "Adafruit BusIO") + 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.