From 1aa6beb5dc937a19e87df70b56df48f0d4e09541 Mon Sep 17 00:00:00 2001 From: 2SinceJuly <2929362966@qq.com> Date: Sun, 1 Nov 2026 18:42:27 +0800 Subject: [PATCH] fix(decompile): name the differing resume identity (#8465) Resume refused every identity mismatch with one generic message and generic input-schema recovery. Report the differing target, profile, layout, or provider facts with the journal and current values so callers can choose the matching provider/profile or a new output directory. Fixes #8349 --- .../decompileDirectory/decompileAll.test.ts | 34 ++++++- .../decompileDirectory/exportTree.ts | 90 ++++++++++++++----- 2 files changed, 100 insertions(+), 24 deletions(-) diff --git a/src/application/decompileDirectory/decompileAll.test.ts b/src/application/decompileDirectory/decompileAll.test.ts index 17470a61d3..f7d6de44e2 100644 --- a/src/application/decompileDirectory/decompileAll.test.ts +++ b/src/application/decompileDirectory/decompileAll.test.ts @@ -138,6 +138,7 @@ const harness = (options?: { Record >; readonly info?: "ok" | "unavailable" | "error"; + readonly listProfile?: typeof profile; readonly subject?: boolean; readonly mismatch?: string; readonly unprofiled?: string; @@ -170,7 +171,7 @@ const harness = (options?: { })), provider, { - analysisProfile: profile, + analysisProfile: options?.listProfile ?? profile, ...(options?.subject === false ? {} : { subject }), }, ), @@ -614,6 +615,37 @@ describe("decompile_all resume", () => { await readFile(join(directory(), "functions/0x1000.c"), "utf8"), ).toContain(sentinel); }); + + it("names the resume identity that differs instead of directing callers to the input schema", async () => { + expect((await run(harness().tools, directory())).ok).toBe(true); + + const resumed = await run( + harness({ listProfile: otherProfile }).tools, + directory(), + { resume: true }, + ); + + expect(resumed.ok).toBe(false); + if (resumed.ok) return; + expect(resumed.error).toBeInstanceOf(AnalysisInputError); + if (!(resumed.error instanceof AnalysisInputError)) return; + expect(resumed.error.userMessage).toBe( + "The decompile journal does not match the current analysis profile digest.", + ); + expect(resumed.error.conformsToInputContract).toBe(true); + expect(resumed.error.remediationAction).toContain("new output directory"); + expect(resumed.error.issues).toEqual([ + expect.objectContaining({ + path: ["output_directory"], + expected: expect.objectContaining({ + analysis_profile_digest: profile.digest, + }), + observed: expect.objectContaining({ + analysis_profile_digest: otherProfile.digest, + }), + }), + ]); + }); }); describe("decompile_all filters and layout", () => { diff --git a/src/application/decompileDirectory/exportTree.ts b/src/application/decompileDirectory/exportTree.ts index 8a79e19082..e28229bd36 100644 --- a/src/application/decompileDirectory/exportTree.ts +++ b/src/application/decompileDirectory/exportTree.ts @@ -29,9 +29,9 @@ import { type DecompileJournalHeader, } from "../../contracts/decompileDirectory.js"; import { + AnalysisInputError, AnalysisOutputError, AnalysisResourceConstraintError, - type AnalysisInputError, } from "../../domain/analysisErrorCore.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; @@ -663,20 +663,18 @@ const readJournal = async ( if (!parsed.ok) return parsed; const unknown = unknownIdentity(parsed.value.header, identity); if (unknown !== undefined) return err(unknown); - return sameIdentity(parsed.value.header, identity) - ? ok({ - records: parsed.value.records, - written: parsed.value.written, - paths: parsed.value.paths, - committedBytes: parsed.value.committedBytes, - journalBytes: parsed.value.journalBytes, - }) - : err( - decompileInputError( - "output_directory", - "The decompile journal does not match this target, provider, analysis profile, or layout.", - ), - ); + const mismatches = identityMismatches(parsed.value.header, identity); + if (mismatches.length > 0) + return err( + journalIdentityMismatch(parsed.value.header, identity, mismatches), + ); + return ok({ + records: parsed.value.records, + written: parsed.value.written, + paths: parsed.value.paths, + committedBytes: parsed.value.committedBytes, + journalBytes: parsed.value.journalBytes, + }); }; const parseJournal = ( @@ -747,16 +745,62 @@ const unknownIdentity = ( return undefined; }; -const sameIdentity = ( +/** Identity components in the order presented to the caller. */ +const identityMismatches = ( header: DecompileJournalHeader, identity: ExportIdentity, -): boolean => - header.target_sha256 === identity.targetSha256 && - header.analysis_profile_digest === identity.analysisProfileDigest && - header.layout === identity.layout && - header.provider.id === identity.provider.id && - header.provider.name === identity.provider.name && - header.provider.version === identity.provider.version; +): readonly string[] => [ + ...(header.target_sha256 === identity.targetSha256 ? [] : ["target SHA-256"]), + ...(header.analysis_profile_digest === identity.analysisProfileDigest + ? [] + : ["analysis profile digest"]), + ...(header.layout === identity.layout ? [] : ["layout"]), + ...(header.provider.id === identity.provider.id ? [] : ["provider id"]), + ...(header.provider.name === identity.provider.name ? [] : ["provider name"]), + ...(header.provider.version === identity.provider.version + ? [] + : ["provider version"]), +]; + +/** + * Report the concrete resume identity that differs. The request is valid, so + * generic input-schema recovery would send the caller to the wrong boundary. + */ +const journalIdentityMismatch = ( + header: DecompileJournalHeader, + identity: ExportIdentity, + mismatches: readonly string[], +): AnalysisInputError => { + const message = `The decompile journal does not match the current ${mismatches.join(", ")}.`; + return new AnalysisInputError( + "decompile_all", + { + userMessage: message, + conformsToInputContract: true, + remediationAction: + "Resume with the target, provider, analysis profile, and layout that created this directory, or choose a new output directory. The journal and existing files were left unchanged.", + }, + [ + { + path: ["output_directory"], + reason: "invalid_value", + message, + expected: { + target_sha256: header.target_sha256, + analysis_profile_digest: header.analysis_profile_digest, + layout: header.layout, + provider: header.provider, + }, + observed: { + target_sha256: identity.targetSha256, + analysis_profile_digest: identity.analysisProfileDigest, + layout: identity.layout, + provider: identity.provider, + }, + }, + ], + ); +}; const incompatibleJournal = (): AnalysisInputError => decompileInputError(