Compare commits

...
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 8c723269cb Merge origin/main into fix/ib-object-limit-reasons
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:07:48 +00:00
Kaoru0822andClaude Opus 5.5 5412c7361b fix(apple): attribute object-budget omissions to the object budget
decode-interface-builder reported connection_limit_reached when the graph
object budget dropped a connection's source node, and
serialized_view_hierarchy_incomplete when the NIB projection stopped at the
same caller object budget. Count connection-edge-budget omissions separately
from endpoints dropped by the object budget, and carry whether the NIB
hierarchy projection hit its node bound so both facets report
graph_limit_reached for that cause.

Fixes #5540

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:48:34 +00:00
6 changed files with 106 additions and 16 deletions

No files matched your search

@@ -183,7 +183,10 @@ export const analyzeInterfaceBuilderBundle = async (input: {
...(numberLiterals === undefined ? {} : { numberLiterals }),
...(nib === null || nib.omitted === 0
? {}
: { omittedSerializedHierarchyReferences: nib.omitted }),
: {
omittedSerializedHierarchyReferences: nib.omitted,
serializedHierarchyLimitReached: nib.limitReached,
}),
});
} catch (cause: unknown) {
if (input.signal?.aborted === true) throw cause;
@@ -607,6 +610,7 @@ const projectNibArchive = (
});
}
let hierarchyOmitted = 0;
let hierarchyLimitReached = false;
let hierarchyNodeCount = 0;
const hierarchyFor = (
objectId: number,
@@ -619,6 +623,7 @@ const projectNibArchive = (
}
if (hierarchyNodeCount >= maxHierarchyNodes) {
hierarchyOmitted += 1;
hierarchyLimitReached = true;
return null;
}
if (!budget.tryReserve(128)) {
@@ -680,6 +685,10 @@ const projectNibArchive = (
);
return {
omitted: viewHierarchy.omitted + hierarchyOmitted + merged.omitted,
limitReached:
hierarchyLimitReached ||
viewHierarchy.limitReached ||
merged.limitReached,
unresolvedConnections,
raw: jsonValueSchema.parse({
"com.apple.ibtool.document.objects": objects,
+16 -5
View File
@@ -17,6 +17,7 @@ describe("serialized NIB view hierarchy bounds", () => {
expect(projectNibViewHierarchy([object(0)], new Set([0]))).toEqual({
hierarchy: null,
omitted: 0,
limitReached: false,
});
});
@@ -37,6 +38,7 @@ describe("serialized NIB view hierarchy bounds", () => {
},
],
omitted: 0,
limitReached: false,
});
});
@@ -44,6 +46,7 @@ describe("serialized NIB view hierarchy bounds", () => {
expect(projectNibViewHierarchy([object(0, 99)], new Set([0]))).toEqual({
hierarchy: [],
omitted: 1,
limitReached: false,
});
});
@@ -59,7 +62,11 @@ describe("serialized NIB view hierarchy bounds", () => {
],
new Set([0]),
),
).toEqual({ hierarchy: [{ objectID: "0", children: [] }], omitted: 1 });
).toEqual({
hierarchy: [{ objectID: "0", children: [] }],
omitted: 1,
limitReached: false,
});
});
it("marks unsupported serialized parent values as incomplete", () => {
@@ -68,7 +75,7 @@ describe("serialized NIB view hierarchy bounds", () => {
[{ id: 0, class_name: "NSView", values: { NSSuperview: "unknown" } }],
new Set([0]),
),
).toEqual({ hierarchy: null, omitted: 1 });
).toEqual({ hierarchy: null, omitted: 1, limitReached: false });
});
it("reports cyclic and self-referential parentage as incomplete", () => {
@@ -80,6 +87,7 @@ describe("serialized NIB view hierarchy bounds", () => {
).toEqual({
hierarchy: [],
omitted: 3,
limitReached: false,
});
});
@@ -89,6 +97,7 @@ describe("serialized NIB view hierarchy bounds", () => {
).toEqual({
hierarchy: [],
omitted: 1,
limitReached: false,
});
});
@@ -106,10 +115,10 @@ describe("serialized NIB view hierarchy bounds", () => {
const objects = Array.from({ length: 20_002 }, (_, id) =>
object(id, id === 0 ? undefined : 0),
);
// The record bound is the caller's object budget, so the omission names it.
expect(
projectNibViewHierarchy(objects, new Set(objects.map(({ id }) => id)))
.omitted,
).toBe(2);
projectNibViewHierarchy(objects, new Set(objects.map(({ id }) => id))),
).toMatchObject({ omitted: 2, limitReached: true });
});
});
@@ -163,6 +172,7 @@ describe("legacy NIB hierarchy preservation", () => {
{ objectID: "parent", children: [{ objectID: "child", children: [] }] },
],
omitted: 0,
limitReached: false,
});
});
it("reports conflicting encoded parents rather than dropping a link silently", () => {
@@ -184,6 +194,7 @@ describe("legacy NIB hierarchy preservation", () => {
expect(mergeNibHierarchies(legacy, recovered)).toEqual({
hierarchy: legacy,
omitted: 1,
limitReached: false,
});
});
});
+8 -2
View File
@@ -17,7 +17,8 @@ export const projectNibViewHierarchy = (
objects,
retainedIds,
);
if (parents.size === 0) return { hierarchy: null, omitted: invalid };
if (parents.size === 0)
return { hierarchy: null, omitted: invalid, limitReached: false };
const children = new Map<number, number[]>();
for (const [child, parent] of parents) {
if (!retainedIds.has(parent) || !retainedIds.has(child)) continue;
@@ -31,9 +32,11 @@ export const projectNibViewHierarchy = (
for (const id of involved)
if (!parents.has(id)) pending.push({ id, depth: 0, output: roots });
const visited = new Set<number>();
let limitReached = false;
while (pending.length > 0) {
const item = pending.pop();
if (item === undefined) break;
if (!visited.has(item.id) && visited.size >= maxNodes) limitReached = true;
if (
visited.has(item.id) ||
item.depth > 128 ||
@@ -50,6 +53,7 @@ export const projectNibViewHierarchy = (
return {
hierarchy: roots,
omitted: involved.size - visited.size + invalid,
limitReached,
};
};
@@ -83,6 +87,7 @@ export const mergeNibHierarchies = (
);
}
let omitted = 0;
let limitReached = false;
const additions = recovered.map((node) => ({
node,
output: legacy,
@@ -94,6 +99,7 @@ export const mergeNibHierarchies = (
let target = existing.get(entry.node.objectID);
if (target === undefined) {
if (existing.size >= maxNodes || !reserveNode()) {
if (existing.size >= maxNodes) limitReached = true;
omitted += 1;
continue;
}
@@ -126,7 +132,7 @@ export const mergeNibHierarchies = (
parent: target.objectID,
});
}
return { hierarchy: legacy, omitted };
return { hierarchy: legacy, omitted, limitReached };
};
const wouldCycle = (
+17 -7
View File
@@ -43,6 +43,8 @@ export interface InterfaceBuilderDocumentInput {
readonly numberLiterals?: PlistNumberLiterals;
/** Serialized hierarchy links omitted before normalized graph projection. */
readonly omittedSerializedHierarchyReferences?: number;
/** Whether the object budget, rather than the archive, caused that omission. */
readonly serializedHierarchyLimitReached?: boolean;
}
type InvestigationNode = z.infer<typeof nativeInvestigationNodeSchema>;
@@ -155,6 +157,9 @@ export const buildInterfaceBuilderAnalysis = (input: {
let connectionEdgeCount = 0;
let hierarchyEdgeCount = 0;
let documentOmittedConnections = 0;
// Edges dropped by the connection edge budget, as opposed to connection
// edges whose source node did not fit the graph object budget.
let documentConnectionBudgetOmissions = 0;
let documentOmittedHierarchyEdges = 0;
let hierarchyTruncated = false;
const objectNodeId = (objectId: string): string =>
@@ -175,6 +180,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
} else {
omittedConnections += 1;
documentOmittedConnections += 1;
documentConnectionBudgetOmissions += 1;
}
return;
}
@@ -508,13 +514,15 @@ export const buildInterfaceBuilderAnalysis = (input: {
? "partial"
: "complete",
reason:
connectionsTruncated || documentOmittedConnections > 0
connectionsTruncated || documentConnectionBudgetOmissions > 0
? "connection_limit_reached"
: connectionsUnresolved
? "connection_unresolved"
: unsupportedConnections
? "unsupported_connection_type"
: null,
: documentOmittedConnections > 0
? "graph_limit_reached"
: connectionsUnresolved
? "connection_unresolved"
: unsupportedConnections
? "unsupported_connection_type"
: null,
examined: Math.min(
parsed.connections.length,
input.limits.max_connections,
@@ -535,7 +543,9 @@ export const buildInterfaceBuilderAnalysis = (input: {
parserOmittedHierarchyReferences > 0
? "archive_hierarchy_references_missing"
: omittedSerializedHierarchyReferences > 0
? "serialized_view_hierarchy_incomplete"
? document.serializedHierarchyLimitReached === true
? "graph_limit_reached"
: "serialized_view_hierarchy_incomplete"
: parsed.hierarchy.length === 0
? "archive_hierarchy_not_decoded"
: hierarchyTruncated
@@ -547,7 +547,7 @@ describe("bounded Interface Builder archive decoding", () => {
expect.objectContaining({
facet: "hierarchy:RepeatedHierarchy.nib",
status: "partial",
reason: "serialized_view_hierarchy_incomplete",
reason: "graph_limit_reached",
}),
);
});
@@ -1404,6 +1404,57 @@ describe("NIBArchive UIKit event connections", () => {
});
describe("NIBArchive ownerless connector accounting", () => {
it("attributes connections without retained endpoints to the object budget", async () => {
const root = await createTestTempDirectory("rea-ib-object-limit-");
const bundle = join(root, "Example.app");
await mkdir(bundle, { recursive: true });
await writeFile(
join(bundle, "Cell.nib"),
encodeNibArchiveFixture({
classes: [
"UIProxyObject",
"UIClassSwapper",
"UIButton",
"UIRuntimeEventConnection",
"NSString",
],
objects: [
{ classIndex: 0, values: { UIProxiedObjectIdentifier: { ref: 4 } } },
{ classIndex: 1, values: { UIClassName: { ref: 5 } } },
{ classIndex: 2, values: {} },
{
classIndex: 3,
values: {
UISource: { ref: 2 },
UIDestination: { ref: 1 },
UILabel: { ref: 6 },
UIEventMask: 64,
},
},
{ classIndex: 4, values: { "NS.bytes": "IBFilesOwner" } },
{ classIndex: 4, values: { "NS.bytes": "BuildCell" } },
{ classIndex: 4, values: { "NS.bytes": "buildTapped:" } },
],
}),
);
const analysis = await analyzeInterfaceBuilderBundle({
bundlePath: bundle,
targetSha256: "e".repeat(64),
limits: { max_objects: 1 },
});
// The connection fits its own budget; only the object budget dropped its
// source, so raising that budget is what recovers it.
expect(analysis.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "connections:Cell.nib",
status: "partial",
reason: "graph_limit_reached",
}),
);
});
it("keeps a connection dropped by the limit reported as a limit", async () => {
const root = await createTestTempDirectory("rea-ib-ownerless-limit-");
const bundle = join(root, "Example.app");
@@ -178,10 +178,13 @@ describe.skipIf(!xcodeIbtoolAvailable)(
});
expect(result.documents[0]?.hierarchy_complete).toBe(false);
expect(result.graph.truncated).toBe(true);
// Raising the object budget recovers these links, so the reason must
// name that budget rather than an incomplete serialized hierarchy.
expect(result.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "hierarchy:Contents/Resources/Main.nib",
status: "partial",
reason: "graph_limit_reached",
}),
);
},