diff --git a/src/domain/apple/interfaceBuilderGraph.test.ts b/src/domain/apple/interfaceBuilderGraph.test.ts index 627b200705..cf84d4b379 100644 --- a/src/domain/apple/interfaceBuilderGraph.test.ts +++ b/src/domain/apple/interfaceBuilderGraph.test.ts @@ -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) => ({ diff --git a/src/domain/apple/interfaceBuilderGraph.ts b/src/domain/apple/interfaceBuilderGraph.ts index b2abd5fff7..dee955dc55 100644 --- a/src/domain/apple/interfaceBuilderGraph.ts +++ b/src/domain/apple/interfaceBuilderGraph.ts @@ -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, diff --git a/src/domain/apple/interfaceBuilderKeyedArchive.test.ts b/src/domain/apple/interfaceBuilderKeyedArchive.test.ts index e4ac3c2dd9..194a97a961 100644 --- a/src/domain/apple/interfaceBuilderKeyedArchive.test.ts +++ b/src/domain/apple/interfaceBuilderKeyedArchive.test.ts @@ -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", () => { diff --git a/tests/acceptance/applications/interfaceBuilderHierarchyLimits.test.ts b/tests/acceptance/applications/interfaceBuilderHierarchyLimits.test.ts index 3bcd6ce9a9..f3416938a0 100644 --- a/tests/acceptance/applications/interfaceBuilderHierarchyLimits.test.ts +++ b/tests/acceptance/applications/interfaceBuilderHierarchyLimits.test.ts @@ -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", diff --git a/tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts b/tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts index 7a3b52e89a..5f86aa9ea9 100644 --- a/tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts +++ b/tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts @@ -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({ diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 42e523d89f..2526f9ff26 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -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, diff --git a/tests/fixtures/interfaceBuilderEncoding.ts b/tests/fixtures/interfaceBuilderEncoding.ts index 536a65b80e..cc95db5fa9 100644 --- a/tests/fixtures/interfaceBuilderEncoding.ts +++ b/tests/fixtures/interfaceBuilderEncoding.ts @@ -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); diff --git a/tests/fixtures/interfaceBuilderMalformedUid.ts b/tests/fixtures/interfaceBuilderMalformedUid.ts index 903efdc116..60391304d9 100644 --- a/tests/fixtures/interfaceBuilderMalformedUid.ts +++ b/tests/fixtures/interfaceBuilderMalformedUid.ts @@ -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. */