Two answers to "add .sh to sh files". Exactly one file lacks the suffix: scripts/hooks/pre-commit. git locates a hook by exact filename, so renaming it would leave a gate that looks present and does nothing — verified in a throwaway repository, where hooks/pre-commit printed and hooks/pre-commit.sh was ignored while the commit succeeded regardless. The name belongs to git, so the file keeps it and conventions.md now states the exception rather than leaving it as an inconsistency someone will try to tidy again. The extension was never the defect anyway. The dangling-path gate had only ever matched backticked citations, so `git add docs/surface.md` in the hook — an argument, not a citation — survived the docs/ rename and surfaced as a fatal inside a commit that otherwise succeeded. That gate now also reads paths in scripts/ unquoted, which is the check that would have caught it. Proven both ways: restoring the exact bug fails the gate with "reference to a path that does not exist: docs/surface.md", and a working tree passes. The first attempt did not catch it — docs had been dropped from the alternation because the directory no longer exists, which is precisely the class of stale reference worth failing on, so docs is in the bare pattern on purpose. Backslashes are stripped before comparing, so a path written as a regex — \.claude/settings\.json — is checked as the file it means rather than flagged as the file it is not. Prose is still checked only inside backticks: a sentence saying "under harness/" makes a point no filesystem can verify, while a script naming a path either has it right or is broken. One gate label changed with it. `pass "harness/HARNESS.md coupling"` read fine while the directory was docs/ and now parses as a filename, which the new check duly flagged; it is "harness and HARNESS.md move together". No doc cited the old label. 4 files. No rule or threshold moved — one gate widened, one convention written down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
505 lines
25 KiB
Bash
Executable File
505 lines
25 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 and HARNESS.md move together"
|
|
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)
|
|
# Bare paths in the gate's own scripts, where a backtick is prose but the path is what actually runs.
|
|
# `git add harness/surface.md` in the pre-commit hook is not a citation, it is an argument — and the hook
|
|
# staged a deleted path for two commits because this check had only ever looked inside backticks. Only
|
|
# scripts/ is scanned bare: prose says "under harness/" for reasons a filesystem cannot check, while a
|
|
# shell script naming a path either has it right or is broken.
|
|
#
|
|
# Backslashes are stripped first, so a path written as a regex — `\.claude/settings\.json` — is compared
|
|
# as the file it means rather than flagged as the file it is not.
|
|
bare=$(sed 's/\\//g' $(find scripts -type f) 2>/dev/null |
|
|
grep -ohE '(harness|docs|scripts|ideas|reference)/[A-Za-z0-9_./-]+' | sed 's/[.]$//' | sort -u)
|
|
dangling=""
|
|
for f in $refs $bare; 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
|