mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(native): own Objective-C category identity
This commit is contained in:
1 parent
c4242cb85e
commit
ad1a57b65d
4 files changed
+274
-81
No files matched your search
@@ -255,82 +255,120 @@ describe("Interface Builder dispatch joins", () => {
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("chooses an instance method and matches category-qualified owner names", () => {
|
||||
const ui = buildInterfaceBuilderAnalysis({
|
||||
targetSha256: "b".repeat(64),
|
||||
toolVersion: "test",
|
||||
documents: [
|
||||
{
|
||||
relativePath: "Main.nib",
|
||||
archiveSha256: "c".repeat(64),
|
||||
documentKind: "nib",
|
||||
raw: {
|
||||
"com.apple.ibtool.document.objects": {
|
||||
controller: { customClass: "BuildViewController" },
|
||||
button: { class: "UIButton" },
|
||||
},
|
||||
"com.apple.ibtool.document.connections": {
|
||||
button: [
|
||||
{
|
||||
type: "action",
|
||||
label: "buildTapped:",
|
||||
destinationId: "controller",
|
||||
},
|
||||
],
|
||||
describe("Objective-C category ownership joins", () => {
|
||||
it.each([
|
||||
{
|
||||
className: "BuildViewController",
|
||||
category: "BuilderActions",
|
||||
legacy: false,
|
||||
},
|
||||
{
|
||||
className: "BuildViewController(Literal)",
|
||||
category: undefined,
|
||||
legacy: false,
|
||||
},
|
||||
{ className: "BuildViewController", category: undefined, legacy: true },
|
||||
])(
|
||||
"matches exact instance owner $className with category $category",
|
||||
({ className, category, legacy }) => {
|
||||
const ui = buildInterfaceBuilderAnalysis({
|
||||
targetSha256: "b".repeat(64),
|
||||
toolVersion: "test",
|
||||
documents: [
|
||||
{
|
||||
relativePath: "Main.nib",
|
||||
archiveSha256: "c".repeat(64),
|
||||
documentKind: "nib",
|
||||
raw: {
|
||||
"com.apple.ibtool.document.objects": {
|
||||
controller: { customClass: className },
|
||||
button: { class: "UIButton" },
|
||||
},
|
||||
"com.apple.ibtool.document.connections": {
|
||||
button: [
|
||||
{
|
||||
type: "action",
|
||||
label: "buildTapped:",
|
||||
destinationId: "controller",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
limits: interfaceBuilderLimitsSchema.parse({}),
|
||||
});
|
||||
const metadata = {
|
||||
objc_classes: [],
|
||||
objc_protocols: [],
|
||||
objc_categories: [],
|
||||
swift_decls: [],
|
||||
objc_ivars: [],
|
||||
objc_protocol_records: [],
|
||||
objc_dispatch_implementations: [
|
||||
{
|
||||
class_name: "BuildViewController",
|
||||
selector: "buildTapped:",
|
||||
method_type: "class" as const,
|
||||
implementation_address: "0x9999",
|
||||
location: { address: "0x9999", file_offset: 0 },
|
||||
decode: { status: "decoded" as const, reason: null },
|
||||
evidence,
|
||||
},
|
||||
{
|
||||
class_name: "BuildViewController(BuilderActions)",
|
||||
selector: "buildTapped:",
|
||||
method_type: "instance" as const,
|
||||
implementation_address: "0x1000",
|
||||
location: { address: "0x1000", file_offset: 0 },
|
||||
decode: { status: "decoded" as const, reason: null },
|
||||
evidence,
|
||||
},
|
||||
],
|
||||
swift_conformances: [],
|
||||
swift_dispatch_slots: [],
|
||||
swift_symbols: [],
|
||||
relative_pointers: [],
|
||||
coverage: [],
|
||||
db_save_result: null,
|
||||
};
|
||||
],
|
||||
limits: interfaceBuilderLimitsSchema.parse({}),
|
||||
});
|
||||
const metadata = {
|
||||
objc_classes: [],
|
||||
objc_protocols: [],
|
||||
objc_categories: [],
|
||||
swift_decls: [],
|
||||
objc_ivars: [],
|
||||
objc_protocol_records: [],
|
||||
objc_dispatch_implementations: [
|
||||
{
|
||||
class_name: "BuildViewController",
|
||||
selector: "buildTapped:",
|
||||
method_type: "class" as const,
|
||||
implementation_address: "0x9999",
|
||||
location: { address: "0x9999", file_offset: 0 },
|
||||
decode: { status: "decoded" as const, reason: null },
|
||||
evidence,
|
||||
},
|
||||
{
|
||||
class_name: legacy ? `${className}(BuilderActions)` : className,
|
||||
...(category === undefined ? {} : { category }),
|
||||
selector: "buildTapped:",
|
||||
method_type: "instance" as const,
|
||||
implementation_address: "0x1000",
|
||||
location: { address: "0x1000", file_offset: 0 },
|
||||
decode: legacy
|
||||
? { status: "partial" as const, reason: "symbol_name_only" }
|
||||
: { status: "decoded" as const, reason: null },
|
||||
evidence: legacy
|
||||
? [
|
||||
{
|
||||
...evidence[0]!,
|
||||
kind: "symbol" as const,
|
||||
description: `Provider symbol -[${className}(BuilderActions) buildTapped:]`,
|
||||
},
|
||||
]
|
||||
: evidence,
|
||||
},
|
||||
{
|
||||
// A historical or binary class identity does not declare a category.
|
||||
class_name: `${className}(Legacy)`,
|
||||
selector: "buildTapped:",
|
||||
method_type: "instance" as const,
|
||||
implementation_address: "0x2000",
|
||||
location: { address: "0x2000", file_offset: 0 },
|
||||
decode: { status: "partial" as const, reason: "symbol_name_only" },
|
||||
evidence,
|
||||
},
|
||||
],
|
||||
swift_conformances: [],
|
||||
swift_dispatch_slots: [],
|
||||
swift_symbols: [],
|
||||
relative_pointers: [],
|
||||
coverage: [],
|
||||
db_save_result: null,
|
||||
};
|
||||
|
||||
const joined = joinInterfaceBuilderDispatch(ui.graph, metadata);
|
||||
expect(joined.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
relation: "objc_dispatch",
|
||||
resolution: "resolved",
|
||||
to: "native:function:0x1000",
|
||||
}),
|
||||
);
|
||||
expect(joined.edges).not.toContainEqual(
|
||||
expect.objectContaining({ to: "native:function:0x9999" }),
|
||||
);
|
||||
});
|
||||
const joined = joinInterfaceBuilderDispatch(ui.graph, metadata);
|
||||
expect(joined.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
relation: "objc_dispatch",
|
||||
resolution: legacy ? "inferred" : "resolved",
|
||||
to: "native:function:0x1000",
|
||||
}),
|
||||
);
|
||||
expect(joined.edges).not.toContainEqual(
|
||||
expect.objectContaining({ to: "native:function:0x9999" }),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("placeholder dispatch joins", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
objcImplementationClassName,
|
||||
nativeMetadataEvidenceSchema,
|
||||
nativeMetadataLocationSchema,
|
||||
type ObjcSwiftMetadata,
|
||||
@@ -351,7 +352,7 @@ export const joinInterfaceBuilderDispatch = (
|
||||
candidate.method_type === "instance" &&
|
||||
candidate.selector === selector &&
|
||||
(className === null ||
|
||||
candidate.class_name.replace(/\([^)]*\)$/u, "") === className),
|
||||
objcImplementationClassName(candidate) === className),
|
||||
);
|
||||
const implementation = candidates.length === 1 ? candidates[0] : undefined;
|
||||
const edgeId = `dispatch:${selectorNodeId}:${className ?? owner?.id ?? "unknown"}:${selector}`;
|
||||
|
||||
@@ -1,8 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { inspectNativeDispatchMetadata } from "./objcSwiftMetadata.js";
|
||||
import {
|
||||
inspectNativeDispatchMetadata,
|
||||
objcImplementationClassName,
|
||||
} from "./objcSwiftMetadata.js";
|
||||
|
||||
describe("symbol-only Objective-C implementation identity", () => {
|
||||
it.each([" ", " ", "\t"])(
|
||||
"interprets legacy symbol ownership with separator %j without rewriting retained facts",
|
||||
(separator) => {
|
||||
const metadata = inspectNativeDispatchMetadata(
|
||||
[
|
||||
{
|
||||
address: "0x1000",
|
||||
name: `-[StoreController(Actions)${separator}buildTapped:]`,
|
||||
},
|
||||
],
|
||||
1,
|
||||
);
|
||||
const current = metadata.objc_dispatch_implementations[0]!;
|
||||
const { category: _category, ...record } = current;
|
||||
const legacy = { ...record, class_name: "StoreController(Actions)" };
|
||||
expect(objcImplementationClassName(legacy)).toBe("StoreController");
|
||||
expect(legacy.class_name).toBe("StoreController(Actions)");
|
||||
expect(legacy.evidence).toBe(current.evidence);
|
||||
expect(objcImplementationClassName({ ...legacy, evidence: [] })).toBe(
|
||||
legacy.class_name,
|
||||
);
|
||||
expect(
|
||||
objcImplementationClassName({
|
||||
...legacy,
|
||||
evidence: [
|
||||
...legacy.evidence,
|
||||
{
|
||||
...legacy.evidence[0]!,
|
||||
description:
|
||||
"Provider symbol -[OtherController(Actions) buildTapped:]",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(legacy.class_name);
|
||||
expect(objcImplementationClassName({ ...legacy, category: null })).toBe(
|
||||
legacy.class_name,
|
||||
);
|
||||
expect(
|
||||
objcImplementationClassName({
|
||||
...legacy,
|
||||
decode: { status: "decoded", reason: null },
|
||||
}),
|
||||
).toBe(legacy.class_name);
|
||||
},
|
||||
);
|
||||
|
||||
it("parses symbol category ownership once and retains the original symbol evidence", () => {
|
||||
const names = [
|
||||
"-[StoreController(Actions) buildTapped:]",
|
||||
"-[StoreController(OtherActions) buildTapped:]",
|
||||
];
|
||||
const result = inspectNativeDispatchMetadata(
|
||||
names.map((name) => ({ address: "0x1000", name })),
|
||||
2,
|
||||
);
|
||||
expect(result.objc_classes.map(({ name }) => name)).toEqual([
|
||||
"StoreController",
|
||||
]);
|
||||
expect(result.objc_dispatch_implementations).toMatchObject([
|
||||
{
|
||||
class_name: "StoreController",
|
||||
category: "Actions",
|
||||
decode: { status: "partial", reason: "symbol_name_only" },
|
||||
evidence: [{ description: `Provider symbol ${names[0]}` }],
|
||||
},
|
||||
{
|
||||
class_name: "StoreController",
|
||||
category: "OtherActions",
|
||||
evidence: [{ description: `Provider symbol ${names[1]}` }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains an owner outside the category-symbol grammar without guessing a category", () => {
|
||||
const result = inspectNativeDispatchMetadata(
|
||||
[{ address: "0x1000", name: "-[StoreController(A)(B) buildTapped:]" }],
|
||||
1,
|
||||
);
|
||||
expect(result.objc_dispatch_implementations[0]).toMatchObject({
|
||||
class_name: "StoreController(A)(B)",
|
||||
});
|
||||
expect(result.objc_dispatch_implementations[0]).not.toHaveProperty(
|
||||
"category",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("symbol-only Objective-C implementation addresses", () => {
|
||||
it.each([
|
||||
{ source: "0X00002000", canonical: "0x2000", numeric: 8192 },
|
||||
{
|
||||
|
||||
@@ -194,6 +194,60 @@ type ObjcDispatchImplementation = z.infer<
|
||||
typeof objcDispatchImplementationSchema
|
||||
>;
|
||||
|
||||
/** Interpret category qualification only within the Objective-C method-symbol grammar. */
|
||||
const objcMethodSymbolOwner = (owner: string) => {
|
||||
const categoryOwner = /^([^()]+)\(([^()]+)\)$/u.exec(owner);
|
||||
return {
|
||||
class_name: categoryOwner?.[1] ?? owner,
|
||||
...(categoryOwner?.[2] === undefined ? {} : { category: categoryOwner[2] }),
|
||||
};
|
||||
};
|
||||
|
||||
/** The accepted provider spelling, shared by new and retained symbol records. */
|
||||
const parseObjcMethodSymbol = (symbol: string) => {
|
||||
const match = /^([+-])\[([^\s\]]+)\s+([^\]]+)\]$/u.exec(symbol);
|
||||
if (match?.[1] === undefined || !match[2] || !match[3]) return undefined;
|
||||
return {
|
||||
...objcMethodSymbolOwner(match[2]),
|
||||
reported_owner: match[2],
|
||||
selector: match[3],
|
||||
method_type: match[1] === "+" ? ("class" as const) : ("instance" as const),
|
||||
};
|
||||
};
|
||||
|
||||
/** Retained symbol-only records predate separate category ownership. */
|
||||
export const objcImplementationClassName = (
|
||||
implementation: ObjcDispatchImplementation,
|
||||
): string => {
|
||||
if (
|
||||
implementation.category !== undefined ||
|
||||
implementation.decode.reason !== "symbol_name_only"
|
||||
)
|
||||
return implementation.class_name;
|
||||
const symbols = implementation.evidence.filter(
|
||||
({ kind }) => kind === "symbol",
|
||||
);
|
||||
// Conflicting or absent source facts cannot establish category qualification.
|
||||
if (
|
||||
symbols.length === 0 ||
|
||||
symbols.some((item) => {
|
||||
const prefix = "Provider symbol ";
|
||||
if (!item.description.startsWith(prefix)) return true;
|
||||
const parsed = parseObjcMethodSymbol(
|
||||
item.description.slice(prefix.length),
|
||||
);
|
||||
return (
|
||||
parsed === undefined ||
|
||||
parsed.reported_owner !== implementation.class_name ||
|
||||
parsed.selector !== implementation.selector ||
|
||||
parsed.method_type !== implementation.method_type
|
||||
);
|
||||
})
|
||||
)
|
||||
return implementation.class_name;
|
||||
return objcMethodSymbolOwner(implementation.class_name).class_name;
|
||||
};
|
||||
|
||||
/** Project class declarations without selecting an ambiguous or unrepresentable address. */
|
||||
export const projectObjcClassMethods = (
|
||||
implementations: readonly ObjcDispatchImplementation[],
|
||||
@@ -517,13 +571,21 @@ export const inspectNativeDispatchMetadata = (
|
||||
const classSymbol = objcClassSymbol(entry.name);
|
||||
if (classSymbol !== undefined) classNames.add(classSymbol.name);
|
||||
|
||||
const methodMatch = /^([+-])\[([^\s\]]+)\s+([^\]]+)\]$/u.exec(entry.name);
|
||||
if (methodMatch?.[1] !== undefined && methodMatch[2] && methodMatch[3]) {
|
||||
const methodType = methodMatch[1] === "+" ? "class" : "instance";
|
||||
const selector = methodMatch[3];
|
||||
const method = parseObjcMethodSymbol(entry.name);
|
||||
if (method !== undefined) {
|
||||
const {
|
||||
method_type: methodType,
|
||||
selector,
|
||||
class_name: className,
|
||||
category,
|
||||
} = method;
|
||||
// The provider's method-symbol grammar owns category qualification. Binary
|
||||
// class names and historical records are identities, not display strings
|
||||
// for downstream consumers to split. Keep the original symbol in Evidence.
|
||||
const key = parsedAddress.success
|
||||
? compositeKey([
|
||||
methodMatch[2],
|
||||
className,
|
||||
category ?? null,
|
||||
selector,
|
||||
methodType,
|
||||
parsedAddress.data,
|
||||
@@ -534,7 +596,8 @@ export const inspectNativeDispatchMetadata = (
|
||||
if (previous !== undefined) previous.evidence.push(...evidence);
|
||||
else
|
||||
implementations.set(key, {
|
||||
class_name: methodMatch[2],
|
||||
class_name: className,
|
||||
...(category === undefined ? {} : { category }),
|
||||
selector,
|
||||
method_type: methodType,
|
||||
implementation_address: address,
|
||||
@@ -542,7 +605,7 @@ export const inspectNativeDispatchMetadata = (
|
||||
decode: { status: "partial", reason: "symbol_name_only" },
|
||||
evidence,
|
||||
});
|
||||
classNames.add(methodMatch[2]);
|
||||
classNames.add(className);
|
||||
}
|
||||
|
||||
if (isSwiftMangledSymbolName(entry.name) || entry.name.startsWith("_Tt"))
|
||||
|
||||
Reference in new issue
Block a user