diff --git a/src/application/decompileDirectory/decompileAll.test.ts b/src/application/decompileDirectory/decompileAll.test.ts index 39fc809e88..34120d6cc4 100644 --- a/src/application/decompileDirectory/decompileAll.test.ts +++ b/src/application/decompileDirectory/decompileAll.test.ts @@ -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[] } + ).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", () => { diff --git a/src/application/decompileDirectory/decompileAll.ts b/src/application/decompileDirectory/decompileAll.ts index 78b97fffb6..fa72ebad92 100644 --- a/src/application/decompileDirectory/decompileAll.ts +++ b/src/application/decompileDirectory/decompileAll.ts @@ -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; }; diff --git a/src/contracts/decompileDirectory.ts b/src/contracts/decompileDirectory.ts index 376d8de71b..56d569215d 100644 --- a/src/contracts/decompileDirectory.ts +++ b/src/contracts/decompileDirectory.ts @@ -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([