Files
khosra/scripts/verify.sh
T
bdeshi 28face98d6 harness: permissions changes owe no HARNESS.md note
The coupling gate treated every path under .claude/ as mechanism, so editing the
pre-approved command list demanded an explanation of a machine that had not
changed. HARNESS.md already calls settings.json a convenience — if its schema
changed the harness would still work, you would just get more prompts — so the
gate now exempts that one file and nothing else.

Carries the settings.json edit that exposed it.
2026-08-01 02:23:34 +06:00

387 lines
18 KiB
Bash
Executable File

#!/usr/bin/env bash
# The objective gate. No opinions, only exit codes.
# Usage: ./scripts/verify.sh
set -uo pipefail
# --list answers "does this gate actually exist?" without reading the script. Docs that claim a gate are
# checkable against it. The pattern is written pas[s] so this grep does not match its own source line.
if [ "${1:-}" = "--list" ]; then
printf 'gates in %s:\n' "$0"
grep -oE 'pas[s] "[^"]+"' "$0" | awk -F'"' '{print $2}' |
sed 's/\$([^)]*)/…/g; s/\$[A-Za-z_][A-Za-z_0-9]*/…/g; s/^/ /' | sort -u
exit 0
fi
cd "$(dirname "$0")/.." || exit 1
[ -f scripts/budgets.env ] && . scripts/budgets.env
CORE_LOC_MAX=${CORE_LOC_MAX:-2000}
EXT_LOC_MAX=${EXT_LOC_MAX:-2000}
FILE_LOC_WARN=${FILE_LOC_WARN:-500}
FUNC_LOC_WARN=${FUNC_LOC_WARN:-60}
DEPS_MAX=${DEPS_MAX:-6}
fail=0
warn=0
pass() { printf ' ok %s\n' "$1"; }
bad() { printf ' FAIL %s\n' "$1"; fail=1; }
note() { printf ' warn %s\n' "$1"; warn=$((warn + 1)); }
head_() { printf '\n%s\n' "$1"; }
if ! command -v go >/dev/null 2>&1 || ! command -v gofmt >/dev/null 2>&1; then
echo "FAIL go toolchain not found on PATH — cannot verify anything"
exit 1
fi
# --- documentation coupling -------------------------------------------------
# Runs before the Go gates so it still applies while the repo is harness-only.
# Code and the docs describing it move together, or they drift apart silently.
head_ "documentation"
if [ -d .git ] && command -v git >/dev/null 2>&1; then
# -uall matters: the default collapses untracked directories to "dir/", which would hide a
# brand-new internal/ext/feeds/feeds.go from the .go check below.
changed=$(git status --porcelain -uall 2>/dev/null | sed 's/^...//' | sed 's/.* -> //')
if [ -z "$changed" ]; then
pass "working tree clean — nothing to couple"
else
if echo "$changed" | grep -qE '\.go$' && ! echo "$changed" | grep -qx 'docs/state.md'; then
bad "*.go changed but docs/state.md did not — inventory, counters and the verified-against line move with the code"
else
pass "code/state.md coupling"
fi
if echo "$changed" | grep -qE '^(cmd|internal)/.*\.go$' && ! echo "$changed" | grep -qE '_test\.go$'; then
# A comment-only or gofmt-only diff ships no behaviour, so it owes no test. Compare the added and
# removed lines with comments, blanks and indentation stripped: equal sets mean nothing happened.
gochanged=$(echo "$changed" | grep -E '^(cmd|internal)/.*\.go$')
godiff=$(git diff HEAD -- $gochanged 2>/dev/null)
# strip the marker, comments, blanks, and *all* whitespace runs — gofmt realignment then compares
# equal, while any real edit still differs.
strip() { grep -E "^[$1]" | grep -vE '^(\+\+\+|---)' | sed "s/^[$1]//" |
grep -vE '^[[:space:]]*(//|/\*|\*/|\* )' | tr -s ' \t' ' ' |
sed 's/^ //; s/ $//' | grep -v '^$' | sort; }
newgo=$(git ls-files --others --exclude-standard -- $gochanged 2>/dev/null)
if [ -z "$newgo" ] && [ "$(echo "$godiff" | strip '+')" = "$(echo "$godiff" | strip '-')" ]; then
pass "code/test coupling (comment- or format-only)"
else
bad "cmd/ or internal/ .go changed but no _test.go did — behaviour ships with a test (conventions.md)"
fi
else
pass "code/test coupling"
fi
if echo "$changed" | grep -qE '^internal/.*templates/.*\.html$' && ! echo "$changed" | grep -qx 'docs/theme-contract.md'; then
bad "embedded templates changed but docs/theme-contract.md did not — they drift together (ADR-0023)"
else
pass "templates/theme-contract coupling"
fi
# settings.json is permissions, not mechanism — HARNESS.md calls it a convenience, so a tweak there
# owes no explanation. Everything else under .claude/ changes how the machine behaves.
harnesschanged=$(echo "$changed" | grep -E '^(CLAUDE\.md$|scripts/|\.claude/)' | grep -v '^\.claude/settings\.json$' || true)
if [ -n "$harnesschanged" ] && ! echo "$changed" | grep -qx 'HARNESS.md'; then
bad "the harness changed (CLAUDE.md, scripts/ or .claude/) but HARNESS.md did not — the guide to the machine is part of the machine"
else
pass "harness/HARNESS.md coupling"
fi
fi
# The reference theme is a contract demonstration, not a design: zero JavaScript (ADR-0026).
themefiles=$(find internal -path '*templates*' -name '*.html' 2>/dev/null || true)
if [ -n "$themefiles" ]; then
scripted=$(echo "$themefiles" | xargs grep -ln '<script' 2>/dev/null || true)
if [ -n "$scripted" ]; then
bad "<script> in the reference theme — it is a contract demonstration, not a design (ADR-0026): $(echo "$scripted" | tr '\n' ' ')"
else
pass "reference theme is script-free"
fi
fi
# Dangling references. Every one of these found a real stale pointer when run by hand.
refs=$(grep -rhoE '`(docs|scripts|ideas|reference|\.claude)/[A-Za-z0-9_./-]+`' \
docs CLAUDE.md HARNESS.md ideas reference .claude scripts 2>/dev/null | tr -d '`' | sort -u)
dangling=""
for f in $refs; do [ -e "$f" ] || dangling="$dangling $f"; done
[ -n "$dangling" ] && bad "reference to a path that does not exist:$dangling"
adrs=$(grep -rhoE 'ADR-[0-9]{4}' docs CLAUDE.md HARNESS.md ideas reference .claude scripts cmd internal 2>/dev/null | sort -u)
missingadr=""
for a in $adrs; do
# the log registers every number ever used — as an entry, or in the withdrawn line
grep -q "$a" docs/decisions.md 2>/dev/null || missingadr="$missingadr $a"
done
[ -n "$missingadr" ] && bad "reference to an ADR with no entry in decisions.md:$missingadr"
secs=$(grep -rhoE 'CLAUDE\.md`? §[0-9]+' docs HARNESS.md ideas reference .claude scripts 2>/dev/null |
grep -oE '§[0-9]+' | tr -d '§' | sort -u)
missingsec=""
for s in $secs; do
grep -q "^## $s\. " CLAUDE.md || missingsec="$missingsec §$s"
done
[ -n "$missingsec" ] && bad "reference to a CLAUDE.md section that does not exist:$missingsec"
[ -z "$dangling$missingadr$missingsec" ] && pass "references resolve"
# Index rot: a folder of files nobody lists is a folder nobody reads.
for d in ideas reference; do
[ -d "$d" ] || continue
files=$(find "$d" -name '*.md' -not -name 'README.md' | wc -l | tr -d ' ')
listed=$(grep -c '^- \[' "$d/README.md" 2>/dev/null || true)
if [ "$files" != "${listed:-0}" ]; then
note "$d: $files file(s), $listed index line(s) — index out of date"
fi
if [ "$d" = "ideas" ]; then
unstatused=$(grep -L '^Status:' ideas/*.md 2>/dev/null | grep -v 'README.md' || true)
[ -n "$unstatused" ] && note "ideas: no Status line: $(echo "$unstatused" | tr '\n' ' ')"
fi
done
# True staleness: has any Go file changed since the commit state.md claims to describe?
recorded=$(awk -F'`' '/^\*\*Verified against:\*\*/{print $2; exit}' docs/state.md 2>/dev/null)
if git rev-parse --verify -q HEAD >/dev/null 2>&1; then
if git rev-parse --verify -q "${recorded:-nonexistent}^{commit}" >/dev/null 2>&1; then
behind=$(git rev-list "$recorded..HEAD" -- '*.go' 2>/dev/null | wc -l | tr -d ' ')
if [ "${behind:-0}" -gt 0 ]; then
note "docs/state.md describes $recorded; $behind commit(s) have touched .go since"
else
pass "docs/state.md is current with HEAD"
fi
else
note "docs/state.md 'verified against' does not name a commit this repo knows ($recorded)"
fi
fi
else
note "not a git repo — doc coupling unenforceable"
fi
result_and_exit() {
head_ "result"
if [ "$fail" -eq 0 ]; then
printf ' PASS %s warning(s)\n\n' "$warn"
else
printf ' FAIL fix the above before reporting success\n\n'
fi
exit "$fail"
}
if [ ! -f go.mod ]; then
head_ "go"
note "no go.mod — nothing to verify yet. Run 'go mod init' as part of the first feature."
result_and_exit
fi
mod=$(awk '/^module /{print $2; exit}' go.mod)
# Engine source only. The site root is outside this repo (ADR-0011); ideas/ and reference/ may hold
# exploratory scratch code, which is not the engine and is never formatted, vetted, built or budgeted.
gofiles=$(find . -name '*.go' -not -path './vendor/*' -not -path './.git/*' \
-not -path './ideas/*' -not -path './reference/*' -not -path './.scratch/*' 2>/dev/null)
if [ -z "$gofiles" ]; then
head_ "go"
note "go.mod present but no .go files yet — nothing to verify."
result_and_exit
fi
head_ "correctness"
pkgs=$(go list ./... 2>/dev/null | grep -vE "^$mod/(ideas|reference|\.scratch)(/|$)")
if [ -z "$pkgs" ]; then
head_ "go"; note "no engine packages yet — nothing to verify."
result_and_exit
fi
unformatted=$(echo "$gofiles" | xargs gofmt -l 2>/dev/null || true)
if [ -n "$unformatted" ]; then bad "gofmt: $(echo "$unformatted" | tr '\n' ' ')"; else pass "gofmt"; fi
if go vet $pkgs >/tmp/vet.log 2>&1; then pass "go vet"; else bad "go vet"; sed 's/^/ /' /tmp/vet.log; fi
if go build $pkgs >/tmp/build.log 2>&1; then pass "go build"; else bad "go build"; sed 's/^/ /' /tmp/build.log; fi
if echo "$gofiles" | grep -q '_test\.go$'; then
if go test -race $pkgs >/tmp/test.log 2>&1; then
pass "go test ($(grep -c '^ok' /tmp/test.log) packages)"
else
bad "go test"; sed 's/^/ /' /tmp/test.log
fi
else
note "no tests exist yet"
fi
head_ "dependencies"
reqs=$(awk '
/^require[ \t]*\(/ { inb = 1; next }
inb && /^\)/ { inb = 0; next }
{
ln = $0
ind = (ln ~ /\/\/[ \t]*indirect/) ? "indirect" : "direct"
sub(/\/\/.*/, "", ln)
gsub(/^[ \t]+|[ \t]+$/, "", ln)
if (ln == "") next
if (!inb) { if (ln !~ /^require[ \t]/) next; sub(/^require[ \t]+/, "", ln) }
split(ln, a, /[ \t]+/)
if (a[1] != "") print a[1], ind
}' go.mod)
direct=$(echo "$reqs" | awk '$2=="direct" {print $1}' | grep -v '^$' || true)
total=$(echo "$reqs" | grep -cv '^$' || true)
if [ -f scripts/allowed-deps.txt ]; then
# the file documents "# starts a comment", so strip trailing ones too, not just whole-line
allowed=$(sed 's/#.*//' scripts/allowed-deps.txt | sed 's/[[:space:]]*$//' | grep -v '^$' || true)
unlisted=""
for d in $direct; do
echo "$allowed" | grep -qxF "$d" || unlisted="$unlisted $d"
done
if [ -n "$unlisted" ]; then
bad "dependency not on allowlist:$unlisted (needs an ADR + scripts/allowed-deps.txt)"
else
directcount=$(echo "$direct" | grep -c . || true)
pass "allowlist ($directcount direct)"
fi
fi
if [ "$total" -gt "$DEPS_MAX" ]; then
bad "module count $total exceeds DEPS_MAX=$DEPS_MAX"
else
pass "module count $total / $DEPS_MAX"
fi
head_ "architecture"
# The layering in conventions.md, enforced. Dependencies point inward; a sibling import here is
# what turns a layered engine into a ball of mud, and it always looks locally reasonable.
# One line per package: "importpath imp1 imp2 …". go list is authoritative; grepping source is not.
pkgimports=$(go list -f '{{.ImportPath}}{{range .Imports}} {{.}}{{end}}' ./... 2>/dev/null)
pairs=$(echo "$pkgimports" | awk '{for (i=2; i<=NF; i++) print $1, $i}')
layer() { # $1 = importing layer, $2..$n = layers it may not import
local from="$1"; shift
for to in "$@"; do
echo "$pairs" | awk -v f="$mod/internal/$from" -v t="$mod/internal/$to" \
'index($1,f)==1 && index($2,t)==1 {print $1" → "$2}'
done
}
violations=$(
layer content render web ext
layer render web ext
layer web ext
echo "$pairs" | awk -v c="$mod/cmd" 'index($2,c)==1 && index($1,c)!=1 {print $1" → "$2}'
)
# Sibling imports between features are what make an agent's read set compound (ADR-0027).
siblings=$(echo "$pairs" | awk -v e="$mod/internal/ext/" '
index($1,e)==1 && index($2,e)==1 && $1!=$2 {print $1" → "$2}')
if [ -n "$violations$siblings" ]; then
bad "import boundary violated (conventions.md layering): $(echo "$violations$siblings" | tr '\n' ' ')"
else
pass "import boundaries"
fi
nopkgdoc=""
for dir in $(echo "$gofiles" | grep -v '_test\.go$' | xargs -n1 dirname 2>/dev/null | sort -u); do
documented=$(awk 'prev ~ /^\/\// && /^package /{print FILENAME} {prev=$0}' "$dir"/*.go 2>/dev/null | head -1)
[ -z "$documented" ] && nopkgdoc="$nopkgdoc $dir"
done
if [ -n "$nopkgdoc" ]; then
bad "package without a package comment (conventions.md Documentation):$nopkgdoc"
else
pass "every package documented"
fi
undocumented=$(echo "$gofiles" | grep -v '_test\.go$' | xargs awk '
/^(func|type|var|const) [A-Z]/ { if (prev !~ /^\/\//) printf "%s:%d ", FILENAME, FNR }
{ prev = $0 }' 2>/dev/null)
if [ -n "$undocumented" ]; then
note "exported identifier without a doc comment: $undocumented"
else
pass "exported identifiers documented"
fi
missingdoc=""
for dir in $(echo "$gofiles" | grep '^\./internal/ext/' | xargs -n1 dirname 2>/dev/null | sort -u); do
[ -f "$dir/doc.go" ] || missingdoc="$missingdoc $dir"
done
if [ -n "$missingdoc" ]; then
bad "internal/ext package without doc.go — contributes / cascade keys / contract fields / not doing (ADR-0027):$missingdoc"
else
pass "every feature has a doc.go"
fi
head_ "budgets"
# Two trees, two ceilings: the core must stop growing after Arc 2, ext is where growth belongs.
loc() { [ -z "$1" ] && { echo 0; return; }; echo "$1" | xargs wc -l 2>/dev/null | awk '$2!="total"{t+=$1} END{print t+0}'; }
nontest=$(echo "$gofiles" | grep -v '_test\.go$' || true)
extfiles=$(echo "$nontest" | grep '^\./internal/ext/' || true)
corefiles=$(echo "$nontest" | grep -v '^\./internal/ext/' || true)
coreloc=$(loc "$corefiles")
extloc=$(loc "$extfiles")
if [ "$coreloc" -gt "$CORE_LOC_MAX" ]; then
bad "core is $coreloc lines, over CORE_LOC_MAX=$CORE_LOC_MAX (shrink it, or raise it in an ADR)"
else
pass "core $coreloc / $CORE_LOC_MAX lines"
fi
if [ "$extloc" -gt "$EXT_LOC_MAX" ]; then
bad "internal/ext is $extloc lines, over EXT_LOC_MAX=$EXT_LOC_MAX (a template may have done it)"
else
pass "ext $extloc / $EXT_LOC_MAX lines"
fi
oversized=$(echo "$gofiles" | xargs wc -l 2>/dev/null | awk -v m="$FILE_LOC_WARN" '$2!="total" && $1>m {print $2"("$1")"}')
if [ -n "$oversized" ]; then
note "files over FILE_LOC_WARN=$FILE_LOC_WARN: $(echo "$oversized" | tr '\n' ' ')"
else
pass "no file over $FILE_LOC_WARN lines"
fi
longfuncs=$(echo "$gofiles" | xargs awk -v m="$FUNC_LOC_WARN" '
/^func / { start = FNR; name = $0; inf = 1; next }
inf && /^}/ { if (FNR - start > m) printf "%s:%d(%d)\n", FILENAME, start, FNR - start; inf = 0 }
' 2>/dev/null)
[ -n "$longfuncs" ] && note "functions over $FUNC_LOC_WARN lines: $(echo "$longfuncs" | tr '\n' ' ')"
if [ -f Dockerfile ] && [ "${VERIFY_DOCKER:-0}" = "1" ]; then
head_ "container (VERIFY_DOCKER=1)"
if command -v docker >/dev/null 2>&1; then
if docker build -q -t khosra:verify . >/tmp/docker.log 2>&1; then
pass "docker build"
else
bad "docker build"; tail -20 /tmp/docker.log | sed 's/^/ /'
fi
else
note "docker not on PATH"
fi
fi
head_ "style floor"
# conventions.md states these absolutely, so they fail. A rule enforced as a suggestion teaches
# the agent to read every rule as a suggestion.
badpkg=$(find ./cmd ./internal -type d 2>/dev/null | grep -Ei '/(utils?|helpers?|common|shared|misc|manager|base|impl|core)$' || true)
if [ -n "$badpkg" ]; then bad "package name says nothing (CLAUDE.md rule 3.6): $(echo "$badpkg" | tr '\n' ' ')"; else pass "package names"; fi
inits=$(echo "$gofiles" | xargs grep -ln '^func init()' 2>/dev/null || true)
if [ -n "$inits" ]; then bad "init() found — wire explicitly in cmd/: $(echo "$inits" | tr '\n' ' ')"; else pass "no init()"; fi
badlog=$(echo "$pairs" | awk '$2=="log" {print $1}' | sort -u)
if [ -n "$badlog" ]; then bad "imports log, not log/slog: $(echo "$badlog" | tr '\n' ' ')"; else pass "log/slog only"; fi
clocks=$(echo "$gofiles" | grep -v '_test\.go$' | grep -v '/clock\.go$' | xargs grep -ln 'time\.Now(' 2>/dev/null || true)
if [ -n "$clocks" ]; then bad "time.Now() outside a clock.go — a render that reads the clock is only true for a while (conventions.md): $(echo "$clocks" | tr '\n' ' ')"; else pass "clock accessed through clock.go"; fi
panics=$(echo "$gofiles" | grep -v '_test\.go$' | grep -v '^\./cmd/' | xargs grep -ln 'panic(' 2>/dev/null || true)
if [ -n "$panics" ]; then bad "panic() outside cmd/ — request-time failure degrades (conventions.md): $(echo "$panics" | tr '\n' ' ')"; else pass "no panic outside cmd"; fi
head_ "smells (advisory)"
nowrap=$(echo "$gofiles" | xargs grep -n 'fmt\.Errorf(' 2>/dev/null | grep -v '%w' | wc -l | tr -d ' ')
[ "${nowrap:-0}" -gt 0 ] && note "$nowrap fmt.Errorf without %w (wrap at package boundaries)"
# -H forces the filename even for a single file; comment lines are prose, where "any" is a word
anyuse=$(echo "$gofiles" | grep -v '_test\.go$' | xargs grep -Hn 'interface{}\|\bany\b' 2>/dev/null |
grep -vE ':[[:space:]]*//' | grep -v 'map\[string\]any' | cut -d: -f1 | sort -u || true)
[ -n "$anyuse" ] && note "interface{} or any present (no empty interface for flexibility): $(echo "$anyuse" | tr '\n' ' ')"
deep=$(echo "$gofiles" | xargs awk '/^\t\t\t\t\t[^\t}]/ {print FILENAME; nextfile}' 2>/dev/null | sort -u || true)
[ -n "$deep" ] && note "nesting past 4 (conventions.md): $(echo "$deep" | tr '\n' ' ')"
deadexp=$(echo "$gofiles" | grep '^\./internal/' | grep -v '_test\.go$' | xargs grep -hoE '^func [A-Z][A-Za-z0-9_]*' 2>/dev/null | awk '{print $2}' | sort -u |
while read -r sym; do
n=$(echo "$gofiles" | xargs grep -c "\b$sym\b" 2>/dev/null | awk -F: '{s+=$2} END{print s+0}')
[ "${n:-0}" -le 1 ] && printf '%s ' "$sym"
done)
[ -n "$deadexp" ] && note "exported but referenced once — unexport or delete: $deadexp"
todos=$(echo "$gofiles" | xargs grep -c 'TODO\|FIXME\|XXX' 2>/dev/null | awk -F: '{s+=$2} END{print s+0}')
[ "${todos:-0}" -gt 0 ] && note "$todos TODO/FIXME markers (latent items belong in docs/state.md)"
result_and_exit