lintro 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of lintro might be problematic. Click here for more details.

Files changed (85) hide show
  1. lintro/__init__.py +3 -0
  2. lintro/__main__.py +6 -0
  3. lintro/ascii-art/fail.txt +404 -0
  4. lintro/ascii-art/success.txt +484 -0
  5. lintro/cli.py +70 -0
  6. lintro/cli_utils/__init__.py +7 -0
  7. lintro/cli_utils/commands/__init__.py +7 -0
  8. lintro/cli_utils/commands/check.py +210 -0
  9. lintro/cli_utils/commands/format.py +167 -0
  10. lintro/cli_utils/commands/list_tools.py +114 -0
  11. lintro/enums/__init__.py +0 -0
  12. lintro/enums/action.py +29 -0
  13. lintro/enums/darglint_strictness.py +22 -0
  14. lintro/enums/group_by.py +31 -0
  15. lintro/enums/hadolint_enums.py +46 -0
  16. lintro/enums/output_format.py +40 -0
  17. lintro/enums/tool_name.py +36 -0
  18. lintro/enums/tool_type.py +27 -0
  19. lintro/enums/yamllint_format.py +22 -0
  20. lintro/exceptions/__init__.py +0 -0
  21. lintro/exceptions/errors.py +15 -0
  22. lintro/formatters/__init__.py +0 -0
  23. lintro/formatters/core/__init__.py +0 -0
  24. lintro/formatters/core/output_style.py +21 -0
  25. lintro/formatters/core/table_descriptor.py +24 -0
  26. lintro/formatters/styles/__init__.py +17 -0
  27. lintro/formatters/styles/csv.py +41 -0
  28. lintro/formatters/styles/grid.py +91 -0
  29. lintro/formatters/styles/html.py +48 -0
  30. lintro/formatters/styles/json.py +61 -0
  31. lintro/formatters/styles/markdown.py +41 -0
  32. lintro/formatters/styles/plain.py +39 -0
  33. lintro/formatters/tools/__init__.py +35 -0
  34. lintro/formatters/tools/darglint_formatter.py +72 -0
  35. lintro/formatters/tools/hadolint_formatter.py +84 -0
  36. lintro/formatters/tools/prettier_formatter.py +76 -0
  37. lintro/formatters/tools/ruff_formatter.py +116 -0
  38. lintro/formatters/tools/yamllint_formatter.py +87 -0
  39. lintro/models/__init__.py +0 -0
  40. lintro/models/core/__init__.py +0 -0
  41. lintro/models/core/tool.py +104 -0
  42. lintro/models/core/tool_config.py +23 -0
  43. lintro/models/core/tool_result.py +39 -0
  44. lintro/parsers/__init__.py +0 -0
  45. lintro/parsers/darglint/__init__.py +0 -0
  46. lintro/parsers/darglint/darglint_issue.py +9 -0
  47. lintro/parsers/darglint/darglint_parser.py +62 -0
  48. lintro/parsers/hadolint/__init__.py +1 -0
  49. lintro/parsers/hadolint/hadolint_issue.py +24 -0
  50. lintro/parsers/hadolint/hadolint_parser.py +65 -0
  51. lintro/parsers/prettier/__init__.py +0 -0
  52. lintro/parsers/prettier/prettier_issue.py +10 -0
  53. lintro/parsers/prettier/prettier_parser.py +60 -0
  54. lintro/parsers/ruff/__init__.py +1 -0
  55. lintro/parsers/ruff/ruff_issue.py +43 -0
  56. lintro/parsers/ruff/ruff_parser.py +89 -0
  57. lintro/parsers/yamllint/__init__.py +0 -0
  58. lintro/parsers/yamllint/yamllint_issue.py +24 -0
  59. lintro/parsers/yamllint/yamllint_parser.py +68 -0
  60. lintro/tools/__init__.py +40 -0
  61. lintro/tools/core/__init__.py +0 -0
  62. lintro/tools/core/tool_base.py +320 -0
  63. lintro/tools/core/tool_manager.py +167 -0
  64. lintro/tools/implementations/__init__.py +0 -0
  65. lintro/tools/implementations/tool_darglint.py +245 -0
  66. lintro/tools/implementations/tool_hadolint.py +302 -0
  67. lintro/tools/implementations/tool_prettier.py +270 -0
  68. lintro/tools/implementations/tool_ruff.py +618 -0
  69. lintro/tools/implementations/tool_yamllint.py +240 -0
  70. lintro/tools/tool_enum.py +17 -0
  71. lintro/utils/__init__.py +0 -0
  72. lintro/utils/ascii_normalize_cli.py +84 -0
  73. lintro/utils/config.py +39 -0
  74. lintro/utils/console_logger.py +783 -0
  75. lintro/utils/formatting.py +173 -0
  76. lintro/utils/output_manager.py +301 -0
  77. lintro/utils/path_utils.py +41 -0
  78. lintro/utils/tool_executor.py +443 -0
  79. lintro/utils/tool_utils.py +431 -0
  80. lintro-0.3.2.dist-info/METADATA +338 -0
  81. lintro-0.3.2.dist-info/RECORD +85 -0
  82. lintro-0.3.2.dist-info/WHEEL +5 -0
  83. lintro-0.3.2.dist-info/entry_points.txt +2 -0
  84. lintro-0.3.2.dist-info/licenses/LICENSE +21 -0
  85. lintro-0.3.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,245 @@
1
+ """Darglint docstring linter integration."""
2
+
3
+ import subprocess
4
+ from dataclasses import dataclass, field
5
+
6
+ from loguru import logger
7
+
8
+ from lintro.enums.darglint_strictness import (
9
+ DarglintStrictness,
10
+ normalize_darglint_strictness,
11
+ )
12
+ from lintro.enums.tool_type import ToolType
13
+ from lintro.models.core.tool import ToolConfig, ToolResult
14
+ from lintro.parsers.darglint.darglint_parser import parse_darglint_output
15
+ from lintro.tools.core.tool_base import BaseTool
16
+ from lintro.utils.tool_utils import walk_files_with_excludes
17
+
18
+ # Constants for Darglint configuration
19
+ DARGLINT_DEFAULT_TIMEOUT: int = 10
20
+ DARGLINT_DEFAULT_PRIORITY: int = 45
21
+ DARGLINT_FILE_PATTERNS: list[str] = ["*.py"]
22
+ DARGLINT_STRICTNESS_LEVELS: tuple[str, ...] = tuple(
23
+ m.name.lower() for m in DarglintStrictness
24
+ )
25
+ DARGLINT_MIN_VERBOSITY: int = 1
26
+ DARGLINT_MAX_VERBOSITY: int = 3
27
+ DARGLINT_DEFAULT_VERBOSITY: int = 2
28
+ DARGLINT_DEFAULT_STRICTNESS: str = "full"
29
+
30
+
31
+ @dataclass
32
+ class DarglintTool(BaseTool):
33
+ """Darglint docstring linter integration.
34
+
35
+ Darglint is a Python docstring linter that checks docstring style and completeness.
36
+ It verifies that docstrings match the function signature and contain all required
37
+ sections.
38
+
39
+ Attributes:
40
+ name: str: Tool name.
41
+ description: str: Tool description.
42
+ can_fix: bool: Whether the core can fix issues.
43
+ config: ToolConfig: Tool configuration.
44
+ exclude_patterns: list[str]: List of patterns to exclude.
45
+ include_venv: bool: Whether to include virtual environment files.
46
+ """
47
+
48
+ name: str = "darglint"
49
+ description: str = (
50
+ "Python docstring linter that checks docstring style and completeness"
51
+ )
52
+ can_fix: bool = False # Darglint can only check, not fix
53
+ config: ToolConfig = field(
54
+ default_factory=lambda: ToolConfig(
55
+ priority=DARGLINT_DEFAULT_PRIORITY, # Lower priority than formatters, \
56
+ # slightly lower than flake8
57
+ conflicts_with=[], # No direct conflicts
58
+ file_patterns=DARGLINT_FILE_PATTERNS, # Only applies to Python files
59
+ tool_type=ToolType.LINTER,
60
+ options={
61
+ "timeout": DARGLINT_DEFAULT_TIMEOUT, # Default timeout in seconds \
62
+ # per file
63
+ "ignore": None, # List of error codes to ignore
64
+ "ignore_regex": None, # Regex pattern for error codes to ignore
65
+ "ignore_syntax": False, # Whether to ignore syntax errors
66
+ "message_template": None, # Custom message template
67
+ "verbosity": DARGLINT_DEFAULT_VERBOSITY, # Verbosity level (1-3) - \
68
+ # use 2 for descriptive messages
69
+ "strictness": DARGLINT_DEFAULT_STRICTNESS, # Strictness level \
70
+ # (short, long, full)
71
+ },
72
+ ),
73
+ )
74
+
75
+ def set_options(
76
+ self,
77
+ ignore: list[str] | None = None,
78
+ ignore_regex: str | None = None,
79
+ ignore_syntax: bool | None = None,
80
+ message_template: str | None = None,
81
+ verbosity: int | None = None,
82
+ strictness: str | DarglintStrictness | None = None,
83
+ **kwargs,
84
+ ) -> None:
85
+ """Set Darglint-specific options.
86
+
87
+ Args:
88
+ ignore: list[str] | None: List of error codes to ignore.
89
+ ignore_regex: str | None: Regex pattern for error codes to ignore.
90
+ ignore_syntax: bool | None: Whether to ignore syntax errors.
91
+ message_template: str | None: Custom message template.
92
+ verbosity: int | None: Verbosity level (1-3).
93
+ strictness: str | None: Strictness level (short, long, full).
94
+ **kwargs: Other core options.
95
+
96
+ Raises:
97
+ ValueError: If an option value is invalid.
98
+ """
99
+ if ignore is not None and not isinstance(ignore, list):
100
+ raise ValueError("ignore must be a list of error codes")
101
+ if ignore_regex is not None and not isinstance(ignore_regex, str):
102
+ raise ValueError("ignore_regex must be a string")
103
+ if ignore_syntax is not None and not isinstance(ignore_syntax, bool):
104
+ raise ValueError("ignore_syntax must be a boolean")
105
+ if message_template is not None and not isinstance(message_template, str):
106
+ raise ValueError("message_template must be a string")
107
+ if verbosity is not None:
108
+ if not isinstance(verbosity, int):
109
+ raise ValueError("verbosity must be an integer")
110
+ if not DARGLINT_MIN_VERBOSITY <= verbosity <= DARGLINT_MAX_VERBOSITY:
111
+ raise ValueError(
112
+ f"verbosity must be between {DARGLINT_MIN_VERBOSITY} and "
113
+ f"{DARGLINT_MAX_VERBOSITY}",
114
+ )
115
+ if strictness is not None:
116
+ strict_enum = normalize_darglint_strictness( # type: ignore[arg-type]
117
+ strictness,
118
+ )
119
+ strictness = strict_enum.name.lower()
120
+
121
+ options: dict = {
122
+ "ignore": ignore,
123
+ "ignore_regex": ignore_regex,
124
+ "ignore_syntax": ignore_syntax,
125
+ "message_template": message_template,
126
+ "verbosity": verbosity,
127
+ "strictness": strictness,
128
+ }
129
+ # Remove None values
130
+ options = {k: v for k, v in options.items() if v is not None}
131
+ super().set_options(**options, **kwargs)
132
+
133
+ def _build_command(self) -> list[str]:
134
+ """Build the Darglint command.
135
+
136
+ Returns:
137
+ list[str]: List of command arguments.
138
+ """
139
+ cmd: list[str] = ["darglint"]
140
+
141
+ # Add configuration options
142
+ if self.options.get("ignore"):
143
+ cmd.extend(["--ignore", ",".join(self.options["ignore"])])
144
+ if self.options.get("ignore_regex"):
145
+ cmd.extend(["--ignore-regex", self.options["ignore_regex"]])
146
+ if self.options.get("ignore_syntax"):
147
+ cmd.append("--ignore-syntax")
148
+ # Remove message_template override to use default output
149
+ # if self.options.get("message_template"):
150
+ # cmd.extend(["--message-template", self.options["message_template"]])
151
+ if self.options.get("verbosity"):
152
+ cmd.extend(["--verbosity", str(self.options["verbosity"])])
153
+ if self.options.get("strictness"):
154
+ cmd.extend(["--strictness", self.options["strictness"]])
155
+
156
+ return cmd
157
+
158
+ def check(
159
+ self,
160
+ paths: list[str],
161
+ ) -> ToolResult:
162
+ """Check Python files for docstring issues with Darglint.
163
+
164
+ Args:
165
+ paths: list[str]: List of file or directory paths to check.
166
+
167
+ Returns:
168
+ ToolResult: ToolResult instance.
169
+ """
170
+ self._validate_paths(paths=paths)
171
+ if not paths:
172
+ return ToolResult(
173
+ name=self.name,
174
+ success=True,
175
+ output="No files to check.",
176
+ issues_count=0,
177
+ )
178
+ # Use shared utility for file discovery
179
+ python_files: list[str] = walk_files_with_excludes(
180
+ paths=paths,
181
+ file_patterns=self.config.file_patterns,
182
+ exclude_patterns=self.exclude_patterns,
183
+ include_venv=self.include_venv,
184
+ )
185
+
186
+ logger.debug(f"Files to check: {python_files}")
187
+
188
+ timeout: int = self.options.get("timeout", DARGLINT_DEFAULT_TIMEOUT)
189
+ all_outputs: list[str] = []
190
+ all_success: bool = True
191
+ skipped_files: list[str] = []
192
+ total_issues: int = 0
193
+
194
+ for file_path in python_files:
195
+ cmd: list[str] = self._build_command() + [str(file_path)]
196
+ try:
197
+ success: bool
198
+ output: str
199
+ success, output = self._run_subprocess(cmd=cmd, timeout=timeout)
200
+ issues = parse_darglint_output(output=output)
201
+ issues_count: int = len(issues)
202
+ if not (success and issues_count == 0):
203
+ all_success = False
204
+ total_issues += issues_count
205
+ # Store parsed issues on the aggregate result later via ToolResult
206
+ all_outputs.append(output)
207
+ except subprocess.TimeoutExpired:
208
+ skipped_files.append(file_path)
209
+ all_success = False
210
+ except Exception as e:
211
+ all_outputs.append(f"Error processing {file_path}: {str(e)}")
212
+ all_success = False
213
+
214
+ output: str = "\n".join(all_outputs)
215
+ if skipped_files:
216
+ output += f"\n\nSkipped {len(skipped_files)} files due to timeout:"
217
+ for file in skipped_files:
218
+ output += f"\n - {file}"
219
+
220
+ if not output:
221
+ output = None
222
+
223
+ return ToolResult(
224
+ name=self.name,
225
+ success=all_success,
226
+ output=output,
227
+ issues_count=total_issues,
228
+ )
229
+
230
+ def fix(
231
+ self,
232
+ paths: list[str],
233
+ ) -> ToolResult:
234
+ """Darglint cannot fix issues, only report them.
235
+
236
+ Args:
237
+ paths: list[str]: List of file or directory paths to fix.
238
+
239
+ Raises:
240
+ NotImplementedError: As Darglint does not support fixing issues.
241
+ """
242
+ raise NotImplementedError(
243
+ "Darglint cannot automatically fix issues. Run 'lintro check' to see "
244
+ "issues.",
245
+ )
@@ -0,0 +1,302 @@
1
+ """Hadolint Dockerfile linter integration."""
2
+
3
+ import subprocess
4
+ from dataclasses import dataclass, field
5
+
6
+ from loguru import logger
7
+
8
+ from lintro.enums.hadolint_enums import (
9
+ HadolintFailureThreshold,
10
+ HadolintFormat,
11
+ normalize_hadolint_format,
12
+ normalize_hadolint_threshold,
13
+ )
14
+ from lintro.enums.tool_type import ToolType
15
+ from lintro.models.core.tool import ToolConfig, ToolResult
16
+ from lintro.parsers.hadolint.hadolint_parser import parse_hadolint_output
17
+ from lintro.tools.core.tool_base import BaseTool
18
+ from lintro.utils.tool_utils import walk_files_with_excludes
19
+
20
+ # Constants for Hadolint configuration
21
+ HADOLINT_DEFAULT_TIMEOUT: int = 30
22
+ HADOLINT_DEFAULT_PRIORITY: int = 50
23
+ HADOLINT_FILE_PATTERNS: list[str] = ["Dockerfile", "Dockerfile.*"]
24
+ HADOLINT_DEFAULT_FORMAT: str = "tty"
25
+ HADOLINT_DEFAULT_FAILURE_THRESHOLD: str = "info"
26
+ HADOLINT_DEFAULT_NO_COLOR: bool = True
27
+ HADOLINT_FORMATS: tuple[str, ...] = tuple(m.name.lower() for m in HadolintFormat)
28
+ HADOLINT_FAILURE_THRESHOLDS: tuple[str, ...] = tuple(
29
+ m.name.lower() for m in HadolintFailureThreshold
30
+ )
31
+
32
+
33
+ @dataclass
34
+ class HadolintTool(BaseTool):
35
+ """Hadolint Dockerfile linter integration.
36
+
37
+ Hadolint is a Dockerfile linter that helps you build best practice Docker images.
38
+ It parses the Dockerfile into an AST and performs rules on top of the AST.
39
+ It also uses ShellCheck to lint the Bash code inside RUN instructions.
40
+
41
+ Attributes:
42
+ name: str: Tool name.
43
+ description: str: Tool description.
44
+ can_fix: bool: Whether the tool can fix issues (hadolint cannot fix issues).
45
+ config: ToolConfig: Tool configuration.
46
+ exclude_patterns: list[str]: List of patterns to exclude.
47
+ include_venv: bool: Whether to include virtual environment files.
48
+ """
49
+
50
+ name: str = "hadolint"
51
+ description: str = (
52
+ "Dockerfile linter that helps you build best practice Docker images"
53
+ )
54
+ can_fix: bool = False # Hadolint can only check, not fix
55
+ config: ToolConfig = field(
56
+ default_factory=lambda: ToolConfig(
57
+ priority=HADOLINT_DEFAULT_PRIORITY, # Medium priority for \
58
+ # infrastructure linting
59
+ conflicts_with=[], # No direct conflicts
60
+ file_patterns=HADOLINT_FILE_PATTERNS,
61
+ tool_type=ToolType.LINTER | ToolType.INFRASTRUCTURE,
62
+ options={
63
+ "timeout": HADOLINT_DEFAULT_TIMEOUT, # Default timeout in seconds
64
+ "format": HADOLINT_DEFAULT_FORMAT, # Output format (tty, json, \
65
+ # checkstyle, etc.)
66
+ "failure_threshold": HADOLINT_DEFAULT_FAILURE_THRESHOLD, # \
67
+ # Threshold for failure (error, warning, info, style)
68
+ "ignore": None, # List of rule codes to ignore
69
+ "trusted_registries": None, # List of trusted Docker registries
70
+ "require_labels": None, # List of required labels with schemas
71
+ "strict_labels": False, # Whether to use strict label checking
72
+ "no_fail": False, # Whether to suppress exit codes
73
+ "no_color": HADOLINT_DEFAULT_NO_COLOR, # Disable color output \
74
+ # for parsing
75
+ },
76
+ ),
77
+ )
78
+
79
+ def set_options(
80
+ self,
81
+ format: str | HadolintFormat | None = None,
82
+ failure_threshold: str | HadolintFailureThreshold | None = None,
83
+ ignore: list[str] | None = None,
84
+ trusted_registries: list[str] | None = None,
85
+ require_labels: list[str] | None = None,
86
+ strict_labels: bool | None = None,
87
+ no_fail: bool | None = None,
88
+ no_color: bool | None = None,
89
+ **kwargs,
90
+ ) -> None:
91
+ """Set Hadolint-specific options.
92
+
93
+ Args:
94
+ format: str | None: Output format (tty, json, checkstyle, codeclimate, \
95
+ etc.).
96
+ failure_threshold: str | None: Exit with failure only when rules with \
97
+ severity >= threshold.
98
+ ignore: list[str] | None: List of rule codes to ignore (e.g., \
99
+ ['DL3006', 'SC2086']).
100
+ trusted_registries: list[str] | None: List of trusted Docker registries.
101
+ require_labels: list[str] | None: List of required labels with schemas \
102
+ (e.g., ['version:semver']).
103
+ strict_labels: bool | None: Whether to use strict label checking.
104
+ no_fail: bool | None: Whether to suppress exit codes.
105
+ no_color: bool | None: Whether to disable color output.
106
+ **kwargs: Other tool options.
107
+
108
+ Raises:
109
+ ValueError: If an option value is invalid.
110
+ """
111
+ if format is not None:
112
+ fmt_enum = normalize_hadolint_format(format) # type: ignore[arg-type]
113
+ format = fmt_enum.name.lower()
114
+
115
+ if failure_threshold is not None:
116
+ thr_enum = normalize_hadolint_threshold( # type: ignore[arg-type]
117
+ failure_threshold,
118
+ )
119
+ failure_threshold = thr_enum.name.lower()
120
+
121
+ if ignore is not None and not isinstance(ignore, list):
122
+ raise ValueError("ignore must be a list of rule codes")
123
+
124
+ if trusted_registries is not None and not isinstance(trusted_registries, list):
125
+ raise ValueError("trusted_registries must be a list of registry URLs")
126
+
127
+ if require_labels is not None and not isinstance(require_labels, list):
128
+ raise ValueError("require_labels must be a list of label schemas")
129
+
130
+ if strict_labels is not None and not isinstance(strict_labels, bool):
131
+ raise ValueError("strict_labels must be a boolean")
132
+
133
+ if no_fail is not None and not isinstance(no_fail, bool):
134
+ raise ValueError("no_fail must be a boolean")
135
+
136
+ if no_color is not None and not isinstance(no_color, bool):
137
+ raise ValueError("no_color must be a boolean")
138
+
139
+ options: dict = {
140
+ "format": format,
141
+ "failure_threshold": failure_threshold,
142
+ "ignore": ignore,
143
+ "trusted_registries": trusted_registries,
144
+ "require_labels": require_labels,
145
+ "strict_labels": strict_labels,
146
+ "no_fail": no_fail,
147
+ "no_color": no_color,
148
+ }
149
+ # Remove None values
150
+ options = {k: v for k, v in options.items() if v is not None}
151
+ super().set_options(**options, **kwargs)
152
+
153
+ def _build_command(self) -> list[str]:
154
+ """Build the hadolint command.
155
+
156
+ Returns:
157
+ list[str]: List of command arguments.
158
+ """
159
+ cmd: list[str] = ["hadolint"]
160
+
161
+ # Add format option
162
+ format_option: str = self.options.get("format", HADOLINT_DEFAULT_FORMAT)
163
+ cmd.extend(["--format", format_option])
164
+
165
+ # Add failure threshold
166
+ failure_threshold: str = self.options.get(
167
+ "failure_threshold",
168
+ HADOLINT_DEFAULT_FAILURE_THRESHOLD,
169
+ )
170
+ cmd.extend(["--failure-threshold", failure_threshold])
171
+
172
+ # Add ignore rules
173
+ ignore_rules: list[str] | None = self.options.get("ignore")
174
+ if ignore_rules is None:
175
+ ignore_rules = []
176
+ for rule in ignore_rules:
177
+ cmd.extend(["--ignore", rule])
178
+
179
+ # Add trusted registries
180
+ trusted_registries: list[str] | None = self.options.get("trusted_registries")
181
+ if trusted_registries is None:
182
+ trusted_registries = []
183
+ for registry in trusted_registries:
184
+ cmd.extend(["--trusted-registry", registry])
185
+
186
+ # Add required labels
187
+ require_labels: list[str] | None = self.options.get("require_labels")
188
+ if require_labels is None:
189
+ require_labels = []
190
+ for label in require_labels:
191
+ cmd.extend(["--require-label", label])
192
+
193
+ # Add strict labels
194
+ if self.options.get("strict_labels", False):
195
+ cmd.append("--strict-labels")
196
+
197
+ # Add no-fail option
198
+ if self.options.get("no_fail", False):
199
+ cmd.append("--no-fail")
200
+
201
+ # Add no-color option (default to True for better parsing)
202
+ if self.options.get("no_color", HADOLINT_DEFAULT_NO_COLOR):
203
+ cmd.append("--no-color")
204
+
205
+ return cmd
206
+
207
+ def check(
208
+ self,
209
+ paths: list[str],
210
+ ) -> ToolResult:
211
+ """Check files with Hadolint.
212
+
213
+ Args:
214
+ paths: list[str]: List of file or directory paths to check.
215
+
216
+ Returns:
217
+ ToolResult: ToolResult instance.
218
+ """
219
+ self._validate_paths(paths=paths)
220
+ if not paths:
221
+ return ToolResult(
222
+ name=self.name,
223
+ success=True,
224
+ output="No files to check.",
225
+ issues_count=0,
226
+ )
227
+
228
+ # Use shared utility for file discovery
229
+ dockerfile_files: list[str] = walk_files_with_excludes(
230
+ paths=paths,
231
+ file_patterns=self.config.file_patterns,
232
+ exclude_patterns=self.exclude_patterns,
233
+ include_venv=self.include_venv,
234
+ )
235
+
236
+ logger.debug(f"Files to check: {dockerfile_files}")
237
+
238
+ timeout: int = self.options.get("timeout", HADOLINT_DEFAULT_TIMEOUT)
239
+ all_outputs: list[str] = []
240
+ all_issues: list = []
241
+ all_success: bool = True
242
+ skipped_files: list[str] = []
243
+ total_issues: int = 0
244
+
245
+ for file_path in dockerfile_files:
246
+ cmd: list[str] = self._build_command() + [str(file_path)]
247
+ try:
248
+ success: bool
249
+ output: str
250
+ success, output = self._run_subprocess(cmd=cmd, timeout=timeout)
251
+ issues = parse_hadolint_output(output=output)
252
+ issues_count: int = len(issues)
253
+ # Tool is successful if subprocess succeeds, regardless of issues found
254
+ if not success:
255
+ all_success = False
256
+ total_issues += issues_count
257
+ # Prefer parsed issues for formatted output; keep raw for metadata
258
+ if issues:
259
+ all_outputs.append(output)
260
+ all_issues.extend(issues)
261
+ except subprocess.TimeoutExpired:
262
+ skipped_files.append(file_path)
263
+ all_success = False
264
+ except Exception as e:
265
+ all_outputs.append(f"Error processing {file_path}: {str(e)}")
266
+ all_success = False
267
+
268
+ output: str = "\n".join(all_outputs) if all_outputs else ""
269
+ if skipped_files:
270
+ if output:
271
+ output += "\n\n"
272
+ output += f"Skipped {len(skipped_files)} files due to timeout:"
273
+ for file in skipped_files:
274
+ output += f"\n - {file}"
275
+
276
+ if not output.strip():
277
+ output = None
278
+
279
+ return ToolResult(
280
+ name=self.name,
281
+ success=all_success,
282
+ output=output,
283
+ issues_count=total_issues,
284
+ issues=all_issues,
285
+ )
286
+
287
+ def fix(
288
+ self,
289
+ paths: list[str],
290
+ ) -> ToolResult:
291
+ """Hadolint cannot fix issues, only report them.
292
+
293
+ Args:
294
+ paths: list[str]: List of file or directory paths to fix.
295
+
296
+ Raises:
297
+ NotImplementedError: As Hadolint does not support fixing issues.
298
+ """
299
+ raise NotImplementedError(
300
+ "Hadolint cannot automatically fix issues. Run 'lintro check' to see "
301
+ "issues.",
302
+ )