fix(apple)!: use archive UIDs for keyed nib identity

Use original object-table UIDs for inventory, hierarchy and connector references. Retain authored identity fields as data, refuse forged reference aliases and select UIKit top-level collections without substituting unrelated roots.

BREAKING CHANGE: Keyed archives that previously used authored objectID or object-id graph identities now use decimal table UIDs. Authored values remain attributes; ibtool and native NIBArchive identities retain their existing source semantics.

Fixes #6849
Fixes #6850
This commit is contained in:
morluto committed 2026-10-24 02:00:09 +08:00
1 parent 5e4004c413
commit 6fbe67d66e
5 files changed
+557 -77

No files matched your search

+23 -5
View File
@@ -841,11 +841,29 @@ Integral reals stay numbers, and ambiguous or incompletely observed values retai
explicit precision limitations. Before plist decoding, the existing aggregate
decode budget reserves estimated room for number metadata and attribute
projection; these estimates are not a hard process-memory bound. Keyed-archive
object identities come from their original `$objects` table indices unless an
explicit `objectID` or `object-id` is present. An authored `archiveUID` field
remains ordinary object data and does not change identity. NIBArchive float32
and float64 values retain non-finite values and negative zero through projection
as the same typed real wrappers used for plist values; per-document limitations
object identities always come from their original `$objects` table indices.
Authored `objectID`, `object-id`, `archiveUID` and `id` fields remain ordinary
object data and do not change inventory, reference, connector or hierarchy
identity. This changes keyed-archive graph IDs that previously used authored
`objectID` or `object-id` values: `interface_builder_object_id` and linked node
IDs now use the decimal table UID, while the authored values remain in attributes.
For example, an authored `objectID: "2"` on table entry 1 now identifies object
`"1"`; its graph node ends in `object:["1"]`. Nonkeyed ibtool dictionary IDs
and native NIBArchive IDs keep their existing source-specific meaning.
Connector endpoints require encoded UID references to archived objects; strings
and dictionaries that merely resemble resolved reference stubs do not establish
identity. Inline hierarchy dictionaries have no table identity and count as
omitted references, while independently referenced children remain available.
The UID identity rule follows the
[Foundation unarchiver's table lookup](https://github.com/swiftlang/swift-corelibs-foundation/blob/28249ced079d8e7ec03b821c039bf9868738c3fc/Sources/Foundation/NSKeyedUnarchiver.swift#L213-L223).
UIKit keyed nibs select their explicitly named
`UINibTopLevelObjectsKey` collection; generic `root` is used when that named key
is absent. Unsupported or dangling named roots remain incomplete rather than
falling back to another collection. The key spelling follows the independent
[WinObjC UINib reader](https://github.com/microsoft/WinObjC/blob/94f6b5bfdc09d945e30607b56124b6f94a5b9d7b/Frameworks/UIKit/UINib.mm#L104-L109).
NIBArchive float32 and float64 values retain non-finite values and negative zero
through projection as the same typed real wrappers used for plist values; per-document limitations
count those values. NIBArchive nil remains JSON null.
Dates are reported as `{ "$plist_type": "date", "iso8601": "<timestamp>" }`.
@@ -0,0 +1,332 @@
import { expect, it } from "vitest";
import {
buildInterfaceBuilderAnalysis,
interfaceBuilderLimitsSchema,
} from "./interfaceBuilderGraph.js";
import { parseInterfaceBuilderRecords } from "./interfaceBuilderKeyedArchive.js";
const uid = (value: number) => ({ UID: value });
const archive = (
fields: Record<string, unknown> = {},
top: Record<string, unknown> = { root: uid(1) },
) => ({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: [
"$null",
{ $class: uid(4), NSSubviews: [uid(2)], ...fields },
{ $class: uid(4) },
{
$class: uid(5),
NSSource: uid(2),
NSDestination: uid(1),
NSLabel: "parent",
},
{ $classname: "NSView", $classes: ["NSView", "NSObject"] },
{
$classname: "NSNibOutletConnector",
$classes: ["NSNibOutletConnector", "NSNibConnector", "NSObject"],
},
{ $class: uid(7), "NS.objects": [uid(1)] },
{ $classname: "NSArray", $classes: ["NSArray", "NSObject"] },
] as unknown[],
$top: top,
});
it.each([
["id", "2"],
["id", "record-17"],
["recordID", "2"],
["archiveUID", 2],
["objectID", "2"],
["object-id", "2"],
["objectID", "record-17"],
["object-id", "record-17"],
])(
"uses one inventory/reference/hierarchy identity for the authored %s field %s",
(field, value) => {
const parsed = parseInterfaceBuilderRecords(archive({ [field]: value }));
expect(parsed.objects.map(({ id }) => id)).toEqual(["1", "2", "3", "6"]);
expect(parsed.objects[0]?.attributes).toMatchObject({ [field]: value });
expect(parsed.hierarchy).toEqual([
{ objectID: "1", children: [{ objectID: "2", children: [] }] },
]);
expect(parsed.connections).toMatchObject([
{ source_id: "2", destination_id: "1" },
]);
},
);
it.each([0, 8])(
"resolves authored objectID UID %s as data without changing identity",
(index) => {
const source = archive({ objectID: uid(index) });
source.$objects.push("archived-id");
const parsed = parseInterfaceBuilderRecords(source);
expect(parsed.objects[0]).toMatchObject({
id: "1",
attributes: { objectID: index === 0 ? null : "archived-id" },
});
expect(parsed.hierarchy).toEqual([
{ objectID: "1", children: [{ objectID: "2", children: [] }] },
]);
expect(parsed.connections).toMatchObject([
{ source_id: "2", destination_id: "1" },
]);
},
);
it.each(["objectID", "object-id"])(
"keeps distinct graph nodes and links despite duplicate authored %s values",
(field) => {
const source = archive({ [field]: uid(8), title: "Parent" });
source.$objects[2] = { $class: uid(4), [field]: uid(8), title: "Child" };
// The authored string also collides with the child's numeric UID spelling.
source.$objects.push("2");
const result = buildInterfaceBuilderAnalysis({
targetSha256: "a".repeat(64),
toolVersion: "test",
documents: [
{
relativePath: "Main.nib",
archiveSha256: "b".repeat(64),
documentKind: "nib",
raw: source,
},
],
limits: interfaceBuilderLimitsSchema.parse({}),
});
const byObjectId = new Map(
result.graph.nodes.map((node) => [
node.attributes.interface_builder_object_id,
node,
]),
);
expect(byObjectId.get("1")).toMatchObject({
name: "Parent",
attributes: { [field]: "2" },
});
expect(byObjectId.get("2")).toMatchObject({
name: "Child",
attributes: { [field]: "2" },
});
expect(result.graph.edges).toContainEqual(
expect.objectContaining({
from: byObjectId.get("1")?.id,
to: byObjectId.get("2")?.id,
relation: "contains",
resolution: "observed",
}),
);
const outlet = result.graph.nodes.find(({ kind }) => kind === "outlet");
expect(result.graph.edges).toContainEqual(
expect.objectContaining({
from: byObjectId.get("2")?.id,
to: outlet?.id,
relation: "contains",
}),
);
expect(result.graph.edges).toContainEqual(
expect.objectContaining({
from: outlet?.id,
to: byObjectId.get("1")?.id,
relation: "outlet_to",
resolution: "observed",
}),
);
expect(result.documents[0]).toMatchObject({
object_count: 4,
hierarchy_complete: true,
});
expect(result.graph.truncated).toBe(false);
},
);
it.each([
"2",
{ objectID: "2" },
{ "object-id": "2" },
{ id: "2" },
{ archiveUID: 2 },
{ archiveUID: 2, className: "NSView" },
{ UID: 2, archiveUID: 2 },
{ UID: "2" },
uid(-1),
uid(99),
uid(0),
uid(4), // A class descriptor is not an archived connection endpoint.
])("does not interpret a non-object-UID connector endpoint: %j", (endpoint) => {
const source = archive();
source.$objects[3] = {
$class: uid(5),
NSSource: endpoint,
NSDestination: uid(1),
};
const parsed = parseInterfaceBuilderRecords(source);
expect(parsed.connections).toEqual([]);
expect(parsed.unresolvedConnections).toBe(1);
source.$objects[3] = {
$class: uid(5),
NSSource: uid(2),
NSDestination: endpoint,
};
expect(parseInterfaceBuilderRecords(source).connections).toMatchObject([
{ source_id: "2", destination_id: null },
]);
});
it.each([
["source", "destination"],
["from", "to"],
["owner", "target"],
])(
"keeps AppKit %s/%s role aliases only for raw UIDs",
(sourceField, destinationField) => {
const source = archive();
source.$objects[3] = {
$class: uid(5),
[sourceField]: uid(2),
[destinationField]: uid(1),
};
expect(parseInterfaceBuilderRecords(source).connections).toMatchObject([
{ source_id: "2", destination_id: "1" },
]);
source.$objects[3] = {
$class: uid(5),
[sourceField]: "2",
[destinationField]: "1",
};
expect(parseInterfaceBuilderRecords(source).unresolvedConnections).toBe(1);
},
);
it("uses a real UIKit UID even when a role alias looks like a generated stub", () => {
const source = archive();
source.$objects[5] = {
$classname: "UIRuntimeOutletConnection",
$classes: ["UIRuntimeOutletConnection", "NSObject"],
};
source.$objects[3] = {
$class: uid(5),
UISource: uid(2),
UIDestination: uid(1),
source: { archiveUID: 1 },
};
expect(parseInterfaceBuilderRecords(source).connections).toMatchObject([
{ source_id: "2", destination_id: "1" },
]);
source.$objects[3] = {
$class: uid(5),
UISource: { archiveUID: 2 },
UIDestination: uid(1),
source: uid(2),
};
expect(parseInterfaceBuilderRecords(source).unresolvedConnections).toBe(1);
});
it("keeps a dangling class's owned object UID as a connector endpoint", () => {
const source = archive();
source.$objects[2] = { $class: uid(99) };
expect(parseInterfaceBuilderRecords(source).connections).toMatchObject([
{ source_id: "2", destination_id: "1" },
]);
});
it.each([
{ objectID: "1", children: [uid(2)] },
{ "object-id": "1", children: [uid(2)] },
{ archiveUID: 1, className: "NSView", children: [uid(2)] },
])("marks unsupported inline hierarchy identity incomplete: %j", (inline) => {
const source = archive({}, { root: inline });
const parsed = parseInterfaceBuilderRecords(source);
expect(parsed.hierarchy).toEqual([{ objectID: "2", children: [] }]);
expect(parsed.omittedHierarchyReferences).toBe(1);
const result = buildInterfaceBuilderAnalysis({
targetSha256: "a".repeat(64),
toolVersion: "test",
documents: [
{
relativePath: "Main.nib",
archiveSha256: "b".repeat(64),
documentKind: "nib",
raw: source,
},
],
limits: interfaceBuilderLimitsSchema.parse({}),
});
expect(result.documents[0]?.hierarchy_complete).toBe(false);
expect(result.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "hierarchy:Main.nib",
status: "partial",
reason: "archive_hierarchy_references_missing",
examined: 1,
omitted: 1,
}),
);
});
it("selects the named UIKit top-level collection before a generic root alias", () => {
const parsed = parseInterfaceBuilderRecords(
archive(
{},
{
UINibTopLevelObjectsKey: uid(6),
root: uid(2),
},
),
);
expect(parsed.hierarchy).toEqual([
{ objectID: "1", children: [{ objectID: "2", children: [] }] },
]);
});
it.each([
{ UINibTopLevelObjectsKey: "opaque", root: uid(1) },
{ UINibTopLevelObjectsKey: null, root: uid(1) },
{ UITopLevelObjectsKey: uid(6) },
])("keeps an unsupported named root incomplete: %j", (top) => {
const result = buildInterfaceBuilderAnalysis({
targetSha256: "a".repeat(64),
toolVersion: "test",
documents: [
{
relativePath: "Main.nib",
archiveSha256: "b".repeat(64),
documentKind: "nib",
raw: archive({}, top),
},
],
limits: interfaceBuilderLimitsSchema.parse({}),
});
expect(result.documents[0]?.hierarchy_complete).toBe(false);
expect(result.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "hierarchy:Main.nib",
status: "unsupported",
reason: "archive_hierarchy_not_decoded",
examined: 0,
omitted: 0,
}),
);
expect(
result.graph.edges.filter(({ id }) => id.includes(":hierarchy:")),
).toEqual([]);
});
it("counts a dangling named root without substituting a valid generic root", () => {
const parsed = parseInterfaceBuilderRecords(
archive(
{},
{
UINibTopLevelObjectsKey: uid(99),
root: uid(1),
},
),
);
expect(parsed.hierarchy).toEqual([]);
expect(parsed.omittedHierarchyReferences).toBe(1);
});
@@ -245,14 +245,19 @@ const keyedArchiveTable = (objectTable: readonly unknown[]) => {
? text
: undefined;
};
const text = (value: unknown): string | null => {
const uid = archiveUid(value);
return firstString(
uid === undefined ? value : archivedString(objectTable[uid]),
);
/** Only an encoded UID proves which archived object a connector names. */
const objectReference = (...values: unknown[]): string | null => {
for (const value of values) {
const uid = archiveUid(value);
if (
uid !== undefined &&
uid !== 0 &&
Object.hasOwn(record(objectTable[uid]), "$class")
)
return String(uid);
}
return null;
};
const authoredId = (item: Record<string, unknown>): 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];
@@ -260,10 +265,8 @@ const keyedArchiveTable = (objectTable: readonly unknown[]) => {
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),
};
};
@@ -291,21 +294,11 @@ const keyedArchiveTable = (objectTable: readonly unknown[]) => {
];
const appendNode = (
item: Record<string, unknown>,
uid: number | undefined,
uid: number,
output: unknown[],
): void => {
const objectId = firstString(
authoredId(item),
text(item.id),
uid === undefined ? null : String(uid),
);
if (objectId === null) {
for (const [key, child] of Object.entries(item).reverse())
if (HIERARCHY_KEY.test(key)) pending.push({ value: child, output });
return;
}
const children: unknown[] = [];
output.push({ objectID: objectId, children });
output.push({ objectID: String(uid), children });
for (const [key, child] of Object.entries(item).reverse())
if (HIERARCHY_KEY.test(key))
pending.push({ value: child, output: children });
@@ -352,7 +345,11 @@ const keyedArchiveTable = (objectTable: readonly unknown[]) => {
if (child !== undefined) pending.push({ value: child, output });
}
} else if (typeof value === "object" && value !== null) {
appendNode(record(value), undefined, output);
// Inline dictionaries have no object-table identity. Preserve any
// independently referenced children without inventing a parent ID.
omittedReferences += 1;
for (const [key, child] of Object.entries(value).reverse())
if (HIERARCHY_KEY.test(key)) pending.push({ value: child, output });
}
}
return { items: root, omittedReferences };
@@ -381,6 +378,7 @@ const keyedArchiveTable = (objectTable: readonly unknown[]) => {
hasClassSuperclass,
classChainOf,
classTable,
objectReference,
};
};
@@ -398,6 +396,7 @@ const parseKeyedArchive = (
hasClassSuperclass,
classChainOf,
classTable,
objectReference,
} = keyedArchiveTable(objectTable);
const objects: InterfaceBuilderObject[] = [];
const connections: InterfaceBuilderConnection[] = [];
@@ -416,11 +415,9 @@ const parseKeyedArchive = (
const fields = { ...resolved };
// Keep the unresolvable class reference itself, e.g. a dangling UID.
if (className === null) fields.$class = record(raw).$class;
const authoredId = firstString(fields.objectID, fields["object-id"]);
// Object identity comes from the archive table. `archiveUID` is ordinary
// application data when it appears on an archived object; only UID
// reference stubs select object-table entries.
const objectId = authoredId ?? String(index);
// Authored objectID, object-id, id and archiveUID fields are data. Only
// the original table position identifies an archived object.
const objectId = String(index);
const kind = classifyObject(className, classChainOf(raw));
if (objects.length < 20_000)
objects.push(
@@ -450,13 +447,14 @@ const parseKeyedArchive = (
// class-specific field namespaces; the source is the outlet owner.
const connectionFields = interfaceBuilderConnectionFieldNames(className);
if (isSourceLessAppKitAnnotationConnector(className, fields)) continue;
const rawFields = record(raw);
const source = uiKit
? archivedObjectReference(fields[connectionFields.source])
: firstObjectReference(
controlAction ? fields.destination : fields.source,
archivedObjectReference(fields[connectionFields.source]),
controlAction ? fields.to : fields.from,
controlAction ? fields.target : fields.owner,
? objectReference(rawFields[connectionFields.source])
: objectReference(
controlAction ? rawFields.destination : rawFields.source,
rawFields[connectionFields.source],
controlAction ? rawFields.to : rawFields.from,
controlAction ? rawFields.target : rawFields.owner,
);
if (source === null) {
// A recognized connector that cannot name its owner is an unresolved
@@ -471,12 +469,12 @@ const parseKeyedArchive = (
kind,
source_id: source,
destination_id: uiKit
? archivedObjectReference(fields[connectionFields.destination])
: firstObjectReference(
controlAction ? fields.source : fields.destination,
archivedObjectReference(fields[connectionFields.destination]),
controlAction ? fields.from : fields.to,
controlAction ? fields.owner : fields.target,
? objectReference(rawFields[connectionFields.destination])
: objectReference(
controlAction ? rawFields.source : rawFields.destination,
rawFields[connectionFields.destination],
controlAction ? rawFields.from : rawFields.to,
controlAction ? rawFields.owner : rawFields.target,
),
label: uiKit
? firstString(fields[connectionFields.label])
@@ -494,7 +492,13 @@ const parseKeyedArchive = (
}
}
const top = record(root.$top);
const hierarchy = toHierarchy(top.root ?? top.UITopLevelObjectsKey);
// UIKit names its top-level collection explicitly. A present but undecoded
// named root must not be replaced by an unrelated generic root alias.
const hierarchy = toHierarchy(
Object.hasOwn(top, "UINibTopLevelObjectsKey")
? top.UINibTopLevelObjectsKey
: top.root,
);
const classes = classTable();
return {
objects,
@@ -520,35 +524,6 @@ export const firstString = (...values: unknown[]): string | null => {
return null;
};
/**
* The node identity of a resolved archive reference: its authored object ID,
* otherwise its archive UID, which is how the referenced object's node is keyed.
*/
const archivedObjectReference = (value: unknown): string | null => {
const item = record(value);
if (typeof item.archiveUID !== "number") return null;
return (
firstString(item.objectID, item["object-id"]) ?? String(item.archiveUID)
);
};
const firstObjectReference = (...values: unknown[]): string | null => {
for (const value of values) {
const direct = firstString(value);
if (direct !== null) return direct;
const item = record(value);
const objectId = firstString(
item.objectID,
item["object-id"],
item.id,
item.identifier,
typeof item.archiveUID === "number" ? String(item.archiveUID) : null,
);
if (objectId !== null) return objectId;
}
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.
@@ -1,13 +1,15 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { join } from "node:path";
import { build } from "plist";
import { build, buildBinary } from "plist";
import { expect } from "vitest";
import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { cliTest } from "../../support/cli/cliFixture.js";
/** Write an app bundle whose only Interface Builder document is `Main.nib`. */
@@ -94,6 +96,159 @@ cliTest(
},
);
cliTest.for(["xml", "binary"] as const)(
"preserves UID identity despite colliding authored IDs in %s through CLI and MCP",
async (format, { cli }) => {
// Source-derived keyed-nib records, not output from an Apple compiler.
const uid = (value: number) =>
format === "binary" ? { UID: value } : { CF$UID: value };
const source = {
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: [
"$null",
{
$class: uid(4),
id: "2",
archiveUID: 2,
objectID: uid(8),
"object-id": uid(9),
title: "Root",
NSSubviews: [uid(2)],
},
{
$class: uid(4),
objectID: uid(8),
"object-id": uid(9),
title: "Child",
},
{
$class: uid(5),
objectID: uid(8),
UISource: uid(2),
UIDestination: uid(1),
UILabel: "parent",
},
{
$classname: "ArchiveProbeView",
$classes: ["ArchiveProbeView", "UIView", "NSObject"],
},
{
$classname: "UIRuntimeOutletConnection",
$classes: [
"UIRuntimeOutletConnection",
"UIRuntimeConnection",
"NSObject",
],
},
{ $class: uid(7), "NS.objects": [uid(1)] },
{ $classname: "NSArray", $classes: ["NSArray", "NSObject"] },
"2",
"shared",
],
$top: { UINibTopLevelObjectsKey: uid(6) },
};
const bytes = Buffer.from(
format === "binary" ? buildBinary(source) : build(source),
);
const app = await writeUIKitApp("rea-keyed-nib-hierarchy-", bytes);
const path = join(app, "Contents/Resources/Main.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 evidence = parseEvidence(output.json);
const { call } = await connectLocalToolsMcp();
const opened = await call("open_bundle_resources", { path: app });
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
const reply = await call("decode_interface_builder", {});
expect(reply.isError, JSON.stringify(reply)).not.toBe(true);
expect(parseEvidence(reply.structuredContent).normalized_result).toEqual(
evidence.normalized_result,
);
const report = interfaceBuilderAnalysisSchema.parse(
evidence.normalized_result,
);
const nodes = new Map(report.graph.nodes.map((node) => [node.id, node]));
expect(
report.graph.edges
.filter(({ id }) => id.includes(":hierarchy:"))
.map(({ from, to, resolution }) => ({
from:
nodes.get(from)?.attributes.interface_builder_object_id ??
"document",
to:
to === null
? null
: nodes.get(to)?.attributes.interface_builder_object_id,
resolution,
})),
).toEqual([
{ from: "document", to: "1", resolution: "observed" },
{ from: "1", to: "2", resolution: "observed" },
]);
expect(
report.graph.nodes.find(
({ attributes }) => attributes.interface_builder_object_id === "1",
),
).toMatchObject({
attributes: {
id: "2",
archiveUID: 2,
objectID: "2",
"object-id": "shared",
title: "Root",
},
});
const root = report.graph.nodes.find(
({ attributes }) => attributes.interface_builder_object_id === "1",
);
const child = report.graph.nodes.find(
({ attributes }) => attributes.interface_builder_object_id === "2",
);
expect(child).toMatchObject({
name: "Child",
attributes: { objectID: "2", "object-id": "shared" },
});
const outlet = report.graph.nodes.find(({ kind }) => kind === "outlet");
expect(outlet).toMatchObject({
id: 'ib:"Contents/Resources/Main.nib":outlet:["archive:3"]',
});
expect(report.graph.edges).toContainEqual(
expect.objectContaining({
from: child?.id,
to: outlet?.id,
relation: "contains",
resolution: "observed",
}),
);
expect(report.graph.edges).toContainEqual(
expect.objectContaining({
from: outlet?.id,
to: root?.id,
relation: "outlet_to",
resolution: "observed",
}),
);
expect(report.documents[0]).toMatchObject({
archive_sha256: createHash("sha256").update(bytes).digest("hex"),
connection_count: 1,
hierarchy_complete: true,
});
expect(report.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "hierarchy:Contents/Resources/Main.nib",
status: "complete",
examined: 2,
omitted: 0,
}),
);
expect(report.graph.truncated).toBe(false);
expect(await readFile(path)).toEqual(bytes);
},
);
cliTest(
"classifies UIKit keyed-archive objects by their declared superclasses through CLI",
async ({ cli }) => {
@@ -563,7 +563,7 @@ describe("keyed archive hierarchy coverage", () => {
]),
);
const rootId = analysis.graph.nodes.find(
({ attributes }) => attributes.interface_builder_object_id === "root",
({ attributes }) => attributes.interface_builder_object_id === "1",
)?.id;
const childNames = analysis.graph.edges
.filter(
@@ -573,7 +573,7 @@ describe("keyed archive hierarchy coverage", () => {
id.includes(":hierarchy:"),
)
.map(({ to }) => (to === null ? null : archiveIdsByNodeId.get(to)));
expect(childNames).toEqual(["first", "second"]);
expect(childNames).toEqual(["3", "5"]);
});
it.skipIf(process.platform !== "darwin")(