| #!/usr/bin/env python |
| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| """Verify the skill-eval ran against the current guidance files. |
| |
| ``dev/skill-evals/eval.py`` records a hash of its inputs in |
| ``dev/skill-evals/last-eval-hash.txt`` after every completed run. This hook |
| recomputes the hash from the current file contents and fails when AGENTS.md |
| or the eval cases changed without a re-run. The hash proves the eval *ran* |
| on this exact content — not that all cases passed. |
| |
| The comparison is pure file content — no git state — so the hook behaves |
| identically at commit time, at push time, and in CI (``prek --all-files``). |
| """ |
| |
| from __future__ import annotations |
| |
| import hashlib |
| import sys |
| from pathlib import Path |
| |
| REPO_ROOT = Path(__file__).resolve().parents[3] |
| AGENTS_FILE = REPO_ROOT / "AGENTS.md" |
| CASES_DIR = REPO_ROOT / "dev" / "skill-evals" / "cases" |
| HASH_FILE = REPO_ROOT / "dev" / "skill-evals" / "last-eval-hash.txt" |
| |
| HASH_FILE_HEADER = """\ |
| # Generated by dev/skill-evals/eval.py — do not edit or resolve conflicts by hand. |
| # Run `prek run run-skill-eval --hook-stage manual --all-files` to regenerate. |
| """ |
| |
| |
| def compute_guidance_hash(agents_file: Path, cases_dir: Path) -> str: |
| """Hash the skill-eval inputs: AGENTS.md plus every case file. |
| |
| Labels are hashed alongside contents so renaming a case file changes |
| the hash. SKILL.md files are intentionally excluded — no skill cases |
| exist yet; extend the inputs when per-skill cases land (runs with |
| ``SKILL_NAME=...``). |
| """ |
| digest = hashlib.sha256() |
| entries = [("AGENTS.md", agents_file)] |
| entries += [(f"cases/{path.name}", path) for path in sorted(cases_dir.glob("*.yaml"))] |
| for label, path in entries: |
| digest.update(label.encode()) |
| digest.update(b"\0") |
| digest.update(path.read_bytes()) |
| digest.update(b"\0") |
| return digest.hexdigest() |
| |
| |
| def read_recorded_hash(hash_file: Path) -> str | None: |
| """Return the hash recorded in ``hash_file``, or None if absent.""" |
| if not hash_file.is_file(): |
| return None |
| for line in hash_file.read_text().splitlines(): |
| stripped = line.strip() |
| if stripped and not stripped.startswith("#"): |
| return stripped |
| return None |
| |
| |
| def write_recorded_hash(value: str, hash_file: Path) -> None: |
| """Record ``value`` with a regeneration note, like breeze's output-commands-hash.txt.""" |
| hash_file.write_text(f"{HASH_FILE_HEADER}{value}\n") |
| |
| |
| def main() -> int: |
| current = compute_guidance_hash(AGENTS_FILE, CASES_DIR) |
| recorded = read_recorded_hash(HASH_FILE) |
| if current == recorded: |
| return 0 |
| print( |
| "AGENTS.md or dev/skill-evals/cases/ changed without re-running the" |
| " skill-eval\n(dev/skill-evals/last-eval-hash.txt does not match).\n" |
| "\n" |
| " Run: prek run run-skill-eval --hook-stage manual --all-files\n" |
| " then commit the updated dev/skill-evals/last-eval-hash.txt\n" |
| "\n" |
| " WIP commit: SKIP=check-eval-hash git commit ...\n" |
| " Can't run the eval? Ask a maintainer to run it and push the updated\n" |
| " hash file to your PR branch (see dev/skill-evals/README.md).", |
| file=sys.stderr, |
| ) |
| return 1 |
| |
| |
| if __name__ == "__main__": |
| raise SystemExit(main()) |