Files
summercms/scripts/check-phase11.1.sh
Jakub Zych 85ce154640 fix(11.1-06): remove the docs gate's scratch directories on a refusal
- a refusal exits from inside a stage, where the RETURN trap never ran,
  leaving the self-test's 48 MB scratch copy in /tmp; an EXIT trap now
  removes every stage's scratch paths
- the self-test copies examples/ with tar, excluding examples/*/bin,
  instead of git ls-files, so a PHASE11_1_ROOT copy without .git works
2026-10-01 00:21:34 +02:00

402 lines
16 KiB
Bash
Executable File

#!/usr/bin/env bash
# Phase 11.1 fail-closed gate (documentation for humans and AI agents:
# DOCS-01 to DOCS-08).
#
# The semantic checks (identifiers, links and anchors, src= snippets,
# command names, consuming-application names, fence policy) live in Go and
# run through `go test` and `summer docs:build --check`. This script only
# orchestrates them and adds repository-level assertions: preconditions,
# the dependency delta, the built output and the forbidden-name sweep.
# --self-test plants one violation per rule in a scratch copy and requires
# docs:build --check to refuse it for that rule, then runs the Go
# planted-violation corpus. --named runs every named test of the phase by
# exact name and requires each to PASS: a failure, a skip, a missing or
# renamed test and "no tests to run" all refuse.
set -euo pipefail
# Scratch paths made by the stages. A refusal exits from inside a stage,
# where the stage's RETURN trap does not run, so they are also removed on
# exit.
SCRATCH=()
cleanup() {
[ "${#SCRATCH[@]}" -eq 0 ] || rm -rf "${SCRATCH[@]}"
}
trap cleanup EXIT
ROOT="${PHASE11_1_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
PHASE11_DIR="$ROOT/.planning/phases/11-jobs-realtime-and-search-infrastructure"
DEPS_BASE="${PHASE11_1_DEPS_BASE:-9033d81}"
# Module paths this phase may add to go.mod (D-15).
ALLOWED_NEW_DEPS="github.com/alecthomas/chroma/v2 github.com/dlclark/regexp2/v2"
# Phase 11 modules that must be documented before the docs are written.
PHASE11_MODULES=(conga lighthouse flare beachcomber)
# D-11 consuming-application spellings, byte-level alternation so it works
# in any locale (the accented variant matches the Phase 11 hygiene regex).
FORBIDDEN_RE='fonoteka|p(l|ł|Ł)(y|ý|Ý)tarium'
usage() {
cat >&2 <<'EOF'
usage:
check-phase11.1.sh --preconditions
check-phase11.1.sh --deps
check-phase11.1.sh --docs
check-phase11.1.sh --forbidden
check-phase11.1.sh --claude
check-phase11.1.sh --self-test
check-phase11.1.sh --named
check-phase11.1.sh --go
check-phase11.1.sh --all
EOF
exit 2
}
refuse() {
echo "refuse: $*" >&2
return 1
}
# build_summer compiles the summer tool into $1 once per mode.
build_summer() {
(cd "$ROOT" && go build -o "$1" ./cmd/summer)
}
run_preconditions() {
[ -f "$PHASE11_DIR/11-08-SUMMARY.md" ] || refuse "gap plan 11-08 has no SUMMARY (module READMEs are not stable yet)"
local m
for m in "${PHASE11_MODULES[@]}"; do
[ -f "$ROOT/modules/$m/README.md" ] || refuse "modules/$m has no README.md"
grep -qF "| [$m](modules/$m/README.md) |" "$ROOT/README.md" || refuse "root README has no table row for $m"
done
echo "phase11.1 preconditions passed"
}
# module_paths prints the module paths required by a go.mod file.
module_paths() {
(cd "$ROOT" && go mod edit -json "$1") | python3 -c '
import json, sys
for r in json.load(sys.stdin).get("Require") or []:
print(r["Path"])
' | sort -u
}
run_deps() {
local tmp base now removed added dep
tmp="$(mktemp -d)"
SCRATCH+=("$tmp")
trap 'rm -rf "$tmp"' RETURN
git -C "$ROOT" show "$DEPS_BASE:go.mod" >"$tmp/base.mod"
cp "$ROOT/go.mod" "$tmp/now.mod"
base="$(module_paths "$tmp/base.mod")"
now="$(module_paths "$tmp/now.mod")"
removed="$(comm -23 <(echo "$base") <(echo "$now"))"
[ -z "$removed" ] || refuse "go.mod dropped modules since $DEPS_BASE: $removed"
added="$(comm -13 <(echo "$base") <(echo "$now"))"
for dep in $added; do
case " $ALLOWED_NEW_DEPS " in
*" $dep "*) ;;
*) refuse "go.mod added $dep, which no phase decision approves" ;;
esac
done
# D-15: chroma/v2 highlights code at build time, as a direct requirement.
(cd "$ROOT" && go mod edit -json) | python3 -c '
import json, sys
direct = [r for r in json.load(sys.stdin).get("Require") or [] if r["Path"] == "github.com/alecthomas/chroma/v2" and not r.get("Indirect")]
sys.exit(0 if direct else 1)
' || refuse "go.mod does not require github.com/alecthomas/chroma/v2 directly"
if grep -q 'goldmark-highlighting' "$ROOT/go.mod"; then
refuse "go.mod requires goldmark-highlighting (D-15 rejects it)"
fi
echo "phase11.1 deps passed"
}
run_docs() {
local tmp site name
tmp="$(mktemp -d)"
SCRATCH+=("$tmp")
trap 'rm -rf "$tmp"' RETURN
build_summer "$tmp/summer"
site="$tmp/site"
(cd "$ROOT" && "$tmp/summer" docs:build --root "$ROOT" --out "$site") || refuse "docs:build failed"
for name in index.html index.md llms.txt llms-full.txt search-index.json assets/site.css .summer-docs; do
[ -f "$site/$name" ] || refuse "docs:build output has no $name"
done
if find "$site" -name '*.go' | grep -q .; then
refuse "docs:build output contains .go files"
fi
echo "phase11.1 docs passed"
}
# forbidden_hits prints the files under the given paths that name a
# consuming application, never the matching lines.
forbidden_hits() {
grep -rliE "$FORBIDDEN_RE" "$@" 2>/dev/null || true
}
run_forbidden() {
local tmp hits
tmp="$(mktemp -d)"
SCRATCH+=("$tmp")
trap 'rm -rf "$tmp"' RETURN
build_summer "$tmp/summer"
(cd "$ROOT" && "$tmp/summer" docs:build --root "$ROOT" --out "$tmp/site" >/dev/null) || refuse "docs:build failed"
hits="$(forbidden_hits "$ROOT/docs" "$tmp/site")"
[ -z "$hits" ] || refuse "consuming-application name in: $(echo "$hits" | sed "s|$tmp/||; s|$ROOT/||" | tr '\n' ' ')"
echo "phase11.1 forbidden passed"
}
# The D-13 / DOCS-08 rules CLAUDE.md's Documentation section must carry.
CLAUDE_RULES=(
'also updates the affected pages under `docs/` in the same change'
'`go test ./cmd/summer -run TestDocsTree` and `summer docs:build --check` check identifiers'
'Every identifier named in a README or a docs page must exist in the package.'
'Config keys named in README or docs pages are not checked automatically yet'
)
run_claude() {
local section rule
section="$(awk '/^## Documentation$/{on=1; next} /^## /{on=0} on' "$ROOT/CLAUDE.md")"
[ -n "$section" ] || refuse "CLAUDE.md has no Documentation section"
for rule in "${CLAUDE_RULES[@]}"; do
grep -Fq -- "$rule" <<<"$section" || refuse "CLAUDE.md Documentation section is missing: $rule"
done
echo "phase11.1 claude passed"
}
run_go() {
(cd "$ROOT" && go vet ./...)
(cd "$ROOT" && go test ./...)
echo "phase11.1 go passed"
}
# detect_json reads a go test -json log. It refuses a build failure, any
# failed test or package, any skipped test, "no tests to run", a run with
# zero passing tests, and any required "<package> <Test>" pair (from
# PHASE11_1_REQUIRE, newline separated) that did not PASS.
detect_json() {
python3 - "$1" <<'PY'
import json, os, sys
path = sys.argv[1]
require = [r.strip() for r in os.environ.get("PHASE11_1_REQUIRE", "").splitlines() if r.strip()]
passed = set()
with open(path, encoding="utf-8", errors="replace") as fh:
for raw in fh:
line = raw.strip()
if not line.startswith("{"):
continue
try:
ev = json.loads(line)
except json.JSONDecodeError:
print("refuse: non-json test output", file=sys.stderr)
sys.exit(4)
action, test, pkg = ev.get("Action"), ev.get("Test") or "", ev.get("Package") or ""
if action == "build-fail" or (action == "fail" and ev.get("FailedBuild")):
print(f"refuse: build failed in {pkg}", file=sys.stderr)
sys.exit(1)
if action == "output" and "no tests to run" in (ev.get("Output") or ""):
print(f"refuse: no tests to run in {pkg}", file=sys.stderr)
sys.exit(3)
if action == "skip" and test:
print(f"refuse: skipped {pkg} {test}", file=sys.stderr)
sys.exit(2)
if action == "fail":
print(f"refuse: failed {pkg} {test}".rstrip(), file=sys.stderr)
sys.exit(1)
if action == "pass" and test:
passed.add(f"{pkg} {test}")
if not passed:
print("refuse: zero tests", file=sys.stderr)
sys.exit(3)
missing = [r for r in require if r not in passed]
if missing:
print("refuse: named tests did not pass (missing, renamed or filtered out): " + ", ".join(missing), file=sys.stderr)
sys.exit(5)
PY
}
# go_json_named runs `go test -json -count=1 -run '^(names)$'` for one
# package and requires every named test to PASS.
go_json_named() {
local pkg="$1" log import names name require="" code=0
shift
import="$(cd "$ROOT" && go list -f '{{.ImportPath}}' "$pkg")" || refuse "go list $pkg failed" || return 1
names="$(IFS='|'; echo "$*")"
for name in "$@"; do
require+="$import $name"$'\n'
done
log="$(mktemp)"
SCRATCH+=("$log")
(cd "$ROOT" && go test -json -count=1 "$pkg" -run "^($names)\$" >"$log" 2>&1) || code=$?
if ! PHASE11_1_REQUIRE="$require" detect_json "$log"; then
rm -f "$log"
return 1
fi
rm -f "$log"
[ "$code" -eq 0 ] || refuse "go test $pkg exited $code"
}
# expect_refusal runs docs:build --check on the scratch root and requires a
# non-zero exit whose output names the rule.
expect_refusal() {
local name="$1" want="$2" summer="$3" root="$4" output
if output="$(cd "$root" && GOWORK=off "$summer" docs:build --check --root "$root" 2>&1)"; then
echo "refuse: self-test accepted planted $name" >&2
exit 1
fi
if ! grep -Fq -- "$want" <<<"$output"; then
echo "refuse: self-test $name failed for the wrong rule: $output" >&2
exit 1
fi
}
# restore copies a pristine file back over its planted scratch copy.
restore() {
cp "$1/$3" "$2/$3"
}
run_self_test() {
bash -n "${BASH_SOURCE[0]}"
local tmp scratch pristine summer
tmp="$(mktemp -d)"
SCRATCH+=("$tmp")
trap 'rm -rf "$tmp"' RETURN
scratch="$tmp/root"
pristine="$tmp/pristine"
summer="$tmp/summer"
build_summer "$summer"
mkdir -p "$scratch" "$pristine"
cp -R "$ROOT/docs" "$ROOT/modules" "$ROOT/go.mod" "$ROOT/go.sum" "$ROOT/README.md" "$scratch/"
# The command checker reads bonfire.Command literals under examples/
# (built binaries under examples/*/bin are left out).
tar -C "$ROOT" --exclude='examples/*/bin' -cf - examples | tar -C "$scratch" -xf -
cp -R "$ROOT/docs" "$pristine/"
(cd "$scratch" && GOWORK=off "$summer" docs:build --check --root "$scratch" >/dev/null) ||
refuse "self-test baseline: the unplanted scratch copy does not pass docs:build --check"
printf '\nSee `bonfire.NoSuchThing`.\n' >>"$scratch/docs/index.md"
expect_refusal 'unknown identifier' 'identifier: bonfire.NoSuchThing does not exist in modules/bonfire' "$summer" "$scratch"
restore "$pristine" "$scratch" docs/index.md
mkdir "$scratch/modules/zzplant"
printf 'package zzplant\n' >"$scratch/modules/zzplant/zzplant.go"
expect_refusal 'missing README' 'readme: package has Go files but no README.md' "$summer" "$scratch"
rm -rf "$scratch/modules/zzplant"
sed -i 's/Hello, %s/Hi, %s/' "$scratch/docs/setup/installation.md"
expect_refusal 'drifted snippet' 'snippet: body differs' "$summer" "$scratch"
restore "$pristine" "$scratch" docs/setup/installation.md
sed -i '1a colour: red' "$scratch/docs/setup/installation.md"
expect_refusal 'frontmatter typo' 'frontmatter: unknown field' "$summer" "$scratch"
restore "$pristine" "$scratch" docs/setup/installation.md
printf '\nSee [nowhere](#no-such-anchor).\n' >>"$scratch/docs/index.md"
expect_refusal 'broken anchor' 'link: #no-such-anchor not found in' "$summer" "$scratch"
restore "$pristine" "$scratch" docs/index.md
printf '\nRun `summer no:such`.\n' >>"$scratch/docs/index.md"
expect_refusal 'unknown command' '"no:such" is not a summer or application command' "$summer" "$scratch"
restore "$pristine" "$scratch" docs/index.md
# Built from two halves so this script's plant is not itself a hit.
printf '\nThe %s%s application.\n' 'fono' 'teka' >>"$scratch/docs/index.md"
expect_refusal 'forbidden name' 'forbidden: consuming-application name in output' "$summer" "$scratch"
restore "$pristine" "$scratch" docs/index.md
printf '\n```go\nx := 1\n```\n' >>"$scratch/docs/index.md"
expect_refusal 'go fence without src=' 'snippet: go code block has no src= reference' "$summer" "$scratch"
restore "$pristine" "$scratch" docs/index.md
printf '\n> [!DANGER]\n> Planted.\n' >>"$scratch/docs/index.md"
expect_refusal 'unknown callout' 'callout: unknown type DANGER (use NOTE, TIP or WARNING)' "$summer" "$scratch"
restore "$pristine" "$scratch" docs/index.md
# The planted-violation corpus: one fixture per checker rule, each of
# which must fail for its own rule, and the clean fixture. Skips, zero
# matches and "no tests to run" refuse.
go_json_named ./internal/docsite TestPlantedViolations TestCleanFixture ||
refuse "self-test: the planted-violation corpus did not pass"
echo "phase11.1 self-test passed"
}
# docs_example_tests prints the TestDocs* functions and the Example*
# functions with an output comment (the ones go test runs) declared in the
# _test.go files of one package directory.
docs_example_tests() {
python3 - "$ROOT/$1" <<'PY'
import glob, os, re, sys
names = []
for path in sorted(glob.glob(os.path.join(sys.argv[1], "*_test.go"))):
src = open(path, encoding="utf-8").read()
for m in re.finditer(r"^func (TestDocs\w*|Example\w*)\(", src, re.M):
name = m.group(1)
if name.startswith("Example"):
end = re.search(r"^}", src[m.end():], re.M)
body = src[m.end():m.end() + (end.start() if end else len(src))]
if not re.search(r"//\s*(Unordered output|Output):", body):
continue
names.append(name)
print(" ".join(names))
PY
}
# Named tests of the phase, by package. The docs examples in modules are
# derived from the sources (every TestDocs* and every Example with an
# output comment); the generator, CLI and walkthrough tests are listed.
DOCSITE_TESTS=(TestPlantedViolations TestCleanFixture TestBuildOutputGuard TestSlugIDs TestSlugIDsEdgeCases
TestReadmeIngestion TestSnippetForms TestSnippetConfinement TestSyncRewritesDrift TestSnippetGenericsAndGroups
TestSyncPreservesAndReports TestIdentifierChecker TestIdentifierIndexDuplicateName TestIdentifierIndexForms
TestIdentifierGoDocFallback TestLinkChecker TestCommandChecker TestCommandTokenForms TestForbiddenChecker
TestFencePolicy TestFrontmatterEncodingProblems TestParseSite TestLLMSTxtShape TestLLMSFullBlocks
TestMarkdownSiblings TestSearchIndexSchema TestBaseURLPrefixing TestTOCThreshold TestLinkRewriting
TestCalloutRendering TestHighlightGo TestHighlightYAML TestHighlightShell TestHighlightFallback
TestBuildSiteMarkers TestThemeAssetsAndPager TestServeHandler TestServeHandlerBranches TestServeAddrPolicy
TestServeRefusesNonLoopback TestServeRebuildKeepsLastGoodBuild TestServeWatchRebuilds)
SUMMER_TESTS=(TestPhase11_1Acceptance TestDocsTree TestDocsBuildRealTree TestEveryModuleInSidebar
TestDocsAIOutputsInSync TestDocsRequiredPages TestDocsCommandNames TestDocsCommandsMirrorGeneratedMain
TestDocsBuildCheckOutput TestDocsSyncOutput TestDocsServeRefusal TestToolCommandNames)
run_named() {
local dir names
go_json_named ./internal/docsite "${DOCSITE_TESTS[@]}" || refuse "named: internal/docsite"
go_json_named ./cmd/summer "${SUMMER_TESTS[@]}" || refuse "named: cmd/summer"
go_json_named ./docs/examples/blog TestPluginActivates TestRoutesRegistered TestScaffoldLayout \
TestMigrateUpAndRollback TestPostsRouteAgainstDatabase TestPublishCommandAgainstDatabase \
TestPublishCommandOpensDatabase || refuse "named: docs/examples/blog"
go_json_named ./docs/examples/blog/models TestPostTableAndFill || refuse "named: docs/examples/blog/models"
go_json_named ./docs/examples/blog/updates TestMigrationIDs || refuse "named: docs/examples/blog/updates"
go_json_named ./docs/examples/blog/controllers TestPostsControllerDeclaration || refuse "named: docs/examples/blog/controllers"
go_json_named ./docs/examples/blog/console TestPublishCommandShape || refuse "named: docs/examples/blog/console"
while IFS= read -r dir; do
names="$(docs_example_tests "$dir")"
[ -n "$names" ] || refuse "named: $dir has an example_test.go but no Example with output or TestDocs test"
# shellcheck disable=SC2086 # names is a space-separated list of identifiers
go_json_named "./$dir" $names || refuse "named: $dir"
done < <(cd "$ROOT" && find modules -name example_test.go -not -path '*/testdata/*' -printf '%h\n' | sort -u)
echo "phase11.1 named passed"
}
case "${1:-}" in
--preconditions) run_preconditions ;;
--deps) run_deps ;;
--docs) run_docs ;;
--forbidden) run_forbidden ;;
--claude) run_claude ;;
--self-test) run_self_test ;;
--named) run_named ;;
--go) run_go ;;
--all)
run_preconditions
run_deps
run_self_test
run_docs
run_forbidden
run_claude
run_named
run_go
echo "phase11.1 all passed"
;;
*) usage ;;
esac