Coverage for tools / sel_tools / code_evaluation / jobs / cpp.py: 99%
163 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-21 08:53 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-21 08:53 +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, is_cpp
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 clang_format_file = repo_path / ".clang-format"
74 if not clang_format_file.exists():
75 self._comment = (
76 "`.clang-format` not found: Add a `.clang-format` file so clang-format can apply the project style. "
77 "Common pitfall: naming the file `clang-format` without the leading dot."
78 )
79 return 0
80 score = run_shell_command(
81 rf"find . -type f -regex '.*\.\(cpp\|hpp\|cu\|c\|cc\|h\)' -not -path '*/{HW_BUILD_FOLDER}/*' "
82 "| xargs clang-format --style=file -i --dry-run --Werror",
83 repo_path,
84 )
85 if score == 0:
86 self._comment = "Clang format check failed: Make sure you format your code according to the style guide."
87 return score
88 return 1
91class CodeCoverageTestJob(EvaluationJob):
92 """Job for checking the code coverage."""
94 name = "Code Coverage"
96 @property
97 def dependencies(self) -> list[EvaluationJob]:
98 return [MakeTestJob(cmake_options="-DCMAKE_BUILD_TYPE=Debug")]
100 def __init__(self, weight: int = 1, min_coverage: int = 75) -> None:
101 super().__init__(weight)
102 self.__min_coverage = min_coverage
104 @staticmethod
105 def parse_total_coverage(coverage_file: Path) -> int:
106 coverage_file_pattern = r"TOTAL.*\s(\d*)%"
107 text = coverage_file.read_text()
108 coverage = re.search(coverage_file_pattern, text, re.DOTALL)
109 return int(coverage.group(1)) if coverage else 0
111 def _run(self, repo_path: Path) -> int:
112 build_folder = repo_path / HW_BUILD_FOLDER
113 coverage_file = build_folder.resolve() / "report.txt"
114 gcovr_cmd = f"gcovr --root {repo_path.resolve()} --exclude _deps -o {coverage_file}"
115 if (score := run_shell_command(gcovr_cmd, build_folder)) == 0:
116 self._comment = "Coverage failed report generation failed."
117 return score
118 coverage = self.parse_total_coverage(coverage_file)
119 self._comment = f"Code coverage: {coverage}%. We require at least {self.__min_coverage}%."
120 return int(coverage > self.__min_coverage)
123class ClangTidyTestJob(EvaluationJob):
124 """Job for checking with clang tidy."""
126 name = "Clang Tidy Check"
128 def _run(self, repo_path: Path) -> int:
129 clang_tidy_file = repo_path / ".clang-tidy"
130 if not clang_tidy_file.exists():
131 self._comment = (
132 "`.clang-tidy` not found: Add a `.clang-tidy` file so clang-tidy can load checks. "
133 "Common pitfall: naming the file `clang-tidy` without the leading dot."
134 )
135 return 0
136 hw_cmake_module_path = repo_path / "hw_cmake"
137 hw_cmake_module_path.mkdir(parents=True, exist_ok=True)
138 copy_item(CMAKE_MODULE_PATH / "ClangTidy.cmake", hw_cmake_module_path / "ClangTidy.cmake")
139 cmake_lists = repo_path / CMAKELISTS_FILE_NAME
141 if not cmake_lists.exists():
142 self._comment = (
143 f"{CMAKELISTS_FILE_NAME} not found: Make sure you project has a {CMAKELISTS_FILE_NAME} file."
144 )
145 return 0
147 content = cmake_lists.read_text()
148 content += "\n"
149 content += f"list(APPEND CMAKE_MODULE_PATH ${{PROJECT_SOURCE_DIR}}/{hw_cmake_module_path.stem})\n"
150 content += "include(ClangTidy)\n"
151 cmake_lists.write_text(content)
152 score = CMakeBuildJob().run(repo_path)[-1].score
153 git.Repo(repo_path).git.restore(".") # Undo all changes
154 return score
157class CleanRepoJob(EvaluationJob):
158 """Job for checking if build files were committed."""
160 name = "Clean Repo Check"
162 class CleanRepoVisitor(FileVisitor):
163 """Check if build files are committed."""
165 def __init__(self) -> None:
166 self.__is_clean: bool = True
167 self.__build_folder = f"/{HW_BUILD_FOLDER}/"
168 self.__dirty_suffixes = [".make", ".includecache"]
169 self.__dirty_file_names = ["CMakeCache.txt", "cmake_install.cmake"]
170 self.__dirty_directories = ["/CMakeFiles/", ".dir/"]
172 @property
173 def is_clean(self) -> bool:
174 return self.__is_clean
176 def visit_file(self, file: Path) -> None:
177 if self.__build_folder in str(file):
178 return
179 self.__is_clean = self.__is_clean and (
180 file.name not in self.__dirty_file_names
181 and file.suffix not in self.__dirty_suffixes
182 and all(directory not in str(file) for directory in self.__dirty_directories)
183 )
185 class SourceFilesCountVisitor(FileVisitor):
186 """Count the number of source files."""
188 def __init__(self, max_source_file_count: int) -> None:
189 self.__max_source_file_count = max_source_file_count
190 self.__source_file_count = 0
191 self.__build_folder = f"/{HW_BUILD_FOLDER}/"
193 @property
194 def is_below_max_source_file_count(self) -> bool:
195 return self.__source_file_count < self.__max_source_file_count
197 def visit_file(self, file: Path) -> None:
198 if self.__build_folder in str(file):
199 return
200 self.__source_file_count += 1
202 def _run(self, repo_path: Path) -> int:
203 clean_repo_visitor = CleanRepoJob.CleanRepoVisitor()
204 source_file_count_visitor = CleanRepoJob.SourceFilesCountVisitor(100)
205 file_tree = FileTree(repo_path)
206 file_tree.accept(clean_repo_visitor)
207 file_tree.accept(source_file_count_visitor)
208 if not clean_repo_visitor.is_clean:
209 self._comment = (
210 "We found build files committed to the repository. "
211 "Make sure that any files produced by the build system are not committed. "
212 "If you already have, make sure you remove them."
213 )
214 return 0
215 if not source_file_count_visitor.is_below_max_source_file_count:
216 self._comment = (
217 "We found too many third party source files committed to the repository. "
218 "Check if you really need them or if there is another way to include them."
219 )
220 return 0
221 return 1
224class RelativeIncludeJob(EvaluationJob):
225 """Job for checking that no relative includes are used."""
227 name = "Relative Include Check"
229 class RelativeIncludeVisitor(FileVisitor):
230 """Detect relative #include directives using '..' path components."""
232 RELATIVE_INCLUDE_PATTERN = re.compile(r'#include\s+["<]\.\.?/')
234 def __init__(self) -> None:
235 self.__offending_files: list[Path] = []
237 @property
238 def offending_files(self) -> list[Path]:
239 return self.__offending_files
241 def visit_file(self, file: Path) -> None:
242 if not is_cpp(file):
243 return
244 content = file.read_text()
245 if self.RELATIVE_INCLUDE_PATTERN.search(content):
246 self.__offending_files.append(file)
248 def _run(self, repo_path: Path) -> int:
249 visitor = RelativeIncludeJob.RelativeIncludeVisitor()
250 FileTree(repo_path).accept(visitor)
251 if visitor.offending_files:
252 self._comment = (
253 'Relative includes (e.g. `#include "../include/foo.h"`) found in: '
254 + ", ".join(str(f) for f in visitor.offending_files)
255 + ".\nUse non-relative includes instead (e.g. `#include <foo.h>`) by "
256 + "providing the appropriate include directories in CMake."
257 )
258 return 0
259 return 1