Since Arc 1 the latent list has carried "raw HTML is omitted only because goldmark's default omits it". Shortcodes were the trigger, and they came and went without needing unsafe mode — a call renders a theme template (ADR-0036). So the right close is not a note saying we got away with it, but a gate: verify.sh now fails on WithUnsafe in any non-comment line under cmd/ or internal/. Proved both directions before keeping it. The first version also failed on the comment in render.go that explains the rule — a gate that fires on its own documentation is one nobody keeps, so it now skips comment lines. That is the seventh gate defect found by running against real code rather than reasoning about it. The latent row is gone rather than reworded, and the untrusted-boundary row says which half is now mechanism and which half still isn't.
416 lines
19 KiB
Bash
Executable File
416 lines
19 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
|
|
|
|
# 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. goldmark drops raw HTML by default, and that default is
|
|
# the only thing standing between an authored Markdown file and script injection. A shortcode wanting to
|
|
# emit HTML renders a theme template instead (ADR-0036), so nothing in this engine has a reason to turn
|
|
# it off — and the day something seems to, it should have to argue with a failing gate first.
|
|
# Comment lines are excluded: the rule is worth explaining next to the code it governs, and a gate that
|
|
# fires on its own documentation is a gate nobody keeps.
|
|
unsafe=$(grep -rn 'WithUnsafe' --include='*.go' cmd internal 2>/dev/null |
|
|
grep -vE ':[0-9]+:[[:space:]]*//' | cut -d: -f1,2 || true)
|
|
if [ -n "$unsafe" ]; then
|
|
bad "html.WithUnsafe enables raw HTML from authored content (ADR-0003, ADR-0036): $(echo "$unsafe" | tr '\n' ' ')"
|
|
else
|
|
pass "raw HTML stays disabled"
|
|
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
|
|
|
|
# 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 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)"
|
|
# 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 docs/state.md)"
|
|
|
|
result_and_exit
|