- plugins: vet, short tests and the additive-export check of the user (GOWORK=off, against 930b158 and v0.1.9), OAuth server (d31f8e7) and blog (ffa2c8f) plugins, the blog plugin's MCP tests by name - consumers: the three applications build and pass at their pins, the demo also with the MCP tag; the pin check accepts a plugin submodule ahead of its pin only by test-only commits - docker: the cabana, user plugin, OAuth server and demo Postgres suites by name - docs: decision note and STACK rows, docs checks, no consuming application named in sources, docs or the phase's tests - self-test proves additive_check on a planted repository
833 lines
33 KiB
Bash
Executable File
833 lines
33 KiB
Bash
Executable File
#!/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 and its pin check.
|
||
# 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
|
||
./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/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
|
||
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
|