fix(hopper): normalize native block endpoints and retain terminal evidence

This commit is contained in:
morluto
2026-10-09 05:31:49 +08:00
parent 4458128a84
commit 2847311a57
6 changed files with 179 additions and 23 deletions
+20 -21
View File
@@ -387,13 +387,26 @@ def _containing_procedure(document, address):
return (procedure, None) if procedure is not None else (None, "not_in_procedure")
def _basic_block_end(procedure, block):
"""Normalize native block endpoints to an exclusive byte address."""
end = block.getEndingAddress()
# Hopper 6.1 returns the final instruction address, despite the Python
# documentation describing an exclusive end. Membership distinguishes
# that representation from an actual exclusive endpoint in other builds.
if procedure.getBasicBlockAtAddress(end) == block:
instruction = procedure.getSegment().getInstructionAtAddress(end)
if instruction is not None and instruction.getInstructionLength() > 0:
return end + instruction.getInstructionLength()
return end
def _instruction_addresses(procedure):
result = []
seen = set()
segment = procedure.getSegment()
for block in procedure.basicBlockIterator():
address = block.getStartingAddress()
end = block.getEndingAddress()
end = _basic_block_end(procedure, block)
while address < end and address not in seen:
seen.add(address)
instruction = segment.getInstructionAtAddress(address)
@@ -633,25 +646,11 @@ def _render_instruction(segment, address):
def _assembly(procedure):
"""Render assembly while guarding against malformed instruction cycles."""
lines = []
segment = procedure.getSegment()
seen = set()
for block in procedure.basicBlockIterator():
address = block.getStartingAddress()
end = block.getEndingAddress()
while address < end and address not in seen:
seen.add(address)
instruction = segment.getInstructionAtAddress(address)
if instruction is None:
break
line = _render_instruction(segment, address)
if line is not None:
lines.append(line)
length = instruction.getInstructionLength()
if length <= 0:
break
address += length
return "\n".join(lines)
return "\n".join(
line for address in _instruction_addresses(procedure)
if (line := _render_instruction(segment, address)) is not None
)
def _read_function_instructions(document, params):
@@ -688,7 +687,7 @@ def _analyze_function(document, params):
successors.append(_hex(successor))
blocks.append({
"start": _hex(block.getStartingAddress()),
"end": _hex(block.getEndingAddress()),
"end": _hex(_basic_block_end(procedure, block)),
"successors": sorted(set(successors), key=lambda value: int(value, 16)),
})
pseudo = _pseudocode(document, procedure) or ""
@@ -986,7 +985,7 @@ def _dispatch(method, params):
return sorted((_hex(item.getEntryPoint()) for item in procedure.getAllCalleeProcedures()), key=lambda value: int(value, 16))
if method == "procedure_info":
blocks = list(procedure.basicBlockIterator())
length = sum(max(0, block.getEndingAddress() - block.getStartingAddress()) for block in blocks)
length = sum(max(0, _basic_block_end(procedure, block) - block.getStartingAddress()) for block in blocks)
return {
"name": _procedure_name(procedure),
"entrypoint": _hex(procedure.getEntryPoint()),
+3 -1
View File
@@ -211,7 +211,9 @@ CallReference classifications across reference and dossier results, retains long
string fragment metadata in dossiers, and exercises pathological regex deadline
and cancellation followed by successful requests in the same native session. When the macOS
Objective-C fixture is present, the lane also verifies native UTF-16 string objects
and their inferred encodings against their actual bytes.
and their inferred encodings against their actual bytes. Native terminal calls
are checked across reference, instruction, assembly, block-range and procedure-length
projections; block endpoints are normalized using actual native membership.
Unmapped annotation destinations and
oversized later batch names fail before any earlier edit is applied.
The Linux demo lane remains a separate `verify:hopper:linux` command.
+28
View File
@@ -394,6 +394,33 @@ export async function verifyHopperLifecycleAndCli(client, options, targets) {
JSON.parse(analyzed.stdout).normalized_result,
expectedFunction,
);
let cliTerminalFunctionParity = null;
if (targets.unicode !== undefined) {
const objc = join(directory, `objc-${suffix}`);
await copyFile(targets.unicode, objc);
await call("open_binary", { path: objc });
const procedures = await call("list_procedures");
const delegate = procedures.find(
(item) => item.value === "-[REAWidget delegate]",
);
assert.ok(delegate, "Objective-C fixture omitted its tail-call method");
const expected = await call("analyze_function", {
procedure: delegate.address,
});
await call("close_binary");
const analyzed = await runCli([
dispatcher,
"function",
objc,
delegate.address,
"--provider",
"hopper",
"--format",
"json",
]);
assert.deepEqual(JSON.parse(analyzed.stdout).normalized_result, expected);
cliTerminalFunctionParity = true;
}
let failure;
try {
await runCli([
@@ -424,6 +451,7 @@ export async function verifyHopperLifecycleAndCli(client, options, targets) {
cliInvalidAddress: true,
cliLiteralTraceParity: true,
cliFunctionDossierParity: true,
cliTerminalFunctionParity,
callerCancellationRecovered: true,
closedDocumentAbsent: true,
};
+115
View File
@@ -1,3 +1,4 @@
import assert from "node:assert/strict";
import { requireMcpResult } from "./mcp-verifier-results.mjs";
export const verifyHopperFunctionBasics = async (
@@ -64,3 +65,117 @@ export const verifyHopperFunctionBasics = async (
instructionWindowCount: instructions.instructions.length,
};
};
/** Check native terminal call sites across instruction, reference and dossier projections. */
export async function verifyHopperTerminalInstructions(call) {
const procedures = await call("list_procedures");
const selected = procedures.filter(
({ value }) =>
value === "-[REAWidget delegate]" ||
value.startsWith("imp___stubs__") ||
value.startsWith("_objc_msgSend$"),
);
if (selected.length === 0)
throw new Error("Objective-C fixture omitted terminal-call procedures");
let terminalCalls = 0;
for (const { address } of selected) {
const references = await call("procedure_references", {
procedure: address,
direction: "outgoing",
});
const nativeCalls = references.references.filter(
(edge) => edge.call !== undefined,
);
if (nativeCalls.length === 0)
throw new Error(
`Native fixture procedure ${address} omitted its terminal call`,
);
const dossier = await call("analyze_function", { procedure: address });
const instructions = await call("read_function_instructions", {
procedure: address,
});
const assembly = await call("procedure_assembly", { procedure: address });
const info = await call("procedure_info", { procedure: address });
for (const edge of nativeCalls) {
const observed = dossier.outgoing_references.find(
(item) =>
item.source_address === edge.source_address &&
item.target_address === edge.target_address,
);
assert.ok(
observed,
`Dossier discarded native call ${edge.source_address} -> ${edge.target_address}`,
);
assert.deepEqual(observed.call, edge.call);
assert.ok(
instructions.instructions.some((line) =>
line.startsWith(`${edge.source_address}:`),
),
`Instruction inventory discarded native call site ${edge.source_address}`,
);
assert.ok(
dossier.basic_blocks.some(
({ start, end }) =>
BigInt(start) <= BigInt(edge.source_address) &&
BigInt(edge.source_address) < BigInt(end),
),
`Normalized block excluded native call site ${edge.source_address}`,
);
terminalCalls++;
}
assert.equal(assembly, instructions.instructions.join("\n"));
assert.deepEqual(dossier.assembly, instructions.instructions);
if (address === selected[0].address)
await verifyHopperTerminalComment(
call,
address,
nativeCalls.at(-1).source_address,
);
const expectedLength = dossier.basic_blocks.reduce(
(total, { start, end }) => total + Number(BigInt(end) - BigInt(start)),
0,
);
assert.equal(
info.length,
expectedLength,
"Procedure length disagrees with normalized native blocks",
);
}
if (terminalCalls === 0)
throw new Error("Objective-C fixture omitted native terminal calls");
return {
procedures: selected.length,
terminalCalls,
normalizedBlockEndpoints: true,
terminalCommentPreserved: true,
};
}
async function verifyHopperTerminalComment(call, address, terminal) {
const original = await call("comment", { address: terminal });
try {
await call("set_comment", {
address: terminal,
comment: "REA terminal instruction comment",
});
const annotated = await call("analyze_function", {
procedure: address,
});
if (
!annotated.comments.some(
(item) =>
item.address === terminal &&
item.kind === "comment" &&
item.text === "REA terminal instruction comment",
)
)
throw new Error(
"Dossier discarded a comment on the native terminal instruction",
);
} finally {
await call("set_comment", {
address: terminal,
comment: original ?? "",
});
}
}
+10 -1
View File
@@ -30,7 +30,10 @@ import {
requireMcpResult,
requireWorkflowEvidenceProvider,
} from "./lib/mcp-verifier-results.mjs";
import { verifyHopperFunctionBasics } from "./lib/real-hopper-function-basics.mjs";
import {
verifyHopperTerminalInstructions,
verifyHopperFunctionBasics,
} from "./lib/real-hopper-function-basics.mjs";
import { openAndVerifyLargeFixture } from "./lib/real-hopper-exhaustive-search.mjs";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
import { requireHopperSelection } from "./lib/real-hopper-selection.mjs";
@@ -465,6 +468,7 @@ try {
);
if (closed.isError === true) throw new Error(textValue(closed));
let unicodeStrings = null;
let terminalInstructions = null;
if (fixtureTargets.unicode !== undefined) {
const call = async (name, args = {}) =>
requireSuccessfulTool(
@@ -472,6 +476,7 @@ try {
name,
);
await call("open_binary", { path: fixtureTargets.unicode.path });
terminalInstructions = await verifyHopperTerminalInstructions(call);
unicodeStrings = await verifyHopperStringObjects(call, [
{ value: "REA_UTF16_é_😀", encoding: "utf-16-le" },
{ value: 'REA_UTF16_ESCAPED_é_"\\line\nend\t\r', encoding: "utf-16-le" },
@@ -482,6 +487,9 @@ try {
const lifecycleAndCli = await verifyHopperLifecycleAndCli(client, options, {
primary: targetA,
secondary: targetB,
...(fixtureTargets.unicode === undefined
? {}
: { unicode: fixtureTargets.unicode.path }),
ownedProcessIds,
});
const closedSession = requireMcpResult(
@@ -509,6 +517,7 @@ try {
analyses: [firstAnalysis, secondAnalysis],
fixtureAnalysis,
unicodeStrings,
terminalInstructions,
boundaryContracts,
lifecycleAndCli,
inventoryCounts,
+3
View File
@@ -71,6 +71,9 @@ class FakeProcedure:
def basicBlockIterator(self):
return [FakeBlock()]
def getBasicBlockAtAddress(self, address):
return None
def getBasicBlockCount(self):
return 1