diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 3bbcd635c3..73d5bc43e9 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -700,10 +700,16 @@ complete observation. release their representation reservation for later documents. Repeated NIBArchive fields retain all serialized values in order. Array outlet connections resolve each serialized member and retain the collection - object ID and member position on the outlet observation. AppKit object-parent - registries use paired keys and parents; objects paired with `NSRoot` are - top-level, while nested objects keep their recorded parent. Conflicting or - unreadable registry pairs remain incomplete hierarchy evidence. + object ID and member position on the outlet observation. NIB hierarchy uses + recognized child fields, reverse `NSSuperview` links and window content links. + AppKit registry pairs with a resolvable `NSRoot` owner establish top-level + membership; other registry associations alone do not establish visual + parentage and leave hierarchy coverage partial. Known non-view cell ownership + and control-view backreferences remain raw facts. Conflicting explicit parents + remain unknown rather than choosing an archive-order winner. Retained objects + keep their decoded fields; uncertain registry diagnostics retain the source + registry, child and value IDs. Missing, malformed or repeated registry + declarations also qualify hierarchy coverage. `NSString` objects with the [recognized `NS.bytes` representation](https://github.com/swiftlang/swift-corelibs-foundation/blob/main/Sources/Foundation/NSString.swift#L201-L215) are decoded as UTF-8 without substituting malformed bytes. A failed conversion retains the source string object ID and `nibValues.NS.bytes` base64 data; dependent diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index ef2c593229..852dd850c3 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -49,11 +49,7 @@ import { interfaceBuilderLimitsSchema, type InterfaceBuilderDocumentInput, } from "../../domain/apple/interfaceBuilderGraph.js"; -import { - mergeNibHierarchies, - projectNibViewHierarchy, - type NibHierarchyNode, -} from "./NibViewHierarchy.js"; +import { projectNibViewHierarchy } from "./NibViewHierarchy.js"; import { jsonValueSchema, type JsonValue } from "../../domain/jsonValue.js"; import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; @@ -806,7 +802,6 @@ const projectNibArchive = ( .map(projectObject), ); const connections: Record = {}; - const hierarchy: NibHierarchyNode[] = []; // A recognized connector whose owner reference cannot be resolved produces no // connection entry, so the omission has to be counted here: the projected // dictionary carries no trace of it for a downstream consumer to find. @@ -904,80 +899,26 @@ const projectNibArchive = ( facets: ["objects", "connections", "classes"], description: `NSString NS.bytes is not valid UTF-8 for ${unavailableStringIds.size} source objects; their bytes and object IDs are retained, while dependent class names, titles and selectors remain unknown.`, }); - let hierarchyOmitted = 0; - let hierarchyNodeCount = 0; - const hierarchyFor = ( - objectId: number, - seen: Set, - depth: number, - ): NibHierarchyNode | null => { - if (depth > 32 || seen.has(objectId)) { - hierarchyOmitted += 1; - return null; - } - if (hierarchyNodeCount >= maxHierarchyNodes) { - hierarchyOmitted += 1; - return null; - } - if (!budget.tryReserve(128)) { - hierarchyOmitted += 1; - return null; - } - const object = byId.get(objectId); - if (object === undefined) { - hierarchyOmitted += 1; - return null; - } - hierarchyNodeCount += 1; - const nextSeen = new Set(seen).add(objectId); - const children: NibHierarchyNode[] = []; - for (const [key, value] of Object.entries(object.values)) { - if (!/(?:subviews|contentview|childviewcontrollers|view)$/iu.test(key)) - continue; - const direct = reference(value); - if (direct === null) continue; - const target = byId.get(direct); - if (target === undefined) { - hierarchyOmitted += 1; - continue; - } - if (/array|set/iu.test(target.class_name)) { - for (const candidate of nibCollectionMembers(target) ?? []) { - const child = reference(candidate); - const nested = - child === null ? null : hierarchyFor(child, nextSeen, depth + 1); - if (nested !== null) children.push(nested); - } - } else { - const nested = hierarchyFor(direct, nextSeen, depth + 1); - if (nested !== null) children.push(nested); - } - } - return { objectID: String(objectId), children }; - }; - const ibData = archive.objects.find( - ({ class_name }) => normalizeClassName(class_name) === "NSIBObjectData", - ); - const rootId = reference(ibData?.values.NSRoot); - const hierarchyRoots = rootId === null ? [] : [rootId]; - for (const hierarchyRoot of hierarchyRoots) { - const root = hierarchyFor(hierarchyRoot, new Set(), 0); - if (root !== null) hierarchy.push(root); - } const viewHierarchy = projectNibViewHierarchy( archive.objects, new Set(Object.keys(objects).map(Number)), () => budget.tryReserve(128), maxHierarchyNodes, - ); - const merged = mergeNibHierarchies( - hierarchy, - viewHierarchy.hierarchy ?? [], - () => budget.tryReserve(96), - maxHierarchyNodes, + (description) => { + budget.reserve( + 256, + "NIB registry hierarchy diagnostics exceed the aggregate Interface Builder decode budget", + ); + budget.reserveJsonStringOccurrence(description); + unexaminedSources.push({ + reason: "nib_registry_parentage_unexamined", + facets: ["hierarchy"], + description, + }); + }, ); return { - omitted: viewHierarchy.omitted + hierarchyOmitted + merged.omitted, + omitted: viewHierarchy.omitted, unexaminedSources, unresolvedConnections, nonFiniteRealCount, @@ -985,7 +926,7 @@ const projectNibArchive = ( raw: jsonValueSchema.parse({ "com.apple.ibtool.document.objects": objects, "com.apple.ibtool.document.connections": connections, - "com.apple.ibtool.document.hierarchy": merged.hierarchy, + "com.apple.ibtool.document.hierarchy": viewHierarchy.hierarchy ?? [], "com.apple.ibtool.document.classes": Object.fromEntries( archive.classes.map((name) => { const className = normalizeClassName(name); diff --git a/src/artifacts/apple/NibViewHierarchy.test.ts b/src/artifacts/apple/NibViewHierarchy.test.ts index bcda88ee0c..4aedb7f11f 100644 --- a/src/artifacts/apple/NibViewHierarchy.test.ts +++ b/src/artifacts/apple/NibViewHierarchy.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vitest"; -import { - mergeNibHierarchies, - projectNibViewHierarchy, -} from "./NibViewHierarchy.js"; +import { projectNibViewHierarchy } from "./NibViewHierarchy.js"; const object = (id: number, parent?: number) => ({ id, @@ -13,7 +10,7 @@ const object = (id: number, parent?: number) => ({ }); describe("serialized NIB view hierarchy bounds", () => { - it("leaves archives without superview links to the legacy projection", () => { + it("does not invent hierarchy for objects without serialized relationships", () => { expect(projectNibViewHierarchy([object(0)], new Set([0]))).toEqual({ hierarchy: null, omitted: 0, @@ -113,77 +110,32 @@ describe("serialized NIB view hierarchy bounds", () => { }); }); -describe("legacy NIB hierarchy preservation", () => { - it("keeps encoded window parents while merging shared views only once", () => { - const legacy = [ +it("does not treat a dangling registry owner as top-level authority", () => { + const result = projectNibViewHierarchy( + [ { - objectID: "window", - children: [ - { objectID: "view", children: [{ objectID: "first", children: [] }] }, - ], + id: 0, + class_name: "NSIBObjectData", + values: { + NSRoot: { $nib_object_ref: 99 }, + NSObjectsKeys: { $nib_object_ref: 2 }, + NSObjectsValues: { $nib_object_ref: 3 }, + }, }, - ]; - const recovered = [ + object(1), { - objectID: "view", - children: [ - { objectID: "first", children: [] }, - { objectID: "second", children: [] }, - ], + id: 2, + class_name: "NSArray", + values: { "NS.objects": { $nib_object_ref: 1 } }, }, - ]; - expect(mergeNibHierarchies(legacy, recovered).hierarchy).toEqual([ { - objectID: "window", - children: [ - { - objectID: "view", - children: [ - { objectID: "first", children: [] }, - { objectID: "second", children: [] }, - ], - }, - ], + id: 3, + class_name: "NSArray", + values: { "NS.objects": { $nib_object_ref: 99 } }, }, - ]); - }); - it("moves an existing root under its encoded parent without duplicate visits", () => { - expect( - mergeNibHierarchies( - [{ objectID: "child", children: [] }], - [ - { - objectID: "parent", - children: [{ objectID: "child", children: [] }], - }, - ], - ), - ).toEqual({ - hierarchy: [ - { objectID: "parent", children: [{ objectID: "child", children: [] }] }, - ], - omitted: 0, - }); - }); - it("reports conflicting encoded parents rather than dropping a link silently", () => { - const legacy = [ - { objectID: "first", children: [{ objectID: "child", children: [] }] }, - ]; - const recovered = [ - { objectID: "second", children: [{ objectID: "child", children: [] }] }, - ]; - expect(mergeNibHierarchies(legacy, recovered).omitted).toBe(1); - }); - it("does not introduce a cycle when moving an existing root", () => { - const legacy = [ - { objectID: "child", children: [{ objectID: "parent", children: [] }] }, - ]; - const recovered = [ - { objectID: "parent", children: [{ objectID: "child", children: [] }] }, - ]; - expect(mergeNibHierarchies(legacy, recovered)).toEqual({ - hierarchy: legacy, - omitted: 1, - }); - }); + ], + new Set([1]), + ); + expect(result.hierarchy).toEqual([]); + expect(result.omitted).toBeGreaterThan(0); }); diff --git a/src/artifacts/apple/NibViewHierarchy.ts b/src/artifacts/apple/NibViewHierarchy.ts index 6b6741809f..53e7b0bf25 100644 --- a/src/artifacts/apple/NibViewHierarchy.ts +++ b/src/artifacts/apple/NibViewHierarchy.ts @@ -6,25 +6,195 @@ export type NibHierarchyNode = { children: NibHierarchyNode[]; }; -/** Project encoded view parents; archive owners are not inferred view parents. */ +const childCollections = new Set([ + "subviews", + "NSSubviews", + "UISubviews", + "childViewControllers", + "NSChildViewControllers", + "UIChildViewControllers", +]); +const childReferences = new Set([ + "view", + "NSView", + "UIView", + "contentView", + "NSContentView", + "UIContentView", +]); + +// AppKit declares NSButtonCell : NSActionCell : NSCell : NSObject, not NSView. +// NSActionCell encodes its control-view backreference as NSControlView. +const nonViewCellClasses = new Set(["NSCell", "NSActionCell", "NSButtonCell"]); +const isNonViewCell = ( + object: NibArchiveDocument["objects"][number] | undefined, +) => + object !== undefined && + nonViewCellClasses.has(object.class_name.replace(/\0+$/u, "")); + +/** Interpret serialized relationships once, before bounded forest materialization. */ export const projectNibViewHierarchy = ( objects: NibArchiveDocument["objects"], retainedIds: ReadonlySet, reserveNode: () => boolean = () => true, maxNodes = 20_000, + reportUnknownRegistry: (description: string) => void = () => {}, ) => { - const { parents, involved, invalid } = collectNibParents( - objects, - retainedIds, - ); - if (involved.size === 0) return { hierarchy: null, omitted: invalid }; + const byId = new Map(objects.map((object) => [object.id, object])); + const parents = new Map(); + const involved = new Set(); + const registryUnknown = new Set(); + let invalid = 0; + let ownerWithoutRegistry = false; + const addParent = (child: number, parent: number) => { + if (retainedIds.has(parent)) involved.add(parent); + if (!retainedIds.has(child)) { + invalid += 1; + return; + } + involved.add(child); + const previous = parents.get(child); + // A disagreement invalidates placement; archive order is not authority. + parents.set( + child, + previous === undefined || previous === parent ? parent : null, + ); + }; + const fieldValues = ( + object: NibArchiveDocument["objects"][number], + key: string, + ) => + object.repeated_values?.[key] ?? + (Object.hasOwn(object.values, key) ? [object.values[key]!] : []); + const addReference = (value: JsonValue, parent: number) => { + if (value === null) return; + const child = reference(value); + if (child === null) invalid += 1; + else addParent(child, parent); + }; + for (const object of objects) { + if (!retainedIds.has(object.id)) continue; + for (const value of fieldValues(object, "NSSuperview")) { + if (value === null) continue; + const parent = reference(value); + if (parent === null) invalid += 1; + else addParent(object.id, parent); + } + const keys = new Set([ + ...Object.keys(object.values), + ...Object.keys(object.repeated_values ?? {}), + ]); + for (const key of keys) { + if (childCollections.has(key)) { + for (const value of fieldValues(object, key)) { + if (value === null) continue; + const collection = reference(value); + const members = nibCollectionMembers( + collection === null ? undefined : byId.get(collection), + ); + if (members === undefined) invalid += 1; + else { + involved.add(object.id); + for (const member of members) addReference(member, object.id); + } + } + } else if ( + childReferences.has(key) || + (key === "NSWindowView" && + object.class_name.replace(/\0+$/u, "") === "NSWindowTemplate") + ) { + for (const value of fieldValues(object, key)) + addReference(value, object.id); + } else if ( + !["NSSuperview", "NSNextKeyView", "NSPreviousKeyView"].includes(key) && + !(key === "NSControlView" && isNonViewCell(object)) && + /(?:subviews|contentview|childviewcontrollers|view)$/iu.test(key) + ) { + // Preserve formerly admitted fields as raw facts without inventing semantics. + invalid += 1; + } + } + } + for (const object of objects) { + if (object.class_name.replace(/\0+$/u, "") !== "NSIBObjectData") continue; + const registryFields = ["NSRoot", "NSObjectsKeys", "NSObjectsValues"]; + const ambiguous = registryFields.filter( + (key) => fieldValues(object, key).length > 1, + ); + if (ambiguous.length > 0) { + invalid += 1; + reportUnknownRegistry( + `NSIBObjectData object ${object.id} has repeated ${ambiguous.join(", ")} declarations; registry hierarchy interpretation remains unknown.`, + ); + continue; + } + const rootValue = fieldValues(object, "NSRoot")[0]; + const keysValue = fieldValues(object, "NSObjectsKeys")[0]; + const valuesValue = fieldValues(object, "NSObjectsValues")[0]; + const ownerReference = reference(rootValue); + const owner = + ownerReference !== null && byId.has(ownerReference) + ? ownerReference + : null; + if (rootValue !== undefined && rootValue !== null && owner === null) + invalid += 1; + const keysRef = reference(keysValue); + const valuesRef = reference(valuesValue); + if (keysValue === undefined && valuesValue === undefined) { + if (rootValue !== undefined) ownerWithoutRegistry = true; + continue; + } + const keys = nibCollectionMembers( + keysRef === null ? undefined : byId.get(keysRef), + ); + const values = nibCollectionMembers( + valuesRef === null ? undefined : byId.get(valuesRef), + ); + if ( + keys === undefined || + values === undefined || + keys.length !== values.length + ) { + invalid += 1; + continue; + } + for (let index = 0; index < keys.length; index += 1) { + const child = reference(keys[index]); + const parent = reference(values[index]); + if (child === null || parent === null) { + invalid += 1; + continue; + } + if (!retainedIds.has(child) || isNonViewCell(byId.get(child))) continue; + involved.add(child); + // The registry identifies top-level membership through the owner. + // Other registry associations alone do not establish view parentage. + if (owner !== null && parent === owner) { + if (parents.has(child)) parents.set(child, null); + } else { + registryUnknown.add(child); + if (!parents.has(child)) + reportUnknownRegistry( + `NSIBObjectData object ${object.id}: NSObjectsKeys/NSObjectsValues pair child ${child} with registry value ${parent}; NSRoot is ${ownerReference === null ? "unresolved" : owner === null ? `${ownerReference} (unresolved)` : owner}. This decoded registry association does not establish visual parentage.`, + ); + } + } + } + if (ownerWithoutRegistry) invalid += 1; + for (const child of registryUnknown) + if (!parents.has(child)) parents.set(child, null); const children = new Map(); for (const [child, parent] of parents) { - if (!retainedIds.has(parent) || !retainedIds.has(child)) continue; + if (parent === null || !retainedIds.has(parent)) continue; const siblings = children.get(parent) ?? []; siblings.push(child); children.set(parent, siblings); } + if (involved.size === 0) + return { + hierarchy: null, + omitted: invalid, + }; const roots: NibHierarchyNode[] = []; const pending: { id: number; depth: number; output: NibHierarchyNode[] }[] = []; @@ -47,101 +217,7 @@ export const projectNibViewHierarchy = ( for (const child of [...(children.get(item.id) ?? [])].reverse()) pending.push({ id: child, depth: item.depth + 1, output: node.children }); } - return { - hierarchy: roots, - omitted: involved.size - visited.size + invalid, - }; -}; - -/** Add encoded parent links once; conflicting legacy parents remain unknown. */ -export const mergeNibHierarchies = ( - legacy: NibHierarchyNode[], - recovered: NibHierarchyNode[], - reserveNode: () => boolean = () => true, - maxNodes = 20_000, -) => { - const existing = new Map(); - const owners = new Map(); - const parents = new Map(); - const pending = legacy.map((node) => ({ - node, - output: legacy, - parent: null as string | null, - })); - while (pending.length > 0) { - const entry = pending.pop(); - if (entry === undefined || existing.has(entry.node.objectID)) continue; - existing.set(entry.node.objectID, entry.node); - owners.set(entry.node.objectID, entry.output); - if (entry.parent !== null) parents.set(entry.node.objectID, entry.parent); - pending.push( - ...entry.node.children.map((node) => ({ - node, - output: entry.node.children, - parent: entry.node.objectID, - })), - ); - } - let omitted = 0; - const additions = recovered.map((node) => ({ - node, - output: legacy, - parent: null as string | null, - })); - while (additions.length > 0) { - const entry = additions.pop(); - if (entry === undefined) break; - let target = existing.get(entry.node.objectID); - if (target === undefined) { - if (existing.size >= maxNodes || !reserveNode()) { - omitted += 1; - continue; - } - target = { objectID: entry.node.objectID, children: [] }; - existing.set(target.objectID, target); - entry.output.push(target); - owners.set(target.objectID, entry.output); - if (entry.parent !== null) parents.set(target.objectID, entry.parent); - } else if ( - entry.parent !== null && - owners.get(target.objectID) !== entry.output - ) { - const owner = owners.get(target.objectID); - if ( - owner !== legacy || - wouldCycle(target.objectID, entry.parent, parents) - ) { - omitted += 1; - } else { - owner.splice(owner.indexOf(target), 1); - entry.output.push(target); - owners.set(target.objectID, entry.output); - parents.set(target.objectID, entry.parent); - } - } - for (const node of [...entry.node.children].reverse()) - additions.push({ - node, - output: target.children, - parent: target.objectID, - }); - } - return { hierarchy: legacy, omitted }; -}; - -const wouldCycle = ( - child: string, - parent: string, - parents: ReadonlyMap, -): boolean => { - const seen = new Set(); - let current: string | undefined = parent; - while (current !== undefined && !seen.has(current)) { - if (current === child) return true; - seen.add(current); - current = parents.get(current); - } - return false; + return { hierarchy: roots, omitted: involved.size - visited.size + invalid }; }; const reference = (value: JsonValue | undefined): number | null => { @@ -151,84 +227,3 @@ const reference = (value: JsonValue | undefined): number | null => { ? value.$nib_object_ref : null; }; - -const collectNibParents = ( - objects: NibArchiveDocument["objects"], - retainedIds: ReadonlySet, -) => { - const parents = new Map(); - const involved = new Set(); - let invalid = 0; - for (const object of objects) { - if (!retainedIds.has(object.id)) continue; - const child = reference(object.values.NSWindowView); - if ( - object.class_name.replace(/\0+$/u, "") === "NSWindowTemplate" && - invalidReference(object.values.NSWindowView) - ) - invalid += 1; - if ( - object.class_name.replace(/\0+$/u, "") === "NSWindowTemplate" && - child !== null - ) { - parents.set(child, object.id); - involved.add(object.id); - involved.add(child); - } - const parent = reference(object.values.NSSuperview); - if (invalidReference(object.values.NSSuperview)) invalid += 1; - if (parent === null) continue; - parents.set(object.id, parent); - involved.add(object.id); - if (retainedIds.has(parent)) involved.add(parent); - } - const byId = new Map(objects.map((object) => [object.id, object])); - for (const object of objects) { - if (object.class_name.replace(/\0+$/u, "") !== "NSIBObjectData") continue; - const keysRef = reference(object.values.NSObjectsKeys); - const valuesRef = reference(object.values.NSObjectsValues); - if (keysRef === null && valuesRef === null) continue; - const keys = nibCollectionMembers( - keysRef === null ? undefined : byId.get(keysRef), - ); - const values = nibCollectionMembers( - valuesRef === null ? undefined : byId.get(valuesRef), - ); - if ( - keys === undefined || - values === undefined || - keys.length !== values.length - ) { - invalid += 1; - continue; - } - const rootOwner = reference(object.values.NSRoot); - for (let index = 0; index < keys.length; index += 1) { - const child = reference(keys[index]); - const parent = reference(values[index]); - if (child === null || parent === null) { - invalid += 1; - continue; - } - // Registry keys include non-UI records. Only retained objects contribute - // hierarchy, and a paired NSRoot owner identifies a top-level object. - if (!retainedIds.has(child)) continue; - involved.add(child); - const prior = parents.get(child); - if (parent === rootOwner) { - if (prior !== undefined) invalid += 1; - continue; - } - if (prior !== undefined && prior !== parent) { - invalid += 1; - continue; - } - parents.set(child, parent); - if (retainedIds.has(parent)) involved.add(parent); - } - } - return { parents, involved, invalid }; -}; - -const invalidReference = (value: JsonValue | undefined): boolean => - value !== undefined && value !== null && reference(value) === null; diff --git a/tests/acceptance/applications/interfaceBuilderNibRelationships.test.ts b/tests/acceptance/applications/interfaceBuilderNibRelationships.test.ts new file mode 100644 index 0000000000..043fb57753 --- /dev/null +++ b/tests/acceptance/applications/interfaceBuilderNibRelationships.test.ts @@ -0,0 +1,314 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, onTestFinished } from "vitest"; +import { build } from "plist"; + +import { encodeNibArchiveFixture } from "../../../src/artifacts/apple/NibArchive.fixture.js"; +import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js"; +import { thinMach } from "../../../src/domain/binaryTarget.fixture.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { createTestMcp } from "../../support/mcp/mcpFixture.js"; +import { openBundleResourcesTarget } from "../../support/mcp/artifactSession.js"; + +type Objects = Parameters[0]["objects"]; +const cases: readonly { + name: string; + objects: Objects; + links: readonly (readonly [string, string])[]; + complete: boolean; + registryDiagnostic?: string; + decodeRejected?: boolean; +}[] = [ + { + name: "archive owner alone does not establish a visual hierarchy", + objects: [ + { classIndex: 0, values: { NSRoot: { ref: 1 } } }, + { classIndex: 3, values: {} }, + ], + links: [], + complete: false, + }, + { + name: "repeated registry declarations", + objects: [ + { + classIndex: 0, + values: [ + ["NSRoot", { ref: 1 }], + ["NSRoot", { ref: 2 }], + ], + }, + { classIndex: 3, values: {} }, + { classIndex: 3, values: {} }, + ], + links: [], + complete: false, + }, + { + name: "malformed registry owner with valid empty collections", + objects: [ + { + classIndex: 0, + values: { + NSRoot: "unknown", + NSObjectsKeys: { ref: 1 }, + NSObjectsValues: { ref: 2 }, + }, + }, + { classIndex: 2, values: {} }, + { classIndex: 2, values: {} }, + ], + links: [], + complete: false, + }, + { + name: "dangling registry owner is rejected at the archive boundary", + objects: [ + { + classIndex: 0, + values: { + NSRoot: { ref: 99 }, + NSObjectsKeys: { ref: 2 }, + NSObjectsValues: { ref: 3 }, + }, + }, + { classIndex: 1, values: {} }, + { classIndex: 2, values: { "NS.objects": { ref: 1 } } }, + { classIndex: 2, values: { "NS.objects": { ref: 99 } } }, + ], + links: [], + complete: false, + decodeRejected: true, + }, + { + name: "superview direction when the archive owner is a view", + objects: [ + { classIndex: 0, values: { NSRoot: { ref: 1 } } }, + { classIndex: 1, values: { NSSuperview: { ref: 2 } } }, + { classIndex: 1, values: {} }, + ], + links: [["2", "1"]], + complete: false, + }, + { + name: "conflicting child collections and superview", + objects: [ + { classIndex: 1, values: { NSSubviews: { ref: 3 } } }, + { classIndex: 1, values: { NSSuperview: { ref: 2 } } }, + { classIndex: 1, values: {} }, + { classIndex: 2, values: { "NS.objects": { ref: 1 } } }, + ], + links: [], + complete: false, + }, + { + name: "registry ownership without an encoded view parent", + registryDiagnostic: + "NSObjectsKeys/NSObjectsValues pair child 3 with registry value 2", + objects: [ + { + classIndex: 0, + values: { + NSRoot: { ref: 1 }, + NSObjectsKeys: { ref: 4 }, + NSObjectsValues: { ref: 5 }, + }, + }, + { classIndex: 3, values: {} }, + { classIndex: 3, values: {} }, + { classIndex: 1, values: {} }, + { classIndex: 2, values: { "NS.objects": { ref: 3 } } }, + { classIndex: 2, values: { "NS.objects": { ref: 2 } } }, + ], + links: [], + complete: false, + }, + { + name: "repeated identical child links", + objects: [ + { classIndex: 1, values: { subviews: { ref: 2 } } }, + { classIndex: 1, values: { NSSuperview: { ref: 0 } } }, + { + classIndex: 2, + values: [ + ["NS.objects", { ref: 1 }], + ["NS.objects", { ref: 1 }], + ], + }, + ], + links: [["0", "1"]], + complete: true, + }, + { + name: "key-view navigation is retained without visual containment", + objects: [ + { + classIndex: 1, + values: { NSNextKeyView: { ref: 1 }, NSPreviousKeyView: { ref: 1 } }, + }, + { classIndex: 1, values: {} }, + { classIndex: 1, values: { NSSuperview: { ref: 0 } } }, + ], + links: [["0", "2"]], + complete: true, + }, + { + name: "known non-view cells retain control backreferences without hierarchy claims", + objects: [ + { + classIndex: 0, + values: { + NSRoot: { ref: 1 }, + NSObjectsKeys: { ref: 4 }, + NSObjectsValues: { ref: 5 }, + }, + }, + { classIndex: 3, values: {} }, + { classIndex: 1, values: {} }, + { classIndex: 4, values: { NSControlView: { ref: 2 } } }, + { classIndex: 2, values: { "NS.objects": { ref: 3 } } }, + { classIndex: 2, values: { "NS.objects": { ref: 2 } } }, + { classIndex: 1, values: { NSSuperview: { ref: 2 } } }, + ], + links: [["2", "6"]], + complete: true, + }, + { + name: "unknown cell classes retain unknown registry parentage", + objects: [ + { + classIndex: 0, + values: { + NSRoot: { ref: 1 }, + NSObjectsKeys: { ref: 4 }, + NSObjectsValues: { ref: 5 }, + }, + }, + { classIndex: 3, values: {} }, + { classIndex: 1, values: {} }, + { classIndex: 5, values: { NSControlView: { ref: 2 } } }, + { classIndex: 2, values: { "NS.objects": { ref: 3 } } }, + { classIndex: 2, values: { "NS.objects": { ref: 2 } } }, + ], + links: [], + complete: false, + registryDiagnostic: + "NSObjectsKeys/NSObjectsValues pair child 3 with registry value 2", + }, + { + name: "unrecognized relationship fields", + objects: [ + { classIndex: 1, values: { preview: { ref: 1 } } }, + { classIndex: 1, values: {} }, + ], + links: [], + complete: false, + }, +]; + +cliTest.for(cases)( + "preserves NIB relationship evidence through CLI and MCP: $name", + async ( + { objects, links, complete, registryDiagnostic, decodeRejected }, + { cli }, + ) => { + const root = await createTestTempDirectory("rea-nib-relationships-"); + const app = join(root, "Fixture.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"), + build({ CFBundleExecutable: "Fixture" }), + ); + await writeFile( + join(resources, "Main.nib"), + encodeNibArchiveFixture({ + classes: [ + "NSIBObjectData", + "NSView", + "NSArray", + "NSCustomObject", + "NSButtonCell", + "CustomButtonCell", + ], + objects, + }), + ); + const output = await cli.run({ + arguments: ["decode-interface-builder", app, "--json"], + environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" }, + }); + expect(output.exitCode, output.stdout + output.stderr).toBe(0); + if (output.json === null) + throw new Error( + `CLI returned no JSON: ${output.stdout} ${output.stderr}`, + ); + const report = interfaceBuilderAnalysisSchema.parse( + parseEvidence(output.json).normalized_result, + ); + if (decodeRejected) { + expect(report.documents).toEqual([]); + expect(report.graph.coverage).toContainEqual( + expect.objectContaining({ facet: "archive_decode", status: "partial" }), + ); + } else expect(report.documents[0]?.hierarchy_complete).toBe(complete); + const objectId = (id: string | null) => + id === null + ? undefined + : report.graph.nodes.find((node) => node.id === id)?.attributes + .interface_builder_object_id; + const objectLinks = report.graph.edges.flatMap((edge) => { + const from = objectId(edge.from); + const to = objectId(edge.to); + return edge.relation === "contains" && + typeof from === "string" && + typeof to === "string" + ? [[from, to]] + : []; + }); + expect(objectLinks).toEqual(links); + for (const [index, object] of objects.entries()) { + if ( + decodeRejected || + ![1, 4, 5].includes(object.classIndex) || + Array.isArray(object.values) + ) + continue; + const node = report.graph.nodes.find( + ({ attributes }) => + attributes.interface_builder_object_id === String(index), + ); + for (const [key, value] of Object.entries(object.values)) { + if (typeof value !== "object" || value === null || !("ref" in value)) + continue; + expect(node?.attributes.nibValues).toMatchObject({ + [key]: { objectID: String(value.ref) }, + }); + } + } + if (registryDiagnostic !== undefined) + expect(report.limitations.join("\n")).toContain(registryDiagnostic); + const mcp = createTestMcp(); + onTestFinished(async () => mcp.close()); + const client = await openBundleResourcesTarget(mcp, onTestFinished, app); + const result = await client.callTool({ + name: "decode_interface_builder", + arguments: {}, + }); + expect(result.isError, JSON.stringify(result)).not.toBe(true); + expect( + interfaceBuilderAnalysisSchema.parse( + parseEvidence(result.structuredContent).normalized_result, + ), + ).toEqual(report); + }, +); diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index e67b515fe6..844ce0411b 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -745,8 +745,10 @@ describe("bounded Interface Builder archive decoding", () => { } }, ); +}); - it("marks repeated NIB hierarchy references partial at the caller object limit", async () => { +describe("bounded Interface Builder hierarchy and value expansion", () => { + it("deduplicates repeated NIB links while reporting the unavailable owner registry", async () => { const root = await createTestTempDirectory("rea-ib-repeated-hierarchy-"); const bundle = join(root, "Example.app"); await mkdir(bundle, { recursive: true }); @@ -794,6 +796,7 @@ describe("bounded Interface Builder archive decoding", () => { facet: "hierarchy:RepeatedHierarchy.nib", status: "partial", reason: "serialized_view_hierarchy_incomplete", + omitted: 1, }), ); });