fix(apple): derive NIB hierarchy from observed relationships

Replace overlapping traversals with one bounded relationship owner. Preserve superview direction, treat conflicting or unsupported registry placement as unknown, and retain source facts and coverage.
This commit is contained in:
morluto committed 2026-10-27 07:09:25 +08:00
1 parent db0908a732
commit 3052a067a3
6 files changed
+545 -334

No files matched your search

+10 -4
View File
@@ -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
+15 -74
View File
@@ -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<string, JsonValue[]> = {};
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<number>,
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);
+24 -72
View File
@@ -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);
});
+178 -183
View File
@@ -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<number>,
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<number, number | null>();
const involved = new Set<number>();
const registryUnknown = new Set<number>();
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<number, number[]>();
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<string, NibHierarchyNode>();
const owners = new Map<string, NibHierarchyNode[]>();
const parents = new Map<string, string>();
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<string, string>,
): boolean => {
const seen = new Set<string>();
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<number>,
) => {
const parents = new Map<number, number>();
const involved = new Set<number>();
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;
@@ -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<typeof encodeNibArchiveFixture>[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);
},
);
@@ -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,
}),
);
});