Coverage for lintro / tools / definitions / astro_check.py: 74%

135 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Astro check tool definition. 

2 

3Astro check is Astro's built-in type checking command that provides TypeScript 

4diagnostics for `.astro` files including frontmatter scripts, component props, 

5and template expressions. 

6 

7Example: 

8 # Check Astro project 

9 lintro check src/ --tools astro-check 

10 

11 # Check with specific root 

12 lintro check . --tools astro-check --tool-options "astro-check:root=./packages/web" 

13""" 

14 

15from __future__ import annotations 

16 

17import shutil 

18import subprocess # nosec B404 - used safely with shell disabled 

19from dataclasses import dataclass 

20from pathlib import Path 

21from typing import Any, NoReturn 

22 

23from loguru import logger 

24 

25from lintro._tool_versions import get_min_version 

26from lintro.enums.doc_url_template import DocUrlTemplate 

27from lintro.enums.tool_name import ToolName 

28from lintro.enums.tool_type import ToolType 

29from lintro.models.core.tool_result import ToolResult 

30from lintro.parsers.astro_check.astro_check_parser import parse_astro_check_output 

31from lintro.parsers.base_parser import strip_ansi_codes 

32from lintro.plugins.base import BaseToolPlugin 

33from lintro.plugins.protocol import ToolDefinition 

34from lintro.plugins.registry import register_tool 

35from lintro.tools.core.timeout_utils import create_timeout_result 

36 

37# Constants for Astro check configuration 

38ASTRO_CHECK_DEFAULT_TIMEOUT: int = 120 

39ASTRO_CHECK_DEFAULT_PRIORITY: int = 83 # After tsc (82) 

40ASTRO_CHECK_FILE_PATTERNS: list[str] = ["*.astro"] 

41_ASTRO_CONFIG_NAMES: tuple[str, ...] = ( 

42 "astro.config.mjs", 

43 "astro.config.ts", 

44 "astro.config.js", 

45 "astro.config.cjs", 

46) 

47 

48 

49@register_tool 

50@dataclass 

51class AstroCheckPlugin(BaseToolPlugin): 

52 """Astro check type checking plugin. 

53 

54 This plugin integrates the Astro check command with Lintro for static 

55 type checking of Astro components. 

56 """ 

57 

58 @property 

59 def definition(self) -> ToolDefinition: 

60 """Return the tool definition. 

61 

62 Returns: 

63 ToolDefinition containing tool metadata. 

64 """ 

65 return ToolDefinition( 

66 name="astro-check", 

67 description="Astro type checker for Astro component diagnostics", 

68 can_fix=False, 

69 tool_type=ToolType.LINTER | ToolType.TYPE_CHECKER, 

70 file_patterns=ASTRO_CHECK_FILE_PATTERNS, 

71 priority=ASTRO_CHECK_DEFAULT_PRIORITY, 

72 conflicts_with=[], 

73 native_configs=list(_ASTRO_CONFIG_NAMES), 

74 version_command=["astro", "--version"], 

75 min_version=get_min_version(ToolName.ASTRO_CHECK), 

76 default_options={ 

77 "timeout": ASTRO_CHECK_DEFAULT_TIMEOUT, 

78 "root": None, 

79 }, 

80 default_timeout=ASTRO_CHECK_DEFAULT_TIMEOUT, 

81 ) 

82 

83 def set_options( 

84 self, 

85 root: str | None = None, 

86 **kwargs: Any, 

87 ) -> None: 

88 """Set astro-check-specific options. 

89 

90 Args: 

91 root: Root directory for the Astro project. 

92 **kwargs: Other tool options. 

93 

94 Raises: 

95 ValueError: If any provided option is of an unexpected type. 

96 """ 

97 if root is not None and not isinstance(root, str): 

98 raise ValueError("root must be a string path") 

99 

100 options: dict[str, object] = {"root": root} 

101 options = {k: v for k, v in options.items() if v is not None} 

102 super().set_options(**options, **kwargs) 

103 

104 def _get_astro_command(self) -> list[str]: 

105 """Get the command to run astro check. 

106 

107 Prefers direct astro executable, falls back to bunx/npx. 

108 

109 Returns: 

110 Command arguments for astro check. 

111 """ 

112 # Prefer direct executable if available 

113 if shutil.which("astro"): 

114 return ["astro", "check"] 

115 # Try bunx (bun) 

116 if shutil.which("bunx"): 

117 return ["bunx", "astro", "check"] 

118 # Try npx (npm) 

119 if shutil.which("npx"): 

120 return ["npx", "astro", "check"] 

121 # Last resort 

122 return ["astro", "check"] 

123 

124 def _find_astro_config(self, cwd: Path) -> Path | None: 

125 """Find astro config file by walking up from *cwd*. 

126 

127 The computed ``cwd`` is typically the common parent of all discovered 

128 ``.astro`` files (e.g. ``src/``), but ``astro.config.*`` usually 

129 lives at the project root. Walking up ensures we find the config 

130 even when the tool starts in a subdirectory. 

131 

132 Args: 

133 cwd: Starting directory for the upward search. 

134 

135 Returns: 

136 Path to the first astro config found, or ``None``. 

137 """ 

138 current = cwd.resolve() 

139 root = Path(current.anchor) 

140 while True: 

141 for config_name in _ASTRO_CONFIG_NAMES: 

142 config_path = current / config_name 

143 if config_path.exists(): 

144 return config_path 

145 if current == root: 

146 break 

147 current = current.parent 

148 return None 

149 

150 def _build_command( 

151 self, 

152 options: dict[str, object] | None = None, 

153 ) -> list[str]: 

154 """Build the astro check invocation command. 

155 

156 Args: 

157 options: Options dict to use for flags. Defaults to self.options. 

158 

159 Returns: 

160 A list of command arguments ready to be executed. 

161 """ 

162 if options is None: 

163 options = self.options 

164 

165 cmd: list[str] = self._get_astro_command() 

166 

167 # Root directory option 

168 root = options.get("root") 

169 if root: 

170 cmd.extend(["--root", str(root)]) 

171 

172 return cmd 

173 

174 # Canonical message for "no Astro files" early returns 

175 _NO_FILES_MESSAGE: str = "No Astro files to check." 

176 

177 def doc_url(self, code: str) -> str | None: 

178 """Return Astro TypeScript documentation URL. 

179 

180 Astro check emits TypeScript error codes. Links to the Astro 

181 TypeScript guide since per-error pages are not available. 

182 

183 Args: 

184 code: TypeScript error code (e.g., "TS2322"). 

185 

186 Returns: 

187 URL to the Astro TypeScript guide, or None if code is empty. 

188 """ 

189 if not code: 

190 return None 

191 return DocUrlTemplate.ASTRO_CHECK 

192 

193 def check(self, paths: list[str], options: dict[str, object]) -> ToolResult: 

194 """Check files with astro check. 

195 

196 Astro check runs on the entire project and uses the project's 

197 astro.config and tsconfig.json for configuration. 

198 

199 Args: 

200 paths: List of file or directory paths to check. 

201 options: Runtime options that override defaults. 

202 

203 Returns: 

204 ToolResult with check results. 

205 """ 

206 # Merge runtime options 

207 merged_options = dict(self.options) 

208 merged_options.update(options) 

209 

210 # Use shared preparation for version check, path validation, file discovery 

211 ctx = self._prepare_execution( 

212 paths, 

213 merged_options, 

214 no_files_message=self._NO_FILES_MESSAGE, 

215 ) 

216 

217 if ctx.should_skip and ctx.early_result is not None: 

218 # Normalize "no files" messages to the canonical form. 

219 # _prepare_execution may generate "No .astro files found to check." 

220 # from file patterns; detect and normalize robustly. 

221 output_lower = (ctx.early_result.output or "").lower() 

222 if "no" in output_lower and "astro" in output_lower: 

223 ctx.early_result.output = self._NO_FILES_MESSAGE 

224 return ctx.early_result 

225 

226 # Safety check: if should_skip but no early_result, create one 

227 if ctx.should_skip: 

228 return ToolResult( 

229 name=self.definition.name, 

230 success=True, 

231 output=self._NO_FILES_MESSAGE, 

232 issues_count=0, 

233 ) 

234 

235 logger.debug("[astro-check] Discovered {} Astro file(s)", len(ctx.files)) 

236 

237 # Honor the "root" option if provided, otherwise use ctx.cwd 

238 root_opt = merged_options.get("root") 

239 if root_opt and isinstance(root_opt, str): 

240 cwd_path = Path(root_opt) 

241 if not cwd_path.is_absolute(): 

242 # Resolve relative to ctx.cwd 

243 base = Path(ctx.cwd) if ctx.cwd else Path.cwd() 

244 cwd_path = (base / cwd_path).resolve() 

245 # Sync merged_options with the resolved absolute path 

246 # so _build_command uses the same absolute root 

247 merged_options["root"] = str(cwd_path) 

248 else: 

249 cwd_path = Path(ctx.cwd) if ctx.cwd else Path.cwd() 

250 

251 # Warn if no astro config found, but still proceed with defaults. 

252 # When the config lives in a parent directory (common when ctx.cwd 

253 # is e.g. src/), re-root to the config's directory so that 

254 # astro-check can resolve project paths correctly. 

255 astro_config = self._find_astro_config(cwd_path) 

256 if astro_config: 

257 config_dir = astro_config.parent.resolve() 

258 if config_dir != cwd_path.resolve(): 

259 logger.debug( 

260 "[astro-check] Re-rooting cwd from {} to {} (config found at {})", 

261 cwd_path, 

262 config_dir, 

263 astro_config, 

264 ) 

265 cwd_path = config_dir 

266 else: 

267 logger.warning( 

268 "[astro-check] No astro.config.* found — proceeding with defaults", 

269 ) 

270 

271 # Check if dependencies need installing 

272 from lintro.utils.node_deps import install_node_deps, should_install_deps 

273 

274 try: 

275 needs_install = should_install_deps(cwd_path) 

276 except PermissionError as e: 

277 logger.warning("[astro-check] {}", e) 

278 return ToolResult( 

279 name=self.definition.name, 

280 success=True, 

281 output=f"Skipping astro-check: {e}", 

282 issues_count=0, 

283 skipped=True, 

284 skip_reason="directory not writable", 

285 ) 

286 

287 if needs_install: 

288 auto_install = merged_options.get("auto_install", False) 

289 if auto_install: 

290 logger.info("[astro-check] Auto-installing Node.js dependencies...") 

291 install_ok, install_output = install_node_deps(cwd_path) 

292 if install_ok: 

293 logger.info( 

294 "[astro-check] Dependencies installed successfully", 

295 ) 

296 else: 

297 logger.warning( 

298 "[astro-check] Auto-install failed, skipping: {}", 

299 install_output, 

300 ) 

301 return ToolResult( 

302 name=self.definition.name, 

303 success=True, 

304 output=( 

305 f"Skipping astro-check: auto-install failed.\n" 

306 f"{install_output}" 

307 ), 

308 issues_count=0, 

309 skipped=True, 

310 skip_reason="auto-install failed", 

311 ) 

312 else: 

313 return ToolResult( 

314 name=self.definition.name, 

315 output=( 

316 "node_modules not found. " 

317 "Use --auto-install to install dependencies." 

318 ), 

319 issues_count=0, 

320 skipped=True, 

321 skip_reason="node_modules not found", 

322 ) 

323 

324 # Build command 

325 cmd = self._build_command(options=merged_options) 

326 logger.debug("[astro-check] Running with cwd={} and cmd={}", cwd_path, cmd) 

327 

328 try: 

329 success, output = self._run_subprocess( 

330 cmd=cmd, 

331 timeout=ctx.timeout, 

332 cwd=str(cwd_path), 

333 ) 

334 except subprocess.TimeoutExpired: 

335 timeout_result = create_timeout_result( 

336 tool=self, 

337 timeout=ctx.timeout, 

338 cmd=cmd, 

339 ) 

340 return ToolResult( 

341 name=self.definition.name, 

342 success=timeout_result.success, 

343 output=timeout_result.output, 

344 issues_count=timeout_result.issues_count, 

345 issues=timeout_result.issues, 

346 ) 

347 except FileNotFoundError as e: 

348 return ToolResult( 

349 name=self.definition.name, 

350 success=False, 

351 output=f"Astro not found: {e}\n\n" 

352 "Please ensure astro is installed:\n" 

353 " - Run 'bun add astro' or 'npm install astro'\n" 

354 " - Or install globally: 'bun add -g astro'", 

355 issues_count=0, 

356 ) 

357 except OSError as e: 

358 logger.error("[astro-check] Failed to run astro check: {}", e) 

359 return ToolResult( 

360 name=self.definition.name, 

361 success=False, 

362 output="astro check execution failed: " + str(e), 

363 issues_count=0, 

364 ) 

365 

366 # Parse output 

367 all_issues = parse_astro_check_output(output=output or "") 

368 issues_count = len(all_issues) 

369 

370 # Normalize output for fallback analysis 

371 normalized_output = strip_ansi_codes(output) if output else "" 

372 

373 # Handle dependency errors 

374 if not success and issues_count == 0 and normalized_output: 

375 if ( 

376 "Cannot find module" in normalized_output 

377 or "Cannot find type definition" in normalized_output 

378 ): 

379 helpful_output = ( 

380 f"Astro check configuration error:\n{normalized_output}\n\n" 

381 "This usually means dependencies aren't installed.\n" 

382 "Suggestions:\n" 

383 " - Run 'bun install' or 'npm install' in your project\n" 

384 " - Use '--auto-install' flag to auto-install dependencies" 

385 ) 

386 return ToolResult( 

387 name=self.definition.name, 

388 success=False, 

389 output=helpful_output, 

390 issues_count=0, 

391 ) 

392 

393 # Generic failure 

394 return ToolResult( 

395 name=self.definition.name, 

396 success=False, 

397 output=normalized_output or "astro check execution failed.", 

398 issues_count=0, 

399 ) 

400 

401 if not success and issues_count == 0: 

402 return ToolResult( 

403 name=self.definition.name, 

404 success=False, 

405 output="astro check execution failed.", 

406 issues_count=0, 

407 ) 

408 

409 return ToolResult( 

410 name=self.definition.name, 

411 success=issues_count == 0, 

412 output=None, 

413 issues_count=issues_count, 

414 issues=all_issues, 

415 ) 

416 

417 def fix(self, paths: list[str], options: dict[str, object]) -> NoReturn: 

418 """Astro check does not support auto-fixing. 

419 

420 Args: 

421 paths: Paths or files passed for completeness. 

422 options: Runtime options (unused). 

423 

424 Raises: 

425 NotImplementedError: Always, because astro check cannot fix issues. 

426 """ 

427 raise NotImplementedError( 

428 "Astro check cannot automatically fix issues. Type errors require " 

429 "manual code changes.", 

430 )