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
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Astro check tool definition.
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.
7Example:
8 # Check Astro project
9 lintro check src/ --tools astro-check
11 # Check with specific root
12 lintro check . --tools astro-check --tool-options "astro-check:root=./packages/web"
13"""
15from __future__ import annotations
17import shutil
18import subprocess # nosec B404 - used safely with shell disabled
19from dataclasses import dataclass
20from pathlib import Path
21from typing import Any, NoReturn
23from loguru import logger
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
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)
49@register_tool
50@dataclass
51class AstroCheckPlugin(BaseToolPlugin):
52 """Astro check type checking plugin.
54 This plugin integrates the Astro check command with Lintro for static
55 type checking of Astro components.
56 """
58 @property
59 def definition(self) -> ToolDefinition:
60 """Return the tool definition.
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 )
83 def set_options(
84 self,
85 root: str | None = None,
86 **kwargs: Any,
87 ) -> None:
88 """Set astro-check-specific options.
90 Args:
91 root: Root directory for the Astro project.
92 **kwargs: Other tool options.
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")
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)
104 def _get_astro_command(self) -> list[str]:
105 """Get the command to run astro check.
107 Prefers direct astro executable, falls back to bunx/npx.
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"]
124 def _find_astro_config(self, cwd: Path) -> Path | None:
125 """Find astro config file by walking up from *cwd*.
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.
132 Args:
133 cwd: Starting directory for the upward search.
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
150 def _build_command(
151 self,
152 options: dict[str, object] | None = None,
153 ) -> list[str]:
154 """Build the astro check invocation command.
156 Args:
157 options: Options dict to use for flags. Defaults to self.options.
159 Returns:
160 A list of command arguments ready to be executed.
161 """
162 if options is None:
163 options = self.options
165 cmd: list[str] = self._get_astro_command()
167 # Root directory option
168 root = options.get("root")
169 if root:
170 cmd.extend(["--root", str(root)])
172 return cmd
174 # Canonical message for "no Astro files" early returns
175 _NO_FILES_MESSAGE: str = "No Astro files to check."
177 def doc_url(self, code: str) -> str | None:
178 """Return Astro TypeScript documentation URL.
180 Astro check emits TypeScript error codes. Links to the Astro
181 TypeScript guide since per-error pages are not available.
183 Args:
184 code: TypeScript error code (e.g., "TS2322").
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
193 def check(self, paths: list[str], options: dict[str, object]) -> ToolResult:
194 """Check files with astro check.
196 Astro check runs on the entire project and uses the project's
197 astro.config and tsconfig.json for configuration.
199 Args:
200 paths: List of file or directory paths to check.
201 options: Runtime options that override defaults.
203 Returns:
204 ToolResult with check results.
205 """
206 # Merge runtime options
207 merged_options = dict(self.options)
208 merged_options.update(options)
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 )
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
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 )
235 logger.debug("[astro-check] Discovered {} Astro file(s)", len(ctx.files))
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()
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 )
271 # Check if dependencies need installing
272 from lintro.utils.node_deps import install_node_deps, should_install_deps
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 )
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 )
324 # Build command
325 cmd = self._build_command(options=merged_options)
326 logger.debug("[astro-check] Running with cwd={} and cmd={}", cwd_path, cmd)
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 )
366 # Parse output
367 all_issues = parse_astro_check_output(output=output or "")
368 issues_count = len(all_issues)
370 # Normalize output for fallback analysis
371 normalized_output = strip_ansi_codes(output) if output else ""
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 )
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 )
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 )
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 )
417 def fix(self, paths: list[str], options: dict[str, object]) -> NoReturn:
418 """Astro check does not support auto-fixing.
420 Args:
421 paths: Paths or files passed for completeness.
422 options: Runtime options (unused).
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 )