| #!/usr/bin/env python3 | |
| # Copyright 2023 The ChromiumOS Authors | |
| # Use of this source code is governed by a BSD-style license that can be | |
| # found in the LICENSE file. | |
| """Ensure source files are not missed and included in a gn file. | |
| Occasionally, a source file, especially a unit test file, is added to the repo | |
| but is not included in the build. This kind of mistake can often evade code | |
| review, or is discovered only later in the review stage and wasted time for | |
| early discovery of errors. This script examines each source file and checks | |
| whether they are present in a *.gn file in their project directory. | |
| Additionally, when a build file is changed, occasionally some source files | |
| (especially test files) are left unbuilt. This script also checks for source | |
| files that are not included anywhere when a build file is modified. | |
| """ | |
| import argparse | |
| import os | |
| from pathlib import Path | |
| from pathlib import PurePath | |
| import sys | |
| from typing import FrozenSet, Iterable, List, Optional, Tuple, Union | |
| _HACK_VAR_TO_DISABLE_ISORT = "hack" | |
| # pylint: disable=wrong-import-position | |
| import chromite_init # pylint: disable=unused-import | |
| from chromite.lib import cros_build_lib | |
| from chromite.lib import git | |
| from chromite.lint.linters import gnlint | |
| TOP_DIR = Path(__file__).resolve().parent.parent | |
| SOURCE_FILE_SUFFICES = (".c", ".cc", ".cpp", ".cxx") | |
| # Not all platform2 sources are intended to appear in a cros build (e.g. | |
| # examples and vm-specific code), so exclude those. | |
| EXCLUDED_DIRS = ( | |
| "common-mk/example/", | |
| "vm_tools/cros_im", | |
| "vm_tools/reference_vm/data/", | |
| "vm_tools/sommelier/quirks/", | |
| ) | |
| class ProjectLiterals: | |
| """Manages project literals.""" | |
| def __init__(self, commit: str): | |
| # A map that saves project literals. | |
| self._literals: map = {} | |
| self._commit: str = commit | |
| def GetLiterals(self, project: str) -> FrozenSet[PurePath]: | |
| """Get the literal source files from a project. | |
| If the literals have not gathered yet, gather them. | |
| Args: | |
| project: The project to get literals from. | |
| Returns: | |
| Literals from the specified project. | |
| """ | |
| if project not in self._literals: | |
| self._literals[project] = self._GatherLiteralsFromProject(project) | |
| return self._literals[project] | |
| def _GatherLiteralsFromProject(self, project: str) -> FrozenSet[PurePath]: | |
| """Gather the literal source files from a project. | |
| Args: | |
| project: The project to gather source file literals from. | |
| Returns: | |
| A set of all source file literals from a project. | |
| """ | |
| def _gather() -> Iterable[Tuple[PurePath, PurePath]]: | |
| """Generate the gn file path and its source file literals.""" | |
| for gn_file in self._FindGnFiles(project): | |
| yield from ( | |
| (gn_file, literal) | |
| for literal in ProjectLiterals._GatherLiteralsFromGn( | |
| self._ReadFileAtCommit(project, gn_file) | |
| ) | |
| ) | |
| def _resolve(gn_file: PurePath, literal: PurePath) -> PurePath: | |
| """Resolve a source file literal relative to the top dir.""" | |
| return ( | |
| ( | |
| TOP_DIR | |
| / project | |
| / PurePath(gn_file).parent | |
| / gnlint.GetNodeValue(literal) | |
| ) | |
| .resolve() | |
| .relative_to(TOP_DIR) | |
| ) | |
| return frozenset( | |
| _resolve(gn_file, literal) for gn_file, literal in _gather() | |
| ) | |
| def _ReadFileAtCommit(self, project: str, file_path: os.PathLike) -> str: | |
| """Read a file at a commit. | |
| Args: | |
| project: The project to read the file from. | |
| file_path: The path to the file to read. Path is relative to the | |
| project root directory. | |
| Returns: | |
| The content of the file. | |
| """ | |
| return git.GetObjectAtRev( | |
| TOP_DIR / project, project / file_path, self._commit | |
| ) | |
| def _FindFilesEndingWith( | |
| self, project: str, suffix: Union[str, Tuple[str]] | |
| ) -> Iterable[PurePath]: | |
| """Find all gn files in a project. | |
| Args: | |
| project: The project to find files in. | |
| suffix: The suffix that files end with. Can also be a tuple of | |
| suffices. | |
| Returns: | |
| An iterable of all files ending with the given suffix. | |
| """ | |
| yield from ( | |
| f.name | |
| for f in git.LsTree(TOP_DIR / project, self._commit) | |
| if f.is_file and f.name.name.endswith(suffix) | |
| ) | |
| def _FindGnFiles(self, project: str) -> Iterable[PurePath]: | |
| """Find all gn files in a project. | |
| Args: | |
| project: The project to find gn files in. | |
| Returns: | |
| An iterable of all gn files. | |
| """ | |
| yield from self._FindFilesEndingWith(project, ".gn") | |
| def FindSourceFiles(self, project: str) -> Iterable[PurePath]: | |
| """Find all source files in a project. | |
| Args: | |
| project: The project to find source files in. | |
| Returns: | |
| An iterable of all source files. | |
| """ | |
| yield from self._FindFilesEndingWith(project, SOURCE_FILE_SUFFICES) | |
| @staticmethod | |
| def _GatherLiteralsFromGn(gn_data: str) -> List[dict]: | |
| """Gather all source file literals from a gn file. | |
| Args: | |
| gn_data: The content of a gn file to gather literals from. | |
| Returns: | |
| A list of literal assignments. | |
| """ | |
| try: | |
| ast = gnlint.ParseAst(gn_data) | |
| except cros_build_lib.RunCommandError as e: | |
| cros_build_lib.Die("Failed to run gn format: %s", e) | |
| except Exception as e: | |
| cros_build_lib.Die("Invalid format: %s", e) | |
| return gnlint.FindAllLiteralAssignments( | |
| ast, ["sources"], operators=["=", "+="] | |
| ) | |
| def CheckSourceFileIncludedInBuild( | |
| commit: str, file_paths: Iterable[Union[str, os.PathLike]] | |
| ) -> bool: | |
| """Check that source files are included in builds. | |
| Args: | |
| commit: The commit to check in. | |
| file_paths: Files modified in this commit. Non-source files in this list | |
| would be ignored. | |
| Returns: | |
| True if source files are included in a *.gn file in the project | |
| directory. False otherwise. | |
| """ | |
| ret = True | |
| project_literals = ProjectLiterals(commit) | |
| for path in file_paths: | |
| path = PurePath(path) | |
| if not path.name.endswith(SOURCE_FILE_SUFFICES): | |
| # Header files are not checked here because they do not necessarily | |
| # need to be present in a build file. | |
| continue | |
| if str(path).startswith(EXCLUDED_DIRS): | |
| # List of files that don't need to be checked (e.g. examples). | |
| continue | |
| project = path.parts[0] | |
| if not (Path(project) / "BUILD.gn").exists(): | |
| # This project does not use gn. | |
| # We are trying to be conservative here, as we don't want to check | |
| # projects that only uses gn in a subdirectory. | |
| continue | |
| if path not in project_literals.GetLiterals(project): | |
| print( | |
| f"{__file__}: {path} is not included in any " | |
| f"*.gn files in {project}. " | |
| "If you have added the file via an intermediate variable, " | |
| "please ensure the source is set via source_set().", | |
| file=sys.stderr, | |
| ) | |
| ret = False | |
| return ret | |
| def CheckBuildFileIncludingAllSourceFiles( | |
| commit: str, file_paths: Iterable[str] | |
| ) -> bool: | |
| """Check that BUILD.gn files including all source files. | |
| Args: | |
| commit: The commit to check in. | |
| file_paths: Files modified in this commit. Non-build files in this list | |
| would be ignored. | |
| """ | |
| ret = True | |
| project_literals = ProjectLiterals(commit) | |
| # Calling CheckSourceFileIncludedInBuild with all source files in that | |
| # project as parameter to examine if all source files are included. | |
| for path in file_paths: | |
| if not path.endswith(".gn"): | |
| continue | |
| project = PurePath(path).parts[0] | |
| if not CheckSourceFileIncludedInBuild( | |
| commit, | |
| (project / f for f in project_literals.FindSourceFiles(project)), | |
| ): | |
| ret = False | |
| return ret | |
| def get_parser(): | |
| """Return an argument parser.""" | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("--commit", help="Hash of commit to check in.") | |
| parser.add_argument("files", nargs="*", help="Files to check.") | |
| return parser | |
| def main(argv: Optional[List[str]] = None) -> Optional[int]: | |
| parser = get_parser() | |
| opts = parser.parse_args(argv) | |
| return ( | |
| 0 | |
| if ( | |
| CheckSourceFileIncludedInBuild(opts.commit, opts.files) | |
| and CheckBuildFileIncludingAllSourceFiles(opts.commit, opts.files) | |
| ) | |
| else 1 | |
| ) | |
| if __name__ == "__main__": | |
| sys.exit(main(sys.argv[1:])) |