lintro 0.6.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 (109) hide show
  1. lintro/__init__.py +1 -1
  2. lintro/cli.py +230 -14
  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/format.py +2 -2
  7. lintro/cli_utils/commands/init.py +361 -0
  8. lintro/cli_utils/commands/list_tools.py +180 -42
  9. lintro/cli_utils/commands/test.py +316 -0
  10. lintro/cli_utils/commands/versions.py +81 -0
  11. lintro/config/__init__.py +62 -0
  12. lintro/config/config_loader.py +420 -0
  13. lintro/config/lintro_config.py +189 -0
  14. lintro/config/tool_config_generator.py +403 -0
  15. lintro/enums/__init__.py +1 -0
  16. lintro/enums/darglint_strictness.py +10 -0
  17. lintro/enums/hadolint_enums.py +22 -0
  18. lintro/enums/tool_name.py +2 -0
  19. lintro/enums/tool_type.py +2 -0
  20. lintro/enums/yamllint_format.py +11 -0
  21. lintro/exceptions/__init__.py +1 -0
  22. lintro/formatters/__init__.py +1 -0
  23. lintro/formatters/core/__init__.py +1 -0
  24. lintro/formatters/core/output_style.py +11 -0
  25. lintro/formatters/core/table_descriptor.py +8 -0
  26. lintro/formatters/styles/csv.py +2 -0
  27. lintro/formatters/styles/grid.py +2 -0
  28. lintro/formatters/styles/html.py +2 -0
  29. lintro/formatters/styles/json.py +2 -0
  30. lintro/formatters/styles/markdown.py +2 -0
  31. lintro/formatters/styles/plain.py +2 -0
  32. lintro/formatters/tools/__init__.py +12 -0
  33. lintro/formatters/tools/black_formatter.py +27 -5
  34. lintro/formatters/tools/darglint_formatter.py +16 -1
  35. lintro/formatters/tools/eslint_formatter.py +108 -0
  36. lintro/formatters/tools/hadolint_formatter.py +13 -0
  37. lintro/formatters/tools/markdownlint_formatter.py +88 -0
  38. lintro/formatters/tools/prettier_formatter.py +15 -0
  39. lintro/formatters/tools/pytest_formatter.py +201 -0
  40. lintro/formatters/tools/ruff_formatter.py +26 -5
  41. lintro/formatters/tools/yamllint_formatter.py +14 -1
  42. lintro/models/__init__.py +1 -0
  43. lintro/models/core/__init__.py +1 -0
  44. lintro/models/core/tool_config.py +11 -7
  45. lintro/parsers/__init__.py +69 -9
  46. lintro/parsers/actionlint/actionlint_parser.py +1 -1
  47. lintro/parsers/bandit/__init__.py +6 -0
  48. lintro/parsers/bandit/bandit_issue.py +49 -0
  49. lintro/parsers/bandit/bandit_parser.py +99 -0
  50. lintro/parsers/black/black_issue.py +4 -0
  51. lintro/parsers/darglint/__init__.py +1 -0
  52. lintro/parsers/darglint/darglint_issue.py +11 -0
  53. lintro/parsers/eslint/__init__.py +6 -0
  54. lintro/parsers/eslint/eslint_issue.py +26 -0
  55. lintro/parsers/eslint/eslint_parser.py +63 -0
  56. lintro/parsers/markdownlint/__init__.py +6 -0
  57. lintro/parsers/markdownlint/markdownlint_issue.py +22 -0
  58. lintro/parsers/markdownlint/markdownlint_parser.py +113 -0
  59. lintro/parsers/prettier/__init__.py +1 -0
  60. lintro/parsers/prettier/prettier_issue.py +12 -0
  61. lintro/parsers/prettier/prettier_parser.py +1 -1
  62. lintro/parsers/pytest/__init__.py +21 -0
  63. lintro/parsers/pytest/pytest_issue.py +28 -0
  64. lintro/parsers/pytest/pytest_parser.py +483 -0
  65. lintro/parsers/ruff/ruff_parser.py +6 -2
  66. lintro/parsers/yamllint/__init__.py +1 -0
  67. lintro/tools/__init__.py +3 -1
  68. lintro/tools/core/__init__.py +1 -0
  69. lintro/tools/core/timeout_utils.py +112 -0
  70. lintro/tools/core/tool_base.py +286 -50
  71. lintro/tools/core/tool_manager.py +77 -24
  72. lintro/tools/core/version_requirements.py +482 -0
  73. lintro/tools/implementations/__init__.py +1 -0
  74. lintro/tools/implementations/pytest/pytest_command_builder.py +311 -0
  75. lintro/tools/implementations/pytest/pytest_config.py +200 -0
  76. lintro/tools/implementations/pytest/pytest_error_handler.py +128 -0
  77. lintro/tools/implementations/pytest/pytest_executor.py +122 -0
  78. lintro/tools/implementations/pytest/pytest_handlers.py +375 -0
  79. lintro/tools/implementations/pytest/pytest_option_validators.py +212 -0
  80. lintro/tools/implementations/pytest/pytest_output_processor.py +408 -0
  81. lintro/tools/implementations/pytest/pytest_result_processor.py +113 -0
  82. lintro/tools/implementations/pytest/pytest_utils.py +697 -0
  83. lintro/tools/implementations/tool_actionlint.py +106 -16
  84. lintro/tools/implementations/tool_bandit.py +34 -29
  85. lintro/tools/implementations/tool_black.py +236 -29
  86. lintro/tools/implementations/tool_darglint.py +183 -22
  87. lintro/tools/implementations/tool_eslint.py +374 -0
  88. lintro/tools/implementations/tool_hadolint.py +94 -25
  89. lintro/tools/implementations/tool_markdownlint.py +354 -0
  90. lintro/tools/implementations/tool_prettier.py +317 -24
  91. lintro/tools/implementations/tool_pytest.py +327 -0
  92. lintro/tools/implementations/tool_ruff.py +278 -84
  93. lintro/tools/implementations/tool_yamllint.py +448 -34
  94. lintro/tools/tool_enum.py +8 -0
  95. lintro/utils/__init__.py +1 -0
  96. lintro/utils/ascii_normalize_cli.py +5 -0
  97. lintro/utils/config.py +41 -18
  98. lintro/utils/console_logger.py +211 -25
  99. lintro/utils/path_utils.py +42 -0
  100. lintro/utils/tool_executor.py +339 -45
  101. lintro/utils/tool_utils.py +51 -24
  102. lintro/utils/unified_config.py +926 -0
  103. {lintro-0.6.2.dist-info → lintro-0.17.2.dist-info}/METADATA +172 -30
  104. lintro-0.17.2.dist-info/RECORD +134 -0
  105. lintro-0.6.2.dist-info/RECORD +0 -96
  106. {lintro-0.6.2.dist-info → lintro-0.17.2.dist-info}/WHEEL +0 -0
  107. {lintro-0.6.2.dist-info → lintro-0.17.2.dist-info}/entry_points.txt +0 -0
  108. {lintro-0.6.2.dist-info → lintro-0.17.2.dist-info}/licenses/LICENSE +0 -0
  109. {lintro-0.6.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.
@@ -136,7 +158,9 @@ class DarglintTool(BaseTool):
136
158
  Returns:
137
159
  list[str]: List of command arguments.
138
160
  """
139
- cmd: list[str] = ["darglint"]
161
+ # Prefer running via the active environment (uv run) if available,
162
+ # falling back to a direct executable when necessary.
163
+ cmd: list[str] = self._get_executable_command("darglint")
140
164
 
141
165
  # Add configuration options
142
166
  if self.options.get("ignore"):
@@ -155,6 +179,66 @@ class DarglintTool(BaseTool):
155
179
 
156
180
  return cmd
157
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
+
158
242
  def check(
159
243
  self,
160
244
  paths: list[str],
@@ -167,6 +251,11 @@ class DarglintTool(BaseTool):
167
251
  Returns:
168
252
  ToolResult: ToolResult instance.
169
253
  """
254
+ # Check version requirements
255
+ version_result = self._verify_tool_version()
256
+ if version_result is not None:
257
+ return version_result
258
+
170
259
  self._validate_paths(paths=paths)
171
260
  if not paths:
172
261
  return ToolResult(
@@ -187,44 +276,116 @@ class DarglintTool(BaseTool):
187
276
 
188
277
  timeout: int = self.options.get("timeout", DARGLINT_DEFAULT_TIMEOUT)
189
278
  all_outputs: list[str] = []
279
+ all_issues: list[DarglintIssue] = []
190
280
  all_success: bool = True
191
281
  skipped_files: list[str] = []
282
+ execution_failures: int = 0
192
283
  total_issues: int = 0
193
284
 
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):
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:
203
333
  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
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)
213
366
 
214
367
  output: str = "\n".join(all_outputs)
215
368
  if skipped_files:
216
- 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
+ )
217
373
  for file in skipped_files:
218
374
  output += f"\n - {file}"
219
375
 
220
376
  if not output:
221
377
  output = None
222
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
+
223
383
  return ToolResult(
224
384
  name=self.name,
225
385
  success=all_success,
226
386
  output=output,
227
- issues_count=total_issues,
387
+ issues_count=total_issues_with_failures,
388
+ issues=all_issues,
228
389
  )
229
390
 
230
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
+ )