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

1"""Shellcheck tool definition. 

2 

3ShellCheck is a static analysis tool for shell scripts. It identifies bugs, 

4syntax issues, and suggests improvements for bash/sh/dash/ksh scripts. 

5""" 

6 

7from __future__ import annotations 

8 

9import subprocess # nosec B404 - used safely with shell disabled 

10from dataclasses import dataclass 

11from typing import Any 

12 

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) 

28 

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" 

35 

36# Valid severity levels for shellcheck 

37SHELLCHECK_SEVERITY_LEVELS: tuple[str, ...] = ("error", "warning", "info", "style") 

38 

39# Valid shell dialects for shellcheck (official: bash, sh, dash, ksh) 

40SHELLCHECK_SHELL_DIALECTS: tuple[str, ...] = ("bash", "sh", "dash", "ksh") 

41 

42 

43def normalize_shellcheck_severity(value: str) -> str: 

44 """Normalize shellcheck severity level. 

45 

46 Args: 

47 value: Severity level string to normalize. 

48 

49 Returns: 

50 Normalized severity level string (lowercase). 

51 

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 

60 

61 

62def normalize_shellcheck_shell(value: str) -> str: 

63 """Normalize shellcheck shell dialect. 

64 

65 Args: 

66 value: Shell dialect string to normalize. 

67 

68 Returns: 

69 Normalized shell dialect string (lowercase). 

70 

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 

79 

80 

81@register_tool 

82@dataclass 

83class ShellcheckPlugin(BaseToolPlugin): 

84 """ShellCheck shell script linter plugin. 

85 

86 This plugin integrates ShellCheck with Lintro for checking shell scripts 

87 against best practices and identifying potential bugs. 

88 """ 

89 

90 @property 

91 def definition(self) -> ToolDefinition: 

92 """Return the tool definition. 

93 

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 ) 

119 

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. 

128 

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) 

137 

138 if shell is not None: 

139 shell = normalize_shellcheck_shell(shell) 

140 

141 validate_list(exclude, "exclude") 

142 validate_str(severity, "severity") 

143 validate_str(shell, "shell") 

144 

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) 

151 

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

153 """Return ShellCheck wiki URL for the given code. 

154 

155 Args: 

156 code: ShellCheck code (e.g., "SC2086"). 

157 

158 Returns: 

159 URL to the ShellCheck wiki page. 

160 """ 

161 if code: 

162 return DocUrlTemplate.SHELLCHECK.format(code=code.upper()) 

163 return None 

164 

165 def _build_command(self) -> list[str]: 

166 """Build the shellcheck command. 

167 

168 Returns: 

169 List of command arguments. 

170 """ 

171 cmd: list[str] = ["shellcheck"] 

172 

173 # Always use json1 format for reliable parsing 

174 cmd.extend(["--format", SHELLCHECK_DEFAULT_FORMAT]) 

175 

176 # Add severity option 

177 severity = str(self.options.get("severity") or SHELLCHECK_DEFAULT_SEVERITY) 

178 cmd.extend(["--severity", severity]) 

179 

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

185 

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

190 

191 return cmd 

192 

193 def _process_single_file( 

194 self, 

195 file_path: str, 

196 timeout: int, 

197 ) -> FileProcessingResult: 

198 """Process a single shell script with shellcheck. 

199 

200 Args: 

201 file_path: Path to the shell script to process. 

202 timeout: Timeout in seconds for the shellcheck command. 

203 

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 ) 

230 

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

232 """Check files with Shellcheck. 

233 

234 Args: 

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

236 options: Runtime options that override defaults. 

237 

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] 

244 

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 ) 

250 

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 ) 

258 

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

260 """Shellcheck cannot fix issues, only report them. 

261 

262 Args: 

263 paths: List of file or directory paths to fix. 

264 options: Tool-specific options. 

265 

266 Returns: 

267 ToolResult: Never returns, always raises NotImplementedError. 

268 

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 )