fix(native): preserve observed function dossier coverage

Declare observed Ghidra and Hopper facets, preserve unavailable pseudocode, and attach dossier call-set coverage. Version the analysis profiles to invalidate older snapshots and remove the retired include_assembly fixture branch.

Add MCP comparison and snapshot-replay regressions plus real-provider checks for empty and unavailable facets. Fixes #2980.
This commit is contained in:
morluto 2026-10-14 07:11:55 +08:00
parent 80592b82a6
commit b1b0211c28
16 changed files with 232 additions and 23 deletions

View File

@ -1490,9 +1490,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
InstructionScan scan = scanInstructions(function, Integer.MAX_VALUE);
DecompileResults decompilation = decompile(function);
String pseudocode = decompiledText(decompilation);
if (pseudocode == null) {
pseudocode = "";
}
List<Reference> incomingReferences = collectReferences(function, "incoming")
.stream()
.filter(reference -> !function.getBody().contains(reference.getFromAddress()))
@ -1514,7 +1511,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
procedure.add("locals", functionLocals(function));
JsonObject result = new JsonObject();
result.add("procedure", procedure);
result.addProperty("pseudocode", pseudocode);
result.addProperty("pseudocode", pseudocode == null ? "" : pseudocode);
result.add("assembly", assembly);
result.add("comments", comments);
result.add("callers", callers);
@ -1523,6 +1520,20 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.add("outgoing_references", outgoing);
result.add("referenced_strings", referencedStrings);
result.add("referenced_names", referencedNames);
JsonObject facets = new JsonObject();
JsonObject pseudocodeAvailability = new JsonObject();
pseudocodeAvailability.addProperty("available", pseudocode != null);
if (pseudocode == null) {
pseudocodeAvailability.addProperty("reason", "Ghidra did not supply pseudocode.");
}
facets.add("pseudocode", pseudocodeAvailability);
for (String facet : List.of("assembly", "comments", "callers", "callees",
"references", "strings_names", "cfg")) {
JsonObject available = new JsonObject();
available.addProperty("available", true);
facets.add(facet, available);
}
result.add("facet_availability", facets);
boolean swift = swiftProgram();
boolean aarch64 = currentProgram.getLanguageID().getIdAsString().startsWith("AARCH64:");
SwiftStringLiteralDecode swiftLiterals = swift && aarch64 ? swiftStringLiterals(function) : null;

View File

@ -644,7 +644,7 @@ def _analyze_function(document, params):
"end": _hex(_basic_block_end(procedure, block)),
"successors": sorted(set(successors), key=lambda value: int(value, 16)),
})
pseudo = _pseudocode(document, procedure) or ""
pseudo = _pseudocode(document, procedure)
assembly_lines = _assembly(procedure).splitlines()
callers = sorted((_procedure_identity(item) for item in procedure.getAllCallerProcedures()), key=lambda item: int(item["address"], 16))
callees = sorted((_procedure_identity(item) for item in procedure.getAllCalleeProcedures()), key=lambda item: int(item["address"], 16))
@ -706,7 +706,7 @@ def _analyze_function(document, params):
"signature": procedure.signatureString(),
"locals": _procedure_locals(procedure),
},
"pseudocode": pseudo,
"pseudocode": pseudo or "",
"assembly": assembly_lines,
"comments": comments,
"callers": callers, "callees": callees,
@ -718,6 +718,12 @@ def _analyze_function(document, params):
"basic_blocks": blocks,
"native_api": None,
"native_value_flow": None,
"facet_availability": {
"pseudocode": {"available": True} if pseudo is not None else _unavailable("Hopper did not supply pseudocode."),
**{facet: {"available": True} for facet in (
"assembly", "comments", "callers", "callees", "references", "strings_names", "cfg"
)},
},
"limitations": [
"Native CallReference classifications are observed where available; detailed reference flags remain unknown.",
"Hopper's public Python API does not expose equivalent external or thunk classification in this dossier.",

View File

@ -58,7 +58,13 @@ side declares a required facet unavailable. An empty legacy facet without a
coverage declaration is also unknown. A producer that actually observed an
empty facet can declare it available so observed absence remains comparable.
Call comparison additionally retains the Evidence relationship coverage
checks. IDA declares the facets its selected adapter cannot provide; an empty
checks. Ghidra and Hopper declare their observed dossier facets and complete
provider-observed caller/callee sets, including empty sets. Missing decompiler
output remains unavailable; complete call coverage does not claim every runtime
target. Hopper's unavailable reference-kind metadata and reported unresolved
calls still prevent equality conclusions for those dimensions. Analysis profiles
version these declarations so older snapshots are not reused as current results.
IDA declares the facets its selected adapter cannot provide; an empty
unsupported array does not prove observed absence.
Evidence and bundle imports reject unsupported fields in their envelopes,
identities, manifests and other fixed metadata before authentication. Imports

View File

@ -235,7 +235,11 @@ async function verifyNativeValueAdapters(
);
assert.notEqual(comparison.isError, true, JSON.stringify(comparison));
const comparisonEvidence = parseEvidence(comparison.structuredContent);
assert.equal(comparisonEvidence.normalized_result.status, "unchanged");
assert.equal(
comparisonEvidence.normalized_result.status,
"unchanged",
JSON.stringify(comparisonEvidence.normalized_result.dimensions),
);
assert.deepEqual(
comparisonEvidence.normalized_result,
comparison.structuredContent?.normalized_result,

View File

@ -131,6 +131,25 @@ export async function verifyGhidraBoundaries(
const externalDossier = await call("analyze_function", {
procedure: external.address,
});
assert.equal(externalDossier.pseudocode, "");
assert.deepEqual(externalDossier.assembly, []);
assert.deepEqual(externalDossier.callees, []);
assert.deepEqual(externalDossier.facet_availability.pseudocode, {
available: false,
reason: "Ghidra did not supply pseudocode.",
});
for (const facet of [
"assembly",
"comments",
"callers",
"callees",
"references",
"strings_names",
"cfg",
])
assert.deepEqual(externalDossier.facet_availability[facet], {
available: true,
});
const externalEntry = await call("resolve_containing_procedure", {
address: external.address,
});

View File

@ -327,14 +327,57 @@ export const analyzeFixtureProcedure = async (
options,
procedure,
normalizedResult,
) =>
normalizedResult(
) => {
const reply = await client.callTool(
{
name: "analyze_function",
arguments: { procedure: procedure.address },
},
options,
);
const dossier = normalizedResult(reply, "analyze_function");
assert.deepEqual(reply.structuredContent.relationship_coverage, {
status: "complete",
});
for (const facet of [
"pseudocode",
"assembly",
"comments",
"callers",
"callees",
"references",
"strings_names",
"cfg",
])
assert.deepEqual(dossier.facet_availability[facet], { available: true });
const comparison = normalizedResult(
await client.callTool(
{
name: "analyze_function",
arguments: { procedure: procedure.address },
name: "compare_functions",
arguments: {
left: reply.structuredContent,
right: reply.structuredContent,
},
},
options,
),
"analyze_function",
"compare_functions",
);
assert.deepEqual(
Object.fromEntries(
comparison.dimensions.map(({ dimension, status }) => [dimension, status]),
),
{
identity: "unchanged",
pseudocode: "unchanged",
assembly: "unchanged",
comments: "unchanged",
calls: "unchanged",
references: "unknown",
strings_names: "unchanged",
cfg: "unchanged",
},
JSON.stringify(comparison.dimensions),
);
return dossier;
};

View File

@ -147,7 +147,7 @@ export const ghidraNativeApiBoundary = () => ({
],
});
export const ghidraFunctionDossier = (includeAssembly = true): JsonValue => {
export const ghidraFunctionDossier = (): JsonValue => {
const pseudocode = "int fixture_main(void) { return 42; }";
return {
procedure: {
@ -156,10 +156,18 @@ export const ghidraFunctionDossier = (includeAssembly = true): JsonValue => {
locals: [],
},
pseudocode,
assembly: includeAssembly
? ["0x401000: CALL 0x401020", "0x401005: RET"]
: [],
assembly: ["0x401000: CALL 0x401020", "0x401005: RET"],
comments: [],
facet_availability: {
pseudocode: { available: true },
assembly: { available: true },
comments: { available: true },
callers: { available: true },
callees: { available: true },
references: { available: true },
strings_names: { available: true },
cfg: { available: true },
},
callers: [],
callees: [],
incoming_references: [],

View File

@ -126,6 +126,8 @@ export const resolveGhidraAnalysisProfile = async (
function_body_evidence: "complete-inclusive-ranges-v1",
function_references: "complete-body-and-entry-reference-manager-v2",
call_relationship_coverage: "resolved-call-reference-diagnostics-v1",
function_dossier_coverage:
"observed-facets-and-call-relationships-v1",
string_inventory_evidence: "defined-data-coverage-v1",
location_resolution: "explicit-address-exact-entry-symbol-first-v3",
procedure_file_offset_resolution:
@ -221,6 +223,7 @@ export const resolveGhidraAnalysisProfile = async (
function_body_evidence: "complete-inclusive-ranges-v1",
function_references: "complete-body-and-entry-reference-manager-v2",
call_relationship_coverage: "resolved-call-reference-diagnostics-v1",
function_dossier_coverage: "observed-facets-and-call-relationships-v1",
string_inventory_evidence: "defined-data-coverage-v1",
location_resolution: "explicit-address-exact-entry-symbol-first-v3",
procedure_file_offset_resolution:

View File

@ -628,6 +628,7 @@ describe("Ghidra client projection", () => {
language_id: "auto-from-header",
compiler_spec_id: "auto-default",
call_relationship_coverage: "resolved-call-reference-diagnostics-v1",
function_dossier_coverage: "observed-facets-and-call-relationships-v1",
});
const result = await ghidra

View File

@ -51,7 +51,9 @@ const resolvedCallReferenceLimitation =
export const relationshipCoverageFor = (
operation: string,
): RelationshipCoverage | undefined =>
operation === "procedure_callers" || operation === "procedure_callees"
operation === "procedure_callers" ||
operation === "procedure_callees" ||
operation === "analyze_function"
? {
status: "complete",
diagnostics: [resolvedCallReferenceLimitation],

View File

@ -82,6 +82,7 @@ export const resolveHopperAnalysisProfile = async (
profile: createAnalysisProfile(provider, {
target_kind: target.kind,
target_format: target.format,
function_dossier_coverage: "observed-facets-and-call-relationships-v1",
architecture: target.architecture ?? null,
available_architectures: [
...(target.availableArchitectures ?? []),

View File

@ -272,7 +272,8 @@ export class HopperProvider implements AnalysisProviderCandidate {
rawResult,
...(profile === undefined ? {} : { analysisProfile: profile }),
...(operation === "procedure_callers" ||
operation === "procedure_callees"
operation === "procedure_callees" ||
operation === "analyze_function"
? { relationshipCoverage: { status: "complete" as const } }
: {}),
limitations: [

View File

@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { EnhancedTools } from "../../../src/application/EnhancedTools.js";
import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js";
import { functionDossierSchema } from "../../../src/domain/hopperValues.js";
import { functionComparisonResultSchema } from "../../../src/domain/functionComparisonSchemas.js";
import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js";
import {
jsonValueSchema,
@ -14,6 +15,97 @@ import {
type GhidraFunctionOperation,
} from "../../../src/ghidra/GhidraFunctionValues.js";
it.each([true, false])(
"compares observed empty Ghidra facets while preserving pseudocode availability (%s)",
async (pseudocodeAvailable) => {
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
Object.assign(dossier, {
comments: [],
callers: [],
callees: [],
incoming_references: [],
outgoing_references: [],
referenced_strings: [],
referenced_names: [],
pseudocode: pseudocodeAvailable ? dossier.pseudocode : "",
facet_availability: {
pseudocode: pseudocodeAvailable
? { available: true }
: { available: false, reason: "Ghidra did not supply pseudocode." },
assembly: { available: true },
comments: { available: true },
callers: { available: true },
callees: { available: true },
references: { available: true },
strings_names: { available: true },
cfg: { available: true },
},
});
const harness = await connectGhidraMcp(
"ghidra-empty-dossier-coverage",
(operation) => {
if (operation !== "analyze_function")
throw new Error(`Unexpected operation: ${operation}`);
return Promise.resolve(
ok({ value: jsonValueSchema.parse(dossier), limitations: [] }),
);
},
);
try {
const analyzed = await harness.mcp.callTool({
name: "analyze_function",
arguments: { procedure: dossier.procedure.address },
});
expect(analyzed.isError).not.toBe(true);
const evidence = sessionEvidence(
harness.session,
analyzed.structuredContent,
);
expect(evidence.relationship_coverage).toMatchObject({
status: "complete",
});
expect(evidence.normalized_result).toEqual(dossier);
const reply = await harness.mcp.callTool({
name: "compare_functions",
arguments: { left: evidence, right: evidence },
});
expect(reply.isError).not.toBe(true);
const comparison = functionComparisonResultSchema.parse(
sessionEvidence(harness.session, reply.structuredContent)
.normalized_result,
);
expect(
Object.fromEntries(
comparison.dimensions.map(({ dimension, status }) => [
dimension,
status,
]),
),
).toEqual({
identity: "unchanged",
pseudocode: pseudocodeAvailable ? "unchanged" : "unknown",
assembly: "unchanged",
comments: "unchanged",
calls: "unchanged",
references: "unchanged",
strings_names: "unchanged",
cfg: "unchanged",
});
const replay = await harness.session.execute(
"analyze_function",
evidence.parameters,
);
if (!replay.ok) throw replay.error;
expect(replay.value.relationshipCoverage).toEqual(
evidence.relationship_coverage,
);
expect(harness.calls).toEqual(["analyze_function"]);
} finally {
await harness.close();
}
},
);
it("retains terminal-call qualifications across function views and snapshot replay", async () => {
const limitation =
"Ghidra Listing reports a terminal call at 0x401000 to EXTERNAL:0x20 __tls_get_addr (external=true, thunk=false, hasNoReturn=true); fallthrough is excluded by Ghidra's flow model. This records Ghidra's FunctionManager flag, not an independent verification that the callee cannot return.";

View File

@ -246,10 +246,7 @@ const resultBuilders = new Map<GhidraOperation, GhidraResultBuilder>([
}),
],
["xrefs", () => ["0x401001"]],
[
"analyze_function",
(input) => ghidraFunctionDossier(input.include_assembly === true),
],
["analyze_function", () => ghidraFunctionDossier()],
]);
const resultFor = (

View File

@ -106,6 +106,8 @@ describe("Hopper analysis profiles", () => {
value: {
profile: {
parameters: {
function_dossier_coverage:
"observed-facets-and-call-relationships-v1",
loader: {
source: scenario.source,
arguments: scenario.arguments,

View File

@ -167,6 +167,19 @@ describe("Hopper API facade", () => {
},
native_api: null,
native_value_flow: null,
facet_availability: {
pseudocode: {
available: false,
reason: "Hopper did not supply pseudocode.",
},
assembly: { available: true },
comments: { available: true },
callers: { available: true },
callees: { available: true },
references: { available: true },
strings_names: { available: true },
cfg: { available: true },
},
});
expect(result.provider_faults.map((reply) => reply.error.message)).toEqual([
"TypeError: Hopper bridge operation failed",