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.
This commit is contained in:
Roland Dobai
2025-06-02 16:32:49 +02:00
parent c3729929a8
commit adb2d5deee
3 changed files with 387 additions and 0 deletions

View File

@@ -320,3 +320,12 @@ foreach(component_target ${build_component_targets})
endif() endif()
set(__idf_component_context 0) set(__idf_component_context 0)
endforeach() 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()

View File

@@ -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 <dir relative to component>) "
"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 <dir relative to component>) "
"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()

View File

@@ -3,6 +3,7 @@
import json import json
import logging import logging
import os import os
import re
import shutil import shutil
from collections.abc import Generator from collections.abc import Generator
from pathlib import Path 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) ret = idf_py('reconfigure', check=False)
assert "Failed to resolve component 'unknown' required by component 'main'" in ret.stderr 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}'
)