#!/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 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)" 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" } # 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