#!/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. set -euo pipefail 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 --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)" 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)" 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)" 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 " " 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)" (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)" 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/ # (the tracked files only: examples/hello/bin holds built binaries). git -C "$ROOT" ls-files -z examples | tar -C "$ROOT" --null -T - -cf - | 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" } case "${1:-}" in --preconditions) run_preconditions ;; --deps) run_deps ;; --docs) run_docs ;; --forbidden) run_forbidden ;; --claude) run_claude ;; --self-test) run_self_test ;; --go) run_go ;; --all) run_preconditions run_deps run_self_test run_docs run_forbidden run_claude run_go echo "phase11.1 all passed" ;; *) usage ;; esac