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
@@ -11,16 +11,67 @@ from lintro.enums.group_by import GroupBy, normalize_group_by
11
11
  from lintro.enums.output_format import OutputFormat, normalize_output_format
12
12
  from lintro.tools import tool_manager
13
13
  from lintro.tools.tool_enum import ToolEnum
14
- from lintro.utils.config import load_lintro_tool_config, load_post_checks_config
14
+ from lintro.utils.config import load_post_checks_config
15
15
  from lintro.utils.console_logger import create_logger
16
16
  from lintro.utils.output_manager import OutputManager
17
17
  from lintro.utils.tool_utils import format_tool_output
18
+ from lintro.utils.unified_config import UnifiedConfigManager
18
19
 
19
20
  # Constants
20
21
  DEFAULT_EXIT_CODE_SUCCESS: int = 0
21
22
  DEFAULT_EXIT_CODE_FAILURE: int = 1
22
23
  DEFAULT_REMAINING_COUNT: int = 1
23
24
 
25
+ # Mapping from ToolEnum to canonical display names
26
+ _TOOL_DISPLAY_NAMES: dict[ToolEnum, str] = {
27
+ ToolEnum.BLACK: "black",
28
+ ToolEnum.DARGLINT: "darglint",
29
+ ToolEnum.HADOLINT: "hadolint",
30
+ ToolEnum.PRETTIER: "prettier",
31
+ ToolEnum.PYTEST: "pytest",
32
+ ToolEnum.RUFF: "ruff",
33
+ ToolEnum.YAMLLINT: "yamllint",
34
+ ToolEnum.ACTIONLINT: "actionlint",
35
+ ToolEnum.BANDIT: "bandit",
36
+ }
37
+
38
+
39
+ def _get_tool_display_name(tool_enum: ToolEnum) -> str:
40
+ """Get the canonical display name for a tool enum.
41
+
42
+ This function provides a consistent mapping from ToolEnum to user-friendly
43
+ display names. It first attempts to get the tool instance to use its canonical
44
+ name, but falls back to a predefined mapping if the tool cannot be instantiated.
45
+
46
+ Args:
47
+ tool_enum: The ToolEnum instance.
48
+
49
+ Returns:
50
+ str: The canonical display name for the tool.
51
+ """
52
+ # Try to get the tool instance to use its canonical name
53
+ try:
54
+ tool = tool_manager.get_tool(tool_enum)
55
+ return tool.name
56
+ except Exception:
57
+ # Fall back to predefined mapping if tool cannot be instantiated
58
+ return _TOOL_DISPLAY_NAMES.get(tool_enum, tool_enum.name.lower())
59
+
60
+
61
+ def _get_tool_lookup_keys(tool_enum: ToolEnum, tool_name: str) -> set[str]:
62
+ """Get all possible lookup keys for a tool in tool_option_dict.
63
+
64
+ This includes the tool's display name and enum name (both lowercased).
65
+
66
+ Args:
67
+ tool_enum: The ToolEnum instance.
68
+ tool_name: The canonical display name for the tool.
69
+
70
+ Returns:
71
+ set[str]: Set of lowercase keys to check in tool_option_dict.
72
+ """
73
+ return {tool_name.lower(), tool_enum.name.lower()}
74
+
24
75
 
25
76
  def _get_tools_to_run(
26
77
  tools: str | None,
@@ -30,7 +81,7 @@ def _get_tools_to_run(
30
81
 
31
82
  Args:
32
83
  tools: str | None: Comma-separated tool names, "all", or None.
33
- action: str: "check" or "fmt".
84
+ action: str: "check", "fmt", or "test".
34
85
 
35
86
  Returns:
36
87
  list[ToolEnum]: List of ToolEnum instances to run.
@@ -38,19 +89,43 @@ def _get_tools_to_run(
38
89
  Raises:
39
90
  ValueError: If unknown tool names are provided.
40
91
  """
92
+ if action == "test":
93
+ # Test action only supports pytest
94
+ if tools and tools.lower() != "pytest":
95
+ raise ValueError(
96
+ (
97
+ "Only 'pytest' is supported for the test action; "
98
+ "run 'lintro test' without --tools or "
99
+ "use '--tools pytest'"
100
+ ),
101
+ )
102
+ try:
103
+ return [ToolEnum["PYTEST"]]
104
+ except KeyError:
105
+ raise ValueError(
106
+ "pytest tool is not available",
107
+ ) from None
108
+
41
109
  if tools == "all" or tools is None:
42
110
  # Get all available tools for the action
43
111
  if action == "fmt":
44
112
  available_tools = tool_manager.get_fix_tools()
45
113
  else: # check
46
114
  available_tools = tool_manager.get_check_tools()
47
- return list(available_tools.keys())
115
+ # Filter out pytest for check/fmt actions
116
+ return [t for t in available_tools if t.name.upper() != "PYTEST"]
48
117
 
49
118
  # Parse specific tools
50
119
  tool_names: list[str] = [name.strip().upper() for name in tools.split(",")]
51
120
  tools_to_run: list[ToolEnum] = []
52
121
 
53
122
  for name in tool_names:
123
+ # Reject pytest for check/fmt actions
124
+ if name == "PYTEST":
125
+ raise ValueError(
126
+ "pytest tool is not available for check/fmt actions. "
127
+ "Use 'lintro test' instead.",
128
+ )
54
129
  try:
55
130
  tool_enum = ToolEnum[name]
56
131
  # Verify the tool supports the requested action
@@ -62,10 +137,12 @@ def _get_tools_to_run(
62
137
  )
63
138
  tools_to_run.append(tool_enum)
64
139
  except KeyError:
65
- available_names: list[str] = [e.name.lower() for e in ToolEnum]
140
+ available_names: list[str] = [
141
+ e.name.lower() for e in ToolEnum if e.name.upper() != "PYTEST"
142
+ ]
66
143
  raise ValueError(
67
144
  f"Unknown tool '{name.lower()}'. Available tools: {available_names}",
68
- )
145
+ ) from None
69
146
 
70
147
  return tools_to_run
71
148
 
@@ -144,7 +221,7 @@ def _parse_tool_options(tool_options: str | None) -> dict[str, dict[str, object]
144
221
  # Skip malformed fragment
145
222
  continue
146
223
  opt_name, opt_value = tool_opt.split("=", 1)
147
- tool_name = tool_name.strip()
224
+ tool_name = tool_name.strip().lower()
148
225
  opt_name = opt_name.strip()
149
226
  opt_value = opt_value.strip()
150
227
  if not tool_name or not opt_name:
@@ -156,6 +233,189 @@ def _parse_tool_options(tool_options: str | None) -> dict[str, dict[str, object]
156
233
  return tool_option_dict
157
234
 
158
235
 
236
+ def _write_output_file(
237
+ *,
238
+ output_path: str,
239
+ output_format: OutputFormat,
240
+ all_results: list,
241
+ action: str,
242
+ total_issues: int,
243
+ total_fixed: int,
244
+ ) -> None:
245
+ """Write results to user-specified output file.
246
+
247
+ Args:
248
+ output_path: str: Path to the output file.
249
+ output_format: OutputFormat: Format for the output.
250
+ all_results: list: List of ToolResult objects.
251
+ action: str: The action performed (check, fmt, test).
252
+ total_issues: int: Total number of issues found.
253
+ total_fixed: int: Total number of issues fixed.
254
+ """
255
+ import csv
256
+ import datetime
257
+ import json
258
+ from pathlib import Path
259
+
260
+ output_file = Path(output_path)
261
+ output_file.parent.mkdir(parents=True, exist_ok=True)
262
+
263
+ if output_format == OutputFormat.JSON:
264
+ # Build JSON structure similar to stdout JSON mode
265
+ json_data = {
266
+ "timestamp": datetime.datetime.now().isoformat(),
267
+ "action": action,
268
+ "summary": {
269
+ "total_issues": total_issues,
270
+ "total_fixed": total_fixed,
271
+ "tools_run": len(all_results),
272
+ },
273
+ "results": [],
274
+ }
275
+ for result in all_results:
276
+ result_data = {
277
+ "tool": result.name,
278
+ "success": getattr(result, "success", True),
279
+ "issues_count": getattr(result, "issues_count", 0),
280
+ "output": getattr(result, "output", ""),
281
+ }
282
+ if hasattr(result, "issues") and result.issues:
283
+ result_data["issues"] = [
284
+ {
285
+ "file": getattr(issue, "file", ""),
286
+ "line": getattr(issue, "line", ""),
287
+ "code": getattr(issue, "code", ""),
288
+ "message": getattr(issue, "message", ""),
289
+ }
290
+ for issue in result.issues
291
+ ]
292
+ json_data["results"].append(result_data)
293
+ output_file.write_text(
294
+ json.dumps(json_data, indent=2, ensure_ascii=False),
295
+ encoding="utf-8",
296
+ )
297
+
298
+ elif output_format == OutputFormat.CSV:
299
+ # Write CSV format
300
+ rows: list[list[str]] = []
301
+ header: list[str] = ["tool", "issues_count", "file", "line", "code", "message"]
302
+ for result in all_results:
303
+ if hasattr(result, "issues") and result.issues:
304
+ for issue in result.issues:
305
+ rows.append(
306
+ [
307
+ result.name,
308
+ str(getattr(result, "issues_count", 0)),
309
+ str(getattr(issue, "file", "")),
310
+ str(getattr(issue, "line", "")),
311
+ str(getattr(issue, "code", "")),
312
+ str(getattr(issue, "message", "")),
313
+ ],
314
+ )
315
+ else:
316
+ rows.append(
317
+ [
318
+ result.name,
319
+ str(getattr(result, "issues_count", 0)),
320
+ "",
321
+ "",
322
+ "",
323
+ "",
324
+ ],
325
+ )
326
+ with output_file.open("w", encoding="utf-8", newline="") as f:
327
+ writer = csv.writer(f)
328
+ writer.writerow(header)
329
+ writer.writerows(rows)
330
+
331
+ elif output_format == OutputFormat.MARKDOWN:
332
+ # Write Markdown format
333
+ lines: list[str] = ["# Lintro Report", ""]
334
+ lines.append("## Summary\n")
335
+ lines.append("| Tool | Issues |")
336
+ lines.append("|------|--------|")
337
+ for result in all_results:
338
+ lines.append(f"| {result.name} | {getattr(result, 'issues_count', 0)} |")
339
+ lines.append("")
340
+ for result in all_results:
341
+ issues_count = getattr(result, "issues_count", 0)
342
+ lines.append(f"### {result.name} ({issues_count} issues)")
343
+ if hasattr(result, "issues") and result.issues:
344
+ lines.append("| File | Line | Code | Message |")
345
+ lines.append("|------|------|------|---------|")
346
+ for issue in result.issues:
347
+ file_val = str(getattr(issue, "file", "")).replace("|", r"\|")
348
+ line_val = getattr(issue, "line", "")
349
+ code_val = str(getattr(issue, "code", "")).replace("|", r"\|")
350
+ msg_val = str(getattr(issue, "message", "")).replace("|", r"\|")
351
+ lines.append(
352
+ f"| {file_val} | {line_val} | {code_val} | {msg_val} |",
353
+ )
354
+ lines.append("")
355
+ else:
356
+ lines.append("No issues found.\n")
357
+ output_file.write_text("\n".join(lines), encoding="utf-8")
358
+
359
+ elif output_format == OutputFormat.HTML:
360
+ # Write HTML format
361
+ html_lines: list[str] = [
362
+ "<html><head><title>Lintro Report</title></head><body>",
363
+ ]
364
+ html_lines.append("<h1>Lintro Report</h1>")
365
+ html_lines.append("<h2>Summary</h2>")
366
+ html_lines.append("<table border='1'><tr><th>Tool</th><th>Issues</th></tr>")
367
+ for result in all_results:
368
+ import html
369
+
370
+ safe_name = html.escape(result.name)
371
+ html_lines.append(
372
+ f"<tr><td>{safe_name}</td>"
373
+ f"<td>{getattr(result, 'issues_count', 0)}</td></tr>",
374
+ )
375
+ html_lines.append("</table>")
376
+ for result in all_results:
377
+ import html
378
+
379
+ issues_count = getattr(result, "issues_count", 0)
380
+ html_lines.append(
381
+ f"<h3>{html.escape(result.name)} ({issues_count} issues)</h3>",
382
+ )
383
+ if hasattr(result, "issues") and result.issues:
384
+ html_lines.append(
385
+ "<table border='1'><tr><th>File</th><th>Line</th>"
386
+ "<th>Code</th><th>Message</th></tr>",
387
+ )
388
+ for issue in result.issues:
389
+ f_val = html.escape(str(getattr(issue, "file", "")))
390
+ l_val = getattr(issue, "line", "")
391
+ c_val = html.escape(str(getattr(issue, "code", "")))
392
+ m_val = html.escape(str(getattr(issue, "message", "")))
393
+ html_lines.append(
394
+ f"<tr><td>{f_val}</td><td>{l_val}</td>"
395
+ f"<td>{c_val}</td><td>{m_val}</td></tr>",
396
+ )
397
+ html_lines.append("</table>")
398
+ else:
399
+ html_lines.append("<p>No issues found.</p>")
400
+ html_lines.append("</body></html>")
401
+ output_file.write_text("\n".join(html_lines), encoding="utf-8")
402
+
403
+ else:
404
+ # Plain or Grid format - write formatted text output
405
+ lines: list[str] = [f"Lintro {action.capitalize()} Report", "=" * 40, ""]
406
+ for result in all_results:
407
+ issues_count = getattr(result, "issues_count", 0)
408
+ lines.append(f"{result.name}: {issues_count} issues")
409
+ output_text = getattr(result, "output", "")
410
+ if output_text and output_text.strip():
411
+ lines.append(output_text.strip())
412
+ lines.append("")
413
+ lines.append(f"Total Issues: {total_issues}")
414
+ if action == "fmt":
415
+ lines.append(f"Total Fixed: {total_fixed}")
416
+ output_file.write_text("\n".join(lines), encoding="utf-8")
417
+
418
+
159
419
  def run_lint_tools_simple(
160
420
  *,
161
421
  action: str,
@@ -168,6 +428,7 @@ def run_lint_tools_simple(
168
428
  output_format: str,
169
429
  verbose: bool,
170
430
  raw_output: bool = False,
431
+ output_file: str | None = None,
171
432
  ) -> int:
172
433
  """Simplified runner using Loguru-based logging with rich formatting.
173
434
 
@@ -188,6 +449,7 @@ def run_lint_tools_simple(
188
449
  output_format: str: Output format for results.
189
450
  verbose: bool: Whether to enable verbose output.
190
451
  raw_output: bool: Whether to show raw tool output instead of formatted output.
452
+ output_file: str | None: Optional file path to write results to.
191
453
 
192
454
  Returns:
193
455
  int: Exit code (0 for success, 1 for failures).
@@ -231,7 +493,7 @@ def run_lint_tools_simple(
231
493
  post_cfg_early = load_post_checks_config()
232
494
  post_enabled_early = bool(post_cfg_early.get("enabled", False))
233
495
  post_tools_early: set[str] = (
234
- set(t.lower() for t in (post_cfg_early.get("tools", []) or []))
496
+ {t.lower() for t in (post_cfg_early.get("tools", []) or [])}
235
497
  if post_enabled_early
236
498
  else set()
237
499
  )
@@ -275,11 +537,11 @@ def run_lint_tools_simple(
275
537
 
276
538
  # Run each tool with rich formatting
277
539
  for tool_enum in tools_to_run:
278
- tool_name: str = tool_enum.name.lower()
279
540
  # Resolve the tool instance; if unavailable, record failure and continue
280
541
  try:
281
542
  tool = tool_manager.get_tool(tool_enum)
282
543
  except Exception as e:
544
+ tool_name: str = _get_tool_display_name(tool_enum)
283
545
  logger.warning(f"Tool '{tool_name}' unavailable: {e}")
284
546
  from lintro.models.core.tool_result import ToolResult
285
547
 
@@ -293,22 +555,30 @@ def run_lint_tools_simple(
293
555
  )
294
556
  continue
295
557
 
558
+ # Use canonical display name for consistent logging
559
+ tool_name: str = _get_tool_display_name(tool_enum)
296
560
  # Print rich tool header (skip for JSON mode)
297
561
  if not json_output_mode:
298
562
  logger.print_tool_header(tool_name=tool_name, action=action)
299
563
 
300
564
  try:
301
- # Configure tool options
302
- # 1) Load config from pyproject.toml / lintro.toml
303
- cfg: dict = load_lintro_tool_config(tool_name)
304
- if cfg:
305
- try:
306
- tool.set_options(**cfg)
307
- except Exception as e:
308
- logger.debug(f"Ignoring invalid config for {tool_name}: {e}")
309
- # 2) CLI --tool-options overrides config file
310
- if tool_name in tool_option_dict:
311
- tool.set_options(**tool_option_dict[tool_name])
565
+ # Configure tool options using UnifiedConfigManager
566
+ # Priority: CLI --tool-options > [tool.lintro.<tool>] > global settings
567
+ config_manager = UnifiedConfigManager()
568
+
569
+ # Build CLI overrides from --tool-options
570
+ cli_overrides: dict[str, object] = {}
571
+ lookup_keys = _get_tool_lookup_keys(tool_enum, tool_name)
572
+ for option_key in lookup_keys:
573
+ overrides = tool_option_dict.get(option_key)
574
+ if overrides:
575
+ cli_overrides.update(overrides)
576
+
577
+ # Apply unified config with CLI overrides
578
+ config_manager.apply_config_to_tool(
579
+ tool=tool,
580
+ cli_overrides=cli_overrides if cli_overrides else None,
581
+ )
312
582
 
313
583
  # Set common options
314
584
  if exclude:
@@ -324,19 +594,19 @@ def run_lint_tools_simple(
324
594
  # CLI or config. This keeps Ruff focused on lint fixes while Black
325
595
  # handles formatting.
326
596
  if "black" in post_tools_early and tool_name == "ruff":
327
- # Respect explicit overrides from CLI or config
328
- cli_overrides = tool_option_dict.get("ruff", {})
329
- cfg_overrides = cfg or {}
597
+ # Get tool config from manager to check for explicit overrides
598
+ tool_config = config_manager.get_tool_config(tool_name)
599
+ lintro_tool_cfg = tool_config.lintro_tool_config or {}
330
600
  if action == "fmt":
331
601
  if (
332
602
  "format" not in cli_overrides
333
- and "format" not in cfg_overrides
603
+ and "format" not in lintro_tool_cfg
334
604
  ):
335
605
  tool.set_options(format=False)
336
606
  else: # check
337
607
  if (
338
608
  "format_check" not in cli_overrides
339
- and "format_check" not in cfg_overrides
609
+ and "format_check" not in lintro_tool_cfg
340
610
  ):
341
611
  tool.set_options(format_check=False)
342
612
 
@@ -392,10 +662,7 @@ def run_lint_tools_simple(
392
662
  if not json_output_mode:
393
663
  # Use raw output if raw_output is true, otherwise use
394
664
  # formatted output
395
- if raw_output:
396
- display_output = output
397
- else:
398
- display_output = formatted_output
665
+ display_output = output if raw_output else formatted_output
399
666
  logger.print_tool_result(
400
667
  tool_name=tool_name,
401
668
  output=display_output,
@@ -443,10 +710,15 @@ def run_lint_tools_simple(
443
710
  continue
444
711
 
445
712
  # Optionally run post-checks (explicit, after main tools)
446
- post_cfg = post_cfg_early or load_post_checks_config()
447
- post_enabled = bool(post_cfg.get("enabled", False))
448
- post_tools: list[str] = list(post_cfg.get("tools", [])) if post_enabled else []
449
- enforce_failure: bool = bool(post_cfg.get("enforce_failure", action == "check"))
713
+ # Skip post-checks for test action - test commands should only run tests
714
+ if action == "test":
715
+ post_tools = []
716
+ enforce_failure = False
717
+ else:
718
+ post_cfg = post_cfg_early or load_post_checks_config()
719
+ post_enabled = bool(post_cfg.get("enabled", False))
720
+ post_tools = list(post_cfg.get("tools", [])) if post_enabled else []
721
+ enforce_failure = bool(post_cfg.get("enforce_failure", action == "check"))
450
722
 
451
723
  # In JSON mode, we still need exit-code enforcement even if we skip
452
724
  # rendering post-check outputs. If a post-check tool is unavailable
@@ -492,22 +764,19 @@ def run_lint_tools_simple(
492
764
  f"Post-check '{post_tool_name}' unavailable: {e}",
493
765
  )
494
766
  continue
495
- tool_name = tool_enum.name.lower()
767
+ # Use canonical display name for consistent logging
768
+ tool_name = _get_tool_display_name(tool_enum)
496
769
 
497
770
  # Post-checks run with explicit headers (reuse standard header)
498
771
  if not json_output_mode:
499
772
  logger.print_tool_header(tool_name=tool_name, action=action)
500
773
 
501
774
  try:
502
- # Load tool-specific config and common options
503
- cfg: dict = load_lintro_tool_config(tool_name)
504
- if cfg:
505
- try:
506
- tool.set_options(**cfg)
507
- except Exception as e:
508
- logger.debug(
509
- f"Ignoring invalid config for {tool_name}: {e}",
510
- )
775
+ # Configure post-check tool using UnifiedConfigManager
776
+ # This replaces manual sync logic with unified config management
777
+ post_config_manager = UnifiedConfigManager()
778
+ post_config_manager.apply_config_to_tool(tool=tool)
779
+
511
780
  tool.set_options(include_venv=include_venv)
512
781
  if exclude:
513
782
  exclude_patterns: list[str] = [
@@ -633,11 +902,36 @@ def run_lint_tools_simple(
633
902
  # Log at debug to avoid failing the run for non-critical persistence.
634
903
  logger.debug(f"Error saving outputs: {e}")
635
904
 
905
+ # Write to user-specified output file if provided
906
+ if output_file:
907
+ try:
908
+ _write_output_file(
909
+ output_path=output_file,
910
+ output_format=output_fmt_enum,
911
+ all_results=all_results,
912
+ action=action,
913
+ total_issues=total_issues,
914
+ total_fixed=total_fixed,
915
+ )
916
+ logger.debug(f"Wrote results to {output_file}")
917
+ except Exception as e:
918
+ logger.error(f"Failed to write output to {output_file}: {e}")
919
+
636
920
  # Return appropriate exit code
637
921
  if action == "fmt":
638
- # Format operations succeed if they complete successfully
639
- # (even if there are remaining unfixable issues)
640
- return DEFAULT_EXIT_CODE_SUCCESS
922
+ # Format operations should fail if:
923
+ # 1. Any tool reported failure (execution error)
924
+ # 2. There are remaining unfixable issues after formatting
925
+ any_failed: bool = any(
926
+ not getattr(result, "success", True) for result in all_results
927
+ )
928
+ # Check if there are remaining issues that couldn't be fixed
929
+ has_remaining_issues: bool = total_remaining > 0
930
+ return (
931
+ DEFAULT_EXIT_CODE_SUCCESS
932
+ if (not any_failed and not has_remaining_issues)
933
+ else DEFAULT_EXIT_CODE_FAILURE
934
+ )
641
935
  else: # check
642
936
  # Check operations fail if issues are found OR any tool reported failure
643
937
  any_failed: bool = any(