Files
dashboards/.agents/validate.sh
T
ducoterra 9e732c1f87 chore(agent): commit the validation gate the phase pipeline runs
AGENTS.md and PLAN.md already told every phase to `bash .agents/validate.sh`, but the script
itself was never tracked, so a fresh clone could not run the gate at all.

Four checks: `uv run pytest --cov --cov-fail-under=94`, `uv run ruff check src tests agent`,
`uv run pyright`, and a zero-dependency `node --check` of every served JS. Plus repo hygiene
(tracked files matching .gitignore, log drops/key material, non-placeholder secrets in the
tracked env templates) and an advisory `uv pip list --outdated`.

Two rules are baked in because breaking them made a false green: run the project toolchain
through `uv run` (a bare `python3`/`ruff` on PATH tests an interpreter nobody deploys and an
older linter than the pyproject floor), and treat a missing tool as a failure rather than an
`if command -v` skip.

LLM-Generated: true
2026-10-17 12:27:52 -04:00

164 lines
8.0 KiB
Bash
Executable File

#!/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 <file>
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 <file>'):" >&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 <file>' 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"