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

1"""Cpp code evaluation jobs.""" 

2 

3import re 

4from pathlib import Path 

5 

6import git 

7 

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 

16 

17CMAKE_MODULE_PATH = REPO_DIR / "cmake" 

18HW_BUILD_FOLDER = "hw_build" 

19 

20 

21class CMakeBuildJob(EvaluationJob): 

22 """Job for compiling the project.""" 

23 

24 name = "CMake Build" 

25 

26 def __init__(self, weight: int = 1, cmake_options: str = "") -> None: 

27 super().__init__(weight) 

28 self.__cmake_options = cmake_options 

29 

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 

40 

41 

42class MakeTestJob(EvaluationJob): 

43 """Job for running make test.""" 

44 

45 name = "Make Test" 

46 

47 def __init__(self, weight: int = 1, cmake_options: str = "") -> None: 

48 super().__init__(weight) 

49 self._cmake_options = cmake_options 

50 

51 @property 

52 def dependencies(self) -> list[EvaluationJob]: 

53 return [CMakeBuildJob(cmake_options=self._cmake_options)] 

54 

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 

65 

66 

67class ClangFormatTestJob(EvaluationJob): 

68 """Job for checking the code format.""" 

69 

70 name = "Clang Format Check" 

71 

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 

89 

90 

91class CodeCoverageTestJob(EvaluationJob): 

92 """Job for checking the code coverage.""" 

93 

94 name = "Code Coverage" 

95 

96 @property 

97 def dependencies(self) -> list[EvaluationJob]: 

98 return [MakeTestJob(cmake_options="-DCMAKE_BUILD_TYPE=Debug")] 

99 

100 def __init__(self, weight: int = 1, min_coverage: int = 75) -> None: 

101 super().__init__(weight) 

102 self.__min_coverage = min_coverage 

103 

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 

110 

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) 

121 

122 

123class ClangTidyTestJob(EvaluationJob): 

124 """Job for checking with clang tidy.""" 

125 

126 name = "Clang Tidy Check" 

127 

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 

140 

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 

146 

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 

155 

156 

157class CleanRepoJob(EvaluationJob): 

158 """Job for checking if build files were committed.""" 

159 

160 name = "Clean Repo Check" 

161 

162 class CleanRepoVisitor(FileVisitor): 

163 """Check if build files are committed.""" 

164 

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/"] 

171 

172 @property 

173 def is_clean(self) -> bool: 

174 return self.__is_clean 

175 

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 ) 

184 

185 class SourceFilesCountVisitor(FileVisitor): 

186 """Count the number of source files.""" 

187 

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}/" 

192 

193 @property 

194 def is_below_max_source_file_count(self) -> bool: 

195 return self.__source_file_count < self.__max_source_file_count 

196 

197 def visit_file(self, file: Path) -> None: 

198 if self.__build_folder in str(file): 

199 return 

200 self.__source_file_count += 1 

201 

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 

222 

223 

224class RelativeIncludeJob(EvaluationJob): 

225 """Job for checking that no relative includes are used.""" 

226 

227 name = "Relative Include Check" 

228 

229 class RelativeIncludeVisitor(FileVisitor): 

230 """Detect relative #include directives using '..' path components.""" 

231 

232 RELATIVE_INCLUDE_PATTERN = re.compile(r'#include\s+["<]\.\.?/') 

233 

234 def __init__(self) -> None: 

235 self.__offending_files: list[Path] = [] 

236 

237 @property 

238 def offending_files(self) -> list[Path]: 

239 return self.__offending_files 

240 

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) 

247 

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