diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index 02ecd9821f..00db7f03c7 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -149,6 +149,9 @@ export const analyzeInterfaceBuilderBundle = async (input: { ? "storyboard_scene" : "nib", raw, + ...(nib === null || nib.omitted === 0 + ? {} + : { omittedSerializedHierarchyReferences: nib.omitted }), }); } catch (cause: unknown) { if (input.signal?.aborted === true) throw cause; @@ -266,34 +269,14 @@ const finalizeHierarchyCoverage = ( ...result, documents: result.documents.map((document) => ({ ...document, - // The dictionary parser only sees connectors that were projected, so the - // omission recorded where an ownerless connector was dropped has to be - // added here for this producer to report the same document count as the - // keyed-archive producer for the same recognized connector. connection_count: document.connection_count + (unresolvedConnections.get(document.relative_path) ?? 0), - hierarchy_complete: - document.hierarchy_complete && - !incompleteHierarchies.has(document.relative_path), })), graph: { ...result.graph, coverage: [ ...result.graph.coverage.map((facet) => { - const omittedHierarchy = incompleteHierarchies.get( - facet.facet.replace(/^hierarchy:/u, ""), - ); - if ( - facet.facet.startsWith("hierarchy:") && - omittedHierarchy !== undefined - ) - return { - ...facet, - status: "partial" as const, - reason: "serialized_view_hierarchy_incomplete", - omitted: facet.omitted + omittedHierarchy, - }; const omittedConnection = unresolvedConnections.get( facet.facet.replace(/^connections:/u, ""), ); @@ -336,7 +319,6 @@ const finalizeHierarchyCoverage = ( result.graph.truncated || counts.omitted > 0 || archiveDecodePartial || - incompleteHierarchies.size > 0 || unresolvedConnections.size > 0, }, limitations: [ diff --git a/src/domain/apple/interfaceBuilderGraph.test.ts b/src/domain/apple/interfaceBuilderGraph.test.ts index 3105e1c5cc..51f92cccba 100644 --- a/src/domain/apple/interfaceBuilderGraph.test.ts +++ b/src/domain/apple/interfaceBuilderGraph.test.ts @@ -360,6 +360,83 @@ describe("Interface Builder graph limits", () => { }); }); +describe("Interface Builder hierarchy completeness", () => { + it("marks only the later document incomplete when its hierarchy root is omitted", () => { + const document = (relativePath: string, objectId: string) => ({ + relativePath, + archiveSha256: hash, + documentKind: "nib" as const, + raw: { + "com.apple.ibtool.document.objects": { + [objectId]: { class: "UIView" }, + }, + "com.apple.ibtool.document.hierarchy": [{ objectID: objectId }], + }, + }); + const result = buildInterfaceBuilderAnalysis({ + targetSha256: hash, + toolVersion: "test", + documents: [ + document("First.nib", "first"), + document("Second.nib", "second"), + ], + limits: interfaceBuilderLimitsSchema.parse({ max_objects: 2 }), + }); + + expect( + result.documents.map(({ hierarchy_complete }) => hierarchy_complete), + ).toEqual([true, false]); + expect(result.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "hierarchy:Second.nib", + status: "partial", + reason: "graph_limit_reached", + omitted: 1, + }), + ); + }); + + it("marks retained hierarchy edges with unresolved endpoints partial", () => { + const result = buildInterfaceBuilderAnalysis({ + targetSha256: hash, + toolVersion: "test", + documents: [ + { + relativePath: "Endpoint.nib", + archiveSha256: hash, + documentKind: "nib", + raw: { + "com.apple.ibtool.document.objects": { + kept: { class: "UIView" }, + omitted: { class: "UIView" }, + }, + "com.apple.ibtool.document.hierarchy": [{ objectID: "omitted" }], + }, + }, + ], + limits: interfaceBuilderLimitsSchema.parse({ max_objects: 2 }), + }); + + expect(result.documents[0]?.hierarchy_complete).toBe(false); + expect(result.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "hierarchy:Endpoint.nib", + status: "partial", + reason: "graph_limit_reached", + omitted: 0, + }), + ); + expect(result.graph.edges).toContainEqual( + expect.objectContaining({ + relation: "contains", + resolution: "unresolved", + to: null, + reason: "endpoint_omitted_by_object_limit", + }), + ); + }); +}); + 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 75bc6b7358..8e1250f4da 100644 --- a/src/domain/apple/interfaceBuilderGraph.ts +++ b/src/domain/apple/interfaceBuilderGraph.ts @@ -35,6 +35,8 @@ export interface InterfaceBuilderDocumentInput { readonly archiveSha256: string; readonly documentKind: "nib" | "storyboard_scene"; readonly raw: unknown; + /** Serialized hierarchy links omitted before normalized graph projection. */ + readonly omittedSerializedHierarchyReferences?: number; } type InvestigationNode = z.infer; @@ -128,6 +130,7 @@ export const buildInterfaceBuilderAnalysis = (input: { let hierarchyEdgeCount = 0; let documentOmittedConnections = 0; let documentOmittedHierarchyEdges = 0; + let hierarchyTruncated = false; const objectNodeId = (objectId: string): string => graphId("object", [objectId]); const addEdge = ( @@ -141,6 +144,7 @@ export const buildInterfaceBuilderAnalysis = (input: { ) { truncated = true; if (isHierarchy) { + hierarchyTruncated = true; documentOmittedHierarchyEdges += 1; } else { omittedConnections += 1; @@ -160,9 +164,11 @@ export const buildInterfaceBuilderAnalysis = (input: { description: string, reason?: string, ) => { + const isHierarchy = relation === "contains" && id.includes(":hierarchy:"); if (!nodes.has(from)) { truncated = true; - if (relation === "contains" && id.includes(":hierarchy:")) { + if (isHierarchy) { + hierarchyTruncated = true; documentOmittedHierarchyEdges += 1; } else { omittedConnections += 1; @@ -172,7 +178,10 @@ export const buildInterfaceBuilderAnalysis = (input: { } const evidence = evidenceFor(description); const unresolvedEndpoint = to !== null && !nodes.has(to); - if (unresolvedEndpoint) truncated = true; + if (unresolvedEndpoint) { + truncated = true; + if (isHierarchy) hierarchyTruncated = true; + } const edgeTo = unresolvedEndpoint ? null : to; const edgeReason = unresolvedEndpoint ? "endpoint_omitted_by_object_limit" @@ -198,11 +207,10 @@ export const buildInterfaceBuilderAnalysis = (input: { evidence, limitations: [], }, - relation === "contains" && id.includes(":hierarchy:"), + isHierarchy, ); }; let hierarchyCount = 0; - let hierarchyTruncated = false; const visitHierarchy = ( initial: unknown, initialParentId: string, @@ -405,7 +413,10 @@ export const buildInterfaceBuilderAnalysis = (input: { const objectsTruncated = parsed.omittedObjects > 0 || parsed.objects.length > documentObjectCount; const parserOmittedHierarchyReferences = parsed.omittedHierarchyReferences; - omittedHierarchyReferences += parserOmittedHierarchyReferences; + const omittedSerializedHierarchyReferences = + document.omittedSerializedHierarchyReferences ?? 0; + omittedHierarchyReferences += + parserOmittedHierarchyReferences + omittedSerializedHierarchyReferences; const parserOmittedConnections = parsed.omittedConnections; const connectionsTruncated = parserOmittedConnections > 0 || @@ -421,6 +432,15 @@ export const buildInterfaceBuilderAnalysis = (input: { Math.max(0, parsed.connections.length - input.limits.max_connections) + parserOmittedConnections + parsed.unresolvedConnections; + const hierarchyStatus = + parserOmittedHierarchyReferences > 0 || + omittedSerializedHierarchyReferences > 0 + ? "partial" + : parsed.hierarchy.length === 0 + ? "unsupported" + : hierarchyTruncated + ? "partial" + : "complete"; summaries.push({ relative_path: document.relativePath, archive_sha256: document.archiveSha256, @@ -430,10 +450,7 @@ export const buildInterfaceBuilderAnalysis = (input: { parsed.connections.length + parserOmittedConnections + parsed.unresolvedConnections, - hierarchy_complete: - parsed.hierarchy.length > 0 && - !hierarchyTruncated && - parserOmittedHierarchyReferences === 0, + hierarchy_complete: hierarchyStatus === "complete", }); coverage.push( { @@ -474,25 +491,22 @@ export const buildInterfaceBuilderAnalysis = (input: { }, { facet: `hierarchy:${document.relativePath}`, - status: - parserOmittedHierarchyReferences > 0 - ? "partial" - : parsed.hierarchy.length === 0 - ? "unsupported" - : hierarchyTruncated || documentOmittedHierarchyEdges > 0 - ? "partial" - : "complete", + status: hierarchyStatus, reason: parserOmittedHierarchyReferences > 0 ? "archive_hierarchy_references_missing" - : parsed.hierarchy.length === 0 - ? "archive_hierarchy_not_decoded" - : hierarchyTruncated || documentOmittedHierarchyEdges > 0 - ? "graph_limit_reached" - : null, + : omittedSerializedHierarchyReferences > 0 + ? "serialized_view_hierarchy_incomplete" + : parsed.hierarchy.length === 0 + ? "archive_hierarchy_not_decoded" + : hierarchyTruncated + ? "graph_limit_reached" + : null, examined: hierarchyCount, omitted: - documentOmittedHierarchyEdges + parserOmittedHierarchyReferences, + documentOmittedHierarchyEdges + + parserOmittedHierarchyReferences + + omittedSerializedHierarchyReferences, }, { facet: `classes:${document.relativePath}`,