mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user