Coverage for src / moai_adk / core / project / checker.py: 29.51%

61 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-20 20:52 +0900

1"""System requirements validation module. 

2 

3Checks whether required and optional tools are installed. 

4""" 

5 

6import platform 

7import shutil 

8import subprocess 

9import sys 

10from pathlib import Path 

11 

12 

13class SystemChecker: 

14 """Validate system requirements.""" 

15 

16 REQUIRED_TOOLS: dict[str, str] = { 

17 "git": "git --version", 

18 "python": "python3 --version", 

19 } 

20 

21 OPTIONAL_TOOLS: dict[str, str] = { 

22 "gh": "gh --version", 

23 "docker": "docker --version", 

24 } 

25 

26 LANGUAGE_TOOLS: dict[str, dict[str, list[str]]] = { 

27 "python": { 

28 "required": ["python3", "pip"], 

29 "recommended": ["pytest", "mypy", "ruff"], 

30 "optional": ["black", "pylint"], 

31 }, 

32 "typescript": { 

33 "required": ["node", "npm"], 

34 "recommended": ["vitest", "biome"], 

35 "optional": ["typescript", "eslint"], 

36 }, 

37 "javascript": { 

38 "required": ["node", "npm"], 

39 "recommended": ["jest", "eslint"], 

40 "optional": ["prettier", "webpack"], 

41 }, 

42 "java": { 

43 "required": ["java", "javac"], 

44 "recommended": ["maven", "gradle"], 

45 "optional": ["junit", "checkstyle"], 

46 }, 

47 "go": { 

48 "required": ["go"], 

49 "recommended": ["golangci-lint", "gofmt"], 

50 "optional": ["delve", "gopls"], 

51 }, 

52 "rust": { 

53 "required": ["rustc", "cargo"], 

54 "recommended": ["rustfmt", "clippy"], 

55 "optional": ["rust-analyzer", "cargo-audit"], 

56 }, 

57 "dart": { 

58 "required": ["dart"], 

59 "recommended": ["flutter", "dart_test"], 

60 "optional": ["dartfmt", "dartanalyzer"], 

61 }, 

62 "swift": { 

63 "required": ["swift", "swiftc"], 

64 "recommended": ["xcrun", "swift-format"], 

65 "optional": ["swiftlint", "sourcekit-lsp"], 

66 }, 

67 "kotlin": { 

68 "required": ["kotlin", "kotlinc"], 

69 "recommended": ["gradle", "ktlint"], 

70 "optional": ["detekt", "kotlin-language-server"], 

71 }, 

72 "csharp": { 

73 "required": ["dotnet"], 

74 "recommended": ["msbuild", "nuget"], 

75 "optional": ["csharpier", "roslyn"], 

76 }, 

77 "php": { 

78 "required": ["php"], 

79 "recommended": ["composer", "phpunit"], 

80 "optional": ["psalm", "phpstan"], 

81 }, 

82 "ruby": { 

83 "required": ["ruby", "gem"], 

84 "recommended": ["bundler", "rspec"], 

85 "optional": ["rubocop", "solargraph"], 

86 }, 

87 "elixir": { 

88 "required": ["elixir", "mix"], 

89 "recommended": ["hex", "dialyzer"], 

90 "optional": ["credo", "ex_unit"], 

91 }, 

92 "scala": { 

93 "required": ["scala", "scalac"], 

94 "recommended": ["sbt", "scalatest"], 

95 "optional": ["scalafmt", "metals"], 

96 }, 

97 "clojure": { 

98 "required": ["clojure", "clj"], 

99 "recommended": ["leiningen", "clojure.test"], 

100 "optional": ["cider", "clj-kondo"], 

101 }, 

102 "haskell": { 

103 "required": ["ghc", "ghci"], 

104 "recommended": ["cabal", "stack"], 

105 "optional": ["hlint", "haskell-language-server"], 

106 }, 

107 "c": { 

108 "required": ["gcc", "make"], 

109 "recommended": ["clang", "cmake"], 

110 "optional": ["gdb", "valgrind"], 

111 }, 

112 "cpp": { 

113 "required": ["g++", "make"], 

114 "recommended": ["clang++", "cmake"], 

115 "optional": ["gdb", "cppcheck"], 

116 }, 

117 "lua": { 

118 "required": ["lua"], 

119 "recommended": ["luarocks", "busted"], 

120 "optional": ["luacheck", "lua-language-server"], 

121 }, 

122 "ocaml": { 

123 "required": ["ocaml", "opam"], 

124 "recommended": ["dune", "ocamlformat"], 

125 "optional": ["merlin", "ocp-indent"], 

126 }, 

127 } 

128 

129 def check_all(self) -> dict[str, bool]: 

130 """Validate every tool. 

131 

132 Returns: 

133 Dictionary mapping tool names to availability. 

134 """ 

135 result = {} 

136 

137 # Check required tools 

138 for tool, command in self.REQUIRED_TOOLS.items(): 

139 result[tool] = self._check_tool(command) 

140 

141 # Check optional tools 

142 for tool, command in self.OPTIONAL_TOOLS.items(): 

143 result[tool] = self._check_tool(command) 

144 

145 return result 

146 

147 def _check_tool(self, command: str) -> bool: 

148 """Check an individual tool. 

149 

150 Args: 

151 command: Command to run (e.g., "git --version"). 

152 

153 Returns: 

154 True when the tool is available. 

155 """ 

156 if not command: 

157 return False 

158 

159 try: 

160 # Extract the tool name (first token) 

161 tool_name = command.split()[0] 

162 # Determine availability via shutil.which 

163 return shutil.which(tool_name) is not None 

164 except Exception: 

165 return False 

166 

167 def check_language_tools(self, language: str | None) -> dict[str, bool]: 

168 """Validate toolchains by language. 

169 

170 Args: 

171 language: Programming language name (e.g., "python", "typescript"). 

172 

173 Returns: 

174 Dictionary mapping tool names to availability. 

175 """ 

176 # Guard clause: no language specified 

177 if not language: 

178 return {} 

179 

180 language_lower = language.lower() 

181 

182 # Guard clause: unsupported language 

183 if language_lower not in self.LANGUAGE_TOOLS: 

184 return {} 

185 

186 # Retrieve tool configuration for the language 

187 tools_config = self.LANGUAGE_TOOLS[language_lower] 

188 

189 # Evaluate tools by category and collect results 

190 result: dict[str, bool] = {} 

191 for category in ["required", "recommended", "optional"]: 

192 tools = tools_config.get(category, []) 

193 for tool in tools: 

194 result[tool] = self._is_tool_available(tool) 

195 

196 return result 

197 

198 def _is_tool_available(self, tool: str) -> bool: 

199 """Check whether a tool is available (helper). 

200 

201 Args: 

202 tool: Tool name. 

203 

204 Returns: 

205 True when the tool is available. 

206 """ 

207 return shutil.which(tool) is not None 

208 

209 def get_tool_version(self, tool: str | None) -> str | None: 

210 """Retrieve tool version information. 

211 

212 Args: 

213 tool: Tool name (for example, "python3", "node"). 

214 

215 Returns: 

216 Version string or None when the tool is unavailable. 

217 """ 

218 # Guard clause: unspecified or unavailable tool 

219 if not tool or not self._is_tool_available(tool): 

220 return None 

221 

222 try: 

223 # Call the tool with --version to obtain the version string 

224 result = subprocess.run( 

225 [tool, "--version"], 

226 capture_output=True, 

227 text=True, 

228 timeout=2, # 2-second timeout to respect performance constraints 

229 check=False, 

230 ) 

231 

232 # Return the version when the command succeeds 

233 if result.returncode == 0 and result.stdout: 

234 return self._extract_version_line(result.stdout) 

235 

236 return None 

237 

238 except (subprocess.TimeoutExpired, OSError): 

239 # Gracefully handle timeout and OS errors 

240 return None 

241 

242 def _extract_version_line(self, version_output: str) -> str: 

243 """Extract the first line from version output (helper). 

244 

245 Args: 

246 version_output: Output captured from the --version command. 

247 

248 Returns: 

249 First line containing version information. 

250 """ 

251 return version_output.strip().split("\n")[0] 

252 

253 

254def check_environment() -> dict[str, bool]: 

255 """Validate the overall environment (used by the CLI doctor command). 

256 

257 Returns: 

258 Mapping from check description to boolean status. 

259 """ 

260 return { 

261 "Python >= 3.13": sys.version_info >= (3, 13), 

262 "Git installed": shutil.which("git") is not None, 

263 "Project structure (.moai/)": Path(".moai").exists(), 

264 "Config file (.moai/config/config.json)": Path( 

265 ".moai/config/config.json" 

266 ).exists(), 

267 } 

268 

269 

270def get_platform_specific_message(unix_message: str, windows_message: str) -> str: 

271 """Return platform-specific message. 

272 

273 Args: 

274 unix_message: Message for Unix/Linux/macOS. 

275 windows_message: Message for Windows. 

276 

277 Returns: 

278 Platform-appropriate message. 

279 

280 Examples: 

281 >>> get_platform_specific_message("chmod 755 .moai", "Check directory permissions") 

282 'chmod 755 .moai' # on Unix/Linux/macOS 

283 >>> get_platform_specific_message("chmod 755 .moai", "Check directory permissions") 

284 'Check directory permissions' # on Windows 

285 """ 

286 if platform.system() == "Windows": 

287 return windows_message 

288 return unix_message 

289 

290 

291def get_permission_fix_message(path: str) -> str: 

292 """Get platform-specific permission fix message. 

293 

294 Args: 

295 path: Path to fix permissions for. 

296 

297 Returns: 

298 Platform-specific fix instructions. 

299 """ 

300 if platform.system() == "Windows": 

301 return f"Run with administrator privileges or verify permissions in the properties of the '{path}' directory" 

302 return f"Run 'chmod 755 {path}' and try again"