mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(apple): preserve archive admission and authored UI dispatch coverage
This commit is contained in:
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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] ??
|
||||
|
||||
@@ -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: [] });
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user