Coverage for lintro / tools / definitions / shellcheck.py: 98%
85 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"""Shellcheck tool definition.
3ShellCheck is a static analysis tool for shell scripts. It identifies bugs,
4syntax issues, and suggests improvements for bash/sh/dash/ksh scripts.
5"""
7from __future__ import annotations
9import subprocess # nosec B404 - used safely with shell disabled
10from dataclasses import dataclass
11from typing import Any
13from lintro._tool_versions import get_min_version
14from lintro.enums.doc_url_template import DocUrlTemplate
15from lintro.enums.tool_name import ToolName
16from lintro.enums.tool_type import ToolType
17from lintro.models.core.tool_result import ToolResult
18from lintro.parsers.shellcheck.shellcheck_parser import parse_shellcheck_output
19from lintro.plugins.base import BaseToolPlugin
20from lintro.plugins.file_processor import FileProcessingResult
21from lintro.plugins.protocol import ToolDefinition
22from lintro.plugins.registry import register_tool
23from lintro.tools.core.option_validators import (
24 filter_none_options,
25 validate_list,
26 validate_str,
27)
29# Constants for Shellcheck configuration
30SHELLCHECK_DEFAULT_TIMEOUT: int = 30
31SHELLCHECK_DEFAULT_PRIORITY: int = 50
32SHELLCHECK_FILE_PATTERNS: list[str] = ["*.sh", "*.bash", "*.ksh"]
33SHELLCHECK_DEFAULT_FORMAT: str = "json1"
34SHELLCHECK_DEFAULT_SEVERITY: str = "style"
36# Valid severity levels for shellcheck
37SHELLCHECK_SEVERITY_LEVELS: tuple[str, ...] = ("error", "warning", "info", "style")
39# Valid shell dialects for shellcheck (official: bash, sh, dash, ksh)
40SHELLCHECK_SHELL_DIALECTS: tuple[str, ...] = ("bash", "sh", "dash", "ksh")
43def normalize_shellcheck_severity(value: str) -> str:
44 """Normalize shellcheck severity level.
46 Args:
47 value: Severity level string to normalize.
49 Returns:
50 Normalized severity level string (lowercase).
52 Raises:
53 ValueError: If the severity level is not valid.
54 """
55 normalized = value.lower()
56 if normalized not in SHELLCHECK_SEVERITY_LEVELS:
57 valid = ", ".join(SHELLCHECK_SEVERITY_LEVELS)
58 raise ValueError(f"Invalid severity level: {value!r}. Valid levels: {valid}")
59 return normalized
62def normalize_shellcheck_shell(value: str) -> str:
63 """Normalize shellcheck shell dialect.
65 Args:
66 value: Shell dialect string to normalize.
68 Returns:
69 Normalized shell dialect string (lowercase).
71 Raises:
72 ValueError: If the shell dialect is not valid.
73 """
74 normalized = value.lower()
75 if normalized not in SHELLCHECK_SHELL_DIALECTS:
76 valid = ", ".join(SHELLCHECK_SHELL_DIALECTS)
77 raise ValueError(f"Invalid shell dialect: {value!r}. Valid dialects: {valid}")
78 return normalized
81@register_tool
82@dataclass
83class ShellcheckPlugin(BaseToolPlugin):
84 """ShellCheck shell script linter plugin.
86 This plugin integrates ShellCheck with Lintro for checking shell scripts
87 against best practices and identifying potential bugs.
88 """
90 @property
91 def definition(self) -> ToolDefinition:
92 """Return the tool definition.
94 Returns:
95 ToolDefinition containing tool metadata.
96 """
97 return ToolDefinition(
98 name="shellcheck",
99 description=(
100 "Static analysis tool for shell scripts that identifies bugs and "
101 "suggests improvements"
102 ),
103 can_fix=False,
104 tool_type=ToolType.LINTER,
105 file_patterns=SHELLCHECK_FILE_PATTERNS,
106 priority=SHELLCHECK_DEFAULT_PRIORITY,
107 conflicts_with=[],
108 native_configs=[".shellcheckrc"],
109 version_command=["shellcheck", "--version"],
110 min_version=get_min_version(ToolName.SHELLCHECK),
111 default_options={
112 "timeout": SHELLCHECK_DEFAULT_TIMEOUT,
113 "severity": SHELLCHECK_DEFAULT_SEVERITY,
114 "exclude": None,
115 "shell": None,
116 },
117 default_timeout=SHELLCHECK_DEFAULT_TIMEOUT,
118 )
120 def set_options(
121 self,
122 severity: str | None = None,
123 exclude: list[str] | None = None,
124 shell: str | None = None,
125 **kwargs: Any,
126 ) -> None:
127 """Set Shellcheck-specific options.
129 Args:
130 severity: Minimum severity to report (error, warning, info, style).
131 exclude: List of codes to exclude (e.g., ["SC2086", "SC2046"]).
132 shell: Force shell dialect (bash, sh, dash, ksh).
133 **kwargs: Other tool options.
134 """
135 if severity is not None:
136 severity = normalize_shellcheck_severity(severity)
138 if shell is not None:
139 shell = normalize_shellcheck_shell(shell)
141 validate_list(exclude, "exclude")
142 validate_str(severity, "severity")
143 validate_str(shell, "shell")
145 options = filter_none_options(
146 severity=severity,
147 exclude=exclude,
148 shell=shell, # nosec B604 - shell is dialect, not subprocess shell=True
149 )
150 super().set_options(**options, **kwargs)
152 def doc_url(self, code: str) -> str | None:
153 """Return ShellCheck wiki URL for the given code.
155 Args:
156 code: ShellCheck code (e.g., "SC2086").
158 Returns:
159 URL to the ShellCheck wiki page.
160 """
161 if code:
162 return DocUrlTemplate.SHELLCHECK.format(code=code.upper())
163 return None
165 def _build_command(self) -> list[str]:
166 """Build the shellcheck command.
168 Returns:
169 List of command arguments.
170 """
171 cmd: list[str] = ["shellcheck"]
173 # Always use json1 format for reliable parsing
174 cmd.extend(["--format", SHELLCHECK_DEFAULT_FORMAT])
176 # Add severity option
177 severity = str(self.options.get("severity") or SHELLCHECK_DEFAULT_SEVERITY)
178 cmd.extend(["--severity", severity])
180 # Add exclude codes
181 exclude_opt = self.options.get("exclude")
182 if exclude_opt is not None and isinstance(exclude_opt, list):
183 for code in exclude_opt:
184 cmd.extend(["--exclude", str(code)])
186 # Add shell dialect
187 shell_opt = self.options.get("shell")
188 if shell_opt is not None:
189 cmd.extend(["--shell", str(shell_opt)])
191 return cmd
193 def _process_single_file(
194 self,
195 file_path: str,
196 timeout: int,
197 ) -> FileProcessingResult:
198 """Process a single shell script with shellcheck.
200 Args:
201 file_path: Path to the shell script to process.
202 timeout: Timeout in seconds for the shellcheck command.
204 Returns:
205 FileProcessingResult with processing outcome.
206 """
207 cmd = self._build_command() + [str(file_path)]
208 try:
209 success, output = self._run_subprocess(cmd=cmd, timeout=timeout)
210 issues = parse_shellcheck_output(output=output)
211 return FileProcessingResult(
212 success=success,
213 output=output,
214 issues=issues,
215 )
216 except subprocess.TimeoutExpired:
217 return FileProcessingResult(
218 success=False,
219 output="",
220 issues=[],
221 skipped=True,
222 )
223 except (OSError, ValueError, RuntimeError) as e:
224 return FileProcessingResult(
225 success=False,
226 output="",
227 issues=[],
228 error=str(e),
229 )
231 def check(self, paths: list[str], options: dict[str, object]) -> ToolResult:
232 """Check files with Shellcheck.
234 Args:
235 paths: List of file or directory paths to check.
236 options: Runtime options that override defaults.
238 Returns:
239 ToolResult with check results.
240 """
241 ctx = self._prepare_execution(paths=paths, options=options)
242 if ctx.should_skip:
243 return ctx.early_result # type: ignore[return-value]
245 result = self._process_files_with_progress(
246 files=ctx.files,
247 processor=lambda f: self._process_single_file(f, ctx.timeout),
248 timeout=ctx.timeout,
249 )
251 return ToolResult(
252 name=self.definition.name,
253 success=result.all_success and result.total_issues == 0,
254 output=result.build_output(timeout=ctx.timeout),
255 issues_count=result.total_issues,
256 issues=result.all_issues,
257 )
259 def fix(self, paths: list[str], options: dict[str, object]) -> ToolResult:
260 """Shellcheck cannot fix issues, only report them.
262 Args:
263 paths: List of file or directory paths to fix.
264 options: Tool-specific options.
266 Returns:
267 ToolResult: Never returns, always raises NotImplementedError.
269 Raises:
270 NotImplementedError: Shellcheck does not support fixing issues.
271 """
272 raise NotImplementedError(
273 "Shellcheck cannot automatically fix issues. Run 'lintro check' to see "
274 "issues.",
275 )