Files

357 lines
13 KiB
Python
Raw Permalink Normal View History

2026-07-12 16:00:16 +01:00
"""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).
2026-07-12 16:00:16 +01:00
"""
from __future__ import annotations
import importlib.util
import os
2026-07-12 16:00:16 +01:00
import re
2026-07-22 11:25:26 +01:00
import shutil
import subprocess
import sys
import tempfile
import tomllib
2026-07-12 16:00:16 +01:00
from dataclasses import dataclass
from pathlib import Path
from .results import GuardResult
2026-07-22 11:18:53 +01:00
from .skill_policy import catalog_snapshot, validate_catalog
2026-07-12 16:00:16 +01:00
__all__ = [
"GuardResult",
2026-07-12 16:00:16 +01:00
"ReleaseCheckResult",
"check_release",
"main",
"resolve_reference",
2026-07-12 16:00:16 +01:00
]
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
2026-07-12 16:00:16 +01:00
@dataclass
class ReleaseCheckResult(GuardResult):
pass
2026-07-12 16:00:16 +01:00
def _plugin_root() -> Path:
# plugins/violin_guard/release.py -> plugins/violin_guard
return Path(__file__).resolve().parent
2026-07-12 16:00:16 +01:00
2026-07-17 20:44:06 +01:00
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."""
2026-07-12 16:00:16 +01:00
plugin_yaml = root / "plugin.yaml"
provides_tools: list[str] = []
versions: dict[str, str] = {}
2026-07-12 16:00:16 +01:00
if plugin_yaml.exists():
import yaml
2026-07-12 16:00:16 +01:00
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):
2026-07-12 16:00:16 +01:00
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")
2026-07-12 16:00:16 +01:00
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()))}")
2026-07-12 16:00:16 +01:00
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."""
2026-07-13 08:53:57 +01:00
module_name = "violin_guard_release_check"
old_module = sys.modules.get(module_name)
mod = None
try:
2026-07-13 08:53:57 +01:00
sys.path.insert(0, str(root.parent))
spec = importlib.util.spec_from_file_location(
2026-07-13 08:53:57 +01:00
module_name,
root / "__init__.py",
submodule_search_locations=[str(root)],
)
2026-07-13 08:53:57 +01:00
if spec is None or spec.loader is None:
raise ImportError("could not build plugin import specification")
mod = importlib.util.module_from_spec(spec)
2026-07-13 08:53:57 +01:00
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}")
2026-07-13 08:53:57 +01:00
finally:
sys.path.pop(0)
if old_module is None:
sys.modules.pop(module_name, None)
else:
sys.modules[module_name] = old_module
2026-07-12 16:00:16 +01:00
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")
2026-07-12 16:00:16 +01:00
def _check_skill_snapshot(root: Path, result: ReleaseCheckResult) -> None:
"""Verify checked-in external-skill dependency manifest against approved catalog."""
2026-07-22 11:18:53 +01:00
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")
2026-07-12 16:00:16 +01:00
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",
2026-07-13 08:53:57 +01:00
"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)
2026-07-12 16:00:16 +01:00
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())