#!/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 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 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. # # 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 :; 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 ) # 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 ) # 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 modules/pier/reaper_test.go modules/pier/reaper_smoke_test.go modules/bouncer/auth_time_test.go modules/bouncer/auth_time_smoke_test.go ) 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 : 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:-}, 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]}" 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 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 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 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" 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" ./... 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 # 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" } # 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)" continue fi printf '%s\n' "$changed" >&2 echo "refuse: $name is ahead of its pin $want with non-test changes its canonical checkout ${canonical:-} 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)" } # 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 ;; --demo-pins) demo_pins ;; --admin | admin) run_admin ;; --docs | docs) run_docs ;; --marker | marker) run_marker ;; --all | all) run_all ;; *) usage ;; esac