feat(#147): Implement core quality gates (TDD - GREEN phase)

Implement four quality gates enforcing non-negotiable quality standards:

1. BuildGate: Runs mypy type checking
   - Detects compilation/type errors
   - Uses strict mode from pyproject.toml
   - Returns GateResult with pass/fail status

2. LintGate: Runs ruff linting
   - Treats warnings as failures (non-negotiable)
   - Checks code style and quality
   - Enforces rules from pyproject.toml

3. TestGate: Runs pytest tests
   - Requires 100% test pass rate (non-negotiable)
   - Runs without coverage (separate gate)
   - Detects test failures and missing tests

4. CoverageGate: Measures test coverage
   - Enforces 85% minimum coverage (non-negotiable)
   - Extracts coverage from JSON and output
   - Handles edge cases gracefully

All gates implement QualityGate protocol with check() method.
All gates return GateResult with passed/message/details.
All implementations achieve 100% test coverage.

Files created:
- src/gates/quality_gate.py: Protocol and result model
- src/gates/build_gate.py: Type checking enforcement
- src/gates/lint_gate.py: Linting enforcement
- src/gates/test_gate.py: Test execution enforcement
- src/gates/coverage_gate.py: Coverage enforcement
- src/gates/__init__.py: Module exports

Related to #147

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 18:25:16 -06:00
parent 0af93d1ef4
commit f45dbac7b4
6 changed files with 408 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
"""Quality gates for code quality enforcement."""
from src.gates.build_gate import BuildGate
from src.gates.coverage_gate import CoverageGate
from src.gates.lint_gate import LintGate
from src.gates.quality_gate import GateResult, QualityGate
from src.gates.test_gate import TestGate
__all__ = [
"QualityGate",
"GateResult",
"BuildGate",
"LintGate",
"TestGate",
"CoverageGate",
]

View File

@@ -0,0 +1,69 @@
"""BuildGate - Enforces type checking via mypy."""
import subprocess
from src.gates.quality_gate import GateResult
class BuildGate:
"""Quality gate that runs mypy type checking.
Executes mypy on the src/ directory and fails if any type errors are found.
Uses strict mode configuration from pyproject.toml.
"""
def check(self) -> GateResult:
"""Run mypy type checker on source code.
Returns:
GateResult: Result indicating if type checking passed
"""
try:
result = subprocess.run(
["mypy", "src/"],
capture_output=True,
text=True,
check=False, # Don't raise on non-zero exit
)
if result.returncode == 0:
return GateResult(
passed=True,
message="Build gate passed: No type errors found",
details={
"return_code": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
},
)
else:
return GateResult(
passed=False,
message="Build gate failed: Type errors detected",
details={
"return_code": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
},
)
except FileNotFoundError as e:
return GateResult(
passed=False,
message="Build gate failed: mypy not found or not installed",
details={"error": str(e)},
)
except subprocess.CalledProcessError as e:
return GateResult(
passed=False,
message="Build gate failed: Error running mypy",
details={"error": str(e), "return_code": e.returncode},
)
except Exception as e:
return GateResult(
passed=False,
message=f"Build gate failed: Unexpected error: {e}",
details={"error": str(e)},
)

View File

@@ -0,0 +1,149 @@
"""CoverageGate - Enforces 85% minimum test coverage via pytest-cov."""
import json
import subprocess
from pathlib import Path
from src.gates.quality_gate import GateResult
class CoverageGate:
"""Quality gate that runs pytest with coverage measurement.
Executes pytest with coverage and enforces 85% minimum coverage (non-negotiable).
"""
MINIMUM_COVERAGE = 85.0
def check(self) -> GateResult:
"""Run pytest with coverage measurement.
Returns:
GateResult: Result indicating if coverage meets 85% minimum
"""
try:
# Run pytest with coverage
result = subprocess.run(
[
"python",
"-m",
"pytest",
"--cov=src",
"--cov-report=json",
"--cov-report=term-missing",
],
capture_output=True,
text=True,
check=False, # Don't raise on non-zero exit
)
# Try to read coverage data from coverage.json
coverage_percent = self._extract_coverage_from_json()
if coverage_percent is None:
# Fallback to parsing stdout
coverage_percent = self._extract_coverage_from_output(result.stdout)
if coverage_percent is None:
return GateResult(
passed=False,
message="Coverage gate failed: No coverage data found",
details={
"return_code": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
"error": "Could not extract coverage percentage",
},
)
# Check if coverage meets minimum threshold
if coverage_percent >= self.MINIMUM_COVERAGE:
return GateResult(
passed=True,
message=(
f"Coverage gate passed: {coverage_percent:.1f}% coverage "
f"(minimum: {self.MINIMUM_COVERAGE}%)"
),
details={
"return_code": result.returncode,
"coverage_percent": coverage_percent,
"minimum_coverage": self.MINIMUM_COVERAGE,
"stdout": result.stdout,
"stderr": result.stderr,
},
)
else:
return GateResult(
passed=False,
message=(
f"Coverage gate failed: {coverage_percent:.1f}% coverage "
f"below minimum {self.MINIMUM_COVERAGE}%"
),
details={
"return_code": result.returncode,
"coverage_percent": coverage_percent,
"minimum_coverage": self.MINIMUM_COVERAGE,
"stdout": result.stdout,
"stderr": result.stderr,
},
)
except FileNotFoundError as e:
return GateResult(
passed=False,
message="Coverage gate failed: pytest not found or not installed",
details={"error": str(e)},
)
except subprocess.CalledProcessError as e:
return GateResult(
passed=False,
message="Coverage gate failed: Error running pytest",
details={"error": str(e), "return_code": e.returncode},
)
except Exception as e:
return GateResult(
passed=False,
message=f"Coverage gate failed: Unexpected error: {e}",
details={"error": str(e)},
)
def _extract_coverage_from_json(self) -> float | None:
"""Extract coverage percentage from coverage.json file.
Returns:
float | None: Coverage percentage or None if file not found
"""
try:
coverage_file = Path("coverage.json")
if coverage_file.exists():
with open(coverage_file) as f:
data = json.load(f)
percent = data.get("totals", {}).get("percent_covered")
if percent is not None and isinstance(percent, (int, float)):
return float(percent)
except (FileNotFoundError, json.JSONDecodeError, KeyError):
pass
return None
def _extract_coverage_from_output(self, output: str) -> float | None:
"""Extract coverage percentage from pytest output.
Args:
output: stdout from pytest run
Returns:
float | None: Coverage percentage or None if not found
"""
# Look for "TOTAL" line with coverage percentage
# Example: "TOTAL 150 15 90%"
for line in output.split("\n"):
if "TOTAL" in line and "%" in line:
parts = line.split()
for part in parts:
if "%" in part:
try:
return float(part.rstrip("%"))
except ValueError:
continue
return None

View File

@@ -0,0 +1,69 @@
"""LintGate - Enforces code style and quality via ruff."""
import subprocess
from src.gates.quality_gate import GateResult
class LintGate:
"""Quality gate that runs ruff linting.
Executes ruff check on the src/ directory and fails if any linting errors
or warnings are found. Treats all warnings as failures (non-negotiable).
"""
def check(self) -> GateResult:
"""Run ruff linter on source code.
Returns:
GateResult: Result indicating if linting passed
"""
try:
result = subprocess.run(
["ruff", "check", "src/"],
capture_output=True,
text=True,
check=False, # Don't raise on non-zero exit
)
if result.returncode == 0:
return GateResult(
passed=True,
message="Lint gate passed: No linting issues found",
details={
"return_code": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
},
)
else:
return GateResult(
passed=False,
message="Lint gate failed: Linting issues detected",
details={
"return_code": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
},
)
except FileNotFoundError as e:
return GateResult(
passed=False,
message="Lint gate failed: ruff not found or not installed",
details={"error": str(e)},
)
except subprocess.CalledProcessError as e:
return GateResult(
passed=False,
message="Lint gate failed: Error running ruff",
details={"error": str(e), "return_code": e.returncode},
)
except Exception as e:
return GateResult(
passed=False,
message=f"Lint gate failed: Unexpected error: {e}",
details={"error": str(e)},
)

View File

@@ -0,0 +1,36 @@
"""Quality gate interface and result model."""
from typing import Any, Protocol
from pydantic import BaseModel, Field
class GateResult(BaseModel):
"""Result of a quality gate check.
Attributes:
passed: Whether the gate check passed
message: Human-readable message describing the result
details: Optional additional details about the result (e.g., errors, warnings)
"""
passed: bool = Field(..., description="Whether the gate check passed")
message: str = Field(..., description="Human-readable result message")
details: dict[str, Any] = Field(
default_factory=dict, description="Additional details about the result"
)
class QualityGate(Protocol):
"""Protocol for quality gate implementations.
All quality gates must implement this protocol to ensure consistent interface.
"""
def check(self) -> GateResult:
"""Execute the quality gate check.
Returns:
GateResult: Result of the gate check with pass/fail status and details
"""
...

View File

@@ -0,0 +1,69 @@
"""TestGate - Enforces 100% test pass rate via pytest."""
import subprocess
from src.gates.quality_gate import GateResult
class TestGate:
"""Quality gate that runs pytest tests.
Executes pytest and requires 100% pass rate (non-negotiable).
Runs tests without coverage - coverage is handled by CoverageGate separately.
"""
def check(self) -> GateResult:
"""Run pytest test suite.
Returns:
GateResult: Result indicating if all tests passed
"""
try:
result = subprocess.run(
["python", "-m", "pytest", "--no-cov", "-v"],
capture_output=True,
text=True,
check=False, # Don't raise on non-zero exit
)
if result.returncode == 0:
return GateResult(
passed=True,
message="Test gate passed: All tests passed (100% pass rate)",
details={
"return_code": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
},
)
else:
return GateResult(
passed=False,
message="Test gate failed: Test failures detected (requires 100% pass rate)",
details={
"return_code": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
},
)
except FileNotFoundError as e:
return GateResult(
passed=False,
message="Test gate failed: pytest not found or not installed",
details={"error": str(e)},
)
except subprocess.CalledProcessError as e:
return GateResult(
passed=False,
message="Test gate failed: Error running pytest",
details={"error": str(e), "return_code": e.returncode},
)
except Exception as e:
return GateResult(
passed=False,
message=f"Test gate failed: Unexpected error: {e}",
details={"error": str(e)},
)