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

134 statements  

« prev     ^ index     » next       coverage.py v7.6.4, created at 2024-11-04 21:22 +0000

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

2 

3import re 

4from pathlib import Path 

5from typing import ClassVar 

6 

7from sel_tools.code_evaluation.jobs.common import ( 

8 EvaluationJob, 

9 run_shell_command, 

10 run_shell_command_with_output, 

11) 

12from sel_tools.config import CMAKE_MODULE_PATH, HW_BUILD_FOLDER 

13from sel_tools.file_export.copy_item import copy_item 

14from sel_tools.utils.config import CMAKELISTS_FILE_NAME 

15from sel_tools.utils.files import FileTree, FileVisitor, is_cpp 

16 

17 

18class CMakeBuildJob(EvaluationJob): 

19 """Job for compiling the project.""" 

20 

21 name = "CMake Build" 

22 

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

24 super().__init__(weight) 

25 self.__cmake_options = cmake_options 

26 

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

28 build_folder = repo_path / HW_BUILD_FOLDER 

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

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

31 self._comment = f"CMake step failed with option {self.__cmake_options}" 

32 return 0 

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

34 self._comment = "Make step failed" 

35 return 0 

36 return 1 

37 

38 

39class MakeTestJob(EvaluationJob): 

40 """Job for running make test.""" 

41 

42 name = "Make Test" 

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

44 

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

46 build_folder = repo_path / HW_BUILD_FOLDER 

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

48 if score != 0 and not output: 

49 self._comment = "No tests registered" 

50 return 0 

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

52 self._comment = "No tests were found" 

53 return 0 

54 return score 

55 

56 

57class ClangFormatTestJob(EvaluationJob): 

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

59 

60 name = "Clang Format Check" 

61 

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

63 return run_shell_command( 

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

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

66 repo_path, 

67 ) 

68 

69 

70class CodeCoverageTestJob(EvaluationJob): 

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

72 

73 name = "Code Coverage" 

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

75 

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

77 super().__init__(weight) 

78 self.__min_coverage = min_coverage 

79 

80 @staticmethod 

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

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

83 text = coverage_file.read_text() 

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

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

86 

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

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

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

90 self._comment = "Coverage failed" 

91 return score 

92 coverage = self.parse_total_coverage(coverage_file) 

93 self._comment = f"Code coverage: {coverage}%" 

94 return int(coverage > self.__min_coverage) 

95 

96 

97class ClangTidyTestJob(EvaluationJob): 

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

99 

100 name = "Clang Tidy Check" 

101 

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

103 copy_item(CMAKE_MODULE_PATH, repo_path) 

104 cmake_lists = repo_path / CMAKELISTS_FILE_NAME 

105 

106 if not cmake_lists.exists(): 

107 self._comment = f"{CMAKELISTS_FILE_NAME} not found" 

108 return 0 

109 

110 content = cmake_lists.read_text() 

111 content += "\n" 

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

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

114 cmake_lists.write_text(content) 

115 return CMakeBuildJob().run(repo_path)[-1].score 

116 

117 

118class OOPTestJob(EvaluationJob): 

119 """Job for checking if all OOP is used.""" 

120 

121 name = "OOP Check" 

122 

123 class OOPVisitor(FileVisitor): 

124 """Check if given cpp files are OOP.""" 

125 

126 def __init__(self) -> None: 

127 self.__struct_usages: int = 0 

128 

129 @property 

130 def is_oop(self) -> bool: 

131 return self.__struct_usages == 0 

132 

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

134 if is_cpp(file): 

135 cpp_content = file.read_text() 

136 self.__struct_usages += self.find_struct_usages(cpp_content) 

137 

138 @staticmethod 

139 def find_struct_usages(file_content: str) -> int: 

140 # TODO this is probably a very weak check 

141 # but we can improve in the future with more checks 

142 return len(re.findall(r"\sstruct\s", file_content, re.DOTALL)) 

143 

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

145 oop_visitor = OOPTestJob.OOPVisitor() 

146 FileTree(repo_path).accept(oop_visitor) 

147 return int(oop_visitor.is_oop) 

148 

149 

150class CleanRepoJob(EvaluationJob): 

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

152 

153 name = "Clean Repo Check" 

154 

155 class CleanRepoVisitor(FileVisitor): 

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

157 

158 def __init__(self) -> None: 

159 self.__is_clean: bool = True 

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

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

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

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

164 

165 @property 

166 def is_clean(self) -> bool: 

167 return self.__is_clean 

168 

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

170 if self.__build_folder in str(file): 

171 return 

172 self.__is_clean = self.__is_clean and ( 

173 file.name not in self.__dirty_file_names 

174 and file.suffix not in self.__dirty_suffixes 

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

176 ) 

177 

178 class SourceFilesCountVisitor(FileVisitor): 

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

180 

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

182 self.__max_source_file_count = max_source_file_count 

183 self.__source_file_count = 0 

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

185 

186 @property 

187 def is_below_max_source_file_count(self) -> bool: 

188 return self.__source_file_count < self.__max_source_file_count 

189 

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

191 if self.__build_folder in str(file): 

192 return 

193 self.__source_file_count += 1 

194 

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

196 clean_repo_visitor = CleanRepoJob.CleanRepoVisitor() 

197 source_file_count_visitor = CleanRepoJob.SourceFilesCountVisitor(100) 

198 file_tree = FileTree(repo_path) 

199 file_tree.accept(clean_repo_visitor) 

200 file_tree.accept(source_file_count_visitor) 

201 if not clean_repo_visitor.is_clean: 

202 self._message = "Build files committed" 

203 return 0 

204 if not source_file_count_visitor.is_below_max_source_file_count: 

205 self._message = "Committed to many third party source file" 

206 return 0 

207 return 1