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

1"""Vue-tsc tool definition. 

2 

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. 

6 

7Example: 

8 # Check Vue project 

9 lintro check src/ --tools vue-tsc 

10 

11 # With specific config 

12 lintro check src/ --tools vue-tsc --tool-options "vue-tsc:project=tsconfig.app.json" 

13""" 

14 

15from __future__ import annotations 

16 

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 

25 

26from loguru import logger 

27 

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 

44 

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"] 

49 

50 

51@register_tool 

52@dataclass 

53class VueTscPlugin(BaseToolPlugin): 

54 """Vue-tsc type checking plugin. 

55 

56 This plugin integrates vue-tsc with Lintro for static type checking 

57 of Vue Single File Components. 

58 """ 

59 

60 @property 

61 def definition(self) -> ToolDefinition: 

62 """Return the tool definition. 

63 

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 ) 

87 

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. 

97 

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. 

105 

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") 

117 

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) 

126 

127 @functools.cached_property 

128 def _vue_tsc_cmd(self) -> list[str]: 

129 """Get the command to run vue-tsc. 

130 

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. 

135 

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"] 

150 

151 def _find_tsconfig(self, cwd: Path) -> Path | None: 

152 """Find tsconfig.json in the working directory. 

153 

154 Checks for both tsconfig.json and tsconfig.app.json (Vite projects). 

155 

156 Args: 

157 cwd: Working directory to search for tsconfig.json. 

158 

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 

170 

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 

175 

176 # Check for tsconfig.json 

177 tsconfig = cwd / "tsconfig.json" 

178 return tsconfig if tsconfig.exists() else None 

179 

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. 

187 

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. 

192 

193 Returns: 

194 Path to the temporary tsconfig.json file. 

195 

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] 

201 

202 compiler_options: dict[str, Any] = { 

203 "noEmit": True, 

204 } 

205 

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 ) 

221 

222 temp_config = { 

223 "extends": str(abs_base), 

224 "include": abs_files, 

225 "exclude": [], 

226 "compilerOptions": compiler_options, 

227 } 

228 

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 

270 

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 

277 

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) 

285 

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. 

293 

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. 

298 

299 Returns: 

300 A list of command arguments ready to be executed. 

301 """ 

302 if options is None: 

303 options = self.options 

304 

305 cmd: list[str] = list(self._vue_tsc_cmd) 

306 

307 # Core flags for type checking only 

308 cmd.extend(["--noEmit", "--pretty", "false"]) 

309 

310 # Project flag 

311 if project_path: 

312 cmd.extend(["--project", str(project_path)]) 

313 

314 # Strict mode override 

315 if options.get("strict") is True: 

316 cmd.append("--strict") 

317 

318 # Skip lib check (faster, avoids issues with node_modules types) 

319 if options.get("skip_lib_check", True): 

320 cmd.append("--skipLibCheck") 

321 

322 # Only pass files directly if no project config is being used 

323 if not project_path and files: 

324 cmd.extend(files) 

325 

326 return cmd 

327 

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

329 """Return TypeScript error documentation URL for Vue. 

330 

331 Vue-tsc emits TypeScript error codes. Uses the same reference 

332 as tsc since the error codes are identical. 

333 

334 Args: 

335 code: TypeScript error code (e.g., "TS2322" or "2322"). 

336 

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 

347 

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

349 """Check files with vue-tsc. 

350 

351 Args: 

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

353 options: Runtime options that override defaults. 

354 

355 Returns: 

356 ToolResult with check results. 

357 """ 

358 # Merge runtime options 

359 merged_options = dict(self.options) 

360 merged_options.update(options) 

361 

362 # Use shared preparation 

363 ctx = self._prepare_execution( 

364 paths, 

365 merged_options, 

366 no_files_message="No Vue files to check.", 

367 ) 

368 

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

370 return ctx.early_result 

371 

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 ) 

379 

380 logger.debug("[vue-tsc] Discovered {} Vue file(s)", len(ctx.files)) 

381 

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

383 

384 # Check if dependencies need installing 

385 from lintro.utils.node_deps import install_node_deps, should_install_deps 

386 

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 ) 

399 

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 ) 

434 

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 

440 

441 try: 

442 # Find existing tsconfig.json 

443 base_tsconfig = self._find_tsconfig(cwd_path) 

444 

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 ) 

469 

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) 

477 

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 ) 

515 

516 # Parse output 

517 all_issues = parse_vue_tsc_output(output=output or "") 

518 issues_count = len(all_issues) 

519 

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

521 

522 # Categorize issues 

523 type_errors, dependency_errors = categorize_vue_tsc_issues(all_issues) 

524 

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}") 

537 

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 ) 

547 

548 if type_errors: 

549 dep_output_lines.insert(0, f"Type errors: {len(type_errors)}") 

550 dep_output_lines.insert(1, "") 

551 

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 ) 

559 

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 ) 

579 

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 ) 

586 

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 ) 

594 

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 ) 

616 

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

618 """Vue-tsc does not support auto-fixing. 

619 

620 Args: 

621 paths: Paths or files passed for completeness. 

622 options: Runtime options (unused). 

623 

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 )