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),