From 14cd78c3224edcf79fca152915077112f547b9ed Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:31:00 +0800 Subject: [PATCH] fix(apple): retain unsupported NIB connectors in connection coverage Recognize AppKit binding connectors as explicit unknown connection facts and report partial semantic coverage rather than silently omitting them. Share format-aware connector admission across keyed archives and NIBArchive. Make the keyed archive table own parsed class and superclass facts for names, array hierarchy traversal, inherited connectors, and class-table projection. Preserve malformed class evidence and exclude unrelated custom class names. Validation: 84 focused tests passed, including compiled CLI, filesystem, MCP summary, and retained views; check:fast and test discovery passed. Independent source review found no blocker. Production CLI and stdio MCP replayed a read-only copy of Terminal 2.15's picker NIB: 13 connections with partial unsupported-type coverage, versus baseline 9 with complete coverage. Advertised schemas and retained coverage results validated with the SDK/Ajv. No app, analysis target, or optional deep provider was executed. Fixes #5590 --- .../apple/InterfaceBuilderAnalysis.ts | 3 +- src/artifacts/apple/NibArchive.test.ts | 17 ++ src/artifacts/apple/NibArchive.ts | 5 +- .../apple/interfaceBuilderGraph.test.ts | 163 ++++++++++++++++++ src/domain/apple/interfaceBuilderGraph.ts | 10 +- .../apple/interfaceBuilderKeyedArchive.ts | 111 ++++++++---- .../filesystem/interfaceBuilderBundle.test.ts | 149 ++++++++++++++++ 7 files changed, 415 insertions(+), 43 deletions(-) diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index 5d65e59e4b..f05c03b780 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -4,6 +4,7 @@ import { parseBinary } from "plist"; import { appleResourceKeyFields } from "../../domain/apple/appleAssetCatalog.js"; import { interfaceBuilderConnectionFieldNames, + isInterfaceBuilderConnectionClass, isUIKitRuntimeConnectionClass, } from "../../domain/apple/interfaceBuilderKeyedArchive.js"; import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; @@ -576,7 +577,7 @@ const projectNibArchive = ( // AppKit connectors use NS-prefixed keys; UIKit's runtime outlet and // event connections use UI-prefixed keys with the same roles. const uiKit = isUIKitRuntimeConnectionClass(className); - if (!uiKit && !className.includes("Connector")) continue; + if (!isInterfaceBuilderConnectionClass(className)) continue; const values = object.values; const connectionFields = interfaceBuilderConnectionFieldNames(className); const source = reference(values[connectionFields.source]); diff --git a/src/artifacts/apple/NibArchive.test.ts b/src/artifacts/apple/NibArchive.test.ts index 665617d281..9d621b659b 100644 --- a/src/artifacts/apple/NibArchive.test.ts +++ b/src/artifacts/apple/NibArchive.test.ts @@ -77,6 +77,23 @@ describe("NIBArchive decoder", () => { expect(decoded.coder_version).toBe(10); }); + it("counts AppKit outlet, action, and binding connector objects", () => { + const archive = encodeArchive({ + classes: [ + "NSNibOutletConnector", + "NSNibControlConnector", + "NSNibBindingConnector", + ], + objects: [ + { classIndex: 0, values: {} }, + { classIndex: 1, values: {} }, + { classIndex: 2, values: {} }, + ], + }); + + expect(decodeNibArchive(archive).connection_object_count).toBe(3); + }); + it("rejects malformed references and unsupported format versions", () => { const valid = encodeArchive({ classes: ["NSObject"], diff --git a/src/artifacts/apple/NibArchive.ts b/src/artifacts/apple/NibArchive.ts index c673184f46..4d2d6b77f7 100644 --- a/src/artifacts/apple/NibArchive.ts +++ b/src/artifacts/apple/NibArchive.ts @@ -1,5 +1,6 @@ import type { JsonValue } from "../../domain/jsonValue.js"; import { hasNibArchiveSignature } from "../../domain/apple/appleArchiveFormat.js"; +import { isInterfaceBuilderConnectionClass } from "../../domain/apple/interfaceBuilderKeyedArchive.js"; import { TextDecoder } from "node:util"; import { InterfaceBuilderDecodeBudget, @@ -147,9 +148,7 @@ export const decodeNibArchive = ( }); let connectionObjectCount = 0; for (const object of objects) - if ( - /(?:Outlet|Connection|Segue|ActionConnection)/iu.test(object.class_name) - ) + if (isInterfaceBuilderConnectionClass(object.class_name)) connectionObjectCount += 1; return { diff --git a/src/domain/apple/interfaceBuilderGraph.test.ts b/src/domain/apple/interfaceBuilderGraph.test.ts index ed8a96cb53..8f5bcc06b1 100644 --- a/src/domain/apple/interfaceBuilderGraph.test.ts +++ b/src/domain/apple/interfaceBuilderGraph.test.ts @@ -707,7 +707,170 @@ describe("AppKit keyed archive connectors", () => { }), ]); }); +}); +describe("unsupported keyed archive connections", () => { + it("keeps unsupported binding connectors in connection coverage", () => { + const raw = { + $archiver: "NSKeyedArchiver", + $objects: [ + "$null", + { $class: { UID: 6 } }, + { $class: { UID: 6 } }, + { + $class: { UID: 7 }, + NSSource: { UID: 1 }, + NSDestination: { UID: 2 }, + NSLabel: "outlet", + }, + { + $class: { UID: 8 }, + NSSource: { UID: 1 }, + NSDestination: { UID: 2 }, + NSLabel: "performAction:", + }, + { + $class: { UID: 9 }, + NSSource: { UID: 1 }, + NSDestination: { UID: 2 }, + NSLabel: "value", + }, + { $classname: "NSView", $classes: ["NSView", "NSObject"] }, + { + $classname: "NSNibOutletConnector", + $classes: ["NSNibOutletConnector", "NSNibConnector", "NSObject"], + }, + { + $classname: "NSNibControlConnector", + $classes: ["NSNibControlConnector", "NSNibConnector", "NSObject"], + }, + { + $classname: "NSNibBindingConnector", + $classes: ["NSNibBindingConnector", "NSNibConnector", "NSObject"], + }, + ], + $top: {}, + }; + + const parsed = parseInterfaceBuilderRecords(raw); + expect(parsed.connections.map(({ kind }) => kind)).toEqual([ + "outlet", + "action", + "other", + ]); + + const analysis = buildInterfaceBuilderAnalysis({ + targetSha256: hash, + toolVersion: "test", + documents: [ + { + relativePath: "MainMenu.nib", + archiveSha256: hash, + documentKind: "nib", + raw, + }, + ], + limits: interfaceBuilderLimitsSchema.parse({}), + }); + expect(analysis.documents[0]?.connection_count).toBe(3); + expect(analysis.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "connections:MainMenu.nib", + status: "partial", + reason: "unsupported_connection_type", + examined: 3, + omitted: 0, + }), + ); + expect(analysis.graph.edges).toContainEqual( + expect.objectContaining({ + relation: "contains", + reason: "unsupported_connection_type", + evidence: [ + expect.objectContaining({ description: "connection type other" }), + ], + }), + ); + }); +}); + +describe("keyed archive class identity", () => { + it("does not classify a custom class containing Connector as a connection", () => { + const parsed = parseInterfaceBuilderRecords({ + $archiver: "NSKeyedArchiver", + $objects: [ + "$null", + { + $class: { UID: 2 }, + NSSource: { UID: 1 }, + NSDestination: { UID: 1 }, + }, + { + $classname: "TelemetryConnectorState", + $classes: ["TelemetryConnectorState", "NSObject"], + }, + ], + $top: {}, + }); + + expect(parsed.objects).toContainEqual( + expect.objectContaining({ + class_name: "TelemetryConnectorState", + id: "1", + }), + ); + expect(parsed.connections).toEqual([]); + expect(parsed.unresolvedConnections).toBe(0); + + const inheritedConnector = parseInterfaceBuilderRecords({ + $archiver: "NSKeyedArchiver", + $objects: [ + "$null", + { + $class: { UID: 3 }, + NSSource: { UID: 1 }, + NSDestination: { UID: 1 }, + }, + { $classname: "NSNibConnector", $classes: ["NSNibConnector"] }, + { + $classname: "CustomBindingConnector", + $classes: ["CustomBindingConnector", "NSNibConnector", "NSObject"], + }, + ], + $top: {}, + }); + expect(inheritedConnector.connections).toMatchObject([ + { kind: "other", source_id: "1", destination_id: "1" }, + ]); + }); + + it("preserves a malformed class UID without inferring a connector", () => { + const parsed = parseInterfaceBuilderRecords({ + $archiver: "NSKeyedArchiver", + $objects: [ + "$null", + { + $class: { UID: 99 }, + NSSource: { UID: 1 }, + NSDestination: { UID: 1 }, + }, + ], + $top: {}, + }); + + expect(parsed.objects).toContainEqual( + expect.objectContaining({ + class_name: null, + id: "1", + attributes: expect.objectContaining({ $class: { UID: 99 } }), + }), + ); + expect(parsed.connections).toEqual([]); + expect(parsed.unresolvedConnections).toBe(0); + }); +}); + +describe("AppKit control connectors", () => { it("projects an AppKit action from the control through the selector to its target", () => { const analysis = buildInterfaceBuilderAnalysis({ targetSha256: hash, diff --git a/src/domain/apple/interfaceBuilderGraph.ts b/src/domain/apple/interfaceBuilderGraph.ts index 005bd23b39..e38a6d326c 100644 --- a/src/domain/apple/interfaceBuilderGraph.ts +++ b/src/domain/apple/interfaceBuilderGraph.ts @@ -453,6 +453,9 @@ export const buildInterfaceBuilderAnalysis = (input: { // no connection record, so its coverage is partial with its own reason // rather than absent or truncated. const connectionsUnresolved = parsed.unresolvedConnections > 0; + const unsupportedConnections = parsed.connections.some( + ({ kind }) => kind === "other", + ); omittedObjects += parsed.omittedObjects + Math.max(0, parsed.objects.length - documentObjectCount); @@ -500,7 +503,8 @@ export const buildInterfaceBuilderAnalysis = (input: { status: connectionsTruncated || documentOmittedConnections > 0 || - connectionsUnresolved + connectionsUnresolved || + unsupportedConnections ? "partial" : "complete", reason: @@ -508,7 +512,9 @@ export const buildInterfaceBuilderAnalysis = (input: { ? "connection_limit_reached" : connectionsUnresolved ? "connection_unresolved" - : null, + : unsupportedConnections + ? "unsupported_connection_type" + : null, examined: Math.min( parsed.connections.length, input.limits.max_connections, diff --git a/src/domain/apple/interfaceBuilderKeyedArchive.ts b/src/domain/apple/interfaceBuilderKeyedArchive.ts index a12b4bad09..94ba33c375 100644 --- a/src/domain/apple/interfaceBuilderKeyedArchive.ts +++ b/src/domain/apple/interfaceBuilderKeyedArchive.ts @@ -38,6 +38,13 @@ type InterfaceBuilderConnection = z.infer; export const isUIKitRuntimeConnectionClass = (className: string): boolean => /^UIRuntime\w*Connection$/u.test(className); +/** 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, + ); + /** The connection class selects the source archive field namespace. */ export const interfaceBuilderConnectionFieldNames = (className: string) => isUIKitRuntimeConnectionClass(className) @@ -167,13 +174,51 @@ const HIERARCHY_KEY = * names the referenced object; the hierarchy walk visits each object once. */ const keyedArchiveTable = (objectTable: readonly unknown[]) => { - const classNameOf = (value: unknown): string | null => { - const classReference = record(value).$class; - const uid = archiveUid(classReference); - return firstString( - record(uid === undefined ? classReference : objectTable[uid]).$classname, - ); + type ClassFacts = { + readonly name: string | null; + readonly rawSuperclasses: unknown[]; + readonly superclasses: readonly string[]; }; + const classFactsByUid = new Map(); + const parseClassFacts = (value: unknown): ClassFacts | null => { + const descriptor = record(value); + const rawSuperclasses = Array.isArray(descriptor.$classes) + ? descriptor.$classes + : []; + if ( + !Object.hasOwn(descriptor, "$classname") && + rawSuperclasses.length === 0 + ) + return null; + return { + name: firstString(descriptor.$classname), + rawSuperclasses, + superclasses: rawSuperclasses.filter( + (name): name is string => typeof name === "string", + ), + }; + }; + const classFactsForUid = (uid: number): ClassFacts | null => { + const cached = classFactsByUid.get(uid); + if (cached !== undefined) return cached; + if (!Number.isInteger(uid) || uid < 0 || uid >= objectTable.length) + return null; + const facts = parseClassFacts(objectTable[uid]); + if (facts !== null) classFactsByUid.set(uid, facts); + return facts; + }; + const classFactsOfReference = (reference: unknown): ClassFacts | null => { + const uid = archiveUid(reference); + return uid === undefined + ? parseClassFacts(reference) + : classFactsForUid(uid); + }; + const classFactsOfObject = (value: unknown) => + classFactsOfReference(record(value).$class); + const classNameOf = (value: unknown): string | null => + classFactsOfObject(value)?.name ?? null; + const hasClassSuperclass = (value: unknown, superclass: string): boolean => + classFactsOfObject(value)?.superclasses.includes(superclass) ?? false; /** The string an archived string object holds, if it is one. */ const archivedString = (target: unknown): string | undefined => { if (typeof target === "string") return target; @@ -294,20 +339,23 @@ const keyedArchiveTable = (objectTable: readonly unknown[]) => { return { items: root, omittedReferences }; }; const archivedArrayClass = (classReference: unknown): boolean => { - const uid = archiveUid(classReference); - const descriptor = record( - uid === undefined ? classReference : objectTable[uid], - ); - const classNames = Array.isArray(descriptor.$classes) - ? descriptor.$classes.filter( - (name): name is string => typeof name === "string", - ) - : []; + const superclasses = + classFactsOfReference(classReference)?.superclasses ?? []; return ( - classNames.includes("NSArray") || classNames.includes("NSMutableArray") + superclasses.includes("NSArray") || + superclasses.includes("NSMutableArray") ); }; - return { resolve, hierarchy }; + const classTable = () => + Object.fromEntries( + objectTable.flatMap((_, uid) => { + const facts = classFactsForUid(uid); + return facts === null || facts.name === null + ? [] + : [[facts.name, { superclasses: facts.rawSuperclasses }]]; + }), + ); + return { resolve, hierarchy, hasClassSuperclass, classTable }; }; /** Project class and connection objects from an NSKeyedArchiver object table. */ @@ -317,7 +365,12 @@ const parseKeyedArchive = ( if (root.$archiver !== "NSKeyedArchiver" || !Array.isArray(root.$objects)) return null; const objectTable: readonly unknown[] = root.$objects; - const { resolve, hierarchy: toHierarchy } = keyedArchiveTable(objectTable); + const { + resolve, + hierarchy: toHierarchy, + hasClassSuperclass, + classTable, + } = keyedArchiveTable(objectTable); const objects: InterfaceBuilderObject[] = []; const connections: InterfaceBuilderConnection[] = []; let objectCount = 0; @@ -363,9 +416,8 @@ const parseKeyedArchive = ( ); if ( className !== null && - /(?:Outlet|Connection|Segue|ActionConnection|ControlConnector)/iu.test( - className, - ) + (isInterfaceBuilderConnectionClass(className) || + hasClassSuperclass(raw, "NSNibConnector")) ) { const controlAction = /ControlConnector/iu.test(className); const uiKit = isUIKitRuntimeConnectionClass(className); @@ -417,22 +469,7 @@ const parseKeyedArchive = ( } const top = record(root.$top); const hierarchy = toHierarchy(top.root ?? top.UITopLevelObjectsKey); - const classes = Object.fromEntries( - objectTable.flatMap((raw) => { - const item = record(raw); - const name = firstString(item.$classname); - return name === null - ? [] - : [ - [ - name, - { - superclasses: Array.isArray(item.$classes) ? item.$classes : [], - }, - ], - ]; - }), - ); + const classes = classTable(); return { objects, connections, diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 73f9425d04..d0bba09217 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -745,6 +745,155 @@ cliTest( }, ); +cliTest( + "compiled CLI reports unsupported keyed archive bindings in connection coverage", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-ib-binding-coverage-"); + 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, "MainMenu.nib"), + buildBinary({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: [ + "$null", + { $class: { UID: 6 } }, + { $class: { UID: 6 } }, + { + $class: { UID: 7 }, + NSSource: { UID: 1 }, + NSDestination: { UID: 2 }, + NSLabel: "outlet", + }, + { + $class: { UID: 8 }, + NSSource: { UID: 1 }, + NSDestination: { UID: 2 }, + NSLabel: "performAction:", + }, + { + $class: { UID: 9 }, + NSSource: { UID: 1 }, + NSDestination: { UID: 2 }, + NSLabel: "value", + }, + { $classname: "NSView", $classes: ["NSView", "NSObject"] }, + { + $classname: "NSNibOutletConnector", + $classes: ["NSNibOutletConnector", "NSNibConnector", "NSObject"], + }, + { + $classname: "NSNibControlConnector", + $classes: ["NSNibControlConnector", "NSNibConnector", "NSObject"], + }, + { + $classname: "NSNibBindingConnector", + $classes: ["NSNibBindingConnector", "NSNibConnector", "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/MainMenu.nib", + status: "partial", + reason: "unsupported_connection_type", + examined: 3, + omitted: 0, + }), + ); + expect(result.graph.edges).toContainEqual( + expect.objectContaining({ + relation: "contains", + reason: "unsupported_connection_type", + }), + ); + }, +); + +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"); + const resources = join(bundle, "Contents", "Resources"); + await mkdir(resources, { recursive: true }); + await writeFile( + join(resources, "Main.nib"), + encodeNibArchiveFixture({ + classes: [ + "NSView", + "NSNibBindingConnector", + "NSString", + "TelemetryConnectorState", + ], + objects: [ + { classIndex: 0, values: {} }, + { classIndex: 0, values: {} }, + { + classIndex: 1, + values: { + NSSource: { ref: 0 }, + NSDestination: { ref: 1 }, + NSLabel: { ref: 3 }, + }, + }, + { classIndex: 2, values: { "NS.bytes": "value" } }, + { + classIndex: 3, + values: { NSSource: { ref: 0 }, NSDestination: { ref: 1 } }, + }, + ], + }), + ); + + const result = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "e".repeat(64), + }); + + expect(result.documents[0]?.connection_count).toBe(1); + expect(result.graph.nodes).toContainEqual( + expect.objectContaining({ name: "TelemetryConnectorState" }), + ); + expect(result.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "connections:Contents/Resources/Main.nib", + status: "partial", + reason: "unsupported_connection_type", + examined: 1, + omitted: 0, + }), + ); + expect(result.graph.edges).toContainEqual( + expect.objectContaining({ + relation: "contains", + reason: "unsupported_connection_type", + }), + ); +}); + cliTest( "compiled CLI preserves a good archive and reports repeated key and data omissions", async ({ cli }) => {