This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
"""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"],
|
||||
}
|
||||
Reference in New Issue
Block a user