diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index bcf6820146..670c641d30 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -5,6 +5,7 @@ import { appleResourceKeyFields } from "../../domain/apple/appleAssetCatalog.js" import { interfaceBuilderConnectionFieldNames, isInterfaceBuilderConnectionClass, + isSourceLessAppKitAnnotationConnector, isUIKitRuntimeConnectionClass, } from "../../domain/apple/interfaceBuilderKeyedArchive.js"; import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; @@ -54,7 +55,6 @@ import { const MAX_AGGREGATE_INPUT_BYTES = 32 * 1024 * 1024; const MAX_AGGREGATE_DECODE_BYTES = 256 * 1024 * 1024; const MAX_REPORTED_OMITTED_ARCHIVES = 64; - /** Decode compiled Interface Builder archives from a local app bundle. */ export const analyzeInterfaceBuilderBundle = async (input: { readonly bundlePath: string; @@ -611,6 +611,9 @@ const projectNibArchive = ( if (!isInterfaceBuilderConnectionClass(className)) continue; const values = object.values; const connectionFields = interfaceBuilderConnectionFieldNames(className); + // A source-less known AppKit annotation names one object, not a connection. + // Unknown connectors and annotations with an encoded source remain evidence. + if (isSourceLessAppKitAnnotationConnector(className, values)) continue; const source = reference(values[connectionFields.source]); if (source === null) { unresolvedConnections += 1; diff --git a/src/domain/apple/interfaceBuilderKeyedArchive.ts b/src/domain/apple/interfaceBuilderKeyedArchive.ts index 94ba33c375..56ac6d1990 100644 --- a/src/domain/apple/interfaceBuilderKeyedArchive.ts +++ b/src/domain/apple/interfaceBuilderKeyedArchive.ts @@ -34,6 +34,12 @@ const connection = z.strictObject({ type InterfaceBuilderObject = z.infer; type InterfaceBuilderConnection = z.infer; +const APPKIT_SINGLE_OBJECT_ANNOTATION_CONNECTORS = new Set([ + "NSIBHelpConnector", + "NSNibAXAttributeConnector", + "NSIBUserDefinedRuntimeAttributesConnector", +]); + /** UIKit runtime connection classes store their endpoints and labels under UI-prefixed keys. */ export const isUIKitRuntimeConnectionClass = (className: string): boolean => /^UIRuntime\w*Connection$/u.test(className); @@ -41,9 +47,17 @@ export const isUIKitRuntimeConnectionClass = (className: string): boolean => /** AppKit NIB connector classes and UIKit runtime connection records. */ export const isInterfaceBuilderConnectionClass = (className: string): boolean => isUIKitRuntimeConnectionClass(className) || - /^NSNib(?:Connector|BindingConnector|ControlConnector|OutletConnector)$/u.test( - className, - ); + // Keep unfamiliar AppKit connector records so projection can report their + // missing owner as unresolved instead of silently dropping their identity. + (className.startsWith("NS") && className.endsWith("Connector")); + +/** These AppKit records annotate one object and only mean a connection if a source key is encoded. */ +export const isSourceLessAppKitAnnotationConnector = ( + className: string, + values: Readonly>, +): boolean => + APPKIT_SINGLE_OBJECT_ANNOTATION_CONNECTORS.has(className) && + !Object.hasOwn(values, "NSSource"); /** The connection class selects the source archive field namespace. */ export const interfaceBuilderConnectionFieldNames = (className: string) => @@ -424,6 +438,7 @@ const parseKeyedArchive = ( // AppKit and UIKit archive the same endpoint roles under their own // class-specific field namespaces; the source is the outlet owner. const connectionFields = interfaceBuilderConnectionFieldNames(className); + if (isSourceLessAppKitAnnotationConnector(className, fields)) continue; const source = uiKit ? archivedObjectReference(fields[connectionFields.source]) : firstObjectReference( diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index d0bba09217..5f55094ad8 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -834,6 +834,94 @@ cliTest( }, ); +cliTest( + "compiled CLI omits only source-less keyed archive annotation connectors", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-ib-keyed-annotations-"); + const bundle = join(root, "Example.app"); + const contents = join(bundle, "Contents"); + const resources = join(contents, "Resources"); + await mkdir(resources, { recursive: true }); + await mkdir(join(contents, "MacOS"), { recursive: true }); + await writeFile( + join(contents, "MacOS", "App"), + thinMach(0xfeedfacf, 0x0100000c), + ); + await writeFile( + join(contents, "Info.plist"), + "CFBundleExecutableApp", + ); + await writeFile( + join(resources, "Annotations.nib"), + buildBinary({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: [ + "$null", + { + $class: { UID: 9 }, + NSSource: { UID: 7 }, + NSDestination: { UID: 8 }, + NSLabel: "outlet", + }, + { $class: { UID: 10 }, NSDestination: { UID: 8 } }, + { $class: { UID: 11 }, AXDestinationArchiveKey: { UID: 8 } }, + { $class: { UID: 12 }, NSObject: { UID: 8 } }, + { $class: { UID: 13 }, NSDestination: { UID: 8 } }, + { + $class: { UID: 10 }, + NSSource: null, + NSDestination: { UID: 8 }, + }, + { $class: { UID: 14 } }, + { $class: { UID: 14 } }, + { + $classname: "NSNibOutletConnector", + $classes: ["NSNibOutletConnector", "NSNibConnector", "NSObject"], + }, + { + $classname: "NSIBHelpConnector", + $classes: ["NSIBHelpConnector", "NSObject"], + }, + { + $classname: "NSNibAXAttributeConnector", + $classes: ["NSNibAXAttributeConnector", "NSObject"], + }, + { + $classname: "NSIBUserDefinedRuntimeAttributesConnector", + $classes: ["NSIBUserDefinedRuntimeAttributesConnector", "NSObject"], + }, + { + $classname: "NSFutureConnector", + $classes: ["NSFutureConnector", "NSObject"], + }, + { $classname: "NSView", $classes: ["NSView", "NSObject"] }, + ], + $top: { root: { UID: 1 } }, + }), + ); + + const output = await cli.run({ + arguments: ["decode-interface-builder", bundle, "--json"], + environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" }, + }); + expect(output.exitCode, output.stderr).toBe(0); + const result = interfaceBuilderAnalysisSchema.parse( + parseEvidence(output.json).normalized_result, + ); + expect(result.documents[0]?.connection_count).toBe(3); + expect(result.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "connections:Contents/Resources/Annotations.nib", + status: "partial", + reason: "connection_unresolved", + examined: 1, + omitted: 2, + }), + ); + }, +); + it("projects an NIBArchive binding as an unsupported partial connection", async () => { const root = await createTestTempDirectory("rea-ib-nibarchive-binding-"); const bundle = join(root, "Example.app"); @@ -1479,6 +1567,210 @@ describe("NIBArchive ownerless connector accounting", () => { }); }); +describe("NIBArchive single-object connectors", () => { + it("does not count tooltip, accessibility or runtime attribute connectors as ownerless", async () => { + const root = await createTestTempDirectory("rea-ib-annotation-"); + const bundle = join(root, "Example.app"); + await mkdir(bundle, { recursive: true }); + // Xcode encodes a tooltip, an accessibility description and user-defined + // runtime attributes as connector objects that name one object and no + // NSSource, beside the outlet that is the only connection here. + await writeFile( + join(bundle, "Window.nib"), + encodeNibArchiveFixture({ + classes: [ + "NSCustomObject", + "NSButton", + "NSNibOutletConnector", + "NSIBHelpConnector", + "NSNibAXAttributeConnector", + "NSIBUserDefinedRuntimeAttributesConnector", + "NSString", + "NSArray", + "NSNumber", + ], + objects: [ + { classIndex: 0, values: {} }, + { classIndex: 1, values: {} }, + { + classIndex: 2, + values: { + NSSource: { ref: 0 }, + NSDestination: { ref: 1 }, + NSLabel: { ref: 6 }, + }, + }, + { + classIndex: 3, + values: { + NSDestination: { ref: 1 }, + NSFile: { ref: 7 }, + NSMarker: { ref: 8 }, + }, + }, + { + classIndex: 4, + values: { + AXDestinationArchiveKey: { ref: 1 }, + AXAttributeTypeArchiveKey: { ref: 9 }, + AXAttributeValueArchiveKey: { ref: 10 }, + }, + }, + { + classIndex: 5, + values: { + NSObject: { ref: 1 }, + NSValues: { ref: 11 }, + NSKeyPaths: { ref: 12 }, + }, + }, + { classIndex: 6, values: { "NS.bytes": "button" } }, + { classIndex: 6, values: { "NS.bytes": "NSToolTipHelpKey" } }, + { classIndex: 6, values: { "NS.bytes": "Change text style" } }, + { classIndex: 6, values: { "NS.bytes": "AXDescription" } }, + { classIndex: 6, values: { "NS.bytes": "text style" } }, + { + classIndex: 7, + values: { + NSInlinedValue: false, + UINibEncoderEmptyKey: { ref: 13 }, + }, + }, + { + classIndex: 7, + values: { + NSInlinedValue: false, + UINibEncoderEmptyKey: { ref: 14 }, + }, + }, + { classIndex: 8, values: { "NS.intval": 4 } }, + { classIndex: 6, values: { "NS.bytes": "layer.cornerRadius" } }, + ], + }), + ); + + const analysis = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "e".repeat(64), + }); + + expect(analysis.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "connections:Window.nib", + status: "complete", + reason: null, + examined: 1, + omitted: 0, + }), + ); + expect(analysis.documents).toContainEqual( + expect.objectContaining({ + relative_path: "Window.nib", + connection_count: 1, + }), + ); + expect(analysis.graph.truncated).toBe(false); + expect(analysis.limitations).not.toContain( + "Some recognized NIBArchive connectors declare no resolvable owner; they carry no connection evidence and are reported as omissions.", + ); + }); +}); + +// Only an absent NSSource marks an annotation; an encoded but unusable one is +// still a connection whose owner cannot be resolved. +describe("NIBArchive connectors with unusable sources", () => { + it("keeps an unknown AppKit connector without NSSource unresolved", async () => { + const root = await createTestTempDirectory("rea-ib-unknown-connector-"); + const bundle = join(root, "Example.app"); + await mkdir(bundle, { recursive: true }); + await writeFile( + join(bundle, "Window.nib"), + encodeNibArchiveFixture({ + classes: ["NSCustomObject", "NSButton", "NSFutureConnector"], + objects: [ + { classIndex: 0, values: {} }, + { classIndex: 1, values: {} }, + { classIndex: 2, values: { NSDestination: { ref: 1 } } }, + ], + }), + ); + + const analysis = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "e".repeat(64), + }); + + expect(analysis.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "connections:Window.nib", + status: "partial", + reason: "connection_unresolved", + omitted: 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.", + ); + }); + + it.each([ + ["NSNibOutletConnector", "nil", null], + ["NSNibOutletConnector", "a non-reference value", 7], + ["NSIBHelpConnector", "nil", null], + ] as const)( + "still counts %s whose NSSource is %s as ownerless", + async (className, _label, source) => { + const root = await createTestTempDirectory("rea-ib-unusable-source-"); + const bundle = join(root, "Example.app"); + await mkdir(bundle, { recursive: true }); + await writeFile( + join(bundle, "Window.nib"), + encodeNibArchiveFixture({ + classes: [ + "NSCustomObject", + "NSButton", + "NSNibOutletConnector", + "NSIBHelpConnector", + "NSString", + ], + objects: [ + { classIndex: 0, values: {} }, + { classIndex: 1, values: {} }, + { + classIndex: className === "NSNibOutletConnector" ? 2 : 3, + values: { + NSSource: source, + NSDestination: { ref: 1 }, + NSLabel: { ref: 3 }, + }, + }, + { classIndex: 4, values: { "NS.bytes": "button" } }, + ], + }), + ); + + const analysis = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "e".repeat(64), + }); + + expect(analysis.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "connections:Window.nib", + status: "partial", + reason: "connection_unresolved", + omitted: 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.", + ); + }, + ); +}); + // This exact file is already selected by the unchanged native Apple CI lane. describe.skipIf(process.platform !== "darwin")( "Foundation Interface Builder XML encodings",