From f2deda3e36399e09c51e1da0faf5fbeb50aa8a9e Mon Sep 17 00:00:00 2001 From: Youcef Kadri Date: Mon, 13 Apr 2026 23:25:45 +1200 Subject: [PATCH 1/7] Add CLI commands and executor for quality rules Completes v0.8.1 feature by adding user-facing CLI interface. CLI Commands: - mapper quality list - Show all available quality rules - mapper quality check - Run all enabled rules - mapper quality type-coverage - Check type hint coverage - mapper quality docstring-coverage - Check docstring coverage - mapper quality param-complexity - Check parameter counts All commands support: - --json / --csv output formats - Exit codes for CI/CD (0 = pass, 1 = fail) - Rich console output with colors and file breakdowns Executor: - QualityExecutor class for running rules against Neo4j - execute() - Run single rule with validation - execute_all() - Run all enabled rules - Config loading from mapper.toml - Error handling for missing/disabled rules Tests: - 17 unit tests for executor and CLI commands - Mock-based tests for isolation - Exit code and format validation Co-Authored-By: Claude Sonnet 4.5 --- src/mapper/cli/__init__.py | 3 +- src/mapper/cli/quality.py | 297 ++++++++++++++++++++++ src/mapper/quality/__init__.py | 4 +- src/mapper/quality/executor.py | 86 +++++++ tests/unit/cli/test_quality.py | 373 ++++++++++++++++++++++++++++ tests/unit/quality/test_executor.py | 193 ++++++++++++++ 6 files changed, 953 insertions(+), 3 deletions(-) create mode 100644 src/mapper/cli/quality.py create mode 100644 src/mapper/quality/executor.py create mode 100644 tests/unit/cli/test_quality.py create mode 100644 tests/unit/quality/test_executor.py diff --git a/src/mapper/cli/__init__.py b/src/mapper/cli/__init__.py index bd28cb8..7a12b55 100644 --- a/src/mapper/cli/__init__.py +++ b/src/mapper/cli/__init__.py @@ -2,7 +2,7 @@ import typer -from mapper.cli import analyse, config, queries, setup, status, version +from mapper.cli import analyse, config, quality, queries, setup, status, version # Main application app = typer.Typer(help="Mapper - Application Mapper for Python code") @@ -15,6 +15,7 @@ # Register command groups app.add_typer(analyse.app, name="analyse") app.add_typer(queries.app, name="query") +app.add_typer(quality.app, name="quality") app.add_typer(config.app, name="config") diff --git a/src/mapper/cli/quality.py b/src/mapper/cli/quality.py new file mode 100644 index 0000000..43e7755 --- /dev/null +++ b/src/mapper/cli/quality.py @@ -0,0 +1,297 @@ +"""Quality check commands for Mapper CLI.""" + +import sys + +import typer +from neo4j.exceptions import DriverError +from rich.console import Console + +from mapper import config_manager, graph +from mapper.quality import config, executor, formatters, registry +from mapper.quality.formatters import OutputFormat + +console = Console() + +app = typer.Typer(help="Run code quality checks") + + +@app.command(name="list") +def list_rules() -> None: + """List all available quality rules. + + Shows quality rules with their descriptions and default thresholds. + """ + reg = registry.get_registry() + rules = reg.list_all() + + console.print(f"\n[bold]Available Quality Rules[/bold] ({len(rules)} total)\n") + console.print(f"{'Rule':<25} {'Description'}") + console.print(f"{'-' * 25} {'-' * 50}") + + for rule in rules: + console.print(f"[cyan]{rule.name:<25}[/cyan] {rule.description}") + + console.print() + console.print("[dim]Use 'mapper quality ' to run a specific rule[/dim]") + console.print("[dim]Use 'mapper quality check ' to run all enabled rules[/dim]") + console.print() + + +@app.command() +def check( + package: str, + format_type: OutputFormat = typer.Option( + OutputFormat.CONSOLE, "--format", help="Output format: console, json, csv" + ), + json_flag: bool = typer.Option( + False, "--json", help="Output as JSON (shorthand for --format json)" + ), + csv_flag: bool = typer.Option( + False, "--csv", help="Output as CSV (shorthand for --format csv)" + ), + config_path: str | None = typer.Option( + None, "--config", help="Path to mapper.toml config file" + ), +) -> None: + """Run all enabled quality checks on a package. + + Runs all quality rules that are enabled in configuration. + Exit code 0 if all checks pass, 1 if any check fails. + + Example: + mapper quality check mypackage + mapper quality check mypackage --json + """ + # Resolve format (flags override --format option) + output_format = ( + OutputFormat.JSON if json_flag else OutputFormat.CSV if csv_flag else format_type + ) + + try: + # Get Neo4j credentials and config + user, password = config_manager.get_neo4j_credentials() + db_config = config_manager.load_config() + + # Create connection + connection = graph.Neo4jConnection( + uri=db_config.neo4j.uri, + user=user, + password=password, + database=db_config.neo4j.database, + ) + + # Test connection + success, message = connection.test_connection() + if not success: + console.print(f"[red]Neo4j connection failed:[/red] {message}") + console.print("\nEnsure Neo4j is running and credentials are correct.") + console.print("Run 'mapper status' to check configuration.") + raise typer.Exit(code=1) + + # Load quality configuration + quality_config = config.load_quality_config(config_path) + + # Execute all enabled rules + exec = executor.QualityExecutor(connection) + results = exec.execute_all(package, quality_config) + + # Format and output + formatter = formatters.get_formatter(output_format) + output = formatter.format_results(results) + + # Print output + match output_format: + case OutputFormat.CONSOLE: + # Rich console with colors + console.print(output, end="") + case OutputFormat.JSON: + # JSON - write to stdout with newline + sys.stdout.write(output) + sys.stdout.write("\n") + case OutputFormat.CSV: + # CSV - write to stdout + sys.stdout.write(output) + + # Cleanup + connection.close() + + # Exit with appropriate code + all_passed = all(result.status == "pass" for result in results) + raise typer.Exit(code=0 if all_passed else 1) + + except ValueError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(code=1) from None + except (FileNotFoundError, OSError, DriverError) as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(code=1) from e + + +@app.command(name="type-coverage") +def type_coverage_command( + package: str, + format_type: OutputFormat = typer.Option( + OutputFormat.CONSOLE, "--format", help="Output format: console, json, csv" + ), + json_flag: bool = typer.Option( + False, "--json", help="Output as JSON (shorthand for --format json)" + ), + csv_flag: bool = typer.Option( + False, "--csv", help="Output as CSV (shorthand for --format csv)" + ), + config_path: str | None = typer.Option( + None, "--config", help="Path to mapper.toml config file" + ), +) -> None: + """Check type hint coverage on public functions. + + Verifies that public functions have type hints for all parameters. + Exit code 0 if check passes, 1 if fails. + + Example: + mapper quality type-coverage mypackage + mapper quality type-coverage mypackage --json + """ + _run_single_rule("type-coverage", package, format_type, json_flag, csv_flag, config_path) + + +@app.command(name="docstring-coverage") +def docstring_coverage_command( + package: str, + format_type: OutputFormat = typer.Option( + OutputFormat.CONSOLE, "--format", help="Output format: console, json, csv" + ), + json_flag: bool = typer.Option( + False, "--json", help="Output as JSON (shorthand for --format json)" + ), + csv_flag: bool = typer.Option( + False, "--csv", help="Output as CSV (shorthand for --format csv)" + ), + config_path: str | None = typer.Option( + None, "--config", help="Path to mapper.toml config file" + ), +) -> None: + """Check docstring coverage on public functions. + + Verifies that public functions have docstrings. + Exit code 0 if check passes, 1 if fails. + + Example: + mapper quality docstring-coverage mypackage + mapper quality docstring-coverage mypackage --json + """ + _run_single_rule( + "docstring-coverage", package, format_type, json_flag, csv_flag, config_path + ) + + +@app.command(name="param-complexity") +def param_complexity_command( + package: str, + format_type: OutputFormat = typer.Option( + OutputFormat.CONSOLE, "--format", help="Output format: console, json, csv" + ), + json_flag: bool = typer.Option( + False, "--json", help="Output as JSON (shorthand for --format json)" + ), + csv_flag: bool = typer.Option( + False, "--csv", help="Output as CSV (shorthand for --format csv)" + ), + config_path: str | None = typer.Option( + None, "--config", help="Path to mapper.toml config file" + ), +) -> None: + """Check parameter count complexity on functions. + + Verifies that functions don't exceed the maximum parameter count. + Exit code 0 if check passes, 1 if fails. + + Example: + mapper quality param-complexity mypackage + mapper quality param-complexity mypackage --json + """ + _run_single_rule( + "param-complexity", package, format_type, json_flag, csv_flag, config_path + ) + + +def _run_single_rule( + rule_name: str, + package: str, + format_type: OutputFormat, + json_flag: bool, + csv_flag: bool, + config_path: str | None, +) -> None: + """Run a single quality rule (internal helper). + + Args: + rule_name: Name of the rule to run + package: Package name to check + format_type: Output format type + json_flag: JSON flag + csv_flag: CSV flag + config_path: Path to config file + """ + # Resolve format (flags override --format option) + output_format = ( + OutputFormat.JSON if json_flag else OutputFormat.CSV if csv_flag else format_type + ) + + try: + # Get Neo4j credentials and config + user, password = config_manager.get_neo4j_credentials() + db_config = config_manager.load_config() + + # Create connection + connection = graph.Neo4jConnection( + uri=db_config.neo4j.uri, + user=user, + password=password, + database=db_config.neo4j.database, + ) + + # Test connection + success, message = connection.test_connection() + if not success: + console.print(f"[red]Neo4j connection failed:[/red] {message}") + console.print("\nEnsure Neo4j is running and credentials are correct.") + console.print("Run 'mapper status' to check configuration.") + raise typer.Exit(code=1) + + # Load quality configuration + quality_config = config.load_quality_config(config_path) + + # Execute rule + exec = executor.QualityExecutor(connection) + result = exec.execute(rule_name, package, quality_config) + + # Format and output + formatter = formatters.get_formatter(output_format) + output = formatter.format_results([result]) + + # Print output + match output_format: + case OutputFormat.CONSOLE: + # Rich console with colors + console.print(output, end="") + case OutputFormat.JSON: + # JSON - write to stdout with newline + sys.stdout.write(output) + sys.stdout.write("\n") + case OutputFormat.CSV: + # CSV - write to stdout + sys.stdout.write(output) + + # Cleanup + connection.close() + + # Exit with appropriate code + raise typer.Exit(code=0 if result.status == "pass" else 1) + + except ValueError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(code=1) from None + except (FileNotFoundError, OSError, DriverError) as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(code=1) from e diff --git a/src/mapper/quality/__init__.py b/src/mapper/quality/__init__.py index 68de3fc..583c089 100644 --- a/src/mapper/quality/__init__.py +++ b/src/mapper/quality/__init__.py @@ -7,6 +7,6 @@ CI/CD integration with exit codes (0 = pass, 1 = fail). """ -from mapper.quality import config, models, registry +from mapper.quality import config, executor, formatters, models, registry -__all__ = ["config", "models", "registry"] +__all__ = ["config", "executor", "formatters", "models", "registry"] diff --git a/src/mapper/quality/executor.py b/src/mapper/quality/executor.py new file mode 100644 index 0000000..7cac549 --- /dev/null +++ b/src/mapper/quality/executor.py @@ -0,0 +1,86 @@ +"""Quality rule executor for running quality checks.""" + +from mapper import graph +from mapper.quality import config, models, registry + + +class QualityExecutor: + """Executes quality rules against a package in Neo4j.""" + + def __init__(self, connection: graph.Neo4jConnection): + """Initialize executor with Neo4j connection. + + Args: + connection: Neo4j database connection + """ + self.connection = connection + self.registry = registry.get_registry() + + def execute( + self, rule_name: str, package: str, quality_config: models.QualityConfig | None = None + ) -> models.CoverageQualityResult | models.ComplexityQualityResult: + """Execute a single quality rule. + + Args: + rule_name: Name of the rule to execute (e.g., 'type-coverage') + package: Package name to check + quality_config: Quality configuration (loads from file if None) + + Returns: + Quality result for the rule + + Raises: + ValueError: If rule not found or disabled + """ + # Load config if not provided + if quality_config is None: + quality_config = config.load_quality_config() + + # Get rule from registry + rule = self.registry.get(rule_name) + if rule is None: + available = ", ".join(self.registry.get_rule_names()) + raise ValueError( + f"Quality rule '{rule_name}' not found. Available rules: {available}" + ) + + # Check if rule is enabled + if not rule.is_enabled(quality_config): + raise ValueError(f"Quality rule '{rule_name}' is disabled in configuration") + + # Execute rule + return rule.run(self.connection, package) + + def execute_all( + self, package: str, quality_config: models.QualityConfig | None = None + ) -> list[models.CoverageQualityResult | models.ComplexityQualityResult]: + """Execute all enabled quality rules. + + Args: + package: Package name to check + quality_config: Quality configuration (loads from file if None) + + Returns: + List of quality results for all enabled rules + + Raises: + ValueError: If no rules are enabled + """ + # Load config if not provided + if quality_config is None: + quality_config = config.load_quality_config() + + # Get all rules and filter enabled + all_rules = self.registry.list_all() + enabled_rules = [rule for rule in all_rules if rule.is_enabled(quality_config)] + + if not enabled_rules: + raise ValueError("No quality rules are enabled in configuration") + + # Execute all enabled rules + results = [] + for rule in enabled_rules: + result = rule.run(self.connection, package) + results.append(result) + + return results diff --git a/tests/unit/cli/test_quality.py b/tests/unit/cli/test_quality.py new file mode 100644 index 0000000..4cd78ae --- /dev/null +++ b/tests/unit/cli/test_quality.py @@ -0,0 +1,373 @@ +"""Tests for quality CLI commands.""" + +from unittest import mock + +import pytest +import typer +from typer.testing import CliRunner + +from mapper.cli.quality import app +from mapper.quality import models + +runner = CliRunner() + + +@pytest.fixture +def mock_connection(): + """Create mock Neo4j connection.""" + conn = mock.MagicMock() + conn.test_connection.return_value = (True, "Connected") + return conn + + +@pytest.fixture +def sample_passing_result(): + """Sample passing quality result.""" + return models.CoverageQualityResult( + rule="type-coverage", + threshold=80, + actual=85.0, + overall=models.OverallResult(total=20, compliant=17, percentage=85.0), + by_file=[], + ) + + +@pytest.fixture +def sample_failing_result(): + """Sample failing quality result.""" + return models.CoverageQualityResult( + rule="type-coverage", + threshold=80, + actual=75.0, + overall=models.OverallResult(total=20, compliant=15, percentage=75.0), + by_file=[], + ) + + +class TestQualityList: + """Test 'mapper quality list' command.""" + + @mock.patch("mapper.cli.quality.registry.get_registry") + def test_list_shows_all_rules(self, mock_get_registry): + """Should list all available quality rules.""" + # Mock registry + mock_registry = mock.MagicMock() + mock_rule1 = mock.MagicMock() + mock_rule1.name = "type-coverage" + mock_rule1.description = "Check type hint coverage" + + mock_rule2 = mock.MagicMock() + mock_rule2.name = "docstring-coverage" + mock_rule2.description = "Check docstring coverage" + + mock_registry.list_all.return_value = [mock_rule1, mock_rule2] + mock_get_registry.return_value = mock_registry + + result = runner.invoke(app, ["list"]) + + assert result.exit_code == 0 + assert "type-coverage" in result.stdout + assert "docstring-coverage" in result.stdout + assert "Check type hint coverage" in result.stdout + assert "Check docstring coverage" in result.stdout + + +class TestQualityCheck: + """Test 'mapper quality check' command.""" + + @mock.patch("mapper.cli.quality.graph.Neo4jConnection") + @mock.patch("mapper.cli.quality.config_manager.load_config") + @mock.patch("mapper.cli.quality.config_manager.get_neo4j_credentials") + @mock.patch("mapper.cli.quality.config.load_quality_config") + @mock.patch("mapper.cli.quality.executor.QualityExecutor") + @mock.patch("mapper.cli.quality.formatters.get_formatter") + def test_check_all_passing( + self, + mock_get_formatter, + mock_executor_class, + mock_load_quality, + mock_get_creds, + mock_load_config, + mock_connection_class, + mock_connection, + sample_passing_result, + ): + """Should exit 0 when all checks pass.""" + # Setup mocks + mock_get_creds.return_value = ("user", "password") + mock_config = mock.MagicMock() + mock_config.neo4j.uri = "bolt://localhost:7687" + mock_config.neo4j.database = "neo4j" + mock_load_config.return_value = mock_config + + mock_connection_class.return_value = mock_connection + + mock_quality_config = models.QualityConfig() + mock_load_quality.return_value = mock_quality_config + + mock_executor = mock.MagicMock() + mock_executor.execute_all.return_value = [sample_passing_result] + mock_executor_class.return_value = mock_executor + + mock_formatter = mock.MagicMock() + mock_formatter.format_results.return_value = "✓ All checks passed" + mock_get_formatter.return_value = mock_formatter + + result = runner.invoke(app, ["check", "testpackage"]) + + assert result.exit_code == 0 + mock_executor.execute_all.assert_called_once_with("testpackage", mock_quality_config) + + @mock.patch("mapper.cli.quality.graph.Neo4jConnection") + @mock.patch("mapper.cli.quality.config_manager.load_config") + @mock.patch("mapper.cli.quality.config_manager.get_neo4j_credentials") + @mock.patch("mapper.cli.quality.config.load_quality_config") + @mock.patch("mapper.cli.quality.executor.QualityExecutor") + @mock.patch("mapper.cli.quality.formatters.get_formatter") + def test_check_some_failing( + self, + mock_get_formatter, + mock_executor_class, + mock_load_quality, + mock_get_creds, + mock_load_config, + mock_connection_class, + mock_connection, + sample_failing_result, + ): + """Should exit 1 when any check fails.""" + # Setup mocks + mock_get_creds.return_value = ("user", "password") + mock_config = mock.MagicMock() + mock_config.neo4j.uri = "bolt://localhost:7687" + mock_config.neo4j.database = "neo4j" + mock_load_config.return_value = mock_config + + mock_connection_class.return_value = mock_connection + + mock_quality_config = models.QualityConfig() + mock_load_quality.return_value = mock_quality_config + + mock_executor = mock.MagicMock() + mock_executor.execute_all.return_value = [sample_failing_result] + mock_executor_class.return_value = mock_executor + + mock_formatter = mock.MagicMock() + mock_formatter.format_results.return_value = "✗ Some checks failed" + mock_get_formatter.return_value = mock_formatter + + result = runner.invoke(app, ["check", "testpackage"]) + + assert result.exit_code == 1 + + @mock.patch("mapper.cli.quality.graph.Neo4jConnection") + @mock.patch("mapper.cli.quality.config_manager.load_config") + @mock.patch("mapper.cli.quality.config_manager.get_neo4j_credentials") + def test_check_connection_failed( + self, mock_get_creds, mock_load_config, mock_connection_class + ): + """Should exit 1 when Neo4j connection fails.""" + mock_get_creds.return_value = ("user", "password") + mock_config = mock.MagicMock() + mock_config.neo4j.uri = "bolt://localhost:7687" + mock_config.neo4j.database = "neo4j" + mock_load_config.return_value = mock_config + + mock_connection = mock.MagicMock() + mock_connection.test_connection.return_value = (False, "Connection refused") + mock_connection_class.return_value = mock_connection + + result = runner.invoke(app, ["check", "testpackage"]) + + assert result.exit_code == 1 + assert "Connection refused" in result.stdout + + @mock.patch("mapper.cli.quality.graph.Neo4jConnection") + @mock.patch("mapper.cli.quality.config_manager.load_config") + @mock.patch("mapper.cli.quality.config_manager.get_neo4j_credentials") + @mock.patch("mapper.cli.quality.config.load_quality_config") + @mock.patch("mapper.cli.quality.executor.QualityExecutor") + def test_check_value_error( + self, + mock_executor_class, + mock_load_quality, + mock_get_creds, + mock_load_config, + mock_connection_class, + mock_connection, + ): + """Should exit 1 on ValueError.""" + mock_get_creds.return_value = ("user", "password") + mock_config = mock.MagicMock() + mock_config.neo4j.uri = "bolt://localhost:7687" + mock_config.neo4j.database = "neo4j" + mock_load_config.return_value = mock_config + + mock_connection_class.return_value = mock_connection + + mock_quality_config = models.QualityConfig() + mock_load_quality.return_value = mock_quality_config + + mock_executor = mock.MagicMock() + mock_executor.execute_all.side_effect = ValueError("No rules enabled") + mock_executor_class.return_value = mock_executor + + result = runner.invoke(app, ["check", "testpackage"]) + + assert result.exit_code == 1 + assert "No rules enabled" in result.stdout + + +class TestQualitySingleRule: + """Test single rule commands.""" + + @mock.patch("mapper.cli.quality.graph.Neo4jConnection") + @mock.patch("mapper.cli.quality.config_manager.load_config") + @mock.patch("mapper.cli.quality.config_manager.get_neo4j_credentials") + @mock.patch("mapper.cli.quality.config.load_quality_config") + @mock.patch("mapper.cli.quality.executor.QualityExecutor") + @mock.patch("mapper.cli.quality.formatters.get_formatter") + def test_type_coverage_passing( + self, + mock_get_formatter, + mock_executor_class, + mock_load_quality, + mock_get_creds, + mock_load_config, + mock_connection_class, + mock_connection, + sample_passing_result, + ): + """Should exit 0 when type-coverage passes.""" + # Setup mocks + mock_get_creds.return_value = ("user", "password") + mock_config = mock.MagicMock() + mock_config.neo4j.uri = "bolt://localhost:7687" + mock_config.neo4j.database = "neo4j" + mock_load_config.return_value = mock_config + + mock_connection_class.return_value = mock_connection + + mock_quality_config = models.QualityConfig() + mock_load_quality.return_value = mock_quality_config + + mock_executor = mock.MagicMock() + mock_executor.execute.return_value = sample_passing_result + mock_executor_class.return_value = mock_executor + + mock_formatter = mock.MagicMock() + mock_formatter.format_results.return_value = "✓ Type coverage passed" + mock_get_formatter.return_value = mock_formatter + + result = runner.invoke(app, ["type-coverage", "testpackage"]) + + assert result.exit_code == 0 + mock_executor.execute.assert_called_once_with( + "type-coverage", "testpackage", mock_quality_config + ) + + @mock.patch("mapper.cli.quality.graph.Neo4jConnection") + @mock.patch("mapper.cli.quality.config_manager.load_config") + @mock.patch("mapper.cli.quality.config_manager.get_neo4j_credentials") + @mock.patch("mapper.cli.quality.config.load_quality_config") + @mock.patch("mapper.cli.quality.executor.QualityExecutor") + @mock.patch("mapper.cli.quality.formatters.get_formatter") + def test_docstring_coverage_failing( + self, + mock_get_formatter, + mock_executor_class, + mock_load_quality, + mock_get_creds, + mock_load_config, + mock_connection_class, + mock_connection, + sample_failing_result, + ): + """Should exit 1 when docstring-coverage fails.""" + # Setup mocks + mock_get_creds.return_value = ("user", "password") + mock_config = mock.MagicMock() + mock_config.neo4j.uri = "bolt://localhost:7687" + mock_config.neo4j.database = "neo4j" + mock_load_config.return_value = mock_config + + mock_connection_class.return_value = mock_connection + + mock_quality_config = models.QualityConfig() + mock_load_quality.return_value = mock_quality_config + + # Make result have rule name matching docstring-coverage + failing_result = models.CoverageQualityResult( + rule="docstring-coverage", + threshold=90, + actual=75.0, + overall=models.OverallResult(total=20, compliant=15, percentage=75.0), + by_file=[], + ) + + mock_executor = mock.MagicMock() + mock_executor.execute.return_value = failing_result + mock_executor_class.return_value = mock_executor + + mock_formatter = mock.MagicMock() + mock_formatter.format_results.return_value = "✗ Docstring coverage failed" + mock_get_formatter.return_value = mock_formatter + + result = runner.invoke(app, ["docstring-coverage", "testpackage"]) + + assert result.exit_code == 1 + mock_executor.execute.assert_called_once_with( + "docstring-coverage", "testpackage", mock_quality_config + ) + + @mock.patch("mapper.cli.quality.graph.Neo4jConnection") + @mock.patch("mapper.cli.quality.config_manager.load_config") + @mock.patch("mapper.cli.quality.config_manager.get_neo4j_credentials") + @mock.patch("mapper.cli.quality.config.load_quality_config") + @mock.patch("mapper.cli.quality.executor.QualityExecutor") + @mock.patch("mapper.cli.quality.formatters.get_formatter") + def test_param_complexity_with_json( + self, + mock_get_formatter, + mock_executor_class, + mock_load_quality, + mock_get_creds, + mock_load_config, + mock_connection_class, + mock_connection, + ): + """Should output JSON when --json flag provided.""" + # Setup mocks + mock_get_creds.return_value = ("user", "password") + mock_config = mock.MagicMock() + mock_config.neo4j.uri = "bolt://localhost:7687" + mock_config.neo4j.database = "neo4j" + mock_load_config.return_value = mock_config + + mock_connection_class.return_value = mock_connection + + mock_quality_config = models.QualityConfig() + mock_load_quality.return_value = mock_quality_config + + passing_result = models.ComplexityQualityResult( + rule="param-complexity", + threshold=5, + total_violations=0, + by_file=[], + ) + + mock_executor = mock.MagicMock() + mock_executor.execute.return_value = passing_result + mock_executor_class.return_value = mock_executor + + mock_formatter = mock.MagicMock() + mock_formatter.format_results.return_value = '{"rule": "param-complexity"}' + mock_get_formatter.return_value = mock_formatter + + result = runner.invoke(app, ["param-complexity", "testpackage", "--json"]) + + assert result.exit_code == 0 + # Should call get_formatter with JSON format + from mapper.quality.formatters import OutputFormat + + mock_get_formatter.assert_called_once_with(OutputFormat.JSON) diff --git a/tests/unit/quality/test_executor.py b/tests/unit/quality/test_executor.py new file mode 100644 index 0000000..f7b8073 --- /dev/null +++ b/tests/unit/quality/test_executor.py @@ -0,0 +1,193 @@ +"""Tests for quality rule executor.""" + +from unittest import mock + +import pytest + +from mapper.quality import executor, models + + +class TestQualityExecutor: + """Test QualityExecutor class.""" + + @pytest.fixture + def mock_connection(self): + """Create mock Neo4j connection.""" + return mock.MagicMock() + + @pytest.fixture + def mock_config(self): + """Create mock quality config.""" + return models.QualityConfig( + type_coverage=models.TypeCoverageConfig(enabled=True, min_coverage=80), + docstring_coverage=models.DocstringCoverageConfig(enabled=True, min_coverage=90), + param_complexity=models.ParamComplexityConfig(enabled=True, max_parameters=5), + ) + + @pytest.fixture + def sample_coverage_result(self): + """Sample coverage result.""" + return models.CoverageQualityResult( + rule="type-coverage", + threshold=80, + actual=85.0, + overall=models.OverallResult(total=20, compliant=17, percentage=85.0), + by_file=[], + ) + + @pytest.fixture + def sample_complexity_result(self): + """Sample complexity result.""" + return models.ComplexityQualityResult( + rule="param-complexity", + threshold=5, + total_violations=0, + by_file=[], + ) + + def test_init(self, mock_connection): + """Should initialize with connection.""" + exec = executor.QualityExecutor(mock_connection) + assert exec.connection == mock_connection + assert exec.registry is not None + + def test_execute_success(self, mock_connection, mock_config, sample_coverage_result): + """Should execute single rule successfully.""" + exec = executor.QualityExecutor(mock_connection) + + # Mock the rule's run method + with mock.patch.object(exec.registry, "get") as mock_get: + mock_rule = mock.MagicMock() + mock_rule.is_enabled.return_value = True + mock_rule.run.return_value = sample_coverage_result + mock_get.return_value = mock_rule + + result = exec.execute("type-coverage", "testpackage", mock_config) + + assert result == sample_coverage_result + mock_rule.is_enabled.assert_called_once_with(mock_config) + mock_rule.run.assert_called_once_with(mock_connection, "testpackage") + + def test_execute_rule_not_found(self, mock_connection, mock_config): + """Should raise ValueError if rule not found.""" + exec = executor.QualityExecutor(mock_connection) + + with mock.patch.object(exec.registry, "get") as mock_get: + mock_get.return_value = None + + with pytest.raises(ValueError, match="not found"): + exec.execute("nonexistent-rule", "testpackage", mock_config) + + def test_execute_rule_disabled(self, mock_connection, mock_config): + """Should raise ValueError if rule is disabled.""" + exec = executor.QualityExecutor(mock_connection) + + # Mock the rule's is_enabled to return False + with mock.patch.object(exec.registry, "get") as mock_get: + mock_rule = mock.MagicMock() + mock_rule.is_enabled.return_value = False + mock_get.return_value = mock_rule + + with pytest.raises(ValueError, match="disabled"): + exec.execute("type-coverage", "testpackage", mock_config) + + def test_execute_loads_config_if_none(self, mock_connection, sample_coverage_result): + """Should load config from file if not provided.""" + exec = executor.QualityExecutor(mock_connection) + + with mock.patch("mapper.quality.executor.config.load_quality_config") as mock_load: + with mock.patch.object(exec.registry, "get") as mock_get: + mock_config = models.QualityConfig() + mock_load.return_value = mock_config + + mock_rule = mock.MagicMock() + mock_rule.is_enabled.return_value = True + mock_rule.run.return_value = sample_coverage_result + mock_get.return_value = mock_rule + + result = exec.execute("type-coverage", "testpackage", None) + + mock_load.assert_called_once_with() + assert result == sample_coverage_result + + def test_execute_all_success( + self, mock_connection, mock_config, sample_coverage_result, sample_complexity_result + ): + """Should execute all enabled rules.""" + exec = executor.QualityExecutor(mock_connection) + + # Create mock rules + mock_rule1 = mock.MagicMock() + mock_rule1.is_enabled.return_value = True + mock_rule1.run.return_value = sample_coverage_result + + mock_rule2 = mock.MagicMock() + mock_rule2.is_enabled.return_value = True + mock_rule2.run.return_value = sample_complexity_result + + with mock.patch.object(exec.registry, "list_all") as mock_list: + mock_list.return_value = [mock_rule1, mock_rule2] + + results = exec.execute_all("testpackage", mock_config) + + assert len(results) == 2 + assert results[0] == sample_coverage_result + assert results[1] == sample_complexity_result + + def test_execute_all_filters_disabled( + self, mock_connection, mock_config, sample_coverage_result + ): + """Should filter out disabled rules.""" + exec = executor.QualityExecutor(mock_connection) + + # Create mock rules (one enabled, one disabled) + mock_rule1 = mock.MagicMock() + mock_rule1.is_enabled.return_value = True + mock_rule1.run.return_value = sample_coverage_result + + mock_rule2 = mock.MagicMock() + mock_rule2.is_enabled.return_value = False + + with mock.patch.object(exec.registry, "list_all") as mock_list: + mock_list.return_value = [mock_rule1, mock_rule2] + + results = exec.execute_all("testpackage", mock_config) + + assert len(results) == 1 + assert results[0] == sample_coverage_result + mock_rule2.run.assert_not_called() + + def test_execute_all_no_enabled_rules(self, mock_connection, mock_config): + """Should raise ValueError if no rules are enabled.""" + exec = executor.QualityExecutor(mock_connection) + + # Create mock disabled rule + mock_rule = mock.MagicMock() + mock_rule.is_enabled.return_value = False + + with mock.patch.object(exec.registry, "list_all") as mock_list: + mock_list.return_value = [mock_rule] + + with pytest.raises(ValueError, match="No quality rules are enabled"): + exec.execute_all("testpackage", mock_config) + + def test_execute_all_loads_config_if_none( + self, mock_connection, sample_coverage_result + ): + """Should load config from file if not provided.""" + exec = executor.QualityExecutor(mock_connection) + + with mock.patch("mapper.quality.executor.config.load_quality_config") as mock_load: + with mock.patch.object(exec.registry, "list_all") as mock_list: + mock_config = models.QualityConfig() + mock_load.return_value = mock_config + + mock_rule = mock.MagicMock() + mock_rule.is_enabled.return_value = True + mock_rule.run.return_value = sample_coverage_result + mock_list.return_value = [mock_rule] + + results = exec.execute_all("testpackage", None) + + mock_load.assert_called_once_with() + assert len(results) == 1 From 7a57aff8a54fa6a4416a78c4ef9067c9a2d98cfd Mon Sep 17 00:00:00 2001 From: Youcef Kadri Date: Mon, 13 Apr 2026 23:25:57 +1200 Subject: [PATCH 2/7] Fix quality rules to work with current Neo4j schema Updated queries to use existing graph structure instead of non-existent properties. Schema Changes: - Get file_path from Module.path via DEFINES relationships - Count parameters via HAS_PARAMETER relationships (not array property) - Defer line numbers to v0.8.4 (GitHub issue #87) Rules Updated: - type_coverage: Query Parameter nodes for has_type_hint - docstring_coverage: Use Module.path for file grouping - param_complexity: Count via HAS_PARAMETER, line set to null Test Results: - All 19 rule unit tests passing - Manual test against datalake/utils package: 3 rules executed - Type coverage: 100% (32/32 functions) - PASS - Docstring coverage: 50% (16/32 functions) - FAIL - Param complexity: 1 violation (8 params) - FAIL - JSON/CSV output validated - Exit codes working correctly Co-Authored-By: Claude Sonnet 4.5 --- src/mapper/quality/rules/docstring_coverage.py | 8 ++++---- src/mapper/quality/rules/param_complexity.py | 16 ++++++++++------ src/mapper/quality/rules/type_coverage.py | 17 +++++++++-------- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/mapper/quality/rules/docstring_coverage.py b/src/mapper/quality/rules/docstring_coverage.py index 3b33779..40d5f78 100644 --- a/src/mapper/quality/rules/docstring_coverage.py +++ b/src/mapper/quality/rules/docstring_coverage.py @@ -39,17 +39,17 @@ def run( # Build Cypher query to find functions with/without docstrings query = """ - MATCH (f:Function {package: $package}) + MATCH (m:Module)-[:DEFINES]->(f:Function {package: $package}) WHERE f.is_public = true // Check if function has docstring - WITH f, + WITH m, f, CASE WHEN f.docstring IS NOT NULL AND f.docstring <> "" THEN true ELSE false END as has_docstring - // Aggregate by file - RETURN f.file_path as file_path, + // Aggregate by file (using Module.path) + RETURN m.path as file_path, count(*) as total, sum(CASE WHEN has_docstring THEN 1 ELSE 0 END) as compliant, collect(CASE WHEN NOT has_docstring THEN f.name ELSE null END) as violations diff --git a/src/mapper/quality/rules/param_complexity.py b/src/mapper/quality/rules/param_complexity.py index b8b2498..f23d8e5 100644 --- a/src/mapper/quality/rules/param_complexity.py +++ b/src/mapper/quality/rules/param_complexity.py @@ -39,16 +39,20 @@ def run( # Build Cypher query to find functions exceeding parameter threshold query = """ - MATCH (f:Function {package: $package}) + MATCH (m:Module)-[:DEFINES]->(f:Function {package: $package}) WHERE f.is_public = true - AND size(f.parameters) > $max_parameters - // Return violations grouped by file - RETURN f.file_path as file_path, + // Count parameters via HAS_PARAMETER relationships + OPTIONAL MATCH (f)-[:HAS_PARAMETER]->(p:Parameter) + WITH m, f, count(p) as param_count + WHERE param_count > $max_parameters + + // Return violations grouped by file (using Module.path) + RETURN m.path as file_path, collect({ function: f.name, - line: f.start_line, - param_count: size(f.parameters) + line: null, + param_count: param_count }) as violations ORDER BY file_path """ diff --git a/src/mapper/quality/rules/type_coverage.py b/src/mapper/quality/rules/type_coverage.py index 61902b4..b12a2d1 100644 --- a/src/mapper/quality/rules/type_coverage.py +++ b/src/mapper/quality/rules/type_coverage.py @@ -39,24 +39,25 @@ def run( # Build Cypher query to find functions with/without type hints query = """ - MATCH (f:Function {package: $package}) + MATCH (m:Module)-[:DEFINES]->(f:Function {package: $package}) WHERE f.is_public = true - // Count parameters with type hints - WITH f, - size([p IN f.parameters WHERE p.has_type_hint = true]) as typed_params, - size(f.parameters) as total_params + // Count parameters with type hints via HAS_PARAMETER relationships + OPTIONAL MATCH (f)-[:HAS_PARAMETER]->(p:Parameter) + WITH m, f, + count(p) as total_params, + sum(CASE WHEN p.has_type_hint = true THEN 1 ELSE 0 END) as typed_params // Function has type coverage if all params have type hints // (or has no parameters) - WITH f, + WITH m, f, CASE WHEN total_params = 0 THEN true WHEN typed_params = total_params THEN true ELSE false END as has_type_coverage - // Aggregate by file - RETURN f.file_path as file_path, + // Aggregate by file (using Module.path) + RETURN m.path as file_path, count(*) as total, sum(CASE WHEN has_type_coverage THEN 1 ELSE 0 END) as compliant, collect(CASE WHEN NOT has_type_coverage THEN f.name ELSE null END) as violations From 5d5eadc00e579265b5091deb0de0fb38f4ecc5dc Mon Sep 17 00:00:00 2001 From: Youcef Kadri Date: Mon, 13 Apr 2026 23:28:11 +1200 Subject: [PATCH 3/7] Fix linting: remove unused import and format code Co-Authored-By: Claude Sonnet 4.5 --- src/mapper/cli/quality.py | 8 ++------ src/mapper/quality/executor.py | 4 +--- tests/unit/cli/test_quality.py | 5 +---- tests/unit/quality/test_executor.py | 4 +--- 4 files changed, 5 insertions(+), 16 deletions(-) diff --git a/src/mapper/cli/quality.py b/src/mapper/cli/quality.py index 43e7755..394c9cb 100644 --- a/src/mapper/cli/quality.py +++ b/src/mapper/cli/quality.py @@ -180,9 +180,7 @@ def docstring_coverage_command( mapper quality docstring-coverage mypackage mapper quality docstring-coverage mypackage --json """ - _run_single_rule( - "docstring-coverage", package, format_type, json_flag, csv_flag, config_path - ) + _run_single_rule("docstring-coverage", package, format_type, json_flag, csv_flag, config_path) @app.command(name="param-complexity") @@ -210,9 +208,7 @@ def param_complexity_command( mapper quality param-complexity mypackage mapper quality param-complexity mypackage --json """ - _run_single_rule( - "param-complexity", package, format_type, json_flag, csv_flag, config_path - ) + _run_single_rule("param-complexity", package, format_type, json_flag, csv_flag, config_path) def _run_single_rule( diff --git a/src/mapper/quality/executor.py b/src/mapper/quality/executor.py index 7cac549..d2bdbdd 100644 --- a/src/mapper/quality/executor.py +++ b/src/mapper/quality/executor.py @@ -40,9 +40,7 @@ def execute( rule = self.registry.get(rule_name) if rule is None: available = ", ".join(self.registry.get_rule_names()) - raise ValueError( - f"Quality rule '{rule_name}' not found. Available rules: {available}" - ) + raise ValueError(f"Quality rule '{rule_name}' not found. Available rules: {available}") # Check if rule is enabled if not rule.is_enabled(quality_config): diff --git a/tests/unit/cli/test_quality.py b/tests/unit/cli/test_quality.py index 4cd78ae..7e3df26 100644 --- a/tests/unit/cli/test_quality.py +++ b/tests/unit/cli/test_quality.py @@ -3,7 +3,6 @@ from unittest import mock import pytest -import typer from typer.testing import CliRunner from mapper.cli.quality import app @@ -163,9 +162,7 @@ def test_check_some_failing( @mock.patch("mapper.cli.quality.graph.Neo4jConnection") @mock.patch("mapper.cli.quality.config_manager.load_config") @mock.patch("mapper.cli.quality.config_manager.get_neo4j_credentials") - def test_check_connection_failed( - self, mock_get_creds, mock_load_config, mock_connection_class - ): + def test_check_connection_failed(self, mock_get_creds, mock_load_config, mock_connection_class): """Should exit 1 when Neo4j connection fails.""" mock_get_creds.return_value = ("user", "password") mock_config = mock.MagicMock() diff --git a/tests/unit/quality/test_executor.py b/tests/unit/quality/test_executor.py index f7b8073..207a74a 100644 --- a/tests/unit/quality/test_executor.py +++ b/tests/unit/quality/test_executor.py @@ -171,9 +171,7 @@ def test_execute_all_no_enabled_rules(self, mock_connection, mock_config): with pytest.raises(ValueError, match="No quality rules are enabled"): exec.execute_all("testpackage", mock_config) - def test_execute_all_loads_config_if_none( - self, mock_connection, sample_coverage_result - ): + def test_execute_all_loads_config_if_none(self, mock_connection, sample_coverage_result): """Should load config from file if not provided.""" exec = executor.QualityExecutor(mock_connection) From 95614740959d5acd91fea1c98668a50a7e38eaaf Mon Sep 17 00:00:00 2001 From: Youcef Kadri Date: Wed, 15 Apr 2026 23:36:25 +1200 Subject: [PATCH 4/7] Rename test file to avoid pytest collection conflict Renamed tests/unit/quality/test_executor.py to test_quality_executor.py to avoid conflict with tests/unit/query_system/test_executor.py. Python's import system requires unique module names across the test suite. All 290 tests passing with 81% coverage. Co-Authored-By: Claude Sonnet 4.5 --- tests/unit/quality/{test_executor.py => test_quality_executor.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/unit/quality/{test_executor.py => test_quality_executor.py} (100%) diff --git a/tests/unit/quality/test_executor.py b/tests/unit/quality/test_quality_executor.py similarity index 100% rename from tests/unit/quality/test_executor.py rename to tests/unit/quality/test_quality_executor.py From 6233bfb595102d54dba13d7dff87d0ae09f944f3 Mon Sep 17 00:00:00 2001 From: Youcef Kadri Date: Wed, 15 Apr 2026 23:36:59 +1200 Subject: [PATCH 5/7] Update CHANGELOG for v0.8.2 release Co-Authored-By: Claude Sonnet 4.5 --- CHANGELOG.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b0f040..3afad46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.2] - 2026-04-14 + +### Added +- **Quality rules CLI commands** - Complete user-facing interface for code quality checks + - `mapper quality list` - List all available quality rules with descriptions + - `mapper quality check ` - Run all enabled quality rules + - `mapper quality type-coverage ` - Check type hint coverage + - `mapper quality docstring-coverage ` - Check docstring coverage + - `mapper quality param-complexity ` - Check parameter counts + - All commands support `--json` and `--csv` flags for CI/CD integration + - Exit codes: 0 for pass, 1 for fail (enables blocking in CI pipelines) + - Rich console output with colors, file-level breakdowns, and violation details +- **Quality rule executor** - `QualityExecutor` class for running rules against Neo4j + - `execute(rule_name, package, config)` - Run single rule with validation + - `execute_all(package, config)` - Run all enabled rules + - Automatic config loading from `mapper.toml` + - Error handling for missing/disabled rules and connection failures +- **Comprehensive unit tests** - 17 new tests for CLI and executor + - 9 executor tests: single/all rule execution, config loading, validation + - 8 CLI tests: all commands, exit codes, output formats, error handling + - All tests use mocking for fast, isolated validation + +### Fixed +- **Quality rule Neo4j schema compatibility** - Updated queries to work with current graph structure + - File paths now retrieved from `Module.path` via DEFINES relationships (not Function.file_path) + - Parameter counts calculated via HAS_PARAMETER relationships (not array property) + - Line numbers deferred to v0.8.4 (tracked in GitHub issue #87) + - All three rules (type-coverage, docstring-coverage, param-complexity) working correctly + - Manual test against real codebase (32 functions): type coverage 100%, docstring coverage 50%, param complexity 1 violation + +### Changed +- Test file renamed from `test_executor.py` to `test_quality_executor.py` to avoid pytest collection conflict with query system executor tests + +### Documentation +- Added v0.8.4 roadmap entry for line number storage (GitHub issue #87) +- Line numbers will enable "function_name (line 45): 8 parameters" output format + +### Test Coverage +- Total test count: 290 unit tests (up from 256) +- Coverage: 81% (exceeds 75% threshold) +- All quality rules validated with both unit and manual integration testing + +### Notes +- v0.8.2 completes the quality rules feature that shipped incomplete in v0.8.1 +- v0.8.1 shipped foundation (models, queries, formatters) but CLI was missing +- Users can now use quality rules via `mapper quality` commands + ## [0.8.1] - 2026-04-13 ### Added From af07aa888d1452e6a903e973eadc98149c087a89 Mon Sep 17 00:00:00 2001 From: Youcef Kadri Date: Wed, 15 Apr 2026 23:37:04 +1200 Subject: [PATCH 6/7] =?UTF-8?q?Bump=20version:=200.8.1=20=E2=86=92=200.8.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 4 ++-- README.md | 2 +- pyproject.toml | 4 ++-- src/mapper/__init__.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c2aa3fb..e9cd731 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -422,5 +422,5 @@ Review these documents to understand patterns and best practices: --- -**Last Updated**: 2026-04-13 -**Current Version**: 0.8.1 +**Last Updated**: 2026-04-14 +**Current Version**: 0.8.2 diff --git a/README.md b/README.md index b6e96b2..4ca2c4a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Mapper (Application Mapper) -![Version](https://img.shields.io/badge/version-0.8.1-blue.svg) +![Version](https://img.shields.io/badge/version-0.8.2-blue.svg) ![Tests](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/ydkadri/9501806ed5eac873dd324bc606c6dd79/raw/mapper-tests.json&cacheSeconds=300) ![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/ydkadri/9501806ed5eac873dd324bc606c6dd79/raw/mapper-coverage.json&cacheSeconds=300) ![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg) diff --git a/pyproject.toml b/pyproject.toml index 440faee..449584e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mapper" -version = "0.8.1" +version = "0.8.2" description = "Mapper (Application Mapper) - AST-based Python code analyzer with Neo4j graph storage" readme = "README.md" requires-python = ">=3.10" @@ -93,7 +93,7 @@ addopts = [ testpaths = ["tests"] [tool.bumpversion] -current_version = "0.8.1" +current_version = "0.8.2" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)" serialize = ["{major}.{minor}.{patch}"] search = "{current_version}" diff --git a/src/mapper/__init__.py b/src/mapper/__init__.py index 7b2ffaa..9e5ea71 100644 --- a/src/mapper/__init__.py +++ b/src/mapper/__init__.py @@ -3,7 +3,7 @@ # Public modules for programmatic access from mapper import analyser, graph, graph_loader -__version__ = "0.8.1" +__version__ = "0.8.2" __all__ = [ # Version From 96e20e57d0213113a1a438c075463b72a1a5e487 Mon Sep 17 00:00:00 2001 From: Youcef Kadri Date: Mon, 20 Apr 2026 21:55:56 +1200 Subject: [PATCH 7/7] Refactor quality CLI to match queries API pattern Changed from multiple commands to single 'run' command pattern: - OLD: mapper quality check - OLD: mapper quality type-coverage - NEW: mapper quality run all --package - NEW: mapper quality run type-coverage --package Changes: - Simplified CLI to 'list' and 'run' commands only - Support 'all' (case insensitive) to run all enabled checks - Extracted execution logic to _quality_helpers.py module - Updated tests to match new API (7 tests, all passing) - Created GitHub issue #89 for query CLI helpers refactoring Benefits: - Consistent with 'mapper query run' API pattern - Cleaner separation of CLI commands vs execution logic - Easier to test (helpers can be unit tested in isolation) - More intuitive --package flag (required option like queries) All 289 unit tests passing, linting clean. Co-Authored-By: Claude Sonnet 4.5 --- src/mapper/cli/_quality_helpers.py | 98 ++++++++ src/mapper/cli/quality.py | 257 ++------------------- tests/unit/cli/test_quality.py | 352 ++++------------------------- 3 files changed, 161 insertions(+), 546 deletions(-) create mode 100644 src/mapper/cli/_quality_helpers.py diff --git a/src/mapper/cli/_quality_helpers.py b/src/mapper/cli/_quality_helpers.py new file mode 100644 index 0000000..1d01351 --- /dev/null +++ b/src/mapper/cli/_quality_helpers.py @@ -0,0 +1,98 @@ +"""Internal helper functions for quality CLI commands.""" + +import sys + +import typer +from neo4j.exceptions import DriverError +from rich.console import Console + +from mapper import config_manager, graph +from mapper.quality import config, executor, formatters +from mapper.quality.formatters import OutputFormat + +console = Console() + + +def run_quality_checks( + check_name: str, + package: str, + output_format: OutputFormat, + config_path: str | None = None, +) -> int: + """Execute quality check(s) and return exit code. + + Args: + check_name: Name of check to run, or "all" for all checks + package: Package name to check + output_format: Output format (console, JSON, CSV) + config_path: Optional path to config file + + Returns: + Exit code (0 for pass, 1 for fail) + + Raises: + typer.Exit: On errors + """ + try: + # Get Neo4j credentials and config + user, password = config_manager.get_neo4j_credentials() + db_config = config_manager.load_config() + + # Create connection + connection = graph.Neo4jConnection( + uri=db_config.neo4j.uri, + user=user, + password=password, + database=db_config.neo4j.database, + ) + + # Test connection + success, message = connection.test_connection() + if not success: + console.print(f"[red]Neo4j connection failed:[/red] {message}") + console.print("\nEnsure Neo4j is running and credentials are correct.") + console.print("Run 'mapper status' to check configuration.") + raise typer.Exit(code=1) + + # Load quality configuration + quality_config = config.load_quality_config(config_path) + + # Execute check(s) + exec = executor.QualityExecutor(connection) + + if check_name.lower() == "all": + results = exec.execute_all(package, quality_config) + else: + result = exec.execute(check_name, package, quality_config) + results = [result] + + # Format and output + formatter = formatters.get_formatter(output_format) + output = formatter.format_results(results) + + # Print output + match output_format: + case OutputFormat.CONSOLE: + # Rich console with colors + console.print(output, end="") + case OutputFormat.JSON: + # JSON - write to stdout with newline + sys.stdout.write(output) + sys.stdout.write("\n") + case OutputFormat.CSV: + # CSV - write to stdout + sys.stdout.write(output) + + # Cleanup + connection.close() + + # Return exit code + all_passed = all(result.status == "pass" for result in results) + return 0 if all_passed else 1 + + except ValueError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(code=1) from None + except (FileNotFoundError, OSError, DriverError) as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(code=1) from e diff --git a/src/mapper/cli/quality.py b/src/mapper/cli/quality.py index 394c9cb..5d808e6 100644 --- a/src/mapper/cli/quality.py +++ b/src/mapper/cli/quality.py @@ -1,13 +1,10 @@ """Quality check commands for Mapper CLI.""" -import sys - import typer -from neo4j.exceptions import DriverError from rich.console import Console -from mapper import config_manager, graph -from mapper.quality import config, executor, formatters, registry +from mapper.cli import _quality_helpers +from mapper.quality import registry from mapper.quality.formatters import OutputFormat console = Console() @@ -32,14 +29,17 @@ def list_rules() -> None: console.print(f"[cyan]{rule.name:<25}[/cyan] {rule.description}") console.print() - console.print("[dim]Use 'mapper quality ' to run a specific rule[/dim]") - console.print("[dim]Use 'mapper quality check ' to run all enabled rules[/dim]") + console.print("[dim]Use 'mapper quality run --package ' to run a check[/dim]") + console.print( + "[dim]Use 'mapper quality run all --package ' to run all enabled checks[/dim]" + ) console.print() -@app.command() -def check( - package: str, +@app.command(name="run") +def run( + check: str = typer.Argument(..., help="Quality check to run (or 'all' for all enabled checks)"), + package: str = typer.Option(..., help="Package name to check"), format_type: OutputFormat = typer.Option( OutputFormat.CONSOLE, "--format", help="Output format: console, json, csv" ), @@ -53,241 +53,20 @@ def check( None, "--config", help="Path to mapper.toml config file" ), ) -> None: - """Run all enabled quality checks on a package. + """Run a quality check against an analyzed package. - Runs all quality rules that are enabled in configuration. Exit code 0 if all checks pass, 1 if any check fails. - Example: - mapper quality check mypackage - mapper quality check mypackage --json - """ - # Resolve format (flags override --format option) - output_format = ( - OutputFormat.JSON if json_flag else OutputFormat.CSV if csv_flag else format_type - ) - - try: - # Get Neo4j credentials and config - user, password = config_manager.get_neo4j_credentials() - db_config = config_manager.load_config() - - # Create connection - connection = graph.Neo4jConnection( - uri=db_config.neo4j.uri, - user=user, - password=password, - database=db_config.neo4j.database, - ) - - # Test connection - success, message = connection.test_connection() - if not success: - console.print(f"[red]Neo4j connection failed:[/red] {message}") - console.print("\nEnsure Neo4j is running and credentials are correct.") - console.print("Run 'mapper status' to check configuration.") - raise typer.Exit(code=1) - - # Load quality configuration - quality_config = config.load_quality_config(config_path) - - # Execute all enabled rules - exec = executor.QualityExecutor(connection) - results = exec.execute_all(package, quality_config) - - # Format and output - formatter = formatters.get_formatter(output_format) - output = formatter.format_results(results) - - # Print output - match output_format: - case OutputFormat.CONSOLE: - # Rich console with colors - console.print(output, end="") - case OutputFormat.JSON: - # JSON - write to stdout with newline - sys.stdout.write(output) - sys.stdout.write("\n") - case OutputFormat.CSV: - # CSV - write to stdout - sys.stdout.write(output) - - # Cleanup - connection.close() - - # Exit with appropriate code - all_passed = all(result.status == "pass" for result in results) - raise typer.Exit(code=0 if all_passed else 1) - - except ValueError as e: - console.print(f"[red]Error:[/red] {e}") - raise typer.Exit(code=1) from None - except (FileNotFoundError, OSError, DriverError) as e: - console.print(f"[red]Error:[/red] {e}") - raise typer.Exit(code=1) from e - - -@app.command(name="type-coverage") -def type_coverage_command( - package: str, - format_type: OutputFormat = typer.Option( - OutputFormat.CONSOLE, "--format", help="Output format: console, json, csv" - ), - json_flag: bool = typer.Option( - False, "--json", help="Output as JSON (shorthand for --format json)" - ), - csv_flag: bool = typer.Option( - False, "--csv", help="Output as CSV (shorthand for --format csv)" - ), - config_path: str | None = typer.Option( - None, "--config", help="Path to mapper.toml config file" - ), -) -> None: - """Check type hint coverage on public functions. - - Verifies that public functions have type hints for all parameters. - Exit code 0 if check passes, 1 if fails. - - Example: - mapper quality type-coverage mypackage - mapper quality type-coverage mypackage --json - """ - _run_single_rule("type-coverage", package, format_type, json_flag, csv_flag, config_path) - - -@app.command(name="docstring-coverage") -def docstring_coverage_command( - package: str, - format_type: OutputFormat = typer.Option( - OutputFormat.CONSOLE, "--format", help="Output format: console, json, csv" - ), - json_flag: bool = typer.Option( - False, "--json", help="Output as JSON (shorthand for --format json)" - ), - csv_flag: bool = typer.Option( - False, "--csv", help="Output as CSV (shorthand for --format csv)" - ), - config_path: str | None = typer.Option( - None, "--config", help="Path to mapper.toml config file" - ), -) -> None: - """Check docstring coverage on public functions. - - Verifies that public functions have docstrings. - Exit code 0 if check passes, 1 if fails. - - Example: - mapper quality docstring-coverage mypackage - mapper quality docstring-coverage mypackage --json - """ - _run_single_rule("docstring-coverage", package, format_type, json_flag, csv_flag, config_path) - - -@app.command(name="param-complexity") -def param_complexity_command( - package: str, - format_type: OutputFormat = typer.Option( - OutputFormat.CONSOLE, "--format", help="Output format: console, json, csv" - ), - json_flag: bool = typer.Option( - False, "--json", help="Output as JSON (shorthand for --format json)" - ), - csv_flag: bool = typer.Option( - False, "--csv", help="Output as CSV (shorthand for --format csv)" - ), - config_path: str | None = typer.Option( - None, "--config", help="Path to mapper.toml config file" - ), -) -> None: - """Check parameter count complexity on functions. - - Verifies that functions don't exceed the maximum parameter count. - Exit code 0 if check passes, 1 if fails. - - Example: - mapper quality param-complexity mypackage - mapper quality param-complexity mypackage --json - """ - _run_single_rule("param-complexity", package, format_type, json_flag, csv_flag, config_path) - - -def _run_single_rule( - rule_name: str, - package: str, - format_type: OutputFormat, - json_flag: bool, - csv_flag: bool, - config_path: str | None, -) -> None: - """Run a single quality rule (internal helper). - - Args: - rule_name: Name of the rule to run - package: Package name to check - format_type: Output format type - json_flag: JSON flag - csv_flag: CSV flag - config_path: Path to config file + Examples: + mapper quality run type-coverage --package mypackage + mapper quality run all --package mypackage + mapper quality run docstring-coverage --package mypackage --json """ # Resolve format (flags override --format option) output_format = ( OutputFormat.JSON if json_flag else OutputFormat.CSV if csv_flag else format_type ) - try: - # Get Neo4j credentials and config - user, password = config_manager.get_neo4j_credentials() - db_config = config_manager.load_config() - - # Create connection - connection = graph.Neo4jConnection( - uri=db_config.neo4j.uri, - user=user, - password=password, - database=db_config.neo4j.database, - ) - - # Test connection - success, message = connection.test_connection() - if not success: - console.print(f"[red]Neo4j connection failed:[/red] {message}") - console.print("\nEnsure Neo4j is running and credentials are correct.") - console.print("Run 'mapper status' to check configuration.") - raise typer.Exit(code=1) - - # Load quality configuration - quality_config = config.load_quality_config(config_path) - - # Execute rule - exec = executor.QualityExecutor(connection) - result = exec.execute(rule_name, package, quality_config) - - # Format and output - formatter = formatters.get_formatter(output_format) - output = formatter.format_results([result]) - - # Print output - match output_format: - case OutputFormat.CONSOLE: - # Rich console with colors - console.print(output, end="") - case OutputFormat.JSON: - # JSON - write to stdout with newline - sys.stdout.write(output) - sys.stdout.write("\n") - case OutputFormat.CSV: - # CSV - write to stdout - sys.stdout.write(output) - - # Cleanup - connection.close() - - # Exit with appropriate code - raise typer.Exit(code=0 if result.status == "pass" else 1) - - except ValueError as e: - console.print(f"[red]Error:[/red] {e}") - raise typer.Exit(code=1) from None - except (FileNotFoundError, OSError, DriverError) as e: - console.print(f"[red]Error:[/red] {e}") - raise typer.Exit(code=1) from e + # Execute checks and exit with appropriate code + exit_code = _quality_helpers.run_quality_checks(check, package, output_format, config_path) + raise typer.Exit(code=exit_code) diff --git a/tests/unit/cli/test_quality.py b/tests/unit/cli/test_quality.py index 7e3df26..f7c4b75 100644 --- a/tests/unit/cli/test_quality.py +++ b/tests/unit/cli/test_quality.py @@ -2,47 +2,13 @@ from unittest import mock -import pytest from typer.testing import CliRunner from mapper.cli.quality import app -from mapper.quality import models runner = CliRunner() -@pytest.fixture -def mock_connection(): - """Create mock Neo4j connection.""" - conn = mock.MagicMock() - conn.test_connection.return_value = (True, "Connected") - return conn - - -@pytest.fixture -def sample_passing_result(): - """Sample passing quality result.""" - return models.CoverageQualityResult( - rule="type-coverage", - threshold=80, - actual=85.0, - overall=models.OverallResult(total=20, compliant=17, percentage=85.0), - by_file=[], - ) - - -@pytest.fixture -def sample_failing_result(): - """Sample failing quality result.""" - return models.CoverageQualityResult( - rule="type-coverage", - threshold=80, - actual=75.0, - overall=models.OverallResult(total=20, compliant=15, percentage=75.0), - by_file=[], - ) - - class TestQualityList: """Test 'mapper quality list' command.""" @@ -71,300 +37,72 @@ def test_list_shows_all_rules(self, mock_get_registry): assert "Check docstring coverage" in result.stdout -class TestQualityCheck: - """Test 'mapper quality check' command.""" - - @mock.patch("mapper.cli.quality.graph.Neo4jConnection") - @mock.patch("mapper.cli.quality.config_manager.load_config") - @mock.patch("mapper.cli.quality.config_manager.get_neo4j_credentials") - @mock.patch("mapper.cli.quality.config.load_quality_config") - @mock.patch("mapper.cli.quality.executor.QualityExecutor") - @mock.patch("mapper.cli.quality.formatters.get_formatter") - def test_check_all_passing( - self, - mock_get_formatter, - mock_executor_class, - mock_load_quality, - mock_get_creds, - mock_load_config, - mock_connection_class, - mock_connection, - sample_passing_result, - ): - """Should exit 0 when all checks pass.""" - # Setup mocks - mock_get_creds.return_value = ("user", "password") - mock_config = mock.MagicMock() - mock_config.neo4j.uri = "bolt://localhost:7687" - mock_config.neo4j.database = "neo4j" - mock_load_config.return_value = mock_config - - mock_connection_class.return_value = mock_connection - - mock_quality_config = models.QualityConfig() - mock_load_quality.return_value = mock_quality_config - - mock_executor = mock.MagicMock() - mock_executor.execute_all.return_value = [sample_passing_result] - mock_executor_class.return_value = mock_executor +class TestQualityRun: + """Test 'mapper quality run' command.""" - mock_formatter = mock.MagicMock() - mock_formatter.format_results.return_value = "✓ All checks passed" - mock_get_formatter.return_value = mock_formatter + @mock.patch("mapper.cli._quality_helpers.run_quality_checks") + def test_run_all_passing(self, mock_run_checks): + """Should exit 0 when all checks pass.""" + mock_run_checks.return_value = 0 - result = runner.invoke(app, ["check", "testpackage"]) + result = runner.invoke(app, ["run", "all", "--package", "testpackage"]) assert result.exit_code == 0 - mock_executor.execute_all.assert_called_once_with("testpackage", mock_quality_config) - - @mock.patch("mapper.cli.quality.graph.Neo4jConnection") - @mock.patch("mapper.cli.quality.config_manager.load_config") - @mock.patch("mapper.cli.quality.config_manager.get_neo4j_credentials") - @mock.patch("mapper.cli.quality.config.load_quality_config") - @mock.patch("mapper.cli.quality.executor.QualityExecutor") - @mock.patch("mapper.cli.quality.formatters.get_formatter") - def test_check_some_failing( - self, - mock_get_formatter, - mock_executor_class, - mock_load_quality, - mock_get_creds, - mock_load_config, - mock_connection_class, - mock_connection, - sample_failing_result, - ): - """Should exit 1 when any check fails.""" - # Setup mocks - mock_get_creds.return_value = ("user", "password") - mock_config = mock.MagicMock() - mock_config.neo4j.uri = "bolt://localhost:7687" - mock_config.neo4j.database = "neo4j" - mock_load_config.return_value = mock_config + mock_run_checks.assert_called_once_with("all", "testpackage", mock.ANY, None) - mock_connection_class.return_value = mock_connection - - mock_quality_config = models.QualityConfig() - mock_load_quality.return_value = mock_quality_config - - mock_executor = mock.MagicMock() - mock_executor.execute_all.return_value = [sample_failing_result] - mock_executor_class.return_value = mock_executor - - mock_formatter = mock.MagicMock() - mock_formatter.format_results.return_value = "✗ Some checks failed" - mock_get_formatter.return_value = mock_formatter + @mock.patch("mapper.cli._quality_helpers.run_quality_checks") + def test_run_all_failing(self, mock_run_checks): + """Should exit 1 when any check fails.""" + mock_run_checks.return_value = 1 - result = runner.invoke(app, ["check", "testpackage"]) + result = runner.invoke(app, ["run", "all", "--package", "testpackage"]) assert result.exit_code == 1 - @mock.patch("mapper.cli.quality.graph.Neo4jConnection") - @mock.patch("mapper.cli.quality.config_manager.load_config") - @mock.patch("mapper.cli.quality.config_manager.get_neo4j_credentials") - def test_check_connection_failed(self, mock_get_creds, mock_load_config, mock_connection_class): - """Should exit 1 when Neo4j connection fails.""" - mock_get_creds.return_value = ("user", "password") - mock_config = mock.MagicMock() - mock_config.neo4j.uri = "bolt://localhost:7687" - mock_config.neo4j.database = "neo4j" - mock_load_config.return_value = mock_config + @mock.patch("mapper.cli._quality_helpers.run_quality_checks") + def test_run_single_rule(self, mock_run_checks): + """Should run single rule.""" + mock_run_checks.return_value = 0 - mock_connection = mock.MagicMock() - mock_connection.test_connection.return_value = (False, "Connection refused") - mock_connection_class.return_value = mock_connection - - result = runner.invoke(app, ["check", "testpackage"]) - - assert result.exit_code == 1 - assert "Connection refused" in result.stdout - - @mock.patch("mapper.cli.quality.graph.Neo4jConnection") - @mock.patch("mapper.cli.quality.config_manager.load_config") - @mock.patch("mapper.cli.quality.config_manager.get_neo4j_credentials") - @mock.patch("mapper.cli.quality.config.load_quality_config") - @mock.patch("mapper.cli.quality.executor.QualityExecutor") - def test_check_value_error( - self, - mock_executor_class, - mock_load_quality, - mock_get_creds, - mock_load_config, - mock_connection_class, - mock_connection, - ): - """Should exit 1 on ValueError.""" - mock_get_creds.return_value = ("user", "password") - mock_config = mock.MagicMock() - mock_config.neo4j.uri = "bolt://localhost:7687" - mock_config.neo4j.database = "neo4j" - mock_load_config.return_value = mock_config - - mock_connection_class.return_value = mock_connection - - mock_quality_config = models.QualityConfig() - mock_load_quality.return_value = mock_quality_config - - mock_executor = mock.MagicMock() - mock_executor.execute_all.side_effect = ValueError("No rules enabled") - mock_executor_class.return_value = mock_executor - - result = runner.invoke(app, ["check", "testpackage"]) - - assert result.exit_code == 1 - assert "No rules enabled" in result.stdout - - -class TestQualitySingleRule: - """Test single rule commands.""" - - @mock.patch("mapper.cli.quality.graph.Neo4jConnection") - @mock.patch("mapper.cli.quality.config_manager.load_config") - @mock.patch("mapper.cli.quality.config_manager.get_neo4j_credentials") - @mock.patch("mapper.cli.quality.config.load_quality_config") - @mock.patch("mapper.cli.quality.executor.QualityExecutor") - @mock.patch("mapper.cli.quality.formatters.get_formatter") - def test_type_coverage_passing( - self, - mock_get_formatter, - mock_executor_class, - mock_load_quality, - mock_get_creds, - mock_load_config, - mock_connection_class, - mock_connection, - sample_passing_result, - ): - """Should exit 0 when type-coverage passes.""" - # Setup mocks - mock_get_creds.return_value = ("user", "password") - mock_config = mock.MagicMock() - mock_config.neo4j.uri = "bolt://localhost:7687" - mock_config.neo4j.database = "neo4j" - mock_load_config.return_value = mock_config - - mock_connection_class.return_value = mock_connection - - mock_quality_config = models.QualityConfig() - mock_load_quality.return_value = mock_quality_config - - mock_executor = mock.MagicMock() - mock_executor.execute.return_value = sample_passing_result - mock_executor_class.return_value = mock_executor - - mock_formatter = mock.MagicMock() - mock_formatter.format_results.return_value = "✓ Type coverage passed" - mock_get_formatter.return_value = mock_formatter - - result = runner.invoke(app, ["type-coverage", "testpackage"]) + result = runner.invoke(app, ["run", "type-coverage", "--package", "testpackage"]) assert result.exit_code == 0 - mock_executor.execute.assert_called_once_with( - "type-coverage", "testpackage", mock_quality_config - ) + mock_run_checks.assert_called_once_with("type-coverage", "testpackage", mock.ANY, None) - @mock.patch("mapper.cli.quality.graph.Neo4jConnection") - @mock.patch("mapper.cli.quality.config_manager.load_config") - @mock.patch("mapper.cli.quality.config_manager.get_neo4j_credentials") - @mock.patch("mapper.cli.quality.config.load_quality_config") - @mock.patch("mapper.cli.quality.executor.QualityExecutor") - @mock.patch("mapper.cli.quality.formatters.get_formatter") - def test_docstring_coverage_failing( - self, - mock_get_formatter, - mock_executor_class, - mock_load_quality, - mock_get_creds, - mock_load_config, - mock_connection_class, - mock_connection, - sample_failing_result, - ): - """Should exit 1 when docstring-coverage fails.""" - # Setup mocks - mock_get_creds.return_value = ("user", "password") - mock_config = mock.MagicMock() - mock_config.neo4j.uri = "bolt://localhost:7687" - mock_config.neo4j.database = "neo4j" - mock_load_config.return_value = mock_config - - mock_connection_class.return_value = mock_connection - - mock_quality_config = models.QualityConfig() - mock_load_quality.return_value = mock_quality_config - - # Make result have rule name matching docstring-coverage - failing_result = models.CoverageQualityResult( - rule="docstring-coverage", - threshold=90, - actual=75.0, - overall=models.OverallResult(total=20, compliant=15, percentage=75.0), - by_file=[], - ) + @mock.patch("mapper.cli._quality_helpers.run_quality_checks") + def test_run_with_json_flag(self, mock_run_checks): + """Should pass JSON format to helper.""" + from mapper.quality.formatters import OutputFormat - mock_executor = mock.MagicMock() - mock_executor.execute.return_value = failing_result - mock_executor_class.return_value = mock_executor + mock_run_checks.return_value = 0 - mock_formatter = mock.MagicMock() - mock_formatter.format_results.return_value = "✗ Docstring coverage failed" - mock_get_formatter.return_value = mock_formatter + result = runner.invoke(app, ["run", "type-coverage", "--package", "testpackage", "--json"]) - result = runner.invoke(app, ["docstring-coverage", "testpackage"]) + assert result.exit_code == 0 + # Check that OutputFormat.JSON was passed + call_args = mock_run_checks.call_args + assert call_args[0][2] == OutputFormat.JSON - assert result.exit_code == 1 - mock_executor.execute.assert_called_once_with( - "docstring-coverage", "testpackage", mock_quality_config - ) + @mock.patch("mapper.cli._quality_helpers.run_quality_checks") + def test_run_with_config_path(self, mock_run_checks): + """Should pass config path to helper.""" + mock_run_checks.return_value = 0 - @mock.patch("mapper.cli.quality.graph.Neo4jConnection") - @mock.patch("mapper.cli.quality.config_manager.load_config") - @mock.patch("mapper.cli.quality.config_manager.get_neo4j_credentials") - @mock.patch("mapper.cli.quality.config.load_quality_config") - @mock.patch("mapper.cli.quality.executor.QualityExecutor") - @mock.patch("mapper.cli.quality.formatters.get_formatter") - def test_param_complexity_with_json( - self, - mock_get_formatter, - mock_executor_class, - mock_load_quality, - mock_get_creds, - mock_load_config, - mock_connection_class, - mock_connection, - ): - """Should output JSON when --json flag provided.""" - # Setup mocks - mock_get_creds.return_value = ("user", "password") - mock_config = mock.MagicMock() - mock_config.neo4j.uri = "bolt://localhost:7687" - mock_config.neo4j.database = "neo4j" - mock_load_config.return_value = mock_config - - mock_connection_class.return_value = mock_connection - - mock_quality_config = models.QualityConfig() - mock_load_quality.return_value = mock_quality_config - - passing_result = models.ComplexityQualityResult( - rule="param-complexity", - threshold=5, - total_violations=0, - by_file=[], + result = runner.invoke( + app, ["run", "all", "--package", "testpackage", "--config", "/path/to/config.toml"] ) - mock_executor = mock.MagicMock() - mock_executor.execute.return_value = passing_result - mock_executor_class.return_value = mock_executor + assert result.exit_code == 0 + mock_run_checks.assert_called_once_with( + "all", "testpackage", mock.ANY, "/path/to/config.toml" + ) - mock_formatter = mock.MagicMock() - mock_formatter.format_results.return_value = '{"rule": "param-complexity"}' - mock_get_formatter.return_value = mock_formatter + @mock.patch("mapper.cli._quality_helpers.run_quality_checks") + def test_run_case_insensitive_all(self, mock_run_checks): + """Should accept 'ALL' (case insensitive).""" + mock_run_checks.return_value = 0 - result = runner.invoke(app, ["param-complexity", "testpackage", "--json"]) + result = runner.invoke(app, ["run", "ALL", "--package", "testpackage"]) assert result.exit_code == 0 - # Should call get_formatter with JSON format - from mapper.quality.formatters import OutputFormat - - mock_get_formatter.assert_called_once_with(OutputFormat.JSON) + mock_run_checks.assert_called_once_with("ALL", "testpackage", mock.ANY, None)