"""Release gate checks for the Violin plugin. The checker is a REAL gate: it runs an isolated plugin import, compares the manifest's provides_tools against the tools actually registered, and (unless disabled) shells out to ruff and pytest. Failures surface as errors and cause a non-zero exit code — so CI cannot pass a broken tree. Heavy checks (ruff/pytest) are gated behind VIOLIN_CHECK_RELEASE_SKIP_HEAVY=1 (default: run them). """ from __future__ import annotations import importlib.util import os import re import shutil import subprocess import sys import tempfile import tomllib from dataclasses import dataclass from pathlib import Path from .results import GuardResult from .skill_policy import catalog_snapshot, validate_catalog __all__ = [ "GuardResult", "ReleaseCheckResult", "check_release", "main", "resolve_reference", ] def _project_python(repo_root: Path) -> str: """Prefer the repository virtualenv when a profile runtime invokes the CLI.""" candidates = ( repo_root / ".venv" / "Scripts" / "python.exe", repo_root / ".venv" / "bin" / "python", ) for candidate in candidates: if candidate.exists(): return str(candidate) return sys.executable @dataclass class ReleaseCheckResult(GuardResult): pass def _plugin_root() -> Path: # plugins/violin_guard/release.py -> plugins/violin_guard return Path(__file__).resolve().parent def _pytest_basetemp(repo_path: Path) -> str: """Create pytest's private temp directory in the ignored engagement tree.""" engagement_root = repo_path / "engagements" engagement_root.mkdir(parents=True, exist_ok=True) return tempfile.mkdtemp(prefix=".pytest-release-", dir=engagement_root) def resolve_reference(source: Path, reference: str) -> Path: """Resolve a pentest skill reference from the skill package root.""" source = source.resolve() for parent in (source.parent, *source.parents): if parent.name == "pentest" and parent.parent.name == "skills": return (parent / reference).resolve() raise ValueError(f"source is not inside skills/pentest: {source}") def _check_manifest_and_changelog(root: Path, result: ReleaseCheckResult) -> list[str]: """Require exact SemVer equality across every published version surface.""" plugin_yaml = root / "plugin.yaml" provides_tools: list[str] = [] versions: dict[str, str] = {} if plugin_yaml.exists(): import yaml data = yaml.safe_load(plugin_yaml.read_text(encoding="utf-8")) version = str(data.get("version", "0.0.0")) versions["plugin manifest"] = version provides_tools = list(data.get("provides_tools", []) or []) if not re.fullmatch(r"\d+\.\d+\.\d+", version): result.add_error(f"plugin.yaml version '{version}' is not a valid semver") else: result.add_error("plugin.yaml not found") repo = root.parent.parent pyproject = repo / "pyproject.toml" if pyproject.exists(): project = tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"] versions["project"] = str(project.get("version", "")) dependencies = [str(value).lower() for value in project.get("dependencies", [])] if not any(value.startswith("pyyaml") for value in dependencies): result.add_error("PyYAML must be a runtime project dependency") else: result.add_info("PyYAML is declared as a runtime dependency") else: result.add_error("pyproject.toml not found") distribution = repo / "distribution.yaml" if distribution.exists(): import yaml versions["distribution"] = str( yaml.safe_load(distribution.read_text(encoding="utf-8")).get("version", "") ) else: result.add_error("distribution.yaml not found") changelog = repo / "CHANGELOG.md" if changelog.exists(): match = re.search( r"(?m)^## (\d+\.\d+\.\d+)(?: \(Unreleased\))?\s*$", changelog.read_text(encoding="utf-8"), ) if match: versions["changelog"] = match.group(1) else: result.add_error("CHANGELOG.md has no top SemVer entry") else: result.add_error("CHANGELOG.md not found") invalid = { name: value for name, value in versions.items() if not re.fullmatch(r"\d+\.\d+\.\d+", value) } for name, value in invalid.items(): result.add_error(f"{name} version '{value}' is not exact SemVer") if versions and len(set(versions.values())) != 1: result.add_error( "version mismatch: " + ", ".join(f"{name}={value}" for name, value in versions.items()) ) elif versions: result.add_info(f"all version surfaces match {next(iter(versions.values()))}") return provides_tools def _check_isolated_import_and_tools( root: Path, provides_tools: list[str], result: ReleaseCheckResult ) -> None: """Verify isolated plugin import and manifest vs registered tools match.""" module_name = "violin_guard_release_check" old_module = sys.modules.get(module_name) mod = None try: sys.path.insert(0, str(root.parent)) spec = importlib.util.spec_from_file_location( module_name, root / "__init__.py", submodule_search_locations=[str(root)], ) if spec is None or spec.loader is None: raise ImportError("could not build plugin import specification") mod = importlib.util.module_from_spec(spec) sys.modules[module_name] = mod spec.loader.exec_module(mod) result.add_info("isolated plugin import OK") except Exception as exc: # noqa: BLE001 result.add_error(f"plugin import failed: {type(exc).__name__}: {exc}") finally: sys.path.pop(0) if old_module is None: sys.modules.pop(module_name, None) else: sys.modules[module_name] = old_module if mod is not None: registered = sorted(getattr(mod, "REGISTERED_TOOLS", []) or []) if not registered: result.add_warning("plugin exposes no REGISTERED_TOOLS list") elif sorted(provides_tools) != registered: result.add_error( "provides_tools mismatch: manifest=" f"{sorted(provides_tools)} registered={registered}" ) else: result.add_info("provides_tools matches registered tools") definitions = list(getattr(mod, "TOOL_DEFINITIONS", []) or []) for definition in definitions: expected = mod.schemas.to_tool_schema(definition.model)["parameters"] if definition.schema.get("parameters") != expected: result.add_error(f"tool schema drift for {definition.name}") if len(definitions) == len(registered): result.add_info("tool registry models and exported schemas are consistent") def _check_skill_snapshot(root: Path, result: ReleaseCheckResult) -> None: """Verify checked-in external-skill dependency manifest against approved catalog.""" snapshot_path = root.parent.parent / "skills.snapshot.json" catalog_errors = validate_catalog() if catalog_errors: result.errors.extend(catalog_errors) elif not snapshot_path.exists(): result.add_error("skills.snapshot.json not found") else: import json try: checked_in = json.loads(snapshot_path.read_text(encoding="utf-8")) expected = catalog_snapshot(root.parent.parent) for entry in expected["skills"]: entry.pop("path", None) if checked_in != expected: result.add_error("skills.snapshot.json does not match the approved skill catalog") else: result.add_info("external skill dependency snapshot matches catalog") except (OSError, json.JSONDecodeError) as exc: result.add_error(f"skills.snapshot.json is invalid: {exc}") def _check_heavy_linter_and_tests(root: Path, result: ReleaseCheckResult) -> None: """Execute ruff and pytest heavy release gates unless opted out via env.""" if os.environ.get("VIOLIN_CHECK_RELEASE_SKIP_HEAVY") == "1": result.add_info("heavy checks skipped (VIOLIN_CHECK_RELEASE_SKIP_HEAVY=1)") return repo_path = root.parent.parent repo_root = str(repo_path) python = _project_python(repo_path) try: ruff = subprocess.run( [python, "-m", "ruff", "check", "."], cwd=repo_root, capture_output=True, text=True, ) if ruff.returncode != 0: result.add_error("ruff check failed:\n" + (ruff.stdout or ruff.stderr).strip()[:2000]) else: result.add_info("ruff check passed") except FileNotFoundError: result.add_warning("ruff not installed; skipped") basetemp = _pytest_basetemp(repo_path) try: pytest = subprocess.run( [ python, "-m", "pytest", "-q", "-p", "no:cacheprovider", "--basetemp", basetemp, ], cwd=repo_root, capture_output=True, text=True, ) if pytest.returncode != 0: result.add_error( "test suite failed:\n" + (pytest.stdout or pytest.stderr).strip()[:2000] ) else: result.add_info("test suite passed") except FileNotFoundError: result.add_warning("pytest not installed; skipped") finally: shutil.rmtree(basetemp, ignore_errors=True) def _check_stale_skill_docs(root: Path, result: ReleaseCheckResult) -> None: """Scan skill documentation for stale or deprecated guard surface references.""" profile_root = root.parent.parent skills_root = profile_root / "skills" forbidden = { "scripts/guard/": "removed legacy guard package", "hypothesis_guard.py": "removed hypothesis wrapper", "session_search": "unavailable session-search tool", "violin_record_history": "removed executor-owned history tool", "violin_message_tick": "removed model-visible message tool", "violin_guard.py close": "nonexistent close subcommand", "check-closeout": "nonexistent closeout subcommand", "sync-clear": "nonexistent sync-clear subcommand", "validate_scope_data": "private legacy scope validator", } if not skills_root.exists(): return docs = [*skills_root.rglob("*.md"), *skills_root.rglob("*.yaml")] for doc in docs: try: text = doc.read_text(encoding="utf-8") except Exception: continue for token, reason in forbidden.items(): if token in text: result.add_error( f"stale skill reference in {doc.relative_to(profile_root)}: " f"{token!r} ({reason})" ) if not any("stale skill reference" in e for e in result.errors): result.add_info("skill documentation matches the current guard surface") def _check_active_documentation_contracts(root: Path, result: ReleaseCheckResult) -> None: repo = root.parent.parent docs = [repo / "README.md", *sorted((repo / "skills").rglob("*.md"))] stale_credit = re.compile( r"(?:Recon|RECON):?\s*5|(?:Exploitation|EXPLOITATION):?\s*10", re.IGNORECASE ) stale_findings = re.compile(r"\|\s*Findings\s*\|[^\n]*findings\.yaml", re.IGNORECASE) for path in docs: text = path.read_text(encoding="utf-8") if stale_credit.search(text): result.add_error(f"stale sync-credit contract in {path.relative_to(repo)}") if stale_findings.search(text): result.add_error(f"stale canonical-finding contract in {path.relative_to(repo)}") if not any("contract in" in error for error in result.errors): result.add_info("active documentation contracts are current") def check_release() -> ReleaseCheckResult: """Run all release gate checks. This is a REAL gate — failures add errors and cause a non-zero exit code (see CLI cmd_check_release).""" result = ReleaseCheckResult() root = _plugin_root() provides_tools = _check_manifest_and_changelog(root, result) _check_isolated_import_and_tools(root, provides_tools, result) _check_skill_snapshot(root, result) _check_heavy_linter_and_tests(root, result) tests_dir = root.parent.parent / "tests" if tests_dir.exists(): result.add_info(f"tests directory found: {tests_dir}") else: result.add_warning("tests directory not found") _check_stale_skill_docs(root, result) _check_active_documentation_contracts(root, result) return result def main() -> int: """Print release diagnostics and return nonzero only when errors exist.""" result = check_release() for item in result.errors: print(f"ERROR: {item}") for item in result.warnings: print(f"WARN: {item}") for item in result.infos: print(f"OK: {item}") return 1 if result.errors else 0 if __name__ == "__main__": sys.exit(main())