Verify generated Android app contracts

ober

30c88450b7d000e20e041caf729653942195764f

diff --git a/Makefile b/Makefile
index 49b7229..e6a28c1 100644
--- a/Makefile
+++ b/Makefile
@@ -6,6 +6,7 @@ CLIENT_EXAMPLE ?= examples/client/app.ss
 SSD_EXAMPLE ?= tests/fixtures/ssd-app.ss
 ORIGINAL_TACTICS_EXAMPLE ?= tests/fixtures/original-tactics-app.ss
 ORIGINAL_TACTICS_BUILD_DIR ?= build/original-tactics
+SSD_CONTRACT_BUILD_DIR ?= build/ssd-contract
 JAVA_HOME ?= /opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
 ANDROID_HOME ?= $(HOME)/Library/Android/sdk
 ANDROID_SDK_ROOT ?= $(ANDROID_HOME)
@@ -14,7 +15,7 @@ export ANDROID_HOME
 export ANDROID_SDK_ROOT
 export PATH := $(JAVA_HOME)/bin:/opt/homebrew/bin:$(PATH)
 
-.PHONY: help clean generate test check-no-raw-kotlin security supply-chain verify ssd-generate ssd-compile original-tactics-generate original-tactics-compile release-evidence apk
+.PHONY: help clean generate test check-no-raw-kotlin check-generated-app-contracts security supply-chain verify ssd-generate ssd-compile original-tactics-generate original-tactics-compile release-evidence apk
 .NOTPARALLEL:
 
 help:
@@ -25,6 +26,7 @@ help:
 	@echo "  verify    Run the release-required local verification suite"
 	@echo "  ssd-compile  Compile the SSD fixture with pinned Gradle/JDK tools"
 	@echo "  original-tactics-compile  Compile the Original Tactics fixture with pinned Gradle/JDK tools"
+	@echo "  check-generated-app-contracts  Verify both generated APK source contracts"
 	@echo "  release-evidence  Emit SBOM/provenance from a clean committed tree"
 	@echo "  clean     Remove generated output"
 
@@ -61,12 +63,17 @@ test: check-no-raw-kotlin clean generate
 	$(JERBOA) jandroid.ss generate $(ORIGINAL_TACTICS_EXAMPLE) $(ORIGINAL_TACTICS_BUILD_DIR)
 	test -f $(ORIGINAL_TACTICS_BUILD_DIR)/app/src/main/java/com/jerboa/originaltactics/ShipUiNames.kt
 	grep -q 'label.length <= maxChars' $(ORIGINAL_TACTICS_BUILD_DIR)/app/src/main/java/com/jerboa/originaltactics/ShipUiNames.kt
+	$(JERBOA) jandroid.ss generate $(SSD_EXAMPLE) $(SSD_CONTRACT_BUILD_DIR)
+	$(MAKE) check-generated-app-contracts
 	$(MAKE) security
 	$(MAKE) supply-chain
 
 check-no-raw-kotlin:
 	scripts/check-no-raw-kotlin.sh
 
+check-generated-app-contracts:
+	scripts/verify-generated-app-contracts.sh $(ORIGINAL_TACTICS_BUILD_DIR) $(SSD_CONTRACT_BUILD_DIR)
+
 security:
 	JERBOA="$(JERBOA)" tests/security-test.sh
 	JERBOA="$(JERBOA)" tests/ssd-security-test.sh
diff --git a/scripts/verify-generated-app-contracts.sh b/scripts/verify-generated-app-contracts.sh
new file mode 100755
index 0000000..7a153ab
--- /dev/null
+++ b/scripts/verify-generated-app-contracts.sh
@@ -0,0 +1,153 @@
+#!/bin/sh
+set -eu
+
+repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P)
+generated_only=false
+
+if [ "${1:-}" = "--generated-only" ]; then
+    generated_only=true
+    shift
+fi
+
+if [ "$#" -ne 2 ]; then
+    printf 'usage: %s [--generated-only] ORIGINAL_PROJECT SSD_PROJECT\n' "$0" >&2
+    exit 2
+fi
+
+java_root() {
+    case "$1" in
+        */app/src/main/java) printf '%s\n' "$1" ;;
+        *) printf '%s/app/src/main/java\n' "${1%/}" ;;
+    esac
+}
+
+original_root=$(java_root "$1")
+ssd_root=$(java_root "$2")
+failures=0
+missing_files='|'
+
+fail() {
+    printf 'app contract failure: %s\n' "$1" >&2
+    failures=$((failures + 1))
+}
+
+require_root() {
+    if [ ! -d "$1" ]; then
+        fail "generated Kotlin root is missing: $1"
+    fi
+}
+
+require_declaration() {
+    root=$1
+    relative=$2
+    declaration=$3
+    name=$4
+    file="$root/$relative"
+
+    if [ ! -f "$file" ]; then
+        case "$missing_files" in
+            *"|$file|"*) ;;
+            *)
+                fail "generated Kotlin file is missing: $file"
+                missing_files="${missing_files}${file}|"
+                ;;
+        esac
+        return
+    fi
+
+    case "$declaration" in
+        class)
+            pattern="(^|[[:space:]])class[[:space:]]+$name([[:space:](<:]|$)"
+            ;;
+        object)
+            pattern="(^|[[:space:]])object[[:space:]]+$name([[:space:]{:]|$)"
+            ;;
+        fun)
+            pattern="(^|[[:space:]])fun[[:space:]]+$name[[:space:]]*\\("
+            ;;
+        *)
+            printf 'internal checker error: unknown declaration kind %s\n' "$declaration" >&2
+            exit 2
+            ;;
+    esac
+
+    if ! LC_ALL=C grep -Eq "$pattern" "$file"; then
+        fail "$relative does not declare $declaration $name"
+    fi
+}
+
+require_root "$original_root"
+require_root "$ssd_root"
+
+if [ "$generated_only" = false ]; then
+    "$repo/scripts/check-no-raw-kotlin.sh"
+fi
+
+original_package=com/jerboa/originaltactics
+require_declaration "$original_root" "$original_package/MainActivity.kt" class MainActivity
+require_declaration "$original_root" "$original_package/MainActivity.kt" fun onCreate
+require_declaration "$original_root" "$original_package/RulesClient.kt" object EmbeddedRulesBridge
+require_declaration "$original_root" "$original_package/RulesClient.kt" fun handleServiceJson
+require_declaration "$original_root" "$original_package/RulesClient.kt" class RulesClient
+require_declaration "$original_root" "$original_package/RulesClient.kt" fun post
+require_declaration "$original_root" "$original_package/TacticalMapView.kt" class TacticalMapView
+require_declaration "$original_root" "$original_package/TacticalMapView.kt" fun setGame
+require_declaration "$original_root" "$original_package/TacticalMapView.kt" fun setTacticalStatus
+require_declaration "$original_root" "$original_package/TacticalMapView.kt" fun resetView
+require_declaration "$original_root" "$original_package/TacticalMapView.kt" fun onTouchEvent
+require_declaration "$original_root" "$original_package/TacticalMapView.kt" fun onDraw
+require_declaration "$original_root" "$original_package/SsdDamageView.kt" class SsdDamageView
+require_declaration "$original_root" "$original_package/SsdDamageView.kt" fun setData
+require_declaration "$original_root" "$original_package/SsdDamageView.kt" fun onDraw
+require_declaration "$original_root" "$original_package/BattleCommandView.kt" class BattleCommandView
+require_declaration "$original_root" "$original_package/BattleCommandView.kt" fun setGame
+require_declaration "$original_root" "$original_package/BattleCommandView.kt" fun onDraw
+require_declaration "$original_root" "$original_package/BridgeHudView.kt" class BridgeHudView
+require_declaration "$original_root" "$original_package/BridgeHudView.kt" fun setData
+require_declaration "$original_root" "$original_package/CombatLogView.kt" class CombatLogView
+require_declaration "$original_root" "$original_package/CombatLogView.kt" fun setData
+require_declaration "$original_root" "$original_package/ShipStatusView.kt" class ShipStatusView
+require_declaration "$original_root" "$original_package/ShipStatusView.kt" fun setGame
+require_declaration "$original_root" "$original_package/ZoomPanFrameLayout.kt" class ZoomPanFrameLayout
+require_declaration "$original_root" "$original_package/ZoomPanFrameLayout.kt" fun onDoubleTap
+require_declaration "$original_root" "$original_package/ZoomPanFrameLayout.kt" fun onTouchEvent
+
+ssd_package=com/sfb/ssdreview
+require_declaration "$ssd_root" "$ssd_package/MainActivity.kt" class MainActivity
+require_declaration "$ssd_root" "$ssd_package/MainActivity.kt" fun onCreate
+require_declaration "$ssd_root" "$ssd_package/MainActivity.kt" fun onResume
+require_declaration "$ssd_root" "$ssd_package/MainActivity.kt" fun onPause
+require_declaration "$ssd_root" "$ssd_package/MainActivityLifecycle.kt" fun mainActivityOnCreate
+require_declaration "$ssd_root" "$ssd_package/MainActivityUi.kt" fun mainActivityBuildUi
+require_declaration "$ssd_root" "$ssd_package/MainActivityInteraction.kt" fun mainActivityApplySelectMode
+require_declaration "$ssd_root" "$ssd_package/MainActivityInteraction.kt" fun mainActivityFinishSelectInteraction
+require_declaration "$ssd_root" "$ssd_package/SsdReviewView.kt" class SsdReviewView
+require_declaration "$ssd_root" "$ssd_package/SsdReviewView.kt" fun fitImage
+require_declaration "$ssd_root" "$ssd_package/SsdReviewView.kt" fun rotateClockwise
+require_declaration "$ssd_root" "$ssd_package/SsdReviewView.kt" fun captureViewport
+require_declaration "$ssd_root" "$ssd_package/SsdReviewView.kt" fun restoreViewport
+require_declaration "$ssd_root" "$ssd_package/SsdReviewView.kt" fun centerOnGroup
+require_declaration "$ssd_root" "$ssd_package/SsdReviewView.kt" fun finishSelection
+require_declaration "$ssd_root" "$ssd_package/SsdReviewView.kt" fun onTouchEvent
+require_declaration "$ssd_root" "$ssd_package/TruthStore.kt" class TruthStore
+require_declaration "$ssd_root" "$ssd_package/TruthStore.kt" fun saveSession
+require_declaration "$ssd_root" "$ssd_package/TruthStore.kt" fun applyTruth
+require_declaration "$ssd_root" "$ssd_package/TruthStore.kt" fun applyLearnedGuesses
+require_declaration "$ssd_root" "$ssd_package/SsdDetector.kt" fun detectCells
+require_declaration "$ssd_root" "$ssd_package/SsdOcr.kt" object SsdOcr
+require_declaration "$ssd_root" "$ssd_package/SsdOcr.kt" fun recognizeWords
+require_declaration "$ssd_root" "$ssd_package/SfbServerClient.kt" class SfbServerClient
+require_declaration "$ssd_root" "$ssd_package/SfbServerClient.kt" fun selectFastest
+require_declaration "$ssd_root" "$ssd_package/SfbServerClient.kt" fun fetchServerPdfs
+require_declaration "$ssd_root" "$ssd_package/SfbServerClient.kt" fun fetchServerPageTruth
+require_declaration "$ssd_root" "$ssd_package/SfbServerClient.kt" fun fetchServerRecognition
+require_declaration "$ssd_root" "$ssd_package/SfbServerClient.kt" fun fetchServerSnapshot
+require_declaration "$ssd_root" "$ssd_package/ClientCrashInstall.kt" fun mainActivityInstallClientCrashLoggerHandler
+require_declaration "$ssd_root" "$ssd_package/ClientLogTransport.kt" fun sendClientLogBlocking
+
+if [ "$failures" -ne 0 ]; then
+    printf '%s\n' "generated app contract check failed with $failures error(s)" >&2
+    exit 1
+fi
+
+printf '%s\n' 'generated Original Tactics and SSD Kotlin contracts verified'