feat(wasm): compare retained core-module interfaces (#2464)

Squash merge of https://github.com/morluto/rea/pull/2464.

Co-authored-by: Thang Luu <76550342+thangldw@users.noreply.github.com>
This commit is contained in:
Thang Luu 2026-10-14 00:49:04 +09:00 committed by GitHub
parent 75e7965dc3
commit 911894d758
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1757 additions and 54 deletions

View File

@ -249,3 +249,9 @@ resolution is explicit; missing references and size mismatch remain visible.
Other payloads are opaque, and this operation does not decode images or simulate
Windows resource loading. Consult the budgets and supported tree profile in
[offline binary diagnostics](https://github.com/morluto/rea/blob/main/docs/binary-diagnostics.md).
For exact core-module import/export differences, use `compare_wasm_interfaces`
with two complete inspection Evidence records (or current-session retained
references) and explicit scope. Preserve unknown types/duplicate names; static
interface equality is not runtime compatibility. CLI accepts complete inline
Evidence in `rea compare-wasm-interfaces input.json --json`.

View File

@ -0,0 +1,10 @@
# Plan
1. Check issue/timeline and complete open/closed PR searches; review #2406.
2. Extend the existing core-module WABT inspection in an isolated worktree.
3. Extend producer-owned inspection with optional typed interface facts;
preserve unknown unsupported signatures and exact WAT forms.
4. Add shared Evidence-only comparator and strict input/output contracts.
5. Register additive CLI/MCP workflow and documentation.
6. Validate original-module workflows, malformed/unknown/cancellation cases;
run checks, independent review, repair findings and rerun affected checks.

View File

@ -0,0 +1,61 @@
# Core WebAssembly interface comparison (#2356)
## Outcome and scope
Compare two exact authenticated `inspect_wasm_artifact` Evidence inputs, inline
or retained session references, under required `scope: imports | exports | both`.
No module acquisition or execution occurs during comparison. This is a derived
workflow within existing WABT inspection authority, not a new provider.
Producer-owned typed `interface` facts are additive and optional in historical
inspection Evidence. WABT's validated canonical WAT owns decoding; exact original
forms and names remain available. Comparison never reparses retained display text.
Function/tag signatures resolve type uses; memory page size defaults to 65536 bytes and explicit custom page sizes are retained; table/memory limits and global types
are compared exactly. Unsupported type/proposal forms remain unknown rather than
being compared by raw text or inferred compatible. Names are exact decoded UTF-8,
without normalization, including empty names, escapes, punctuation and BOM.
## Comparison contract
Return both Evidence IDs and original artifact path/digest/length. Group imports
by exact module/name and exports by exact name, independently of kind, so a kind
change is reported as changed. Unique admitted signatures yield added, removed,
changed or unchanged. Duplicate keys retain every original occurrence and yield
unknown; no Cartesian pairing or deduplication. Missing historical facets and
incomplete inventories prevent absence claims. Unknown signatures do not erase
independently observed entries. Ordering is deterministic by direction and names.
Both subject digest/path and inspection parameters must match normalized artifact
identity. Authenticate Evidence before projection; summary Evidence must be
resolved to its retained complete parent by the caller. Report unknown coverage
and explicit limitations. Exact static equality is not runtime compatibility,
ABI compatibility, linking success or source identity.
## Authority and overlap
Reads supplied Evidence only. MCP records derived Evidence through the existing
handler/ledger; CLI accepts portable inline Evidence from an explicit JSON input.
No new provider, implicit fetch, target mutation or foreign-code execution.
PR #2406 (head 86865035) adds component inspection, preamble routing and embedded
core-module inspection. Its component parser is not required or duplicated here;
shared contract/CLI registration changes remain additive. Core GC/recursive types
are outside admitted signature semantics and keep unknown mappings.
## Resource admission
Typed producer rows, names and declaration tokenization are admitted before
allocation against the existing 32 MiB WABT output representation capacity.
Comparison uses linear grouping and cooperative transport yields; it never
builds Cartesian duplicate pairings. CLI JSON input retains the shared file
admission gate. Token usage and peak process-family memory remain unmeasured.
## Verification
Original owned modules assembled using WABT 1.0.42 cover semantically equivalent
encodings, changes of kind/signature, names/duplicates, imported index spaces,
tables/memory/globals/tags, unknown types and historical incomplete Evidence.
Exercise authenticated identity, cancellation, retained lookup, CLI/MCP parity
and advertised schemas. Run focused existing WASM/Evidence tests, repository
checks and docs validation; independent subagent review before handoff.
Format reference: https://webassembly.github.io/spec/core/binary/modules.html

View File

@ -0,0 +1,8 @@
# Tasks
- [x] Confirm scope and overlap boundaries against #2406.
- [x] Record spec and implementation plan.
- [x] Implement typed producer interface and shared comparison.
- [x] Add CLI/MCP contracts and public workflow tests.
- [x] Run focused, static and documentation checks.
- [x] Resolve independent review findings and rerun relevant checks.

View File

@ -0,0 +1,47 @@
# Verification
Verified base: a2d8b94dd50dab8d966239dd63ab84718aa8b4b4 (fetched main).
The change was reapplied without conflicts and relevant checks rerun.
- Focused existing/new parser, provider, service, contract and CLI/MCP tests:
8 files / 56 tests passed.
- After the final partial-uniqueness correction, reran service and compiled
CLI/MCP boundary tests: 2 files / 5 tests passed.
- `npm run check`: typecheck, lint, formatting, knip, module boundaries and
architecture guards passed. Lint reports warnings; no errors.
- `npm run docs:generate`, `npm run docs:check` and
`npm run verify:test-discovery` passed; every test file was discovered exactly once.
- Real WABT 1.0.42 comparison through compiled CLI and stdio MCP passed:
equivalent padded LEB128 original encodings, function signatures, all admitted
kinds, custom memory page size, escaped/BOM names, empty interfaces, explicit
scope, duplicate imports and unadmitted exception reference signatures.
- Existing real WABT artifact verifier passed after correcting its stale inline
partial-observation assumption to resolve the MCP retained reference from the
Evidence bundle and authenticate the original record.
## Independent review
Three findings repaired at their owning boundary:
1. Memory page size omitted: now preserved, default 65536 canonicalized.
2. Shared signature expansion undercounted: charge every retained occurrence.
Reviewer independently measured the 1000-params/10000-exports case: original
262956-byte WAT produced 61824014-byte JSON before correction. It now retains
1949 observations in a partial facet, with 12046676-byte JSON and an explicit
capacity issue. Original WAT stays available; incomplete coverage prevents
absence and uniqueness claims.
3. Partial singleton uniqueness: comparison now returns unknown whenever either
selected inventory is incomplete, including matched names.
Final independent source review reported no actionable findings. Review does not
replace the executed tests or real-tool evidence.
## Scope and measurement limits
On the original implementation base f254f5a, the complete comparison verifier reported 7.98 seconds elapsed, 6.56 user and
3.13 system seconds, and 429441024 bytes maximum RSS via macOS `/usr/bin/time -l`.
These are command-accounting measurements, not aggregate concurrent process-family
memory or model tokens. Token usage and full process-family peak are unmeasured.
Real-tool verification covers macOS arm64 only; Linux/Windows remain unverified.
No module was instantiated/executed and no source was fetched. Static equality
is not runtime compatibility. Full CI is separate from this local verification record.

View File

@ -79,3 +79,46 @@ Both CLI and MCP use the same typed port and application service. MCP publishes
SDK-compatible schemas and records Evidence for `get_evidence_bundle` readback.
See [WABT provenance](https://github.com/morluto/rea/blob/main/third_party/wabt/README.md) and
[testing](testing.md#offline-wasm-artifacts) for real-tool verification.
## Compare core-module interfaces
Inspection adds producer-owned typed `interface` facts while keeping exact WAT
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.
`compare_wasm_interfaces` accepts two complete `inspect_wasm_artifact` Evidence
records, or exact retained references in the current MCP session, and required
`scope: "imports" | "exports" | "both"`. It reads no files and needs no active
WABT toolchain. CLI uses portable inline Evidence in one JSON document:
```bash
rea compare-wasm-interfaces comparison-input.json --json
```
```json
{
"left": { "kind": "retained-evidence", "evidence_id": "ev_<left-digest>" },
"right": { "kind": "retained-evidence", "evidence_id": "ev_<right-digest>" },
"scope": "both"
}
```
The references above are MCP-only; put complete Evidence objects in the CLI
input. Resolve summary delivery to its retained complete parent before comparing.
Results retain both input Evidence IDs, artifact paths/digests/lengths and every
selected occurrence. Unique names are added, removed, changed or unchanged;
duplicate names and unsupported signatures are unknown. Incomplete or historical
inventories never establish absence. Imports group by module/name, exports by
name, so a change of kind is visible. Output ordering is deterministic.
Static type equality does not establish runtime compatibility, ABI compatibility
or linking success. Comparison covers core modules; WebAssembly components and
canonical ABI relationships belong to their separate inspection workflow.
After building, run `REA_WABT_BIN_DIRECTORY=/absolute/wabt/bin node
scripts/verify-wasm-interface-comparison.mjs` for original-module comparison
through the compiled CLI and stdio MCP. It uses owned fixtures and WABT 1.0.42;
no WASM module is instantiated or executed.

View File

@ -269,6 +269,7 @@
"verify:binary:layout": "npm run build:cached && node scripts/verify-binary-layout.mjs",
"verify:go:binary": "npm run build:cached && node scripts/verify-go-binary.mjs",
"verify:wasm:package": "npm run build:cached && node scripts/verify-packaged-wasm-artifact.mjs",
"verify:wasm-interface-comparison": "npm run build:cached && node scripts/verify-wasm-interface-comparison.mjs",
"verify:wasm:artifact": "npm run build:cached && node scripts/verify-wasm-artifact.mjs",
"verify:evm:interface": "npm run build:cached && node scripts/verify-evm-interface.mjs",
"verify:solidity:metadata": "npm run build:cached && node scripts/verify-solidity-metadata.mjs",

View File

@ -213,7 +213,9 @@ export const providerCatalog = (sources) => {
},
{
identity: sources.wasmProvider.WABT_PROVIDER_IDENTITY,
contracts: sources.wasmContracts.WASM_TOOL_CONTRACTS,
contracts: sources.wasmContracts.WASM_TOOL_CONTRACTS.filter(
({ kind }) => kind === "artifact-provider",
),
},
{
identity: sources.evmProvider.EVMOLE_PROVIDER_IDENTITY,

View File

@ -310,7 +310,23 @@ try {
}),
);
assert.equal(error.category, "invalid_input");
const failed = parseEvidence(error.details.partial_observation);
const partial = error.details.partial_observation;
assert.equal(partial.kind, "retained-evidence");
const failedBundleReply = await client.callTool({
name: "get_evidence_bundle",
arguments: {},
});
assert.notEqual(
failedBundleReply.isError,
true,
mcpTextValue(failedBundleReply),
);
const failedBundle = JSON.parse(mcpTextValue(failedBundleReply)).result;
const failed = parseEvidence(
failedBundle.records.find(
({ evidence_id }) => evidence_id === partial.evidence_id,
),
);
assert.equal(failed.subject.digest.sha256, sha256(invalidBytes));
assert.equal(
failed.normalized_result.validation,

View File

@ -0,0 +1,208 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises";
import { join, resolve, isAbsolute } from "node:path";
import { tmpdir } from "node:os";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../dist/domain/evidence.js";
const execute = promisify(execFile);
const bin = process.env.REA_WABT_BIN_DIRECTORY;
if (!isAbsolute(bin ?? ""))
throw new Error("Supply absolute REA_WABT_BIN_DIRECTORY with WABT 1.0.42");
const root = await mkdtemp(join(tmpdir(), "rea-wasm-interface-real-"));
const cli = resolve("scripts/rea.mjs");
const environment = { ...process.env };
const command = async (file, args) =>
(
await execute(file, args, {
env: environment,
cwd: root,
timeout: 120000,
maxBuffer: 64 * 1024 * 1024,
})
).stdout;
const client = new Client({
name: "wasm-interface-real-verifier",
version: "1",
});
const transport = new StdioClientTransport({
command: process.execPath,
args: [cli, "mcp"],
env: environment,
stderr: "pipe",
});
const source = (parameter) =>
`(module (type (func (param ${parameter}) (result i64))) (import "env" "f" (func (type 0))) (table 1 3 funcref) (memory 1 2) (global (mut i64) (i64.const 0)) (tag (param i32)) (export "run\\22\\ef\\bb\\bf" (func 0)) (export "table" (table 0)) (export "memory" (memory 0)) (export "global" (global 0)) (export "tag" (tag 0)))`;
const assemble = async (name, wat) => {
await writeFile(join(root, `${name}.wat`), wat);
const path = join(root, `${name}.wasm`);
await command(join(bin, "wat2wasm"), [
"--enable-all",
`${name}.wat`,
"-o",
path,
]);
return path;
};
const inspect = async (path) => {
const result = await client.callTool({
name: "inspect_wasm_artifact",
arguments: { path },
});
assert.notEqual(result.isError, true, JSON.stringify(result));
return parseEvidence(result.structuredContent);
};
const compare = async (left, right, scope = "both") => {
const input = {
left: { kind: "retained-evidence", evidence_id: left.evidence_id },
right: { kind: "retained-evidence", evidence_id: right.evidence_id },
scope,
};
const reply = await client.callTool({
name: "compare_wasm_interfaces",
arguments: input,
});
assert.notEqual(reply.isError, true, JSON.stringify(reply));
const mcp = parseEvidence(reply.structuredContent);
const file = join(root, "comparison.json");
await writeFile(file, JSON.stringify({ left, right, scope }));
const local = parseEvidence(
JSON.parse(
await command(process.execPath, [
cli,
"compare-wasm-interfaces",
file,
"--json",
]),
),
);
assert.deepEqual(local, mcp);
return mcp.normalized_result;
};
try {
assert.equal(
(await command(join(bin, "wat2wasm"), ["--version"])).trim(),
"1.0.42",
);
await client.connect(transport);
const path = await assemble("baseline", source("i32"));
const original = await readFile(path);
assert.equal(original[8], 1);
assert.ok(original[9] < 128);
// Section lengths allow padded u32 LEB128 encodings. Different original
// byte identities must compare equal after validated type resolution.
const equivalentPath = join(root, "equivalent.wasm");
await writeFile(
equivalentPath,
Buffer.concat([
original.subarray(0, 9),
Buffer.from([original[9] | 128, 0]),
original.subarray(10),
]),
);
const left = await inspect(path);
const equivalent = await inspect(equivalentPath);
assert.notEqual(left.subject.digest.sha256, equivalent.subject.digest.sha256);
const same = await compare(left, equivalent);
assert.deepEqual(same.summary, {
added: 0,
removed: 0,
changed: 0,
unchanged: 6,
unknown: 0,
});
const facts = left.normalized_result.interface.entries;
assert.deepEqual(facts.find((e) => e.name === "memory").signature, {
kind: "memory",
page_size: "65536",
address_type: "i32",
minimum: "1",
maximum: "2",
shared: false,
});
assert.deepEqual(facts.find((e) => e.name === "tag").signature, {
kind: "tag",
params: ["i32"],
results: [],
});
assert.deepEqual(facts.find((e) => e.name === 'run"\ufeff').signature, {
kind: "func",
params: ["i32"],
results: ["i64"],
});
const changed = await inspect(await assemble("changed", source("i64")));
assert.deepEqual((await compare(left, changed)).summary, {
added: 0,
removed: 0,
changed: 2,
unchanged: 4,
unknown: 0,
});
const smallPage = await inspect(
await assemble(
"small-page",
'(module (memory 1 2 (pagesize 1)) (export "memory" (memory 0)))',
),
);
const defaultPage = await inspect(
await assemble(
"default-page",
'(module (memory 1 2) (export "memory" (memory 0)))',
),
);
assert.equal((await compare(defaultPage, smallPage)).summary.changed, 1);
const empty = await inspect(await assemble("empty", "(module)"));
assert.equal((await compare(left, empty)).summary.removed, 6);
assert.equal((await compare(empty, left, "exports")).summary.added, 5);
const duplicate = await inspect(
await assemble(
"duplicate",
'(module (import "e" "same" (func (param i32))) (import "e" "same" (func (param i64))))',
),
);
const duplicates = await compare(duplicate, duplicate, "imports");
assert.equal(duplicates.summary.unknown, 1);
assert.equal(duplicates.changes[0].left.length, 2);
const unsupported = await inspect(
await assemble(
"exnref",
'(module (import "e" "abstract-ref" (global exnref)))',
),
);
assert.equal((await compare(unsupported, unsupported)).summary.unknown, 1);
console.log(
JSON.stringify(
{
verified: true,
toolchain: "WABT 1.0.42",
transport: "compiled CLI + stdio MCP",
interface_fact_bytes: Buffer.byteLength(
JSON.stringify(left.normalized_result.interface),
),
comparison_result_bytes: Buffer.byteLength(JSON.stringify(same)),
original_sha256: left.subject.digest.sha256,
equivalent_sha256: equivalent.subject.digest.sha256,
cases: [
"equivalent padded LEB128",
"function signature change",
"all admitted kinds",
"custom memory page-size change",
"escaped/BOM name",
"empty interface additions/removals",
"scope",
"duplicate imports",
"unsupported exception reference",
],
runtime_execution: "not-performed",
},
null,
2,
),
);
} finally {
await client.close();
await rm(root, { recursive: true, force: true });
}

View File

@ -0,0 +1,198 @@
import { expect, it } from "vitest";
import { createEvidence } from "../../domain/evidence.js";
import { wasmInterfaceComparisonSchema } from "../../contracts/wasm/wasmComparisonContracts.js";
import { compareWasmInterfaces } from "./WasmInterfaceComparisonService.js";
import type { WasmInterface } from "../../domain/wasm/wasmInterface.js";
import { wasmArtifactFixture } from "../../../tests/fixtures/wasm/artifact.js";
const facts = (
name: string,
parameter: "i32" | "i64" = "i32",
): WasmInterface => ({
state: "complete",
issues: [],
entries: [
{
direction: "export",
module: null,
name,
kind: "func",
index: 0,
signature: { kind: "func", params: [parameter], results: [] },
wat_form: `(export "${name}" (func 0))`,
issue: null,
},
],
});
const evidence = (interfaceFacts?: WasmInterface) => {
const report = wasmArtifactFixture();
if (interfaceFacts !== undefined) report.interface = interfaceFacts;
return createEvidence(
{
path: report.artifact.path,
sha256: report.artifact.sha256,
format: "file",
},
{ id: "fixture", name: "typed fixture", version: "1" },
{
operation: "inspect_wasm_artifact",
parameters: { path: report.artifact.path },
result: report,
confidence: "observed",
},
);
};
const compare = async (
left: ReturnType<typeof evidence>,
right: ReturnType<typeof evidence>,
) => {
const result = await compareWasmInterfaces({ left, right, scope: "both" });
if (!result.ok) throw result.error;
return wasmInterfaceComparisonSchema.parse(result.value.normalized_result);
};
it("reports all five comparison states, including duplicate ambiguity and explicit selected scope", async () => {
const left = facts("changed");
const right = facts("changed", "i64");
left.entries.push(
...facts("unchanged").entries,
...facts("removed").entries,
...facts("dup").entries,
...facts("dup").entries,
);
right.entries.push(
...facts("unchanged").entries,
...facts("added").entries,
...facts("dup").entries,
);
const report = await compare(evidence(left), evidence(right));
expect(report.summary).toEqual({
changed: 1,
unchanged: 1,
removed: 1,
added: 1,
unknown: 1,
});
expect(report.changes.find((c) => c.name === "dup")).toMatchObject({
state: "unknown",
left: [expect.anything(), expect.anything()],
right: [expect.anything()],
});
const selected = await compareWasmInterfaces({
left: evidence(left),
right: evidence(right),
scope: "imports",
});
if (!selected.ok) throw selected.error;
expect(selected.value.normalized_result).toMatchObject({
changes: [],
coverage: "complete-within-inputs",
});
});
it("withholds absence claims for historical or partial inventories and unsupported signatures", async () => {
const known = facts("f");
const absent = await compare(evidence(), evidence(known));
expect(absent).toMatchObject({
coverage: "partial",
changes: [{ state: "unknown" }],
});
const partial: WasmInterface = {
state: "partial",
issues: ["unread import table"],
entries: [],
};
expect(await compare(evidence(partial), evidence(known))).toMatchObject({
changes: [{ state: "unknown" }],
});
const unsupported = facts("f");
unsupported.entries[0]!.signature = null;
expect(await compare(evidence(known), evidence(unsupported))).toMatchObject({
changes: [{ state: "unknown" }],
});
const partialMatch = facts("f");
partialMatch.state = "partial";
partialMatch.issues = ["Later imports were not examined"];
expect(await compare(evidence(partialMatch), evidence(known))).toMatchObject({
changes: [{ state: "unknown" }],
});
expect(await compare(evidence(), evidence())).toMatchObject({
coverage: "unknown",
changes: [],
});
});
it("authenticates semantic Evidence and binds display subject and selection identity", async () => {
const left = JSON.parse(JSON.stringify(evidence(facts("f"))));
left.normalized_result.interface.entries[0].name = "tampered";
expect(
await compareWasmInterfaces({
left,
right: evidence(facts("f")),
scope: "both",
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
const rebound = JSON.parse(JSON.stringify(evidence(facts("f"))));
rebound.subject.local_path = "/other.wasm";
expect(
await compareWasmInterfaces({
left: rebound,
right: evidence(facts("f")),
scope: "both",
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
});
it("resolves exact retained references and permits cancellation between comparison batches", async () => {
const left = evidence(facts("f"));
const right = evidence(facts("f", "i64"));
const references = {
left: { kind: "retained-evidence", evidence_id: left.evidence_id },
right: { kind: "retained-evidence", evidence_id: right.evidence_id },
scope: "both",
};
expect(await compareWasmInterfaces(references)).toMatchObject({
ok: false,
error: { _tag: "EvidenceIntegrityError" },
});
const result = await compareWasmInterfaces(references, {}, (id) =>
id === left.evidence_id ? left : right,
);
if (!result.ok) throw result.error;
expect(result.value.evidence_links).toEqual([
left.evidence_id,
right.evidence_id,
]);
const large = facts("f");
large.entries.push(
...Array.from({ length: 2000 }, (_, i) => facts(`f${i}`).entries[0]!),
);
const controller = new AbortController();
const pending = compareWasmInterfaces(
{ left: evidence(large), right: evidence(large), scope: "both" },
{ signal: controller.signal },
);
setImmediate(() => controller.abort());
expect(await pending).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
});
it.each([false, true])(
"keeps a one-sided unsupported interface unknown with reverse=%s",
async (reverse) => {
const empty: WasmInterface = { state: "complete", issues: [], entries: [] };
const unsupported = facts("exception");
unsupported.entries[0]!.signature = null;
unsupported.entries[0]!.issue = "Unsupported exnref global type";
const sides = reverse ? [unsupported, empty] : [empty, unsupported];
expect(await compare(evidence(sides[0]), evidence(sides[1]))).toMatchObject(
{
coverage: "partial",
summary: { added: 0, removed: 0, unknown: 1 },
changes: [{ state: "unknown" }],
},
);
},
);

View File

@ -0,0 +1,250 @@
import {
WASM_INTERFACE_REPRESENTATION_BYTES,
wasmInterfaceEntryRepresentationBytes,
} from "../../domain/wasm/wasmInterface.js";
import { setImmediate } from "node:timers/promises";
import {
compareWasmInterfacesInputSchema,
wasmInterfaceComparisonSchema,
} from "../../contracts/wasm/wasmComparisonContracts.js";
import {
createEvidence,
parseEvidence,
type Evidence,
} from "../../domain/evidence.js";
import {
wasmArtifactSchema,
type WasmArtifact,
} from "../../domain/wasm/wasmArtifact.js";
import type { WasmInterfaceEntry } from "../../domain/wasm/wasmInterface.js";
import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisResourceConstraintError,
} from "../../domain/analysisErrorCore.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import { err, ok, type Result } from "../../domain/result.js";
import { ARTIFACT_COMPARISON_PROVIDER } from "../InvestigationProviders.js";
import {
resolvePairedEvidenceRequest,
type EvidenceLookup,
} from "../EvidenceInputResolver.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { z } from "zod";
const OPERATION = "compare_wasm_interfaces";
const parseInspection = (
raw: unknown,
): { evidence: Evidence; result: WasmArtifact } => {
const evidence = parseEvidence(raw);
if (evidence.operation !== "inspect_wasm_artifact")
throw new TypeError("Expected complete inspect_wasm_artifact Evidence");
const result = wasmArtifactSchema.parse(evidence.normalized_result);
if (
evidence.subject?.digest.sha256 !== result.artifact.sha256 ||
evidence.subject.local_path !== result.artifact.path ||
evidence.parameters.path !== result.artifact.path
)
throw new TypeError(
"WASM Evidence subject/selection does not match normalized artifact identity",
);
return { evidence, result };
};
/** Compare only producer-owned typed facts; no raw WAT or original file is reread. */
export const compareWasmInterfaces = async (
rawInput: unknown,
options?: ExecutionOptions,
lookup?: EvidenceLookup,
): Promise<Result<Evidence, AnalysisError>> => {
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(OPERATION));
const parsed = compareWasmInterfacesInputSchema.safeParse(rawInput);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(OPERATION, parsed.error.issues, rawInput),
);
const resolved = resolvePairedEvidenceRequest(parsed.data, lookup);
if (!resolved.ok) return resolved;
try {
await setImmediate();
options?.signal?.throwIfAborted();
const left = parseInspection(resolved.value.left);
const right = parseInspection(resolved.value.right);
const scope = parsed.data.scope;
const selected = (entry: WasmInterfaceEntry): boolean =>
scope === "both" ||
entry.direction === (scope === "imports" ? "import" : "export");
const groups = new Map<
string,
{
left: WasmInterfaceEntry[];
right: WasmInterfaceEntry[];
entry: WasmInterfaceEntry;
}
>();
let work = 0;
for (const [side, inspection] of [
["left", left],
["right", right],
] as const) {
for (const entry of inspection.result.interface?.entries ?? []) {
if (++work % 256 === 0) {
await setImmediate();
options?.signal?.throwIfAborted();
}
if (!selected(entry)) continue;
const key = JSON.stringify([entry.direction, entry.module, entry.name]);
let group = groups.get(key);
if (group === undefined) {
group = { left: [], right: [], entry };
groups.set(key, group);
}
group[side].push(entry);
}
}
const knownInventory = (inspection: typeof left): boolean =>
inspection.result.interface?.state === "complete";
const changes: z.infer<typeof wasmInterfaceComparisonSchema>["changes"] =
[];
const summary = {
added: 0,
removed: 0,
changed: 0,
unchanged: 0,
unknown: 0,
};
let remaining = WASM_INTERFACE_REPRESENTATION_BYTES;
for (const group of groups.values()) {
remaining -=
512 +
group.left.reduce(
(sum, entry) => sum + wasmInterfaceEntryRepresentationBytes(entry),
0,
) +
group.right.reduce(
(sum, entry) => sum + wasmInterfaceEntryRepresentationBytes(entry),
0,
);
if (remaining < 0)
throw new AnalysisResourceConstraintError(
OPERATION,
"capacity",
"WASM comparison exceeds typed interface representation capacity",
{ maximum_bytes: WASM_INTERFACE_REPRESENTATION_BYTES },
);
if (++work % 256 === 0) {
await setImmediate();
options?.signal?.throwIfAborted();
}
let state: keyof typeof summary;
let reason: string | null = null;
const l = group.left[0];
const r = group.right[0];
if (group.left.length > 1 || group.right.length > 1) {
state = "unknown";
reason =
"Duplicate interface names are ambiguous; all occurrences retained";
} else if (!knownInventory(left) || !knownInventory(right)) {
state = "unknown";
reason =
"Incomplete interface inventories cannot establish per-key uniqueness or absence";
} else if (
[l, r].some(
(entry) =>
entry !== undefined &&
(entry.signature === null ||
entry.issue !== null ||
entry.kind === "unknown"),
)
) {
state = "unknown";
reason =
"Signature/type semantics were not admitted for every present entry";
} else if (l === undefined) state = "added";
else if (r === undefined) state = "removed";
else if (l.kind !== r.kind) state = "changed";
else
state =
JSON.stringify(l.signature) === JSON.stringify(r.signature)
? "unchanged"
: "changed";
summary[state]++;
changes.push({
direction: group.entry.direction,
module: group.entry.module,
name: group.entry.name,
state,
left: group.left,
right: group.right,
reason,
});
}
const compareText = (a: string, b: string): number =>
a < b ? -1 : a > b ? 1 : 0;
changes.sort(
(a, b) =>
compareText(a.direction, b.direction) ||
compareText(a.module ?? "", b.module ?? "") ||
compareText(a.name, b.name),
);
const coverage =
left.result.interface === undefined &&
right.result.interface === undefined
? "unknown"
: !knownInventory(left) || !knownInventory(right) || summary.unknown > 0
? "partial"
: "complete-within-inputs";
const limitations = [
"Exact declared interface equality is not runtime/ABI compatibility, successful linking or original-source identity.",
"Typed facts come from the selected validated WABT decoding; unsupported signature semantics and duplicate names remain unknown.",
];
const report = wasmInterfaceComparisonSchema.parse({
left: {
evidence_id: left.evidence.evidence_id,
artifact: left.result.artifact,
},
right: {
evidence_id: right.evidence.evidence_id,
artifact: right.result.artifact,
},
scope,
coverage,
changes,
summary,
limitations,
});
await setImmediate();
options?.signal?.throwIfAborted();
return ok(
createEvidence(undefined, ARTIFACT_COMPARISON_PROVIDER, {
operation: OPERATION,
predicateType: "rea.wasm-interface-comparison",
parameters: {
left_evidence_id: left.evidence.evidence_id,
right_evidence_id: right.evidence.evidence_id,
scope,
},
result: report,
confidence: "derived",
authority: "analyst-inference",
limitations,
evidenceLinks: [left.evidence.evidence_id, right.evidence.evidence_id],
}),
);
} catch (cause: unknown) {
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(OPERATION));
if (cause instanceof AnalysisError) return err(cause);
return err(
new AnalysisInputError(OPERATION, { cause }, [
{
path: [],
reason: "invalid_value",
message: cause instanceof Error ? cause.message : String(cause),
},
]),
);
}
};

View File

@ -1,3 +1,6 @@
import { compareWasmInterfaces } from "../application/wasm/WasmInterfaceComparisonService.js";
import { parseCliJsonInput } from "../cliJsonInput.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { z } from "incur";
import { resolve } from "node:path";
import { wasmArtifactModule } from "../composition/wasm.js";
@ -13,6 +16,29 @@ export const registerWasmCommands = (
logger: Logger,
environment: Readonly<NodeJS.ProcessEnv>,
): void => {
cli.command(CLI_COMMANDS.compareWasmInterfaces, {
description:
"Compare core WASM interfaces from two exact inspection Evidence records",
args: z.object({
input: z
.string()
.describe(
"Inline JSON or input file containing left/right complete Evidence and required scope",
),
}),
run: ({ args }) =>
withCommandCancellation(async (signal) =>
logCliCommand(logger, CLI_COMMANDS.compareWasmInterfaces, async () => {
const input = await parseCliJsonInput(
args.input,
"compare_wasm_interfaces",
);
if (!input.ok) return input.error;
const result = await compareWasmInterfaces(input.value, { signal });
return result.ok ? result.value : projectAnalysisError(result.error);
}),
),
});
const service = wasmArtifactModule.create(environment);
cli.command(wasmArtifactModule.cliCommand, {
description: "Inspect an explicit local WASM artifact with WABT",

View File

@ -4,6 +4,7 @@ export const CLI_COMMANDS = Object.freeze({
inspectPacketCapture: "inspect-packet-capture",
inspectSignalDecoders: "inspect-signal-decoders",
decodeSignalRecording: "decode-signal-recording",
compareWasmInterfaces: "compare-wasm-interfaces",
inspectWasmArtifact: "inspect-wasm-artifact",
inspectSqliteDatabase: "inspect-sqlite-database",
inspectEvmInterface: "inspect-evm-interface",

View File

@ -27,6 +27,8 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly<Record<string, string>> = {
inspect_macho: "runs host Mach-O inspection tools, not the target",
inspect_evm_interface:
"decodes bytecode in REA's bundled worker without executing it",
compare_wasm_interfaces:
"compares retained inspection Evidence without executing modules",
inspect_wasm_artifact:
"WABT validates and decompiles the module without executing it",
identify_flutter_build:

View File

@ -623,6 +623,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
}),
compare_process_captures: sessionEvidence,
compare_artifacts: sessionEvidence,
compare_wasm_interfaces: sessionEvidence,
compare_functions: sessionEvidence,
compare_bundles: effects({ mutatesSession: true }),
find_changed_behavior: sessionEvidence,

View File

@ -0,0 +1,63 @@
import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js";
import { z } from "zod";
import { evidenceInputSchema } from "../evidenceInputContracts.js";
import { wasmInterfaceSchema } from "../../domain/wasm/wasmInterface.js";
import { toolContractMetadata } from "../toolEffects.js";
import type { ToolContract } from "../toolContractTypes.js";
export const compareWasmInterfacesInputSchema = z.strictObject({
left: evidenceInputSchema.describe(
"Exact complete inspect_wasm_artifact Evidence or retained session reference",
),
right: evidenceInputSchema.describe(
"Exact complete inspect_wasm_artifact Evidence or retained session reference",
),
scope: z
.enum(["imports", "exports", "both"])
.describe(
"Explicit interface selection; static equality does not establish runtime compatibility",
),
});
const identity = z.object({
evidence_id: z.string(),
artifact: z.object({
path: z.string(),
sha256: z.string(),
bytes: z.number().int().nonnegative(),
}),
});
export const wasmInterfaceComparisonSchema = z.object({
left: identity,
right: identity,
scope: z.enum(["imports", "exports", "both"]),
coverage: z.enum(["complete-within-inputs", "partial", "unknown"]),
changes: z.array(
z.object({
direction: z.enum(["import", "export"]),
module: z.string().nullable(),
name: z.string(),
state: z.enum(["added", "removed", "changed", "unchanged", "unknown"]),
left: wasmInterfaceSchema.shape.entries,
right: wasmInterfaceSchema.shape.entries,
reason: z.string().nullable(),
}),
),
summary: z.object({
added: z.number(),
removed: z.number(),
changed: z.number(),
unchanged: z.number(),
unknown: z.number(),
}),
limitations: z.array(z.string()),
});
export const COMPARE_WASM_INTERFACES_CONTRACT = {
name: "compare_wasm_interfaces",
...toolContractMetadata("compare_wasm_interfaces"),
kind: "application",
description:
"Compare typed core WASM import/export names, kinds and exact signatures from two authenticated inspect_wasm_artifact Evidence records. Select imports, exports or both explicitly. Retained references resolve only in the current session; CLI accepts portable inline Evidence. Duplicate names, unsupported types and missing historical interface facets remain unknown. Preserve original module digests and every occurrence; no source acquisition, module execution or runtime-compatibility inference.",
inputSchema: compareWasmInterfacesInputSchema,
outputSchema: evidenceResultOf(wasmInterfaceComparisonSchema),
examples: [],
} as const satisfies ToolContract;

View File

@ -1,3 +1,4 @@
import { COMPARE_WASM_INTERFACES_CONTRACT } from "./wasmComparisonContracts.js";
import {
inspectWasmArtifactInputSchema,
wasmArtifactSchema,
@ -40,4 +41,7 @@ export const INSPECT_WASM_ARTIFACT_CONTRACT = {
} as const satisfies ToolContract;
/** Keep the public catalog in its existing order, using its named contract. */
export const WASM_TOOL_CONTRACTS = [INSPECT_WASM_ARTIFACT_CONTRACT] as const;
export const WASM_TOOL_CONTRACTS = [
INSPECT_WASM_ARTIFACT_CONTRACT,
COMPARE_WASM_INTERFACES_CONTRACT,
] as const;

View File

@ -1,3 +1,4 @@
import { wasmInterfaceSchema } from "./wasmInterface.js";
import { z } from "zod";
import { createHash } from "node:crypto";
import type { EvidenceSubjectTarget } from "../evidence.js";
@ -32,6 +33,7 @@ export const wasmArtifactSchema = z
description: z.string(),
}),
),
interface: wasmInterfaceSchema.exactOptional(),
imports: z.array(z.string()),
exports: z.array(z.string()),
headers: z.string(),

View File

@ -0,0 +1,109 @@
import { z } from "zod";
const kind = z.enum(["func", "table", "memory", "global", "tag", "unknown"]);
const valueType = z.enum([
"i32",
"i64",
"f32",
"f64",
"v128",
"funcref",
"externref",
]);
const limit = z.string().regex(/^(?:0|[1-9][0-9]*)$/u);
const signature = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("func"),
params: z.array(valueType),
results: z.array(valueType),
}),
z.object({
kind: z.literal("tag"),
params: z.array(valueType),
results: z.array(valueType),
}),
z.object({
kind: z.literal("table"),
element: z.enum(["funcref", "externref"]),
address_type: z.enum(["i32", "i64"]),
minimum: limit,
maximum: limit.nullable(),
shared: z.boolean(),
}),
z.object({
kind: z.literal("memory"),
page_size: limit,
address_type: z.enum(["i32", "i64"]),
minimum: limit,
maximum: limit.nullable(),
shared: z.boolean(),
}),
z.object({
kind: z.literal("global"),
value_type: valueType,
mutable: z.boolean(),
}),
]);
/** Typed observations decoded once at the WABT producer boundary. */
export const wasmInterfaceSchema = z.object({
state: z.enum(["complete", "partial"]),
entries: z.array(
z
.object({
direction: z.enum(["import", "export"]),
module: z.string().nullable(),
name: z.string(),
kind,
index: z.number().int().nonnegative().max(0xffff_ffff).nullable(),
signature: signature.nullable(),
wat_form: z.string(),
issue: z.string().nullable(),
})
.superRefine((entry, context) => {
if (
(entry.direction === "export" && entry.module !== null) ||
(entry.direction === "import" && entry.module === null)
)
context.addIssue({
code: "custom",
message: "Interface direction does not match module-name presence",
});
if (entry.signature !== null && entry.signature.kind !== entry.kind)
context.addIssue({
code: "custom",
message: "Interface kind does not match its typed signature",
});
}),
),
issues: z.array(z.string()),
});
export type WasmInterface = z.infer<typeof wasmInterfaceSchema>;
export type WasmInterfaceEntry = WasmInterface["entries"][number];
export type WasmInterfaceSignature = NonNullable<
WasmInterfaceEntry["signature"]
>;
export type WasmInterfaceValueType = z.infer<typeof valueType>;
/** Bound eager typed output and repeated signature expansion independently of WAT bytes. */
export const WASM_INTERFACE_REPRESENTATION_BYTES = 32 * 1024 * 1024;
/** Conservative JSON/retention charge; shared arrays still expand at every occurrence. */
export const wasmInterfaceEntryRepresentationBytes = (
entry: WasmInterfaceEntry,
): number => {
let bytes =
1024 +
[entry.module, entry.name, entry.wat_form, entry.issue].reduce<number>(
(sum, value) => sum + (value?.length ?? 0) * 6,
0,
);
const signature = entry.signature;
if (signature?.kind === "func" || signature?.kind === "tag")
bytes += (signature.params.length + signature.results.length) * 16;
else if (signature !== null)
for (const value of Object.values(signature))
if (typeof value === "string") bytes += value.length * 6;
return bytes;
};

View File

@ -1,3 +1,7 @@
import { compareWasmInterfaces } from "../application/wasm/WasmInterfaceComparisonService.js";
import { COMPARE_WASM_INTERFACES_CONTRACT } from "../contracts/wasm/wasmComparisonContracts.js";
import { resolvePairedEvidenceRequest } from "../application/EvidenceInputResolver.js";
import { recordSessionEvidenceSources } from "./sessionEvidence.js";
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import type { WasmArtifactService } from "../application/wasm/WasmArtifactService.js";
@ -21,6 +25,38 @@ export const registerWasmTools = (
withAdmittedAnalysis?: WithAdmittedAnalysis,
evidenceById?: EvidenceLookup,
): void => {
const comparison = COMPARE_WASM_INTERFACES_CONTRACT;
const compareHandler = createEvidenceToolHandler(
server,
async (rawInput, options) => {
const input = comparison.inputSchema.safeParse(rawInput);
if (!input.success)
return err(
analysisInputErrorFromIssues(
comparison.name,
input.error.issues,
rawInput,
),
);
const sources = resolvePairedEvidenceRequest(input.data, evidenceById);
if (!sources.ok) return sources;
const result = await compareWasmInterfaces(sources.value, options);
if (!result.ok) return result;
const recorded = recordSessionEvidenceSources(recordEvidence, [
sources.value.left,
sources.value.right,
]);
return recorded.ok ? result : recorded;
},
logger,
recordEvidence,
withAdmittedAnalysis,
);
server.registerTool(
comparison.name,
toolRegistrationOptions(comparison),
compareHandler(comparison),
);
const contract = wasmArtifactModule.contract;
const handler = createEvidenceToolHandler(
server,

View File

@ -362,8 +362,13 @@ export class WabtArtifactProvider implements WasmArtifactPort {
OPERATION,
"Unexpected WABT decoding output.",
);
const { imports, exports, customSectionNames, debugModuleNames } =
parseWabtWat(wat.stdout.text);
const {
imports,
exports,
customSectionNames,
debugModuleNames,
interface: interfaceFacts,
} = parseWabtWat(wat.stdout.text);
capturedOutput = {
stdout: dump.stdout.text,
stderr: dump.stderr.text,
@ -388,6 +393,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
sections,
imports,
exports,
interface: interfaceFacts,
headers,
details,
wat: {

View File

@ -0,0 +1,116 @@
import { expect, it } from "vitest";
import { parseWabtWat } from "./WabtWat.js";
it("resolves imported index spaces and decoded escaped names without depending on body/type numbering", () => {
const report = parseWabtWat(`(module
(type (;0;) (func (param i32) (result i64)))
(import "a\\22b" "\\ef\\bb\\bf\\00" (func (;0;) (type 0)))
(func (;1;) (type 0) (param i32) (result i64) local.get 0 i64.extend_i32_s)
(export "" (func 1)))`).interface;
expect(report.entries).toMatchObject([
{
direction: "import",
module: 'a"b',
name: "\ufeff\0",
index: 0,
signature: { kind: "func", params: ["i32"], results: ["i64"] },
},
{
direction: "export",
name: "",
index: 1,
signature: { kind: "func", params: ["i32"], results: ["i64"] },
},
]);
});
it("retains exact memory/table/global/tag declarations and keeps unsupported references unknown", () => {
const report = parseWabtWat(`(module
(type (func (param i32)))
(type (struct (field i32)))
(import "env" "table" (table 1 3 funcref))
(memory i64 2 4 shared)
(global (mut i64) (i64.const 1))
(tag (type 0))
(func (type 0))
(func (param (ref null 1)))
(export "memory" (memory 0)) (export "global" (global 0))
(export "tag" (tag 0)) (export "gc" (func 1)))`).interface;
expect(report.entries.map((e) => e.signature)).toEqual([
{
kind: "table",
element: "funcref",
address_type: "i32",
minimum: "1",
maximum: "3",
shared: false,
},
{
kind: "memory",
page_size: "65536",
address_type: "i64",
minimum: "2",
maximum: "4",
shared: true,
},
{ kind: "global", value_type: "i64", mutable: true },
{ kind: "tag", params: ["i32"], results: [] },
null,
]);
expect(report.entries[4]?.issue).toBeTruthy();
});
it("does not bind recursive-type index spaces by guessing their cardinality", () => {
const report = parseWabtWat(
`(module (rec (type (struct)) (type (func))) (func (type 1)) (export "f" (func 0)))`,
).interface;
expect(report.entries).toMatchObject([
{ name: "f", signature: null, issue: expect.any(String) },
]);
});
it("preserves custom memory page sizes and treats unadmitted nested declarations as unknown", () => {
const custom = parseWabtWat(
'(module (memory 1 2 (pagesize 1)) (export "m" (memory 0)))',
).interface;
expect(custom.entries[0]?.signature).toMatchObject({
kind: "memory",
page_size: "1",
});
const unknown = parseWabtWat(
'(module (memory 1 (future-proposal 0)) (export "m" (memory 0)))',
).interface;
expect(unknown.entries[0]?.signature).toBeNull();
});
it("refuses typed name expansion within the source WABT byte cap before retaining interface output", () => {
const name = "x".repeat(6 * 1024 * 1024);
expect(
parseWabtWat(`(module (import "${name}" "f" (func)))`).interface,
).toMatchObject({
state: "partial",
entries: [],
issues: [expect.stringMatching(/representation budget/u)],
});
});
it("charges shared type signatures at every retained occurrence and preserves bounded partial facts", () => {
const params = Array(1000).fill("i32").join(" ");
const exports = Array.from(
{ length: 10000 },
(_, i) => `(export "f${i}" (func 0))`,
).join(" ");
const report = parseWabtWat(
`(module (type (func (param ${params}))) (import "e" "f" (func (type 0))) ${exports})`,
).interface;
expect(report.state).toBe("partial");
expect(report.entries.length).toBeGreaterThan(1);
expect(report.entries.length).toBeLessThan(10001);
expect(report.entries[0]?.signature).toMatchObject({
kind: "func",
params: expect.any(Array),
});
expect(Buffer.byteLength(JSON.stringify(report))).toBeLessThan(
32 * 1024 * 1024,
);
});

325
src/wasm/WabtInterface.ts Normal file
View File

@ -0,0 +1,325 @@
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
import { wasmInterfaceEntryRepresentationBytes } from "../domain/wasm/wasmInterface.js";
import { WasmInterfaceBudget } from "./WasmInterfaceBudget.js";
import type {
WasmInterface,
WasmInterfaceEntry,
WasmInterfaceSignature,
WasmInterfaceValueType,
} from "../domain/wasm/wasmInterface.js";
import { watString } from "./WabtString.js";
/** Split direct WAT children only; function instruction bodies are never materialized. */
const parts = (form: string): string[] => {
const budget = new WasmInterfaceBudget();
const items: string[] = [];
let i = 1;
while (i < form.length - 1) {
if (/\s/u.test(form[i] ?? "")) {
i++;
continue;
}
if (form.startsWith("(;", i)) {
let depth = 1;
i += 2;
while (i < form.length && depth > 0) {
if (form.startsWith("(;", i)) {
depth++;
i += 2;
} else if (form.startsWith(";)", i)) {
depth--;
i += 2;
} else i++;
}
continue;
}
if (form.startsWith(";;", i)) {
const end = form.indexOf("\n", i);
i = end < 0 ? form.length : end + 1;
continue;
}
const start = i;
if (form[i] === '"') {
i = watString(form, i + 1).end;
} else if (form[i] === "(") {
let depth = 1;
i++;
while (i < form.length && depth > 0) {
if (form[i] === '"') i = watString(form, i + 1).end;
else {
if (form[i] === "(") depth++;
else if (form[i] === ")") depth--;
i++;
}
}
} else {
while (i < form.length && !/[\s()]/u.test(form[i] ?? "")) i++;
const atom = form.slice(start, i);
if (items[0] === "func" && items.length > 1 && !atom.startsWith("$"))
break;
}
if (i <= start) throw new Error("Malformed WABT form");
budget.reserve(64 + (i - start) * 2);
items.push(form.slice(start, i));
}
return items;
};
const head = (form: string): string | undefined => parts(form)[0];
const admittedValues = new Set([
"i32",
"i64",
"f32",
"f64",
"v128",
"funcref",
"externref",
]);
const isValueType = (type: string): type is WasmInterfaceValueType =>
admittedValues.has(type);
const values = (
form: string,
category: string,
): WasmInterfaceValueType[] | null => {
const result: WasmInterfaceValueType[] = [];
for (const item of parts(form)) {
if (!item.startsWith("(")) continue;
const group = parts(item);
if (group[0] !== category) continue;
for (const type of group.slice(1).filter((t) => !t.startsWith("$"))) {
if (!isValueType(type)) return null;
result.push(type);
}
}
return result;
};
const functionType = (
form: string,
kind: "func" | "tag",
): WasmInterfaceSignature | null => {
const params = values(form, "param");
const results = values(form, "result");
return params === null || results === null ? null : { kind, params, results };
};
const name = (text: string | undefined): string => {
if (text?.startsWith('"') !== true)
throw new Error("WABT interface name is missing");
return new TextDecoder("utf8", { fatal: true, ignoreBOM: true }).decode(
watString(text, 1).bytes,
);
};
const integer = (text: string): string | null => {
if (!/^(?:\d+|0x[\da-f]+)$/iu.test(text)) return null;
return BigInt(text).toString();
};
/** Resolve index spaces at the producer, not from retained raw WAT in consumers. */
const decodeWabtInterface = (
forms: readonly string[],
result: WasmInterface,
): WasmInterface => {
const budget = new WasmInterfaceBudget();
const types: (WasmInterfaceSignature | null)[] = [];
const typeNames = new Map<string, number>();
const definitions = new Map<
string,
{ signature: WasmInterfaceSignature | null; label: string | undefined }[]
>();
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);
const body = p.find((item) => item.startsWith("("));
budget.reserve(256 + (body?.length ?? 0) * 6);
types.push(
body !== undefined && head(body) === "func" && !typeSpaceUnknown
? functionType(body, "func")
: null,
);
}
const signature = (form: string): WasmInterfaceSignature | null => {
const p = parts(form);
const kind = p[0];
if (kind === "func" || kind === "tag") {
const use = p.find((t) => t.startsWith("(type "));
if (use === undefined) return functionType(form, kind);
const reference = parts(use)[1];
const index =
reference === undefined
? undefined
: reference.startsWith("$")
? typeNames.get(reference)
: Number(reference);
const type =
index === undefined || typeSpaceUnknown ? undefined : types[index];
return type?.kind === "func" ? { ...type, kind } : null;
}
if (kind === "memory" || kind === "table") {
const groups = p.slice(1).filter((t) => t.startsWith("("));
let pageSize = "65536";
for (const group of groups) {
const declaration = parts(group);
if (
kind !== "memory" ||
declaration[0] !== "pagesize" ||
declaration.length !== 2
)
return null;
const parsedPageSize = integer(declaration[1] ?? "");
if (parsedPageSize === null) return null;
pageSize = parsedPageSize;
}
const tokens = p
.slice(1)
.filter((t) => !t.startsWith("$") && !t.startsWith("("));
const address_type = tokens[0] === "i64" ? "i64" : "i32";
if (tokens[0] === "i64" || tokens[0] === "i32") tokens.shift();
const minimum = integer(tokens.shift() ?? "");
const possibleMaximum =
tokens[0] === undefined ? null : integer(tokens[0]);
if (possibleMaximum !== null) tokens.shift();
const shared = tokens[0] === "shared";
if (shared) tokens.shift();
if (minimum === null) return null;
if (kind === "memory")
return tokens.length === 0
? {
kind,
page_size: pageSize,
address_type,
minimum,
maximum: possibleMaximum,
shared,
}
: null;
const element = tokens.shift();
return tokens.length === 0 &&
element !== undefined &&
(element === "funcref" || element === "externref")
? {
kind,
address_type,
minimum,
maximum: possibleMaximum,
shared,
element,
}
: null;
}
if (kind === "global") {
const declaration = p[1]?.startsWith("$") === true ? p[2] : p[1];
const mutable = declaration?.startsWith("(mut ") === true;
const value_type =
mutable && declaration !== undefined
? parts(declaration)[1]
: declaration;
return value_type !== undefined && isValueType(value_type)
? { kind, value_type, mutable }
: null;
}
return null;
};
const addDefinition = (
form: string,
): {
index: number;
kind: WasmInterfaceEntry["kind"];
signature: WasmInterfaceSignature | null;
} => {
const p = parts(form);
const rawKind = p[0] ?? "unknown";
const kind = ["func", "table", "memory", "global", "tag"].includes(rawKind)
? (rawKind as WasmInterfaceEntry["kind"])
: "unknown";
const space = definitions.get(kind) ?? [];
const item = {
signature: signature(form),
label: p[1]?.startsWith("$") === true ? p[1] : undefined,
};
budget.reserve(256);
const index = space.length;
space.push(item);
definitions.set(kind, space);
return { index, kind, signature: item.signature };
};
for (const form of forms) {
const p = parts(form);
if (p[0] !== "import") continue;
const body = p[3];
if (body === undefined) throw new Error("Incomplete WABT import");
const observed = addDefinition(body);
const entry: WasmInterfaceEntry = {
direction: "import",
module: name(p[1]),
name: name(p[2]),
...observed,
wat_form: form,
issue:
observed.signature === null
? "Import type is outside admitted core signature semantics"
: null,
};
budget.reserve(wasmInterfaceEntryRepresentationBytes(entry));
result.entries.push(entry);
}
for (const form of forms) {
const kind = head(form);
if (
kind !== undefined &&
["func", "table", "memory", "global", "tag"].includes(kind)
)
addDefinition(form);
}
for (const form of forms) {
const p = parts(form);
if (p[0] !== "export") continue;
const body = p[2];
if (body === undefined) throw new Error("Incomplete WABT export");
const descriptor = parts(body);
const rawKind = descriptor[0] ?? "unknown";
const kind = ["func", "table", "memory", "global", "tag"].includes(rawKind)
? (rawKind as WasmInterfaceEntry["kind"])
: "unknown";
const reference = descriptor[1];
const space = definitions.get(kind) ?? [];
const index =
reference === undefined
? -1
: reference.startsWith("$")
? space.findIndex((d) => d.label === reference)
: Number(reference);
const observed =
Number.isSafeInteger(index) && index >= 0 ? space[index] : undefined;
const entry: WasmInterfaceEntry = {
direction: "export",
module: null,
name: name(p[1]),
kind,
index: observed === undefined ? null : index,
signature: observed?.signature ?? null,
wat_form: form,
issue:
observed?.signature == null
? "Export type/index is outside admitted core signature semantics"
: null,
};
budget.reserve(wasmInterfaceEntryRepresentationBytes(entry));
result.entries.push(entry);
}
// Unsupported signatures leave inventory completeness intact. A missing
// signature is unknown per entry, not proof that a name is absent.
return result;
};
/** New typed coverage may be incomplete without discarding existing decoded WAT Evidence. */
export const parseWabtInterface = (forms: readonly string[]): WasmInterface => {
const result: WasmInterface = { state: "complete", entries: [], issues: [] };
try {
return decodeWabtInterface(forms, result);
} catch (cause: unknown) {
if (!(cause instanceof AnalysisResourceConstraintError)) throw cause;
result.state = "partial";
result.issues.push(cause.message);
return result;
}
};

View File

@ -229,7 +229,7 @@ it("extracts escaped WAT import/export forms while retaining multiline producer
new URL("../../tests/fixtures/wasm/decoded.wat", import.meta.url),
"utf8",
);
expect(parseWabtWat(text)).toEqual({
expect(parseWabtWat(text)).toMatchObject({
imports: ['(import "env" "foo" (func (;0;) (type 0)))'],
exports: ['(export "memory" (memory 0))', '(export "run" (func 1))'],
customSectionNames: ["test"],
@ -239,7 +239,7 @@ it("extracts escaped WAT import/export forms while retaining multiline producer
parseWabtWat(
'(module (import "a.b" "c\\0aline" (func)) (export "fake\\22Export[1]:" (func 0)))',
),
).toEqual({
).toMatchObject({
imports: ['(import "a.b" "c\\0aline" (func))'],
exports: ['(export "fake\\22Export[1]:" (func 0))'],
customSectionNames: [],

46
src/wasm/WabtString.ts Normal file
View File

@ -0,0 +1,46 @@
/** Decode a producer WAT string to frame objdump's raw display text. */
export const watString = (form: string, start: number) => {
let end = start;
while (end < form.length && form[end] !== '"') {
if (form[end] === "\\") end++;
end++;
}
if (end === form.length)
throw new Error("Incomplete WABT custom-section name.");
const name = form.slice(start, end);
// Escapes shrink; literal UTF-8 has the same byte extent as its source.
const bytes = Buffer.alloc(Buffer.byteLength(name, "utf8"));
let length = 0;
for (let index = 0; index < name.length;) {
if (name[index] !== "\\") {
const next = name.indexOf("\\", index);
const end = next < 0 ? name.length : next;
length += bytes.write(name.slice(index, end), length, "utf8");
index = end;
continue;
}
const hex = name.slice(index + 1, index + 3);
if (/^[\da-f]{2}$/iu.test(hex)) {
bytes[length++] = Number.parseInt(hex, 16);
index += 3;
} else {
const escape = name[index + 1];
const value =
escape === "t"
? 9
: escape === "n"
? 10
: escape === "r"
? 13
: escape === '"'
? 34
: escape === "\\"
? 92
: undefined;
if (value === undefined) throw new Error("Unsupported WABT name escape.");
bytes[length++] = value;
index += 2;
}
}
return { bytes: bytes.subarray(0, length), end: end + 1 };
};

View File

@ -1,49 +1,5 @@
/** Decode a producer WAT string to frame objdump's raw display text. */
const watString = (form: string, start: number) => {
let end = start;
while (end < form.length && form[end] !== '"') {
if (form[end] === "\\") end++;
end++;
}
if (end === form.length)
throw new Error("Incomplete WABT custom-section name.");
const name = form.slice(start, end);
// Escapes shrink; literal UTF-8 has the same byte extent as its source.
const bytes = Buffer.alloc(Buffer.byteLength(name, "utf8"));
let length = 0;
for (let index = 0; index < name.length;) {
if (name[index] !== "\\") {
const next = name.indexOf("\\", index);
const end = next < 0 ? name.length : next;
length += bytes.write(name.slice(index, end), length, "utf8");
index = end;
continue;
}
const hex = name.slice(index + 1, index + 3);
if (/^[\da-f]{2}$/iu.test(hex)) {
bytes[length++] = Number.parseInt(hex, 16);
index += 3;
} else {
const escape = name[index + 1];
const value =
escape === "t"
? 9
: escape === "n"
? 10
: escape === "r"
? 13
: escape === '"'
? 34
: escape === "\\"
? 92
: undefined;
if (value === undefined) throw new Error("Unsupported WABT name escape.");
bytes[length++] = value;
index += 2;
}
}
return { bytes: bytes.subarray(0, length), end: end + 1 };
};
import { parseWabtInterface } from "./WabtInterface.js";
import { watString } from "./WabtString.js";
/** Read only module-name metadata from WABT's preserved name-section payload. */
const moduleNames = (bytes: Uint8Array): string[] => {
@ -85,6 +41,7 @@ const moduleNames = (bytes: Uint8Array): string[] => {
export const parseWabtWat = (text: string) => {
if (!/^\(module(?:\s|\))/u.test(text))
throw new Error("Unsupported WABT WAT module prefix.");
const forms: string[] = [];
const imports: string[] = [];
const exports: string[] = [];
const customSectionNames: string[] = [];
@ -140,6 +97,12 @@ export const parseWabtWat = (text: string) => {
} else if (char === ")") {
if (depth === 2) {
const form = text.slice(start, index + 1);
if (
/^\((?:type|rec|import|export|func|table|memory|global|tag)\s/u.test(
form,
)
)
forms.push(form);
if (/^\(import\s/u.test(form)) imports.push(form);
else if (/^\(export\s/u.test(form)) exports.push(form);
else if (/^\(@custom\s/u.test(form)) {
@ -172,5 +135,11 @@ export const parseWabtWat = (text: string) => {
}
if (depth !== 0 || quoted || commentDepth !== 0)
throw new Error("Incomplete WABT WAT output.");
return { imports, exports, customSectionNames, debugModuleNames };
return {
imports,
exports,
customSectionNames,
debugModuleNames,
interface: parseWabtInterface(forms),
};
};

View File

@ -0,0 +1,17 @@
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
import { WASM_INTERFACE_REPRESENTATION_BYTES } from "../domain/wasm/wasmInterface.js";
/** Eager typed observations share the existing WABT output capacity, not an agent token estimate. */
export class WasmInterfaceBudget {
#remaining = WASM_INTERFACE_REPRESENTATION_BYTES;
reserve(bytes: number): void {
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.#remaining)
throw new AnalysisResourceConstraintError(
"inspect_wasm_artifact",
"capacity",
"Typed WASM interface decoding exceeds the WABT output representation budget",
{ maximum_bytes: WASM_INTERFACE_REPRESENTATION_BYTES },
);
this.#remaining -= bytes;
}
}

View File

@ -0,0 +1,130 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect } from "vitest";
import { createEvidence, parseEvidence } from "../../src/domain/evidence.js";
import { wasmInterfaceComparisonSchema } from "../../src/contracts/wasm/wasmComparisonContracts.js";
import { composeBinarySession } from "../../src/application/binary/BinarySessionComposition.js";
import { AnalysisProviderRegistry } from "../../src/application/binary/AnalysisProviderRegistry.js";
import { createServer } from "../../src/server/createServer.js";
import { wasmArtifactFixture } from "../fixtures/wasm/artifact.js";
import { cliTest } from "../support/cli/cliFixture.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
cliTest(
"compares portable Evidence identically through CLI and retained MCP with schema/provenance",
async ({ cli }) => {
const sources = (["i32", "i64"] as const).map((parameter) => {
const report = wasmArtifactFixture();
report.interface = {
state: "complete",
issues: [],
entries: [
{
direction: "export",
module: null,
name: 'quoted"\ufeffname',
kind: "func",
index: 0,
signature: { kind: "func", params: [parameter], results: [] },
wat_form: '(export "quoted\\22\\ef\\bb\\bfname" (func 0))',
issue: null,
},
],
};
return createEvidence(
{
path: report.artifact.path,
sha256: report.artifact.sha256,
format: "file",
},
{ id: "typed-fixture", name: "Typed fixture", version: "1" },
{
operation: "inspect_wasm_artifact",
parameters: { path: report.artifact.path },
result: report,
confidence: "observed",
},
);
});
const [left, right] = sources;
if (left === undefined || right === undefined)
throw new Error("Missing paired fixture");
const root = await createTestTempDirectory("rea-wasm-comparison-");
const path = join(root, "input.json");
const input = { left, right, scope: "both" };
await writeFile(path, JSON.stringify(input));
const cliResult = await cli.run({
arguments: ["compare-wasm-interfaces", path, "--json"],
});
expect(cliResult.exitCode).toBe(0);
const cliEvidence = parseEvidence(cliResult.json);
const cliReport = wasmInterfaceComparisonSchema.parse(
cliEvidence.normalized_result,
);
expect(cliReport).toMatchObject({
coverage: "complete-within-inputs",
changes: [{ name: 'quoted"\ufeffname', state: "changed" }],
left: {
evidence_id: left.evidence_id,
artifact: {
path: "/artifacts/module.wasm",
sha256: "a".repeat(64),
bytes: 8,
},
},
});
const session = composeBinarySession(new AnalysisProviderRegistry([]));
const server = createServer({ kind: "session", session });
const client = new Client({ name: "wasm-interface-parity", version: "1" });
const [ct, st] = InMemoryTransport.createLinkedPair();
try {
await server.connect(st);
await client.connect(ct);
const tool = (await client.listTools()).tools.find(
(t) => t.name === "compare_wasm_interfaces",
);
if (tool?.outputSchema === undefined)
throw new Error("Missing comparison schema");
const outputSchema: Record<string, unknown> = tool.outputSchema;
const ajv = new Ajv2020({ strict: false, validateFormats: false });
expect(ajv.validate(tool.inputSchema, input)).toBe(true);
expect(ajv.validate(tool.inputSchema, { left, right })).toBe(false);
const mcp = await client.callTool({ name: tool.name, arguments: input });
expect(mcp.isError, JSON.stringify(mcp)).not.toBe(true);
expect(
ajv.validate(outputSchema, mcp.structuredContent),
JSON.stringify(ajv.errors),
).toBe(true);
const mcpEvidence = parseEvidence(mcp.structuredContent);
expect(mcpEvidence.normalized_result).toEqual(cliReport);
expect(mcpEvidence.evidence_id).toBe(cliEvidence.evidence_id);
expect(mcpEvidence.evidence_links).toEqual([
left.evidence_id,
right.evidence_id,
]);
const retained = await client.callTool({
name: tool.name,
arguments: {
scope: "both",
left: { kind: "retained-evidence", evidence_id: left.evidence_id },
right: { kind: "retained-evidence", evidence_id: right.evidence_id },
},
});
expect(retained.isError).not.toBe(true);
expect(parseEvidence(retained.structuredContent).evidence_id).toBe(
mcpEvidence.evidence_id,
);
const invalid = await client.callTool({
name: tool.name,
arguments: { ...input, scope: "all" },
});
expect(invalid.isError).toBe(true);
} finally {
await client.close();
await server.close();
await session.close();
}
},
);