mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
1 parent
5e4004c413
commit
6fbe67d66e
5 files changed
+557
-77
No files matched your search
@@ -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")(
|
||||
|
||||
Reference in new issue
Block a user