From f6d81a987fd2e70efec9cacf4f1e4c34e24985bd Mon Sep 17 00:00:00 2001 From: "tembo[bot]" <208362400+tembo[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 22:30:34 +0000 Subject: [PATCH 1/4] refactor(scripts,tools): consolidate coverage and lint tools infrastructure Co-authored-by: Jurie --- scripts/check-coverage.ps1 | 37 +-- scripts/check-coverage.sh | 33 +-- scripts/coverage/__init__.py | 5 + scripts/coverage/runner.py | 295 ++++++++++++++++++++++++ scripts/measure-coverage.ps1 | 53 +---- scripts/measure-coverage.sh | 49 +--- tools/core/__init__.py | 5 + tools/core/cli/__init__.py | 21 ++ tools/core/cli/base.py | 235 +++++++++++++++++++ tools/core/cli/formatters.py | 181 +++++++++++++++ tools/core/cli/severity.py | 38 ++++ tools/core/models/__init__.py | 5 + tools/core/models/lint_models.py | 108 +++++++++ tools/markdown_lint/cli.py | 373 +++++++++---------------------- tools/markdown_lint/linter.py | 6 +- tools/markdown_lint/models.py | 72 +----- tools/yaml_lint/cli.py | 356 +++++------------------------ tools/yaml_lint/linter.py | 37 ++- tools/yaml_lint/models.py | 72 +----- 19 files changed, 1117 insertions(+), 864 deletions(-) create mode 100644 scripts/coverage/__init__.py create mode 100644 scripts/coverage/runner.py create mode 100644 tools/core/__init__.py create mode 100644 tools/core/cli/__init__.py create mode 100644 tools/core/cli/base.py create mode 100644 tools/core/cli/formatters.py create mode 100644 tools/core/cli/severity.py create mode 100644 tools/core/models/__init__.py create mode 100644 tools/core/models/lint_models.py diff --git a/scripts/check-coverage.ps1 b/scripts/check-coverage.ps1 index c446d92..7c7e295 100644 --- a/scripts/check-coverage.ps1 +++ b/scripts/check-coverage.ps1 @@ -1,4 +1,5 @@ # Check test coverage and enforce quality gates +# This script delegates to the unified Python coverage utility param( [int]$CoverageThreshold = 70 @@ -6,38 +7,6 @@ param( $ErrorActionPreference = 'Stop' -$CoverageFile = "coverage.xml" - -Write-Host "========================================" -ForegroundColor Cyan -Write-Host "CodeFlow Engine - Coverage Check" -ForegroundColor Cyan -Write-Host "========================================" -ForegroundColor Cyan -Write-Host "" - -# Check if coverage file exists -if (-not (Test-Path $CoverageFile)) { - Write-Host "⚠️ Coverage file not found. Running tests with coverage..." -ForegroundColor Yellow - poetry run pytest --cov=codeflow_engine --cov-report=xml --cov-report=term -} - -# Get current coverage percentage -$CoverageOutput = poetry run coverage report | Select-String "TOTAL" -$Coverage = [regex]::Match($CoverageOutput, '(\d+(?:\.\d+)?)%').Groups[1].Value -$CoverageValue = [double]$Coverage - -Write-Host "Current coverage: ${Coverage}%" -Write-Host "Target coverage: ${CoverageThreshold}%" -Write-Host "" - -# Check if coverage meets threshold -if ($CoverageValue -lt $CoverageThreshold) { - Write-Host "❌ Coverage ${Coverage}% is below threshold of ${CoverageThreshold}%" -ForegroundColor Red - Write-Host "" - Write-Host "Coverage by module:" -ForegroundColor Yellow - poetry run coverage report --show-missing | Select-String "codeflow_engine" | Select-Object -First 20 - exit 1 -} -else { - Write-Host "✅ Coverage ${Coverage}% meets threshold of ${CoverageThreshold}%" -ForegroundColor Green - exit 0 -} +# Use the unified Python coverage utility +python -m scripts.coverage.runner check --threshold $CoverageThreshold diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh index 808dd65..c16139a 100644 --- a/scripts/check-coverage.sh +++ b/scripts/check-coverage.sh @@ -1,38 +1,11 @@ #!/bin/bash # Check test coverage and enforce quality gates +# This script delegates to the unified Python coverage utility set -e COVERAGE_THRESHOLD=${1:-70} -COVERAGE_FILE="coverage.xml" -echo "==========================================" -echo "CodeFlow Engine - Coverage Check" -echo "==========================================" -echo "" - -# Check if coverage file exists -if [ ! -f "$COVERAGE_FILE" ]; then - echo "⚠️ Coverage file not found. Running tests with coverage..." - poetry run pytest --cov=codeflow_engine --cov-report=xml --cov-report=term -fi - -# Get current coverage percentage -COVERAGE=$(poetry run coverage report | grep TOTAL | awk '{print $NF}' | sed 's/%//') - -echo "Current coverage: ${COVERAGE}%" -echo "Target coverage: ${COVERAGE_THRESHOLD}%" -echo "" - -# Check if coverage meets threshold -if (( $(echo "$COVERAGE < $COVERAGE_THRESHOLD" | bc -l) )); then - echo "❌ Coverage ${COVERAGE}% is below threshold of ${COVERAGE_THRESHOLD}%" - echo "" - echo "Coverage by module:" - poetry run coverage report --show-missing | grep -E "codeflow_engine" | head -20 - exit 1 -else - echo "✅ Coverage ${COVERAGE}% meets threshold of ${COVERAGE_THRESHOLD}%" - exit 0 -fi +# Use the unified Python coverage utility +python -m scripts.coverage.runner check --threshold "$COVERAGE_THRESHOLD" diff --git a/scripts/coverage/__init__.py b/scripts/coverage/__init__.py new file mode 100644 index 0000000..bf1053f --- /dev/null +++ b/scripts/coverage/__init__.py @@ -0,0 +1,5 @@ +"""Coverage utilities for test quality gates.""" + +from scripts.coverage.runner import CoverageRunner + +__all__ = ["CoverageRunner"] diff --git a/scripts/coverage/runner.py b/scripts/coverage/runner.py new file mode 100644 index 0000000..984d3a6 --- /dev/null +++ b/scripts/coverage/runner.py @@ -0,0 +1,295 @@ +"""Unified coverage runner for test quality gates. + +This module provides a cross-platform Python implementation for running +test coverage, replacing the duplicated bash and PowerShell scripts. +""" + +import argparse +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class CoverageResult: + """Result of a coverage run.""" + + total_coverage: float + by_module: dict[str, float] + low_coverage_files: list[tuple[str, float]] + no_coverage_files: list[str] + success: bool + error_message: str = "" + + +class CoverageRunner: + """Unified coverage runner for running tests and generating reports.""" + + def __init__( + self, + module_name: str = "codeflow_engine", + threshold: float = 70.0, + low_coverage_threshold: float = 50.0, + ): + """Initialize the coverage runner. + + Args: + module_name: The Python module to measure coverage for + threshold: Minimum coverage percentage required to pass + low_coverage_threshold: Threshold below which files are flagged + """ + self.module_name = module_name + self.threshold = threshold + self.low_coverage_threshold = low_coverage_threshold + + def run_tests(self, generate_html: bool = True, generate_xml: bool = True) -> bool: + """Run pytest with coverage. + + Args: + generate_html: Whether to generate HTML report + generate_xml: Whether to generate XML report + + Returns: + True if tests passed, False otherwise + """ + cmd = [ + "poetry", "run", "pytest", + f"--cov={self.module_name}", + "--cov-report=term", + ] + + if generate_html: + cmd.append("--cov-report=html") + if generate_xml: + cmd.append("--cov-report=xml") + + result = subprocess.run(cmd, capture_output=False) + return result.returncode == 0 + + def get_coverage_report(self) -> str: + """Get the coverage report output.""" + result = subprocess.run( + ["poetry", "run", "coverage", "report"], + capture_output=True, + text=True, + ) + return result.stdout + + def get_coverage_report_with_missing(self) -> str: + """Get the coverage report with missing lines.""" + result = subprocess.run( + ["poetry", "run", "coverage", "report", "--show-missing"], + capture_output=True, + text=True, + ) + return result.stdout + + def parse_coverage(self, report: str) -> CoverageResult: + """Parse coverage report and extract metrics. + + Args: + report: The coverage report output + + Returns: + CoverageResult with parsed metrics + """ + total_coverage = 0.0 + by_module: dict[str, float] = {} + low_coverage_files: list[tuple[str, float]] = [] + no_coverage_files: list[str] = [] + + lines = report.strip().split("\n") + + for line in lines: + # Parse TOTAL line + if line.startswith("TOTAL"): + match = re.search(r"(\d+(?:\.\d+)?)%", line) + if match: + total_coverage = float(match.group(1)) + continue + + # Parse module lines + if self.module_name in line: + parts = line.split() + if len(parts) >= 4: + file_path = parts[0] + # Extract coverage percentage + match = re.search(r"(\d+(?:\.\d+)?)%", line) + if match: + coverage = float(match.group(1)) + by_module[file_path] = coverage + + if coverage == 0: + no_coverage_files.append(file_path) + elif coverage < self.low_coverage_threshold: + low_coverage_files.append((file_path, coverage)) + + return CoverageResult( + total_coverage=total_coverage, + by_module=by_module, + low_coverage_files=sorted(low_coverage_files, key=lambda x: x[1]), + no_coverage_files=no_coverage_files, + success=total_coverage >= self.threshold, + ) + + def print_header(self, title: str) -> None: + """Print a formatted header.""" + print("=" * 42) + print(f"CodeFlow Engine - {title}") + print("=" * 42) + print() + + def print_section(self, title: str) -> None: + """Print a formatted section header.""" + print(title) + print("-" * 40) + + def check_coverage(self) -> int: + """Check if coverage meets the threshold. + + Returns: + Exit code (0 for success, 1 for failure) + """ + self.print_header("Coverage Check") + + # Check if coverage file exists + if not Path("coverage.xml").exists(): + print("Coverage file not found. Running tests with coverage...") + if not self.run_tests(): + print("Tests failed!") + return 1 + print() + + report = self.get_coverage_report() + result = self.parse_coverage(report) + + print(f"Current coverage: {result.total_coverage}%") + print(f"Target coverage: {self.threshold}%") + print() + + if result.success: + print(f"Coverage {result.total_coverage}% meets threshold of {self.threshold}%") + return 0 + else: + print(f"Coverage {result.total_coverage}% is below threshold of {self.threshold}%") + print() + self.print_section("Coverage by module:") + report_with_missing = self.get_coverage_report_with_missing() + for line in report_with_missing.split("\n"): + if self.module_name in line: + print(line) + return 1 + + def measure_coverage(self) -> int: + """Measure coverage and generate detailed report. + + Returns: + Exit code (always 0) + """ + self.print_header("Coverage Measurement") + + print("Running tests with coverage...") + self.run_tests(generate_html=True, generate_xml=True) + print() + + self.print_header("Coverage Summary") + + report = self.get_coverage_report() + result = self.parse_coverage(report) + + print(f"Overall Coverage: {result.total_coverage}%") + print() + + self.print_section("Coverage by Module (sorted by coverage %):") + report_with_missing = self.get_coverage_report_with_missing() + module_lines = [ + line for line in report_with_missing.split("\n") + if self.module_name in line + ] + for line in sorted(module_lines, key=lambda x: self._extract_coverage(x)): + print(line) + print() + + self.print_section(f"Files with Coverage < {self.low_coverage_threshold}%:") + if result.low_coverage_files: + for file_path, coverage in result.low_coverage_files: + print(f" {file_path}: {coverage}%") + else: + print("None") + print() + + self.print_section("Files with No Coverage:") + if result.no_coverage_files: + for file_path in result.no_coverage_files: + print(f" {file_path}") + else: + print("None") + print() + + self.print_header("Detailed Report") + print("HTML report generated: htmlcov/index.html") + print("XML report generated: coverage.xml") + print() + print("Open HTML report:") + print(" - macOS/Linux: open htmlcov/index.html") + print(" - Windows: start htmlcov/index.html") + print() + + return 0 + + def _extract_coverage(self, line: str) -> float: + """Extract coverage percentage from a report line.""" + match = re.search(r"(\d+(?:\.\d+)?)%", line) + return float(match.group(1)) if match else 0.0 + + +def main() -> int: + """Main entry point for the coverage CLI.""" + parser = argparse.ArgumentParser( + description="Unified coverage tool for test quality gates" + ) + + parser.add_argument( + "command", + choices=["check", "measure"], + help="Command to run: 'check' validates threshold, 'measure' generates full report", + ) + parser.add_argument( + "--threshold", + type=float, + default=70.0, + help="Coverage threshold percentage (default: 70)", + ) + parser.add_argument( + "--module", + type=str, + default="codeflow_engine", + help="Module name to measure coverage for (default: codeflow_engine)", + ) + parser.add_argument( + "--low-threshold", + type=float, + default=50.0, + help="Threshold for flagging low coverage files (default: 50)", + ) + + args = parser.parse_args() + + runner = CoverageRunner( + module_name=args.module, + threshold=args.threshold, + low_coverage_threshold=args.low_threshold, + ) + + if args.command == "check": + return runner.check_coverage() + elif args.command == "measure": + return runner.measure_coverage() + + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/measure-coverage.ps1 b/scripts/measure-coverage.ps1 index 43b57f8..fa0f764 100644 --- a/scripts/measure-coverage.ps1 +++ b/scripts/measure-coverage.ps1 @@ -1,55 +1,8 @@ # Measure current test coverage and generate detailed report +# This script delegates to the unified Python coverage utility $ErrorActionPreference = 'Stop' -Write-Host "========================================" -ForegroundColor Cyan -Write-Host "CodeFlow Engine - Coverage Measurement" -ForegroundColor Cyan -Write-Host "========================================" -ForegroundColor Cyan -Write-Host "" - -# Run tests with coverage -Write-Host "Running tests with coverage..." -ForegroundColor Yellow -poetry run pytest --cov=codeflow_engine --cov-report=html --cov-report=term --cov-report=xml - -Write-Host "" -Write-Host "========================================" -ForegroundColor Cyan -Write-Host "Coverage Summary" -ForegroundColor Cyan -Write-Host "========================================" -ForegroundColor Cyan -Write-Host "" - -# Get overall coverage -$CoverageOutput = poetry run coverage report | Select-String "TOTAL" -$Coverage = [regex]::Match($CoverageOutput, '(\d+(?:\.\d+)?)%').Groups[1].Value -Write-Host "Overall Coverage: ${Coverage}%" -Write-Host "" - -# Show coverage by module -Write-Host "Coverage by Module:" -ForegroundColor Yellow -Write-Host "----------------------------------------" -poetry run coverage report --show-missing | Select-String "codeflow_engine" -Write-Host "" - -# Show files with low coverage -Write-Host "Files with Coverage < 50%:" -ForegroundColor Yellow -Write-Host "----------------------------------------" -$LowCoverage = poetry run coverage report | Select-String "codeflow_engine" | Where-Object { - if ($_ -match '(\d+(?:\.\d+)?)%') { - [double]$matches[1] -lt 50 - } -} -if ($LowCoverage) { - $LowCoverage | ForEach-Object { Write-Host $_ } -} else { - Write-Host "None" -} -Write-Host "" - -Write-Host "========================================" -ForegroundColor Cyan -Write-Host "Detailed Report" -ForegroundColor Cyan -Write-Host "========================================" -ForegroundColor Cyan -Write-Host "HTML report generated: htmlcov/index.html" -Write-Host "XML report generated: coverage.xml" -Write-Host "" -Write-Host "Open HTML report: Start-Process htmlcov/index.html" -Write-Host "" +# Use the unified Python coverage utility +python -m scripts.coverage.runner measure diff --git a/scripts/measure-coverage.sh b/scripts/measure-coverage.sh index 84a46b6..5ba464c 100644 --- a/scripts/measure-coverage.sh +++ b/scripts/measure-coverage.sh @@ -1,52 +1,9 @@ #!/bin/bash # Measure current test coverage and generate detailed report +# This script delegates to the unified Python coverage utility set -e -echo "==========================================" -echo "CodeFlow Engine - Coverage Measurement" -echo "==========================================" -echo "" - -# Run tests with coverage -echo "Running tests with coverage..." -poetry run pytest --cov=codeflow_engine --cov-report=html --cov-report=term --cov-report=xml - -echo "" -echo "==========================================" -echo "Coverage Summary" -echo "==========================================" -echo "" - -# Get overall coverage -COVERAGE=$(poetry run coverage report | grep TOTAL | awk '{print $NF}') -echo "Overall Coverage: ${COVERAGE}" -echo "" - -# Show coverage by module -echo "Coverage by Module (sorted by coverage %):" -echo "----------------------------------------" -poetry run coverage report --show-missing | grep -E "codeflow_engine" | sort -k4 -n -echo "" - -# Show files with low coverage -echo "Files with Coverage < 50%:" -echo "----------------------------------------" -poetry run coverage report | grep -E "codeflow_engine.*[0-9]+.*[0-9]+.*[0-9]+%" | awk '$NF < 50 {print}' || echo "None" -echo "" - -# Show files with no coverage -echo "Files with No Coverage:" -echo "----------------------------------------" -poetry run coverage report --show-missing | grep -E "codeflow_engine.*0.*0.*0%" || echo "None" -echo "" - -echo "==========================================" -echo "Detailed Report" -echo "==========================================" -echo "HTML report generated: htmlcov/index.html" -echo "XML report generated: coverage.xml" -echo "" -echo "Open HTML report: open htmlcov/index.html" -echo "" +# Use the unified Python coverage utility +python -m scripts.coverage.runner measure diff --git a/tools/core/__init__.py b/tools/core/__init__.py new file mode 100644 index 0000000..1a99472 --- /dev/null +++ b/tools/core/__init__.py @@ -0,0 +1,5 @@ +"""Shared core utilities for tools.""" + +from tools.core.models import FileReport, IssueSeverity, LintIssue + +__all__ = ["IssueSeverity", "LintIssue", "FileReport"] diff --git a/tools/core/cli/__init__.py b/tools/core/cli/__init__.py new file mode 100644 index 0000000..9b6b713 --- /dev/null +++ b/tools/core/cli/__init__.py @@ -0,0 +1,21 @@ +"""Shared CLI utilities for linting tools.""" + +from tools.core.cli.base import BaseLinterCLI +from tools.core.cli.formatters import ( + format_issue_json, + format_issue_text, + format_report_json, + format_report_text, + format_summary, +) +from tools.core.cli.severity import get_severity_threshold + +__all__ = [ + "BaseLinterCLI", + "get_severity_threshold", + "format_issue_text", + "format_issue_json", + "format_report_text", + "format_report_json", + "format_summary", +] diff --git a/tools/core/cli/base.py b/tools/core/cli/base.py new file mode 100644 index 0000000..8b79a68 --- /dev/null +++ b/tools/core/cli/base.py @@ -0,0 +1,235 @@ +"""Base CLI class for linting tools. + +This module provides a base class with common CLI argument parsing +that can be extended by specific linting tools. +""" + +import argparse +from abc import ABC, abstractmethod +from pathlib import Path +import sys +from typing import Any + +from tools.core.cli.formatters import format_report_json, format_report_text +from tools.core.cli.severity import get_severity_threshold + + +class BaseLinterCLI(ABC): + """Base class for linter CLI implementations. + + This class provides common argument parsing and output formatting + that can be shared across different linting tools. + """ + + # Override these in subclasses + tool_name: str = "linter" + tool_description: str = "Lint files" + tool_version: str = "0.1.0" + file_extensions: set[str] = set() + + def __init__(self) -> None: + """Initialize the CLI.""" + self.parser = self._create_parser() + + def _create_parser(self) -> argparse.ArgumentParser: + """Create the argument parser with common arguments.""" + parser = argparse.ArgumentParser( + description=self.tool_description, + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + + # Positional arguments + parser.add_argument( + "paths", + nargs="*", + default=["."], + help="Files or directories to check (default: current directory)", + ) + + # Add common argument groups + self._add_output_arguments(parser) + self._add_fix_arguments(parser) + self._add_filter_arguments(parser) + + # Version + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {self.tool_version}", + ) + + # Add tool-specific arguments + self._add_linting_arguments(parser) + + return parser + + def _add_output_arguments(self, parser: argparse.ArgumentParser) -> None: + """Add output-related arguments.""" + output_group = parser.add_argument_group("Output Options") + output_group.add_argument( + "--format", + choices=["text", "json"], + default="text", + help="Output format", + ) + output_group.add_argument( + "--no-color", + action="store_true", + help="Disable colored output", + ) + output_group.add_argument( + "-v", + "--verbose", + action="count", + default=0, + help="Increase verbosity (can be used multiple times)", + ) + + def _add_fix_arguments(self, parser: argparse.ArgumentParser) -> None: + """Add fix-related arguments.""" + fix_group = parser.add_argument_group("Fixing Options") + fix_group.add_argument( + "--fix", + action="store_true", + help="Automatically fix fixable issues", + ) + fix_group.add_argument( + "--dry-run", + action="store_true", + help="Show what would be fixed without making changes", + ) + + def _add_filter_arguments(self, parser: argparse.ArgumentParser) -> None: + """Add filtering-related arguments.""" + filter_group = parser.add_argument_group("Filtering Options") + filter_group.add_argument( + "--exclude", + action="append", + default=[], + help="Exclude files/directories that match the given glob patterns", + ) + filter_group.add_argument( + "--severity", + choices=["error", "warning", "style"], + default="warning", + help="Minimum severity to report", + ) + + @abstractmethod + def _add_linting_arguments(self, parser: argparse.ArgumentParser) -> None: + """Add tool-specific linting arguments. + + Subclasses must implement this to add their specific options. + """ + pass + + @abstractmethod + def create_linter(self, args: argparse.Namespace) -> Any: + """Create a linter instance with the given configuration. + + Subclasses must implement this to create their specific linter. + """ + pass + + def parse_args(self, args: list[str] | None = None) -> argparse.Namespace: + """Parse command line arguments.""" + return self.parser.parse_args(args) + + def process_paths( + self, linter: Any, paths: list[str], exclude: list[str], verbose: int = 0 + ) -> None: + """Process all specified paths with the linter. + + Args: + linter: The linter instance + paths: List of file/directory paths to process + exclude: List of glob patterns to exclude + verbose: Verbosity level + """ + for path_str in paths: + path = Path(path_str) + + if path.is_file(): + if not self.file_extensions or path.suffix in self.file_extensions: + linter.check_file(path) + elif verbose > 0: + print(f"Skipping file with unsupported extension: {path}") + elif path.is_dir(): + linter.check_directory(path, exclude=exclude) + + def format_output( + self, reports: dict, args: argparse.Namespace + ) -> str: + """Format the linting output based on arguments.""" + min_severity = get_severity_threshold(args.severity) + use_color = not args.no_color and sys.stdout.isatty() + + if args.format == "json": + return format_report_json(reports, min_severity=min_severity) + return format_report_text( + reports, + use_color=use_color, + verbose=args.verbose, + min_severity=min_severity, + ) + + def has_issues_at_severity( + self, reports: dict, severity_name: str + ) -> bool: + """Check if any reports have issues at or above the given severity.""" + severity_threshold = get_severity_threshold(severity_name) + return any( + any( + issue.severity.value <= severity_threshold.value + for issue in report.issues + ) + for report in reports.values() + ) + + def run(self, args: list[str] | None = None) -> int: + """Run the linter CLI. + + Args: + args: Command line arguments (defaults to sys.argv[1:]) + + Returns: + Exit code (0 for success, 1 for issues found) + """ + try: + parsed_args = self.parse_args(args) + linter = self.create_linter(parsed_args) + + # Process paths + self.process_paths( + linter, + parsed_args.paths, + parsed_args.exclude, + parsed_args.verbose, + ) + + # Apply fixes if requested + if parsed_args.fix: + if parsed_args.dry_run: + fixed_count = linter.fix_files(dry_run=True) + if parsed_args.verbose > 0: + if fixed_count == 0: + print("No fixable issues found (dry run)") + else: + print(f"Would fix {fixed_count} file(s) (dry run)") + else: + fixed_count = linter.fix_files(dry_run=False) + if fixed_count > 0 and parsed_args.verbose > 0: + print(f"Applied fixes to {fixed_count} file(s)") + + # Format and print output + output = self.format_output(linter.reports, parsed_args) + if output.strip(): + print(output) + + # Return exit code based on issues found + return 1 if self.has_issues_at_severity(linter.reports, parsed_args.severity) else 0 + + except KeyboardInterrupt: + return 130 + except Exception: + return 1 diff --git a/tools/core/cli/formatters.py b/tools/core/cli/formatters.py new file mode 100644 index 0000000..caaec8c --- /dev/null +++ b/tools/core/cli/formatters.py @@ -0,0 +1,181 @@ +"""Output formatting utilities for linting tools. + +This module provides consistent formatting for linting output across all +linting tools, supporting both text and JSON output formats. +""" + +import json +import sys +from pathlib import Path +from typing import Any + +from tools.core.models import FileReport, IssueSeverity, LintIssue + +# ANSI color codes for terminal output +COLORS = { + IssueSeverity.ERROR: "\033[31m", # Red + IssueSeverity.WARNING: "\033[33m", # Yellow + IssueSeverity.STYLE: "\033[36m", # Cyan +} +RESET = "\033[0m" + + +def format_issue_text( + issue: LintIssue, + file_path: Path, + use_color: bool = True, + verbose: int = 0, +) -> str: + """Format a single issue for text output. + + Args: + issue: The LintIssue to format + file_path: The path to the file containing the issue + use_color: Whether to use ANSI color codes + verbose: Verbosity level (0=minimal, 1+=detailed) + + Returns: + A formatted string representation of the issue + """ + severity_color = COLORS.get(issue.severity, "") if use_color else "" + reset = RESET if use_color else "" + severity_name = issue.severity.name.lower() + + location = f"{file_path}:{issue.line}:{issue.column}" + + if verbose >= 1: + # Detailed format with context + code_part = f": {issue.code}" if issue.code else "" + result = f"{location}: {severity_color}{severity_name}{reset}{code_part}: {issue.message}" + if issue.context: + result += f"\n {issue.context}" + return result + + # Standard format + if issue.code: + return f"{location}: {severity_color}{severity_name}{reset}: {issue.message} [{issue.code}]" + return f"{location}: {severity_color}{severity_name}{reset}: {issue.message}" + + +def format_issue_json(issue: LintIssue) -> dict[str, Any]: + """Format a single issue for JSON output. + + Args: + issue: The LintIssue to format + + Returns: + A dictionary representation of the issue + """ + return { + "line": issue.line, + "column": issue.column, + "code": issue.code, + "message": issue.message, + "severity": issue.severity.name.lower(), + "fixable": issue.fixable, + } + + +def format_report_text( + reports: dict[Path, FileReport], + use_color: bool | None = None, + verbose: int = 0, + min_severity: IssueSeverity | None = None, +) -> str: + """Format multiple file reports for text output. + + Args: + reports: Dictionary mapping file paths to FileReport objects + use_color: Whether to use ANSI colors (None = auto-detect from TTY) + verbose: Verbosity level (0=minimal, 1+=detailed) + min_severity: Minimum severity level to include (None = include all) + + Returns: + A formatted string representation of all reports + """ + if use_color is None: + use_color = sys.stdout.isatty() + + lines: list[str] = [] + total_issues = 0 + total_files = 0 + + for file_path, report in sorted(reports.items()): + if not report.has_issues: + continue + + # Filter by severity if specified + issues = report.issues + if min_severity is not None: + issues = [i for i in issues if i.severity.value <= min_severity.value] + + if not issues: + continue + + total_files += 1 + total_issues += len(issues) + + lines.append(f"\n{file_path}:") + for issue in sorted(issues, key=lambda x: (x.line, x.column)): + lines.append(f" {format_issue_text(issue, file_path, use_color, verbose)}") + + if total_issues > 0 or verbose > 0: + lines.append(format_summary(total_issues, total_files)) + + return "\n".join(lines) + + +def format_report_json( + reports: dict[Path, FileReport], + min_severity: IssueSeverity | None = None, + indent: int = 2, +) -> str: + """Format multiple file reports for JSON output. + + Args: + reports: Dictionary mapping file paths to FileReport objects + min_severity: Minimum severity level to include (None = include all) + indent: JSON indentation level + + Returns: + A JSON string representation of all reports + """ + output: list[dict[str, Any]] = [] + + for file_path, report in reports.items(): + if not report.has_issues: + continue + + # Filter by severity if specified + issues = report.issues + if min_severity is not None: + issues = [i for i in issues if i.severity.value <= min_severity.value] + + if not issues: + continue + + file_data = { + "file": str(file_path), + "issues": [format_issue_json(issue) for issue in issues], + } + output.append(file_data) + + return json.dumps(output, indent=indent) + + +def format_summary(issue_count: int, file_count: int) -> str: + """Format a summary line for the report. + + Args: + issue_count: Total number of issues found + file_count: Total number of files with issues + + Returns: + A formatted summary string + """ + issue_word = "issue" if issue_count == 1 else "issues" + file_word = "file" if file_count == 1 else "files" + + if issue_count == 0: + return "\nNo issues found" + return f"\nFound {issue_count} {issue_word} in {file_count} {file_word}" diff --git a/tools/core/cli/severity.py b/tools/core/cli/severity.py new file mode 100644 index 0000000..5a2903d --- /dev/null +++ b/tools/core/cli/severity.py @@ -0,0 +1,38 @@ +"""Severity handling utilities for CLI tools.""" + +from tools.core.models import IssueSeverity + +# Mapping from severity string names to enum values +SEVERITY_MAPPING: dict[str, IssueSeverity] = { + "error": IssueSeverity.ERROR, + "warning": IssueSeverity.WARNING, + "style": IssueSeverity.STYLE, +} + + +def get_severity_threshold(severity_name: str) -> IssueSeverity: + """Convert severity name to IssueSeverity enum. + + Args: + severity_name: The name of the severity level (case-insensitive) + + Returns: + The corresponding IssueSeverity enum value + + Raises: + KeyError: If the severity name is not recognized + """ + return SEVERITY_MAPPING[severity_name.lower()] + + +def filter_issues_by_severity(issues: list, min_severity: IssueSeverity) -> list: + """Filter issues to only include those at or above the minimum severity. + + Args: + issues: List of LintIssue objects + min_severity: The minimum severity level to include + + Returns: + Filtered list of issues + """ + return [issue for issue in issues if issue.severity.value <= min_severity.value] diff --git a/tools/core/models/__init__.py b/tools/core/models/__init__.py new file mode 100644 index 0000000..d7c4f47 --- /dev/null +++ b/tools/core/models/__init__.py @@ -0,0 +1,5 @@ +"""Shared data models for linting tools.""" + +from tools.core.models.lint_models import FileReport, IssueSeverity, LintIssue + +__all__ = ["IssueSeverity", "LintIssue", "FileReport"] diff --git a/tools/core/models/lint_models.py b/tools/core/models/lint_models.py new file mode 100644 index 0000000..f9613a1 --- /dev/null +++ b/tools/core/models/lint_models.py @@ -0,0 +1,108 @@ +"""Data models for linting tools. + +This module provides shared data structures used across all linting tools +(markdown, YAML, etc.) to eliminate code duplication and ensure consistency. +""" + +from collections.abc import Callable +from dataclasses import dataclass, field +from enum import Enum, auto +from pathlib import Path + + +class IssueSeverity(Enum): + """Severity levels for linting issues. + + Severity levels are ordered from most severe to least severe: + - ERROR: Critical issues that must be fixed + - WARNING: Issues that should be addressed + - STYLE: Stylistic issues that are optional to fix + """ + + ERROR = auto() + WARNING = auto() + STYLE = auto() + + +@dataclass +class LintIssue: + """Represents a single linting issue. + + Attributes: + line: The line number where the issue was found (1-indexed) + column: The column number where the issue was found (0-indexed) + code: A short code identifying the rule (e.g., "MD022", "YML001") + message: A human-readable description of the issue + severity: The severity level of the issue + fixable: Whether this issue can be automatically fixed + fix: An optional function that takes the line content and returns the fixed content + context: Additional context about the issue (e.g., the problematic line) + """ + + line: int + column: int = 0 + code: str = "" + message: str = "" + severity: IssueSeverity = IssueSeverity.WARNING + fixable: bool = False + fix: Callable[[str], str] | None = None + context: str = "" + + def __str__(self) -> str: + """Return a string representation of the issue.""" + return f"{self.severity.name}:{self.code} - {self.message} (line {self.line}, col {self.column})" + + +@dataclass +class FileReport: + """Collection of lint issues for a single file. + + Attributes: + path: The path to the file that was linted + issues: A list of LintIssue objects found in the file + fixed_content: The content of the file after fixes have been applied (if any) + """ + + path: Path + issues: list[LintIssue] = field(default_factory=list) + fixed_content: list[str] | None = None + + @property + def has_issues(self) -> bool: + """Return True if there are any issues.""" + return bool(self.issues) + + @property + def has_errors(self) -> bool: + """Return True if there are any error-level issues.""" + return any(issue.severity == IssueSeverity.ERROR for issue in self.issues) + + @property + def has_warnings(self) -> bool: + """Return True if there are any warning-level issues.""" + return any(issue.severity == IssueSeverity.WARNING for issue in self.issues) + + @property + def has_fixable_issues(self) -> bool: + """Return True if there are any fixable issues.""" + return any(issue.fixable for issue in self.issues) + + def add_issue(self, issue: LintIssue) -> None: + """Add an issue to the report.""" + self.issues.append(issue) + + def get_issues_by_severity(self, severity: IssueSeverity) -> list[LintIssue]: + """Get all issues with the given severity.""" + return [issue for issue in self.issues if issue.severity == severity] + + def get_error_count(self) -> int: + """Get the count of error-level issues.""" + return len(self.get_issues_by_severity(IssueSeverity.ERROR)) + + def get_warning_count(self) -> int: + """Get the count of warning-level issues.""" + return len(self.get_issues_by_severity(IssueSeverity.WARNING)) + + def get_style_count(self) -> int: + """Get the count of style-level issues.""" + return len(self.get_issues_by_severity(IssueSeverity.STYLE)) diff --git a/tools/markdown_lint/cli.py b/tools/markdown_lint/cli.py index 3a9c9ce..e17b3a3 100644 --- a/tools/markdown_lint/cli.py +++ b/tools/markdown_lint/cli.py @@ -1,290 +1,119 @@ """Command-line interface for the markdown linter.""" import argparse -from pathlib import Path import sys -from typing import Any +from tools.core.cli.base import BaseLinterCLI +from tools.markdown_lint.linter import MarkdownLinter -try: - from tools.markdown_lint.linter import MarkdownLinter - from tools.markdown_lint.models import IssueSeverity -except ImportError: - from linter import MarkdownLinter - from models import IssueSeverity +class MarkdownLinterCLI(BaseLinterCLI): + """CLI for the markdown linter.""" -def parse_args(args: list[str]) -> argparse.Namespace: - """Parse command line arguments.""" - parser = argparse.ArgumentParser( - description="Lint and fix markdown files.", - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - ) + tool_name = "markdown-lint" + tool_description = "Lint and fix markdown files." + tool_version = "0.1.0" + file_extensions = {".md", ".markdown"} - # Positional arguments - parser.add_argument( - "paths", - nargs="*", - default=["."], - help="Files or directories to check (default: current directory)", - ) - - # Linting options - lint_group = parser.add_argument_group("Linting Options") - lint_group.add_argument( - "--max-line-length", - type=int, - default=120, - help="Maximum allowed line length", - ) - lint_group.add_argument( - "--no-blank-line-before-heading", - action="store_false", - dest="require_blank_line_before_heading", - help="Don't require blank lines before headings", - ) - lint_group.add_argument( - "--no-blank-line-after-heading", - action="store_false", - dest="require_blank_line_after_heading", - help="Don't require blank lines after headings", - ) - lint_group.add_argument( - "--allow-multiple-blank-lines", - action="store_true", - help="Allow multiple consecutive blank lines", - ) - lint_group.add_argument( - "--no-trim-trailing-whitespace", - action="store_false", - dest="trim_trailing_whitespace", - help="Don't trim trailing whitespace", - ) - lint_group.add_argument( - "--no-check-blank-lines-around-headings", - action="store_false", - dest="check_blank_lines_around_headings", - help="Disable MD022: Don't check for blank lines around headings", - ) - lint_group.add_argument( - "--no-check-blank-lines-around-lists", - action="store_false", - dest="check_blank_lines_around_lists", - help="Disable MD032: Don't check for blank lines around lists", - ) - lint_group.add_argument( - "--no-check-ordered-list-numbering", - action="store_false", - dest="check_ordered_list_numbering", - help="Disable MD029: Don't check ordered list item numbering", - ) - lint_group.add_argument( - "--no-check-fenced-code-blocks", - action="store_false", - dest="check_fenced_code_blocks", - help="Disable MD031/MD040: Don't check fenced code blocks spacing and language", - ) - lint_group.add_argument( - "--no-check-duplicate-headings", - action="store_false", - dest="check_duplicate_headings", - help="Disable MD024: Don't check for duplicate headings", - ) - lint_group.add_argument( - "--no-check-bare-urls", - action="store_false", - dest="check_bare_urls", - help="Disable MD034: Don't check for bare URLs", - ) - lint_group.add_argument( - "--no-insert-final-newline", - action="store_false", - dest="insert_final_newline", - help="Don't require a final newline at the end of files", - ) - - # Output options - output_group = parser.add_argument_group("Output Options") - output_group.add_argument( - "--format", - choices=["text", "json"], - default="text", - help="Output format", - ) - output_group.add_argument( - "--no-color", - action="store_true", - help="Disable colored output", - ) - output_group.add_argument( - "--verbose", - "-v", - action="count", - default=0, - help="Increase verbosity (can be used multiple times)", - ) - - # Fixing options - fix_group = parser.add_argument_group("Fixing Options") - fix_group.add_argument( - "--fix", - action="store_true", - help="Automatically fix fixable issues", - ) - fix_group.add_argument( - "--dry-run", - action="store_true", - help="Show what would be fixed without making changes", - ) - - # Filtering options - filter_group = parser.add_argument_group("Filtering Options") - filter_group.add_argument( - "--exclude", - action="append", - default=[], - help="Exclude files/directories that match the given glob patterns", - ) - filter_group.add_argument( - "--severity", - choices=["error", "warning", "style"], - default="warning", - help="Minimum severity to report", - ) - - # Other options - parser.add_argument( - "--version", - action="version", - version=f"%(prog)s {__import__('tools.markdown_lint', fromlist=['__version__']).__version__}", - ) - - return parser.parse_args(args) - - -def format_issue(issue, path: Path, verbose: int = 0) -> str: - """Format an issue as a string.""" - severity = issue.severity.name.lower() - - if verbose >= 1: - return ( - f"{path!s}:{issue.line}:{issue.column}: {severity}: {issue.code}: {issue.message}\n" - f" {issue.context or ''}" + def _add_linting_arguments(self, parser: argparse.ArgumentParser) -> None: + """Add markdown-specific linting arguments.""" + lint_group = parser.add_argument_group("Linting Options") + lint_group.add_argument( + "--max-line-length", + type=int, + default=120, + help="Maximum allowed line length", + ) + lint_group.add_argument( + "--no-blank-line-before-heading", + action="store_false", + dest="require_blank_line_before_heading", + help="Don't require blank lines before headings", + ) + lint_group.add_argument( + "--no-blank-line-after-heading", + action="store_false", + dest="require_blank_line_after_heading", + help="Don't require blank lines after headings", + ) + lint_group.add_argument( + "--allow-multiple-blank-lines", + action="store_true", + help="Allow multiple consecutive blank lines", + ) + lint_group.add_argument( + "--no-trim-trailing-whitespace", + action="store_false", + dest="trim_trailing_whitespace", + help="Don't trim trailing whitespace", + ) + lint_group.add_argument( + "--no-check-blank-lines-around-headings", + action="store_false", + dest="check_blank_lines_around_headings", + help="Disable MD022: Don't check for blank lines around headings", + ) + lint_group.add_argument( + "--no-check-blank-lines-around-lists", + action="store_false", + dest="check_blank_lines_around_lists", + help="Disable MD032: Don't check for blank lines around lists", + ) + lint_group.add_argument( + "--no-check-ordered-list-numbering", + action="store_false", + dest="check_ordered_list_numbering", + help="Disable MD029: Don't check ordered list item numbering", + ) + lint_group.add_argument( + "--no-check-fenced-code-blocks", + action="store_false", + dest="check_fenced_code_blocks", + help="Disable MD031/MD040: Don't check fenced code blocks spacing and language", + ) + lint_group.add_argument( + "--no-check-duplicate-headings", + action="store_false", + dest="check_duplicate_headings", + help="Disable MD024: Don't check for duplicate headings", + ) + lint_group.add_argument( + "--no-check-bare-urls", + action="store_false", + dest="check_bare_urls", + help="Disable MD034: Don't check for bare URLs", + ) + lint_group.add_argument( + "--no-insert-final-newline", + action="store_false", + dest="insert_final_newline", + help="Don't require a final newline at the end of files", ) - return f"{path!s}:{issue.line}:{issue.column}: {severity}: {issue.message}" - - -def print_report(reports: dict[Path, Any], args: argparse.Namespace) -> int: - """Print a report of all issues found.""" - issue_count = 0 - file_count = 0 - - if args.format == "json": - import json - - output = [] - for path, report in reports.items(): - if report["issues"]: - file_count += 1 - issue_count += len(report["issues"]) - output.append( - { - "file": str(path), - "issues": [ - { - "line": issue.line, - "column": issue.column, - "code": issue.code, - "message": issue.message, - "severity": issue.severity.name.lower(), - "fixable": issue.fixable, - } - for issue in report["issues"] - ], - } - ) - - if output or args.verbose > 0: - print(json.dumps(output, indent=2)) - else: - # Text output - for path, report in sorted(reports.items()): - if report["issues"]: - file_count += 1 - issue_count += len(report["issues"]) - - for issue in sorted(report["issues"], key=lambda x: x.line): - print(format_issue(issue, path, args.verbose)) - - # Print summary - if issue_count > 0 or args.verbose > 0: - print(f"Found {issue_count} issue(s) in {file_count} file(s)") - return 1 if issue_count > 0 else 0 + def create_linter(self, args: argparse.Namespace) -> MarkdownLinter: + """Create a MarkdownLinter instance with the given configuration.""" + return MarkdownLinter( + { + "max_line_length": args.max_line_length, + "require_blank_line_before_heading": args.require_blank_line_before_heading, + "require_blank_line_after_heading": args.require_blank_line_after_heading, + "allow_multiple_blank_lines": args.allow_multiple_blank_lines, + "trim_trailing_whitespace": args.trim_trailing_whitespace, + "insert_final_newline": args.insert_final_newline, + "check_blank_lines_around_headings": args.check_blank_lines_around_headings, + "check_blank_lines_around_lists": args.check_blank_lines_around_lists, + "check_ordered_list_numbering": args.check_ordered_list_numbering, + "check_fenced_code_blocks": args.check_fenced_code_blocks, + "check_duplicate_headings": args.check_duplicate_headings, + "check_bare_urls": args.check_bare_urls, + } + ) def main() -> int: """Main entry point for the CLI.""" - args = parse_args(sys.argv[1:]) - - # Initialize linter with configuration - linter = MarkdownLinter( - { - "max_line_length": args.max_line_length, - "require_blank_line_before_heading": args.require_blank_line_before_heading, - "require_blank_line_after_heading": args.require_blank_line_after_heading, - "allow_multiple_blank_lines": args.allow_multiple_blank_lines, - "trim_trailing_whitespace": args.trim_trailing_whitespace, - "insert_final_newline": args.insert_final_newline, - "check_blank_lines_around_headings": args.check_blank_lines_around_headings, - "check_blank_lines_around_lists": args.check_blank_lines_around_lists, - "check_ordered_list_numbering": args.check_ordered_list_numbering, - "check_fenced_code_blocks": args.check_fenced_code_blocks, - "check_duplicate_headings": args.check_duplicate_headings, - "check_bare_urls": args.check_bare_urls, - } - ) - - # Process each path - paths = [Path(p) for p in args.paths] - for path in paths: - if path.is_file(): - linter.check_file(path) - elif path.is_dir(): - linter.check_directory(path, exclude=args.exclude) - else: - return 1 - - # Filter issues by severity - min_severity = { - "error": IssueSeverity.ERROR, - "warning": IssueSeverity.WARNING, - "style": IssueSeverity.STYLE, - }[args.severity.lower()] - - filtered_reports = {} - for path, report in linter.reports.items(): - filtered_issues = [ - issue - for issue in report.issues - if issue.severity.value >= min_severity.value - ] - if filtered_issues: - filtered_reports[path] = {"issues": filtered_issues} - - # Apply fixes if requested - if args.fix and not args.dry_run: - fixed_count = linter.fix_files(dry_run=False) - if fixed_count > 0: - pass - elif args.dry_run: - linter.fix_files(dry_run=True) - - # Print report - if not args.fix or args.dry_run: - return print_report(filtered_reports, args) - - return 0 + cli = MarkdownLinterCLI() + return cli.run() if __name__ == "__main__": diff --git a/tools/markdown_lint/linter.py b/tools/markdown_lint/linter.py index 33a6f15..1f529c5 100644 --- a/tools/markdown_lint/linter.py +++ b/tools/markdown_lint/linter.py @@ -5,11 +5,7 @@ from pathlib import Path import re - -try: - from tools.markdown_lint.models import FileReport, IssueSeverity, LintIssue -except ImportError: - from models import FileReport, IssueSeverity, LintIssue +from tools.core.models import FileReport, IssueSeverity, LintIssue class MarkdownLinter: diff --git a/tools/markdown_lint/models.py b/tools/markdown_lint/models.py index 811a519..05752ef 100644 --- a/tools/markdown_lint/models.py +++ b/tools/markdown_lint/models.py @@ -1,69 +1,9 @@ -"""Data models for the markdown linter.""" +"""Data models for the markdown linter. -from collections.abc import Callable -from dataclasses import dataclass, field -from enum import Enum, auto -from pathlib import Path +This module re-exports the shared lint models from tools.core.models +for backwards compatibility. +""" +from tools.core.models import FileReport, IssueSeverity, LintIssue -class IssueSeverity(Enum): - """Severity levels for linting issues.""" - - ERROR = auto() - WARNING = auto() - STYLE = auto() - - -@dataclass -class LintIssue: - """Represents a single linting issue.""" - - line: int - column: int = 0 - code: str = "" - message: str = "" - severity: IssueSeverity = IssueSeverity.WARNING - fixable: bool = False - fix: Callable[[str], str] | None = None - context: str = "" - - def __str__(self) -> str: - """Return a string representation of the issue.""" - return f"{self.severity.name}:{self.code} - {self.message} (line {self.line}, col {self.column})" - - -@dataclass -class FileReport: - """Collection of lint issues for a single file.""" - - path: Path - issues: list[LintIssue] = field(default_factory=list) - fixed_content: list[str] | None = None - - @property - def has_issues(self) -> bool: - """Return True if there are any issues.""" - return bool(self.issues) - - @property - def has_errors(self) -> bool: - """Return True if there are any error-level issues.""" - return any(issue.severity == IssueSeverity.ERROR for issue in self.issues) - - @property - def has_warnings(self) -> bool: - """Return True if there are any warning-level issues.""" - return any(issue.severity == IssueSeverity.WARNING for issue in self.issues) - - @property - def has_fixable_issues(self) -> bool: - """Return True if there are any fixable issues.""" - return any(issue.fixable for issue in self.issues) - - def add_issue(self, issue: LintIssue) -> None: - """Add an issue to the report.""" - self.issues.append(issue) - - def get_issues_by_severity(self, severity: IssueSeverity) -> list[LintIssue]: - """Get all issues with the given severity.""" - return [issue for issue in self.issues if issue.severity == severity] +__all__ = ["IssueSeverity", "LintIssue", "FileReport"] diff --git a/tools/yaml_lint/cli.py b/tools/yaml_lint/cli.py index 4b95e18..1bfff63 100644 --- a/tools/yaml_lint/cli.py +++ b/tools/yaml_lint/cli.py @@ -1,314 +1,82 @@ """Command-line interface for the YAML linter.""" import argparse -import json -from pathlib import Path import sys -from typing import Any +from tools.core.cli.base import BaseLinterCLI from tools.yaml_lint.linter import YAMLLinter -from tools.yaml_lint.models import IssueSeverity -def parse_args(args: list[str]) -> argparse.Namespace: - """Parse command line arguments.""" - parser = argparse.ArgumentParser( - description="Lint and fix YAML files.", - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - ) +class YAMLLinterCLI(BaseLinterCLI): + """CLI for the YAML linter.""" - # Positional arguments - parser.add_argument( - "paths", - nargs="*", - default=["."], - help="Files or directories to check (default: current directory)", - ) + tool_name = "yaml-lint" + tool_description = "Lint and fix YAML files." + tool_version = "0.1.0" + file_extensions = {".yml", ".yaml"} - # Linting options - lint_group = parser.add_argument_group("Linting Options") - lint_group.add_argument( - "--max-line-length", - type=int, - default=120, - help="Maximum allowed line length", - ) - lint_group.add_argument( - "--indent-size", - type=int, - default=2, - help="Expected indentation size", - ) - lint_group.add_argument( - "--no-document-start", - dest="enforce_document_start", - action="store_false", - default=True, - help="Don't require document start marker (---)", - ) - lint_group.add_argument( - "--document-end", - dest="enforce_document_end", - action="store_true", - default=False, - help="Require document end marker (...)", - ) - lint_group.add_argument( - "--no-empty-values", - dest="check_empty_values", - action="store_false", - default=True, - help="Don't check for empty values", - ) - lint_group.add_argument( - "--no-truthy", - dest="check_truthy", - action="store_false", - default=True, - help="Don't check for problematic truthy values", - ) - - # Output options - output_group = parser.add_argument_group("Output Options") - output_group.add_argument( - "--format", - choices=["text", "json"], - default="text", - help="Output format", - ) - output_group.add_argument( - "--no-color", - action="store_true", - help="Disable colored output", - ) - output_group.add_argument( - "-v", - "--verbose", - action="count", - default=0, - help="Increase verbosity (can be used multiple times)", - ) - - # Fixing options - fix_group = parser.add_argument_group("Fixing Options") - fix_group.add_argument( - "--fix", - action="store_true", - help="Automatically fix fixable issues", - ) - fix_group.add_argument( - "--dry-run", - action="store_true", - help="Show what would be fixed without making changes", - ) - - # Filtering options - filter_group = parser.add_argument_group("Filtering Options") - filter_group.add_argument( - "--exclude", - action="append", - default=[], - help="Exclude files/directories that match the given glob patterns", - ) - filter_group.add_argument( - "--severity", - choices=["error", "warning", "style"], - default="warning", - help="Minimum severity to report", - ) - - # Version - parser.add_argument( - "--version", - action="version", - version="%(prog)s 0.1.0", - ) - - return parser.parse_args(args) - - -def get_severity_threshold(severity_name: str) -> IssueSeverity: - """Convert severity name to IssueSeverity enum.""" - mapping = { - "error": IssueSeverity.ERROR, - "warning": IssueSeverity.WARNING, - "style": IssueSeverity.STYLE, - } - return mapping[severity_name] - - -def format_issue_text(issue, file_path: Path, use_color: bool = True) -> str: - """Format a single issue for text output.""" - colors = { - IssueSeverity.ERROR: "\033[31m" if use_color else "", # Red - IssueSeverity.WARNING: "\033[33m" if use_color else "", # Yellow - IssueSeverity.STYLE: "\033[36m" if use_color else "", # Cyan - } - reset = "\033[0m" if use_color else "" - - severity_color = colors.get(issue.severity, "") - severity_name = issue.severity.name.lower() - - location = f"{file_path}:{issue.line}:{issue.column}" - message = f"{severity_color}{severity_name}{reset}: {issue.message}" - - if issue.code: - message += f" [{issue.code}]" - - return f" {location}: {message}" - - -def format_output_text(reports: dict[Path, Any], args: argparse.Namespace) -> str: - """Format output in text format.""" - lines = [] - severity_threshold = get_severity_threshold(args.severity) - use_color = not args.no_color and sys.stdout.isatty() - - total_issues = 0 - total_files = 0 - - for file_path, report in reports.items(): - if not report.has_issues: - continue - - # Filter issues by severity - filtered_issues = [ - issue - for issue in report.issues - if issue.severity.value <= severity_threshold.value - ] - - if not filtered_issues: - continue - - total_files += 1 - total_issues += len(filtered_issues) - - lines.append(f"\n{file_path}:") - - lines.extend( - format_issue_text(issue, file_path, use_color) for issue in filtered_issues + def _add_linting_arguments(self, parser: argparse.ArgumentParser) -> None: + """Add YAML-specific linting arguments.""" + lint_group = parser.add_argument_group("Linting Options") + lint_group.add_argument( + "--max-line-length", + type=int, + default=120, + help="Maximum allowed line length", + ) + lint_group.add_argument( + "--indent-size", + type=int, + default=2, + help="Expected indentation size", + ) + lint_group.add_argument( + "--no-document-start", + dest="enforce_document_start", + action="store_false", + default=True, + help="Don't require document start marker (---)", + ) + lint_group.add_argument( + "--document-end", + dest="enforce_document_end", + action="store_true", + default=False, + help="Require document end marker (...)", + ) + lint_group.add_argument( + "--no-empty-values", + dest="check_empty_values", + action="store_false", + default=True, + help="Don't check for empty values", + ) + lint_group.add_argument( + "--no-truthy", + dest="check_truthy", + action="store_false", + default=True, + help="Don't check for problematic truthy values", ) - if total_issues > 0: - lines.append(f"\nFound {total_issues} issue(s) in {total_files} file(s)") - elif args.verbose > 0: - lines.append("No issues found") - - return "\n".join(lines) - - -def format_output_json(reports: dict[Path, Any], args: argparse.Namespace) -> str: - """Format output in JSON format.""" - severity_threshold = get_severity_threshold(args.severity) - - output = [] - for file_path, report in reports.items(): - if not report.has_issues: - continue - - # Filter issues by severity - filtered_issues = [ - issue - for issue in report.issues - if issue.severity.value <= severity_threshold.value - ] - - if not filtered_issues: - continue - - file_data = {"file": str(file_path), "issues": []} - - for issue in filtered_issues: - issue_data = { - "line": issue.line, - "column": issue.column, - "code": issue.code, - "message": issue.message, - "severity": issue.severity.name.lower(), - "fixable": issue.fixable, + def create_linter(self, args: argparse.Namespace) -> YAMLLinter: + """Create a YAMLLinter instance with the given configuration.""" + return YAMLLinter( + { + "max_line_length": args.max_line_length, + "indent_size": args.indent_size, + "enforce_document_start": args.enforce_document_start, + "enforce_document_end": args.enforce_document_end, + "check_empty_values": args.check_empty_values, + "check_truthy": args.check_truthy, } - file_data["issues"].append(issue_data) - - output.append(file_data) - - return json.dumps(output, indent=2) - - -def run_linter(args: argparse.Namespace) -> int: - """Run the YAML linter with the given arguments.""" - # Create linter configuration - config = { - "max_line_length": args.max_line_length, - "indent_size": args.indent_size, - "enforce_document_start": args.enforce_document_start, - "enforce_document_end": args.enforce_document_end, - "check_empty_values": args.check_empty_values, - "check_truthy": args.check_truthy, - } - - linter = YAMLLinter(config) - - # Check all specified paths - for path_str in args.paths: - path = Path(path_str) - - if path.is_file(): - if path.suffix in {".yml", ".yaml"}: - linter.check_file(path) - elif args.verbose > 0: - print(f"Skipping non-YAML file: {path}") - elif path.is_dir(): - linter.check_directory(path, exclude=args.exclude) - - # Apply fixes if requested - if args.fix: - if args.dry_run: - fixed_count = linter.fix_files(dry_run=True) - if args.verbose > 0: - if fixed_count == 0: - print("No fixable issues found (dry run)") - else: - print(f"Would fix {fixed_count} file(s) (dry run)") - else: - fixed_count = linter.fix_files(dry_run=False) - if fixed_count > 0 and args.verbose > 0: - print(f"Applied fixes to {fixed_count} file(s)") - # Re-run linter to show remaining issues - new_linter = YAMLLinter(config) - for file_path in linter.reports: - new_linter.check_file(file_path) - linter.reports = new_linter.reports - - # Generate output - if args.format == "json": - output = format_output_json(linter.reports, args) - else: - output = format_output_text(linter.reports, args) - - if output.strip(): - print(output) - pass - - # Determine exit code - severity_threshold = get_severity_threshold(args.severity) - has_issues_at_threshold = any( - any(issue.severity.value <= severity_threshold.value for issue in report.issues) - for report in linter.reports.values() - ) - - return 1 if has_issues_at_threshold else 0 + ) def main(args: list[str] | None = None) -> int: """Main entry point for the CLI.""" - try: - parsed_args = parse_args(args or sys.argv[1:]) - return run_linter(parsed_args) - except KeyboardInterrupt: - return 130 - except Exception: - return 1 + cli = YAMLLinterCLI() + return cli.run(args) if __name__ == "__main__": diff --git a/tools/yaml_lint/linter.py b/tools/yaml_lint/linter.py index 33df952..782d749 100644 --- a/tools/yaml_lint/linter.py +++ b/tools/yaml_lint/linter.py @@ -4,7 +4,7 @@ import re from typing import Any -from tools.yaml_lint.models import FileReport, IssueSeverity, LintIssue +from tools.core.models import FileReport, IssueSeverity, LintIssue # Constants for magic numbers @@ -37,6 +37,41 @@ def __init__(self, config: dict[str, Any] | None = None): self.config = config or self.DEFAULT_CONFIG.copy() self.reports = {} + def check_file(self, file_path: Path) -> FileReport: + """Check a single YAML file for issues. + + This method is an alias for lint_file to maintain API compatibility + with the base linter CLI. + """ + report = self.lint_file(file_path) + self.reports[file_path] = report + return report + + def check_directory( + self, directory: Path, exclude: list[str] | None = None + ) -> dict[Path, FileReport]: + """Check all YAML files in a directory.""" + directory = Path(directory).resolve() + exclude = exclude or [] + + # Find all YAML files + yaml_files = [] + for pattern in ["**/*.yml", "**/*.yaml"]: + yaml_files.extend(directory.glob(pattern)) + + # Filter excluded files + excluded_dirs = {".git", "node_modules", "__pycache__", ".pytest_cache"} + for file_path in yaml_files: + # Skip files in excluded directories + if any(excluded in file_path.parts for excluded in excluded_dirs): + continue + # Skip files matching exclude patterns + if any(file_path.match(pattern) for pattern in exclude): + continue + self.check_file(file_path) + + return self.reports + def lint_file(self, file_path: Path) -> FileReport: """Lint a single YAML file.""" try: diff --git a/tools/yaml_lint/models.py b/tools/yaml_lint/models.py index 1b46ba4..0310672 100644 --- a/tools/yaml_lint/models.py +++ b/tools/yaml_lint/models.py @@ -1,69 +1,9 @@ -"""Data models for the YAML linter.""" +"""Data models for the YAML linter. -from collections.abc import Callable -from dataclasses import dataclass, field -from enum import Enum, auto -from pathlib import Path +This module re-exports the shared lint models from tools.core.models +for backwards compatibility. +""" +from tools.core.models import FileReport, IssueSeverity, LintIssue -class IssueSeverity(Enum): - """Severity levels for linting issues.""" - - ERROR = auto() - WARNING = auto() - STYLE = auto() - - -@dataclass -class LintIssue: - """Represents a single linting issue.""" - - line: int - column: int = 0 - code: str = "" - message: str = "" - severity: IssueSeverity = IssueSeverity.WARNING - fixable: bool = False - fix: Callable[[str], str] | None = None - context: str = "" - - def __str__(self) -> str: - """Return a string representation of the issue.""" - return f"{self.severity.name}:{self.code} - {self.message} (line {self.line}, col {self.column})" - - -@dataclass -class FileReport: - """Collection of lint issues for a single file.""" - - path: Path - issues: list[LintIssue] = field(default_factory=list) - fixed_content: list[str] | None = None - - @property - def has_issues(self) -> bool: - """Return True if there are any issues.""" - return bool(self.issues) - - @property - def has_errors(self) -> bool: - """Return True if there are any error-level issues.""" - return any(issue.severity == IssueSeverity.ERROR for issue in self.issues) - - @property - def has_warnings(self) -> bool: - """Return True if there are any warning-level issues.""" - return any(issue.severity == IssueSeverity.WARNING for issue in self.issues) - - @property - def has_fixable_issues(self) -> bool: - """Return True if there are any fixable issues.""" - return any(issue.fixable for issue in self.issues) - - def add_issue(self, issue: LintIssue) -> None: - """Add an issue to the report.""" - self.issues.append(issue) - - def get_issues_by_severity(self, severity: IssueSeverity) -> list[LintIssue]: - """Get all issues with the given severity.""" - return [issue for issue in self.issues if issue.severity == severity] +__all__ = ["IssueSeverity", "LintIssue", "FileReport"] From 82186f7fd6a48e7a68c686cbe0dddda12de94de7 Mon Sep 17 00:00:00 2001 From: Jurie Smit Date: Wed, 24 Dec 2025 00:40:35 +0200 Subject: [PATCH 2/4] Update scripts/coverage/runner.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- scripts/coverage/runner.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/coverage/runner.py b/scripts/coverage/runner.py index 984d3a6..861de3b 100644 --- a/scripts/coverage/runner.py +++ b/scripts/coverage/runner.py @@ -191,7 +191,9 @@ def measure_coverage(self) -> int: self.print_header("Coverage Measurement") print("Running tests with coverage...") - self.run_tests(generate_html=True, generate_xml=True) + tests_passed = self.run_tests(generate_html=True, generate_xml=True) + if not tests_passed: + print("Warning: Some tests failed. Coverage report may be incomplete.") print() self.print_header("Coverage Summary") From 62c1b6bc386ca6cf7cab9481f578e6a901368084 Mon Sep 17 00:00:00 2001 From: Jurie Smit Date: Wed, 24 Dec 2025 00:41:39 +0200 Subject: [PATCH 3/4] Update tools/core/cli/base.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- tools/core/cli/base.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/core/cli/base.py b/tools/core/cli/base.py index 8b79a68..9449b43 100644 --- a/tools/core/cli/base.py +++ b/tools/core/cli/base.py @@ -231,5 +231,10 @@ def run(self, args: list[str] | None = None) -> int: except KeyboardInterrupt: return 130 - except Exception: + except Exception as e: + if parsed_args.verbose > 0: + import traceback + traceback.print_exc() + else: + print(f"Error: {e}", file=sys.stderr) return 1 From bc06bd8dc931a4fc7e5575b28c66767336637a73 Mon Sep 17 00:00:00 2001 From: "tembo[bot]" <208362400+tembo[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 22:43:57 +0000 Subject: [PATCH 4/4] fix(cli): update path processing to handle non-existent paths and improve yaml linter config merging --- tools/core/cli/base.py | 5 +++++ tools/core/cli/formatters.py | 10 ++++++++-- tools/yaml_lint/cli.py | 21 +++++++++++---------- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/tools/core/cli/base.py b/tools/core/cli/base.py index 9449b43..479aab2 100644 --- a/tools/core/cli/base.py +++ b/tools/core/cli/base.py @@ -149,6 +149,11 @@ def process_paths( for path_str in paths: path = Path(path_str) + if not path.exists(): + if verbose > 0: + print(f"Warning: Path does not exist: {path}", file=sys.stderr) + continue + if path.is_file(): if not self.file_extensions or path.suffix in self.file_extensions: linter.check_file(path) diff --git a/tools/core/cli/formatters.py b/tools/core/cli/formatters.py index caaec8c..af7bacf 100644 --- a/tools/core/cli/formatters.py +++ b/tools/core/cli/formatters.py @@ -25,6 +25,7 @@ def format_issue_text( file_path: Path, use_color: bool = True, verbose: int = 0, + omit_file: bool = False, ) -> str: """Format a single issue for text output. @@ -33,6 +34,8 @@ def format_issue_text( file_path: The path to the file containing the issue use_color: Whether to use ANSI color codes verbose: Verbosity level (0=minimal, 1+=detailed) + omit_file: Whether to omit the file path from the output (useful when + file path is already displayed as a header) Returns: A formatted string representation of the issue @@ -41,7 +44,10 @@ def format_issue_text( reset = RESET if use_color else "" severity_name = issue.severity.name.lower() - location = f"{file_path}:{issue.line}:{issue.column}" + if omit_file: + location = f"{issue.line}:{issue.column}" + else: + location = f"{file_path}:{issue.line}:{issue.column}" if verbose >= 1: # Detailed format with context @@ -117,7 +123,7 @@ def format_report_text( lines.append(f"\n{file_path}:") for issue in sorted(issues, key=lambda x: (x.line, x.column)): - lines.append(f" {format_issue_text(issue, file_path, use_color, verbose)}") + lines.append(f" {format_issue_text(issue, file_path, use_color, verbose, omit_file=True)}") if total_issues > 0 or verbose > 0: lines.append(format_summary(total_issues, total_files)) diff --git a/tools/yaml_lint/cli.py b/tools/yaml_lint/cli.py index 1bfff63..b5f68a9 100644 --- a/tools/yaml_lint/cli.py +++ b/tools/yaml_lint/cli.py @@ -61,16 +61,17 @@ def _add_linting_arguments(self, parser: argparse.ArgumentParser) -> None: def create_linter(self, args: argparse.Namespace) -> YAMLLinter: """Create a YAMLLinter instance with the given configuration.""" - return YAMLLinter( - { - "max_line_length": args.max_line_length, - "indent_size": args.indent_size, - "enforce_document_start": args.enforce_document_start, - "enforce_document_end": args.enforce_document_end, - "check_empty_values": args.check_empty_values, - "check_truthy": args.check_truthy, - } - ) + # Merge provided args with defaults to ensure all config keys are present + config = { + **YAMLLinter.DEFAULT_CONFIG, + "max_line_length": args.max_line_length, + "indent_size": args.indent_size, + "enforce_document_start": args.enforce_document_start, + "enforce_document_end": args.enforce_document_end, + "check_empty_values": args.check_empty_values, + "check_truthy": args.check_truthy, + } + return YAMLLinter(config) def main(args: list[str] | None = None) -> int: