lintro 0.13.2__py3-none-any.whl → 0.17.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.
Files changed (72) hide show
  1. lintro/__init__.py +1 -1
  2. lintro/cli.py +226 -16
  3. lintro/cli_utils/commands/__init__.py +8 -1
  4. lintro/cli_utils/commands/check.py +1 -0
  5. lintro/cli_utils/commands/config.py +325 -0
  6. lintro/cli_utils/commands/init.py +361 -0
  7. lintro/cli_utils/commands/list_tools.py +180 -42
  8. lintro/cli_utils/commands/test.py +316 -0
  9. lintro/cli_utils/commands/versions.py +81 -0
  10. lintro/config/__init__.py +62 -0
  11. lintro/config/config_loader.py +420 -0
  12. lintro/config/lintro_config.py +189 -0
  13. lintro/config/tool_config_generator.py +403 -0
  14. lintro/enums/tool_name.py +2 -0
  15. lintro/enums/tool_type.py +2 -0
  16. lintro/formatters/tools/__init__.py +12 -0
  17. lintro/formatters/tools/eslint_formatter.py +108 -0
  18. lintro/formatters/tools/markdownlint_formatter.py +88 -0
  19. lintro/formatters/tools/pytest_formatter.py +201 -0
  20. lintro/parsers/__init__.py +69 -9
  21. lintro/parsers/bandit/__init__.py +6 -0
  22. lintro/parsers/bandit/bandit_issue.py +49 -0
  23. lintro/parsers/bandit/bandit_parser.py +99 -0
  24. lintro/parsers/black/black_issue.py +4 -0
  25. lintro/parsers/eslint/__init__.py +6 -0
  26. lintro/parsers/eslint/eslint_issue.py +26 -0
  27. lintro/parsers/eslint/eslint_parser.py +63 -0
  28. lintro/parsers/markdownlint/__init__.py +6 -0
  29. lintro/parsers/markdownlint/markdownlint_issue.py +22 -0
  30. lintro/parsers/markdownlint/markdownlint_parser.py +113 -0
  31. lintro/parsers/pytest/__init__.py +21 -0
  32. lintro/parsers/pytest/pytest_issue.py +28 -0
  33. lintro/parsers/pytest/pytest_parser.py +483 -0
  34. lintro/tools/__init__.py +2 -0
  35. lintro/tools/core/timeout_utils.py +112 -0
  36. lintro/tools/core/tool_base.py +255 -45
  37. lintro/tools/core/tool_manager.py +77 -24
  38. lintro/tools/core/version_requirements.py +482 -0
  39. lintro/tools/implementations/pytest/pytest_command_builder.py +311 -0
  40. lintro/tools/implementations/pytest/pytest_config.py +200 -0
  41. lintro/tools/implementations/pytest/pytest_error_handler.py +128 -0
  42. lintro/tools/implementations/pytest/pytest_executor.py +122 -0
  43. lintro/tools/implementations/pytest/pytest_handlers.py +375 -0
  44. lintro/tools/implementations/pytest/pytest_option_validators.py +212 -0
  45. lintro/tools/implementations/pytest/pytest_output_processor.py +408 -0
  46. lintro/tools/implementations/pytest/pytest_result_processor.py +113 -0
  47. lintro/tools/implementations/pytest/pytest_utils.py +697 -0
  48. lintro/tools/implementations/tool_actionlint.py +106 -16
  49. lintro/tools/implementations/tool_bandit.py +23 -7
  50. lintro/tools/implementations/tool_black.py +236 -29
  51. lintro/tools/implementations/tool_darglint.py +180 -21
  52. lintro/tools/implementations/tool_eslint.py +374 -0
  53. lintro/tools/implementations/tool_hadolint.py +94 -25
  54. lintro/tools/implementations/tool_markdownlint.py +354 -0
  55. lintro/tools/implementations/tool_prettier.py +313 -26
  56. lintro/tools/implementations/tool_pytest.py +327 -0
  57. lintro/tools/implementations/tool_ruff.py +247 -70
  58. lintro/tools/implementations/tool_yamllint.py +448 -34
  59. lintro/tools/tool_enum.py +6 -0
  60. lintro/utils/config.py +41 -18
  61. lintro/utils/console_logger.py +211 -25
  62. lintro/utils/path_utils.py +42 -0
  63. lintro/utils/tool_executor.py +336 -39
  64. lintro/utils/tool_utils.py +38 -2
  65. lintro/utils/unified_config.py +926 -0
  66. {lintro-0.13.2.dist-info → lintro-0.17.2.dist-info}/METADATA +131 -29
  67. lintro-0.17.2.dist-info/RECORD +134 -0
  68. lintro-0.13.2.dist-info/RECORD +0 -96
  69. {lintro-0.13.2.dist-info → lintro-0.17.2.dist-info}/WHEEL +0 -0
  70. {lintro-0.13.2.dist-info → lintro-0.17.2.dist-info}/entry_points.txt +0 -0
  71. {lintro-0.13.2.dist-info → lintro-0.17.2.dist-info}/licenses/LICENSE +0 -0
  72. {lintro-0.13.2.dist-info → lintro-0.17.2.dist-info}/top_level.txt +0 -0
@@ -3,6 +3,7 @@
3
3
  import subprocess # nosec B404 - vetted use via BaseTool._run_subprocess
4
4
  from dataclasses import dataclass, field
5
5
 
6
+ import click
6
7
  from loguru import logger
7
8
 
8
9
  from lintro.enums.darglint_strictness import (
@@ -11,12 +12,14 @@ from lintro.enums.darglint_strictness import (
11
12
  )
12
13
  from lintro.enums.tool_type import ToolType
13
14
  from lintro.models.core.tool import ToolConfig, ToolResult
15
+ from lintro.parsers.darglint.darglint_issue import DarglintIssue
14
16
  from lintro.parsers.darglint.darglint_parser import parse_darglint_output
15
17
  from lintro.tools.core.tool_base import BaseTool
16
18
  from lintro.utils.tool_utils import walk_files_with_excludes
17
19
 
18
20
  # Constants for Darglint configuration
19
- DARGLINT_DEFAULT_TIMEOUT: int = 30
21
+ # Increased from 30 to handle large files with complex docstrings
22
+ DARGLINT_DEFAULT_TIMEOUT: int = 60
20
23
  DARGLINT_DEFAULT_PRIORITY: int = 45
21
24
  DARGLINT_FILE_PATTERNS: list[str] = ["*.py"]
22
25
  DARGLINT_STRICTNESS_LEVELS: tuple[str, ...] = tuple(
@@ -28,6 +31,25 @@ DARGLINT_DEFAULT_VERBOSITY: int = 2
28
31
  DARGLINT_DEFAULT_STRICTNESS: str = "full"
29
32
 
30
33
 
34
+ @dataclass
35
+ class FileProcessResult:
36
+ """Result of processing a single file with Darglint.
37
+
38
+ Attributes:
39
+ success: Whether the file was processed successfully.
40
+ issues_count: Number of issues found.
41
+ issues: List of parsed issues.
42
+ output: Raw output from the tool, or None if no output.
43
+ timeout_issue: Timeout issue if a timeout occurred, or None.
44
+ """
45
+
46
+ success: bool
47
+ issues_count: int
48
+ issues: list[DarglintIssue]
49
+ output: str | None
50
+ timeout_issue: DarglintIssue | None
51
+
52
+
31
53
  @dataclass
32
54
  class DarglintTool(BaseTool):
33
55
  """Darglint docstring linter integration.
@@ -157,6 +179,66 @@ class DarglintTool(BaseTool):
157
179
 
158
180
  return cmd
159
181
 
182
+ def _process_file(
183
+ self,
184
+ file_path: str,
185
+ timeout: int,
186
+ ) -> FileProcessResult:
187
+ """Process a single file with Darglint.
188
+
189
+ Args:
190
+ file_path: Path to the file to process.
191
+ timeout: Timeout in seconds for the subprocess execution.
192
+
193
+ Returns:
194
+ FileProcessResult: Result containing success status, issues, and output.
195
+ """
196
+ cmd: list[str] = self._build_command() + [str(file_path)]
197
+ try:
198
+ success: bool
199
+ output: str
200
+ success, output = self._run_subprocess(cmd=cmd, timeout=timeout)
201
+ issues = parse_darglint_output(output=output)
202
+ issues_count: int = len(issues)
203
+ return FileProcessResult(
204
+ success=success and issues_count == 0,
205
+ issues_count=issues_count,
206
+ issues=issues,
207
+ output=output,
208
+ timeout_issue=None,
209
+ )
210
+ except subprocess.TimeoutExpired:
211
+ # Create a timeout issue object to display in the table
212
+ timeout_issue = DarglintIssue(
213
+ file=str(file_path),
214
+ line=0,
215
+ code="TIMEOUT",
216
+ message=(
217
+ f"Darglint execution timed out "
218
+ f"({timeout}s limit exceeded). "
219
+ "This may indicate:\n"
220
+ " - Large file taking too long to analyze\n"
221
+ " - Complex docstrings requiring extensive parsing\n"
222
+ " - Need to increase timeout via "
223
+ "--tool-options darglint:timeout=N"
224
+ ),
225
+ )
226
+ return FileProcessResult(
227
+ success=False,
228
+ issues_count=0,
229
+ issues=[],
230
+ output=None,
231
+ timeout_issue=timeout_issue,
232
+ )
233
+ except Exception as e:
234
+ return FileProcessResult(
235
+ success=False,
236
+ issues_count=0,
237
+ issues=[],
238
+ output=f"Error processing {file_path}: {str(e)}",
239
+ timeout_issue=None,
240
+ )
241
+
160
242
  def check(
161
243
  self,
162
244
  paths: list[str],
@@ -169,6 +251,11 @@ class DarglintTool(BaseTool):
169
251
  Returns:
170
252
  ToolResult: ToolResult instance.
171
253
  """
254
+ # Check version requirements
255
+ version_result = self._verify_tool_version()
256
+ if version_result is not None:
257
+ return version_result
258
+
172
259
  self._validate_paths(paths=paths)
173
260
  if not paths:
174
261
  return ToolResult(
@@ -189,44 +276,116 @@ class DarglintTool(BaseTool):
189
276
 
190
277
  timeout: int = self.options.get("timeout", DARGLINT_DEFAULT_TIMEOUT)
191
278
  all_outputs: list[str] = []
279
+ all_issues: list[DarglintIssue] = []
192
280
  all_success: bool = True
193
281
  skipped_files: list[str] = []
282
+ execution_failures: int = 0
194
283
  total_issues: int = 0
195
284
 
196
- for file_path in python_files:
197
- cmd: list[str] = self._build_command() + [str(file_path)]
198
- try:
199
- success: bool
200
- output: str
201
- success, output = self._run_subprocess(cmd=cmd, timeout=timeout)
202
- issues = parse_darglint_output(output=output)
203
- issues_count: int = len(issues)
204
- if not (success and issues_count == 0):
285
+ # Show progress bar only when processing multiple files
286
+ if len(python_files) >= 2:
287
+ with click.progressbar(
288
+ python_files,
289
+ label="Processing files",
290
+ bar_template="%(label)s %(info)s",
291
+ ) as bar:
292
+ for file_path in bar:
293
+ result = self._process_file(file_path=file_path, timeout=timeout)
294
+ if not result.success:
295
+ all_success = False
296
+ total_issues += result.issues_count
297
+ if result.issues:
298
+ all_issues.extend(result.issues)
299
+ if result.output:
300
+ all_outputs.append(result.output)
301
+ if result.timeout_issue:
302
+ skipped_files.append(file_path)
303
+ execution_failures += 1
304
+ all_issues.append(result.timeout_issue)
305
+ elif (
306
+ not result.success
307
+ and not result.timeout_issue
308
+ and result.issues_count == 0
309
+ and result.output
310
+ and "Error" in result.output
311
+ ):
312
+ # Only count as execution failure if no lint issues were found
313
+ # and there's an actual error (not just lint findings)
314
+ execution_failures += 1
315
+ # Create an execution error issue to keep issues consistent
316
+ # with issues_count
317
+ error_issue = DarglintIssue(
318
+ file=str(file_path),
319
+ line=0,
320
+ code="EXEC_ERROR",
321
+ message=(
322
+ f"Execution error: {result.output.strip()}"
323
+ if result.output
324
+ else "Execution error during darglint processing"
325
+ ),
326
+ )
327
+ all_issues.append(error_issue)
328
+ else:
329
+ # Process without progress bar for single file or no files
330
+ for file_path in python_files:
331
+ result = self._process_file(file_path=file_path, timeout=timeout)
332
+ if not result.success:
205
333
  all_success = False
206
- total_issues += issues_count
207
- # Store parsed issues on the aggregate result later via ToolResult
208
- all_outputs.append(output)
209
- except subprocess.TimeoutExpired:
210
- skipped_files.append(file_path)
211
- all_success = False
212
- except Exception as e:
213
- all_outputs.append(f"Error processing {file_path}: {str(e)}")
214
- all_success = False
334
+ total_issues += result.issues_count
335
+ if result.issues:
336
+ all_issues.extend(result.issues)
337
+ if result.output:
338
+ all_outputs.append(result.output)
339
+ if result.timeout_issue:
340
+ skipped_files.append(file_path)
341
+ execution_failures += 1
342
+ all_issues.append(result.timeout_issue)
343
+ elif (
344
+ not result.success
345
+ and not result.timeout_issue
346
+ and result.issues_count == 0
347
+ and result.output
348
+ and "Error" in result.output
349
+ ):
350
+ # Only count as execution failure if no lint issues were found
351
+ # and there's an actual error (not just lint findings)
352
+ execution_failures += 1
353
+ # Create an execution error issue to keep issues consistent
354
+ # with issues_count
355
+ error_issue = DarglintIssue(
356
+ file=str(file_path),
357
+ line=0,
358
+ code="EXEC_ERROR",
359
+ message=(
360
+ f"Execution error: {result.output.strip()}"
361
+ if result.output
362
+ else "Execution error during darglint processing"
363
+ ),
364
+ )
365
+ all_issues.append(error_issue)
215
366
 
216
367
  output: str = "\n".join(all_outputs)
217
368
  if skipped_files:
218
- output += f"\n\nSkipped {len(skipped_files)} files due to timeout:"
369
+ output += (
370
+ f"\n\nSkipped {len(skipped_files)} file(s) due to timeout "
371
+ f"({timeout}s limit exceeded):"
372
+ )
219
373
  for file in skipped_files:
220
374
  output += f"\n - {file}"
221
375
 
222
376
  if not output:
223
377
  output = None
224
378
 
379
+ # Include execution failures (timeouts/errors) in issues_count
380
+ # to properly reflect tool failure status
381
+ total_issues_with_failures = total_issues + execution_failures
382
+
225
383
  return ToolResult(
226
384
  name=self.name,
227
385
  success=all_success,
228
386
  output=output,
229
- issues_count=total_issues,
387
+ issues_count=total_issues_with_failures,
388
+ issues=all_issues,
230
389
  )
231
390
 
232
391
  def fix(
@@ -0,0 +1,374 @@
1
+ """ESLint linter integration."""
2
+
3
+ import os
4
+ import subprocess # nosec B404 - used safely with shell disabled
5
+ from dataclasses import dataclass, field
6
+
7
+ from loguru import logger
8
+
9
+ from lintro.enums.tool_type import ToolType
10
+ from lintro.models.core.tool import Tool, ToolConfig, ToolResult
11
+ from lintro.parsers.eslint.eslint_issue import EslintIssue
12
+ from lintro.parsers.eslint.eslint_parser import parse_eslint_output
13
+ from lintro.tools.core.tool_base import BaseTool
14
+ from lintro.utils.tool_utils import walk_files_with_excludes
15
+
16
+ # Constants for ESLint configuration
17
+ ESLINT_DEFAULT_TIMEOUT: int = 30
18
+ ESLINT_DEFAULT_PRIORITY: int = 50 # Lower priority than formatters
19
+ ESLINT_FILE_PATTERNS: list[str] = [
20
+ "*.js",
21
+ "*.jsx",
22
+ "*.ts",
23
+ "*.tsx",
24
+ "*.mjs",
25
+ "*.cjs",
26
+ ]
27
+
28
+
29
+ @dataclass
30
+ class EslintTool(BaseTool):
31
+ """ESLint linter integration.
32
+
33
+ A linter for JavaScript and TypeScript that identifies problems in code.
34
+ """
35
+
36
+ name: str = "eslint"
37
+ description: str = (
38
+ "Linter for JavaScript and TypeScript that identifies and reports "
39
+ "on patterns in code"
40
+ )
41
+ can_fix: bool = True
42
+ config: ToolConfig = field(
43
+ default_factory=lambda: ToolConfig(
44
+ priority=ESLINT_DEFAULT_PRIORITY, # Lower priority than formatters
45
+ conflicts_with=[], # No direct conflicts
46
+ file_patterns=ESLINT_FILE_PATTERNS,
47
+ tool_type=ToolType.LINTER,
48
+ ),
49
+ )
50
+
51
+ def __post_init__(self) -> None:
52
+ """Initialize eslint tool."""
53
+ super().__post_init__()
54
+ # Note: .eslintignore is handled natively by ESLint
55
+ # ESLint config files (.eslintrc.*, eslint.config.*) are also
56
+ # discovered natively
57
+
58
+ def set_options(
59
+ self,
60
+ exclude_patterns: list[str] | None = None,
61
+ include_venv: bool = False,
62
+ timeout: int | None = None,
63
+ verbose_fix_output: bool | None = None,
64
+ **kwargs,
65
+ ) -> None:
66
+ """Set options for the core.
67
+
68
+ Args:
69
+ exclude_patterns: List of patterns to exclude
70
+ include_venv: Whether to include virtual environment directories
71
+ timeout: Timeout in seconds per file (default: 30)
72
+ verbose_fix_output: If True, include raw ESLint output in fix()
73
+ **kwargs: Additional options (ignored for compatibility)
74
+ """
75
+ if exclude_patterns is not None:
76
+ self.exclude_patterns = exclude_patterns.copy()
77
+ self.include_venv = include_venv
78
+ if timeout is not None:
79
+ self.options["timeout"] = timeout
80
+ if verbose_fix_output is not None:
81
+ self.options["verbose_fix_output"] = verbose_fix_output
82
+
83
+ def _create_timeout_result(
84
+ self,
85
+ timeout_val: int,
86
+ initial_issues: list | None = None,
87
+ initial_count: int = 0,
88
+ ) -> ToolResult:
89
+ """Create a ToolResult for timeout scenarios.
90
+
91
+ Args:
92
+ timeout_val: The timeout value that was exceeded.
93
+ initial_issues: Optional list of issues found before timeout.
94
+ initial_count: Optional count of initial issues.
95
+
96
+ Returns:
97
+ ToolResult: ToolResult instance representing timeout failure.
98
+ """
99
+ timeout_msg = (
100
+ f"ESLint execution timed out ({timeout_val}s limit exceeded).\n\n"
101
+ "This may indicate:\n"
102
+ " - Large codebase taking too long to process\n"
103
+ " - Need to increase timeout via --tool-options eslint:timeout=N"
104
+ )
105
+ timeout_issue = EslintIssue(
106
+ file="execution",
107
+ line=1,
108
+ code="TIMEOUT",
109
+ message=timeout_msg,
110
+ column=1,
111
+ severity=2,
112
+ fixable=False,
113
+ )
114
+ combined_issues = (initial_issues or []) + [timeout_issue]
115
+ return ToolResult(
116
+ name=self.name,
117
+ success=False,
118
+ output=timeout_msg,
119
+ issues_count=len(combined_issues),
120
+ issues=combined_issues,
121
+ initial_issues_count=initial_count,
122
+ fixed_issues_count=0,
123
+ remaining_issues_count=len(combined_issues),
124
+ )
125
+
126
+ def check(
127
+ self,
128
+ paths: list[str],
129
+ ) -> ToolResult:
130
+ """Check files with ESLint without making changes.
131
+
132
+ Args:
133
+ paths: List of file or directory paths to check
134
+
135
+ Returns:
136
+ ToolResult instance
137
+ """
138
+ # Check version requirements
139
+ version_result = self._verify_tool_version()
140
+ if version_result is not None:
141
+ return version_result
142
+
143
+ self._validate_paths(paths=paths)
144
+ eslint_files: list[str] = walk_files_with_excludes(
145
+ paths=paths,
146
+ file_patterns=self.config.file_patterns,
147
+ exclude_patterns=self.exclude_patterns,
148
+ include_venv=self.include_venv,
149
+ )
150
+ logger.debug(
151
+ f"[EslintTool] Discovered {len(eslint_files)} files matching patterns: "
152
+ f"{self.config.file_patterns}",
153
+ )
154
+ logger.debug(
155
+ f"[EslintTool] Exclude patterns applied: {self.exclude_patterns}",
156
+ )
157
+ if eslint_files:
158
+ logger.debug(
159
+ f"[EslintTool] Files to check (first 10): " f"{eslint_files[:10]}",
160
+ )
161
+ if not eslint_files:
162
+ return Tool.to_result(
163
+ name=self.name,
164
+ success=True,
165
+ output="No files to check.",
166
+ issues_count=0,
167
+ )
168
+
169
+ # Use relative paths and set cwd to the common parent
170
+ cwd: str = self.get_cwd(paths=eslint_files)
171
+ logger.debug(f"[EslintTool] Working directory: {cwd}")
172
+ rel_files: list[str] = [
173
+ os.path.relpath(f, cwd) if cwd else f for f in eslint_files
174
+ ]
175
+
176
+ # Build ESLint command with JSON output format
177
+ cmd: list[str] = self._get_executable_command(tool_name="eslint") + [
178
+ "--format",
179
+ "json",
180
+ ]
181
+
182
+ # Add Lintro config injection args if available
183
+ config_args = self._build_config_args()
184
+ if config_args:
185
+ cmd.extend(config_args)
186
+ logger.debug(
187
+ "[EslintTool] Using Lintro config injection",
188
+ )
189
+
190
+ cmd.extend(rel_files)
191
+ logger.debug(f"[EslintTool] Running: {' '.join(cmd)} (cwd={cwd})")
192
+ timeout_val: int = self.options.get("timeout", self._default_timeout)
193
+ try:
194
+ result = self._run_subprocess(
195
+ cmd=cmd,
196
+ timeout=timeout_val,
197
+ cwd=cwd,
198
+ )
199
+ except subprocess.TimeoutExpired:
200
+ return self._create_timeout_result(timeout_val=timeout_val)
201
+ output: str = result[1]
202
+ issues: list = parse_eslint_output(output=output)
203
+ issues_count: int = len(issues)
204
+ success: bool = issues_count == 0
205
+
206
+ # Standardize: suppress ESLint's informational output when no issues
207
+ # so the unified logger prints a single, consistent success line.
208
+ if success:
209
+ output = None
210
+
211
+ return ToolResult(
212
+ name=self.name,
213
+ success=success,
214
+ output=output,
215
+ issues_count=issues_count,
216
+ issues=issues,
217
+ )
218
+
219
+ def fix(
220
+ self,
221
+ paths: list[str],
222
+ ) -> ToolResult:
223
+ """Fix auto-fixable issues in files with ESLint.
224
+
225
+ Args:
226
+ paths: List of file or directory paths to fix
227
+
228
+ Returns:
229
+ ToolResult: Result object with counts and messages.
230
+ """
231
+ # Check version requirements
232
+ version_result = self._verify_tool_version()
233
+ if version_result is not None:
234
+ return version_result
235
+
236
+ self._validate_paths(paths=paths)
237
+ eslint_files: list[str] = walk_files_with_excludes(
238
+ paths=paths,
239
+ file_patterns=self.config.file_patterns,
240
+ exclude_patterns=self.exclude_patterns,
241
+ include_venv=self.include_venv,
242
+ )
243
+ if not eslint_files:
244
+ return Tool.to_result(
245
+ name=self.name,
246
+ success=True,
247
+ output="No files to fix.",
248
+ issues_count=0,
249
+ )
250
+
251
+ # First, check for issues before fixing
252
+ cwd: str = self.get_cwd(paths=eslint_files)
253
+ rel_files: list[str] = [
254
+ os.path.relpath(f, cwd) if cwd else f for f in eslint_files
255
+ ]
256
+
257
+ # Get Lintro config injection args if available
258
+ config_args = self._build_config_args()
259
+
260
+ # Check for issues first
261
+ check_cmd: list[str] = self._get_executable_command(tool_name="eslint") + [
262
+ "--format",
263
+ "json",
264
+ ]
265
+ if config_args:
266
+ check_cmd.extend(config_args)
267
+ check_cmd.extend(rel_files)
268
+ logger.debug(f"[EslintTool] Checking: {' '.join(check_cmd)} (cwd={cwd})")
269
+ timeout_val: int = self.options.get("timeout", self._default_timeout)
270
+ try:
271
+ check_result = self._run_subprocess(
272
+ cmd=check_cmd,
273
+ timeout=timeout_val,
274
+ cwd=cwd,
275
+ )
276
+ except subprocess.TimeoutExpired:
277
+ return self._create_timeout_result(timeout_val=timeout_val)
278
+ check_output: str = check_result[1]
279
+
280
+ # Parse initial issues
281
+ initial_issues: list = parse_eslint_output(output=check_output)
282
+ initial_count: int = len(initial_issues)
283
+
284
+ # Now fix the issues
285
+ fix_cmd: list[str] = self._get_executable_command(tool_name="eslint") + [
286
+ "--fix",
287
+ ]
288
+ if config_args:
289
+ fix_cmd.extend(config_args)
290
+ fix_cmd.extend(rel_files)
291
+ logger.debug(f"[EslintTool] Fixing: {' '.join(fix_cmd)} (cwd={cwd})")
292
+ try:
293
+ fix_result = self._run_subprocess(
294
+ cmd=fix_cmd,
295
+ timeout=timeout_val,
296
+ cwd=cwd,
297
+ )
298
+ except subprocess.TimeoutExpired:
299
+ return self._create_timeout_result(
300
+ timeout_val=timeout_val,
301
+ initial_issues=initial_issues,
302
+ initial_count=initial_count,
303
+ )
304
+ fix_output: str = fix_result[1]
305
+
306
+ # Check for remaining issues after fixing
307
+ try:
308
+ final_check_result = self._run_subprocess(
309
+ cmd=check_cmd,
310
+ timeout=timeout_val,
311
+ cwd=cwd,
312
+ )
313
+ except subprocess.TimeoutExpired:
314
+ return self._create_timeout_result(
315
+ timeout_val=timeout_val,
316
+ initial_issues=initial_issues,
317
+ initial_count=initial_count,
318
+ )
319
+ final_check_output: str = final_check_result[1]
320
+ remaining_issues: list = parse_eslint_output(output=final_check_output)
321
+ remaining_count: int = len(remaining_issues)
322
+
323
+ # Calculate fixed issues
324
+ fixed_count: int = max(0, initial_count - remaining_count)
325
+
326
+ # Build output message
327
+ output_lines: list[str] = []
328
+ if fixed_count > 0:
329
+ output_lines.append(f"Fixed {fixed_count} issue(s)")
330
+
331
+ if remaining_count > 0:
332
+ output_lines.append(
333
+ f"Found {remaining_count} issue(s) that cannot be auto-fixed",
334
+ )
335
+ for issue in remaining_issues[:5]:
336
+ output_lines.append(f" {issue.file} - {issue.message}")
337
+ if len(remaining_issues) > 5:
338
+ output_lines.append(f" ... and {len(remaining_issues) - 5} more")
339
+
340
+ # If there were no initial issues, rely on the logger's unified
341
+ # success line (avoid duplicate "No issues found" lines here)
342
+ elif remaining_count == 0 and fixed_count > 0:
343
+ output_lines.append("All issues were successfully auto-fixed")
344
+
345
+ # Add verbose raw fix output only when explicitly requested
346
+ if (
347
+ self.options.get("verbose_fix_output", False)
348
+ and fix_output
349
+ and fix_output.strip()
350
+ ):
351
+ output_lines.append(f"Fix output:\n{fix_output}")
352
+
353
+ final_output: str | None = "\n".join(output_lines) if output_lines else None
354
+
355
+ # Success means no remaining issues
356
+ success: bool = remaining_count == 0
357
+
358
+ # Use only remaining issues (post-fix list) to avoid duplicates
359
+ # The formatter relies on metadata counters (initial_issues_count,
360
+ # fixed_issues_count, remaining_issues_count) for summaries
361
+ all_issues = remaining_issues or []
362
+
363
+ return ToolResult(
364
+ name=self.name,
365
+ success=success,
366
+ output=final_output,
367
+ # For fix operations, issues_count represents remaining for summaries
368
+ issues_count=remaining_count,
369
+ # Provide both initial (fixed) and remaining issues for display
370
+ issues=all_issues,
371
+ initial_issues_count=initial_count,
372
+ fixed_issues_count=fixed_count,
373
+ remaining_issues_count=remaining_count,
374
+ )