fix(decompile): keep the provider's null-pseudocode reason on inline failures

decompile_all inline failure rows (failures and last_failures) carried
only address, code, message and name, so the provider's typed
pseudocode_absence reason stayed in the index only. Rows now include
pseudocode_absence. When the inline budget is short the name is dropped
first, then the reason, before the row; the index always keeps both.

Fixes #8731

The report and an earlier manifest-note approach are from #8735 by
wunianze666-netizen.

Co-Authored-By: wunianze666-netizen <wunianze666@gmail.com>
Co-Authored-By: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
authored and Ysopwnable committed 2026-11-14 14:16:47 +01:00
1 parent 9d2a57fd8d
commit c9f33aaa82
3 files changed
+82 -4

No files matched your search

@@ -599,6 +599,10 @@ describe("decompile_all external functions", () => {
expect(resumed.error.userMessage).toContain("not a compatible export");
},
);
});
describe("decompile_all pseudocode absence", () => {
const directory = useOutputDirectory();
it("reports a null pseudocode result as no pseudocode, not as text of the wrong type", async () => {
const { tools } = harness({ fail: { "0x2000": "null" } });
@@ -611,6 +615,61 @@ describe("decompile_all external functions", () => {
failures: [{ address: "0x2000", code: "no_pseudocode" }],
});
});
it.each([
["fits", "Flow exceeded maximum instructions"],
["exceeds the inline budget", "x".repeat(4_096)],
] as const)(
"keeps the provider's null-pseudocode reason on the inline row while it %s",
async (_case, reason) => {
const { port } = harness();
const absence = { kind: "provider-failure", reason } as const;
const reporting: AnalysisOperationPort = {
execute: (operation, parameters, options) =>
operation === "procedure_pseudo_code" &&
parameters.procedure === "0x2000"
? Promise.resolve(
ok(
createAnalysisExecution(null, provider, {
analysisProfile: profile,
pseudocodeAbsence: absence,
}),
),
)
: port.execute(operation, parameters, options),
};
const result = await run(new EnhancedTools(reporting), directory());
if (!result.ok) throw result.error;
const [failure] = (
result.value as { failures: Record<string, unknown>[] }
).failures;
expect(failure).toMatchObject({
address: "0x2000",
code: "no_pseudocode",
});
// Name goes first, then the reason; the index always keeps both.
if (reason.length < 100)
expect(failure).toMatchObject({
name: "Foo::helper",
pseudocode_absence: absence,
});
else {
expect(failure).not.toHaveProperty("name");
expect(failure).not.toHaveProperty("pseudocode_absence");
}
const index = decompileDirectoryIndexSchema.parse(
JSON.parse(
await readFile(
join(directory(), DECOMPILE_DIRECTORY_INDEX_NAME),
"utf8",
),
),
);
expect(
index.functions.find(({ address }) => address === "0x2000"),
).toMatchObject({ pseudocode_absence: absence });
},
);
});
describe("decompile_all resume", () => {
@@ -1,6 +1,7 @@
import { ExportPublishedCleanupFailure } from "./exportResources.js";
import { join } from "node:path";
import { z } from "zod";
import type { PseudocodeAbsence } from "../../domain/native/pseudocodeAbsence.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
@@ -647,7 +648,12 @@ const publishOne = async (input: {
"The provider reported a function decompilation failure. If it supports independent decompilation, resume this export with retry_failed: independent_fresh (CLI: --resume --retry-failed independent_fresh). The retry may use different analysis context.",
}
: pseudocodeFailure(pseudocode);
recordFailure(input.state, input.procedure, reason);
recordFailure(
input.state,
input.procedure,
reason,
decompiled.value.pseudocodeAbsence,
);
return {
result: await input.session.append(
failureRecord(input.procedure, body.fact, reason, {
@@ -951,14 +957,18 @@ const recordFailure = (
state: PublishState,
procedure: SelectedProcedure,
reason: FailureReason,
pseudocodeAbsence?: PseudocodeAbsence,
): void => {
state.failed += 1;
state.lastFailureCode = reason.code;
const failure = {
const failure: NamedFailure = {
address: procedure.address,
name: procedure.name,
code: reason.code,
message: reason.message,
...(pseudocodeAbsence === undefined
? {}
: { pseudocode_absence: pseudocodeAbsence }),
};
if (state.failures.length < DECOMPILE_DIRECTORY_INLINE_FAILURE_LIMIT) {
const comma = state.failures.length === 0 ? 0 : 1;
@@ -999,8 +1009,12 @@ const fitInlineFailure = (
// Name is display context; address remains the complete lookup identity.
const { name: _name, ...withoutName } = failure;
const compactBytes = jsonValueByteLength(withoutName, budget);
return compactBytes <= budget
? { row: withoutName, bytes: compactBytes }
if (compactBytes <= budget) return { row: withoutName, bytes: compactBytes };
// The provider's reason is kept in the index record, so it goes next.
const { pseudocode_absence: _absence, ...minimal } = withoutName;
const minimalBytes = jsonValueByteLength(minimal, budget);
return minimalBytes <= budget
? { row: minimal, bytes: minimalBytes }
: undefined;
};
+5
View File
@@ -62,6 +62,11 @@ const failureFields = {
address: z.string().min(1),
code: z.string().min(1),
message: z.string().min(1),
pseudocode_absence: pseudocodeAbsenceSchema
.exactOptional()
.describe(
"The provider's reported reason for null pseudocode; omitted after the name to fit the inline text budget, and always kept in the index.",
),
};
const failureSchema = z.union([