From 2847311a579ce2da4c8ded4d89fcdd01545e6da5 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 9 Oct 2026 05:31:25 +0800 Subject: [PATCH] fix(hopper): normalize native block endpoints and retain terminal evidence --- bridge/hopper_bridge.py | 41 ++++--- docs/testing.md | 4 +- scripts/lib/real-hopper-boundaries.mjs | 28 +++++ scripts/lib/real-hopper-function-basics.mjs | 115 ++++++++++++++++++++ scripts/verify-real-hopper.mjs | 11 +- tests/fixtures/hopperBridgeFacadeProbe.py | 3 + 6 files changed, 179 insertions(+), 23 deletions(-) diff --git a/bridge/hopper_bridge.py b/bridge/hopper_bridge.py index d99669a58..559432172 100644 --- a/bridge/hopper_bridge.py +++ b/bridge/hopper_bridge.py @@ -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()), diff --git a/docs/testing.md b/docs/testing.md index f6076d26b..224af2b91 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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. diff --git a/scripts/lib/real-hopper-boundaries.mjs b/scripts/lib/real-hopper-boundaries.mjs index e54e71810..8414cbc45 100644 --- a/scripts/lib/real-hopper-boundaries.mjs +++ b/scripts/lib/real-hopper-boundaries.mjs @@ -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, }; diff --git a/scripts/lib/real-hopper-function-basics.mjs b/scripts/lib/real-hopper-function-basics.mjs index 998902f24..b6384fcbc 100644 --- a/scripts/lib/real-hopper-function-basics.mjs +++ b/scripts/lib/real-hopper-function-basics.mjs @@ -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 ?? "", + }); + } +} diff --git a/scripts/verify-real-hopper.mjs b/scripts/verify-real-hopper.mjs index 4277ef1fa..0fa483036 100644 --- a/scripts/verify-real-hopper.mjs +++ b/scripts/verify-real-hopper.mjs @@ -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, diff --git a/tests/fixtures/hopperBridgeFacadeProbe.py b/tests/fixtures/hopperBridgeFacadeProbe.py index 6fa24f42f..195292136 100644 --- a/tests/fixtures/hopperBridgeFacadeProbe.py +++ b/tests/fixtures/hopperBridgeFacadeProbe.py @@ -71,6 +71,9 @@ class FakeProcedure: def basicBlockIterator(self): return [FakeBlock()] + def getBasicBlockAtAddress(self, address): + return None + def getBasicBlockCount(self): return 1