diff --git a/docs/testing.md b/docs/testing.md
index c280643b6e..0deb82f19c 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -1243,7 +1243,9 @@ host behavior or native process-supervision controls.
real `.nib` with Xcode `ibtool`, wraps it in a temporary app bundle, and runs
the compiled CLI and stdio MCP server with production providers. It compares
their decoded results and checks the view hierarchy, outlet, action, evidence coverage, and truncation
-status. Storyboard compilation additionally requires an installed iOS platform.
+status. It also recompiles the XIB with toggled control booleans and requires
+both decodes to match `ibtool --export` for each source. Storyboard compilation
+additionally requires an installed iOS platform.
Keep the provider-specific acceptance path independent from optional
cross-compilers. Cross-format failures belong to the cross-format lane and must
diff --git a/scripts/verify/apple/interface-builder.mjs b/scripts/verify/apple/interface-builder.mjs
index 1d9d3b5d99..2ea1fb8840 100644
--- a/scripts/verify/apple/interface-builder.mjs
+++ b/scripts/verify/apple/interface-builder.mjs
@@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import {
+ artifactCli,
artifactCliEvidence,
artifactMcpResult,
withArtifactMcp,
@@ -25,6 +26,56 @@ const nibPath = join(resourcesPath, "Main.nib");
const sourceRoot = fileURLToPath(
new URL("../../../tests/conformance/interface-builder/", import.meta.url),
);
+const mainSource = join(sourceRoot, "Main.xib");
+// ibtool property names mapped to the keys AppKit archives for NSControl.
+const buttonBooleanKeys = {
+ continuous: "NSControlContinuous",
+ refusesFirstResponder: "NSControlRefusesFirstResponder",
+};
+const plistDocument = (body) =>
+ `
+
+${body}\n`;
+const compileNib = (source) =>
+ exec("xcrun", [
+ "ibtool",
+ "--errors",
+ "--warnings",
+ "--compile",
+ nibPath,
+ source,
+ ]);
+/** Apple's source-level property values: the oracle for compiled booleans. */
+const exportedButtonBooleans = async (source) => {
+ const exported = join(fixtureRoot, "export.plist");
+ const { stdout } = await exec("xcrun", [
+ "ibtool",
+ "--export",
+ join(fixtureRoot, "export-request.plist"),
+ source,
+ ]);
+ await writeFile(exported, stdout);
+ const { stdout: json } = await exec("plutil", [
+ "-convert",
+ "json",
+ "-o",
+ "-",
+ exported,
+ ]);
+ return JSON.parse(json)["com.apple.ibtool.document.export"].button;
+};
+const decodedButtonBooleans = (result) => {
+ const button = result.graph.nodes.find(
+ (node) => node.kind === "control" && node.name === "NSButton",
+ );
+ assert.ok(button, "compiled button control was not decoded");
+ return Object.fromEntries(
+ Object.entries(buttonBooleanKeys).map(([property, key]) => [
+ property,
+ button.attributes.nibValues?.[key],
+ ]),
+ );
+};
let report;
try {
@@ -36,14 +87,7 @@ try {
CFBundleExecutableFixtureCFBundleIdentifierdev.rea.interface-builder-fixture\n`,
);
- await exec("xcrun", [
- "ibtool",
- "--errors",
- "--warnings",
- "--compile",
- nibPath,
- join(sourceRoot, "Main.xib"),
- ]);
+ await compileNib(mainSource);
await exec("cc", [join(sourceRoot, "fixture.c"), "-o", executablePath]);
const evidence = await artifactCliEvidence(
@@ -110,6 +154,53 @@ try {
);
assert.equal(decoded.graph.truncated, false);
+ // Compiled booleans must keep their source polarity in both directions.
+ const properties = Object.keys(buttonBooleanKeys);
+ await writeFile(
+ join(fixtureRoot, "export-request.plist"),
+ plistDocument(
+ `NSControl${properties.map((property) => `${property}`).join("")}`,
+ ),
+ );
+ await writeFile(
+ join(fixtureRoot, "toggle.plist"),
+ plistDocument(
+ `button${properties.map((property) => `${property}`).join("")}`,
+ ),
+ );
+ const toggledSource = join(fixtureRoot, "Toggled.xib");
+ await exec("xcrun", [
+ "ibtool",
+ "--import",
+ join(fixtureRoot, "toggle.plist"),
+ "--write",
+ toggledSource,
+ mainSource,
+ ]);
+ const mainBooleans = await exportedButtonBooleans(mainSource);
+ const toggledBooleans = await exportedButtonBooleans(toggledSource);
+ assert.deepEqual(mainBooleans, {
+ continuous: false,
+ refusesFirstResponder: false,
+ });
+ assert.deepEqual(toggledBooleans, {
+ continuous: true,
+ refusesFirstResponder: true,
+ });
+ assert.deepEqual(
+ decodedButtonBooleans(decoded),
+ mainBooleans,
+ "compiled false booleans differ from the source export",
+ );
+ await compileNib(toggledSource);
+ assert.deepEqual(
+ decodedButtonBooleans(
+ await artifactCli("decode-interface-builder", appPath),
+ ),
+ toggledBooleans,
+ "compiled true booleans differ from the source export",
+ );
+
report = {
ok: true,
fixture: "compiled-appkit-nib",
@@ -122,6 +213,7 @@ try {
connections: decoded.documents[0].connection_count,
recovered_control: button.name,
recovered_action: action.name,
+ button_booleans: { main: mainBooleans, toggled: toggledBooleans },
hierarchy_complete: decoded.documents[0].hierarchy_complete,
truncated: decoded.graph.truncated,
};
diff --git a/src/artifacts/apple/NibArchive.fixture.ts b/src/artifacts/apple/NibArchive.fixture.ts
index 09f2d6e2c5..8b142b9456 100644
--- a/src/artifacts/apple/NibArchive.fixture.ts
+++ b/src/artifacts/apple/NibArchive.fixture.ts
@@ -75,7 +75,8 @@ const varint = (value: number): Buffer => {
const encodeValue = (
value: number | string | boolean | null | { ref: number },
): Buffer => {
- if (typeof value === "boolean") return Buffer.from([value ? 4 : 5]);
+ // Tags as written by ibtool and read by AppKit: 4 is NO, 5 is YES.
+ if (typeof value === "boolean") return Buffer.from([value ? 5 : 4]);
if (value === null) return Buffer.from([9]);
if (typeof value === "number") {
const bytes = Buffer.alloc(5);
diff --git a/src/artifacts/apple/NibArchive.test.ts b/src/artifacts/apple/NibArchive.test.ts
index 9d621b659b..4c9ddb2a79 100644
--- a/src/artifacts/apple/NibArchive.test.ts
+++ b/src/artifacts/apple/NibArchive.test.ts
@@ -77,6 +77,37 @@ describe("NIBArchive decoder", () => {
expect(decoded.coder_version).toBe(10);
});
+ it("reads boolean value tags with AppKit's polarity", () => {
+ // Raw tags, independent of the fixture encoder's boolean encoding: AppKit's
+ // decodeBoolForKey: returns NO for tag 4 and YES for tag 5 in compiled nibs.
+ const archive = encodeArchive({
+ classes: ["NSButton"],
+ objects: [
+ {
+ classIndex: 0,
+ values: {
+ NSControlContinuous: null,
+ NSControlRefusesFirstResponder: null,
+ NSEnabled: 0,
+ },
+ },
+ ],
+ });
+ const values = archive.readUInt32LE(38);
+ // Value records: key varint, then type tag; both fields start as nil (9).
+ expect([...archive.subarray(values, values + 4)]).toEqual([
+ 0x80, 9, 0x81, 9,
+ ]);
+ const tagged = Buffer.from(archive);
+ tagged[values + 1] = 4;
+ tagged[values + 3] = 5;
+ expect(decodeNibArchive(tagged).objects[0]?.values).toEqual({
+ NSControlContinuous: false,
+ NSControlRefusesFirstResponder: true,
+ NSEnabled: 0,
+ });
+ });
+
it("counts AppKit outlet, action, and binding connector objects", () => {
const archive = encodeArchive({
classes: [
diff --git a/src/artifacts/apple/NibArchive.ts b/src/artifacts/apple/NibArchive.ts
index 4d2d6b77f7..24220f6c9a 100644
--- a/src/artifacts/apple/NibArchive.ts
+++ b/src/artifacts/apple/NibArchive.ts
@@ -338,6 +338,24 @@ const parseValues = (
return output;
};
+/**
+ * Value type tags in a NIBArchive value table. The two boolean tags carry no
+ * payload; AppKit's own nib decoder reads tag 4 as NO and tag 5 as YES.
+ */
+const NibValueType = {
+ int8: 0,
+ int16: 1,
+ int32: 2,
+ int64: 3,
+ booleanFalse: 4,
+ booleanTrue: 5,
+ float32: 6,
+ float64: 7,
+ data: 8,
+ nil: 9,
+ objectReference: 10,
+} as const;
+
const readValue = (
bytes: Buffer,
cursor: { offset: number },
@@ -347,24 +365,24 @@ const readValue = (
budget: InterfaceBuilderDecodeBudget,
): JsonValue => {
switch (type) {
- case 0:
+ case NibValueType.int8:
ensureRange(cursor.offset, 1, end, `value ${index}`);
return bytes.readInt8(cursor.offset++);
- case 1:
+ case NibValueType.int16:
ensureRange(cursor.offset, 2, end, `value ${index}`);
{
const value = bytes.readInt16LE(cursor.offset);
cursor.offset += 2;
return value;
}
- case 2:
+ case NibValueType.int32:
ensureRange(cursor.offset, 4, end, `value ${index}`);
{
const value = bytes.readInt32LE(cursor.offset);
cursor.offset += 4;
return value;
}
- case 3:
+ case NibValueType.int64:
ensureRange(cursor.offset, 8, end, `value ${index}`);
{
const value = bytes.readBigInt64LE(cursor.offset);
@@ -374,25 +392,25 @@ const readValue = (
? Number(value)
: value.toString();
}
- case 4:
- return true;
- case 5:
+ case NibValueType.booleanFalse:
return false;
- case 6:
+ case NibValueType.booleanTrue:
+ return true;
+ case NibValueType.float32:
ensureRange(cursor.offset, 4, end, `value ${index}`);
{
const value = bytes.readFloatLE(cursor.offset);
cursor.offset += 4;
return Number.isFinite(value) ? value : null;
}
- case 7:
+ case NibValueType.float64:
ensureRange(cursor.offset, 8, end, `value ${index}`);
{
const value = bytes.readDoubleLE(cursor.offset);
cursor.offset += 8;
return Number.isFinite(value) ? value : null;
}
- case 8: {
+ case NibValueType.data: {
const length = readVarint(bytes, cursor, end);
const next = checkedEnd(
cursor.offset,
@@ -408,9 +426,9 @@ const readValue = (
cursor.offset = next;
return { $nib_data_base64: encoded };
}
- case 9:
+ case NibValueType.nil:
return null;
- case 10:
+ case NibValueType.objectReference:
ensureRange(cursor.offset, 4, end, `value ${index} reference`);
{
const objectIndex = bytes.readUInt32LE(cursor.offset);
@@ -432,33 +450,33 @@ const skipValue = (
index: number,
): void => {
switch (type) {
- case 0:
+ case NibValueType.int8:
ensureRange(cursor.offset, 1, end, `value ${index}`);
cursor.offset += 1;
return;
- case 1:
+ case NibValueType.int16:
ensureRange(cursor.offset, 2, end, `value ${index}`);
cursor.offset += 2;
return;
- case 2:
- case 10:
+ case NibValueType.int32:
+ case NibValueType.objectReference:
ensureRange(cursor.offset, 4, end, `value ${index}`);
cursor.offset += 4;
return;
- case 3:
- case 7:
+ case NibValueType.int64:
+ case NibValueType.float64:
ensureRange(cursor.offset, 8, end, `value ${index}`);
cursor.offset += 8;
return;
- case 6:
+ case NibValueType.float32:
ensureRange(cursor.offset, 4, end, `value ${index}`);
cursor.offset += 4;
return;
- case 4:
- case 5:
- case 9:
+ case NibValueType.booleanFalse:
+ case NibValueType.booleanTrue:
+ case NibValueType.nil:
return;
- case 8: {
+ case NibValueType.data: {
const length = readVarint(bytes, cursor, end);
cursor.offset = checkedEnd(
cursor.offset,