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
@@ -1,11 +1,19 @@
1
1
  """Yamllint YAML linter integration."""
2
2
 
3
+ import contextlib
4
+ import fnmatch
3
5
  import os
4
6
  import subprocess # nosec B404 - used safely with shell disabled
5
7
  from dataclasses import dataclass, field
6
8
 
9
+ import click
7
10
  from loguru import logger
8
11
 
12
+ try:
13
+ import yaml
14
+ except ImportError:
15
+ yaml = None # type: ignore[assignment]
16
+
9
17
  from lintro.enums.tool_type import ToolType
10
18
  from lintro.enums.yamllint_format import (
11
19
  YamllintFormat,
@@ -120,15 +128,166 @@ class YamllintTool(BaseTool):
120
128
  options = {k: v for k, v in options.items() if v is not None}
121
129
  super().set_options(**options, **kwargs)
122
130
 
131
+ def _find_yamllint_config(self, search_dir: str | None = None) -> str | None:
132
+ """Locate yamllint config file if not explicitly provided.
133
+
134
+ Yamllint searches upward from the file's directory to find config files,
135
+ so we do the same to match native behavior.
136
+
137
+ Args:
138
+ search_dir: Directory to start searching from. If None, searches from
139
+ current working directory.
140
+
141
+ Returns:
142
+ str | None: Path to config file if found, None otherwise.
143
+ """
144
+ # If config_file is explicitly set, use it
145
+ if self.options.get("config_file"):
146
+ return self.options.get("config_file")
147
+
148
+ # Check for config files in order of precedence
149
+ config_paths = [
150
+ ".yamllint",
151
+ ".yamllint.yml",
152
+ ".yamllint.yaml",
153
+ ]
154
+ # Search upward from search_dir (or cwd) to find config, just like yamllint does
155
+ start_dir = os.path.abspath(search_dir) if search_dir else os.getcwd()
156
+ current_dir = start_dir
157
+
158
+ # Walk upward from the file's directory to find config
159
+ # Stop at filesystem root to avoid infinite loop
160
+ while True:
161
+ for config_name in config_paths:
162
+ config_path = os.path.join(current_dir, config_name)
163
+ if os.path.exists(config_path):
164
+ logger.debug(
165
+ f"[YamllintTool] Found config file: {config_path} "
166
+ f"(searched from {start_dir})",
167
+ )
168
+ return config_path
169
+
170
+ # Move up one directory
171
+ parent_dir = os.path.dirname(current_dir)
172
+ # Stop if we've reached the filesystem root (parent == current)
173
+ if parent_dir == current_dir:
174
+ break
175
+ current_dir = parent_dir
176
+
177
+ return None
178
+
179
+ def _load_yamllint_ignore_patterns(
180
+ self,
181
+ config_file: str | None,
182
+ ) -> list[str]:
183
+ """Load ignore patterns from yamllint config file.
184
+
185
+ Yamllint's ignore patterns only work when scanning directories, not when
186
+ individual files are passed. We need to respect these patterns at the
187
+ lintro level by filtering out ignored files before passing them to yamllint.
188
+
189
+ Args:
190
+ config_file: Path to yamllint config file, or None.
191
+
192
+ Returns:
193
+ list[str]: List of ignore patterns from the config file.
194
+ """
195
+ if not config_file or not os.path.exists(config_file):
196
+ return []
197
+
198
+ ignore_patterns: list[str] = []
199
+ if yaml is None:
200
+ logger.debug(
201
+ "[YamllintTool] PyYAML not available, cannot parse ignore patterns",
202
+ )
203
+ return ignore_patterns
204
+ try:
205
+ with open(config_file, encoding="utf-8") as f:
206
+ config_data = yaml.safe_load(f)
207
+ if config_data and isinstance(config_data, dict):
208
+ # Check for ignore patterns in line-length rule
209
+ line_length_config = config_data.get("rules", {}).get(
210
+ "line-length",
211
+ {},
212
+ )
213
+ if isinstance(line_length_config, dict):
214
+ ignore_value = line_length_config.get("ignore")
215
+ if ignore_value:
216
+ if isinstance(ignore_value, str):
217
+ # Multi-line string - split by newlines
218
+ ignore_patterns.extend(
219
+ [
220
+ line.strip()
221
+ for line in ignore_value.split("\n")
222
+ if line.strip()
223
+ ],
224
+ )
225
+ elif isinstance(ignore_value, list):
226
+ # List of patterns
227
+ ignore_patterns.extend(ignore_value)
228
+ logger.debug(
229
+ f"[YamllintTool] Loaded {len(ignore_patterns)} ignore patterns "
230
+ f"from {config_file}: {ignore_patterns}",
231
+ )
232
+ except Exception as e:
233
+ logger.debug(
234
+ f"[YamllintTool] Failed to load ignore patterns "
235
+ f"from {config_file}: {e}",
236
+ )
237
+
238
+ return ignore_patterns
239
+
240
+ def _should_ignore_file(
241
+ self,
242
+ file_path: str,
243
+ ignore_patterns: list[str],
244
+ ) -> bool:
245
+ """Check if a file should be ignored based on yamllint ignore patterns.
246
+
247
+ Args:
248
+ file_path: Path to the file to check.
249
+ ignore_patterns: List of ignore patterns from yamllint config.
250
+
251
+ Returns:
252
+ bool: True if the file should be ignored, False otherwise.
253
+ """
254
+ if not ignore_patterns:
255
+ return False
256
+
257
+ # Normalize path separators for cross-platform compatibility
258
+ normalized_path: str = file_path.replace("\\", "/")
259
+
260
+ for pattern in ignore_patterns:
261
+ pattern = pattern.strip()
262
+ if not pattern:
263
+ continue
264
+ # Yamllint ignore patterns are directory paths (like .github/workflows/)
265
+ # Check if the pattern appears as a directory path in the file path
266
+ # Handle both relative paths (./.github/workflows/) and absolute paths
267
+ # Pattern should match if it appears as /pattern/ or at the start
268
+ if normalized_path.startswith(pattern):
269
+ return True
270
+ # Check if pattern appears as a directory in the path
271
+ # (handles ./prefix and absolute paths)
272
+ if f"/{pattern}" in normalized_path:
273
+ return True
274
+ # Also try glob matching for patterns with wildcards
275
+ if fnmatch.fnmatch(normalized_path, pattern):
276
+ return True
277
+
278
+ return False
279
+
123
280
  def _build_command(self) -> list[str]:
124
281
  """Build the yamllint command.
125
282
 
126
283
  Returns:
127
284
  list[str]: Command arguments for yamllint.
128
285
  """
129
- cmd: list[str] = ["yamllint"]
286
+ cmd: list[str] = self._get_executable_command("yamllint")
130
287
  format_option: str = self.options.get("format", YAMLLINT_FORMATS[0])
131
288
  cmd.extend(["--format", format_option])
289
+ # Note: Config file discovery happens per-file in _process_yaml_file
290
+ # since yamllint discovers configs relative to each file
132
291
  config_file: str | None = self.options.get("config_file")
133
292
  if config_file:
134
293
  cmd.extend(["--config-file", config_file])
@@ -143,6 +302,162 @@ class YamllintTool(BaseTool):
143
302
  cmd.append("--no-warnings")
144
303
  return cmd
145
304
 
305
+ def _process_yaml_file(
306
+ self,
307
+ file_path: str,
308
+ timeout: int,
309
+ ) -> tuple[int, list, bool, bool, bool, bool]:
310
+ """Process a single YAML file with yamllint.
311
+
312
+ Args:
313
+ file_path: Path to the YAML file to process.
314
+ timeout: Timeout in seconds for the subprocess call.
315
+
316
+ Returns:
317
+ tuple containing:
318
+ - issues_count: Number of issues found
319
+ - issues_list: List of parsed issues
320
+ - skipped_flag: True if file was skipped due to timeout
321
+ - execution_failure_flag: True if there was an execution failure
322
+ - success_flag: False if issues were found
323
+ - should_continue: True if file should be silently skipped
324
+ (missing file)
325
+ """
326
+ # Use absolute path; run with the file's parent as cwd so that
327
+ # yamllint discovers any local .yamllint config beside the file.
328
+ abs_file: str = os.path.abspath(file_path)
329
+ file_cwd: str = self.get_cwd(paths=[abs_file])
330
+ file_dir: str = os.path.dirname(abs_file)
331
+ # Build command and discover config relative to file's directory
332
+ cmd: list[str] = self._get_executable_command("yamllint")
333
+ format_option: str = self.options.get("format", YAMLLINT_FORMATS[0])
334
+ cmd.extend(["--format", format_option])
335
+ # Discover config file relative to the file being checked
336
+ config_file: str | None = self._find_yamllint_config(search_dir=file_dir)
337
+ if config_file:
338
+ # Ensure config file path is absolute so yamllint can find it
339
+ # even when cwd is a subdirectory
340
+ abs_config_file = os.path.abspath(config_file)
341
+ cmd.extend(["--config-file", abs_config_file])
342
+ logger.debug(
343
+ f"[YamllintTool] Using config file: {abs_config_file} "
344
+ f"(original: {config_file})",
345
+ )
346
+ config_data: str | None = self.options.get("config_data")
347
+ if config_data:
348
+ cmd.extend(["--config-data", config_data])
349
+ if self.options.get("strict", False):
350
+ cmd.append("--strict")
351
+ if self.options.get("relaxed", False):
352
+ cmd.append("--relaxed")
353
+ if self.options.get("no_warnings", False):
354
+ cmd.append("--no-warnings")
355
+ cmd.append(abs_file)
356
+ logger.debug(
357
+ f"[YamllintTool] Processing file: {abs_file} (cwd={file_cwd})",
358
+ )
359
+ logger.debug(f"[YamllintTool] Command: {' '.join(cmd)}")
360
+ try:
361
+ success, output = self._run_subprocess(
362
+ cmd=cmd,
363
+ timeout=timeout,
364
+ cwd=file_cwd,
365
+ )
366
+ issues = parse_yamllint_output(output=output)
367
+ issues_count: int = len(issues)
368
+ # Yamllint returns 1 on errors/warnings unless --no-warnings/relaxed
369
+ # Use parsed issues to determine success and counts reliably.
370
+ # Honor subprocess exit status: if it failed and we have no parsed
371
+ # issues, that's an execution failure (invalid config, crash,
372
+ # missing dependency, etc.)
373
+ # The 'success' flag from _run_subprocess reflects the subprocess
374
+ # exit status
375
+ success_flag: bool = success and issues_count == 0
376
+ # Execution failure occurs when subprocess failed but no lint issues
377
+ # were found
378
+ # This distinguishes execution errors from lint findings
379
+ execution_failure = not success and issues_count == 0
380
+ # Log execution failures with error details for debugging
381
+ if execution_failure and output:
382
+ logger.debug(
383
+ f"Yamllint execution failure for {file_path}: {output}",
384
+ )
385
+ return issues_count, issues, False, execution_failure, success_flag, False
386
+ except subprocess.TimeoutExpired:
387
+ # Count timeout as an execution failure
388
+ return 0, [], True, True, False, False
389
+ except Exception as e:
390
+ # Suppress missing file noise in console output; keep as debug
391
+ err_msg = str(e)
392
+ if "No such file or directory" in err_msg:
393
+ # treat as skipped/missing silently for user; do not fail run
394
+ return 0, [], False, False, True, True
395
+ # Log execution errors for debugging while keeping user output clean
396
+ logger.debug(
397
+ f"Yamllint execution error for {file_path}: {err_msg}",
398
+ )
399
+ # Do not add raw errors to user-facing output; mark failure only
400
+ # Count execution errors as failures
401
+ return 0, [], False, True, False, False
402
+
403
+ def _process_yaml_file_result(
404
+ self,
405
+ issues_count: int,
406
+ issues: list,
407
+ skipped_flag: bool,
408
+ execution_failure_flag: bool,
409
+ success_flag: bool,
410
+ file_path: str,
411
+ all_success: bool,
412
+ all_issues: list,
413
+ skipped_files: list[str],
414
+ timeout_skipped_count: int,
415
+ other_execution_failures: int,
416
+ total_issues: int,
417
+ ) -> tuple[bool, list, list[str], int, int, int]:
418
+ """Process a single file's result and update accumulators.
419
+
420
+ Args:
421
+ issues_count: Number of issues found in the file.
422
+ issues: List of parsed issues.
423
+ skipped_flag: True if file was skipped due to timeout.
424
+ execution_failure_flag: True if there was an execution failure.
425
+ success_flag: False if issues were found.
426
+ file_path: Path to the file being processed.
427
+ all_success: Current overall success flag.
428
+ all_issues: Current list of all issues.
429
+ skipped_files: Current list of skipped files.
430
+ timeout_skipped_count: Current count of timeout skips.
431
+ other_execution_failures: Current count of execution failures.
432
+ total_issues: Current total issue count.
433
+
434
+ Returns:
435
+ tuple containing updated accumulators:
436
+ (all_success, all_issues, skipped_files,
437
+ timeout_skipped_count, other_execution_failures, total_issues)
438
+ """
439
+ if not success_flag:
440
+ all_success = False
441
+ total_issues += issues_count
442
+ if issues:
443
+ all_issues.extend(issues)
444
+ if skipped_flag:
445
+ skipped_files.append(file_path)
446
+ all_success = False
447
+ timeout_skipped_count += 1
448
+ elif execution_failure_flag:
449
+ # Only count execution failures if not already counted as skipped
450
+ all_success = False
451
+ other_execution_failures += 1
452
+ return (
453
+ all_success,
454
+ all_issues,
455
+ skipped_files,
456
+ timeout_skipped_count,
457
+ other_execution_failures,
458
+ total_issues,
459
+ )
460
+
146
461
  def check(
147
462
  self,
148
463
  paths: list[str],
@@ -155,6 +470,11 @@ class YamllintTool(BaseTool):
155
470
  Returns:
156
471
  ToolResult: Result of the check operation.
157
472
  """
473
+ # Check version requirements
474
+ version_result = self._verify_tool_version()
475
+ if version_result is not None:
476
+ return version_result
477
+
158
478
  self._validate_paths(paths=paths)
159
479
  if not paths:
160
480
  return ToolResult(
@@ -169,51 +489,145 @@ class YamllintTool(BaseTool):
169
489
  exclude_patterns=self.exclude_patterns,
170
490
  include_venv=self.include_venv,
171
491
  )
492
+ logger.debug(
493
+ f"[YamllintTool] Discovered {len(yaml_files)} files matching patterns: "
494
+ f"{self.config.file_patterns}",
495
+ )
496
+ logger.debug(
497
+ f"[YamllintTool] Exclude patterns applied: {self.exclude_patterns}",
498
+ )
499
+ if yaml_files:
500
+ logger.debug(
501
+ f"[YamllintTool] Files to check (first 10): {yaml_files[:10]}",
502
+ )
503
+ # Check for yamllint config files
504
+ config_paths = [
505
+ ".yamllint",
506
+ ".yamllint.yml",
507
+ ".yamllint.yaml",
508
+ "setup.cfg",
509
+ "pyproject.toml",
510
+ ]
511
+ found_config = None
512
+ config_file_option = self.options.get("config_file")
513
+ if config_file_option:
514
+ found_config = os.path.abspath(config_file_option)
515
+ logger.debug(
516
+ f"[YamllintTool] Using explicit config file: {found_config}",
517
+ )
518
+ else:
519
+ for config_name in config_paths:
520
+ config_path = os.path.abspath(config_name)
521
+ if os.path.exists(config_path):
522
+ found_config = config_path
523
+ logger.debug(
524
+ f"[YamllintTool] Found config file: {config_path}",
525
+ )
526
+ break
527
+ if not found_config:
528
+ logger.debug(
529
+ "[YamllintTool] No yamllint config file found (using defaults)",
530
+ )
531
+ # Load ignore patterns from yamllint config and filter files
532
+ ignore_patterns: list[str] = self._load_yamllint_ignore_patterns(
533
+ config_file=found_config,
534
+ )
535
+ if ignore_patterns:
536
+ original_count = len(yaml_files)
537
+ yaml_files = [
538
+ f
539
+ for f in yaml_files
540
+ if not self._should_ignore_file(
541
+ file_path=f,
542
+ ignore_patterns=ignore_patterns,
543
+ )
544
+ ]
545
+ filtered_count = original_count - len(yaml_files)
546
+ if filtered_count > 0:
547
+ logger.debug(
548
+ f"[YamllintTool] Filtered out {filtered_count} files based on "
549
+ f"yamllint ignore patterns: {ignore_patterns}",
550
+ )
172
551
  logger.debug(f"Files to check: {yaml_files}")
173
552
  timeout: int = self.options.get("timeout", YAMLLINT_DEFAULT_TIMEOUT)
174
553
  # Aggregate parsed issues across files and rely on table renderers upstream
175
554
  all_success: bool = True
176
555
  all_issues: list = []
177
556
  skipped_files: list[str] = []
557
+ timeout_skipped_count: int = 0
558
+ other_execution_failures: int = 0
178
559
  total_issues: int = 0
179
- for file_path in yaml_files:
180
- # Use absolute path; run with the file's parent as cwd so that
181
- # yamllint discovers any local .yamllint config beside the file.
182
- abs_file: str = os.path.abspath(file_path)
183
- cmd: list[str] = self._build_command() + [abs_file]
184
- try:
185
- success, output = self._run_subprocess(
186
- cmd=cmd,
187
- timeout=timeout,
188
- cwd=self.get_cwd(paths=[abs_file]),
189
- )
190
- issues = parse_yamllint_output(output=output)
191
- issues_count: int = len(issues)
192
- # Yamllint returns 1 on errors/warnings unless --no-warnings/relaxed
193
- # Use parsed issues to determine success and counts reliably.
194
- if issues_count > 0:
195
- all_success = False
196
- total_issues += issues_count
197
- if issues:
198
- all_issues.extend(issues)
199
- except subprocess.TimeoutExpired:
200
- skipped_files.append(file_path)
201
- all_success = False
202
- except Exception as e:
203
- # Suppress missing file noise in console output; keep as debug
204
- err_msg = str(e)
205
- if "No such file or directory" in err_msg:
206
- # treat as skipped/missing silently for user; do not fail run
560
+
561
+ # Show progress bar only when processing multiple files
562
+ if len(yaml_files) >= 2:
563
+ files_to_iterate = click.progressbar(
564
+ yaml_files,
565
+ label="Processing files",
566
+ bar_template="%(label)s %(info)s",
567
+ )
568
+ context_mgr = files_to_iterate
569
+ else:
570
+ files_to_iterate = yaml_files
571
+ context_mgr = contextlib.nullcontext()
572
+
573
+ with context_mgr:
574
+ for file_path in files_to_iterate:
575
+ (
576
+ issues_count,
577
+ issues,
578
+ skipped_flag,
579
+ execution_failure_flag,
580
+ success_flag,
581
+ should_continue,
582
+ ) = self._process_yaml_file(file_path=file_path, timeout=timeout)
583
+ if should_continue:
207
584
  continue
208
- # Do not add raw errors to user-facing output; mark failure only
209
- all_success = False
210
- # Let the unified formatter render a table from issues; no raw output
211
- output = None
585
+ (
586
+ all_success,
587
+ all_issues,
588
+ skipped_files,
589
+ timeout_skipped_count,
590
+ other_execution_failures,
591
+ total_issues,
592
+ ) = self._process_yaml_file_result(
593
+ issues_count=issues_count,
594
+ issues=issues,
595
+ skipped_flag=skipped_flag,
596
+ execution_failure_flag=execution_failure_flag,
597
+ success_flag=success_flag,
598
+ file_path=file_path,
599
+ all_success=all_success,
600
+ all_issues=all_issues,
601
+ skipped_files=skipped_files,
602
+ timeout_skipped_count=timeout_skipped_count,
603
+ other_execution_failures=other_execution_failures,
604
+ total_issues=total_issues,
605
+ )
606
+ # Build output message if there are skipped files or execution failures
607
+ output: str | None = None
608
+ if timeout_skipped_count > 0 or other_execution_failures > 0:
609
+ output_lines: list[str] = []
610
+ if timeout_skipped_count > 0:
611
+ output_lines.append(
612
+ f"Skipped {timeout_skipped_count} file(s) due to timeout "
613
+ f"({timeout}s limit exceeded):",
614
+ )
615
+ for file in skipped_files:
616
+ output_lines.append(f" - {file}")
617
+ if other_execution_failures > 0:
618
+ output_lines.append(
619
+ f"Failed to process {other_execution_failures} file(s) "
620
+ "due to execution errors",
621
+ )
622
+ output = "\n".join(output_lines) if output_lines else None
623
+ # Include execution failures (timeouts/errors) in issues_count
624
+ # to properly reflect tool failure status
625
+ total_issues_with_failures = total_issues + other_execution_failures
212
626
  return ToolResult(
213
627
  name=self.name,
214
628
  success=all_success,
215
629
  output=output,
216
- issues_count=total_issues,
630
+ issues_count=total_issues_with_failures,
217
631
  issues=all_issues,
218
632
  )
219
633
 
lintro/tools/tool_enum.py CHANGED
@@ -6,8 +6,11 @@ from lintro.tools.implementations.tool_actionlint import ActionlintTool
6
6
  from lintro.tools.implementations.tool_bandit import BanditTool
7
7
  from lintro.tools.implementations.tool_black import BlackTool
8
8
  from lintro.tools.implementations.tool_darglint import DarglintTool
9
+ from lintro.tools.implementations.tool_eslint import EslintTool
9
10
  from lintro.tools.implementations.tool_hadolint import HadolintTool
11
+ from lintro.tools.implementations.tool_markdownlint import MarkdownlintTool
10
12
  from lintro.tools.implementations.tool_prettier import PrettierTool
13
+ from lintro.tools.implementations.tool_pytest import PytestTool
11
14
  from lintro.tools.implementations.tool_ruff import RuffTool
12
15
  from lintro.tools.implementations.tool_yamllint import YamllintTool
13
16
 
@@ -17,8 +20,11 @@ class ToolEnum(Enum):
17
20
 
18
21
  BLACK = BlackTool
19
22
  DARGLINT = DarglintTool
23
+ ESLINT = EslintTool
20
24
  HADOLINT = HadolintTool
25
+ MARKDOWNLINT = MarkdownlintTool
21
26
  PRETTIER = PrettierTool
27
+ PYTEST = PytestTool
22
28
  RUFF = RuffTool
23
29
  YAMLLINT = YamllintTool
24
30
  ACTIONLINT = ActionlintTool
lintro/utils/config.py CHANGED
@@ -1,5 +1,8 @@
1
1
  """Project configuration helpers for Lintro.
2
2
 
3
+ This module provides backward-compatible access to configuration functions.
4
+ The canonical implementation is in unified_config.py.
5
+
3
6
  Reads configuration from `pyproject.toml` under the `[tool.lintro]` table.
4
7
  Allows tool-specific defaults via `[tool.lintro.<tool>]` (e.g., `[tool.lintro.ruff]`).
5
8
  """
@@ -10,8 +13,44 @@ import tomllib
10
13
  from pathlib import Path
11
14
  from typing import Any
12
15
 
16
+ # Re-export from unified_config for backward compatibility
17
+ from lintro.utils.unified_config import (
18
+ get_effective_line_length,
19
+ load_lintro_global_config,
20
+ load_lintro_tool_config,
21
+ )
22
+ from lintro.utils.unified_config import (
23
+ validate_config_consistency as validate_line_length_consistency,
24
+ )
25
+
26
+
27
+ def get_central_line_length() -> int | None:
28
+ """Get the central line length configuration.
29
+
30
+ Backward-compatible wrapper that returns the effective line length
31
+ for Ruff (which serves as the source of truth).
32
+
33
+ Returns:
34
+ Line length value if configured, None otherwise.
35
+ """
36
+ return get_effective_line_length("ruff")
37
+
38
+
39
+ __all__ = [
40
+ "get_central_line_length",
41
+ "load_lintro_global_config",
42
+ "load_lintro_tool_config",
43
+ "load_post_checks_config",
44
+ "validate_line_length_consistency",
45
+ ]
46
+
47
+
48
+ def _load_lintro_section() -> dict[str, Any]:
49
+ """Load Lintro configuration section from pyproject.toml.
13
50
 
14
- def _load_pyproject() -> dict[str, Any]:
51
+ Returns:
52
+ Dict containing [tool.lintro] section or empty dict.
53
+ """
15
54
  pyproject_path = Path("pyproject.toml")
16
55
  if not pyproject_path.exists():
17
56
  return {}
@@ -23,22 +62,6 @@ def _load_pyproject() -> dict[str, Any]:
23
62
  return {}
24
63
 
25
64
 
26
- def load_lintro_tool_config(tool_name: str) -> dict[str, Any]:
27
- """Load tool-specific config from pyproject.
28
-
29
- Args:
30
- tool_name: Tool name (e.g., "ruff").
31
-
32
- Returns:
33
- A dict of options for the given tool, or an empty dict if none.
34
- """
35
- cfg = _load_pyproject()
36
- section = cfg.get(tool_name, {})
37
- if isinstance(section, dict):
38
- return section
39
- return {}
40
-
41
-
42
65
  def load_post_checks_config() -> dict[str, Any]:
43
66
  """Load post-checks configuration from pyproject.
44
67
 
@@ -48,7 +71,7 @@ def load_post_checks_config() -> dict[str, Any]:
48
71
  - tools: list[str]
49
72
  - enforce_failure: bool
50
73
  """
51
- cfg = _load_pyproject()
74
+ cfg = _load_lintro_section()
52
75
  section = cfg.get("post_checks", {})
53
76
  if isinstance(section, dict):
54
77
  return section