From d816470bcbddd70a98f70924fecb614931ec008d Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Thu, 15 Oct 2026 09:33:02 -0700 Subject: [PATCH] fix(apple): count Interface Builder hierarchy records beyond the object limit (#4162) The ibtool dictionary parser cut the hierarchy array at a fixed 20,000 entries without counting what it discarded, so an identified record past that position vanished while the document still reported complete hierarchy coverage. The graph walker already owns the hierarchy limit; the parser now passes the decoded array through. The walker applies max_objects per identified record and keeps walking past the limit to count omitted records, instead of stopping on a pending structural entry. Coverage now reports the omitted count, and a hierarchy that exactly reaches the limit is no longer marked partial. Fixes #3950 Co-authored-by: Claude Opus 5.5 --- .../apple/interfaceBuilderGraph.test.ts | 87 +++++++++++++++++++ src/domain/apple/interfaceBuilderGraph.ts | 15 ++-- .../apple/interfaceBuilderKeyedArchive.ts | 2 +- 3 files changed, 97 insertions(+), 7 deletions(-) diff --git a/src/domain/apple/interfaceBuilderGraph.test.ts b/src/domain/apple/interfaceBuilderGraph.test.ts index cffcd8db93..ed8a96cb53 100644 --- a/src/domain/apple/interfaceBuilderGraph.test.ts +++ b/src/domain/apple/interfaceBuilderGraph.test.ts @@ -481,6 +481,93 @@ describe("Interface Builder hierarchy completeness", () => { }); }); +describe("Interface Builder hierarchy record limits", () => { + const analyze = ( + hierarchy: unknown[], + maxObjects?: number, + objects: Record = {}, + ) => + buildInterfaceBuilderAnalysis({ + targetSha256: hash, + toolVersion: "test", + documents: [ + { + relativePath: "Main.nib", + archiveSha256: hash, + documentKind: "nib", + raw: { + "com.apple.ibtool.document.objects": objects, + "com.apple.ibtool.document.hierarchy": hierarchy, + }, + }, + ], + limits: interfaceBuilderLimitsSchema.parse( + maxObjects === undefined ? {} : { max_objects: maxObjects }, + ), + }); + const hierarchyFacet = (result: ReturnType) => + result.graph.coverage.find(({ facet }) => facet === "hierarchy:Main.nib"); + + it("keeps an identified record after more than 20,000 structural records", () => { + const hierarchy: unknown[] = Array.from({ length: 20_001 }, () => ({ + children: [], + })); + hierarchy[20_000] = { objectID: "tail", children: [] }; + + const result = analyze(hierarchy); + + expect( + result.graph.nodes.map( + ({ attributes }) => attributes.interface_builder_object_id, + ), + ).toContain("tail"); + expect(result.graph.edges).toHaveLength(1); + expect(result.graph.truncated).toBe(false); + expect(hierarchyFacet(result)).toMatchObject({ + status: "complete", + examined: 1, + omitted: 0, + }); + }); + + it("counts identified records beyond the object limit as omitted", () => { + const result = analyze( + ["a", "b", "c", "d"].map((objectID) => ({ objectID, children: [] })), + 2, + ); + + expect(result.documents[0]?.hierarchy_complete).toBe(false); + expect(result.graph.truncated).toBe(true); + expect(hierarchyFacet(result)).toMatchObject({ + status: "partial", + reason: "graph_limit_reached", + examined: 2, + omitted: 2, + }); + }); + + it("stays complete when the identified records exactly reach the object limit", () => { + // The root and both objects fill the three graph nodes; the third + // hierarchy record reaches the hierarchy limit and has a structural child. + const result = analyze( + [ + { objectID: "a", children: [{ objectID: "b", children: [] }] }, + { objectID: "b", children: [{ children: [] }] }, + ], + 3, + { a: { class: "UIView" }, b: { class: "UIView" } }, + ); + + expect(result.documents[0]?.hierarchy_complete).toBe(true); + expect(result.graph.truncated).toBe(false); + expect(hierarchyFacet(result)).toMatchObject({ + status: "complete", + examined: 3, + omitted: 0, + }); + }); +}); + describe("keyed archive object references", () => { it("keeps referenced objects as stubs so shared and cyclic references stay finite", () => { // Each view references its window, superview, and next key view, so diff --git a/src/domain/apple/interfaceBuilderGraph.ts b/src/domain/apple/interfaceBuilderGraph.ts index 8e1250f4da..1539412da7 100644 --- a/src/domain/apple/interfaceBuilderGraph.ts +++ b/src/domain/apple/interfaceBuilderGraph.ts @@ -211,6 +211,7 @@ export const buildInterfaceBuilderAnalysis = (input: { ); }; let hierarchyCount = 0; + let documentOmittedHierarchyNodes = 0; const visitHierarchy = ( initial: unknown, initialParentId: string, @@ -218,11 +219,6 @@ export const buildInterfaceBuilderAnalysis = (input: { const pending: { readonly value: unknown; readonly parentId: string }[] = [{ value: initial, parentId: initialParentId }]; while (pending.length > 0) { - if (hierarchyCount >= input.limits.max_objects) { - truncated = true; - hierarchyTruncated = true; - return; - } const entry = pending.pop(); if (entry === undefined) break; const { value, parentId } = entry; @@ -242,7 +238,13 @@ export const buildInterfaceBuilderAnalysis = (input: { typeof item.archiveUID === "number" ? String(item.archiveUID) : null, ); let nextParent = parentId; - if (objectId !== null) { + if (objectId !== null && hierarchyCount >= input.limits.max_objects) { + // The walk continues over the decoded records so the omission is + // counted rather than reported as an unknown remainder. + truncated = true; + hierarchyTruncated = true; + documentOmittedHierarchyNodes += 1; + } else if (objectId !== null) { const known = objectIds.has(objectId); const childId = known ? objectNodeId(objectId) @@ -504,6 +506,7 @@ export const buildInterfaceBuilderAnalysis = (input: { : null, examined: hierarchyCount, omitted: + documentOmittedHierarchyNodes + documentOmittedHierarchyEdges + parserOmittedHierarchyReferences + omittedSerializedHierarchyReferences, diff --git a/src/domain/apple/interfaceBuilderKeyedArchive.ts b/src/domain/apple/interfaceBuilderKeyedArchive.ts index e6145a0d27..a12b4bad09 100644 --- a/src/domain/apple/interfaceBuilderKeyedArchive.ts +++ b/src/domain/apple/interfaceBuilderKeyedArchive.ts @@ -131,7 +131,7 @@ export const parseInterfaceBuilderRecords = ( return { objects, connections, - hierarchy: Array.isArray(hierarchyRaw) ? hierarchyRaw.slice(0, 20_000) : [], + hierarchy: Array.isArray(hierarchyRaw) ? hierarchyRaw : [], classes: classesRaw, objectCount: objectEntries.length, omittedObjects: Math.max(0, objectEntries.length - 20_000),