Files
summercms/scripts/check-phase19.7.sh
Jakub Zych 0b8e027b81 test(19.7-05): security review pins for the MCP endpoint's outer layers
- TestEndpointGuards: a foreign browser Origin is 403 before the bearer is
  read, a body over the endpoint cap is 413, a long GET stream ends once
  its grant stops verifying (T-19.7-18, T-19.7-19)
- TestDispatch: an argument key the input struct does not declare is
  refused before dispatch (T-19.7-17)
- the gate requires TestEndpointGuards (untagged and tagged) and runs the
  demo's deploy check (T-19.7-37)
2026-10-23 14:26:26 +02:00

838 lines
33 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-phase19.7: fail-closed gate for Phase 19.7, the generic MCP module
# (pier) with its framework seams, the user plugin's account link and MCP
# guard, the OAuth server plugin's MCP verifier and consent, the blog plugin's
# MCP tools and the demo application that wires them end to end. It proves
# the phase across six repositories.
#
# Stages:
# self-test the detectors on planted output, the docker NOT RUN rule on a
# faked host, the marker rule on planted files and the additive
# check on planted diffs.
# go go vet ./... (untagged and with -tags summercms_mcp), go test
# -short ./..., go -C examples/hello test ./..., go build -tags
# summercms_mcp ./..., the hello binary without the MCP SDK, then
# the Docker-free framework tests by required name (GO_TESTS,
# and GO_TAGGED_TESTS with -tags summercms_mcp).
# plugins in the user plugin (GOWORK=off), the OAuth server plugin and
# the blog plugin: go vet, go test -short, the Docker-free tests
# by required name and the additive check (no exported func,
# method or type removed from a non-test .go file since the
# plugin's last commit before the phase).
# consumers the applications at their bumped pins (D-31): go build ./...
# and go test -short ./... in each, the demo also with -tags
# summercms_mcp, its pin check and its deploy check (the nginx
# locations of the MCP endpoint).
# docker Docker host only: the Postgres suites by required name in the
# framework (cabana), the three plugins and the demo (tagged).
# admin npm --prefix admin run typecheck, the vitest run (the phase's
# files must pass), scripts/check-admin-openapi.sh --check and
# scripts/check-admin-dist.sh.
# docs TestDocsTree, summer docs:build --check, the decision note's
# dependency names, the STACK rows, and no framework source,
# README or docs page and no test file of this phase names a
# consuming application.
# marker D-17: no non-test .go file under modules/ outside modules/pier
# and modules/bouncer calls bouncer.WithDispatch, and
# modules/pact depends on neither the MCP SDK, jsonschema-go nor
# modules/bouncer.
#
# 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 (a subtest pass never counts
# for its parent's name). The vitest detector refuses a run whose summary is
# not all-passing, that reports a failed, skipped or todo test, or that has
# no passing line for a required file.
#
# --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 PHASE197_REQUIRE_DOCKER=1).
#
# Environment overrides (default: the sibling checkouts next to this
# repository): PHASE197_ROOT (this repository), USER_PLUGIN,
# OAUTHSERVER_PLUGIN, JOURNAL_PLUGIN, DEMO_APP, FONOTEKA, BM_APP, the
# additive bases PHASE197_USER_BASE, PHASE197_OAS_BASE,
# PHASE197_JOURNAL_BASE, and PHASE197_REQUIRE_DOCKER.
set -euo pipefail
unset FORCE_COLOR
export NO_COLOR=1
ROOT="${PHASE197_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
DEMO_APP="${DEMO_APP:-$ROOT/../sm-demo-app}"
USER_PLUGIN="${USER_PLUGIN:-$DEMO_APP/plugins/golem15/user}"
OAUTHSERVER_PLUGIN="${OAUTHSERVER_PLUGIN:-$ROOT/../sm-oauthserver-plugin}"
JOURNAL_PLUGIN="${JOURNAL_PLUGIN:-$ROOT/../sm-journal-plugin}"
FONOTEKA="${FONOTEKA:-$ROOT/../fono""teka.go}"
BM_APP="${BM_APP:-$ROOT/../sm-bm-app}"
# The plugins' last commits before the phase (the additive check's bases).
USER_BASE="${PHASE197_USER_BASE:-930b158 v0.1.9}"
OAS_BASE="${PHASE197_OAS_BASE:-d31f8e7}"
JOURNAL_BASE="${PHASE197_JOURNAL_BASE:-ffa2c8f}"
MCP_TAG="summercms_mcp"
# ---------------------------------------------------------------------------
# Required test names. Each entry is <package>:<TestName>; the go stage runs
# every package's names with -run '^(a|b|...)$' and every name must pass.
# go stage: Docker-free framework tests (-short).
GO_TESTS=(
# Plan 01 smokes.
./modules/surf:TestDispatchSmoke ./modules/surf:TestStreamingRouteSmoke
./modules/surf:TestPierMountSmoke ./modules/bouncer:TestDispatchedSmoke
./modules/cabana:TestDispatchMarkerSmoke ./modules/wristband:TestMCPScopesSmoke
./modules/cabana:TestAccountContributionsSmoke ./modules/cabana:TestRecordActionPromptSmoke
./modules/pact:TestPhase197ContractsOnSamplePlugin ./modules/pact:TestMCPGuardsRegistry
./modules/pact:TestPactStaysSDKFree
# Plan 02 smokes.
./modules/pier:TestPierTracerSmoke ./modules/pier:TestPierSessionsSmoke
./modules/pier:TestPierFanOutSmoke ./modules/pier:TestPierStatelessSmoke
./modules/pier:TestExampleBlogPlugin
# The cabana subject-event fix (d96c726).
./modules/cabana:TestFireSubjectChangedReachesPointerListener
# Plan 05: the validation map (SC-1..SC-3, D-04, D-10..D-17, D-20..D-27,
# D-30, D-34).
./modules/pier:TestToolsList ./modules/pier:TestCatalog ./modules/pier:TestDispatch
./modules/pier:TestGuardRules ./modules/pier:TestSessions ./modules/pier:TestFanOut
# Plan 05 security review pins (T-19.7-18, T-19.7-19).
./modules/pier:TestEndpointGuards
./modules/bouncer:TestDispatchMarker ./modules/cabana:TestDispatchMarker
./modules/surf:TestStreamingRoute ./modules/surf:TestPierMount
./modules/wristband:TestMCPScopes
./modules/cabana:TestAccountContributions ./modules/cabana:TestRecordActionPrompt
./modules/cabana:TestScopeLimitForMethods
)
# go stage: framework tests that also run in a binary built with the MCP
# tag (surf links pier there; TestPierMount takes its tagged branch).
GO_TAGGED_TESTS=(
./modules/pier:TestToolsList ./modules/pier:TestCatalog ./modules/pier:TestDispatch
./modules/pier:TestGuardRules ./modules/pier:TestSessions ./modules/pier:TestFanOut
./modules/pier:TestPierTracerSmoke ./modules/pier:TestPierStatelessSmoke ./modules/pier:TestEndpointGuards
./modules/surf:TestPierMount ./modules/surf:TestStreamingRoute ./modules/surf:TestDispatchSmoke
)
# docker stage: Postgres tests in the framework.
DOCKER_TESTS=(
./modules/cabana:TestDispatchMarker ./modules/cabana:TestAccountContributions
./modules/cabana:TestRecordActionPromptRun ./modules/cabana:TestFireSubjectChangedWaitsForCommit
)
# admin stage: vitest files that must pass.
ADMIN_FILES=(
tests/account/AccountContributions.test.ts
tests/form/RecordActionsPrompt.test.ts
)
# go stage: My account's own handlers stay as they were before the phase
# (D-04: existing payloads unchanged).
ACCOUNT_BASE="51d088a"
# plugins stage: Docker-free tests by required name. The user plugin has
# none (its suites need Postgres and run in the docker stage).
OAS_SHORT_TESTS=()
JOURNAL_SHORT_TESTS=(
.:TestJournalMCPSmoke .:TestJournalMCP
./controllers/api:TestOptionalBackendPrincipalDispatch
)
# docker stage: the plugins' and the demo's Postgres suites.
USER_TESTS=(
TestBackendLinkSmoke TestBackendLinkAdminSmoke TestUserMCPGuardSmoke TestFrontendPermissionCatalogSmoke
TestBackendLink TestFrontendPermissionCatalog TestUserMCPGuard
)
OAS_TESTS=(TestMCPTokenVerifierSmoke TestMCPConsentSmoke TestMCPTokenVerifier TestMCPConsent)
DEMO_TESTS=(TestMCPEndToEnd)
# docs stage: the phase's framework test files, checked for consuming
# application names next to every non-test file under modules/ and docs/.
PHASE_TEST_DIRS=(modules/pier)
PHASE_TEST_FILES=(
modules/bouncer/dispatch_test.go modules/bouncer/dispatch_smoke_test.go
modules/cabana/dispatch_marker_test.go modules/cabana/dispatch_smoke_test.go
modules/cabana/account_contributions_test.go modules/cabana/account_contributions_smoke_test.go
modules/cabana/record_action_prompt_test.go modules/cabana/record_action_prompt_smoke_test.go
modules/cabana/record_action_prompt_postgres_test.go modules/cabana/scope_limit_for_test.go
modules/cabana/subject_events_test.go
modules/surf/streaming_test.go modules/surf/streaming_smoke_test.go
modules/surf/pier_mount_test.go modules/surf/pier_mount_smoke_test.go modules/surf/dispatch_smoke_test.go
modules/wristband/mcp_scopes_test.go modules/wristband/mcp_scopes_smoke_test.go
admin/tests/account/AccountContributions.test.ts admin/tests/form/RecordActionsPrompt.test.ts
)
STACK_NAMES=("github.com/modelcontextprotocol/go-sdk" "github.com/google/jsonschema-go")
# docs stage: names of consuming applications (split so this file does not
# match itself), the decision note and the dependency names it must carry.
APP_NAMES=("p[lł]yta""rium" "fono""teka" "wave""path" "horo""skopia" "bm\\.jakub""zych" "grzyby""funkcjonalne" "stu""dia")
DECISION_NOTE=".planning/notes/2026-10-22-mcp-go-sdk.md"
DECISION_NAMES=("modelcontextprotocol/go-sdk" "google/jsonschema-go" "govulncheck")
usage() {
cat >&2 <<'EOF'
usage:
check-phase19.7.sh --self-test
check-phase19.7.sh go | plugins | consumers | docker | admin | docs | marker
check-phase19.7.sh --go | --plugins | --consumers | --docker | --admin | --docs | --marker
check-phase19.7.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.
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 did not pass.
# REQUIRE_FILES is a space-separated list. Only the summary and the file
# lines are read: a slow test's title may contain any word.
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_ENV (word-split) is prepended to the go command's
# environment, for GOWORK=off.
go_json() {
local dir="$1"
shift
local log err rc=0 dc=0
log="$(mktemp)"
err="$(mktemp)"
# shellcheck disable=SC2086
(cd "$dir" && env ${GO_ENV:-} go test -json "$@") >"$log" 2>"$err" || rc=$?
detect_go "$log" || dc=$?
if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then
cat "$err" >&2 || true
grep -v '^{' "$log" | tail -n 40 >&2 || true
python3 -c 'import json,sys
for raw in open(sys.argv[1], errors="replace"):
try: ev = json.loads(raw)
except ValueError: continue
if ev.get("Action") == "output" and ("FAIL" in ev.get("Output", "") or "SKIP" in ev.get("Output", "") or "_test.go:" in ev.get("Output", "")):
sys.stderr.write(ev["Output"])' "$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 PACKAGES FLAGS NAME... runs exactly the named top-level tests
# and requires every one of them to pass. PACKAGES and FLAGS are word-split.
go_named() {
local dir="$1" pkgs="$2" flags="$3"
shift 3
if [[ "$#" -eq 0 ]]; then
echo "refuse: go_named $pkgs 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" $pkgs
}
# 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
if [[ "$#" -eq 0 ]]; then
echo "refuse: go_pkg_named in $dir without required names" >&2
return 1
fi
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 PACKAGES runs go test -short PACKAGES in DIR; on failure it
# prints the tail of the output and fails. GO_ENV and GO_FLAGS are
# word-split into the command.
go_short() {
local dir="$1" pkgs="${2:-./...}" out
# shellcheck disable=SC2086
if ! out="$(cd "$dir" && env ${GO_ENV:-} go test ${GO_FLAGS:-} -count=1 -short $pkgs 2>&1)"; then
printf '%s\n' "$out" | grep -v '^ok ' | tail -n 60 >&2
echo "refuse: go test -short $pkgs in $dir" >&2
return 1
fi
echo "short: $dir ($pkgs ${GO_FLAGS:-})"
}
go_vet() {
local dir="$1"
shift
# shellcheck disable=SC2086
(cd "$dir" && env ${GO_ENV:-} go vet "$@")
echo "vet: $dir ($*)"
}
require_dir() {
[[ -d "$1" ]] || {
echo "refuse: $2 $1 not found" >&2
return 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
}
# expect_mode NAME WANT_EXIT WANT_TEXT MODE ENV... runs this script in MODE
# with ENV and checks its exit status and that its output carries WANT_TEXT.
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
}
# marker_callers DIR prints every non-test .go file under DIR/modules,
# outside modules/pier and modules/bouncer, that calls the dispatch setter.
marker_callers() {
local dir="$1"
(cd "$dir" && grep -rlE '(bouncer\.)?WithDispatch\(' --include='*.go' modules 2>/dev/null) |
grep -vE '_test\.go$' | grep -vE '^modules/(pier|bouncer)/' || true
}
# removed_exports reads a unified diff and prints every removed line that
# declares an exported func, method or type.
removed_exports() {
grep -E '^-(func ([A-Z]|\([^)]*\) [A-Z])|type [A-Z])' | grep -vE '^--- ' || true
}
run_self_test() {
bash -n "${BASH_SOURCE[0]}"
expect_detector detect_go pass 0 '{"Action":"pass","Package":"p","Test":"TestToolsList"}'
expect_detector detect_go fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
{"Action":"fail","Package":"p","Test":"TestDispatchMarker"}'
expect_detector detect_go package-fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
{"Action":"fail","Package":"p"}'
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":"TestToolsList"}'
expect_detector detect_go subtest-skip 2 '{"Action":"skip","Package":"p","Test":"TestToolsList/union_of_both_guards"}
{"Action":"pass","Package":"p","Test":"TestToolsList"}'
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="TestToolsList TestDispatchMarker" expect_detector detect_go missing-named 5 \
'{"Action":"pass","Package":"p","Test":"TestToolsList"}'
REQUIRE_TESTS="TestDispatch" expect_detector detect_go prefix-is-not-the-test 5 \
'{"Action":"pass","Package":"p","Test":"TestDispatchMarker"}'
REQUIRE_TESTS="TestDispatchMarker" expect_detector detect_go subtest-is-not-parent 5 \
'{"Action":"pass","Package":"p","Test":"TestOuter/TestDispatchMarker"}
{"Action":"pass","Package":"p","Test":"TestOther"}'
local ok=' ✓ tests/account/AccountContributions.test.ts (9 tests) 15ms
✓ tests/form/RecordActionsPrompt.test.ts (8 tests) 147ms
Test Files 2 passed (2)
Tests 17 passed (17)'
REQUIRE_FILES="tests/account/AccountContributions.test.ts tests/form/RecordActionsPrompt.test.ts" expect_detector detect_vitest pass 0 "$ok"
REQUIRE_FILES="tests/form/FormView.test.ts" expect_detector detect_vitest missing-file 5 "$ok"
expect_detector detect_vitest failed 1 ' ✓ tests/account/AccountContributions.test.ts (9 tests) 15ms
Test Files 1 failed | 1 passed (2)
Tests 1 failed | 16 passed (17)'
expect_detector detect_vitest skipped 1 ' ✓ tests/account/AccountContributions.test.ts (9 tests | 1 skipped) 15ms
Test Files 1 passed (1)
Tests 8 passed | 1 skipped (9)'
expect_detector detect_vitest no-summary 3 ' ✓ tests/account/AccountContributions.test.ts (9 tests) 15ms'
expect_detector detect_vitest zero 3 ' Tests 0 passed (0)'
# The marker rule on planted files: a caller outside pier and bouncer is
# found, a test file and pier's own call are not.
local tmp
tmp="$(mktemp -d)"
mkdir -p "$tmp/modules/pier" "$tmp/modules/bouncer" "$tmp/modules/cabana"
printf 'package pier\nfunc f() { _ = bouncer.WithDispatch(nil, nil) }\n' >"$tmp/modules/pier/dispatch.go"
printf 'package bouncer\nfunc WithDispatch() {}\n' >"$tmp/modules/bouncer/dispatch.go"
printf 'package cabana\nfunc f() { _ = bouncer.WithDispatch(nil, nil) }\n' >"$tmp/modules/cabana/x_test.go"
if [[ -n "$(marker_callers "$tmp")" ]]; then
rm -rf "$tmp"
echo "refuse: self-test marker_callers flagged pier, bouncer or a test file" >&2
return 1
fi
printf 'package cabana\nfunc g() { _ = bouncer.WithDispatch(nil, nil) }\n' >"$tmp/modules/cabana/forge.go"
if [[ "$(marker_callers "$tmp")" != "modules/cabana/forge.go" ]]; then
rm -rf "$tmp"
echo "refuse: self-test marker_callers missed a caller outside pier" >&2
return 1
fi
# 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" PHASE197_REQUIRE_DOCKER=0
expect_mode docker-required 1 "FAIL docker" --docker-gate "PATH=$tmp/bin:$PATH" PHASE197_REQUIRE_DOCKER=1
expect_mode docker-stage-needs-daemon 1 "needs a reachable Docker daemon" --docker "PATH=$tmp/bin:$PATH"
rm -rf "$tmp"
# The additive check refuses a removed export and accepts additions.
local diff_bad diff_ok line
diff_bad=$'--- a/classes/x.go\n+++ b/classes/x.go\n-func LinkedBackendUser(ctx context.Context) error {\n+func linkedBackendUser(ctx context.Context) error {'
diff_ok=$'--- a/classes/x.go\n+++ b/classes/x.go\n+func FireSubjectChanged() {\n-func helper() {}\n-\t// func Old() is gone'
if [[ -z "$(removed_exports <<<"$diff_bad")" ]]; then
echo "refuse: self-test removed_exports missed a removed exported func" >&2
return 1
fi
if [[ -n "$(removed_exports <<<"$diff_ok")" ]]; then
echo "refuse: self-test removed_exports refused an additive diff" >&2
return 1
fi
for line in '-type MCPTokenVerifier struct {' '-func (s *BackendLinks) SetLink(ctx context.Context) error {' '-func (Plugin) MCP() pact.MCP {'; do
if [[ -z "$(removed_exports <<<"$line")" ]]; then
echo "refuse: self-test removed_exports missed: $line" >&2
return 1
fi
done
# additive_check end to end on a planted repository: an added export
# passes, a removed one fails, an unknown base fails.
local repo base
repo="$(mktemp -d)"
git -C "$repo" init -q
printf 'package p\n\nfunc Keep() {}\n\nfunc Gone() {}\n' >"$repo/p.go"
git -C "$repo" add p.go
git -C "$repo" -c user.name=t -c user.email=t@example.test commit -q -m base
base="$(git -C "$repo" rev-parse HEAD)"
printf 'package p\n\nfunc Keep() {}\n\nfunc Gone() {}\n\nfunc Added() {}\n' >"$repo/p.go"
if ! additive_check planted "$repo" "$base" >/dev/null 2>&1; then
rm -rf "$repo"
echo "refuse: self-test additive_check refused an addition" >&2
return 1
fi
printf 'package p\n\nfunc Keep() {}\n' >"$repo/p.go"
if additive_check planted "$repo" "$base" >/dev/null 2>&1; then
rm -rf "$repo"
echo "refuse: self-test additive_check accepted a removed export" >&2
return 1
fi
if additive_check planted "$repo" "no-such-base" >/dev/null 2>&1; then
rm -rf "$repo"
echo "refuse: self-test additive_check accepted an unknown base" >&2
return 1
fi
rm -rf "$repo"
local mode
for mode in self-test go plugins consumers docker admin docs marker all; do
grep -q -- "^ --$mode" "${BASH_SOURCE[0]}" || {
echo "refuse: missing mode --$mode" >&2
return 1
}
done
echo "phase19.7 self-test passed"
}
run_go() {
go_vet "$ROOT" ./...
go_vet "$ROOT" -tags "$MCP_TAG" ./...
go_short "$ROOT" ./...
(cd "$ROOT" && go -C examples/hello test -count=1 ./...) >/dev/null
echo "hello: examples/hello passed"
(cd "$ROOT" && go build -tags "$MCP_TAG" ./...)
echo "build: go build -tags $MCP_TAG ./..."
local sdk
sdk="$(cd "$ROOT" && go -C examples/hello list -deps ./... | grep -E 'modelcontextprotocol|google/jsonschema-go|modules/pier$' || true)"
if [[ -n "$sdk" ]]; then
printf '%s\n' "$sdk" >&2
echo "refuse: the untagged hello binary links the MCP module or its SDK (D-30)" >&2
return 1
fi
echo "untagged: examples/hello links no MCP SDK"
if ! (cd "$ROOT" && git diff --quiet "$ACCOUNT_BASE" -- modules/cabana/account.go); then
echo "refuse: modules/cabana/account.go changed since $ACCOUNT_BASE (D-04: My account's payloads stay as they were)" >&2
return 1
fi
echo "account: modules/cabana/account.go unchanged since $ACCOUNT_BASE"
go_pkg_named "$ROOT" "-short" "${GO_TESTS[@]}"
go_pkg_named "$ROOT" "-short -tags $MCP_TAG" "${GO_TAGGED_TESTS[@]}"
echo "phase19.7 go passed"
}
run_marker() {
local callers deps
callers="$(marker_callers "$ROOT")"
if [[ -n "$callers" ]]; then
printf '%s\n' "$callers" >&2
echo "refuse: a framework file outside modules/pier and modules/bouncer calls bouncer.WithDispatch (D-17)" >&2
return 1
fi
if ! (cd "$ROOT" && grep -q 'bouncer.WithDispatch(' modules/pier/dispatch.go); then
echo "refuse: modules/pier/dispatch.go no longer sets the marker; update the marker rule" >&2
return 1
fi
echo "marker: only modules/pier sets the dispatch marker"
deps="$(cd "$ROOT" && go list -deps ./modules/pact | grep -E 'modelcontextprotocol|google/jsonschema-go|summercms/modules/bouncer$' || true)"
if [[ -n "$deps" ]]; then
printf '%s\n' "$deps" >&2
echo "refuse: modules/pact depends on the MCP SDK, jsonschema-go or bouncer" >&2
return 1
fi
echo "pact: no MCP SDK, jsonschema-go or bouncer dependency"
echo "phase19.7 marker passed"
}
# additive_check NAME DIR BASES reports the exported declarations removed
# from DIR's non-test .go files since each of BASES (D-31: the core
# plugins stay additive). git's status is captured before any filtering,
# so an unknown base fails instead of reading as an empty diff.
additive_check() {
local name="$1" dir="$2" bases="$3" base diff rc removed added
for base in $bases; do
rc=0
diff="$(git -C "$dir" diff "$base" -- '*.go' ':(exclude)*_test.go' 2>&1)" || rc=$?
if [[ "$rc" -ne 0 ]]; then
printf '%s\n' "$diff" | tail -n 5 >&2
echo "refuse: git diff $base in $dir failed (exit $rc)" >&2
return 1
fi
removed="$(removed_exports <<<"$diff")"
if [[ -n "$removed" ]]; then
printf '%s\n' "$removed" >&2
echo "refuse: the $name plugin removed or changed exported declarations since $base (additive only)" >&2
return 1
fi
added="$(grep -cE '^\+(func|type) ' <<<"$diff" || true)"
echo "additive: $name: no exported func, method or type removed since $base ($added declarations added)"
done
}
run_plugins() {
require_dir "$USER_PLUGIN" "user plugin"
require_dir "$OAUTHSERVER_PLUGIN" "OAuth server plugin"
require_dir "$JOURNAL_PLUGIN" "blog plugin"
# The user plugin is checked against its own go.mod (GOWORK=off), the
# way a consuming application resolves it.
GO_ENV="GOWORK=off" go_vet "$USER_PLUGIN" ./...
GO_ENV="GOWORK=off" go_short "$USER_PLUGIN" ./...
additive_check user "$USER_PLUGIN" "$USER_BASE"
go_vet "$OAUTHSERVER_PLUGIN" ./...
go_short "$OAUTHSERVER_PLUGIN" ./...
if [[ "${#OAS_SHORT_TESTS[@]}" -gt 0 ]]; then
go_pkg_named "$OAUTHSERVER_PLUGIN" "-short" "${OAS_SHORT_TESTS[@]}"
fi
additive_check oauthserver "$OAUTHSERVER_PLUGIN" "$OAS_BASE"
go_vet "$JOURNAL_PLUGIN" ./...
go_short "$JOURNAL_PLUGIN" ./...
go_pkg_named "$JOURNAL_PLUGIN" "-short" "${JOURNAL_SHORT_TESTS[@]}"
additive_check journal "$JOURNAL_PLUGIN" "$JOURNAL_BASE"
echo "phase19.7 plugins passed"
}
run_consumers() {
local app
for app in "$FONOTEKA" "$BM_APP" "$DEMO_APP"; do
require_dir "$app" "application"
(cd "$app" && go build ./...)
echo "build: $app"
go_short "$app" ./...
done
(cd "$DEMO_APP" && go build -tags "$MCP_TAG" ./...)
echo "build: $DEMO_APP (-tags $MCP_TAG)"
GO_FLAGS="-tags $MCP_TAG" go_short "$DEMO_APP" ./...
demo_pins
# T-19.7-37: the demo's vhost opens exactly the MCP paths, unbuffered.
(cd "$DEMO_APP" && bash scripts/check-deploy.sh)
echo "phase19.7 consumers passed"
}
# demo_pins runs the demo's pin check (every plugin submodule on the commit
# go.mod requires). A submodule ahead of its pin only by commits that touch
# *_test.go files (this phase's plugin tests, committed but not released)
# ships the same code and is reported, not refused; any other mismatch
# fails.
demo_pins() {
local out rc=0 line name want head changed
[[ -f "$DEMO_APP/scripts/check-pins.sh" ]] || {
echo "refuse: $DEMO_APP/scripts/check-pins.sh not found" >&2
return 1
}
out="$(cd "$DEMO_APP" && bash scripts/check-pins.sh 2>&1)" || rc=$?
if [[ "$rc" -eq 0 ]]; then
printf '%s\n' "$out"
return 0
fi
while IFS= read -r line; do
[[ -z "$line" ]] && continue
if [[ "$line" =~ ^check-pins:\ (plugins/[^ ]+)\ is\ at\ ([0-9a-f]+),\ go\.mod\ requires\ .*\(([0-9a-f]+)\)$ ]]; then
name="${BASH_REMATCH[1]}"
head="${BASH_REMATCH[2]}"
want="${BASH_REMATCH[3]}"
if ! git -C "$DEMO_APP/$name" merge-base --is-ancestor "$want" HEAD 2>/dev/null; then
echo "refuse: $name at $head is not a descendant of its pin $want" >&2
return 1
fi
changed="$(git -C "$DEMO_APP/$name" diff --name-only "$want" HEAD | grep -vE '_test\.go$' || true)"
if [[ -n "$changed" ]]; then
printf '%s\n' "$changed" >&2
echo "refuse: $name is ahead of its pin $want with non-test changes" >&2
return 1
fi
echo "pins: $name is ahead of its pin $want by test-only commits (release not needed for shipped code)"
continue
fi
printf '%s\n' "$out" >&2
echo "refuse: the demo's pin check failed" >&2
return 1
done <<<"$out"
}
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 "$USER_PLUGIN" "user plugin"
require_dir "$OAUTHSERVER_PLUGIN" "OAuth server plugin"
require_dir "$DEMO_APP" "demo application"
go_pkg_named "$ROOT" "-timeout 20m" "${DOCKER_TESTS[@]}"
GO_ENV="GOWORK=off" go_named "$USER_PLUGIN" . "-timeout 20m" "${USER_TESTS[@]}"
go_named "$OAUTHSERVER_PLUGIN" . "-timeout 20m" "${OAS_TESTS[@]}"
go_named "$DEMO_APP" . "-timeout 20m -tags $MCP_TAG" "${DEMO_TESTS[@]}"
echo "phase19.7 docker passed"
}
# docker_gate is the docker step of --all: run the stage when a daemon
# answers, fail with PHASE197_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 [[ "${PHASE197_REQUIRE_DOCKER:-0}" == "1" ]]; then
echo "FAIL docker: no reachable Docker daemon and PHASE197_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 user, OAuth server and blog
plugins and the demo's end-to-end MCP test did NOT run. Run on a Docker
host (through sudo -u golem when the socket is denied) before
/msd-verify-work:
bash scripts/check-phase19.7.sh docker
************************************************************************
EOF
echo "NOT RUN docker (Docker-host only; see the notice above)"
}
# npm_checked NAME DETECT_FILES... runs npm --prefix admin run NAME and
# checks its output with detect_vitest, requiring DETECT_FILES.
npm_checked() {
local script="$1"
shift
local log rc=0 dc=0
log="$(mktemp)"
npm --prefix "$ROOT/admin" run "$script" >"$log" 2>&1 || rc=$?
REQUIRE_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 $script in admin (test=$rc detect=$dc)" >&2
return 1
fi
echo "vitest ($script): $(grep -E '^\s*Tests\s' "$log" | tr -s ' ')"
rm -f "$log"
}
run_admin() {
local log rc=0
log="$(mktemp)"
npm --prefix "$ROOT/admin" run typecheck >"$log" 2>&1 || rc=$?
if [[ "$rc" -ne 0 ]]; then
tail -n 40 "$log" >&2
rm -f "$log"
echo "refuse: npm run typecheck in admin" >&2
return 1
fi
rm -f "$log"
echo "typecheck: admin"
npm_checked test "${ADMIN_FILES[@]}"
(cd "$ROOT" && bash scripts/check-admin-openapi.sh --check)
(cd "$ROOT" && bash scripts/check-admin-dist.sh)
echo "phase19.7 admin passed"
}
run_docs() {
local name
for name in "${DECISION_NAMES[@]}"; do
if ! grep -qF -- "$name" "$ROOT/$DECISION_NOTE"; then
echo "refuse: the decision note $DECISION_NOTE does not name $name" >&2
return 1
fi
done
echo "note: $DECISION_NOTE names ${DECISION_NAMES[*]}"
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[*]}"
go_named "$ROOT" ./cmd/summer "" TestDocsTree
(cd "$ROOT" && go run ./cmd/summer docs:build --check)
local pattern hits f files=()
pattern="$(IFS='|'; echo "${APP_NAMES[*]}")"
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$ROOT/modules" "$ROOT/docs" -type f ! -name '*_test.go' ! -path '*/node_modules/*' -print0)
for f in "${PHASE_TEST_DIRS[@]}"; do
while IFS= read -r -d '' g; do
files+=("$g")
done < <(find "$ROOT/$f" -type f -name '*_test.go' -print0)
done
for f in "${PHASE_TEST_FILES[@]}"; do
if [[ ! -f "$ROOT/$f" ]]; then
echo "refuse: phase test file $f is missing" >&2
return 1
fi
files+=("$ROOT/$f")
done
hits="$(grep -inHIE "$pattern" "${files[@]}" || true)"
if [[ -n "$hits" ]]; then
printf '%s\n' "$hits" | head -n 20 >&2
echo "refuse: a framework source, README, docs page or phase test names a consuming application" >&2
return 1
fi
echo "names: ${#files[@]} files (modules/ and docs/ sources, the phase's tests) checked, no consuming application named"
echo "phase19.7 docs 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 19.7 gate passed"
}
case "${1:---all}" 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 ;;
--admin | admin) run_admin ;;
--docs | docs) run_docs ;;
--marker | marker) run_marker ;;
--all | all) run_all ;;
*) usage ;;
esac