#!/usr/bin/env bash # .agents/validate.sh — validation gate for the phased-execution pipeline. # # A unit is only moved to .agents/phases/complete/ if this script exits 0, # and a phase is only COMMITTED if its final pass exits 0 — so this script # is the last line of defense for "no phase commits unless 100% passing". # It MUST run the project's FULL test suite (every test, not a subset) plus # its linter, so a failure anywhere in the project blocks the commit. # # Adapted to THIS project's real gate (AGENTS.md "Definition of done", PLAN.md # "Validation / Verification Workflow"): # # uv run pytest --cov hermetic full suite, coverage floor 94% # uv run ruff check src tests agent # uv run pyright # # Two rules this script had to learn the hard way: # # * Run the PROJECT toolchain, not whatever is on PATH. `python3 -m pytest` here # resolves to /usr/bin/python3 + ~/.local site-packages (tests/conftest.py puts # src/ on sys.path, so it passes while testing an interpreter nobody deploys), # and a bare `ruff` on PATH is older than the `ruff>=0.17.1` floor in # pyproject.toml. `uv run` is the only invocation that matches the docs. # * Never skip a check silently. `if command -v ruff; then ruff check .; fi` # exits 0 when the linter is missing — a gate that can pass without linting is # not a gate. A missing tool is a FAILURE here; the checks are listed by name. set -uo pipefail rc=0 # Run from the repo root no matter where the harness invoked us, so `src`, # `tests`, `agent` and the pytest/ruff/pyright configs all mean the same tree. ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" || { echo "validation FAILED: cannot cd to $ROOT"; exit 1; } # --- the Python gate ---------------------------------------------------------- # uv is the documented entry point (`uv sync --python 3.14`); if it is gone the # venv is still usable directly, and only a total absence of both is fatal. if command -v uv >/dev/null 2>&1; then RUN=(uv run) elif [[ -x .venv/bin/pytest && -x .venv/bin/ruff && -x .venv/bin/pyright ]]; then RUN=(.venv/bin/) echo "note: uv not on PATH — using .venv/bin directly" >&2 else echo "validation FAILED: no uv and no complete .venv (pytest+ruff+pyright)." >&2 echo "Run 'uv sync --python 3.14'. Refusing to guess a system interpreter:" >&2 echo "that path skips the pinned toolchain and can pass on the wrong stack." >&2 exit 1 fi echo "== uv run pytest --cov (hermetic: no network, live tests skip) ==" # --cov-fail-under is the floor from PLAN.md (overall must not drop below 94%); # the project floor in AGENTS.md is 80%, and we are at 95%. "${RUN[@]}" pytest --cov --cov-fail-under=94 || rc=1 echo "== uv run ruff check src tests agent ==" "${RUN[@]}" ruff check src tests agent || rc=1 echo "== uv run pyright ==" # pyright's `include` in pyproject.toml is src+tests on purpose: agent/host_agent.py # is stdlib-only and must stay runnable on the fleet's Python 3.10, so it is linted # (ruff, above) but not type-checked against 3.14 stubs. "${RUN[@]}" pyright || rc=1 # --- static assets: syntax only, no toolchain added --------------------------- # There is no JS build step or JS linter (a locked anchor), so a broken page is # only caught in a browser. `node --check` is a zero-dependency syntax gate that # catches the class of error a stray brace makes; a missing node is a visible # skip, never a silent pass of a check that ran nothing. if command -v node >/dev/null 2>&1; then echo "== node --check src/dashboards/web/static/*.js ==" for js in src/dashboards/web/static/*.js; do node --check "$js" || rc=1 done else echo "note: node not on PATH — skipping the JS syntax check (browser verification only)" >&2 fi # --- dependency currency (advisory, not a gate) ------------------------------- # AGENTS.md rule 8 checks this after touching dependencies; making it a hard gate # would block an unrelated phase on an upstream release that landed five minutes # ago, so it is loud in the log instead of silent. if command -v uv >/dev/null 2>&1 && [[ -f uv.lock ]]; then outdated="$(uv pip list --outdated 2>/dev/null | grep -vE '^(Package|---|$)' || true)" if [[ -n "$outdated" ]]; then echo "advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):" >&2 printf ' %s\n' "$outdated" >&2 fi fi # --- repo hygiene (language-agnostic; runs in every project) ------------------ # Scoped staging commits everything the phase touched, so a file that is not # ignored gets committed — including raw log drops and key material. These # gates make the phase fail instead. Fix by untracking, keeping the working # copy: git rm --cached if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then # 1. A tracked file that matches a .gitignore rule should never be tracked. junk="$(git ls-files -i -c --exclude-standard 2>/dev/null || true)" if [[ -n "$junk" ]]; then echo "repo hygiene: TRACKED files match .gitignore rules (untrack with 'git rm --cached '):" >&2 printf ' %s\n' "$junk" >&2 rc=1 fi # 2. Personal-data / key-material file SHAPES that must never be tracked, # even in a project with no .gitignore rule for them. DANGEROUS='(^|/)[^/]*\.log$|(^|/)[^/]*\.jsonl$|(^|/)\.env($|\.)|\.(pem|key|crt|cer|p12|pfx|keystore|jks)$|(^|/)(id_rsa|id_ecdsa|id_ed25519)(\.pub)?$|(^|/)(credentials|service-account|client_secret)([-a-z0-9]*)\.json$' # Project allow-list (this repo's deliberate, documented choice — see .gitignore): # the env *templates* `.env.example` / `.env.dev.example` are tracked on purpose, # because `cp .env.example .env` is the setup documented in README.md and every # real `.env` / `.env.dev` stays git-ignored. A template is documentation, not key # material — and 2b below keeps that claim honest, so this allow-list cannot smuggle # a live credential past the gate. Nothing else is allow-listed: logs, key material, # and real dot-env files still fail here. SAFE_TEMPLATES='(^|/)\.env[^/]*\.(example|template|sample)$' bad="$(git ls-files | grep -E "$DANGEROUS" | grep -vE "$SAFE_TEMPLATES" || true)" if [[ -n "$bad" ]]; then echo "repo hygiene: TRACKED files look like log drops / key material (untrack with 'git rm --cached ' and add a .gitignore rule):" >&2 printf ' %s\n' "$bad" >&2 rc=1 fi # 2b. A tracked env template may only carry placeholders: every # *_PASSWORD|*_TOKEN|*_SECRET|*_KEY assignment must be empty or a placeholder # word (AGENTS.md rule 6 — "never leak a credential", not even into a sample). PLACEHOLDER='^(|changeme|change-me|replace-me[-a-z0-9]*|secret|placeholder|dummy|example|example\.net|x+)$' tmpl_hits="" while IFS= read -r f; do [[ -z "$f" ]] && continue while IFS= read -r line; do val="${line#*=}" val="${val%%#*}" val=${val//[[:space:]]/} val=${val//\"/} val=${val//\'/} [[ "${val,,}" =~ $PLACEHOLDER ]] && continue tmpl_hits+=" $f: $line"$'\n' done < <(grep -inE '^[[:space:]]*(export[[:space:]]+)?[A-Za-z0-9_]*(PASSWORD|TOKEN|SECRET|KEY)[A-Za-z0-9_]*=' "$f" 2>/dev/null || true) done < <(git ls-files | grep -E "$SAFE_TEMPLATES" || true) if [[ -n "$tmpl_hits" ]]; then echo "repo hygiene: tracked env TEMPLATE carries a non-placeholder secret (move the real value to the git-ignored .env):" >&2 printf '%s' "$tmpl_hits" >&2 rc=1 fi # 3. Project-specific content scan for personal data inside tracked files # (client IPs, account ids, ...). Empty by default — fill it in per # project, and allow-list documentation ranges (RFC 5737 / 4193 / 3849, # 127.0.0.0/8, RFC1918) so fixtures do not trip it. Format: one ERE. PII_PATTERNS="${PII_PATTERNS:-}" if [[ -n "$PII_PATTERNS" ]]; then hits="$(git ls-files -z | xargs -0 grep -IHnE "$PII_PATTERNS" 2>/dev/null || true)" if [[ -n "$hits" ]]; then echo "personal-data gate: TRACKED files match PII_PATTERNS — redact them:" >&2 printf ' %s\n' "$hits" >&2 rc=1 fi fi fi if [[ $rc -ne 0 ]]; then echo "validation FAILED (see output above)" fi exit "$rc"