From 3daf38dfaa38b3045fb744a6434d80770e868cbc Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:12:33 +0000 Subject: [PATCH 1/3] fix(loader): `use M::{x}` must inline x's transitive helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/codegen-deno/dom_startup_error.affine says `use Dom::{VNode, div, h1, p, text}`. Module_loader.flatten_imports inlined exactly those decls — but stdlib/Dom.affine's div/h1/p are one-line wrappers around Dom's own `pub fn h(...)`. `h` was never named by the import list, so it was left out of the flattened program and the emitted module called an undefined `h`: `ReferenceError: h is not defined`. ImportGlob/ImportSimple already inline every public decl for this reason; ImportList was the odd one out. Close over the named decls' free variables until a fixpoint, pulling in the module's own value decls (private helpers included — a public wrapper may delegate to one) in dependency order. Aliased items keep their behaviour: the closure runs on the original names, then renaming is applied. To share the walker instead of adding a fourth private copy (codegen.ml had one; borrow.ml has another), move find_free_vars into ast.ml and re-export it from codegen.ml for the existing call sites. (Codegen depends on Module_loader, so the loader could not reach the copy where it lived.) Verified locally: the emitted-module call pattern is what the harness died on; the fix makes `h` part of the flattened program. Full verification needs the OCaml build in CI. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- lib/ast.ml | 110 +++++++++++++++++++++++++++++++++++++++++++ lib/codegen.ml | 103 ++-------------------------------------- lib/module_loader.ml | 105 ++++++++++++++++++++++++++++++++--------- 3 files changed, 197 insertions(+), 121 deletions(-) diff --git a/lib/ast.ml b/lib/ast.ml index 68d16bec..dd3b5114 100644 --- a/lib/ast.ml +++ b/lib/ast.ml @@ -540,3 +540,113 @@ and stmt_contains_return (s : stmt) : bool = let fn_body_contains_return : fn_body -> bool = function | FnExpr e -> expr_contains_return e | FnBlock b -> block_contains_return b + +(** Free variables of an expression. + + Returns the names used in [expr] but not bound within it; [bound_vars] + lists the names already bound by the enclosing scope (parameters, let + bindings). + + Shared by every pass that needs to know what a body refers to: wasm + codegen, and [Module_loader]'s `use`-list flattening, which uses it to + pull in helpers a selective import never names (`use Dom::{div}` needs + `div`'s own helper `h`; omitting it emitted a module that called an + undefined `h`). Like the other walkers in this module it is deliberately + conservative: constructs it does not inspect contribute [] rather than a + wrong answer. *) +let rec find_free_vars (bound_vars : string list) (expr : expr) : string list = + match expr with + | ExprLit _ -> [] + | ExprVar id -> + if List.mem id.name bound_vars then [] else [id.name] + | ExprBinary (e1, _, e2) -> + find_free_vars bound_vars e1 @ find_free_vars bound_vars e2 + | ExprStringConcat (e1, e2) -> + find_free_vars bound_vars e1 @ find_free_vars bound_vars e2 + | ExprUnary (_, e) -> + find_free_vars bound_vars e + | ExprIf ei -> + find_free_vars bound_vars ei.ei_cond @ + find_free_vars bound_vars ei.ei_then @ + (match ei.ei_else with + | Some e -> find_free_vars bound_vars e + | None -> []) + | ExprLet lb -> + let rhs_free = find_free_vars bound_vars lb.el_value in + (* Add bound variable to scope for body *) + let new_bound = match lb.el_pat with + | PatVar id -> id.name :: bound_vars + | _ -> bound_vars + in + let body_free = match lb.el_body with + | Some e -> find_free_vars new_bound e + | None -> [] + in + rhs_free @ body_free + | ExprLambda lam -> + (* Parameters are bound within lambda *) + let param_names = List.map (fun p -> p.p_name.name) lam.elam_params in + find_free_vars (param_names @ bound_vars) lam.elam_body + | ExprApp (f, args) -> + find_free_vars bound_vars f @ + List.concat (List.map (find_free_vars bound_vars) args) + | ExprBlock blk -> + (* Statements may introduce bindings *) + let (bound_after, free) = List.fold_left (fun (bound, acc_free) stmt -> + match stmt with + | StmtLet sl -> + let rhs_free = find_free_vars bound sl.sl_value in + let new_bound = match sl.sl_pat with + | PatVar id -> id.name :: bound + | _ -> bound + in + (new_bound, acc_free @ rhs_free) + | StmtExpr e -> + (bound, acc_free @ find_free_vars bound e) + | _ -> (bound, acc_free) + ) (bound_vars, []) blk.blk_stmts in + (* The tail expression is in scope of the block's own `let` + bindings, so its free vars must exclude them — use the + threaded [bound_after], not the original [bound_vars]. (Prior + code used [bound_vars], spuriously reporting block-local + binders as free; surfaced by #225 PR3c chained continuations.) *) + let expr_free = match blk.blk_expr with + | Some e -> find_free_vars bound_after e + | None -> [] + in + free @ expr_free + | ExprMatch m -> + find_free_vars bound_vars m.em_scrutinee @ + List.concat (List.map (fun arm -> find_free_vars bound_vars arm.ma_body) m.em_arms) + | ExprReturn e_opt -> + (match e_opt with Some e -> find_free_vars bound_vars e | None -> []) + | ExprTuple exprs | ExprArray exprs -> + List.concat (List.map (find_free_vars bound_vars) exprs) + | ExprRecord r -> + List.concat (List.map (fun (_, e_opt) -> + match e_opt with + | Some e -> find_free_vars bound_vars e + | None -> [] + ) r.er_fields) + | ExprField (e, _) -> find_free_vars bound_vars e + | ExprTupleIndex (e, _) -> find_free_vars bound_vars e + | ExprIndex (e1, e2) -> + find_free_vars bound_vars e1 @ find_free_vars bound_vars e2 + | ExprVariant _ -> [] + | ExprSpan (e, _) -> find_free_vars bound_vars e + (* Float-wall elaboration nodes (codegen runs on the post-elaborate tree, so + these CAN appear here): traverse them exactly like their pre-elaboration + forms, else a variable captured only inside a float expression is missed + and the closure mis-lowers to UnboundVariable. *) + | ExprFloatBinary (a, _, b) -> + find_free_vars bound_vars a @ find_free_vars bound_vars b + | ExprFloatArray exprs -> List.concat (List.map (find_free_vars bound_vars) exprs) + | ExprFloatIndex (a, b) -> + find_free_vars bound_vars a @ find_free_vars bound_vars b + | ExprCellTuple cells -> + List.concat (List.map (fun (e, _) -> find_free_vars bound_vars e) cells) + | ExprCellTupleIndex (e, _, _) -> find_free_vars bound_vars e + | ExprCellRecord fields -> + List.concat (List.map (fun (_, e, _) -> find_free_vars bound_vars e) fields) + | ExprCellField (e, _, _) -> find_free_vars bound_vars e + | _ -> [] (* Other expressions *) diff --git a/lib/codegen.ml b/lib/codegen.ml index 2a30c16a..bcba5b27 100644 --- a/lib/codegen.ml +++ b/lib/codegen.ml @@ -344,104 +344,11 @@ let gen_heap_alloc (ctx : context) (size_in_bytes : int) : (context * instr list (ctx', alloc_code) (** Find free variables in an expression. - Returns list of variable names that are used but not bound within the expression. - bound_vars: variables already bound in enclosing scope (parameters, let bindings) *) -let rec find_free_vars (bound_vars : string list) (expr : expr) : string list = - match expr with - | ExprLit _ -> [] - | ExprVar id -> - if List.mem id.name bound_vars then [] else [id.name] - | ExprBinary (e1, _, e2) -> - find_free_vars bound_vars e1 @ find_free_vars bound_vars e2 - | ExprStringConcat (e1, e2) -> - find_free_vars bound_vars e1 @ find_free_vars bound_vars e2 - | ExprUnary (_, e) -> - find_free_vars bound_vars e - | ExprIf ei -> - find_free_vars bound_vars ei.ei_cond @ - find_free_vars bound_vars ei.ei_then @ - (match ei.ei_else with - | Some e -> find_free_vars bound_vars e - | None -> []) - | ExprLet lb -> - let rhs_free = find_free_vars bound_vars lb.el_value in - (* Add bound variable to scope for body *) - let new_bound = match lb.el_pat with - | PatVar id -> id.name :: bound_vars - | _ -> bound_vars - in - let body_free = match lb.el_body with - | Some e -> find_free_vars new_bound e - | None -> [] - in - rhs_free @ body_free - | ExprLambda lam -> - (* Parameters are bound within lambda *) - let param_names = List.map (fun p -> p.p_name.name) lam.elam_params in - find_free_vars (param_names @ bound_vars) lam.elam_body - | ExprApp (f, args) -> - find_free_vars bound_vars f @ - List.concat (List.map (find_free_vars bound_vars) args) - | ExprBlock blk -> - (* Statements may introduce bindings *) - let (bound_after, free) = List.fold_left (fun (bound, acc_free) stmt -> - match stmt with - | StmtLet sl -> - let rhs_free = find_free_vars bound sl.sl_value in - let new_bound = match sl.sl_pat with - | PatVar id -> id.name :: bound - | _ -> bound - in - (new_bound, acc_free @ rhs_free) - | StmtExpr e -> - (bound, acc_free @ find_free_vars bound e) - | _ -> (bound, acc_free) - ) (bound_vars, []) blk.blk_stmts in - (* The tail expression is in scope of the block's own `let` - bindings, so its free vars must exclude them — use the - threaded [bound_after], not the original [bound_vars]. (Prior - code used [bound_vars], spuriously reporting block-local - binders as free; surfaced by #225 PR3c chained continuations.) *) - let expr_free = match blk.blk_expr with - | Some e -> find_free_vars bound_after e - | None -> [] - in - free @ expr_free - | ExprMatch m -> - find_free_vars bound_vars m.em_scrutinee @ - List.concat (List.map (fun arm -> find_free_vars bound_vars arm.ma_body) m.em_arms) - | ExprReturn e_opt -> - (match e_opt with Some e -> find_free_vars bound_vars e | None -> []) - | ExprTuple exprs | ExprArray exprs -> - List.concat (List.map (find_free_vars bound_vars) exprs) - | ExprRecord r -> - List.concat (List.map (fun (_, e_opt) -> - match e_opt with - | Some e -> find_free_vars bound_vars e - | None -> [] - ) r.er_fields) - | ExprField (e, _) -> find_free_vars bound_vars e - | ExprTupleIndex (e, _) -> find_free_vars bound_vars e - | ExprIndex (e1, e2) -> - find_free_vars bound_vars e1 @ find_free_vars bound_vars e2 - | ExprVariant _ -> [] - | ExprSpan (e, _) -> find_free_vars bound_vars e - (* Float-wall elaboration nodes (codegen runs on the post-elaborate tree, so - these CAN appear here): traverse them exactly like their pre-elaboration - forms, else a variable captured only inside a float expression is missed - and the closure mis-lowers to UnboundVariable. *) - | ExprFloatBinary (a, _, b) -> - find_free_vars bound_vars a @ find_free_vars bound_vars b - | ExprFloatArray exprs -> List.concat (List.map (find_free_vars bound_vars) exprs) - | ExprFloatIndex (a, b) -> - find_free_vars bound_vars a @ find_free_vars bound_vars b - | ExprCellTuple cells -> - List.concat (List.map (fun (e, _) -> find_free_vars bound_vars e) cells) - | ExprCellTupleIndex (e, _, _) -> find_free_vars bound_vars e - | ExprCellRecord fields -> - List.concat (List.map (fun (_, e, _) -> find_free_vars bound_vars e) fields) - | ExprCellField (e, _, _) -> find_free_vars bound_vars e - | _ -> [] (* Other expressions *) + The walker itself lives in {!Ast} so every pass shares one definition + instead of keeping a private copy (module inlining needs the same + answer when it closes over a transitive helper a `use` list never + names). Re-exported here for the existing call sites. *) +let find_free_vars = Ast.find_free_vars (** Remove duplicates from list *) let dedup (lst : string list) : string list = diff --git a/lib/module_loader.ml b/lib/module_loader.ml index b7e67b6c..cbea8753 100644 --- a/lib/module_loader.ml +++ b/lib/module_loader.ml @@ -321,31 +321,90 @@ let flatten_imports (loader : t) (prog : program) : program = the bodies present. *) public_decls | ImportList (_, items) -> - List.filter_map (fun item -> - let target = item.ii_name.name in - List.find_opt (fun (n, _) -> n = target) public_decls - |> Option.map (fun (_, found) -> - let bound_name = match item.ii_alias with - | Some a -> a.name - | None -> target - in - let renamed = match found with + (* The name a directly-named item is imported under (alias + honoured). *) + let bound_name_of target = + match List.find_opt (fun item -> item.ii_name.name = target) items with + | Some { ii_alias = Some a; _ } -> a.name + | _ -> target + in + let rename_decl bound_name found = + match found with + | `Fn fd -> + `Fn { fd with fd_name = { fd.fd_name with name = bound_name } } + | `Const (TopConst { tc_vis; tc_mut; tc_name; tc_ty; tc_value }) -> + `Const (TopConst { + tc_vis; + tc_mut; + tc_name = { tc_name with name = bound_name }; + tc_ty; + tc_value; + }) + | `Const _ -> + (* Unreachable: public_decls only stores TopConst under `Const`. *) + found + in + let direct = + List.filter_map (fun item -> + let target = item.ii_name.name in + List.find_opt (fun (n, _) -> n = target) public_decls + |> Option.map (fun (_, found) -> (target, found)) + ) items + in + (* Transitive closure. A selected declaration's body may call + helpers of the same module that the import list never names: + `use Dom::{div}` pulls in `div`, whose body calls `h`. Leaving + `h` out of the flattened program produced + `ReferenceError: h is not defined` in the emitted module + (tests/codegen-deno/dom_startup_error). Private helpers are + pulled in too — a public wrapper may delegate to one. *) + let module_value_decls = + List.filter_map (fun decl -> + match decl with + | TopFn fd when fd.fd_body <> FnExtern -> + Some (fd.fd_name.name, `Fn fd) + | TopConst { tc_name; _ } as d -> + Some (tc_name.name, `Const d) + | _ -> None + ) lm.mod_program.prog_decls + in + let by_name = Hashtbl.create 16 in + List.iter (fun (n, d) -> Hashtbl.replace by_name n d) module_value_decls; + let seen = Hashtbl.create 16 in + List.iter (fun (n, _) -> Hashtbl.replace seen n ()) direct; + let rec close pending acc = + match pending with + | [] -> List.rev acc + | (_, dk) :: rest -> + let free = match dk with | `Fn fd -> - `Fn { fd with fd_name = { fd.fd_name with name = bound_name } } - | `Const (TopConst { tc_vis; tc_mut; tc_name; tc_ty; tc_value }) -> - `Const (TopConst { - tc_vis; - tc_mut; - tc_name = { tc_name with name = bound_name }; - tc_ty; - tc_value; - }) - | `Const _ -> - (* Unreachable: public_decls only stores TopConst under `Const`. *) - found + let params = + List.map (fun (p : param) -> p.p_name.name) fd.fd_params + in + (match fd.fd_body with + | FnExtern -> [] + | FnExpr e -> find_free_vars params e + | FnBlock b -> find_free_vars params (ExprBlock b)) + | `Const (TopConst { tc_value; _ }) -> find_free_vars [] tc_value + | `Const _ -> [] + in + let (pending, acc) = + List.fold_left (fun (pending, acc) name -> + if Hashtbl.mem seen name then (pending, acc) + else begin + Hashtbl.replace seen name (); + match Hashtbl.find_opt by_name name with + | Some dep -> ((name, dep) :: pending, (name, dep) :: acc) + | None -> (pending, acc) + end + ) (rest, acc) free in - (bound_name, renamed)) - ) items + close pending acc + in + let extras = close direct [] in + List.map + (fun (n, dk) -> (bound_name_of n, rename_decl (bound_name_of n) dk)) + (direct @ extras) in List.iter (fun (name, decl_kind) -> add_imported name decl_kind) select ) prog.prog_imports; From 4d8d1f9f8f773ee346224f1559459d7083b25677 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:19:51 +0000 Subject: [PATCH 2/3] fix(loader): carry user-defined enum constructors across a selective `use` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next link in the same chain as the previous commit. With `h` inlined, the emitted module moved on to `ReferenceError: VText is not defined`: stdlib/Dom.affine's `text(content)` returns `VText(content)`, i.e. a constructor of Dom's own `pub enum VNode`, and flatten_imports carries no type decls at all — the #138 note says imported TYPE decls were deliberately dropped because re-emitting the prelude's Option/Result constructors produced duplicate `const Some` declarations under node. So the closure now also carries an enum when a constructor of it is referenced, with the #138 hazard closed rather than ignored: Some/None/Ok/Err are never carried, because every non-wasm backend preamble already provides them. That narrows type-carrying to user enums a preamble cannot supply, which is exactly what a consumer hits with `use Dom::{div}`. Also let a locally-declared type name suppress a same-named import, the same rule local functions and constants already follow. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- lib/module_loader.ml | 42 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/lib/module_loader.ml b/lib/module_loader.ml index cbea8753..d1cd07e9 100644 --- a/lib/module_loader.ml +++ b/lib/module_loader.ml @@ -275,6 +275,7 @@ let flatten_imports (loader : t) (prog : program) : program = List.filter_map (function | TopFn fd -> Some fd.fd_name.name | TopConst { tc_name; _ } -> Some tc_name.name + | TopType td -> Some td.td_name.name | _ -> None ) prog.prog_decls in @@ -340,8 +341,10 @@ let flatten_imports (loader : t) (prog : program) : program = tc_ty; tc_value; }) - | `Const _ -> - (* Unreachable: public_decls only stores TopConst under `Const`. *) + | `Const _ | `Type _ -> + (* `Const` here is unreachable: public_decls only stores + TopConst under `Const`. `Type` decls are never renamed — + they are carried under their own name. *) found in let direct = @@ -368,6 +371,28 @@ let flatten_imports (loader : t) (prog : program) : program = | _ -> None ) lm.mod_program.prog_decls in + (* Constructors of this module's own enums, keyed by constructor + name. An inlined wrapper body can name a constructor + (`text` returns `VText(content)`) and the constructor exists + only where the enum is declared. Option/Result constructors + are excluded: every non-wasm preamble already defines + Some/None/Ok/Err, and re-emitting them is the duplicate-`const` + crash that reverted type-carrying in #138 — this narrows the + carry to user enums a backend preamble cannot supply. *) + let preamble_ctors = [ "Some"; "None"; "Ok"; "Err" ] in + let ctor_decls = Hashtbl.create 16 in + List.iter (fun decl -> + match decl with + | TopType td -> + (match td.td_body with + | TyEnum variants -> + List.iter (fun (vd : variant_decl) -> + if not (List.mem vd.vd_name.name preamble_ctors) then + Hashtbl.replace ctor_decls vd.vd_name.name + (td.td_name.name, decl) + ) variants + | _ -> ()) + | _ -> ()) lm.mod_program.prog_decls; let by_name = Hashtbl.create 16 in List.iter (fun (n, d) -> Hashtbl.replace by_name n d) module_value_decls; let seen = Hashtbl.create 16 in @@ -386,7 +411,7 @@ let flatten_imports (loader : t) (prog : program) : program = | FnExpr e -> find_free_vars params e | FnBlock b -> find_free_vars params (ExprBlock b)) | `Const (TopConst { tc_value; _ }) -> find_free_vars [] tc_value - | `Const _ -> [] + | `Const _ | `Type _ -> [] in let (pending, acc) = List.fold_left (fun (pending, acc) name -> @@ -395,7 +420,15 @@ let flatten_imports (loader : t) (prog : program) : program = Hashtbl.replace seen name (); match Hashtbl.find_opt by_name name with | Some dep -> ((name, dep) :: pending, (name, dep) :: acc) - | None -> (pending, acc) + | None -> + (* A constructor name: carry the enum it belongs to, + once per type. *) + (match Hashtbl.find_opt ctor_decls name with + | Some (td_name, decl) when not (Hashtbl.mem seen td_name) -> + Hashtbl.replace seen td_name (); + ((td_name, `Type decl) :: pending, + (td_name, `Type decl) :: acc) + | _ -> (pending, acc)) end ) (rest, acc) free in @@ -414,6 +447,7 @@ let flatten_imports (loader : t) (prog : program) : program = match Hashtbl.find_opt imported_by_name name with | Some (`Fn fd) -> Some (TopFn fd) | Some (`Const decl) -> Some decl + | Some (`Type decl) -> Some decl | None -> None) in (* #138 follow-up: imported TYPE decls are intentionally NOT inlined here. From 0ba85178240a6ef53d953fe4c65a272a64dd6068 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:24:14 +0000 Subject: [PATCH 3/3] chore(ci): retire the temporary failure-localisation probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cascade these probes existed for has resolved: every step of the build job now reports a conclusion on its own, and all of them pass — including the three steps (native Bun-ESM, face transformers, issue references) that had never once executed as steps because they sat behind a failing one. Delete the two probe workflows and the annotation-dumping script, and drop the `[diag]` step that invoked it from ci.yml. Repoint the E2E comment that cited the probe, keeping its measured variant table — that evidence is the fix's justification and should not travel with a deleted tool. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/ci.yml | 7 - .github/workflows/zz-probe-a.yml | 18 --- .github/workflows/zz-probe-b.yml | 23 --- test/test_e2e.ml | 2 +- tools/ci/diag-probe.sh | 254 ------------------------------- 5 files changed, 1 insertion(+), 303 deletions(-) delete mode 100644 .github/workflows/zz-probe-a.yml delete mode 100644 .github/workflows/zz-probe-b.yml delete mode 100755 tools/ci/diag-probe.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db64be02..4cfea1e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,13 +88,6 @@ jobs: run: | set -o pipefail opam exec -- dune runtest 2>&1 | tee runtest.log - # ── TEMPORARY DIAGNOSTIC (removed before merge) ─────────────────────── - # Republishes the failure + a downstream probe as annotations, so the - # result is readable through the API rather than the Actions log UI. - - name: "[diag] surface runtest failure and probe downstream" - if: always() - run: bash tools/ci/diag-probe.sh - # ───────────────────────────────────────────────────────────────────── - name: Consumer on-ramp example (issue #771) # The consumer-facing contract this project owes its downstream # readers: a host surface declared with `extern fn`, compiled to diff --git a/.github/workflows/zz-probe-a.yml b/.github/workflows/zz-probe-a.yml deleted file mode 100644 index 9ceef5c5..00000000 --- a/.github/workflows/zz-probe-a.yml +++ /dev/null @@ -1,18 +0,0 @@ -# This workflow is managed by gh actions-lock. -# SPDX-License-Identifier: MPL-2.0 -# -# TEMPORARY PROBE (deleted before merge) — hypothesis A: -# the local reusable call fails at run-creation because the CALLER JOB does -# not grant permissions explicitly (the only reusable caller in this repo that -# works, spark-theatre-gate.yml, does grant them at job level). Same shape as -# governance-baseline.yml, plus job-level permissions. -name: ZZ Probe A -on: - pull_request: -permissions: - contents: read -jobs: - governance: - uses: ./.github/workflows/governance-baseline-impl.yml - permissions: - contents: read diff --git a/.github/workflows/zz-probe-b.yml b/.github/workflows/zz-probe-b.yml deleted file mode 100644 index 25da43fa..00000000 --- a/.github/workflows/zz-probe-b.yml +++ /dev/null @@ -1,23 +0,0 @@ -# This workflow is managed by gh actions-lock. -# SPDX-License-Identifier: MPL-2.0 -# -# TEMPORARY PROBE (deleted before merge) — hypothesis B: no reusable workflow -# at all, and the pinned context reproduced by naming the job literally. -# A normal (non-caller) workflow always starts, so this is what the bridge -# becomes if the reusable mechanism itself is what the platform refuses. -name: ZZ Probe B -on: - pull_request: -permissions: - contents: read -jobs: - governance: - name: Validate Hypatia baseline - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Checkout - uses: actions/checkout@v7.0.1 - - name: Validate .hypatia-baseline.json (if present) - run: | - echo "probe B: plain job, no reusable workflow" diff --git a/test/test_e2e.ml b/test/test_e2e.ml index 5e5fd562..18b8030c 100644 --- a/test/test_e2e.ml +++ b/test/test_e2e.ml @@ -1084,7 +1084,7 @@ let test_empty_match_arm_block_parses () = The issue's repro additionally put the `match` in NON-final statement position *without* a `;`, and reported the resulting failure at the `}` that closes the `match`. Measured with `affinescript parse` on the - repro and its variants (`tools/ci/diag-probe.sh`, 2026-10-03): + repro and its variants (2026-10-03): - empty arm, `match` FINAL in the block -> parses - empty arm, `match` mid-block, no `;` -> fails AT the match's `}` - empty arm, `match` mid-block, with `;` -> parses diff --git a/tools/ci/diag-probe.sh b/tools/ci/diag-probe.sh deleted file mode 100755 index 070f3827..00000000 --- a/tools/ci/diag-probe.sh +++ /dev/null @@ -1,254 +0,0 @@ -#!/usr/bin/env bash -# SPDX-License-Identifier: MPL-2.0 -# -# TEMPORARY diagnostics bridge (PR-only; deleted before merge). -# -# Why this exists: this repo's Actions job logs are served from -# productionresultssa1.blob.core.windows.net, which is unreachable from some -# sandboxes. GitHub *annotations* are reachable through api.github.com, so -# this republishes the interesting parts of a failing `dune runtest` — plus -# two probes — as annotations, readable without the Actions log UI. -set -uo pipefail - -python3 - <<'PY' -import os, pathlib, re, subprocess, urllib.parse - -def annotate(title, text, limit=60000): - msg = urllib.parse.quote(text[:limit], safe="") - print(f"::error title={title}::{msg}", flush=True) - -def summary(title, text): - path = os.environ.get("GITHUB_STEP_SUMMARY") - if not path: - return - with open(path, "a") as fh: - fh.write(f"\n### {title}\n\n```\n{text}\n```\n") - -def run(argv, timeout=300): - try: - r = subprocess.run(argv, capture_output=True, text=True, timeout=timeout) - return r.returncode, (r.stdout + r.stderr).strip() - except subprocess.TimeoutExpired: - return 124, "TIMEOUT" - -# ── 1. the dune runtest failure ──────────────────────────────────────────── -log = pathlib.Path("runtest.log") -if log.exists(): - lines = log.read_text(errors="replace").splitlines() - keep = [l for l in lines - if ("[FAIL]" in l or "FAIL " in l or "Error" in l or "error:" in l - or "Assert" in l or "expected" in l)] - body = ("== lines matching FAIL/Error/Assert/expected ==\n" - + "\n".join(keep[:80]) - + "\n\n== tail (120 lines) ==\n" - + "\n".join(lines[-120:])) - annotate("diag-runtest", body) -else: - annotate("diag-runtest", "runtest.log was not produced") - -# ── 1b. the whole masked cascade, in one shot ───────────────────────────── -# `dune runtest` failed first, so every later step in the build job was -# skipped and its state was unknown. Now that the tests pass, surface the -# whole remaining chain at once instead of one failure per CI cycle. -CASCADE = [ - ("codegen WASM", ["bash", "tools/run_codegen_wasm_tests.sh"]), - ("codegen Bun-ESM (codegen-deno corpus)", ["bash", "tools/run_codegen_deno_tests.sh"]), - ("native Bun-ESM", ["bash", "tools/run_codegen_bun_tests.sh"]), - ("face transformers", ["bash", "tools/run_face_transformer_tests.sh"]), - ("no-extension-ts", ["bash", "tools/check-no-extension-ts.sh"]), -] -BAD_LINE = re.compile( - r"(::error::|AssertionError|TypeError|ReferenceError|SyntaxError" - r"|\b[Ee]rror\b|FAIL|FAILED|✗|\bpanic\b|No such file|denied|not allowed)" -) - - -def digest(text, budget=700): - """Dense, capped summary of one cascade step. - - GitHub truncates check-run annotations at ~4096 bytes, so a raw tail of - each step's output silently loses every step after the first oversized - one (compiler chatter is verbose). Report only the interesting lines. - """ - lines = [l.rstrip() for l in text.splitlines() if l.strip()] - picked, seen = [], set() - for l in lines: - if not BAD_LINE.search(l): - continue - key = re.sub(r"\d+", "#", l)[:70] - if key in seen: - continue - seen.add(key) - picked.append(l) - if len(picked) == 5: - break - # The runner's own failure roll-call (names, not prose). - for l in lines: - if (l.startswith(" - ") or l.startswith("✗")) and l not in picked: - picked.append(l) - if len(picked) >= 8: - break - for l in lines[-2:]: - if l not in picked: - picked.append(l) - body = "\n".join(" ! " + l[:160] for l in picked) - return body[:budget] if body else " (no error-like lines; rc shown above)" - - -out = [] -for label, argv in CASCADE: - rc, text = run(argv, timeout=900) - n = len(text.splitlines()) - head = f"===== {label}: rc={rc} ({n} lines) =====" - out.append(head if rc == 0 else head + "\n" + digest(text)) -body = "\n\n".join(out) -annotate("diag-cascade", body) -summary("diag: masked cascade", body) - -# ── 2. parser probe: which construct does the #644 test need? ───────────── -VARIANTS = { - "v1-exact-test-source": """module EmptyArm; -enum Opt { SomeV(Int), NoneV } -pub fn f(o: Opt) -> Int { - match o { - SomeV(v) => { return v; } - NoneV => {} - } - return 0; -} -""", - "v2-plus-semicolon-after-match": """module EmptyArm; -enum Opt { SomeV(Int), NoneV } -pub fn f(o: Opt) -> Int { - match o { - SomeV(v) => { return v; } - NoneV => {} - }; - return 0; -} -""", - "v3-match-as-final-expr": """module EmptyArm; -enum Opt { SomeV(Int), NoneV } -pub fn f(o: Opt) -> Int { - match o { - SomeV(v) => { return v; } - NoneV => {} - } -} -""", - "v4-empty-block-arm-only-final": """module EmptyArm; -enum Opt { Som(Int), Non } -pub fn f(o: Opt) -> Int { - match o { - Non => {} - } -} -""", - "v5-nonempty-block-arm-only-final": """module EmptyArm; -enum Opt { Som(Int), Non } -pub fn f(o: Opt) -> Int { - match o { - Som(v) => { return v; } - } -} -""", - "v6-empty-block-empty-body": """module EmptyArm; -pub fn f() -> Int { - {} -} -""", - "v7-two-empty-block-arms-final": """module EmptyArm; -enum Opt { Som(Int), Non } -pub fn f(o: Opt) -> Int { - match o { - Som(v) => {} - Non => {} - } -} -""", - # Isolation pair for the `;`-after-`match` question: v8 is the issue's - # own "contrast" case (non-empty arm) still missing the `;`; v11 moves the - # empty arm off the last position. If v8 fails like v1, the empty arm is - # irrelevant and the missing statement terminator is the whole cause. - "v8-issue-contrast-no-semicolon": """module EmptyArm; -enum Opt { SomeV(Int), NoneV } -pub fn f(o: Opt) -> Int { - match o { - SomeV(v) => { return v; } - NoneV => { return 0; } - } - return 0; -} -""", - "v11-empty-arm-first-no-semicolon": """module EmptyArm; -enum Opt { SomeV(Int), NoneV } -pub fn f(o: Opt) -> Int { - match o { - NoneV => {} - SomeV(v) => { return v; } - } - return 0; -} -""", - "v12-expr-arms-no-semicolon": """module EmptyArm; -enum Opt { SomeV(Int), NoneV } -pub fn f(o: Opt) -> Int { - match o { - SomeV(v) => v - NoneV => 0 - } - return 0; -} -""", -} - -out = ["parser probe: `affinescript parse` on variants of the #644 test source"] -probe_dir = pathlib.Path("/tmp/parse-probe") -probe_dir.mkdir(parents=True, exist_ok=True) -for name, src in VARIANTS.items(): - p = probe_dir / f"{name}.affine" - p.write_text(src) - for label, extra in (("canonical", []), ("face-js", ["--face", "js"])): - rc, text = run(["opam", "exec", "--", "dune", "exec", "affinescript", - "--", "parse"] + extra + [str(p)]) - first = " | ".join(text.splitlines()[:3]) if text else "(silent)" - out.append(f"{name} [{label}] rc={rc}: {first}") - -body = "\n".join(out) -annotate("diag-parser", body) -summary("diag: parser probe", body) - -# ── 3. on-ramp example consumer (compile + run) ─────────────────────────── -example = pathlib.Path("examples/consumers/extension-boundary") -if example.exists(): - rc, text = run(["bash", str(example / "build.sh")]) - body = f"build.sh rc={rc}\n---\n{text[-6000:]}" -else: - body = "examples/consumers/extension-boundary missing" -annotate("diag-example", body) -summary("diag: on-ramp example", body) - -# ── 4. downstream probe: blocky-writer's sources (issue #771) ───────────── -probe = pathlib.Path("/tmp/probe") -probe.mkdir(parents=True, exist_ok=True) -clone = subprocess.run( - ["git", "clone", "--depth", "1", "--quiet", - "https://github.com/hyperpolymath/blocky-writer", "/tmp/probe/bw"], - capture_output=True, text=True) -out = [] -if clone.returncode != 0: - out.append("clone failed: " + clone.stderr[-400:]) -else: - src = pathlib.Path("/tmp/probe/bw/src") - files = sorted(str(p) for p in src.rglob("*.affine")) - out.append(f"downstream .affine files: {len(files)}") - for f in files: - rc, text = run(["opam", "exec", "--", "dune", "exec", "affinescript", - "--", "check", f]) - first = " | ".join(text.splitlines()[:2]) if text else "(silent)" - out.append(f"{f} rc={rc}: {first}") -body = "\n".join(out) -annotate("diag-downstream", body) -summary("diag: downstream probe", body) -print("[diag] done") -PY