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

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 

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 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 

82 

83 

84class CodeCoverageTestJob(EvaluationJob): 

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

86 

87 name = "Code Coverage" 

88 

89 @property 

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

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

92 

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

94 super().__init__(weight) 

95 self.__min_coverage = min_coverage 

96 

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 

103 

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) 

114 

115 

116class ClangTidyTestJob(EvaluationJob): 

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

118 

119 name = "Clang Tidy Check" 

120 

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 

126 

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 

132 

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 

141 

142 

143class CleanRepoJob(EvaluationJob): 

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

145 

146 name = "Clean Repo Check" 

147 

148 class CleanRepoVisitor(FileVisitor): 

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

150 

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

157 

158 @property 

159 def is_clean(self) -> bool: 

160 return self.__is_clean 

161 

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 ) 

170 

171 class SourceFilesCountVisitor(FileVisitor): 

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

173 

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

178 

179 @property 

180 def is_below_max_source_file_count(self) -> bool: 

181 return self.__source_file_count < self.__max_source_file_count 

182 

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

184 if self.__build_folder in str(file): 

185 return 

186 self.__source_file_count += 1 

187 

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