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
@@ -7,9 +7,11 @@ structured issues, and returns a normalized `ToolResult`.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import contextlib
10
11
  import subprocess # nosec B404 - used safely with shell disabled
11
12
  from dataclasses import dataclass, field
12
13
 
14
+ import click
13
15
  from loguru import logger
14
16
 
15
17
  from lintro.enums.tool_type import ToolType
@@ -66,6 +68,55 @@ class ActionlintTool(BaseTool):
66
68
  """
67
69
  return ["actionlint"]
68
70
 
71
+ def _process_file(
72
+ self,
73
+ file_path: str,
74
+ base_cmd: list[str],
75
+ timeout: int,
76
+ all_outputs: list[str],
77
+ all_issues: list,
78
+ skipped_files: list[str],
79
+ all_success: bool,
80
+ execution_failures: int,
81
+ ) -> tuple[bool, int]:
82
+ """Process a single file with actionlint.
83
+
84
+ Args:
85
+ file_path: Path to the file to process.
86
+ base_cmd: Base command list for actionlint.
87
+ timeout: Timeout in seconds.
88
+ all_outputs: List to append raw output to.
89
+ all_issues: List to extend with parsed issues.
90
+ skipped_files: List to append skipped file paths to.
91
+ all_success: Current success flag.
92
+ execution_failures: Current execution failures count.
93
+
94
+ Returns:
95
+ Tuple of (updated_all_success, updated_execution_failures).
96
+ """
97
+ cmd = base_cmd + [file_path]
98
+ try:
99
+ success, output = self._run_subprocess(cmd=cmd, timeout=timeout)
100
+ issues = parse_actionlint_output(output)
101
+ if not success:
102
+ all_success = False
103
+ # Preserve output when subprocess fails even if parsing yields no issues
104
+ if output and (issues or not success):
105
+ all_outputs.append(output)
106
+ if issues:
107
+ all_issues.extend(issues)
108
+ except subprocess.TimeoutExpired:
109
+ skipped_files.append(file_path)
110
+ all_success = False
111
+ # Count timeout as an execution failure
112
+ execution_failures += 1
113
+ except Exception as e: # pragma: no cover
114
+ all_success = False
115
+ all_outputs.append(f"Error checking {file_path}: {e}")
116
+ # Count execution errors as failures
117
+ execution_failures += 1
118
+ return all_success, execution_failures
119
+
69
120
  def check(self, paths: list[str]) -> ToolResult:
70
121
  """Check GitHub Actions workflow files with actionlint.
71
122
 
@@ -76,6 +127,11 @@ class ActionlintTool(BaseTool):
76
127
  A `ToolResult` containing success status, aggregated output (if any),
77
128
  issue count, and parsed issues.
78
129
  """
130
+ # Check version requirements
131
+ version_result = self._verify_tool_version()
132
+ if version_result is not None:
133
+ return version_result
134
+
79
135
  self._validate_paths(paths=paths)
80
136
  if not paths:
81
137
  return ToolResult(
@@ -111,26 +167,60 @@ class ActionlintTool(BaseTool):
111
167
  all_outputs: list[str] = []
112
168
  all_issues = []
113
169
  all_success = True
170
+ execution_failures: int = 0
114
171
 
172
+ skipped_files: list[str] = []
115
173
  base_cmd = self._build_command()
116
- for file_path in workflow_files:
117
- cmd = base_cmd + [file_path]
118
- try:
119
- success, output = self._run_subprocess(cmd=cmd, timeout=timeout)
120
- issues = parse_actionlint_output(output)
121
- if not success:
122
- all_success = False
123
- if issues:
124
- all_outputs.append(output)
125
- all_issues.extend(issues)
126
- except subprocess.TimeoutExpired:
127
- all_success = False
128
- all_outputs.append(f"Timeout while checking {file_path}")
129
- except Exception as e: # pragma: no cover
130
- all_success = False
131
- all_outputs.append(f"Error checking {file_path}: {e}")
174
+
175
+ # Show progress bar only when processing multiple files
176
+ if len(workflow_files) >= 2:
177
+ files_to_iterate = click.progressbar(
178
+ workflow_files,
179
+ label="Processing files",
180
+ bar_template="%(label)s %(info)s",
181
+ )
182
+ context_mgr = files_to_iterate
183
+ else:
184
+ files_to_iterate = workflow_files
185
+ context_mgr = contextlib.nullcontext()
186
+
187
+ with context_mgr:
188
+ for file_path in files_to_iterate:
189
+ all_success, execution_failures = self._process_file(
190
+ file_path=file_path,
191
+ base_cmd=base_cmd,
192
+ timeout=timeout,
193
+ all_outputs=all_outputs,
194
+ all_issues=all_issues,
195
+ skipped_files=skipped_files,
196
+ all_success=all_success,
197
+ execution_failures=execution_failures,
198
+ )
132
199
 
133
200
  combined_output = "\n".join(all_outputs) if all_outputs else None
201
+ if skipped_files:
202
+ timeout_msg = (
203
+ f"Skipped {len(skipped_files)} file(s) due to timeout "
204
+ f"({timeout}s limit exceeded):"
205
+ )
206
+ for file in skipped_files:
207
+ timeout_msg += f"\n - {file}"
208
+ if combined_output:
209
+ combined_output = f"{combined_output}\n\n{timeout_msg}"
210
+ else:
211
+ combined_output = timeout_msg
212
+ # Add summary of execution failures (non-timeout errors) if any
213
+ non_timeout_failures = execution_failures - len(skipped_files)
214
+ if non_timeout_failures > 0:
215
+ failure_msg = (
216
+ f"Failed to process {non_timeout_failures} file(s) "
217
+ "due to execution errors"
218
+ )
219
+ if combined_output:
220
+ combined_output = f"{combined_output}\n\n{failure_msg}"
221
+ else:
222
+ combined_output = failure_msg
223
+ # issues_count reflects only linting issues, not execution failures
134
224
  return ToolResult(
135
225
  name=self.name,
136
226
  success=all_success,
@@ -319,10 +319,12 @@ class BanditTool(BaseTool):
319
319
 
320
320
  Returns:
321
321
  ToolResult: ToolResult instance.
322
-
323
- Raises:
324
- subprocess.TimeoutExpired: If the subprocess exceeds the timeout.
325
322
  """
323
+ # Check version requirements
324
+ version_result = self._verify_tool_version()
325
+ if version_result is not None:
326
+ return version_result
327
+
326
328
  self._validate_paths(paths=paths)
327
329
  if not paths:
328
330
  return ToolResult(
@@ -361,6 +363,7 @@ class BanditTool(BaseTool):
361
363
  cmd: list[str] = self._build_check_command(files=rel_files)
362
364
 
363
365
  output: str
366
+ execution_failure: bool = False
364
367
  # Run Bandit via the shared safe runner in BaseTool. This enforces
365
368
  # argument validation and consistent subprocess handling across tools.
366
369
  try:
@@ -372,10 +375,21 @@ class BanditTool(BaseTool):
372
375
  output = (combined or "").strip()
373
376
  rc: int = 0 if success else 1
374
377
  except subprocess.TimeoutExpired:
375
- raise
378
+ # Handle timeout gracefully
379
+ execution_failure = True
380
+ timeout_msg = (
381
+ f"Bandit execution timed out ({timeout}s limit exceeded).\n\n"
382
+ "This may indicate:\n"
383
+ " - Large codebase taking too long to process\n"
384
+ " - Need to increase timeout via --tool-options bandit:timeout=N"
385
+ )
386
+ output = timeout_msg
387
+ rc = 1
376
388
  except Exception as e:
377
389
  logger.error(f"Failed to run Bandit: {e}")
378
- output = ""
390
+ output = f"Bandit failed: {e}"
391
+ execution_failure = True
392
+ rc = 1
379
393
 
380
394
  # Parse the JSON output
381
395
  try:
@@ -398,12 +412,14 @@ class BanditTool(BaseTool):
398
412
  issues_count = len(issues)
399
413
 
400
414
  # Bandit returns 0 if no issues; 1 if issues found (still successful run)
401
- execution_success = len(bandit_data.get("errors", [])) == 0
415
+ execution_success = (
416
+ len(bandit_data.get("errors", [])) == 0 and not execution_failure
417
+ )
402
418
 
403
419
  return ToolResult(
404
420
  name=self.name,
405
421
  success=execution_success,
406
- output=None,
422
+ output=output if execution_failure else None,
407
423
  issues_count=issues_count,
408
424
  issues=issues,
409
425
  )
@@ -10,12 +10,14 @@ Project: https://github.com/psf/black
10
10
  from __future__ import annotations
11
11
 
12
12
  import os
13
+ import subprocess # nosec B404 - used safely with shell disabled
13
14
  from dataclasses import dataclass, field
14
15
 
15
16
  from loguru import logger
16
17
 
17
18
  from lintro.enums.tool_type import ToolType
18
19
  from lintro.models.core.tool import ToolConfig, ToolResult
20
+ from lintro.parsers.black.black_issue import BlackIssue
19
21
  from lintro.parsers.black.black_parser import parse_black_output
20
22
  from lintro.tools.core.tool_base import BaseTool
21
23
  from lintro.utils.tool_utils import walk_files_with_excludes
@@ -94,17 +96,165 @@ class BlackTool(BaseTool):
94
96
  super().set_options(**options, **kwargs)
95
97
 
96
98
  def _build_common_args(self) -> list[str]:
99
+ """Build common CLI arguments for Black.
100
+
101
+ Uses Lintro config injection when available, otherwise falls back
102
+ to options-based configuration.
103
+
104
+ Returns:
105
+ list[str]: CLI arguments for Black.
106
+ """
97
107
  args: list[str] = []
98
- if self.options.get("line_length"):
99
- args.extend(["--line-length", str(self.options["line_length"])])
100
- if self.options.get("target_version"):
101
- args.extend(["--target-version", str(self.options["target_version"])])
108
+
109
+ # Try Lintro config injection first (--config flag)
110
+ config_args = self._build_config_args()
111
+ if config_args:
112
+ args.extend(config_args)
113
+ else:
114
+ # Fallback to options-based configuration
115
+ if self.options.get("line_length"):
116
+ args.extend(["--line-length", str(self.options["line_length"])])
117
+ if self.options.get("target_version"):
118
+ args.extend(["--target-version", str(self.options["target_version"])])
119
+
120
+ # These flags are always passed via CLI (not in config file)
102
121
  if self.options.get("fast"):
103
122
  args.append("--fast")
104
123
  if self.options.get("preview"):
105
124
  args.append("--preview")
106
125
  return args
107
126
 
127
+ def _check_line_length_violations(
128
+ self,
129
+ files: list[str],
130
+ cwd: str | None,
131
+ ) -> list[BlackIssue]:
132
+ """Check for line length violations using Ruff's E501 rule.
133
+
134
+ This catches lines that exceed the line length limit but cannot be
135
+ safely wrapped by Black. Black's --check mode only reports files that
136
+ would be reformatted, so it misses unwrappable long lines.
137
+
138
+ Args:
139
+ files: List of file paths (relative to cwd) to check.
140
+ cwd: Working directory for the check.
141
+
142
+ Returns:
143
+ list[BlackIssue]: List of line length violations converted to
144
+ BlackIssue objects.
145
+ """
146
+ if not files:
147
+ return []
148
+
149
+ # Import here to avoid circular dependency
150
+ from lintro.tools.implementations.tool_ruff import RuffTool
151
+
152
+ try:
153
+ ruff_tool = RuffTool()
154
+ # Use the same line_length as Black is configured with
155
+ line_length = self.options.get("line_length")
156
+ if line_length:
157
+ ruff_tool.set_options(
158
+ select=["E501"],
159
+ line_length=line_length,
160
+ timeout=self.options.get("timeout", BLACK_DEFAULT_TIMEOUT),
161
+ )
162
+ else:
163
+ # If no line_length configured, use Ruff's default (88)
164
+ ruff_tool.set_options(
165
+ select=["E501"],
166
+ timeout=self.options.get("timeout", BLACK_DEFAULT_TIMEOUT),
167
+ )
168
+
169
+ # Convert relative paths to absolute paths for RuffTool
170
+ abs_files: list[str] = []
171
+ for file_path in files:
172
+ if cwd and not os.path.isabs(file_path):
173
+ abs_files.append(os.path.abspath(os.path.join(cwd, file_path)))
174
+ else:
175
+ abs_files.append(file_path)
176
+
177
+ # Run Ruff E501 check on the absolute file paths
178
+ ruff_result = ruff_tool.check(paths=abs_files)
179
+
180
+ # Convert Ruff E501 violations to BlackIssue objects
181
+ black_issues: list[BlackIssue] = []
182
+ if ruff_result.issues:
183
+ for ruff_issue in ruff_result.issues:
184
+ # Only process E501 violations
185
+ if hasattr(ruff_issue, "code") and ruff_issue.code == "E501":
186
+ file_path = ruff_issue.file
187
+ # Ensure absolute path
188
+ if not os.path.isabs(file_path):
189
+ if cwd:
190
+ file_path = os.path.abspath(
191
+ os.path.join(cwd, file_path),
192
+ )
193
+ else:
194
+ file_path = os.path.abspath(file_path)
195
+ message = (
196
+ f"Line {ruff_issue.line} exceeds line length limit "
197
+ f"({ruff_issue.message})"
198
+ )
199
+ # Line length violations cannot be fixed by Black
200
+ black_issues.append(
201
+ BlackIssue(
202
+ file=file_path,
203
+ message=message,
204
+ fixable=False,
205
+ ),
206
+ )
207
+
208
+ except Exception as e:
209
+ # If Ruff check fails, log but don't fail the entire Black check
210
+ logger.debug(f"Failed to check line length violations with Ruff: {e}")
211
+ return []
212
+
213
+ return black_issues
214
+
215
+ def _handle_timeout_error(
216
+ self,
217
+ timeout_val: int,
218
+ initial_count: int | None = None,
219
+ ) -> ToolResult:
220
+ """Handle timeout errors consistently across all Black operations.
221
+
222
+ Args:
223
+ timeout_val: The timeout value that was exceeded.
224
+ initial_count: Optional initial issues count for fix operations.
225
+
226
+ Returns:
227
+ ToolResult: Standardized timeout error result.
228
+ """
229
+ timeout_msg = (
230
+ f"Black execution timed out ({timeout_val}s limit exceeded).\n\n"
231
+ "This may indicate:\n"
232
+ " - Large codebase taking too long to process\n"
233
+ " - Need to increase timeout via --tool-options black:timeout=N"
234
+ )
235
+ if initial_count is not None:
236
+ # Fix operation timeout - preserve known initial count
237
+ return ToolResult(
238
+ name=self.name,
239
+ success=False,
240
+ output=timeout_msg,
241
+ issues_count=max(initial_count, 1),
242
+ issues=[],
243
+ initial_issues_count=(
244
+ initial_count if not self.options.get("diff") else 0
245
+ ),
246
+ fixed_issues_count=0,
247
+ remaining_issues_count=max(initial_count, 1),
248
+ )
249
+ # Check operation timeout
250
+ return ToolResult(
251
+ name=self.name,
252
+ success=False,
253
+ output=timeout_msg,
254
+ issues_count=1, # Count timeout as execution failure
255
+ issues=[],
256
+ )
257
+
108
258
  def check(self, paths: list[str]) -> ToolResult:
109
259
  """Check files using Black without applying changes.
110
260
 
@@ -114,6 +264,11 @@ class BlackTool(BaseTool):
114
264
  Returns:
115
265
  ToolResult: Result containing success flag, issue count, and issues.
116
266
  """
267
+ # Check version requirements
268
+ version_result = self._verify_tool_version()
269
+ if version_result is not None:
270
+ return version_result
271
+
117
272
  self._validate_paths(paths=paths)
118
273
 
119
274
  py_files: list[str] = walk_files_with_excludes(
@@ -141,21 +296,36 @@ class BlackTool(BaseTool):
141
296
  cmd.extend(rel_files)
142
297
 
143
298
  logger.debug(f"[BlackTool] Running: {' '.join(cmd)} (cwd={cwd})")
144
- success, output = self._run_subprocess(
145
- cmd=cmd,
146
- timeout=self.options.get("timeout", BLACK_DEFAULT_TIMEOUT),
299
+ timeout_val: int = self.options.get("timeout", BLACK_DEFAULT_TIMEOUT)
300
+ try:
301
+ success, output = self._run_subprocess(
302
+ cmd=cmd,
303
+ timeout=timeout_val,
304
+ cwd=cwd,
305
+ )
306
+ except subprocess.TimeoutExpired:
307
+ return self._handle_timeout_error(timeout_val)
308
+
309
+ black_issues = parse_black_output(output=output)
310
+
311
+ # Also check for line length violations that Black cannot wrap
312
+ # This catches E501 violations that Ruff finds but Black doesn't report
313
+ line_length_issues = self._check_line_length_violations(
314
+ files=rel_files,
147
315
  cwd=cwd,
148
316
  )
149
317
 
150
- issues = parse_black_output(output=output)
151
- count = len(issues)
318
+ # Combine Black formatting issues with line length violations
319
+ all_issues = black_issues + line_length_issues
320
+ count = len(all_issues)
321
+
152
322
  # In check mode, success means no differences
153
323
  return ToolResult(
154
324
  name=self.name,
155
325
  success=(success and count == 0),
156
326
  output=None if count == 0 else output,
157
327
  issues_count=count,
158
- issues=issues,
328
+ issues=all_issues,
159
329
  )
160
330
 
161
331
  def fix(self, paths: list[str]) -> ToolResult:
@@ -167,6 +337,11 @@ class BlackTool(BaseTool):
167
337
  Returns:
168
338
  ToolResult: Result containing counts and any remaining issues.
169
339
  """
340
+ # Check version requirements
341
+ version_result = self._verify_tool_version()
342
+ if version_result is not None:
343
+ return version_result
344
+
170
345
  self._validate_paths(paths=paths)
171
346
 
172
347
  py_files: list[str] = walk_files_with_excludes(
@@ -196,15 +371,19 @@ class BlackTool(BaseTool):
196
371
  # When diff is requested, skip the initial check to ensure the middle
197
372
  # invocation is the formatting run (as exercised by unit tests) and to
198
373
  # avoid redundant subprocess calls.
374
+ timeout_val: int = self.options.get("timeout", BLACK_DEFAULT_TIMEOUT)
199
375
  if self.options.get("diff"):
200
376
  initial_issues = []
201
377
  initial_count = 0
202
378
  else:
203
- _, check_output = self._run_subprocess(
204
- cmd=check_cmd,
205
- timeout=self.options.get("timeout", BLACK_DEFAULT_TIMEOUT),
206
- cwd=cwd,
207
- )
379
+ try:
380
+ _, check_output = self._run_subprocess(
381
+ cmd=check_cmd,
382
+ timeout=timeout_val,
383
+ cwd=cwd,
384
+ )
385
+ except subprocess.TimeoutExpired:
386
+ return self._handle_timeout_error(timeout_val, initial_count=0)
208
387
  initial_issues = parse_black_output(output=check_output)
209
388
  initial_count = len(initial_issues)
210
389
 
@@ -219,22 +398,50 @@ class BlackTool(BaseTool):
219
398
  fix_cmd.extend(rel_files)
220
399
 
221
400
  logger.debug(f"[BlackTool] Fixing: {' '.join(fix_cmd)} (cwd={cwd})")
222
- _, fix_output = self._run_subprocess(
223
- cmd=fix_cmd,
224
- timeout=self.options.get("timeout", BLACK_DEFAULT_TIMEOUT),
225
- cwd=cwd,
226
- )
401
+ try:
402
+ _, fix_output = self._run_subprocess(
403
+ cmd=fix_cmd,
404
+ timeout=timeout_val,
405
+ cwd=cwd,
406
+ )
407
+ except subprocess.TimeoutExpired:
408
+ return self._handle_timeout_error(timeout_val, initial_count=initial_count)
227
409
 
228
410
  # Final check for remaining differences
229
- final_success, final_output = self._run_subprocess(
230
- cmd=check_cmd,
231
- timeout=self.options.get("timeout", BLACK_DEFAULT_TIMEOUT),
411
+ try:
412
+ final_success, final_output = self._run_subprocess(
413
+ cmd=check_cmd,
414
+ timeout=timeout_val,
415
+ cwd=cwd,
416
+ )
417
+ except subprocess.TimeoutExpired:
418
+ return self._handle_timeout_error(timeout_val, initial_count=initial_count)
419
+ remaining_issues = parse_black_output(output=final_output)
420
+
421
+ # Also check for line length violations that Black cannot wrap
422
+ # This catches E501 violations that Ruff finds but Black doesn't report
423
+ line_length_issues = self._check_line_length_violations(
424
+ files=rel_files,
232
425
  cwd=cwd,
233
426
  )
234
- remaining_issues = parse_black_output(output=final_output)
235
- remaining_count = len(remaining_issues)
236
427
 
237
- fixed_count = max(0, initial_count - remaining_count)
428
+ # Combine Black formatting issues with line length violations
429
+ all_remaining_issues = remaining_issues + line_length_issues
430
+ remaining_count = len(all_remaining_issues)
431
+
432
+ # Parse per-file reformats from the formatting run to display in console
433
+ fixed_issues_parsed = parse_black_output(output=fix_output)
434
+ fixed_count_from_output = len(fixed_issues_parsed)
435
+
436
+ # Calculate fixed count: use reformatted files count if available,
437
+ # otherwise calculate from initial - remaining
438
+ if fixed_count_from_output > 0:
439
+ fixed_count = fixed_count_from_output
440
+ else:
441
+ # Subtract only Black-related remaining issues (exclude line-length)
442
+ line_length_issues_count = len(line_length_issues)
443
+ remaining_black_count = max(0, remaining_count - line_length_issues_count)
444
+ fixed_count = max(0, initial_count - remaining_black_count)
238
445
 
239
446
  # Build concise summary
240
447
  summary: list[str] = []
@@ -246,15 +453,15 @@ class BlackTool(BaseTool):
246
453
  )
247
454
  final_summary = "\n".join(summary) if summary else "No fixes applied."
248
455
 
249
- # Parse per-file reformats from the formatting run to display in console
250
- fixed_issues_parsed = parse_black_output(output=fix_output)
456
+ # Combine fixed and remaining issues so formatter can split them by fixability
457
+ all_issues = (fixed_issues_parsed or []) + all_remaining_issues
251
458
 
252
459
  return ToolResult(
253
460
  name=self.name,
254
461
  success=(remaining_count == 0),
255
462
  output=final_summary,
256
463
  issues_count=remaining_count,
257
- issues=fixed_issues_parsed if fixed_issues_parsed else remaining_issues,
464
+ issues=all_issues,
258
465
  initial_issues_count=initial_count,
259
466
  fixed_issues_count=fixed_count,
260
467
  remaining_issues_count=remaining_count,