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
@@ -1,15 +1,20 @@
1
1
  """Base core implementation for Lintro."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import os
4
6
  import shutil
5
7
  import subprocess # nosec B404 - subprocess used safely with shell=False
6
8
  from abc import ABC, abstractmethod
7
9
  from dataclasses import dataclass, field
10
+ from pathlib import Path
8
11
 
9
12
  from loguru import logger
10
13
 
14
+ from lintro.config import LintroConfig
11
15
  from lintro.enums.tool_type import ToolType
12
16
  from lintro.models.core.tool import ToolConfig, ToolResult
17
+ from lintro.utils.path_utils import find_lintro_ignore
13
18
 
14
19
  # Constants for default values
15
20
  DEFAULT_TIMEOUT: int = 30
@@ -91,6 +96,17 @@ class BaseTool(ABC):
91
96
  if not isinstance(self.config.tool_type, ToolType):
92
97
  raise ValueError("Tool tool_type must be a ToolType instance")
93
98
 
99
+ def _find_lintro_ignore(self) -> str | None:
100
+ """Find .lintro-ignore file by searching upward from current directory.
101
+
102
+ Uses the shared utility function to ensure consistent behavior.
103
+
104
+ Returns:
105
+ str | None: Path to .lintro-ignore file if found, None otherwise.
106
+ """
107
+ lintro_ignore_path = find_lintro_ignore()
108
+ return str(lintro_ignore_path) if lintro_ignore_path else None
109
+
94
110
  def _setup_defaults(self) -> None:
95
111
  """Set up default core options and patterns."""
96
112
  # Add default exclude patterns if not already present
@@ -99,10 +115,11 @@ class BaseTool(ABC):
99
115
  self.exclude_patterns.append(pattern)
100
116
 
101
117
  # Add .lintro-ignore patterns (project-wide) if present
118
+ # Search upward from current directory to find project root
102
119
  try:
103
- lintro_ignore_path = os.path.abspath(".lintro-ignore")
104
- if os.path.exists(lintro_ignore_path):
105
- with open(lintro_ignore_path, "r", encoding="utf-8") as f:
120
+ lintro_ignore_path = self._find_lintro_ignore()
121
+ if lintro_ignore_path and os.path.exists(lintro_ignore_path):
122
+ with open(lintro_ignore_path, encoding="utf-8") as f:
106
123
  for line in f:
107
124
  line_stripped = line.strip()
108
125
  if not line_stripped or line_stripped.startswith("#"):
@@ -147,6 +164,9 @@ class BaseTool(ABC):
147
164
  TimeoutExpired: If command times out.
148
165
  FileNotFoundError: If command executable is not found.
149
166
  """
167
+ # Validate command arguments for safety prior to execution
168
+ self._validate_subprocess_command(cmd=cmd)
169
+
150
170
  try:
151
171
  result = subprocess.run( # nosec B603 - args list, shell=False
152
172
  cmd,
@@ -157,7 +177,6 @@ class BaseTool(ABC):
157
177
  "timeout",
158
178
  self._default_timeout,
159
179
  ),
160
- check=False,
161
180
  cwd=cwd,
162
181
  )
163
182
  return result.returncode == 0, result.stdout + result.stderr
@@ -184,6 +203,31 @@ class BaseTool(ABC):
184
203
  f"Please ensure it is installed and in your PATH.",
185
204
  ) from e
186
205
 
206
+ def _validate_subprocess_command(self, cmd: list[str]) -> None:
207
+ """Validate a subprocess command argument list for safety.
208
+
209
+ Ensures that the command is a non-empty list of strings and that no
210
+ argument contains shell metacharacters that could enable command
211
+ injection when passed to subprocess (even with ``shell=False``).
212
+
213
+ Args:
214
+ cmd: list[str]: Command and arguments to validate.
215
+
216
+ Raises:
217
+ ValueError: If the command list is empty, contains non-strings,
218
+ or contains unsafe characters.
219
+ """
220
+ if not cmd or not isinstance(cmd, list):
221
+ raise ValueError("Command must be a non-empty list of strings")
222
+
223
+ unsafe_chars: set[str] = {";", "&", "|", ">", "<", "`", "$", "\\", "\n", "\r"}
224
+
225
+ for arg in cmd:
226
+ if not isinstance(arg, str):
227
+ raise ValueError("All command arguments must be strings")
228
+ if any(ch in arg for ch in unsafe_chars):
229
+ raise ValueError("Unsafe character detected in command argument")
230
+
187
231
  def set_options(self, **kwargs) -> None:
188
232
  """Set core options.
189
233
 
@@ -233,11 +277,10 @@ class BaseTool(ABC):
233
277
  self,
234
278
  paths: list[str],
235
279
  ) -> str | None:
236
- """Return the common parent directory for the given paths, or None if not
237
- applicable.
280
+ """Return the common parent directory for the given paths.
238
281
 
239
282
  Args:
240
- paths: list[str]: List of file paths to find common parent directory for.
283
+ paths: list[str]: Paths to compute a common parent directory for.
241
284
 
242
285
  Returns:
243
286
  str | None: Common parent directory path, or None if not applicable.
@@ -256,64 +299,257 @@ class BaseTool(ABC):
256
299
  ) -> list[str]:
257
300
  """Get the command prefix to execute a tool.
258
301
 
259
- Prefer running via ``uv run`` when available to ensure the tool executes
260
- within the active Python environment, avoiding PATH collisions with
261
- user-level shims. Fall back to a direct executable when ``uv`` is not
262
- present, and finally to the bare tool name.
302
+ Uses a unified approach based on tool category:
303
+ - Python bundled tools: Use python -m (guaranteed to use lintro's environment)
304
+ - Node.js tools: Use npx (respects project's package.json)
305
+ - Binary tools: Use system executable
263
306
 
264
307
  Args:
265
308
  tool_name: str: Name of the tool executable to find.
266
309
 
267
310
  Returns:
268
311
  list[str]: Command prefix to execute the tool.
269
-
270
- Examples:
271
- >>> self._get_executable_command("ruff")
272
- ["uv", "run", "ruff"] # preferred when uv is available
273
-
274
- >>> self._get_executable_command("ruff")
275
- ["ruff"] # if uv is not available but the tool is on PATH
276
312
  """
277
- # Tool-specific preferences to balance reliability vs. historical expectations
278
- python_tools_prefer_uv = {"black", "bandit", "yamllint", "darglint"}
279
-
280
- # Ruff: keep historical expectation for tests (direct invocation first)
281
- if tool_name == "ruff":
282
- if shutil.which(tool_name):
283
- return [tool_name]
284
- if shutil.which("uv"):
285
- return ["uv", "run", tool_name]
313
+ import sys
314
+
315
+ # Python tools bundled with lintro (guaranteed in our environment)
316
+ # Note: darglint cannot be run as a module (python -m darglint),
317
+ # so it's excluded
318
+ python_bundled_tools = {"ruff", "black", "bandit", "yamllint"}
319
+ if tool_name in python_bundled_tools:
320
+ # Use python -m to ensure we use the tool from lintro's environment
321
+ python_exe = sys.executable
322
+ if python_exe:
323
+ return [python_exe, "-m", tool_name]
324
+ # Fallback to direct executable if python path not found
286
325
  return [tool_name]
287
326
 
288
- # Black: prefer system binary first, then project env via uv run,
289
- # and finally uvx as a last resort.
290
- if tool_name == "black":
291
- if shutil.which(tool_name):
292
- return [tool_name]
293
- if shutil.which("uv"):
294
- return ["uv", "run", tool_name]
295
- if shutil.which("uvx"):
296
- return ["uvx", tool_name]
327
+ # Pytest: user environment tool (not bundled)
328
+ if tool_name == "pytest":
329
+ # Use python -m pytest for cross-platform compatibility
330
+ python_exe = sys.executable
331
+ if python_exe:
332
+ return [python_exe, "-m", "pytest"]
333
+ # Fall back to direct executable
297
334
  return [tool_name]
298
335
 
299
- # Python-based tools where running inside env avoids PATH shim issues
300
- if tool_name in python_tools_prefer_uv:
301
- if shutil.which(tool_name):
302
- return [tool_name]
303
- if shutil.which("uv"):
304
- return ["uv", "run", tool_name]
305
- if shutil.which("uvx"):
306
- return ["uvx", tool_name]
336
+ # Node.js tools: use npx to respect project's package.json
337
+ nodejs_tools = {"eslint", "prettier"}
338
+ if tool_name in nodejs_tools:
339
+ if shutil.which("npx"):
340
+ return ["npx", "--yes", tool_name]
341
+ # Fall back to direct executable
307
342
  return [tool_name]
308
343
 
309
- # Default: prefer direct system executable (node/binary tools like
310
- # prettier, hadolint, actionlint)
311
- if shutil.which(tool_name):
312
- return [tool_name]
313
- if shutil.which("uv"):
314
- return ["uv", "run", tool_name]
344
+ # Binary tools: use system executable
315
345
  return [tool_name]
316
346
 
347
+ def _verify_tool_version(self) -> ToolResult | None:
348
+ """Verify that the tool meets minimum version requirements.
349
+
350
+ Returns:
351
+ Optional[ToolResult]: None if version check passes, or a skip result
352
+ if it fails
353
+ """
354
+ from lintro.tools.core.version_requirements import check_tool_version
355
+
356
+ command = self._get_executable_command(self.name)
357
+ version_info = check_tool_version(self.name, command)
358
+
359
+ if version_info.version_check_passed:
360
+ return None # Version check passed
361
+
362
+ # Version check failed - return skip result with warning
363
+ skip_message = (
364
+ f"Skipping {self.name}: {version_info.error_message}. "
365
+ f"Minimum required: {version_info.min_version}. "
366
+ f"{version_info.install_hint}"
367
+ )
368
+
369
+ return ToolResult(
370
+ name=self.name,
371
+ success=True, # Not an error, just skipping
372
+ output=skip_message,
373
+ issues_count=0,
374
+ )
375
+
376
+ # -------------------------------------------------------------------------
377
+ # Lintro Config Support - Tiered Model
378
+ # -------------------------------------------------------------------------
379
+
380
+ def _get_lintro_config(self) -> LintroConfig:
381
+ """Get the current Lintro configuration.
382
+
383
+ Returns:
384
+ LintroConfig: The loaded Lintro configuration.
385
+ """
386
+ from lintro.config import get_config
387
+
388
+ return get_config()
389
+
390
+ def _should_use_lintro_config(self) -> bool:
391
+ """Check if Lintro config should be used.
392
+
393
+ Returns True if:
394
+ 1. LINTRO_SKIP_CONFIG_INJECTION env var is NOT set, AND
395
+ 2. A .lintro-config.yaml exists, OR [tool.lintro] is in pyproject.toml
396
+
397
+ Returns:
398
+ bool: True if Lintro config should be used.
399
+ """
400
+ # Allow tests to disable config injection
401
+ if os.environ.get("LINTRO_SKIP_CONFIG_INJECTION"):
402
+ return False
403
+
404
+ lintro_config = self._get_lintro_config()
405
+ return lintro_config.config_path is not None
406
+
407
+ def _get_enforced_settings(self) -> set[str]:
408
+ """Get the set of settings that are enforced by Lintro config.
409
+
410
+ This allows tools to check whether a setting is already being
411
+ injected via CLI args from the enforce tier, so they can avoid
412
+ adding duplicate arguments from their own options.
413
+
414
+ Returns:
415
+ set[str]: Set of setting names like 'line_length', 'target_python'.
416
+ """
417
+ if not self._should_use_lintro_config():
418
+ return set()
419
+
420
+ lintro_config = self._get_lintro_config()
421
+ enforced: set[str] = set()
422
+
423
+ if lintro_config.enforce.line_length is not None:
424
+ enforced.add("line_length")
425
+ if lintro_config.enforce.target_python is not None:
426
+ enforced.add("target_python")
427
+
428
+ return enforced
429
+
430
+ def _get_enforce_cli_args(self) -> list[str]:
431
+ """Get CLI arguments for enforced settings.
432
+
433
+ Returns CLI args that inject enforce settings (like line_length)
434
+ directly into the tool command line.
435
+
436
+ Returns:
437
+ list[str]: CLI arguments for enforced settings.
438
+ """
439
+ from lintro.config import get_enforce_cli_args
440
+
441
+ if not self._should_use_lintro_config():
442
+ return []
443
+
444
+ lintro_config = self._get_lintro_config()
445
+ return get_enforce_cli_args(
446
+ tool_name=self.name,
447
+ lintro_config=lintro_config,
448
+ )
449
+
450
+ def _get_defaults_config_args(self) -> list[str]:
451
+ """Get CLI arguments for defaults config injection.
452
+
453
+ If the tool has no native config and defaults are defined in
454
+ the Lintro config, generates a temp config file and returns
455
+ the CLI args to pass it to the tool.
456
+
457
+ Returns:
458
+ list[str]: CLI arguments for defaults config injection.
459
+ """
460
+ from lintro.config import (
461
+ generate_defaults_config,
462
+ get_defaults_injection_args,
463
+ )
464
+
465
+ if not self._should_use_lintro_config():
466
+ return []
467
+
468
+ lintro_config = self._get_lintro_config()
469
+ config_path = generate_defaults_config(
470
+ tool_name=self.name,
471
+ lintro_config=lintro_config,
472
+ )
473
+
474
+ if config_path is None:
475
+ return []
476
+
477
+ return get_defaults_injection_args(
478
+ tool_name=self.name,
479
+ config_path=config_path,
480
+ )
481
+
482
+ def _build_config_args(self) -> list[str]:
483
+ """Build complete config-related CLI arguments for this tool.
484
+
485
+ Uses the tiered model:
486
+ 1. Enforced settings are injected via CLI flags
487
+ 2. Defaults config is used only if no native config exists
488
+
489
+ Returns:
490
+ list[str]: Combined CLI arguments for configuration.
491
+ """
492
+ args: list[str] = []
493
+
494
+ # Add enforce CLI args (e.g., --line-length 88)
495
+ args.extend(self._get_enforce_cli_args())
496
+
497
+ # Add defaults config args if applicable
498
+ args.extend(self._get_defaults_config_args())
499
+
500
+ return args
501
+
502
+ # -------------------------------------------------------------------------
503
+ # Deprecated methods for backward compatibility
504
+ # -------------------------------------------------------------------------
505
+
506
+ def _generate_tool_config(self) -> Path | None:
507
+ """Generate a temporary config file for this tool.
508
+
509
+ DEPRECATED: Use _get_enforce_cli_args() and _get_defaults_config_args()
510
+ instead.
511
+
512
+ Returns:
513
+ Path | None: Path to generated config file, or None if not needed.
514
+ """
515
+ from lintro.config import generate_defaults_config
516
+
517
+ logger.debug(
518
+ f"_generate_tool_config() is deprecated for {self.name}. "
519
+ "Use _build_config_args() or call _get_enforce_cli_args() and "
520
+ "_get_defaults_config_args() directly.",
521
+ )
522
+ lintro_config = self._get_lintro_config()
523
+ return generate_defaults_config(
524
+ tool_name=self.name,
525
+ lintro_config=lintro_config,
526
+ )
527
+
528
+ def _get_config_injection_args(self) -> list[str]:
529
+ """Get CLI arguments to inject Lintro config into this tool.
530
+
531
+ DEPRECATED: Use _get_enforce_cli_args() and _get_defaults_config_args()
532
+ instead, or use _build_config_args() which combines both.
533
+
534
+ Returns:
535
+ list[str]: CLI arguments for config injection (enforce + defaults).
536
+ """
537
+ args: list[str] = []
538
+ args.extend(self._get_enforce_cli_args())
539
+ args.extend(self._get_defaults_config_args())
540
+ return args
541
+
542
+ def _get_no_auto_config_args(self) -> list[str]:
543
+ """Get CLI arguments to disable native config auto-discovery.
544
+
545
+ DEPRECATED: No longer needed with the tiered model.
546
+ Tools use their native configs by default.
547
+
548
+ Returns:
549
+ list[str]: Empty list (no longer used).
550
+ """
551
+ return []
552
+
317
553
  @abstractmethod
318
554
  def check(
319
555
  self,
@@ -3,8 +3,11 @@
3
3
  from dataclasses import dataclass, field
4
4
  from typing import Any
5
5
 
6
+ from loguru import logger
7
+
6
8
  from lintro.models.core.tool import Tool
7
9
  from lintro.tools.tool_enum import ToolEnum
10
+ from lintro.utils.unified_config import get_ordered_tools
8
11
 
9
12
 
10
13
  @dataclass
@@ -14,9 +17,14 @@ class ToolManager:
14
17
  This class is responsible for:
15
18
  - Tool registration
16
19
  - Tool conflict resolution
17
- - Tool execution order
20
+ - Tool execution order (priority-based, alphabetical, or custom)
18
21
  - Tool configuration management
19
22
 
23
+ Tool ordering is controlled by [tool.lintro].tool_order in pyproject.toml:
24
+ - "priority" (default): Formatters run before linters based on priority values
25
+ - "alphabetical": Tools run in alphabetical order by name
26
+ - "custom": Tools run in order specified by [tool.lintro].tool_order_custom
27
+
20
28
  Attributes:
21
29
  _tools: Dictionary mapping core names to core classes
22
30
  _check_tools: Dictionary mapping core names to core classes that can check
@@ -77,17 +85,23 @@ class ToolManager:
77
85
  ) -> list[ToolEnum]:
78
86
  """Get the order in which tools should be executed.
79
87
 
80
- This method takes into account:
81
- - Tool conflicts
82
- - Alphabetical ordering
83
- - Tool dependencies
88
+ Tool ordering is controlled by [tool.lintro].tool_order in pyproject.toml:
89
+ - "priority" (default): Formatters run before linters based on priority
90
+ - "alphabetical": Tools run in alphabetical order by name
91
+ - "custom": Tools run in order specified by [tool.lintro].tool_order_custom
92
+
93
+ This method also handles:
94
+ - Tool conflicts (unless ignore_conflicts is True)
84
95
 
85
96
  Args:
86
- tool_list: List of core names to execute
87
- ignore_conflicts: Whether to ignore core conflicts
97
+ tool_list: List of tools to order.
98
+ ignore_conflicts: If True, skip conflict checking.
88
99
 
89
100
  Returns:
90
- List of core names in alphabetical execution order
101
+ List of ToolEnum members in execution order based on configured strategy.
102
+
103
+ Raises:
104
+ ValueError: If duplicate tools are found in tool_list.
91
105
  """
92
106
  if not tool_list:
93
107
  return []
@@ -95,30 +109,69 @@ class ToolManager:
95
109
  # Get core instances
96
110
  tools = {name: self.get_tool(name) for name in tool_list}
97
111
 
98
- # Sort tools alphabetically by name
99
- if ignore_conflicts:
100
- return sorted(
101
- tool_list,
102
- key=lambda name: name.name,
112
+ # Validate for duplicate tools
113
+ seen_names: set[str] = set()
114
+ duplicates: list[str] = []
115
+ for tool in tool_list:
116
+ tool_name_lower = tool.name.lower()
117
+ if tool_name_lower in seen_names:
118
+ duplicates.append(tool.name)
119
+ else:
120
+ seen_names.add(tool_name_lower)
121
+ if duplicates:
122
+ raise ValueError(
123
+ f"Duplicate tools found in tool_list: {', '.join(duplicates)}",
103
124
  )
104
125
 
126
+ # Convert ToolEnum to tool names for unified config ordering
127
+ tool_names = [t.name.lower() for t in tool_list]
128
+ ordered_names = get_ordered_tools(tool_names)
129
+
130
+ # Map back to ToolEnum in the ordered sequence
131
+ name_to_enum = {t.name.lower(): t for t in tool_list}
132
+ sorted_tools = [
133
+ name_to_enum[name] for name in ordered_names if name in name_to_enum
134
+ ]
135
+
136
+ # Validate that all requested tools are preserved
137
+ original_names = {t.name.lower() for t in tool_list}
138
+ sorted_names = {t.name.lower() for t in sorted_tools}
139
+ missing_tools = original_names - sorted_names
140
+ if missing_tools:
141
+ # Append missing tools in their original order
142
+ missing_enums = [t for t in tool_list if t.name.lower() in missing_tools]
143
+ sorted_tools.extend(missing_enums)
144
+ logger.warning(
145
+ f"Some tools were not found in ordered list and appended: "
146
+ f"{[t.name for t in missing_enums]}",
147
+ )
148
+
149
+ if ignore_conflicts:
150
+ return sorted_tools
151
+
105
152
  # Build conflict graph
106
153
  conflict_graph: dict[ToolEnum, set[ToolEnum]] = {
107
154
  name: set() for name in tool_list
108
155
  }
156
+ # Create mapping from tool name strings to ToolEnum members
157
+ # Handle both lowercase strings and ToolEnum members in conflicts_with
158
+ name_to_enum_map = {t.name.lower(): t for t in ToolEnum}
109
159
  for name, tool in tools.items():
110
160
  for conflict in tool.config.conflicts_with:
111
- if conflict in tool_list:
112
- conflict_graph[name].add(conflict)
113
- conflict_graph[conflict].add(name)
114
-
115
- # Sort tools alphabetically by name
116
- sorted_tools = sorted(
117
- tool_list,
118
- key=lambda name: name.name,
119
- )
120
-
121
- # Resolve conflicts by keeping the first alphabetical tool
161
+ # Convert conflict string to ToolEnum if needed
162
+ conflict_enum: ToolEnum | None = None
163
+ if isinstance(conflict, str):
164
+ # Look up by lowercase name
165
+ conflict_enum = name_to_enum_map.get(conflict.lower())
166
+ elif isinstance(conflict, ToolEnum):
167
+ # Already a ToolEnum member
168
+ conflict_enum = conflict
169
+ # Only add to conflict graph if conflict_enum is in tool_list
170
+ if conflict_enum is not None and conflict_enum in tool_list:
171
+ conflict_graph[name].add(conflict_enum)
172
+ conflict_graph[conflict_enum].add(name)
173
+
174
+ # Resolve conflicts by keeping the first tool in ordered sequence
122
175
  result = []
123
176
  for tool_name in sorted_tools:
124
177
  # Check if this core conflicts with any already selected tools