Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/rules/plugin-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<integer>TABLEPRO_PLUGINKIT_VERSION</integer>` 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.
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/fix-issue/references/platform-apple.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ The macOS app and the iOS app, with everything they build from. Every profile ha
| `package` | `<Package> [regex]` | `Packages/` changed and you want the whole package; `test <Suite>` already routes a package suite |
| `ios` | `<Suite> [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` | `<merge-base>` | 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 |
Expand Down
39 changes: 14 additions & 25 deletions scripts/check-pluginkit-abi.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down Expand Up @@ -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."
192 changes: 192 additions & 0 deletions scripts/ci/test_check_pluginkit_abi.py
Original file line number Diff line number Diff line change
@@ -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()
Loading