feat: validate eval manifest coverage states (#129)

* feat: validate eval manifest coverage states

* test: create fixture directory explicitly

---------

Co-authored-by: magnus919 <magnus919>
This commit is contained in:
Magnus Hedemark
2026-07-24 18:13:32 -04:00
committed by GitHub
co-authored by magnus919 <magnus919>
parent 7495651fe5
commit a617ccaf2d
18 changed files with 1174 additions and 88 deletions
+10
View File
@@ -20,8 +20,18 @@ jobs:
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: '3.3' ruby-version: '3.3'
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Python validator dependencies
run: python3 -m pip install -r requirements-dev.txt
- name: Validate skill format and links - name: Validate skill format and links
run: ruby scripts/validate-skills.rb run: ruby scripts/validate-skills.rb
- name: Test eval manifest validation
run: python3 scripts/test-eval-validation.py
- name: Validate eval manifests
run: python3 scripts/validate-evals.py
- name: Test changed-skill quality validation - name: Test changed-skill quality validation
run: ruby scripts/test-validate-skill-quality.rb run: ruby scripts/test-validate-skill-quality.rb
- name: Validate changed skill quality - name: Validate changed skill quality
+25 -2
View File
@@ -97,9 +97,31 @@ Every skill description must start with an imperative verb and define both when
## Eval Requirements ## Eval Requirements
Every new skill must include `evals/evals.json` with at least five representative output-quality cases. Each case needs a realistic prompt, an expected outcome, and observable assertions. Trigger-only checks (should-trigger / should-not-trigger probes) are harness-specific and belong in a separate test set, not in `evals/evals.json`. `evals/evals.json` is a versioned contract owned by this repository; it is not part of the normative Agent Skills specification. The declarative contract is [`schemas/evals-v1.schema.json`](schemas/evals-v1.schema.json), and `assertions` is the canonical case field. Do not substitute or alias `expectations`.
Existing skills are grandfathered via `scripts/grandfathered-skills.txt`. As overall eval coverage climbs past 25%, modified skills without evals receive a warning; past 50%, they fail CI. The coverage report is available via `python3 scripts/eval-coverage.py`. The ratchet is enforced in CI via `python3 scripts/eval-coverage.py --modified-from <base-sha>` on every pull request. A skill is considered modified when any tracked file under its directory changes, not only `SKILL.md`. Coverage must not decrease between the base revision and the candidate; a decrease fails CI. Every new skill must include a schema-versioned `evals/evals.json` with at least five representative output-quality cases. Each case needs a stable ID, realistic prompt, expected outcome, and observable assertions. Renaming an eval ID breaks durable evidence references; do not attempt heuristic rename matching. Trigger-only checks (should-trigger / should-not-trigger probes) are harness-specific and belong in a separate test set, not in `evals/evals.json`.
Coverage reports these five states separately:
| State | Evidence required |
|-------|-------------------|
| `manifest_present` | `evals/evals.json` exists. This alone does not prove behavioral quality. |
| `schema_valid` | The manifest passes the repository's v1 structural and semantic validation. |
| `executable_grader_bindings_present` | Not assessed in v1. Requires a separate versioned grader-binding contract. |
| `recent_run_evidence_present` | Not assessed in v1. Requires a separate versioned provenance/freshness contract. |
| `release_gated_evidence_present` | Not assessed in v1. Requires a separate versioned release-gate contract. |
v1 only covers manifest structure and semantic validity. It does not establish runtime provenance or release-gate evidence. Validate the contract and run its focused tests with:
```sh
python3 -m venv .venv
. .venv/bin/activate
python3 -m pip install -r requirements-dev.txt
python3 scripts/test-eval-validation.py
python3 scripts/validate-evals.py
```
Existing skills are grandfathered via `scripts/grandfathered-skills.txt`. As schema-valid manifest coverage climbs past 25%, modified skills without valid manifests receive a warning; past 50%, they fail CI. The coverage report is available via `python3 scripts/eval-coverage.py`. The ratchet is enforced in CI via `python3 scripts/eval-coverage.py --modified-from <base-sha>` on every pull request. A skill is considered modified when any tracked file under its directory changes, not only `SKILL.md`. Schema-valid manifest coverage must not decrease between the base revision and the candidate; a decrease fails CI.
## Best Practices ## Best Practices
@@ -130,6 +152,7 @@ When creating or modifying a skill in this repo, validate against the format:
- **`README.md` exists in the skill root** with all required sections (see [README Format](#readme-format) above) - **`README.md` exists in the skill root** with all required sections (see [README Format](#readme-format) above)
- **README is written for humans** — no agent instructions, JSON schemas, or progressive disclosure notes in the README. Those belong in `SKILL.md`. - **README is written for humans** — no agent instructions, JSON schemas, or progressive disclosure notes in the README. Those belong in `SKILL.md`.
- **`evals/evals.json` exists** with at least five output-quality cases for new skills (see [Eval Requirements](#eval-requirements)) - **`evals/evals.json` exists** with at least five output-quality cases for new skills (see [Eval Requirements](#eval-requirements))
- `python3 scripts/validate-evals.py` accepts every present eval manifest
### Generated Artifacts ### Generated Artifacts
+8 -1
View File
@@ -20,7 +20,9 @@ Each skill must:
- Use relative links that work from a fresh clone. - Use relative links that work from a fresh clone.
- Use an imperative-verb description that defines both positive and negative trigger boundaries. - Use an imperative-verb description that defines both positive and negative trigger boundaries.
- Describe when the skill should be loaded, and identify the nearest alternative when overlap matters. - Describe when the skill should be loaded, and identify the nearest alternative when overlap matters.
- Include `evals/evals.json` with at least five representative output-quality cases for every new skill. Existing skills are grandfathered via `scripts/grandfathered-skills.txt`; the ratchet tightens as overall coverage climbs. The ratchet runs in CI on every pull request via `python3 scripts/eval-coverage.py --modified-from <base-sha>`. A skill counts as modified when any tracked file under its directory changes. Coverage must not decrease between the base revision and the candidate. - Include `evals/evals.json` with explicit `schema_version: 1` and at least five representative output-quality cases for every new skill. This is a repository-owned contract, not part of the normative Agent Skills specification; see [`schemas/evals-v1.schema.json`](schemas/evals-v1.schema.json). Use canonical `assertions`, not `expectations`. Existing skills are grandfathered via `scripts/grandfathered-skills.txt`; the ratchet tightens as schema-valid manifest coverage climbs. The ratchet runs in CI on every pull request via `python3 scripts/eval-coverage.py --modified-from <base-sha>`. A skill counts as modified when any tracked file under its directory changes. Schema-valid manifest coverage must not decrease between the base revision and the candidate.
The coverage report keeps claims separate. `manifest_present` means only that a file exists. Per skill, `schema_valid` is `not_applicable` when `evals/evals.json` is missing, `false` when a present manifest fails parsing/schema/semantic validation, and `true` only when the manifest passes repository v1 validation. Aggregate schema-valid coverage counts only skills where `schema_valid` is `true`. The remaining states are named but intentionally `not_assessed` in v1: `executable_grader_bindings_present`, `recent_run_evidence_present`, and `release_gated_evidence_present`. Those require separate versioned contracts for grader bindings, provenance/freshness, and release-gate evidence.
## Development ## Development
@@ -29,8 +31,13 @@ Clone the repository and run the validators from its root:
```sh ```sh
git clone https://github.com/magnus919/agent-skills.git git clone https://github.com/magnus919/agent-skills.git
cd agent-skills cd agent-skills
python3 -m venv .venv
. .venv/bin/activate
python3 -m pip install -r requirements-dev.txt
ruby scripts/validate-skills.rb ruby scripts/validate-skills.rb
ruby scripts/validate-skill-quality.rb --base origin/main ruby scripts/validate-skill-quality.rb --base origin/main
python3 scripts/test-eval-validation.py
python3 scripts/validate-evals.py
python3 scripts/test-eval-coverage.py python3 scripts/test-eval-coverage.py
python3 scripts/eval-coverage.py python3 scripts/eval-coverage.py
``` ```
+1
View File
@@ -1,4 +1,5 @@
{ {
"schema_version": 1,
"skill_name": "de-spin", "skill_name": "de-spin",
"evals": [ "evals": [
{ {
+1
View File
@@ -1,4 +1,5 @@
{ {
"schema_version": 1,
"skill_name": "esp32-development", "skill_name": "esp32-development",
"evals": [ "evals": [
{ {
+1
View File
@@ -1,4 +1,5 @@
{ {
"schema_version": 1,
"skill_name": "raleigh", "skill_name": "raleigh",
"evals": [ "evals": [
{ {
+2
View File
@@ -0,0 +1,2 @@
jsonschema[format]==4.26.0
requests==2.34.2
+1
View File
@@ -1,4 +1,5 @@
{ {
"schema_version": 1,
"skill_name": "restic", "skill_name": "restic",
"evals": [ "evals": [
{ {
+50
View File
@@ -0,0 +1,50 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/magnus919/agent-skills/schemas/evals-v1.schema.json",
"title": "agent-skills repository eval manifest v1",
"type": "object",
"additionalProperties": false,
"required": ["schema_version", "skill_name", "evals"],
"properties": {
"schema_version": {"type": "integer", "const": 1},
"skill_name": {"type": "string", "minLength": 1, "pattern": "\\S"},
"evals": {
"type": "array",
"minItems": 1,
"items": {"$ref": "#/$defs/eval_case"}
}
},
"$defs": {
"relative_path": {
"type": "string",
"minLength": 1,
"pattern": "^(?=.*\\S)(?!/)(?!\\./)(?!.*//)(?!.*(?:^|/)\\.(?:/|$))(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\)(?!.*\\/$).+$"
},
"eval_case": {
"type": "object",
"additionalProperties": false,
"required": ["id", "prompt", "expected_output", "assertions"],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
},
"prompt": {"type": "string", "minLength": 1, "pattern": "\\S"},
"expected_output": {"type": "string", "minLength": 1, "pattern": "\\S"},
"assertions": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {"type": "string", "minLength": 1, "pattern": "\\S"}
},
"files": {
"type": "array",
"uniqueItems": true,
"items": {"$ref": "#/$defs/relative_path"}
}
}
}
}
}
+110 -84
View File
@@ -11,13 +11,17 @@ Usage:
""" """
import argparse import argparse
import io
import json import json
import subprocess import subprocess
import sys import sys
import tarfile
import tempfile
from pathlib import Path from pathlib import Path
from eval_validation import NOT_ASSESSED, STATE_NAMES, ValidationResult, validate_manifest
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
GRANDFATHER_FILE = ROOT / "scripts" / "grandfathered-skills.txt"
# Phase 3 ratchet thresholds (percent of skills with evals) # Phase 3 ratchet thresholds (percent of skills with evals)
WARN_THRESHOLD = 25 # modified skills without evals get a warning WARN_THRESHOLD = 25 # modified skills without evals get a warning
@@ -48,10 +52,28 @@ def find_skills() -> list[Path]:
return sorted(skills) return sorted(skills)
def resolve_ref_to_commit(ref: str) -> str:
"""Resolve *ref* to a commit SHA or raise ValueError.
The caller must use only the returned SHA in subsequent git commands.
"""
result = subprocess.run(
["git", "rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}"],
cwd=ROOT,
capture_output=True,
text=True,
)
if result.returncode != 0:
raise ValueError(
f"invalid --modified-from ref: {ref!r} is not an existing commit"
)
return result.stdout.strip()
def find_skills_at(ref: str) -> list[Path]: def find_skills_at(ref: str) -> list[Path]:
"""Find canonical skill directories tracked at a git revision.""" """Find canonical skill directories tracked at a git revision."""
result = subprocess.run( result = subprocess.run(
["git", "ls-tree", "-r", "--name-only", ref], ["git", "ls-tree", "-r", "--name-only", ref, "--"],
cwd=ROOT, cwd=ROOT,
check=True, check=True,
capture_output=True, capture_output=True,
@@ -66,30 +88,16 @@ def find_skills_at(ref: str) -> list[Path]:
skills.append(Path(name).parent) skills.append(Path(name).parent)
return sorted(skills) return sorted(skills)
def check_eval_states(skill_dir: Path) -> ValidationResult:
def load_grandfathered() -> set[str]: """Return validation and evidence states for one skill."""
if not GRANDFATHER_FILE.exists(): evals_file = ROOT / skill_dir / "evals" / "evals.json"
return set() return validate_manifest(evals_file, ROOT)
return {
line.strip()
for line in GRANDFATHER_FILE.read_text().splitlines()
if line.strip() and not line.strip().startswith("#")
}
def check_evals(skill_dir: Path) -> tuple[bool, int]: def check_evals(skill_dir: Path) -> tuple[bool, int]:
"""Return (has_valid_evals, case_count) for a skill directory.""" """Return (has_schema_valid_manifest, case_count) for compatibility."""
evals_file = ROOT / skill_dir / "evals" / "evals.json" result = check_eval_states(skill_dir)
if not evals_file.exists(): return result.states["schema_valid"] is True, result.case_count
return False, 0
try:
data = json.loads(evals_file.read_text(encoding="utf-8"))
if isinstance(data, dict) and isinstance(data.get("evals"), list):
count = len(data["evals"])
return count > 0, count
return False, 0
except (json.JSONDecodeError, OSError):
return False, 0
def count_references(skill_name: str, all_skill_dirs: list[Path]) -> int: def count_references(skill_name: str, all_skill_dirs: list[Path]) -> int:
@@ -107,7 +115,7 @@ def count_references(skill_name: str, all_skill_dirs: list[Path]) -> int:
return count return count
def modified_skills(base_ref: str) -> set[Path]: def modified_skills(base_ref_commit: str) -> set[Path]:
"""Return skill directories with any tracked file changed since base_ref. """Return skill directories with any tracked file changed since base_ref.
A skill is considered modified when *any* file under its directory A skill is considered modified when *any* file under its directory
@@ -115,7 +123,7 @@ def modified_skills(base_ref: str) -> set[Path]:
fixtures, README, and eval manifests. fixtures, README, and eval manifests.
""" """
result = subprocess.run( result = subprocess.run(
["git", "diff", "--name-only", base_ref, "HEAD"], ["git", "diff", "--name-only", base_ref_commit, "HEAD", "--"],
cwd=ROOT, cwd=ROOT,
capture_output=True, capture_output=True,
text=True, text=True,
@@ -129,7 +137,7 @@ def modified_skills(base_ref: str) -> set[Path]:
# Include skills from both revisions so complete directory deletions # Include skills from both revisions so complete directory deletions
# remain observable under their old name. # remain observable under their old name.
known_skills = sorted( known_skills = sorted(
set(find_skills()) | set(find_skills_at(base_ref)), set(find_skills()) | set(find_skills_at(base_ref_commit)),
key=lambda path: len(path.parts), key=lambda path: len(path.parts),
reverse=True, reverse=True,
) )
@@ -159,62 +167,50 @@ def evaluate_ratchet(
name = str(skill_dir) name = str(skill_dir)
if coverage_pct >= FAIL_THRESHOLD: if coverage_pct >= FAIL_THRESHOLD:
errors.append( errors.append(
f"{name}: modified skill has no evals " f"{name}: modified skill has no schema-valid eval manifest "
f"(coverage {coverage_pct:.1f}% >= {FAIL_THRESHOLD}% — " f"(coverage {coverage_pct:.1f}% >= {FAIL_THRESHOLD}% — "
"evals required on modification)" "evals required on modification)"
) )
elif coverage_pct >= WARN_THRESHOLD: elif coverage_pct >= WARN_THRESHOLD:
warnings.append( warnings.append(
f"{name}: modified skill has no evals " f"{name}: modified skill has no schema-valid eval manifest "
f"(coverage {coverage_pct:.1f}% >= {WARN_THRESHOLD}% — " f"(coverage {coverage_pct:.1f}% >= {WARN_THRESHOLD}% — "
"evals recommended)" "evals recommended)"
) )
return warnings, errors return warnings, errors
def coverage_decreased(base_ref: str) -> tuple[bool, float, float]: def coverage_decreased(base_ref_commit: str) -> tuple[bool, float, float]:
"""Compare eval coverage between base_ref and HEAD. """Compare eval coverage between base_ref and HEAD.
Returns (decreased, base_pct, head_pct). Coverage is the percentage Returns (decreased, base_pct, head_pct). Coverage is the percentage
of canonical skills that have a non-empty evals/evals.json. of canonical skills that have a schema-valid v1 evals/evals.json.
""" """
head_skills = find_skills() head_skills = find_skills()
head_with = sum(1 for s in head_skills if check_evals(s)[0]) head_with = sum(1 for s in head_skills if check_evals(s)[0])
head_pct = (head_with / len(head_skills) * 100) if head_skills else 0.0 head_pct = (head_with / len(head_skills) * 100) if head_skills else 0.0
# Count skills with evals at the base revision. base_skill_dirs = set(find_skills_at(base_ref_commit))
result = subprocess.run(
["git", "ls-tree", "-r", "--name-only", base_ref],
cwd=ROOT,
capture_output=True,
text=True,
)
base_skill_dirs: set[Path] = set()
for line in result.stdout.strip().splitlines():
if (
line
and line.endswith("/SKILL.md")
and "/agent-council/profiles/skills/" not in line
):
base_skill_dirs.add(Path(line).parent)
base_with = 0 base_with = 0
for skill_dir in base_skill_dirs: archive = subprocess.run(
evals_path = f"{skill_dir}/evals/evals.json" ["git", "archive", "--format=tar", base_ref_commit, "--"],
cat = subprocess.run( cwd=ROOT,
["git", "show", f"{base_ref}:{evals_path}"], check=True,
cwd=ROOT, capture_output=True,
).stdout
with tempfile.TemporaryDirectory() as tmp:
snapshot = Path(tmp)
with tarfile.open(fileobj=io.BytesIO(archive), mode="r:") as tar:
tar.extractall(snapshot, filter="data")
subprocess.run(["git", "init", "-q"], cwd=snapshot, check=True)
subprocess.run(
["git", "add", "-f", "--all"], cwd=snapshot, check=True,
capture_output=True, capture_output=True,
text=True,
) )
if cat.returncode != 0: for skill_dir in base_skill_dirs:
continue manifest = snapshot / skill_dir / "evals" / "evals.json"
try: if validate_manifest(manifest, snapshot).states["schema_valid"] is True:
data = json.loads(cat.stdout)
if isinstance(data, dict) and isinstance(data.get("evals"), list) and len(data["evals"]) > 0:
base_with += 1 base_with += 1
except (json.JSONDecodeError, ValueError):
pass
base_pct = (base_with / len(base_skill_dirs) * 100) if base_skill_dirs else 0.0 base_pct = (base_with / len(base_skill_dirs) * 100) if base_skill_dirs else 0.0
return head_pct < base_pct, base_pct, head_pct return head_pct < base_pct, base_pct, head_pct
@@ -230,22 +226,54 @@ def main() -> int:
) )
args = parser.parse_args() args = parser.parse_args()
resolved_base_ref = None
if args.modified_from:
try:
resolved_base_ref = resolve_ref_to_commit(args.modified_from)
except ValueError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
skills = find_skills() skills = find_skills()
grandfathered = load_grandfathered()
total = len(skills) total = len(skills)
with_evals: list[dict] = [] skill_states: list[dict] = []
without_evals: list[str] = [] without_evals: list[str] = []
for skill_dir in skills: for skill_dir in skills:
has, count = check_evals(skill_dir) validation = check_eval_states(skill_dir)
has = validation.states["schema_valid"]
count = validation.case_count
name = str(skill_dir) name = str(skill_dir)
if has: skill_states.append({"skill": name, "cases": count, **validation.states})
with_evals.append({"skill": name, "cases": count}) if has is not True:
else:
without_evals.append(name) without_evals.append(name)
coverage_pct = (len(with_evals) / total * 100) if total else 0.0 supported_states = ("manifest_present", "schema_valid")
state_summary: dict[str, dict[str, object]] = {}
for state in supported_states:
count = sum(1 for entry in skill_states if entry[state] is True)
state_summary[state] = {
"assessment": "supported",
"count": count,
"percentage": round((count / total * 100) if total else 0.0, 1),
}
not_assessed_reasons = {
"executable_grader_bindings_present": "No repository contract for executable grader bindings in schema v1.",
"recent_run_evidence_present": "No versioned provenance/freshness contract is defined for schema v1.",
"release_gated_evidence_present": "No versioned release-gate contract is defined for schema v1.",
}
for state in STATE_NAMES:
if state in state_summary:
continue
state_summary[state] = {
"assessment": NOT_ASSESSED,
"count": None,
"percentage": None,
"reason": not_assessed_reasons[state],
}
coverage_pct = state_summary["schema_valid"]["percentage"]
# Sort skills without evals: most-referenced first, then alphabetical # Sort skills without evals: most-referenced first, then alphabetical
ref_counts = { ref_counts = {
@@ -256,8 +284,8 @@ def main() -> int:
# Phase 3 ratchet check # Phase 3 ratchet check
ratchet_warnings: list[str] = [] ratchet_warnings: list[str] = []
ratchet_errors: list[str] = [] ratchet_errors: list[str] = []
if args.modified_from: if resolved_base_ref:
modified = modified_skills(args.modified_from) modified = modified_skills(resolved_base_ref)
ratchet_warnings, ratchet_errors = evaluate_ratchet( ratchet_warnings, ratchet_errors = evaluate_ratchet(
modified=modified, modified=modified,
current=set(skills), current=set(skills),
@@ -266,7 +294,7 @@ def main() -> int:
) )
# Monotonic coverage floor: fail if coverage decreased. # Monotonic coverage floor: fail if coverage decreased.
decreased, base_pct, head_pct = coverage_decreased(args.modified_from) decreased, base_pct, head_pct = coverage_decreased(resolved_base_ref)
if decreased: if decreased:
ratchet_errors.append( ratchet_errors.append(
f"eval coverage decreased from {base_pct:.1f}% to {head_pct:.1f}% " f"eval coverage decreased from {base_pct:.1f}% to {head_pct:.1f}% "
@@ -278,14 +306,8 @@ def main() -> int:
json.dumps( json.dumps(
{ {
"total_skills": total, "total_skills": total,
"skills_with_evals": len(with_evals), "states": state_summary,
"skills_without_evals": len(without_evals), "skills": skill_states,
"coverage_pct": round(coverage_pct, 1),
"with_evals": with_evals,
"without_evals": [
{"skill": n, "references": ref_counts.get(n, 0)}
for n in without_evals
],
"ratchet": { "ratchet": {
"warn_threshold": WARN_THRESHOLD, "warn_threshold": WARN_THRESHOLD,
"fail_threshold": FAIL_THRESHOLD, "fail_threshold": FAIL_THRESHOLD,
@@ -297,14 +319,18 @@ def main() -> int:
) )
) )
else: else:
print(f"Eval coverage: {len(with_evals)}/{total} skills ({coverage_pct:.1f}%)") print("Eval evidence states (a manifest alone does not prove behavioral quality):")
for state in STATE_NAMES:
summary = state_summary[state]
if summary["assessment"] == "supported":
print(
f" {state}: {summary['count']}/{total} skills "
f"({summary['percentage']:.1f}%)"
)
continue
print(f" {state}: not assessed ({summary['reason']})")
print() print()
if with_evals: print(f"Skills WITHOUT schema-valid eval manifests ({len(without_evals)}), by reference count:")
print("Skills WITH evals:")
for entry in with_evals:
print(f" + {entry['skill']} ({entry['cases']} cases)")
print()
print(f"Skills WITHOUT evals ({len(without_evals)}), by reference count:")
for name in without_evals: for name in without_evals:
refs = ref_counts.get(name, 0) refs = ref_counts.get(name, 0)
print(f" - {name} (referenced by {refs} skills)") print(f" - {name} (referenced by {refs} skills)")
+364
View File
@@ -0,0 +1,364 @@
#!/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)
+180 -1
View File
@@ -6,16 +6,20 @@ coverage-decrease detection, and threshold behavior.
""" """
import json import json
import io
import os import os
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import unittest import unittest
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path from pathlib import Path
from unittest import mock
# Import the module under test. The script is named eval-coverage.py # Import the module under test. The script is named eval-coverage.py
# (hyphenated), so we load it via importlib rather than a normal import. # (hyphenated), so we load it via importlib rather than a normal import.
import importlib.util # noqa: E402 import importlib.util # noqa: E402
from eval_validation import NOT_APPLICABLE # noqa: E402
SCRIPT_DIR = Path(__file__).resolve().parent SCRIPT_DIR = Path(__file__).resolve().parent
_spec = importlib.util.spec_from_file_location( _spec = importlib.util.spec_from_file_location(
@@ -49,7 +53,7 @@ def write_skill(repo: str, name: str, evals: list[dict] | None = None) -> None:
evals_dir = skill_dir / "evals" evals_dir = skill_dir / "evals"
evals_dir.mkdir(exist_ok=True) evals_dir.mkdir(exist_ok=True)
(evals_dir / "evals.json").write_text( (evals_dir / "evals.json").write_text(
json.dumps({"skill_name": name, "evals": evals}, indent=2) json.dumps({"schema_version": 1, "skill_name": name, "evals": evals}, indent=2)
) )
@@ -275,6 +279,24 @@ class TestCoverageDecreased(unittest.TestCase):
self.assertAlmostEqual(base_pct, 100.0) self.assertAlmostEqual(base_pct, 100.0)
self.assertAlmostEqual(head_pct, 50.0) self.assertAlmostEqual(head_pct, 50.0)
def test_replacing_valid_manifest_with_invalid_is_regression(self) -> None:
write_skill(self.repo, "s1", evals=[make_case("c1")])
base = self._commit("valid manifest")
(Path(self.repo) / "s1" / "evals" / "evals.json").write_text(
json.dumps({"schema_version": 1, "skill_name": "s1", "evals": []}),
encoding="utf-8",
)
self._commit("break manifest")
old_root = eval_coverage.ROOT
eval_coverage.ROOT = Path(self.repo)
try:
decreased, base_pct, head_pct = eval_coverage.coverage_decreased(base)
finally:
eval_coverage.ROOT = old_root
self.assertTrue(decreased)
self.assertAlmostEqual(base_pct, 100.0)
self.assertAlmostEqual(head_pct, 0.0)
def test_no_decrease_when_stable(self) -> None: def test_no_decrease_when_stable(self) -> None:
write_skill(self.repo, "s1", evals=[make_case("c1")]) write_skill(self.repo, "s1", evals=[make_case("c1")])
write_skill(self.repo, "s2") write_skill(self.repo, "s2")
@@ -361,5 +383,162 @@ class TestRatchetThresholds(unittest.TestCase):
self.assertEqual([], errors) self.assertEqual([], errors)
class TestBaseRefValidation(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.repo = self.tmp.name
git(self.repo, "init", "-b", "main")
git(self.repo, "config", "user.email", "test@test.invalid")
git(self.repo, "config", "user.name", "Test")
write_skill(self.repo, "alpha", evals=[make_case("c1")])
git(self.repo, "add", "-A")
git(self.repo, "commit", "-m", "initial")
def tearDown(self) -> None:
self.tmp.cleanup()
def test_main_rejects_invalid_modified_from_ref(self) -> None:
old_root = eval_coverage.ROOT
eval_coverage.ROOT = Path(self.repo)
stderr = io.StringIO()
try:
with mock.patch.object(
sys, "argv", ["eval-coverage.py", "--modified-from", "does-not-exist"]
), redirect_stderr(stderr):
exit_code = eval_coverage.main()
finally:
eval_coverage.ROOT = old_root
self.assertEqual(2, exit_code)
self.assertEqual(
"ERROR: invalid --modified-from ref: 'does-not-exist' is not an existing commit\n",
stderr.getvalue(),
)
def test_main_rejects_option_like_modified_from_ref(self) -> None:
old_root = eval_coverage.ROOT
eval_coverage.ROOT = Path(self.repo)
stderr = io.StringIO()
try:
with mock.patch.object(
sys, "argv", ["eval-coverage.py", "--modified-from=--name-only"]
), redirect_stderr(stderr):
exit_code = eval_coverage.main()
finally:
eval_coverage.ROOT = old_root
self.assertEqual(2, exit_code)
self.assertEqual(
"ERROR: invalid --modified-from ref: '--name-only' is not an existing commit\n",
stderr.getvalue(),
)
def test_main_accepts_valid_branch_ref(self) -> None:
old_root = eval_coverage.ROOT
eval_coverage.ROOT = Path(self.repo)
stderr = io.StringIO()
try:
with mock.patch.object(
sys, "argv", ["eval-coverage.py", "--modified-from", "main", "--json"]
), redirect_stderr(stderr), redirect_stdout(io.StringIO()):
exit_code = eval_coverage.main()
finally:
eval_coverage.ROOT = old_root
self.assertEqual(0, exit_code)
self.assertEqual("", stderr.getvalue())
class TestCoverageOutput(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.repo = self.tmp.name
git(self.repo, "init", "-b", "main")
write_skill(self.repo, "covered", evals=[make_case("c1")])
write_skill(self.repo, "uncovered")
git(self.repo, "add", "-A")
def tearDown(self) -> None:
self.tmp.cleanup()
def test_json_names_all_five_states_and_per_skill_values(self) -> None:
old_root = eval_coverage.ROOT
eval_coverage.ROOT = Path(self.repo)
output = io.StringIO()
try:
with mock.patch.object(sys, "argv", ["eval-coverage.py", "--json"]), redirect_stdout(output):
exit_code = eval_coverage.main()
finally:
eval_coverage.ROOT = old_root
self.assertEqual(0, exit_code)
report = json.loads(output.getvalue())
expected = {
"manifest_present",
"schema_valid",
"executable_grader_bindings_present",
"recent_run_evidence_present",
"release_gated_evidence_present",
}
self.assertEqual(expected, set(report["states"]))
self.assertEqual("supported", report["states"]["manifest_present"]["assessment"])
self.assertEqual("supported", report["states"]["schema_valid"]["assessment"])
for key in (
"executable_grader_bindings_present",
"recent_run_evidence_present",
"release_gated_evidence_present",
):
self.assertEqual("not_assessed", report["states"][key]["assessment"])
self.assertIsNone(report["states"][key]["count"])
self.assertIsNone(report["states"][key]["percentage"])
self.assertTrue(report["states"][key]["reason"])
for skill in report["skills"]:
self.assertTrue(expected.issubset(skill))
self.assertIsInstance(skill["manifest_present"], bool)
self.assertIn(skill["schema_valid"], (True, False, NOT_APPLICABLE))
self.assertEqual("not_assessed", skill["executable_grader_bindings_present"])
self.assertEqual("not_assessed", skill["recent_run_evidence_present"])
self.assertEqual("not_assessed", skill["release_gated_evidence_present"])
per_skill = {entry["skill"]: entry for entry in report["skills"]}
self.assertTrue(per_skill["covered"]["schema_valid"])
self.assertEqual(NOT_APPLICABLE, per_skill["uncovered"]["schema_valid"])
def test_human_output_uses_same_state_names_and_not_assessed_wording(self) -> None:
old_root = eval_coverage.ROOT
eval_coverage.ROOT = Path(self.repo)
output = io.StringIO()
try:
with mock.patch.object(sys, "argv", ["eval-coverage.py"]), redirect_stdout(output):
exit_code = eval_coverage.main()
finally:
eval_coverage.ROOT = old_root
self.assertEqual(0, exit_code)
text = output.getvalue()
self.assertIn("manifest_present", text)
self.assertIn("schema_valid", text)
self.assertIn("executable_grader_bindings_present: not assessed", text)
self.assertIn("recent_run_evidence_present: not assessed", text)
self.assertIn("release_gated_evidence_present: not assessed", text)
def test_present_invalid_and_missing_manifest_reporting(self) -> None:
invalid_manifest = Path(self.repo) / "uncovered" / "evals"
invalid_manifest.mkdir(parents=True, exist_ok=True)
(invalid_manifest / "evals.json").write_text(
json.dumps({"schema_version": 1, "skill_name": "uncovered", "evals": []}),
encoding="utf-8",
)
old_root = eval_coverage.ROOT
eval_coverage.ROOT = Path(self.repo)
try:
covered = eval_coverage.check_eval_states(Path("covered"))
uncovered = eval_coverage.check_eval_states(Path("uncovered"))
missing = eval_coverage.check_eval_states(Path("missing"))
finally:
eval_coverage.ROOT = old_root
self.assertTrue(covered.states["manifest_present"])
self.assertTrue(covered.states["schema_valid"])
self.assertTrue(uncovered.states["manifest_present"])
self.assertFalse(uncovered.states["schema_valid"])
self.assertFalse(missing.states["manifest_present"])
self.assertEqual(NOT_APPLICABLE, missing.states["schema_valid"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+388
View File
@@ -0,0 +1,388 @@
#!/usr/bin/env python3
"""Focused tests for the repository-owned eval manifest v1 contract."""
from __future__ import annotations
import json
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
from eval_validation import NOT_APPLICABLE, SCHEMA_VERSION, find_skill_manifests, validate_manifest
SCRIPT_DIR = Path(__file__).resolve().parent
ROOT = SCRIPT_DIR.parent
def git(repo: Path, *args: str) -> None:
subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True)
def git_output(repo: Path, *args: str) -> str:
completed = subprocess.run(
["git", *args],
cwd=repo,
check=True,
capture_output=True,
text=True,
)
return completed.stdout
def eval_case(**updates: object) -> dict[str, object]:
case: dict[str, object] = {
"id": "case-1",
"prompt": "Do the task.",
"expected_output": "A verified result.",
"assertions": ["Includes verification."],
}
case.update(updates)
return case
class EvalValidationTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
git(self.root, "init", "-q")
git(self.root, "config", "user.email", "test@example.invalid")
git(self.root, "config", "user.name", "Test")
self.skill = self.root / "example"
(self.skill / "evals").mkdir(parents=True)
(self.skill / "SKILL.md").write_text("---\nname: example\ndescription: test\n---\n", encoding="utf-8")
self.manifest = self.skill / "evals" / "evals.json"
def tearDown(self) -> None:
self.tmp.cleanup()
def valid(self, **updates: object) -> dict[str, object]:
payload: dict[str, object] = {
"schema_version": SCHEMA_VERSION,
"skill_name": "example",
"evals": [eval_case()],
}
payload.update(updates)
return payload
def write(self, data: object, *, track_all: bool = True) -> None:
self.manifest.write_text(json.dumps(data, indent=2), encoding="utf-8")
if track_all:
git(self.root, "add", "-A")
def errors(self, data: object) -> list[str]:
self.write(data)
return validate_manifest(self.manifest, self.root).errors
def test_valid_manifest_passes(self) -> None:
self.write(self.valid())
result = validate_manifest(self.manifest, self.root)
self.assertTrue(result.states["manifest_present"])
self.assertTrue(result.states["schema_valid"])
self.assertEqual("not_assessed", result.states["executable_grader_bindings_present"])
self.assertEqual("not_assessed", result.states["recent_run_evidence_present"])
self.assertEqual("not_assessed", result.states["release_gated_evidence_present"])
self.assertEqual([], result.errors)
def test_missing_manifest_reports_schema_valid_not_applicable(self) -> None:
missing_manifest = self.skill / "evals" / "missing.json"
result = validate_manifest(missing_manifest, self.root)
self.assertFalse(result.states["manifest_present"])
self.assertEqual(NOT_APPLICABLE, result.states["schema_valid"])
self.assertEqual([], result.errors)
def test_targeted_schema_version_errors(self) -> None:
missing_errors = self.errors({"skill_name": "example", "evals": [eval_case()]})
self.assertTrue(any("missing required schema_version" in error for error in missing_errors))
string_errors = self.errors(self.valid(schema_version="1"))
self.assertTrue(any("must be integer 1, not a string" in error for error in string_errors))
unknown_errors = self.errors(self.valid(schema_version=2))
self.assertTrue(any("unsupported schema_version" in error for error in unknown_errors))
def test_boolean_and_non_integer_schema_version_errors(self) -> None:
bool_errors = self.errors(self.valid(schema_version=True))
self.assertTrue(any("schema_version must be integer 1" in error for error in bool_errors))
float_errors = self.errors(self.valid(schema_version=1.5))
self.assertTrue(any("schema_version must be integer 1" in error for error in float_errors))
def test_expectations_alias_errors(self) -> None:
legacy_only = eval_case()
legacy_only.pop("assertions")
legacy_only["expectations"] = ["legacy"]
errors = self.errors(self.valid(evals=[legacy_only]))
self.assertTrue(any("expectations is not supported" in error for error in errors))
both = eval_case(expectations=["legacy"])
errors = self.errors(self.valid(evals=[both]))
self.assertTrue(any("cannot both be present" in error for error in errors))
def test_missing_required_fields_fail(self) -> None:
for field in ("id", "prompt", "expected_output", "assertions"):
with self.subTest(field=field):
case = eval_case()
case.pop(field)
errors = self.errors(self.valid(evals=[case]))
self.assertTrue(any(field in error for error in errors))
def test_whitespace_prompt_and_expected_output_fail(self) -> None:
errors = self.errors(self.valid(evals=[eval_case(prompt=" ")]))
self.assertTrue(any("prompt" in error for error in errors))
errors = self.errors(self.valid(evals=[eval_case(expected_output="\t")]))
self.assertTrue(any("expected_output" in error for error in errors))
def test_invalid_ids_fail(self) -> None:
invalid_values: list[object] = [123, "", "UPPER", "has spaces", "path/slash", "a" * 65]
for bad in invalid_values:
with self.subTest(bad=bad):
errors = self.errors(self.valid(evals=[eval_case(id=bad)]))
self.assertTrue(any("id" in error for error in errors))
def test_duplicate_ids_fail(self) -> None:
errors = self.errors(self.valid(evals=[eval_case(id="dup"), eval_case(id="dup")]))
self.assertTrue(any("duplicate case ID" in error for error in errors))
def test_duplicate_assertions_fail(self) -> None:
errors = self.errors(self.valid(evals=[eval_case(assertions=["A", "A"])]))
self.assertTrue(any("duplicate assertion" in error for error in errors))
def test_empty_evals_fail(self) -> None:
errors = self.errors(self.valid(evals=[]))
self.assertTrue(any("evals" in error and "non-empty" in error.lower() for error in errors))
def test_empty_and_whitespace_only_assertions_fail(self) -> None:
for assertion in ("", " "):
with self.subTest(assertion=repr(assertion)):
errors = self.errors(self.valid(evals=[eval_case(assertions=[assertion])]))
self.assertTrue(any("nonempty non-whitespace" in error for error in errors))
def test_unknown_top_level_and_case_fields_fail(self) -> None:
top_level_errors = self.errors(self.valid(unknown_field=True))
self.assertTrue(
any("Additional properties" in error and "unknown_field" in error for error in top_level_errors)
)
case_errors = self.errors(self.valid(evals=[eval_case(unknown_field=True)]))
self.assertTrue(
any("Additional properties" in error and "unknown_field" in error for error in case_errors)
)
def test_evidence_property_is_rejected_as_unknown_v1_property(self) -> None:
errors = self.errors(self.valid(evidence={"grader_bindings": ["scripts/grader.py"]}))
self.assertTrue(any("Additional properties" in error and "evidence" in error for error in errors))
def test_duplicate_json_object_keys_fail_before_semantic_validation(self) -> None:
self.manifest.write_text(
"""
{
"schema_version": 1,
"schema_version": 2,
"skill_name": "example",
"evals": [{
"id": "case-1",
"prompt": "P",
"expected_output": "E",
"assertions": ["A"]
}]
}
""".strip()
+ "\n",
encoding="utf-8",
)
git(self.root, "add", "-A")
result = validate_manifest(self.manifest, self.root)
self.assertFalse(result.states["schema_valid"])
self.assertEqual(1, len(result.errors))
self.assertIn("duplicate JSON object key", result.errors[0])
def test_malformed_json_manifest_is_present_but_schema_invalid(self) -> None:
self.manifest.write_text("{\n", encoding="utf-8")
git(self.root, "add", "-A")
result = validate_manifest(self.manifest, self.root)
self.assertTrue(result.states["manifest_present"])
self.assertFalse(result.states["schema_valid"])
self.assertTrue(any("invalid JSON" in error for error in result.errors))
def test_skill_name_must_match_containing_directory(self) -> None:
errors = self.errors(self.valid(skill_name="wrong"))
self.assertTrue(any("containing skill directory" in error for error in errors))
def test_files_accepts_tracked_regular_file(self) -> None:
fixture = self.skill / "fixtures" / "input.json"
fixture.parent.mkdir(parents=True, exist_ok=True)
fixture.write_text("{}\n", encoding="utf-8")
self.write(self.valid(evals=[eval_case(files=["fixtures/input.json"])]))
result = validate_manifest(self.manifest, self.root)
self.assertTrue(result.states["schema_valid"])
def test_data_dot_dot_filename_is_allowed(self) -> None:
fixture = self.skill / "fixtures" / "data..json"
fixture.parent.mkdir(parents=True, exist_ok=True)
fixture.write_text("{}\n", encoding="utf-8")
self.write(self.valid(evals=[eval_case(files=["fixtures/data..json"])]))
result = validate_manifest(self.manifest, self.root)
self.assertTrue(result.states["schema_valid"])
def test_duplicate_file_paths_fail(self) -> None:
fixture = self.skill / "fixtures" / "input.json"
fixture.parent.mkdir(parents=True, exist_ok=True)
fixture.write_text("{}\n", encoding="utf-8")
errors = self.errors(
self.valid(evals=[eval_case(files=["fixtures/input.json", "fixtures/input.json"])]),
)
self.assertTrue(any("non-unique elements" in error for error in errors))
def test_lexical_dot_empty_and_trailing_slash_paths_fail(self) -> None:
fixture = self.skill / "fixtures" / "input.json"
fixture.parent.mkdir(parents=True, exist_ok=True)
fixture.write_text("{}\n", encoding="utf-8")
for bad in (
"fixtures/./input.json",
"fixtures//input.json",
"./input.json",
"fixtures/input.json/",
):
with self.subTest(path=bad):
errors = self.errors(self.valid(evals=[eval_case(files=[bad])]))
self.assertTrue(any("files[0]" in error for error in errors))
def test_symlink_component_escaping_skill_is_rejected(self) -> None:
external_dir = self.root / "external"
external_dir.mkdir(parents=True, exist_ok=True)
(external_dir / "outside.json").write_text("{}\n", encoding="utf-8")
fixtures_dir = self.skill / "fixtures"
fixtures_dir.mkdir(parents=True, exist_ok=True)
os.symlink(external_dir, fixtures_dir / "external")
git(self.root, "add", "-A")
errors = self.errors(self.valid(evals=[eval_case(files=["fixtures/external/outside.json"])]))
self.assertTrue(any("symlink" in error for error in errors))
def test_tracked_but_missing_file_fails_when_index_still_lists_it(self) -> None:
fixture = self.skill / "fixtures" / "input.json"
fixture.parent.mkdir(parents=True, exist_ok=True)
fixture.write_text("{}\n", encoding="utf-8")
self.write(self.valid(evals=[eval_case(files=["fixtures/input.json"])]))
fixture.unlink()
tracked_entry = git_output(
self.root,
"ls-files",
"-s",
"--",
str(fixture.relative_to(self.root)),
)
self.assertNotEqual("", tracked_entry)
errors = validate_manifest(self.manifest, self.root).errors
self.assertTrue(any("does not exist in working tree" in error for error in errors))
def test_nested_bundle_skill_name_matches_leaf_directory(self) -> None:
nested_skill = self.root / "bundles" / "demo-bundle" / "skills" / "child-skill"
(nested_skill / "evals").mkdir(parents=True)
(nested_skill / "SKILL.md").write_text(
"---\nname: child-skill\ndescription: test\n---\n",
encoding="utf-8",
)
nested_manifest = nested_skill / "evals" / "evals.json"
nested_manifest.write_text(
json.dumps(
{
"schema_version": SCHEMA_VERSION,
"skill_name": "child-skill",
"evals": [eval_case()],
},
indent=2,
),
encoding="utf-8",
)
git(self.root, "add", "-A")
result = validate_manifest(nested_manifest, self.root)
self.assertTrue(result.states["schema_valid"])
def test_file_boundary_failures(self) -> None:
tracked = self.skill / "Fixtures" / "Input.json"
tracked.parent.mkdir(parents=True, exist_ok=True)
tracked.write_text("{}\n", encoding="utf-8")
outside = self.root / "outside.json"
outside.write_text("{}\n", encoding="utf-8")
directory = self.skill / "fixtures"
directory.mkdir(parents=True, exist_ok=True)
symlink_target = directory / "target.json"
symlink_target.write_text("{}\n", encoding="utf-8")
os.symlink(symlink_target, directory / "linked.json")
git(self.root, "add", "-A")
invalid_paths = [
str(tracked.resolve()),
"../outside.json",
"fixtures/missing.json",
"fixtures",
"fixtures\\bad.json",
"fixtures/linked.json",
"fixtures/\u0001bad.json",
"fixtures/./input.json",
"Fixtures/input.json",
]
for bad in invalid_paths:
with self.subTest(path=bad):
errors = self.errors(self.valid(evals=[eval_case(files=[bad])]))
self.assertTrue(any("files[0]" in error for error in errors))
def test_missing_and_untracked_fixture_paths_report_distinct_reasons(self) -> None:
untracked = self.skill / "fixtures" / "untracked.json"
untracked.parent.mkdir(parents=True, exist_ok=True)
untracked.write_text("{}\n", encoding="utf-8")
self.write(self.valid(evals=[eval_case(files=["fixtures/missing.json"])]), track_all=False)
git(self.root, "add", str(self.manifest.relative_to(self.root)))
missing_errors = validate_manifest(self.manifest, self.root).errors
self.assertTrue(any("does not exist" in error for error in missing_errors))
self.assertFalse(any("not tracked by Git" in error for error in missing_errors))
self.write(self.valid(evals=[eval_case(files=["fixtures/untracked.json"])]), track_all=False)
git(self.root, "add", str(self.manifest.relative_to(self.root)))
tracked_untracked = git_output(
self.root,
"ls-files",
"--",
str(untracked.relative_to(self.root)),
)
self.assertEqual("", tracked_untracked)
untracked_errors = validate_manifest(self.manifest, self.root).errors
self.assertTrue(any("not tracked by Git" in error for error in untracked_errors))
self.assertFalse(any("does not exist" in error for error in untracked_errors))
class RepositoryManifestTest(unittest.TestCase):
def test_known_migrated_set_is_present_and_all_discovered_manifests_validate(self) -> None:
known = {
"de-spin/evals/evals.json",
"esp32-development/evals/evals.json",
"raleigh/evals/evals.json",
"restic/evals/evals.json",
"supabase/evals/evals.json",
"vercel-eve/evals/evals.json",
"verification-methodology/evals/evals.json",
}
manifests = find_skill_manifests(ROOT)
discovered = {str(path.relative_to(ROOT)) for path in manifests}
self.assertTrue(known.issubset(discovered))
invalid = []
for manifest in manifests:
result = validate_manifest(manifest, ROOT)
if result.states["schema_valid"] is not True:
invalid.append((manifest, result.errors))
self.assertEqual([], invalid)
if __name__ == "__main__":
unittest.main()
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""Validate every eval manifest against the repository-owned v1 contract."""
import sys
from pathlib import Path
from eval_validation import find_skill_manifests, validate_manifest
ROOT = Path(__file__).resolve().parent.parent
def main() -> int:
manifests = find_skill_manifests(ROOT)
errors = []
for manifest in manifests:
errors.extend(validate_manifest(manifest, ROOT).errors)
if errors:
print("\n".join(errors), file=sys.stderr)
return 1
print(
f"Validated {len(manifests)} eval manifests against repository schema v1 and semantic repository checks."
)
return 0
if __name__ == "__main__":
sys.exit(main())
+3
View File
@@ -4,6 +4,7 @@
require "yaml" require "yaml"
require "set" require "set"
require "json" require "json"
require "open3"
ROOT = File.expand_path("..", __dir__) ROOT = File.expand_path("..", __dir__)
ALLOWED_FIELDS = %w[name description license compatibility metadata allowed-tools].freeze ALLOWED_FIELDS = %w[name description license compatibility metadata allowed-tools].freeze
@@ -12,6 +13,8 @@ GRANDFATHER_FILE = File.join(ROOT, "scripts", "grandfathered-skills.txt")
MIN_EVAL_CASES = 5 MIN_EVAL_CASES = 5
errors = [] errors = []
eval_stdout, eval_stderr, eval_status = Open3.capture3("python3", File.join(ROOT, "scripts", "validate-evals.py"), chdir: ROOT)
errors.concat(eval_stderr.lines(chomp: true)) unless eval_status.success?
skills = Dir.glob("#{ROOT}/**/SKILL.md").sort.reject do |skill| skills = Dir.glob("#{ROOT}/**/SKILL.md").sort.reject do |skill|
skill.include?("/agent-council/profiles/skills/") skill.include?("/agent-council/profiles/skills/")
end end
+1
View File
@@ -1,4 +1,5 @@
{ {
"schema_version": 1,
"skill_name": "supabase", "skill_name": "supabase",
"evals": [ "evals": [
{ {
+1
View File
@@ -1,4 +1,5 @@
{ {
"schema_version": 1,
"skill_name": "vercel-eve", "skill_name": "vercel-eve",
"evals": [ "evals": [
{ {
@@ -1,4 +1,5 @@
{ {
"schema_version": 1,
"skill_name": "verification-methodology", "skill_name": "verification-methodology",
"evals": [ "evals": [
{ {