Coverage for lintro / cli_utils / commands / doctor.py: 74%
261 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"""Doctor command for checking tool installation status and version compatibility.
3Provides a Flutter doctor-style diagnostic that checks ALL tools, grouped by
4install category, with context-aware install hints.
5"""
7from __future__ import annotations
9import json
10import shutil
11import subprocess
12import sys
13from dataclasses import asdict, dataclass
15import click
16from rich.console import Console
17from rich.text import Text
19from lintro.enums.tool_status import ToolStatus
20from lintro.tools.core.install_context import RuntimeContext
21from lintro.tools.core.install_strategies import get_strategy
22from lintro.tools.core.tool_registry import CATEGORY_LABELS, ManifestTool, ToolRegistry
23from lintro.tools.core.version_parsing import (
24 compare_versions,
25 extract_version_from_output,
26)
27from lintro.utils.environment import (
28 EnvironmentReport,
29 collect_full_environment,
30 render_environment_report,
31)
34@dataclass
35class ToolCheckResult:
36 """Result of a tool health check.
38 Attributes:
39 tool: The manifest tool entry.
40 status: ToolStatus value (OK, MISSING, OUTDATED, UNKNOWN).
41 installed_version: Detected version string, or None.
42 error: Error type if check failed.
43 details: Additional error details.
44 path: Filesystem path where the tool was found.
45 install_hint: Context-aware install command.
46 upgrade_hint: Context-aware upgrade command for outdated tools.
47 """
49 tool: ManifestTool
50 status: ToolStatus
51 installed_version: str | None = None
52 error: str | None = None
53 details: str | None = None
54 path: str | None = None
55 install_hint: str = ""
56 upgrade_hint: str = ""
59def _check_tool(tool: ManifestTool, context: RuntimeContext) -> ToolCheckResult:
60 """Check a single tool's installation status and version.
62 Args:
63 tool: Manifest tool entry.
64 context: Runtime context for install hints.
66 Returns:
67 ToolCheckResult with status and details.
68 """
69 strategy = get_strategy(tool.install_type)
70 env = context.environment
71 if strategy:
72 _args = (
73 env,
74 tool.name,
75 tool.version,
76 tool.install_package,
77 tool.install_component,
78 )
79 hint = strategy.install_hint(*_args)
80 upgrade_hint = strategy.upgrade_hint(*_args)
81 else:
82 hint = f"Install {tool.name} manually"
83 upgrade_hint = f"Upgrade {tool.name} manually"
85 if not tool.version_command:
86 return ToolCheckResult(
87 tool=tool,
88 status=ToolStatus.MISSING,
89 error="no_command",
90 details="No version command defined",
91 install_hint=hint,
92 upgrade_hint=upgrade_hint,
93 )
95 # Find the main executable (may be a wrapper like "sh", "cargo", etc.)
96 main_cmd = tool.version_command[0]
97 tool_path = shutil.which(main_cmd)
99 if not tool_path:
100 return ToolCheckResult(
101 tool=tool,
102 status=ToolStatus.MISSING,
103 error="not_in_path",
104 details=main_cmd,
105 install_hint=hint,
106 upgrade_hint=upgrade_hint,
107 )
109 try:
110 result = subprocess.run(
111 tool.version_command,
112 capture_output=True,
113 text=True,
114 timeout=10,
115 check=False,
116 )
117 output = result.stdout + result.stderr
119 if result.returncode != 0:
120 return ToolCheckResult(
121 tool=tool,
122 status=ToolStatus.MISSING,
123 error="command_failed",
124 details=f"Exit {result.returncode}: {output[:100]}",
125 path=tool_path,
126 install_hint=hint,
127 )
129 version = extract_version_from_output(output, tool.name)
130 if not version:
131 return ToolCheckResult(
132 tool=tool,
133 status=ToolStatus.UNKNOWN,
134 error="no_version",
135 details=f"Output: {output[:100]}",
136 path=tool_path,
137 install_hint=hint,
138 )
140 status = _compare_versions(version, tool.version)
141 return ToolCheckResult(
142 tool=tool,
143 status=status,
144 installed_version=version,
145 path=tool_path,
146 install_hint=hint,
147 upgrade_hint=upgrade_hint,
148 )
149 except subprocess.TimeoutExpired:
150 return ToolCheckResult(
151 tool=tool,
152 status=ToolStatus.MISSING,
153 error="timeout",
154 path=tool_path,
155 install_hint=hint,
156 upgrade_hint=upgrade_hint,
157 )
158 except (FileNotFoundError, OSError) as e:
159 return ToolCheckResult(
160 tool=tool,
161 status=ToolStatus.MISSING,
162 error="os_error",
163 details=str(e),
164 install_hint=hint,
165 upgrade_hint=upgrade_hint,
166 )
169def _compare_versions(installed: str, expected: str) -> ToolStatus:
170 """Compare installed version against expected minimum.
172 Delegates to version_parsing.compare_versions which uses the
173 packaging library for robust PEP 440 version comparison.
175 Args:
176 installed: Installed version string.
177 expected: Expected minimum version string.
179 Returns:
180 ToolStatus.OK, ToolStatus.OUTDATED, or ToolStatus.UNKNOWN.
181 """
182 try:
183 return (
184 ToolStatus.OK
185 if compare_versions(installed, expected) >= 0
186 else ToolStatus.OUTDATED
187 )
188 except ValueError:
189 return ToolStatus.UNKNOWN
192def _render_category(
193 console: Console,
194 category_label: str,
195 results: list[ToolCheckResult],
196 *,
197 verbose: bool = False,
198 is_dev: bool = False,
199) -> None:
200 """Render a category section of the doctor output.
202 Args:
203 console: Rich console for output.
204 category_label: Section header (e.g., "Bundled Python tools").
205 results: Check results for this category.
206 verbose: Show paths and extra details.
207 is_dev: If True, mark missing tools as optional.
208 """
209 ok_count = sum(1 for r in results if r.status == ToolStatus.OK)
210 total = len(results)
211 console.print()
213 header = Text(f" {category_label} ", style="bold")
214 header.append(f"({ok_count}/{total} OK)", style="dim")
215 console.print(header)
217 for r in sorted(results, key=lambda x: x.tool.name):
218 _render_tool_line(console, r, verbose=verbose, is_dev=is_dev)
221def _render_tool_line(
222 console: Console,
223 r: ToolCheckResult,
224 *,
225 verbose: bool = False,
226 is_dev: bool = False,
227) -> None:
228 """Render a single tool's status line.
230 Args:
231 console: Rich console.
232 r: Tool check result.
233 verbose: Show extra details.
234 is_dev: Mark missing as optional instead of error.
235 """
236 name = f"{r.tool.name:<16}"
237 expected = f"(>= {r.tool.version})"
239 if r.status == ToolStatus.OK:
240 line = Text(" ")
241 line.append("[OK] ", style="green bold")
242 line.append(name, style="cyan")
243 line.append(f"{r.installed_version:<10}", style="yellow")
244 line.append(expected, style="dim")
245 if verbose and r.path:
246 line.append(f" {r.path}", style="dim")
247 console.print(line)
249 elif r.status == ToolStatus.OUTDATED:
250 line = Text(" ")
251 line.append("[!!] ", style="yellow bold")
252 line.append(name, style="cyan")
253 line.append(f"{r.installed_version:<10}", style="yellow")
254 line.append(expected, style="dim")
255 console.print(line)
256 console.print(f" [dim]Upgrade: {r.upgrade_hint}[/dim]")
258 elif r.status == ToolStatus.MISSING:
259 line = Text(" ")
260 if is_dev:
261 line.append("[--] ", style="dim")
262 line.append(name, style="dim")
263 line.append("not installed (optional)", style="dim")
264 else:
265 line.append("[!!] ", style="red bold")
266 line.append(name, style="cyan")
267 line.append("not installed", style="red")
268 console.print(line)
269 console.print(f" [dim]Install: {r.install_hint}[/dim]")
271 else: # unknown
272 line = Text(" ")
273 line.append("[??] ", style="dim")
274 line.append(name, style="cyan")
275 line.append("version unknown", style="dim")
276 console.print(line)
277 if verbose and r.details:
278 console.print(f" [dim]{r.details}[/dim]")
281def _generate_markdown_report(
282 env: EnvironmentReport,
283 context: RuntimeContext,
284 results_by_cat: dict[str, list[ToolCheckResult]],
285 dev_results: list[ToolCheckResult],
286) -> str:
287 """Generate a markdown report for GitHub issues.
289 Args:
290 env: Environment report.
291 context: Runtime context.
292 results_by_cat: Results grouped by category.
293 dev_results: Dev-tier tool results.
295 Returns:
296 Markdown string.
297 """
298 lines = ["### Environment", "", "```"]
299 lines.append(f"Lintro: {env.lintro.version}")
300 lines.append(
301 f"Context: {context.install_context} ({context.platform_label})",
302 )
303 lines.append(f"OS: {env.system.platform_name} ({env.system.architecture})")
304 lines.append(f"Python: {env.python.version}")
305 if env.node:
306 lines.append(f"Node: {env.node.version or 'installed'}")
307 if env.rust:
308 lines.append(f"Rust: {env.rust.rustc_version or 'installed'}")
309 lines.append("```")
310 lines.append("")
312 lines.append("### Tool Versions")
313 lines.append("")
314 lines.append("| Category | Tool | Installed | Expected | Status |")
315 lines.append("|----------|------|-----------|----------|--------|")
317 for cat, results in results_by_cat.items():
318 label = CATEGORY_LABELS.get(cat, cat)
319 for r in sorted(results, key=lambda x: x.tool.name):
320 installed = r.installed_version or "-"
321 status_icon = {
322 ToolStatus.OK: "OK",
323 ToolStatus.MISSING: "MISSING",
324 ToolStatus.OUTDATED: "OUTDATED",
325 ToolStatus.UNKNOWN: "?",
326 }.get(r.status, "?")
327 lines.append(
328 f"| {label} | {r.tool.name} | {installed} "
329 f"| {r.tool.version} | {status_icon} |",
330 )
331 label = ""
333 if dev_results:
334 for r in dev_results:
335 installed = r.installed_version or "-"
336 status = r.status.upper()
337 lines.append(
338 f"| Dev (optional) | {r.tool.name} | {installed} "
339 f"| {r.tool.version} | {status} |",
340 )
342 lines.append("")
343 return "\n".join(lines)
346@click.command()
347@click.option("--json", "json_output", is_flag=True, help="Output as JSON.")
348@click.option(
349 "--tools",
350 type=str,
351 help="Comma-separated list of tools to check (default: all).",
352)
353@click.option(
354 "--verbose",
355 "-v",
356 is_flag=True,
357 help="Show comprehensive environment information and tool paths.",
358)
359@click.option(
360 "--report",
361 is_flag=True,
362 help="Generate markdown report for GitHub issues.",
363)
364@click.option(
365 "--fix",
366 is_flag=True,
367 help="Attempt to install missing tools.",
368)
369def doctor_command(
370 json_output: bool,
371 tools: str | None,
372 *,
373 verbose: bool,
374 report: bool,
375 fix: bool,
376) -> None:
377 """Check tool installation status and version compatibility.
379 Checks all supported tools grouped by category (bundled, npm, external).
380 Shows actionable install commands for missing or outdated tools.
382 Args:
383 json_output: Output results as JSON.
384 tools: Comma-separated tool names to check.
385 verbose: Show environment details and tool paths.
386 report: Generate markdown report.
387 fix: Attempt to install missing tools.
389 Raises:
390 SystemExit: When missing or broken tools are detected.
391 click.UsageError: When --fix is combined with --report or --json.
393 Examples:
394 lintro doctor
395 lintro doctor --tools hadolint,actionlint
396 lintro doctor --json
397 lintro doctor --verbose
398 lintro doctor --fix
399 """
400 display_console = Console()
402 registry = ToolRegistry.load()
403 context = RuntimeContext.detect()
405 env_report = None
406 if verbose or report or json_output:
407 env_report = collect_full_environment()
409 # Determine which tools to check
410 if tools:
411 tool_names = [t.strip() for t in tools.split(",") if t.strip()]
412 unknown_names = [n for n in tool_names if n not in registry]
413 if unknown_names:
414 display_console.print(
415 f" [red]Unknown tools: {', '.join(unknown_names)}[/red]",
416 )
417 available = ", ".join(
418 sorted(t.name for t in registry.all_tools(include_dev=True)),
419 )
420 display_console.print(f" [dim]Available: {available}[/dim]")
421 raise SystemExit(1)
422 tools_to_check = [registry.get(n) for n in tool_names]
423 else:
424 tools_to_check = list(registry.all_tools(include_dev=True))
426 # Check all tools
427 all_results = [_check_tool(tool, context) for tool in tools_to_check]
429 # Split into production and dev
430 prod_results = [r for r in all_results if r.tool.tier != "dev"]
431 dev_results = [r for r in all_results if r.tool.tier == "dev"]
433 # Group production results by category
434 results_by_cat: dict[str, list[ToolCheckResult]] = {}
435 for r in prod_results:
436 results_by_cat.setdefault(r.tool.category, []).append(r)
438 # Stats (dev tools don't count as failures)
439 ok_count = sum(1 for r in prod_results if r.status == ToolStatus.OK)
440 missing_count = sum(1 for r in prod_results if r.status == ToolStatus.MISSING)
441 outdated_count = sum(1 for r in prod_results if r.status == ToolStatus.OUTDATED)
442 unknown_count = sum(1 for r in prod_results if r.status == ToolStatus.UNKNOWN)
443 dev_ok = sum(1 for r in dev_results if r.status == ToolStatus.OK)
444 dev_total = len(dev_results)
445 total_prod = len(prod_results)
447 # ── Reject incompatible flag combinations ──
448 if fix and (report or json_output):
449 raise click.UsageError("--fix cannot be combined with --report or --json")
451 # ── Markdown report mode ──
452 if report:
453 assert env_report is not None
454 markdown = _generate_markdown_report(
455 env_report,
456 context,
457 results_by_cat,
458 dev_results,
459 )
460 click.echo(markdown)
461 if missing_count > 0 or outdated_count > 0 or unknown_count > 0:
462 sys.exit(1)
463 return
465 # ── JSON output mode ──
466 if json_output:
467 _output_json(
468 all_results,
469 context,
470 env_report,
471 ok_count,
472 missing_count,
473 outdated_count,
474 unknown_count,
475 )
476 if missing_count > 0 or outdated_count > 0 or unknown_count > 0:
477 sys.exit(1)
478 return
480 # ── Rich terminal output ──
481 display_console.print()
482 display_console.print(" [bold]Lintro Doctor[/bold]")
483 display_console.print(
484 f" [dim]Context: {context.install_context.value}, "
485 f"{context.platform_label}[/dim]",
486 )
488 if verbose and env_report:
489 render_environment_report(display_console, env_report)
491 for cat in ("bundled", "npm", "external"):
492 if cat in results_by_cat:
493 label = CATEGORY_LABELS.get(cat, cat)
494 _render_category(
495 display_console,
496 label,
497 results_by_cat[cat],
498 verbose=verbose,
499 )
501 if dev_results:
502 _render_category(
503 display_console,
504 "Dev tools",
505 dev_results,
506 verbose=verbose,
507 is_dev=True,
508 )
510 # Summary
511 display_console.print()
512 summary_parts: list[str] = []
513 summary_parts.append(f"[green]{ok_count}[/green] OK")
514 if missing_count > 0:
515 summary_parts.append(f"[red]{missing_count}[/red] missing")
516 if outdated_count > 0:
517 summary_parts.append(f"[yellow]{outdated_count}[/yellow] outdated")
518 if unknown_count > 0:
519 summary_parts.append(f"[dim]{unknown_count}[/dim] unknown")
520 if dev_total > 0:
521 summary_parts.append(f"[dim]{dev_ok}/{dev_total}[/dim] dev")
523 display_console.print(
524 f" Summary: {', '.join(summary_parts)} "
525 f"[dim]({total_prod} production tools)[/dim]",
526 )
528 has_fixable = missing_count > 0 or outdated_count > 0
529 if has_fixable:
530 display_console.print()
531 affected_names = [
532 r.tool.name
533 for r in prod_results
534 if r.status in (ToolStatus.MISSING, ToolStatus.OUTDATED)
535 ]
536 upgrade_flag = " --upgrade" if outdated_count > 0 else ""
537 display_console.print(
538 f" [dim]Quick fix: lintro install{upgrade_flag}"
539 f" {' '.join(affected_names)}[/dim]",
540 )
542 display_console.print()
544 if fix and has_fixable:
545 _run_fix(display_console, prod_results, context, registry)
546 # Re-check after fix attempt (unknown may resolve to ok/missing/outdated)
547 rechecked = [_check_tool(tool, context) for tool in tools_to_check]
548 rechecked_prod = [r for r in rechecked if r.tool.tier != "dev"]
549 missing_count = sum(1 for r in rechecked_prod if r.status == ToolStatus.MISSING)
550 outdated_count = sum(
551 1 for r in rechecked_prod if r.status == ToolStatus.OUTDATED
552 )
553 unknown_count = sum(1 for r in rechecked_prod if r.status == ToolStatus.UNKNOWN)
555 if missing_count > 0 or outdated_count > 0 or unknown_count > 0:
556 raise SystemExit(1)
559def _output_json(
560 all_results: list[ToolCheckResult],
561 context: RuntimeContext,
562 env_report: EnvironmentReport | None,
563 ok_count: int,
564 missing_count: int,
565 outdated_count: int,
566 unknown_count: int,
567) -> None:
568 """Output doctor results as JSON."""
569 tools_json: dict[str, dict[str, str | None]] = {}
570 issues: list[dict[str, str]] = []
572 for r in all_results:
573 tools_json[r.tool.name] = {
574 "expected": r.tool.version,
575 "installed": r.installed_version,
576 "status": r.status,
577 "category": r.tool.category,
578 "tier": r.tool.tier,
579 "install_type": r.tool.install_type,
580 "error": r.error,
581 "details": r.details,
582 "path": r.path,
583 "install_hint": r.install_hint,
584 "upgrade_hint": r.upgrade_hint,
585 }
586 if r.status == ToolStatus.MISSING and r.tool.tier != "dev":
587 issues.append(
588 {
589 "tool": r.tool.name,
590 "severity": "error",
591 "message": f"not installed ({r.error or 'unknown'})",
592 "install_hint": r.install_hint,
593 },
594 )
595 elif r.status == ToolStatus.OUTDATED and r.tool.tier != "dev":
596 issues.append(
597 {
598 "tool": r.tool.name,
599 "severity": "warning",
600 "message": (f"outdated ({r.installed_version} < {r.tool.version})"),
601 "upgrade_hint": r.upgrade_hint,
602 },
603 )
604 elif r.status == ToolStatus.UNKNOWN and r.tool.tier != "dev":
605 issues.append(
606 {
607 "tool": r.tool.name,
608 "severity": "warning",
609 "message": f"version unknown ({r.error or 'unparseable output'})",
610 "install_hint": r.install_hint,
611 },
612 )
614 output: dict[str, object] = {
615 "context": {
616 "install_method": context.install_context.value,
617 "platform": context.platform_label,
618 "is_ci": context.is_ci,
619 },
620 "tools": tools_json,
621 "issues": issues,
622 "summary": {
623 "total": ok_count + missing_count + outdated_count + unknown_count,
624 "ok": ok_count,
625 "missing": missing_count,
626 "outdated": outdated_count,
627 "unknown": unknown_count,
628 },
629 }
631 if env_report:
632 output["environment"] = {
633 "lintro": asdict(env_report.lintro),
634 "system": asdict(env_report.system),
635 "python": asdict(env_report.python),
636 "node": asdict(env_report.node) if env_report.node else None,
637 "rust": asdict(env_report.rust) if env_report.rust else None,
638 }
640 click.echo(json.dumps(output, indent=2))
643def _run_fix(
644 console: Console,
645 results: list[ToolCheckResult],
646 context: RuntimeContext,
647 registry: ToolRegistry,
648) -> None:
649 """Attempt to install missing/outdated tools via the central installer."""
650 from lintro.tools.core.tool_installer import ToolInstaller
652 fixable = [
653 r for r in results if r.status in (ToolStatus.MISSING, ToolStatus.OUTDATED)
654 ]
655 if not fixable:
656 return
658 console.print(" [bold]Attempting to install missing tools...[/bold]")
659 console.print()
661 installer = ToolInstaller(registry, context)
662 tool_names = [r.tool.name for r in fixable]
663 has_outdated = any(r.status == ToolStatus.OUTDATED for r in fixable)
664 plan = installer.plan(tools=tool_names, upgrade=has_outdated)
665 install_results = installer.execute(plan)
667 for r in install_results:
668 if r.success:
669 console.print(
670 f" [green]OK[/green] {r.tool.name} "
671 f"[dim]({r.duration_seconds:.1f}s)[/dim]",
672 )
673 else:
674 console.print(f" [red]FAIL[/red] {r.tool.name}: {r.message}")
676 if plan.skipped:
677 for tool, reason in plan.skipped:
678 console.print(f" [yellow]SKIP[/yellow] {tool.name}: {reason}")
680 console.print()
681 console.print(" [dim]Run 'lintro doctor' again to verify.[/dim]")