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
86 changes: 86 additions & 0 deletions maintenance/affinescript-wasm-ctor-link/README.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// SPDX-License-Identifier: MPL-2.0
= AffineScript WASM cross-module constructor linking — proven patch
:toc: macro

Verified fix for an *untracked* AffineScript compiler gap that blocks the
svalinn ReScript→AffineScript migration (stapeln #96).

[IMPORTANT]
====
This is *not* affinescript #138. #138 is *CLOSED* (PR #193) — it was the
resolver/typecheck half ("remove the `b895374` Some/None/Ok/Err seed; resolve
imports recursively"). This patch is the remaining *WASM-codegen* half, which
has no tracking issue yet (see "New issue to file" below).
====

== The bug

`affinescript compile <file>` (default core-WASM) on a module that imports an
enum's value constructors — e.g. `use prelude::{Option, Some, None}` — fails:

----
Code generation error: (Codegen.UnboundVariable "Function or variable not found: Some")
----

…even though `affinescript check <file>` *passes*. The WASM backend's
`gen_imports` (`lib/codegen.ml`) linked only imported `TopFn`/`TopConst`, never
`TopType`, so constructor tags were never registered in `variant_tags`;
`Some(x)` then fell through to the function-call path and errored at
`codegen.ml:1752`.

Non-WASM backends (Julia/JS/Deno/…) use `flatten_imports` + structural
constructor emission and were unaffected — and the stdlib AOT gate
(`test/test_stdlib_aot.ml`) only compiles to *Deno-ESM*, which is why this WASM
gap went uncaught while epic #128 / #131–#138 was marked "compiles through
codegen".

== The fix

`lib/codegen.ml` `gen_imports`: when an imported name is neither fn nor const,
resolve it as an imported public enum *type* or *constructor* and register that
enum's constructors into `variant_tags` with their canonical positional tags
(identical to local `TopType` handling in `gen_decl`, `codegen.ml:3202-3209`).
Glob `use M::*` also brings enum constructors. Plus a WASM-target regression
test — the coverage the Deno-only AOT gate missed.

Patch: `affinescript-wasm-ctor-link.patch` (apply at affinescript HEAD
`58dc2a0` with `git apply`). 4 files, +99/−1.

== Proof (verified locally 2026-06-21, affinescript HEAD `58dc2a0`)

* *Build*: `dune build bin/main.exe` → exit 0.
* *Before*: `compile` of a `use prelude::{Option,Some,None}` consumer →
`Codegen.UnboundVariable "… Some"`.
* *After*: same consumer compiles → `out.wasm` (382 B; valid — `WebAssembly.compile`
accepts it). Option+Result construct+match likewise.
* *Regression*: before/after `compile` diff over the 30-file `conformance` +
`tests/modules` corpus — identical (OK=16 / FAIL=14 both; zero `OK→FAIL`).
* *Full suite*: `dune runtest` → *458 tests green*, including the new
"WASM gen_imports links imported constructors" case.

== New affinescript issue to file (the proper tracking)

[%hardbreaks]
*Title*: WASM codegen does not link cross-module enum constructors (`gen_imports` drops `TopType`)
*Body*:
`affinescript compile` (core-WASM / WASM-GC) of a module importing an enum's
value constructors (`use prelude::{Option, Some, None}`) raises
`Codegen.UnboundVariable "… Some"` although `check` passes. Root cause:
`lib/codegen.ml` `gen_imports` registers imported `TopFn`/`TopConst` only;
imported `TopType` constructors never reach `variant_tags`. #138 / PR #193 fixed
the resolver half; this is the WASM-codegen half. The stdlib AOT gate only
exercises Deno-ESM, so the WASM path is unguarded. Fix + WASM-target regression
test attached (proven at HEAD `58dc2a0`); also widen the AOT gate to a WASM
target so this can't silently regress. `Closes #<this issue>`.

== Apply

----
cd <affinescript>
git checkout main && git pull
git apply maintenance/affinescript-wasm-ctor-link/affinescript-wasm-ctor-link.patch
dune build bin/main.exe && dune runtest
----

Or run the bundled `repro.sh <path-to-affinescript-checkout>` for the
before/after compile.
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
From ae956929613f72a3f90bb7931c81070e7bcea240 Mon Sep 17 00:00:00 2001
From: hyperpolymath <paraordinate@yahoo.co.uk>
Date: Sun, 21 Jun 2026 02:24:56 +0000
Subject: [PATCH] fix(codegen): link imported enum constructors in WASM
gen_imports
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

A module importing an enum's value constructors (use M::{Some, None}) type-checks
but failed to compile to WASM with Codegen.UnboundVariable "Function or variable
not found: Some": the WASM backend's gen_imports linked only imported TopFn/
TopConst, never TopType, so constructor tags were never registered in
variant_tags. Non-WASM backends use flatten_imports + structural emission and
were unaffected; the stdlib AOT gate only exercises the Deno-ESM backend, which
is why this WASM gap went uncaught.

#138 (PR #193) fixed the resolver/typecheck half (constructors resolve through
the module path); this fixes the remaining WASM-codegen half. gen_imports now
registers imported public enum constructors into variant_tags with their
canonical positional tags (mirroring local TopType handling in gen_decl), and
glob 'use M::*' brings enum constructors too. Adds a WASM-target cross-module
constructor regression test — the coverage the Deno-only AOT gate missed.
---
lib/codegen.ml | 46 +++++++++++++++++++++-
test/e2e/fixtures/CtorCallee.affine | 10 +++++
test/e2e/fixtures/cross_ctor_caller.affine | 23 +++++++++++
test/test_e2e.ml | 21 ++++++++++
4 files changed, 99 insertions(+), 1 deletion(-)
create mode 100644 test/e2e/fixtures/CtorCallee.affine
create mode 100644 test/e2e/fixtures/cross_ctor_caller.affine

diff --git a/lib/codegen.ml b/lib/codegen.ml
index 2b60b74..d362fce 100644
--- a/lib/codegen.ml
+++ b/lib/codegen.ml
@@ -3288,7 +3288,45 @@ let gen_imports (loader : Module_loader.t) (imports : import_decl list) (ctx : c
| _ -> None
) loaded.mod_program.prog_decls in
match item with
- | None -> Ok ctx
+ | None ->
+ (* #138 follow-up — cross-module value CONSTRUCTORS.
+ The name is neither a fn nor a const, so it is either an imported
+ enum TYPE (`use prelude::{Option}`) or one of its value
+ constructors (`use prelude::{Some, None}`). Variant constructors
+ are not host-imported into WASM — the backend lowers them to
+ integer tags looked up in [variant_tags] (the `ExprVar … when
+ List.mem_assoc … variant_tags` construction path, and `gen_pattern`
+ match arms). #138 (PR #193) made the *resolver* resolve these
+ through the module path, but [gen_imports] never registered the
+ tags for WASM codegen, so `Some(x)` fell through to the call path
+ and raised `UnboundVariable "… Some"`. Register the whole enum's
+ constructors with their canonical positional tags — identical to
+ the local `TopType (TyEnum …)` handling in [gen_decl] — so
+ construction sites and match arms agree on tags. *)
+ let register_enum c variants =
+ List.fold_left (fun c_acc (idx, vd) ->
+ if List.mem_assoc vd.vd_name.name c_acc.variant_tags then c_acc
+ else { c_acc with
+ variant_tags = (vd.vd_name.name, idx) :: c_acc.variant_tags }
+ ) c (List.mapi (fun i v -> (i, v)) variants)
+ in
+ let imported_enum_variants =
+ List.find_map (function
+ | TopType td
+ when (td.td_vis = Public || td.td_vis = PubCrate) ->
+ (match td.td_body with
+ | TyEnum variants
+ when td.td_name.name = orig_name
+ || List.exists
+ (fun vd -> vd.vd_name.name = orig_name) variants ->
+ Some variants
+ | _ -> None)
+ | _ -> None
+ ) loaded.mod_program.prog_decls
+ in
+ (match imported_enum_variants with
+ | Some variants -> Ok (register_enum ctx variants)
+ | None -> Ok ctx)
| Some (`Fn fd) ->
let ft = func_type_of_fn_decl fd in
let (type_idx, types_after) = intern_func_type ctx.types ft in
@@ -3336,6 +3374,12 @@ let gen_imports (loader : Module_loader.t) (imports : import_decl list) (ctx : c
| TopConst { tc_vis; tc_name; _ }
when tc_vis = Public || tc_vis = PubCrate ->
Some (p, tc_name.name, None)
+ | TopType td when (td.td_vis = Public || td.td_vis = PubCrate) ->
+ (* #138 follow-up: glob `use M::*` also brings enum constructors.
+ Emit the type name; [process_one] expands to all constructors. *)
+ (match td.td_body with
+ | TyEnum _ -> Some (p, td.td_name.name, None)
+ | _ -> None)
| _ -> None
) lm.mod_program.prog_decls)
in
diff --git a/test/e2e/fixtures/CtorCallee.affine b/test/e2e/fixtures/CtorCallee.affine
new file mode 100644
index 0000000..632a7a9
--- /dev/null
+++ b/test/e2e/fixtures/CtorCallee.affine
@@ -0,0 +1,10 @@
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-FileCopyrightText: 2026 hyperpolymath
+//
+// Callee module exporting an enum whose value constructors are imported
+// across a module boundary by cross_ctor_caller.affine. Mixed arity
+// (Circle/Square carry a payload; Dot is nullary) exercises both the
+// heap-boxed and the bare-tag construction paths in the WASM backend.
+module CtorCallee;
+
+pub type Shape = Circle(Int) | Square(Int) | Dot
diff --git a/test/e2e/fixtures/cross_ctor_caller.affine b/test/e2e/fixtures/cross_ctor_caller.affine
new file mode 100644
index 0000000..f683b13
--- /dev/null
+++ b/test/e2e/fixtures/cross_ctor_caller.affine
@@ -0,0 +1,23 @@
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-FileCopyrightText: 2026 hyperpolymath
+//
+// Cross-module enum CONSTRUCTOR import — WASM codegen regression fixture.
+// Imports a sibling module's enum type AND its value constructors, then both
+// constructs (Circle/Square/Dot) and matches on them. Before the gen_imports
+// fix, the WASM backend never registered imported constructor tags, so
+// `Circle(x)` fell through to the call path -> Codegen.UnboundVariable.
+// (#138 fixed the resolver half; this exercises the WASM-codegen half.)
+module cross_ctor_caller;
+use CtorCallee::{Shape, Circle, Square, Dot};
+
+pub fn mk(x: Int) -> Shape { Circle(x) }
+
+pub fn dot() -> Shape { Dot }
+
+pub fn classify(s: Shape) -> Int {
+ match s {
+ Circle(r) => r,
+ Square(w) => w,
+ Dot => 0
+ }
+}
diff --git a/test/test_e2e.ml b/test/test_e2e.ml
index 481f9cf..42ed6d1 100644
--- a/test/test_e2e.ml
+++ b/test/test_e2e.ml
@@ -3445,11 +3445,32 @@ let test_wasm_cross_module_const_compiles () =
| Error e, _ -> Alcotest.fail ("callee compile failed: " ^ e)
| _, Error e -> Alcotest.fail ("caller compile failed (regression for #107): " ^ e)

+let test_wasm_cross_module_constructor_compiles () =
+ (* Regression: imported enum value constructors (`use CtorCallee::{Circle,
+ Square, Dot}`) must be linked into WASM codegen. Before the fix,
+ [gen_imports] skipped imported [TopType], so the constructor tags were
+ never registered in [variant_tags] and `Circle(x)` fell through to the
+ call path, raising Codegen.UnboundVariable "Function or variable not
+ found: Circle". #138 (PR #193) fixed the resolver half; this is the
+ WASM-codegen half. *)
+ match compile_fixture_to_wasm (fixture "cross_ctor_caller.affine") with
+ | Ok m ->
+ (* Reaching Ok means codegen lowered the imported constructors (construct +
+ match, mixed arity) without the UnboundVariable early-out; the unit's
+ three public fns emit. *)
+ Alcotest.(check bool)
+ "caller compiles with imported constructors (>=3 funcs emitted)"
+ true (List.length m.funcs >= 3)
+ | Error e ->
+ Alcotest.fail
+ ("cross-module constructor compile failed (WASM constructor-link regression): " ^ e)
+
let cross_module_other_codegens_tests = [
Alcotest.test_case "flatten_imports inlines imported public fns" `Quick test_flatten_imports_inlines_public_fns;
Alcotest.test_case "flatten_imports: local def shadows imported, no dup" `Quick test_flatten_imports_dedup_local_wins;
Alcotest.test_case "flatten_imports inlines imported public consts (#107)" `Quick test_flatten_imports_inlines_public_const;
Alcotest.test_case "WASM gen_imports threads imported consts (#107)" `Quick test_wasm_cross_module_const_compiles;
+ Alcotest.test_case "WASM gen_imports links imported constructors" `Quick test_wasm_cross_module_constructor_compiles;
]

(* ---- extern declarations (issues-drafts/04) ----
--
2.43.0

34 changes: 34 additions & 0 deletions maintenance/affinescript-wasm-ctor-link/repro.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MPL-2.0
# Reproduce + verify the AffineScript WASM cross-module constructor fix.
#
# Usage: repro.sh <path-to-affinescript-checkout>
# Builds the compiler, then compiles a module that imports prelude's Option
# constructors. Pre-fix this fails with Codegen.UnboundVariable "… Some";
# post-fix (apply affinescript-wasm-ctor-link.patch) it compiles to WASM.
set -euo pipefail

AS="${1:?usage: repro.sh <path-to-affinescript-checkout>}"
cd "$AS"

echo "== build compiler =="
dune build bin/main.exe
BIN="./_build/default/bin/main.exe"
export AFFINESCRIPT_STDLIB="$AS/stdlib"

tmp="$(mktemp -d)"
cat > "$tmp/consumer.affine" <<'EOF'
module consumer;
use prelude::{Option, Some, None};
pub fn wrap(x: Int) -> Option<Int> { Some(x) }
pub fn empty() -> Option<Int> { None }
EOF

echo "== check (expect: Type checking passed) =="
"$BIN" check "$tmp/consumer.affine"

echo "== compile (pre-fix: UnboundVariable \"Some\"; post-fix: out.wasm) =="
"$BIN" compile "$tmp/consumer.affine"

echo "== full regression gate =="
dune runtest
Loading