#!/usr/bin/env python3 """Quality gate checker for CleverAgents CI pipeline. Aggregates results from multiple quality tools and produces a summary report. Returns non-zero exit code if any critical gate fails. Usage: python scripts/check-quality-gates.py [--coverage-min 85] [--complexity-max E] Gates checked: 1. Coverage >= threshold (default 85%) 2. No type errors (pyright clean) 3. No high-severity security issues (bandit) 4. No dead code (vulture clean) 5. No extreme complexity (radon grade F) """ import argparse import json import re import subprocess import sys from pathlib import Path _SAFE_PATH_RE = re.compile(r"^[A-Za-z0-9_./-]+$") def validate_path(path_str: str, project_root: Path | None = None) -> Path: """Validate and sanitize a path argument to prevent command injection. Checks for safe path characters, dangerous shell metacharacters, path traversal patterns, project-root containment, and on-disk existence. Args: path_str: The path string to validate project_root: Optional project root for relative path validation Returns: A validated, resolved Path object Raises: ValueError: If the path is invalid or contains dangerous patterns """ if not path_str: raise ValueError("Path cannot be empty") # Check for dangerous patterns that could be used for injection dangerous_patterns = [ "..", # Path traversal ";", # Command separator "|", # Pipe operator "&", # Background/AND operator "$", # Variable/command substitution "`", # Backtick command substitution (deprecated) "(", # Subshell grouping start ")", # Subshell grouping end "<", # Input redirection ">", # Output redirection "\n", # Newline injection "\r", # Carriage return injection "\\n", # Escaped newline injection in feature/CLI input "\\r", # Escaped carriage return injection in feature/CLI input ] for pattern in dangerous_patterns: if pattern in path_str: raise ValueError(f"Path contains dangerous pattern: {pattern}") if _SAFE_PATH_RE.fullmatch(path_str) is None: raise ValueError("Path contains characters outside the safe allowlist") # Convert to Path object path_obj = Path(path_str) # Resolve to absolute path to prevent traversal try: resolved = path_obj.resolve() except (OSError, RuntimeError) as e: raise ValueError(f"Cannot resolve path: {e}") from e if not resolved.is_file() and not resolved.is_dir(): raise ValueError(f"Path does not exist: {resolved}") # If project_root is provided, ensure path is within it if project_root is not None: project_root_resolved = project_root.resolve() try: resolved.relative_to(project_root_resolved) except ValueError as e: msg = f"Path {resolved} is outside project root {project_root_resolved}" raise ValueError(msg) from e return resolved def check_coverage(min_coverage: float) -> tuple[bool, str]: """Check test coverage meets minimum threshold.""" result = subprocess.run( ["coverage", "report", "--format=total"], capture_output=True, text=True, check=False, ) if result.returncode != 0: return False, f"Coverage command failed: {result.stderr.strip()}" try: total = float(result.stdout.strip()) except ValueError: return False, f"Could not parse coverage output: {result.stdout.strip()}" passed = total >= min_coverage msg = f"Coverage: {total:.1f}% (minimum: {min_coverage}%)" return passed, msg def check_typecheck() -> tuple[bool, str]: """Check pyright type checking passes.""" result = subprocess.run( ["pyright", "--outputjson"], capture_output=True, text=True, check=False, ) try: data = json.loads(result.stdout) error_count = data.get("summary", {}).get("errorCount", -1) if error_count == 0: return True, "Type checking: 0 errors" return False, f"Type checking: {error_count} errors" except (json.JSONDecodeError, KeyError): # Fallback to exit code if result.returncode == 0: return True, "Type checking: passed" return False, f"Type checking: failed (exit code {result.returncode})" def check_security() -> tuple[bool, str]: """Check bandit finds no high-severity issues.""" # Validate the path argument to prevent command injection try: project_root = Path(".").resolve() config_path = validate_path("pyproject.toml", project_root) src_path = validate_path("src/cleveragents", project_root) except ValueError as e: return False, f"Security: invalid path - {e}" result = subprocess.run( [ "bandit", "-c", str(config_path), "-r", str(src_path), "--severity-level", "high", "--format", "json", ], capture_output=True, text=True, check=False, ) try: data = json.loads(result.stdout) high_issues = len(data.get("results", [])) if high_issues == 0: return True, "Security: 0 high-severity issues" return False, f"Security: {high_issues} high-severity issues" except json.JSONDecodeError: if result.returncode == 0: return True, "Security: passed" return False, f"Security: failed (exit code {result.returncode})" def check_dead_code() -> tuple[bool, str]: """Check vulture finds no dead code.""" # Validate path arguments to prevent command injection try: project_root = Path(".").resolve() src_path = validate_path("src/cleveragents", project_root) whitelist_path = validate_path("vulture_whitelist.py", project_root) except ValueError as e: return False, f"Dead code: invalid path - {e}" result = subprocess.run( [ "vulture", str(src_path), str(whitelist_path), "--min-confidence", "80", ], capture_output=True, text=True, check=False, ) if result.returncode == 0: return True, "Dead code: none detected" lines = result.stdout.strip().split("\n") return False, f"Dead code: {len(lines)} issue(s) found" def check_complexity(max_grade: str = "E") -> tuple[bool, str]: """Check no functions exceed maximum complexity grade.""" # Validate path argument to prevent command injection try: project_root = Path(".").resolve() src_path = validate_path("src/cleveragents", project_root) except ValueError as e: return False, f"Complexity: invalid path - {e}" result = subprocess.run( [ "radon", "cc", str(src_path), "--min", max_grade, "--show-complexity", "--json", ], capture_output=True, text=True, check=False, ) try: data = json.loads(result.stdout) total_blocks = sum(len(blocks) for blocks in data.values()) if total_blocks == 0: return True, f"Complexity: no blocks with grade >= {max_grade}" return False, f"Complexity: {total_blocks} block(s) with grade >= {max_grade}" except json.JSONDecodeError: return True, "Complexity: passed (no extreme complexity)" def main() -> int: """Run all quality gates and report results.""" parser = argparse.ArgumentParser(description="Check quality gates") parser.add_argument( "--coverage-min", type=int, default=85, help="Minimum coverage percentage" ) parser.add_argument( "--complexity-max", type=str, default="F", help="Maximum allowed complexity grade (F = fail only on worst)", ) args = parser.parse_args() # Ensure build directory exists Path("build").mkdir(exist_ok=True) gates: list[tuple[str, tuple[bool, str]]] = [ ("Coverage", check_coverage(args.coverage_min)), ("Type Check", check_typecheck()), ("Security", check_security()), ("Dead Code", check_dead_code()), ("Complexity", check_complexity(args.complexity_max)), ] print("=" * 60) print("QUALITY GATE REPORT") print("=" * 60) all_passed = True for name, (passed, message) in gates: icon = "[+]" if passed else "[X]" print(f" {icon} {name}: {message}") if not passed: all_passed = False print("=" * 60) if all_passed: print("All quality gates PASSED") else: print("Some quality gates FAILED") print("=" * 60) return 0 if all_passed else 1 if __name__ == "__main__": sys.exit(main())