Coverage for lintro / tools / definitions / tsc.py: 79%
228 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"""Tsc (TypeScript Compiler) tool definition.
3Tsc is the TypeScript compiler which performs static type checking on
4TypeScript files. It helps catch type-related bugs before runtime by
5analyzing type annotations and inferences.
7File Targeting Behavior:
8 By default, lintro respects your file selection even when tsconfig.json exists.
9 This is achieved by creating a temporary tsconfig that extends your project's
10 config but overrides the `include` pattern to target only the specified files.
12 To use native tsconfig.json file selection instead, set `use_project_files=True`.
14Example:
15 # Check only specific files (default behavior)
16 lintro check src/utils.ts --tools tsc
18 # Check all files defined in tsconfig.json
19 lintro check . --tools tsc --tool-options "tsc:use_project_files=True"
20"""
22from __future__ import annotations
24import json
25import shutil
26import subprocess # nosec B404 - used safely with shell disabled
27import tempfile
28from dataclasses import dataclass
29from pathlib import Path
30from typing import Any, NoReturn
32from loguru import logger
34from lintro._tool_versions import get_min_version
35from lintro.enums.doc_url_template import DocUrlTemplate
36from lintro.enums.tool_name import ToolName
37from lintro.enums.tool_type import ToolType
38from lintro.models.core.tool_result import ToolResult
39from lintro.parsers.base_parser import strip_ansi_codes
40from lintro.parsers.tsc.tsc_parser import (
41 categorize_tsc_issues,
42 extract_missing_modules,
43 parse_tsc_output,
44)
45from lintro.plugins.base import BaseToolPlugin
46from lintro.plugins.protocol import ToolDefinition
47from lintro.plugins.registry import register_tool
48from lintro.tools.core.timeout_utils import create_timeout_result
49from lintro.utils.jsonc import extract_type_roots, load_jsonc
51# Constants for Tsc configuration
52TSC_DEFAULT_TIMEOUT: int = 60
53TSC_DEFAULT_PRIORITY: int = 82 # Same as mypy (type checkers)
54TSC_FILE_PATTERNS: list[str] = ["*.ts", "*.tsx", "*.mts", "*.cts"]
56# Framework config files that indicate tsc should defer to framework-specific checker
57# Note: vite.config.ts is NOT included for Vue because it's used by many
58# non-Vue projects (e.g., React, vanilla TS, Svelte without svelte.config)
59FRAMEWORK_CONFIGS: dict[str, tuple[str, list[str]]] = {
60 "Astro": (
61 "astro-check",
62 ["astro.config.mjs", "astro.config.ts", "astro.config.js"],
63 ),
64 "Vue": (
65 "vue-tsc",
66 ["vue.config.js", "vue.config.ts"],
67 ),
68 "Svelte": (
69 "svelte-check",
70 ["svelte.config.js", "svelte.config.ts"],
71 ),
72}
75@register_tool
76@dataclass
77class TscPlugin(BaseToolPlugin):
78 """TypeScript Compiler (tsc) type checking plugin.
80 This plugin integrates the TypeScript compiler with Lintro for static
81 type checking of TypeScript files.
82 """
84 @property
85 def definition(self) -> ToolDefinition:
86 """Return the tool definition.
88 Returns:
89 ToolDefinition containing tool metadata.
90 """
91 return ToolDefinition(
92 name="tsc",
93 description="TypeScript compiler for static type checking",
94 can_fix=False,
95 tool_type=ToolType.LINTER | ToolType.TYPE_CHECKER,
96 file_patterns=TSC_FILE_PATTERNS,
97 priority=TSC_DEFAULT_PRIORITY,
98 conflicts_with=[],
99 native_configs=["tsconfig.json"],
100 version_command=["tsc", "--version"],
101 min_version=get_min_version(ToolName.TSC),
102 default_options={
103 "timeout": TSC_DEFAULT_TIMEOUT,
104 "project": None,
105 "strict": None,
106 "skip_lib_check": True,
107 "use_project_files": False,
108 },
109 default_timeout=TSC_DEFAULT_TIMEOUT,
110 )
112 def set_options(
113 self,
114 project: str | None = None,
115 strict: bool | None = None,
116 skip_lib_check: bool | None = None,
117 use_project_files: bool | None = None,
118 **kwargs: Any,
119 ) -> None:
120 """Set tsc-specific options.
122 Args:
123 project: Path to tsconfig.json file.
124 strict: Enable strict type checking mode.
125 skip_lib_check: Skip type checking of declaration files (default: True).
126 use_project_files: When True, use tsconfig.json's include/files patterns
127 instead of lintro's file targeting. Default is False, meaning lintro
128 respects your file selection even when tsconfig.json exists.
129 **kwargs: Other tool options.
131 Raises:
132 ValueError: If any provided option is of an unexpected type.
133 """
134 if project is not None and not isinstance(project, str):
135 raise ValueError("project must be a string path")
136 if strict is not None and not isinstance(strict, bool):
137 raise ValueError("strict must be a boolean")
138 if skip_lib_check is not None and not isinstance(skip_lib_check, bool):
139 raise ValueError("skip_lib_check must be a boolean")
140 if use_project_files is not None and not isinstance(use_project_files, bool):
141 raise ValueError("use_project_files must be a boolean")
143 options: dict[str, object] = {
144 "project": project,
145 "strict": strict,
146 "skip_lib_check": skip_lib_check,
147 "use_project_files": use_project_files,
148 }
149 options = {k: v for k, v in options.items() if v is not None}
150 super().set_options(**options, **kwargs)
152 def _get_tsc_command(self) -> list[str]:
153 """Get the command to run tsc.
155 Prefers direct tsc executable, falls back to bunx/npx.
157 Returns:
158 Command arguments for tsc.
159 """
160 # Prefer direct executable if available
161 if shutil.which("tsc"):
162 return ["tsc"]
163 # Try bunx (bun) - note: bunx tsc works if typescript is installed
164 if shutil.which("bunx"):
165 return ["bunx", "tsc"]
166 # Try npx (npm)
167 if shutil.which("npx"):
168 return ["npx", "tsc"]
169 # Last resort - hope tsc is in PATH
170 return ["tsc"]
172 def _find_tsconfig(self, cwd: Path) -> Path | None:
173 """Find tsconfig.json in the working directory or via project option.
175 Args:
176 cwd: Working directory to search for tsconfig.json.
178 Returns:
179 Path to tsconfig.json if found, None otherwise.
180 """
181 # Check explicit project option first
182 project_opt = self.options.get("project")
183 if project_opt and isinstance(project_opt, str):
184 project_path = Path(project_opt)
185 if project_path.is_absolute():
186 return project_path if project_path.exists() else None
187 resolved = cwd / project_path
188 return resolved if resolved.exists() else None
190 # Check for tsconfig.json in cwd
191 tsconfig = cwd / "tsconfig.json"
192 return tsconfig if tsconfig.exists() else None
194 def _detect_framework_project(self, cwd: Path) -> tuple[str, str] | None:
195 """Detect if the project uses a framework with its own type checker.
197 Frameworks like Astro, Vue, and Svelte have their own type checkers
198 that handle framework-specific syntax (e.g., .astro, .vue, .svelte files).
199 When these frameworks are detected, tsc should skip and defer to the
200 framework-specific tool.
202 Args:
203 cwd: Working directory to search for framework config files.
205 Returns:
206 Tuple of (framework_name, recommended_tool) if detected, None otherwise.
207 """
208 for framework_name, (tool_name, config_files) in FRAMEWORK_CONFIGS.items():
209 for config_file in config_files:
210 if (cwd / config_file).exists():
211 logger.debug(
212 "[tsc] Detected {} project (found {})",
213 framework_name,
214 config_file,
215 )
216 return (framework_name, tool_name)
217 return None
219 def _create_temp_tsconfig(
220 self,
221 base_tsconfig: Path,
222 files: list[str],
223 cwd: Path,
224 ) -> Path:
225 """Create a temporary tsconfig.json that extends the base config.
227 This allows lintro to respect user file selection while preserving
228 all compiler options from the project's tsconfig.json.
230 Args:
231 base_tsconfig: Path to the original tsconfig.json to extend.
232 files: List of file paths to include (relative to cwd).
233 cwd: Working directory for resolving paths.
235 Returns:
236 Path to the temporary tsconfig.json file.
238 Raises:
239 OSError: If the temporary file cannot be created or written.
240 """
241 abs_base = base_tsconfig.resolve()
243 # Convert relative file paths to absolute paths since the temp tsconfig
244 # may be in a different directory than cwd
245 abs_files = [str((cwd / f).resolve()) for f in files]
247 compiler_options: dict[str, Any] = {
248 # Ensure noEmit is set (type checking only)
249 "noEmit": True,
250 }
252 # Read typeRoots from the base tsconfig so they are preserved in the
253 # temp config. TypeScript resolves typeRoots relative to the config
254 # file, so we resolve them to absolute paths here because the temp
255 # config lives in a different directory.
256 try:
257 base_content = load_jsonc(abs_base.read_text(encoding="utf-8"))
258 resolved_roots = extract_type_roots(base_content, abs_base.parent)
259 if resolved_roots is not None:
260 compiler_options["typeRoots"] = resolved_roots
261 except (json.JSONDecodeError, OSError) as exc:
262 logger.debug("[tsc] Could not read typeRoots from {}: {}", abs_base, exc)
264 temp_config = {
265 "extends": str(abs_base),
266 "include": abs_files,
267 "exclude": [],
268 "compilerOptions": compiler_options,
269 }
271 # Create temp file next to the base tsconfig so TypeScript can resolve
272 # types/typeRoots by walking up from the temp file to node_modules.
273 # Falls back to system temp dir with explicit typeRoots for read-only
274 # filesystems (e.g. Docker volume mounts).
275 try:
276 fd, temp_path = tempfile.mkstemp(
277 suffix=".json",
278 prefix=".lintro-tsc-",
279 dir=abs_base.parent,
280 )
281 except OSError:
282 fd, temp_path = tempfile.mkstemp(
283 suffix=".json",
284 prefix="lintro-tsc-",
285 )
286 # Preserve existing typeRoots from the base tsconfig and add
287 # the default node_modules/@types path so TypeScript can still
288 # resolve type packages from the system temp dir.
289 existing_type_roots: list[str] = []
290 type_roots_explicit = False
291 try:
292 base_content = load_jsonc(
293 base_tsconfig.read_text(encoding="utf-8"),
294 )
295 extracted = extract_type_roots(base_content, abs_base.parent)
296 if extracted is not None:
297 existing_type_roots = extracted
298 type_roots_explicit = True
299 except (json.JSONDecodeError, OSError):
300 pass
301 default_root = str(cwd / "node_modules" / "@types")
302 # Add the default root when typeRoots was absent or had
303 # entries (the temp file lives outside the project tree so
304 # TypeScript cannot discover it by walking up). When the
305 # user explicitly set typeRoots: [] to disable global types,
306 # honour that intent and leave the list empty.
307 if (
308 not type_roots_explicit or existing_type_roots
309 ) and default_root not in existing_type_roots:
310 existing_type_roots.append(default_root)
311 compiler_options["typeRoots"] = existing_type_roots
313 try:
314 with open(fd, "w", encoding="utf-8") as f:
315 json.dump(temp_config, f, indent=2)
316 except OSError:
317 # Clean up on failure
318 Path(temp_path).unlink(missing_ok=True)
319 raise
321 logger.debug(
322 "[tsc] Created temp tsconfig at {} extending {} with {} files",
323 temp_path,
324 abs_base,
325 len(files),
326 )
327 return Path(temp_path)
329 def _build_command(
330 self,
331 files: list[str],
332 project_path: str | Path | None = None,
333 options: dict[str, object] | None = None,
334 ) -> list[str]:
335 """Build the tsc invocation command.
337 Args:
338 files: Relative file paths (used only when no project config).
339 project_path: Path to tsconfig.json to use (temp or user-specified).
340 options: Options dict to use for flags. Defaults to self.options.
342 Returns:
343 A list of command arguments ready to be executed.
344 """
345 if options is None:
346 options = self.options
348 cmd: list[str] = self._get_tsc_command()
350 # Core flags for linting (no output, machine-readable format)
351 cmd.extend(["--noEmit", "--pretty", "false"])
353 # Project flag (uses tsconfig.json - either temp, explicit, or auto-discovered)
354 if project_path:
355 cmd.extend(["--project", str(project_path)])
357 # Strict mode override (--strict is off by default, no flag needed for False)
358 if options.get("strict") is True:
359 cmd.append("--strict")
361 # Skip lib check (faster, avoids issues with node_modules types)
362 if options.get("skip_lib_check", True):
363 cmd.append("--skipLibCheck")
365 # Only pass files directly if no project config is being used
366 if not project_path and files:
367 cmd.extend(files)
369 return cmd
371 def doc_url(self, code: str) -> str | None:
372 """Return TypeScript error documentation URL.
374 Uses typescript.tv, a third-party error reference, since the
375 official TypeScript handbook does not provide per-error pages.
377 Args:
378 code: TypeScript error code (e.g., "TS2307" or "2307").
380 Returns:
381 URL to the TypeScript error documentation, or None if invalid.
382 """
383 if not code:
384 return None
385 # Strip "TS"/"ts" prefix if present to get the numeric portion
386 upper = code.upper()
387 num = code[2:] if upper.startswith("TS") else code
388 if num.isdigit():
389 return DocUrlTemplate.TSC.format(code=num)
390 return None
392 def check(self, paths: list[str], options: dict[str, object]) -> ToolResult:
393 """Check files with tsc.
395 By default, lintro respects your file selection even when tsconfig.json exists.
396 This is achieved by creating a temporary tsconfig that extends your project's
397 config but targets only the specified files.
399 To use native tsconfig.json file selection instead, set use_project_files=True.
401 Note: For projects using Astro, Vue, or Svelte, tsc will skip and recommend
402 using the framework-specific type checker (astro-check, vue-tsc, svelte-check)
403 which handles framework-specific syntax.
405 Args:
406 paths: List of file or directory paths to check.
407 options: Runtime options that override defaults.
409 Returns:
410 ToolResult with check results.
411 """
412 # Merge runtime options
413 merged_options = dict(self.options)
414 merged_options.update(options)
416 # Determine working directory for framework detection
417 # Verify the path exists before using it (paths[0] could be a glob or missing)
418 cwd_for_detection = Path.cwd()
419 if paths:
420 candidate = Path(paths[0]).resolve()
421 if candidate.exists():
422 if candidate.is_file():
423 cwd_for_detection = candidate.parent
424 else:
425 cwd_for_detection = candidate
426 elif candidate.parent.exists():
427 cwd_for_detection = candidate.parent
429 # Check for framework-specific projects that have their own type checkers
430 framework_info = self._detect_framework_project(cwd_for_detection)
431 if framework_info:
432 framework_name, recommended_tool = framework_info
433 return ToolResult(
434 name=self.definition.name,
435 success=True,
436 output=(
437 f"SKIPPED: {framework_name} project detected.\n"
438 f"{framework_name} has its own type checker that handles "
439 f"framework-specific syntax.\n"
440 f"Use '{recommended_tool}' instead: "
441 f"lintro check . --tools {recommended_tool}"
442 ),
443 issues_count=0,
444 skipped=True,
445 skip_reason=f"deferred to {recommended_tool}",
446 )
448 # Use shared preparation for version check, path validation, file discovery
449 ctx = self._prepare_execution(
450 paths,
451 merged_options,
452 no_files_message="No TypeScript files to check.",
453 )
455 if ctx.should_skip and ctx.early_result is not None:
456 return ctx.early_result
458 # Safety check: if should_skip but no early_result, create one
459 if ctx.should_skip:
460 return ToolResult(
461 name=self.definition.name,
462 success=True,
463 output="No TypeScript files to check.",
464 issues_count=0,
465 )
467 logger.debug("[tsc] Discovered {} TypeScript file(s)", len(ctx.files))
469 # Determine project configuration strategy
470 cwd_path = Path(ctx.cwd) if ctx.cwd else Path.cwd()
472 # Check if dependencies need installing
473 from lintro.utils.node_deps import install_node_deps, should_install_deps
475 try:
476 needs_install = should_install_deps(cwd_path)
477 except PermissionError as e:
478 logger.warning("[tsc] {}", e)
479 return ToolResult(
480 name=self.definition.name,
481 success=True,
482 output=f"Skipping tsc: {e}",
483 issues_count=0,
484 skipped=True,
485 skip_reason="directory not writable",
486 )
488 if needs_install:
489 auto_install = merged_options.get("auto_install", False)
490 if auto_install:
491 logger.info("[tsc] Auto-installing Node.js dependencies...")
492 install_ok, install_output = install_node_deps(cwd_path)
493 if install_ok:
494 logger.info("[tsc] Dependencies installed successfully")
495 else:
496 logger.warning(
497 "[tsc] Auto-install failed, skipping: {}",
498 install_output,
499 )
500 return ToolResult(
501 name=self.definition.name,
502 success=True,
503 output=(
504 f"Skipping tsc: auto-install failed.\n" f"{install_output}"
505 ),
506 issues_count=0,
507 skipped=True,
508 skip_reason="auto-install failed",
509 )
510 else:
511 return ToolResult(
512 name=self.definition.name,
513 success=True,
514 output=(
515 "node_modules not found. "
516 "Use --auto-install to install dependencies."
517 ),
518 issues_count=0,
519 skipped=True,
520 skip_reason="node_modules not found",
521 )
523 use_project_files = merged_options.get("use_project_files", False)
524 explicit_project_opt = merged_options.get("project")
525 explicit_project = str(explicit_project_opt) if explicit_project_opt else None
526 temp_tsconfig: Path | None = None
527 project_path: str | None = None
529 try:
530 # Find existing tsconfig.json
531 base_tsconfig = self._find_tsconfig(cwd_path)
533 if use_project_files or explicit_project:
534 # Native mode: use tsconfig.json as-is for file selection
535 # or explicit project path was provided
536 project_path = explicit_project or (
537 str(base_tsconfig) if base_tsconfig else None
538 )
539 logger.debug(
540 "[tsc] Using native tsconfig file selection: {}",
541 project_path,
542 )
543 elif base_tsconfig:
544 # Lintro mode: create temp tsconfig to respect file targeting
545 # while preserving compiler options from the project's config
546 temp_tsconfig = self._create_temp_tsconfig(
547 base_tsconfig=base_tsconfig,
548 files=ctx.rel_files,
549 cwd=cwd_path,
550 )
551 project_path = str(temp_tsconfig)
552 logger.debug(
553 "[tsc] Using temp tsconfig for file targeting: {}",
554 project_path,
555 )
556 else:
557 # No tsconfig.json found - pass files directly
558 project_path = None
559 logger.debug("[tsc] No tsconfig.json found, passing files directly")
561 # Build command
562 cmd = self._build_command(
563 files=ctx.rel_files if not project_path else [],
564 project_path=project_path,
565 options=merged_options,
566 )
567 logger.debug("[tsc] Running with cwd={} and cmd={}", ctx.cwd, cmd)
569 try:
570 success, output = self._run_subprocess(
571 cmd=cmd,
572 timeout=ctx.timeout,
573 cwd=ctx.cwd,
574 )
575 except subprocess.TimeoutExpired:
576 timeout_result = create_timeout_result(
577 tool=self,
578 timeout=ctx.timeout,
579 cmd=cmd,
580 )
581 return ToolResult(
582 name=self.definition.name,
583 success=timeout_result.success,
584 output=timeout_result.output,
585 issues_count=timeout_result.issues_count,
586 issues=timeout_result.issues,
587 )
588 except FileNotFoundError as e:
589 return ToolResult(
590 name=self.definition.name,
591 success=False,
592 output=f"TypeScript compiler not found: {e}\n\n"
593 "Please ensure tsc is installed:\n"
594 " - Run 'npm install -g typescript' or 'bun add -g typescript'\n"
595 " - Or install locally: 'npm install typescript'",
596 issues_count=0,
597 )
598 except OSError as e:
599 logger.error("[tsc] Failed to run tsc: {}", e)
600 return ToolResult(
601 name=self.definition.name,
602 success=False,
603 output="tsc execution failed: " + str(e),
604 issues_count=0,
605 )
607 # Parse output (parser handles ANSI stripping internally)
608 all_issues = parse_tsc_output(output=output or "")
609 issues_count = len(all_issues)
611 # Normalize output for fallback substring matching below
612 normalized_output = strip_ansi_codes(output) if output else ""
614 # Categorize issues into type errors vs dependency errors
615 type_errors, dependency_errors = categorize_tsc_issues(all_issues)
617 # If we have dependency errors, provide helpful guidance
618 if dependency_errors:
619 missing_modules = extract_missing_modules(dependency_errors)
620 dep_output_lines = [
621 "Missing dependencies detected:",
622 f" {len(dependency_errors)} dependency error(s)",
623 ]
624 if missing_modules:
625 modules_str = ", ".join(missing_modules[:10])
626 if len(missing_modules) > 10:
627 modules_str += f", ... (+{len(missing_modules) - 10} more)"
628 dep_output_lines.append(f" Missing: {modules_str}")
630 dep_output_lines.extend(
631 [
632 "",
633 "Suggestions:",
634 " - Run 'bun install' or 'npm install' in your project",
635 " - Use '--auto-install' flag to auto-install dependencies",
636 " - If using Docker, ensure node_modules is available",
637 ],
638 )
640 # If there are also type errors, show both
641 if type_errors:
642 dep_output_lines.insert(
643 0,
644 f"Type errors: {len(type_errors)}",
645 )
646 dep_output_lines.insert(1, "")
648 # Return all issues but with helpful output
649 return ToolResult(
650 name=self.definition.name,
651 success=False,
652 output="\n".join(dep_output_lines),
653 issues_count=issues_count,
654 issues=all_issues,
655 )
657 if not success and issues_count == 0 and normalized_output:
658 # Execution failed but no structured issues were parsed.
659 # This can happen with malformed output or non-standard error formats.
660 # Detect common dependency/configuration errors via substring matching
661 # as a fallback when the parser couldn't extract structured issues.
663 # Type definition errors (usually means node_modules not installed)
664 if (
665 "Cannot find type definition file" in normalized_output
666 or "Cannot find module" in normalized_output
667 ):
668 helpful_output = (
669 f"TypeScript configuration error:\n{normalized_output}\n\n"
670 "This usually means dependencies aren't installed.\n"
671 "Suggestions:\n"
672 " - Run 'bun install' or 'npm install' in your project\n"
673 " - Use '--auto-install' flag to auto-install dependencies\n"
674 " - If using Docker, ensure node_modules is available\n"
675 " - Use --tool-options 'tsc:skip_lib_check=true' to skip "
676 "type checking of declaration files"
677 )
678 return ToolResult(
679 name=self.definition.name,
680 success=False,
681 output=helpful_output,
682 issues_count=0,
683 )
685 # Generic failure
686 return ToolResult(
687 name=self.definition.name,
688 success=False,
689 output=normalized_output or "tsc execution failed.",
690 issues_count=0,
691 )
693 if not success and issues_count == 0:
694 # No output - generic failure
695 return ToolResult(
696 name=self.definition.name,
697 success=False,
698 output="tsc execution failed.",
699 issues_count=0,
700 )
702 return ToolResult(
703 name=self.definition.name,
704 success=issues_count == 0,
705 output=None,
706 issues_count=issues_count,
707 issues=all_issues,
708 )
709 finally:
710 # Clean up temp tsconfig
711 if temp_tsconfig and temp_tsconfig.exists():
712 try:
713 temp_tsconfig.unlink()
714 logger.debug("[tsc] Cleaned up temp tsconfig: {}", temp_tsconfig)
715 except OSError as e:
716 logger.warning("[tsc] Failed to clean up temp tsconfig: {}", e)
718 def fix(self, paths: list[str], options: dict[str, object]) -> NoReturn:
719 """Tsc does not support auto-fixing.
721 Args:
722 paths: Paths or files passed for completeness.
723 options: Runtime options (unused).
725 Raises:
726 NotImplementedError: Always, because tsc cannot fix issues.
727 """
728 raise NotImplementedError(
729 "Tsc cannot automatically fix issues. Type errors require "
730 "manual code changes.",
731 )