diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index fb90594b3..02ecd9821 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -56,6 +56,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { const omittedArchives: Array<{ path: string; reason: string }> = []; let unreportedOmittedArchives = 0; const incompleteHierarchies = new Map(); + const unresolvedConnections = new Map(); const prototypeKeyOmissions = new Map(); let omitted = 0; let attempted = 0; @@ -136,6 +137,8 @@ export const analyzeInterfaceBuilderBundle = async (input: { : { value: nib.raw, omittedPrototypeKeys: 0 }; if (nib !== null && nib.omitted > 0) incompleteHierarchies.set(entry.path, nib.omitted); + if (nib !== null && nib.unresolvedConnections > 0) + unresolvedConnections.set(entry.path, nib.unresolvedConnections); if (omittedPrototypeKeys !== 0) prototypeKeyOmissions.set(entry.path, omittedPrototypeKeys); const documentHash = createHash("sha256").update(bytes).digest("hex"); @@ -170,6 +173,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { }); return finalizeHierarchyCoverage(result, { incompleteHierarchies, + unresolvedConnections, prototypeKeyOmissions, invalid, omittedArchives, @@ -237,6 +241,7 @@ const finalizeHierarchyCoverage = ( result: ReturnType, counts: { incompleteHierarchies: ReadonlyMap; + unresolvedConnections: ReadonlyMap; prototypeKeyOmissions: ReadonlyMap; invalid: readonly string[]; omittedArchives: readonly { path: string; reason: string }[]; @@ -247,6 +252,7 @@ const finalizeHierarchyCoverage = ( ) => { const { incompleteHierarchies, + unresolvedConnections, prototypeKeyOmissions, invalid, omittedArchives, @@ -260,6 +266,13 @@ 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), @@ -268,15 +281,32 @@ const finalizeHierarchyCoverage = ( ...result.graph, coverage: [ ...result.graph.coverage.map((facet) => { - const omitted = incompleteHierarchies.get( + const omittedHierarchy = incompleteHierarchies.get( facet.facet.replace(/^hierarchy:/u, ""), ); - return facet.facet.startsWith("hierarchy:") && omitted !== undefined + 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, ""), + ); + return facet.facet.startsWith("connections:") && + omittedConnection !== undefined ? { ...facet, status: "partial" as const, - reason: "serialized_view_hierarchy_incomplete", - omitted: facet.omitted + omitted, + // A connection dropped by the limit is recoverable by raising + // it, so that cause keeps the reason the graph already named. + // Only a facet with no other cause reports the omission. + reason: facet.reason ?? "connection_unresolved", + omitted: facet.omitted + omittedConnection, } : facet; }), @@ -306,7 +336,8 @@ const finalizeHierarchyCoverage = ( result.graph.truncated || counts.omitted > 0 || archiveDecodePartial || - incompleteHierarchies.size > 0, + incompleteHierarchies.size > 0 || + unresolvedConnections.size > 0, }, limitations: [ ...result.limitations, @@ -316,6 +347,11 @@ const finalizeHierarchyCoverage = ( : [ "Some serialized hierarchy links could not be projected within the bounded hierarchy; their parentage remains unknown.", ]), + ...(unresolvedConnections.size === 0 + ? [] + : [ + "Some recognized NIBArchive connectors declare no resolvable owner; they carry no connection evidence and are reported as omissions.", + ]), ...(invalid.length === 0 ? [] : [ @@ -496,6 +532,10 @@ const projectNibArchive = ( ); const connections: Record = {}; const hierarchy: NibHierarchyNode[] = []; + // A recognized connector whose owner reference cannot be resolved produces no + // connection entry, so the omission has to be counted here: the projected + // dictionary carries no trace of it for a downstream consumer to find. + let unresolvedConnections = 0; const reference = (value: JsonValue | undefined): number | null => { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; @@ -512,7 +552,10 @@ const projectNibArchive = ( const values = object.values; const connectionFields = interfaceBuilderConnectionFieldNames(className); const source = reference(values[connectionFields.source]); - if (source === null) continue; + if (source === null) { + unresolvedConnections += 1; + continue; + } const destination = reference(values[connectionFields.destination]); const label = referencedString(values[connectionFields.label]); const type = className.includes("Outlet") @@ -608,6 +651,7 @@ const projectNibArchive = ( ); return { omitted: viewHierarchy.omitted + hierarchyOmitted + merged.omitted, + unresolvedConnections, raw: jsonValueSchema.parse({ "com.apple.ibtool.document.objects": objects, "com.apple.ibtool.document.connections": connections, diff --git a/src/domain/apple/interfaceBuilderGraph.test.ts b/src/domain/apple/interfaceBuilderGraph.test.ts index bfa2c71de..96c6b4915 100644 --- a/src/domain/apple/interfaceBuilderGraph.test.ts +++ b/src/domain/apple/interfaceBuilderGraph.test.ts @@ -510,6 +510,106 @@ describe("AppKit keyed archive connectors", () => { }); }); +describe("keyed archive unresolved connectors", () => { + // A rooted UIKit archive whose only connector is UID 3, with UID 1 as the + // owner, UID 2 as the destination and UID 7 as the "view" label. + const rootedOutletArchive = (connection: Record) => ({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: [ + "$null", + { $class: { CF$UID: 4 } }, + { $class: { CF$UID: 5 } }, + { $class: { CF$UID: 6 }, ...connection }, + { + $classname: "UIViewController", + $classes: ["UIViewController", "UIResponder", "NSObject"], + }, + { $classname: "UIView", $classes: ["UIView", "UIResponder", "NSObject"] }, + { + $classname: "UIRuntimeOutletConnection", + $classes: [ + "UIRuntimeOutletConnection", + "UIRuntimeConnection", + "NSObject", + ], + }, + "view", + { + $class: { CF$UID: 11 }, + "NS.objects": [{ CF$UID: 1 }, { CF$UID: 2 }, { CF$UID: 3 }], + }, + { $class: { CF$UID: 11 }, "NS.objects": [{ CF$UID: 3 }] }, + { + $class: { CF$UID: 11 }, + "NS.objects": [{ CF$UID: 1 }, { CF$UID: 2 }], + }, + { $classname: "NSArray", $classes: ["NSArray", "NSObject"] }, + ], + $top: { + root: { CF$UID: 8 }, + UINibConnectionsKey: { CF$UID: 9 }, + UINibObjectsKey: { CF$UID: 10 }, + UINibTopLevelObjectsKey: { CF$UID: 10 }, + }, + }); + + const project = (raw: unknown) => + buildInterfaceBuilderAnalysis({ + targetSha256: hash, + toolVersion: "test", + documents: [ + { + relativePath: "Main.nib", + archiveSha256: hash, + documentKind: "nib", + raw, + }, + ], + limits: interfaceBuilderLimitsSchema.parse({}), + }); + + it("covers a resolvable connector as a complete examined connection", () => { + const raw = rootedOutletArchive({ + UISource: { CF$UID: 1 }, + UIDestination: { CF$UID: 2 }, + UILabel: { CF$UID: 7 }, + }); + expect(parseInterfaceBuilderRecords(raw).unresolvedConnections).toBe(0); + expect(project(raw).graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "connections:Main.nib", + status: "complete", + examined: 1, + omitted: 0, + }), + ); + }); + + it("reports a recognized connector with no resolvable owner as unresolved", () => { + const raw = rootedOutletArchive({ + UIDestination: { CF$UID: 2 }, + UILabel: { CF$UID: 7 }, + }); + expect(parseInterfaceBuilderRecords(raw).unresolvedConnections).toBe(1); + + const analysis = project(raw); + expect(analysis.graph.nodes.some(({ kind }) => kind === "outlet")).toBe( + false, + ); + expect(analysis.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "connections:Main.nib", + status: "partial", + reason: "connection_unresolved", + examined: 0, + omitted: 1, + }), + ); + expect(analysis.graph.truncated).toBe(true); + }); +}); + it("names the archive once per evidence record", () => { const relativePath = "Views/Main.storyboardc/scene.nib/objects.nib"; const result = buildInterfaceBuilderAnalysis({ diff --git a/src/domain/apple/interfaceBuilderGraph.ts b/src/domain/apple/interfaceBuilderGraph.ts index f25c8b9b1..b3e5216dc 100644 --- a/src/domain/apple/interfaceBuilderGraph.ts +++ b/src/domain/apple/interfaceBuilderGraph.ts @@ -410,18 +410,26 @@ export const buildInterfaceBuilderAnalysis = (input: { const connectionsTruncated = parserOmittedConnections > 0 || parsed.connections.length > input.limits.max_connections; + // A recognized connector the parser could not attach to an owner produces + // no connection record, so its coverage is partial with its own reason + // rather than absent or truncated. + const connectionsUnresolved = parsed.unresolvedConnections > 0; omittedObjects += parsed.omittedObjects + Math.max(0, parsed.objects.length - documentObjectCount); omittedConnections += Math.max(0, parsed.connections.length - input.limits.max_connections) + - parserOmittedConnections; + parserOmittedConnections + + parsed.unresolvedConnections; summaries.push({ relative_path: document.relativePath, archive_sha256: document.archiveSha256, document_kind: document.documentKind, object_count: parsed.objectCount, - connection_count: parsed.connections.length + parserOmittedConnections, + connection_count: + parsed.connections.length + + parserOmittedConnections + + parsed.unresolvedConnections, hierarchy_complete: parsed.hierarchy.length > 0 && !truncated && @@ -440,13 +448,17 @@ export const buildInterfaceBuilderAnalysis = (input: { { facet: `connections:${document.relativePath}`, status: - connectionsTruncated || documentOmittedConnections > 0 + connectionsTruncated || + documentOmittedConnections > 0 || + connectionsUnresolved ? "partial" : "complete", reason: connectionsTruncated || documentOmittedConnections > 0 ? "connection_limit_reached" - : null, + : connectionsUnresolved + ? "connection_unresolved" + : null, examined: Math.min( parsed.connections.length, input.limits.max_connections, @@ -457,7 +469,8 @@ export const buildInterfaceBuilderAnalysis = (input: { parsed.connections.length - input.limits.max_connections, ) + documentOmittedConnections + - parserOmittedConnections, + parserOmittedConnections + + parsed.unresolvedConnections, }, { facet: `hierarchy:${document.relativePath}`, diff --git a/src/domain/apple/interfaceBuilderKeyedArchive.ts b/src/domain/apple/interfaceBuilderKeyedArchive.ts index 207950bf8..57ebda803 100644 --- a/src/domain/apple/interfaceBuilderKeyedArchive.ts +++ b/src/domain/apple/interfaceBuilderKeyedArchive.ts @@ -55,6 +55,7 @@ export const parseInterfaceBuilderRecords = ( readonly objectCount: number; readonly omittedObjects: number; readonly omittedConnections: number; + readonly unresolvedConnections: number; readonly omittedHierarchyReferences: number; } => { const root = record(value); @@ -135,6 +136,7 @@ export const parseInterfaceBuilderRecords = ( objectCount: objectEntries.length, omittedObjects: Math.max(0, objectEntries.length - 20_000), omittedConnections, + unresolvedConnections: 0, omittedHierarchyReferences: 0, }; }; @@ -320,6 +322,7 @@ const parseKeyedArchive = ( const connections: InterfaceBuilderConnection[] = []; let objectCount = 0; let omittedConnections = 0; + let unresolvedConnections = 0; for (const [index, raw] of objectTable.entries()) { if (index === 0) continue; const resolved = record(resolve(raw)); @@ -371,7 +374,13 @@ const parseKeyedArchive = ( controlAction ? fields.to : fields.from, controlAction ? fields.target : fields.owner, ); - if (source === null) continue; + if (source === null) { + // A recognized connector that cannot name its owner is an unresolved + // connection, not an absent one, and cannot be represented as a + // connection because connection identity requires a source. + unresolvedConnections += 1; + continue; + } const kind = controlAction ? "action" : classifyConnection(className); const parsedConnection = connection.parse({ id: `archive:${objectId}`, @@ -426,6 +435,7 @@ const parseKeyedArchive = ( objectCount, omittedObjects: Math.max(0, objectCount - 20_000), omittedConnections, + unresolvedConnections, omittedHierarchyReferences: hierarchy.omittedReferences, }; }; diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 4446c80dc..d584aaf3c 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -1060,6 +1060,142 @@ describe("NIBArchive UIKit event connections", () => { "UIRuntimeEventConnection", ); }); + + it("reports a runtime connection with no resolvable owner as an omission", async () => { + const root = await createTestTempDirectory("rea-ib-ownerless-"); + const bundle = join(root, "Example.app"); + await mkdir(bundle, { recursive: true }); + // The connector is recognized by class, but carries no UISource reference, + // so no connection can be projected from it. + await writeFile( + join(bundle, "Cell.nib"), + encodeNibArchiveFixture({ + classes: [ + "UIProxyObject", + "UIClassSwapper", + "UIButton", + "UIRuntimeEventConnection", + "NSString", + ], + objects: [ + { classIndex: 0, values: { UIProxiedObjectIdentifier: { ref: 5 } } }, + { classIndex: 1, values: { UIClassName: { ref: 6 } } }, + { classIndex: 2, values: {} }, + { + classIndex: 3, + values: { UIDestination: { ref: 1 }, UIEventMask: 64 }, + }, + { classIndex: 2, values: {} }, + { classIndex: 4, values: { "NS.bytes": "IBFilesOwner" } }, + { classIndex: 4, values: { "NS.bytes": "BuildCell" } }, + ], + }), + ); + + const analysis = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "e".repeat(64), + }); + + expect(analysis.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "connections:Cell.nib", + status: "partial", + reason: "connection_unresolved", + examined: 0, + omitted: 1, + }), + ); + // The keyed-archive producer counts a recognized connector it cannot emit, + // so this producer has to report the same document total for the same + // connector rather than zero. + expect(analysis.documents).toContainEqual( + expect.objectContaining({ + relative_path: "Cell.nib", + connection_count: 1, + }), + ); + expect(analysis.graph.truncated).toBe(true); + expect(analysis.limitations).toContain( + "Some recognized NIBArchive connectors declare no resolvable owner; they carry no connection evidence and are reported as omissions.", + ); + }); +}); + +describe("NIBArchive ownerless connector accounting", () => { + 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"); + await mkdir(bundle, { recursive: true }); + // Two resolvable connectors and one ownerless connector with the limit + // below the resolvable count, so this facet has both causes at once. Only a + // higher limit recovers the dropped connection, so that is the reason the + // caller needs, exactly as the keyed-archive producer reports it. + await writeFile( + join(bundle, "Cell.nib"), + encodeNibArchiveFixture({ + classes: [ + "UIProxyObject", + "UIClassSwapper", + "UIButton", + "UIRuntimeEventConnection", + "NSString", + ], + objects: [ + { classIndex: 0, values: { UIProxiedObjectIdentifier: { ref: 5 } } }, + { classIndex: 1, values: { UIClassName: { ref: 6 } } }, + { classIndex: 2, values: {} }, + { + classIndex: 3, + values: { + UISource: { ref: 2 }, + UIDestination: { ref: 1 }, + UILabel: { ref: 7 }, + UIEventMask: 64, + }, + }, + { classIndex: 2, values: {} }, + { classIndex: 4, values: { "NS.bytes": "IBFilesOwner" } }, + { classIndex: 4, values: { "NS.bytes": "BuildCell" } }, + { classIndex: 4, values: { "NS.bytes": "buildTapped:" } }, + { + classIndex: 3, + values: { + UISource: { ref: 4 }, + UIDestination: { ref: 1 }, + UILabel: { ref: 7 }, + }, + }, + { + classIndex: 3, + values: { UIDestination: { ref: 1 }, UIEventMask: 64 }, + }, + ], + }), + ); + + const analysis = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "e".repeat(64), + limits: { max_connections: 1 }, + }); + + expect(analysis.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "connections:Cell.nib", + status: "partial", + reason: "connection_limit_reached", + examined: 1, + omitted: 2, + }), + ); + expect(analysis.documents).toContainEqual( + expect.objectContaining({ + relative_path: "Cell.nib", + connection_count: 3, + }), + ); + }); }); // This exact file is already selected by the unchanged native Apple CI lane.