diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 0b3ce232cd..936e77179c 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -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": "" }`. diff --git a/src/domain/apple/interfaceBuilderKeyedArchive.test.ts b/src/domain/apple/interfaceBuilderKeyedArchive.test.ts new file mode 100644 index 0000000000..e4ac3c2dd9 --- /dev/null +++ b/src/domain/apple/interfaceBuilderKeyedArchive.test.ts @@ -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 = {}, + top: Record = { 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); +}); diff --git a/src/domain/apple/interfaceBuilderKeyedArchive.ts b/src/domain/apple/interfaceBuilderKeyedArchive.ts index 0942951115..f749cb985a 100644 --- a/src/domain/apple/interfaceBuilderKeyedArchive.ts +++ b/src/domain/apple/interfaceBuilderKeyedArchive.ts @@ -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 | 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, - 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. diff --git a/tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts b/tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts index 0665922320..7a3b52e89a 100644 --- a/tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts +++ b/tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts @@ -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 }) => { diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 67a56c1f96..4ee853b117 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -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")(