fix(wasm): preserve ambiguous identity and partial metadata
This commit is contained in:
1 parent
b3c3b21e43
commit
af8f0badf6
10 files changed
+208
-45
No files matched your search
+11
-2
@@ -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);
|
||||
});
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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] }] },
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
Reference in new issue
Block a user