- TestPhase11_1Acceptance asserts SC1 to SC5 on the real tree: sections and module pages, theme markers and no Node exec, AI outputs in sync, every go fence a src= copy run by go test, concept map and walkthrough - check-phase11.1.sh --named runs every named test of the phase by exact name (modules' TestDocs* and output Examples derived from source) and refuses failures, skips, missing or renamed tests and no tests to run - --all runs preconditions, deps, self-test, docs, forbidden, claude, named and the full go vet and go test
388 lines
16 KiB
Bash
Executable File
388 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
|
|
|
|
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 "<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)"
|
|
(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
|