Files
summercms/scripts/check-phase32.sh
Jakub Zych 9247761c94 test(32-05): check-phase32 gate
- stages self-test, go, plugins, consumers, docker, admin, docs and
  marker; --all with the docker NOT RUN rule (PHASE32_REQUIRE_DOCKER)
- consumers: NOT MOUNTED golem15.seo refuses unless
  PHASE32_ALLOW_UNMOUNTED=1, and the Journal route-drift check between
  the demo's menu_items.yaml and its Nuxt routes
- marker (D-90): lines added since PHASE32_BASE under modules/ and docs/
  name neither SEO nor a consuming application; Journal imports no SEO
  or sitemap plugin; the framework does not require grobotstxt
2026-10-27 12:48:23 +01:00

761 lines
29 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# check-phase32: fail-closed gate for Phase 32, SEO foundation: the
# framework seams (form extensions, record events, content descriptors),
# the Journal plugin's descriptors and events, the SEO plugin
# (sm-seo-plugin) and the demo application that mounts it.
#
# Stages:
# self-test the go test -json and vitest detectors on planted output,
# the marker rule on a planted repository, the route-drift
# comparison, the mount detection and the docker NOT RUN rule
# on a faked host.
# go go vet ./..., go test -short ./... and the Docker-free
# framework tests of the phase by required name (GO_TESTS).
# plugins sm-seo-plugin and sm-journal-plugin: the workspace check (the
# framework resolves to this checkout), go vet, go test -short
# and the Docker-free tests by required name.
# consumers the demo application: the workspace check, go build ./...
# untagged and with -tags summercms_mcp, go test -short ./...
# both ways, the SEO mount (NOT MOUNTED golem15.seo fails unless
# PHASE32_ALLOW_UNMOUNTED=1: the mount waits for the plugin tag,
# plan 32-06) and the route-drift check (each Journal pattern of
# config/menu_items.yaml equals the route the demo's Nuxt app
# configures for the Journal layer, else the layer default;
# strict URLs 301 valid pages when the two drift).
# docker Docker host only: the Postgres suites by required name in the
# framework, both plugins and, once mounted, the demo (tagged).
# admin the FormView vitest file, scripts/check-admin-openapi.sh
# --check, and scripts/check-admin-dist.sh when admin/src
# changed since the phase base.
# docs TestDocsTree, summer docs:build --check, the grobotstxt
# decision note and its STACK row, and the SEO plugin's
# element typecheck and dist check.
# marker D-90: the lines this phase added to non-test Go files,
# READMEs and docs under modules/ and docs/ name neither SEO
# nor a consuming application; the Journal plugin imports
# neither the SEO nor the sitemap plugin; the framework go.mod
# does not require grobotstxt.
#
# A Go stage exits non-zero on a failing command, or on a go test -json run
# that fails, skips a test (a subtest included), matches zero tests, prints
# "no tests to run" or misses a required name.
#
# Several stages may be given at once (--go --plugins ...). --all runs
# self-test, go, plugins, consumers, admin, docs and marker, then docker
# when a Docker daemon answers (NOT RUN docker otherwise, a failure with
# PHASE32_REQUIRE_DOCKER=1). Plan 32-06 adds the additive and compat
# stages.
#
# Environment overrides (default: the sibling checkouts next to this
# repository): PHASE32_ROOT, SEO_PLUGIN, JOURNAL_PLUGIN, DEMO_APP,
# JOURNAL_LAYER, PHASE32_BASE (the commit before the phase, plan 32-01's
# start), PHASE32_ALLOW_UNMOUNTED, PHASE32_REQUIRE_DOCKER.
set -euo pipefail
unset FORCE_COLOR
export NO_COLOR=1
ROOT="${PHASE32_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
SEO_PLUGIN="${SEO_PLUGIN:-$ROOT/../sm-seo-plugin}"
JOURNAL_PLUGIN="${JOURNAL_PLUGIN:-$ROOT/../sm-journal-plugin}"
DEMO_APP="${DEMO_APP:-$ROOT/../sm-demo-app}"
JOURNAL_LAYER="${JOURNAL_LAYER:-$ROOT/../vue-journal-layer}"
# The framework commit before the phase (plan 32-01's start).
PHASE32_BASE="${PHASE32_BASE:-9868838fe3d01e07d578f2b2dd557d4296c48e10}"
MCP_TAG="summercms_mcp"
SEO_ID="golem15.seo"
# ---------------------------------------------------------------------------
# Required test names: <package>:<TestName> for the framework, names for the
# plugins and the demo (package .).
# go stage: Docker-free framework tests (-short).
GO_TESTS=(
./modules/cabana:TestFormExtensionPlacement ./modules/cabana:TestFormExtensionBootErrors
./modules/cabana:TestFormExtensionUnextendedSchemaUnchanged ./modules/cabana:TestFormExtensionTwoPluginsSameAnchor
./modules/cabana:TestFormExtensionOnePluginTwoControllers ./modules/cabana:TestFormExtensionWidgetsAndPartials
./modules/signpost:TestContentLookups ./modules/signpost:TestContentRecordsOptional
./modules/signpost:TestContentBootErrors ./modules/signpost:TestContentMatchPattern
./modules/signpost:TestContentMatchPath
)
# docker stage: Postgres tests in the framework.
DOCKER_TESTS=(
./modules/cabana:TestFormExtensionTracer ./modules/cabana:TestFormExtensionSave
./modules/cabana:TestRecordEvents
)
# plugins stage: Docker-free tests by required name (<package>:<TestName>).
SEO_SHORT_TESTS=(.:TestEveryAdminOperationHasTool .:TestMCPReadToolsAreGET .:TestLangKeysComplete .:TestNormalize)
JOURNAL_SHORT_TESTS=(.:TestJournalMCP ./classes:TestMenuItemTypesPatterns)
# docker stage: the plugins' and the demo's Postgres suites.
SEO_TESTS=(
TestSEOTabTracer TestSEOTabPermission TestSEOTab TestRuleFormPreview TestHistoryRecordsAdminWrite
TestEntryDeletedWithRecord TestGenerateEntries TestResolverPrecedence
TestSlugChange301 TestRedirectEngine TestLoopRefused TestNotFoundLog TestAddressChangeFromEvent
TestStrictURL TestOneHop TestRobotsServed TestRobotsTester TestRobotsScreen TestVerificationFile
TestMCPSetURLSeoTracer TestManagementAPI TestIssues TestBatch TestUndo TestCSVTransfer
)
JOURNAL_TESTS=(TestContentDescriptors TestJournalTagType TestAPIRecordEvents TestRecordSavedDescribesNewURL TestTranslatedSlug)
DEMO_TESTS=(TestSEOTabMounted TestSEOToolsCatalog)
# admin stage: vitest files that must pass.
ADMIN_FILES=(tests/form/FormView.test.ts)
# docs stage: the decision note and the STACK row of the plugin-only
# dependency.
DECISION_NOTE=".planning/notes/2026-10-27-robots-txt-grobotstxt.md"
STACK_NAMES=("github.com/jimsmart/grobotstxt")
# marker stage: names of consuming applications (split so this file does
# not match itself; the list check-phase19.7.sh uses).
APP_NAMES=("p[lł]yta""rium" "fono""teka" "wave""path" "horo""skopia" "bm\\.jakub""zych" "grzyby""funkcjonalne" "stu""dia")
usage() {
cat >&2 <<'EOF'
usage:
check-phase32.sh --self-test
check-phase32.sh [--go] [--plugins] [--consumers] [--docker] [--admin] [--docs] [--marker]
check-phase32.sh --all
EOF
exit 2
}
# detect_go reads go test -json. Exit 1 fail/build, 2 skip, 3 zero/no-tests,
# 4 non-JSON, 5 missing required name (REQUIRE_TESTS, space-separated).
detect_go() {
python3 - "$1" <<'PY'
import json, os, sys
path = sys.argv[1]
require = [n for n in os.environ.get("REQUIRE_TESTS", "").split() if n]
passed = set()
failed = []
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 = ev.get("Action")
test = ev.get("Test") or ""
pkg = ev.get("Package") or ev.get("ImportPath") or ""
if action == "build-fail" or (action == "fail" and ev.get("FailedBuild")):
print(f"refuse: build failed {pkg}", file=sys.stderr)
sys.exit(1)
text = ev.get("Output") or ""
if action == "output" and "no tests to run" in text:
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":
failed.append(f"{pkg} {test}".strip())
if action == "pass" and test:
passed.add(test)
if failed:
print("refuse: failed " + ", ".join(failed), file=sys.stderr)
sys.exit(1)
if not passed:
print("refuse: zero tests", file=sys.stderr)
sys.exit(3)
missing = [n for n in require if n not in passed]
if missing:
print("refuse: required tests did not pass: " + ", ".join(missing), file=sys.stderr)
sys.exit(5)
PY
}
# detect_vitest reads a vitest run's output. Exit 1 failed, skipped or todo
# tests, 3 no all-passing summary, 5 a required file (REQUIRE_FILES) did
# not pass.
detect_vitest() {
python3 - "$1" <<'PY'
import os, re, sys
text = open(sys.argv[1], encoding="utf-8", errors="replace").read()
text = re.sub(r"\x1b\[[0-9;]*m", "", text)
need = os.environ.get("REQUIRE_FILES", "").split()
summary = re.findall(r"^\s*(Test Files|Tests)\s+(.*)$", text, re.M)
if any(re.search(r"\b(failed|skipped|todo)\b", rest) for _, rest in summary) or re.search(r"^\s*(FAIL|×)\s", text, re.M):
print("refuse: vitest reports failed, skipped or todo tests", file=sys.stderr)
sys.exit(1)
m = re.search(r"^\s*Tests\s+(\d+) passed \((\d+)\)\s*$", text, re.M)
if not m or m.group(1) != m.group(2) or m.group(1) == "0":
print("refuse: no all-passing vitest summary", file=sys.stderr)
sys.exit(3)
missing = [f for f in need if not re.search(r"^\s*✓ (?:\|\w+\| )?" + re.escape(f) + r" \(\d+ tests?\)", text, re.M)]
if missing:
print("refuse: did not pass: " + ", ".join(missing), file=sys.stderr)
sys.exit(5)
PY
}
# go_json DIR ARGS... runs go test -json ARGS in DIR and checks the stream
# with detect_go.
go_json() {
local dir="$1"
shift
local log err rc=0 dc=0
log="$(mktemp)"
err="$(mktemp)"
(cd "$dir" && go test -json "$@") >"$log" 2>"$err" || rc=$?
detect_go "$log" || dc=$?
if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then
cat "$err" >&2 || true
python3 -c 'import json,sys
for raw in open(sys.argv[1], errors="replace"):
try: ev = json.loads(raw)
except ValueError: continue
out = ev.get("Output", "")
if ev.get("Action") == "output" and ("FAIL" in out or "SKIP" in out or "_test.go:" in out):
sys.stderr.write(out)' "$log" 2>&1 | tail -n 40 >&2 || true
rm -f "$log" "$err"
echo "refuse: go test $* in $dir (test=$rc detect=$dc)" >&2
return 1
fi
echo "go: $(grep -c '"Action":"pass","Package":"[^"]*","Test":"[^"/]*","Elapsed"' "$log") top-level tests passed in $dir ($*)"
rm -f "$log" "$err"
}
# go_named DIR PACKAGE FLAGS NAME... runs exactly the named top-level tests
# and requires every one to pass. FLAGS is word-split.
go_named() {
local dir="$1" pkg="$2" flags="$3"
shift 3
if [[ "$#" -eq 0 ]]; then
echo "refuse: go_named $pkg in $dir without required names" >&2
return 1
fi
local names=("$@") regex
regex="^($(IFS='|'; echo "${names[*]}"))\$"
# shellcheck disable=SC2086
REQUIRE_TESTS="${names[*]}" go_json "$dir" $flags -count=1 -run "$regex" "$pkg"
}
# go_pkg_named DIR FLAGS ENTRY... groups <package>:<TestName> entries by
# package and runs go_named once per package.
go_pkg_named() {
local dir="$1" flags="$2"
shift 2
local entry pkg name pkgs=()
declare -A by=()
for entry in "$@"; do
pkg="${entry%%:*}"
name="${entry#*:}"
if [[ -z "$pkg" || -z "$name" || "$pkg" == "$entry" ]]; then
echo "refuse: malformed required test entry $entry" >&2
return 1
fi
if [[ -z "${by[$pkg]+x}" ]]; then
pkgs+=("$pkg")
by[$pkg]="$name"
else
by[$pkg]="${by[$pkg]} $name"
fi
done
for pkg in "${pkgs[@]}"; do
# shellcheck disable=SC2086
go_named "$dir" "$pkg" "$flags" ${by[$pkg]}
done
}
# go_short DIR [FLAGS] runs go test -short ./... in DIR.
go_short() {
local dir="$1" flags="${2:-}" out
# shellcheck disable=SC2086
if ! out="$(cd "$dir" && go test $flags -count=1 -short ./... 2>&1)"; then
printf '%s\n' "$out" | grep -v '^ok ' | tail -n 60 >&2
echo "refuse: go test -short ./... $flags in $dir" >&2
return 1
fi
echo "short: $dir ($flags)"
}
go_vet() {
local dir="$1"
shift
(cd "$dir" && go vet "$@")
echo "vet: $dir ($*)"
}
require_dir() {
[[ -d "$1" ]] || {
echo "refuse: $2 $1 not found" >&2
return 1
}
}
# workspace_check DIR refuses a repository that does not resolve the
# framework to this checkout (no trust in local go.work files).
workspace_check() {
local dir="$1" got rgot rwant
got="$( (cd "$dir" && go list -m -f '{{if .Replace}}{{.Replace.Dir}}{{else}}{{.Dir}}{{end}}' git.golem15.com/golem15/summercms 2>/dev/null) || true)"
rgot="$(realpath -e -- "$got" 2>/dev/null || true)"
rwant="$(realpath -e -- "$ROOT" 2>/dev/null || true)"
if [[ -z "$got" || -z "$rgot" || "$rgot" != "$rwant" ]]; then
echo "refuse: $dir resolves git.golem15.com/golem15/summercms to ${got:-<nothing>}, not $ROOT" >&2
return 1
fi
echo "workspace: $dir resolves the framework to $rwant"
}
# seo_mounted MANIFEST reports whether an application's summer.yaml lists
# the SEO plugin.
seo_mounted() {
grep -qE "^[[:space:]]*-[[:space:]]*id:[[:space:]]*${SEO_ID//./\\.}[[:space:]]*$" "$1"
}
# mount_verdict DIR prints the mount state of an application and refuses
# an unmounted one unless PHASE32_ALLOW_UNMOUNTED=1.
mount_verdict() {
local dir="$1"
if seo_mounted "$dir/summer.yaml"; then
echo "mount: $dir mounts $SEO_ID"
return 0
fi
echo "NOT MOUNTED $SEO_ID in $dir (the mount waits for the plugin tag: .planning/phases/32-seo-foundation/32-05-demo-mount.patch, plan 32-06)"
if [[ "${PHASE32_ALLOW_UNMOUNTED:-0}" == "1" ]]; then
return 0
fi
echo "refuse: $SEO_ID is not mounted in $dir; set PHASE32_ALLOW_UNMOUNTED=1 before plan 32-06" >&2
return 1
}
# route_drift MENU_ITEMS NUXT_CONFIG LAYER_ROUTES compares each Journal
# pattern of menu_items.yaml (journal-post, journal-category, journal-tag
# when set) with the route the Nuxt app configures for the Journal layer,
# else the layer default; placeholder names do not count. It prints one
# refuse: line per drift and exits 1 on any.
route_drift() {
python3 - "$1" "$2" "$3" <<'PY'
import re, sys
menu_path, nuxt_path, layer_path = sys.argv[1:4]
def norm(p):
return re.sub(r":[A-Za-z_][A-Za-z0-9_]*", ":*", p.strip().strip("'\"")) if isinstance(p, str) else p
menu = {}
for line in open(menu_path, encoding="utf-8"):
m = re.match(r"^\s+(journal-[a-z]+):\s*(\S+)\s*$", line)
if m:
menu[m.group(1)] = m.group(2).strip("'\"")
def routes_of(text, block):
m = re.search(block, text, re.S)
out = {}
if not m:
return out
for k, v in re.findall(r"(\w+):\s*('[^']*'|\"[^\"]*\"|false)", m.group(1)):
out[k] = False if v == "false" else v.strip("'\"")
return out
layer = {}
try:
layer = routes_of(open(layer_path, encoding="utf-8").read(), r"DEFAULT_JOURNAL_ROUTES[^{]*\{(.*?)\}")
except OSError:
pass
nuxt = {}
try:
nuxt = routes_of(open(nuxt_path, encoding="utf-8").read(), r"journal:\s*\{\s*routes:\s*\{(.*?)\}")
except OSError:
pass
drift = 0
for item, key in (("journal-post", "post"), ("journal-category", "category"), ("journal-tag", "tag")):
if item not in menu:
continue
want = nuxt.get(key, layer.get(key))
if want is None:
print(f"refuse: no Nuxt route for {key} (menu_items {item} {menu[item]})", file=sys.stderr)
drift += 1
continue
if want is False or norm(want) != norm(menu[item]):
print(f"refuse: route drift: menu_items {item} {menu[item]} but the Nuxt {key} route is {want}", file=sys.stderr)
drift += 1
continue
print(f"route: {item} {menu[item]} matches the Nuxt {key} route {want}")
sys.exit(1 if drift else 0)
PY
}
# marker_hits DIR BASE prints file:line for every line added since BASE to
# a non-test Go file, a README or a docs page under modules/ or docs/ that
# names SEO or a consuming application (D-90).
marker_hits() {
local dir="$1" base="$2" pattern
pattern="\\bseo\\b|$(IFS='|'; echo "${APP_NAMES[*]}")"
git -C "$dir" diff -U0 "$base" HEAD -- 'modules/*.go' 'modules/**/*.go' 'modules/**/README.md' 'docs' ':(exclude)*_test.go' ':(exclude)**/*_test.go' |
PATTERN="$pattern" python3 -c '
import os, re, sys
pat = re.compile(os.environ["PATTERN"], re.I)
path, line = "", 0
for raw in sys.stdin:
if raw.startswith("+++ "):
path = raw[6:].rstrip("\n") if raw.startswith("+++ b/") else ""
continue
m = re.match(r"^@@ -\S+ \+(\d+)", raw)
if m:
line = int(m.group(1))
continue
if raw.startswith("+") and not raw.startswith("+++"):
if path and pat.search(raw[1:]):
print(f"{path}:{line}: {raw[1:].strip()[:120]}")
line += 1
'
}
expect_detector() {
local detector="$1" name="$2" want="$3" payload="$4" log dc=0
log="$(mktemp)"
printf '%s\n' "$payload" >"$log"
"$detector" "$log" >/dev/null 2>&1 || dc=$?
rm -f "$log"
if [[ "$dc" -ne "$want" ]]; then
echo "refuse: self-test $detector $name: exit $dc, want $want" >&2
return 1
fi
echo "PASS self-test $detector $name"
}
# expect_mode NAME WANT_EXIT WANT_TEXT MODE ENV... runs this script in MODE
# with ENV and checks its exit status and output.
expect_mode() {
local name="$1" want="$2" text="$3" mode="$4"
shift 4
local out rc=0
out="$(env "$@" bash "${BASH_SOURCE[0]}" "$mode" 2>&1)" || rc=$?
if [[ "$rc" -ne "$want" ]] || ! grep -qF -- "$text" <<<"$out"; then
printf '%s\n' "$out" | tail -n 10 >&2
echo "refuse: self-test $name: exit $rc (want $want), output must carry \"$text\"" >&2
return 1
fi
echo "PASS self-test $name"
}
run_self_test() {
bash -n "${BASH_SOURCE[0]}"
expect_detector detect_go pass 0 '{"Action":"pass","Package":"p","Test":"TestBatch"}'
expect_detector detect_go fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
{"Action":"fail","Package":"p","Test":"TestUndo"}'
expect_detector detect_go build 1 '{"Action":"build-fail","ImportPath":"p"}'
expect_detector detect_go skip 2 '{"Action":"pass","Package":"p","Test":"TestA"}
{"Action":"skip","Package":"p","Test":"TestSEOTabMounted"}'
expect_detector detect_go subtest-skip 2 '{"Action":"skip","Package":"p","Test":"TestLoopRefused/mcp"}
{"Action":"pass","Package":"p","Test":"TestLoopRefused"}'
expect_detector detect_go zero 3 '{"Action":"pass","Package":"p"}'
expect_detector detect_go no-tests 3 '{"Action":"output","Package":"p","Output":"testing: warning: no tests to run\n"}
{"Action":"pass","Package":"p"}'
expect_detector detect_go nonjson 4 '{"Action":"pass",'
REQUIRE_TESTS="TestBatch TestUndo" expect_detector detect_go missing-named 5 '{"Action":"pass","Package":"p","Test":"TestBatch"}'
REQUIRE_TESTS="TestLoopRefused" expect_detector detect_go subtest-is-not-parent 5 '{"Action":"pass","Package":"p","Test":"TestOuter/TestLoopRefused"}
{"Action":"pass","Package":"p","Test":"TestOther"}'
local ok=' ✓ tests/form/FormView.test.ts (12 tests) 15ms
Test Files 1 passed (1)
Tests 12 passed (12)'
REQUIRE_FILES="tests/form/FormView.test.ts" expect_detector detect_vitest pass 0 "$ok"
REQUIRE_FILES="tests/form/Other.test.ts" expect_detector detect_vitest missing-file 5 "$ok"
expect_detector detect_vitest failed 1 ' Test Files 1 failed | 1 passed (2)
Tests 1 failed | 16 passed (17)'
expect_detector detect_vitest no-summary 3 ' ✓ tests/form/FormView.test.ts (12 tests) 15ms'
local tmp
tmp="$(mktemp -d)"
# The marker rule on a planted repository: an added SEO name or
# application name in a module source or a docs page is found; a test
# file, and a line that was there before the base, are not.
git -C "$tmp" init -q
mkdir -p "$tmp/modules/cabana" "$tmp/docs"
printf 'package cabana\n\n// Old mentions SEO from before the phase.\nfunc Old() {}\n' >"$tmp/modules/cabana/old.go"
git -C "$tmp" add -A
git -C "$tmp" -c user.name=t -c user.email=t@example.test commit -q -m base
local base
base="$(git -C "$tmp" rev-parse HEAD)"
printf 'package cabana\n\n// Extension adds fields to a form.\nfunc Extension() {}\n' >"$tmp/modules/cabana/ext.go"
printf 'package cabana\n\n// The SEO tab test.\nfunc helper() {}\n' >"$tmp/modules/cabana/ext_test.go"
git -C "$tmp" add -A
git -C "$tmp" -c user.name=t -c user.email=t@example.test commit -q -m clean
if [[ -n "$(marker_hits "$tmp" "$base")" ]]; then
rm -rf "$tmp"
echo "refuse: self-test marker_hits flagged a clean change or a test file" >&2
return 1
fi
printf 'package cabana\n\n// Extension adds the SEO tab.\nfunc Extension() {}\n' >"$tmp/modules/cabana/ext.go"
printf '# Forms\n\nUsed by fono''teka.\n' >"$tmp/docs/forms.md"
git -C "$tmp" add -A
git -C "$tmp" -c user.name=t -c user.email=t@example.test commit -q -m dirty
local hits
hits="$(marker_hits "$tmp" "$base")"
if ! grep -q '^modules/cabana/ext.go:3:' <<<"$hits" || ! grep -q '^docs/forms.md:3:' <<<"$hits"; then
rm -rf "$tmp"
echo "refuse: self-test marker_hits missed a planted name: $hits" >&2
return 1
fi
echo "PASS self-test marker_hits"
# The route-drift comparison: placeholder names do not count, a moved
# route and a disabled one do.
mkdir -p "$tmp/rd"
printf 'patterns:\n journal-post: /blog/:slug\n journal-category: /blog/category/:slug\n' >"$tmp/rd/menu.yaml"
printf "export default {\n journal: {\n routes: {\n post: '/blog/:slug',\n category: '/blog/category/:category',\n tag: false,\n },\n },\n}\n" >"$tmp/rd/nuxt.ts"
printf "export const DEFAULT_JOURNAL_ROUTES = Object.freeze({\n post: '/blog/:slug',\n category: '/blog/category/:category',\n tag: '/blog/tag/:tag',\n})\n" >"$tmp/rd/routes.ts"
if ! route_drift "$tmp/rd/menu.yaml" "$tmp/rd/nuxt.ts" "$tmp/rd/routes.ts" >/dev/null 2>&1; then
rm -rf "$tmp"
echo "refuse: self-test route_drift refused matching routes" >&2
return 1
fi
printf 'patterns:\n journal-post: /news/:slug\n' >"$tmp/rd/moved.yaml"
printf 'patterns:\n journal-tag: /blog/tag/:slug\n' >"$tmp/rd/tag.yaml"
if route_drift "$tmp/rd/moved.yaml" "$tmp/rd/nuxt.ts" "$tmp/rd/routes.ts" >/dev/null 2>&1 ||
route_drift "$tmp/rd/tag.yaml" "$tmp/rd/nuxt.ts" "$tmp/rd/routes.ts" >/dev/null 2>&1; then
rm -rf "$tmp"
echo "refuse: self-test route_drift accepted a moved or disabled route" >&2
return 1
fi
if ! route_drift "$tmp/rd/menu.yaml" "$tmp/rd/missing.ts" "$tmp/rd/routes.ts" >/dev/null 2>&1; then
rm -rf "$tmp"
echo "refuse: self-test route_drift did not fall back to the layer defaults" >&2
return 1
fi
echo "PASS self-test route_drift"
# The mount detection and the NOT MOUNTED rule.
printf 'plugins:\n - id: golem15.sitemap\n module: x\n - id: golem15.seo\n module: y\n' >"$tmp/mounted.yaml"
printf 'plugins:\n - id: golem15.sitemap\n module: x\n - id: golem15.seox\n module: y\n' >"$tmp/unmounted.yaml"
if ! seo_mounted "$tmp/mounted.yaml" || seo_mounted "$tmp/unmounted.yaml"; then
rm -rf "$tmp"
echo "refuse: self-test seo_mounted misread a manifest" >&2
return 1
fi
mkdir -p "$tmp/app"
cp "$tmp/unmounted.yaml" "$tmp/app/summer.yaml"
expect_mode not-mounted 1 "NOT MOUNTED golem15.seo" --mount-verdict "DEMO_APP=$tmp/app" PHASE32_ALLOW_UNMOUNTED=0
expect_mode not-mounted-allowed 0 "NOT MOUNTED golem15.seo" --mount-verdict "DEMO_APP=$tmp/app" PHASE32_ALLOW_UNMOUNTED=1
# The docker NOT RUN rule: a docker that does not answer.
mkdir -p "$tmp/bin"
printf '#!/bin/sh\nexit 1\n' >"$tmp/bin/docker"
chmod +x "$tmp/bin/docker"
expect_mode docker-not-run 0 "NOT RUN docker" --docker-gate "PATH=$tmp/bin:$PATH" PHASE32_REQUIRE_DOCKER=0
expect_mode docker-required 1 "FAIL docker" --docker-gate "PATH=$tmp/bin:$PATH" PHASE32_REQUIRE_DOCKER=1
expect_mode docker-stage-needs-daemon 1 "needs a reachable Docker daemon" --docker "PATH=$tmp/bin:$PATH"
rm -rf "$tmp"
echo "phase32 self-test passed"
}
run_go() {
go_vet "$ROOT" ./...
go_short "$ROOT"
go_pkg_named "$ROOT" "-short" "${GO_TESTS[@]}"
echo "phase32 go passed"
}
run_plugins() {
require_dir "$SEO_PLUGIN" "SEO plugin"
require_dir "$JOURNAL_PLUGIN" "Journal plugin"
workspace_check "$SEO_PLUGIN"
workspace_check "$JOURNAL_PLUGIN"
go_vet "$SEO_PLUGIN" ./...
go_short "$SEO_PLUGIN"
go_pkg_named "$SEO_PLUGIN" "-short" "${SEO_SHORT_TESTS[@]}"
go_vet "$JOURNAL_PLUGIN" ./...
go_short "$JOURNAL_PLUGIN"
go_pkg_named "$JOURNAL_PLUGIN" "-short" "${JOURNAL_SHORT_TESTS[@]}"
echo "phase32 plugins passed"
}
run_consumers() {
require_dir "$DEMO_APP" "demo application"
workspace_check "$DEMO_APP"
(cd "$DEMO_APP" && go build ./...)
echo "build: $DEMO_APP"
(cd "$DEMO_APP" && go build -tags "$MCP_TAG" ./...)
echo "build: $DEMO_APP (-tags $MCP_TAG)"
go_short "$DEMO_APP"
go_short "$DEMO_APP" "-tags $MCP_TAG"
mount_verdict "$DEMO_APP"
route_drift "$DEMO_APP/config/menu_items.yaml" "$DEMO_APP/vue-demo-app/nuxt.config.ts" "$JOURNAL_LAYER/app/utils/routes.ts"
echo "phase32 consumers passed"
}
docker_up() {
command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1
}
run_docker() {
if ! docker_up; then
echo "refuse: the docker stage needs a reachable Docker daemon (testcontainers); run it on a Docker host" >&2
return 1
fi
require_dir "$SEO_PLUGIN" "SEO plugin"
require_dir "$JOURNAL_PLUGIN" "Journal plugin"
require_dir "$DEMO_APP" "demo application"
workspace_check "$SEO_PLUGIN"
workspace_check "$JOURNAL_PLUGIN"
go_pkg_named "$ROOT" "-timeout 20m" "${DOCKER_TESTS[@]}"
go_named "$SEO_PLUGIN" . "-timeout 20m" "${SEO_TESTS[@]}"
go_named "$JOURNAL_PLUGIN" . "-timeout 20m" "${JOURNAL_TESTS[@]}"
if seo_mounted "$DEMO_APP/summer.yaml"; then
workspace_check "$DEMO_APP"
go_named "$DEMO_APP" . "-timeout 20m -tags $MCP_TAG" "${DEMO_TESTS[@]}"
else
mount_verdict "$DEMO_APP"
fi
echo "phase32 docker passed"
}
# docker_gate is the docker step of --all: run the stage when a daemon
# answers, fail with PHASE32_REQUIRE_DOCKER=1, print NOT RUN otherwise.
docker_gate() {
if docker_up; then
if bash "${BASH_SOURCE[0]}" --docker; then
echo "PASS docker"
return 0
fi
echo "FAIL docker"
return 1
fi
if [[ "${PHASE32_REQUIRE_DOCKER:-0}" == "1" ]]; then
echo "FAIL docker: no reachable Docker daemon and PHASE32_REQUIRE_DOCKER=1"
return 1
fi
cat >&2 <<'EOF'
************************************************************************
NOT RUN docker: no reachable Docker daemon on this host.
The Postgres suites of the framework, the SEO and Journal plugins and the
demo did NOT run. Run on a Docker host (through sudo -u golem when the
socket is denied) before /msd-verify-work:
bash scripts/check-phase32.sh --docker
************************************************************************
EOF
echo "NOT RUN docker (Docker-host only; see the notice above)"
}
run_admin() {
local log rc=0 dc=0
log="$(mktemp)"
npm --prefix "$ROOT/admin" run test >"$log" 2>&1 || rc=$?
REQUIRE_FILES="${ADMIN_FILES[*]}" detect_vitest "$log" || dc=$?
if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then
grep -E '(FAIL|×|Error|expected)' "$log" | head -n 40 >&2 || true
tail -n 20 "$log" >&2
rm -f "$log"
echo "refuse: npm run test in admin (test=$rc detect=$dc)" >&2
return 1
fi
echo "vitest (admin): $(grep -E '^\s*Tests\s' "$log" | tr -s ' ')"
rm -f "$log"
(cd "$ROOT" && bash scripts/check-admin-openapi.sh --check)
if git -C "$ROOT" diff --quiet "$PHASE32_BASE" HEAD -- admin/src; then
echo "admin: admin/src unchanged since $PHASE32_BASE, dist check not needed"
else
(cd "$ROOT" && bash scripts/check-admin-dist.sh)
fi
echo "phase32 admin passed"
}
run_docs() {
go_named "$ROOT" ./cmd/summer "" TestDocsTree
(cd "$ROOT" && go run ./cmd/summer docs:build --check)
if [[ ! -f "$ROOT/$DECISION_NOTE" ]] || ! grep -qF "grobotstxt" "$ROOT/$DECISION_NOTE"; then
echo "refuse: the decision note $DECISION_NOTE is missing or does not name grobotstxt" >&2
return 1
fi
echo "note: $DECISION_NOTE"
local name
for name in "${STACK_NAMES[@]}"; do
if ! grep -qF -- "| \`$name\`" "$ROOT/.planning/research/STACK.md"; then
echo "refuse: .planning/research/STACK.md has no row for $name" >&2
return 1
fi
done
echo "stack: rows for ${STACK_NAMES[*]}"
require_dir "$SEO_PLUGIN" "SEO plugin"
npm --prefix "$SEO_PLUGIN" run typecheck >/dev/null
echo "typecheck: $SEO_PLUGIN elements"
bash "$SEO_PLUGIN/scripts/check-dist.sh"
echo "phase32 docs passed"
}
run_marker() {
if ! git -C "$ROOT" rev-parse --verify -q "$PHASE32_BASE^{commit}" >/dev/null; then
echo "refuse: PHASE32_BASE $PHASE32_BASE is not a commit of $ROOT" >&2
return 1
fi
local hits imports
hits="$(marker_hits "$ROOT" "$PHASE32_BASE")"
if [[ -n "$hits" ]]; then
printf '%s\n' "$hits" | head -n 40 >&2
echo "refuse: framework lines added since $PHASE32_BASE name SEO or a consuming application (D-90)" >&2
return 1
fi
echo "marker: no line added since ${PHASE32_BASE:0:7} under modules/ or docs/ names SEO or a consuming application"
require_dir "$JOURNAL_PLUGIN" "Journal plugin"
imports="$(grep -rnE '"git\.golem15\.com/golem15/sm-(seo|sitemap)-plugin' --include='*.go' "$JOURNAL_PLUGIN" || true)"
if [[ -n "$imports" ]]; then
printf '%s\n' "$imports" >&2
echo "refuse: the Journal plugin imports the SEO or the sitemap plugin (D-90: content plugins import nothing)" >&2
return 1
fi
echo "journal: imports neither sm-seo-plugin nor sm-sitemap-plugin"
if grep -q 'jimsmart/grobotstxt' "$ROOT/go.mod"; then
echo "refuse: the framework go.mod requires grobotstxt (plugin-only dependency)" >&2
return 1
fi
echo "go.mod: the framework does not require grobotstxt"
echo "phase32 marker passed"
}
run_all() {
local stage
for stage in self-test go plugins consumers admin docs marker; do
if bash "${BASH_SOURCE[0]}" "--$stage"; then
echo "PASS $stage"
else
echo "FAIL $stage"
exit 1
fi
done
docker_gate || exit 1
echo "Phase 32 gate passed"
}
run_stage() {
case "$1" in
--self-test | self-test) run_self_test ;;
--go | go) run_go ;;
--plugins | plugins) run_plugins ;;
--consumers | consumers) run_consumers ;;
--docker | docker) run_docker ;;
--docker-gate) docker_gate ;;
--mount-verdict) mount_verdict "$DEMO_APP" ;;
--admin | admin) run_admin ;;
--docs | docs) run_docs ;;
--marker | marker) run_marker ;;
--all | all) run_all ;;
*) usage ;;
esac
}
if [[ "$#" -eq 0 ]]; then
run_all
exit 0
fi
if [[ "$#" -eq 1 ]]; then
run_stage "$1"
exit 0
fi
for arg in "$@"; do
if bash "${BASH_SOURCE[0]}" "$arg"; then
echo "PASS ${arg#--}"
else
echo "FAIL ${arg#--}"
exit 1
fi
done