diff --git a/.github/workflows/svalinn-affine-build.yml b/.github/workflows/svalinn-affine-build.yml index 06456c6e..891bd819 100644 --- a/.github/workflows/svalinn-affine-build.yml +++ b/.github/workflows/svalinn-affine-build.yml @@ -28,7 +28,7 @@ permissions: env: # Pinned to the same commit the svalinn Containerfile uses. - AFFINESCRIPT_REF: d2875a552f1d389b4a60c4adfdc02ae53e36aca3 + AFFINESCRIPT_REF: 58dc2a0bdfcd78bcc3448fe5a1785e2128adc005 jobs: affine-build: @@ -61,6 +61,10 @@ jobs: git clone https://github.com/hyperpolymath/affinescript.git /tmp/affinescript cd /tmp/affinescript git checkout "${AFFINESCRIPT_REF}" + # Carry the vetted WASM cross-module constructor-linking fix (gen_imports + # links imported enum constructors) until it lands upstream in affinescript; + # then drop this apply and bump AFFINESCRIPT_REF to the merged SHA. + git apply "${GITHUB_WORKSPACE}/container-stack/svalinn/patches/affinescript-wasm-ctor-link.patch" opam install --deps-only -y . eval "$(opam env)" dune build --release diff --git a/container-stack/svalinn/AFFINE-MIGRATION-TASK.md b/container-stack/svalinn/AFFINE-MIGRATION-TASK.md index 124a732e..4d954532 100644 --- a/container-stack/svalinn/AFFINE-MIGRATION-TASK.md +++ b/container-stack/svalinn/AFFINE-MIGRATION-TASK.md @@ -29,7 +29,10 @@ Commit per logical module; push; keep the PR draft until all gates pass. ```bash # affinescript compiler — PIN must match Containerfile + svalinn-affine-build.yml git clone https://github.com/hyperpolymath/affinescript.git /tmp/affinescript -cd /tmp/affinescript && git checkout d2875a552f1d389b4a60c4adfdc02ae53e36aca3 +cd /tmp/affinescript && git checkout 58dc2a0bdfcd78bcc3448fe5a1785e2128adc005 +# Carry the vetted WASM cross-module constructor-linking fix until it lands +# upstream in affinescript (then drop this apply + bump the pin to the merged SHA): +git apply /container-stack/svalinn/patches/affinescript-wasm-ctor-link.patch opam install --deps-only -y . && eval "$(opam env)" && dune build --release export AFFINESCRIPT_BIN=/tmp/affinescript/_build/install/default/bin/affinescript diff --git a/container-stack/svalinn/Containerfile b/container-stack/svalinn/Containerfile index 83b9ad0f..0f0377cf 100644 --- a/container-stack/svalinn/Containerfile +++ b/container-stack/svalinn/Containerfile @@ -14,7 +14,7 @@ # # Upstream tool repos are pinned by commit for reproducibility. -ARG AFFINESCRIPT_REF=d2875a552f1d389b4a60c4adfdc02ae53e36aca3 +ARG AFFINESCRIPT_REF=58dc2a0bdfcd78bcc3448fe5a1785e2128adc005 ARG TYPED_WASM_REF=e90e2d1a307c33d594d54065c902500da327977c # --------------------------------------------------------------------------- @@ -29,6 +29,11 @@ USER opam WORKDIR /opt RUN git clone https://github.com/hyperpolymath/affinescript.git \ && cd affinescript && git checkout "${AFFINESCRIPT_REF}" +# Carry the vetted WASM cross-module constructor-linking fix (gen_imports links +# imported enum constructors) until it lands upstream; then drop the COPY+apply +# and bump AFFINESCRIPT_REF to the merged SHA. +COPY patches/affinescript-wasm-ctor-link.patch /tmp/affinescript-wasm-ctor-link.patch +RUN cd /opt/affinescript && git apply /tmp/affinescript-wasm-ctor-link.patch WORKDIR /opt/affinescript RUN opam install --deps-only -y . \ && eval "$(opam env)" \ diff --git a/container-stack/svalinn/patches/affinescript-wasm-ctor-link.patch b/container-stack/svalinn/patches/affinescript-wasm-ctor-link.patch new file mode 100644 index 00000000..50af795d --- /dev/null +++ b/container-stack/svalinn/patches/affinescript-wasm-ctor-link.patch @@ -0,0 +1,181 @@ +From ae956929613f72a3f90bb7931c81070e7bcea240 Mon Sep 17 00:00:00 2001 +From: hyperpolymath +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 + diff --git a/container-stack/svalinn/src/Main.affine b/container-stack/svalinn/src/Main.affine index caa5b793..685044db 100644 --- a/container-stack/svalinn/src/Main.affine +++ b/container-stack/svalinn/src/Main.affine @@ -11,6 +11,7 @@ module Main; +use prelude::{Option, Some, None}; use Json; use PolicyEngine; diff --git a/container-stack/svalinn/src/auth/AuthTypes.affine b/container-stack/svalinn/src/auth/AuthTypes.affine index bc7aafe4..79e9425f 100644 --- a/container-stack/svalinn/src/auth/AuthTypes.affine +++ b/container-stack/svalinn/src/auth/AuthTypes.affine @@ -5,6 +5,10 @@ module AuthTypes; +// Option + its constructors are owned by `prelude` and must be imported +// explicitly (the pre-#138 seeded-builtin `Some`/`None` was removed upstream). +use prelude::{Option, Some, None}; + pub enum AuthMethod { OAuth2, OIDC, diff --git a/container-stack/svalinn/src/auth/Authz.affine b/container-stack/svalinn/src/auth/Authz.affine index 5ef27b10..3d0d68d3 100644 --- a/container-stack/svalinn/src/auth/Authz.affine +++ b/container-stack/svalinn/src/auth/Authz.affine @@ -11,6 +11,7 @@ module Authz; +use prelude::{Option, Some, None}; use AuthTypes; // JWT standard-claim validation (JWT.res verifyJWT, minus the signature diff --git a/container-stack/svalinn/src/gateway/GatewayTypes.affine b/container-stack/svalinn/src/gateway/GatewayTypes.affine index 5f3cf349..a9f2b6da 100644 --- a/container-stack/svalinn/src/gateway/GatewayTypes.affine +++ b/container-stack/svalinn/src/gateway/GatewayTypes.affine @@ -6,6 +6,10 @@ module GatewayTypes; +// Option + its constructors are owned by `prelude` and must be imported +// explicitly (the pre-#138 seeded-builtin `Some`/`None` was removed upstream). +use prelude::{Option, Some, None}; + pub enum ContainerState { Created, Running, diff --git a/container-stack/svalinn/src/gateway/Metrics.affine b/container-stack/svalinn/src/gateway/Metrics.affine index b4cfd573..a462dfa7 100644 --- a/container-stack/svalinn/src/gateway/Metrics.affine +++ b/container-stack/svalinn/src/gateway/Metrics.affine @@ -10,15 +10,15 @@ module Metrics; fn counter_block(name: String, help: String, value: Float) -> String { - "# HELP " + name + " " + help + "\n" - + "# TYPE " + name + " counter\n" - + name + " " + float_to_string(value) + "\n" + "# HELP " ++ name ++ " " ++ help ++ "\n" + ++ "# TYPE " ++ name ++ " counter\n" + ++ name ++ " " ++ float_to_string(value) ++ "\n" } fn gauge_block(name: String, help: String, value: Float) -> String { - "# HELP " + name + " " + help + "\n" - + "# TYPE " + name + " gauge\n" - + name + " " + float_to_string(value) + "\n" + "# HELP " ++ name ++ " " ++ help ++ "\n" + ++ "# TYPE " ++ name ++ " gauge\n" + ++ name ++ " " ++ float_to_string(value) ++ "\n" } // Cumulative histogram (Prometheus buckets are cumulative). @@ -27,20 +27,20 @@ fn histogram_block( buckets: [Float], counts: [Float], sum: Float, count: Float ) -> String { - let out = "# HELP " + name + " " + help + "\n" - + "# TYPE " + name + " histogram\n"; - let cumulative = 0.0; - let i = 0; + let mut out = "# HELP " ++ name ++ " " ++ help ++ "\n" + ++ "# TYPE " ++ name ++ " histogram\n"; + let mut cumulative = 0.0; + let mut i = 0; while i < len(buckets) { let c = if i < len(counts) { counts[i] } else { 0.0 }; cumulative = cumulative + c; - out = out + name + "_bucket{le=\"" + float_to_string(buckets[i]) + "\"} " - + float_to_string(cumulative) + "\n"; + out = out ++ name ++ "_bucket{le=\"" ++ float_to_string(buckets[i]) ++ "\"} " + ++ float_to_string(cumulative) ++ "\n"; i = i + 1; } - out = out + name + "_bucket{le=\"+Inf\"} " + float_to_string(count) + "\n"; - out = out + name + "_sum " + float_to_string(sum) + "\n"; - out = out + name + "_count " + float_to_string(count) + "\n"; + out = out ++ name ++ "_bucket{le=\"+Inf\"} " ++ float_to_string(count) ++ "\n"; + out = out ++ name ++ "_sum " ++ float_to_string(sum) ++ "\n"; + out = out ++ name ++ "_count " ++ float_to_string(count) ++ "\n"; out } @@ -57,18 +57,18 @@ pub fn format_prometheus( ) -> String { counter_block("svalinn_requests_total", "Total HTTP requests received", requests_total) - + "\n" - + counter_block("svalinn_requests_errors_total", + ++ "\n" + ++ counter_block("svalinn_requests_errors_total", "Total HTTP request errors (5xx)", requests_errors_total) - + "\n" - + counter_block("svalinn_auth_failures_total", + ++ "\n" + ++ counter_block("svalinn_auth_failures_total", "Total authentication failures", auth_failures_total) - + "\n" - + histogram_block("svalinn_request_duration_seconds", + ++ "\n" + ++ histogram_block("svalinn_request_duration_seconds", "HTTP request duration in seconds", hist_buckets, hist_counts, hist_sum, hist_count) - + "\n" - + gauge_block("svalinn_containers_active", + ++ "\n" + ++ gauge_block("svalinn_containers_active", "Number of currently active containers", containers_active) } diff --git a/container-stack/svalinn/src/policy/PolicyEngine.affine b/container-stack/svalinn/src/policy/PolicyEngine.affine index a12418e9..e123b62f 100644 --- a/container-stack/svalinn/src/policy/PolicyEngine.affine +++ b/container-stack/svalinn/src/policy/PolicyEngine.affine @@ -8,6 +8,7 @@ module PolicyEngine; +use prelude::{Option, Some, None, Result, Ok, Err}; use Json; pub enum PolicyMode { diff --git a/container-stack/svalinn/src/vordr/Client.affine b/container-stack/svalinn/src/vordr/Client.affine index d37744d4..1e96d82a 100644 --- a/container-stack/svalinn/src/vordr/Client.affine +++ b/container-stack/svalinn/src/vordr/Client.affine @@ -9,6 +9,7 @@ module Client; +use prelude::{Option, Some, None}; use Json; use VordrTypes; diff --git a/maintenance/affinescript-wasm-ctor-link/README.adoc b/maintenance/affinescript-wasm-ctor-link/README.adoc new file mode 100644 index 00000000..b15006a6 --- /dev/null +++ b/maintenance/affinescript-wasm-ctor-link/README.adoc @@ -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 ` (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 ` *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 #`. + +== Apply + +---- +cd +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 ` for the +before/after compile. diff --git a/maintenance/affinescript-wasm-ctor-link/affinescript-wasm-ctor-link.patch b/maintenance/affinescript-wasm-ctor-link/affinescript-wasm-ctor-link.patch new file mode 100644 index 00000000..50af795d --- /dev/null +++ b/maintenance/affinescript-wasm-ctor-link/affinescript-wasm-ctor-link.patch @@ -0,0 +1,181 @@ +From ae956929613f72a3f90bb7931c81070e7bcea240 Mon Sep 17 00:00:00 2001 +From: hyperpolymath +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 + diff --git a/maintenance/affinescript-wasm-ctor-link/repro.sh b/maintenance/affinescript-wasm-ctor-link/repro.sh new file mode 100755 index 00000000..09234d1f --- /dev/null +++ b/maintenance/affinescript-wasm-ctor-link/repro.sh @@ -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 +# 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 }" +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 { Some(x) } +pub fn empty() -> Option { 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