Coverage for lintro / tools / definitions / vue_tsc.py: 74%
212 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"""Vue-tsc tool definition.
3Vue-tsc is the TypeScript type checker for Vue Single File Components (SFCs).
4This enables proper type checking for `.vue` files that regular `tsc` cannot
5handle.
7Example:
8 # Check Vue project
9 lintro check src/ --tools vue-tsc
11 # With specific config
12 lintro check src/ --tools vue-tsc --tool-options "vue-tsc:project=tsconfig.app.json"
13"""
15from __future__ import annotations
17import functools
18import json
19import shutil
20import subprocess # nosec B404 - used safely with shell disabled
21import tempfile
22from dataclasses import dataclass
23from pathlib import Path
24from typing import Any, NoReturn
26from loguru import logger
28from lintro._tool_versions import get_min_version
29from lintro.enums.doc_url_template import DocUrlTemplate
30from lintro.enums.tool_name import ToolName
31from lintro.enums.tool_type import ToolType
32from lintro.models.core.tool_result import ToolResult
33from lintro.parsers.base_parser import strip_ansi_codes
34from lintro.parsers.vue_tsc.vue_tsc_parser import (
35 categorize_vue_tsc_issues,
36 extract_missing_modules,
37 parse_vue_tsc_output,
38)
39from lintro.plugins.base import BaseToolPlugin
40from lintro.plugins.protocol import ToolDefinition
41from lintro.plugins.registry import register_tool
42from lintro.tools.core.timeout_utils import create_timeout_result
43from lintro.utils.jsonc import extract_type_roots, load_jsonc
45# Constants for Vue-tsc configuration
46VUE_TSC_DEFAULT_TIMEOUT: int = 120
47VUE_TSC_DEFAULT_PRIORITY: int = 83 # After tsc (82)
48VUE_TSC_FILE_PATTERNS: list[str] = ["*.vue"]
51@register_tool
52@dataclass
53class VueTscPlugin(BaseToolPlugin):
54 """Vue-tsc type checking plugin.
56 This plugin integrates vue-tsc with Lintro for static type checking
57 of Vue Single File Components.
58 """
60 @property
61 def definition(self) -> ToolDefinition:
62 """Return the tool definition.
64 Returns:
65 ToolDefinition containing tool metadata.
66 """
67 return ToolDefinition(
68 name="vue-tsc",
69 description="Vue TypeScript type checker for Vue SFC diagnostics",
70 can_fix=False,
71 tool_type=ToolType.LINTER | ToolType.TYPE_CHECKER,
72 file_patterns=VUE_TSC_FILE_PATTERNS,
73 priority=VUE_TSC_DEFAULT_PRIORITY,
74 conflicts_with=[],
75 native_configs=["tsconfig.json", "tsconfig.app.json"],
76 version_command=self._vue_tsc_cmd + ["--version"],
77 min_version=get_min_version(ToolName.VUE_TSC),
78 default_options={
79 "timeout": VUE_TSC_DEFAULT_TIMEOUT,
80 "project": None,
81 "strict": None,
82 "skip_lib_check": True,
83 "use_project_files": False,
84 },
85 default_timeout=VUE_TSC_DEFAULT_TIMEOUT,
86 )
88 def set_options(
89 self,
90 project: str | None = None,
91 strict: bool | None = None,
92 skip_lib_check: bool | None = None,
93 use_project_files: bool | None = None,
94 **kwargs: Any,
95 ) -> None:
96 """Set vue-tsc-specific options.
98 Args:
99 project: Path to tsconfig.json file.
100 strict: Enable strict type checking mode.
101 skip_lib_check: Skip type checking of declaration files (default: True).
102 use_project_files: When True, use tsconfig.json's include/files patterns
103 instead of lintro's file targeting. Default is False.
104 **kwargs: Other tool options.
106 Raises:
107 ValueError: If any provided option is of an unexpected type.
108 """
109 if project is not None and not isinstance(project, str):
110 raise ValueError("project must be a string path")
111 if strict is not None and not isinstance(strict, bool):
112 raise ValueError("strict must be a boolean")
113 if skip_lib_check is not None and not isinstance(skip_lib_check, bool):
114 raise ValueError("skip_lib_check must be a boolean")
115 if use_project_files is not None and not isinstance(use_project_files, bool):
116 raise ValueError("use_project_files must be a boolean")
118 options: dict[str, object] = {
119 "project": project,
120 "strict": strict,
121 "skip_lib_check": skip_lib_check,
122 "use_project_files": use_project_files,
123 }
124 options = {k: v for k, v in options.items() if v is not None}
125 super().set_options(**options, **kwargs)
127 @functools.cached_property
128 def _vue_tsc_cmd(self) -> list[str]:
129 """Get the command to run vue-tsc.
131 Prefers direct vue-tsc executable, falls back to bunx/npx.
132 The result is cached so that repeated accesses (e.g. from the
133 ``definition`` property and ``_build_command``) reuse the stored
134 command without repeated ``shutil.which()`` lookups.
136 Returns:
137 Command arguments for vue-tsc.
138 """
139 # Prefer direct executable if available
140 if shutil.which("vue-tsc"):
141 return ["vue-tsc"]
142 # Try bunx (bun)
143 if shutil.which("bunx"):
144 return ["bunx", "vue-tsc"]
145 # Try npx (npm)
146 if shutil.which("npx"):
147 return ["npx", "vue-tsc"]
148 # Last resort
149 return ["vue-tsc"]
151 def _find_tsconfig(self, cwd: Path) -> Path | None:
152 """Find tsconfig.json in the working directory.
154 Checks for both tsconfig.json and tsconfig.app.json (Vite projects).
156 Args:
157 cwd: Working directory to search for tsconfig.json.
159 Returns:
160 Path to tsconfig.json if found, None otherwise.
161 """
162 # Check explicit project option first
163 project_opt = self.options.get("project")
164 if project_opt and isinstance(project_opt, str):
165 project_path = Path(project_opt)
166 if project_path.is_absolute():
167 return project_path if project_path.exists() else None
168 resolved = cwd / project_path
169 return resolved if resolved.exists() else None
171 # Check for tsconfig.app.json first (Vite Vue projects)
172 tsconfig_app = cwd / "tsconfig.app.json"
173 if tsconfig_app.exists():
174 return tsconfig_app
176 # Check for tsconfig.json
177 tsconfig = cwd / "tsconfig.json"
178 return tsconfig if tsconfig.exists() else None
180 def _create_temp_tsconfig(
181 self,
182 base_tsconfig: Path,
183 files: list[str],
184 cwd: Path,
185 ) -> Path:
186 """Create a temporary tsconfig.json that extends the base config.
188 Args:
189 base_tsconfig: Path to the original tsconfig.json to extend.
190 files: List of file paths to include (relative to cwd).
191 cwd: Working directory for resolving paths.
193 Returns:
194 Path to the temporary tsconfig.json file.
196 Raises:
197 OSError: If writing the temporary file fails.
198 """
199 abs_base = base_tsconfig.resolve()
200 abs_files = [str((cwd / f).resolve()) for f in files]
202 compiler_options: dict[str, Any] = {
203 "noEmit": True,
204 }
206 # Read typeRoots from the base tsconfig so they are preserved in the
207 # temp config. TypeScript resolves typeRoots relative to the config
208 # file, so we resolve them to absolute paths here because the temp
209 # config lives in a different directory.
210 try:
211 base_content = load_jsonc(abs_base.read_text(encoding="utf-8"))
212 resolved_roots = extract_type_roots(base_content, abs_base.parent)
213 if resolved_roots is not None:
214 compiler_options["typeRoots"] = resolved_roots
215 except (json.JSONDecodeError, OSError) as exc:
216 logger.debug(
217 "[vue-tsc] Could not read typeRoots from {}: {}",
218 abs_base,
219 exc,
220 )
222 temp_config = {
223 "extends": str(abs_base),
224 "include": abs_files,
225 "exclude": [],
226 "compilerOptions": compiler_options,
227 }
229 # Create temp file next to the base tsconfig so TypeScript can resolve
230 # types/typeRoots by walking up from the temp file to node_modules.
231 # Falls back to system temp dir with explicit typeRoots for read-only
232 # filesystems (e.g. Docker volume mounts).
233 try:
234 fd, temp_path = tempfile.mkstemp(
235 suffix=".json",
236 prefix=".lintro-vue-tsc-",
237 dir=abs_base.parent,
238 )
239 except OSError:
240 fd, temp_path = tempfile.mkstemp(
241 suffix=".json",
242 prefix="lintro-vue-tsc-",
243 )
244 # Preserve existing typeRoots from the base tsconfig and add
245 # the default node_modules/@types path so TypeScript can still
246 # resolve type packages from the system temp dir.
247 existing_type_roots: list[str] = []
248 type_roots_explicit = False
249 try:
250 base_content = load_jsonc(
251 base_tsconfig.read_text(encoding="utf-8"),
252 )
253 extracted = extract_type_roots(base_content, abs_base.parent)
254 if extracted is not None:
255 existing_type_roots = extracted
256 type_roots_explicit = True
257 except (json.JSONDecodeError, OSError):
258 pass
259 default_root = str(cwd / "node_modules" / "@types")
260 # Add the default root when typeRoots was absent or had
261 # entries (the temp file lives outside the project tree so
262 # TypeScript cannot discover it by walking up). When the
263 # user explicitly set typeRoots: [] to disable global types,
264 # honour that intent and leave the list empty.
265 if (
266 not type_roots_explicit or existing_type_roots
267 ) and default_root not in existing_type_roots:
268 existing_type_roots.append(default_root)
269 compiler_options["typeRoots"] = existing_type_roots
271 try:
272 with open(fd, "w", encoding="utf-8") as f:
273 json.dump(temp_config, f, indent=2)
274 except OSError:
275 Path(temp_path).unlink(missing_ok=True)
276 raise
278 logger.debug(
279 "[vue-tsc] Created temp tsconfig at {} extending {} with {} files",
280 temp_path,
281 abs_base,
282 len(files),
283 )
284 return Path(temp_path)
286 def _build_command(
287 self,
288 files: list[str],
289 project_path: str | Path | None = None,
290 options: dict[str, object] | None = None,
291 ) -> list[str]:
292 """Build the vue-tsc invocation command.
294 Args:
295 files: Relative file paths (used only when no project config).
296 project_path: Path to tsconfig.json to use.
297 options: Options dict to use for flags. Defaults to self.options.
299 Returns:
300 A list of command arguments ready to be executed.
301 """
302 if options is None:
303 options = self.options
305 cmd: list[str] = list(self._vue_tsc_cmd)
307 # Core flags for type checking only
308 cmd.extend(["--noEmit", "--pretty", "false"])
310 # Project flag
311 if project_path:
312 cmd.extend(["--project", str(project_path)])
314 # Strict mode override
315 if options.get("strict") is True:
316 cmd.append("--strict")
318 # Skip lib check (faster, avoids issues with node_modules types)
319 if options.get("skip_lib_check", True):
320 cmd.append("--skipLibCheck")
322 # Only pass files directly if no project config is being used
323 if not project_path and files:
324 cmd.extend(files)
326 return cmd
328 def doc_url(self, code: str) -> str | None:
329 """Return TypeScript error documentation URL for Vue.
331 Vue-tsc emits TypeScript error codes. Uses the same reference
332 as tsc since the error codes are identical.
334 Args:
335 code: TypeScript error code (e.g., "TS2322" or "2322").
337 Returns:
338 URL to the TypeScript error documentation, or None if invalid.
339 """
340 if not code:
341 return None
342 upper = code.upper()
343 num = code[2:] if upper.startswith("TS") else code
344 if num.isdigit():
345 return DocUrlTemplate.TSC.format(code=num)
346 return None
348 def check(self, paths: list[str], options: dict[str, object]) -> ToolResult:
349 """Check files with vue-tsc.
351 Args:
352 paths: List of file or directory paths to check.
353 options: Runtime options that override defaults.
355 Returns:
356 ToolResult with check results.
357 """
358 # Merge runtime options
359 merged_options = dict(self.options)
360 merged_options.update(options)
362 # Use shared preparation
363 ctx = self._prepare_execution(
364 paths,
365 merged_options,
366 no_files_message="No Vue files to check.",
367 )
369 if ctx.should_skip and ctx.early_result is not None:
370 return ctx.early_result
372 if ctx.should_skip:
373 return ToolResult(
374 name=self.definition.name,
375 success=True,
376 output="No Vue files to check.",
377 issues_count=0,
378 )
380 logger.debug("[vue-tsc] Discovered {} Vue file(s)", len(ctx.files))
382 cwd_path = Path(ctx.cwd) if ctx.cwd else Path.cwd()
384 # Check if dependencies need installing
385 from lintro.utils.node_deps import install_node_deps, should_install_deps
387 try:
388 needs_install = should_install_deps(cwd_path)
389 except PermissionError as e:
390 logger.warning("[vue-tsc] {}", e)
391 return ToolResult(
392 name=self.definition.name,
393 success=True,
394 output=f"Skipping vue-tsc: {e}",
395 issues_count=0,
396 skipped=True,
397 skip_reason="directory not writable",
398 )
400 if needs_install:
401 auto_install = merged_options.get("auto_install", False)
402 if auto_install:
403 logger.info("[vue-tsc] Auto-installing Node.js dependencies...")
404 install_ok, install_output = install_node_deps(cwd_path)
405 if install_ok:
406 logger.info("[vue-tsc] Dependencies installed successfully")
407 else:
408 logger.warning(
409 "[vue-tsc] Auto-install failed, skipping: {}",
410 install_output,
411 )
412 return ToolResult(
413 name=self.definition.name,
414 success=True,
415 output=(
416 f"Skipping vue-tsc: auto-install failed.\n"
417 f"{install_output}"
418 ),
419 issues_count=0,
420 skipped=True,
421 skip_reason="auto-install failed",
422 )
423 else:
424 return ToolResult(
425 name=self.definition.name,
426 output=(
427 "node_modules not found. "
428 "Use --auto-install to install dependencies."
429 ),
430 issues_count=0,
431 skipped=True,
432 skip_reason="node_modules not found",
433 )
435 use_project_files = merged_options.get("use_project_files", False)
436 explicit_project_opt = merged_options.get("project")
437 explicit_project = str(explicit_project_opt) if explicit_project_opt else None
438 temp_tsconfig: Path | None = None
439 project_path: str | None = None
441 try:
442 # Find existing tsconfig.json
443 base_tsconfig = self._find_tsconfig(cwd_path)
445 if use_project_files or explicit_project:
446 project_path = explicit_project or (
447 str(base_tsconfig) if base_tsconfig else None
448 )
449 logger.debug(
450 "[vue-tsc] Using native tsconfig file selection: {}",
451 project_path,
452 )
453 elif base_tsconfig:
454 temp_tsconfig = self._create_temp_tsconfig(
455 base_tsconfig=base_tsconfig,
456 files=ctx.rel_files,
457 cwd=cwd_path,
458 )
459 project_path = str(temp_tsconfig)
460 logger.debug(
461 "[vue-tsc] Using temp tsconfig for file targeting: {}",
462 project_path,
463 )
464 else:
465 project_path = None
466 logger.debug(
467 "[vue-tsc] No tsconfig.json found, passing files directly",
468 )
470 # Build command
471 cmd = self._build_command(
472 files=ctx.rel_files if not project_path else [],
473 project_path=project_path,
474 options=merged_options,
475 )
476 logger.debug("[vue-tsc] Running with cwd={} and cmd={}", ctx.cwd, cmd)
478 try:
479 success, output = self._run_subprocess(
480 cmd=cmd,
481 timeout=ctx.timeout,
482 cwd=ctx.cwd,
483 )
484 except subprocess.TimeoutExpired:
485 timeout_result = create_timeout_result(
486 tool=self,
487 timeout=ctx.timeout,
488 cmd=cmd,
489 )
490 return ToolResult(
491 name=self.definition.name,
492 success=timeout_result.success,
493 output=timeout_result.output,
494 issues_count=timeout_result.issues_count,
495 issues=timeout_result.issues,
496 )
497 except FileNotFoundError as e:
498 return ToolResult(
499 name=self.definition.name,
500 success=False,
501 output=f"vue-tsc not found: {e}\n\n"
502 "Please ensure vue-tsc is installed:\n"
503 " - Run 'bun add -D vue-tsc' or 'npm install -D vue-tsc'\n"
504 " - Or install globally: 'bun add -g vue-tsc'",
505 issues_count=0,
506 )
507 except OSError as e:
508 logger.error("[vue-tsc] Failed to run vue-tsc: {}", e)
509 return ToolResult(
510 name=self.definition.name,
511 success=False,
512 output="vue-tsc execution failed: " + str(e),
513 issues_count=0,
514 )
516 # Parse output
517 all_issues = parse_vue_tsc_output(output=output or "")
518 issues_count = len(all_issues)
520 normalized_output = strip_ansi_codes(output) if output else ""
522 # Categorize issues
523 type_errors, dependency_errors = categorize_vue_tsc_issues(all_issues)
525 # Handle dependency errors
526 if dependency_errors:
527 missing_modules = extract_missing_modules(dependency_errors)
528 dep_output_lines = [
529 "Missing dependencies detected:",
530 f" {len(dependency_errors)} dependency error(s)",
531 ]
532 if missing_modules:
533 modules_str = ", ".join(missing_modules[:10])
534 if len(missing_modules) > 10:
535 modules_str += f", ... (+{len(missing_modules) - 10} more)"
536 dep_output_lines.append(f" Missing: {modules_str}")
538 dep_output_lines.extend(
539 [
540 "",
541 "Suggestions:",
542 " - Run 'bun install' or 'npm install' in your project",
543 " - Use '--auto-install' flag to auto-install dependencies",
544 " - If using Docker, ensure node_modules is available",
545 ],
546 )
548 if type_errors:
549 dep_output_lines.insert(0, f"Type errors: {len(type_errors)}")
550 dep_output_lines.insert(1, "")
552 return ToolResult(
553 name=self.definition.name,
554 success=False,
555 output="\n".join(dep_output_lines),
556 issues_count=issues_count,
557 issues=all_issues,
558 )
560 if not success and issues_count == 0 and normalized_output:
561 if (
562 "Cannot find type definition file" in normalized_output
563 or "Cannot find module" in normalized_output
564 ):
565 helpful_output = (
566 f"vue-tsc configuration error:\n{normalized_output}\n\n"
567 "This usually means dependencies aren't installed.\n"
568 "Suggestions:\n"
569 " - Run 'bun install' or 'npm install' in your project\n"
570 " - Use '--auto-install' flag to auto-install dependencies\n"
571 " - If using Docker, ensure node_modules is available"
572 )
573 return ToolResult(
574 name=self.definition.name,
575 success=False,
576 output=helpful_output,
577 issues_count=0,
578 )
580 return ToolResult(
581 name=self.definition.name,
582 success=False,
583 output=normalized_output or "vue-tsc execution failed.",
584 issues_count=0,
585 )
587 if not success and issues_count == 0:
588 return ToolResult(
589 name=self.definition.name,
590 success=False,
591 output="vue-tsc execution failed.",
592 issues_count=0,
593 )
595 return ToolResult(
596 name=self.definition.name,
597 success=success and issues_count == 0,
598 output=None,
599 issues_count=issues_count,
600 issues=all_issues,
601 )
602 finally:
603 # Clean up temp tsconfig
604 if temp_tsconfig and temp_tsconfig.exists():
605 try:
606 temp_tsconfig.unlink()
607 logger.debug(
608 "[vue-tsc] Cleaned up temp tsconfig: {}",
609 temp_tsconfig,
610 )
611 except OSError as e:
612 logger.warning(
613 "[vue-tsc] Failed to clean up temp tsconfig: {}",
614 e,
615 )
617 def fix(self, paths: list[str], options: dict[str, object]) -> NoReturn:
618 """Vue-tsc does not support auto-fixing.
620 Args:
621 paths: Paths or files passed for completeness.
622 options: Runtime options (unused).
624 Raises:
625 NotImplementedError: Always, because vue-tsc cannot fix issues.
626 """
627 raise NotImplementedError(
628 "vue-tsc cannot automatically fix issues. Type errors require "
629 "manual code changes.",
630 )