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
@@ -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,7 +137,9 @@ 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
@@ -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).
@@ -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
 
@@ -440,10 +710,15 @@ def run_lint_tools_simple(
440
710
  continue
441
711
 
442
712
  # Optionally run post-checks (explicit, after main tools)
443
- post_cfg = post_cfg_early or load_post_checks_config()
444
- post_enabled = bool(post_cfg.get("enabled", False))
445
- post_tools: list[str] = list(post_cfg.get("tools", [])) if post_enabled else []
446
- 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"))
447
722
 
448
723
  # In JSON mode, we still need exit-code enforcement even if we skip
449
724
  # rendering post-check outputs. If a post-check tool is unavailable
@@ -489,22 +764,19 @@ def run_lint_tools_simple(
489
764
  f"Post-check '{post_tool_name}' unavailable: {e}",
490
765
  )
491
766
  continue
492
- tool_name = tool_enum.name.lower()
767
+ # Use canonical display name for consistent logging
768
+ tool_name = _get_tool_display_name(tool_enum)
493
769
 
494
770
  # Post-checks run with explicit headers (reuse standard header)
495
771
  if not json_output_mode:
496
772
  logger.print_tool_header(tool_name=tool_name, action=action)
497
773
 
498
774
  try:
499
- # Load tool-specific config and common options
500
- cfg: dict = load_lintro_tool_config(tool_name)
501
- if cfg:
502
- try:
503
- tool.set_options(**cfg)
504
- except Exception as e:
505
- logger.debug(
506
- f"Ignoring invalid config for {tool_name}: {e}",
507
- )
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
+
508
780
  tool.set_options(include_venv=include_venv)
509
781
  if exclude:
510
782
  exclude_patterns: list[str] = [
@@ -630,11 +902,36 @@ def run_lint_tools_simple(
630
902
  # Log at debug to avoid failing the run for non-critical persistence.
631
903
  logger.debug(f"Error saving outputs: {e}")
632
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
+
633
920
  # Return appropriate exit code
634
921
  if action == "fmt":
635
- # Format operations succeed if they complete successfully
636
- # (even if there are remaining unfixable issues)
637
- 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
+ )
638
935
  else: # check
639
936
  # Check operations fail if issues are found OR any tool reported failure
640
937
  any_failed: bool = any(
@@ -27,14 +27,26 @@ from lintro.formatters.tools.darglint_formatter import (
27
27
  DarglintTableDescriptor,
28
28
  format_darglint_issues,
29
29
  )
30
+ from lintro.formatters.tools.eslint_formatter import (
31
+ EslintTableDescriptor,
32
+ format_eslint_issues,
33
+ )
30
34
  from lintro.formatters.tools.hadolint_formatter import (
31
35
  HadolintTableDescriptor,
32
36
  format_hadolint_issues,
33
37
  )
38
+ from lintro.formatters.tools.markdownlint_formatter import (
39
+ MarkdownlintTableDescriptor,
40
+ format_markdownlint_issues,
41
+ )
34
42
  from lintro.formatters.tools.prettier_formatter import (
35
43
  PrettierTableDescriptor,
36
44
  format_prettier_issues,
37
45
  )
46
+ from lintro.formatters.tools.pytest_formatter import (
47
+ PytestFailuresTableDescriptor,
48
+ format_pytest_issues,
49
+ )
38
50
  from lintro.formatters.tools.ruff_formatter import (
39
51
  RuffTableDescriptor,
40
52
  format_ruff_issues,
@@ -47,9 +59,13 @@ from lintro.parsers.bandit.bandit_parser import parse_bandit_output
47
59
  from lintro.parsers.black.black_issue import BlackIssue
48
60
  from lintro.parsers.black.black_parser import parse_black_output
49
61
  from lintro.parsers.darglint.darglint_parser import parse_darglint_output
62
+ from lintro.parsers.eslint.eslint_issue import EslintIssue
63
+ from lintro.parsers.eslint.eslint_parser import parse_eslint_output
50
64
  from lintro.parsers.hadolint.hadolint_parser import parse_hadolint_output
65
+ from lintro.parsers.markdownlint.markdownlint_parser import parse_markdownlint_output
51
66
  from lintro.parsers.prettier.prettier_issue import PrettierIssue
52
67
  from lintro.parsers.prettier.prettier_parser import parse_prettier_output
68
+ from lintro.parsers.pytest.pytest_parser import parse_pytest_text_output
53
69
  from lintro.parsers.ruff.ruff_issue import RuffFormatIssue, RuffIssue
54
70
  from lintro.parsers.ruff.ruff_parser import parse_ruff_output
55
71
  from lintro.parsers.yamllint.yamllint_parser import parse_yamllint_output
@@ -57,13 +73,16 @@ from lintro.parsers.yamllint.yamllint_parser import parse_yamllint_output
57
73
  # Constants
58
74
  TOOL_TABLE_FORMATTERS: dict[str, tuple] = {
59
75
  "darglint": (DarglintTableDescriptor(), format_darglint_issues),
76
+ "eslint": (EslintTableDescriptor(), format_eslint_issues),
60
77
  "hadolint": (HadolintTableDescriptor(), format_hadolint_issues),
61
78
  "black": (BlackTableDescriptor(), format_black_issues),
62
79
  "prettier": (PrettierTableDescriptor(), format_prettier_issues),
80
+ "pytest": (PytestFailuresTableDescriptor(), format_pytest_issues),
63
81
  "ruff": (RuffTableDescriptor(), format_ruff_issues),
64
82
  "yamllint": (YamllintTableDescriptor(), format_yamllint_issues),
65
83
  "actionlint": (ActionlintTableDescriptor(), format_actionlint_issues),
66
84
  "bandit": (BanditTableDescriptor(), format_bandit_issues),
85
+ "markdownlint": (MarkdownlintTableDescriptor(), format_markdownlint_issues),
67
86
  }
68
87
  VENV_PATTERNS: list[str] = [
69
88
  "venv",
@@ -304,9 +323,19 @@ def format_tool_output(
304
323
  isinstance(i, RuffIssue) and getattr(i, "fixable", False)
305
324
  )
306
325
  if tool == "prettier":
307
- return lambda i: isinstance(i, PrettierIssue) or True
326
+ return lambda i: isinstance(i, PrettierIssue)
308
327
  if tool == "black":
309
- return lambda i: isinstance(i, BlackIssue) or True
328
+ return lambda i: isinstance(i, BlackIssue) and getattr(
329
+ i,
330
+ "fixable",
331
+ True,
332
+ )
333
+ if tool == "eslint":
334
+ return lambda i: isinstance(i, EslintIssue) and getattr(
335
+ i,
336
+ "fixable",
337
+ False,
338
+ )
310
339
  return None
311
340
 
312
341
  is_fixable = _is_fixable_predicate(tool_name)
@@ -364,10 +393,14 @@ def format_tool_output(
364
393
  parsed_issues = parse_black_output(output=output)
365
394
  elif tool_name == "darglint":
366
395
  parsed_issues = parse_darglint_output(output=output)
396
+ elif tool_name == "eslint":
397
+ parsed_issues = parse_eslint_output(output=output)
367
398
  elif tool_name == "hadolint":
368
399
  parsed_issues = parse_hadolint_output(output=output)
369
400
  elif tool_name == "yamllint":
370
401
  parsed_issues = parse_yamllint_output(output=output)
402
+ elif tool_name == "markdownlint":
403
+ parsed_issues = parse_markdownlint_output(output=output)
371
404
  elif tool_name == "bandit":
372
405
  # Bandit emits JSON; try parsing when raw output is provided
373
406
  try:
@@ -376,6 +409,9 @@ def format_tool_output(
376
409
  )
377
410
  except Exception:
378
411
  parsed_issues = []
412
+ elif tool_name == "pytest":
413
+ # Pytest emits text output; parse it
414
+ parsed_issues = parse_pytest_text_output(output=output)
379
415
 
380
416
  if parsed_issues and tool_name in TOOL_TABLE_FORMATTERS:
381
417
  _, formatter_func = TOOL_TABLE_FORMATTERS[tool_name]