Skip to content

Commit 4a36a7d

Browse files
test(loader): INT-02 — prove + regression-lock the host-agnostic loader bridge (Refs #179) (#267)
The INT-02 loader (`packages/affine-js/loader.js`) was already implemented + merged (host-agnostic readBytes, multi-namespace buildImportObject, parseOwnershipSection, Deno/Node/browser detectHost; mod.js consumes it — SAT-02 gone). What was missing was the *guarantee*, exactly the INT-01 #244 pattern: 1. loader_test.js (14 unit tests) had NO run task — green only with the right --allow-read/--allow-write flags; a fresh run looked "1 failed" (a permission error, not a loader bug). Pinned: deno.json `tasks.test` -> `deno task test` = 14/14, reproducible. 2. Nothing proved the loader on REAL compiler-emitted wasm (unit tests use synthetic bytes; the INT-01 xmod-link harness still hand-rolls Deno.readFile + a manual import object — the SAT-02 anti-pattern the loader replaces). Added tests/modules/loader-bridge/ (bridge.mjs + run.sh + README): compiles the INT-01 fixtures and drives them through the *actual* loader API — readBytes loads both; buildImportObject wires the genuine `CrossCallee` cross-module namespace; linked caller.main() === 42; parseOwnershipSection reads a real Linear-param ownership entry from compiler output. Closes INT-01 <-> INT-02. Finding (documented in the harness README): buildImportObject *spreads* module members (merge-not-clobber), so a catch-all Proxy wasi stub must be attached as a whole namespace, not via the spread — mirroring real usage (host owns wasi; loader owns the affine runtime + cross-module namespaces). Ledger truthed: ECOSYSTEM/TECH-DEBT INT-02 -> PROVEN+locked; SAT-02 -> FIXED. No compiler change (loader is JS); hermetic dune gate unaffected by construction (docs + deno.json + harness only; dune build clean). Refs #179 (not Closes — owner-gated; satellite shell downstream). Co-authored-by: hyperpolymath <hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 34b7bc1 commit 4a36a7d

6 files changed

Lines changed: 192 additions & 6 deletions

File tree

docs/ECOSYSTEM.adoc

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,9 +188,15 @@ link:TECH-DEBT.adoc[TECH-DEBT.adoc].
188188
+ `Mod.fn(x)` qualified-value path WIRED+locked (parse-boundary lowering;
189189
4 hermetic tests). Distinct parser follow-up: `Mod::fn(x)` in expr position
190190
|INT-02 |Host-agnostic loader bridge (`affinescript-dom-loader`) |#179 |loader
191-
landed in `packages/affine-js` (SAT-02 fixed; Deno/Node/browser parity,
192-
multi-namespace import object, ownership-section accessor). S1; unblocks
193-
INT-05/08/11. The `affinescript-dom-loader` satellite shell is downstream.
191+
in `packages/affine-js` (SAT-02 fixed; Deno/Node/browser parity,
192+
multi-namespace import object, ownership-section accessor). *PROVEN +
193+
regression-locked:* 14 unit tests via pinned `deno task test` (was
194+
flag-fragile — no run task) + `tests/modules/loader-bridge/` drives the
195+
*real* loader API over genuine compiler-emitted cross-module wasm
196+
(`readBytes`+`buildImportObject` link `CrossCallee.consume(42)`=42;
197+
`parseOwnershipSection` reads a real Linear-param entry) — closes
198+
INT-01 ↔ INT-02. S1; unblocks INT-05/08/11. The `affinescript-dom-loader`
199+
satellite shell is downstream.
194200
|INT-03 |WASI preview2 / host I/O beyond stdout |#180 |S1, ADR-015
195201
ACCEPTED (owner-chosen full WASM Component-Model re-target). Staged
196202
S1..S6; legacy preview1 stdout path is the default until S6

docs/TECH-DEBT.adoc

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,11 @@ a parse error (`::`-in-value-expr unwired; `::` reserved for
158158
`Type::Variant`). |S1 |`use ::{}`/`::*` + `use Mod;`/`as`-qualified
159159
`Mod.fn(x)` DONE (PR Refs #178); `::`-in-expression a separate parser
160160
follow-up
161-
|INT-02 |Host-agnostic loader bridge |S1 |open #179 (blocks INT-05/08/11)
161+
|INT-02 |Host-agnostic loader bridge |S1 |*PROVEN + locked* (Refs #179):
162+
`packages/affine-js` loader (SAT-02 fixed; Deno/Node/browser parity,
163+
multi-ns import object, ownership accessor); 14 unit tests via pinned
164+
`deno task test` + `tests/modules/loader-bridge/` e2e on real
165+
compiler-emitted xmod wasm (closes INT-01↔INT-02). Unblocks INT-05/08/11
162166
|INT-03 |WASI preview2 / host I/O |S1 |#180 ADR-015 accepted (full
163167
Component-Model re-target, staged S1..S6); S3+ hard-gated on S2
164168
toolchain (`wasm-tools`/`wasm-component-ld`)
@@ -177,8 +181,11 @@ fixed; runtime blocked by #255 (wasm loop-codegen defect)
177181
|===
178182
|ID |Item |Sev |Status
179183
|SAT-01 |`affinescript-dom` reconciler (skeleton → real) |S2 |open (=INT-08)
180-
|SAT-02 |`packages/affine-js` hardcoded path / env-only imports |S1 |open
181-
(addressed by INT-02)
184+
|SAT-02 |`packages/affine-js` hardcoded path / env-only imports |S1
185+
|*FIXED* by INT-02 (Refs #179): `Deno.readFile(url.pathname)` replaced by
186+
host-agnostic `readBytes`; `env`-only import shape replaced by
187+
multi-namespace `buildImportObject`; `mod.js` consumes the loader.
188+
Proven + locked (see INT-02)
182189
|SAT-03 |`affinescript-tea` runtime build-out |S2 |open (=INT-07)
183190
|SAT-04 |`affinescript-cadre` router runtime |S2 |planned (=INT-09)
184191
|SAT-05 |`affinescript-pixijs` migration prerequisite |S3 |open #56

packages/affine-js/deno.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
"./runtime": "./runtime.js"
99
},
1010
"license": "PMPL-1.0-or-later",
11+
"tasks": {
12+
"test": "deno test --allow-read --allow-write loader_test.js"
13+
},
1114
"publish": {
1215
"exclude": ["loader_test.js"]
1316
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
= INT-02 loader-bridge acceptance harness (#179)
3+
4+
Proves the host-agnostic loader (`packages/affine-js/loader.js`) on
5+
*real* compiler-emitted, cross-module wasm — the level above
6+
`packages/affine-js/loader_test.js` (14 unit tests on synthetic bytes,
7+
run via `deno task test` in that package).
8+
9+
== What it asserts
10+
11+
`bridge.mjs` uses the **actual loader API** (not a hand-rolled
12+
`Deno.readFile` + manual import object — that is the SAT-02 anti-pattern
13+
the loader replaces, still present by hand in the INT-01
14+
`tests/modules/xmod-link` harness; this closes INT-01 ↔ INT-02):
15+
16+
* `readBytes` loads both compiled fixtures host-agnostically;
17+
* `buildImportObject` wires the genuine `CrossCallee` cross-module
18+
namespace INT-01/#178 emits (multi-namespace, not `env`-only);
19+
* the linked `caller.main()` returns `42` across the wasm boundary;
20+
* `parseOwnershipSection` reads a real ownership entry (a Linear param
21+
for `consume(own x: Int)`) from compiler-emitted wasm — the typed-wasm
22+
contract carrier, exercised on genuine output.
23+
24+
== Run
25+
26+
[source,sh]
27+
----
28+
dune build bin/main.exe
29+
./run.sh # deno on PATH, or $AFFINESCRIPT_DENO
30+
----
31+
32+
Exit 0 + `PASS:` line = INT-02 substrate proven. Reproducible; kept out
33+
of the hermetic OCaml gate by design (needs a wasm engine + deno),
34+
exactly like `tests/modules/xmod-link`.
35+
36+
== Note
37+
38+
WASI is host-supplied as a whole catch-all namespace. `buildImportObject`
39+
*spreads* module members (to merge, not clobber), so a catch-all `Proxy`
40+
is attached directly as `wasi_snapshot_preview1` rather than via the
41+
spread — mirroring real usage: the host owns wasi, the loader owns the
42+
affine runtime + cross-module namespaces.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// INT-02 / #179 — host-agnostic loader bridge, end-to-end acceptance.
3+
//
4+
// Proves the *actual* INT-02 loader API (packages/affine-js/loader.js)
5+
// drives genuine compiler-emitted, cross-module wasm — not synthetic
6+
// bytes (loader_test.js does the unit level) and not a hand-rolled
7+
// `Deno.readFile` + manual import object (that is the SAT-02 anti-pattern
8+
// the loader exists to replace; the INT-01 xmod-link harness still does it
9+
// by hand — this closes INT-01 ↔ INT-02).
10+
//
11+
// callee.wasm ← module CrossCallee; pub fn consume(own x: Int) -> Int { x }
12+
// caller.wasm ← use CrossCallee::{consume}; pub fn main() -> Int { consume(42) }
13+
//
14+
// Usage: deno run --allow-read=<dir> bridge.mjs <callee.wasm> <caller.wasm>
15+
// Exit 0 + PASS iff: readBytes loads both; buildImportObject wires the
16+
// cross-module import; caller.main() === 42; parseOwnershipSection returns
17+
// real entries from the compiler-emitted module.
18+
19+
import {
20+
buildImportObject,
21+
parseOwnershipSection,
22+
readBytes,
23+
} from "../../../packages/affine-js/loader.js";
24+
25+
const [calleePath, callerPath] = Deno.args;
26+
if (!calleePath || !callerPath) {
27+
console.error("usage: bridge.mjs <callee.wasm> <caller.wasm>");
28+
Deno.exit(64);
29+
}
30+
31+
// WASI is host-supplied, a catch-all namespace (println-style codegen
32+
// imports fd_write). `buildImportObject` *spreads* module members (to merge
33+
// rather than clobber), so a catch-all Proxy must be attached as a whole
34+
// namespace, not via the spread — mirroring real usage where the host owns
35+
// wasi while the loader owns the affine runtime + cross-module namespaces.
36+
const wasiStub = new Proxy({}, { get: () => () => 0 });
37+
const withWasi = (io) => {
38+
io.wasi_snapshot_preview1 = wasiStub;
39+
return io;
40+
};
41+
42+
// 1. readBytes — the host-agnostic reader (the SAT-02 fix).
43+
const calleeBytes = await readBytes(calleePath);
44+
const callerBytes = await readBytes(callerPath);
45+
46+
// 2. buildImportObject — the loader builds the affine import object; the
47+
// caller's is multi-namespace (NOT env-only): it carries the genuine
48+
// `CrossCallee` cross-module namespace INT-01/#178 emits.
49+
const callee = await WebAssembly.instantiate(
50+
calleeBytes,
51+
withWasi(buildImportObject({})),
52+
);
53+
const consume = callee.instance.exports.consume;
54+
if (typeof consume !== "function") {
55+
console.error("FAIL: callee does not export a callable `consume`");
56+
Deno.exit(2);
57+
}
58+
59+
let caller;
60+
try {
61+
caller = await WebAssembly.instantiate(
62+
callerBytes,
63+
withWasi(buildImportObject({}, { modules: { CrossCallee: { consume } } })),
64+
);
65+
} catch (e) {
66+
console.error("FAIL: caller did not link via the loader import object:", e.message);
67+
Deno.exit(3);
68+
}
69+
70+
const got = caller.instance.exports.main();
71+
if (got !== 42) {
72+
console.error(`FAIL: cross-module call returned ${got}, expected 42`);
73+
Deno.exit(4);
74+
}
75+
76+
// 3. parseOwnershipSection — the typed-wasm contract carrier, on REAL
77+
// compiler output (loader_test.js only round-trips a hand-built buffer).
78+
const calleeMod = await WebAssembly.compile(calleeBytes);
79+
const ownership = parseOwnershipSection(calleeMod);
80+
if (!Array.isArray(ownership) || ownership.length === 0) {
81+
console.error(
82+
"FAIL: parseOwnershipSection returned no entries for compiler-emitted " +
83+
"wasm (expected at least CrossCallee.consume's linear param)",
84+
);
85+
Deno.exit(5);
86+
}
87+
const consumeOwn = ownership.find((e) => e.paramKinds.includes("linear"));
88+
if (!consumeOwn) {
89+
console.error(
90+
"FAIL: no linear param found; `consume(own x: Int)` should be Linear",
91+
);
92+
Deno.exit(6);
93+
}
94+
95+
console.log(
96+
`PASS: loader bridge — readBytes + buildImportObject linked ` +
97+
`CrossCallee.consume(42) === ${got}; parseOwnershipSection read ` +
98+
`${ownership.length} entr${ownership.length === 1 ? "y" : "ies"} ` +
99+
`(consume has a Linear param) from real compiler output`,
100+
);

tests/modules/loader-bridge/run.sh

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: PMPL-1.0-or-later
3+
# INT-02 / #179 — compile the INT-01 cross-module fixtures and drive them
4+
# through the real packages/affine-js loader API. Reproducible acceptance.
5+
#
6+
# ./run.sh # uses `deno` on PATH (or $AFFINESCRIPT_DENO)
7+
# Exit 0 = loader bridge proven on real compiler output; non-zero = regression.
8+
set -euo pipefail
9+
10+
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11+
root="$(cd "$here/../../.." && pwd)"
12+
bin="$root/_build/default/bin/main.exe"
13+
fix="$root/test/e2e/fixtures"
14+
deno="${AFFINESCRIPT_DENO:-deno}"
15+
16+
export AFFINESCRIPT_STDLIB="$root/stdlib"
17+
tmp="$(mktemp -d)"
18+
trap 'rm -rf "$tmp"' EXIT
19+
20+
[ -x "$bin" ] || { echo "build the compiler first: dune build bin/main.exe" >&2; exit 9; }
21+
command -v "$deno" >/dev/null 2>&1 || { echo "deno not found (set \$AFFINESCRIPT_DENO)" >&2; exit 9; }
22+
23+
( cd "$fix" && "$bin" compile CrossCallee.affine -o "$tmp/callee.wasm" >/dev/null )
24+
( cd "$fix" && "$bin" compile cross_caller_ok.affine -o "$tmp/caller.wasm" >/dev/null )
25+
26+
# bridge.mjs imports the loader from packages/affine-js, so allow-read spans
27+
# the repo root (loader source) + the temp dir (the .wasm).
28+
"$deno" run --allow-read="$root","$tmp" "$here/bridge.mjs" "$tmp/callee.wasm" "$tmp/caller.wasm"

0 commit comments

Comments
 (0)