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,618 @@
1
+ """Ruff Python linter and formatter integration."""
2
+
3
+ import os
4
+ import tomllib
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from loguru import logger
9
+
10
+ from lintro.enums.tool_type import ToolType
11
+ from lintro.models.core.tool import ToolConfig, ToolResult
12
+ from lintro.parsers.ruff.ruff_issue import RuffFormatIssue
13
+ from lintro.parsers.ruff.ruff_parser import (
14
+ parse_ruff_format_check_output,
15
+ parse_ruff_output,
16
+ )
17
+ from lintro.tools.core.tool_base import BaseTool
18
+ from lintro.utils.tool_utils import walk_files_with_excludes
19
+
20
+ # Constants for Ruff configuration
21
+ RUFF_DEFAULT_TIMEOUT: int = 30
22
+ RUFF_DEFAULT_PRIORITY: int = 85
23
+ RUFF_FILE_PATTERNS: list[str] = ["*.py", "*.pyi"]
24
+ RUFF_OUTPUT_FORMAT: str = "json"
25
+ RUFF_TEST_MODE_ENV: str = "LINTRO_TEST_MODE"
26
+ RUFF_TEST_MODE_VALUE: str = "1"
27
+ DEFAULT_REMAINING_ISSUES_DISPLAY: int = 5
28
+
29
+
30
+ def _load_ruff_config() -> dict:
31
+ """Load ruff configuration from pyproject.toml.
32
+
33
+ Returns:
34
+ dict: Ruff configuration dictionary.
35
+ """
36
+ config: dict = {}
37
+ pyproject_path = Path("pyproject.toml")
38
+
39
+ if pyproject_path.exists():
40
+ try:
41
+ with open(pyproject_path, "rb") as f:
42
+ pyproject_data = tomllib.load(f)
43
+ if "tool" in pyproject_data and "ruff" in pyproject_data["tool"]:
44
+ config = pyproject_data["tool"]["ruff"]
45
+ except Exception as e:
46
+ logger.warning(f"Failed to load ruff configuration: {e}")
47
+
48
+ return config
49
+
50
+
51
+ def _load_lintro_ignore() -> list[str]:
52
+ """Load patterns from .lintro-ignore file.
53
+
54
+ Returns:
55
+ list[str]: List of ignore patterns.
56
+ """
57
+ ignore_patterns: list[str] = []
58
+ lintro_ignore_path = Path(".lintro-ignore")
59
+
60
+ if lintro_ignore_path.exists():
61
+ try:
62
+ with open(lintro_ignore_path, "r", encoding="utf-8") as f:
63
+ for line in f:
64
+ line = line.strip()
65
+ # Skip empty lines and comments
66
+ if line and not line.startswith("#"):
67
+ ignore_patterns.append(line)
68
+ except Exception as e:
69
+ logger.warning(f"Failed to load .lintro-ignore: {e}")
70
+
71
+ return ignore_patterns
72
+
73
+
74
+ @dataclass
75
+ class RuffTool(BaseTool):
76
+ """Ruff Python linter and formatter integration.
77
+
78
+ Ruff is an extremely fast Python linter and code formatter written in Rust.
79
+ It can replace multiple Python tools like flake8, black, isort, and more.
80
+
81
+ Attributes:
82
+ name: str: Tool name.
83
+ description: str: Tool description.
84
+ can_fix: bool: Whether the tool can fix issues.
85
+ config: ToolConfig: Tool configuration.
86
+ exclude_patterns: list[str]: List of patterns to exclude.
87
+ include_venv: bool: Whether to include virtual environment files.
88
+ """
89
+
90
+ name: str = "ruff"
91
+ description: str = (
92
+ "Extremely fast Python linter and formatter that replaces multiple tools"
93
+ )
94
+ can_fix: bool = True # Ruff can both check and fix issues
95
+ config: ToolConfig = field(
96
+ default_factory=lambda: ToolConfig(
97
+ priority=RUFF_DEFAULT_PRIORITY, # High priority, higher than most linters
98
+ conflicts_with=[], # Can work alongside other tools
99
+ file_patterns=RUFF_FILE_PATTERNS, # Python files only
100
+ tool_type=ToolType.LINTER | ToolType.FORMATTER, # Both linter and formatter
101
+ options={
102
+ "timeout": RUFF_DEFAULT_TIMEOUT, # Default timeout in seconds
103
+ "select": None, # Rules to enable
104
+ "ignore": None, # Rules to ignore
105
+ "extend_select": None, # Additional rules to enable
106
+ "extend_ignore": None, # Additional rules to ignore
107
+ "line_length": None, # Line length limit
108
+ "target_version": None, # Python version target
109
+ "fix_only": False, # Only apply fixes, don't report remaining issues
110
+ "unsafe_fixes": False, # Do NOT enable unsafe fixes by default
111
+ "show_fixes": False, # Show enumeration of fixes applied
112
+ # Wrapper-first defaults:
113
+ # format_check: include `ruff format --check` during check
114
+ # format: run `ruff format` during fix
115
+ # Default True: `lintro chk` runs formatting and lint checks.
116
+ "format_check": True,
117
+ # Default to running the formatter during fmt to apply
118
+ # reformatting along with lint fixes
119
+ "format": True,
120
+ # Allow disabling the lint-fix stage if users only want
121
+ # formatting changes
122
+ "lint_fix": True,
123
+ },
124
+ ),
125
+ )
126
+
127
+ def __post_init__(self) -> None:
128
+ """Initialize the tool with default configuration."""
129
+ super().__post_init__()
130
+ # Load ruff configuration from pyproject.toml
131
+ ruff_config = _load_ruff_config()
132
+
133
+ # Load .lintro-ignore patterns
134
+ lintro_ignore_patterns = _load_lintro_ignore()
135
+
136
+ # Update exclude patterns from configuration and .lintro-ignore
137
+ if "exclude" in ruff_config:
138
+ self.exclude_patterns.extend(ruff_config["exclude"])
139
+ if lintro_ignore_patterns:
140
+ self.exclude_patterns.extend(lintro_ignore_patterns)
141
+
142
+ # Update other options from configuration
143
+ if "line_length" in ruff_config:
144
+ self.options["line_length"] = ruff_config["line_length"]
145
+ if "target_version" in ruff_config:
146
+ self.options["target_version"] = ruff_config["target_version"]
147
+ if "select" in ruff_config:
148
+ self.options["select"] = ruff_config["select"]
149
+ if "ignore" in ruff_config:
150
+ self.options["ignore"] = ruff_config["ignore"]
151
+ if "unsafe_fixes" in ruff_config:
152
+ self.options["unsafe_fixes"] = ruff_config["unsafe_fixes"]
153
+
154
+ def set_options(
155
+ self,
156
+ select: list[str] | None = None,
157
+ ignore: list[str] | None = None,
158
+ extend_select: list[str] | None = None,
159
+ extend_ignore: list[str] | None = None,
160
+ line_length: int | None = None,
161
+ target_version: str | None = None,
162
+ fix_only: bool | None = None,
163
+ unsafe_fixes: bool | None = None,
164
+ show_fixes: bool | None = None,
165
+ format: bool | None = None,
166
+ lint_fix: bool | None = None,
167
+ format_check: bool | None = None,
168
+ **kwargs,
169
+ ) -> None:
170
+ """Set Ruff-specific options.
171
+
172
+ Args:
173
+ select: list[str] | None: Rules to enable.
174
+ ignore: list[str] | None: Rules to ignore.
175
+ extend_select: list[str] | None: Additional rules to enable.
176
+ extend_ignore: list[str] | None: Additional rules to ignore.
177
+ line_length: int | None: Line length limit.
178
+ target_version: str | None: Python version target.
179
+ fix_only: bool | None: Only apply fixes, don't report remaining issues.
180
+ unsafe_fixes: bool | None: Include unsafe fixes.
181
+ show_fixes: bool | None: Show enumeration of fixes applied.
182
+ format: bool | None: Whether to run `ruff format` during fix.
183
+ lint_fix: bool | None: Whether to run `ruff check --fix` during fix.
184
+ format_check: bool | None: Whether to run `ruff format --check` in check.
185
+ **kwargs: Other tool options.
186
+
187
+ Raises:
188
+ ValueError: If an option value is invalid.
189
+ """
190
+ if select is not None and not isinstance(select, list):
191
+ raise ValueError("select must be a list of rule codes")
192
+ if ignore is not None and not isinstance(ignore, list):
193
+ raise ValueError("ignore must be a list of rule codes")
194
+ if extend_select is not None and not isinstance(extend_select, list):
195
+ raise ValueError("extend_select must be a list of rule codes")
196
+ if extend_ignore is not None and not isinstance(extend_ignore, list):
197
+ raise ValueError("extend_ignore must be a list of rule codes")
198
+ if line_length is not None:
199
+ if not isinstance(line_length, int):
200
+ raise ValueError("line_length must be an integer")
201
+ if line_length <= 0:
202
+ raise ValueError("line_length must be positive")
203
+ if target_version is not None and not isinstance(target_version, str):
204
+ raise ValueError("target_version must be a string")
205
+ if fix_only is not None and not isinstance(fix_only, bool):
206
+ raise ValueError("fix_only must be a boolean")
207
+ if unsafe_fixes is not None and not isinstance(unsafe_fixes, bool):
208
+ raise ValueError("unsafe_fixes must be a boolean")
209
+ if show_fixes is not None and not isinstance(show_fixes, bool):
210
+ raise ValueError("show_fixes must be a boolean")
211
+ if format is not None and not isinstance(format, bool):
212
+ raise ValueError("format must be a boolean")
213
+ if format_check is not None and not isinstance(format_check, bool):
214
+ raise ValueError("format_check must be a boolean")
215
+
216
+ options: dict = {
217
+ "select": select,
218
+ "ignore": ignore,
219
+ "extend_select": extend_select,
220
+ "extend_ignore": extend_ignore,
221
+ "line_length": line_length,
222
+ "target_version": target_version,
223
+ "fix_only": fix_only,
224
+ "unsafe_fixes": unsafe_fixes,
225
+ "show_fixes": show_fixes,
226
+ "format": format,
227
+ "lint_fix": lint_fix,
228
+ "format_check": format_check,
229
+ }
230
+ # Remove None values
231
+ options = {k: v for k, v in options.items() if v is not None}
232
+ super().set_options(**options, **kwargs)
233
+
234
+ def _build_check_command(
235
+ self,
236
+ files: list[str],
237
+ fix: bool = False,
238
+ ) -> list[str]:
239
+ """Build the ruff check command.
240
+
241
+ Args:
242
+ files: list[str]: List of files to check.
243
+ fix: bool: Whether to apply fixes.
244
+
245
+ Returns:
246
+ list[str]: List of command arguments.
247
+ """
248
+ cmd: list[str] = self._get_executable_command(tool_name="ruff") + ["check"]
249
+
250
+ # Add --isolated if in test mode
251
+ if os.environ.get(RUFF_TEST_MODE_ENV) == RUFF_TEST_MODE_VALUE:
252
+ cmd.append("--isolated")
253
+
254
+ # Add configuration options
255
+ if self.options.get("select"):
256
+ cmd.extend(["--select", ",".join(self.options["select"])])
257
+ if self.options.get("ignore"):
258
+ cmd.extend(["--ignore", ",".join(self.options["ignore"])])
259
+ if self.options.get("extend_select"):
260
+ cmd.extend(["--extend-select", ",".join(self.options["extend_select"])])
261
+ if self.options.get("extend_ignore"):
262
+ cmd.extend(["--extend-ignore", ",".join(self.options["extend_ignore"])])
263
+ if self.options.get("line_length"):
264
+ cmd.extend(["--line-length", str(self.options["line_length"])])
265
+ if self.options.get("target_version"):
266
+ cmd.extend(["--target-version", self.options["target_version"]])
267
+
268
+ # Fix options
269
+ if fix:
270
+ cmd.append("--fix")
271
+ if self.options.get("unsafe_fixes"):
272
+ cmd.append("--unsafe-fixes")
273
+ if self.options.get("show_fixes"):
274
+ cmd.append("--show-fixes")
275
+ if self.options.get("fix_only"):
276
+ cmd.append("--fix-only")
277
+
278
+ # Output format
279
+ cmd.extend(["--output-format", RUFF_OUTPUT_FORMAT])
280
+
281
+ # Add files
282
+ cmd.extend(files)
283
+
284
+ return cmd
285
+
286
+ def _build_format_command(
287
+ self,
288
+ files: list[str],
289
+ check_only: bool = False,
290
+ ) -> list[str]:
291
+ """Build the ruff format command.
292
+
293
+ Args:
294
+ files: list[str]: List of files to format.
295
+ check_only: bool: Whether to only check formatting without applying changes.
296
+
297
+ Returns:
298
+ list[str]: List of command arguments.
299
+ """
300
+ cmd: list[str] = self._get_executable_command(tool_name="ruff") + ["format"]
301
+
302
+ if check_only:
303
+ cmd.append("--check")
304
+
305
+ # Add configuration options
306
+ if self.options.get("line_length"):
307
+ cmd.extend(["--line-length", str(self.options["line_length"])])
308
+ if self.options.get("target_version"):
309
+ cmd.extend(["--target-version", self.options["target_version"]])
310
+
311
+ # Add files
312
+ cmd.extend(files)
313
+
314
+ return cmd
315
+
316
+ def check(
317
+ self,
318
+ paths: list[str],
319
+ ) -> ToolResult:
320
+ """Check files with Ruff (lint only by default).
321
+
322
+ Args:
323
+ paths: list[str]: List of file or directory paths to check.
324
+
325
+ Returns:
326
+ ToolResult: ToolResult instance.
327
+ """
328
+ self._validate_paths(paths=paths)
329
+ if not paths:
330
+ return ToolResult(
331
+ name=self.name,
332
+ success=True,
333
+ output="No files to check.",
334
+ issues_count=0,
335
+ )
336
+
337
+ # Use shared utility for file discovery
338
+ python_files: list[str] = walk_files_with_excludes(
339
+ paths=paths,
340
+ file_patterns=self.config.file_patterns,
341
+ exclude_patterns=self.exclude_patterns,
342
+ include_venv=self.include_venv,
343
+ )
344
+
345
+ if not python_files:
346
+ return ToolResult(
347
+ name=self.name,
348
+ success=True,
349
+ output="No Python files found to check.",
350
+ issues_count=0,
351
+ )
352
+
353
+ logger.debug(f"Files to check: {python_files}")
354
+
355
+ # Ensure Ruff discovers the correct configuration by setting the
356
+ # working directory to the common parent of the target files and by
357
+ # passing file paths relative to that directory.
358
+ cwd: str | None = self.get_cwd(paths=python_files)
359
+ rel_files: list[str] = [
360
+ os.path.relpath(f, cwd) if cwd else f for f in python_files
361
+ ]
362
+
363
+ timeout: int = self.options.get("timeout", RUFF_DEFAULT_TIMEOUT)
364
+ # Lint check
365
+ cmd: list[str] = self._build_check_command(files=rel_files, fix=False)
366
+ success_lint: bool
367
+ output_lint: str
368
+ success_lint, output_lint = self._run_subprocess(
369
+ cmd=cmd, timeout=timeout, cwd=cwd
370
+ )
371
+ lint_issues = parse_ruff_output(output=output_lint)
372
+ lint_issues_count: int = len(lint_issues)
373
+
374
+ # Optional format check via `format_check` flag
375
+ format_issues_count: int = 0
376
+ format_files: list[str] = []
377
+ format_issues: list[RuffFormatIssue] = []
378
+ if self.options.get("format_check", False):
379
+ format_cmd: list[str] = self._build_format_command(
380
+ files=rel_files,
381
+ check_only=True,
382
+ )
383
+ success_format: bool
384
+ output_format: str
385
+ success_format, output_format = self._run_subprocess(
386
+ cmd=format_cmd,
387
+ timeout=timeout,
388
+ cwd=cwd,
389
+ )
390
+ format_files = parse_ruff_format_check_output(output=output_format)
391
+ # Normalize files to absolute paths to keep behavior consistent with
392
+ # direct CLI calls and stabilize tests that compare exact paths.
393
+ normalized_files: list[str] = []
394
+ for file_path in format_files:
395
+ if cwd and not os.path.isabs(file_path):
396
+ absolute_path = os.path.abspath(os.path.join(cwd, file_path))
397
+ normalized_files.append(absolute_path)
398
+ else:
399
+ normalized_files.append(file_path)
400
+ format_issues_count = len(normalized_files)
401
+ format_issues = [RuffFormatIssue(file=file) for file in normalized_files]
402
+
403
+ # Combine results
404
+ issues_count: int = lint_issues_count + format_issues_count
405
+ success: bool = issues_count == 0
406
+
407
+ # Suppress narrative blocks; rely on standardized tables and summary lines
408
+ output_summary: str | None = None
409
+
410
+ # Combine linting and formatting issues for the formatters
411
+ all_issues = lint_issues + format_issues
412
+
413
+ return ToolResult(
414
+ name=self.name,
415
+ success=success,
416
+ output=output_summary,
417
+ issues_count=issues_count,
418
+ issues=all_issues,
419
+ )
420
+
421
+ def fix(
422
+ self,
423
+ paths: list[str],
424
+ ) -> ToolResult:
425
+ """Fix issues in files with Ruff.
426
+
427
+ Args:
428
+ paths: list[str]: List of file or directory paths to fix.
429
+
430
+ Returns:
431
+ ToolResult: ToolResult instance.
432
+ """
433
+ self._validate_paths(paths=paths)
434
+ if not paths:
435
+ return ToolResult(
436
+ name=self.name,
437
+ success=True,
438
+ output="No files to fix.",
439
+ issues_count=0,
440
+ )
441
+
442
+ # Use shared utility for file discovery
443
+ python_files: list[str] = walk_files_with_excludes(
444
+ paths=paths,
445
+ file_patterns=self.config.file_patterns,
446
+ exclude_patterns=self.exclude_patterns,
447
+ include_venv=self.include_venv,
448
+ )
449
+
450
+ if not python_files:
451
+ return ToolResult(
452
+ name=self.name,
453
+ success=True,
454
+ output="No Python files found to fix.",
455
+ issues_count=0,
456
+ )
457
+
458
+ logger.debug(f"Files to fix: {python_files}")
459
+ timeout: int = self.options.get("timeout", RUFF_DEFAULT_TIMEOUT)
460
+ all_outputs: list[str] = []
461
+ overall_success: bool = True
462
+
463
+ # Track unsafe fixes for internal decisioning; do not emit as user-facing noise
464
+ unsafe_fixes_enabled: bool = self.options.get("unsafe_fixes", False)
465
+
466
+ # First, count issues before fixing
467
+ cmd_check: list[str] = self._build_check_command(files=python_files, fix=False)
468
+ success_check: bool
469
+ output_check: str
470
+ success_check, output_check = self._run_subprocess(
471
+ cmd=cmd_check,
472
+ timeout=timeout,
473
+ )
474
+ initial_issues = parse_ruff_output(output=output_check)
475
+ initial_count: int = len(initial_issues)
476
+
477
+ # Also check formatting issues before fixing
478
+ format_cmd_check: list[str] = self._build_format_command(
479
+ files=python_files,
480
+ check_only=True,
481
+ )
482
+ success_format_check: bool
483
+ output_format_check: str
484
+ success_format_check, output_format_check = self._run_subprocess(
485
+ cmd=format_cmd_check,
486
+ timeout=timeout,
487
+ )
488
+ format_files = parse_ruff_format_check_output(output=output_format_check)
489
+ initial_format_count: int = len(format_files)
490
+
491
+ # Total initial issues (linting + formatting)
492
+ total_initial_count: int = initial_count + initial_format_count
493
+
494
+ # Optionally run ruff check --fix (lint fixes)
495
+ remaining_issues = []
496
+ remaining_count = 0
497
+ if self.options.get("lint_fix", True):
498
+ cmd: list[str] = self._build_check_command(files=python_files, fix=True)
499
+ success: bool
500
+ output: str
501
+ success, output = self._run_subprocess(cmd=cmd, timeout=timeout)
502
+ remaining_issues = parse_ruff_output(output=output)
503
+ remaining_count = len(remaining_issues)
504
+
505
+ # Calculate how many issues were actually fixed
506
+ # Add formatting fixes if formatter ran
507
+ fixed_count: int = total_initial_count - remaining_count
508
+
509
+ # Do not print raw initial counts; keep output concise and unified
510
+
511
+ # Do not print intermediate fixed counts; unify after formatting phase
512
+
513
+ # If there are remaining issues, check if any are fixable with unsafe fixes
514
+ if remaining_count > 0:
515
+ # If unsafe fixes are disabled, check if any remaining issues are
516
+ # fixable with unsafe fixes
517
+ if not unsafe_fixes_enabled:
518
+ # Try running ruff with unsafe fixes in dry-run mode to see if it
519
+ # would fix more
520
+ cmd_unsafe: list[str] = self._build_check_command(
521
+ files=python_files,
522
+ fix=True,
523
+ )
524
+ if "--unsafe-fixes" not in cmd_unsafe:
525
+ cmd_unsafe.append("--unsafe-fixes")
526
+ # Only run if not already run with unsafe fixes
527
+ success_unsafe: bool
528
+ output_unsafe: str
529
+ success_unsafe, output_unsafe = self._run_subprocess(
530
+ cmd=cmd_unsafe,
531
+ timeout=timeout,
532
+ )
533
+ remaining_unsafe = parse_ruff_output(output=output_unsafe)
534
+ if len(remaining_unsafe) < remaining_count:
535
+ all_outputs.append(
536
+ "Some remaining issues could be fixed by enabling unsafe "
537
+ "fixes (use --tool-options ruff:unsafe_fixes=True)",
538
+ )
539
+ all_outputs.append(
540
+ f"{remaining_count} issue(s) cannot be auto-fixed",
541
+ )
542
+ for issue in remaining_issues[:DEFAULT_REMAINING_ISSUES_DISPLAY]:
543
+ file_path: str = getattr(issue, "file", "")
544
+ try:
545
+ file_rel: str = os.path.relpath(file_path)
546
+ except (ValueError, TypeError):
547
+ file_rel = file_path
548
+ all_outputs.append(
549
+ f" {file_rel}:{getattr(issue, 'line', '?')} - "
550
+ f"{getattr(issue, 'message', 'Unknown issue')}",
551
+ )
552
+ if len(remaining_issues) > DEFAULT_REMAINING_ISSUES_DISPLAY:
553
+ all_outputs.append(
554
+ f" ... and "
555
+ f"{len(remaining_issues) - DEFAULT_REMAINING_ISSUES_DISPLAY} more",
556
+ )
557
+
558
+ if total_initial_count == 0:
559
+ # Avoid duplicate success messages; rely on unified logger
560
+ pass
561
+ elif remaining_count == 0 and fixed_count > 0:
562
+ all_outputs.append("All linting issues were successfully auto-fixed")
563
+
564
+ if not (success and remaining_count == 0):
565
+ overall_success = False
566
+
567
+ # Run ruff format if enabled (default: True)
568
+ if self.options.get("format", False):
569
+ format_cmd: list[str] = self._build_format_command(
570
+ files=python_files,
571
+ check_only=False,
572
+ )
573
+ format_success: bool
574
+ format_output: str
575
+ format_success, format_output = self._run_subprocess(
576
+ cmd=format_cmd,
577
+ timeout=timeout,
578
+ )
579
+ # If we detected formatting issues initially, consider them fixed now
580
+ if initial_format_count > 0:
581
+ fixed_count += initial_format_count
582
+ # Suppress raw formatter output for consistency; rely on unified summary
583
+ # Only consider formatting failure if there are actual formatting
584
+ # issues. Don't fail the overall operation just because formatting
585
+ # failed when there are no issues
586
+ if not format_success and total_initial_count > 0:
587
+ overall_success = False
588
+
589
+ # Build concise, unified summary output for fmt runs
590
+ summary_lines: list[str] = []
591
+ if fixed_count > 0:
592
+ summary_lines.append(f"Fixed {fixed_count} issue(s)")
593
+ if remaining_count > 0:
594
+ summary_lines.append(
595
+ f"Found {remaining_count} issue(s) that cannot be auto-fixed",
596
+ )
597
+ final_output: str = (
598
+ "\n".join(summary_lines) if summary_lines else "No fixes applied."
599
+ )
600
+
601
+ # Success should be based on whether there are remaining issues after fixing
602
+ # If there are no initial issues, success should be True
603
+ if total_initial_count == 0:
604
+ overall_success = True
605
+ else:
606
+ overall_success = remaining_count == 0
607
+
608
+ return ToolResult(
609
+ name=self.name,
610
+ success=overall_success,
611
+ output=final_output,
612
+ # For fix operations, issues_count represents remaining for summaries
613
+ issues_count=remaining_count,
614
+ issues=remaining_issues,
615
+ initial_issues_count=total_initial_count,
616
+ fixed_issues_count=fixed_count,
617
+ remaining_issues_count=remaining_count,
618
+ )