Skip to content

Commit 4166c4e

Browse files
Merge branch 'main' into claude/upbeat-gauss-D5f72
2 parents 8b040aa + 828cf82 commit 4166c4e

17 files changed

Lines changed: 582 additions & 24 deletions

docs/ECOSYSTEM.adoc

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -220,11 +220,14 @@ builtins lower to on-demand `wasi_snapshot_preview1.*` imports
220220
`ctx.wasi_func_indices`; zero impact on units that don't use them;
221221
verified with a multi-import combo regression). Component path
222222
bridges to `wasi:clocks`/`wasi:cli`. Real-host main-invoke deferred
223-
to S6 (WIT export-lifting / wasi:cli/run command shape). String
224-
accessors (env_at/arg_at) gated on a byte-level wasm-IR extension
225-
(I32Load8U/I32Store8 absent today) — tracked as the next slice
226-
before/with S5 filesystem.** WIT world of
227-
record: `wit/affinescript.wit`
223+
to S6 (WIT export-lifting / wasi:cli/run command shape).
224+
**S5 string accessors (env_at/arg_at) DONE: the wasm IR gained
225+
the byte-level load/store family (I32Load8U/I32Store8 + the full
226+
WebAssembly 1.0 §5.4.6 row, opcodes 0x2C..0x35 / 0x3A..0x3E);
227+
accessors lower to on-demand `environ_get`/`args_get` paired with
228+
the existing `*_sizes_get` import (dedup keeps each WASI import
229+
once even when both `*_count` and `*_at` are used).** WIT world
230+
of record: `wit/affinescript.wit`
228231
|INT-04 |Publish compiler + runtime to JSR (then npm) |#181 |runtime
229232
packaging READY (affine-js + affinescript-tea JSR dry-run green;
230233
manual-only `publish-jsr.yml`; docs/PACKAGING.adoc). INT-01 dep

docs/TECH-DEBT.adoc

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -189,10 +189,13 @@ lowers to `{__cell: x}` single-field object (`lib/codegen_deno.ml`);
189189
3 hermetic e2e tests in "E2E STDLIB-04a Mut #328" (Int + String
190190
round-trips + Deno codegen __cell-shape assertion) |S3 |DONE
191191
2026-05-24 (Refs #328)
192-
|STDLIB-04b |Throws extern `error<T>` — missing in all backends
193-
(`lib/interp.ml`, `lib/js_codegen.ml`, `lib/codegen_deno.ml`); sibling
194-
`panic` is wired and `error` should mirror it (divergent `T`) |S3 |open
195-
— issue #329
192+
|STDLIB-04b |Throws extern `error<T>` — *LANDED* (Refs #329): mirrors
193+
`panic`'s divergent semantics with a polymorphic return (`<T>` unifies
194+
with the call-site expectation, unobservable at runtime). Wired in
195+
interp (`RuntimeError`), Deno codegen (`throw new Error`), resolve
196+
seed, and typecheck as a scheme (`poly1` so each call instantiates a
197+
fresh tyvar). 3 hermetic tests in "E2E STDLIB-04b error #329". |S3
198+
|DONE 2026-05-24 (Refs #329)
196199
|STDLIB-04c |`string_concat` extern — no direct wiring found; `++`
197200
operator independently lowered. Decide: remove (operator-only) or wire
198201
to mirror `++` |S3 |open — issue #330
@@ -240,11 +243,14 @@ Component-Model re-target, S1..S6); S2 toolchain #251 closed;
240243
S3 componentize done; **S4a (clock) + S4b (env_count, arg_count)
241244
DONE — on-demand preview1 imports via Effect_sites pre-scan,
242245
canonical-order indexing through `ctx.wasi_func_indices`; combo
243-
regression proves no collision. String accessors (env_at/arg_at)
244-
gated on byte-level wasm IR (I32Load8U/I32Store8 absent today) —
245-
tracked next slice. Real-host main-invoke = S6 (WIT export
246-
lifting). Next S5
247-
(native clocks/env/argv)**
246+
regression proves no collision. **S5 (env_at/arg_at) DONE — wasm
247+
IR extended with the byte-level load/store family
248+
(I32Load8U/I32Store8 and siblings); accessors lower to on-demand
249+
`environ_get`/`args_get` imports paired with the existing
250+
`*_sizes_get` (dedup keeps each WASI import exactly once even when
251+
both `*_count` and `*_at` are used in the same unit); guest
252+
allocates a length-prefixed AS string and byte-copies from the
253+
WASI buffer.** Real-host main-invoke = S6 (WIT export lifting)
248254
|INT-04 |Publish to JSR/npm |S2 |#181 packaging READY (dry-run green,
249255
manual workflow); compiler-binary distribution decided = **ADR-019**
250256
(#260, Releases + thin Deno/JSR shim, staged S1..S4) — S1/S2/S3

lib/codegen.ml

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -873,8 +873,9 @@ let rec gen_expr (ctx : context) (expr : expr) : (context * instr list) result =
873873
(added on-demand at module assembly; idx looked up in
874874
[ctx.wasi_func_indices]). The Unit arg satisfies the
875875
zero-param-fn collapse wart; it is evaluated but its value
876-
is unused. String accessors (env_at/arg_at) need byte-level
877-
wasm IR ops (currently absent) and are a tracked follow-up. *)
876+
is unused. The companion string accessors `env_at`/`arg_at`
877+
landed alongside the byte-level wasm IR extension — see
878+
the case below. *)
878879
let wasi_name =
879880
if id.name = "env_count" then "environ_sizes_get"
880881
else "args_sizes_get"
@@ -894,6 +895,47 @@ let rec gen_expr (ctx : context) (expr : expr) : (context * instr list) result =
894895
in
895896
Ok (ctx_with_heap, code)
896897

898+
| ExprVar id when (id.name = "env_at" || id.name = "arg_at")
899+
&& List.length args = 1 ->
900+
(* ADR-015 S5 (#180): env_at(i: Int) / arg_at(i: Int) -> String.
901+
Allocates a length-prefixed AS string and byte-copies the
902+
i-th null-terminated entry from the WASI environ/argv
903+
buffer. Uses [I32Load8U]/[I32Store8] (the byte-level wasm
904+
IR extension landed alongside this slice). Pairs the
905+
existing on-demand `*_sizes_get` import with the matching
906+
`environ_get`/`args_get` import (registered above in the
907+
`optional_wasi` table; deduped by wasi name). *)
908+
let sizes_name, get_name =
909+
if id.name = "env_at" then "environ_sizes_get", "environ_get"
910+
else "args_sizes_get", "args_get"
911+
in
912+
let sizes_func_idx =
913+
try List.assoc sizes_name ctx.wasi_func_indices
914+
with Not_found -> 1
915+
in
916+
let get_func_idx =
917+
try List.assoc get_name ctx.wasi_func_indices
918+
with Not_found -> 2
919+
in
920+
let* (ctx0, arg_code) = gen_expr ctx (List.hd args) in
921+
let (c1, n_local) = alloc_local ctx0 ("__" ^ id.name ^ "_n") in
922+
let (c2, scratch_local) = alloc_local c1 ("__" ^ id.name ^ "_scratch") in
923+
let (c3, count_local) = alloc_local c2 ("__" ^ id.name ^ "_count") in
924+
let (c4, bufsize_local) = alloc_local c3 ("__" ^ id.name ^ "_bufsize") in
925+
let (c5, ptrvec_local) = alloc_local c4 ("__" ^ id.name ^ "_ptrvec") in
926+
let (c6, src_local) = alloc_local c5 ("__" ^ id.name ^ "_src") in
927+
let (c7, dst_local) = alloc_local c6 ("__" ^ id.name ^ "_dst") in
928+
let (c8, result_local) = alloc_local c7 ("__" ^ id.name ^ "_result") in
929+
let (ctx_with_heap, heap_idx) = ensure_heap_ptr c8 in
930+
let code =
931+
arg_code @
932+
Wasi_runtime.gen_str_at_via_get
933+
heap_idx n_local scratch_local count_local bufsize_local
934+
ptrvec_local src_local dst_local result_local
935+
sizes_func_idx get_func_idx
936+
in
937+
Ok (ctx_with_heap, code)
938+
897939
| ExprVar id when List.mem_assoc id.name ctx.variant_tags ->
898940
(* Enum constructor called as a function: Circle(5), Rect({x:1,y:2}), etc.
899941
Layout: [tag: i32][field1: i32][field2: i32]...
@@ -2545,13 +2587,25 @@ let generate_module ?loader (prog : program) : wasm_module result =
25452587
false prog
25462588
in
25472589
let optional_wasi =
2548-
(* (guest_builtin_name, wasi_import_name, factory) — canonical order. *)
2590+
(* (guest_builtin_name, wasi_import_name, factory) — canonical order.
2591+
Multiple builtins MAY require the same WASI import (e.g. both
2592+
`env_count` and `env_at` need `environ_sizes_get`); the dedup
2593+
pass below keeps the first occurrence so each wasm import shows
2594+
up exactly once with a stable index. *)
25492595
[ ("clock_now_ms", "clock_time_get", Wasi_runtime.create_clock_time_get_import);
25502596
("env_count", "environ_sizes_get", Wasi_runtime.create_environ_sizes_get_import);
25512597
("arg_count", "args_sizes_get", Wasi_runtime.create_args_sizes_get_import);
2598+
("env_at", "environ_sizes_get", Wasi_runtime.create_environ_sizes_get_import);
2599+
("env_at", "environ_get", Wasi_runtime.create_environ_get_import);
2600+
("arg_at", "args_sizes_get", Wasi_runtime.create_args_sizes_get_import);
2601+
("arg_at", "args_get", Wasi_runtime.create_args_get_import);
25522602
]
2553-
|> List.filter_map
2554-
(fun (b, w, f) -> if uses b then Some (w, f ()) else None)
2603+
|> List.filter (fun (b, _, _) -> uses b)
2604+
|> List.fold_left
2605+
(fun acc (_, w, f) ->
2606+
if List.exists (fun (w', _) -> w' = w) acc then acc
2607+
else acc @ [(w, f ())])
2608+
[]
25552609
|> List.mapi (fun i (w, (imp, ty)) -> (i + 1, w, imp, ty))
25562610
in
25572611
let opt_types = List.map (fun (_, _, _, ty) -> ty) optional_wasi in

lib/codegen_deno.ml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,10 @@ let () =
275275
b "int_to_char" (fun a -> Printf.sprintf "__as_intToChar(%s)" (arg 0 a));
276276
b "show" (fun a -> Printf.sprintf "__as_show(%s)" (arg 0 a));
277277
b "panic" (fun a -> Printf.sprintf "(() => { throw new Error(%s); })()" (arg 0 a));
278+
(* STDLIB-04b (Refs #329): `error<T>` is panic's polymorphic sibling.
279+
Same divergent runtime semantics (throw); the polymorphic return
280+
type is unobservable. *)
281+
b "error" (fun a -> Printf.sprintf "(() => { throw new Error(%s); })()" (arg 0 a));
278282
(* Mut effect builtins (STDLIB-04a, Refs #328) — runtime mutable cells.
279283
Distinct from borrow-checker [&]/[&mut] references: these back the
280284
[stdlib/effects.affine] [Ref<T>] type declared `/ Mut`. Lowered as

lib/interp.ml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,16 @@ let create_initial_env () : env =
761761
| [VString msg] -> Error (RuntimeError msg)
762762
| _ -> Error (RuntimeError "panic!")
763763
));
764+
(* STDLIB-04b (Refs #329): `error<T>(msg)` is panic's polymorphic
765+
sibling. Same divergent runtime semantics (RuntimeError); the
766+
polymorphic return type is unobservable because the call never
767+
returns. Backs the `extern fn error<T>(msg: String) -> T / Throws`
768+
in stdlib/effects.affine. *)
769+
("error", VBuiltin ("error", fun args ->
770+
match args with
771+
| [VString msg] -> Error (RuntimeError msg)
772+
| _ -> Error (RuntimeError "error!")
773+
));
764774
("read_file", VBuiltin ("read_file", fun args ->
765775
match args with
766776
| [VString path] ->

lib/resolve.ml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ let seed_builtins (symbols : Symbol.t) : unit =
7676
def "getenv"; def "setenv"; def "getcwd"; def "chdir";
7777
def "list_dir"; def "create_dir"; def "remove_file"; def "remove_dir";
7878
def "panic"; def "exit";
79+
(* STDLIB-04b (Refs #329): divergent throw with polymorphic return *)
80+
def "error";
7981
(* Time *)
8082
def "time_now";
8183
(* TEA runtime — The Elm Architecture interpreter loop *)

lib/typecheck.ml

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1325,11 +1325,18 @@ let register_builtins (ctx : context) : unit =
13251325
(TArrow (ty_int, QOmega, ty_int, ESingleton "Time"));
13261326
(* ADR-015 S4b (#180): WASI environment / argv COUNTS. The Unit arg
13271327
satisfies the zero-param-fn collapse wart (`fn()->T` lowers to
1328-
bare `T`; callable zero-arg builtins take `Unit -> R`). String
1329-
accessors (env_at/arg_at) need byte-level wasm IR ops — tracked
1330-
follow-up. Effect row `Time` (reserved). *)
1328+
bare `T`; callable zero-arg builtins take `Unit -> R`).
1329+
Effect row `Time` (reserved). *)
13311330
bind_var ctx "env_count" (TArrow (ty_unit, QOmega, ty_int, ESingleton "Time"));
13321331
bind_var ctx "arg_count" (TArrow (ty_unit, QOmega, ty_int, ESingleton "Time"));
1332+
(* ADR-015 S5 (#180): WASI environment / argv STRING ACCESSORS. Returns
1333+
the i-th entry as a length-prefixed AS string. Lowered via
1334+
`environ_get`/`args_get` + a byte-level scan + byte-copy, which
1335+
became expressible once `I32Load8U`/`I32Store8` joined the wasm IR.
1336+
Index out-of-bounds is UB at this layer — the guest is expected to
1337+
bound-check against `env_count(())`/`arg_count(())`. *)
1338+
bind_var ctx "env_at" (TArrow (ty_int, QOmega, ty_string, ESingleton "Time"));
1339+
bind_var ctx "arg_at" (TArrow (ty_int, QOmega, ty_string, ESingleton "Time"));
13331340
bind_var ctx "eprint" (TArrow (ty_string, QOmega, ty_unit, ESingleton "IO"));
13341341
bind_var ctx "eprintln" (TArrow (ty_string, QOmega, ty_unit, ESingleton "IO"));
13351342
bind_var ctx "read_line"
@@ -1449,6 +1456,13 @@ let register_builtins (ctx : context) : unit =
14491456
bind_var ctx "atan2"
14501457
(TArrow (ty_float, QOmega, TArrow (ty_float, QOmega, ty_float, EPure), EPure));
14511458
bind_var ctx "panic" (TArrow (ty_string, QOmega, ty_never, EPure));
1459+
(* STDLIB-04b (Refs #329): `error<T>(msg)` is panic's polymorphic sibling
1460+
— diverges, but unifies with whatever return type the call site
1461+
expects (`T` is unobservable at runtime because the call doesn't
1462+
return). Bound as a scheme so each call instantiates a fresh tyvar,
1463+
same pattern as `len`/`show`/`RuntimeError`. *)
1464+
bind_scheme ctx "error"
1465+
(poly1 (fun a -> TArrow (ty_string, QOmega, a, EPure)));
14521466
bind_var ctx "exit" (TArrow (ty_int, QOmega, ty_never, ESingleton "IO"));
14531467
(* TEA runtime — accepts any record, returns unit with IO effect *)
14541468
let tea_tv = fresh_tyvar 0 in

lib/wasi_runtime.ml

Lines changed: 158 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -359,8 +359,8 @@ let gen_print_str (heap_ptr_global : int) (str_ptr_local : int) (fd_write_idx :
359359
Signature: `(envc_out: i32, envbuf_size_out: i32) -> errno: i32`.
360360
Writes the env-var count and the total byte size of the
361361
null-terminated `KEY=VAL\0…` buffer the next call would need.
362-
String accessor (`env_at`) is gated on byte-level wasm IR ops,
363-
deferred to a follow-up slice. *)
362+
Paired with `environ_get` (created by
363+
{!create_environ_get_import}) for the `env_at` string accessor. *)
364364
let create_environ_sizes_get_import () : import * func_type =
365365
let func_type = {
366366
ft_params = [I32; I32]; (* envc_out_ptr, envbuf_size_out_ptr *)
@@ -413,3 +413,159 @@ let gen_count_via_sizes_get
413413
LocalGet scratch_local;
414414
I32Load (2, 0);
415415
]
416+
417+
(** Create the WASI `environ_get` import (ADR-015 S5, #180).
418+
Signature: `(environ_ptr_ptr: i32, environ_buf_ptr: i32) -> errno: i32`.
419+
Fills two regions: a vector of pointers (one per env-var, written
420+
at `environ_ptr_ptr`) and a contiguous buffer of null-terminated
421+
`KEY=VAL` strings (written at `environ_buf_ptr`). The sizes that
422+
must be allocated are reported by `environ_sizes_get`. *)
423+
let create_environ_get_import () : import * func_type =
424+
let func_type = {
425+
ft_params = [I32; I32]; (* environ_ptr_ptr, environ_buf_ptr *)
426+
ft_results = [I32]; (* errno *)
427+
} in
428+
let import = {
429+
i_module = "wasi_snapshot_preview1";
430+
i_name = "environ_get";
431+
i_desc = ImportFunc 0;
432+
} in
433+
(import, func_type)
434+
435+
(** Create the WASI `args_get` import (ADR-015 S5, #180).
436+
Signature: `(argv_ptr_ptr: i32, argv_buf_ptr: i32) -> errno: i32`.
437+
Same shape as `environ_get`. *)
438+
let create_args_get_import () : import * func_type =
439+
let func_type = {
440+
ft_params = [I32; I32];
441+
ft_results = [I32];
442+
} in
443+
let import = {
444+
i_module = "wasi_snapshot_preview1";
445+
i_name = "args_get";
446+
i_desc = ImportFunc 0;
447+
} in
448+
(import, func_type)
449+
450+
(** Emit `env_at(i)` / `arg_at(i)`: fetch the i-th entry from the WASI
451+
environ/argv vector and return it as a length-prefixed AffineScript
452+
string. Sequence:
453+
1. `*_sizes_get(&count, &bufsize)`
454+
2. allocate `count*4` bytes for the pointer vector + `bufsize`
455+
bytes for the string buffer
456+
3. `*_get(ptrvec, ptrvec + count*4)`
457+
4. resolve `src = ptrvec[i]`
458+
5. scan `src` for the null terminator to compute length
459+
6. allocate `(4 + length)` bytes for the result string,
460+
store length at +0, byte-copy `src..src+length` to `result+4`
461+
7. leave the result pointer on the stack
462+
463+
The byte loops use `I32Load8U`/`I32Store8` (added with the
464+
byte-level wasm IR extension). The caller has placed the index `i`
465+
on the stack; this helper consumes it via [LocalSet n_local].
466+
467+
All locals must be pre-allocated by the caller (8 in total). The
468+
helper itself does not modify the type or scope context — it only
469+
emits instructions. *)
470+
let gen_str_at_via_get
471+
(heap_ptr_global : int)
472+
(n_local : int)
473+
(scratch_local : int)
474+
(count_local : int)
475+
(bufsize_local : int)
476+
(ptrvec_local : int)
477+
(src_local : int)
478+
(dst_local : int)
479+
(result_local : int)
480+
(sizes_func_idx : int)
481+
(get_func_idx : int)
482+
: instr list =
483+
[
484+
(* Index `i` is on the stack from the caller's arg_code. *)
485+
LocalSet n_local;
486+
487+
(* --- Phase 1: sizes_get -> count, bufsize --- *)
488+
GlobalGet heap_ptr_global;
489+
I32Const 8l; I32Add;
490+
GlobalSet heap_ptr_global;
491+
GlobalGet heap_ptr_global;
492+
I32Const 8l; I32Sub;
493+
LocalSet scratch_local;
494+
LocalGet scratch_local; (* count_ptr *)
495+
LocalGet scratch_local; I32Const 4l; I32Add; (* bufsize_ptr *)
496+
Call sizes_func_idx;
497+
Drop;
498+
LocalGet scratch_local; I32Load (2, 0); LocalSet count_local;
499+
LocalGet scratch_local; I32Load (2, 4); LocalSet bufsize_local;
500+
501+
(* --- Phase 2: allocate ptrvec (count*4) + bytebuf (bufsize) --- *)
502+
GlobalGet heap_ptr_global;
503+
LocalSet ptrvec_local;
504+
GlobalGet heap_ptr_global;
505+
LocalGet count_local; I32Const 4l; I32Mul;
506+
LocalGet bufsize_local; I32Add;
507+
I32Add;
508+
GlobalSet heap_ptr_global;
509+
510+
(* --- Phase 3: get(ptrvec, ptrvec + count*4) --- *)
511+
LocalGet ptrvec_local;
512+
LocalGet ptrvec_local; LocalGet count_local; I32Const 4l; I32Mul; I32Add;
513+
Call get_func_idx;
514+
Drop;
515+
516+
(* --- Phase 4: src = *(ptrvec + i*4) --- *)
517+
LocalGet ptrvec_local;
518+
LocalGet n_local; I32Const 4l; I32Mul; I32Add;
519+
I32Load (2, 0);
520+
LocalSet src_local;
521+
522+
(* --- Phase 5: scan for null terminator. Use scratch as cursor. --- *)
523+
LocalGet src_local; LocalSet scratch_local;
524+
Block (BtEmpty, [
525+
Loop (BtEmpty, [
526+
LocalGet scratch_local;
527+
I32Load8U (0, 0);
528+
I32Eqz; BrIf 1; (* exit on 0 byte *)
529+
LocalGet scratch_local; I32Const 1l; I32Add;
530+
LocalSet scratch_local;
531+
Br 0
532+
])
533+
]);
534+
(* length = cursor - src (excludes the null terminator).
535+
Stash it back into count_local, which we are done with. *)
536+
LocalGet scratch_local; LocalGet src_local; I32Sub;
537+
LocalSet count_local;
538+
539+
(* --- Phase 6: allocate (4 + length) for the AS string --- *)
540+
GlobalGet heap_ptr_global;
541+
LocalSet result_local;
542+
GlobalGet heap_ptr_global;
543+
I32Const 4l; LocalGet count_local; I32Add;
544+
I32Add;
545+
GlobalSet heap_ptr_global;
546+
547+
(* Store length at result+0. *)
548+
LocalGet result_local;
549+
LocalGet count_local;
550+
I32Store (2, 0);
551+
552+
(* --- Phase 7: byte-copy src..src+length -> result+4 ---
553+
Reuses scratch as src cursor and count_local as the loop count. *)
554+
LocalGet src_local; LocalSet scratch_local;
555+
LocalGet result_local; I32Const 4l; I32Add; LocalSet dst_local;
556+
Block (BtEmpty, [
557+
Loop (BtEmpty, [
558+
LocalGet count_local; I32Eqz; BrIf 1;
559+
LocalGet dst_local;
560+
LocalGet scratch_local; I32Load8U (0, 0);
561+
I32Store8 (0, 0);
562+
LocalGet scratch_local; I32Const 1l; I32Add; LocalSet scratch_local;
563+
LocalGet dst_local; I32Const 1l; I32Add; LocalSet dst_local;
564+
LocalGet count_local; I32Const 1l; I32Sub; LocalSet count_local;
565+
Br 0
566+
])
567+
]);
568+
569+
(* --- Result: leave the string pointer on the stack. --- *)
570+
LocalGet result_local;
571+
]

0 commit comments

Comments
 (0)