fix(wasm): preserve ambiguous identity and partial metadata

This commit is contained in:
morluto committed 2026-10-24 02:35:58 +08:00
1 parent b3c3b21e43
commit af8f0badf6
10 files changed
+208 -45

No files matched your search

+11 -2
View File
@@ -78,7 +78,9 @@ be interpreted. WABT warning output is preserved in `custom_section_warnings`;
fully interpreted, or uninterpretable. Older portable reports retain their
original strict command profile; missing metadata coverage is `unknown` and
missing warning history is `null`. The raw custom section remains in the
decoded WAT. An uninterpretable name section leaves debug module names unknown.
decoded WAT. Module names decoded before a later malformed subsection remain
available for framing the exact objdump preamble; `uninterpretable` still marks
the overall optional metadata coverage.
The tool profile preserves upstream release/source identity, resolved executable
paths, executable digests, exact banners and arguments. Executable digests are
@@ -150,7 +152,14 @@ import/export forms. Function/tag signatures resolve type indices; table/memory
limits, memory page size and global value type/mutability remain explicit.
Unadmitted types (including GC reference signatures) retain null signatures and
issues. Exact decoded names retain escapes, empty names, BOM and punctuation.
This facet is absent in historical Evidence that did not examine typed interfaces.
Internal WAT labels are display identifiers: a repeated label in a type or
function/table/memory/global/tag index space is ambiguous. Named references to
it retain null signatures or indices and an explicit issue; numeric references
and uniquely named definitions keep their own identity. Comparison treats an
unresolved signature as unknown, including in self-comparison. Inventory
completeness describes observed import/export entries, independently of type
resolution. This facet is absent in historical Evidence that did not examine
typed interfaces.
`compare_wasm_interfaces` accepts two complete `inspect_wasm_artifact` Evidence
records, or exact retained references in the current MCP session, and required
@@ -1,3 +1,4 @@
import { parseWabtWat } from "../../wasm/WabtWat.js";
import { expect, it } from "vitest";
import { setImmediate as nextTurn } from "node:timers/promises";
import { createEvidence, parseEvidence } from "../../domain/evidence.js";
@@ -314,3 +315,19 @@ it("retains the complete comparison when cancelled at the final handoff", async
summary: { changed: 1, added: 0, removed: 0, unchanged: 0, unknown: 0 },
});
});
it("does not call colliding rendered debug identifiers unchanged after a second function signature changes", async () => {
const module = (parameter: string) =>
parseWabtWat(
`(module (func $same (param i32)) (func $same (param ${parameter})) (export "first" (func $same)) (export "second" (func $same)))`,
).interface;
const result = await compare(
evidence(module("i32")),
evidence(module("i64")),
);
expect(result.changes.map(({ state }) => state)).toEqual([
"unknown",
"unknown",
]);
expect(result.summary.unchanged).toBe(0);
});
+24 -5
View File
@@ -118,6 +118,10 @@ export class WabtArtifactProvider implements WasmArtifactPort {
let result: Result<WasmArtifactExecution, AnalysisError>;
let capturedOutput: AnalysisCapturedOutput | undefined;
let versionProbe: WabtVersionProbe | undefined;
let artifactInput: { path: string; field: (string | number)[] } = {
path: input.path,
field: ["path"],
};
const commandTimeoutMs =
input.command_timeout_ms ?? DEFAULT_WASM_COMMAND_TIMEOUT_MS;
try {
@@ -287,6 +291,13 @@ export class WabtArtifactProvider implements WasmArtifactPort {
};
const candidates = [];
for (const path of new Set([input.path, ...input.candidate_paths])) {
artifactInput = {
path,
field:
path === input.path
? ["path"]
: ["candidate_paths", input.candidate_paths.indexOf(path)],
};
const bytes =
path === input.path
? selected
@@ -304,7 +315,8 @@ export class WabtArtifactProvider implements WasmArtifactPort {
});
}
const glue = [];
for (const path of input.glue_paths) {
for (const [index, path] of input.glue_paths.entries()) {
artifactInput = { path, field: ["glue_paths", index] };
const bytes = await readStableArtifact(
path,
WABT_LIMITS.inputBytes,
@@ -326,6 +338,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
),
});
}
phase = "producer";
await writeFile(snapshotPath, selected.bytes, {
flag: "wx",
mode: 0o400,
@@ -496,10 +509,16 @@ export class WabtArtifactProvider implements WasmArtifactPort {
}),
);
} catch (cause: unknown) {
const failure = wabtFailure(cause, phase, input.path, {
commandTimeoutMs,
...(versionProbe === undefined ? {} : { versionProbe }),
});
const failure = wabtFailure(
cause,
phase,
phase === "artifact" ? artifactInput.path : input.path,
{
commandTimeoutMs,
inputField: artifactInput.field,
...(versionProbe === undefined ? {} : { versionProbe }),
},
);
runtime?.retainCommandFailure(cause, failure, this.#resources);
result = err(
failure instanceof AnalysisOutputError &&
+32
View File
@@ -210,3 +210,35 @@ it("keeps oversized tool admission distinct from selected artifact size", () =>
.code,
).toBe("capability_unavailable");
});
it.each([null, { stopped: "caller" }])(
"keeps the command-owned cancellation reason as the direct typed cause: %j",
(reason) => {
const cause = new OwnedCommandFailure(
"cancelled",
"cancelled",
null,
null,
{ cancellation: { reason } },
);
const error = wabtFailure(cause, "producer", "/module.wasm");
expect(error._tag).toBe("AnalysisCancelledError");
expect(error.cause).toBe(reason);
},
);
it("attributes optional artifact admission to its selected caller field", () => {
const cause = Object.assign(new Error("missing candidate"), {
code: "ENOENT",
});
expect(
projectAnalysisError(
wabtFailure(cause, "artifact", "/missing.wasm", {
commandTimeoutMs: 30000,
inputField: ["candidate_paths", 2],
}),
),
).toMatchObject({
code: "invalid_request",
details: { issues: [{ path: ["candidate_paths", 2] }] },
});
});
+13 -2
View File
@@ -46,6 +46,7 @@ export type WabtVersionProbe = {
type WabtFailureContext = {
readonly commandTimeoutMs: number;
readonly versionProbe?: WabtVersionProbe;
readonly inputField?: readonly (string | number)[];
};
/** Configuration is captured by the server; fixing files and changing its environment are distinct repairs. */
@@ -169,7 +170,11 @@ const classifyWabtFailure = (
{ operation: "inspect_wasm_artifact", cause },
);
if (cause.reason === "cancelled")
return new AnalysisCancelledError("inspect_wasm_artifact", options);
return new AnalysisCancelledError("inspect_wasm_artifact", {
...options,
cause:
cause.cancellation === undefined ? cause : cause.cancellation.reason,
});
if (timeout !== undefined) return timeout;
if (phase !== "configuration" && cause.reason === "output-limit")
return new AnalysisResourceConstraintError(
@@ -275,7 +280,13 @@ const classifyWabtFailure = (
return new AnalysisInputError(
"inspect_wasm_artifact",
{ cause, ...cleanupOptions },
[{ path: ["path"], reason: "invalid_value", message: reason }],
[
{
path: [...(execution?.inputField ?? ["path"])],
reason: "invalid_value",
message: reason,
},
],
);
return new AnalysisOutputError("inspect_wasm_artifact", reason, {
cause,
+33
View File
@@ -114,3 +114,36 @@ it("charges shared type signatures at every retained occurrence and preserves bo
32 * 1024 * 1024,
);
});
it("keeps collided labels unknown in each index space and preserves numeric and unique identities", () => {
const report = parseWabtWat(`(module
(type $duplicate (func (param i32)))
(type $duplicate (func (param i64)))
(import "env" "first" (func $same (type 0)))
(func $same (type 1))
(func $unique (type $duplicate))
(export "collided" (func $same))
(export "numeric" (func 1))
(export "ambiguous-type" (func $unique))
(export "imported" (func 0)))`).interface;
expect(report.entries.find((e) => e.name === "collided")).toMatchObject({
index: null,
signature: null,
});
expect(report.entries.find((e) => e.name === "numeric")).toMatchObject({
index: 1,
signature: { kind: "func", params: ["i64"] },
issue: null,
});
expect(report.entries.find((e) => e.name === "ambiguous-type")).toMatchObject(
{ index: 2, signature: null },
);
expect(report.entries.find((e) => e.name === "imported")).toMatchObject({
index: 0,
signature: { params: ["i32"] },
});
expect(report.issues).toEqual([
expect.stringContaining("type index space"),
expect.stringContaining("func index space"),
]);
});
+26 -10
View File
@@ -119,16 +119,29 @@ const decodeWabtInterface = (
): WasmInterface => {
const budget = new WasmInterfaceBudget();
const types: (WasmInterfaceSignature | null)[] = [];
const typeNames = new Map<string, number>();
const typeNames = new Map<string, number | null>();
const definitionNames = new Map<string, Map<string, number | null>>();
const indexLabel = (
labels: Map<string, number | null>,
label: string,
index: number,
space: string,
) => {
const issue = `Ambiguous WABT identifiers in the ${space} index space; named references have unknown identity`;
if (labels.has(label) && !result.issues.includes(issue))
result.issues.push(issue);
labels.set(label, labels.has(label) ? null : index);
};
const definitions = new Map<
string,
{ signature: WasmInterfaceSignature | null; label: string | undefined }[]
{ signature: WasmInterfaceSignature | null }[]
>();
const typeSpaceUnknown = forms.some((f) => head(f) === "rec");
for (const form of forms) {
const p = parts(form);
if (p[0] !== "type") continue;
if (p[1]?.startsWith("$") === true) typeNames.set(p[1], types.length);
if (p[1]?.startsWith("$") === true)
indexLabel(typeNames, p[1], types.length, "type");
const body = p.find((item) => item.startsWith("("));
budget.reserve(256 + (body?.length ?? 0) * 6);
types.push(
@@ -150,8 +163,7 @@ const decodeWabtInterface = (
: reference.startsWith("$")
? typeNames.get(reference)
: Number(reference);
const type =
index === undefined || typeSpaceUnknown ? undefined : types[index];
const type = index == null || typeSpaceUnknown ? undefined : types[index];
return type?.kind === "func" ? { ...type, kind } : null;
}
if (kind === "memory" || kind === "table") {
@@ -232,14 +244,18 @@ const decodeWabtInterface = (
? (rawKind as WasmInterfaceEntry["kind"])
: "unknown";
const space = definitions.get(kind) ?? [];
const item = {
signature: signature(form),
label: p[1]?.startsWith("$") === true ? p[1] : undefined,
};
const item = { signature: signature(form) };
const label = p[1]?.startsWith("$") === true ? p[1] : undefined;
budget.reserve(256);
const index = space.length;
space.push(item);
definitions.set(kind, space);
if (label !== undefined) {
const labels =
definitionNames.get(kind) ?? new Map<string, number | null>();
indexLabel(labels, label, index, kind);
definitionNames.set(kind, labels);
}
return { index, kind, signature: item.signature };
};
for (const form of forms) {
@@ -286,7 +302,7 @@ const decodeWabtInterface = (
reference === undefined
? -1
: reference.startsWith("$")
? space.findIndex((d) => d.label === reference)
? (definitionNames.get(kind)?.get(reference) ?? -1)
: Number(reference);
const observed =
Number.isSafeInteger(index) && index >= 0 ? space[index] : undefined;
+38 -1
View File
@@ -387,7 +387,7 @@ it("matches a CLI-selected ./ module exactly against its glue reference", () =>
});
it("tests long multi-line glue literals for a .wasm suffix in linear time", () => {
const value = `${".wasm#".repeat(32_000)}\nend`;
const value = `${".wasm".repeat(32_000)}\nend`;
const started = performance.now();
expect(
associateWasmGlue(`const s = ${JSON.stringify(value)};`, "/glue.js", []),
@@ -398,3 +398,40 @@ it("tests long multi-line glue literals for a .wasm suffix in linear time", () =
.references,
).toMatchObject([{ value: "first\nmodule.wasm?v=1", line: 1 }]);
});
it("retains earlier module names for objdump framing when a later optional subsection is malformed", async () => {
const parsed = parseWabtWat(
'(module $hello (@custom "name" "\\00\\06\\05hello\\01\\02\\01"))',
);
expect(parsed).toMatchObject({
debugModuleNames: ["hello"],
debugNameMetadata: "uninterpretable",
});
const text = (await golden()).replace(
"\n\nSections:",
"\nmodule name: <hello>\n\nSections:",
);
expect(
parseWabtOutput(text, 76, ["test"], parsed.debugModuleNames).sections,
).toHaveLength(7);
});
it.each(["\n", "\r", "\u2028", "\u2029"])(
"retains glue references with a line separator in a query or fragment: %j",
(separator) => {
for (const delimiter of ["?", "#"]) {
const value = `https://example.test/module.wasm${delimiter}before${separator}after`;
expect(
associateWasmGlue(`fetch(${JSON.stringify(value)});`, "/glue.js", [
"/module.wasm",
]).references,
).toMatchObject([
{
value,
candidate_paths: ["/module.wasm"],
association: "basename-candidates",
},
]);
}
},
);
+9 -9
View File
@@ -2,7 +2,9 @@ import { parseWabtInterface } from "./WabtInterface.js";
import { watString, watStringEnd } from "./WabtString.js";
/** Read only module-name metadata from WABT's preserved name-section payload. */
const moduleNames = (bytes: Uint8Array): string[] | null => {
const moduleNames = (
bytes: Uint8Array,
): { names: string[]; complete: boolean } => {
const names: string[] = [];
let offset = 0;
const uint32 = (end: number): number => {
@@ -36,9 +38,9 @@ const moduleNames = (bytes: Uint8Array): string[] | null => {
offset = end;
}
} catch {
return null;
return { names, complete: false };
}
return names;
return { names, complete: true };
};
/** Extract exact top-level WABT WAT forms; no raw WASM decoding or import/export name unescaping. */
@@ -133,13 +135,11 @@ export const parseWabtWat = (text: string) => {
nameString.end + payloadPrefix[0].length,
);
const names = moduleNames(payload.bytes);
if (names === null) {
debugNameMetadata = "uninterpretable";
debugModuleNames.length = 0;
} else if (debugNameMetadata !== "uninterpretable") {
// Framing uses every name decoded before an optional subsection failure.
debugModuleNames.push(...names.names);
if (!names.complete) debugNameMetadata = "uninterpretable";
else if (debugNameMetadata !== "uninterpretable")
debugNameMetadata = "complete";
debugModuleNames.push(...names);
}
}
}
}
+5 -16
View File
@@ -4,22 +4,11 @@ import { parseJavaScriptSource } from "../domain/javascript/javascriptSourcePars
import { traverseJavaScriptAst } from "../domain/javascript/javascriptSemanticTraversal.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
/**
* `.` cannot cross a line terminator and `$` only matches at the end of the
* literal, so a `.wasm` suffix can only occur on its last line. Testing that
* line alone keeps long multi-line literals linear.
*/
const endsWithWasmReference = (value: string): boolean =>
/\.wasm(?:[?#].*)?$/iu.test(
value.slice(
Math.max(
value.lastIndexOf("\n"),
value.lastIndexOf("\r"),
value.lastIndexOf("
"),
value.lastIndexOf("
"),
) + 1,
),
);
/** Query/fragment contents do not change the path suffix; one bounded scan is enough. */
const endsWithWasmReference = (value: string): boolean => {
const delimiter = value.search(/[?#]/u);
return /\.wasm$/iu.test(delimiter < 0 ? value : value.slice(0, delimiter));
};
/** Associate visible literals with selected candidates without asserting data flow or URL identity. */
export const associateWasmGlue = (