Coverage for tools/sel_tools/code_evaluation/jobs/cpp.py: 98%

120 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-02 05:55 +0000

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

2 

3import re 

4from pathlib import Path 

5from typing import ClassVar 

6 

7import git 

8 

9from sel_tools.code_evaluation.jobs.common import ( 

10 EvaluationJob, 

11 run_shell_command, 

12 run_shell_command_with_output, 

13) 

14from sel_tools.config import CMAKE_MODULE_PATH, HW_BUILD_FOLDER 

15from sel_tools.file_export.copy_item import copy_item 

16from sel_tools.utils.config import CMAKELISTS_FILE_NAME 

17from sel_tools.utils.files import FileTree, FileVisitor 

18 

19 

20class CMakeBuildJob(EvaluationJob): 

21 """Job for compiling the project.""" 

22 

23 name = "CMake Build" 

24 

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

26 super().__init__(weight) 

27 self.__cmake_options = cmake_options 

28 

29 def _run(self, repo_path: Path) -> int: 

30 build_folder = repo_path / HW_BUILD_FOLDER 

31 build_folder.mkdir(parents=True, exist_ok=True) 

32 if run_shell_command(f"cmake {self.__cmake_options} ..", build_folder) == 0: 

33 self._comment = f"CMake step failed with option {self.__cmake_options}: Make sure cmake .. passes." 

34 return 0 

35 if run_shell_command("make", build_folder) == 0: 

36 self._comment = "Make step failed: Make sure you build passes when calling make." 

37 return 0 

38 return 1 

39 

40 

41class MakeTestJob(EvaluationJob): 

42 """Job for running make test.""" 

43 

44 name = "Make Test" 

45 dependencies: ClassVar[list[EvaluationJob]] = [CMakeBuildJob()] 

46 

47 def _run(self, repo_path: Path) -> int: 

48 build_folder = repo_path / HW_BUILD_FOLDER 

49 score, output = run_shell_command_with_output("make test", build_folder) 

50 if score != 0 and not output: 

51 self._comment = "No tests registered: Make sure you have tests registered in CMakeLists.txt." 

52 return 0 

53 if score != 0 and "No tests were found" in output: 

54 self._comment = "No tests were found: Make sure you have tests registered in CMakeLists.txt." 

55 return 0 

56 return score 

57 

58 

59class ClangFormatTestJob(EvaluationJob): 

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

61 

62 name = "Clang Format Check" 

63 

64 def _run(self, repo_path: Path) -> int: 

65 return run_shell_command( 

66 rf"find . -type f -regex '.*\.\(cpp\|hpp\|cu\|c\|cc\|h\)' -not -path '*/{HW_BUILD_FOLDER}/*' " 

67 "| xargs clang-format --style=file -i --dry-run --Werror", 

68 repo_path, 

69 ) 

70 

71 

72class CodeCoverageTestJob(EvaluationJob): 

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

74 

75 name = "Code Coverage" 

76 dependencies: ClassVar[list[EvaluationJob]] = [CMakeBuildJob(cmake_options="-DCMAKE_BUILD_TYPE=Debug")] 

77 

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

79 super().__init__(weight) 

80 self.__min_coverage = min_coverage 

81 

82 @staticmethod 

83 def parse_total_coverage(coverage_file: Path) -> int: 

84 coverage_file_pattern = r"TOTAL.*\s(\d*)%" 

85 text = coverage_file.read_text() 

86 coverage = re.search(coverage_file_pattern, text, re.DOTALL) 

87 return int(coverage.group(1)) if coverage else 0 

88 

89 def _run(self, repo_path: Path) -> int: 

90 coverage_file = repo_path.resolve() / HW_BUILD_FOLDER / "report.txt" 

91 if (score := run_shell_command(f"gcovr -o {coverage_file}", repo_path)) == 0: 

92 self._comment = "Coverage failed report generation failed." 

93 return score 

94 coverage = self.parse_total_coverage(coverage_file) 

95 self._comment = f"Code coverage: {coverage}%. We require at least {self.__min_coverage}%." 

96 return int(coverage > self.__min_coverage) 

97 

98 

99class ClangTidyTestJob(EvaluationJob): 

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

101 

102 name = "Clang Tidy Check" 

103 

104 def _run(self, repo_path: Path) -> int: 

105 hw_cmake_module_path = repo_path / "hw_cmake" 

106 hw_cmake_module_path.mkdir(parents=True, exist_ok=True) 

107 copy_item(CMAKE_MODULE_PATH / "ClangTidy.cmake", hw_cmake_module_path / "ClangTidy.cmake") 

108 cmake_lists = repo_path / CMAKELISTS_FILE_NAME 

109 

110 if not cmake_lists.exists(): 

111 self._comment = ( 

112 f"{CMAKELISTS_FILE_NAME} not found: Make sure you project has a {CMAKELISTS_FILE_NAME} file." 

113 ) 

114 return 0 

115 

116 content = cmake_lists.read_text() 

117 content += "\n" 

118 content += f"list(APPEND CMAKE_MODULE_PATH ${{PROJECT_SOURCE_DIR}}/{hw_cmake_module_path.stem})\n" 

119 content += "include(ClangTidy)\n" 

120 cmake_lists.write_text(content) 

121 score = CMakeBuildJob().run(repo_path)[-1].score 

122 git.Repo(repo_path).git.restore(".") # Undo all changes 

123 return score 

124 

125 

126class CleanRepoJob(EvaluationJob): 

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

128 

129 name = "Clean Repo Check" 

130 

131 class CleanRepoVisitor(FileVisitor): 

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

133 

134 def __init__(self) -> None: 

135 self.__is_clean: bool = True 

136 self.__build_folder = f"/{HW_BUILD_FOLDER}/" 

137 self.__dirty_suffixes = [".make", ".includecache"] 

138 self.__dirty_file_names = ["CMakeCache.txt", "cmake_install.cmake"] 

139 self.__dirty_directories = ["/CMakeFiles/", ".dir/"] 

140 

141 @property 

142 def is_clean(self) -> bool: 

143 return self.__is_clean 

144 

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

146 if self.__build_folder in str(file): 

147 return 

148 self.__is_clean = self.__is_clean and ( 

149 file.name not in self.__dirty_file_names 

150 and file.suffix not in self.__dirty_suffixes 

151 and all(directory not in str(file) for directory in self.__dirty_directories) 

152 ) 

153 

154 class SourceFilesCountVisitor(FileVisitor): 

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

156 

157 def __init__(self, max_source_file_count: int) -> None: 

158 self.__max_source_file_count = max_source_file_count 

159 self.__source_file_count = 0 

160 self.__build_folder = f"/{HW_BUILD_FOLDER}/" 

161 

162 @property 

163 def is_below_max_source_file_count(self) -> bool: 

164 return self.__source_file_count < self.__max_source_file_count 

165 

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

167 if self.__build_folder in str(file): 

168 return 

169 self.__source_file_count += 1 

170 

171 def _run(self, repo_path: Path) -> int: 

172 clean_repo_visitor = CleanRepoJob.CleanRepoVisitor() 

173 source_file_count_visitor = CleanRepoJob.SourceFilesCountVisitor(100) 

174 file_tree = FileTree(repo_path) 

175 file_tree.accept(clean_repo_visitor) 

176 file_tree.accept(source_file_count_visitor) 

177 if not clean_repo_visitor.is_clean: 

178 self._comment = ( 

179 "We found build files committed to the repository. " 

180 "Make sure that any files produced by the build system are not committed. " 

181 "If you already have, make sure you remove them." 

182 ) 

183 return 0 

184 if not source_file_count_visitor.is_below_max_source_file_count: 

185 self._comment = ( 

186 "We found too many third party source files committed to the repository. " 

187 "Check if you really need them or if there is another way to include them." 

188 ) 

189 return 0 

190 return 1