Files
rea/src/domain/interfaceBuilderGraph.test.ts
T
27c272bd9a fix(artifacts): read AppKit connectors from flat keyed-archive NIBs (#900)
* fix(artifacts): read AppKit connectors from flat keyed-archive NIBs

Flat AppKit NIBs archive NSNibOutletConnector and NSNibControlConnector
endpoints and the outlet or selector name under NSSource, NSDestination,
and NSLabel. The keyed-archive parser read only source, destination,
and label, so it skipped every such connector: XLD.app's 64 decoded NIBs
had no outlet or target-action edges. The NS-prefixed keys now follow the
same source and destination convention as compiled NIBArchive documents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(artifacts): read AppKit control connectors from the control to its target

NSNibControlConnector archives NSSource as the sending control and
NSDestination as the action target. The previous commit inverted them
like the unprefixed fields, so every flat AppKit action ran from the
target to the control. The action trace selects a control and follows
control -> action, and the graph keys the selector by its target, so
neither could resolve these actions. The NS-prefixed keys are now read
as source and destination for outlets and actions alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(artifacts): key AppKit connector endpoints by their node identity

An NSSource or NSDestination object with an identifier field was
referenced by that identifier, but its node is keyed by its authored
object ID or archive UID. The connector then pointed at a synthetic
unknown node. NS-prefixed endpoints now use the referenced object's
node identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:08:02 +08:00

427 lines
13 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { buildBinary, parseBinary } from "plist";
import {
buildInterfaceBuilderAnalysis,
interfaceBuilderLimitsSchema,
parseInterfaceBuilderRecords,
} from "./interfaceBuilderGraph.js";
const hash = "a".repeat(64);
describe("compiled Interface Builder graph projection", () => {
it("normalizes object, hierarchy, action, outlet, and segue facts", () => {
const raw = {
"com.apple.ibtool.document.objects": {
controller: { customClass: "StoreViewController", label: "Store" },
button: { class: "UIButton", title: "Build" },
next: { customClass: "BuildViewController" },
},
"com.apple.ibtool.document.connections": {
button: [
{
type: "action",
label: "buildTapped:",
destinationId: "controller",
},
{ type: "segue", identifier: "showBuild", destinationId: "next" },
],
controller: [
{ type: "outlet", label: "buildButton", destinationId: "button" },
],
},
"com.apple.ibtool.document.hierarchy": [
{ objectID: "controller", children: [{ objectID: "button" }] },
],
"com.apple.ibtool.document.classes": { StoreViewController: {} },
};
const decoded = parseInterfaceBuilderRecords(raw);
expect(decoded.objects).toHaveLength(3);
expect(decoded.connections.map(({ kind }) => kind)).toEqual([
"action",
"segue",
"outlet",
]);
const result = buildInterfaceBuilderAnalysis({
targetSha256: hash,
toolVersion: "test",
documents: [
{
relativePath: "Views/Main.storyboardc/scene.nib/objects.nib",
archiveSha256: hash,
documentKind: "storyboard_scene",
raw,
},
],
limits: interfaceBuilderLimitsSchema.parse({}),
});
expect(result.graph.nodes.some(({ name }) => name === "buildTapped:")).toBe(
true,
);
expect(result.graph.nodes[0]?.evidence[0]).toMatchObject({
artifact_path: "Views/Main.storyboardc/scene.nib/objects.nib",
artifact_sha256: hash,
});
expect(result.graph.edges).toContainEqual(
expect.objectContaining({
relation: "segue_to",
resolution: "observed",
}),
);
expect(result.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "hierarchy:Views/Main.storyboardc/scene.nib/objects.nib",
status: "complete",
}),
);
});
it("keeps missing external destinations as unresolved evidence", () => {
const result = buildInterfaceBuilderAnalysis({
targetSha256: hash,
toolVersion: "test",
documents: [
{
relativePath: "Legacy.nib",
archiveSha256: hash,
documentKind: "nib",
raw: {
"com.apple.ibtool.document.objects": {
button: { class: "UIButton" },
},
"com.apple.ibtool.document.connections": {
button: [
{ type: "action", label: "save:", destinationId: "external" },
],
},
},
},
],
limits: interfaceBuilderLimitsSchema.parse({ max_objects: 3 }),
});
expect(result.graph.edges).toContainEqual(
expect.objectContaining({
relation: "target_action",
resolution: "unresolved",
reason: "endpoint_omitted_by_object_limit",
evidence: [
expect.objectContaining({
kind: "interface_builder_resource",
artifact_path: "Legacy.nib",
artifact_sha256: hash,
}),
],
}),
);
const retainedNodes = new Set(result.graph.nodes.map(({ id }) => id));
expect(
result.graph.edges.every(
({ from, to }) =>
retainedNodes.has(from) && (to === null || retainedNodes.has(to)),
),
).toBe(true);
expect(result.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "graph_nodes",
status: "partial",
reason: "max_objects_reached",
}),
);
expect(result.graph.truncated).toBe(true);
});
it("reads recognized objects from a compiled NSKeyedArchiver object table", () => {
const bytes = buildBinary({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: [
"$null",
{ $class: { UID: 2 }, title: "Build", identifier: "build-button" },
{
$classname: "UIButton",
$classes: ["UIButton", "UIControl", "UIView", "NSObject"],
},
],
$top: { root: { UID: 1 } },
});
const parsed = parseInterfaceBuilderRecords(parseBinary(bytes));
expect(parsed.objects).toMatchObject([
{
id: "1",
kind: "control",
class_name: "UIButton",
name: "Build",
},
]);
});
});
describe("Interface Builder parser bounds", () => {
it("reports connections discarded by the parser's hard ceiling", () => {
const connections = Array.from({ length: 40_001 }, (_, index) => ({
type: "custom-connection",
label: `action${String(index)}:`,
}));
const raw = {
"com.apple.ibtool.document.objects": {
button: { class: "UIButton" },
},
"com.apple.ibtool.document.connections": { button: connections },
};
const parsed = parseInterfaceBuilderRecords(raw);
expect(parsed.connections).toHaveLength(40_000);
expect(parsed.omittedConnections).toBe(1);
const result = buildInterfaceBuilderAnalysis({
targetSha256: hash,
toolVersion: "test",
documents: [
{
relativePath: "Main.storyboardc/scene.nib",
archiveSha256: hash,
documentKind: "storyboard_scene",
raw,
},
],
limits: interfaceBuilderLimitsSchema.parse({}),
});
expect(result.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "connections:Main.storyboardc/scene.nib",
status: "partial",
omitted: 1,
}),
);
});
});
describe("keyed archive decoding and graph limits", () => {
it("preserves keyed archive UID identities, arrays, and control actions", () => {
const parsed = parseInterfaceBuilderRecords({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: [
"$null",
{
subviews: [{ UID: 2 }],
$class: { UID: 5 },
},
{ $class: { UID: 6 } },
{ $class: { UID: 7 } },
{
source: { UID: 3 },
destination: { UID: 2 },
label: "runAction:",
$class: { UID: 8 },
},
{ $classname: "UIView", $classes: ["UIView", "NSObject"] },
{ $classname: "UIButton", $classes: ["UIButton", "UIView"] },
{
$classname: "ViewController",
$classes: ["ViewController", "NSObject"],
},
{
$classname: "NSNibControlConnector",
$classes: ["NSNibControlConnector", "NSObject"],
},
[{ UID: 1 }],
],
$top: { root: { UID: 9 } },
});
expect(parsed.objects.map(({ id }) => id)).toContain("2");
expect(parsed.hierarchy).toMatchObject([
{
objectID: "1",
children: [{ objectID: "2" }],
},
]);
expect(parsed.connections).toContainEqual(
expect.objectContaining({
kind: "action",
source_id: "2",
destination_id: "3",
label: "runAction:",
}),
);
});
it("reports records omitted by the parser's hard object ceiling", () => {
const objects = Object.fromEntries(
Array.from({ length: 20_001 }, (_, index) => [
`object-${index}`,
{ class: "UIView" },
]),
);
const parsed = parseInterfaceBuilderRecords({
"com.apple.ibtool.document.objects": objects,
});
expect(parsed.objects).toHaveLength(20_000);
expect(parsed.objectCount).toBe(20_001);
expect(parsed.omittedObjects).toBe(1);
});
it("keeps hierarchy edges out of the connection edge budget", () => {
const relativePath = "Main.nib";
const result = buildInterfaceBuilderAnalysis({
targetSha256: hash,
toolVersion: "test",
documents: [
{
relativePath,
archiveSha256: hash,
documentKind: "nib",
raw: {
"com.apple.ibtool.document.objects": {
button: { class: "UIButton" },
controller: { customClass: "MainViewController" },
parent: { class: "UIView" },
child: { class: "UIView" },
},
"com.apple.ibtool.document.connections": {
button: [
{
type: "action",
label: "run:",
destinationId: "controller",
},
],
},
"com.apple.ibtool.document.hierarchy": [
{
objectID: "parent",
children: [
{ objectID: "child", children: [{ objectID: "button" }] },
],
},
],
},
},
],
limits: interfaceBuilderLimitsSchema.parse({
max_objects: 20,
max_connections: 1,
}),
});
expect(
result.graph.edges.filter(({ relation }) => relation === "target_action"),
).toHaveLength(3);
expect(result.graph.coverage).toContainEqual(
expect.objectContaining({
facet: `connections:${relativePath}`,
status: "complete",
omitted: 0,
}),
);
});
});
describe("AppKit keyed archive connectors", () => {
it("reads outlet and control connectors from NS-prefixed keys", () => {
const parsed = parseInterfaceBuilderRecords({
$archiver: "NSKeyedArchiver",
$objects: [
"$null",
{ $class: { UID: 6 } },
{ $class: { UID: 7 }, identifier: "unrelated-identifier" },
{
$class: { UID: 8 },
NSSource: { UID: 1 },
NSDestination: { UID: 2 },
NSLabel: { UID: 5 },
},
{
$class: { UID: 9 },
NSSource: { UID: 2 },
NSDestination: { UID: 1 },
NSLabel: { UID: 10 },
},
"o_button",
{
$classname: "NSCustomObject",
$classes: ["NSCustomObject", "NSObject"],
},
{ $classname: "NSButton", $classes: ["NSButton", "NSObject"] },
{
$classname: "NSNibOutletConnector",
$classes: ["NSNibOutletConnector", "NSNibConnector", "NSObject"],
},
{
$classname: "NSNibControlConnector",
$classes: ["NSNibControlConnector", "NSNibConnector", "NSObject"],
},
"buttonClicked:",
],
$top: {},
});
expect(parsed.connections).toEqual([
expect.objectContaining({
kind: "outlet",
source_id: "1",
destination_id: "2",
label: "o_button",
}),
expect.objectContaining({
kind: "action",
source_id: "2",
destination_id: "1",
label: "buttonClicked:",
}),
]);
});
it("projects an AppKit action from the control through the selector to its target", () => {
const analysis = buildInterfaceBuilderAnalysis({
targetSha256: hash,
toolVersion: "test",
documents: [
{
relativePath: "MainMenu.nib",
archiveSha256: hash,
documentKind: "nib",
raw: {
$archiver: "NSKeyedArchiver",
$objects: [
"$null",
{ $class: { UID: 4 } },
{ $class: { UID: 5 } },
{
$class: { UID: 6 },
NSSource: { UID: 2 },
NSDestination: { UID: 1 },
NSLabel: "buttonClicked:",
},
{ $classname: "BuildDelegate", $classes: ["NSObject"] },
{ $classname: "NSButton", $classes: ["NSButton", "NSObject"] },
{
$classname: "NSNibControlConnector",
$classes: ["NSNibControlConnector", "NSObject"],
},
],
$top: {},
},
},
],
limits: interfaceBuilderLimitsSchema.parse({}),
});
const byId = new Map(analysis.graph.nodes.map((node) => [node.id, node]));
const action = analysis.graph.nodes.find(({ kind }) => kind === "action");
const from = analysis.graph.edges
.filter(
({ to, relation }) => relation === "target_action" && to === action?.id,
)
.map(({ from: id }) => byId.get(id)?.name);
const to = analysis.graph.edges
.filter(
({ from: id, relation }) =>
relation === "target_action" && id === action?.id,
)
.map(({ to: id }) => (id === null ? null : byId.get(id)?.kind));
expect(from).toEqual(["NSButton"]);
expect(to).toEqual(expect.arrayContaining(["objc_selector", "unknown"]));
});
});