testsAndMisc/meta/scripts/validate_evidence.py
Krzysztof kuhy Rudnicki 2e1e370c0f refactor: extract all inline Python from shell scripts into proper .py files
Move every multi-line python heredoc/`-c` block into a dedicated .py file so
ruff, mypy, pylint, bandit, and pytest can apply to it:
- linux_configuration/zsh/calc-live.zsh → python_pkg/live_calc/calc_eval.py (100% branch cov, 46 tests)
- meta/scripts/check_ai_evidence.sh → meta/scripts/validate_evidence.py
- meta/scripts/check_agent_contract.sh → meta/scripts/validate_contract.py
- phone_focus_mode/lib/monitor.sh → phone_focus_mode/lib/monitor_report.py
- phone_focus_mode/deploy.sh → phone_focus_mode/strip_workout_hosts.py
- linux_configuration/.../analyze_repo.sh → fast_count.py

Also: add zsh-syntax pre-commit hook (zsh -n); exclude zsh from shellcheck;
add tests for all 4 non-python_pkg helpers; update CLAUDE.md Shell Style with
the no-inline-Python rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 10:31:48 +02:00

136 lines
4.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""Validate an AI-evidence artifact against the required schema.
Used by the ``check_ai_evidence.sh`` pre-commit hook: it is given one evidence
JSON path as ``argv[1]``, prints ``<path>: schema OK`` and exits 0 when the file
satisfies the schema, or writes each problem to stderr and exits 1 otherwise.
Kept as a standalone module (not inline ``python <<PY`` in the shell hook) so the
repository's Python tooling applies; see CLAUDE.md "Shell Style".
"""
from __future__ import annotations
import json
from pathlib import Path
import sys
# Top-level keys every evidence artifact must define.
_REQUIRED_KEYS = ("intent", "scope", "changes", "verification", "risks", "rollback")
# Keys whose value must be a non-empty list of non-empty strings.
_STRING_LIST_KEYS = ("scope", "changes", "risks", "rollback")
# Fields required on every entry of the "verification" list.
_VERIFICATION_FIELDS = ("command", "result", "evidence")
# Rationalization phrases that must be replaced with concrete evidence.
_BANNED_PHRASES = ("should work", "probably fine", "seems right")
def _is_nonempty_str(value: object) -> bool:
"""Return True if ``value`` is a string with non-whitespace content."""
return isinstance(value, str) and bool(value.strip())
def _check_required_keys(data: dict[str, object]) -> list[str]:
"""Report any required top-level keys that are absent."""
missing = [key for key in _REQUIRED_KEYS if key not in data]
if missing:
return [f"missing required keys: {', '.join(missing)}"]
return []
def _check_intent(data: dict[str, object]) -> list[str]:
"""The ``intent`` field must be a non-empty string."""
if not _is_nonempty_str(data.get("intent")):
return ["intent must be a non-empty string"]
return []
def _check_string_lists(data: dict[str, object]) -> list[str]:
"""Each string-list field must be a non-empty list of non-empty strings."""
errors: list[str] = []
for key in _STRING_LIST_KEYS:
value = data.get(key)
if not isinstance(value, list) or not value:
errors.append(f"{key} must be a non-empty list")
continue
if any(not _is_nonempty_str(item) for item in value):
errors.append(f"{key} entries must be non-empty strings")
return errors
def _check_verification(data: dict[str, object]) -> list[str]:
"""``verification`` must be a non-empty list of fully-populated objects."""
verification = data.get("verification")
if not isinstance(verification, list) or not verification:
return ["verification must be a non-empty list"]
errors: list[str] = []
for index, item in enumerate(verification):
if not isinstance(item, dict):
errors.append(f"verification[{index}] must be an object")
continue
missing = [field for field in _VERIFICATION_FIELDS if field not in item]
if missing:
errors.append(f"verification[{index}] missing fields: {', '.join(missing)}")
bad = [
field
for field in _VERIFICATION_FIELDS
if field in item and not _is_nonempty_str(item[field])
]
errors.extend(
f"verification[{index}].{field} must be a non-empty string" for field in bad
)
return errors
def _check_phrases(text: str) -> list[str]:
"""Reject artifacts containing rationalization phrases instead of evidence."""
lowered = text.lower()
return [
f"contains rationalization phrase '{phrase}', replace with evidence"
for phrase in _BANNED_PHRASES
if phrase in lowered
]
def validate(path: Path) -> list[str]:
"""Return a list of schema problems for ``path`` (empty when it is valid)."""
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
return [f"cannot read file ({exc})"]
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
return [f"invalid JSON ({exc})"]
if not isinstance(data, dict):
return ["top-level JSON value must be an object"]
errors = _check_required_keys(data)
if errors: # without the keys present, the per-field checks are noise
return errors
errors += _check_intent(data)
errors += _check_string_lists(data)
errors += _check_verification(data)
errors += _check_phrases(text)
return errors
def main() -> int:
"""Validate the artifact named by ``argv[1]``; return a process exit code."""
args = sys.argv[1:]
if not args:
sys.stderr.write("usage: validate_evidence.py <evidence.json>\n")
return 2
path = Path(args[0])
errors = validate(path)
if errors:
for error in errors:
sys.stderr.write(f"{path}: {error}\n")
return 1
sys.stdout.write(f"{path}: schema OK\n")
return 0
if __name__ == "__main__":
sys.exit(main())