feat(javascript): recover code-split bundles from multiple inputs

recover_javascript_sources accepted a single path, and
validateModuleSet bound every module's provenance to that one snapshot.
A code-split app (runtime + chunks, or an Electron main/preload/renderer
set) had to be recovered one file per call, and modules recovered alone
could not resolve imports across chunks: cross-chunk require(id)
references stayed as numeric ids with property reads.

Add an optional additional_paths input. The listed files are
concatenated, in order after path, into the single engine snapshot, so
one Wakaru run sees every module and cross-chunk imports resolve.
validateModuleSet's single-snapshot binding is unchanged. Module
provenance is attributed back to the input file its first extraction
range starts in, with byte ranges rebased to that file's own offsets;
a module whose ranges cross an input boundary keeps the concatenated
snapshot identity and offsets, recorded as an explicit limitation.
Single-path behavior is byte-for-byte unchanged (one segment,
offset 0).

Fixes #1605
This commit is contained in:
JingHao-Leon
2026-10-11 11:24:09 +08:00
parent e5bc4e85ec
commit 5ddf8766b8
9 changed files with 392 additions and 20 deletions
@@ -129,4 +129,49 @@ describe("JavaScript recovery workflow", () => {
if (cancelled.ok) throw new Error("expected cancellation");
expect(cancelled.error._tag).toBe("AnalysisCancelledError");
});
it("rejects additional_paths repeating path or each other", async () => {
const service = new JavaScriptRecoveryService({
recover: async () => {
throw new Error("port must not execute");
},
});
for (const additional_paths of [
[artifact.path],
["/tmp/shared.js", "/tmp/shared.js"],
]) {
const result = await service.recover({
...input,
additional_paths,
});
if (result.ok) throw new Error("expected duplicate rejection");
expect(result.error._tag).toBe("AnalysisInputError");
expect(result.error).toMatchObject({
issues: [{ path: ["additional_paths"], reason: "invalid_format" }],
});
}
});
it("rejects relative additional_paths before acquiring the port", async () => {
let invoked = false;
const service = new JavaScriptRecoveryService({
recover: async () => {
invoked = true;
return err(
new AnalysisCapabilityUnavailableError(
"fixture",
"recover_javascript_sources",
"engine missing",
),
);
},
});
const result = await service.recover({
...input,
additional_paths: ["relative-chunk.js"],
});
expect(invoked).toBe(false);
if (result.ok) throw new Error("expected invalid path");
expect(result.error).toMatchObject({
issues: [{ path: ["additional_paths"], reason: "invalid_format" }],
});
});
});
@@ -44,17 +44,37 @@ export class JavaScriptRecoveryService {
const invalidPaths = (["path", "output_directory"] as const).filter(
(field) => !isAbsolute(parsed.data[field]),
);
if (invalidPaths.length > 0)
const invalidAdditional = (parsed.data.additional_paths ?? []).filter(
(additional) => !isAbsolute(additional),
);
if (invalidPaths.length > 0 || invalidAdditional.length > 0)
return err(
new AnalysisInputError(
OPERATION,
undefined,
invalidPaths.map((field) => ({
new AnalysisInputError(OPERATION, undefined, [
...invalidPaths.map((field) => ({
path: [field],
reason: "invalid_format",
reason: "invalid_format" as const,
message: `${field} must be an absolute filesystem path on this host.`,
})),
),
...invalidAdditional.map((additional) => ({
path: ["additional_paths"],
reason: "invalid_format" as const,
message: `additional_paths entries must be absolute filesystem paths on this host: ${additional}`,
})),
]),
);
const selectedInputs = new Set([
parsed.data.path,
...(parsed.data.additional_paths ?? []),
]);
if (selectedInputs.size !== 1 + (parsed.data.additional_paths ?? []).length)
return err(
new AnalysisInputError(OPERATION, undefined, [
{
path: ["additional_paths"],
reason: "invalid_format",
message: "additional_paths must not repeat path or each other.",
},
]),
);
const execution = await this.provider.recover(parsed.data, options);
if (!execution.ok) return execution;
@@ -8,6 +8,13 @@ export const javascriptRecoveryInputSchema = z.strictObject({
path: localPathStringSchema.describe(
"Absolute path to a UTF-8 JavaScript file; never executed",
),
additional_paths: z
.array(localPathStringSchema)
.max(16)
.optional()
.describe(
"Additional absolute UTF-8 JavaScript files concatenated into the same recovery snapshot, in order, after path; lets a code-split bundle be recovered as one input so modules can resolve imports across chunks",
),
output_directory: localPathStringSchema.describe(
"Absolute absent output directory; parent must exist",
),
@@ -0,0 +1,120 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
snapshotRecoveryInput,
snapshotRecoveryInputs,
} from "./RecoveryFiles.js";
const directories: string[] = [];
afterEach(async () => {
for (const directory of directories.splice(0)) {
await rm(directory, { recursive: true, force: true });
}
});
const newWorkspace = async (): Promise<string> => {
const directory = await mkdtemp(join(tmpdir(), "rea-recovery-files-"));
directories.push(directory);
return directory;
};
const writeInput = async (
workspace: string,
name: string,
content: string,
): Promise<string> => {
const path = join(workspace, name);
const { writeFile } = await import("node:fs/promises");
await writeFile(path, content, { mode: 0o600 });
return path;
};
describe("snapshotRecoveryInputs", () => {
it("concatenates inputs in order and records per-segment offsets", async () => {
const workspace = await newWorkspace();
const first = await writeInput(workspace, "entry.js", "console.log(1);\n");
const second = await writeInput(workspace, "chunk.js", "export {};\n");
const snapshotPath = join(workspace, "bundle.js");
const snapshot = await snapshotRecoveryInputs(
[first, second],
snapshotPath,
);
expect(snapshot.path).toBe(first);
expect(snapshot.bytes).toBe(
Buffer.byteLength("console.log(1);\n") +
Buffer.byteLength("export {};\n"),
);
expect(snapshot.segments).toEqual([
{
path: first,
sha256: expect.any(String),
bytes: Buffer.byteLength("console.log(1);\n"),
offset: 0,
},
{
path: second,
sha256: expect.any(String),
bytes: Buffer.byteLength("export {};\n"),
offset: Buffer.byteLength("console.log(1);\n"),
},
]);
expect(await readFile(snapshotPath, { encoding: "utf8" })).toBe(
"console.log(1);\nexport {};\n",
);
});
it("matches the single-input snapshot for one path", async () => {
const workspace = await newWorkspace();
const only = await writeInput(
workspace,
"only.js",
"export const a = 1;\n",
);
const singlePath = join(workspace, "single-expected.js");
const multiPath = join(workspace, "multi-actual.js");
const expected = await snapshotRecoveryInput(only, singlePath);
const actual = await snapshotRecoveryInputs([only], multiPath);
expect(actual.sha256).toBe(expected.sha256);
expect(actual.bytes).toBe(expected.bytes);
expect(actual.segments).toEqual([
{ path: only, sha256: expected.sha256, bytes: expected.bytes, offset: 0 },
]);
});
it("rejects a relative path", async () => {
const workspace = await newWorkspace();
const snapshotPath = join(workspace, "bundle.js");
await expect(
snapshotRecoveryInputs(["relative.js"], snapshotPath),
).rejects.toMatchObject({
issues: [{ path: ["path"], reason: "invalid_value" }],
});
});
it("rejects non-UTF-8 input before writing the snapshot", async () => {
const workspace = await newWorkspace();
const path = join(workspace, "binary.js");
const { writeFile } = await import("node:fs/promises");
await writeFile(path, Buffer.from([0xff, 0xfe, 0x00]));
const snapshotPath = join(workspace, "bundle.js");
await expect(
snapshotRecoveryInputs([path], snapshotPath),
).rejects.toMatchObject({
issues: [{ path: ["path"], reason: "invalid_value" }],
});
await expect(readFile(snapshotPath)).rejects.toMatchObject({
code: "ENOENT",
});
});
});
+56
View File
@@ -121,6 +121,62 @@ export const snapshotRecoveryInput = async (
};
};
/** Snapshot several caller-selected UTF-8 scripts as one concatenated engine input. */
export const snapshotRecoveryInputs = async (
paths: readonly string[],
snapshotPath: string,
signal?: AbortSignal,
): Promise<
Awaited<ReturnType<typeof snapshotRecoveryInput>> & {
segments: readonly {
path: string;
sha256: string;
bytes: number;
offset: number;
}[];
}
> => {
const segments: {
path: string;
sha256: string;
bytes: number;
offset: number;
}[] = [];
const parts: Buffer[] = [];
let offset = 0;
for (const path of paths) {
let bytes: Buffer;
try {
bytes = await readRecoveryFile(path, RECOVERY_LIMITS.inputBytes, signal);
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch (cause: unknown) {
if (cause instanceof AnalysisCancelledError) throw cause;
throw recoveryInputFailure(
"path",
`Cannot snapshot UTF-8 JavaScript input ${path}: ${recoveryFailureMessage(cause)}`,
cause,
);
}
segments.push({
path,
sha256: recoveryDigest(bytes),
bytes: bytes.length,
offset,
});
parts.push(bytes);
offset += bytes.length;
}
const combined = Buffer.concat(parts, offset);
await writeFile(snapshotPath, combined, { flag: "wx", mode: 0o600 });
return {
path: paths[0] as string,
snapshot_path: snapshotPath,
sha256: recoveryDigest(combined),
bytes: combined.length,
segments,
};
};
/** Inventory staging without following symlinks or publishing special files. */
export const inventoryRecoveryFiles = async (
root: string,
+12 -6
View File
@@ -15,7 +15,7 @@ import {
inventoryRecoveryFiles,
readRecoveryFile,
recoveryDigest,
type snapshotRecoveryInput,
type snapshotRecoveryInputs,
} from "./RecoveryFiles.js";
import {
runWakaruCommand,
@@ -38,7 +38,7 @@ type RecoveryWorkspace = {
root: string;
tree: SafeOutputTree;
input: JavaScriptRecoveryInput;
source: Awaited<ReturnType<typeof snapshotRecoveryInput>>;
source: Awaited<ReturnType<typeof snapshotRecoveryInputs>>;
engine: Awaited<ReturnType<typeof resolveWakaruCommand>>;
environment: Readonly<Record<string, string | undefined>>;
launcher: WakaruLauncher | undefined;
@@ -126,10 +126,16 @@ export const prepareWakaruExecution = async (context: RecoveryWorkspace) => {
});
if (
recoveryDigest(
await readRecoveryFile(
input.path,
RECOVERY_LIMITS.inputBytes,
options?.signal,
Buffer.concat(
await Promise.all(
source.segments.map(async (segment) =>
readRecoveryFile(
segment.path,
RECOVERY_LIMITS.inputBytes,
options?.signal,
),
),
),
),
) !== source.sha256
)
+3 -3
View File
@@ -22,7 +22,7 @@ import {
checkRecoveryDeadline,
recoveryFailureMessage,
recoveryInputFailure,
snapshotRecoveryInput,
snapshotRecoveryInputs,
} from "./RecoveryFiles.js";
import {
resolveWakaruCommand,
@@ -109,8 +109,8 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
const staging = join(root, "modules");
await mkdir(inputs, { mode: 0o700 });
await mkdir(staging, { mode: 0o700 });
const source = await snapshotRecoveryInput(
input.path,
const source = await snapshotRecoveryInputs(
[input.path, ...(input.additional_paths ?? [])],
join(inputs, "bundle.js"),
options?.signal,
);
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { originatingSegment } from "./WakaruPublication.js";
const segments = [
{ path: "/app/entry.js", sha256: "a".repeat(64), bytes: 10, offset: 0 },
{
path: "/app/shared.js",
sha256: "b".repeat(64),
bytes: 10,
offset: 10,
},
];
describe("originatingSegment", () => {
it("attributes ranges inside the second segment to that file", () => {
const origin = originatingSegment(segments, [[12, 18]]);
expect(origin).toMatchObject({
path: "/app/shared.js",
sha256: "b".repeat(64),
offset: 10,
attributable: true,
});
});
it("attributes ranges in the first segment to the primary input", () => {
const origin = originatingSegment(segments, [
[0, 10],
[4, 6],
]);
expect(origin).toMatchObject({
path: "/app/entry.js",
sha256: "a".repeat(64),
offset: 0,
attributable: true,
});
});
it("falls back to the primary snapshot identity for ranges crossing a boundary", () => {
const origin = originatingSegment(segments, [[8, 14]]);
expect(origin).toMatchObject({
path: "/app/entry.js",
sha256: "a".repeat(64),
offset: 0,
attributable: false,
});
});
it("reports the primary input as unattributable without ranges", () => {
const origin = originatingSegment(segments, []);
expect(origin).toMatchObject({
path: "/app/entry.js",
sha256: "a".repeat(64),
offset: 0,
attributable: false,
});
});
});
+60 -4
View File
@@ -33,6 +33,12 @@ type PublicationContext = {
snapshot_path: string;
sha256: string;
bytes: number;
segments: readonly {
path: string;
sha256: string;
bytes: number;
offset: number;
}[];
};
engine: Awaited<ReturnType<typeof resolveWakaruCommand>>;
admitted: Exclude<WakaruVersionAdmission, { status: "unsupported" }>;
@@ -134,6 +140,11 @@ export const publishWakaruArtifacts = async (
runtime_equivalence: "unknown" as const,
limitations: [
...RECOVERY_LIMITATIONS,
...(context.source.segments.length > 1
? [
"Multiple inputs were concatenated into one recovery snapshot in the listed order; module provenance byte offsets refer to that concatenated snapshot.",
]
: []),
...(context.admitted.status === "compatible"
? [context.admitted.limitation]
: []),
@@ -182,6 +193,7 @@ const publishModule = async (
"recover_javascript_sources",
`Missing module provenance: ${module.filename}`,
);
const origin = originatingSegment(context.source.segments, provenance.ranges);
return {
reported_filename: module.filename,
reported_status: module.status,
@@ -189,12 +201,56 @@ const publishModule = async (
source_map: sourceMap,
provenance: {
reported_input: provenance.input,
original_path: source.path,
original_sha256: source.sha256,
original_path: origin.path,
original_sha256: origin.sha256,
extraction: provenance.extraction,
byte_ranges: recoveryRanges(provenance.ranges),
context_byte_ranges: recoveryRanges(provenance.context_ranges ?? []),
byte_ranges: recoveryRanges(rangesRelativeTo(origin, provenance.ranges)),
context_byte_ranges: recoveryRanges(
rangesRelativeTo(origin, provenance.context_ranges ?? []),
),
range_semantics: "half-open-utf8-bytes",
},
};
};
/**
* Attribute a module to the input segment its first extraction range starts
* in, rebasing its ranges to that file's own offsets. A module whose ranges
* cross a segment boundary cannot be attributed to one input file; it then
* keeps the concatenated-snapshot identity and offsets (recorded as a
* limitation when multiple inputs were recovered).
*/
export const originatingSegment = (
segments: PublicationContext["source"]["segments"],
ranges: readonly [number, number][],
): { path: string; sha256: string; offset: number; attributable: boolean } => {
const primary = segments[0];
if (primary === undefined)
return { path: "", sha256: "", offset: 0, attributable: false };
const start = ranges[0]?.[0];
if (start === undefined) return { ...primary, attributable: false };
let owner = primary;
for (const segment of segments) {
if (start >= segment.offset) owner = segment;
}
const attributable = ranges.every(
([rangeStart, rangeEnd]) =>
rangeStart >= owner.offset && rangeEnd <= owner.offset + owner.bytes,
);
return attributable
? { ...owner, attributable }
: { ...primary, attributable: false };
};
const rangesRelativeTo = (
origin: {
path: string;
sha256: string;
offset: number;
attributable: boolean;
},
ranges: readonly [number, number][],
): [number, number][] =>
origin.attributable
? ranges.map(([start, end]) => [start - origin.offset, end - origin.offset])
: [...ranges];