mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
a617ccaf2d
* feat: validate eval manifest coverage states * test: create fixture directory explicitly --------- Co-authored-by: magnus919 <magnus919>
365 lines
12 KiB
Python
365 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Validation for this repository's eval manifest v1 contract."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
from dataclasses import dataclass, field
|
|
from functools import lru_cache
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Any
|
|
|
|
from jsonschema import Draft202012Validator
|
|
|
|
SCHEMA_VERSION = 1
|
|
STATE_NAMES = (
|
|
"manifest_present",
|
|
"schema_valid",
|
|
"executable_grader_bindings_present",
|
|
"recent_run_evidence_present",
|
|
"release_gated_evidence_present",
|
|
)
|
|
NOT_ASSESSED = "not_assessed"
|
|
NOT_APPLICABLE = "not_applicable"
|
|
SCHEMA_PATH = Path(__file__).resolve().parent.parent / "schemas" / "evals-v1.schema.json"
|
|
CASE_ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
|
|
|
|
@dataclass
|
|
class ValidationResult:
|
|
manifest_path: Path
|
|
case_count: int = 0
|
|
errors: list[str] = field(default_factory=list)
|
|
states: dict[str, bool | str] = field(
|
|
default_factory=lambda: {
|
|
"manifest_present": False,
|
|
"schema_valid": NOT_APPLICABLE,
|
|
"executable_grader_bindings_present": NOT_ASSESSED,
|
|
"recent_run_evidence_present": NOT_ASSESSED,
|
|
"release_gated_evidence_present": NOT_ASSESSED,
|
|
}
|
|
)
|
|
|
|
def error(self, location: str, message: str) -> None:
|
|
suffix = f" {location}" if location else ""
|
|
self.errors.append(f"{self.manifest_path}:{suffix}: {message}")
|
|
|
|
|
|
class DuplicateKeyError(ValueError):
|
|
def __init__(self, key: str):
|
|
super().__init__(f"duplicate key {key!r}")
|
|
self.key = key
|
|
|
|
|
|
def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
out: dict[str, Any] = {}
|
|
for key, value in pairs:
|
|
if key in out:
|
|
raise DuplicateKeyError(key)
|
|
out[key] = value
|
|
return out
|
|
|
|
|
|
def _json_pointer(path: Any) -> str:
|
|
if not path:
|
|
return "$"
|
|
location = "$"
|
|
for part in path:
|
|
location += f"[{part}]" if isinstance(part, int) else f".{part}"
|
|
return location
|
|
|
|
|
|
def _nonempty_string(value: Any) -> bool:
|
|
return isinstance(value, str) and bool(value.strip())
|
|
|
|
|
|
def _contains_control_characters(value: str) -> bool:
|
|
return any(ord(char) < 32 or ord(char) == 127 for char in value)
|
|
|
|
|
|
def _git(repo_root: Path, *args: str) -> subprocess.CompletedProcess[bytes]:
|
|
return subprocess.run(["git", *args], cwd=repo_root, capture_output=True, check=False)
|
|
|
|
|
|
def _tracked_files(repo_root: Path) -> set[str]:
|
|
result = _git(repo_root, "ls-files", "-z")
|
|
if result.returncode != 0:
|
|
return set()
|
|
return {
|
|
path
|
|
for path in result.stdout.decode("utf-8", errors="replace").split("\0")
|
|
if path
|
|
}
|
|
|
|
|
|
def _tracked_mode(repo_root: Path, relative_path: str) -> str | None:
|
|
result = _git(repo_root, "ls-files", "-s", "--", relative_path)
|
|
if result.returncode != 0 or not result.stdout:
|
|
return None
|
|
return result.stdout.decode("utf-8", errors="replace").split(None, 1)[0]
|
|
|
|
|
|
def _case_mismatch(tracked: set[str], relative_path: str) -> bool:
|
|
lower = relative_path.lower()
|
|
return any(path.lower() == lower and path != relative_path for path in tracked)
|
|
|
|
|
|
def _load_json_strict(path: Path) -> dict[str, Any]:
|
|
text = path.read_text(encoding="utf-8")
|
|
parsed = json.loads(text, object_pairs_hook=_reject_duplicate_keys)
|
|
if not isinstance(parsed, dict):
|
|
raise TypeError("top-level JSON value must be an object")
|
|
return parsed
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def _schema_validator() -> Draft202012Validator:
|
|
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
Draft202012Validator.check_schema(schema)
|
|
return Draft202012Validator(schema)
|
|
|
|
|
|
def _validate_schema(data: dict[str, Any], result: ValidationResult) -> None:
|
|
validator = _schema_validator()
|
|
for issue in sorted(validator.iter_errors(data), key=lambda e: list(e.path)):
|
|
result.error(_json_pointer(issue.absolute_path), issue.message)
|
|
|
|
|
|
def _targeted_schema_version_errors(data: dict[str, Any], result: ValidationResult) -> None:
|
|
if "schema_version" not in data:
|
|
result.error(
|
|
"$.schema_version",
|
|
"missing required schema_version; add \"schema_version\": 1",
|
|
)
|
|
return
|
|
version = data["schema_version"]
|
|
if isinstance(version, str):
|
|
result.error(
|
|
"$.schema_version",
|
|
"schema_version must be integer 1, not a string",
|
|
)
|
|
return
|
|
if isinstance(version, bool) or not isinstance(version, int):
|
|
result.error("$.schema_version", "schema_version must be integer 1")
|
|
return
|
|
if version != SCHEMA_VERSION:
|
|
result.error(
|
|
"$.schema_version",
|
|
f"unsupported schema_version {version!r}; supported version is 1",
|
|
)
|
|
|
|
|
|
def _validate_case_aliases(case: dict[str, Any], index: int, result: ValidationResult) -> None:
|
|
has_assertions = "assertions" in case
|
|
has_expectations = "expectations" in case
|
|
if has_assertions and has_expectations:
|
|
result.error(
|
|
f"$.evals[{index}].expectations",
|
|
"assertions and expectations cannot both be present; keep assertions only",
|
|
)
|
|
elif has_expectations:
|
|
result.error(
|
|
f"$.evals[{index}].expectations",
|
|
"expectations is not supported by repository schema v1; rename it to assertions",
|
|
)
|
|
|
|
|
|
def _validate_fixture_path(
|
|
value: Any,
|
|
*,
|
|
skill_root: Path,
|
|
repo_root: Path,
|
|
tracked: set[str],
|
|
location: str,
|
|
result: ValidationResult,
|
|
) -> None:
|
|
if not _nonempty_string(value):
|
|
result.error(location, "must be a nonempty relative path string")
|
|
return
|
|
|
|
path_text = value
|
|
if _contains_control_characters(path_text):
|
|
result.error(location, "must not contain control characters")
|
|
return
|
|
if "\\" in path_text:
|
|
result.error(location, "must not contain backslashes")
|
|
return
|
|
|
|
lexical_parts = path_text.split("/")
|
|
if any(part in {"", ".", ".."} for part in lexical_parts):
|
|
result.error(location, "must not contain '.', '..', or empty path components")
|
|
return
|
|
|
|
pure = PurePosixPath(path_text)
|
|
if pure.is_absolute() or not pure.parts:
|
|
result.error(location, "must be a relative path")
|
|
return
|
|
|
|
candidate = skill_root.joinpath(*lexical_parts)
|
|
relative = candidate.relative_to(repo_root).as_posix()
|
|
|
|
if _case_mismatch(tracked, relative):
|
|
result.error(location, f"path is case-mismatched with tracked Git path: {path_text}")
|
|
return
|
|
|
|
probe = skill_root
|
|
for part in lexical_parts:
|
|
probe = probe / part
|
|
if probe.is_symlink():
|
|
result.error(location, f"symlinks are not allowed in path components: {path_text}")
|
|
return
|
|
|
|
if not candidate.exists():
|
|
mode = _tracked_mode(repo_root, relative)
|
|
if mode is None:
|
|
result.error(location, f"file does not exist: {path_text}")
|
|
else:
|
|
result.error(
|
|
location,
|
|
f"file does not exist in working tree (tracked in Git index): {path_text}",
|
|
)
|
|
return
|
|
|
|
try:
|
|
resolved = candidate.resolve(strict=True)
|
|
resolved.relative_to(skill_root.resolve())
|
|
except ValueError:
|
|
result.error(location, f"path escapes skill root: {path_text}")
|
|
return
|
|
except OSError:
|
|
result.error(location, f"file does not exist: {path_text}")
|
|
return
|
|
|
|
if not resolved.is_file():
|
|
result.error(location, f"must resolve to a regular file: {path_text}")
|
|
return
|
|
|
|
if relative not in tracked:
|
|
result.error(location, f"file is not tracked by Git: {path_text}")
|
|
return
|
|
|
|
mode = _tracked_mode(repo_root, relative)
|
|
if mode is not None and mode == "120000":
|
|
result.error(location, f"tracked symlink is not allowed: {path_text}")
|
|
|
|
|
|
def _semantic_checks(data: dict[str, Any], result: ValidationResult, repo_root: Path) -> None:
|
|
manifest_path = result.manifest_path
|
|
skill_root = manifest_path.parent.parent.resolve()
|
|
tracked = _tracked_files(repo_root)
|
|
|
|
_targeted_schema_version_errors(data, result)
|
|
|
|
skill_name = data.get("skill_name")
|
|
if skill_name != skill_root.name:
|
|
result.error(
|
|
"$.skill_name",
|
|
f"must equal containing skill directory name {skill_root.name!r}",
|
|
)
|
|
|
|
evals = data.get("evals")
|
|
if isinstance(evals, list):
|
|
result.case_count = len(evals)
|
|
else:
|
|
result.case_count = 0
|
|
return
|
|
|
|
seen_ids: dict[str, int] = {}
|
|
for index, case in enumerate(evals):
|
|
if not isinstance(case, dict):
|
|
continue
|
|
_validate_case_aliases(case, index, result)
|
|
|
|
case_id = case.get("id")
|
|
if isinstance(case_id, str):
|
|
if len(case_id) > 64:
|
|
result.error(f"$.evals[{index}].id", "must be 1-64 characters")
|
|
if not CASE_ID_RE.fullmatch(case_id):
|
|
result.error(
|
|
f"$.evals[{index}].id",
|
|
"must match ^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
|
)
|
|
prior = seen_ids.get(case_id)
|
|
if prior is not None:
|
|
result.error(
|
|
f"$.evals[{index}].id",
|
|
f"duplicate case ID {case_id!r}; first seen at $.evals[{prior}].id",
|
|
)
|
|
else:
|
|
seen_ids[case_id] = index
|
|
|
|
assertions = case.get("assertions")
|
|
if isinstance(assertions, list):
|
|
seen_assertions: set[str] = set()
|
|
for assertion_index, assertion in enumerate(assertions):
|
|
if not _nonempty_string(assertion):
|
|
result.error(
|
|
f"$.evals[{index}].assertions[{assertion_index}]",
|
|
"must be a nonempty non-whitespace string",
|
|
)
|
|
continue
|
|
if assertion in seen_assertions:
|
|
result.error(
|
|
f"$.evals[{index}].assertions[{assertion_index}]",
|
|
"duplicate assertion values are not allowed",
|
|
)
|
|
continue
|
|
seen_assertions.add(assertion)
|
|
|
|
files = case.get("files")
|
|
if isinstance(files, list):
|
|
for file_index, file_value in enumerate(files):
|
|
_validate_fixture_path(
|
|
file_value,
|
|
skill_root=skill_root,
|
|
repo_root=repo_root,
|
|
tracked=tracked,
|
|
location=f"$.evals[{index}].files[{file_index}]",
|
|
result=result,
|
|
)
|
|
|
|
|
|
def validate_manifest(manifest_path: Path, repo_root: Path) -> ValidationResult:
|
|
"""Validate one manifest against schema and repository semantics."""
|
|
manifest_path = Path(manifest_path)
|
|
repo_root = Path(repo_root).resolve()
|
|
result = ValidationResult(manifest_path)
|
|
if not manifest_path.is_file():
|
|
return result
|
|
|
|
result.states["manifest_present"] = True
|
|
result.states["schema_valid"] = False
|
|
try:
|
|
data = _load_json_strict(manifest_path)
|
|
except DuplicateKeyError as exc:
|
|
result.error("$", f"duplicate JSON object key: {exc.key!r}")
|
|
return result
|
|
except json.JSONDecodeError as exc:
|
|
result.error("$", f"invalid JSON: {exc}")
|
|
return result
|
|
except OSError as exc:
|
|
result.error("$", f"unable to read manifest: {exc}")
|
|
return result
|
|
except TypeError as exc:
|
|
result.error("$", str(exc))
|
|
return result
|
|
|
|
_validate_schema(data, result)
|
|
_semantic_checks(data, result, repo_root)
|
|
|
|
result.states["schema_valid"] = not result.errors
|
|
return result
|
|
|
|
|
|
def find_skill_manifests(repo_root: Path) -> list[Path]:
|
|
manifests = []
|
|
for skill_file in Path(repo_root).glob("**/SKILL.md"):
|
|
if "agent-council/profiles/skills" in skill_file.as_posix():
|
|
continue
|
|
manifest = skill_file.parent / "evals" / "evals.json"
|
|
if manifest.is_file():
|
|
manifests.append(manifest)
|
|
return sorted(manifests)
|