150 lines
5.8 KiB
Python
150 lines
5.8 KiB
Python
"""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"],
|
|
}
|