From 2a4667bb79117b202abb009c0bb9ebfdfde57158 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:10:25 +0000 Subject: [PATCH 1/8] fix(artifacts): decode flat keyed-archive NIBs with data and shared references decode_interface_builder failed on every flat (keyed-archive) NIB that holds plist data or dates. Archived colors keep their components as data, so this covers nearly every AppKit NIB: XLD.app decoded 0 of 64. The decoded Uint8Array and Date values failed JSON validation. They now use the shared plist projection, which keeps them as explicit $plist_type values. Decoding those archives exposed a second failure. The keyed-archive parser inlined every UID reference up to 16 levels for every object, so an archive whose views reference their window, superview, and next key view expanded exponentially. A 9 KB NIB took 3 s, and a 158 KB MainMenu.nib did not finish. A reference now resolves to its string, or to a stub naming the referenced object's archive UID, authored ID, and class. The hierarchy walk visits each object once. The parser also reads XML CF$UID references and treats UID 0 as nil. Co-Authored-By: Claude Opus 5.5 --- src/application/InterfaceBuilderAnalysis.ts | 15 +- src/domain/interfaceBuilderGraph.test.ts | 85 +++++++++ src/domain/interfaceBuilderKeyedArchive.ts | 171 ++++++++++++------ .../filesystem/interfaceBuilderBundle.test.ts | 42 +++++ 4 files changed, 256 insertions(+), 57 deletions(-) diff --git a/src/application/InterfaceBuilderAnalysis.ts b/src/application/InterfaceBuilderAnalysis.ts index 697a1fd8fb..f223dae3f0 100644 --- a/src/application/InterfaceBuilderAnalysis.ts +++ b/src/application/InterfaceBuilderAnalysis.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { parseBinary } from "plist"; import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js"; +import { projectPlistValue } from "../domain/plistValue.js"; import { parseXmlPropertyList } from "../domain/propertyListKeys.js"; import { decodeNibArchive, @@ -370,10 +371,14 @@ const readEntry = async ( return Buffer.concat(chunks, length); }; -const decodePlist = (bytes: Buffer): JsonValue => { - const parsed = +/** + * Decode a keyed-archive plist. Data and dates, such as an archived color's + * `NSWhite` bytes, become explicit `$plist_type` values rather than failing + * JSON validation. + */ +const decodePlist = (bytes: Buffer): JsonValue => + projectPlistValue( bytes.subarray(0, 8).toString("ascii") === "bplist00" ? parseBinary(bytes) - : parseXmlPropertyList(bytes.toString("utf8")).value; - return jsonValueSchema.parse(parsed); -}; + : parseXmlPropertyList(bytes.toString("utf8")).value, + ).value; diff --git a/src/domain/interfaceBuilderGraph.test.ts b/src/domain/interfaceBuilderGraph.test.ts index f287433226..6a7061c87d 100644 --- a/src/domain/interfaceBuilderGraph.test.ts +++ b/src/domain/interfaceBuilderGraph.test.ts @@ -317,3 +317,88 @@ describe("keyed archive decoding and graph limits", () => { ); }); }); + +describe("keyed archive object references", () => { + it("keeps referenced objects as stubs so shared and cyclic references stay finite", () => { + // Each view references its window, superview, and next key view, so + // inlining references would expand the table into an exponential tree. + const viewCount = 40; + const views = Array.from({ length: viewCount }, (_, index) => ({ + $class: { UID: 1 }, + NSWindow: { UID: 2 }, + NSSuperview: { UID: index === 0 ? 2 : 3 + index - 1 }, + NSNextKeyView: { UID: 3 + ((index + 1) % viewCount) }, + NSSubviews: index + 1 < viewCount ? [{ UID: 3 + index + 1 }] : [], + NSTitle: { UID: 3 + viewCount }, + })); + const parsed = parseInterfaceBuilderRecords({ + $archiver: "NSKeyedArchiver", + $objects: [ + "$null", + { $classname: "NSView", $classes: ["NSView", "NSObject"] }, + { $class: { UID: 3 + viewCount + 1 }, NSContentView: { UID: 3 } }, + ...views, + "Title", + { $classname: "NSWindow", $classes: ["NSWindow", "NSObject"] }, + ], + $top: { root: { UID: 2 } }, + }); + + expect(parsed.objects).toHaveLength(viewCount + 1); + const view = parsed.objects.find(({ id }) => id === "3"); + expect(view?.name).toBe("NSView"); + expect(view?.attributes).toEqual({ + NSWindow: { archiveUID: 2, className: "NSWindow" }, + NSSuperview: { archiveUID: 2, className: "NSWindow" }, + NSNextKeyView: { archiveUID: 4, className: "NSView" }, + NSSubviews: [{ archiveUID: 4, className: "NSView" }], + NSTitle: "Title", + }); + expect(parsed.hierarchy).toMatchObject([ + { + objectID: "2", + children: [{ objectID: "3", children: [{ objectID: "4" }] }], + }, + ]); + }); + + it("resolves XML CF$UID references and the archived nil", () => { + const parsed = parseInterfaceBuilderRecords({ + $archiver: "NSKeyedArchiver", + $objects: [ + "$null", + { + $class: { CF$UID: 3 }, + NSSource: { CF$UID: 2 }, + source: { CF$UID: 2 }, + destination: { CF$UID: 0 }, + label: { CF$UID: 4 }, + }, + { $class: { CF$UID: 5 } }, + { + $classname: "NSNibOutletConnector", + $classes: ["NSNibOutletConnector", "NSObject"], + }, + { $class: { CF$UID: 6 }, "NS.string": "delegate" }, + { $classname: "NSButton", $classes: ["NSButton", "NSObject"] }, + { + $classname: "NSMutableString", + $classes: ["NSMutableString", "NSString", "NSObject"], + }, + ], + $top: {}, + }); + + expect(parsed.connections).toEqual([ + expect.objectContaining({ + kind: "outlet", + source_id: "2", + destination_id: null, + label: "delegate", + }), + ]); + expect(parsed.objects.find(({ id }) => id === "2")).toMatchObject({ + class_name: "NSButton", + }); + }); +}); diff --git a/src/domain/interfaceBuilderKeyedArchive.ts b/src/domain/interfaceBuilderKeyedArchive.ts index 24378a75cb..e08f0faf20 100644 --- a/src/domain/interfaceBuilderKeyedArchive.ts +++ b/src/domain/interfaceBuilderKeyedArchive.ts @@ -117,62 +117,130 @@ export const parseInterfaceBuilderRecords = ( }; }; +/** + * A UID decodes as a one-key `UID` (binary) or `CF$UID` (XML) dictionary with + * an integer value. Any other dictionary is ordinary archive data. + */ +const archiveUid = (value: unknown): number | undefined => { + if (typeof value !== "object" || value === null || Array.isArray(value)) + return undefined; + if (Object.keys(value).length !== 1) return undefined; + const item = record(value); + const marker = Object.hasOwn(item, "UID") ? item.UID : item["CF$UID"]; + return typeof marker === "number" && Number.isInteger(marker) + ? marker + : undefined; +}; + +const HIERARCHY_KEY = + /(?:subviews|contentview|childviewcontrollers|children|views)$/iu; + +/** + * Read an NSKeyedArchiver object table without expanding it into a tree. + * Archived objects reference each other freely, including cycles such as a + * view and its superview, so inlining every reference grows exponentially. + * A reference instead resolves to the referenced string, or to a stub that + * names the referenced object; the hierarchy walk visits each object once. + */ +const keyedArchiveTable = (objectTable: readonly unknown[]) => { + const classNameOf = (value: unknown): string | null => { + const classReference = record(value).$class; + const uid = archiveUid(classReference); + return firstString( + record(uid === undefined ? classReference : objectTable[uid]).$classname, + ); + }; + /** The string an archived string object holds, if it is one. */ + const archivedString = (target: unknown): string | undefined => { + if (typeof target === "string") return target; + const text = record(target)["NS.string"]; + return typeof text === "string" && + /^NS(?:Mutable)?String$/u.test(classNameOf(target) ?? "") + ? text + : undefined; + }; + const text = (value: unknown): string | null => { + const uid = archiveUid(value); + return firstString( + uid === undefined ? value : archivedString(objectTable[uid]), + ); + }; + const authoredId = (item: Record): string | null => + firstString(text(item.objectID), text(item["object-id"])); + const reference = (uid: number): unknown => { + // UID 0 is the archived nil. + const target = uid === 0 ? null : objectTable[uid]; + if (target === undefined || target === null) return null; + if (typeof target !== "object") return target; + const string = archivedString(target); + if (string !== undefined) return string; + const objectId = authoredId(record(target)); + return { + archiveUID: uid, + ...(objectId === null ? {} : { objectID: objectId }), + className: classNameOf(target), + }; + }; + /** Resolve one object's own fields, leaving references to other objects as stubs. */ + const resolve = (value: unknown): unknown => { + const uid = archiveUid(value); + if (uid !== undefined) return reference(uid); + if (Array.isArray(value)) return value.map(resolve); + if (typeof value !== "object" || value === null) return value; + const output: Record = {}; + for (const [key, child] of Object.entries(value)) { + if (key === "$class") output.className = classNameOf(value); + else output[key] = resolve(child); + } + return output; + }; + const hierarchy = (start: unknown): unknown[] => { + const visited = new Set(); + const node = ( + item: Record, + uid: number | undefined, + depth: number, + ): unknown[] => { + const objectId = firstString( + authoredId(item), + text(item.id), + uid === undefined ? null : String(uid), + ); + const children = Object.entries(item) + .filter(([key]) => HIERARCHY_KEY.test(key)) + .flatMap(([, child]) => visit(child, depth + 1)); + return objectId === null ? children : [{ objectID: objectId, children }]; + }; + const visit = (value: unknown, depth: number): unknown[] => { + if (depth > 32) return []; + const uid = archiveUid(value); + if (uid !== undefined) { + if (visited.has(uid)) return []; + visited.add(uid); + const target = objectTable[uid]; + if (Array.isArray(target)) return visit(target, depth + 1); + return typeof target === "object" && target !== null + ? node(record(target), uid, depth) + : []; + } + if (Array.isArray(value)) + return value.flatMap((item) => visit(item, depth + 1)); + if (typeof value !== "object" || value === null) return []; + return node(record(value), undefined, depth); + }; + return visit(start, 0); + }; + return { resolve, hierarchy }; +}; + /** Project class and connection objects from an NSKeyedArchiver object table. */ const parseKeyedArchive = ( root: Record, ): ReturnType | null => { if (root.$archiver !== "NSKeyedArchiver" || !Array.isArray(root.$objects)) return null; - const objectTable = root.$objects; - const resolve = (value: unknown, depth = 0): unknown => { - if (depth > 16) return null; - const uid = record(value).UID; - if (typeof uid === "number") { - const target = objectTable[uid]; - const resolved = target === undefined ? null : resolve(target, depth + 1); - return typeof resolved === "object" && - resolved !== null && - !Array.isArray(resolved) - ? { ...resolved, archiveUID: uid } - : resolved; - } - if (Array.isArray(value)) - return value.map((item) => resolve(item, depth + 1)); - if (typeof value !== "object" || value === null) return value; - const output: Record = {}; - for (const [key, child] of Object.entries(value)) { - if (key === "$class") { - const classObject = record( - typeof record(child).UID === "number" - ? objectTable[record(child).UID as number] - : child, - ); - output.className = firstString(classObject.$classname); - } else output[key] = resolve(child, depth + 1); - } - return output; - }; - const toHierarchy = (value: unknown, depth = 0): unknown[] => { - if (depth > 32) return []; - if (Array.isArray(value)) - return value.flatMap((item) => toHierarchy(item, depth + 1)); - if (typeof value !== "object" || value === null) return []; - const item = record(value); - const objectId = firstString( - item.objectID, - item["object-id"], - item.id, - typeof item.archiveUID === "number" ? String(item.archiveUID) : null, - ); - const children = Object.entries(item) - .filter(([key]) => - /(?:subviews|contentview|childviewcontrollers|children|views)$/iu.test( - key, - ), - ) - .flatMap(([, child]) => toHierarchy(child, depth + 1)); - return objectId === null ? children : [{ objectID: objectId, children }]; - }; + const objectTable: readonly unknown[] = root.$objects; + const { resolve, hierarchy: toHierarchy } = keyedArchiveTable(objectTable); const objects: InterfaceBuilderObject[] = []; const connections: InterfaceBuilderConnection[] = []; let objectCount = 0; @@ -245,8 +313,7 @@ const parseKeyedArchive = ( } } const top = record(root.$top); - const topObject = resolve(top.root ?? top.UITopLevelObjectsKey); - const hierarchy = toHierarchy(topObject); + const hierarchy = toHierarchy(top.root ?? top.UITopLevelObjectsKey); const classes = Object.fromEntries( objectTable.flatMap((raw) => { const item = record(raw); diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 77c8ef7aa6..a6f9e4fc84 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -71,6 +71,48 @@ describe("compiled Interface Builder bundle reader", () => { ); }); + it("decodes flat nib archives that hold data and date values", async () => { + const root = await createTestTempDirectory("rea-ib-test-"); + const bundle = join(root, "Example.app"); + const resources = join(bundle, "Contents", "Resources", "English.lproj"); + await mkdir(resources, { recursive: true }); + await writeFile( + join(resources, "Binary.nib"), + buildBinary({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: [ + "$null", + { $class: { UID: 2 }, title: "Build", NSColor: { UID: 3 } }, + { $classname: "NSButton", $classes: ["NSButton", "NSObject"] }, + { $class: { UID: 4 }, NSWhite: Buffer.from("0.5\0", "ascii") }, + { $classname: "NSColor", $classes: ["NSColor", "NSObject"] }, + ], + $top: { root: { UID: 1 } }, + }), + ); + await writeFile( + join(resources, "Xml.nib"), + '$archiverNSKeyedArchiver$objects$nullNSWhiteMC41AA==NSDate2026-10-07T00:00:00Z$top', + ); + + const analysis = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "d".repeat(64), + }); + + expect( + analysis.documents.map(({ relative_path }) => relative_path).sort(), + ).toEqual([ + "Contents/Resources/English.lproj/Binary.nib", + "Contents/Resources/English.lproj/Xml.nib", + ]); + expect(analysis.graph.coverage).toContainEqual( + expect.objectContaining({ facet: "archive_decode", status: "complete" }), + ); + expect(analysis.graph.nodes.map(({ name }) => name)).toContain("Build"); + }); + it("honors cancellation during directory traversal", async () => { const root = await createTestTempDirectory("rea-ib-test-"); const controller = new AbortController(); From b08228d19f8a0f6dbc940e8d322ef2bb42d2ce1a Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:39:27 +0000 Subject: [PATCH 2/8] fix(artifacts): project compiled AppKit actions from the control to its target NSNibControlConnector archives the sending control as NSSource and the action target as NSDestination; a nil target is the first responder. The NIBArchive projection swapped them for control connectors, so every compiled AppKit action ran from its target to the control. The action trace, which follows control -> action, could not find them, and the selector node was keyed by the control instead of the target class. When the target was nil, the swapped source was null and the action was dropped: Chess lost 29 menu actions, KeepingYouAwake 7. Connectors now keep NSSource -> NSDestination. The NIBArchive test encoder moves to a fixture module so a bundle test can build compiled NIBs. Co-Authored-By: Claude Opus 5.5 --- src/application/InterfaceBuilderAnalysis.ts | 14 ++- src/artifacts/NibArchive.fixture.ts | 94 ++++++++++++++++++ src/artifacts/NibArchive.test.ts | 95 +------------------ .../filesystem/interfaceBuilderBundle.test.ts | 79 +++++++++++++++ 4 files changed, 180 insertions(+), 102 deletions(-) create mode 100644 src/artifacts/NibArchive.fixture.ts diff --git a/src/application/InterfaceBuilderAnalysis.ts b/src/application/InterfaceBuilderAnalysis.ts index 697a1fd8fb..3bf8691654 100644 --- a/src/application/InterfaceBuilderAnalysis.ts +++ b/src/application/InterfaceBuilderAnalysis.ts @@ -255,16 +255,14 @@ const projectNibArchive = (archive: NibArchiveDocument) => { : className.includes("Control") ? "action" : className; - const controlAction = className.includes("Control"); - const connectionSource = controlAction ? destination : source; - const connectionDestination = controlAction ? source : destination; - if (connectionSource === null) continue; - (connections[String(connectionSource)] ??= []).push({ + // NSSource is the outlet owner or the sending control; NSDestination is + // the outlet value or the action target, and a nil target is the first + // responder. + (connections[String(source)] ??= []).push({ type, - "destination-id": - connectionDestination === null ? null : String(connectionDestination), + "destination-id": destination === null ? null : String(destination), label, - source_id: String(connectionSource), + source_id: String(source), archive_object_id: String(object.id), }); } diff --git a/src/artifacts/NibArchive.fixture.ts b/src/artifacts/NibArchive.fixture.ts new file mode 100644 index 0000000000..09f2d6e2c5 --- /dev/null +++ b/src/artifacts/NibArchive.fixture.ts @@ -0,0 +1,94 @@ +/** Encode a minimal NIBArchive object table for decoder and projection tests. */ +export const encodeNibArchiveFixture = (input: { + readonly classes: readonly string[]; + readonly objects: readonly { + readonly classIndex: number; + readonly values: Readonly< + Record + >; + }[]; +}): Buffer => { + const keys = [ + ...new Set(input.objects.flatMap(({ values }) => Object.keys(values))), + ]; + const keyIndex = new Map(keys.map((key, index) => [key, index])); + const valueRecords = input.objects.flatMap(({ values }) => + Object.entries(values).map(([key, value]) => + Buffer.concat([varint(keyIndex.get(key) ?? 0), encodeValue(value)]), + ), + ); + let valueStart = 0; + const records = input.objects.map(({ classIndex, values }) => { + const count = Object.keys(values).length; + const result = Buffer.concat([ + varint(classIndex), + varint(valueStart), + varint(count), + ]); + valueStart += count; + return result; + }); + const objects = Buffer.concat(records); + const encodedKeys = Buffer.concat( + keys.map((key) => + Buffer.concat([varint(Buffer.byteLength(key)), Buffer.from(key)]), + ), + ); + const values = Buffer.concat(valueRecords); + const classes = Buffer.concat( + input.classes.map((name) => { + const bytes = Buffer.from(name); + return Buffer.concat([varint(bytes.length), varint(0), bytes]); + }), + ); + const objectsOffset = 50; + const keysOffset = objectsOffset + objects.length; + const valuesOffset = keysOffset + encodedKeys.length; + const classesOffset = valuesOffset + values.length; + const header = Buffer.alloc(50); + header.write("NIBArchive", 0, "ascii"); + header.writeUInt32LE(1, 10); + header.writeUInt32LE(10, 14); + header.writeUInt32LE(input.objects.length, 18); + header.writeUInt32LE(objectsOffset, 22); + header.writeUInt32LE(keys.length, 26); + header.writeUInt32LE(keysOffset, 30); + header.writeUInt32LE(valueStart, 34); + header.writeUInt32LE(valuesOffset, 38); + header.writeUInt32LE(input.classes.length, 42); + header.writeUInt32LE(classesOffset, 46); + return Buffer.concat([header, objects, encodedKeys, values, classes]); +}; + +const varint = (value: number): Buffer => { + const bytes: number[] = []; + let remaining = value; + do { + let byte = remaining & 0x7f; + remaining >>>= 7; + if (remaining === 0) byte |= 0x80; + bytes.push(byte); + } while (remaining > 0); + return Buffer.from(bytes); +}; + +const encodeValue = ( + value: number | string | boolean | null | { ref: number }, +): Buffer => { + if (typeof value === "boolean") return Buffer.from([value ? 4 : 5]); + if (value === null) return Buffer.from([9]); + if (typeof value === "number") { + const bytes = Buffer.alloc(5); + bytes[0] = 2; + bytes.writeInt32LE(value, 1); + return bytes; + } + if (typeof value === "object") { + const bytes = Buffer.alloc(5); + bytes[0] = 10; + bytes.writeUInt32LE(value.ref, 1); + return bytes; + } + const data = Buffer.from(value); + return Buffer.concat([Buffer.from([8]), varint(data.length), data]); +}; diff --git a/src/artifacts/NibArchive.test.ts b/src/artifacts/NibArchive.test.ts index f9b4fac922..a771217853 100644 --- a/src/artifacts/NibArchive.test.ts +++ b/src/artifacts/NibArchive.test.ts @@ -1,100 +1,7 @@ import { describe, expect, it } from "vitest"; import { decodeNibArchive } from "./NibArchive.js"; - -const encodeArchive = (input: { - readonly classes: readonly string[]; - readonly objects: readonly { - readonly classIndex: number; - readonly values: Readonly< - Record - >; - }[]; -}): Buffer => { - const keys = [ - ...new Set(input.objects.flatMap(({ values }) => Object.keys(values))), - ]; - const keyIndex = new Map(keys.map((key, index) => [key, index])); - const valueRecords = input.objects.flatMap(({ values }) => - Object.entries(values).map(([key, value]) => - Buffer.concat([varint(keyIndex.get(key) ?? 0), encodeValue(value)]), - ), - ); - let valueStart = 0; - const records = input.objects.map(({ classIndex, values }) => { - const count = Object.keys(values).length; - const result = Buffer.concat([ - varint(classIndex), - varint(valueStart), - varint(count), - ]); - valueStart += count; - return result; - }); - const objects = Buffer.concat(records); - const encodedKeys = Buffer.concat( - keys.map((key) => - Buffer.concat([varint(Buffer.byteLength(key)), Buffer.from(key)]), - ), - ); - const values = Buffer.concat(valueRecords); - const classes = Buffer.concat( - input.classes.map((name) => { - const bytes = Buffer.from(name); - return Buffer.concat([varint(bytes.length), varint(0), bytes]); - }), - ); - const objectsOffset = 50; - const keysOffset = objectsOffset + objects.length; - const valuesOffset = keysOffset + encodedKeys.length; - const classesOffset = valuesOffset + values.length; - const header = Buffer.alloc(50); - header.write("NIBArchive", 0, "ascii"); - header.writeUInt32LE(1, 10); - header.writeUInt32LE(10, 14); - header.writeUInt32LE(input.objects.length, 18); - header.writeUInt32LE(objectsOffset, 22); - header.writeUInt32LE(keys.length, 26); - header.writeUInt32LE(keysOffset, 30); - header.writeUInt32LE(valueStart, 34); - header.writeUInt32LE(valuesOffset, 38); - header.writeUInt32LE(input.classes.length, 42); - header.writeUInt32LE(classesOffset, 46); - return Buffer.concat([header, objects, encodedKeys, values, classes]); -}; - -const varint = (value: number): Buffer => { - const bytes: number[] = []; - let remaining = value; - do { - let byte = remaining & 0x7f; - remaining >>>= 7; - if (remaining === 0) byte |= 0x80; - bytes.push(byte); - } while (remaining > 0); - return Buffer.from(bytes); -}; - -const encodeValue = ( - value: number | string | boolean | null | { ref: number }, -): Buffer => { - if (typeof value === "boolean") return Buffer.from([value ? 4 : 5]); - if (value === null) return Buffer.from([9]); - if (typeof value === "number") { - const bytes = Buffer.alloc(5); - bytes[0] = 2; - bytes.writeInt32LE(value, 1); - return bytes; - } - if (typeof value === "object") { - const bytes = Buffer.alloc(5); - bytes[0] = 10; - bytes.writeUInt32LE(value.ref, 1); - return bytes; - } - const data = Buffer.from(value); - return Buffer.concat([Buffer.from([8]), varint(data.length), data]); -}; +import { encodeNibArchiveFixture as encodeArchive } from "./NibArchive.fixture.js"; describe("NIBArchive decoder", () => { it("decodes bounded object, key, class, and reference tables", () => { diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 77c8ef7aa6..fecd242a05 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -8,6 +8,7 @@ import { buildBinary } from "plist"; import { describe, expect, it } from "vitest"; import { analyzeInterfaceBuilderBundle } from "../../../src/application/InterfaceBuilderAnalysis.js"; +import { encodeNibArchiveFixture } from "../../../src/artifacts/NibArchive.fixture.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const compile = promisify(execFile); @@ -71,6 +72,84 @@ describe("compiled Interface Builder bundle reader", () => { ); }); + it("projects compiled AppKit actions from the control to their target", async () => { + const root = await createTestTempDirectory("rea-ib-test-"); + const bundle = join(root, "Example.app"); + const resources = join(bundle, "Contents", "Resources"); + await mkdir(resources, { recursive: true }); + // NSNibControlConnector archives the sending control as NSSource and its + // target as NSDestination; a nil target is the first responder. + await writeFile( + join(resources, "Panel.nib"), + encodeNibArchiveFixture({ + classes: [ + "NSNibExternalObjectPlaceholder", + "NSButton", + "NSNibControlConnector", + "NSString", + "NSMenuItem", + ], + objects: [ + { classIndex: 0, values: {} }, + { classIndex: 1, values: {} }, + { + classIndex: 2, + values: { + NSSource: { ref: 1 }, + NSDestination: { ref: 0 }, + NSLabel: { ref: 3 }, + }, + }, + { classIndex: 3, values: { "NS.bytes": "doOK:" } }, + { classIndex: 4, values: {} }, + { + classIndex: 2, + values: { + NSSource: { ref: 4 }, + NSDestination: null, + NSLabel: { ref: 6 }, + }, + }, + { classIndex: 3, values: { "NS.bytes": "arrangeInFront:" } }, + ], + }), + ); + + const analysis = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "e".repeat(64), + }); + const byId = new Map(analysis.graph.nodes.map((node) => [node.id, node])); + const routes = analysis.graph.nodes + .filter(({ kind }) => kind === "action") + .map((action) => ({ + action: action.name, + from: analysis.graph.edges + .filter( + ({ relation, to }) => + relation === "target_action" && to === action.id, + ) + .map(({ from }) => byId.get(from)?.name), + to: analysis.graph.edges + .filter( + ({ relation, from, to }) => + relation === "target_action" && + from === action.id && + (to === null || byId.get(to)?.kind !== "objc_selector"), + ) + .map(({ to }) => (to === null ? null : byId.get(to)?.name)), + })) + .sort((left, right) => left.action.localeCompare(right.action)); + expect(routes).toEqual([ + { action: "arrangeInFront:", from: ["NSMenuItem"], to: [null] }, + { + action: "doOK:", + from: ["NSButton"], + to: ["NSNibExternalObjectPlaceholder"], + }, + ]); + }); + it("honors cancellation during directory traversal", async () => { const root = await createTestTempDirectory("rea-ib-test-"); const controller = new AbortController(); From c60b737f7db8e2c9a1982cdb4357fcda1f47ff08 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:46:57 +0000 Subject: [PATCH 3/8] test(artifacts): report observed action routes when a compiled route is missing Co-Authored-By: Claude Opus 5.5 --- .../filesystem/interfaceBuilderBundle.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index fecd242a05..1010b87ca6 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -234,7 +234,18 @@ describe("bounded Interface Builder archive decoding", () => { analysis.graph.nodes.find(({ id }) => id === from)?.kind === "control", ); - expect(actionSource).toBeDefined(); + const describeNode = (id: string | null) => { + const node = analysis.graph.nodes.find((item) => item.id === id); + return node === undefined ? String(id) : `${node.kind}:${node.name}`; + }; + const actionRoutes = analysis.graph.edges + .filter( + ({ relation, from, to }) => + relation === "target_action" && + (from === action?.id || to === action?.id), + ) + .map(({ from, to }) => `${describeNode(from)} -> ${describeNode(to)}`); + expect(actionSource, actionRoutes.join("; ")).toBeDefined(); expect( analysis.graph.edges.some( ({ from, relation, to }) => From aeebba391ee5c288b75cd828e7d18d32b40aa698 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:59:28 +0000 Subject: [PATCH 4/8] feat(native): open iOS-style and iOS-on-Mac wrapper app bundles Every native command rejected an iOS app installed on an Apple silicon Mac, such as /Applications/Stash.app, with "app has no readable CFBundleExecutable": the resolver understood only the macOS layout, Contents/Info.plist with Contents/MacOS/. iOS-style bundles, including those extracted from an IPA, keep Info.plist and the program file at the bundle root, and an iOS-on-Mac wrapper holds that bundle in Wrapper/.app. Bundle resolution moves to AppBundleExecutable and accepts the macOS layout, then the flat layout, then a wrapper's single real Wrapper/*.app directory. The WrappedBundle symlink is never followed, and the program file must stay inside its bundle. inspect_plist defaults to the Info.plist beside the program file when the bundle has no Contents/Info.plist. Co-Authored-By: Claude Opus 5.5 --- src/application/AppBundleExecutable.ts | 168 ++++++++++++++++++ src/application/BinaryTargetResolver.ts | 83 +-------- src/native/NativeMacOSProvider.ts | 21 ++- .../boundary/filesystem/binaryTarget.test.ts | 56 ++++++ .../providers/native/nativeProvider.test.ts | 20 +++ 5 files changed, 266 insertions(+), 82 deletions(-) create mode 100644 src/application/AppBundleExecutable.ts diff --git a/src/application/AppBundleExecutable.ts b/src/application/AppBundleExecutable.ts new file mode 100644 index 0000000000..36e4452f64 --- /dev/null +++ b/src/application/AppBundleExecutable.ts @@ -0,0 +1,168 @@ +import { execFile } from "node:child_process"; +import { lstat, readdir, readFile, realpath } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { z } from "zod"; + +import { BinaryTargetError } from "../domain/configurationErrors.js"; +import { isPathWithinRoot } from "../domain/localPath.js"; +import { parseXmlPropertyList } from "../domain/propertyListKeys.js"; +import { err, ok, type Result } from "../domain/result.js"; + +const execFileAsync = promisify(execFile); + +/** Where one app bundle layout keeps its Info.plist and program file. */ +interface BundleLayout { + readonly plist: readonly string[]; + readonly programs: readonly string[]; + readonly programsLabel: string; +} + +const BUNDLE_LAYOUTS: readonly BundleLayout[] = [ + // macOS bundles keep both under Contents. + { + plist: ["Contents", "Info.plist"], + programs: ["Contents", "MacOS"], + programsLabel: "Contents/MacOS", + }, + // iOS-style bundles, as extracted from an IPA, keep both at the root. + { plist: ["Info.plist"], programs: [], programsLabel: "the bundle root" }, +]; + +/** + * Resolve an app bundle directory to its declared program file. macOS and + * flat iOS-style layouts are read directly. An iOS app installed on a Mac is + * a wrapper whose `Wrapper` directory holds the iOS bundle; that bundle is + * found by listing `Wrapper`, never by following the `WrappedBundle` link. + */ +export const resolveAppBundleExecutable = async ( + path: string, +): Promise> => { + const layout = await presentLayout(path); + if (layout !== undefined) return resolveLayoutExecutable(path, layout); + const wrapped = await wrappedBundle(path); + if (wrapped !== undefined) { + const wrappedLayout = await presentLayout(wrapped); + if (wrappedLayout !== undefined) + return resolveLayoutExecutable(wrapped, wrappedLayout); + } + return err( + new BinaryTargetError(path, "app has no readable CFBundleExecutable", { + cause: new Error( + "No Contents/Info.plist, root Info.plist, or single Wrapper/*.app bundle was found", + ), + }), + ); +}; + +const presentLayout = async ( + bundle: string, +): Promise => { + for (const layout of BUNDLE_LAYOUTS) + if (await isRegularFile(join(bundle, ...layout.plist))) return layout; + return undefined; +}; + +const isRegularFile = async (path: string): Promise => { + try { + return (await lstat(path)).isFile(); + } catch { + return false; + } +}; + +/** The single real `.app` directory inside an iOS-on-Mac `Wrapper`. */ +const wrappedBundle = async (bundle: string): Promise => { + const wrapper = join(bundle, "Wrapper"); + try { + if (!(await lstat(wrapper)).isDirectory()) return undefined; + const apps = (await readdir(wrapper, { withFileTypes: true })).filter( + (entry) => + entry.isDirectory() && entry.name.toLowerCase().endsWith(".app"), + ); + const [app] = apps; + return apps.length === 1 && app !== undefined + ? join(wrapper, app.name) + : undefined; + } catch { + return undefined; + } +}; + +const resolveLayoutExecutable = async ( + bundle: string, + layout: BundleLayout, +): Promise> => { + const plistPath = join(bundle, ...layout.plist); + let name: string; + try { + const plist = await readFile(plistPath); + name = + plist.subarray(0, 6).toString("ascii") === "bplist" + ? await readBinaryPlistExecutable(plistPath) + : parseXmlPlistExecutable(plist.toString("utf8")); + } catch (cause: unknown) { + return err( + new BinaryTargetError(bundle, "app has no readable CFBundleExecutable", { + cause, + }), + ); + } + if (!isSafeExecutableName(name)) + return err( + new BinaryTargetError(bundle, "app has an unsafe CFBundleExecutable"), + ); + const programs = join(bundle, ...layout.programs); + const executable = join(programs, name); + try { + const [canonicalPrograms, canonicalExecutable] = await Promise.all([ + realpath(programs), + realpath(executable), + ]); + if (!isPathWithinRoot(canonicalPrograms, canonicalExecutable)) + return err( + new BinaryTargetError( + bundle, + `app program file leaves ${layout.programsLabel}`, + ), + ); + return ok(canonicalExecutable); + } catch (cause: unknown) { + return err( + new BinaryTargetError(bundle, "app program file is missing", { cause }), + ); + } +}; + +/** Decode the top-level executable name with an XML parser, not a pattern. */ +const parseXmlPlistExecutable = (plist: string): string => { + // An unrelated `__proto__` entry must not make the bundle unreadable. + const { value } = parseXmlPropertyList(plist); + const executable = executableEntrySchema.safeParse(value); + if (!executable.success) throw new Error("CFBundleExecutable is missing"); + return executable.data.CFBundleExecutable; +}; + +const executableEntrySchema = z.looseObject({ CFBundleExecutable: z.string() }); + +const readBinaryPlistExecutable = async (plistPath: string): Promise => + // `-n` strips only the newline plutil appends; it requires macOS 12+. + ( + await execFileAsync("/usr/bin/plutil", [ + "-extract", + "CFBundleExecutable", + "raw", + "-n", + "-o", + "-", + plistPath, + ]) + ).stdout; + +const isSafeExecutableName = (name: string): boolean => + name.length > 0 && + name !== "." && + name !== ".." && + !name.includes("\0") && + !/[/\\]/u.test(name); diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 1b2fb8fdcf..564a867afa 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -3,22 +3,16 @@ import { createHash } from "node:crypto"; import { access, open, - readFile, realpath, stat, type FileHandle, } from "node:fs/promises"; -import { execFile } from "node:child_process"; -import { extname, isAbsolute, join, resolve } from "node:path"; -import { promisify } from "node:util"; +import { extname, isAbsolute, resolve } from "node:path"; -import { z } from "zod"; - -import { isPathWithinRoot } from "../domain/localPath.js"; -import { parseXmlPropertyList } from "../domain/propertyListKeys.js"; import { BinaryTargetError } from "../domain/configurationErrors.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { err, ok, type Result } from "../domain/result.js"; +import { resolveAppBundleExecutable } from "./AppBundleExecutable.js"; import { validateDosComLength, type ExecutableFormatHint, @@ -35,8 +29,6 @@ import { type ExecutableMetadata, } from "../domain/binaryTarget.js"; -const execFileAsync = promisify(execFile); - /** * Resolve and classify a readable local target before a provider is selected. * FAT Mach-O inputs select only a host-compatible architecture so setup remains @@ -223,82 +215,15 @@ const resolveAppBundle = async ( return err( new BinaryTargetError( path, - "this opening route requires a file or macOS app bundle", + "this opening route requires a file or app bundle", { constraint: "directory_requires_file", }, ), ); - const plistPath = join(path, "Contents", "Info.plist"); - let name: string; - try { - const plist = await readFile(plistPath); - name = - plist.subarray(0, 6).toString("ascii") === "bplist" - ? await readBinaryPlistExecutable(plistPath) - : parseXmlPlistExecutable(plist.toString("utf8")); - } catch (cause: unknown) { - return err( - new BinaryTargetError(path, "app has no readable CFBundleExecutable", { - cause, - }), - ); - } - if (!isSafeExecutableName(name)) - return err( - new BinaryTargetError(path, "app has an unsafe CFBundleExecutable"), - ); - const programs = join(path, "Contents", "MacOS"); - const executable = join(programs, name); - try { - const [canonicalPrograms, canonicalExecutable] = await Promise.all([ - realpath(programs), - realpath(executable), - ]); - if (!isPathWithinRoot(canonicalPrograms, canonicalExecutable)) - return err( - new BinaryTargetError(path, "app program file leaves Contents/MacOS"), - ); - return ok(canonicalExecutable); - } catch (cause: unknown) { - return err( - new BinaryTargetError(path, "app program file is missing", { cause }), - ); - } + return resolveAppBundleExecutable(path); }; -/** Decode the top-level executable name with an XML parser, not a pattern. */ -const parseXmlPlistExecutable = (plist: string): string => { - // An unrelated `__proto__` entry must not make the bundle unreadable. - const { value } = parseXmlPropertyList(plist); - const executable = executableEntrySchema.safeParse(value); - if (!executable.success) throw new Error("CFBundleExecutable is missing"); - return executable.data.CFBundleExecutable; -}; - -const executableEntrySchema = z.looseObject({ CFBundleExecutable: z.string() }); - -const readBinaryPlistExecutable = async (plistPath: string): Promise => - // `-n` strips only the newline plutil appends; it requires macOS 12+. - ( - await execFileAsync("/usr/bin/plutil", [ - "-extract", - "CFBundleExecutable", - "raw", - "-n", - "-o", - "-", - plistPath, - ]) - ).stdout; - -const isSafeExecutableName = (name: string): boolean => - name.length > 0 && - name !== "." && - name !== ".." && - !name.includes("\0") && - !/[/\\]/u.test(name); - const readExecutableMetadata = async ( handle: FileHandle, hostArchitecture: NodeJS.Architecture, diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 78d0b2bb79..584b511f75 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -713,6 +713,14 @@ const requireRegularPlist = async ( } }; +const isFile = async (path: string): Promise => { + try { + return (await stat(path)).isFile(); + } catch { + return false; + } +}; + const resolvePlistPath = async ( target: BinaryTarget, requested: string | undefined, @@ -723,10 +731,17 @@ const resolvePlistPath = async ( return ok(resolve(requested)); const sourceMetadata = await stat(source); const root = sourceMetadata.isDirectory() ? source : dirname(target.path); + if (requested !== undefined) return ok(resolve(root, requested)); + const macOS = resolve(root, "Contents/Info.plist"); + // iOS-style bundles, including the one inside an iOS-on-Mac wrapper, keep + // Info.plist beside the program file the target resolved to. + const beside = resolve(dirname(target.path), "Info.plist"); return ok( - requested === undefined - ? resolve(root, "Contents/Info.plist") - : resolve(root, requested), + sourceMetadata.isDirectory() && + !(await isFile(macOS)) && + (await isFile(beside)) + ? beside + : macOS, ); } catch (cause: unknown) { return err( diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index 81f9126df9..7096fcdce4 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -293,6 +293,62 @@ describe("app executable filename fidelity", () => { ); }); +describe("iOS-style app bundle targets", () => { + const plist = (name: string) => + `CFBundleExecutable${name}`; + + it("resolves a flat bundle's program file beside its Info.plist", async () => { + const directory = await createTestTempDirectory("rea-target-"); + const app = join(directory, "Flat.app"); + await mkdir(app); + await writeFile(join(app, "Info.plist"), plist("Flat")); + await writeFile(join(app, "Flat"), thinMach(0xfeedfacf, 0x0100000c)); + const result = await parseBinaryTarget(app, directory, "arm64"); + expect(result.ok && result.value).toMatchObject({ + path: await realpath(join(app, "Flat")), + format: "mach-o", + }); + }); + + it.skipIf(process.platform === "win32")( + "resolves an iOS-on-Mac wrapper through its single Wrapper bundle", + async () => { + const directory = await createTestTempDirectory("rea-target-"); + const app = join(directory, "Phone.app"); + const wrapped = join(app, "Wrapper", "Phone.app"); + await mkdir(wrapped, { recursive: true }); + await writeFile(join(wrapped, "Info.plist"), plist("Phone")); + await writeFile(join(wrapped, "Phone"), thinMach(0xfeedfacf, 0x0100000c)); + await symlink("Wrapper/Phone.app", join(app, "WrappedBundle")); + const result = await parseBinaryTarget(app, directory, "arm64"); + expect(result.ok && result.value).toMatchObject({ + path: await realpath(join(wrapped, "Phone")), + format: "mach-o", + }); + + await mkdir(join(app, "Wrapper", "Second.app")); + expect((await parseBinaryTarget(app, directory, "arm64")).ok).toBe(false); + }, + ); + + it.skipIf(process.platform === "win32")( + "rejects a flat bundle program symlink that leaves the bundle", + async () => { + const directory = await createTestTempDirectory("rea-target-"); + const app = join(directory, "Escaping.app"); + const outside = join(directory, "outside"); + await mkdir(app); + await writeFile(outside, thinMach(0xfeedfacf, 0x0100000c)); + await writeFile(join(app, "Info.plist"), plist("Escaping")); + await symlink(outside, join(app, "Escaping")); + const result = await parseBinaryTarget(app, directory, "arm64"); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected an escaping program file"); + expect(result.error.message).toContain("leaves the bundle root"); + }, + ); +}); + describe("DOS binary target I/O", () => { it("validates a complete DOS file beyond the initial metadata probe", async () => { const directory = await createTestTempDirectory("rea-dos-target-"); diff --git a/tests/boundary/providers/native/nativeProvider.test.ts b/tests/boundary/providers/native/nativeProvider.test.ts index 53027135a1..ed86df0e09 100644 --- a/tests/boundary/providers/native/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -278,6 +278,26 @@ describe("native macOS provider inspection", () => { }); }); +describe("native plist defaults for iOS-style bundles", () => { + it("defaults to the Info.plist beside a flat bundle's program file", async () => { + directory = await createTestTempDirectory("rea-native-flat-"); + const app = join(directory, "Flat.app"); + const executable = join(app, "Flat"); + await mkdir(app, { recursive: true }); + await writeFile(executable, "fixture"); + await writeFile(join(app, "Info.plist"), "fixture"); + const client = new NativeMacOSProvider( + new FixtureRunner(), + "darwin", + ).createClient(machoTarget(executable, app)); + + const plist = await client.execute("inspect_plist", {}); + expect(plist.ok && plist.value.result).toMatchObject({ + source_path: join(app, "Info.plist"), + }); + }); +}); + describe("native dispatch metadata error results", () => { it("preserves tagged cancellation and integrity failures", async () => { directory = await createTestTempDirectory("rea-dispatch-errors-"); From d421d3f286ce515b5126859a0f9cac60667a168e Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:00:41 +0000 Subject: [PATCH 5/8] test(artifacts): report compiled connector records when a storyboard route is missing Co-Authored-By: Claude Opus 5.5 --- .../filesystem/interfaceBuilderBundle.test.ts | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 1010b87ca6..1ffd7e1b61 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -245,7 +245,55 @@ describe("bounded Interface Builder archive decoding", () => { (from === action?.id || to === action?.id), ) .map(({ from, to }) => `${describeNode(from)} -> ${describeNode(to)}`); - expect(actionSource, actionRoutes.join("; ")).toBeDefined(); + // Temporary diagnostic: the compiled connector records. + const { decodeNibArchive } = + await import("../../../src/artifacts/NibArchive.js"); + const { readdir, readFile } = await import("node:fs/promises"); + const compiled = join(resources, "MacFixture.storyboardc"); + const records: string[] = []; + for (const entry of await readdir(compiled, { recursive: true })) { + if (!String(entry).endsWith(".nib")) continue; + let bytes: Buffer; + try { + bytes = await readFile(join(compiled, String(entry))); + } catch { + continue; + } + if (bytes.subarray(0, 10).toString("ascii") !== "NIBArchive") { + records.push( + `${String(entry)}: ${bytes.subarray(0, 8).toString("ascii")}`, + ); + continue; + } + const archive = decodeNibArchive(bytes); + const byId = new Map(archive.objects.map((item) => [item.id, item])); + const named = (value: unknown) => { + const ref = + typeof value === "object" && + value !== null && + "$nib_object_ref" in value + ? Number(value.$nib_object_ref) + : undefined; + return ref === undefined + ? JSON.stringify(value) + : `${byId.get(ref)?.class_name.replace(/\0+$/u, "") ?? "?"}#${String(ref)}`; + }; + for (const item of archive.objects) { + const name = item.class_name.replace(/\0+$/u, ""); + if (!/Connector|Placeholder/u.test(name)) continue; + records.push( + `${String(entry)}#${String(item.id)} ${name} {${Object.entries( + item.values, + ) + .map(([key, value]) => `${key}=${named(value)}`) + .join(", ")}}`, + ); + } + } + expect( + actionSource, + `${actionRoutes.join("; ")} | ${records.join(" | ")}`, + ).toBeDefined(); expect( analysis.graph.edges.some( ({ from, relation, to }) => From 53bfa7e3f792cdb9f2e95fc954f3616426414bcc Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:06:36 +0000 Subject: [PATCH 6/8] test(artifacts): write the storyboard fixture's action in Cocoa syntax MacFixture.storyboard declared its button action with the iOS attribute, . Cocoa documents name an action's receiver with target=. ibtool compiled the iOS spelling into an NSNibControlConnector whose NSSource is the view controller placeholder and whose NSDestination is the button. That is the reverse of what Xcode-authored AppKit NIBs contain (Music, Chess, KeepingYouAwake, and Transmission all archive NSSource as the control), and it is what the old inversion had matched. With target= the compiled fixture archives the button as NSSource. The temporary connector dump is removed; the observed routes remain in the assertion message. Co-Authored-By: Claude Opus 5.5 --- .../filesystem/interfaceBuilderBundle.test.ts | 50 +------------------ .../interface-builder/MacFixture.storyboard | 2 +- 2 files changed, 2 insertions(+), 50 deletions(-) diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 1ffd7e1b61..1010b87ca6 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -245,55 +245,7 @@ describe("bounded Interface Builder archive decoding", () => { (from === action?.id || to === action?.id), ) .map(({ from, to }) => `${describeNode(from)} -> ${describeNode(to)}`); - // Temporary diagnostic: the compiled connector records. - const { decodeNibArchive } = - await import("../../../src/artifacts/NibArchive.js"); - const { readdir, readFile } = await import("node:fs/promises"); - const compiled = join(resources, "MacFixture.storyboardc"); - const records: string[] = []; - for (const entry of await readdir(compiled, { recursive: true })) { - if (!String(entry).endsWith(".nib")) continue; - let bytes: Buffer; - try { - bytes = await readFile(join(compiled, String(entry))); - } catch { - continue; - } - if (bytes.subarray(0, 10).toString("ascii") !== "NIBArchive") { - records.push( - `${String(entry)}: ${bytes.subarray(0, 8).toString("ascii")}`, - ); - continue; - } - const archive = decodeNibArchive(bytes); - const byId = new Map(archive.objects.map((item) => [item.id, item])); - const named = (value: unknown) => { - const ref = - typeof value === "object" && - value !== null && - "$nib_object_ref" in value - ? Number(value.$nib_object_ref) - : undefined; - return ref === undefined - ? JSON.stringify(value) - : `${byId.get(ref)?.class_name.replace(/\0+$/u, "") ?? "?"}#${String(ref)}`; - }; - for (const item of archive.objects) { - const name = item.class_name.replace(/\0+$/u, ""); - if (!/Connector|Placeholder/u.test(name)) continue; - records.push( - `${String(entry)}#${String(item.id)} ${name} {${Object.entries( - item.values, - ) - .map(([key, value]) => `${key}=${named(value)}`) - .join(", ")}}`, - ); - } - } - expect( - actionSource, - `${actionRoutes.join("; ")} | ${records.join(" | ")}`, - ).toBeDefined(); + expect(actionSource, actionRoutes.join("; ")).toBeDefined(); expect( analysis.graph.edges.some( ({ from, relation, to }) => diff --git a/tests/fixtures/interface-builder/MacFixture.storyboard b/tests/fixtures/interface-builder/MacFixture.storyboard index 995dc1d128..f9fe157f92 100644 --- a/tests/fixtures/interface-builder/MacFixture.storyboard +++ b/tests/fixtures/interface-builder/MacFixture.storyboard @@ -11,7 +11,7 @@ <connections> - <action selector="buildTapped:" destination="vc1" id="action1"/> + <action selector="buildTapped:" target="vc1" id="action1"/> </connections> </button> </subviews> From 418a5562f6f640503c751924a4787586c2c5c98a Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:10:29 +0000 Subject: [PATCH 7/8] fix(native): keep the resolved bundle plist and permission failures - Carry the Info.plist that declared the program file on the target as bundleInfoPlist, so inspect_plist defaults to it even when the program file is a symlink into another bundle directory. - Accept a readable Info.plist symlink, as reading the plist directly did. - Treat only a missing path as an absent layout or wrapper; a permission denial now reaches the resolver's permission-denied classification. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/application/AppBundleExecutable.ts | 56 +++++++++++------ src/application/BinaryTargetResolver.ts | 14 +++-- src/domain/binaryTargetTypes.ts | 2 + src/native/NativeMacOSProvider.ts | 22 +------ .../boundary/filesystem/binaryTarget.test.ts | 62 ++++++++++++++++++- .../providers/native/nativeProvider.test.ts | 7 ++- 6 files changed, 118 insertions(+), 45 deletions(-) diff --git a/src/application/AppBundleExecutable.ts b/src/application/AppBundleExecutable.ts index 36e4452f64..f3e8b30c3f 100644 --- a/src/application/AppBundleExecutable.ts +++ b/src/application/AppBundleExecutable.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { lstat, readdir, readFile, realpath } from "node:fs/promises"; +import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -30,6 +30,12 @@ const BUNDLE_LAYOUTS: readonly BundleLayout[] = [ { plist: ["Info.plist"], programs: [], programsLabel: "the bundle root" }, ]; +/** A resolved bundle program file and the Info.plist that declared it. */ +export interface ResolvedAppBundle { + readonly executable: string; + readonly infoPlist?: string; +} + /** * Resolve an app bundle directory to its declared program file. macOS and * flat iOS-style layouts are read directly. An iOS app installed on a Mac is @@ -38,7 +44,7 @@ const BUNDLE_LAYOUTS: readonly BundleLayout[] = [ */ export const resolveAppBundleExecutable = async ( path: string, -): Promise<Result<string, BinaryTargetError>> => { +): Promise<Result<ResolvedAppBundle, BinaryTargetError>> => { const layout = await presentLayout(path); if (layout !== undefined) return resolveLayoutExecutable(path, layout); const wrapped = await wrappedBundle(path); @@ -60,40 +66,52 @@ const presentLayout = async ( bundle: string, ): Promise<BundleLayout | undefined> => { for (const layout of BUNDLE_LAYOUTS) - if (await isRegularFile(join(bundle, ...layout.plist))) return layout; + if (await layoutPlistPresent(join(bundle, ...layout.plist))) return layout; return undefined; }; -const isRegularFile = async (path: string): Promise<boolean> => { +/** + * Whether a layout's Info.plist exists. Readable symlinks count, as they did + * when the plist was read directly; any failure other than absence, such as + * a permission denial, is left for the caller to report. + */ +const layoutPlistPresent = async (path: string): Promise<boolean> => { try { - return (await lstat(path)).isFile(); - } catch { - return false; + return (await stat(path)).isFile(); + } catch (cause: unknown) { + if (isAbsence(cause)) return false; + throw cause; } }; +const isAbsence = (cause: unknown): boolean => { + const code: unknown = + cause instanceof Error ? Reflect.get(cause, "code") : undefined; + return code === "ENOENT" || code === "ENOTDIR"; +}; + /** The single real `.app` directory inside an iOS-on-Mac `Wrapper`. */ const wrappedBundle = async (bundle: string): Promise<string | undefined> => { const wrapper = join(bundle, "Wrapper"); try { if (!(await lstat(wrapper)).isDirectory()) return undefined; - const apps = (await readdir(wrapper, { withFileTypes: true })).filter( - (entry) => - entry.isDirectory() && entry.name.toLowerCase().endsWith(".app"), - ); - const [app] = apps; - return apps.length === 1 && app !== undefined - ? join(wrapper, app.name) - : undefined; - } catch { - return undefined; + } catch (cause: unknown) { + if (isAbsence(cause)) return undefined; + throw cause; } + const apps = (await readdir(wrapper, { withFileTypes: true })).filter( + (entry) => entry.isDirectory() && entry.name.toLowerCase().endsWith(".app"), + ); + const [app] = apps; + return apps.length === 1 && app !== undefined + ? join(wrapper, app.name) + : undefined; }; const resolveLayoutExecutable = async ( bundle: string, layout: BundleLayout, -): Promise<Result<string, BinaryTargetError>> => { +): Promise<Result<ResolvedAppBundle, BinaryTargetError>> => { const plistPath = join(bundle, ...layout.plist); let name: string; try { @@ -127,7 +145,7 @@ const resolveLayoutExecutable = async ( `app program file leaves ${layout.programsLabel}`, ), ); - return ok(canonicalExecutable); + return ok({ executable: canonicalExecutable, infoPlist: plistPath }); } catch (cause: unknown) { return err( new BinaryTargetError(bundle, "app program file is missing", { cause }), diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 564a867afa..938633e9b2 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -12,7 +12,10 @@ import { extname, isAbsolute, resolve } from "node:path"; import { BinaryTargetError } from "../domain/configurationErrors.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { err, ok, type Result } from "../domain/result.js"; -import { resolveAppBundleExecutable } from "./AppBundleExecutable.js"; +import { + resolveAppBundleExecutable, + type ResolvedAppBundle, +} from "./AppBundleExecutable.js"; import { validateDosComLength, type ExecutableFormatHint, @@ -47,7 +50,9 @@ export const parseBinaryTarget = async ( const canonical = await realpath(candidate); const resolved = await resolveAppBundle(canonical); if (!resolved.ok) return err(resolved.error); - const path = resolved.value; + const { executable: path, infoPlist } = resolved.value; + const bundle = + infoPlist === undefined ? {} : { bundleInfoPlist: infoPlist }; const handle = await open(path, "r"); try { if (!(await handle.stat()).isFile()) @@ -99,6 +104,7 @@ export const parseBinaryTarget = async ( return ok({ path, sourcePath: process.platform === "win32" ? candidate : canonical, + ...bundle, sha256: await sha256Handle(handle), kind: "executable", ...detected.value, @@ -208,9 +214,9 @@ const sha256Handle = async (handle: FileHandle): Promise<string> => { const resolveAppBundle = async ( path: string, -): Promise<Result<string, BinaryTargetError>> => { +): Promise<Result<ResolvedAppBundle, BinaryTargetError>> => { const metadata = await stat(path); - if (!metadata.isDirectory()) return ok(path); + if (!metadata.isDirectory()) return ok({ executable: path }); if (extname(path).toLowerCase() !== ".app") return err( new BinaryTargetError( diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index 9083b13c14..edb9147066 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -4,6 +4,8 @@ export type BinaryArchitecture = "x86" | "x86_64" | "arm" | "arm64"; interface BinaryTargetIdentity { readonly path: string; readonly sourcePath?: string; + /** Info.plist of the app bundle the target was resolved from. */ + readonly bundleInfoPlist?: string; readonly sha256: string; } diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 584b511f75..0d832d5e71 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -713,14 +713,6 @@ const requireRegularPlist = async ( } }; -const isFile = async (path: string): Promise<boolean> => { - try { - return (await stat(path)).isFile(); - } catch { - return false; - } -}; - const resolvePlistPath = async ( target: BinaryTarget, requested: string | undefined, @@ -732,17 +724,9 @@ const resolvePlistPath = async ( const sourceMetadata = await stat(source); const root = sourceMetadata.isDirectory() ? source : dirname(target.path); if (requested !== undefined) return ok(resolve(root, requested)); - const macOS = resolve(root, "Contents/Info.plist"); - // iOS-style bundles, including the one inside an iOS-on-Mac wrapper, keep - // Info.plist beside the program file the target resolved to. - const beside = resolve(dirname(target.path), "Info.plist"); - return ok( - sourceMetadata.isDirectory() && - !(await isFile(macOS)) && - (await isFile(beside)) - ? beside - : macOS, - ); + // A bundle target records the Info.plist its program file was declared + // in: Contents/Info.plist, or the root plist of an iOS-style bundle. + return ok(target.bundleInfoPlist ?? resolve(root, "Contents/Info.plist")); } catch (cause: unknown) { return err( new ProviderAdapterError(IDENTITY.id, "inspect_plist", { cause }), diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index 7096fcdce4..9799045470 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdir, realpath, symlink, writeFile } from "node:fs/promises"; +import { chmod, mkdir, realpath, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -306,10 +306,70 @@ describe("iOS-style app bundle targets", () => { const result = await parseBinaryTarget(app, directory, "arm64"); expect(result.ok && result.value).toMatchObject({ path: await realpath(join(app, "Flat")), + bundleInfoPlist: join(await realpath(app), "Info.plist"), format: "mach-o", }); }); + it.skipIf(process.platform === "win32")( + "keeps the declaring Info.plist for a symlinked program file", + async () => { + const directory = await createTestTempDirectory("rea-target-"); + const app = join(directory, "Linked.app"); + await mkdir(join(app, "bin"), { recursive: true }); + await writeFile(join(app, "Info.plist"), plist("Linked")); + await writeFile( + join(app, "bin", "real"), + thinMach(0xfeedfacf, 0x0100000c), + ); + await symlink("bin/real", join(app, "Linked")); + const result = await parseBinaryTarget(app, directory, "arm64"); + expect(result.ok && result.value).toMatchObject({ + path: await realpath(join(app, "bin", "real")), + bundleInfoPlist: join(await realpath(app), "Info.plist"), + }); + }, + ); + + it.skipIf(process.platform === "win32")( + "follows a readable Contents/Info.plist symlink", + async () => { + const directory = await createTestTempDirectory("rea-target-"); + const app = join(directory, "Linked.app"); + const contents = join(app, "Contents"); + await mkdir(join(contents, "MacOS"), { recursive: true }); + await writeFile(join(contents, "Real.plist"), plist("Linked")); + await symlink("Real.plist", join(contents, "Info.plist")); + await writeFile( + join(contents, "MacOS", "Linked"), + thinMach(0xfeedfacf, 0x0100000c), + ); + const result = await parseBinaryTarget(app, directory, "arm64"); + expect(result.ok && result.value).toMatchObject({ + path: await realpath(join(contents, "MacOS", "Linked")), + }); + }, + ); + + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "reports a wrapper it cannot read as a permission denial", + async () => { + const directory = await createTestTempDirectory("rea-target-"); + const app = join(directory, "Locked.app"); + const wrapper = join(app, "Wrapper"); + await mkdir(join(wrapper, "Locked.app"), { recursive: true }); + await chmod(wrapper, 0o000); + try { + const result = await parseBinaryTarget(app, directory, "arm64"); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected a permission denial"); + expect(result.error.message).toContain("permission denied"); + } finally { + await chmod(wrapper, 0o755); + } + }, + ); + it.skipIf(process.platform === "win32")( "resolves an iOS-on-Mac wrapper through its single Wrapper bundle", async () => { diff --git a/tests/boundary/providers/native/nativeProvider.test.ts b/tests/boundary/providers/native/nativeProvider.test.ts index ed86df0e09..0fb8276cc3 100644 --- a/tests/boundary/providers/native/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -279,7 +279,7 @@ describe("native macOS provider inspection", () => { }); describe("native plist defaults for iOS-style bundles", () => { - it("defaults to the Info.plist beside a flat bundle's program file", async () => { + it("defaults to the Info.plist the bundle program was resolved from", async () => { directory = await createTestTempDirectory("rea-native-flat-"); const app = join(directory, "Flat.app"); const executable = join(app, "Flat"); @@ -289,7 +289,10 @@ describe("native plist defaults for iOS-style bundles", () => { const client = new NativeMacOSProvider( new FixtureRunner(), "darwin", - ).createClient(machoTarget(executable, app)); + ).createClient({ + ...machoTarget(executable, app), + bundleInfoPlist: join(app, "Info.plist"), + }); const plist = await client.execute("inspect_plist", {}); expect(plist.ok && plist.value.result).toMatchObject({ From 8b243877c22a5550d005e7b8abf93791ce8566b9 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:42:44 +0000 Subject: [PATCH 8/8] fix(artifacts): project UIKit runtime outlet and event connections Compiled UIKit NIBs and storyboards archive outlets and control actions as UIRuntimeOutletConnection and UIRuntimeEventConnection with UISource, UIDestination, and UILabel. The NIBArchive projection recognized only AppKit *Connector classes with NS-prefixed keys, so an iOS app produced no outlet or action edges: Stash's 64 decoded documents yielded none. These connections are now projected with the same source -> destination roles, an event connection carries its UIEventMask as ui_event_mask, UIClassSwapper reports its UIClassName, UIProxyObject is a placeholder, and connection records are no longer emitted as unknown objects. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/application/InterfaceBuilderAnalysis.ts | 56 +++++++----- src/domain/interfaceBuilderKeyedArchive.ts | 6 +- .../filesystem/interfaceBuilderBundle.test.ts | 89 +++++++++++++++++++ 3 files changed, 127 insertions(+), 24 deletions(-) diff --git a/src/application/InterfaceBuilderAnalysis.ts b/src/application/InterfaceBuilderAnalysis.ts index 3bf8691654..07f288f70e 100644 --- a/src/application/InterfaceBuilderAnalysis.ts +++ b/src/application/InterfaceBuilderAnalysis.ts @@ -192,15 +192,17 @@ const projectNibArchive = (archive: NibArchiveDocument) => { const object = byId.get(objectId); if (object === undefined) return null; const storedClass = object.class_name.replace(/\0+$/u, ""); - return storedClass === "NSClassSwapper" - ? (referencedString(object.values.NSClassName) ?? storedClass) + if (storedClass === "NSClassSwapper") + return referencedString(object.values.NSClassName) ?? storedClass; + return storedClass === "UIClassSwapper" + ? (referencedString(object.values.UIClassName) ?? storedClass) : storedClass; }; const objects = Object.fromEntries( archive.objects .filter((object) => { const name = runtimeClass(object.id) ?? ""; - return !/^(?:NSObject|NSIBObjectData|NSString|NSMutableString|NSNumber|NSArray|NSMutableArray|NSSet|NSMutableSet|NSDictionary|NSMutableDictionary|NSApplication|NSNib.*Connector)$/u.test( + return !/^(?:NSObject|NSIBObjectData|NSString|NSMutableString|NSNumber|NSArray|NSMutableArray|NSSet|NSMutableSet|NSDictionary|NSMutableDictionary|NSApplication|NSNib.*Connector|UIRuntime\w*Connection)$/u.test( name, ); }) @@ -246,26 +248,34 @@ const projectNibArchive = (archive: NibArchiveDocument) => { }; for (const object of archive.objects) { const className = object.class_name.replace(/\0+$/u, ""); - const source = reference(object.values.NSSource); - const destination = reference(object.values.NSDestination); - if (className.includes("Connector") && source !== null) { - const label = referencedString(object.values.NSLabel); - const type = className.includes("Outlet") - ? "outlet" - : className.includes("Control") - ? "action" - : className; - // NSSource is the outlet owner or the sending control; NSDestination is - // the outlet value or the action target, and a nil target is the first - // responder. - (connections[String(source)] ??= []).push({ - type, - "destination-id": destination === null ? null : String(destination), - label, - source_id: String(source), - archive_object_id: String(object.id), - }); - } + // AppKit connectors use NS-prefixed keys; UIKit's runtime outlet and + // event connections use UI-prefixed keys with the same roles. + const uiKit = /^UIRuntime\w*Connection$/u.test(className); + if (!uiKit && !className.includes("Connector")) continue; + const values = object.values; + const source = reference(uiKit ? values.UISource : values.NSSource); + if (source === null) continue; + const destination = reference( + uiKit ? values.UIDestination : values.NSDestination, + ); + const label = referencedString(uiKit ? values.UILabel : values.NSLabel); + const type = className.includes("Outlet") + ? "outlet" + : className.includes("Control") || className.includes("Event") + ? "action" + : className; + const eventMask = values.UIEventMask; + // The source is the outlet owner or the sending control; the destination + // is the outlet value or the action target, and a nil target is the + // first responder. + (connections[String(source)] ??= []).push({ + type, + "destination-id": destination === null ? null : String(destination), + label, + source_id: String(source), + archive_object_id: String(object.id), + ...(typeof eventMask === "number" ? { ui_event_mask: eventMask } : {}), + }); } let hierarchyOmitted = 0; const hierarchyFor = ( diff --git a/src/domain/interfaceBuilderKeyedArchive.ts b/src/domain/interfaceBuilderKeyedArchive.ts index 24378a75cb..6d43ca57e5 100644 --- a/src/domain/interfaceBuilderKeyedArchive.ts +++ b/src/domain/interfaceBuilderKeyedArchive.ts @@ -308,7 +308,11 @@ const classifyObject = ( const value = className?.toLowerCase() ?? ""; if (value.includes("constraint")) return "constraint"; if (value.includes("layoutguide")) return "layout_guide"; - if (value.includes("placeholder") || value.includes("firstresponder")) + if ( + value.includes("placeholder") || + value.includes("firstresponder") || + value === "uiproxyobject" + ) return "placeholder"; if ( value.includes("image") || diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 1010b87ca6..ef0cda84bf 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -268,3 +268,92 @@ describe("bounded Interface Builder archive decoding", () => { }, ); }); + +describe("compiled UIKit NIB connections", () => { + it("projects UIKit runtime outlet and event connections", async () => { + const root = await createTestTempDirectory("rea-ib-test-"); + const bundle = join(root, "Example.app"); + await mkdir(bundle, { recursive: true }); + await writeFile( + join(bundle, "Cell.nib"), + encodeNibArchiveFixture({ + classes: [ + "UIProxyObject", + "UIClassSwapper", + "UIButton", + "UIRuntimeEventConnection", + "UIRuntimeOutletConnection", + "NSString", + ], + objects: [ + { classIndex: 0, values: { UIProxiedObjectIdentifier: { ref: 5 } } }, + { classIndex: 1, values: { UIClassName: { ref: 6 } } }, + { classIndex: 2, values: {} }, + { + classIndex: 3, + values: { + UISource: { ref: 2 }, + UIDestination: { ref: 1 }, + UILabel: { ref: 7 }, + UIEventMask: 64, + }, + }, + { + classIndex: 4, + values: { + UISource: { ref: 1 }, + UIDestination: { ref: 2 }, + UILabel: { ref: 8 }, + }, + }, + { classIndex: 5, values: { "NS.bytes": "IBFilesOwner" } }, + { classIndex: 5, values: { "NS.bytes": "BuildCell" } }, + { classIndex: 5, values: { "NS.bytes": "buildTapped:" } }, + { classIndex: 5, values: { "NS.bytes": "buildButton" } }, + ], + }), + ); + + const analysis = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "e".repeat(64), + }); + const byId = new Map(analysis.graph.nodes.map((node) => [node.id, node])); + const named = (id: string | null) => + id === null ? null : (byId.get(id)?.name ?? null); + const action = analysis.graph.nodes.find(({ kind }) => kind === "action"); + expect(action).toMatchObject({ + name: "buildTapped:", + attributes: { ui_event_mask: 64 }, + }); + const actionEdges = analysis.graph.edges.filter( + ({ relation }) => relation === "target_action", + ); + expect( + actionEdges + .filter(({ to }) => to === action?.id) + .map(({ from }) => named(from)), + ).toEqual(["UIButton"]); + expect( + actionEdges + .filter(({ from }) => from === action?.id) + .map(({ to }) => named(to)), + ).toEqual(expect.arrayContaining(["BuildCell"])); + const outlet = analysis.graph.nodes.find(({ kind }) => kind === "outlet"); + expect(outlet?.name).toBe("buildButton"); + expect( + analysis.graph.edges + .filter( + ({ relation, from }) => + relation === "outlet_to" && from === outlet?.id, + ) + .map(({ to }) => named(to)), + ).toEqual(["UIButton"]); + expect( + analysis.graph.nodes.find(({ name }) => name === "IBFilesOwner")?.kind, + ).toBe("placeholder"); + expect(analysis.graph.nodes.map(({ name }) => name)).not.toContain( + "UIRuntimeEventConnection", + ); + }); +});