fix(apple): preserve archive admission and authored UI dispatch coverage

This commit is contained in:
morluto
2026-10-24 02:42:40 +08:00
parent 7fba9919a2
commit 8b93eff452
12 changed files with 246 additions and 81 deletions
@@ -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",
},
},
});
});
+1 -1
View File
@@ -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,
},
}),
);
@@ -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)
@@ -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",
}),
);
});
+7 -2
View File
@@ -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"
@@ -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);
});
@@ -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";
};
@@ -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",
}),
);
});
+23 -6
View File
@@ -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(
+1 -1
View File
@@ -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] ??
+16 -1
View File
@@ -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: [] });
});
+1 -3
View File
@@ -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 {