- 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
761 lines
29 KiB
Bash
Executable File
761 lines
29 KiB
Bash
Executable File
#!/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
|