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
This commit is contained in:
2SinceJuly authored and GitHub committed 2026-11-01 18:42:27 +08:00
1 parent 71746351d3
commit 1aa6beb5dc
2 files changed
+100 -24

No files matched your search

@@ -138,6 +138,7 @@ const harness = (options?: {
Record<string, "error" | "timeout" | "empty" | "null">
>;
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", () => {
@@ -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(