From 0344d578b2afd86a7cde905ca0c0ca0f4bb27850 Mon Sep 17 00:00:00 2001 From: Muhammad Hamza <32600505+mhamzasakhi@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:41:13 +0500 Subject: [PATCH] fix(apple): classify keyed-archive objects by declared superclasses (#5228) * fix(apple): classify keyed-archive objects by declared superclasses Interface Builder keyed archives record each object's superclass chain in `$classes`, but the decoder classified objects by class-name substrings alone. UINavigationController, UITabBarController and UISearchController contain "control" but not "viewcontroller", so they were reported as controls. Classify keyed-archive objects by UIKit/AppKit view-controller, control and view bases in their declared chain first, falling back to the name heuristic only when the chain names none. The decoder now reads `$classes` through one helper shared with NSArray detection. Fixes #5218 * test(apple): cover each declared keyed-archive base with neutral class names Neutral names classify only through the archived superclass chain, so each UIKit and AppKit base entry and the control-before-view ordering now has a case that fails without it. * perf(apple): parse each keyed-archive class chain once Objects sharing one class descriptor reparsed and scanned its whole `$classes` array per object, so 20,000 objects sharing a 20,000-name chain took about 16 seconds to decode. Cache each descriptor's chain as a set by archive UID; the same archive decodes in about 3.5 seconds, matching main. --------- Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- .../apple/interfaceBuilderKeyedArchive.ts | 32 ++++- .../interfaceBuilderUIKitKeyedArchive.test.ts | 113 +++++++++++++++--- 2 files changed, 127 insertions(+), 18 deletions(-) diff --git a/src/domain/apple/interfaceBuilderKeyedArchive.ts b/src/domain/apple/interfaceBuilderKeyedArchive.ts index 56ac6d1990..8b0cb5a812 100644 --- a/src/domain/apple/interfaceBuilderKeyedArchive.ts +++ b/src/domain/apple/interfaceBuilderKeyedArchive.ts @@ -233,6 +233,9 @@ const keyedArchiveTable = (objectTable: readonly unknown[]) => { classFactsOfObject(value)?.name ?? null; const hasClassSuperclass = (value: unknown, superclass: string): boolean => classFactsOfObject(value)?.superclasses.includes(superclass) ?? false; + /** The class and superclass names an object's descriptor declares in `$classes`. */ + const classChainOf = (value: unknown): readonly string[] => + classFactsOfObject(value)?.superclasses ?? []; /** The string an archived string object holds, if it is one. */ const archivedString = (target: unknown): string | undefined => { if (typeof target === "string") return target; @@ -369,7 +372,7 @@ const keyedArchiveTable = (objectTable: readonly unknown[]) => { : [[facts.name, { superclasses: facts.rawSuperclasses }]]; }), ); - return { resolve, hierarchy, hasClassSuperclass, classTable }; + return { resolve, hierarchy, hasClassSuperclass, classChainOf, classTable }; }; /** Project class and connection objects from an NSKeyedArchiver object table. */ @@ -383,6 +386,7 @@ const parseKeyedArchive = ( resolve, hierarchy: toHierarchy, hasClassSuperclass, + classChainOf, classTable, } = keyedArchiveTable(objectTable); const objects: InterfaceBuilderObject[] = []; @@ -410,7 +414,7 @@ const parseKeyedArchive = ( (typeof fields.archiveUID === "number" ? String(fields.archiveUID) : String(index)); - const kind = classifyObject(className); + const kind = classifyObject(className, classChainOf(raw)); if (objects.length < 20_000) objects.push( objectNode.parse({ @@ -538,9 +542,33 @@ const firstObjectReference = (...values: unknown[]): string | null => { return null; }; +/** + * Framework base classes whose presence in an archived `$classes` chain settles + * an object's kind, most specific first: UIControl and NSControl are views. + */ +const archivedKindBases: readonly (readonly [ + InterfaceBuilderObject["kind"], + readonly string[], +])[] = [ + [ + "view_controller", + ["UIViewController", "NSViewController", "NSWindowController"], + ], + ["control", ["UIControl", "NSControl"]], + ["view", ["UIView", "NSView", "UIWindow", "NSWindow"]], +]; + +/** + * Classify an object by its declared superclass chain when the archive records + * one, otherwise by its class name alone. Names are only a heuristic: + * `UINavigationController` names no view controller and contains "control". + */ const classifyObject = ( className: string | null, + classChain: readonly string[] = [], ): InterfaceBuilderObject["kind"] => { + for (const [kind, bases] of archivedKindBases) + if (bases.some((base) => classChain.includes(base))) return kind; const value = className?.toLowerCase() ?? ""; if (value.includes("constraint")) return "constraint"; if (value.includes("layoutguide")) return "layout_guide"; diff --git a/tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts b/tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts index 02ec7c607b..0665922320 100644 --- a/tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts +++ b/tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts @@ -1,6 +1,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { build } from "plist"; import { expect } from "vitest"; import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js"; @@ -9,25 +10,31 @@ import { thinMach } from "../../../src/domain/binaryTarget.fixture.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +/** Write an app bundle whose only Interface Builder document is `Main.nib`. */ +const writeUIKitApp = async (prefix: string, nib: string | Buffer) => { + const root = await createTestTempDirectory(prefix); + const app = join(root, "UIKitFixture.app"); + const contents = join(app, "Contents"); + const resources = join(contents, "Resources"); + await mkdir(resources, { recursive: true }); + await mkdir(join(contents, "MacOS")); + await writeFile( + join(contents, "MacOS", "Fixture"), + thinMach(0xfeedfacf, 0x0100000c), + ); + await writeFile( + join(contents, "Info.plist"), + "CFBundleExecutableFixture", + ); + await writeFile(join(resources, "Main.nib"), nib); + return app; +}; + cliTest( "decodes a rooted UIKit keyed-archive outlet through CLI", async ({ cli }) => { - const root = await createTestTempDirectory("rea-cli-uikit-outlet-"); - const app = join(root, "UIKitFixture.app"); - const contents = join(app, "Contents"); - const resources = join(contents, "Resources"); - await mkdir(resources, { recursive: true }); - await mkdir(join(contents, "MacOS")); - await writeFile( - join(contents, "MacOS", "Fixture"), - thinMach(0xfeedfacf, 0x0100000c), - ); - await writeFile( - join(contents, "Info.plist"), - "CFBundleExecutableFixture", - ); - await writeFile( - join(resources, "Main.nib"), + const app = await writeUIKitApp( + "rea-cli-uikit-outlet-", await readFile( join(process.cwd(), "tests/fixtures/interface-builder/UIKitOutlet.xml"), ), @@ -86,3 +93,77 @@ cliTest( ); }, ); + +cliTest( + "classifies UIKit keyed-archive objects by their declared superclasses through CLI", + async ({ cli }) => { + // Class name and its archived `$classes` chain, with the expected kind. + // Neutral names classify only through their declared bases; controls also + // declare their view base. The constraint declares no framework base, so + // its name still classifies it. + const cases = [ + ["UINavigationController", ["UIViewController"], "view_controller"], + ["UITabBarController", ["UIViewController"], "view_controller"], + ["UISearchController", ["UIViewController"], "view_controller"], + ["UIViewController", [], "view_controller"], + ["SettingsScreen", ["UIViewController"], "view_controller"], + ["InspectorPane", ["NSViewController"], "view_controller"], + ["DocumentShell", ["NSWindowController"], "view_controller"], + ["UIButton", ["UIControl", "UIView"], "control"], + ["RatingPicker", ["UIControl", "UIView"], "control"], + ["LevelGauge", ["NSControl", "NSView"], "control"], + ["UIImageView", ["UIView"], "view"], + ["CanvasSurface", ["NSView"], "view"], + ["AppShell", ["UIWindow", "UIView"], "view"], + ["Palette", ["NSWindow"], "view"], + ["NSLayoutConstraint", [], "constraint"], + ] as const; + const uid = (value: number) => ({ CF$UID: value }); + const descriptorUid = (index: number) => cases.length + 1 + index; + const rootUid = cases.length * 2 + 1; + const nib = build({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: [ + "$null", + ...cases.map((_, index) => ({ $class: uid(descriptorUid(index)) })), + ...cases.map(([name, bases]) => ({ + $classname: name, + $classes: [name, ...bases, "NSObject"], + })), + { + $class: uid(rootUid + 1), + "NS.objects": cases.map((_, index) => uid(index + 1)), + }, + { $classname: "NSArray", $classes: ["NSArray", "NSObject"] }, + ], + $top: { root: uid(rootUid) }, + }); + const app = await writeUIKitApp("rea-cli-uikit-kinds-", nib); + + const output = await cli.run({ + arguments: ["decode-interface-builder", app, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(output.exitCode, output.stderr).toBe(0); + const result = interfaceBuilderAnalysisSchema.parse( + parseEvidence(output.json).normalized_result, + ); + const names = new Set(cases.map(([name]) => name)); + expect( + result.graph.nodes + .filter(({ attributes }) => + names.has(String(attributes.class_name ?? "")), + ) + .map(({ kind, attributes }) => [attributes.class_name, kind]), + ).toEqual(cases.map(([name, , kind]) => [name, kind])); + expect(result.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "objects:Contents/Resources/Main.nib", + status: "complete", + examined: cases.length + 1, + omitted: 0, + }), + ); + }, +);