Skip to content

Commit 6d44e15

Browse files
diag(probe): parser variants for #644 + candidate consumer on-ramp example
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
1 parent 2e3f0e0 commit 6d44e15

4 files changed

Lines changed: 353 additions & 33 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: MPL-2.0
3+
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
4+
#
5+
# Build + run the consumer on-ramp example (issue #771).
6+
#
7+
# This is the "smallest thing" the consumer asked for, in executable form:
8+
#
9+
# ./build.sh compile src/boundary.affine -> dist/boundary.wasm,
10+
# then run the Node harness against it.
11+
#
12+
# The compiler is located the three ways a consumer legitimately has it:
13+
# an in-tree dune build (a checkout of this repo), an `affinescript` on
14+
# PATH (a release binary), or `dune exec` (a checkout without a prior
15+
# build). Nothing else is required — no deno.json, no npm manifest, no
16+
# bundler config.
17+
set -euo pipefail
18+
19+
HERE="$(cd "$(dirname "$0")" && pwd)"
20+
ROOT="$(cd "$HERE/../../.." && pwd)"
21+
22+
SRC="$HERE/src/boundary.affine"
23+
OUT_DIR="$HERE/dist"
24+
OUT="$OUT_DIR/boundary.wasm"
25+
26+
if [ -x "$ROOT/_build/default/bin/main.exe" ]; then
27+
COMPILE=("$ROOT/_build/default/bin/main.exe" compile)
28+
elif command -v affinescript >/dev/null 2>&1; then
29+
COMPILE=(affinescript compile)
30+
else
31+
COMPILE=(dune exec affinescript -- compile)
32+
fi
33+
34+
mkdir -p "$OUT_DIR"
35+
36+
echo "Compiling $(basename "$SRC") -> $(basename "$OUT")"
37+
"${COMPILE[@]}" "$SRC" -o "$OUT"
38+
39+
echo "Running host.mjs"
40+
node "$HERE/host.mjs"
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
//
4+
// Node ESM harness for examples/consumers/extension-boundary.
5+
//
6+
// This file is the other half of the on-ramp: it shows exactly how a host
7+
// satisfies the `extern fn` surface declared in src/boundary.affine, and
8+
// what the assertable contract looks like in CI. Node is used because CI
9+
// already has it; nothing here is Node-specific except `readFile`, so the
10+
// same object literal works in Deno, Bun, or a browser extension
11+
// (`WebAssembly.instantiate` with the same `imports`).
12+
import assert from "node:assert/strict";
13+
import { readFile } from "node:fs/promises";
14+
import { fileURLToPath } from "node:url";
15+
import { dirname, join } from "node:path";
16+
17+
const here = dirname(fileURLToPath(import.meta.url));
18+
19+
// The error taxonomy a real consumer needs is stable and enumerated. These
20+
// are the codes the host returns; the guest only ever sees integers.
21+
const BW = {
22+
OK: 0,
23+
BW_PDF_ENCRYPTED: 1042,
24+
BW_NO_BLOCKS_FOUND: 1051,
25+
};
26+
27+
// The host owns the message text. Nothing crosses the boundary as a string
28+
// — `bw_error_message_*` is a byte-wise accessor over this value.
29+
const MESSAGES = {
30+
[BW.BW_PDF_ENCRYPTED]: "document is encrypted; decrypt before filling",
31+
[BW.BW_NO_BLOCKS_FOUND]: "no fillable blocks detected in document",
32+
};
33+
34+
// `context` is the third leg of the {code, message, context} payload the
35+
// consumer contract requires; it stays host-side too.
36+
let lastFailure = null;
37+
38+
function fail(code, context) {
39+
lastFailure = { code, message: MESSAGES[code] ?? "", context };
40+
return code;
41+
}
42+
43+
const calls = { detect: 0, fill: 0 };
44+
45+
const imports = {
46+
wasi_snapshot_preview1: {
47+
// Nothing in this guest touches WASI, but the compiler links the
48+
// preview-1 surface when the file mentions effects; a stub keeps the
49+
// instantiation total rather than depending on what codegen emitted.
50+
fd_write: () => 0,
51+
},
52+
env: {
53+
bw_detect_blocks: (pdfLen) => {
54+
calls.detect += 1;
55+
assert.ok(Number.isInteger(pdfLen) && pdfLen > 0, "host got the length");
56+
return BW.OK;
57+
},
58+
bw_fill_blocks: (pdfLen, fieldCount) => {
59+
calls.fill += 1;
60+
assert.ok(Number.isInteger(pdfLen) && pdfLen > 0, "host got the length");
61+
assert.ok(fieldCount > 0, "host got the field count");
62+
// Second document is encrypted — the failure path, deliberately.
63+
return fail(BW.BW_PDF_ENCRYPTED, { pdfLen, fieldCount });
64+
},
65+
bw_error_code: () => lastFailure?.code ?? BW.OK,
66+
bw_error_message_len: () => lastFailure?.message.length ?? 0,
67+
bw_error_message_byte: (offset) =>
68+
lastFailure?.message.charCodeAt(offset) ?? 0,
69+
},
70+
};
71+
72+
const wasmPath = join(here, "dist", "boundary.wasm");
73+
const bytes = await readFile(wasmPath);
74+
const { instance } = await WebAssembly.instantiate(bytes, imports);
75+
const guest = instance.exports;
76+
77+
// ── The contract, asserted ───────────────────────────────────────────────
78+
79+
// 1. Success path: the guest normalises "host said 0" to 0.
80+
assert.equal(guest.detect(4096), 0, "detect returns 0 on success");
81+
assert.equal(guest.is_ok(0), 1, "is_ok(0) is true");
82+
assert.equal(guest.message_len(0), 0, "no message for a success status");
83+
assert.equal(calls.detect, 1, "host detected once");
84+
85+
// 2. Failure path: the guest propagates the stable code, not a string.
86+
assert.equal(
87+
guest.fill(4096, 3),
88+
BW.BW_PDF_ENCRYPTED,
89+
"fill propagates the host's BW_* code",
90+
);
91+
assert.equal(calls.fill, 1, "host filled once");
92+
93+
// 3. The message is host-owned and byte-addressable, exactly as declared.
94+
const len = guest.message_len(BW.BW_PDF_ENCRYPTED);
95+
assert.equal(len, MESSAGES[BW.BW_PDF_ENCRYPTED].length, "message length crosses");
96+
const rebuilt = Array.from({ length: len }, (_, i) =>
97+
String.fromCharCode(guest.message_byte(BW.BW_PDF_ENCRYPTED, i)),
98+
).join("");
99+
assert.equal(rebuilt, MESSAGES[BW.BW_PDF_ENCRYPTED], "message round-trips");
100+
assert.equal(guest.message_len(0), 0, "success still has no message");
101+
102+
// 4. The consumer-facing payload shape: {code, message, context} — never a
103+
// bare string, which is the taxonomy requirement this example exists for.
104+
assert.deepEqual(lastFailure, {
105+
code: BW.BW_PDF_ENCRYPTED,
106+
message: MESSAGES[BW.BW_PDF_ENCRYPTED],
107+
context: { pdfLen: 4096, fieldCount: 3 },
108+
});
109+
110+
// 5. The guest's public surface is what the consumer advertised — a
111+
// two-function boundary plus the error accessors.
112+
for (const name of [
113+
"detect", "fill", "is_ok", "message_len", "message_byte",
114+
]) {
115+
assert.equal(typeof guest[name], "function", `${name} is exported`);
116+
}
117+
118+
console.log("examples/consumers/extension-boundary: OK");
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
//
4+
// Consumer on-ramp example (issue #771) — the "extension boundary" shape.
5+
//
6+
// This module is what a downstream consumer writes. It is deliberately
7+
// shaped after the first real consumer request the project received: a
8+
// browser extension with a WASM core behind a two-function boundary
9+
// (`detect` / `fill`), whose error taxonomy has to cross that boundary as
10+
// stable integer codes plus a message the host owns.
11+
//
12+
// Two things are being demonstrated:
13+
//
14+
// 1. A consumer owns its own host surface. The `extern fn` declarations
15+
// below are the entire binding story: the WASM backend emits one
16+
// `(import "env" "<name>")` per declaration, and the host supplies
17+
// them in the import object. No compiler change is needed to add a
18+
// host function.
19+
//
20+
// 2. Errors do not cross as strings. The guest returns a status code
21+
// (0 = ok, non-zero = a stable `BW_*` code) and asks the host for the
22+
// message only when a human needs to read it.
23+
//
24+
// Compile:
25+
// affinescript compile src/boundary.affine -o dist/boundary.wasm
26+
// Run the harness:
27+
// node host.mjs
28+
29+
module boundary;
30+
31+
// ── The host surface this consumer owns ──────────────────────────────────
32+
// Implemented in the host's `env` import object (see host.mjs). Params and
33+
// results are i32 across the WASM boundary.
34+
35+
/// Scan a document for fillable blocks. PDF bytes stay host-side; the guest
36+
/// passes only the length it was told about. Returns 0 or a `BW_*` code.
37+
pub extern fn bw_detect_blocks(pdf_len: Int) -> Int;
38+
39+
/// Write field values into the blocks found by `bw_detect_blocks`.
40+
/// `field_count` is the number of entries the host handed over. Returns 0
41+
/// or a `BW_*` code.
42+
pub extern fn bw_fill_blocks(pdf_len: Int, field_count: Int) -> Int;
43+
44+
/// The `BW_*` code for the most recent failure. Always safe to call.
45+
pub extern fn bw_error_code() -> Int;
46+
47+
/// Byte length of the host-owned message for the most recent failure.
48+
pub extern fn bw_error_message_len() -> Int;
49+
50+
/// Byte `offset` of the host-owned message for the most recent failure.
51+
pub extern fn bw_error_message_byte(offset: Int) -> Int;
52+
53+
// ── Guest-side vocabulary ────────────────────────────────────────────────
54+
55+
/// Status values are the `BW_*` taxonomy, narrowed to the two the guest
56+
/// reasons about: 0 is success, anything else is a failure whose detail
57+
/// lives host-side.
58+
pub fn is_ok(status: Int) -> Bool {
59+
status == 0
60+
}
61+
62+
/// Detect blocks, normalising the host's status to the documented
63+
/// "0 = ok, non-zero = BW_* code" contract.
64+
pub fn detect(pdf_len: Int) -> Int {
65+
let status = bw_detect_blocks(pdf_len);
66+
if is_ok(status) { 0 } else { bw_error_code() }
67+
}
68+
69+
/// Fill blocks. Same normalisation as `detect`.
70+
pub fn fill(pdf_len: Int, field_count: Int) -> Int {
71+
let status = bw_fill_blocks(pdf_len, field_count);
72+
if is_ok(status) { 0 } else { bw_error_code() }
73+
}
74+
75+
/// Length of the human-readable message for `status`, or 0 when `status`
76+
/// is success. The guest never materialises the message as a String: the
77+
/// host owns it, and the guest asks for it only when someone will read it.
78+
pub fn message_len(status: Int) -> Int {
79+
if is_ok(status) { 0 } else { bw_error_message_len() }
80+
}
81+
82+
/// Byte of the human-readable message for `status`, or 0 when `status` is
83+
/// success. Mirrors `message_len`.
84+
pub fn message_byte(status: Int, offset: Int) -> Int {
85+
if is_ok(status) { 0 } else { bw_error_message_byte(offset) }
86+
}

0 commit comments

Comments
 (0)