From adb2d5deeebeddf02aa702146d714ef1e5e50cfa Mon Sep 17 00:00:00 2001 From: Roland Dobai Date: Mon, 2 Jun 2025 16:32:49 +0200 Subject: [PATCH] feat(cmake): Produce warnings when component dependencies are not defined There are idf.py hints for helping the user to set component dependencies properly instead of building sources out-of-component or including headers from outside the component directory. These are produced with tools/idf_py_actions/hint_modules/component_requirements.py. However, idf.py hints are printed only when the build fails. If the user starts with a buildable solution then the suggestions to add component dependencies are not printed. This commit introduces cmake-level warnings for building source files from outside the component and including header files without setting up proper component dependencies. --- CMakeLists.txt | 9 + tools/cmake/component_validation.cmake | 132 +++++++++++ tools/test_build_system/test_components.py | 246 +++++++++++++++++++++ 3 files changed, 387 insertions(+) create mode 100644 tools/cmake/component_validation.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index d21d2a38ec..9b049561b2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -320,3 +320,12 @@ foreach(component_target ${build_component_targets}) endif() set(__idf_component_context 0) endforeach() + +# Run component validation checks after all components have been processed +# Only run validation for the main project, not subprojects like bootloader +idf_build_get_property(bootloader_build BOOTLOADER_BUILD) +idf_build_get_property(esp_tee_build ESP_TEE_BUILD) +if(NOT bootloader_build AND NOT esp_tee_build) + include("${CMAKE_CURRENT_LIST_DIR}/tools/cmake/component_validation.cmake") + __component_validation_run_checks() +endif() diff --git a/tools/cmake/component_validation.cmake b/tools/cmake/component_validation.cmake new file mode 100644 index 0000000000..f7d66103df --- /dev/null +++ b/tools/cmake/component_validation.cmake @@ -0,0 +1,132 @@ +# +# Component validation checks +# +# This module contains checks that validate component source files and include directories +# to ensure they belong to the correct component. These checks run after all components +# have been discovered and processed. +# + +# +# Check if a path belongs to a specific component +# +function(__component_validation_get_component_for_path var path) + # Determine the starting directory to check: use the path itself if it's a directory, + # otherwise use its containing directory + set(current_dir "${path}") + if(NOT IS_DIRECTORY "${current_dir}") + get_filename_component(current_dir "${path}" DIRECTORY) + endif() + + # Get all component targets + idf_build_get_property(component_targets __COMPONENT_TARGETS) + + # Walk up the directory tree from the deepest path towards root and return + # the first component whose COMPONENT_DIR matches exactly. This guarantees + # selecting the deepest matching component without extra heuristics. + while(NOT "${current_dir}" STREQUAL "" AND + NOT "${current_dir}" STREQUAL "/" AND + NOT "${current_dir}" MATCHES "^[A-Za-z]:/$") + foreach(component_target ${component_targets}) + __component_get_property(component_dir ${component_target} COMPONENT_DIR) + if(current_dir STREQUAL component_dir) + set(${var} ${component_target} PARENT_SCOPE) + return() + endif() + endforeach() + get_filename_component(current_dir "${current_dir}" DIRECTORY) + endwhile() + + # If no component found, return empty + set(${var} "" PARENT_SCOPE) +endfunction() + +# +# Validate that source files belong to the correct component +# +function(__component_validation_check_sources component_target) + __component_get_property(sources ${component_target} SRCS) + __component_get_property(component_name ${component_target} COMPONENT_NAME) + __component_get_property(component_dir ${component_target} COMPONENT_DIR) + + foreach(src ${sources}) + # Check if this source file belongs to another component + __component_validation_get_component_for_path(owner_component ${src}) + + if(owner_component AND NOT owner_component STREQUAL component_target) + __component_get_property(owner_name ${owner_component} COMPONENT_NAME) + message(WARNING + "Source file '${src}' belongs to component ${owner_name} but is being built by " + "component ${component_name}. It is recommended to build source files by " + "defining component dependencies for ${component_name} " + "via using idf_component_register(REQUIRES ${owner_name}) " + "or idf_component_register(PRIV_REQUIRES ${owner_name}) in the CMakeLists.txt of " + "${component_name}.") + endif() + endforeach() +endfunction() + +# +# Validate that include directories belong to the correct component +# +function(__component_validation_check_include_dirs component_target) + __component_get_property(include_dirs ${component_target} INCLUDE_DIRS) + __component_get_property(priv_include_dirs ${component_target} PRIV_INCLUDE_DIRS) + __component_get_property(component_name ${component_target} COMPONENT_NAME) + __component_get_property(component_dir ${component_target} COMPONENT_DIR) + + # Check public include directories + foreach(dir ${include_dirs}) + # Check if this include directory belongs to another component + # Normalize to absolute path relative to this component directory + get_filename_component(abs_dir ${dir} ABSOLUTE BASE_DIR ${component_dir}) + __component_validation_get_component_for_path(owner_component ${abs_dir}) + + if(owner_component AND NOT owner_component STREQUAL component_target) + __component_get_property(owner_name ${owner_component} COMPONENT_NAME) + message(WARNING + "Include directory '${abs_dir}' belongs to component ${owner_name} but is being " + "used by component ${component_name}. It is recommended to define the " + "component dependency for '${component_name}' on the component ${owner_name}, " + "i.e. 'idf_component_register(... REQUIRES ${owner_name})' in the " + "CMakeLists.txt of ${component_name}, and specify the included directory " + "as idf_component_register(... INCLUDE_DIRS ) " + "in the CMakeLists.txt of component ${owner_name}.") + endif() + endforeach() + + # Check private include directories + foreach(dir ${priv_include_dirs}) + # Check if this include directory belongs to another component + # Normalize to absolute path relative to this component directory + get_filename_component(abs_dir ${dir} ABSOLUTE BASE_DIR ${component_dir}) + __component_validation_get_component_for_path(owner_component ${abs_dir}) + + if(owner_component AND NOT owner_component STREQUAL component_target) + __component_get_property(owner_name ${owner_component} COMPONENT_NAME) + message(WARNING + "Private include directory '${abs_dir}' belongs to component ${owner_name} but " + "is being used by component ${component_name}. " + "It is recommended to define the component dependency for ${component_name} " + "on the component ${owner_name}, " + "i.e. 'idf_component_register(... PRIV_REQUIRES ${owner_name})' in the " + "CMakeLists.txt of ${component_name}, " + "and specify the included directory as " + "idf_component_register(... PRIV_INCLUDE_DIRS ) " + "in the CMakeLists.txt of component ${owner_name}.") + endif() + endforeach() +endfunction() + +# +# Run validation checks for all components +# +function(__component_validation_run_checks) + # Get all component targets + idf_build_get_property(component_targets __COMPONENT_TARGETS) + + # Run validation checks for each component + foreach(component_target ${component_targets}) + __component_validation_check_sources(${component_target}) + __component_validation_check_include_dirs(${component_target}) + endforeach() +endfunction() diff --git a/tools/test_build_system/test_components.py b/tools/test_build_system/test_components.py index 5f13f2a80a..f035e0ec64 100644 --- a/tools/test_build_system/test_components.py +++ b/tools/test_build_system/test_components.py @@ -3,6 +3,7 @@ import json import logging import os +import re import shutil from collections.abc import Generator from pathlib import Path @@ -302,3 +303,248 @@ def test_unknown_component_error(idf_py: IdfPyFunc, test_app_copy: Path) -> None ) ret = idf_py('reconfigure', check=False) assert "Failed to resolve component 'unknown' required by component 'main'" in ret.stderr + + +def test_component_with_improper_dependency(idf_py: IdfPyFunc, test_app_copy: Path) -> None: + # test for __component_validation_check_include_dirs and __component_validation_check_sources + # Checks that the following warnings are produced: + # - Include directory X belongs to component Y but is being used by component Z + # - Source file A belongs to component Y but is being built by component Z + logging.info( + 'Check for warnings when component includes source files or include directories that belong to other components' + ) + idf_py('create-component', '-C', 'components', 'my_comp') + + # Create a source file and include directory in my_comp + (test_app_copy / 'components' / 'my_comp' / 'my_comp.c').write_text('void my_func() {}') + (test_app_copy / 'components' / 'my_comp' / 'include').mkdir(exist_ok=True) + (test_app_copy / 'components' / 'my_comp' / 'include' / 'my_comp.h').write_text('#pragma once') + + # Make main component try to use files from my_comp + replace_in_file( + (test_app_copy / 'main' / 'CMakeLists.txt'), + '# placeholder_inside_idf_component_register', + '"../components/my_comp/my_comp.c"\n INCLUDE_DIRS "../components/my_comp/include"', + ) + ret = idf_py('reconfigure') + + inc_dir = (test_app_copy / 'components' / 'my_comp' / 'include').as_posix() + src_file = (test_app_copy / 'components' / 'my_comp' / 'my_comp.c').as_posix() + + # Check for new validation warnings + re_include = re.compile( + rf"Include directory\s+'{re.escape(inc_dir)}'\s+belongs to component\s+my_comp\s+but is being used by " + rf'component\s+main' + ) + re_source = re.compile( + rf"Source file\s+'{re.escape(src_file)}'\s+belongs to component\s+my_comp\s+but is being built by " + rf'component\s+main' + ) + + assert re_include.search(ret.stderr) is not None, f'Expected include directory warning not found in: {ret.stderr}' + assert re_source.search(ret.stderr) is not None, f'Expected source file warning not found in: {ret.stderr}' + + +def test_component_validation_not_run_in_subprojects(idf_py: IdfPyFunc, test_app_copy: Path) -> None: + # test that component validation doesn't run in subprojects like bootloader + logging.info('Check that component validation warnings are not shown in subprojects') + + # Create a component that would trigger validation warnings + idf_py('create-component', '-C', 'components', 'test_comp') + (test_app_copy / 'components' / 'test_comp' / 'test_comp.c').write_text('void test_func() {}') + (test_app_copy / 'components' / 'test_comp' / 'include').mkdir(exist_ok=True) + (test_app_copy / 'components' / 'test_comp' / 'include' / 'test_comp.h').write_text('#pragma once') + + # Make main component try to use files from test_comp + replace_in_file( + (test_app_copy / 'main' / 'CMakeLists.txt'), + '# placeholder_inside_idf_component_register', + '"../components/test_comp/test_comp.c"\n INCLUDE_DIRS "../components/test_comp/include"', + ) + + # Build the project - this will trigger bootloader build as well + ret = idf_py('build') + + # Check that validation warnings appear in the main build output + inc_dir = (test_app_copy / 'components' / 'test_comp' / 'include').as_posix() + src_file = (test_app_copy / 'components' / 'test_comp' / 'test_comp.c').as_posix() + + re_include = re.compile( + rf"Include directory\s+'{re.escape(inc_dir)}'\s+belongs to component\s+test_comp\s+but is being used by " + rf'component\s+main' + ) + re_source = re.compile( + rf"Source file\s+'{re.escape(src_file)}'\s+belongs to component\s+test_comp\s+but is being built by " + rf'component\s+main' + ) + + # The warnings should appear in the main build, not in bootloader build + assert re_include.search(ret.stderr) is not None, f'Expected include directory warning not found in: {ret.stderr}' + assert re_source.search(ret.stderr) is not None, f'Expected source file warning not found in: {ret.stderr}' + + # Verify that the build completed successfully despite the warnings + assert ret.returncode == 0, 'Build should complete successfully with validation warnings' + + +def test_component_validation_private_include_dirs(idf_py: IdfPyFunc, test_app_copy: Path) -> None: + # test that component validation works for private include directories + logging.info('Check that component validation warnings are shown for private include directories') + + # Create a component with private include directory + idf_py('create-component', '-C', 'components', 'private_comp') + (test_app_copy / 'components' / 'private_comp' / 'private').mkdir(exist_ok=True) + (test_app_copy / 'components' / 'private_comp' / 'private' / 'private.h').write_text('#pragma once') + + # Make main component try to use private include directory from private_comp + replace_in_file( + (test_app_copy / 'main' / 'CMakeLists.txt'), + '# placeholder_inside_idf_component_register', + 'PRIV_INCLUDE_DIRS "../components/private_comp/private"', + ) + + ret = idf_py('reconfigure') + + # Check for private include directory warning + priv_inc_dir = (test_app_copy / 'components' / 'private_comp' / 'private').as_posix() + re_priv_include = re.compile( + rf"Private include directory\s+'{re.escape(priv_inc_dir)}'\s+belongs to " + rf'component\s+private_comp\s+but is being used by component\s+main' + ) + + assert re_priv_include.search(ret.stderr) is not None, ( + f'Expected private include directory warning not found in: {ret.stderr}' + ) + + +def test_component_validation_finds_right_component(idf_py: IdfPyFunc, test_app_copy: Path) -> None: + # test that __component_validation_get_component_for_path finds the correct component for a given path + # + # components/my_comp/test_apps/components/my_subcomp/src/test.c + # The component for test.c should be my_subcomp, not my_comp + + idf_py('create-component', '-C', 'components', 'my_comp') + + nested_components_dir = test_app_copy / 'components' / 'my_comp' / 'test_apps' / 'components' + my_subcomp_dir = nested_components_dir / 'my_subcomp' + (my_subcomp_dir / 'src').mkdir(parents=True) + (my_subcomp_dir / 'include').mkdir(parents=True) + + # Files of the nested component + (my_subcomp_dir / 'src' / 'test.c').write_text('void test_func() {}') + (my_subcomp_dir / 'include' / 'test.h').write_text('#pragma once') + (my_subcomp_dir / 'CMakeLists.txt').write_text('idf_component_register(SRCS "src/test.c" INCLUDE_DIRS "include")') + + # Make sure build system discovers the nested component by adding its parent directory to EXTRA_COMPONENT_DIRS + replace_in_file( + test_app_copy / 'CMakeLists.txt', + '# placeholder_before_include_project_cmake', + f'set(EXTRA_COMPONENT_DIRS {nested_components_dir.as_posix()})', + ) + + # Make main component try to use files from my_subcomp via absolute-like relative paths + replace_in_file( + test_app_copy / 'main' / 'CMakeLists.txt', + '# placeholder_inside_idf_component_register', + '"../components/my_comp/test_apps/components/my_subcomp/src/test.c"\n' + ' INCLUDE_DIRS "../components/my_comp/test_apps/components/my_subcomp/include"', + ) + + ret = idf_py('reconfigure') + + inc_dir = (my_subcomp_dir / 'include').as_posix() + src_file = (my_subcomp_dir / 'src' / 'test.c').as_posix() + + # The warnings must attribute ownership to my_subcomp (deepest component), not my_comp + re_include = re.compile( + rf"Include directory\s+'{re.escape(inc_dir)}'\s+belongs to component\s+my_subcomp\s+but is being used by " + rf'component\s+main' + ) + re_source = re.compile( + rf"Source file\s+'{re.escape(src_file)}'\s+belongs to component\s+my_subcomp\s+but is being built by " + rf'component\s+main' + ) + + assert re_include.search(ret.stderr) is not None, f'Expected include directory warning not found in: {ret.stderr}' + assert re_source.search(ret.stderr) is not None, f'Expected source file warning not found in: {ret.stderr}' + + # components/my_comp/test_apps/components/my_subcomp/include/test.h + # The component for test.h should be my_subcomp, not my_comp + # Modify main to also list the header as a source to exercise file-level ownership + replace_in_file( + test_app_copy / 'main' / 'CMakeLists.txt', + '"../components/my_comp/test_apps/components/my_subcomp/src/test.c"\n' + ' INCLUDE_DIRS "../components/my_comp/test_apps/components/my_subcomp/include"', + '"../components/my_comp/test_apps/components/my_subcomp/src/test.c" ' + '"../components/my_comp/test_apps/components/my_subcomp/include/test.h"\n' + ' INCLUDE_DIRS "../components/my_comp/test_apps/components/my_subcomp/include"', + ) + + ret = idf_py('reconfigure') + + header_path = (my_subcomp_dir / 'include' / 'test.h').as_posix() + re_header = re.compile( + rf"Source file\s+'{re.escape(header_path)}'\s+belongs to component\s+my_subcomp\s+but is being built by " + rf'component\s+main' + ) + assert re_header.search(ret.stderr) is not None, ( + f'Expected header file ownership warning not found in: {ret.stderr}' + ) + + +def test_component_validation_with_common_platform_example(idf_py: IdfPyFunc, test_app_copy: Path) -> None: + # Test the following structure which should not produce a warning:: + # + # my_project/ + # ├── common/ # Common product code for all platforms (not a component) + # │ ├── include/ + # │ │ ├── product_config.h + # │ │ └── business_logic.h + # │ └── src/ + # │ └── business_logic.c + # └── env/ + # ├── esp-idf/ + # │ ├── main/ # main component + # │ │ ├── idf_main.c # includes product_config.h and business_logic.h + # │ │ └── CMakeLists.txt # adds ../../../common/include to include dirs + # │ └── CMakeLists.txt + # └── other_rtos/ + # + # + # Implementation: create a sibling 'common' directory outside the IDF project and + # make the main component include headers and a source file from it. This should + # NOT produce component ownership warnings because the paths don't belong to any component. + + # Create common directory with headers and source outside the project root + common_dir = (test_app_copy / '..' / 'common').resolve() + (common_dir / 'include').mkdir(parents=True, exist_ok=True) + (common_dir / 'src').mkdir(parents=True, exist_ok=True) + (common_dir / 'include' / 'product_config.h').write_text('#pragma once\n') + (common_dir / 'include' / 'business_logic.h').write_text('#pragma once\n') + (common_dir / 'src' / 'business_logic.c').write_text('void bl(void) {}\n') + + # From main component dir to common dir is ../../common + replace_in_file( + test_app_copy / 'main' / 'CMakeLists.txt', + '# placeholder_inside_idf_component_register', + '"../../common/src/business_logic.c"\n INCLUDE_DIRS "../../common/include"', + ) + + # Optionally create a main source that includes the headers (not required for validation) + (test_app_copy / 'main' / 'idf_main.c').write_text( + '#include "product_config.h"\n#include "business_logic.h"\nvoid app_main(void) {}\n' + ) + + ret = idf_py('reconfigure') + + inc_dir_abs = (common_dir / 'include').as_posix() + src_file_abs = (common_dir / 'src' / 'business_logic.c').as_posix() + + re_include = re.compile(rf"Include directory\s+'{re.escape(inc_dir_abs)}'\s+belongs to component") + re_source = re.compile(rf"Source file\s+'{re.escape(src_file_abs)}'\s+belongs to component") + + assert re_include.search(ret.stderr) is None, ( + f'Unexpected include directory ownership warning for common path: {ret.stderr}' + ) + assert re_source.search(ret.stderr) is None, ( + f'Unexpected source file ownership warning for common path: {ret.stderr}' + )