"""Structured wrappers around the repository's supported validation commands.""" from __future__ import annotations from pathlib import Path import shutil import subprocess import sys import time from typing import Any import uuid from .errors import LabyricornMcpError from .gitops import GitOperations from .safety import RepositoryGuard class ValidationOperations: BUILD_SOURCE_DIRECTORIES = ("assets", "configs", "content", "models", "scripts", "templates") def __init__(self, guard: RepositoryGuard, git: GitOperations): self.guard = guard self.git = git def _run(self, command: list[str], *, timeout: int) -> dict[str, Any]: started = time.monotonic() try: result = subprocess.run( command, cwd=self.guard.root, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, check=False, ) except subprocess.TimeoutExpired as exc: return { "passed": False, "returncode": None, "duration_seconds": round(time.monotonic() - started, 3), "stdout": (exc.stdout or "")[-100_000:] if isinstance(exc.stdout, str) else "", "stderr": "Validation timed out.", "timed_out": True, } except OSError as exc: raise LabyricornMcpError("VALIDATION_FAILED", f"Validation could not start: {exc}") from exc return { "passed": result.returncode == 0, "returncode": result.returncode, "duration_seconds": round(time.monotonic() - started, 3), "stdout": result.stdout[-100_000:], "stderr": result.stderr[-100_000:], "truncated": len(result.stdout) > 100_000 or len(result.stderr) > 100_000, } def _lektor_executable(self) -> str: adjacent = Path(sys.executable).with_name("lektor.exe" if sys.platform == "win32" else "lektor") if adjacent.is_file(): return str(adjacent) discovered = shutil.which("lektor") if discovered: return discovered raise LabyricornMcpError( "RUNTIME_MISSING", "Lektor is unavailable. Launch the MCP with the existing Lektor pipx Python environment or put lektor on PATH.", ) def build_site(self) -> dict[str, Any]: cache = self.guard.resolve(".cache") cache.mkdir(exist_ok=True) temporary = cache / f"labyricorn-mcp-build-{uuid.uuid4().hex}" source = temporary / "source" output = temporary / "output" source.mkdir(parents=True) try: for name in self.BUILD_SOURCE_DIRECTORIES: origin = self.guard.resolve(name, must_exist=True) if origin.is_dir(): self._validate_build_tree(origin) shutil.copytree( origin, source / name, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), ) shutil.copy2( self.guard.root / "Labyricorn.lektorproject", source / "Labyricorn.lektorproject", ) command = [ sys.executable, str(source / "scripts" / "build_with_projects.py"), "--site-root", str(source), "--cache-path", str(self.guard.resolve(".cache/project-sources")), "--output-path", str(output), "--lektor", self._lektor_executable(), ] result = self._run(command, timeout=600) result["index_created"] = (output / "index.html").is_file() result["output_retained"] = False result["passed"] = result["passed"] and result["index_created"] return result finally: shutil.rmtree(temporary, ignore_errors=True) def _validate_build_tree(self, root: Path) -> None: if not root.resolve().is_relative_to(self.guard.root): raise LabyricornMcpError("PATH_OUTSIDE_REPOSITORY", "A build source root resolves outside the repository.") for path in root.rglob("*"): if not path.resolve().is_relative_to(self.guard.root): raise LabyricornMcpError( "PATH_OUTSIDE_REPOSITORY", "A build source path resolves outside the repository." ) if path.is_symlink() or getattr(path, "is_junction", lambda: False)(): if not path.resolve().is_relative_to(self.guard.root): raise LabyricornMcpError( "PATH_OUTSIDE_REPOSITORY", "A linked build source path resolves outside the repository." ) def run_tests(self) -> dict[str, Any]: return self._run( [sys.executable, "-m", "unittest", "discover", "-s", "tests", "-v"], timeout=600 ) def check_diff(self) -> dict[str, Any]: result = self._run(["git", "diff", "--check", "HEAD"], timeout=60) result["changed_files"] = [entry["path"] for entry in self.git.status()["changes"]] return result def validate_changes(self) -> dict[str, Any]: build = self.build_site() tests = self.run_tests() diff = self.check_diff() return { "passed": build["passed"] and tests["passed"] and diff["passed"], "build": build, "tests": tests, "diff_check": diff, "warnings": [ line for result in (build, tests, diff) for line in result.get("stderr", "").splitlines() if "warning" in line.casefold() ], "changed_files": diff["changed_files"], }