Files
rea/tests/boundary/mcp/functionComparisonStringDecodingMcp.test.ts
T
morluto 40c235c642 fix(comparison): preserve decoding uncertainty and known string facts
Preserve format and Evidence semantics at their owning boundaries, with focused regression coverage and documentation where applicable.

Fixes #6774
2026-10-23 23:36:21 +08:00

269 lines
8.7 KiB
TypeScript

import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js";
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
import { functionDossierSchema } from "../../../src/domain/hopperValues.js";
import {
functionComparisonResultSchema,
type FunctionComparisonResult as Comparison,
} from "../../../src/domain/functionComparisonSchemas.js";
import { jsonValueSchema } from "../../../src/domain/jsonValue.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
const template = FUNCTION_COMPARISON_EXAMPLE.left;
const base = functionDossierSchema.parse(template.normalized_result);
const evidence = (
id: string,
strings: readonly (typeof base.referenced_strings)[number][],
literalValue?: string,
) =>
createEvidence(
{
path: `/fixture/function-string-decoding/${id}`,
sha256: "1".repeat(64),
format: "mach-o",
},
template.provider,
{
operation: "analyze_function",
parameters: { ...template.parameters, observation: id },
result: jsonValueSchema.parse({
...base,
referenced_strings: [...strings],
swift_string_literals:
literalValue === undefined
? []
: [
{
source_address: "0x1002",
value: literalValue,
registers: ["x0", "x1"],
words: ["0x1", "0x2"],
representation: "small",
address: null,
},
],
swift_string_literal_omissions: [],
}),
confidence: template.confidence,
authority: template.authority,
relationshipCoverage: { status: "complete" },
},
);
const reference = (
value: string,
options: {
readonly decoding?: { readonly available: false; readonly reason: string };
readonly termination?: "missing" | "present_or_not_required";
readonly byteLength?: number;
readonly sourceAddress?: string;
readonly metadata?: boolean;
readonly encodingStatus?: "observed" | "inferred";
} = {},
) => ({
address: "0x2000",
source_address: options.sourceAddress ?? "0x1000",
value,
...(options.decoding === undefined ? {} : { decoding: options.decoding }),
...(options.metadata === false
? {}
: {
string: {
encoding: "utf-8",
...(options.encodingStatus === undefined
? {}
: { encoding_status: options.encodingStatus }),
termination: options.termination ?? "present_or_not_required",
byte_length: options.byteLength ?? 6,
},
}),
});
type Compare = (
left: ReturnType<typeof evidence>,
right: ReturnType<typeof evidence>,
) => Promise<Comparison>;
const session = createTestBinarySession(() => {
throw new Error("Function comparison must not launch a provider");
});
const server = createServer({ kind: "session", session });
const client = new Client({
name: "function-string-decoding-test",
version: "1",
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
let compare: Compare;
beforeAll(async () => {
await server.connect(serverTransport);
await client.connect(clientTransport);
compare = async (left, right) => {
const response = await client.callTool({
name: "compare_functions",
arguments: { left, right },
});
expect(
response.isError,
JSON.stringify(response.structuredContent),
).not.toBe(true);
return functionComparisonResultSchema.parse(
parseEvidence(response.structuredContent).normalized_result,
);
};
});
afterAll(async () => {
await Promise.allSettled([client.close(), server.close(), session.close()]);
});
const stringsNames = (result: Comparison) =>
result.dimensions.find(({ dimension }) => dimension === "strings_names");
describe("compare_functions referenced string decoding", () => {
const reason = "The native string bytes are unavailable";
const undecodable = (id: string, display: string) =>
evidence(id, [
reference(display, { decoding: { available: false, reason } }),
]);
it("keeps unavailable previews unknown even when their display differs", async () => {
const samePreview = await compare(
undecodable("1", "display preview"),
undecodable("2", "display preview"),
);
expect(samePreview.status).toBe("unknown");
expect(stringsNames(samePreview)).toMatchObject({
status: "unknown",
limitations: [`Referenced string decoding unavailable: ${reason}`],
});
const differentPreview = await compare(
undecodable("3", "first display preview"),
undecodable("4", "second display preview"),
);
expect(differentPreview.status).toBe("unknown");
});
it("compares known typed string facts alongside unavailable content", async () => {
const metadataChange = await compare(
evidence("9", [
reference("display preview", {
decoding: { available: false, reason },
byteLength: 6,
}),
]),
evidence("a", [
reference("same display preview", {
decoding: { available: false, reason },
byteLength: 7,
}),
]),
);
expect(metadataChange.status).toBe("changed");
const mixedKnownChange = await compare(
evidence("b", [
reference("uncertain display", {
decoding: { available: false, reason },
sourceAddress: "0x1000",
}),
reference("before", { sourceAddress: "0x1001" }),
]),
evidence("c", [
reference("uncertain display", {
decoding: { available: false, reason },
sourceAddress: "0x1000",
}),
reference("after", { sourceAddress: "0x1001" }),
]),
);
expect(mixedKnownChange.status).toBe("changed");
});
it("retains decoder reasons when strings share a source site", async () => {
const reason = "Typed string bytes could not be read";
const rows = [
reference("display preview", {
decoding: { available: false, reason },
}),
reference("known content"),
];
const result = await compare(
evidence("repeated-left", rows),
evidence("repeated-right", rows),
);
expect(stringsNames(result)).toMatchObject({
status: "unknown",
limitations: expect.arrayContaining([
`Referenced string decoding unavailable: ${reason}`,
]),
});
});
it("compares decoded content and termination facts", async () => {
const decoded = (
id: string,
termination:
| "missing"
| "present_or_not_required" = "present_or_not_required",
byteLength = 6,
) => evidence(id, [reference("hello", { termination, byteLength })]);
const equal = await compare(decoded("5"), decoded("6"));
expect(equal.status).toBe("unchanged");
expect(stringsNames(equal)).toMatchObject({
status: "unchanged",
left_count: 1,
right_count: 1,
left_digest: expect.stringMatching(/^[a-f0-9]{64}$/u),
});
expect(stringsNames(equal)?.right_digest).toBe(
stringsNames(equal)?.left_digest,
);
const different = await compare(decoded("7"), decoded("8", "missing", 5));
expect(different.status).toBe("changed");
expect(stringsNames(different)).toMatchObject({ status: "changed" });
});
it("preserves paired metadata absence and ignores encoding provenance", async () => {
const missingMetadata = await compare(
evidence("d", [reference("same", { metadata: false })]),
evidence("e", [reference("same")]),
);
expect(missingMetadata.status).toBe("unknown");
const absentMetadata = await compare(
evidence("g", [reference("same", { metadata: false })]),
evidence("h", [reference("same", { metadata: false })]),
);
expect(absentMetadata.status).toBe("unchanged");
const provenanceOnly = await compare(
evidence("f", [reference("same", { encodingStatus: "observed" })]),
evidence("0", [reference("same", { encodingStatus: "inferred" })]),
);
expect(provenanceOnly.status).toBe("unchanged");
});
it("keeps literal changes in the combined string dimension", async () => {
const changed = await compare(
evidence("literal-before", [reference("hello")], "literal one"),
evidence("literal-after", [reference("hello")], "literal two"),
);
expect(changed.status).toBe("changed");
expect(stringsNames(changed)).toMatchObject({
status: "changed",
left_count: 2,
right_count: 2,
});
expect(stringsNames(changed)?.left_digest).not.toBe(
stringsNames(changed)?.right_digest,
);
});
});