fix(hopper): recognize stripped Objective-C class labels

Classify legacy class-object names at the Hopper boundary and carry the typed fact into class discovery. Preserve exact labels, literal filtering, unrelated row metadata, and raw replies; derive classification from source text rather than incoming tags.

Closes #5617
This commit is contained in:
morluto committed 2026-10-21 01:01:42 +08:00
1 parent dd0015aaf1
commit 22c84ce64a
9 files changed
+323 -15

No files matched your search

+9 -4
View File
@@ -37,6 +37,7 @@ import { inspectNativeDispatch } from "./native/NativeDispatchMetadataInspection
import { traceNativeValues } from "./native/NativeValueTrace.js";
import { traceNativeUiAction } from "./native/NativeUiActionTrace.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { addressedValueSchema } from "../domain/native/nativeInventory.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import {
@@ -676,11 +677,15 @@ export class EnhancedTools {
) {
const result = await this.#call(tool, {}, signal);
if (!result.ok) return result;
const parsed = z
.array(z.object({ address: z.string(), value: z.string() }))
.safeParse(result.value);
const parsed = z.array(addressedValueSchema).safeParse(result.value);
return parsed.success
? ok(parsed.data.map(({ address, value: name }) => ({ address, name })))
? ok(
parsed.data.map(({ address, value: name, objc_class_label }) => ({
address,
name,
...(objc_class_label === undefined ? {} : { objc_class_label }),
})),
)
: err(
new AnalysisOutputError(
tool,
+1
View File
@@ -13,6 +13,7 @@ import { AnalysisOutputError } from "./analysisErrorCore.js";
export interface AddressedName {
readonly address: string;
readonly name: string;
readonly objc_class_label?: "hopper_legacy_class_object";
}
export interface SegmentSummary {
+1
View File
@@ -14,6 +14,7 @@ export const addressedValueSchema = z.object({
source: z.enum(["default", "analysis", "ai", "imported", "user_defined"]),
})
.exactOptional(),
objc_class_label: z.literal("hopper_legacy_class_object").exactOptional(),
procedure: z
.object({
external: z.boolean(),
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { discoverObjcClasses } from "./symbolAnalysis.js";
describe("Objective-C class label discovery", () => {
it("does not reinterpret a provider-specific label without an owner tag", () => {
expect(
discoverObjcClasses(
[{ address: "0x1", name: "__objc_class_HuntRoot_class" }],
"Hunt",
),
).toEqual({ count: 0, classes: [] });
});
it("uses provider-tagged stripped class labels and filters their exact text", () => {
const labels = [
"__objc_class_HuntRoot_class",
"__objc_class_HuntSub_class",
"__objc_class_HuntWorker_class",
"__objc_class_HuntModel_class",
"__objc_class_HuntLegacy_class",
"__objc_class_Hunt_class_class",
"prefix__objc_class_HuntForeign_class",
"__objc_class__class",
"__objc_class_HuntMissingSuffix",
"__objc_class_HuntExtra_class_suffix",
];
const names = labels.map((name, index) => ({
address: `0x${(index + 1).toString(16)}`,
name,
...(index < 6
? { objc_class_label: "hopper_legacy_class_object" as const }
: {}),
}));
expect(discoverObjcClasses(names, "Hunt")).toEqual({
count: 6,
classes: names.slice(0, 6).map(({ address, name }) => ({
address,
name,
})),
});
expect(discoverObjcClasses(names, "__objc_class_HuntRoot_class")).toEqual({
count: 1,
classes: [{ address: "0x1", name: "__objc_class_HuntRoot_class" }],
});
});
});
+2 -1
View File
@@ -26,8 +26,9 @@ export const discoverObjcClasses = (
const classes = uniqueByName(
names
.filter(
({ name }) =>
({ name, objc_class_label }) =>
name.includes(OBJC_CLASS_MARKER) ||
objc_class_label === "hopper_legacy_class_object" ||
(name.startsWith(LEGACY_OBJC_CLASS_PREFIX) &&
name.length > LEGACY_OBJC_CLASS_PREFIX.length &&
!/^l_OBJC_CLASS_NAME_(?:\.\d+)?$/u.test(name)),
+150
View File
@@ -0,0 +1,150 @@
import { describe, expect, it, vi } from "vitest";
import { parseConfig } from "../config/parseConfig.js";
import { ok } from "../domain/result.js";
import { silentLogger } from "../logger.js";
import { HopperClient } from "./HopperClient.js";
import { HopperProvider } from "./HopperProvider.js";
import { projectHopperNameInventory } from "./HopperNameInventory.js";
describe("Hopper name inventory labels", () => {
it("tags stripped class-object labels without rewriting source values", () => {
const value = [
{
address: "0x1",
value: "__objc_class_HuntRoot_class",
provider_metadata: { section: "__DATA,__objc_classrefs" },
},
{
address: "0x2",
value: "__objc_class_Hunt_class_class",
provider_metadata: { section: "__DATA,__objc_classrefs" },
},
{ address: "0x3", value: "_OBJC_CLASS_$_Unchanged" },
{ address: "0x4", value: "prefix__objc_class_Foreign_class" },
{ address: "0x5", value: "__objc_class__class" },
{ address: "0x6", value: "__objc_class_MissingSuffix" },
{ address: "0x7", value: "__objc_class_Extra_class_suffix" },
];
expect(projectHopperNameInventory(value)).toEqual({
changed: true,
result: [
{
address: "0x1",
value: "__objc_class_HuntRoot_class",
provider_metadata: { section: "__DATA,__objc_classrefs" },
objc_class_label: "hopper_legacy_class_object",
},
{
address: "0x2",
value: "__objc_class_Hunt_class_class",
provider_metadata: { section: "__DATA,__objc_classrefs" },
objc_class_label: "hopper_legacy_class_object",
},
value[2],
value[3],
value[4],
value[5],
value[6],
],
});
});
it("replaces provider-supplied label tags with the Hopper-owned classification", () => {
const value = [
{
address: "0x1",
value: "__objc_class_HuntRoot_class",
objc_class_label: "untrusted_other_label",
provider_metadata: { ordinal: 3 },
},
{
address: "0x2",
value: "ordinary_name",
objc_class_label: "hopper_legacy_class_object",
provider_metadata: { ordinal: 4 },
},
];
expect(projectHopperNameInventory(value)).toEqual({
changed: true,
result: [
{
address: "0x1",
value: "__objc_class_HuntRoot_class",
objc_class_label: "hopper_legacy_class_object",
provider_metadata: { ordinal: 3 },
},
{
address: "0x2",
value: "ordinary_name",
provider_metadata: { ordinal: 4 },
},
],
});
});
it("leaves malformed provider inventories untouched for the consumer to reject", () => {
const value = [{ address: 1, value: "__objc_class_HuntRoot_class" }];
expect(projectHopperNameInventory(value)).toEqual({
result: value,
changed: false,
});
});
it("preserves source rows and the exact raw reply through the provider boundary", async () => {
const config = parseConfig({});
if (!config.ok) throw config.error;
const source = [
{
address: "0x1",
value: "__objc_class_HuntRoot_class",
symbol: { primary: false, source: "imported" },
provider_metadata: { section: "__DATA,__objc_classrefs" },
},
{
address: "0x2",
value: "ordinary_name",
provider_metadata: { ordinal: 2 },
objc_class_label: "hopper_legacy_class_object",
},
];
const callTool = vi
.spyOn(HopperClient.prototype, "callTool")
.mockResolvedValue(ok(source));
const provider = new HopperProvider(
config.value,
silentLogger,
{},
"linux",
);
const client = provider.createClient({
path: "/tmp/fixture.hop",
sha256: "a".repeat(64),
kind: "database",
format: "analysis-database",
});
const result = await client.execute("list_names", {});
expect(callTool).toHaveBeenCalledOnce();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.result).toEqual([
{
...source[0],
objc_class_label: "hopper_legacy_class_object",
},
{
address: "0x2",
value: "ordinary_name",
provider_metadata: { ordinal: 2 },
},
]);
expect(result.value.rawResult).toEqual(source);
}
await client.close();
callTool.mockRestore();
});
});
+47
View File
@@ -0,0 +1,47 @@
import type { JsonValue } from "../domain/jsonValue.js";
import { z } from "zod";
const LEGACY_OBJC_CLASS_LABEL_PREFIX = "__objc_class_";
const LEGACY_OBJC_CLASS_LABEL_SUFFIX = "_class";
const addressedNameRowsSchema = z.array(
z.object({ address: z.string(), value: z.string() }),
);
const isLegacyObjcClassObjectLabel = (value: string): boolean =>
value.startsWith(LEGACY_OBJC_CLASS_LABEL_PREFIX) &&
value.endsWith(LEGACY_OBJC_CLASS_LABEL_SUFFIX) &&
value.length >
LEGACY_OBJC_CLASS_LABEL_PREFIX.length +
LEGACY_OBJC_CLASS_LABEL_SUFFIX.length;
/** Tag Hopper's stripped class-object labels while retaining their exact text. */
export const projectHopperNameInventory = (
value: JsonValue,
): { readonly result: JsonValue; readonly changed: boolean } => {
const parsed = addressedNameRowsSchema.safeParse(value);
if (!parsed.success) return { result: value, changed: false };
let changed = false;
if (!Array.isArray(value)) return { result: value, changed: false };
const result = value.map((entry, index) => {
if (entry === null || Array.isArray(entry) || typeof entry !== "object")
return entry;
const parsedEntry = parsed.data[index];
if (parsedEntry === undefined) return entry;
const { objc_class_label: _untrustedLabel, ...sourceFields } = entry;
const ownsLegacyLabel = isLegacyObjcClassObjectLabel(parsedEntry.value);
if (ownsLegacyLabel) {
changed = true;
return {
...sourceFields,
objc_class_label: "hopper_legacy_class_object" as const,
};
}
if (_untrustedLabel !== undefined) {
changed = true;
return sourceFields;
}
return entry;
});
return changed ? { result, changed } : { result: value, changed: false };
};
+14 -10
View File
@@ -33,6 +33,7 @@ import {
import { mapHopperFileOffset } from "./HopperFileOffset.js";
import { HopperRegexSearch } from "./HopperRegexSearch.js";
import { HopperClient } from "./HopperClient.js";
import { projectHopperNameInventory } from "./HopperNameInventory.js";
import { hopperMachOImageSchema } from "./HopperMachOImage.js";
import {
@@ -274,7 +275,7 @@ export class HopperProvider implements AnalysisProviderCandidate {
rawResult: JsonValue | null = null,
) =>
createAnalysisExecution(normalized, executionProvider, {
// Only the file-offset mapping differs from Hopper's reply.
// Retain the source reply when an adapter-owned projection changes it.
rawResult,
...(profile === undefined ? {} : { analysisProfile: profile }),
...(operation === "procedure_callers" ||
@@ -316,15 +317,18 @@ export class HopperProvider implements AnalysisProviderCandidate {
operation === "address_to_file_offset"
? await mapHopperFileOffset(target, result.value, options?.signal)
: result;
return mapped.ok
? ok(
projectExecution(
operation,
mapped.value,
mapped === result ? null : result.value,
),
)
: mapped;
if (!mapped.ok) return mapped;
const inventory =
operation === "list_names"
? projectHopperNameInventory(mapped.value)
: { result: mapped.value, changed: false };
return ok(
projectExecution(
operation,
inventory.result,
inventory.changed || mapped !== result ? result.value : null,
),
);
},
executeIfReady: async (operation, parameters, options) => {
if (operation !== "list_documents" && operation !== "list_procedures")
@@ -99,6 +99,57 @@ it("discovers declarations without counting duplicates or compiler bookkeeping",
});
});
it("discovers stripped Hopper class labels through MCP with literal filtering", async () => {
const names = [
"__objc_class_HuntRoot_class",
"__objc_class_HuntSub_class",
"__objc_class_HuntWorker_class",
"__objc_class_HuntModel_class",
"__objc_class_HuntLegacy_class",
"prefix__objc_class_HuntForeign_class",
"__objc_class__class",
"__objc_class_HuntMissingSuffix",
];
const client = await connect({
execute: (name) => {
expect(name).toBe("list_names");
return Promise.resolve(
ok(
names.map((value, index) => ({
address: `0x${index + 1}`,
value,
...(index < 5
? { objc_class_label: "hopper_legacy_class_object" }
: {}),
})),
),
);
},
});
const all = jsonResult(
await client.callTool({ name: "get_objc_classes", arguments: {} }),
);
expect(all).toMatchObject({
count: 5,
classes: names.slice(0, 5).map((name, index) => ({
address: `0x${index + 1}`,
name,
})),
});
const filtered = jsonResult(
await client.callTool({
name: "get_objc_classes",
arguments: { pattern: "__objc_class_HuntRoot_class" },
}),
);
expect(filtered).toMatchObject({
count: 1,
classes: [{ address: "0x1", name: "__objc_class_HuntRoot_class" }],
});
});
const completeOverviewPort: AnalysisOperationPort = {
execute: (name) => {
switch (name) {