diff --git a/CHANGELOG.md b/CHANGELOG.md index 86c2f8a..9b0f040 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.1] - 2026-04-13 + +### Added +- **Built-in quality rules** - Three quality checks for enforcing code standards + - `type-coverage` - Enforce type hint coverage on public functions (default: 80% minimum) + - `docstring-coverage` - Enforce docstring coverage on public functions (default: 90% minimum) + - `param-complexity` - Enforce parameter count limits (default: 5 parameters maximum) + - Pass/fail semantics with exit codes for CI/CD integration + - Configurable thresholds and exclusion patterns via `mapper.toml` +- **Quality check output formats** - Console, JSON, and CSV formatters + - Console: Human-readable with Rich markup (colors, check marks ✓/✗) + - JSON: Structured output for CI/CD integration and automation + - CSV: Spreadsheet-compatible format for tracking quality over time +- **Quality configuration system** - TOML-based configuration with validation + - `[quality.type-coverage]` section for type hint settings + - `[quality.docstring-coverage]` section for docstring settings + - `[quality.param-complexity]` section for parameter limit settings + - Per-rule enable/disable flags and exclusion patterns +- **User journey and interface documentation** - Complete quality rules documentation + - User journey: CLI usage, configuration examples, CI/CD integration + - Interface design: Data models, query patterns, formatter specifications + +### Changed +- Quality rule names use hyphens (consistent with query naming: `find-dead-code`) +- Quality rules use `description` field (consistent with Query base class) + ## [0.8.0] - 2026-04-05 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 18fb8d7..c2aa3fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -422,5 +422,5 @@ Review these documents to understand patterns and best practices: --- -**Last Updated**: 2026-04-04 -**Current Version**: 0.8.0 +**Last Updated**: 2026-04-13 +**Current Version**: 0.8.1 diff --git a/README.md b/README.md index 51c543a..b6e96b2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Mapper (Application Mapper) -![Version](https://img.shields.io/badge/version-0.8.0-blue.svg) +![Version](https://img.shields.io/badge/version-0.8.1-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) @@ -18,6 +18,7 @@ Mapper helps you understand complex Python applications by analysing their Abstr - **Incremental Updates**: Track versions and update only what changed - **CLI Tool**: Powerful command-line interface built with Typer - **Risk Detection Queries**: Built-in queries to find dead code, module centrality, and critical functions +- **Quality Rules**: Enforce type coverage, docstring coverage, and parameter complexity standards - **Package-Wide Analysis**: Analyse entire Python packages from a directory ### Use Cases @@ -77,6 +78,11 @@ mapper query list # List available queries mapper query run find-dead-code mypackage # Find unused code mapper query run analyze-module-centrality mypackage # Find central modules +# Run quality checks (CI/CD integration) +mapper quality type-coverage mypackage # Check type hint coverage +mapper quality docstring-coverage mypackage # Check docstring coverage +mapper quality check mypackage --json # Run all quality checks + # View detailed analysis in Neo4j Browser # Navigate to http://localhost:7474 and run Cypher queries ``` diff --git a/docs/contributing/code-architecture.md b/docs/contributing/code-architecture.md index 9b8971a..b5561fc 100644 --- a/docs/contributing/code-architecture.md +++ b/docs/contributing/code-architecture.md @@ -79,12 +79,25 @@ from mapper.ast_parser import models __all__ = ["ASTExtractor", "Node", "Edge", "Graph", "models"] ``` +```python +# mapper/ast_parser/extractor.py (regular module - NO __all__) + +class ASTExtractor: + """Public class - no underscore prefix.""" + ... + +def _validate_ast(tree): + """Private helper - underscore prefix.""" + ... +``` + **Principles:** - Only expose classes/functions needed by external consumers - If only one class from a submodule is needed, import just that class - If multiple things are useful, import them to sit at top level - If a submodule is a useful reference, import the whole module - Everything in `__all__` should be intentionally public +- Regular modules use underscore prefixes for private items, NOT `__all__` **Separate models from logic:** - Models/dataclasses in `models.py` diff --git a/docs/interface/quality-rules.md b/docs/interface/quality-rules.md new file mode 100644 index 0000000..c065083 --- /dev/null +++ b/docs/interface/quality-rules.md @@ -0,0 +1,693 @@ +# Interface Design: Quality Rules + +**Version**: 0.8.1 +**Status**: Design +**Related**: [User Journey: Running Quality Rules](../user-journeys/10-quality-rules.md) + +--- + +## Overview + +Quality rules provide built-in pass/fail checks for code quality standards. This interface defines the CLI commands, configuration schema, output formats, and quality rule specifications. + +**Key Design Principles**: +- **Separate entrypoint**: `mapper quality` is distinct from `mapper query` due to different semantics +- **Pass/fail semantics**: Rules return pass/fail status, not severity rankings +- **Exit codes**: Exit 1 if any rule fails, 0 if all pass (for CI/CD) +- **Package-level enforcement**: Thresholds apply to overall package, not individual files +- **Configurable thresholds**: User-defined thresholds in `mapper.toml` +- **Multiple output formats**: Console (human-readable), JSON (CI/CD), CSV (tracking) + +--- + +## CLI Commands + +### `mapper quality check` + +Run all enabled quality checks. + +**Signature**: +```bash +mapper quality check [OPTIONS] +``` + +**Options**: +- `--format FORMAT`: Output format (console, json, csv). Default: console +- `--json`: Shortcut for `--format json` +- `--csv`: Shortcut for `--format csv` + +**Exit Codes**: +- `0`: All enabled checks passed +- `1`: One or more checks failed + +**Behavior**: +- Reads configuration from `mapper.toml` +- Runs all quality rules where `enabled = true` +- Returns per-rule results (not aggregated) +- If no configuration exists, uses default thresholds with all rules enabled + +--- + +### `mapper quality type-coverage` + +Check type hint coverage on public functions and methods. + +**Signature**: +```bash +mapper quality type-coverage [OPTIONS] +``` + +**Options**: +- `--format FORMAT`: Output format (console, json, csv). Default: console +- `--json`: Shortcut for `--format json` +- `--csv`: Shortcut for `--format csv` + +**Exit Codes**: +- `0`: Type coverage meets or exceeds threshold +- `1`: Type coverage below threshold + +--- + +### `mapper quality docstring-coverage` + +Check docstring coverage on public functions and methods. + +**Signature**: +```bash +mapper quality docstring-coverage [OPTIONS] +``` + +**Options**: +- `--format FORMAT`: Output format (console, json, csv). Default: console +- `--json`: Shortcut for `--format json` +- `--csv`: Shortcut for `--format csv` + +**Exit Codes**: +- `0`: Docstring coverage meets or exceeds threshold +- `1`: Docstring coverage below threshold + +--- + +### `mapper quality param-complexity` + +Check function parameter complexity. + +**Signature**: +```bash +mapper quality param-complexity [OPTIONS] +``` + +**Options**: +- `--format FORMAT`: Output format (console, json, csv). Default: console +- `--json`: Shortcut for `--format json` +- `--csv`: Shortcut for `--format csv` + +**Exit Codes**: +- `0`: No functions exceed parameter threshold +- `1`: One or more functions exceed parameter threshold + +--- + +## Configuration Schema + +Quality rules are configured in `mapper.toml` under `[quality.*]` sections. + +### Type Coverage Configuration + +```toml +[quality.type-coverage] +enabled = true # Enable/disable rule +min_coverage = 80 # Minimum percentage (0-100) +require_return_types = true # Require return type hints +exclude_patterns = ["test_*", "__init__"] # Exclude functions matching patterns +``` + +**Defaults** (if section not present): +- `enabled = true` +- `min_coverage = 80` +- `require_return_types = false` +- `exclude_patterns = []` + +--- + +### Docstring Coverage Configuration + +```toml +[quality.docstring-coverage] +enabled = true # Enable/disable rule +min_coverage = 90 # Minimum percentage (0-100) +exclude_patterns = ["__str__", "__repr__", "__init__"] # Exclude functions +``` + +**Defaults** (if section not present): +- `enabled = true` +- `min_coverage = 90` +- `exclude_patterns = []` + +--- + +### Parameter Complexity Configuration + +```toml +[quality.param-complexity] +enabled = true # Enable/disable rule +max_parameters = 5 # Maximum parameter count +exclude_patterns = ["__init__"] # Exclude functions matching patterns +``` + +**Defaults** (if section not present): +- `enabled = true` +- `max_parameters = 5` +- `exclude_patterns = []` + +--- + +## Output Formats + +### Console Format (Default) + +**For `mapper quality check`**: +``` +Running quality checks... + +✓ Type Coverage: 85% (threshold: 80%) +✗ Docstring Coverage: 75% (threshold: 90%) +✗ Parameter Complexity: 3 violations (max: 5 parameters) + +2 of 3 checks failed + +Exit code: 1 +``` + +**For individual rules** (e.g., `mapper quality type-coverage`): +``` +✓ Type Coverage: 85% (threshold: 80%) + + By File: + src/mapper/analyser/main.py 12/15 (80%) + src/mapper/graph_loader/loader.py 8/10 (80%) + src/mapper/query_system/queries.py 7/8 (87%) + + Overall: 27/33 public functions have type hints + +Exit code: 0 +``` + +--- + +### JSON Format + +**Schema for `mapper quality check`**: + +Returns array of quality rule results. Each rule has a different structure based on its type. + +**Type Coverage / Docstring Coverage** (percentage-based rules): +```json +{ + "rule": "type-coverage", + "status": "pass" | "fail", + "threshold": 80, + "actual": 85.0, + "overall": { + "total": 33, + "compliant": 28, + "percentage": 84.8 + }, + "by_file": [ + { + "path": "src/mapper/analyser/main.py", + "total": 15, + "compliant": 12, + "percentage": 80.0, + "violations": ["process_file", "validate_path", "extract_metadata"] + } + ] +} +``` + +**Parameter Complexity** (count-based rule): +```json +{ + "rule": "param-complexity", + "status": "pass" | "fail", + "threshold": 5, + "total_violations": 3, + "by_file": [ + { + "path": "src/mapper/analyser/main.py", + "violations": [ + {"function": "process_extraction", "line": 42, "param_count": 7}, + {"function": "validate_and_load", "line": 95, "param_count": 6} + ] + } + ] +} +``` + +**Full example for `mapper quality check --json`**: +```json +[ + { + "rule": "type-coverage", + "status": "pass", + "threshold": 80, + "actual": 85.0, + "overall": { + "total": 33, + "compliant": 28, + "percentage": 84.8 + }, + "by_file": [ + { + "path": "src/mapper/analyser/main.py", + "total": 15, + "compliant": 12, + "percentage": 80.0, + "violations": ["process_file", "validate_path"] + } + ] + }, + { + "rule": "docstring-coverage", + "status": "fail", + "threshold": 90, + "actual": 75.8, + "overall": { + "total": 33, + "compliant": 25, + "percentage": 75.8 + }, + "by_file": [ + { + "path": "src/mapper/analyser/main.py", + "total": 15, + "compliant": 10, + "percentage": 66.7, + "violations": ["process_file", "validate_path", "extract_metadata"] + } + ] + }, + { + "rule": "param-complexity", + "status": "fail", + "threshold": 5, + "total_violations": 3, + "by_file": [ + { + "path": "src/mapper/analyser/main.py", + "violations": [ + {"function": "process_extraction", "line": 42, "param_count": 7} + ] + } + ] + } +] +``` + +--- + +### CSV Format + +**Schema for `mapper quality check --csv`**: + +**For percentage-based rules** (type_coverage, docstring_coverage): +```csv +rule,file_path,total_functions,compliant_functions,compliance_percentage,status +type_coverage,src/mapper/analyser/main.py,15,12,80.0,pass +type_coverage,src/mapper/graph_loader/loader.py,10,8,80.0,pass +docstring_coverage,src/mapper/analyser/main.py,15,10,66.7,fail +``` + +**For count-based rules** (param_complexity): +```csv +rule,file_path,function_name,line_number,parameter_count,status +param_complexity,src/mapper/analyser/main.py,process_extraction,42,7,fail +param_complexity,src/mapper/analyser/main.py,validate_and_load,95,6,fail +``` + +**Note**: CSV format for `mapper quality check` concatenates different CSV schemas. Percentage-based rules first, then count-based rules. + +--- + +## Quality Rule Interface + +Each quality rule implements the following interface: + +### QualityRule Protocol + +```python +from typing import Protocol + +class QualityRule(Protocol): + """Protocol for quality rules.""" + + @property + def name(self) -> str: + """Machine-readable rule name (e.g., 'type_coverage').""" + ... + + @property + def display_name(self) -> str: + """Human-readable rule name (e.g., 'Type Coverage').""" + ... + + def is_enabled(self, config: QualityConfig) -> bool: + """Check if rule is enabled in configuration.""" + ... + + def run(self, connection: Neo4jConnection, package: str) -> QualityResult: + """Execute quality rule and return result. + + Args: + connection: Neo4j connection + package: Package name to check + + Returns: + Quality result with pass/fail status + """ + ... +``` + +--- + +### QualityConfig Model + +```python +@dataclass +class TypeCoverageConfig: + """Configuration for type coverage rule.""" + enabled: bool = True + min_coverage: int = 80 + require_return_types: bool = False + exclude_patterns: list[str] = field(default_factory=list) + + +@dataclass +class DocstringCoverageConfig: + """Configuration for docstring coverage rule.""" + enabled: bool = True + min_coverage: int = 90 + exclude_patterns: list[str] = field(default_factory=list) + + +@dataclass +class ParamComplexityConfig: + """Configuration for parameter complexity rule.""" + enabled: bool = True + max_parameters: int = 5 + exclude_patterns: list[str] = field(default_factory=list) + + +@dataclass +class QualityConfig: + """Quality rules configuration from mapper.toml.""" + type_coverage: TypeCoverageConfig = field(default_factory=TypeCoverageConfig) + docstring_coverage: DocstringCoverageConfig = field(default_factory=DocstringCoverageConfig) + param_complexity: ParamComplexityConfig = field(default_factory=ParamComplexityConfig) +``` + +--- + +### QualityResult Models + +**Base result** (for percentage-based rules): +```python +@dataclass +class FileResult: + """Results for a single file.""" + path: str + total: int + compliant: int + percentage: float + violations: list[str] # Function names + + +@dataclass +class OverallResult: + """Overall results across all files.""" + total: int + compliant: int + percentage: float + + +@dataclass +class CoverageQualityResult: + """Result for coverage-based quality rules.""" + rule: str + status: str # "pass" or "fail" + threshold: int + actual: float + overall: OverallResult + by_file: list[FileResult] +``` + +**Complexity result** (for count-based rules): +```python +@dataclass +class ViolationDetail: + """Details of a single violation.""" + function: str + line: int + param_count: int + + +@dataclass +class FileViolations: + """Violations for a single file.""" + path: str + violations: list[ViolationDetail] + + +@dataclass +class ComplexityQualityResult: + """Result for complexity-based quality rules.""" + rule: str + status: str # "pass" or "fail" + threshold: int + total_violations: int + by_file: list[FileViolations] +``` + +--- + +## Neo4j Queries + +Quality rules query Neo4j for package metadata using structured properties. + +### Type Coverage Query + +```cypher +// Find all public functions/methods with parameter metadata +MATCH (f:Function {package: $package}) +WHERE f.is_public = true + AND NOT any(pattern IN $exclude_patterns WHERE f.name =~ pattern) + +// Count functions with type hints on parameters +WITH f, + size([p IN f.parameters WHERE p.has_type_hint = true]) as typed_params, + size(f.parameters) as total_params + +// Function has type coverage if all params have type hints +WITH 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, + 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 +``` + +--- + +### Docstring Coverage Query + +```cypher +// Find all public functions/methods +MATCH (f:Function {package: $package}) +WHERE f.is_public = true + AND NOT any(pattern IN $exclude_patterns WHERE f.name =~ pattern) + +// Check if function has docstring +WITH 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, + 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 +``` + +--- + +### Parameter Complexity Query + +```cypher +// Find all public functions/methods with parameter count exceeding threshold +MATCH (f:Function {package: $package}) +WHERE f.is_public = true + AND NOT any(pattern IN $exclude_patterns WHERE f.name =~ pattern) + AND size(f.parameters) > $max_parameters + +// Return violations grouped by file +RETURN f.file_path as file_path, + collect({ + function: f.name, + line: f.start_line, + param_count: size(f.parameters) + }) as violations +ORDER BY file_path +``` + +--- + +## Output Formatting + +### Console Formatter + +**Responsibilities**: +- Format quality results for human-readable console output +- Use check marks (✓) for pass, X marks (✗) for fail +- Show file-level breakdown with percentages +- List violations for failed rules + +**Interface**: +```python +class ConsoleFormatter: + """Format quality results for console output.""" + + def format_check_results(self, results: list[QualityResult]) -> str: + """Format results from `mapper quality check`. + + Args: + results: List of quality rule results + + Returns: + Formatted console output + """ + ... + + def format_single_result(self, result: QualityResult) -> str: + """Format result from individual quality rule command. + + Args: + result: Single quality rule result + + Returns: + Formatted console output with file breakdown + """ + ... +``` + +--- + +### JSON Formatter + +**Responsibilities**: +- Serialize quality results to JSON +- Maintain schema for each result type +- Support jq parsing for CI/CD + +**Interface**: +```python +class JSONFormatter: + """Format quality results as JSON.""" + + def format(self, results: list[QualityResult]) -> str: + """Format quality results as JSON array. + + Args: + results: List of quality rule results + + Returns: + JSON string + """ + ... +``` + +--- + +### CSV Formatter + +**Responsibilities**: +- Serialize quality results to CSV +- Handle different schemas for percentage vs count rules +- Support spreadsheet import + +**Interface**: +```python +class CSVFormatter: + """Format quality results as CSV.""" + + def format(self, results: list[QualityResult]) -> str: + """Format quality results as CSV. + + Args: + results: List of quality rule results + + Returns: + CSV string with headers + """ + ... +``` + +--- + +## Implementation Notes + +### Configuration Loading + +Configuration is loaded from `mapper.toml`: +1. Check for `[quality.type-coverage]`, `[quality.docstring-coverage]`, `[quality.param-complexity]` sections +2. If section missing, use default configuration +3. Validate configuration values (e.g., percentages 0-100, max_parameters > 0) + +### Exclude Patterns + +Exclude patterns use glob-style matching: +- `test_*`: Match functions starting with "test_" +- `__init__`: Match exact function name +- `*_internal`: Match functions ending with "_internal" + +Patterns are applied to function names, not file paths. + +### Exit Code Logic + +For `mapper quality check`: +```python +def determine_exit_code(results: list[QualityResult]) -> int: + """Determine exit code from quality results.""" + if any(result.status == "fail" for result in results): + return 1 + return 0 +``` + +### Error Handling + +**Neo4j connection errors**: +- Display clear error message +- Exit with code 1 + +**Invalid configuration**: +- Display validation error +- Show expected format +- Exit with code 1 + +**No package analyzed**: +- Display "No analysis found for package" message +- Suggest running `mapper analyse` +- Exit with code 1 + +--- + +## Related Documentation + +- [User Journey: Running Quality Rules](../user-journeys/10-quality-rules.md) +- [User Journey: Querying Structured Metadata](../user-journeys/09-querying-structured-metadata.md) +- [Technical: Neo4j Schema](../technical/neo4j-schema.md) diff --git a/docs/user-journeys/10-quality-rules.md b/docs/user-journeys/10-quality-rules.md new file mode 100644 index 0000000..6b59189 --- /dev/null +++ b/docs/user-journeys/10-quality-rules.md @@ -0,0 +1,386 @@ +# User Journey: Running Quality Rules + +**Version**: 0.8.1 +**Audience**: Tier 1 users (don't want to write Cypher) +**Goal**: Enforce code quality standards with built-in pass/fail checks + +--- + +## Overview + +Quality rules provide built-in pass/fail checks for code quality standards. Unlike exploratory queries that return ranked results, quality rules enforce thresholds and provide clear pass/fail status suitable for CI/CD pipelines. + +**Key Differences from Queries:** +- **Queries** (`mapper query`): Exploratory, ranked results, severity levels (critical → ok) +- **Quality** (`mapper quality`): Enforcement, pass/fail, exit codes for CI/CD + +--- + +## User Story + +**As a** Python developer maintaining code quality standards, +**I want** to enforce type coverage and docstring requirements without writing Cypher, +**So that** I can gate pull requests and maintain consistent code quality across my team. + +--- + +## Prerequisites + +1. Project analyzed with `mapper analyse` +2. Neo4j running and accessible +3. `mapper.toml` configuration file (optional, for custom thresholds) + +--- + +## Workflow + +### Step 1: Configure Quality Rules + +Create or update `mapper.toml` with quality rule thresholds: + +```toml +[quality.type-coverage] +enabled = true +min_coverage = 80 # percentage +require_return_types = true +exclude_patterns = ["test_*", "__init__"] + +[quality.docstring-coverage] +enabled = true +min_coverage = 90 +exclude_patterns = ["__str__", "__repr__"] + +[quality.param-complexity] +enabled = true +max_parameters = 5 +exclude_patterns = ["__init__"] +``` + +**Default behavior (if no config):** +- Type coverage: 80% threshold, return types optional +- Docstring coverage: 90% threshold +- Parameter complexity: 5 parameters maximum + +--- + +### Step 2: Run Individual Quality Checks + +Check type hint coverage: + +```bash +$ mapper quality type-coverage + +✓ Type Coverage: 85% (threshold: 80%) + + By File: + src/mapper/analyser/main.py 12/15 (80%) + src/mapper/graph_loader/loader.py 8/10 (80%) + src/mapper/query_system/queries.py 7/8 (87%) + + Overall: 27/33 public functions have type hints + +Exit code: 0 +``` + +Check docstring coverage: + +```bash +$ mapper quality docstring-coverage + +✗ Docstring Coverage: 75% (threshold: 90%) + + By File: + src/mapper/analyser/main.py 10/15 (66%) ← below threshold + src/mapper/graph_loader/loader.py 8/10 (80%) ← below threshold + src/mapper/query_system/queries.py 7/8 (87%) + + Overall: 25/33 public functions have docstrings + + Missing docstrings: + - src/mapper/analyser/main.py:42 (process_file) + - src/mapper/analyser/main.py:58 (validate_path) + - src/mapper/analyser/main.py:73 (extract_metadata) + - src/mapper/graph_loader/loader.py:105 (create_edge) + - src/mapper/graph_loader/loader.py:120 (find_node) + +Exit code: 1 +``` + +Check parameter complexity: + +```bash +$ mapper quality param-complexity + +✗ Parameter Complexity: 3 violations (threshold: 5 max) + + By File: + src/mapper/analyser/main.py 2 violations + src/mapper/graph_loader/loader.py 1 violation + + Functions exceeding threshold: + - src/mapper/analyser/main.py:42 (process_extraction) - 7 parameters + - src/mapper/analyser/main.py:95 (validate_and_load) - 6 parameters + - src/mapper/graph_loader/loader.py:156 (_create_single_import_node) - 6 parameters + +Exit code: 1 +``` + +--- + +### Step 3: Run All Enabled Checks + +Use `mapper quality check` to run all enabled rules: + +```bash +$ mapper quality check + +Running quality checks... + +✓ Type Coverage: 85% (threshold: 80%) +✗ Docstring Coverage: 75% (threshold: 90%) +✗ Parameter Complexity: 3 violations (max: 5 parameters) + +2 of 3 checks failed + +Exit code: 1 +``` + +--- + +### Step 4: Export Results for CI/CD + +**JSON output (for tooling):** + +```bash +$ mapper quality check --json +[ + { + "rule": "type-coverage", + "status": "pass", + "threshold": 80, + "actual": 85.0, + "overall": { + "total": 33, + "compliant": 28, + "percentage": 84.8 + }, + "by_file": [ + { + "path": "src/mapper/analyser/main.py", + "total": 15, + "compliant": 12, + "percentage": 80.0, + "violations": ["process_file", "validate_path", "extract_metadata"] + } + ] + }, + { + "rule": "docstring-coverage", + "status": "fail", + "threshold": 90, + "actual": 75.8, + "overall": { + "total": 33, + "compliant": 25, + "percentage": 75.8 + }, + "by_file": [ + { + "path": "src/mapper/analyser/main.py", + "total": 15, + "compliant": 10, + "percentage": 66.7, + "violations": ["process_file", "validate_path", "extract_metadata", "helper_fn", "setup"] + } + ] + }, + { + "rule": "param-complexity", + "status": "fail", + "threshold": 5, + "total_violations": 3, + "by_file": [ + { + "path": "src/mapper/analyser/main.py", + "violations": [ + {"function": "process_extraction", "line": 42, "param_count": 7}, + {"function": "validate_and_load", "line": 95, "param_count": 6} + ] + }, + { + "path": "src/mapper/graph_loader/loader.py", + "violations": [ + {"function": "_create_single_import_node", "line": 156, "param_count": 6} + ] + } + ] + } +] +``` + +**CSV output (for tracking over time):** + +```bash +$ mapper quality check --csv +rule,file_path,total_functions,compliant_functions,compliance_percentage,status +type-coverage,src/mapper/analyser/main.py,15,12,80.0,pass +type-coverage,src/mapper/graph_loader/loader.py,10,8,80.0,pass +type-coverage,src/mapper/query_system/queries.py,8,7,87.5,pass +docstring-coverage,src/mapper/analyser/main.py,15,10,66.7,fail +docstring-coverage,src/mapper/graph_loader/loader.py,10,8,80.0,fail +docstring-coverage,src/mapper/query_system/queries.py,8,7,87.5,pass +param-complexity,src/mapper/analyser/main.py,15,13,86.7,fail +param-complexity,src/mapper/graph_loader/loader.py,10,9,90.0,fail +param-complexity,src/mapper/query_system/queries.py,8,8,100.0,pass +``` + +--- + +## CI/CD Integration + +### GitHub Actions Example + +```yaml +name: Code Quality + +on: [pull_request] + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Start Neo4j + run: | + docker run -d --name neo4j \ + -p 7687:7687 \ + -e NEO4J_AUTH=neo4j/password \ + neo4j:5 + + - name: Install mapper + run: pip install mapper-tool + + - name: Analyze code + run: mapper analyse . + env: + NEO4J_USER: neo4j + NEO4J_PASSWORD: password + + - name: Run quality checks + run: mapper quality check --json > quality-report.json + env: + NEO4J_USER: neo4j + NEO4J_PASSWORD: password + + - name: Parse results + if: failure() + run: | + echo "Failed rules:" + jq -r '.[] | select(.status == "fail") | .rule' quality-report.json + + - name: Upload report + uses: actions/upload-artifact@v4 + with: + name: quality-report + path: quality-report.json +``` + +### Parsing JSON with jq + +**Check overall status:** +```bash +mapper quality check --json | jq 'any(.[]; .status == "fail")' +# Output: true (if any failed), false (if all passed) +``` + +**Get all failed rules:** +```bash +mapper quality check --json | jq -r '.[] | select(.status == "fail") | .rule' +# Output: docstring-coverage +``` + +**Count violations per file:** +```bash +mapper quality type-coverage --json | jq '.by_file[] | {path, violations: (.total - .compliant)}' +# Output: {"path": "src/mapper/analyser/main.py", "violations": 3} +``` + +**Get worst-performing files:** +```bash +mapper quality type-coverage --json | jq '.by_file | sort_by(.percentage) | .[0:3]' +# Output: Top 3 files with lowest coverage +``` + +**Extract specific file results:** +```bash +mapper quality check --json | jq '.[] | .by_file[] | select(.path | contains("analyser"))' +# Output: All analyser file results +``` + +--- + +## Verification + +After running quality checks: + +1. **Exit code indicates pass/fail:** + - Exit code 0: All enabled checks passed + - Exit code 1: One or more checks failed + +2. **Console output shows summary:** + - Check mark (✓) for passed rules + - X mark (✗) for failed rules + - File-level breakdown with percentages + +3. **JSON output is parsable:** + - Array of rule results + - Each result has status, threshold, actual, by_file details + - Can be parsed with jq or other JSON tools + +4. **CSV output is importable:** + - Standard CSV format with headers + - One row per file per rule + - Can be imported to spreadsheets or databases + +--- + +## Troubleshooting + +### "No configuration found" + +If no `mapper.toml` exists, default thresholds are used: +- Type coverage: 80% +- Docstring coverage: 90% +- Parameter complexity: 5 parameters maximum + +Create `mapper.toml` in project root to customize. + +### "Quality check failed but I see 100% coverage" + +Check exclude patterns - some functions may be excluded: +- Test files (`test_*.py`) +- Private functions (starting with `_`) +- Special methods (`__init__`, `__str__`, `__repr__`) + +### "Exit code 0 but some files are below threshold" + +Quality checks are package-level, not file-level. A file can be below threshold as long as the overall package meets the threshold. + +To enforce file-level thresholds, use individual file checks: +```bash +mapper quality type-coverage --json | jq '.by_file[] | select(.percentage < 80)' +``` + +--- + +## Related Documentation + +- [Interface Design: Quality Rules](../interface/quality-rules.md) +- [User Journey: Querying Structured Metadata](./09-querying-structured-metadata.md) +- [Technical: Neo4j Schema](../technical/neo4j-schema.md) diff --git a/docs/user-journeys/README.md b/docs/user-journeys/README.md index 4727ce2..ed8713d 100644 --- a/docs/user-journeys/README.md +++ b/docs/user-journeys/README.md @@ -13,7 +13,8 @@ This directory contains user-focused workflow documentation for Mapper. 7. **[Checking System Status](07-checking-status.md)**: Verify Mapper configuration and Neo4j connectivity 8. **[Detecting Code Risks](08-detecting-code-risks.md)**: Run CLI queries to identify risks without Neo4j knowledge (recommended starting point) 9. **[Querying Structured Metadata](09-querying-structured-metadata.md)**: Write precise queries using structured parameter and decorator data (v0.8.0+) -10. **Exporting Data**: Exporting analysis results _(coming soon)_ +10. **[Running Quality Rules](10-quality-rules.md)**: Enforce code quality standards with built-in pass/fail checks (v0.8.1+) +11. **Exporting Data**: Exporting analysis results _(coming soon)_ ## Documentation Format diff --git a/pyproject.toml b/pyproject.toml index 8806319..440faee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mapper" -version = "0.8.0" +version = "0.8.1" 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.0" +current_version = "0.8.1" 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 c97eb7b..7b2ffaa 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.0" +__version__ = "0.8.1" __all__ = [ # Version diff --git a/src/mapper/quality/__init__.py b/src/mapper/quality/__init__.py new file mode 100644 index 0000000..68de3fc --- /dev/null +++ b/src/mapper/quality/__init__.py @@ -0,0 +1,12 @@ +"""Quality rule system for code quality enforcement. + +This module provides built-in quality rules that can be run via CLI commands +to enforce code quality standards without writing Cypher queries. + +Quality rules are pass/fail checks with configurable thresholds, designed for +CI/CD integration with exit codes (0 = pass, 1 = fail). +""" + +from mapper.quality import config, models, registry + +__all__ = ["config", "models", "registry"] diff --git a/src/mapper/quality/config.py b/src/mapper/quality/config.py new file mode 100644 index 0000000..c41e72f --- /dev/null +++ b/src/mapper/quality/config.py @@ -0,0 +1,88 @@ +"""Quality rules configuration loading from mapper.toml.""" + +import sys + +if sys.version_info >= (3, 11): # noqa: UP036 + import tomllib +else: + try: + import tomli as tomllib + except ImportError: + raise ImportError( + "tomli is required for Python 3.10. Install with: pip install tomli" + ) from None + +from mapper.quality import models + + +def load_quality_config(config_path: str | None = None) -> models.QualityConfig: + """Load quality rules configuration from mapper.toml. + + Args: + config_path: Path to mapper.toml file. If None, uses default configuration. + + Returns: + QualityConfig with loaded or default configuration + + Raises: + ValueError: If configuration values are invalid + """ + if config_path is None: + return models.QualityConfig() + + try: + with open(config_path, "rb") as f: + data = tomllib.load(f) + except FileNotFoundError: + return models.QualityConfig() + + quality_section = data.get("quality", {}) + + # Load type coverage config + type_cov_data = quality_section.get("type-coverage", {}) + type_coverage = models.TypeCoverageConfig( + enabled=type_cov_data.get("enabled", True), + min_coverage=type_cov_data.get("min_coverage", 80), + require_return_types=type_cov_data.get("require_return_types", False), + exclude_patterns=type_cov_data.get("exclude_patterns", []), + ) + + # Validate type coverage config + if not (0 <= type_coverage.min_coverage <= 100): + raise ValueError( + f"type-coverage.min_coverage must be between 0 and 100, got {type_coverage.min_coverage}" + ) + + # Load docstring coverage config + doc_cov_data = quality_section.get("docstring-coverage", {}) + docstring_coverage = models.DocstringCoverageConfig( + enabled=doc_cov_data.get("enabled", True), + min_coverage=doc_cov_data.get("min_coverage", 90), + exclude_patterns=doc_cov_data.get("exclude_patterns", []), + ) + + # Validate docstring coverage config + if not (0 <= docstring_coverage.min_coverage <= 100): + raise ValueError( + f"docstring-coverage.min_coverage must be between 0 and 100, got {docstring_coverage.min_coverage}" + ) + + # Load param complexity config + param_comp_data = quality_section.get("param-complexity", {}) + param_complexity = models.ParamComplexityConfig( + enabled=param_comp_data.get("enabled", True), + max_parameters=param_comp_data.get("max_parameters", 5), + exclude_patterns=param_comp_data.get("exclude_patterns", []), + ) + + # Validate param complexity config + if param_complexity.max_parameters <= 0: + raise ValueError( + f"param-complexity.max_parameters must be greater than 0, got {param_complexity.max_parameters}" + ) + + return models.QualityConfig( + type_coverage=type_coverage, + docstring_coverage=docstring_coverage, + param_complexity=param_complexity, + ) diff --git a/src/mapper/quality/formatters.py b/src/mapper/quality/formatters.py new file mode 100644 index 0000000..40636fa --- /dev/null +++ b/src/mapper/quality/formatters.py @@ -0,0 +1,413 @@ +"""Output formatters for quality rule results.""" + +import csv +import io +import json +from enum import Enum +from typing import Protocol + +from rich.console import Console + +from mapper.quality import models + + +class OutputFormat(str, Enum): # noqa: UP042 + """Output format types for quality rule results. + + String-backed enum for compatibility with string operations while providing + type safety and validation. Uses (str, Enum) pattern for Python 3.10+ compatibility + (StrEnum is only available in Python 3.11+). + """ + + CONSOLE = "console" + JSON = "json" + CSV = "csv" + + +class FormatsQualityResults(Protocol): + """Protocol for quality result formatters. + + All formatters must implement format_results() to return a string representation + of quality results. This allows formatters to be used consistently across + different output contexts (CLI, file, tests). + """ + + def format_results( + self, results: list[models.CoverageQualityResult | models.ComplexityQualityResult] + ) -> str: + """Format quality results as string. + + Args: + results: List of quality rule results to format + + Returns: + Formatted string ready for output + """ + ... + + +class ConsoleFormatter: + """Format quality results for human-readable console output.""" + + def _format_check_mark(self, status: str) -> str: + """Get check mark symbol for status. + + Args: + status: "pass" or "fail" + + Returns: + Colored check mark or X symbol + """ + if status == "pass": + return "[bold green]✓[/bold green]" + else: + return "[bold red]✗[/bold red]" + + def _format_coverage_result( + self, result: models.CoverageQualityResult, show_details: bool = False + ) -> str: + """Format a coverage-based result (type/docstring coverage). + + Args: + result: Coverage quality result + show_details: Whether to show file-level breakdown + + Returns: + Formatted string + """ + output = io.StringIO() + console = Console(file=output, force_terminal=True, width=120) + + # Summary line + check = self._format_check_mark(result.status) + rule_name = result.rule.replace("-", " ").title() + console.print(f"{check} {rule_name}: {result.actual:.1f}% (threshold: {result.threshold}%)") + + # File breakdown if requested + if show_details and result.by_file: + console.print("\n By File:") + for file_result in result.by_file: + status_icon = "✓" if file_result.percentage >= result.threshold else "✗" + console.print( + f" {file_result.path:50s} {file_result.compliant}/{file_result.total} ({file_result.percentage:.0f}%) {status_icon}" + ) + + console.print( + f"\n Overall: {result.overall.compliant}/{result.overall.total} " + f"functions meet the standard" + ) + + # Show violations if any + if result.status == "fail": + all_violations = [] + for file_result in result.by_file: + if file_result.violations: + for violation in file_result.violations: + all_violations.append(f"{file_result.path}:{violation}") + + if all_violations: + console.print("\n Missing coverage:") + for violation in all_violations[:10]: # Limit to 10 + console.print(f" - {violation}") + if len(all_violations) > 10: + console.print(f" ... and {len(all_violations) - 10} more") + + return output.getvalue() + + def _format_complexity_result( + self, result: models.ComplexityQualityResult, show_details: bool = False + ) -> str: + """Format a complexity-based result (parameter complexity). + + Args: + result: Complexity quality result + show_details: Whether to show violation details + + Returns: + Formatted string + """ + output = io.StringIO() + console = Console(file=output, force_terminal=True, width=120) + + # Summary line + check = self._format_check_mark(result.status) + rule_name = result.rule.replace("-", " ").title() + + if result.total_violations == 0: + console.print( + f"{check} {rule_name}: No violations (max: {result.threshold} parameters)" + ) + else: + console.print( + f"{check} {rule_name}: {result.total_violations} violations (max: {result.threshold} parameters)" + ) + + # Violation details if requested + if show_details and result.by_file: + console.print("\n Functions exceeding threshold:") + for file_violations in result.by_file: + console.print(f"\n {file_violations.path}:") + for violation in file_violations.violations: + console.print( + f" - {violation.function} (line {violation.line}): {violation.param_count} parameters" + ) + + return output.getvalue() + + def format_results( + self, results: list[models.CoverageQualityResult | models.ComplexityQualityResult] + ) -> str: + """Format quality results for console output. + + For multiple results (mapper quality check), shows summary. + For single result, shows detailed breakdown. + + Args: + results: List of quality rule results + + Returns: + Formatted console output with Rich markup + """ + if len(results) == 0: + return "" + + output = io.StringIO() + console = Console(file=output, force_terminal=True, width=120) + + show_details = len(results) == 1 + + if len(results) > 1: + console.print("\n[bold]Running quality checks...[/bold]\n") + + # Format each result + for result in results: + if isinstance(result, models.CoverageQualityResult): + console.print(self._format_coverage_result(result, show_details)) + else: # ComplexityQualityResult + console.print(self._format_complexity_result(result, show_details)) + + # Overall summary for multiple checks + if len(results) > 1: + failed_count = sum(1 for r in results if r.status == "fail") + if failed_count > 0: + console.print(f"\n{failed_count} of {len(results)} checks failed") + else: + console.print(f"\n[bold green]All {len(results)} checks passed[/bold green]") + + console.print() # Trailing newline + + return output.getvalue() + + +class JSONFormatter: + """Format quality results as JSON.""" + + def _format_coverage_result(self, result: models.CoverageQualityResult) -> dict: + """Convert coverage result to dict for JSON serialization. + + Args: + result: Coverage quality result + + Returns: + Dictionary representation + """ + return { + "rule": result.rule, + "status": result.status, + "threshold": result.threshold, + "actual": result.actual, + "overall": { + "total": result.overall.total, + "compliant": result.overall.compliant, + "percentage": result.overall.percentage, + }, + "by_file": [ + { + "path": fr.path, + "total": fr.total, + "compliant": fr.compliant, + "percentage": fr.percentage, + "violations": fr.violations, + } + for fr in result.by_file + ], + } + + def _format_complexity_result(self, result: models.ComplexityQualityResult) -> dict: + """Convert complexity result to dict for JSON serialization. + + Args: + result: Complexity quality result + + Returns: + Dictionary representation + """ + return { + "rule": result.rule, + "status": result.status, + "threshold": result.threshold, + "total_violations": result.total_violations, + "by_file": [ + { + "path": fv.path, + "violations": [ + { + "function": v.function, + "line": v.line, + "param_count": v.param_count, + } + for v in fv.violations + ], + } + for fv in result.by_file + ], + } + + def format_results( + self, results: list[models.CoverageQualityResult | models.ComplexityQualityResult] + ) -> str: + """Format quality results as JSON. + + Args: + results: List of quality rule results + + Returns: + JSON-formatted string (array of rule results) + """ + output = [] + for result in results: + if isinstance(result, models.CoverageQualityResult): + output.append(self._format_coverage_result(result)) + else: # ComplexityQualityResult + output.append(self._format_complexity_result(result)) + + return json.dumps(output, indent=2) + + +class CSVFormatter: + """Format quality results as CSV.""" + + def _format_coverage_csv(self, result: models.CoverageQualityResult) -> str: + """Format coverage result as CSV rows. + + Args: + result: Coverage quality result + + Returns: + CSV string + """ + output = io.StringIO() + writer = csv.writer(output) + + # Write rows (one per file) + for file_result in result.by_file: + status = "pass" if file_result.percentage >= result.threshold else "fail" + writer.writerow( + [ + result.rule, + file_result.path, + file_result.total, + file_result.compliant, + f"{file_result.percentage:.1f}", + status, + ] + ) + + return output.getvalue() + + def _format_complexity_csv(self, result: models.ComplexityQualityResult) -> str: + """Format complexity result as CSV rows. + + Args: + result: Complexity quality result + + Returns: + CSV string + """ + output = io.StringIO() + writer = csv.writer(output) + + # Write rows (one per violation) + for file_violations in result.by_file: + for violation in file_violations.violations: + writer.writerow( + [ + result.rule, + file_violations.path, + violation.function, + violation.line, + violation.param_count, + "fail", + ] + ) + + return output.getvalue() + + def format_results( + self, results: list[models.CoverageQualityResult | models.ComplexityQualityResult] + ) -> str: + """Format quality results as CSV. + + Different schemas for coverage vs complexity rules: + - Coverage: rule,file_path,total_functions,compliant_functions,compliance_percentage,status + - Complexity: rule,file_path,function_name,line_number,parameter_count,status + + Args: + results: List of quality rule results + + Returns: + CSV-formatted string with headers + """ + output = io.StringIO() + writer = csv.writer(output) + + # Separate coverage and complexity results + coverage_results: list[models.CoverageQualityResult] = [ + r for r in results if isinstance(r, models.CoverageQualityResult) + ] + complexity_results: list[models.ComplexityQualityResult] = [ + r for r in results if isinstance(r, models.ComplexityQualityResult) + ] + + # Write coverage results with header + if coverage_results: + writer.writerow( + [ + "rule", + "file_path", + "total_functions", + "compliant_functions", + "compliance_percentage", + "status", + ] + ) + for coverage_result in coverage_results: + output.write(self._format_coverage_csv(coverage_result)) + + # Write complexity results with header + if complexity_results: + writer.writerow( + ["rule", "file_path", "function_name", "line_number", "parameter_count", "status"] + ) + for complexity_result in complexity_results: + output.write(self._format_complexity_csv(complexity_result)) + + return output.getvalue() + + +def get_formatter(format_type: OutputFormat) -> FormatsQualityResults: + """Get formatter for the specified format type. + + Args: + format_type: Format type (OutputFormat enum) + + Returns: + Appropriate formatter instance + """ + match format_type: + case OutputFormat.CONSOLE: + return ConsoleFormatter() + case OutputFormat.JSON: + return JSONFormatter() + case OutputFormat.CSV: + return CSVFormatter() diff --git a/src/mapper/quality/models.py b/src/mapper/quality/models.py new file mode 100644 index 0000000..43b95ee --- /dev/null +++ b/src/mapper/quality/models.py @@ -0,0 +1,253 @@ +"""Quality rule models and base class.""" + +from abc import ABC, abstractmethod + +import attrs + +from mapper import graph + + +@attrs.define(frozen=True) +class TypeCoverageConfig: + """Configuration for type coverage quality rule. + + Attributes: + enabled: Whether this rule is enabled + min_coverage: Minimum type coverage percentage (0-100) + require_return_types: Whether return type hints are required + exclude_patterns: Glob patterns for functions to exclude + """ + + enabled: bool = True + min_coverage: int = 80 + require_return_types: bool = False + exclude_patterns: list[str] = attrs.field(factory=list) + + +@attrs.define(frozen=True) +class DocstringCoverageConfig: + """Configuration for docstring coverage quality rule. + + Attributes: + enabled: Whether this rule is enabled + min_coverage: Minimum docstring coverage percentage (0-100) + exclude_patterns: Glob patterns for functions to exclude + """ + + enabled: bool = True + min_coverage: int = 90 + exclude_patterns: list[str] = attrs.field(factory=list) + + +@attrs.define(frozen=True) +class ParamComplexityConfig: + """Configuration for parameter complexity quality rule. + + Attributes: + enabled: Whether this rule is enabled + max_parameters: Maximum number of parameters allowed + exclude_patterns: Glob patterns for functions to exclude + """ + + enabled: bool = True + max_parameters: int = 5 + exclude_patterns: list[str] = attrs.field(factory=list) + + +@attrs.define(frozen=True) +class QualityConfig: + """Quality rules configuration from mapper.toml. + + Attributes: + type_coverage: Type coverage rule configuration + docstring_coverage: Docstring coverage rule configuration + param_complexity: Parameter complexity rule configuration + """ + + type_coverage: TypeCoverageConfig = attrs.field(factory=TypeCoverageConfig) + docstring_coverage: DocstringCoverageConfig = attrs.field(factory=DocstringCoverageConfig) + param_complexity: ParamComplexityConfig = attrs.field(factory=ParamComplexityConfig) + + +@attrs.define(frozen=True) +class FileResult: + """Quality check results for a single file. + + Attributes: + path: File path + total: Total number of functions checked + compliant: Number of compliant functions + percentage: Compliance percentage + violations: Names of non-compliant functions + """ + + path: str + total: int + compliant: int + percentage: float + violations: list[str] + + +@attrs.define(frozen=True) +class OverallResult: + """Overall quality check results across all files. + + Attributes: + total: Total number of functions checked + compliant: Number of compliant functions + percentage: Overall compliance percentage + """ + + total: int + compliant: int + percentage: float + + +@attrs.define(frozen=True) +class CoverageQualityResult: + """Result for coverage-based quality rules (type/docstring coverage). + + Attributes: + rule: Machine-readable rule name + threshold: Configured threshold value + actual: Actual coverage percentage + overall: Overall results across all files + by_file: Per-file results + """ + + rule: str + threshold: int + actual: float + overall: OverallResult + by_file: list[FileResult] + + @property + def status(self) -> str: + """Calculate pass/fail status from actual coverage and threshold. + + Returns: + "pass" if actual >= threshold, otherwise "fail" + """ + return "pass" if self.actual >= self.threshold else "fail" + + +@attrs.define(frozen=True) +class ViolationDetail: + """Details of a single parameter complexity violation. + + Attributes: + function: Function name + line: Line number where function is defined + param_count: Number of parameters (exceeds threshold) + """ + + function: str + line: int + param_count: int + + +@attrs.define(frozen=True) +class FileViolations: + """Parameter complexity violations for a single file. + + Attributes: + path: File path + violations: List of violation details + """ + + path: str + violations: list[ViolationDetail] + + +@attrs.define(frozen=True) +class ComplexityQualityResult: + """Result for complexity-based quality rules (parameter complexity). + + Attributes: + rule: Machine-readable rule name + threshold: Configured threshold value (max parameters) + total_violations: Total number of violations across all files + by_file: Per-file violations + """ + + rule: str + threshold: int + total_violations: int + by_file: list[FileViolations] + + @property + def status(self) -> str: + """Calculate pass/fail status from violation count. + + Returns: + "pass" if no violations, otherwise "fail" + """ + return "pass" if self.total_violations == 0 else "fail" + + +class QualityRule(ABC): + """Base class for all quality rules. + + Defines the interface that all quality rules must implement. Concrete rule + classes use @attrs.define to get immutability and convenience methods. + + Subclasses must define these fields as attrs attributes: + - name: str - Machine-readable rule identifier (e.g., "type_coverage") + - display_name: str - Human-readable rule name (e.g., "Type Coverage") + + And implement these methods: + - is_enabled(config) -> bool + - run(connection, package) -> CoverageQualityResult | ComplexityQualityResult + + Example: + @attrs.define(frozen=True) + class MyRule(QualityRule): + name: str = "my-rule" + display_name: str = "My Rule" + + def is_enabled(self, config: QualityConfig) -> bool: + return config.my_rule.enabled + + def run(self, connection: Neo4jConnection, package: str) -> CoverageQualityResult: + # Execute query and return result + ... + """ + + @property + @abstractmethod + def name(self) -> str: + """Machine-readable rule identifier (e.g., 'type-coverage').""" + ... + + @property + @abstractmethod + def description(self) -> str: + """Human-readable rule description (e.g., 'Enforce type hint coverage on public functions').""" + ... + + @abstractmethod + def is_enabled(self, config: QualityConfig) -> bool: + """Check if rule is enabled in configuration. + + Args: + config: Quality configuration + + Returns: + True if rule is enabled, False otherwise + """ + ... + + @abstractmethod + def run( + self, neo4j_connection: graph.Neo4jConnection, package: str + ) -> CoverageQualityResult | ComplexityQualityResult: + """Execute quality rule and return result. + + Args: + neo4j_connection: Neo4j connection + package: Package name to check + + Returns: + Quality result with pass/fail status + """ + ... diff --git a/src/mapper/quality/registry.py b/src/mapper/quality/registry.py new file mode 100644 index 0000000..e1a40ce --- /dev/null +++ b/src/mapper/quality/registry.py @@ -0,0 +1,57 @@ +"""Quality rule registry for managing built-in quality rules.""" + +from mapper.quality import models +from mapper.quality.rules import BUILTIN_RULES + + +class QualityRuleRegistry: + """Registry for quality rules. + + Manages built-in quality rules and provides lookup by name. + """ + + def __init__(self) -> None: + """Initialize registry with built-in quality rules.""" + self._rules: dict[str, models.QualityRule] = {} + for rule in BUILTIN_RULES: + self._rules[rule.name] = rule + + def get(self, name: str) -> models.QualityRule | None: + """Get quality rule by name. + + Args: + name: Rule name (e.g., "type_coverage") + + Returns: + QualityRule instance or None if not found + """ + return self._rules.get(name) + + def list_all(self) -> list[models.QualityRule]: + """Get all registered quality rules. + + Returns: + List of all quality rules sorted by name + """ + return sorted(self._rules.values(), key=lambda r: r.name) + + def get_rule_names(self) -> list[str]: + """Get names of all registered quality rules. + + Returns: + Sorted list of rule names + """ + return sorted(self._rules.keys()) + + +# Global registry instance +_registry = QualityRuleRegistry() + + +def get_registry() -> QualityRuleRegistry: + """Get the global quality rule registry. + + Returns: + Global QualityRuleRegistry instance + """ + return _registry diff --git a/src/mapper/quality/rules/__init__.py b/src/mapper/quality/rules/__init__.py new file mode 100644 index 0000000..1dcc385 --- /dev/null +++ b/src/mapper/quality/rules/__init__.py @@ -0,0 +1,12 @@ +"""Built-in quality rules.""" + +from mapper.quality.rules import docstring_coverage, param_complexity, type_coverage + +# All built-in quality rules (used by registry) +BUILTIN_RULES = [ + type_coverage.TypeCoverageRule(), + docstring_coverage.DocstringCoverageRule(), + param_complexity.ParamComplexityRule(), +] + +__all__ = ["BUILTIN_RULES"] diff --git a/src/mapper/quality/rules/docstring_coverage.py b/src/mapper/quality/rules/docstring_coverage.py new file mode 100644 index 0000000..3b33779 --- /dev/null +++ b/src/mapper/quality/rules/docstring_coverage.py @@ -0,0 +1,112 @@ +"""Docstring coverage quality rule implementation.""" + +import fnmatch + +import attrs + +from mapper import graph +from mapper.quality import models + + +@attrs.define(frozen=True) +class DocstringCoverageRule(models.QualityRule): + """Quality rule for enforcing docstring coverage on public functions.""" + + name: str = "docstring-coverage" + description: str = "Enforce docstring coverage on public functions" + + def is_enabled(self, config: models.QualityConfig) -> bool: + """Check if rule is enabled in configuration.""" + return config.docstring_coverage.enabled + + def run( + self, neo4j_connection: graph.Neo4jConnection, package: str + ) -> models.CoverageQualityResult: + """Execute docstring coverage rule and return result. + + Args: + neo4j_connection: Neo4j connection + package: Package name to check + + Returns: + CoverageQualityResult with pass/fail status + """ + # Load configuration + from mapper.quality import config as config_module + + cfg = config_module.load_quality_config() + doc_cfg = cfg.docstring_coverage + + # Build Cypher query to find functions with/without docstrings + query = """ + MATCH (f:Function {package: $package}) + WHERE f.is_public = true + + // Check if function has docstring + WITH 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, + 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 + ORDER BY file_path + """ + + with neo4j_connection.driver.session(database=neo4j_connection.database) as session: + result = session.run(query, package=package) + records = list(result) + + # Process results + file_results = [] + total_functions = 0 + total_compliant = 0 + + for record in records: + file_path = record["file_path"] + total = record["total"] + violations = [v for v in record["violations"] if v is not None] + + # Apply exclude patterns + violations = [ + v + for v in violations + if not any(fnmatch.fnmatch(v, pattern) for pattern in doc_cfg.exclude_patterns) + ] + + # Recalculate compliant count after filtering + actual_compliant = total - len(violations) + percentage = (actual_compliant / total * 100) if total > 0 else 0.0 + + file_results.append( + models.FileResult( + path=file_path, + total=total, + compliant=actual_compliant, + percentage=percentage, + violations=violations, + ) + ) + + total_functions += total + total_compliant += actual_compliant + + # Calculate overall percentage + overall_percentage = ( + (total_compliant / total_functions * 100) if total_functions > 0 else 0.0 + ) + + return models.CoverageQualityResult( + rule=self.name, + threshold=doc_cfg.min_coverage, + actual=overall_percentage, + overall=models.OverallResult( + total=total_functions, + compliant=total_compliant, + percentage=overall_percentage, + ), + by_file=file_results, + ) diff --git a/src/mapper/quality/rules/param_complexity.py b/src/mapper/quality/rules/param_complexity.py new file mode 100644 index 0000000..b8b2498 --- /dev/null +++ b/src/mapper/quality/rules/param_complexity.py @@ -0,0 +1,97 @@ +"""Parameter complexity quality rule implementation.""" + +import fnmatch + +import attrs + +from mapper import graph +from mapper.quality import models + + +@attrs.define(frozen=True) +class ParamComplexityRule(models.QualityRule): + """Quality rule for enforcing parameter count limits on functions.""" + + name: str = "param-complexity" + description: str = "Enforce parameter count limits on functions" + + def is_enabled(self, config: models.QualityConfig) -> bool: + """Check if rule is enabled in configuration.""" + return config.param_complexity.enabled + + def run( + self, neo4j_connection: graph.Neo4jConnection, package: str + ) -> models.ComplexityQualityResult: + """Execute parameter complexity rule and return result. + + Args: + neo4j_connection: Neo4j connection + package: Package name to check + + Returns: + ComplexityQualityResult with pass/fail status + """ + # Load configuration + from mapper.quality import config as config_module + + cfg = config_module.load_quality_config() + param_cfg = cfg.param_complexity + + # Build Cypher query to find functions exceeding parameter threshold + query = """ + MATCH (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, + collect({ + function: f.name, + line: f.start_line, + param_count: size(f.parameters) + }) as violations + ORDER BY file_path + """ + + with neo4j_connection.driver.session(database=neo4j_connection.database) as session: + result = session.run(query, package=package, max_parameters=param_cfg.max_parameters) + records = list(result) + + # Process results + file_violations = [] + total_violations = 0 + + for record in records: + file_path = record["file_path"] + violations_data = record["violations"] + + # Apply exclude patterns + filtered_violations = [] + for v in violations_data: + func_name = v["function"] + if not any( + fnmatch.fnmatch(func_name, pattern) for pattern in param_cfg.exclude_patterns + ): + filtered_violations.append( + models.ViolationDetail( + function=func_name, + line=v["line"], + param_count=v["param_count"], + ) + ) + + if filtered_violations: + file_violations.append( + models.FileViolations( + path=file_path, + violations=filtered_violations, + ) + ) + total_violations += len(filtered_violations) + + return models.ComplexityQualityResult( + rule=self.name, + threshold=param_cfg.max_parameters, + total_violations=total_violations, + by_file=file_violations, + ) diff --git a/src/mapper/quality/rules/type_coverage.py b/src/mapper/quality/rules/type_coverage.py new file mode 100644 index 0000000..61902b4 --- /dev/null +++ b/src/mapper/quality/rules/type_coverage.py @@ -0,0 +1,119 @@ +"""Type coverage quality rule implementation.""" + +import fnmatch + +import attrs + +from mapper import graph +from mapper.quality import models + + +@attrs.define(frozen=True) +class TypeCoverageRule(models.QualityRule): + """Quality rule for enforcing type hint coverage on public functions.""" + + name: str = "type-coverage" + description: str = "Enforce type hint coverage on public functions" + + def is_enabled(self, config: models.QualityConfig) -> bool: + """Check if rule is enabled in configuration.""" + return config.type_coverage.enabled + + def run( + self, neo4j_connection: graph.Neo4jConnection, package: str + ) -> models.CoverageQualityResult: + """Execute type coverage rule and return result. + + Args: + neo4j_connection: Neo4j connection + package: Package name to check + + Returns: + CoverageQualityResult with pass/fail status + """ + # Load configuration + from mapper.quality import config as config_module + + cfg = config_module.load_quality_config() + type_cfg = cfg.type_coverage + + # Build Cypher query to find functions with/without type hints + query = """ + MATCH (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 + + // Function has type coverage if all params have type hints + // (or has no parameters) + WITH 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, + 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 + ORDER BY file_path + """ + + with neo4j_connection.driver.session(database=neo4j_connection.database) as session: + result = session.run(query, package=package) + records = list(result) + + # Process results + file_results = [] + total_functions = 0 + total_compliant = 0 + + for record in records: + file_path = record["file_path"] + total = record["total"] + violations = [v for v in record["violations"] if v is not None] + + # Apply exclude patterns + violations = [ + v + for v in violations + if not any(fnmatch.fnmatch(v, pattern) for pattern in type_cfg.exclude_patterns) + ] + + # Recalculate compliant count after filtering + actual_compliant = total - len(violations) + percentage = (actual_compliant / total * 100) if total > 0 else 0.0 + + file_results.append( + models.FileResult( + path=file_path, + total=total, + compliant=actual_compliant, + percentage=percentage, + violations=violations, + ) + ) + + total_functions += total + total_compliant += actual_compliant + + # Calculate overall percentage + overall_percentage = ( + (total_compliant / total_functions * 100) if total_functions > 0 else 0.0 + ) + + return models.CoverageQualityResult( + rule=self.name, + threshold=type_cfg.min_coverage, + actual=overall_percentage, + overall=models.OverallResult( + total=total_functions, + compliant=total_compliant, + percentage=overall_percentage, + ), + by_file=file_results, + ) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..898a621 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,13 @@ +"""Shared pytest fixtures for unit tests.""" + +from unittest import mock + +import pytest + + +@pytest.fixture +def mock_neo4j_connection(): + """Create mock Neo4j connection for unit tests.""" + connection = mock.MagicMock() + connection.database = "neo4j" + return connection diff --git a/tests/unit/quality/test_config.py b/tests/unit/quality/test_config.py new file mode 100644 index 0000000..f9821a0 --- /dev/null +++ b/tests/unit/quality/test_config.py @@ -0,0 +1,182 @@ +"""Unit tests for quality rules configuration loading.""" + +import pytest + +from mapper.quality import config, models + + +class TestLoadQualityConfig: + """Test load_quality_config function.""" + + def test_default_config_when_no_path(self): + """Should return default configuration when no path provided.""" + cfg = config.load_quality_config(None) + + assert isinstance(cfg, models.QualityConfig) + assert cfg.type_coverage.enabled is True + assert cfg.type_coverage.min_coverage == 80 + assert cfg.docstring_coverage.min_coverage == 90 + assert cfg.param_complexity.max_parameters == 5 + + def test_default_config_when_file_not_found(self, tmp_path): + """Should return default configuration when file doesn't exist.""" + config_path = tmp_path / "nonexistent.toml" + cfg = config.load_quality_config(str(config_path)) + + assert isinstance(cfg, models.QualityConfig) + assert cfg.type_coverage.enabled is True + assert cfg.type_coverage.min_coverage == 80 + + def test_load_custom_type_coverage_config(self, tmp_path): + """Should load custom type coverage configuration.""" + config_file = tmp_path / "mapper.toml" + config_file.write_text(""" +[quality.type-coverage] +enabled = false +min_coverage = 70 +require_return_types = true +exclude_patterns = ["test_*", "__init__"] +""") + + cfg = config.load_quality_config(str(config_file)) + + assert cfg.type_coverage.enabled is False + assert cfg.type_coverage.min_coverage == 70 + assert cfg.type_coverage.require_return_types is True + assert cfg.type_coverage.exclude_patterns == ["test_*", "__init__"] + + def test_load_custom_docstring_coverage_config(self, tmp_path): + """Should load custom docstring coverage configuration.""" + config_file = tmp_path / "mapper.toml" + config_file.write_text(""" +[quality.docstring-coverage] +enabled = false +min_coverage = 85 +exclude_patterns = ["__str__", "__repr__"] +""") + + cfg = config.load_quality_config(str(config_file)) + + assert cfg.docstring_coverage.enabled is False + assert cfg.docstring_coverage.min_coverage == 85 + assert cfg.docstring_coverage.exclude_patterns == ["__str__", "__repr__"] + + def test_load_custom_param_complexity_config(self, tmp_path): + """Should load custom param complexity configuration.""" + config_file = tmp_path / "mapper.toml" + config_file.write_text(""" +[quality.param-complexity] +enabled = false +max_parameters = 7 +exclude_patterns = ["__init__"] +""") + + cfg = config.load_quality_config(str(config_file)) + + assert cfg.param_complexity.enabled is False + assert cfg.param_complexity.max_parameters == 7 + assert cfg.param_complexity.exclude_patterns == ["__init__"] + + def test_load_all_custom_configs(self, tmp_path): + """Should load all custom configurations together.""" + config_file = tmp_path / "mapper.toml" + config_file.write_text(""" +[quality.type-coverage] +enabled = false +min_coverage = 70 + +[quality.docstring-coverage] +min_coverage = 85 + +[quality.param-complexity] +max_parameters = 7 +""") + + cfg = config.load_quality_config(str(config_file)) + + assert cfg.type_coverage.enabled is False + assert cfg.type_coverage.min_coverage == 70 + assert cfg.docstring_coverage.min_coverage == 85 + assert cfg.param_complexity.max_parameters == 7 + + def test_validate_type_coverage_min_coverage_too_low(self, tmp_path): + """Should raise ValueError when type coverage below 0.""" + config_file = tmp_path / "mapper.toml" + config_file.write_text(""" +[quality.type-coverage] +min_coverage = -10 +""") + + with pytest.raises(ValueError, match="must be between 0 and 100"): + config.load_quality_config(str(config_file)) + + def test_validate_type_coverage_min_coverage_too_high(self, tmp_path): + """Should raise ValueError when type coverage above 100.""" + config_file = tmp_path / "mapper.toml" + config_file.write_text(""" +[quality.type-coverage] +min_coverage = 110 +""") + + with pytest.raises(ValueError, match="must be between 0 and 100"): + config.load_quality_config(str(config_file)) + + def test_validate_docstring_coverage_min_coverage_too_low(self, tmp_path): + """Should raise ValueError when docstring coverage below 0.""" + config_file = tmp_path / "mapper.toml" + config_file.write_text(""" +[quality.docstring-coverage] +min_coverage = -10 +""") + + with pytest.raises(ValueError, match="must be between 0 and 100"): + config.load_quality_config(str(config_file)) + + def test_validate_docstring_coverage_min_coverage_too_high(self, tmp_path): + """Should raise ValueError when docstring coverage above 100.""" + config_file = tmp_path / "mapper.toml" + config_file.write_text(""" +[quality.docstring-coverage] +min_coverage = 150 +""") + + with pytest.raises(ValueError, match="must be between 0 and 100"): + config.load_quality_config(str(config_file)) + + def test_validate_param_complexity_max_parameters_zero(self, tmp_path): + """Should raise ValueError when max_parameters is zero.""" + config_file = tmp_path / "mapper.toml" + config_file.write_text(""" +[quality.param-complexity] +max_parameters = 0 +""") + + with pytest.raises(ValueError, match="must be greater than 0"): + config.load_quality_config(str(config_file)) + + def test_validate_param_complexity_max_parameters_negative(self, tmp_path): + """Should raise ValueError when max_parameters is negative.""" + config_file = tmp_path / "mapper.toml" + config_file.write_text(""" +[quality.param-complexity] +max_parameters = -5 +""") + + with pytest.raises(ValueError, match="must be greater than 0"): + config.load_quality_config(str(config_file)) + + def test_partial_config_uses_defaults(self, tmp_path): + """Should use defaults for missing configuration sections.""" + config_file = tmp_path / "mapper.toml" + config_file.write_text(""" +[quality.type-coverage] +min_coverage = 75 +""") + + cfg = config.load_quality_config(str(config_file)) + + # Custom value + assert cfg.type_coverage.min_coverage == 75 + # Defaults for other rules + assert cfg.docstring_coverage.min_coverage == 90 + assert cfg.param_complexity.max_parameters == 5 diff --git a/tests/unit/quality/test_docstring_coverage_rule.py b/tests/unit/quality/test_docstring_coverage_rule.py new file mode 100644 index 0000000..42a09e7 --- /dev/null +++ b/tests/unit/quality/test_docstring_coverage_rule.py @@ -0,0 +1,86 @@ +"""Unit tests for docstring coverage quality rule.""" + +from unittest import mock + +from mapper.quality import models +from mapper.quality.rules import docstring_coverage + + +class TestDocstringCoverageRule: + """Test DocstringCoverageRule class.""" + + def test_name(self): + """Should return correct machine-readable name.""" + rule = docstring_coverage.DocstringCoverageRule() + assert rule.name == "docstring-coverage" + + def test_description(self): + """Should return correct human-readable description.""" + rule = docstring_coverage.DocstringCoverageRule() + assert rule.description == "Enforce docstring coverage on public functions" + + def test_is_enabled_when_enabled(self): + """Should return True when rule is enabled.""" + rule = docstring_coverage.DocstringCoverageRule() + config = models.QualityConfig( + docstring_coverage=models.DocstringCoverageConfig(enabled=True) + ) + assert rule.is_enabled(config) is True + + def test_is_enabled_when_disabled(self): + """Should return False when rule is disabled.""" + rule = docstring_coverage.DocstringCoverageRule() + config = models.QualityConfig( + docstring_coverage=models.DocstringCoverageConfig(enabled=False) + ) + assert rule.is_enabled(config) is False + + def test_run_passing_threshold(self, mock_neo4j_connection): + """Should return pass status when coverage meets threshold.""" + rule = docstring_coverage.DocstringCoverageRule() + + # Mock Neo4j query result - 9 out of 10 functions have docstrings (90%) + mock_result = [ + { + "file_path": "src/main.py", + "total": 10, + "compliant": 9, + "violations": ["func1"], + } + ] + + mock_session = mock.MagicMock() + mock_session.run.return_value = mock_result + mock_neo4j_connection.driver.session.return_value.__enter__.return_value = mock_session + + result = rule.run(mock_neo4j_connection, "test_package") + + assert result.status == "pass" + assert result.threshold == 90 + assert result.actual == 90.0 + assert result.overall.total == 10 + assert result.overall.compliant == 9 + + def test_run_failing_threshold(self, mock_neo4j_connection): + """Should return fail status when coverage below threshold.""" + rule = docstring_coverage.DocstringCoverageRule() + + # Mock Neo4j query result - 8 out of 10 functions have docstrings (80%) + mock_result = [ + { + "file_path": "src/main.py", + "total": 10, + "compliant": 8, + "violations": ["func1", "func2"], + } + ] + + mock_session = mock.MagicMock() + mock_session.run.return_value = mock_result + mock_neo4j_connection.driver.session.return_value.__enter__.return_value = mock_session + + result = rule.run(mock_neo4j_connection, "test_package") + + assert result.status == "fail" + assert result.threshold == 90 + assert result.actual == 80.0 diff --git a/tests/unit/quality/test_models.py b/tests/unit/quality/test_models.py new file mode 100644 index 0000000..3f4507e --- /dev/null +++ b/tests/unit/quality/test_models.py @@ -0,0 +1,229 @@ +"""Unit tests for quality rule models.""" + +from mapper.quality import models + + +class TestTypeCoverageConfig: + """Test TypeCoverageConfig model.""" + + def test_default_values(self): + """Should create config with default values.""" + config = models.TypeCoverageConfig() + assert config.enabled is True + assert config.min_coverage == 80 + assert config.require_return_types is False + assert config.exclude_patterns == [] + + def test_custom_values(self): + """Should create config with custom values.""" + config = models.TypeCoverageConfig( + enabled=False, + min_coverage=70, + require_return_types=True, + exclude_patterns=["test_*", "__init__"], + ) + assert config.enabled is False + assert config.min_coverage == 70 + assert config.require_return_types is True + assert config.exclude_patterns == ["test_*", "__init__"] + + +class TestDocstringCoverageConfig: + """Test DocstringCoverageConfig model.""" + + def test_default_values(self): + """Should create config with default values.""" + config = models.DocstringCoverageConfig() + assert config.enabled is True + assert config.min_coverage == 90 + assert config.exclude_patterns == [] + + def test_custom_values(self): + """Should create config with custom values.""" + config = models.DocstringCoverageConfig( + enabled=False, + min_coverage=85, + exclude_patterns=["__str__", "__repr__"], + ) + assert config.enabled is False + assert config.min_coverage == 85 + assert config.exclude_patterns == ["__str__", "__repr__"] + + +class TestParamComplexityConfig: + """Test ParamComplexityConfig model.""" + + def test_default_values(self): + """Should create config with default values.""" + config = models.ParamComplexityConfig() + assert config.enabled is True + assert config.max_parameters == 5 + assert config.exclude_patterns == [] + + def test_custom_values(self): + """Should create config with custom values.""" + config = models.ParamComplexityConfig( + enabled=False, + max_parameters=7, + exclude_patterns=["__init__"], + ) + assert config.enabled is False + assert config.max_parameters == 7 + assert config.exclude_patterns == ["__init__"] + + +class TestQualityConfig: + """Test QualityConfig model.""" + + def test_default_values(self): + """Should create config with default sub-configs.""" + config = models.QualityConfig() + assert isinstance(config.type_coverage, models.TypeCoverageConfig) + assert isinstance(config.docstring_coverage, models.DocstringCoverageConfig) + assert isinstance(config.param_complexity, models.ParamComplexityConfig) + + def test_custom_sub_configs(self): + """Should create config with custom sub-configs.""" + type_cov = models.TypeCoverageConfig(enabled=False, min_coverage=70) + doc_cov = models.DocstringCoverageConfig(enabled=False, min_coverage=85) + param_comp = models.ParamComplexityConfig(enabled=False, max_parameters=7) + + config = models.QualityConfig( + type_coverage=type_cov, + docstring_coverage=doc_cov, + param_complexity=param_comp, + ) + + assert config.type_coverage.enabled is False + assert config.type_coverage.min_coverage == 70 + assert config.docstring_coverage.min_coverage == 85 + assert config.param_complexity.max_parameters == 7 + + +class TestFileResult: + """Test FileResult model.""" + + def test_creation(self): + """Should create file result with all fields.""" + result = models.FileResult( + path="src/main.py", + total=10, + compliant=8, + percentage=80.0, + violations=["func1", "func2"], + ) + assert result.path == "src/main.py" + assert result.total == 10 + assert result.compliant == 8 + assert result.percentage == 80.0 + assert result.violations == ["func1", "func2"] + + +class TestOverallResult: + """Test OverallResult model.""" + + def test_creation(self): + """Should create overall result with all fields.""" + result = models.OverallResult( + total=50, + compliant=40, + percentage=80.0, + ) + assert result.total == 50 + assert result.compliant == 40 + assert result.percentage == 80.0 + + +class TestCoverageQualityResult: + """Test CoverageQualityResult model.""" + + def test_creation(self): + """Should create coverage quality result with all fields.""" + overall = models.OverallResult(total=50, compliant=40, percentage=80.0) + file_result = models.FileResult( + path="src/main.py", + total=10, + compliant=8, + percentage=80.0, + violations=["func1", "func2"], + ) + + result = models.CoverageQualityResult( + rule="type_coverage", + threshold=80, + actual=80.0, + overall=overall, + by_file=[file_result], + ) + + assert result.rule == "type_coverage" + assert result.status == "pass" # Calculated from actual >= threshold + assert result.threshold == 80 + assert result.actual == 80.0 + assert result.overall == overall + assert len(result.by_file) == 1 + assert result.by_file[0] == file_result + + +class TestViolationDetail: + """Test ViolationDetail model.""" + + def test_creation(self): + """Should create violation detail with all fields.""" + violation = models.ViolationDetail( + function="complex_function", + line=42, + param_count=8, + ) + assert violation.function == "complex_function" + assert violation.line == 42 + assert violation.param_count == 8 + + +class TestFileViolations: + """Test FileViolations model.""" + + def test_creation(self): + """Should create file violations with all fields.""" + violation = models.ViolationDetail( + function="complex_function", + line=42, + param_count=8, + ) + file_violations = models.FileViolations( + path="src/main.py", + violations=[violation], + ) + assert file_violations.path == "src/main.py" + assert len(file_violations.violations) == 1 + assert file_violations.violations[0] == violation + + +class TestComplexityQualityResult: + """Test ComplexityQualityResult model.""" + + def test_creation(self): + """Should create complexity quality result with all fields.""" + violation = models.ViolationDetail( + function="complex_function", + line=42, + param_count=8, + ) + file_violations = models.FileViolations( + path="src/main.py", + violations=[violation], + ) + + result = models.ComplexityQualityResult( + rule="param_complexity", + threshold=5, + total_violations=1, + by_file=[file_violations], + ) + + assert result.rule == "param_complexity" + assert result.status == "fail" # Calculated from total_violations > 0 + assert result.threshold == 5 + assert result.total_violations == 1 + assert len(result.by_file) == 1 + assert result.by_file[0] == file_violations diff --git a/tests/unit/quality/test_param_complexity_rule.py b/tests/unit/quality/test_param_complexity_rule.py new file mode 100644 index 0000000..cd20965 --- /dev/null +++ b/tests/unit/quality/test_param_complexity_rule.py @@ -0,0 +1,94 @@ +"""Unit tests for parameter complexity quality rule.""" + +from unittest import mock + +from mapper.quality import models +from mapper.quality.rules import param_complexity + + +class TestParamComplexityRule: + """Test ParamComplexityRule class.""" + + def test_name(self): + """Should return correct machine-readable name.""" + rule = param_complexity.ParamComplexityRule() + assert rule.name == "param-complexity" + + def test_description(self): + """Should return correct human-readable description.""" + rule = param_complexity.ParamComplexityRule() + assert rule.description == "Enforce parameter count limits on functions" + + def test_is_enabled_when_enabled(self): + """Should return True when rule is enabled.""" + rule = param_complexity.ParamComplexityRule() + config = models.QualityConfig(param_complexity=models.ParamComplexityConfig(enabled=True)) + assert rule.is_enabled(config) is True + + def test_is_enabled_when_disabled(self): + """Should return False when rule is disabled.""" + rule = param_complexity.ParamComplexityRule() + config = models.QualityConfig(param_complexity=models.ParamComplexityConfig(enabled=False)) + assert rule.is_enabled(config) is False + + def test_run_no_violations(self, mock_neo4j_connection): + """Should return pass status when no violations.""" + rule = param_complexity.ParamComplexityRule() + + # Mock Neo4j query result - no functions exceed threshold + mock_result = [] + + mock_session = mock.MagicMock() + mock_session.run.return_value = mock_result + mock_neo4j_connection.driver.session.return_value.__enter__.return_value = mock_session + + result = rule.run(mock_neo4j_connection, "test_package") + + assert result.status == "pass" + assert result.threshold == 5 + assert result.total_violations == 0 + assert len(result.by_file) == 0 + + def test_run_with_violations(self, mock_neo4j_connection): + """Should return fail status when violations found.""" + rule = param_complexity.ParamComplexityRule() + + # Mock Neo4j query result - functions exceeding threshold + mock_result = [ + { + "file_path": "src/main.py", + "violations": [ + {"function": "complex_func", "line": 10, "param_count": 8}, + {"function": "another_func", "line": 20, "param_count": 7}, + ], + }, + { + "file_path": "src/utils.py", + "violations": [ + {"function": "helper_func", "line": 5, "param_count": 6}, + ], + }, + ] + + mock_session = mock.MagicMock() + mock_session.run.return_value = mock_result + mock_neo4j_connection.driver.session.return_value.__enter__.return_value = mock_session + + result = rule.run(mock_neo4j_connection, "test_package") + + assert result.status == "fail" + assert result.threshold == 5 + assert result.total_violations == 3 + assert len(result.by_file) == 2 + + # Check first file violations + assert result.by_file[0].path == "src/main.py" + assert len(result.by_file[0].violations) == 2 + assert result.by_file[0].violations[0].function == "complex_func" + assert result.by_file[0].violations[0].line == 10 + assert result.by_file[0].violations[0].param_count == 8 + + # Check second file violations + assert result.by_file[1].path == "src/utils.py" + assert len(result.by_file[1].violations) == 1 + assert result.by_file[1].violations[0].function == "helper_func" diff --git a/tests/unit/quality/test_quality_formatters.py b/tests/unit/quality/test_quality_formatters.py new file mode 100644 index 0000000..13c3360 --- /dev/null +++ b/tests/unit/quality/test_quality_formatters.py @@ -0,0 +1,488 @@ +"""Tests for quality result formatters.""" + +import json + +import pytest + +from mapper.quality import formatters, models + + +@pytest.fixture +def sample_coverage_result() -> models.CoverageQualityResult: + """Sample coverage result for testing.""" + return models.CoverageQualityResult( + rule="type-coverage", + threshold=80, + actual=85.5, + overall=models.OverallResult( + total=20, + compliant=17, + percentage=85.5, + ), + by_file=[ + models.FileResult( + path="src/example/module.py", + total=10, + compliant=9, + percentage=90.0, + violations=["uncovered_func"], + ), + models.FileResult( + path="src/example/utils.py", + total=10, + compliant=8, + percentage=80.0, + violations=["helper1", "helper2"], + ), + ], + ) + + +@pytest.fixture +def sample_failing_coverage_result() -> models.CoverageQualityResult: + """Sample failing coverage result for testing.""" + return models.CoverageQualityResult( + rule="docstring-coverage", + threshold=90, + actual=75.0, + overall=models.OverallResult( + total=20, + compliant=15, + percentage=75.0, + ), + by_file=[ + models.FileResult( + path="src/example/api.py", + total=20, + compliant=15, + percentage=75.0, + violations=["func1", "func2", "func3", "func4", "func5"], + ), + ], + ) + + +@pytest.fixture +def sample_complexity_result() -> models.ComplexityQualityResult: + """Sample complexity result for testing.""" + return models.ComplexityQualityResult( + rule="param-complexity", + threshold=5, + total_violations=3, + by_file=[ + models.FileViolations( + path="src/example/handlers.py", + violations=[ + models.ViolationDetail( + function="handle_request", + line=42, + param_count=7, + ), + models.ViolationDetail( + function="process_data", + line=89, + param_count=6, + ), + ], + ), + models.FileViolations( + path="src/example/validators.py", + violations=[ + models.ViolationDetail( + function="validate_input", + line=15, + param_count=8, + ), + ], + ), + ], + ) + + +@pytest.fixture +def sample_passing_complexity_result() -> models.ComplexityQualityResult: + """Sample passing complexity result (no violations).""" + return models.ComplexityQualityResult( + rule="param-complexity", + threshold=5, + total_violations=0, + by_file=[], + ) + + +class TestConsoleFormatter: + """Tests for ConsoleFormatter.""" + + def test_format_single_passing_coverage_result( + self, sample_coverage_result: models.CoverageQualityResult + ) -> None: + """Test formatting a single passing coverage result shows details.""" + formatter = formatters.ConsoleFormatter() + output = formatter.format_results([sample_coverage_result]) + + # Check for check mark and title + assert "✓" in output + assert "Type Coverage" in output + # ANSI codes may split "85.5" so check for parts + assert "85" in output + assert "threshold" in output + assert "80" in output + + # Should show file breakdown for single result + assert "By File:" in output + assert "src/example/module.py" in output + assert "src/example/utils.py" in output + assert "9" in output and "10" in output + assert "8" in output + + # Should show overall summary + assert "17" in output and "20" in output + assert "functions meet the standard" in output + + def test_format_single_failing_coverage_result( + self, sample_failing_coverage_result: models.CoverageQualityResult + ) -> None: + """Test formatting a single failing coverage result shows violations.""" + formatter = formatters.ConsoleFormatter() + output = formatter.format_results([sample_failing_coverage_result]) + + # Check for X mark and title + assert "✗" in output + assert "Docstring Coverage" in output + assert "75.0" in output or "75" in output + assert "threshold" in output + assert "90" in output + + # Should show violations + assert "Missing coverage:" in output + assert "src/example/api.py:func1" in output + assert "src/example/api.py:func2" in output + + def test_format_single_complexity_result( + self, sample_complexity_result: models.ComplexityQualityResult + ) -> None: + """Test formatting a single complexity result shows violations.""" + formatter = formatters.ConsoleFormatter() + output = formatter.format_results([sample_complexity_result]) + + # Check for X mark (has violations) + assert "✗" in output + assert "Parameter Complexity" in output or "Param Complexity" in output + assert "3" in output and "violations" in output + assert "max" in output and "5" in output and "parameters" in output + + # Should show violation details for single result + assert "Functions exceeding threshold:" in output + assert "src/example/handlers.py" in output + assert "handle_request" in output and "42" in output and "7" in output + assert "process_data" in output and "89" in output and "6" in output + assert "src/example/validators.py" in output + assert "validate_input" in output and "15" in output and "8" in output + + def test_format_single_passing_complexity_result( + self, sample_passing_complexity_result: models.ComplexityQualityResult + ) -> None: + """Test formatting a passing complexity result (no violations).""" + formatter = formatters.ConsoleFormatter() + output = formatter.format_results([sample_passing_complexity_result]) + + # Check for check mark + assert "✓" in output + assert "Parameter Complexity" in output or "Param Complexity" in output + assert "No violations" in output + assert "max" in output and "5" in output and "parameters" in output + + def test_format_multiple_results_summary( + self, + sample_coverage_result: models.CoverageQualityResult, + sample_complexity_result: models.ComplexityQualityResult, + ) -> None: + """Test formatting multiple results shows summary without details.""" + formatter = formatters.ConsoleFormatter() + output = formatter.format_results([sample_coverage_result, sample_complexity_result]) + + # Should show header + assert "Running quality checks" in output + + # Should show both results + assert "Type Coverage" in output + assert "Parameter Complexity" in output or "Param Complexity" in output + + # Should NOT show detailed breakdown for multiple results + assert "By File:" not in output + assert "Functions exceeding threshold:" not in output + + # Should show overall summary + assert "1" in output and "2" in output and "checks failed" in output + + def test_format_multiple_results_all_passing( + self, + sample_coverage_result: models.CoverageQualityResult, + sample_passing_complexity_result: models.ComplexityQualityResult, + ) -> None: + """Test formatting multiple passing results shows success.""" + formatter = formatters.ConsoleFormatter() + output = formatter.format_results( + [sample_coverage_result, sample_passing_complexity_result] + ) + + # Should show overall success + assert "All" in output and "2" in output and "checks passed" in output + + def test_format_empty_results(self) -> None: + """Test formatting empty results list returns empty string.""" + formatter = formatters.ConsoleFormatter() + output = formatter.format_results([]) + + assert output == "" + + +class TestJSONFormatter: + """Tests for JSONFormatter.""" + + def test_format_coverage_result( + self, sample_coverage_result: models.CoverageQualityResult + ) -> None: + """Test JSON formatting of coverage result.""" + formatter = formatters.JSONFormatter() + output = formatter.format_results([sample_coverage_result]) + + data = json.loads(output) + assert len(data) == 1 + + result = data[0] + assert result["rule"] == "type-coverage" + assert result["status"] == "pass" + assert result["threshold"] == 80 + assert result["actual"] == 85.5 + + # Check overall section + assert result["overall"]["total"] == 20 + assert result["overall"]["compliant"] == 17 + assert result["overall"]["percentage"] == 85.5 + + # Check by_file section + assert len(result["by_file"]) == 2 + assert result["by_file"][0]["path"] == "src/example/module.py" + assert result["by_file"][0]["total"] == 10 + assert result["by_file"][0]["compliant"] == 9 + assert result["by_file"][0]["percentage"] == 90.0 + assert result["by_file"][0]["violations"] == ["uncovered_func"] + + def test_format_complexity_result( + self, sample_complexity_result: models.ComplexityQualityResult + ) -> None: + """Test JSON formatting of complexity result.""" + formatter = formatters.JSONFormatter() + output = formatter.format_results([sample_complexity_result]) + + data = json.loads(output) + assert len(data) == 1 + + result = data[0] + assert result["rule"] == "param-complexity" + assert result["status"] == "fail" + assert result["threshold"] == 5 + assert result["total_violations"] == 3 + + # Check by_file section + assert len(result["by_file"]) == 2 + assert result["by_file"][0]["path"] == "src/example/handlers.py" + assert len(result["by_file"][0]["violations"]) == 2 + + violation = result["by_file"][0]["violations"][0] + assert violation["function"] == "handle_request" + assert violation["line"] == 42 + assert violation["param_count"] == 7 + + def test_format_multiple_results( + self, + sample_coverage_result: models.CoverageQualityResult, + sample_complexity_result: models.ComplexityQualityResult, + ) -> None: + """Test JSON formatting of multiple results.""" + formatter = formatters.JSONFormatter() + output = formatter.format_results([sample_coverage_result, sample_complexity_result]) + + data = json.loads(output) + assert len(data) == 2 + assert data[0]["rule"] == "type-coverage" + assert data[1]["rule"] == "param-complexity" + + def test_json_is_valid_and_indented( + self, sample_coverage_result: models.CoverageQualityResult + ) -> None: + """Test JSON output is valid and properly indented.""" + formatter = formatters.JSONFormatter() + output = formatter.format_results([sample_coverage_result]) + + # Should be valid JSON + data = json.loads(output) + assert data is not None + + # Should be indented (contains newlines) + assert "\n" in output + assert " " in output + + +class TestCSVFormatter: + """Tests for CSVFormatter.""" + + def test_format_coverage_result( + self, sample_coverage_result: models.CoverageQualityResult + ) -> None: + """Test CSV formatting of coverage result.""" + formatter = formatters.CSVFormatter() + output = formatter.format_results([sample_coverage_result]) + + lines = output.strip().splitlines() + + # Check header + assert ( + lines[0] + == "rule,file_path,total_functions,compliant_functions,compliance_percentage,status" + ) + + # Check data rows (one per file) + assert len(lines) == 3 # Header + 2 files + + # First file (90% >= 80% = pass) + assert "type-coverage,src/example/module.py,10,9,90.0,pass" in lines[1] + + # Second file (80% >= 80% = pass) + assert "type-coverage,src/example/utils.py,10,8,80.0,pass" in lines[2] + + def test_format_failing_coverage_result( + self, sample_failing_coverage_result: models.CoverageQualityResult + ) -> None: + """Test CSV formatting of failing coverage result.""" + formatter = formatters.CSVFormatter() + output = formatter.format_results([sample_failing_coverage_result]) + + lines = output.strip().splitlines() + + # File (75% < 90% = fail) + assert "docstring-coverage,src/example/api.py,20,15,75.0,fail" in lines[1] + + def test_format_complexity_result( + self, sample_complexity_result: models.ComplexityQualityResult + ) -> None: + """Test CSV formatting of complexity result.""" + formatter = formatters.CSVFormatter() + output = formatter.format_results([sample_complexity_result]) + + lines = output.strip().splitlines() + + # Check header + assert lines[0] == "rule,file_path,function_name,line_number,parameter_count,status" + + # Check data rows (one per violation) + assert len(lines) == 4 # Header + 3 violations + + assert "param-complexity,src/example/handlers.py,handle_request,42,7,fail" in lines[1] + assert "param-complexity,src/example/handlers.py,process_data,89,6,fail" in lines[2] + assert "param-complexity,src/example/validators.py,validate_input,15,8,fail" in lines[3] + + def test_format_multiple_results_separate_headers( + self, + sample_coverage_result: models.CoverageQualityResult, + sample_complexity_result: models.ComplexityQualityResult, + ) -> None: + """Test CSV formatting with both coverage and complexity results.""" + formatter = formatters.CSVFormatter() + output = formatter.format_results([sample_coverage_result, sample_complexity_result]) + + lines = output.strip().splitlines() + + # Should have both headers (coverage first, complexity second) + coverage_header = ( + "rule,file_path,total_functions,compliant_functions,compliance_percentage,status" + ) + complexity_header = "rule,file_path,function_name,line_number,parameter_count,status" + + # Find header positions + coverage_header_idx = None + complexity_header_idx = None + for i, line in enumerate(lines): + if line == coverage_header: + coverage_header_idx = i + elif line == complexity_header: + complexity_header_idx = i + + assert coverage_header_idx is not None + assert complexity_header_idx is not None + + # Coverage header should come first + assert coverage_header_idx < complexity_header_idx + + # Should have coverage data rows after coverage header + assert "type-coverage,src/example/module.py" in lines[coverage_header_idx + 1] + + # Should have complexity data rows after complexity header + assert ( + "param-complexity,src/example/handlers.py,handle_request" + in lines[complexity_header_idx + 1] + ) + + def test_format_passing_complexity_result_no_rows( + self, sample_passing_complexity_result: models.ComplexityQualityResult + ) -> None: + """Test CSV formatting of passing complexity result produces header only.""" + formatter = formatters.CSVFormatter() + output = formatter.format_results([sample_passing_complexity_result]) + + lines = output.strip().split("\n") + + # Should only have header (no violations to report) + assert len(lines) == 1 + assert lines[0] == "rule,file_path,function_name,line_number,parameter_count,status" + + +class TestGetFormatter: + """Tests for get_formatter() function.""" + + def test_get_console_formatter(self) -> None: + """Test get_formatter returns ConsoleFormatter for CONSOLE format.""" + formatter = formatters.get_formatter(formatters.OutputFormat.CONSOLE) + assert isinstance(formatter, formatters.ConsoleFormatter) + + def test_get_json_formatter(self) -> None: + """Test get_formatter returns JSONFormatter for JSON format.""" + formatter = formatters.get_formatter(formatters.OutputFormat.JSON) + assert isinstance(formatter, formatters.JSONFormatter) + + def test_get_csv_formatter(self) -> None: + """Test get_formatter returns CSVFormatter for CSV format.""" + formatter = formatters.get_formatter(formatters.OutputFormat.CSV) + assert isinstance(formatter, formatters.CSVFormatter) + + def test_formatter_implements_protocol(self) -> None: + """Test all formatters implement FormatsQualityResults protocol.""" + for format_type in formatters.OutputFormat: + formatter = formatters.get_formatter(format_type) + assert hasattr(formatter, "format_results") + assert callable(formatter.format_results) + + +class TestOutputFormatEnum: + """Tests for OutputFormat enum.""" + + def test_enum_values(self) -> None: + """Test OutputFormat enum has correct values.""" + assert formatters.OutputFormat.CONSOLE.value == "console" + assert formatters.OutputFormat.JSON.value == "json" + assert formatters.OutputFormat.CSV.value == "csv" + + def test_string_comparison(self) -> None: + """Test OutputFormat enum supports string comparison.""" + assert formatters.OutputFormat.CONSOLE == "console" + assert formatters.OutputFormat.JSON == "json" + assert formatters.OutputFormat.CSV == "csv" + + def test_string_formatting(self) -> None: + """Test OutputFormat enum supports .value for string formatting.""" + format_type = formatters.OutputFormat.JSON + # String mixin allows direct comparison with strings + assert format_type == "json" + # Use .value for f-string formatting (mypy has false positive with str enums) + assert f"Format: {format_type.value}" == "Format: json" # type: ignore[attr-defined] diff --git a/tests/unit/quality/test_quality_registry.py b/tests/unit/quality/test_quality_registry.py new file mode 100644 index 0000000..d11c530 --- /dev/null +++ b/tests/unit/quality/test_quality_registry.py @@ -0,0 +1,59 @@ +"""Unit tests for quality rule registry.""" + +from mapper.quality import registry + + +class TestRegistry: + """Test quality rule registry functions.""" + + def test_get_rule_type_coverage(self): + """Should return type coverage rule by name.""" + reg = registry.get_registry() + rule = reg.get("type-coverage") + assert rule is not None + assert rule.name == "type-coverage" + + def test_get_rule_docstring_coverage(self): + """Should return docstring coverage rule by name.""" + reg = registry.get_registry() + rule = reg.get("docstring-coverage") + assert rule is not None + assert rule.name == "docstring-coverage" + + def test_get_rule_param_complexity(self): + """Should return param complexity rule by name.""" + reg = registry.get_registry() + rule = reg.get("param-complexity") + assert rule is not None + assert rule.name == "param-complexity" + + def test_get_rule_nonexistent(self): + """Should return None for nonexistent rule.""" + reg = registry.get_registry() + rule = reg.get("nonexistent_rule") + assert rule is None + + def test_list_all(self): + """Should return all registered rules.""" + reg = registry.get_registry() + rules = reg.list_all() + assert len(rules) == 3 + rule_names = [r.name for r in rules] + assert "type-coverage" in rule_names + assert "docstring-coverage" in rule_names + assert "param-complexity" in rule_names + + def test_get_rule_names(self): + """Should return names of all registered rules.""" + reg = registry.get_registry() + names = reg.get_rule_names() + assert len(names) == 3 + assert "type-coverage" in names + assert "docstring-coverage" in names + assert "param-complexity" in names + + def test_singleton_pattern(self): + """Should return same registry instance.""" + reg1 = registry.get_registry() + reg2 = registry.get_registry() + assert reg1 is reg2 diff --git a/tests/unit/quality/test_type_coverage_rule.py b/tests/unit/quality/test_type_coverage_rule.py new file mode 100644 index 0000000..582f6de --- /dev/null +++ b/tests/unit/quality/test_type_coverage_rule.py @@ -0,0 +1,114 @@ +"""Unit tests for type coverage quality rule.""" + +from unittest import mock + +from mapper.quality import models +from mapper.quality.rules import type_coverage + + +class TestTypeCoverageRule: + """Test TypeCoverageRule class.""" + + def test_name(self): + """Should return correct machine-readable name.""" + rule = type_coverage.TypeCoverageRule() + assert rule.name == "type-coverage" + + def test_description(self): + """Should return correct human-readable description.""" + rule = type_coverage.TypeCoverageRule() + assert rule.description == "Enforce type hint coverage on public functions" + + def test_is_enabled_when_enabled(self): + """Should return True when rule is enabled.""" + rule = type_coverage.TypeCoverageRule() + config = models.QualityConfig(type_coverage=models.TypeCoverageConfig(enabled=True)) + assert rule.is_enabled(config) is True + + def test_is_enabled_when_disabled(self): + """Should return False when rule is disabled.""" + rule = type_coverage.TypeCoverageRule() + config = models.QualityConfig(type_coverage=models.TypeCoverageConfig(enabled=False)) + assert rule.is_enabled(config) is False + + def test_run_passing_threshold(self, mock_neo4j_connection): + """Should return pass status when coverage meets threshold.""" + rule = type_coverage.TypeCoverageRule() + + # Mock Neo4j query result - 8 out of 10 functions have type hints (80%) + mock_result = [ + { + "file_path": "src/main.py", + "total": 10, + "compliant": 8, + "violations": ["func1", "func2"], + } + ] + + mock_session = mock.MagicMock() + mock_session.run.return_value = mock_result + mock_neo4j_connection.driver.session.return_value.__enter__.return_value = mock_session + + result = rule.run(mock_neo4j_connection, "test_package") + + assert result.status == "pass" + assert result.threshold == 80 + assert result.actual == 80.0 + assert result.overall.total == 10 + assert result.overall.compliant == 8 + assert len(result.by_file) == 1 + + def test_run_failing_threshold(self, mock_neo4j_connection): + """Should return fail status when coverage below threshold.""" + rule = type_coverage.TypeCoverageRule() + + # Mock Neo4j query result - 7 out of 10 functions have type hints (70%) + mock_result = [ + { + "file_path": "src/main.py", + "total": 10, + "compliant": 7, + "violations": ["func1", "func2", "func3"], + } + ] + + mock_session = mock.MagicMock() + mock_session.run.return_value = mock_result + mock_neo4j_connection.driver.session.return_value.__enter__.return_value = mock_session + + result = rule.run(mock_neo4j_connection, "test_package") + + assert result.status == "fail" + assert result.threshold == 80 + assert result.actual == 70.0 + + def test_run_multiple_files(self, mock_neo4j_connection): + """Should aggregate results across multiple files.""" + rule = type_coverage.TypeCoverageRule() + + # Mock Neo4j query result - multiple files + mock_result = [ + { + "file_path": "src/main.py", + "total": 10, + "compliant": 8, + "violations": ["func1", "func2"], + }, + { + "file_path": "src/utils.py", + "total": 5, + "compliant": 5, + "violations": [], + }, + ] + + mock_session = mock.MagicMock() + mock_session.run.return_value = mock_result + mock_neo4j_connection.driver.session.return_value.__enter__.return_value = mock_session + + result = rule.run(mock_neo4j_connection, "test_package") + + assert result.overall.total == 15 + assert result.overall.compliant == 13 + assert result.overall.percentage == (13 / 15 * 100) + assert len(result.by_file) == 2 diff --git a/tests/unit/query_system/test_executor.py b/tests/unit/query_system/test_executor.py index 282a69c..3543b01 100644 --- a/tests/unit/query_system/test_executor.py +++ b/tests/unit/query_system/test_executor.py @@ -8,14 +8,6 @@ from mapper.query_system.query import Severity -@pytest.fixture -def mock_neo4j_connection(): - """Create mock Neo4j connection.""" - mock_connection = mock.MagicMock() - mock_connection.database = "neo4j" - return mock_connection - - class TestQueryExecutor: """Tests for QueryExecutor class."""