From b1a2c783e786a976fcdf025ce0f35d08b85be3de Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sun, 18 Oct 2026 15:48:46 +0200 Subject: [PATCH] chore(19.1-06): add fail-closed phase 19.1 gate script Copy the phase 19 detector shape onto five sibling plugin roots, named short tests, postgres tracers plus GDPR names, and the three decision-note greps. Co-authored-by: Cursor --- scripts/check-phase19.1.sh | 397 +++++++++++++++++++++++++++++++++++++ 1 file changed, 397 insertions(+) create mode 100755 scripts/check-phase19.1.sh diff --git a/scripts/check-phase19.1.sh b/scripts/check-phase19.1.sh new file mode 100755 index 00000000..7fbcad48 --- /dev/null +++ b/scripts/check-phase19.1.sh @@ -0,0 +1,397 @@ +#!/usr/bin/env bash +# Phase 19.1 fail-closed gate: Chat satellites (userfriends, forum, +# chatbots, herald, chatvideo). This repository contributes this script +# and the three decision notes. The five sibling plugin repositories +# hold the named suites. +# +# Stages: +# self-test bash -n plus planted JSON that the go detectors must refuse. +# plugins go vet ./... and go test -count=1 -short ./... in each +# sibling, then go_named required short tests. A skipped, +# missing or red required test fails. +# docs bash -n plus greps of the three decision-note paths for +# locked module path + version. +# postgres Docker host only: named testcontainers suites (HTTP tracers, +# TestSendEventRollback, GDPR cascades, LiveKit reconcile). +# Without a reachable Docker daemon this stage fails. --all +# prints NOT RUN postgres unless PHASE19_1_REQUIRE_POSTGRES=1. +# +# Every stage exits non-zero on a failing command; a go test -json run that +# fails, skips, matches zero tests, prints "no tests to run" or misses a +# named required test. --self-test proves the detectors fail on planted +# output. Never installs packages. +# +# --all runs self-test, plugins and docs, then postgres when a Docker +# daemon answers. When none does, --all says so loudly (NOT RUN postgres) +# and the Docker suites stay a manual step for a Docker host: +# bash scripts/check-phase19.1.sh postgres +# Set PHASE19_1_REQUIRE_POSTGRES=1 to make --all fail instead. +# +# Environment overrides (default: the sibling checkouts next to this +# repository): PHASE19_1_ROOT (this repository), USERFRIENDS, FORUM, +# CHATBOTS, HERALD, CHATVIDEO. +set -euo pipefail + +unset FORCE_COLOR +export NO_COLOR=1 + +ROOT="${PHASE19_1_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +USERFRIENDS="${USERFRIENDS:-$ROOT/../sm-userfriends-plugin}" +FORUM="${FORUM:-$ROOT/../sm-forum-plugin}" +CHATBOTS="${CHATBOTS:-$ROOT/../sm-chatbots-plugin}" +HERALD="${HERALD:-$ROOT/../sm-herald-plugin}" +CHATVIDEO="${CHATVIDEO:-$ROOT/../sm-chatvideo-plugin}" + +WEBPUSH_NOTE="$ROOT/.planning/notes/2026-10-18-webpush-go.md" +LIVEKIT_NOTE="$ROOT/.planning/notes/2026-10-18-livekit-server-sdk-go.md" +FORUM_POLICY_NOTE="$ROOT/.planning/notes/2026-10-18-bluemonday-forum-policy.md" + +# --------------------------------------------------------------------------- +# Required test names. Each list is run with -run '^(a|b|...)$' and every name +# must pass; a name that skips or does not run fails the stage. +# +# plugins stage is Docker-free (-short). HTTP tracers, TestReconcileEmptyRoom, +# TestSendEventRollback and GDPR cascades skip under -short (testcontainers), +# so they live in the postgres stage — a skip here is refuse. + +USERFRIENDS_ROOT_SHORT=( + TestPHPMethodMap TestFixtureAbsentFromProduction TestGroupPolicy TestGroupModels +) +USERFRIENDS_UPDATES_SHORT=(TestBaseline) +FORUM_ROOT_SHORT=( + TestPHPMethodMap TestFixtureAbsentFromProduction TestAccessResolverDefault + TestNoUserfriendsImport +) +FORUM_UPDATES_SHORT=(TestBaseline) +CHATBOTS_ROOT_SHORT=( + TestPHPMethodMap TestFixtureAbsentFromProduction TestNoGolem +) +CHATBOTS_UPDATES_SHORT=(TestBaseline) +HERALD_ROOT_SHORT=( + TestPHPMethodMap TestFixtureAbsentFromProduction +) +HERALD_CLASSES_SHORT=(TestSubscribeEndpoint) +HERALD_UPDATES_SHORT=(TestBaseline) +CHATVIDEO_ROOT_SHORT=( + TestPHPMethodMap TestFixtureAbsentFromProduction +) +CHATVIDEO_CLASSES_SHORT=(TestLiveKitTokenClaims TestVideoAuthorizer) +CHATVIDEO_UPDATES_SHORT=(TestBaseline) + +PG_USERFRIENDS_TESTS=(TestHTTPTracer TestUserDeletingHeir TestGroupVisibilityGate) +PG_FORUM_TESTS=(TestHTTPTracer TestUserDeletionAnonymizes) +PG_CHATBOTS_TESTS=(TestHTTPTracer TestUserDeletionCascadesBotFlag) +PG_HERALD_TESTS=(TestHTTPTracer TestSendEventRollback) +PG_CHATVIDEO_TESTS=(TestHTTPTracer TestReconcileEmptyRoom) + +usage() { + cat >&2 <<'EOF' +usage: + check-phase19.1.sh --self-test + check-phase19.1.sh plugins | docs | postgres + check-phase19.1.sh --plugins | --docs | --postgres + check-phase19.1.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 +} + +go_json() { + local dir="$1" + shift + local log err rc=0 dc=0 + log="$(mktemp)" + err="$(mktemp)" + (cd "$dir" && go test -json "$@") >"$log" 2>"$err" || rc=$? + detect_go "$log" || dc=$? + if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then + cat "$err" >&2 || true + 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 "_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. +# Without names it does nothing (the package is covered by another run). +go_named() { + local dir="$1" pkgs="$2" flags="$3" + shift 3 + if [[ "$#" -eq 0 ]]; then + return 0 + fi + local names=("$@") regex + regex="^($(IFS='|'; echo "${names[*]}"))\$" + # shellcheck disable=SC2086 + REQUIRE_TESTS="${names[*]}" go_json "$dir" $flags -count=1 -run "$regex" $pkgs +} + +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 +} + +run_self_test() { + bash -n "${BASH_SOURCE[0]}" + + expect_detector detect_go pass 0 '{"Action":"pass","Package":"p","Test":"TestPHPMethodMap"}' + expect_detector detect_go fail 1 '{"Action":"pass","Package":"p","Test":"TestA"} +{"Action":"fail","Package":"p","Test":"TestPHPMethodMap"}' + 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":"TestHTTPTracer"}' + 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="TestBaseline TestHTTPTracer" expect_detector detect_go missing-named 5 \ + '{"Action":"pass","Package":"p","Test":"TestBaseline"}' + REQUIRE_TESTS="TestPHP" expect_detector detect_go prefix-is-not-the-test 5 \ + '{"Action":"pass","Package":"p","Test":"TestPHPMethodMap"}' + REQUIRE_TESTS="TestPHPMethodMap" expect_detector detect_go subtest-is-not-the-test 5 \ + '{"Action":"pass","Package":"p","Test":"TestGoldenEvents/TestPHPMethodMap"} +{"Action":"pass","Package":"p","Test":"TestOther"}' + + grep -qF '"${PHASE19_1_REQUIRE_POSTGRES:-0}" == "1"' "${BASH_SOURCE[0]}" || { + echo "refuse: --all does not honour PHASE19_1_REQUIRE_POSTGRES" >&2 + return 1 + } + for token in \ + USERFRIENDS FORUM CHATBOTS HERALD CHATVIDEO \ + TestBaseline TestHTTPTracer TestNoGolem TestNoUserfriendsImport \ + TestSubscribeEndpoint TestLiveKitTokenClaims TestVideoAuthorizer \ + TestReconcileEmptyRoom TestAccessResolverDefault TestPHPMethodMap \ + TestFixtureAbsentFromProduction TestSendEventRollback \ + TestUserDeletingHeir TestUserDeletionAnonymizes TestUserDeletionCascadesBotFlag \ + SherClockHolmes/webpush-go livekit/server-sdk-go/v2; do + grep -qF "$token" "${BASH_SOURCE[0]}" || { + echo "refuse: self-test missing required token $token" >&2 + return 1 + } + done + if grep -nE '^run_admin|^run_layers' "${BASH_SOURCE[0]}"; then + echo "refuse: admin/layers stage present" >&2 + return 1 + fi + local mode + for mode in self-test plugins docs postgres all; do + grep -q -- "^ --$mode" "${BASH_SOURCE[0]}" || { + echo "refuse: missing mode --$mode" >&2 + return 1 + } + done + echo "phase19.1 self-test passed" +} + +require_dir() { + [[ -d "$1" ]] || { + echo "refuse: $2 $1 not found" >&2 + return 1 + } +} + +run_plugins() { + require_dir "$USERFRIENDS" "userfriends plugin" + require_dir "$FORUM" "forum plugin" + require_dir "$CHATBOTS" "chatbots plugin" + require_dir "$HERALD" "herald plugin" + require_dir "$CHATVIDEO" "chatvideo plugin" + local repo + for repo in "$USERFRIENDS" "$FORUM" "$CHATBOTS" "$HERALD" "$CHATVIDEO"; do + go -C "$repo" vet ./... + echo "vet: $repo" + go -C "$repo" test -count=1 -short ./... >/dev/null + echo "short: $repo" + done + go_named "$USERFRIENDS" . -short "${USERFRIENDS_ROOT_SHORT[@]}" + go_named "$USERFRIENDS" ./updates -short "${USERFRIENDS_UPDATES_SHORT[@]}" + go_named "$FORUM" . -short "${FORUM_ROOT_SHORT[@]}" + go_named "$FORUM" ./updates -short "${FORUM_UPDATES_SHORT[@]}" + go_named "$CHATBOTS" . -short "${CHATBOTS_ROOT_SHORT[@]}" + go_named "$CHATBOTS" ./updates -short "${CHATBOTS_UPDATES_SHORT[@]}" + go_named "$HERALD" . -short "${HERALD_ROOT_SHORT[@]}" + go_named "$HERALD" ./classes -short "${HERALD_CLASSES_SHORT[@]}" + go_named "$HERALD" ./updates -short "${HERALD_UPDATES_SHORT[@]}" + go_named "$CHATVIDEO" . -short "${CHATVIDEO_ROOT_SHORT[@]}" + go_named "$CHATVIDEO" ./classes -short "${CHATVIDEO_CLASSES_SHORT[@]}" + go_named "$CHATVIDEO" ./updates -short "${CHATVIDEO_UPDATES_SHORT[@]}" + echo "phase19.1 plugins passed" +} + +run_docs() { + bash -n "${BASH_SOURCE[0]}" + [[ -f "$WEBPUSH_NOTE" ]] || { + echo "refuse: missing webpush-go note $WEBPUSH_NOTE" >&2 + return 1 + } + [[ -f "$LIVEKIT_NOTE" ]] || { + echo "refuse: missing livekit note $LIVEKIT_NOTE" >&2 + return 1 + } + [[ -f "$FORUM_POLICY_NOTE" ]] || { + echo "refuse: missing forum-policy note $FORUM_POLICY_NOTE" >&2 + return 1 + } + grep -qF 'SherClockHolmes/webpush-go' "$WEBPUSH_NOTE" || { + echo "refuse: webpush-go note missing SherClockHolmes/webpush-go" >&2 + return 1 + } + grep -qF 'v1.4.0' "$WEBPUSH_NOTE" || { + echo "refuse: webpush-go note missing v1.4.0" >&2 + return 1 + } + grep -qF 'livekit/server-sdk-go/v2' "$LIVEKIT_NOTE" || { + echo "refuse: livekit note missing livekit/server-sdk-go/v2" >&2 + return 1 + } + grep -qF 'v2.19.0' "$LIVEKIT_NOTE" || { + echo "refuse: livekit note missing v2.19.0" >&2 + return 1 + } + grep -qF 'bluemonday' "$FORUM_POLICY_NOTE" || { + echo "refuse: forum-policy note missing bluemonday" >&2 + return 1 + } + grep -qF 'v1.0.27' "$FORUM_POLICY_NOTE" || { + echo "refuse: forum-policy note missing v1.0.27 pin" >&2 + return 1 + } + echo "phase19.1 docs passed" +} + +docker_up() { + command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1 +} + +run_postgres() { + require_dir "$USERFRIENDS" "userfriends plugin" + require_dir "$FORUM" "forum plugin" + require_dir "$CHATBOTS" "chatbots plugin" + require_dir "$HERALD" "herald plugin" + require_dir "$CHATVIDEO" "chatvideo plugin" + if ! docker_up; then + echo "refuse: the postgres stage needs a reachable Docker daemon (testcontainers); run it on a Docker host" >&2 + return 1 + fi + go_named "$USERFRIENDS" . "-timeout 20m" "${PG_USERFRIENDS_TESTS[@]}" + go_named "$FORUM" . "-timeout 20m" "${PG_FORUM_TESTS[@]}" + go_named "$CHATBOTS" . "-timeout 20m" "${PG_CHATBOTS_TESTS[@]}" + go_named "$HERALD" . "-timeout 20m" "${PG_HERALD_TESTS[@]}" + go_named "$CHATVIDEO" . "-timeout 20m" "${PG_CHATVIDEO_TESTS[@]}" + echo "phase19.1 postgres passed" +} + +run_all() { + local stage + for stage in self-test plugins docs; do + if bash "${BASH_SOURCE[0]}" "--$stage"; then + echo "PASS $stage" + else + echo "FAIL $stage" + exit 1 + fi + done + if docker_up; then + if bash "${BASH_SOURCE[0]}" --postgres; then + echo "PASS postgres" + else + echo "FAIL postgres" + exit 1 + fi + elif [[ "${PHASE19_1_REQUIRE_POSTGRES:-0}" == "1" ]]; then + echo "FAIL postgres: no reachable Docker daemon and PHASE19_1_REQUIRE_POSTGRES=1" + exit 1 + else + cat >&2 <<'EOF' +************************************************************************ +NOT RUN postgres: no reachable Docker daemon on this host. +The testcontainers suites of sm-userfriends-plugin (TestHTTPTracer, +TestUserDeletingHeir), sm-forum-plugin (TestHTTPTracer, +TestUserDeletionAnonymizes), sm-chatbots-plugin (TestHTTPTracer, +TestUserDeletionCascadesBotFlag), sm-herald-plugin (TestHTTPTracer, +TestSendEventRollback) and sm-chatvideo-plugin (TestHTTPTracer, +TestReconcileEmptyRoom) did NOT run. Run on a Docker host before +/msd-verify-work: + bash scripts/check-phase19.1.sh postgres +************************************************************************ +EOF + echo "NOT RUN postgres (Docker-host only; see the notice above)" + fi + echo "Phase 19.1 gate passed" +} + +case "${1:---all}" in + --self-test | self-test) run_self_test ;; + --plugins | plugins) run_plugins ;; + --docs | docs) run_docs ;; + --postgres | postgres) run_postgres ;; + --all | all) run_all ;; + *) usage ;; +esac