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 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-23 05:41:13 +08:00
1 parent 2a3e162246
commit 0344d578b2
2 files changed
+127 -18

No files matched your search

@@ -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";
@@ -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"),
"<plist><dict><key>CFBundleExecutable</key><string>Fixture</string></dict></plist>",
);
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"),
"<plist><dict><key>CFBundleExecutable</key><string>Fixture</string></dict></plist>",
);
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<string>(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,
}),
);
},
);