Files
summercms/scripts/check-phase19.7.sh
Jakub Zych 2fdda99c08 fix(19.7): RA-WR-06 qualify the gate verdict while demo pins are unreleased
demo_pins records every plugin submodule it accepts ahead of its go.mod pin
by non-test commits (UNRELEASED_PINS, and PHASE197_PINS_FILE for run_all).
The consumers stage then ends with "phase19.7 consumers passed WITH
UNRELEASED PINS: <names> (the release pins are not yet proven)", and --all
ends with a notice plus "Phase 19.7 gate passed WITH UNRELEASED PINS: the
release pins are not yet proven (<names>)" instead of the unqualified
"Phase 19.7 gate passed". The exit status stays 0 so the pre-release
verification can run; only a PHASE197_RELEASED=1 run proves the pins.

The self-test pins that accepted unreleased drift is recorded, test-only
drift is not, and gate_verdict is unqualified only with no recorded pin.
2026-10-23 20:15:50 +02:00

1263 lines
53 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: the workspace check, 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): the workspace
# check, go build ./... and go test -short ./... in each, the
# demo also with -tags summercms_mcp, its pin check, the demo
# site's vitest run (VUE_FILES must pass) and its deploy check
# (the nginx locations of the MCP endpoint); the applications
# that use the user plugin are also built against USER_PLUGIN
# through a scratch copy of their go.work.
# 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 cooler's KeyPrefix and keyPrefix doc comments, 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).
#
# The workspace check: before a repository's commands run, it must resolve
# the framework to this checkout (go list -m, through its go.work or its
# go.mod replace), and the OAuth server plugin must resolve the user plugin
# to USER_PLUGIN. A mismatch is a refuse: line naming both paths.
#
# The demo's pin check: a plugin submodule ahead of its pin by test-only
# commits is reported; one ahead by other commits is accepted only when its
# HEAD descends from the pin and is the HEAD of, or an ancestor of, the
# canonical checkout (USER_PLUGIN, OAUTHSERVER_PLUGIN or JOURNAL_PLUGIN),
# and is reported as unreleased commits; anything else fails.
#
# Accepted unreleased drift is not a release: the consumers stage then ends
# with "phase19.7 consumers passed WITH UNRELEASED PINS", and --all ends with
# "Phase 19.7 gate passed WITH UNRELEASED PINS: the release pins are not yet
# proven" instead of "Phase 19.7 gate passed" (the exit status stays 0, so
# the pre-release verification can run). A GOWORK=off release build of the
# demo compiles the older pinned code until the plugins are tagged and the
# pins bumped; only a PHASE197_RELEASED=1 run proves the release pins.
#
# 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, PHASE197_REQUIRE_DOCKER, and PHASE197_RELEASED
# (1 after the plugin tags and the application bumps: the demo's pin check
# then refuses any drift, test-only or unreleased).
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
# Gap closure, plans 06 and 07: the smokes and the named tests of plan 09.
./modules/pier:TestPierReaperSmoke ./modules/bouncer:TestAuthTimeSmoke
./modules/pier:TestSessionReaper ./modules/bouncer:TestAuthTime
./modules/pier:TestSessionReserve ./modules/pier:TestLockScope ./modules/pier:TestScopeWidening
./modules/pier:TestStatelessFanOut ./modules/pier:TestPersonalTokenBinding ./modules/pier:TestDispatchHardening
./modules/pier:TestCatalogPathFields ./modules/pier:TestPromptWriteCeiling ./modules/pier:TestTokenScopedRevoke
./modules/pier:TestResolveClientAddress
./modules/pact:TestFrontendTokenScopes ./modules/towel:TestClientIP
./modules/cabana:TestTokenScopeNameParity ./modules/cabana:TestAccountContributionURL
./modules/cabana:TestAccountContributionsIsolation ./modules/cabana:TestRecordActionPromptLocalized
./modules/cabana:TestResolveBackendTokenRemote
)
# 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
./modules/pier:TestSessionReaper ./modules/pier:TestSessionReserve ./modules/pier:TestLockScope
./modules/pier:TestScopeWidening ./modules/pier:TestStatelessFanOut ./modules/pier:TestPersonalTokenBinding
./modules/pier:TestDispatchHardening ./modules/pier:TestCatalogPathFields ./modules/pier:TestPromptWriteCeiling
./modules/pier:TestTokenScopedRevoke ./modules/pier:TestResolveClientAddress
)
# docker stage: Postgres tests in the framework.
DOCKER_TESTS=(
./modules/cabana:TestDispatchMarker ./modules/cabana:TestAccountContributions
./modules/cabana:TestRecordActionPromptRun ./modules/cabana:TestFireSubjectChangedWaitsForCommit
# Gap closure, plan 07 (their -short part returns before the Postgres
# subtests).
./modules/cabana:TestAccountTokenRevokeEvent ./modules/cabana:TestResolveBackendTokenRemote
)
# admin stage: vitest files that must pass.
ADMIN_FILES=(
tests/account/AccountContributions.test.ts
tests/form/RecordActionsPrompt.test.ts
tests/app/hotkeysEditing.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's run
# with GOWORK=off; its other suites need Postgres and run in the docker
# stage).
USER_SHORT_TESTS=(.:TestMemberTokenScopesBoot)
OAS_SHORT_TESTS=()
JOURNAL_SHORT_TESTS=(
.:TestJournalMCPSmoke .:TestJournalMCP
./controllers/api:TestOptionalBackendPrincipalDispatch
.:TestJournalPromptWrites
)
# docker stage: the plugins' and the demo's Postgres suites.
USER_TESTS=(
TestBackendLinkSmoke TestBackendLinkAdminSmoke TestUserMCPGuardSmoke TestFrontendPermissionCatalogSmoke
TestBackendLink TestFrontendPermissionCatalog TestUserMCPGuard
# Gap closure, plan 08 (lane C, E-1, E-2).
TestBackendLinkAuthTime TestBackendLinkShowPost TestAccessRevocation TestMemberTokenScopes
TestOrganisationLogo TestUserMCPGuardClientIP TestAPITokenRevokeEvent
TestBackendLinkAuthTimeSmoke TestMemberTokenScopesSmoke
)
OAS_TESTS=(
TestMCPTokenVerifierSmoke TestMCPConsentSmoke TestMCPTokenVerifier TestMCPConsent
TestMCPVerifierRevocation TestMCPGrantRevocation TestMCPRevocationSmoke
)
DEMO_TESTS=(TestMCPEndToEnd TestMCPRevocationEndToEnd)
# consumers stage: the demo site's vitest files that must pass.
VUE_FILES=(app/lib/linkCode.test.ts)
# 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
modules/pier/reaper_test.go modules/pier/reaper_smoke_test.go
modules/bouncer/auth_time_test.go modules/bouncer/auth_time_smoke_test.go
modules/pier/reserve_test.go modules/pier/lock_scope_test.go modules/pier/widening_test.go
modules/pier/stateless_fanout_test.go modules/pier/personal_binding_test.go
modules/pier/dispatch_hardening_test.go modules/pier/catalog_path_fields_test.go
modules/pier/prompt_ceiling_test.go modules/pier/token_events_test.go
modules/pact/frontend_token_scopes_test.go modules/towel/client_ip_test.go
modules/cabana/account_contributions_hardening_test.go modules/cabana/record_action_prompt_localized_test.go
modules/cabana/token_scope_name_test.go modules/cabana/account_token_events_test.go
modules/cabana/token_resolver_remote_test.go
admin/tests/app/hotkeysEditing.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
}
}
# workspace_resolve DIR MODULE prints the directory MODULE resolves to in
# DIR (its replacement's directory, else its own); GO_ENV is prepended to
# the go command's environment, for GOWORK=off.
workspace_resolve() {
# shellcheck disable=SC2086
(cd "$1" && env ${GO_ENV:-} go list -m -f '{{if .Replace}}{{.Replace.Dir}}{{else}}{{.Dir}}{{end}}' "$2" 2>/dev/null) || true
}
# workspace_verdict LABEL MODULE GOT WANT compares a resolved directory with
# the expected one (both through realpath) and refuses a mismatch, naming
# both paths.
workspace_verdict() {
local label="$1" module="$2" got="$3" want="$4" rgot rwant
rgot="$(realpath -e -- "$got" 2>/dev/null || true)"
rwant="$(realpath -e -- "$want" 2>/dev/null || true)"
if [[ -z "$got" || -z "$rgot" || -z "$rwant" || "$rgot" != "$rwant" ]]; then
echo "refuse: $label resolves $module to ${got:-<nothing>}, not $want" >&2
return 1
fi
echo "workspace: $label resolves $module to $rwant"
}
# workspace_check DIR [EXPECT_USER_PLUGIN] refuses a repository that does
# not resolve the framework to ROOT and, with a second argument, the user
# plugin to it (P-40: no trust in local go.work files).
workspace_check() {
local dir="$1" user="${2:-}"
workspace_verdict "$dir" git.golem15.com/golem15/summercms "$(workspace_resolve "$dir" git.golem15.com/golem15/summercms)" "$ROOT"
if [[ -n "$user" ]]; then
workspace_verdict "$dir" git.golem15.com/golem15/sm-user-plugin "$(workspace_resolve "$dir" git.golem15.com/golem15/sm-user-plugin)" "$user"
fi
}
# gowork_with_user_plugin APP USER FRAMEWORK prints APP's go.work rewritten
# for a scratch workspace: every use and replace path absolute, APP's own
# plugins/golem15/user swapped for USER, and the framework replaced by
# FRAMEWORK (any framework replace of the original is dropped).
gowork_with_user_plugin() {
python3 - "$1" "$2" "$3" <<'PY'
import os, re, sys
app, user, root = (os.path.realpath(a) for a in sys.argv[1:4])
own_user = os.path.realpath(os.path.join(app, "plugins", "golem15", "user"))
framework = "git.golem15.com/golem15/summercms"
def absolute(path):
full = path if path.startswith("/") else os.path.normpath(os.path.join(app, path))
return user if os.path.realpath(full) == own_user else full
def replace(spec):
left, right = (part.strip() for part in spec.split("=>", 1))
if left.split()[0] == framework:
return None
target = right.split()
if len(target) == 1 and target[0].startswith((".", "/")):
right = absolute(target[0])
return f"{left} => {right}"
out, block = [], None
for line in open(os.path.join(app, "go.work"), encoding="utf-8").read().splitlines():
code = line.split("//", 1)[0].strip()
if block is not None:
if code == ")":
block = None
out.append(")")
elif code and block == "use":
out.append("\t" + absolute(code))
elif code:
rewritten = replace(code)
if rewritten is not None:
out.append("\t" + rewritten)
continue
if code in ("use (", "replace ("):
block = code.split()[0]
out.append(code)
continue
m = re.fullmatch(r"use\s+(\S+)", code)
if m:
out.append("use " + absolute(m.group(1)))
continue
m = re.fullmatch(r"replace\s+(.+)", code)
if m:
rewritten = replace(m.group(1))
if rewritten is not None:
out.append("replace " + rewritten)
continue
out.append(line)
out += ["", f"replace {framework} => {root}"]
print("\n".join(out))
PY
}
# consumer_with_user_plugin APP builds APP against USER_PLUGIN through a
# scratch copy of its go.work (APP's own go.work is never edited), after
# checking that the scratch workspace resolves the user plugin there.
consumer_with_user_plugin() {
local app="$1"
(
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
gowork_with_user_plugin "$app" "$USER_PLUGIN" "$ROOT" >"$work/go.work"
GO_ENV="GOWORK=$work/go.work" workspace_check "$app" "$USER_PLUGIN" >/dev/null
(cd "$app" && GOWORK="$work/go.work" go build ./...)
)
echo "build: $app against $USER_PLUGIN (scratch go.work)"
}
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 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
echo "PASS self-test $name"
}
# 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]}"
# The planted pin checks below must not reach run_all's verdict.
unset PHASE197_PINS_FILE
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)'
local vue=' ✓ app/lib/linkCode.test.ts (4 tests) 7ms
✓ app/lib/markdown.test.ts (9 tests) 12ms
Test Files 2 passed (2)
Tests 13 passed (13)'
REQUIRE_FILES="app/lib/linkCode.test.ts" expect_detector detect_vitest vue-required-file 0 "$vue"
REQUIRE_FILES="app/lib/linkCode.test.ts" expect_detector detect_vitest vue-missing-file 5 ' ✓ app/lib/markdown.test.ts (9 tests) 12ms
Test Files 1 passed (1)
Tests 9 passed (9)'
# 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
echo "PASS self-test marker_callers"
# 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
echo "PASS self-test removed_exports"
# 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"
echo "PASS self-test additive_check"
# The workspace check on planted go list answers: this checkout (also
# through a symlink) passes, another checkout or no answer is refused
# with both paths named.
local ws out
ws="$(mktemp -d)"
mkdir -p "$ws/framework" "$ws/other"
ln -s "$ws/framework" "$ws/link"
if ! workspace_verdict planted git.golem15.com/golem15/summercms "$ws/link" "$ws/framework" >/dev/null 2>&1; then
rm -rf "$ws"
echo "refuse: self-test workspace_verdict refused a matching answer" >&2
return 1
fi
out="$(workspace_verdict planted git.golem15.com/golem15/summercms "$ws/other" "$ws/framework" 2>&1)" && {
rm -rf "$ws"
echo "refuse: self-test workspace_verdict accepted another checkout" >&2
return 1
}
if ! grep -qF "refuse: planted resolves git.golem15.com/golem15/summercms to $ws/other, not $ws/framework" <<<"$out"; then
rm -rf "$ws"
echo "refuse: self-test workspace_verdict did not name both paths: $out" >&2
return 1
fi
if workspace_verdict planted git.golem15.com/golem15/summercms "" "$ws/framework" >/dev/null 2>&1; then
rm -rf "$ws"
echo "refuse: self-test workspace_verdict accepted an empty answer" >&2
return 1
fi
echo "PASS self-test workspace_verdict (match, symlinked match, mismatch naming both paths, empty answer)"
# The scratch go.work of consumer_with_user_plugin on a planted
# application: absolute use and replace paths, the application's user
# checkout swapped for the given one, one framework replace.
local gw
mkdir -p "$ws/app/plugins/golem15/user" "$ws/user"
printf '%s\n' 'go 1.27.0' '' 'use (' ' .' ' ./plugins/golem15/user' ' ./plugins/golem15/feedback // a comment' ')' '' \
'use ./tools' '' 'replace git.golem15.com/golem15/summercms => ../summercms.go' '' \
'replace (' ' git.golem15.com/golem15/sm-golem-plugin => ./plugins/golem15/golem' ' git.golem15.com/golem15/sm-user-plugin => ./plugins/golem15/user' ' example.com/x v1.0.0 => example.com/y v1.1.0' ')' >"$ws/app/go.work"
gw="$(gowork_with_user_plugin "$ws/app" "$ws/user" "$ws/framework")"
local want_line
for want_line in " $ws/app" " $ws/user" " $ws/app/plugins/golem15/feedback" "use $ws/app/tools" \
" git.golem15.com/golem15/sm-golem-plugin => $ws/app/plugins/golem15/golem" \
" git.golem15.com/golem15/sm-user-plugin => $ws/user" \
" example.com/x v1.0.0 => example.com/y v1.1.0" \
"replace git.golem15.com/golem15/summercms => $ws/framework"; do
if ! grep -qxF -- "$want_line" <<<"$gw"; then
rm -rf "$ws"
echo "refuse: self-test gowork_with_user_plugin lacks the line \"$want_line\": $gw" >&2
return 1
fi
done
if [[ "$(grep -c 'golem15/summercms =>' <<<"$gw")" != 1 ]] || grep -qE '(^|[[:space:]])\./|\.\./' <<<"$gw"; then
rm -rf "$ws"
echo "refuse: self-test gowork_with_user_plugin left a relative path or a second framework replace: $gw" >&2
return 1
fi
rm -rf "$ws"
echo "PASS self-test gowork_with_user_plugin"
# The demo pin policy on a planted demo whose check-pins.sh prints one
# mismatch: test-only drift is reported, a non-test commit the canonical
# checkout holds is accepted as unreleased, one it does not hold is
# refused, and PHASE197_RELEASED=1 refuses any drift.
local demo sub pin canon
demo="$(mktemp -d)"
sub="$demo/plugins/golem15/user"
mkdir -p "$demo/scripts" "$sub"
git -C "$sub" init -q
printf 'package user\n' >"$sub/user.go"
git -C "$sub" add user.go
git -C "$sub" -c user.name=t -c user.email=t@example.test commit -q -m pin
pin="$(git -C "$sub" rev-parse HEAD)"
printf 'package user\n' >"$sub/user_test.go"
git -C "$sub" add user_test.go
git -C "$sub" -c user.name=t -c user.email=t@example.test commit -q -m test-only
planted_pins() {
printf '#!/usr/bin/env bash\necho "check-pins: plugins/golem15/user is at %s, go.mod requires git.golem15.com/golem15/sm-user-plugin v0.1.10 (%s)" >&2\nexit 1\n' \
"$(git -C "$sub" rev-parse --short=12 HEAD)" "${pin:0:12}" >"$demo/scripts/check-pins.sh"
}
planted_pins
expect_mode pins-test-only 0 "by test-only commits" --demo-pins "DEMO_APP=$demo" "USER_PLUGIN=$sub" PHASE197_RELEASED=0 "PHASE197_PINS_FILE=$demo/pins.txt"
if [[ -s "$demo/pins.txt" ]]; then
echo "refuse: self-test pins-test-only-unrecorded: test-only drift was recorded as unreleased" >&2
return 1
fi
echo "PASS self-test pins-test-only-unrecorded"
expect_mode pins-released-test-only 1 "refuse: PHASE197_RELEASED=1" --demo-pins "DEMO_APP=$demo" "USER_PLUGIN=$sub" PHASE197_RELEASED=1
printf 'package user\n\nfunc Added() {}\n' >"$sub/user.go"
git -C "$sub" -c user.name=t -c user.email=t@example.test commit -q -am feature
planted_pins
canon="$demo/canonical"
git clone -q "$sub" "$canon"
expect_mode pins-unreleased-accepted 0 "is ahead of its pin by unreleased commits" --demo-pins "DEMO_APP=$demo" "USER_PLUGIN=$canon" PHASE197_RELEASED=0 "PHASE197_PINS_FILE=$demo/pins.txt"
if ! grep -qx "plugins/golem15/user" "$demo/pins.txt" 2>/dev/null; then
echo "refuse: self-test pins-unreleased-recorded: accepted unreleased drift was not recorded for the verdict" >&2
return 1
fi
echo "PASS self-test pins-unreleased-recorded"
git -C "$canon" checkout -q "$pin"
expect_mode pins-unreleased-refused 1 "does not hold" --demo-pins "DEMO_APP=$demo" "USER_PLUGIN=$canon" PHASE197_RELEASED=0
git -C "$canon" checkout -q -
expect_mode pins-released-unreleased 1 "refuse: PHASE197_RELEASED=1" --demo-pins "DEMO_APP=$demo" "USER_PLUGIN=$canon" PHASE197_RELEASED=1
unset -f planted_pins
rm -rf "$demo"
# The final verdict: unqualified only without unreleased drift.
local verdict_file verdict
verdict_file="$(mktemp)"
verdict="$(gate_verdict "$verdict_file")"
if [[ "$verdict" != "Phase 19.7 gate passed" ]]; then
rm -f "$verdict_file"
echo "refuse: self-test verdict-released: \"$verdict\"" >&2
return 1
fi
printf 'plugins/golem15/user\n' >"$verdict_file"
verdict="$(gate_verdict "$verdict_file" 2>/dev/null)"
rm -f "$verdict_file"
if [[ "$(tail -n 1 <<<"$verdict")" != "Phase 19.7 gate passed WITH UNRELEASED PINS: the release pins are not yet proven (plugins/golem15/user)" ]]; then
echo "refuse: self-test verdict-unreleased: \"$verdict\"" >&2
return 1
fi
echo "PASS self-test verdict (unqualified only without unreleased pins)"
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" workspace_check "$USER_PLUGIN"
workspace_check "$OAUTHSERVER_PLUGIN" "$USER_PLUGIN"
workspace_check "$JOURNAL_PLUGIN"
GO_ENV="GOWORK=off" go_vet "$USER_PLUGIN" ./...
GO_ENV="GOWORK=off" go_short "$USER_PLUGIN" ./...
GO_ENV="GOWORK=off" go_pkg_named "$USER_PLUGIN" "-short" "${USER_SHORT_TESTS[@]}"
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"
workspace_check "$app"
done
for app in "$FONOTEKA" "$BM_APP" "$DEMO_APP"; do
(cd "$app" && go build ./...)
echo "build: $app"
go_short "$app" ./...
done
# The unreleased user plugin compiles with the applications that use it
# (P-40: additive for its consumers before any tag).
consumer_with_user_plugin "$FONOTEKA"
consumer_with_user_plugin "$BM_APP"
(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
vue_checked
# T-19.7-37: the demo's vhost opens exactly the MCP paths, unbuffered.
(cd "$DEMO_APP" && bash scripts/check-deploy.sh)
if [[ "${#UNRELEASED_PINS[@]}" -gt 0 ]]; then
echo "phase19.7 consumers passed WITH UNRELEASED PINS: ${UNRELEASED_PINS[*]} (the release pins are not yet proven)"
return 0
fi
echo "phase19.7 consumers passed"
}
# UNRELEASED_PINS lists the demo plugin submodules demo_pins accepted ahead
# of their pins by non-test commits; with PHASE197_PINS_FILE set, demo_pins
# also appends each name there, so run_all's verdict sees them.
UNRELEASED_PINS=()
# gate_verdict FILE prints the --all verdict: "Phase 19.7 gate passed" when
# FILE lists no unreleased pin, else a qualified line naming them.
gate_verdict() {
local file="$1" names
names="$(sort -u "$file" 2>/dev/null | paste -sd ' ' -)"
if [[ -z "$names" ]]; then
echo "Phase 19.7 gate passed"
return 0
fi
cat >&2 <<EOF
************************************************************************
UNRELEASED PINS: $names
are ahead of the demo's go.mod pins by non-test commits. The builds and
tests ran against that code; a GOWORK=off release build of the demo still
compiles the older pinned versions. The phase is not releasable until the
plugins are tagged, the pins bumped and this passes:
PHASE197_RELEASED=1 bash scripts/check-phase19.7.sh --consumers
************************************************************************
EOF
echo "Phase 19.7 gate passed WITH UNRELEASED PINS: the release pins are not yet proven ($names)"
}
# canonical_checkout NAME maps a demo plugin submodule to the checkout its
# commits are developed in.
canonical_checkout() {
case "$1" in
plugins/golem15/user) echo "$USER_PLUGIN" ;;
plugins/golem15/oauthserver) echo "$OAUTHSERVER_PLUGIN" ;;
plugins/golem15/journal) echo "$JOURNAL_PLUGIN" ;;
*) echo "" ;;
esac
}
# demo_pins runs the demo's pin check (every plugin submodule on the commit
# go.mod requires). With PHASE197_RELEASED=1 any mismatch fails. Otherwise
# a submodule that descends from its pin is accepted when the commits past
# the pin touch only *_test.go files (the same shipped code, reported as
# test-only), or when its HEAD is the HEAD of, or an ancestor of, the
# plugin's canonical checkout (reported as unreleased commits, released
# after verification); any other mismatch fails.
demo_pins() {
local out rc=0 line name want head full changed canonical
[[ -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
if [[ "${PHASE197_RELEASED:-0}" == "1" ]]; then
printf '%s\n' "$out" >&2
echo "refuse: PHASE197_RELEASED=1 and a demo plugin submodule is not on its pin (bump the pins after the release)" >&2
return 1
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 [[ -z "$changed" ]]; then
echo "pins: $name is ahead of its pin $want by test-only commits (release not needed for shipped code)"
continue
fi
canonical="$(canonical_checkout "$name")"
full="$(git -C "$DEMO_APP/$name" rev-parse HEAD)"
if [[ -n "$canonical" ]] && git -C "$canonical" merge-base --is-ancestor "$full" HEAD 2>/dev/null; then
echo "pins: $name is ahead of its pin by unreleased commits (release after verification; the release pins are not yet proven)"
UNRELEASED_PINS+=("$name")
if [[ -n "${PHASE197_PINS_FILE:-}" ]]; then
printf '%s\n' "$name" >>"$PHASE197_PINS_FILE"
fi
continue
fi
printf '%s\n' "$changed" >&2
echo "refuse: $name is ahead of its pin $want with non-test changes its canonical checkout ${canonical:-<none>} does not hold" >&2
return 1
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_ENV="GOWORK=off" workspace_check "$USER_PLUGIN"
workspace_check "$OAUTHSERVER_PLUGIN" "$USER_PLUGIN"
workspace_check "$DEMO_APP"
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)"
}
# vue_checked runs the demo site's vitest run (pnpm --dir vue-demo-app run
# test) and checks it with detect_vitest, requiring VUE_FILES.
vue_checked() {
local dir="$DEMO_APP/vue-demo-app" log rc=0 dc=0
require_dir "$dir" "demo site"
log="$(mktemp)"
pnpm --dir "$dir" run test >"$log" 2>&1 || rc=$?
REQUIRE_FILES="${VUE_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: pnpm run test in $dir (test=$rc detect=$dc)" >&2
return 1
fi
echo "vitest (vue-demo-app): $(grep -E '^\s*Tests\s' "$log" | tr -s ' ')"
rm -f "$log"
}
# 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[*]}"
# A-IN-02: KeyPrefix and keyPrefix carry their own doc comments.
local doc
doc="$(cd "$ROOT" && go doc ./modules/cooler KeyPrefix | sed -n 4p)"
if [[ "$doc" != " KeyPrefix returns"* ]]; then
echo "refuse: go doc cooler KeyPrefix does not open with its own comment: $doc" >&2
return 1
fi
doc="$(cd "$ROOT" && go doc -u -c ./modules/cooler keyPrefix | sed -n 3,4p)"
if [[ "$doc" != "func keyPrefix("*$'\n'" keyPrefix returns"* ]]; then
echo "refuse: go doc -u cooler keyPrefix does not show its own comment: $doc" >&2
return 1
fi
echo "cooler: KeyPrefix and keyPrefix carry their own doc comments"
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 pins_file
pins_file="$(mktemp)"
# shellcheck disable=SC2064 # expand now: the path is local to run_all
trap "rm -f '$pins_file'" EXIT
export PHASE197_PINS_FILE="$pins_file"
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
gate_verdict "$pins_file"
}
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 ;;
--demo-pins) demo_pins ;;
--admin | admin) run_admin ;;
--docs | docs) run_docs ;;
--marker | marker) run_marker ;;
--all | all) run_all ;;
*) usage ;;
esac