fix(apple): classify Interface Builder edges at their source

Pass hierarchy or connection identity from the producing call rather than
searching graph IDs containing caller-selected paths and authored IDs. Keep
connection omissions from making a retained hierarchy appear partial.

Replace mirrored ID-substring test oracles with exact endpoint relationships.
Cover marker-bearing paths and authored identities through domain, CLI/MCP,
summary and byte-retention boundaries.

Validation: affected source and compiled/public tests passed, including native
Foundation/ibtool fixture lanes; one Linux-only invalid-byte filename case
skipped on macOS. Static, docs and discovery checks passed.
This commit is contained in:
morluto committed 2026-10-24 02:56:59 +08:00
1 parent 34563fcc9b
commit e21b131f25
8 files changed
+166 -66

No files matched your search

@@ -509,6 +509,61 @@ describe("Interface Builder graph limits", () => {
});
});
it.each([
{ relativePath: "Main.nib", sourceId: "missing" },
{ relativePath: "Views/:hierarchy:/Main.nib", sourceId: "missing" },
{ relativePath: "Main.nib", sourceId: "missing:hierarchy:source" },
])(
"keeps connection omissions separate from complete hierarchy for $relativePath / $sourceId",
({ relativePath, sourceId }) => {
const result = buildInterfaceBuilderAnalysis({
targetSha256: hash,
toolVersion: "test",
documents: [
{
relativePath,
archiveSha256: hash,
documentKind: "nib",
raw: {
"com.apple.ibtool.document.objects": {
kept: { class: "UIView" },
},
"com.apple.ibtool.document.hierarchy": [{ objectID: "kept" }],
"com.apple.ibtool.document.connections": {
[sourceId]: [
{ type: "outlet", label: "view", destinationId: "kept" },
],
},
},
},
],
limits: interfaceBuilderLimitsSchema.parse({ max_objects: 2 }),
});
expect(result.documents[0]?.hierarchy_complete).toBe(true);
expect(result.graph.nodes).toHaveLength(2);
expect(result.graph.edges).toHaveLength(1);
expect(result.graph.truncated).toBe(true);
expect(result.graph.coverage).toEqual(
expect.arrayContaining([
{
facet: `hierarchy:${relativePath}`,
status: "complete",
reason: null,
examined: 1,
omitted: 0,
},
{
facet: `connections:${relativePath}`,
status: "partial",
reason: "connection_limit_reached",
examined: 1,
omitted: 2,
},
]),
);
},
);
describe("Interface Builder hierarchy completeness", () => {
it("marks only the later document incomplete when its hierarchy root is omitted", () => {
const document = (relativePath: string, objectId: string) => ({
+13 -1
View File
@@ -191,6 +191,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
else connectionEdgeCount += 1;
};
const connect = (
category: "hierarchy" | "connection",
id: string,
from: string,
to: string | null,
@@ -198,7 +199,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
description: string,
reason?: string,
) => {
const isHierarchy = relation === "contains" && id.includes(":hierarchy:");
const isHierarchy = category === "hierarchy";
if (!nodes.has(from)) {
truncated = true;
if (isHierarchy) {
@@ -296,6 +297,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
)
hierarchyTruncated = true;
connect(
"hierarchy",
graphId("hierarchy", [parentId, childId]),
parentId,
childId,
@@ -343,6 +345,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
evidence: evidenceFor(`action ${item.id}`),
});
connect(
"connection",
graphId("action-edge", [item.id, "source"]),
sourceId,
actionId,
@@ -359,6 +362,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
evidence: evidenceFor(`selector ${item.label}`),
});
connect(
"connection",
graphId("action-edge", [item.id, "selector"]),
actionId,
selectorId,
@@ -368,6 +372,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
}
if (destinationId === null || !nodes.has(destinationId))
connect(
"connection",
graphId("action-edge", [item.id, "destination"]),
actionId,
null,
@@ -377,6 +382,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
);
else
connect(
"connection",
graphId("action-edge", [item.id, "destination"]),
actionId,
destinationId,
@@ -394,6 +400,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
evidence: evidenceFor(`outlet ${item.id}`),
});
connect(
"connection",
graphId("outlet-edge", [item.id, "source"]),
sourceId,
outletId,
@@ -402,6 +409,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
);
if (destinationId === null || !nodes.has(destinationId))
connect(
"connection",
graphId("outlet-edge", [item.id, "destination"]),
outletId,
null,
@@ -411,6 +419,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
);
else
connect(
"connection",
graphId("outlet-edge", [item.id, "destination"]),
outletId,
destinationId,
@@ -420,6 +429,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
} else if (item.kind === "segue") {
if (destinationId === null || !nodes.has(destinationId))
connect(
"connection",
graphId("segue", [index]),
sourceId,
null,
@@ -429,6 +439,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
);
else
connect(
"connection",
graphId("segue", [index]),
sourceId,
destinationId,
@@ -437,6 +448,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
);
} else {
connect(
"connection",
graphId("unknown-connection", [index]),
sourceId,
null,
@@ -312,9 +312,21 @@ it.each([
omitted: 0,
}),
);
const source = result.graph.nodes.find(
({ attributes }) => attributes.interface_builder_object_id === "2",
);
const outlet = result.graph.nodes.find(({ kind }) => kind === "outlet");
expect(source).toBeDefined();
expect(outlet).toBeDefined();
expect(
result.graph.edges.filter(({ id }) => id.includes(":hierarchy:")),
).toEqual([]);
result.graph.edges.filter(({ relation }) => relation === "contains"),
).toEqual([
expect.objectContaining({
from: source?.id,
to: outlet?.id,
resolution: "observed",
}),
]);
});
it("counts a dangling named root without substituting a valid generic root", () => {
@@ -79,14 +79,42 @@ const cases = [
omittedEdges: 0,
unresolvedEdge: true,
},
{
name: "a marker-bearing connection source is omitted without losing hierarchy coverage",
archives: [
[
"Connection.nib",
build({
"com.apple.ibtool.document.objects": {
kept: { class: "UIView" },
},
"com.apple.ibtool.document.hierarchy": [{ objectID: "kept" }],
"com.apple.ibtool.document.connections": {
"missing:hierarchy:source": [
{ type: "outlet", label: "view", destinationId: "kept" },
],
},
}),
],
] as const,
hierarchyComplete: [true],
document: "Connection.nib",
omittedEdges: 0,
unresolvedEdge: false,
omittedConnections: 2,
},
];
cliTest.for(cases)(
"reports partial hierarchy through the CLI when $name",
async (
{ archives, hierarchyComplete, document, omittedEdges, unresolvedEdge },
{ cli },
) => {
"preserves hierarchy and connection coverage through the CLI when $name",
async (testCase, { cli }) => {
const {
archives,
hierarchyComplete,
document,
omittedEdges,
unresolvedEdge,
} = testCase;
const root = await createTestTempDirectory("rea-ib-hierarchy-limits-");
const app = join(root, "Fixture.app");
const contents = join(app, "Contents");
@@ -129,12 +157,20 @@ cliTest.for(cases)(
expect(report.graph.coverage).toContainEqual(
expect.objectContaining({
facet: `hierarchy:Contents/Resources/${document}`,
status: "partial",
reason: "graph_limit_reached",
status: hierarchyComplete.every(Boolean) ? "complete" : "partial",
reason: hierarchyComplete.every(Boolean) ? null : "graph_limit_reached",
examined: 1,
omitted: omittedEdges,
}),
);
if ("omittedConnections" in testCase)
expect(report.graph.coverage).toContainEqual({
facet: `connections:Contents/Resources/${document}`,
status: "partial",
reason: "connection_limit_reached",
examined: 1,
omitted: testCase.omittedConnections,
});
if (unresolvedEdge)
expect(report.graph.edges[0]).toMatchObject({
relation: "contains",
@@ -144,7 +144,7 @@ cliTest.for(["xml", "binary"] as const)(
{ $class: uid(7), "NS.objects": [uid(1)] },
{ $classname: "NSArray", $classes: ["NSArray", "NSObject"] },
"2",
"shared",
"shared:hierarchy:authored",
],
$top: { UINibTopLevelObjectsKey: uid(6) },
};
@@ -170,58 +170,56 @@ cliTest.for(["xml", "binary"] as const)(
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({
const root = report.graph.nodes.find(
({ attributes }) => attributes.interface_builder_object_id === "1",
);
expect(root).toMatchObject({
attributes: {
id: "2",
archiveUID: 2,
objectID: "2",
"object-id": "shared",
"object-id": "shared:hierarchy:authored",
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" },
attributes: { objectID: "2", "object-id": "shared:hierarchy:authored" },
});
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",
}),
const document = report.graph.nodes.find(({ kind }) => kind === "scene");
expect(document).toMatchObject({ name: "Contents/Resources/Main.nib" });
const containment = report.graph.edges.filter(
({ relation }) => relation === "contains",
);
expect(containment).toHaveLength(3);
expect(containment).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: document?.id,
to: root?.id,
relation: "contains",
resolution: "observed",
}),
expect.objectContaining({
from: root?.id,
to: child?.id,
relation: "contains",
resolution: "observed",
}),
expect.objectContaining({
from: child?.id,
to: outlet?.id,
relation: "contains",
resolution: "observed",
}),
]),
);
expect(report.graph.edges).toContainEqual(
expect.objectContaining({
@@ -532,10 +532,7 @@ describe("keyed archive hierarchy coverage", () => {
}),
);
expect(
analysis.graph.edges.filter(
({ relation, id }) =>
relation === "contains" && id.includes(":hierarchy:"),
),
analysis.graph.edges.filter(({ relation }) => relation === "contains"),
).toHaveLength(40);
});
@@ -568,10 +565,7 @@ describe("keyed archive hierarchy coverage", () => {
)?.id;
const childNames = analysis.graph.edges
.filter(
({ from, relation, id }) =>
from === rootId &&
relation === "contains" &&
id.includes(":hierarchy:"),
({ from, relation }) => from === rootId && relation === "contains",
)
.map(({ to }) => (to === null ? null : archiveIdsByNodeId.get(to)));
expect(childNames).toEqual(["3", "5"]);
@@ -609,10 +603,7 @@ describe("keyed archive hierarchy coverage", () => {
),
).toMatchObject({ status: "complete", examined: 41, omitted: 0 });
expect(
analysis.graph.edges.filter(
({ relation, id }) =>
relation === "contains" && id.includes(":hierarchy:"),
),
analysis.graph.edges.filter(({ relation }) => relation === "contains"),
).toHaveLength(41);
},
120_000,
+2 -4
View File
@@ -111,10 +111,8 @@ const expectNativeViewHierarchy = (result: Analysis, oracle: Oracle) => {
return node.id;
};
const children = result.graph.edges.filter(
({ id, from, relation }) =>
from === nodeId(oracle.rootUID) &&
relation === "contains" &&
id.includes(":hierarchy:"),
({ from, relation }) =>
from === nodeId(oracle.rootUID) && relation === "contains",
);
expect(children.map(({ to }) => to)).toEqual(oracle.childUIDs.map(nodeId));
expect(children).toHaveLength(2);
+2 -4
View File
@@ -69,10 +69,8 @@ const expectView = (result: Analysis, oracle: Oracle, uid: number) => {
};
const childEdges = (result: Analysis, oracle: Oracle) =>
result.graph.edges.filter(
({ id, from, relation }) =>
from === viewId(result, oracle.rootUID) &&
relation === "contains" &&
id.includes(":hierarchy:"),
({ from, relation }) =>
from === viewId(result, oracle.rootUID) && relation === "contains",
);
/** Native producer and independent plist oracle precede every consumer assertion. */