fix(native): own Objective-C category identity

This commit is contained in:
morluto committed 2026-10-27 12:25:01 +08:00
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}`;
+92 -1
View File
@@ -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 },
{
+70 -7
View File
@@ -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"))