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
@@ -0,0 +1,926 @@
1
+ """Unified configuration manager for Lintro.
2
+
3
+ This module provides a centralized configuration system that:
4
+ 1. Reads global settings from [tool.lintro]
5
+ 2. Reads native tool configs (for comparison/validation)
6
+ 3. Reads tool-specific overrides from [tool.lintro.<tool>]
7
+ 4. Computes effective config per tool with clear priority rules
8
+ 5. Warns about inconsistencies between configs
9
+ 6. Manages tool execution order (priority-based or alphabetical)
10
+
11
+ Priority order (highest to lowest):
12
+ 1. CLI --tool-options (always wins)
13
+ 2. [tool.lintro.<tool>] in pyproject.toml
14
+ 3. [tool.lintro] global settings in pyproject.toml
15
+ 4. Native tool config (e.g., .prettierrc, [tool.ruff])
16
+ 5. Tool defaults
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import tomllib
23
+ from dataclasses import dataclass, field
24
+ from enum import StrEnum, auto
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from loguru import logger
29
+
30
+ try:
31
+ import yaml
32
+ except ImportError:
33
+ yaml = None # type: ignore[assignment]
34
+
35
+
36
+ def _strip_jsonc_comments(content: str) -> str:
37
+ """Strip JSONC comments from content, preserving strings.
38
+
39
+ This function safely removes // and /* */ comments from JSONC content
40
+ while preserving comment-like sequences inside string values.
41
+
42
+ Args:
43
+ content: JSONC content as string
44
+
45
+ Returns:
46
+ Content with comments stripped
47
+
48
+ Note:
49
+ This is a simple implementation that handles most common cases.
50
+ For complex JSONC with nested comments or edge cases, consider
51
+ using a proper JSONC parser library (e.g., json5 or commentjson).
52
+ """
53
+ result: list[str] = []
54
+ i = 0
55
+ content_len = len(content)
56
+ in_string = False
57
+ escape_next = False
58
+ in_block_comment = False
59
+
60
+ while i < content_len:
61
+ char = content[i]
62
+
63
+ if escape_next:
64
+ escape_next = False
65
+ if not in_block_comment:
66
+ result.append(char)
67
+ i += 1
68
+ continue
69
+
70
+ if char == "\\" and in_string:
71
+ escape_next = True
72
+ if not in_block_comment:
73
+ result.append(char)
74
+ i += 1
75
+ continue
76
+
77
+ if char == '"' and not in_block_comment:
78
+ in_string = not in_string
79
+ result.append(char)
80
+ i += 1
81
+ continue
82
+
83
+ if in_string:
84
+ result.append(char)
85
+ i += 1
86
+ continue
87
+
88
+ # Check for block comment start /* ... */
89
+ if i < content_len - 1 and char == "/" and content[i + 1] == "*":
90
+ in_block_comment = True
91
+ i += 2
92
+ continue
93
+
94
+ # Check for block comment end */
95
+ if (
96
+ i > 0
97
+ and i < content_len
98
+ and char == "/"
99
+ and content[i - 1] == "*"
100
+ and in_block_comment
101
+ ):
102
+ in_block_comment = False
103
+ i += 1
104
+ continue
105
+
106
+ # Check for line comment //
107
+ if (
108
+ i < content_len - 1
109
+ and char == "/"
110
+ and content[i + 1] == "/"
111
+ and not in_block_comment
112
+ ):
113
+ # Skip to end of line
114
+ while i < content_len and content[i] != "\n":
115
+ i += 1
116
+ # Include the newline if present
117
+ if i < content_len:
118
+ result.append("\n")
119
+ i += 1
120
+ continue
121
+
122
+ if not in_block_comment:
123
+ result.append(char)
124
+
125
+ i += 1
126
+
127
+ if in_block_comment:
128
+ logger.warning("Unclosed block comment in JSONC content")
129
+
130
+ return "".join(result)
131
+
132
+
133
+ class ToolOrderStrategy(StrEnum):
134
+ """Strategy for ordering tool execution."""
135
+
136
+ PRIORITY = auto() # Use tool priority values (formatters before linters)
137
+ ALPHABETICAL = auto() # Alphabetical by tool name
138
+ CUSTOM = auto() # Custom order defined in config
139
+
140
+
141
+ @dataclass
142
+ class ToolConfigInfo:
143
+ """Information about a tool's configuration sources.
144
+
145
+ Attributes:
146
+ tool_name: Name of the tool
147
+ native_config: Configuration from native tool config files
148
+ lintro_tool_config: Configuration from [tool.lintro.<tool>]
149
+ effective_config: Computed effective configuration
150
+ warnings: List of warnings about configuration issues
151
+ is_injectable: Whether Lintro can inject config to this tool
152
+ """
153
+
154
+ tool_name: str
155
+ native_config: dict[str, Any] = field(default_factory=dict)
156
+ lintro_tool_config: dict[str, Any] = field(default_factory=dict)
157
+ effective_config: dict[str, Any] = field(default_factory=dict)
158
+ warnings: list[str] = field(default_factory=list)
159
+ is_injectable: bool = True
160
+
161
+
162
+ # Global settings that Lintro can manage across tools
163
+ # Each setting maps to tool-specific config keys and indicates which tools
164
+ # support injection via Lintro config (vs requiring native config files)
165
+ GLOBAL_SETTINGS: dict[str, dict[str, Any]] = {
166
+ "line_length": {
167
+ "tools": {
168
+ "ruff": "line-length",
169
+ "black": "line-length",
170
+ "markdownlint": "config.MD013.line_length", # Nested in config object
171
+ "prettier": "printWidth",
172
+ "yamllint": "rules.line-length.max", # Nested under rules.line-length.max
173
+ },
174
+ "injectable": {
175
+ "ruff",
176
+ "black",
177
+ "markdownlint",
178
+ "prettier",
179
+ "yamllint",
180
+ },
181
+ },
182
+ "target_python": {
183
+ "tools": {
184
+ "ruff": "target-version",
185
+ "black": "target-version",
186
+ },
187
+ "injectable": {"ruff", "black"},
188
+ },
189
+ "indent_size": {
190
+ "tools": {
191
+ "prettier": "tabWidth",
192
+ "ruff": "indent-width",
193
+ },
194
+ "injectable": {"prettier", "ruff"},
195
+ },
196
+ "quote_style": {
197
+ "tools": {
198
+ "ruff": "quote-style", # Under [tool.ruff.format]
199
+ "prettier": "singleQuote", # Boolean: true for single quotes
200
+ },
201
+ "injectable": {"ruff", "prettier"},
202
+ },
203
+ }
204
+
205
+ # Default tool priorities (lower = runs first)
206
+ # Formatters run before linters to avoid false positives
207
+ DEFAULT_TOOL_PRIORITIES: dict[str, int] = {
208
+ "prettier": 10, # Formatter - runs first
209
+ "black": 15, # Formatter
210
+ "ruff": 20, # Linter/Formatter hybrid
211
+ "markdownlint": 30, # Linter
212
+ "yamllint": 35, # Linter
213
+ "darglint": 40, # Linter
214
+ "bandit": 45, # Security linter
215
+ "eslint": 50, # JavaScript/TypeScript linter
216
+ "hadolint": 50, # Docker linter
217
+ "actionlint": 55, # GitHub Actions linter
218
+ "pytest": 100, # Test runner - runs last
219
+ }
220
+
221
+
222
+ def _load_pyproject() -> dict[str, Any]:
223
+ """Load the full pyproject.toml.
224
+
225
+ Returns:
226
+ Full pyproject.toml contents as dict
227
+ """
228
+ pyproject_path = Path("pyproject.toml")
229
+ if not pyproject_path.exists():
230
+ return {}
231
+ try:
232
+ with pyproject_path.open("rb") as f:
233
+ return tomllib.load(f)
234
+ except (OSError, tomllib.TOMLDecodeError):
235
+ return {}
236
+
237
+
238
+ def _load_native_tool_config(tool_name: str) -> dict[str, Any]:
239
+ """Load native configuration for a specific tool.
240
+
241
+ Args:
242
+ tool_name: Name of the tool
243
+
244
+ Returns:
245
+ Native configuration dictionary
246
+ """
247
+ pyproject = _load_pyproject()
248
+ tool_section = pyproject.get("tool", {})
249
+
250
+ # Tools with pyproject.toml config
251
+ if tool_name in ("ruff", "black", "bandit"):
252
+ return tool_section.get(tool_name, {})
253
+
254
+ # Yamllint: check native config files (not pyproject.toml)
255
+ if tool_name == "yamllint":
256
+ yamllint_config_files = [".yamllint", ".yamllint.yaml", ".yamllint.yml"]
257
+ for config_file in yamllint_config_files:
258
+ config_path = Path(config_file)
259
+ if config_path.exists():
260
+ if yaml is None:
261
+ logger.debug(
262
+ f"[UnifiedConfig] Found {config_file} but yaml not installed",
263
+ )
264
+ return {}
265
+ try:
266
+ with config_path.open(encoding="utf-8") as f:
267
+ content = yaml.safe_load(f)
268
+ return content if isinstance(content, dict) else {}
269
+ except (yaml.YAMLError, OSError) as e:
270
+ logger.debug(f"[UnifiedConfig] Failed to parse {config_file}: {e}")
271
+ return {}
272
+
273
+ # Prettier: check multiple config file formats
274
+ if tool_name == "prettier":
275
+ for config_file in [".prettierrc", ".prettierrc.json", "prettier.config.js"]:
276
+ config_path = Path(config_file)
277
+ if config_path.exists() and config_file.endswith(".json"):
278
+ try:
279
+ with config_path.open(encoding="utf-8") as f:
280
+ return json.load(f)
281
+ except (json.JSONDecodeError, FileNotFoundError):
282
+ pass
283
+ elif config_path.exists() and config_file == ".prettierrc":
284
+ # Try parsing as JSON (common format)
285
+ try:
286
+ with config_path.open(encoding="utf-8") as f:
287
+ return json.load(f)
288
+ except (json.JSONDecodeError, FileNotFoundError):
289
+ pass
290
+ # Check package.json prettier field
291
+ pkg_path = Path("package.json")
292
+ if pkg_path.exists():
293
+ try:
294
+ with pkg_path.open(encoding="utf-8") as f:
295
+ pkg = json.load(f)
296
+ if "prettier" in pkg:
297
+ return pkg["prettier"]
298
+ except (json.JSONDecodeError, FileNotFoundError):
299
+ pass
300
+ return {}
301
+
302
+ # ESLint: check multiple config file formats
303
+ if tool_name == "eslint":
304
+ # Check flat config (ESLint 9+)
305
+ flat_config = Path("eslint.config.js")
306
+ if flat_config.exists():
307
+ # Note: We can't easily parse JS files, so return empty dict
308
+ # The tool will use the config natively
309
+ return {}
310
+ # Check legacy config files
311
+ for config_file in [
312
+ ".eslintrc.js",
313
+ ".eslintrc.json",
314
+ ".eslintrc.yaml",
315
+ ".eslintrc.yml",
316
+ ]:
317
+ config_path = Path(config_file)
318
+ if not config_path.exists():
319
+ continue
320
+ # Handle JSON files
321
+ if config_file.endswith(".json"):
322
+ try:
323
+ with config_path.open(encoding="utf-8") as f:
324
+ return json.load(f)
325
+ except (json.JSONDecodeError, FileNotFoundError):
326
+ pass
327
+ # Handle YAML files
328
+ elif config_file.endswith((".yaml", ".yml")):
329
+ if yaml is None:
330
+ logger.debug(
331
+ f"[UnifiedConfig] Found {config_file} but yaml not installed",
332
+ )
333
+ continue
334
+ try:
335
+ with config_path.open(encoding="utf-8") as f:
336
+ content = yaml.safe_load(f)
337
+ return content if isinstance(content, dict) else {}
338
+ except (yaml.YAMLError, OSError) as e:
339
+ logger.debug(f"[UnifiedConfig] Failed to parse {config_file}: {e}")
340
+ # Check package.json eslintConfig field
341
+ pkg_path = Path("package.json")
342
+ if pkg_path.exists():
343
+ try:
344
+ with pkg_path.open(encoding="utf-8") as f:
345
+ pkg = json.load(f)
346
+ if "eslintConfig" in pkg:
347
+ return pkg["eslintConfig"]
348
+ except (json.JSONDecodeError, FileNotFoundError):
349
+ pass
350
+ return {}
351
+
352
+ # Markdownlint: check config files
353
+ if tool_name == "markdownlint":
354
+ for config_file in [
355
+ ".markdownlint.json",
356
+ ".markdownlint.yaml",
357
+ ".markdownlint.yml",
358
+ ".markdownlint.jsonc",
359
+ ]:
360
+ config_path = Path(config_file)
361
+ if not config_path.exists():
362
+ continue
363
+
364
+ # Handle JSON/JSONC files
365
+ if config_file.endswith((".json", ".jsonc")):
366
+ try:
367
+ with config_path.open(encoding="utf-8") as f:
368
+ content = f.read()
369
+ # Strip JSONC comments safely (preserves strings)
370
+ content = _strip_jsonc_comments(content)
371
+ return json.loads(content)
372
+ except (json.JSONDecodeError, FileNotFoundError):
373
+ pass
374
+
375
+ # Handle YAML files
376
+ elif config_file.endswith((".yaml", ".yml")):
377
+ if yaml is None:
378
+ logger.warning(
379
+ "PyYAML not available; cannot parse .markdownlint.yaml",
380
+ )
381
+ continue
382
+ try:
383
+ with config_path.open(encoding="utf-8") as f:
384
+ content = yaml.safe_load(f)
385
+ # Handle multi-document YAML (coerce to dict)
386
+ if isinstance(content, list) and len(content) > 0:
387
+ content = content[0]
388
+ if isinstance(content, dict):
389
+ return content
390
+ except Exception as e: # noqa: BLE001
391
+ # Catch yaml.YAMLError and other exceptions
392
+ # (file I/O, parsing errors)
393
+ # Continue to next config file if this one fails to parse
394
+ logger.debug(
395
+ f"Failed to parse {config_path}: {type(e).__name__}",
396
+ )
397
+ pass
398
+ return {}
399
+
400
+ return {}
401
+
402
+
403
+ def _get_nested_value(config: dict[str, Any], key_path: str) -> Any:
404
+ """Get a nested value from a config dict using dot notation.
405
+
406
+ Args:
407
+ config: Configuration dictionary
408
+ key_path: Dot-separated key path (e.g., "line-length.max")
409
+
410
+ Returns:
411
+ Value at path, or None if not found
412
+ """
413
+ keys = key_path.split(".")
414
+ current = config
415
+ for key in keys:
416
+ if isinstance(current, dict) and key in current:
417
+ current = current[key]
418
+ else:
419
+ return None
420
+ return current
421
+
422
+
423
+ def load_lintro_global_config() -> dict[str, Any]:
424
+ """Load global Lintro configuration from [tool.lintro].
425
+
426
+ Returns:
427
+ Global configuration dictionary (excludes tool-specific sections)
428
+ """
429
+ pyproject = _load_pyproject()
430
+ lintro_config = pyproject.get("tool", {}).get("lintro", {})
431
+
432
+ # Filter out known tool-specific sections
433
+ tool_sections = {
434
+ "ruff",
435
+ "black",
436
+ "prettier",
437
+ "yamllint",
438
+ "markdownlint",
439
+ "markdownlint-cli2",
440
+ "bandit",
441
+ "darglint",
442
+ "hadolint",
443
+ "actionlint",
444
+ "pytest",
445
+ "post_checks",
446
+ "versions",
447
+ }
448
+
449
+ return {k: v for k, v in lintro_config.items() if k not in tool_sections}
450
+
451
+
452
+ def load_lintro_tool_config(tool_name: str) -> dict[str, Any]:
453
+ """Load tool-specific Lintro config from [tool.lintro.<tool>].
454
+
455
+ Args:
456
+ tool_name: Name of the tool
457
+
458
+ Returns:
459
+ Tool-specific Lintro configuration
460
+ """
461
+ pyproject = _load_pyproject()
462
+ lintro_config = pyproject.get("tool", {}).get("lintro", {})
463
+ return lintro_config.get(tool_name, {})
464
+
465
+
466
+ def get_tool_order_config() -> dict[str, Any]:
467
+ """Get tool ordering configuration from [tool.lintro].
468
+
469
+ Returns:
470
+ Tool ordering configuration with keys:
471
+ - strategy: "priority", "alphabetical", or "custom"
472
+ - custom_order: list of tool names (for custom strategy)
473
+ - priority_overrides: dict of tool -> priority (for priority strategy)
474
+ """
475
+ global_config = load_lintro_global_config()
476
+
477
+ return {
478
+ "strategy": global_config.get("tool_order", "priority"),
479
+ "custom_order": global_config.get("tool_order_custom", []),
480
+ "priority_overrides": global_config.get("tool_priorities", {}),
481
+ }
482
+
483
+
484
+ def get_tool_priority(tool_name: str) -> int:
485
+ """Get the execution priority for a tool.
486
+
487
+ Lower values run first. Formatters have lower priorities than linters.
488
+
489
+ Args:
490
+ tool_name: Name of the tool
491
+
492
+ Returns:
493
+ Priority value (lower = runs first)
494
+ """
495
+ order_config = get_tool_order_config()
496
+ priority_overrides = order_config.get("priority_overrides", {})
497
+ # Normalize priority_overrides keys to lowercase for consistent lookup
498
+ priority_overrides_normalized = {
499
+ k.lower(): v for k, v in priority_overrides.items()
500
+ }
501
+ tool_name_lower = tool_name.lower()
502
+
503
+ # Check for override first
504
+ if tool_name_lower in priority_overrides_normalized:
505
+ return priority_overrides_normalized[tool_name_lower]
506
+
507
+ # Use default priority
508
+ return DEFAULT_TOOL_PRIORITIES.get(tool_name_lower, 50)
509
+
510
+
511
+ def get_ordered_tools(
512
+ tool_names: list[str],
513
+ tool_order: str | list[str] | None = None,
514
+ ) -> list[str]:
515
+ """Get tool names in execution order based on configured strategy.
516
+
517
+ Args:
518
+ tool_names: List of tool names to order
519
+ tool_order: Optional override for tool order strategy. Can be:
520
+ - "priority": Sort by tool priority (default)
521
+ - "alphabetical": Sort alphabetically
522
+ - list[str]: Custom order (tools in list come first)
523
+ - None: Read strategy from config
524
+
525
+ Returns:
526
+ List of tool names in execution order
527
+ """
528
+ # Determine strategy and custom order
529
+ if tool_order is None:
530
+ order_config = get_tool_order_config()
531
+ strategy = order_config.get("strategy", "priority")
532
+ custom_order = order_config.get("custom_order", [])
533
+ elif isinstance(tool_order, list):
534
+ strategy = "custom"
535
+ custom_order = tool_order
536
+ else:
537
+ strategy = tool_order
538
+ custom_order = []
539
+
540
+ if strategy == "alphabetical":
541
+ return sorted(tool_names, key=str.lower)
542
+
543
+ if strategy == "custom":
544
+ # Tools in custom_order come first (in that order), then remaining
545
+ # by priority
546
+ ordered: list[str] = []
547
+ remaining = list(tool_names)
548
+
549
+ for tool in custom_order:
550
+ # Case-insensitive matching for custom order
551
+ tool_lower = tool.lower()
552
+ for t in remaining:
553
+ if t.lower() == tool_lower:
554
+ ordered.append(t)
555
+ remaining.remove(t)
556
+ break
557
+
558
+ # Add remaining tools by priority (consistent with default strategy)
559
+ ordered.extend(
560
+ sorted(remaining, key=lambda t: (get_tool_priority(t), t.lower())),
561
+ )
562
+ return ordered
563
+
564
+ # Default: priority-based ordering
565
+ return sorted(tool_names, key=lambda t: (get_tool_priority(t), t.lower()))
566
+
567
+
568
+ def get_effective_line_length(tool_name: str) -> int | None:
569
+ """Get the effective line length for a specific tool.
570
+
571
+ Priority:
572
+ 1. [tool.lintro.<tool>] line_length
573
+ 2. [tool.lintro] line_length
574
+ 3. [tool.ruff] line-length (as fallback source of truth)
575
+ 4. Native tool config
576
+ 5. None (use tool default)
577
+
578
+ Args:
579
+ tool_name: Name of the tool
580
+
581
+ Returns:
582
+ Effective line length, or None to use tool default
583
+ """
584
+ # 1. Check tool-specific lintro config
585
+ lintro_tool = load_lintro_tool_config(tool_name)
586
+ if "line_length" in lintro_tool:
587
+ return lintro_tool["line_length"]
588
+ if "line-length" in lintro_tool:
589
+ return lintro_tool["line-length"]
590
+
591
+ # 2. Check global lintro config
592
+ lintro_global = load_lintro_global_config()
593
+ if "line_length" in lintro_global:
594
+ return lintro_global["line_length"]
595
+ if "line-length" in lintro_global:
596
+ return lintro_global["line-length"]
597
+
598
+ # 3. Fall back to Ruff's line-length as source of truth
599
+ pyproject = _load_pyproject()
600
+ ruff_config = pyproject.get("tool", {}).get("ruff", {})
601
+ if "line-length" in ruff_config:
602
+ return ruff_config["line-length"]
603
+ if "line_length" in ruff_config:
604
+ return ruff_config["line_length"]
605
+
606
+ # 4. Check native tool config (for non-Ruff tools)
607
+ native = _load_native_tool_config(tool_name)
608
+ setting_key = GLOBAL_SETTINGS.get("line_length", {}).get("tools", {}).get(tool_name)
609
+ if setting_key:
610
+ native_value = _get_nested_value(native, setting_key)
611
+ if native_value is not None:
612
+ return native_value
613
+
614
+ return None
615
+
616
+
617
+ def is_tool_injectable(tool_name: str) -> bool:
618
+ """Check if Lintro can inject config to a tool.
619
+
620
+ Args:
621
+ tool_name: Name of the tool
622
+
623
+ Returns:
624
+ True if Lintro can inject config via CLI or generated config file
625
+ """
626
+ return tool_name.lower() in GLOBAL_SETTINGS["line_length"]["injectable"]
627
+
628
+
629
+ def validate_config_consistency() -> list[str]:
630
+ """Check for inconsistencies in line length settings across tools.
631
+
632
+ Returns:
633
+ List of warning messages about inconsistent configurations
634
+ """
635
+ warnings: list[str] = []
636
+ effective_line_length = get_effective_line_length("ruff")
637
+
638
+ if effective_line_length is None:
639
+ return warnings
640
+
641
+ # Check each tool's native config for mismatches
642
+ for tool_name, setting_key in GLOBAL_SETTINGS["line_length"]["tools"].items():
643
+ if tool_name == "ruff":
644
+ continue # Skip Ruff (it's the source of truth)
645
+
646
+ native = _load_native_tool_config(tool_name)
647
+ native_value = _get_nested_value(native, setting_key)
648
+
649
+ if native_value is not None and native_value != effective_line_length:
650
+ injectable = is_tool_injectable(tool_name)
651
+ if injectable:
652
+ warnings.append(
653
+ f"{tool_name}: Native config has {setting_key}={native_value}, "
654
+ f"but Lintro will override with {effective_line_length}",
655
+ )
656
+ else:
657
+ warnings.append(
658
+ f"⚠️ {tool_name}: Native config has {setting_key}={native_value}, "
659
+ f"differs from central line_length={effective_line_length}. "
660
+ f"Lintro cannot override this tool's native config - "
661
+ f"update manually for consistency.",
662
+ )
663
+
664
+ return warnings
665
+
666
+
667
+ def get_tool_config_summary() -> dict[str, ToolConfigInfo]:
668
+ """Get a summary of configuration for all tools.
669
+
670
+ Returns:
671
+ Dictionary mapping tool names to their config info
672
+ """
673
+ tools = [
674
+ "ruff",
675
+ "black",
676
+ "prettier",
677
+ "yamllint",
678
+ "markdownlint",
679
+ "darglint",
680
+ "bandit",
681
+ "hadolint",
682
+ "actionlint",
683
+ ]
684
+ summary: dict[str, ToolConfigInfo] = {}
685
+
686
+ for tool_name in tools:
687
+ info = ToolConfigInfo(
688
+ tool_name=tool_name,
689
+ native_config=_load_native_tool_config(tool_name),
690
+ lintro_tool_config=load_lintro_tool_config(tool_name),
691
+ is_injectable=is_tool_injectable(tool_name),
692
+ )
693
+
694
+ # Compute effective line_length
695
+ effective_ll = get_effective_line_length(tool_name)
696
+ if effective_ll is not None:
697
+ info.effective_config["line_length"] = effective_ll
698
+
699
+ summary[tool_name] = info
700
+
701
+ # Add warnings
702
+ warnings = validate_config_consistency()
703
+ for warning in warnings:
704
+ for tool_name in tools:
705
+ if tool_name in warning.lower():
706
+ summary[tool_name].warnings.append(warning)
707
+ break
708
+
709
+ return summary
710
+
711
+
712
+ def get_config_report() -> str:
713
+ """Generate a configuration report as a string.
714
+
715
+ Returns:
716
+ Formatted configuration report
717
+ """
718
+ summary = get_tool_config_summary()
719
+ central_ll = get_effective_line_length("ruff")
720
+ order_config = get_tool_order_config()
721
+
722
+ lines: list[str] = []
723
+ lines.append("=" * 60)
724
+ lines.append("LINTRO CONFIGURATION REPORT")
725
+ lines.append("=" * 60)
726
+ lines.append("")
727
+
728
+ # Global settings section
729
+ lines.append("── Global Settings ──")
730
+ lines.append(f" Central line_length: {central_ll or 'Not configured'}")
731
+ lines.append(f" Tool order strategy: {order_config.get('strategy', 'priority')}")
732
+ if order_config.get("custom_order"):
733
+ lines.append(f" Custom order: {', '.join(order_config['custom_order'])}")
734
+ lines.append("")
735
+
736
+ # Tool execution order section
737
+ lines.append("── Tool Execution Order ──")
738
+ tool_names = list(summary.keys())
739
+ ordered_tools = get_ordered_tools(tool_names)
740
+ for idx, tool_name in enumerate(ordered_tools, 1):
741
+ priority = get_tool_priority(tool_name)
742
+ lines.append(f" {idx}. {tool_name} (priority: {priority})")
743
+ lines.append("")
744
+
745
+ # Per-tool configuration section
746
+ lines.append("── Per-Tool Configuration ──")
747
+ for tool_name, info in summary.items():
748
+ injectable = "✅ Syncable" if info.is_injectable else "⚠️ Native only"
749
+ effective = info.effective_config.get("line_length", "default")
750
+ lines.append(f" {tool_name}:")
751
+ lines.append(f" Status: {injectable}")
752
+ lines.append(f" Effective line_length: {effective}")
753
+ if info.lintro_tool_config:
754
+ lines.append(f" Lintro config: {info.lintro_tool_config}")
755
+ if info.native_config and tool_name not in ("ruff", "black", "bandit"):
756
+ # Only show native config for tools with external config files
757
+ lines.append(f" Native config: {info.native_config}")
758
+ lines.append("")
759
+
760
+ # Warnings section
761
+ all_warnings = validate_config_consistency()
762
+ if all_warnings:
763
+ lines.append("── Configuration Warnings ──")
764
+ for warning in all_warnings:
765
+ lines.append(f" {warning}")
766
+ lines.append("")
767
+ else:
768
+ lines.append("── Configuration Warnings ──")
769
+ lines.append(" None - all configs consistent!")
770
+ lines.append("")
771
+
772
+ lines.append("=" * 60)
773
+ return "\n".join(lines)
774
+
775
+
776
+ def print_config_report() -> None:
777
+ """Print a report of configuration status for all tools."""
778
+ report = get_config_report()
779
+ for line in report.split("\n"):
780
+ if line.startswith("⚠️") or "Warning" in line:
781
+ logger.warning(line)
782
+ else:
783
+ logger.info(line)
784
+
785
+
786
+ @dataclass
787
+ class UnifiedConfigManager:
788
+ """Central configuration manager for Lintro.
789
+
790
+ This class provides a unified interface for:
791
+ - Loading and merging configurations from multiple sources
792
+ - Computing effective configurations for each tool
793
+ - Validating configuration consistency
794
+ - Managing tool execution order
795
+
796
+ Attributes:
797
+ global_config: Global Lintro configuration from [tool.lintro]
798
+ tool_configs: Per-tool configuration info
799
+ warnings: List of configuration warnings
800
+ """
801
+
802
+ global_config: dict[str, Any] = field(default_factory=dict)
803
+ tool_configs: dict[str, ToolConfigInfo] = field(default_factory=dict)
804
+ warnings: list[str] = field(default_factory=list)
805
+
806
+ def __post_init__(self) -> None:
807
+ """Initialize configuration manager."""
808
+ self.refresh()
809
+
810
+ def refresh(self) -> None:
811
+ """Reload all configuration from files."""
812
+ self.global_config = load_lintro_global_config()
813
+ self.tool_configs = get_tool_config_summary()
814
+ self.warnings = validate_config_consistency()
815
+
816
+ def get_effective_line_length(self, tool_name: str) -> int | None:
817
+ """Get effective line length for a tool.
818
+
819
+ Args:
820
+ tool_name: Name of the tool
821
+
822
+ Returns:
823
+ Effective line length or None
824
+ """
825
+ return get_effective_line_length(tool_name)
826
+
827
+ def get_tool_config(self, tool_name: str) -> ToolConfigInfo:
828
+ """Get configuration info for a specific tool.
829
+
830
+ Args:
831
+ tool_name: Name of the tool
832
+
833
+ Returns:
834
+ Tool configuration info
835
+ """
836
+ if tool_name not in self.tool_configs:
837
+ self.tool_configs[tool_name] = ToolConfigInfo(
838
+ tool_name=tool_name,
839
+ native_config=_load_native_tool_config(tool_name),
840
+ lintro_tool_config=load_lintro_tool_config(tool_name),
841
+ is_injectable=is_tool_injectable(tool_name),
842
+ )
843
+ return self.tool_configs[tool_name]
844
+
845
+ def get_ordered_tools(self, tool_names: list[str]) -> list[str]:
846
+ """Get tools in execution order.
847
+
848
+ Args:
849
+ tool_names: List of tool names
850
+
851
+ Returns:
852
+ List of tool names in execution order
853
+ """
854
+ return get_ordered_tools(tool_names)
855
+
856
+ def apply_config_to_tool(
857
+ self,
858
+ tool: Any,
859
+ cli_overrides: dict[str, Any] | None = None,
860
+ ) -> None:
861
+ """Apply effective configuration to a tool instance.
862
+
863
+ Priority order:
864
+ 1. CLI overrides (if provided)
865
+ 2. [tool.lintro.<tool>] config
866
+ 3. Global [tool.lintro] settings
867
+
868
+ Args:
869
+ tool: Tool instance with set_options method
870
+ cli_overrides: Optional CLI override options
871
+
872
+ Raises:
873
+ TypeError: If tool configuration has type mismatches.
874
+ ValueError: If tool configuration has invalid values.
875
+ """
876
+ tool_name = getattr(tool, "name", "").lower()
877
+ if not tool_name:
878
+ return
879
+
880
+ # Start with global settings
881
+ effective_opts: dict[str, Any] = {}
882
+
883
+ # Apply global line_length if tool supports it
884
+ if is_tool_injectable(tool_name):
885
+ line_length = self.get_effective_line_length(tool_name)
886
+ if line_length is not None:
887
+ effective_opts["line_length"] = line_length
888
+
889
+ # Apply tool-specific lintro config
890
+ lintro_tool_config = load_lintro_tool_config(tool_name)
891
+ effective_opts.update(lintro_tool_config)
892
+
893
+ # Apply CLI overrides last (highest priority)
894
+ if cli_overrides:
895
+ effective_opts.update(cli_overrides)
896
+
897
+ # Apply to tool
898
+ if effective_opts:
899
+ try:
900
+ tool.set_options(**effective_opts)
901
+ logger.debug(f"Applied config to {tool_name}: {effective_opts}")
902
+ except (ValueError, TypeError) as e:
903
+ # Configuration errors should be visible and re-raised
904
+ logger.warning(
905
+ f"Configuration error for {tool_name}: {e}",
906
+ exc_info=True,
907
+ )
908
+ raise
909
+ except Exception as e:
910
+ # Other unexpected errors - log at warning but allow execution
911
+ logger.warning(
912
+ f"Failed to apply config to {tool_name}: {e}",
913
+ exc_info=True,
914
+ )
915
+
916
+ def get_report(self) -> str:
917
+ """Get configuration report.
918
+
919
+ Returns:
920
+ Formatted configuration report string
921
+ """
922
+ return get_config_report()
923
+
924
+ def print_report(self) -> None:
925
+ """Print configuration report."""
926
+ print_config_report()