fix(apple): decode NIBArchive booleans with AppKit's polarity (#6255)

The NIBArchive value decoder read type tag 4 as true and 5 as false, the
reverse of what ibtool writes and AppKit's decoder returns, so every
compiled-nib boolean in decode-interface-builder output was inverted. The
test fixture encoder shared the reversed mapping, hiding it in round trips.

Name the value-type tags once and use them in both the read and skip
paths, fix the fixture encoder, pin raw tags in a unit test, and make
verify:interface-builder compare both polarities with ibtool --export.

Fixes #5541

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822
2026-10-21 23:32:21 -07:00
committed by GitHub
parent 14877a069b
commit ef5ab1f1f2
5 changed files with 177 additions and 33 deletions
+3 -1
View File
@@ -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
+100 -8
View File
@@ -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) =>
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">${body}</plist>\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 {
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict><key>CFBundleExecutable</key><string>Fixture</string><key>CFBundleIdentifier</key><string>dev.rea.interface-builder-fixture</string></dict></plist>\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(
`<dict><key>NSControl</key><array>${properties.map((property) => `<string>${property}</string>`).join("")}</array></dict>`,
),
);
await writeFile(
join(fixtureRoot, "toggle.plist"),
plistDocument(
`<dict><key>button</key><dict>${properties.map((property) => `<key>${property}</key><true/>`).join("")}</dict></dict>`,
),
);
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,
};
+2 -1
View File
@@ -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);
+31
View File
@@ -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: [
+41 -23
View File
@@ -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,