diff --git a/scripts/check-phase14.2.1.sh b/scripts/check-phase14.2.1.sh new file mode 100755 index 0000000..078f820 --- /dev/null +++ b/scripts/check-phase14.2.1.sh @@ -0,0 +1,404 @@ +#!/usr/bin/env bash +# Phase 14.2.1 fail-closed gate (lean Translate plugin, cabana ML fields, +# proof host). Every stage exits non-zero on a failing command, a go test +# run that fails, skips, matches zero tests, prints "no tests to run", a +# named required test that did not pass, a data race, stale generated +# artifacts, a forbidden deferred surface, or an unmitigated high threat. +# --self-test proves the detector fails closed on planted inputs. --all +# runs every stage and must end with "Phase 14.2.1 gate passed". +# +# Sibling repositories are invoked with `go -C`. Full mode runs Postgres +# integration and treats Docker unavailability as failure; -short is not +# final evidence. +set -euo pipefail + +unset FORCE_COLOR + +ROOT="${PHASE1421_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +PLUGIN="${PHASE1421_PLUGIN:-$ROOT/../sm-translate-plugin}" +HOST="${PHASE1421_HOST:-$ROOT/../sm-grzybyfunkcjonalne-app}" +PHP="${PHASE1421_PHP:-/media/nvme/dev/golem15/fonoteka/plugins/golem15/translate}" +PHP_SHA="725d547ec839f02b5fdc0f0a6faaed601a414d50" +PHASE_DIR="${PHASE1421_PHASE_DIR:-$ROOT/.planning/phases/14.2.1-translate-plugin}" +REVIEW="$PHASE_DIR/14.2.1-SECURITY-REVIEW.md" +VALIDATION="$PHASE_DIR/14.2.1-VALIDATION.md" + +PLUGIN_REQUIRE=( + TestTranslateEndToEnd + TestLocalesAdminForbidden + TestTranslatorResolve + TestTranslatorConcurrentIsolation + TestInvalidLocaleRejected + TestTranslatableGetSet + TestFallbackDefaultLocale + TestTranslateTables + TestSeedEnPl + TestTranslateMigrationsRollbackAndRemigrate + TestTranslateEndToEndFixtureAbsentFromProduction +) +FRAMEWORK_REQUIRE=( + TestLocaleResolver + TestML + TestMLFieldTypes + TestMLNestedSave + TestMarkdownRejectsUnsafeHTML + TestMLOpenAPIConformance +) +HOST_REQUIRE=(TestBootUserTranslate) +HIGH_THREATS=( + T-14.2.1-01 T-14.2.1-02 T-14.2.1-04 T-14.2.1-05 T-14.2.1-06 + T-14.2.1-07 T-14.2.1-09 T-14.2.1-10 T-14.2.1-11 T-14.2.1-12 + T-14.2.1-14 T-14.2.1-15 T-14.2.1-18 T-14.2.1-SC +) +ALL_THREATS=( + T-14.2.1-01 T-14.2.1-02 T-14.2.1-03 T-14.2.1-04 T-14.2.1-05 + T-14.2.1-06 T-14.2.1-07 T-14.2.1-08 T-14.2.1-09 T-14.2.1-10 + T-14.2.1-11 T-14.2.1-12 T-14.2.1-13 T-14.2.1-14 T-14.2.1-15 + T-14.2.1-16 T-14.2.1-17 T-14.2.1-18 T-14.2.1-SC +) + +usage() { + cat >&2 <<'EOF' +usage: + check-phase14.2.1.sh --self-test + check-phase14.2.1.sh --php + check-phase14.2.1.sh --layout + check-phase14.2.1.sh --plugin + check-phase14.2.1.sh --framework + check-phase14.2.1.sh --docs + check-phase14.2.1.sh --admin + check-phase14.2.1.sh --host + check-phase14.2.1.sh --forbidden + check-phase14.2.1.sh --security + check-phase14.2.1.sh --all +EOF + exit 2 +} + +# detect reads go test -json. Exit 1 fail/build, 2 skip, 3 zero/no-tests, +# 4 non-JSON, 5 missing required name, 6 data race. +detect() { + 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": + if "no tests to run" in text: + print(f"refuse: no tests to run in {pkg}", file=sys.stderr) + sys.exit(3) + if "WARNING: DATA RACE" in text: + print(f"refuse: data race in {pkg} {test}", file=sys.stderr) + sys.exit(6) + 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) +top = {name for name in passed if "/" not in name} +missing = [n for n in require if n not in top and not any(p.startswith(n + "/") or p == n for p 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 "$log" || dc=$? + if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then + cat "$err" >&2 || true + grep -v '^{' "$log" | tail -n 40 >&2 || true + rm -f "$log" "$err" + echo "refuse: go test $* in $dir (test=$rc detect=$dc)" >&2 + return 1 + fi + rm -f "$log" "$err" +} + +expect_detect() { + local name="$1" want="$2" payload="$3" log dc=0 + log="$(mktemp)" + printf '%s\n' "$payload" >"$log" + detect "$log" 2>/dev/null || dc=$? + rm -f "$log" + if [[ "$dc" -ne "$want" ]]; then + echo "refuse: self-test $name: detector exit $dc, want $want" >&2 + return 1 + fi +} + +run_self_test() { + bash -n "${BASH_SOURCE[0]}" + expect_detect pass 0 '{"Action":"pass","Package":"p","Test":"TestTranslateEndToEnd"}' + expect_detect fail 1 '{"Action":"pass","Package":"p","Test":"TestA"} +{"Action":"fail","Package":"p","Test":"TestLocalesAdminForbidden"}' + expect_detect package-fail 1 '{"Action":"pass","Package":"p","Test":"TestA"} +{"Action":"fail","Package":"p"}' + expect_detect build 1 '{"Action":"build-fail","ImportPath":"p"}' + expect_detect skip 2 '{"Action":"skip","Package":"p","Test":"TestTranslateEndToEnd"}' + expect_detect zero 3 '{"Action":"pass","Package":"p"}' + expect_detect no-tests 3 '{"Action":"output","Package":"p","Output":"testing: warning: no tests to run\n"} +{"Action":"pass","Package":"p"}' + expect_detect nonjson 4 '{"Action":"pass",' + expect_detect race 6 '{"Action":"output","Package":"p","Test":"TestA","Output":"WARNING: DATA RACE\n"} +{"Action":"pass","Package":"p","Test":"TestA"}' + REQUIRE_TESTS="TestTranslateEndToEnd TestML" expect_detect missing-named 5 \ + '{"Action":"pass","Package":"p","Test":"TestTranslateEndToEnd"}' + local flag + for flag in --self-test --php --layout --plugin --framework --docs --admin --host --forbidden --security --all; do + grep -q -- "^ $flag)" "${BASH_SOURCE[0]}" || { + echo "refuse: missing mode $flag" >&2 + return 1 + } + done + echo "phase14.2.1 self-test passed" +} + +run_php() { + [[ -d "$PHP" ]] || { + echo "refuse: PHP pin tree $PHP is missing" >&2 + return 1 + } + local sha + sha="$(git -C "$PHP" rev-parse HEAD)" + if [[ "$sha" != "$PHP_SHA" ]]; then + echo "refuse: PHP SHA $sha, want $PHP_SHA" >&2 + return 1 + fi + if [[ -n "$(git -C "$PHP" status --porcelain)" ]]; then + git -C "$PHP" status --short >&2 + echo "refuse: PHP pin tree has a diff" >&2 + return 1 + fi + echo "phase14.2.1 php passed ($sha)" +} + +run_layout() { + [[ -f "$PLUGIN/go.mod" ]] || { + echo "refuse: plugin go.mod missing" >&2 + return 1 + } + grep -q '^module git.golem15.com/golem15/sm-translate-plugin$' "$PLUGIN/go.mod" || { + echo "refuse: plugin module path" >&2 + return 1 + } + [[ -f "$HOST/go.work" && -f "$HOST/plugins.gen.go" && -f "$HOST/summer.yaml" ]] || { + echo "refuse: host layout is incomplete" >&2 + return 1 + } + local line + for path in plugins/golem15/user plugins/golem15/translate; do + line="$(git -C "$HOST" ls-files -s "$path")" + [[ "$line" == 160000* ]] || { + echo "refuse: $path is not a gitlink: $line" >&2 + return 1 + } + done + if grep -E 'golem15\.journal|acme\.fixture' "$HOST/plugins.gen.go" >/dev/null; then + echo "refuse: production plugin list contains journal or fixture" >&2 + return 1 + fi + echo "phase14.2.1 layout passed" +} + +run_plugin() { + [[ -d "$PLUGIN" ]] || { + echo "refuse: plugin repository $PLUGIN not found" >&2 + return 1 + } + go -C "$PLUGIN" vet ./... + REQUIRE_TESTS="${PLUGIN_REQUIRE[*]}" go_json "$PLUGIN" ./... -count=1 -timeout 20m + REQUIRE_TESTS="${PLUGIN_REQUIRE[*]}" go_json "$PLUGIN" ./... -count=1 -race -timeout 20m + echo "phase14.2.1 plugin passed" +} + +run_framework() { + (cd "$ROOT" && go vet ./modules/cabana ./modules/surf) + REQUIRE_TESTS="${FRAMEWORK_REQUIRE[*]}" go_json "$ROOT" ./modules/cabana ./modules/surf -count=1 -timeout 20m + REQUIRE_TESTS="${FRAMEWORK_REQUIRE[*]}" go_json "$ROOT" ./modules/cabana ./modules/surf -count=1 -race -timeout 30m + echo "phase14.2.1 framework passed" +} + +run_docs() { + REQUIRE_TESTS="TestDocsTree" go_json "$ROOT" ./cmd/summer -count=1 -run '^TestDocsTree$' + local out + out="$(cd "$ROOT" && go run ./cmd/summer docs:build --check 2>&1)" || { + echo "$out" >&2 + echo "refuse: docs:build --check failed" >&2 + return 1 + } + echo "phase14.2.1 docs passed" +} + +run_admin() { + npm --prefix "$ROOT/admin" run typecheck + local log rc=0 + log="$(mktemp)" + npm --prefix "$ROOT/admin" test -- --run tests/form/registry.test.ts tests/form/MLFields.test.ts tests/form/MarkdownField.test.ts >"$log" 2>&1 || rc=$? + if [[ "$rc" -ne 0 ]] || grep -qE 'No test files found|Unhandled (Errors|Rejection)|FAIL ' "$log"; then + tail -n 60 "$log" >&2 + rm -f "$log" + echo "refuse: admin Vitest run failed (exit $rc)" >&2 + return 1 + fi + grep -E 'Test Files|Tests ' "$log" || true + rm -f "$log" + "$ROOT/scripts/check-admin-openapi.sh" --check + "$ROOT/scripts/check-admin-dist.sh" + echo "phase14.2.1 admin passed" +} + +run_host() { + [[ -d "$HOST" ]] || { + echo "refuse: proof host $HOST not found" >&2 + return 1 + } + go -C "$HOST" vet ./... + REQUIRE_TESTS="${HOST_REQUIRE[*]}" go_json "$HOST" ./... -count=1 -timeout 5m + go -C "$HOST" build -o /tmp/phase1421-host ./... + rm -f /tmp/phase1421-host + echo "phase14.2.1 host passed" +} + +run_forbidden() { + local bad=0 hits + hits="$(cd "$PLUGIN" && grep -RInE 'winter_translate_|rainlab_translate_' --include='*.go' . | grep -vE '_test\.go:' || true)" + if [[ -n "$hits" ]]; then + echo "refuse: Winter/RainLab table names in plugin Go: $hits" >&2 + bad=1 + fi + hits="$(cd "$PLUGIN" && grep -RInE 'manage_messages|golem15\.translate\.messages' --include='*.go' . | grep -vE '_test\.go:' || true)" + if [[ -n "$hits" ]]; then + echo "refuse: Messages admin surface in production plugin: $hits" >&2 + bad=1 + fi + hits="$(cd "$PLUGIN" && grep -RInE 'plugin\.Open|yaegi|AutoMigrate' --include='*.go' . | grep -vE '_test\.go:' || true)" + if [[ -n "$hits" ]]; then + echo "refuse: runtime loading or AutoMigrate in production plugin: $hits" >&2 + bad=1 + fi + hits="$(cd "$PLUGIN" && grep -RInE 'acme\.fixture|Acme\\\\Fixture\\\\Models\\\\Post' --include='*.go' . | grep -vE '_test\.go:' || true)" + if [[ -n "$hits" ]]; then + echo "refuse: test fixture in production plugin: $hits" >&2 + bad=1 + fi + hits="$(cd "$ROOT" && grep -RInE 'fonoteka|p[lł]ytarium|grzybyfunkcjonalne' modules/cabana/README.md modules/surf/README.md docs --include='*.md' || true)" + if [[ -n "$hits" ]]; then + echo "refuse: consuming-application name in framework docs: $hits" >&2 + bad=1 + fi + hits="$(cd "$ROOT/admin/src" && grep -RInE 'v-html=|innerHTML|outerHTML|insertAdjacentHTML' . || true)" + if [[ -n "$hits" ]]; then + echo "refuse: raw-HTML sink in admin/src: $hits" >&2 + bad=1 + fi + hits="$(gofmt -l "$PLUGIN"/*.go "$PLUGIN"/classes/*.go "$PLUGIN"/updates/*.go "$ROOT"/modules/cabana/ml_test.go "$ROOT"/modules/cabana/markdown_test.go "$ROOT"/modules/surf/locale_resolver_test.go 2>/dev/null || true)" + if [[ -n "$hits" ]]; then + echo "refuse: gofmt: $hits" >&2 + bad=1 + fi + [[ "$bad" -eq 0 ]] || return 1 + echo "phase14.2.1 forbidden passed" +} + +run_security() { + [[ -f "$REVIEW" ]] || { + echo "refuse: missing $REVIEW" >&2 + return 1 + } + [[ -f "$VALIDATION" ]] || { + echo "refuse: missing $VALIDATION" >&2 + return 1 + } + local id count + for id in "${ALL_THREATS[@]}"; do + count="$(grep -c -- "$id" "$REVIEW" || true)" + if [[ "$count" -lt 1 ]]; then + echo "refuse: security review missing $id" >&2 + return 1 + fi + done + if grep -qiE 'unmitigated high' "$REVIEW"; then + echo "refuse: security review still has an unmitigated high finding" >&2 + return 1 + fi + for id in "${HIGH_THREATS[@]}"; do + grep -q -- "$id" "$REVIEW" || { + echo "refuse: high threat $id missing" >&2 + return 1 + } + grep -A2 -- "$id" "$REVIEW" | grep -qi mitigate || { + echo "refuse: high threat $id is not marked mitigate" >&2 + return 1 + } + done + if ! grep -q 'No external API integration' "$REVIEW"; then + echo "refuse: security review must state there is no external API integration" >&2 + return 1 + fi + if ! grep -q 'nyquist_compliant: true' "$VALIDATION"; then + echo "refuse: VALIDATION is not signed off" >&2 + return 1 + fi + echo "phase14.2.1 security passed" +} + +run_all() { + local stage + for stage in self-test php layout plugin framework docs admin host forbidden security; do + if bash "${BASH_SOURCE[0]}" "--$stage"; then + echo "PASS $stage" + else + echo "FAIL $stage" + exit 1 + fi + done + echo "Phase 14.2.1 gate passed" +} + +case "${1:---all}" in + --self-test) run_self_test ;; + --php) run_php ;; + --layout) run_layout ;; + --plugin) run_plugin ;; + --framework) run_framework ;; + --docs) run_docs ;; + --admin) run_admin ;; + --host) run_host ;; + --forbidden) run_forbidden ;; + --security) run_security ;; + --all) run_all ;; + *) usage ;; +esac