Coverage for tools / sel_tools / code_evaluation / jobs / cpp.py: 95%
133 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-02 18:55 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-02 18:55 +0000
1"""Cpp code evaluation jobs."""
3import re
4from pathlib import Path
6import git
8from sel_tools.code_evaluation.jobs.common import (
9 EvaluationJob,
10 run_shell_command,
11 run_shell_command_with_output,
12)
13from sel_tools.config import REPO_DIR
14from sel_tools.file_export.copy_item import copy_item
15from sel_tools.utils.files import CMAKELISTS_FILE_NAME, FileTree, FileVisitor
17CMAKE_MODULE_PATH = REPO_DIR / "cmake"
18HW_BUILD_FOLDER = "hw_build"
21class CMakeBuildJob(EvaluationJob):
22 """Job for compiling the project."""
24 name = "CMake Build"
26 def __init__(self, weight: int = 1, cmake_options: str = "") -> None:
27 super().__init__(weight)
28 self.__cmake_options = cmake_options
30 def _run(self, repo_path: Path) -> int:
31 build_folder = repo_path / HW_BUILD_FOLDER
32 build_folder.mkdir(parents=True, exist_ok=True)
33 if run_shell_command(f"cmake {self.__cmake_options} ..", build_folder) == 0:
34 self._comment = f"CMake step failed with option {self.__cmake_options}: Make sure cmake .. passes."
35 return 0
36 if run_shell_command("make", build_folder) == 0:
37 self._comment = "Make step failed: Make sure you build passes when calling make."
38 return 0
39 return 1
42class MakeTestJob(EvaluationJob):
43 """Job for running make test."""
45 name = "Make Test"
47 def __init__(self, weight: int = 1, cmake_options: str = "") -> None:
48 super().__init__(weight)
49 self._cmake_options = cmake_options
51 @property
52 def dependencies(self) -> list[EvaluationJob]:
53 return [CMakeBuildJob(cmake_options=self._cmake_options)]
55 def _run(self, repo_path: Path) -> int:
56 build_folder = repo_path / HW_BUILD_FOLDER
57 score, output = run_shell_command_with_output("make test", build_folder)
58 if score != 0 and not output:
59 self._comment = "No tests registered: Make sure you have tests registered in CMakeLists.txt."
60 return 0
61 if score != 0 and "No tests were found" in output:
62 self._comment = "No tests were found: Make sure you have tests registered in CMakeLists.txt."
63 return 0
64 return score
67class ClangFormatTestJob(EvaluationJob):
68 """Job for checking the code format."""
70 name = "Clang Format Check"
72 def _run(self, repo_path: Path) -> int:
73 score = run_shell_command(
74 rf"find . -type f -regex '.*\.\(cpp\|hpp\|cu\|c\|cc\|h\)' -not -path '*/{HW_BUILD_FOLDER}/*' "
75 "| xargs clang-format --style=file -i --dry-run --Werror",
76 repo_path,
77 )
78 if score == 0:
79 self._comment = "Clang format check failed: Make sure you format your code according to the style guide."
80 return score
81 return 1
84class CodeCoverageTestJob(EvaluationJob):
85 """Job for checking the code coverage."""
87 name = "Code Coverage"
89 @property
90 def dependencies(self) -> list[EvaluationJob]:
91 return [MakeTestJob(cmake_options="-DCMAKE_BUILD_TYPE=Debug")]
93 def __init__(self, weight: int = 1, min_coverage: int = 75) -> None:
94 super().__init__(weight)
95 self.__min_coverage = min_coverage
97 @staticmethod
98 def parse_total_coverage(coverage_file: Path) -> int:
99 coverage_file_pattern = r"TOTAL.*\s(\d*)%"
100 text = coverage_file.read_text()
101 coverage = re.search(coverage_file_pattern, text, re.DOTALL)
102 return int(coverage.group(1)) if coverage else 0
104 def _run(self, repo_path: Path) -> int:
105 build_folder = repo_path / HW_BUILD_FOLDER
106 coverage_file = build_folder.resolve() / "report.txt"
107 gcovr_cmd = f"gcovr --root {repo_path.resolve()} --exclude _deps -o {coverage_file}"
108 if (score := run_shell_command(gcovr_cmd, build_folder)) == 0:
109 self._comment = "Coverage failed report generation failed."
110 return score
111 coverage = self.parse_total_coverage(coverage_file)
112 self._comment = f"Code coverage: {coverage}%. We require at least {self.__min_coverage}%."
113 return int(coverage > self.__min_coverage)
116class ClangTidyTestJob(EvaluationJob):
117 """Job for checking with clang tidy."""
119 name = "Clang Tidy Check"
121 def _run(self, repo_path: Path) -> int:
122 hw_cmake_module_path = repo_path / "hw_cmake"
123 hw_cmake_module_path.mkdir(parents=True, exist_ok=True)
124 copy_item(CMAKE_MODULE_PATH / "ClangTidy.cmake", hw_cmake_module_path / "ClangTidy.cmake")
125 cmake_lists = repo_path / CMAKELISTS_FILE_NAME
127 if not cmake_lists.exists():
128 self._comment = (
129 f"{CMAKELISTS_FILE_NAME} not found: Make sure you project has a {CMAKELISTS_FILE_NAME} file."
130 )
131 return 0
133 content = cmake_lists.read_text()
134 content += "\n"
135 content += f"list(APPEND CMAKE_MODULE_PATH ${{PROJECT_SOURCE_DIR}}/{hw_cmake_module_path.stem})\n"
136 content += "include(ClangTidy)\n"
137 cmake_lists.write_text(content)
138 score = CMakeBuildJob().run(repo_path)[-1].score
139 git.Repo(repo_path).git.restore(".") # Undo all changes
140 return score
143class CleanRepoJob(EvaluationJob):
144 """Job for checking if build files were committed."""
146 name = "Clean Repo Check"
148 class CleanRepoVisitor(FileVisitor):
149 """Check if build files are committed."""
151 def __init__(self) -> None:
152 self.__is_clean: bool = True
153 self.__build_folder = f"/{HW_BUILD_FOLDER}/"
154 self.__dirty_suffixes = [".make", ".includecache"]
155 self.__dirty_file_names = ["CMakeCache.txt", "cmake_install.cmake"]
156 self.__dirty_directories = ["/CMakeFiles/", ".dir/"]
158 @property
159 def is_clean(self) -> bool:
160 return self.__is_clean
162 def visit_file(self, file: Path) -> None:
163 if self.__build_folder in str(file):
164 return
165 self.__is_clean = self.__is_clean and (
166 file.name not in self.__dirty_file_names
167 and file.suffix not in self.__dirty_suffixes
168 and all(directory not in str(file) for directory in self.__dirty_directories)
169 )
171 class SourceFilesCountVisitor(FileVisitor):
172 """Count the number of source files."""
174 def __init__(self, max_source_file_count: int) -> None:
175 self.__max_source_file_count = max_source_file_count
176 self.__source_file_count = 0
177 self.__build_folder = f"/{HW_BUILD_FOLDER}/"
179 @property
180 def is_below_max_source_file_count(self) -> bool:
181 return self.__source_file_count < self.__max_source_file_count
183 def visit_file(self, file: Path) -> None:
184 if self.__build_folder in str(file):
185 return
186 self.__source_file_count += 1
188 def _run(self, repo_path: Path) -> int:
189 clean_repo_visitor = CleanRepoJob.CleanRepoVisitor()
190 source_file_count_visitor = CleanRepoJob.SourceFilesCountVisitor(100)
191 file_tree = FileTree(repo_path)
192 file_tree.accept(clean_repo_visitor)
193 file_tree.accept(source_file_count_visitor)
194 if not clean_repo_visitor.is_clean:
195 self._comment = (
196 "We found build files committed to the repository. "
197 "Make sure that any files produced by the build system are not committed. "
198 "If you already have, make sure you remove them."
199 )
200 return 0
201 if not source_file_count_visitor.is_below_max_source_file_count:
202 self._comment = (
203 "We found too many third party source files committed to the repository. "
204 "Check if you really need them or if there is another way to include them."
205 )
206 return 0
207 return 1