Files
cleveragents-core/scripts/check-quality-gates.py
T
HAL9000 bcc4400080
CI / lint (pull_request) Successful in 36s
CI / typecheck (pull_request) Successful in 1m0s
CI / quality (pull_request) Successful in 1m26s
CI / security (pull_request) Successful in 1m55s
CI / build (pull_request) Successful in 34s
CI / helm (pull_request) Successful in 38s
CI / push-validation (pull_request) Successful in 42s
CI / unit_tests (pull_request) Successful in 5m41s
CI / integration_tests (pull_request) Successful in 9m34s
CI / docker (pull_request) Successful in 1m42s
CI / coverage (pull_request) Successful in 11m2s
CI / status-check (pull_request) Successful in 3s
style: apply ruff format to fix CI lint failure
2026-06-10 05:05:40 -04:00

284 lines
8.8 KiB
Python

#!/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())