fix(decompile): retain limitations across failed resume (#7913)

This commit is contained in:
morluto authored and GitHub committed 2026-10-29 07:30:44 +08:00
1 parent 31c028f496
commit 5082b50f07
2 files changed
+122 -19

No files matched your search

@@ -426,13 +426,12 @@ const attemptOne = async (
"The function file already exists and does not match this export.",
};
recordFailure(input.state, procedure, reason);
const retained = retainedObservation(input.session, procedure.address);
const appended = await input.session.append(
failureRecord(
procedure,
retainedBody(input.session, procedure.address),
reason,
{ relativePath: input.session.pathFor(procedure.address) },
),
failureRecord(procedure, retained.body, reason, {
relativePath: input.session.pathFor(procedure.address),
...retained.options,
}),
);
return { result: appended, bodySupport };
}
@@ -442,13 +441,12 @@ const attemptOne = async (
message: "The existing function file could not be read.",
};
recordFailure(input.state, procedure, reason);
const retained = retainedObservation(input.session, procedure.address);
const appended = await input.session.append(
failureRecord(
procedure,
retainedBody(input.session, procedure.address),
reason,
{ relativePath: input.session.pathFor(procedure.address) },
),
failureRecord(procedure, retained.body, reason, {
relativePath: input.session.pathFor(procedure.address),
...retained.options,
}),
);
return { result: appended, bodySupport };
}
@@ -941,12 +939,19 @@ const unavailableBody = (reason: string): FunctionBody => ({
reason: reason.length === 0 ? "unavailable" : reason,
});
/** Keep the journal's body fact when a conflicting file is left unchanged. */
const retainedBody = (
/** Preserve the observations and their caveats when a file cannot be reused. */
const retainedObservation = (
session: DecompileExport,
address: string,
): FunctionBody => {
return session.record(address)?.body ?? unavailableBody("not rewritten");
): { body: FunctionBody; options: { limitations?: readonly string[] } } => {
const record = session.record(address);
return {
body: record?.body ?? unavailableBody("not rewritten"),
options:
record?.limitations === undefined
? {}
: { limitations: record.limitations },
};
};
const exportLimitations = (
+101 -3
View File
@@ -1,12 +1,13 @@
import { readFile } from "node:fs/promises";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createAnalysisExecution,
type AnalysisOperationPort,
} from "../../../src/application/AnalysisProvider.js";
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
import { DecompileExport } from "../../../src/application/decompileDirectory/exportTree.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { err, ok } from "../../../src/domain/result.js";
import {
@@ -56,9 +57,106 @@ const addressCaveats: AnalysisOperationPort = {
},
};
afterEach(closeEnhancedToolResources);
afterEach(async () => {
vi.restoreAllMocks();
await closeEnhancedToolResources();
});
describe("decompile_all MCP caveats", () => {
it("retains all observed caveats across unreadable and conflicting resumes", async () => {
const parent = await createTestTempDirectory(
"rea-decompile-resume-caveats-",
);
const output = join(parent, "out");
const selected = join(output, "functions", "0x1000.c");
const caveats = Array.from(
{ length: 40 },
(_, i) => `provider limitation ${i}`,
);
const calls: string[] = [];
const port: AnalysisOperationPort = {
execute: async (operation) => {
calls.push(operation);
return ok(
createAnalysisExecution(
operation === "list_procedures"
? [{ address: "0x1000", value: "function" }]
: operation === "procedure_info"
? null
: "int f(void) { return 0; }",
provider,
{
analysisProfile: profile,
...(operation === "list_procedures"
? {
subject: {
path: "/tmp/test",
sha256: "ab".repeat(32),
format: "elf" as const,
architecture: "x86_64" as const,
},
}
: {}),
...(operation === "procedure_pseudo_code"
? { limitations: caveats }
: {}),
},
),
);
},
};
const client = await connect(port);
const run = async (resume: boolean) =>
client.callTool({
name: "decompile_all",
arguments: { output_directory: output, resume },
});
const record = async () =>
decompileDirectoryIndexSchema.parse(
JSON.parse(
await readFile(join(output, "decompile-index.json"), "utf8"),
),
).functions[0];
expect((await run(false)).isError).not.toBe(true);
const original = await readFile(selected, "utf8");
const hashFile = DecompileExport.prototype.hashFile;
const blocked = vi
.spyOn(DecompileExport.prototype, "hashFile")
.mockImplementation(function (this: DecompileExport, path, signal) {
return path === "functions/0x1000.c"
? Promise.reject(
Object.assign(new Error("access denied"), { code: "EACCES" }),
)
: hashFile.call(this, path, signal);
});
expect((await run(true)).isError).not.toBe(true);
expect(await record()).toMatchObject({
status: "failed",
failure: { code: "output_io" },
limitations: caveats,
relative_path: "functions/0x1000.c",
});
blocked.mockRestore();
await writeFile(selected, "external change\n");
for (let i = 0; i < 2; i++) {
expect((await run(true)).isError).not.toBe(true);
expect(await record()).toMatchObject({
status: "failed",
failure: { code: "output_exists" },
limitations: caveats,
relative_path: "functions/0x1000.c",
});
expect(await readFile(selected, "utf8")).toBe("external change\n");
}
await writeFile(selected, original);
calls.length = 0;
expect((await run(true)).isError).not.toBe(true);
expect(await record()).toMatchObject({
status: "written",
limitations: caveats,
});
expect(calls).not.toContain("procedure_pseudo_code");
});
it("returns a bounded caveat summary and keeps each function's caveats in its file", async () => {
const parent = await createTestTempDirectory("rea-decompile-caveats-");
const client = await connect(addressCaveats);