Files
SeedPass/scripts/check_critical_coverage.py
thePR0M3TH3AN 03958106e1 Add agent mode, hardened TUI harness, coverage gates, and cross-platform installer smoke CI
This commit delivers four related improvements focused on reliability and automation while preserving existing human/TUI workflows.

1) Agent-first CLI mode (additive)
- Added new `seedpass agent` command group (`src/seedpass/cli/agent.py`) and wired it into CLI root.
- Implemented non-interactive profile bootstrap: `seedpass agent init` with `--seed|--seed-file|--generate-seed` and password env injection (`--password-env`).
- Added policy management commands: `agent policy-show` and `agent policy-set` with allow/deny kind controls.
- Added JSON-based secret retrieval: `seedpass -f <fingerprint> agent get <query> --ttl <sec>` with lease metadata and policy enforcement.
- Kept regular human mode fully available; no behavioral changes to interactive TUI entrypoint.

2) TUI agent harness deepening and stabilization
- Refactored harness into scenario profiles: `core`, `extended`, `stress` with `--scenario` selection.
- Added deterministic seeded stress campaigns: `--stress-cycles`, `--stress-seed`.
- Added scenario-specific required coverage keys and report metadata.
- Added post-condition assertions in extended scenario (archive/restore consistency, lock/unlock retrieval recovery).
- Hardened prompt matching for output variants and fail-fast behavior on non-zero SeedPass exit codes.
- Added harness unit tests for helper logic and control flow.

3) Critical module coverage gate tooling
- Added `scripts/check_critical_coverage.py` to enforce per-module thresholds from coverage JSON and report uncovered functions.
- Added override controls (`--threshold`, `--no-default-thresholds`) and optional JSON result output.
- Integrated coverage JSON generation into CI helper script and optional gate execution via `CRITICAL_COVERAGE_GATE=1`.
- Tuned default thresholds using full-suite baseline and increased strictness:
  - `src/main.py >= 50%`
  - `src/seedpass/core/manager.py >= 60%`
  - `src/seedpass/core/encryption.py >= 80%`
  - `src/seedpass/core/migrations.py >= 90%`
- Added tests for coverage gate parser/analysis logic and targeted manager worker path tests.

4) Installer validation framework for beta/main
- Added cross-platform installer smoke scripts:
  - `scripts/installer_smoke_unix.sh`
  - `scripts/installer_smoke_windows.ps1`
- Added GitHub Actions workflow `.github/workflows/installer-smoke.yml` with:
  - reduced PR matrix
  - full scheduled/manual matrix across OS/branch/mode
  - per-job log capture and artifact uploads for failure triage
- Added installer testing documentation and linked from README.

Additional housekeeping
- Added `.venv/` to `.gitignore`.
- Updated docs for new harness scenarios, seeded stress mode, post-condition metadata, coverage gate usage, and installer testing workflow.
2026-02-23 15:47:59 -05:00

270 lines
8.6 KiB
Python

#!/usr/bin/env python3
"""
Enforce coverage thresholds for critical SeedPass modules.
Reads coverage.py JSON output (pytest --cov-report=json:<path>) and:
1. Fails if critical modules are below configured coverage thresholds.
2. Reports fully uncovered functions/methods for those modules.
"""
from __future__ import annotations
import argparse
import ast
import json
import sys
from dataclasses import dataclass
from pathlib import Path
DEFAULT_THRESHOLDS: dict[str, float] = {
"src/main.py": 50.0,
"src/seedpass/core/manager.py": 60.0,
"src/seedpass/core/encryption.py": 80.0,
"src/seedpass/core/migrations.py": 90.0,
}
@dataclass(frozen=True)
class FunctionCoverage:
name: str
start_line: int
end_line: int
covered: bool
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Validate coverage thresholds for critical modules."
)
parser.add_argument(
"coverage_json",
type=Path,
help="Path to coverage JSON report.",
)
parser.add_argument(
"--threshold",
action="append",
default=[],
metavar="PATH=PERCENT",
help="Override/add threshold mapping, e.g. src/main.py=40",
)
parser.add_argument(
"--no-default-thresholds",
action="store_true",
help="Disable built-in threshold set; use only --threshold entries.",
)
parser.add_argument(
"--repo-root",
type=Path,
default=Path(__file__).resolve().parents[1],
help="Repository root for resolving module paths.",
)
parser.add_argument(
"--json-output",
type=Path,
default=None,
help="Optional path to write machine-readable gate results JSON.",
)
return parser.parse_args()
def _parse_threshold_overrides(values: list[str]) -> dict[str, float]:
parsed: dict[str, float] = {}
for item in values:
if "=" not in item:
raise ValueError(
f"Invalid --threshold value {item!r}; expected PATH=PERCENT"
)
path, raw_percent = item.split("=", 1)
path = path.strip()
if not path:
raise ValueError(f"Invalid --threshold value {item!r}; empty path")
try:
percent = float(raw_percent)
except ValueError as exc:
raise ValueError(
f"Invalid --threshold value {item!r}; percent must be numeric"
) from exc
if not (0.0 <= percent <= 100.0):
raise ValueError(
f"Invalid --threshold value {item!r}; percent must be 0..100"
)
parsed[path] = percent
return parsed
def _load_coverage_json(path: Path) -> dict:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise RuntimeError(f"Coverage JSON not found: {path}") from exc
except json.JSONDecodeError as exc:
raise RuntimeError(f"Invalid coverage JSON: {path}: {exc}") from exc
def _normalize_path(path: str) -> str:
return path.replace("\\", "/")
def _find_file_payload(files_payload: dict, module_path: str):
module_norm = _normalize_path(module_path)
for candidate_key, payload in files_payload.items():
candidate_norm = _normalize_path(candidate_key)
if candidate_norm.endswith(module_norm):
return payload
return None
class _FunctionCollector(ast.NodeVisitor):
def __init__(self) -> None:
self.stack: list[str] = []
self.functions: list[tuple[str, int, int]] = []
def _collect_fn(self, node: ast.AST, fn_name: str) -> None:
end_line = getattr(node, "end_lineno", None) or getattr(node, "lineno", 0)
full_name = ".".join(self.stack + [fn_name])
self.functions.append((full_name, int(node.lineno), int(end_line)))
def visit_ClassDef(self, node: ast.ClassDef) -> None:
self.stack.append(node.name)
self.generic_visit(node)
self.stack.pop()
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
self._collect_fn(node, node.name)
self.generic_visit(node)
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
self._collect_fn(node, node.name)
self.generic_visit(node)
def _compute_function_coverage(
file_path: Path, executed_lines: set[int]
) -> list[FunctionCoverage]:
source = file_path.read_text(encoding="utf-8")
tree = ast.parse(source)
collector = _FunctionCollector()
collector.visit(tree)
functions: list[FunctionCoverage] = []
for name, start_line, end_line in collector.functions:
line_range = range(start_line, end_line + 1)
covered = any(line in executed_lines for line in line_range)
functions.append(
FunctionCoverage(
name=name,
start_line=start_line,
end_line=end_line,
covered=covered,
)
)
return functions
def main() -> int:
args = _parse_args()
try:
overrides = _parse_threshold_overrides(args.threshold)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 2
thresholds = {} if args.no_default_thresholds else dict(DEFAULT_THRESHOLDS)
thresholds.update(overrides)
if not thresholds:
print(
"Error: no thresholds configured. Provide --threshold entries.",
file=sys.stderr,
)
return 2
try:
coverage = _load_coverage_json(args.coverage_json)
except RuntimeError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 2
files_payload = coverage.get("files")
if not isinstance(files_payload, dict):
print("Error: coverage JSON missing 'files' map", file=sys.stderr)
return 2
repo_root = args.repo_root.resolve()
failing_modules: list[str] = []
result_payload: dict[str, dict] = {}
print("Critical module coverage gates:")
for module_path, threshold in sorted(thresholds.items()):
payload = _find_file_payload(files_payload, module_path)
module_result = {
"threshold_percent": threshold,
"actual_percent": None,
"meets_threshold": False,
"uncovered_functions": [],
"error": None,
}
if payload is None:
msg = "missing from coverage report"
print(f"- FAIL {module_path}: {msg}")
module_result["error"] = msg
failing_modules.append(module_path)
result_payload[module_path] = module_result
continue
summary = payload.get("summary", {})
percent = float(summary.get("percent_covered", 0.0))
module_result["actual_percent"] = percent
module_result["meets_threshold"] = percent >= threshold
module_file = (repo_root / module_path).resolve()
try:
executed_lines = {int(x) for x in payload.get("executed_lines", [])}
functions = _compute_function_coverage(module_file, executed_lines)
except Exception as exc: # pragma: no cover - defensive report path
module_result["error"] = f"function coverage analysis error: {exc}"
functions = []
uncovered = [
{
"name": fn.name,
"line": fn.start_line,
}
for fn in functions
if not fn.covered
]
module_result["uncovered_functions"] = uncovered
status = "PASS" if module_result["meets_threshold"] else "FAIL"
print(f"- {status} {module_path}: {percent:.2f}% (threshold {threshold:.2f}%)")
if uncovered:
preview = ", ".join(
f"{item['name']}@L{item['line']}" for item in uncovered[:8]
)
suffix = " ..." if len(uncovered) > 8 else ""
print(f" uncovered functions: {preview}{suffix}")
if not module_result["meets_threshold"]:
failing_modules.append(module_path)
result_payload[module_path] = module_result
output = {
"status": "passed" if not failing_modules else "failed",
"failing_modules": failing_modules,
"modules": result_payload,
"coverage_json": str(args.coverage_json.resolve()),
}
if args.json_output:
args.json_output.parent.mkdir(parents=True, exist_ok=True)
args.json_output.write_text(json.dumps(output, indent=2), encoding="utf-8")
if failing_modules:
print("\nCoverage gate failed for:")
for module in failing_modules:
print(f"- {module}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())