diff --git a/.claude/rules/plugin-system.md b/.claude/rules/plugin-system.md
index ea3c0602ab..ea0d5c7242 100644
--- a/.claude/rules/plugin-system.md
+++ b/.claude/rules/plugin-system.md
@@ -28,8 +28,8 @@ TableProPluginKit is built with Library Evolution, so an already-built plugin ke
- The build stamps that value into `TableProPluginKitVersion` of the app and of every plugin. A plugin's `Info.plist` holds `TABLEPRO_PLUGINKIT_VERSION` and is preprocessed, because every shipped app reads the key with `as? Int`; the source file therefore fails `plutil -lint`.
- After an additive change, built plugins keep loading, and the bump makes an older app refuse a plugin rebuilt against the new API instead of failing its load.
- A breaking change also raises `TABLEPRO_PLUGINKIT_MINIMUM_VERSION`. Every tag ships every registry plugin built against its own kit, so the release that carries the change carries plugins in its range.
-- `scripts/check-pluginkit-abi.sh [--frozen] [base]` (`verify.sh abi [base]`) builds PluginKit at the base, by default the newest `v*` tag reachable from HEAD, and at HEAD with Xcode's ABI checker (`RUN_SWIFT_ABI_CHECKER_TOOL`). macOS Tests runs it whenever it compiles the plugins, with `--frozen` on a PR into `release/*`.
-- A checker error is a breaking change: the kit and the minimum must both be one past the base's kit, so the host refuses every plugin built before it. A baseline that changed with no error needs the kit one past the base's; an identical one needs the base's kit. `--frozen` fails on any change.
+- `scripts/check-pluginkit-abi.sh [--frozen] [base]` (`verify.sh abi [base]`) builds PluginKit at the base, by default the newest `v*` tag reachable from HEAD, and at HEAD with Xcode's ABI checker (`RUN_SWIFT_ABI_CHECKER_TOOL`). When CI runs it: `.claude/rules/ci.md`, macOS Tests.
+- A checker error is a breaking change: the kit and the minimum must both be one past the base's kit, so the host refuses every plugin built before it. A baseline that changed with no error needs the kit one past the base's and the base's minimum; an identical one needs the base's kit and minimum. `--frozen` fails on any ABI change.
- The checker reports breakage only; an additive change shows as a changed baseline. Its errors are in the log `verify.sh abi` prints.
- Mark a public enum `@frozen` only when its case set is closed. `PluginCapability` and the transfer structs stay non-frozen so they can grow.
- PluginKit code with no callers stays: removing it is an ABI change.
diff --git a/.claude/skills/fix-issue/references/platform-apple.md b/.claude/skills/fix-issue/references/platform-apple.md
index 40ab83906b..d384e9155f 100644
--- a/.claude/skills/fix-issue/references/platform-apple.md
+++ b/.claude/skills/fix-issue/references/platform-apple.md
@@ -38,7 +38,7 @@ The macOS app and the iOS app, with everything they build from. Every profile ha
| `package` | ` [regex]` | `Packages/` changed and you want the whole package; `test ` already routes a package suite |
| `ios` | ` [Suite...]`, at least one | `TableProMobile/` or a file it compiles changed |
| `plugins` | none | anything under `Plugins/` changed. Builds `AllPlugins`, building the HANA helper first when it is missing and Go is installed |
-| `abi` | `` | PluginKit changed: after the commit, before the push (`.claude/rules/plugin-system.md`, ABI) |
+| `abi` | `[base]`, default the newest `v*` tag | PluginKit changed (`.claude/rules/plugin-system.md`, ABI) |
| `lint` | AGENTS | every changed Swift file |
| `l10n` | none | strings, or plugin or package messages, changed. Passes only when every plugin and package string is in both the macOS and iOS catalogs, and prints the `--add` command for each target that failed |
| `docs` | `[docs-checkout]`, default `tablepro-docs` beside the main checkout | AGENTS Every change 4 applies, or a menu, shortcut, driver list or docs.tablepro.app link changed |
diff --git a/scripts/check-pluginkit-abi.sh b/scripts/check-pluginkit-abi.sh
index 70c472110d..41602eeef7 100755
--- a/scripts/check-pluginkit-abi.sh
+++ b/scripts/check-pluginkit-abi.sh
@@ -22,21 +22,20 @@ fail() {
}
kits() {
- awk -F' *= *' -v origin="$1" '
+ awk -F' *= *' '
$1 == "TABLEPRO_PLUGINKIT_VERSION" { kit = $2 }
$1 == "TABLEPRO_PLUGINKIT_MINIMUM_VERSION" { minimum = $2 }
END {
- if (kit !~ /^[0-9]+$/ || minimum !~ /^[0-9]+$/) {
- print "::error::no PluginKit versions in " origin
- exit 1
- }
+ if (kit !~ /^[0-9]+$/ || minimum !~ /^[0-9]+$/) exit 1
print kit, minimum
}'
}
-base_kits="$(git -C "$root" show "$base:Configs/PluginKit.xcconfig" | kits "$base")"
-head_kits="$(kits HEAD < "$root/Configs/PluginKit.xcconfig")"
-read -r base_kit _ <<< "$base_kits"
+config="Configs/PluginKit.xcconfig"
+settings="TABLEPRO_PLUGINKIT_VERSION and TABLEPRO_PLUGINKIT_MINIMUM_VERSION"
+base_kits="$(git -C "$root" show "$base:$config" | kits)" || fail "$config at $base does not set $settings to integers."
+head_kits="$(kits < "$root/$config")" || fail "$config does not set $settings to integers."
+read -r base_kit base_minimum <<< "$base_kits"
read -r head_kit head_minimum <<< "$head_kits"
work="$(mktemp -d)"
@@ -86,26 +85,16 @@ else
esac
fi
-next=$((base_kit + 1))
-config="Configs/PluginKit.xcconfig"
if [ "$frozen" -eq 1 ] && [ "$change" != unchanged ]; then
fail "PluginKit's ABI differs from $base, and a release line never changes it."
fi
+next=$((base_kit + 1))
case "$change" in
- breaking)
- if [ "$head_kit" -ne "$next" ] || [ "$head_minimum" -ne "$next" ]; then
- fail "Breaking change since $base: set TABLEPRO_PLUGINKIT_VERSION and TABLEPRO_PLUGINKIT_MINIMUM_VERSION to $next in $config."
- fi
- ;;
- changed)
- if [ "$head_kit" -ne "$next" ]; then
- fail "PluginKit's ABI changed since $base: set TABLEPRO_PLUGINKIT_VERSION to $next in $config."
- fi
- ;;
- unchanged)
- if [ "$head_kit" -ne "$base_kit" ]; then
- fail "PluginKit's ABI matches $base: TABLEPRO_PLUGINKIT_VERSION stays $base_kit in $config."
- fi
- ;;
+ breaking) want_kit=$next want_minimum=$next ;;
+ changed) want_kit=$next want_minimum=$base_minimum ;;
+ unchanged) want_kit=$base_kit want_minimum=$base_minimum ;;
esac
+if [ "$head_kit" -ne "$want_kit" ] || [ "$head_minimum" -ne "$want_minimum" ]; then
+ fail "PluginKit's ABI against $base: $change. Set TABLEPRO_PLUGINKIT_VERSION to $want_kit and TABLEPRO_PLUGINKIT_MINIMUM_VERSION to $want_minimum in $config."
+fi
echo "PluginKit ABI against $base: $change; kit $head_kit, minimum $head_minimum."
diff --git a/scripts/ci/test_check_pluginkit_abi.py b/scripts/ci/test_check_pluginkit_abi.py
new file mode 100644
index 0000000000..dc48b52600
--- /dev/null
+++ b/scripts/ci/test_check_pluginkit_abi.py
@@ -0,0 +1,192 @@
+#!/usr/bin/env python3
+
+import os
+import shutil
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+
+SCRIPT = Path(__file__).resolve().parent.parent / "check-pluginkit-abi.sh"
+API = ["query", "fetchRows"]
+
+# Stands in for Xcode: a build writes the tree's api.txt as its ABI baseline, and the checker
+# reports each baseline line missing from api.txt as a breakage.
+XCODEBUILD = """#!/usr/bin/env bash
+set -euo pipefail
+project="" out="" baseline="" previous=""
+for arg in "$@"; do
+ [ "$previous" = -project ] && project="${arg%/TablePro.xcodeproj}"
+ case "$arg" in
+ SWIFT_ABI_GENERATION_TOOL_OUTPUT_DIR=*) out="${arg#*=}" ;;
+ SWIFT_ABI_CHECKER_BASELINE_DIR=*) baseline="${arg#*=}" ;;
+ esac
+ previous="$arg"
+done
+if [ -e "$project/build-fails" ]; then
+ echo "$project/Kit.swift:1:1: error: cannot find type 'Row' in scope"
+ exit 65
+fi
+mkdir -p "$out"
+cp "$project/api.txt" "$out/arm64-apple-macos.json"
+[ -n "$baseline" ] || exit 0
+removed="$(grep -vxFf "$project/api.txt" "$baseline/ABI/arm64-apple-macos.json" || true)"
+[ -n "$removed" ] || exit 0
+while IFS= read -r symbol; do
+ echo "$project/Kit.swift:1:1: error: ABI breakage: func $symbol has been removed"
+done <<< "$removed"
+exit 65
+"""
+
+
+class CheckPluginKitABITests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ scratch = Path(self.temp.name)
+ stubs = scratch / "bin"
+ stubs.mkdir()
+ (stubs / "xcodebuild").write_text(XCODEBUILD, encoding="utf-8")
+ (stubs / "xcodegen").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
+ for stub in stubs.iterdir():
+ stub.chmod(0o755)
+ self.env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
+ self.env["PATH"] = f"{stubs}{os.pathsep}{self.env['PATH']}"
+ self.repo = scratch / "repo"
+ (self.repo / "scripts").mkdir(parents=True)
+ shutil.copy(SCRIPT, self.repo / "scripts/check-pluginkit-abi.sh")
+ self.git("-c", "init.defaultBranch=main", "init", "-q")
+ self.commit(API, 39, 19, tag="v1.0.0")
+
+ def tearDown(self):
+ self.temp.cleanup()
+
+ def git(self, *args):
+ result = subprocess.run(
+ ["git", "-c", "user.name=test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false",
+ "-c", "tag.gpgsign=false", *args],
+ cwd=self.repo, env=self.env, check=True, capture_output=True, text=True, stdin=subprocess.DEVNULL,
+ )
+ return result.stdout.strip()
+
+ def commit(self, api, kit, minimum, tag=None, config=None):
+ (self.repo / "api.txt").write_text("".join(f"{symbol}\n" for symbol in api), encoding="utf-8")
+ if config is None:
+ config = f"TABLEPRO_PLUGINKIT_VERSION = {kit}\nTABLEPRO_PLUGINKIT_MINIMUM_VERSION = {minimum}\n"
+ (self.repo / "Configs").mkdir(exist_ok=True)
+ (self.repo / "Configs/PluginKit.xcconfig").write_text(
+ "// When to raise each: .claude/rules/plugin-system.md, ABI.\n" + config, encoding="utf-8")
+ self.git("add", "-A")
+ self.git("commit", "-q", "--allow-empty", "-m", "change")
+ if tag:
+ self.git("tag", tag)
+
+ def check(self, *args):
+ result = subprocess.run(
+ ["bash", "scripts/check-pluginkit-abi.sh", *args],
+ cwd=self.repo, env=self.env, capture_output=True, text=True, stdin=subprocess.DEVNULL,
+ )
+ return result.returncode, result.stdout + result.stderr
+
+ def assertPasses(self, expected, *args):
+ code, out = self.check(*args)
+ self.assertEqual(code, 0, out)
+ self.assertIn(expected, out)
+
+ def assertFails(self, expected, *args):
+ code, out = self.check(*args)
+ self.assertEqual(code, 1, out)
+ errors = [line for line in out.splitlines() if line.startswith("::error::")]
+ self.assertEqual(len(errors), 1, out)
+ self.assertIn(expected, errors[0])
+
+ def test_same_api_and_versions_pass(self):
+ self.commit(API, 39, 19)
+ self.assertPasses("against v1.0.0: unchanged; kit 39, minimum 19.")
+
+ def test_same_api_with_a_kit_bump_fails(self):
+ self.commit(API, 40, 19)
+ self.assertFails("against v1.0.0: unchanged. Set TABLEPRO_PLUGINKIT_VERSION to 39 and "
+ "TABLEPRO_PLUGINKIT_MINIMUM_VERSION to 19 in Configs/PluginKit.xcconfig.")
+
+ def test_same_api_with_a_lowered_minimum_fails(self):
+ self.commit(API, 39, 18)
+ self.assertFails("against v1.0.0: unchanged. Set TABLEPRO_PLUGINKIT_VERSION to 39 and "
+ "TABLEPRO_PLUGINKIT_MINIMUM_VERSION to 19")
+
+ def test_reverted_break_that_keeps_its_minimum_fails(self):
+ self.commit(["query"], 40, 40)
+ self.commit(API, 39, 40)
+ self.assertFails("against v1.0.0: unchanged. Set TABLEPRO_PLUGINKIT_VERSION to 39 and "
+ "TABLEPRO_PLUGINKIT_MINIMUM_VERSION to 19")
+
+ def test_added_api_without_a_kit_bump_fails(self):
+ self.commit(API + ["prepare"], 39, 19)
+ self.assertFails("against v1.0.0: changed. Set TABLEPRO_PLUGINKIT_VERSION to 40 and "
+ "TABLEPRO_PLUGINKIT_MINIMUM_VERSION to 19")
+
+ def test_added_api_with_a_kit_bump_passes(self):
+ self.commit(API + ["prepare"], 40, 19)
+ self.assertPasses("against v1.0.0: changed; kit 40, minimum 19.")
+
+ def test_added_api_with_a_raised_minimum_fails(self):
+ self.commit(API + ["prepare"], 40, 40)
+ self.assertFails("against v1.0.0: changed. Set TABLEPRO_PLUGINKIT_VERSION to 40 and "
+ "TABLEPRO_PLUGINKIT_MINIMUM_VERSION to 19")
+
+ def test_removed_api_with_only_a_kit_bump_fails(self):
+ self.commit(["query"], 40, 19)
+ self.assertFails("against v1.0.0: breaking. Set TABLEPRO_PLUGINKIT_VERSION to 40 and "
+ "TABLEPRO_PLUGINKIT_MINIMUM_VERSION to 40")
+ self.assertIn("error: ABI breakage: func fetchRows has been removed", self.check()[1])
+
+ def test_removed_api_with_kit_and_minimum_bumps_passes(self):
+ self.commit(["query"], 40, 40)
+ self.assertPasses("against v1.0.0: breaking; kit 40, minimum 40.")
+
+ def test_frozen_passes_with_no_change(self):
+ self.commit(API, 39, 19)
+ self.assertPasses("against v1.0.0: unchanged; kit 39, minimum 19.", "--frozen")
+
+ def test_frozen_fails_on_added_api_even_with_a_kit_bump(self):
+ self.commit(API + ["prepare"], 40, 19)
+ self.assertFails("PluginKit's ABI differs from v1.0.0, and a release line never changes it.", "--frozen")
+
+ def test_frozen_fails_on_a_minimum_change(self):
+ self.commit(API, 39, 18)
+ self.assertFails("against v1.0.0: unchanged.", "--frozen")
+
+ def test_default_base_is_the_nearest_v_tag_reachable_from_head(self):
+ self.commit(API + ["prepare"], 40, 19, tag="v1.1.0")
+ self.git("checkout", "-q", "-b", "side", "v1.0.0")
+ self.commit(API, 39, 19, tag="v9.0.0")
+ self.git("checkout", "-q", "main")
+ self.commit(API + ["prepare", "explain"], 41, 19, tag="libs-1")
+ self.assertPasses("against v1.1.0: changed; kit 41, minimum 19.")
+
+ def test_an_explicit_base_replaces_the_tag(self):
+ self.commit(API + ["prepare"], 40, 19, tag="v1.1.0")
+ self.assertPasses("against v1.1.0: unchanged; kit 40, minimum 19.")
+ self.assertPasses("against v1.0.0: changed; kit 40, minimum 19.", "v1.0.0")
+
+ def test_versions_that_are_not_integers_fail_with_a_message(self):
+ self.commit(API, 40, 19, config="TABLEPRO_PLUGINKIT_VERSION = 40 // bumped\n"
+ "TABLEPRO_PLUGINKIT_MINIMUM_VERSION = 19\n")
+ self.assertFails("Configs/PluginKit.xcconfig does not set TABLEPRO_PLUGINKIT_VERSION and "
+ "TABLEPRO_PLUGINKIT_MINIMUM_VERSION to integers.")
+
+ def test_a_missing_base_version_fails_with_a_message(self):
+ self.commit(API, 39, 19, config="TABLEPRO_PLUGINKIT_VERSION = 39\n", tag="v1.1.0")
+ self.commit(API, 39, 19)
+ self.assertFails("Configs/PluginKit.xcconfig at v1.1.0 does not set")
+
+ def test_a_head_that_does_not_build_fails(self):
+ (self.repo / "build-fails").touch()
+ self.commit(API, 39, 19)
+ self.assertFails("TableProPluginKit does not build at HEAD")
+
+ def test_two_bases_are_a_usage_error(self):
+ self.assertEqual(self.check("v1.0.0", "v1.0.0")[0], 2)
+
+
+if __name__ == "__main__":
+ unittest.main()