docs/content-model.md opens with "Engine specification". It is also where the rule lives that a leading underscore makes a file unaddressable — and the human who owns this site did not know that rule, because nothing in this repository is addressed to an author. Twelve documents named docs/ while being exclusively about building the parser is a signpost pointing at the wrong room. Naming the directory for its audience makes the gap visible instead of hiding it. docs/ is now reserved and deliberately absent: an empty docs/ is an honest statement that end-user documentation does not exist, where docs/ full of parser specs was a claim that it did. HARNESS.md stays at the root. Root holds the three entry points — README.md for a human, CLAUDE.md for an agent, HARNESS.md for whoever maintains the machine — and harness/README.md is the map of the directory, so moving the guide inside would have collided with it for nothing. Mechanical and wide: 100 path references across 24 files. Every verify.sh gate that names a doc by path, the directory lists the dangling-path and ADR-number gates scan, surface.sh's output target, the Makefile, CLAUDE.md's read order, the skill, four commands, and two Go package comments. A first pass with a shell loop silently edited only four files and the rest still said docs/; the fix was to write the file list out and check the remaining count was zero rather than trust the loop's exit status. No rule, threshold, gate or obligation moved — this is a rename, and the gates demonstrated it twice: they stayed green on the new paths, and the ADR-number gate caught ADR-0082 before the entry existed. Deferred, both on the human's call: the end-user documentation site itself, which wants its own decision about where it lives and whether its claims are gated; and moving examples/ under docs/, since demo-site is a live site root that verify.sh, the coverage test and make demo all point at, and moving it would couple a rename to a design nobody has made. 31 files, +146/-106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
495 lines
24 KiB
Bash
Executable File
495 lines
24 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# The objective gate. No opinions, only exit codes.
|
|
# Usage: ./scripts/verify.sh [--quiet|--list]
|
|
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}
|
|
CLAUDE_LOC_MAX=${CLAUDE_LOC_MAX:-150}
|
|
|
|
fail=0
|
|
warn=0
|
|
# --quiet prints only what needs acting on: failures, warnings, and the verdict. A green run is two
|
|
# lines instead of forty-three, which matters because this is the most-run command in the repo and its
|
|
# output is read far more often by an agent's context window than by a person. Passing gate names are
|
|
# never information — `--list` answers "does this gate exist?" and does it without running anything.
|
|
quiet=0
|
|
[ "${1:-}" = "--quiet" ] && quiet=1
|
|
pass() { [ "$quiet" -eq 1 ] || printf ' ok %s\n' "$1"; }
|
|
bad() { printf ' FAIL %s\n' "$1"; fail=1; }
|
|
note() { printf ' warn %s\n' "$1"; warn=$((warn + 1)); }
|
|
head_() { [ "$quiet" -eq 1 ] || 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 'harness/state.md'; then
|
|
bad "*.go changed but harness/state.md did not — inventory, counters and latent items 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 'harness/theme-contract.md'; then
|
|
bad "embedded templates changed but harness/theme-contract.md did not — they drift together (ADR-0023)"
|
|
else
|
|
pass "templates/theme-contract coupling"
|
|
fi
|
|
|
|
# One exemption, and it is about the agent tooling rather than this project: .claude/settings.json is
|
|
# Claude Code's own permission list, which HARNESS.md calls a convenience, so editing it explains
|
|
# nothing about khosra. khosra's own configuration is not exempt from anything — the engine's settings
|
|
# live in the site root (content-model.md) and the engine validates them itself. Everything else under
|
|
# .claude/, and all of scripts/ including budgets.env, changes how the machine behaves and is gated.
|
|
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
|
|
|
|
# Invariant 2, as mechanism rather than memory. Content from the site root is trusted, so raw HTML renders
|
|
# there (ADR-0060) — but the *trusted* half is the whole claim, and this is what holds it to one place.
|
|
# A second call site means some other input has been handed the pipeline that trusts its author, which is
|
|
# the failure ADR-0003 exists to prevent; when comments arrive they get their own goldmark without it.
|
|
# Matched by file rather than by line, so the rule can still be explained next to the code it governs.
|
|
unsafe=$(grep -rl 'html\.WithUnsafe()' --include='*.go' cmd internal 2>/dev/null | sort || true)
|
|
if [ "$unsafe" != "internal/render/render.go" ]; then
|
|
bad "html.WithUnsafe belongs in internal/render/render.go and nowhere else (ADR-0003, ADR-0060); found in: $(echo "${unsafe:-nothing}" | tr '\n' ' ')"
|
|
else
|
|
pass "raw HTML is enabled in exactly one renderer"
|
|
fi
|
|
|
|
# The example site is a demonstration that has to stay true (ADR-0051). `check` over it catches the class of
|
|
# rot a test cannot: a content rule that changed while the example kept the old shape. Findings are allowed —
|
|
# the example carries one on purpose — but nothing fatal is.
|
|
if [ -d examples/demo-site ]; then
|
|
if go run ./cmd/khosra check -site examples/demo-site >/tmp/example-check.log 2>&1; then
|
|
pass "the example site still checks out"
|
|
else
|
|
bad "examples/demo-site has a fatal finding — the demo must stay a site the engine accepts"
|
|
sed 's/^/ /' /tmp/example-check.log
|
|
fi
|
|
fi
|
|
|
|
# Dangling references. Every one of these found a real stale pointer when run by hand.
|
|
# `.scratch/` is included only when it exists: it is gitignored, so on a fresh clone the handoff is
|
|
# legitimately absent (harness/README.md says "if present") and checking it there would fail correct
|
|
# work. Once the directory is present, a named file missing from it is a stale pointer like any
|
|
# other — which is how state.md's pointer to a deleted build-queue outlived it by two commits. That
|
|
# path is named without backticks on purpose: this gate reads its own file, so the example would be a
|
|
# reference, and the gate would flag the comment explaining it.
|
|
scratchpat=""
|
|
[ -d .scratch ] && scratchpat='|\.scratch'
|
|
refs=$(grep -rhoE "\`(harness|scripts|ideas|reference|\.claude$scratchpat)/[A-Za-z0-9_./-]+\`" \
|
|
harness 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}' harness 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" harness/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]+' harness 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: was state.md updated no earlier than the last commit that touched Go?
|
|
#
|
|
# Compared, never declared. A hand-written sha cannot do this job: a commit cannot name itself, so the
|
|
# doc update had to trail the code in a commit of its own, and folding the two together then left the
|
|
# sha naming a commit that no longer existed (ADR-0057).
|
|
lastgo=$(git log -1 --format=%H -- '*.go' 2>/dev/null)
|
|
laststate=$(git log -1 --format=%H -- harness/state.md 2>/dev/null)
|
|
if [ -n "$lastgo" ]; then
|
|
if [ -n "$laststate" ] && git merge-base --is-ancestor "$lastgo" "$laststate" 2>/dev/null; then
|
|
pass "harness/state.md is current with the code"
|
|
else
|
|
note "harness/state.md was last updated in ${laststate:-no commit}, older than the .go change in $(git log -1 --format=%h -- '*.go')"
|
|
fi
|
|
fi
|
|
# Every counter row says what does *not* count (ADR-0070). Four counters were re-scoped on first contact,
|
|
# each time by naming something that had been wrongly included, so the column is where that thinking has to
|
|
# happen — before the counter is trusted rather than after it misleads. Checked by shape, not by wording: a
|
|
# row of the counters table must have five filled cells.
|
|
counters=$(awk -F'|' '/^\| Counter \| Now \|/{on=1; next} on && /^\|---/{next} on && /^\|/{
|
|
name=$2; gsub(/^[ \t]+|[ \t]+$/, "", name);
|
|
last=$6; gsub(/^[ \t]+|[ \t]+$/, "", last);
|
|
if (last == "") print name
|
|
} on && !/^\|/{on=0}' harness/state.md)
|
|
if [ -n "$counters" ]; then
|
|
bad "counter row with no 'does not count' (ADR-0070): $(echo "$counters" | tr '\n' ';')"
|
|
else
|
|
pass "every counter row says what does not count"
|
|
fi
|
|
|
|
# The generated surface is only worth reading if it cannot be wrong. Regenerate and compare rather
|
|
# than trusting that whoever added a function also ran the script.
|
|
if [ -x scripts/surface.sh ]; then
|
|
if ! scripts/surface.sh | cmp -s - harness/surface.md; then
|
|
bad "harness/surface.md is stale — run 'make surface'"
|
|
else
|
|
pass "harness/surface.md matches the code"
|
|
fi
|
|
fi
|
|
|
|
# CLAUDE.md is re-sent on every single turn, which makes it the most expensive file per byte in the
|
|
# repo. A ceiling here is not tidiness; it is the only budget that bills per turn.
|
|
claudeloc=$(wc -l <CLAUDE.md | tr -d ' ')
|
|
if [ "$claudeloc" -gt "$CLAUDE_LOC_MAX" ]; then
|
|
bad "CLAUDE.md is $claudeloc lines, over CLAUDE_LOC_MAX=$CLAUDE_LOC_MAX (move detail to the owning doc)"
|
|
else
|
|
pass "CLAUDE.md $claudeloc / $CLAUDE_LOC_MAX lines"
|
|
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' "$warn"
|
|
else
|
|
printf ' FAIL fix the above before reporting success\n'
|
|
fi
|
|
[ "$quiet" -eq 1 ] || printf '\n'
|
|
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
|
|
|
|
# The working tree building says nothing about what is being committed. Staging a subset — a changed
|
|
# signature without its caller — lands a commit that does not compile, which `git bisect` then trips over
|
|
# forever. So when something is staged, build the staged tree itself, from a throwaway checkout of the index
|
|
# so nothing here can touch the real one.
|
|
if ! git diff --cached --quiet 2>/dev/null; then
|
|
staged=$(mktemp -d)
|
|
if git archive "$(git write-tree)" 2>/dev/null | tar -x -C "$staged" 2>/dev/null &&
|
|
(cd "$staged" && go build ./... >/tmp/staged-build.log 2>&1); then
|
|
pass "the staged tree builds"
|
|
else
|
|
bad "what is staged does not build on its own — a partial stage, most likely a caller left behind"
|
|
sed 's/^/ /' /tmp/staged-build.log 2>/dev/null
|
|
fi
|
|
rm -rf "$staged"
|
|
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
|
|
|
|
# An untidy go.mod misreports what is direct, so the allowlist check above would silently skip a
|
|
# dependency added by `go get` before anything imported it.
|
|
if go mod tidy -diff >/tmp/tidy.log 2>&1; then
|
|
pass "go.mod is tidy"
|
|
else
|
|
bad "go.mod is untidy — run go mod tidy; until then a direct dependency can hide as indirect"
|
|
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
|
|
layer render web
|
|
echo "$pairs" | awk -v c="$mod/cmd" 'index($2,c)==1 && index($1,c)!=1 {print $1" → "$2}'
|
|
)
|
|
# Invariant 7, as a rule rather than a list: a feature is a leaf, so `cmd/` is the *only* thing that may
|
|
# import one. Stated positively, this also covers a core package that does not exist yet — the enumeration
|
|
# above cannot, and the day someone adds `internal/foo` it would import `internal/ext` unnoticed. Sibling
|
|
# imports between features fail here too: they are what make an agent's read set compound (ADR-0027), and
|
|
# they are the other way a feature stops being deletable. Test files are excluded by `go list .Imports`,
|
|
# which is right — the demo's own test exercises features on purpose (ADR-0069).
|
|
siblings=$(echo "$pairs" | awk -v e="$mod/internal/ext/" -v c="$mod/cmd" '
|
|
index($2,e)==1 && index($1,c)!=1 {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)"
|
|
# Only calls that actually wrap: fmt.Errorf without an err argument is creating an error, not losing one.
|
|
nowrap=$(echo "$gofiles" | xargs grep -hn 'fmt\.Errorf(' 2>/dev/null |
|
|
grep -v '%w' | grep -cE '\berr\b' || true)
|
|
[ "${nowrap:-0}" -gt 0 ] && note "$nowrap fmt.Errorf without %w (wrap at package boundaries)"
|
|
# Only exported signatures: conventions.md bans interface{} as an API escape hatch, but ADR-0002 mandates
|
|
# an open page object, so unexported code reading Extra legitimately takes any and always will.
|
|
anyuse=$(echo "$gofiles" | grep -v '_test\.go$' |
|
|
xargs grep -HnE '^func (\([^)]*\) )?[A-Z][A-Za-z0-9_]*\(.*(\bany\b|interface\{\})' 2>/dev/null |
|
|
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 harness/state.md)"
|
|
|
|
result_and_exit
|