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
284 lines
8.8 KiB
Python
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())
|