Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions .machine_readable/6a2/META.a2ml
Original file line number Diff line number Diff line change
Expand Up @@ -988,8 +988,20 @@ Staged plan (ledger INT-03; each row = one gated PR):
- S4: native `wasi:clocks` + environment + argv via preview2 (wasmtime
host-testable), replacing the preview1 shims behind the component path.
- S5: `wasi:filesystem` (open/read/write/close) — unblocks INT-06.
- S6: `wasi:sockets`; then flip the default wasm target to component
and demote the preview1 stdout path to a named legacy target.
- S6a (WIT export lifting, DONE): codegen emits a `_start : () -> ()`
shim that calls `main` and drops its i32 result whenever a
parameter-less `fn main()` is present (`lib/codegen.ml`);
`tools/componentize.sh --command` wraps with the fetch-pinned
preview1->preview2 *command* adapter (wasmtime v44.0.1, sha256
8ff2ea78...) producing a component that exports `wasi:cli/run@0.2.x`
per `wit/affinescript.wit`. End-to-end gate:
`tests/componentize/command_smoke.sh` proves `wasmtime run` invokes
the component (exit 0), the ownership section survives, and the
lift is asserted. Purely additive: reactor consumers + game-loop
hooks are byte-unchanged.
- S6b: `wasi:sockets`.
- S6c: flip the default wasm target to component and demote the
preview1 stdout path to a named legacy target.
"""
consequences = """
- End-state is one-way (the component model becomes the canonical wasm
Expand Down
24 changes: 20 additions & 4 deletions docs/ECOSYSTEM.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -206,19 +206,35 @@ INT-01 ↔ INT-02. S1; unblocks INT-05/08/11. The `affinescript-dom-loader`
satellite shell is downstream.
|INT-03 |WASI preview2 / host I/O beyond stdout |#180 |S1, ADR-015
ACCEPTED (owner-chosen full WASM Component-Model re-target). Staged
S1..S6; legacy preview1 stdout path is the default until S6
(reversible-in-progress). S2 toolchain provisioned (#251). **S3
S1..S6; legacy preview1 stdout path remains the default until S6c
(reversible-in-progress). S2 toolchain provisioned (#251). S3
DONE: `tools/componentize.sh` wraps the emitted core module into a
valid WASI-0.2 component via the fetch-pinned preview1→preview2
*reactor* adapter (wasmtime v44.0.1, sha256-verified); the
`affinescript.ownership` section is proven to SURVIVE the wrap;
`tests/componentize/smoke.sh` gates it (SKIP-safe without the
toolchain). Codegen UNCHANGED — core preview1 stays the default
(reversible). S4a (clock) + S4b (env_count, arg_count) DONE:
toolchain). S4a (clock) + S4b (env_count, arg_count) DONE:
builtins lower to on-demand `wasi_snapshot_preview1.*` imports
(Effect_sites pre-scan, canonical-order indexing through
`ctx.wasi_func_indices`; zero impact on units that don't use them;
verified with a multi-import combo regression). Component path
bridges to `wasi:clocks`/`wasi:cli`. **S6a (WIT export lifting)
DONE: `lib/codegen.ml` auto-emits a `_start : () -> ()` shim that
calls `main` and drops its i32 result whenever a parameter-less
`fn main()` is present. `tools/componentize.sh --command` wraps
with the fetch-pinned preview1→preview2 *command* adapter
(wasmtime v44.0.1, sha256 8ff2ea78…) — the resulting component
exports `wasi:cli/run@0.2.x` per `wit/affinescript.wit`. End-to-end
smoke `tests/componentize/command_smoke.sh` proves the lift, the
ownership section survives, and `wasmtime run <component>` exits
0 on real-host invoke. Purely additive: reactor consumers, the
`__indirect_function_table` export, and game-loop hooks are
byte-unchanged.** String accessors (env_at/arg_at) gated on a
byte-level wasm-IR extension (I32Load8U/I32Store8 absent today)
— tracked as the next slice before/with S5 filesystem. Remaining
sub-slices: S5 (native clocks/env/argv via preview2), S6b
(`wasi:sockets`), S6c (flip the default wasm target from preview1
→ component). WIT world of record: `wit/affinescript.wit`
bridges to `wasi:clocks`/`wasi:cli`. Real-host main-invoke deferred
to S6 (WIT export-lifting / wasi:cli/run command shape).
**S5 string accessors (env_at/arg_at) DONE: the wasm IR gained
Expand Down
18 changes: 16 additions & 2 deletions docs/TECH-DEBT.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -238,11 +238,25 @@ follow-up
multi-ns import object, ownership accessor); 14 unit tests via pinned
`deno task test` + `tests/modules/loader-bridge/` e2e on real
compiler-emitted xmod wasm (closes INT-01↔INT-02). Unblocks INT-05/08/11
|INT-03 |WASI preview2 / host I/O |S1→S4b |#180 ADR-015 (full
|INT-03 |WASI preview2 / host I/O |S1→S6a |#180 ADR-015 (full
Component-Model re-target, S1..S6); S2 toolchain #251 closed;
S3 componentize done; **S4a (clock) + S4b (env_count, arg_count)
S3 componentize done; S4a (clock) + S4b (env_count, arg_count)
DONE — on-demand preview1 imports via Effect_sites pre-scan,
canonical-order indexing through `ctx.wasi_func_indices`; combo
regression proves no collision. String accessors (env_at/arg_at)
gated on byte-level wasm IR (I32Load8U/I32Store8 absent today) —
tracked next slice. **S6a (WIT export lifting) DONE:
`lib/codegen.ml` auto-emits a `_start : () -> ()` shim whenever
a parameter-less `fn main()` is present (additive, no regression
on reactor consumers); `tools/componentize.sh --command` wraps
with the fetch-pinned command adapter (sha256
8ff2ea78… / wasmtime v44.0.1, provisioned alongside the reactor
adapter); `tests/componentize/command_smoke.sh` gates the
contract end-to-end — `wasi:cli/run` lifted, ownership section
survives, `wasmtime run` exits 0. Real-host main-invoke now
works via `wasmtime run <component>`.** Remaining S6
sub-slices: S5 (native clocks/env/argv), S6b (`wasi:sockets`),
S6c (flip the default wasm target from preview1 → component)
regression proves no collision. **S5 (env_at/arg_at) DONE — wasm
IR extended with the byte-level load/store family
(I32Load8U/I32Store8 and siblings); accessors lower to on-demand
Expand Down
8 changes: 6 additions & 2 deletions docs/specs/SETTLED-DECISIONS.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -302,8 +302,12 @@ change; *S2* toolchain provisioning (`wasm-tools`, `wasm-component-ld`,
(codegen still emits core wasm; post-step wraps it via the standard
preview1→preview2 adapter; ownership-section survival asserted; wasmtime
component-run smoke); *S4* native `wasi:clocks`/environment/argv; *S5*
`wasi:filesystem` (unblocks INT-06); *S6* `wasi:sockets`, then flip the
default wasm target to component and demote preview1 to a legacy target.
`wasi:filesystem` (unblocks INT-06); *S6a* (WIT export lifting) codegen
auto-emits a `_start : () -> ()` shim around a parameter-less `fn main()`
so `tools/componentize.sh --command` (preview1→preview2 *command*
adapter) produces a real `wasi:cli/run`-exporting component that any
WASI 0.2 host can invoke; *S6b* `wasi:sockets`; *S6c* flip the default
wasm target to component and demote preview1 to a legacy target.

This decision is settled; do not reopen without amending the ADR. Full
ADR in `.machine_readable/6a2/META.a2ml` (ADR-015); ledger INT-03 in
Expand Down
56 changes: 53 additions & 3 deletions lib/codegen.ml
Original file line number Diff line number Diff line change
Expand Up @@ -2676,6 +2676,56 @@ let generate_module ?loader (prog : program) : wasm_module result =
@ table_export
in

(* ADR-015 S6 — WIT export lifting. When the unit exports a parameter-
less `main`, also emit a `_start : () -> ()` shim that calls it and
drops the i32 result. With `_start` present, tools/componentize.sh
--command can wrap the core module with the WASI preview1→preview2
*command* adapter, producing a real `wasi:cli/run`-exporting
component that any WASI 0.2 host (`wasmtime run`, jco) can invoke.
Purely additive: every existing consumer (reactor componentize,
game-loop hosts that call `main` directly) sees the same exports
plus an extra `_start`; main-with-params and units that already
export `_start` are skipped. *)
let final_types, final_funcs, final_exports =
let has_user_start =
List.exists (fun e -> e.e_name = "_start") exports_with_mem
in
let main_export =
List.find_opt (fun e -> e.e_name = "main") exports_with_mem
in
match main_export, has_user_start with
| Some { e_desc = ExportFunc main_idx; _ }, false ->
let main_local_idx = main_idx - import_offset in
if main_local_idx < 0 || main_local_idx >= List.length all_funcs then
(ctx'.types, all_funcs, exports_with_mem)
else
let main_func = List.nth all_funcs main_local_idx in
let main_type = List.nth ctx'.types main_func.f_type in
if main_type.ft_params <> [] then
(ctx'.types, all_funcs, exports_with_mem)
else
let void_ft = { ft_params = []; ft_results = [] } in
let (void_type_idx, types_after) =
intern_func_type ctx'.types void_ft
in
let drop_instrs =
List.map (fun _ -> Drop) main_type.ft_results
in
let start_func = {
f_type = void_type_idx;
f_locals = [];
f_body = Call main_idx :: drop_instrs;
} in
let funcs_after = all_funcs @ [start_func] in
let start_idx = import_offset + List.length all_funcs in
let exports_after =
exports_with_mem
@ [{ e_name = "_start"; e_desc = ExportFunc start_idx }]
in
(types_after, funcs_after, exports_after)
| _ -> (ctx'.types, all_funcs, exports_with_mem)
in

(* Stage 2: Build [affinescript.ownership] custom section from collected annotations *)
let ownership_payload = build_ownership_section ctx'.ownership_annots in
let custom_sections = if Bytes.length ownership_payload > 0 then
Expand All @@ -2685,12 +2735,12 @@ let generate_module ?loader (prog : program) : wasm_module result =
in

Ok {
types = ctx'.types;
funcs = all_funcs;
types = final_types;
funcs = final_funcs;
tables = tables;
mems = [{ mem_type = { lim_min = 1; lim_max = None } }]; (* 1 page default *)
globals = ctx'.globals;
exports = exports_with_mem;
exports = final_exports;
imports = ctx'.imports;
elems = elems;
datas = List.rev ctx'.datas; (* Reverse to get original order *)
Expand Down
77 changes: 77 additions & 0 deletions tests/componentize/command_smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MPL-2.0
#
# ADR-015 S6 gated smoke (WIT export lifting): compile an AffineScript
# program with `fn main()`, run it through `tools/componentize.sh
# --command`, and assert
# (a) the resulting component is a valid WASI-0.2 component,
# (b) it exports `wasi:cli/run@0.2.x`,
# (c) `wasmtime run` can invoke it (exit 0), and
# (d) the `affinescript.ownership` custom section survives the wrap.
#
# SKIPs cleanly (exit 0) when the component toolchain or wasmtime is
# not provisioned — opt-in, mirroring tests/componentize/smoke.sh.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT"

if ! command -v wasm-tools >/dev/null \
|| [ ! -f tools/vendor/wasi_snapshot_preview1.command.wasm ]; then
echo "SKIP: component toolchain / command adapter not provisioned (tools/provision-component-toolchain.sh)"
exit 0
fi
if ! command -v wasmtime >/dev/null; then
echo "SKIP: wasmtime not on PATH (real-host run is the S6 contract)"
exit 0
fi

COMPILER="${AFFINESCRIPT:-$ROOT/_build/default/bin/main.exe}"
[ -x "$COMPILER" ] || COMPILER="dune exec affinescript --"

work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT

# Fixture: ownership-bearing imports + a parameter-less `main` so the
# S6 codegen emits `_start` (the command adapter's lift target). The
# fn main() return value is irrelevant — the shim drops it.
cp test/e2e/fixtures/verify_ownership_clean.affine "$work/cmd.affine"
printf '\nfn main() -> Int { add(2, 3) }\n' >> "$work/cmd.affine"

has_section() {
[ "$(wasm-tools print "$1" 2>/dev/null | grep -c 'affinescript.ownership' || true)" -gt 0 ]
}

$COMPILER compile "$work/cmd.affine" -o "$work/cmd.wasm"

# Codegen contract: the unit MUST export `_start` (the S6 shim) since
# `main` is parameter-less. Catches a regression in lib/codegen.ml.
if ! wasm-tools print "$work/cmd.wasm" 2>/dev/null | grep -q '(export "_start"'; then
echo "FAIL: S6 codegen did not emit the _start shim for fn main()"
exit 1
fi

has_section "$work/cmd.wasm" \
|| { echo "FAIL: fixture did not emit the ownership section"; exit 1; }

tools/componentize.sh --command "$work/cmd.wasm" "$work/cmd.component.wasm"

wasm-tools validate --features component-model "$work/cmd.component.wasm"
has_section "$work/cmd.component.wasm" \
|| { echo "FAIL: ownership section lost through command componentization"; exit 1; }

# WIT contract: the lift must produce a `wasi:cli/run` export.
if ! wasm-tools component wit "$work/cmd.component.wasm" 2>/dev/null \
| grep -q 'export wasi:cli/run'; then
echo "FAIL: component does not export wasi:cli/run"
exit 1
fi

# Real-host invoke: the whole point of S6 — `wasmtime run` actually
# runs the program end-to-end. Exit status MUST be 0 (the shim drops
# main's result; trap-free execution is the contract).
if ! wasmtime run "$work/cmd.component.wasm"; then
echo "FAIL: wasmtime run rejected the S6 component"
exit 1
fi

echo "ADR-015 S6 command smoke: PASSED ✓"
80 changes: 64 additions & 16 deletions tools/componentize.sh
Original file line number Diff line number Diff line change
@@ -1,28 +1,52 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MPL-2.0
#
# ADR-015 S3 — the componentize on-ramp. POST-codegen, codegen
# UNCHANGED: the compiler still emits a core `wasi_snapshot_preview1`
# module (the legacy default, ADR-015 reversible-in-progress); this
# wraps that core module into a WASI-0.2 (preview2) WASM *component*
# via the official preview1->preview2 reactor adapter (reactor, not
# command: AffineScript modules export functions, not a WASI `_start`).
# ADR-015 S3 + S6 — the componentize on-ramp. POST-codegen, the OCaml
# compiler still emits a core `wasi_snapshot_preview1` module (the
# legacy default; ADR-015 reversible-in-progress through S5). This
# wrapper turns that core module into a WASI-0.2 (preview2) WASM
# *component* via the official preview1->preview2 adapter family
# (fetch-pinned + checksum-verified by provision-component-toolchain.sh):
#
# reactor (default, ADR-015 S3) — for AffineScript modules used as
# libraries (host calls plain exports like `main`). The resulting
# component instantiates but has no `wasi:cli/run`, so `wasmtime run`
# cannot invoke it; the host loads it through its own WASI bindings.
#
# command (--command, ADR-015 S6) — for AffineScript programs
# invoked as WASI commands (`wasmtime run`, jco). Requires the core
# module to export `_start : () -> ()`; the S6 codegen change in
# `lib/codegen.ml` emits this shim automatically whenever the unit
# exports a parameter-less `main`. The resulting component exports
# `wasi:cli/run@0.2.x` per `wit/affinescript.wit`.
#
# The `affinescript.ownership` custom section (the typed-wasm contract
# carrier, multi-producer ABI) MUST survive the wrap — asserted here.
# carrier, multi-producer ABI) MUST survive the wrap — asserted here
# in both modes.
#
# Usage: tools/componentize.sh <core.wasm> <out.component.wasm>
# Adapter: tools/vendor/wasi_snapshot_preview1.reactor.wasm
# (fetch-pinned + checksum-verified by provision-component-toolchain.sh;
# override with AFFINESCRIPT_WASI_ADAPTER=/path).
# Usage:
# tools/componentize.sh [--command|--reactor] <core.wasm> <out.component.wasm>
#
# Adapters: tools/vendor/wasi_snapshot_preview1.{reactor,command}.wasm
# (fetch-pinned by provision-component-toolchain.sh; override with
# AFFINESCRIPT_WASI_ADAPTER=/path to force a specific adapter file.)
set -euo pipefail

core="${1:?usage: componentize.sh <core.wasm> <out.component.wasm>}"
out="${2:?usage: componentize.sh <core.wasm> <out.component.wasm>}"
adapter="${AFFINESCRIPT_WASI_ADAPTER:-$(cd "$(dirname "$0")" && pwd)/vendor/wasi_snapshot_preview1.reactor.wasm}"
mode="reactor"
if [ "${1:-}" = "--command" ] || [ "${1:-}" = "--reactor" ]; then
mode="${1#--}"
shift
fi

core="${1:?usage: componentize.sh [--command|--reactor] <core.wasm> <out.component.wasm>}"
out="${2:?usage: componentize.sh [--command|--reactor] <core.wasm> <out.component.wasm>}"

adapter_dir="$(cd "$(dirname "$0")" && pwd)/vendor"
default_adapter="${adapter_dir}/wasi_snapshot_preview1.${mode}.wasm"
adapter="${AFFINESCRIPT_WASI_ADAPTER:-${default_adapter}}"

[ -f "$core" ] || { echo "componentize: no core module: $core" >&2; exit 2; }
[ -f "$adapter" ] || { echo "componentize: adapter missing ($adapter) — run tools/provision-component-toolchain.sh" >&2; exit 2; }
[ -f "$adapter" ] || { echo "componentize: ${mode} adapter missing ($adapter) — run tools/provision-component-toolchain.sh" >&2; exit 2; }
command -v wasm-tools >/dev/null || { echo "componentize: wasm-tools not on PATH — run tools/provision-component-toolchain.sh" >&2; exit 2; }

# Count with `grep -c` (reads ALL input, so no early-close SIGPIPE that
Expand All @@ -37,6 +61,18 @@ section_count() {
had_ownership=0
[ "$(section_count "$core")" -gt 0 ] && had_ownership=1

# Command mode requires `_start` in the core (the adapter lifts it into
# `wasi:cli/run`). Fail fast with a pointer to the codegen contract
# rather than letting wasm-tools complain a few lines deeper.
if [ "$mode" = "command" ]; then
if ! wasm-tools print "$core" 2>/dev/null | grep -q '(export "_start"'; then
echo "componentize --command: core module is missing the \`_start\` export." >&2
echo " AffineScript emits \`_start\` automatically when a parameter-less" >&2
echo " \`fn main()\` is present (ADR-015 S6 codegen, lib/codegen.ml)." >&2
exit 2
fi
fi

wasm-tools component new "$core" --adapt "wasi_snapshot_preview1=$adapter" -o "$out"
wasm-tools validate --features component-model "$out"

Expand All @@ -49,4 +85,16 @@ if [ "$had_ownership" = 1 ]; then
echo "ownership section: preserved through componentization ✓"
fi

echo "componentized -> $out (valid WASI-0.2 component)"
# Command-mode contract: assert the result actually exports
# `wasi:cli/run` (catches a regression where the adapter wraps without
# lifting, or the wrong adapter file is pointed at).
if [ "$mode" = "command" ]; then
if ! wasm-tools component wit "$out" 2>/dev/null | grep -q 'export wasi:cli/run'; then
echo "FATAL: --command output does not export wasi:cli/run" >&2
rm -f "$out"
exit 1
fi
echo "wasi:cli/run: lifted into component world ✓"
fi

echo "componentized (${mode}) -> $out (valid WASI-0.2 component)"
Loading
Loading