diff --git a/src/application/EnhancedTools.objcClasses.test.ts b/src/application/EnhancedTools.objcClasses.test.ts index 7552f4c44f..a6b954892a 100644 --- a/src/application/EnhancedTools.objcClasses.test.ts +++ b/src/application/EnhancedTools.objcClasses.test.ts @@ -285,7 +285,10 @@ describe("Objective-C class import identity composition", () => { ok: true, value: { count: 2, - identity_coverage: { status: "unknown" }, + identity_coverage: { + status: "unknown", + reason: "No static Mach-O reader", + }, }, }); }); diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index 0207c405d9..cbcfcb606d 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -326,7 +326,7 @@ export class EnhancedTools { bindings: [], coverage: { status: "unknown", - reason: `Native class-import binding evidence is unavailable (${bindingsExecution.error._tag}).`, + reason: bindingsExecution.error.reason, }, }), ); diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index 26ef62548f..b70a26be73 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, + requireInterfaceBuilderArchive, isInterfaceBuilderConnectionClass, isSourceLessAppKitAnnotationConnector, isUIKitRuntimeConnectionClass, @@ -212,6 +213,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { unknownScalarCount: 0, numberLiterals: undefined, }; + requireInterfaceBuilderArchive(raw); if (nib !== null && nib.omitted > 0) incompleteHierarchies.set(entry.path, nib.omitted); if (nib !== null && nib.unresolvedConnections > 0) diff --git a/src/domain/apple/interfaceBuilderGraph.test.ts b/src/domain/apple/interfaceBuilderGraph.test.ts index 627b200705..ee18d22c4b 100644 --- a/src/domain/apple/interfaceBuilderGraph.test.ts +++ b/src/domain/apple/interfaceBuilderGraph.test.ts @@ -1215,3 +1215,36 @@ it("states each object's class, label, and ID once", () => { }), ]); }); + +it("attributes connection omissions caused by missing source nodes to the object limit", () => { + const result = buildInterfaceBuilderAnalysis({ + targetSha256: hash, + toolVersion: "test", + limits: interfaceBuilderLimitsSchema.parse({ max_objects: 1 }), + documents: [ + { + relativePath: "Main.nib", + archiveSha256: hash, + documentKind: "nib", + raw: { + "com.apple.ibtool.document.objects": { + source: { class: "UIButton" }, + destination: { class: "UIView" }, + }, + "com.apple.ibtool.document.connections": { + source: [ + { type: "action", label: "tap:", destinationId: "destination" }, + ], + }, + }, + }, + ], + }); + expect(result.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "connections:Main.nib", + status: "partial", + reason: "object_limit_reached", + }), + ); +}); diff --git a/src/domain/apple/interfaceBuilderGraph.ts b/src/domain/apple/interfaceBuilderGraph.ts index b2abd5fff7..c5605d25ab 100644 --- a/src/domain/apple/interfaceBuilderGraph.ts +++ b/src/domain/apple/interfaceBuilderGraph.ts @@ -163,6 +163,7 @@ export const buildInterfaceBuilderAnalysis = (input: { let connectionEdgeCount = 0; let hierarchyEdgeCount = 0; let documentOmittedConnections = 0; + let connectionObjectLimit = false; let documentOmittedHierarchyEdges = 0; let hierarchyTruncated = false; const objectNodeId = (objectId: string): string => @@ -207,6 +208,7 @@ export const buildInterfaceBuilderAnalysis = (input: { } else { omittedConnections += 1; documentOmittedConnections += 1; + connectionObjectLimit = true; } return; } @@ -215,6 +217,7 @@ export const buildInterfaceBuilderAnalysis = (input: { if (unresolvedEndpoint) { truncated = true; if (isHierarchy) hierarchyTruncated = true; + else connectionObjectLimit = true; } const edgeTo = unresolvedEndpoint ? null : to; const edgeReason = unresolvedEndpoint @@ -511,12 +514,14 @@ export const buildInterfaceBuilderAnalysis = (input: { status: connectionsTruncated || documentOmittedConnections > 0 || + connectionObjectLimit || connectionsUnresolved || unsupportedConnections ? "partial" : "complete", - reason: - connectionsTruncated || documentOmittedConnections > 0 + reason: connectionObjectLimit + ? "object_limit_reached" + : connectionsTruncated || documentOmittedConnections > 0 ? "connection_limit_reached" : connectionsUnresolved ? "connection_unresolved" diff --git a/src/domain/apple/interfaceBuilderKeyedArchive.test.ts b/src/domain/apple/interfaceBuilderKeyedArchive.test.ts index e4ac3c2dd9..2cd4502686 100644 --- a/src/domain/apple/interfaceBuilderKeyedArchive.test.ts +++ b/src/domain/apple/interfaceBuilderKeyedArchive.test.ts @@ -330,3 +330,25 @@ it("counts a dangling named root without substituting a valid generic root", () expect(parsed.hierarchy).toEqual([]); expect(parsed.omittedHierarchyReferences).toBe(1); }); + +it("distinguishes an unrecognized plist from an explicitly empty Interface Builder document", () => { + expect(() => + parseInterfaceBuilderRecords({ CFBundleIdentifier: "sample" }), + ).toThrow(/not a recognized/); + expect( + parseInterfaceBuilderRecords({ "com.apple.ibtool.document.objects": {} }), + ).toMatchObject({ objects: [], connections: [], objectCount: 0 }); +}); + +it.each([ + ["NSImageView", "view"], + ["UIImageView", "view"], + ["NSColorWell", "control"], + ["NSImage", "resource"], +])("keeps %s UI identity separate from resource names", (className, kind) => { + expect( + parseInterfaceBuilderRecords({ + "com.apple.ibtool.document.objects": { item: { class: className } }, + }).objects[0]?.kind, + ).toBe(kind); +}); diff --git a/src/domain/apple/interfaceBuilderKeyedArchive.ts b/src/domain/apple/interfaceBuilderKeyedArchive.ts index f749cb985a..d736a4ff07 100644 --- a/src/domain/apple/interfaceBuilderKeyedArchive.ts +++ b/src/domain/apple/interfaceBuilderKeyedArchive.ts @@ -65,6 +65,22 @@ export const interfaceBuilderConnectionFieldNames = (className: string) => ? { source: "UISource", destination: "UIDestination", label: "UILabel" } : { source: "NSSource", destination: "NSDestination", label: "NSLabel" }; +/** A decoded plist is not an Interface Builder document merely because it is named .nib. */ +export const requireInterfaceBuilderArchive = (value: unknown): void => { + const root = record(value); + if (root.$archiver === "NSKeyedArchiver" && Array.isArray(root.$objects)) + return; + if ( + ["objects", "connections", "hierarchy", "classes"].some((facet) => + Object.hasOwn(root, `com.apple.ibtool.document.${facet}`), + ) + ) + return; + throw new TypeError( + "Property list is not a recognized Interface Builder archive", + ); +}; + /** Parse ibtool dictionaries or compiled keyed archives into typed records. */ export const parseInterfaceBuilderRecords = ( value: unknown, @@ -79,6 +95,7 @@ export const parseInterfaceBuilderRecords = ( readonly unresolvedConnections: number; readonly omittedHierarchyReferences: number; } => { + requireInterfaceBuilderArchive(value); const root = record(value); const keyed = parseKeyedArchive(root); if (keyed !== null) return keyed; @@ -560,12 +577,6 @@ const classifyObject = ( value === "uiproxyobject" ) return "placeholder"; - if ( - value.includes("image") || - value.includes("color") || - value.includes("font") - ) - return "resource"; if (value.includes("external")) return "external_object"; if (value.includes("viewcontroller") || value.includes("windowcontroller")) return "view_controller"; @@ -574,7 +585,8 @@ const classifyObject = ( value.includes("control") || value.includes("textfield") || value.includes("slider") || - value.includes("menuitem") + value.includes("menuitem") || + value.includes("colorwell") ) return "control"; if ( @@ -583,6 +595,12 @@ const classifyObject = ( value.includes("cell") ) return "view"; + if ( + value.includes("image") || + value.includes("color") || + value.includes("font") + ) + return "resource"; return "other"; }; diff --git a/src/domain/native/nativeInvestigationGraph.test.ts b/src/domain/native/nativeInvestigationGraph.test.ts index 684b2a02ee..b46734fb36 100644 --- a/src/domain/native/nativeInvestigationGraph.test.ts +++ b/src/domain/native/nativeInvestigationGraph.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { objcSwiftMetadataSchema } from "./objcSwiftMetadata.js"; import { nativeInvestigationGraphSchema, @@ -190,67 +191,70 @@ describe("native investigation graph traces", () => { }); describe("Interface Builder dispatch joins", () => { - it("joins a UI action to the target controller class, not the source control", () => { - const ui = buildInterfaceBuilderAnalysis({ - targetSha256: "b".repeat(64), - toolVersion: "test", - documents: [ - { - relativePath: "Main.storyboardc/scene.nib/objects.nib", - archiveSha256: "c".repeat(64), - documentKind: "storyboard_scene", - raw: { - "com.apple.ibtool.document.objects": { - controller: { customClass: "BuildViewController" }, - button: { class: "UIButton", title: "Build" }, - }, - "com.apple.ibtool.document.connections": { - button: [ - { - type: "action", - label: "buildTapped:", - destinationId: "controller", - }, - ], + it.each(["BuildViewController", "BuildView", "BuildButton", "BuildHandler"])( + "joins an action to its declared %s receiver class", + (className) => { + const ui = buildInterfaceBuilderAnalysis({ + targetSha256: "b".repeat(64), + toolVersion: "test", + documents: [ + { + relativePath: "Main.storyboardc/scene.nib/objects.nib", + archiveSha256: "c".repeat(64), + documentKind: "storyboard_scene", + raw: { + "com.apple.ibtool.document.objects": { + controller: { customClass: className }, + button: { class: "UIButton", title: "Build" }, + }, + "com.apple.ibtool.document.connections": { + button: [ + { + type: "action", + label: "buildTapped:", + destinationId: "controller", + }, + ], + }, }, }, - }, - ], - limits: interfaceBuilderLimitsSchema.parse({}), - }); - const joined = joinInterfaceBuilderDispatch(ui.graph, { - objc_classes: [], - objc_protocols: [], - objc_categories: [], - swift_decls: [], - objc_ivars: [], - objc_protocol_records: [], - objc_dispatch_implementations: [ - { - class_name: "BuildViewController", - selector: "buildTapped:", - method_type: "instance", - implementation_address: "0x1000", - location: { address: "0x1000", file_offset: 0 }, - decode: { status: "decoded", reason: null }, - evidence, - }, - ], - swift_conformances: [], - swift_dispatch_slots: [], - swift_symbols: [], - relative_pointers: [], - coverage: [], - db_save_result: null, - }); - expect(joined.edges).toContainEqual( - expect.objectContaining({ - relation: "objc_dispatch", - resolution: "resolved", - to: "native:function:0x1000", - }), - ); - }); + ], + limits: interfaceBuilderLimitsSchema.parse({}), + }); + const joined = joinInterfaceBuilderDispatch(ui.graph, { + objc_classes: [], + objc_protocols: [], + objc_categories: [], + swift_decls: [], + objc_ivars: [], + objc_protocol_records: [], + objc_dispatch_implementations: [ + { + class_name: className, + selector: "buildTapped:", + method_type: "instance", + implementation_address: "0x1000", + location: { address: "0x1000", file_offset: 0 }, + decode: { status: "decoded", reason: null }, + evidence, + }, + ], + swift_conformances: [], + swift_dispatch_slots: [], + swift_symbols: [], + relative_pointers: [], + coverage: [], + db_save_result: null, + }); + expect(joined.edges).toContainEqual( + expect.objectContaining({ + relation: "objc_dispatch", + resolution: "resolved", + to: "native:function:0x1000", + }), + ); + }, + ); it("chooses an instance method and matches category-qualified owner names", () => { const ui = buildInterfaceBuilderAnalysis({ @@ -403,3 +407,51 @@ describe("placeholder dispatch joins", () => { ); }); }); + +it("counts an authored action with an unresolved receiver in dispatch coverage", () => { + const ui = buildInterfaceBuilderAnalysis({ + targetSha256: "b".repeat(64), + toolVersion: "test", + limits: interfaceBuilderLimitsSchema.parse({}), + documents: [ + { + relativePath: "Main.nib", + archiveSha256: "c".repeat(64), + documentKind: "nib", + raw: { + "com.apple.ibtool.document.objects": { + button: { class: "UIButton" }, + }, + "com.apple.ibtool.document.connections": { + button: [ + { + type: "action", + label: "buildTapped:", + destinationId: "missing", + }, + ], + }, + }, + }, + ], + }); + const joined = joinInterfaceBuilderDispatch( + ui.graph, + objcSwiftMetadataSchema.parse({ db_save_result: null }), + ); + expect(joined.coverage).toContainEqual( + expect.objectContaining({ + facet: "ui_to_objc_dispatch", + status: "partial", + examined: 1, + omitted: 1, + }), + ); + expect(joined.edges).toContainEqual( + expect.objectContaining({ + relation: "objc_dispatch", + resolution: "unresolved", + reason: "authored_action_receiver_unresolved", + }), + ); +}); diff --git a/src/domain/native/nativeInvestigationGraph.ts b/src/domain/native/nativeInvestigationGraph.ts index 9c483344c8..5bea77081c 100644 --- a/src/domain/native/nativeInvestigationGraph.ts +++ b/src/domain/native/nativeInvestigationGraph.ts @@ -306,27 +306,44 @@ export const joinInterfaceBuilderDispatch = ( if (typeof selector !== "string" || selector.length === 0) continue; const selectorNodeId = selectorNodesByAction.get(action.id); if (selectorNodeId === undefined) continue; + examined += 1; const destinationEdge = edges.find( (edge) => edge.resolution === "observed" && edge.relation === "target_action" && edge.from === action.id && - ["view_controller", "placeholder", "external_object"].includes( - nodes.get(edge.to)?.kind ?? "", - ), + edge.to !== selectorNodeId && + nodes.has(edge.to), ); if ( destinationEdge === undefined || destinationEdge.resolution !== "observed" - ) + ) { + const edgeId = `dispatch:${selectorNodeId}:${action.id}:receiver`; + if (!existingEdgeIds.has(edgeId)) { + existingEdgeIds.add(edgeId); + edges.push({ + id: edgeId, + from: selectorNodeId, + to: null, + relation: "objc_dispatch", + resolution: "unresolved", + reason: "authored_action_receiver_unresolved", + evidence: action.evidence, + limitations: [ + "The authored action has no resolved receiver; a selector match alone cannot establish its implementation.", + ], + }); + } continue; + } const owner = nodes.get(destinationEdge.to); - examined += 1; const declaredClass = owner?.attributes.class_name; const className = typeof declaredClass === "string" && declaredClass.length > 0 && - owner?.kind === "view_controller" + owner?.kind !== "placeholder" && + owner?.kind !== "external_object" ? declaredClass : null; const candidates = metadata.objc_dispatch_implementations.filter( diff --git a/src/domain/swiftSymbolClassification.ts b/src/domain/swiftSymbolClassification.ts index b04c921bcc..90aa8783f2 100644 --- a/src/domain/swiftSymbolClassification.ts +++ b/src/domain/swiftSymbolClassification.ts @@ -25,7 +25,7 @@ export const classifySwiftSymbol = ( ): { readonly category: SwiftTypeCategory | null } | undefined => { const prefix = /^_?(?:\$[sS]|_T0)/u.exec(name); if (prefix === null) { - if (!name.includes("_Tt")) return undefined; + if (!name.startsWith("_Tt")) return undefined; return { category: LEGACY_CATEGORIES.find(([prefix]) => name.startsWith(prefix))?.[1] ?? diff --git a/src/domain/symbolAnalysis.test.ts b/src/domain/symbolAnalysis.test.ts index 6930a60bff..dfca4c238c 100644 --- a/src/domain/symbolAnalysis.test.ts +++ b/src/domain/symbolAnalysis.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { categorizeSwiftTypes } from "./symbolAnalysis.js"; +import { + categorizeSwiftTypes, + discoverObjcProtocols, +} from "./symbolAnalysis.js"; describe("modern Swift category evidence", () => { it.each([ @@ -217,3 +220,15 @@ describe("Swift alias ambiguity", () => { }, ); }); + +it("does not recognize embedded Swift runtime markers in Objective-C selectors", () => { + const names = [ + "-[Example check_TtC4main3Foo]", + "-[Example check_TtP4main3Bar_]", + ].map((name) => ({ name, address: "0x1" })); + expect(categorizeSwiftTypes(names)).toMatchObject({ + total: 0, + unclassified: [], + }); + expect(discoverObjcProtocols(names)).toEqual({ count: 0, protocols: [] }); +}); diff --git a/src/domain/symbolAnalysis.ts b/src/domain/symbolAnalysis.ts index 838ac62cc1..7bddd2ee79 100644 --- a/src/domain/symbolAnalysis.ts +++ b/src/domain/symbolAnalysis.ts @@ -97,9 +97,7 @@ export const discoverObjcProtocols = ( names.filter( ({ name }) => name.includes(OBJC_PROTOCOL_MARKER) || - name.includes("_TtP") || - (name.endsWith("Mp") && - classifySwiftSymbol(name)?.category === "protocols"), + classifySwiftSymbol(name)?.category === "protocols", ), ); return {