From 3bed17c1bfa4dd449cf6da19f17542a0f139b599 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:18:50 +0000 Subject: [PATCH] fix remaining parser, face, module, and CI issues Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/instant-sync.yml | 11 +++- .github/workflows/publish-jsr.yml | 3 +- bin/main.ml | 34 ++++++---- lib/module_loader.ml | 54 +++++++++------- lib/parser.mly | 19 +++++- lib/python_face.ml | 64 ++++++++++++++----- lib/resolve.ml | 82 ++++++++++++++++++++----- runtime/src/alloc.rs | 2 + runtime/src/ffi.rs | 10 +++ runtime/src/panic.rs | 4 ++ test/e2e/fixtures/GlobA.affine | 7 +++ test/e2e/fixtures/GlobB.affine | 7 +++ test/e2e/fixtures/glob_collision.affine | 8 +++ test/test_e2e.ml | 80 ++++++++++++++++++++++++ 14 files changed, 317 insertions(+), 68 deletions(-) create mode 100644 test/e2e/fixtures/GlobA.affine create mode 100644 test/e2e/fixtures/GlobB.affine create mode 100644 test/e2e/fixtures/glob_collision.affine diff --git a/.github/workflows/instant-sync.yml b/.github/workflows/instant-sync.yml index d3db8d2f..7132f163 100644 --- a/.github/workflows/instant-sync.yml +++ b/.github/workflows/instant-sync.yml @@ -25,9 +25,15 @@ jobs: if: ${{ vars.FARM_DISPATCH_ENABLED == 'true' }} steps: - name: Trigger Propagation + # Keep the action out of the job when the optional secret is absent. + # The variable gate above controls policy; this presence gate prevents + # an unconfigured fork from invoking repository-dispatch at all. + if: ${{ env.FARM_DISPATCH_TOKEN != '' }} + env: + FARM_DISPATCH_TOKEN: ${{ secrets.FARM_DISPATCH_TOKEN }} uses: peter-evans/repository-dispatch@v4.0.1 with: - token: ${{ secrets.FARM_DISPATCH_TOKEN }} + token: ${{ env.FARM_DISPATCH_TOKEN }} repository: hyperpolymath/.git-private-farm event-type: propagate client-payload: |- @@ -38,4 +44,7 @@ jobs: "forges": "" } - name: Confirm + if: ${{ env.FARM_DISPATCH_TOKEN != '' }} + env: + FARM_DISPATCH_TOKEN: ${{ secrets.FARM_DISPATCH_TOKEN }} run: echo "::notice::Propagation triggered for ${{ github.event.repository.name }}" diff --git a/.github/workflows/publish-jsr.yml b/.github/workflows/publish-jsr.yml index 674b7b16..7cb5e19e 100644 --- a/.github/workflows/publish-jsr.yml +++ b/.github/workflows/publish-jsr.yml @@ -43,7 +43,8 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@v7.0.1 - - uses: denoland/setup-deno@v2.0.5 + # v2.0.5 — immutable commit pin (tag: v2.0.5). + - uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed with: deno-version: v2.x - name: Resolve package directory diff --git a/bin/main.ml b/bin/main.ml index d491c8b2..ffdacd2a 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -173,6 +173,18 @@ let parse_file (face : Affinescript.Face.face) path = Affinescript.Span.pp_short span msg; `Error (false, "Parse error") +(** Build a loader rooted at the source file's directory. The historical + CLI used the process CWD as [current_dir], so compiling `src/Main.affine` + could not find a sibling `src/Helper.affine` even though the module + loader already supported relative sibling paths (#642). *) +let loader_config_for_file path = + let absolute_path = + if Filename.is_relative path then Filename.concat (Sys.getcwd ()) path + else path + in + let config = Affinescript.Module_loader.default_config () in + { config with current_dir = Filename.dirname absolute_path } + (** Type-check a file. With [--json], emits a structured diagnostic report on stderr. *) let check_file face json path = @@ -184,7 +196,7 @@ let check_file face json path = let resolve_refs = ref [] in begin try let prog = parse_with_face face path in - let loader_config = Affinescript.Module_loader.default_config () in + let loader_config = loader_config_for_file path in let loader = Affinescript.Module_loader.create loader_config in (match Affinescript.Resolve.resolve_program_with_loader prog loader with | Error (e, span) -> @@ -232,7 +244,7 @@ let check_file face json path = end else begin try let prog = parse_with_face face path in - let loader_config = Affinescript.Module_loader.default_config () in + let loader_config = loader_config_for_file path in let loader = Affinescript.Module_loader.create loader_config in (match Affinescript.Resolve.resolve_program_with_loader prog loader with | Error (e, _span) -> @@ -282,7 +294,7 @@ let eval_file face json path = let add d = diags := d :: !diags in begin try let prog = parse_with_face face path in - let loader_config = Affinescript.Module_loader.default_config () in + let loader_config = loader_config_for_file path in let loader = Affinescript.Module_loader.create loader_config in (match Affinescript.Resolve.resolve_program_with_loader prog loader with | Error (e, span) -> @@ -325,7 +337,7 @@ let eval_file face json path = end else begin try let prog = parse_with_face face path in - let loader_config = Affinescript.Module_loader.default_config () in + let loader_config = loader_config_for_file path in let loader = Affinescript.Module_loader.create loader_config in (match Affinescript.Resolve.resolve_program_with_loader prog loader with | Error (e, _span) -> @@ -507,7 +519,7 @@ let compile_file face json wasm_gc vscode_ext vscode_adapter vscode_no_lc let add d = diags := d :: !diags in begin try let prog = parse_with_face face path in - let loader_config = Affinescript.Module_loader.default_config () in + let loader_config = loader_config_for_file path in let loader = Affinescript.Module_loader.create loader_config in (match Affinescript.Resolve.resolve_program_with_loader prog loader with | Error (e, span) -> @@ -732,7 +744,7 @@ let compile_file face json wasm_gc vscode_ext vscode_adapter vscode_no_lc end else begin try let prog = parse_with_face face path in - let loader_config = Affinescript.Module_loader.default_config () in + let loader_config = loader_config_for_file path in let loader = Affinescript.Module_loader.create loader_config in (match Affinescript.Resolve.resolve_program_with_loader prog loader with | Error (e, _span) -> @@ -1025,7 +1037,7 @@ let lint_file face json path = let add d = diags := d :: !diags in begin try let prog = parse_with_face face path in - let loader_config = Affinescript.Module_loader.default_config () in + let loader_config = loader_config_for_file path in let loader = Affinescript.Module_loader.create loader_config in (match Affinescript.Resolve.resolve_program_with_loader prog loader with | Error (e, span) -> @@ -1045,7 +1057,7 @@ let lint_file face json path = end else begin try let prog = parse_with_face face path in - let loader_config = Affinescript.Module_loader.default_config () in + let loader_config = loader_config_for_file path in let loader = Affinescript.Module_loader.create loader_config in (match Affinescript.Resolve.resolve_program_with_loader prog loader with | Error (e, _span) -> @@ -1084,7 +1096,7 @@ let compile_to_wasm_module face path : (Affinescript.Wasm.wasm_module, string) Result.t = try let prog = parse_with_face face path in - let loader_config = Affinescript.Module_loader.default_config () in + let loader_config = loader_config_for_file path in let loader = Affinescript.Module_loader.create loader_config in match Affinescript.Resolve.resolve_program_with_loader prog loader with | Error (e, _span) -> @@ -1148,7 +1160,7 @@ let verify_file face path = let face = resolve_face face path in try let prog = parse_with_face face path in - let loader_config = Affinescript.Module_loader.default_config () in + let loader_config = loader_config_for_file path in let loader = Affinescript.Module_loader.create loader_config in (match Affinescript.Resolve.resolve_program_with_loader prog loader with | Error (e, _span) -> @@ -1676,7 +1688,7 @@ let preview_cafe_cmd = let run_pipeline_for_query face path = try let prog = parse_with_face face path in - let loader_config = Affinescript.Module_loader.default_config () in + let loader_config = loader_config_for_file path in let loader = Affinescript.Module_loader.create loader_config in match Affinescript.Resolve.resolve_program_with_loader prog loader with | Error (e, _span) -> diff --git a/lib/module_loader.ml b/lib/module_loader.ml index 02793290..b7e67b6c 100644 --- a/lib/module_loader.ml +++ b/lib/module_loader.ml @@ -262,24 +262,37 @@ let clear_cache (loader : t) : unit = demand. If a referenced module isn't cached, its imports are silently skipped — the resolver would have reported the error already. - Imports are processed in declaration order; later imports override - earlier ones with the same fn name. Local decls in [prog.prog_decls] - always win over imported ones. *) + Imports are processed in declaration order; later imports replace + earlier imported declarations with the same name. This mirrors the + resolver's name-table order and keeps the two paths deterministic. + Glob/glob collisions are rejected by the resolver before code generation; + this function retains the same last-import policy as a defensive fallback + for callers that flatten an already-loaded program directly. Local decls + in [prog.prog_decls] always win over imported ones. *) let flatten_imports (loader : t) (prog : program) : program = (* Local-decl names suppress same-named imports of any kind. *) - let local_names = + let local_name_list = List.filter_map (function | TopFn fd -> Some fd.fd_name.name | TopConst { tc_name; _ } -> Some tc_name.name | _ -> None ) prog.prog_decls in - let already_in = Hashtbl.create 32 in - List.iter (fun n -> Hashtbl.add already_in n ()) local_names; - (* A flattened import is either a function or a constant; both share the - same name-collision rule against [already_in]. *) - let imported_decls = - List.concat_map (fun imp -> + let local_names = Hashtbl.create 32 in + List.iter (fun n -> Hashtbl.replace local_names n ()) local_name_list; + (* Keep imported declaration order stable while replacing the declaration + stored for a later duplicate. A plain `already_in` set used to keep the + FIRST declaration here, while resolve.ml kept the LAST one (#743). *) + let imported_by_name = Hashtbl.create 32 in + let imported_order = ref [] in + let add_imported name decl_kind = + if not (Hashtbl.mem local_names name) then begin + if not (Hashtbl.mem imported_by_name name) then + imported_order := name :: !imported_order; + Hashtbl.replace imported_by_name name decl_kind + end + in + List.iter (fun imp -> let path_strs path = List.map (fun (id : ident) -> id.name) path in @@ -287,7 +300,7 @@ let flatten_imports (loader : t) (prog : program) : program = | ImportSimple (p, _) | ImportList (p, _) | ImportGlob p -> path_strs p in match Hashtbl.find_opt loader.loaded mod_path with - | None -> [] + | None -> () | Some lm -> let public_decls = List.filter_map (fun decl -> match decl with @@ -334,16 +347,15 @@ let flatten_imports (loader : t) (prog : program) : program = (bound_name, renamed)) ) items in - List.filter_map (fun (name, decl_kind) -> - if Hashtbl.mem already_in name then None - else begin - Hashtbl.add already_in name (); - match decl_kind with - | `Fn fd -> Some (TopFn fd) - | `Const decl -> Some decl - end - ) select - ) prog.prog_imports + List.iter (fun (name, decl_kind) -> add_imported name decl_kind) select + ) prog.prog_imports; + let imported_decls = + List.rev !imported_order + |> List.filter_map (fun name -> + match Hashtbl.find_opt imported_by_name name with + | Some (`Fn fd) -> Some (TopFn fd) + | Some (`Const decl) -> Some decl + | None -> None) in (* #138 follow-up: imported TYPE decls are intentionally NOT inlined here. An earlier #138 revision carried imported public [TopType]s so the diff --git a/lib/parser.mly b/lib/parser.mly index 1f9d6049..11da5d72 100644 --- a/lib/parser.mly +++ b/lib/parser.mly @@ -951,6 +951,11 @@ expr_primary: stdlib/traits.affine failed to parse. Resolves/typechecks as an ordinary parameter binding named "self". */ | SELF_KW { ExprVar (mk_ident "self" $startpos $endpos) } + /* `total` is a soft keyword: it modifies `fn` in declaration position, + but is a perfectly ordinary value name in expression/assignment + position. Keep the lexer keyword so `total fn` remains unambiguous, + and recover it here as an identifier (#682). */ + | TOTAL { ExprVar (mk_ident "total" $startpos $endpos) } | name = lower_ident { ExprVar (mk_ident name $startpos $endpos) } /* Struct literal: `Point #{ x: v, y: w }` (affinescript#215). The `#{` sigil makes this unambiguous against a bare block and removes the @@ -1255,9 +1260,15 @@ block_terminator: { ExprBlock inner } block: - | LBRACE stmts = list(stmt) RBRACE + /* Keep the empty block explicit. Using `list(stmt)` for this alternative + made `Pat => {}` depend on Menhir's conflict choice between the inner + and outer closing braces (#644). A block with statements remains + non-empty in the two statement-bearing alternatives below. */ + | LBRACE RBRACE + { { blk_stmts = []; blk_expr = None } } + | LBRACE stmts = nonempty_list(stmt) RBRACE { { blk_stmts = stmts; blk_expr = None } } - | LBRACE stmts = list(stmt) final = block_terminator RBRACE + | LBRACE stmts = nonempty_list(stmt) final = block_terminator RBRACE { { blk_stmts = stmts; blk_expr = Some final } } | LBRACE stmts = stmt_list_nonempty_trailing_expr RBRACE { { blk_stmts = fst stmts; blk_expr = Some (snd stmts) } } @@ -1300,6 +1311,10 @@ pattern_or: pattern_primary: | UNDERSCORE { PatWildcard (mk_span $startpos $endpos) } + /* `total` is contextual rather than reserved in a binder. The lexer must + keep TOTAL for the `total fn` modifier, so accept it as a pattern + variable here as well (#682). */ + | TOTAL { PatVar (mk_ident "total" $startpos $endpos) } | name = lower_ident { PatVar (mk_ident name $startpos $endpos) } | n = INT { PatLit (LitInt (n, mk_span $startpos $endpos)) } | c = CHAR { PatLit (LitChar (c, mk_span $startpos $endpos)) } diff --git a/lib/python_face.ml b/lib/python_face.ml index 39e423a6..16b4c6c7 100644 --- a/lib/python_face.ml +++ b/lib/python_face.ml @@ -226,20 +226,32 @@ let is_blank_line raw = (** Transform Python-style AffineScript source text to canonical AffineScript. The result is valid input for the standard lexer + Menhir parser. - Tail-position detection: a regular statement (non-block-opener) in the - last position of a block — i.e. the next meaningful line's indent is + Tail-position detection: a regular expression in the last position of a + value-producing block — i.e. the next meaningful line's indent is strictly less than the current line's indent — is emitted WITHOUT a - trailing `;`. This preserves the expression-as-return-value semantics - that AffineScript blocks require (a trailing `;` would make the block - yield unit rather than the expression's value). *) + trailing `;`. Loop bodies are statement blocks, however: their final + assignment/expression still needs a semicolon (#683). *) +type block_kind = + | Value_block + | Statement_block + let transform_source source = let lines = Array.of_list (String.split_on_char '\n' source) in let n = Array.length lines in let out = Buffer.create (String.length source + 256) in - (* Indentation stack: innermost level at head, outermost (0) at tail. *) - let stack = ref [0] in - - let top () = match !stack with h :: _ -> h | [] -> 0 in + (* Indentation stack: innermost level at head, outermost (0) at tail. + Each nested indentation also records the kind of the block that opened + it. This matters because `while`/`for` bodies are statement blocks, + even though a function or conditional body may use its final expression + as a return value. *) + let stack = ref [(0, Value_block)] in + let pending_kind = ref None in + + let top () = match !stack with (h, _) :: _ -> h | [] -> 0 in + + let current_kind () = + match !stack with (_, kind) :: _ -> kind | [] -> Value_block + in let emit_dedents target = while top () > target do @@ -280,37 +292,55 @@ let transform_source source = We then continue with `else {` — no leading `}` here because emit_dedents already supplied it. *) emit_dedents ind; + pending_kind := Some Value_block; Buffer.add_string out (with_comment "else {") end else if is_elif_clause stripped then begin (* `elif COND:` — same structure as `else:` *) emit_dedents ind; + pending_kind := Some Value_block; let cond = apply_keywords (elif_condition stripped) in Buffer.add_string out (with_comment ("else if " ^ cond ^ " {")) end else begin - (* Normal line (statement or block-opener) *) + (* Normal line (statement or block-opener). *) emit_dedents ind; - (* Push a new indent level when we step in *) - if ind > top () then stack := ind :: !stack; + (* Push a new indent level when we step in. The pending opener was + recorded on the preceding line; consume it exactly once. *) + if ind > top () then begin + let kind = Option.value !pending_kind ~default:Value_block in + stack := (ind, kind) :: !stack; + pending_kind := None + end else + (* A same-level or dedented line cannot be the body of the previous + opener. Do not leak its kind into a later block. *) + pending_kind := None; let indent_str = String.make ind ' ' in (* Tail-position check: the next meaningful line is less indented (or EOF), meaning this is the last expression in its block. Omit `;` - so the block's value is this expression, not unit. *) + only for value-producing blocks; the final statement in a loop body + must remain terminated. *) let next_ind = next_meaningful_indent i in let is_tail = next_ind < ind in (* -1 (EOF) satisfies this for ind > 0 *) + let may_drop_tail_semicolon = + is_tail && current_kind () = Value_block + in let line_text = match transform_import_line stripped with | Some s -> s (* imports are always top-level statements *) | None -> - if is_block_opener stripped then - (* Replace trailing `:` with ` {` *) + if is_block_opener stripped then begin + (* Replace trailing `:` with ` {`; remember whether the body is a + statement block for the next indentation level. *) + pending_kind := + Some (if starts_with stripped "while " || starts_with stripped "for " + then Statement_block else Value_block); apply_keywords (strip_block_colon stripped) ^ " {" - else if is_tail then + end else if may_drop_tail_semicolon then (* Tail expression: no `;` — this is the block's return value *) apply_keywords stripped else - (* Mid-block statement: terminate with `;` *) + (* Mid-block statement, or the tail of a loop body. *) apply_keywords stripped ^ ";" in Buffer.add_string out (indent_str ^ with_comment line_text) diff --git a/lib/resolve.ml b/lib/resolve.ml index 1a7ca993..4269eca9 100644 --- a/lib/resolve.ml +++ b/lib/resolve.ml @@ -714,6 +714,32 @@ and resolve_imports_with_loader (type_ctx : Typecheck.context) (loader : Module_loader.t) (imports : import_decl list) : unit result = + (* A glob import has no source-level disambiguation: if two glob imports + export the same binding, selecting one would make typechecking and + non-WASM flattening disagree (#743). Record the names introduced by + each glob and reject a collision before registering either side. The + check is based on declarations rather than [all_symbols], because an + imported module's symbol table also contains its seeded builtins. *) + let glob_exports = Hashtbl.create 32 in + let public_decl_names (program : program) = + List.concat_map (function + | TopFn fd when fd.fd_vis = Public || fd.fd_vis = PubCrate -> + [fd.fd_name.name] + | TopType td when td.td_vis = Public || td.td_vis = PubCrate -> + let variants = match td.td_body with + | TyEnum vs -> List.map (fun v -> v.vd_name.name) vs + | TyAlias _ | TyStruct _ | TyExtern -> [] + in + td.td_name.name :: variants + | TopEffect ed when ed.ed_vis = Public || ed.ed_vis = PubCrate -> + ed.ed_name.name :: List.map (fun op -> op.eod_name.name) ed.ed_ops + | TopTrait td when td.trd_vis = Public || td.trd_vis = PubCrate -> + [td.trd_name.name] + | TopConst { tc_vis; tc_name; _ } + when tc_vis = Public || tc_vis = PubCrate -> + [tc_name.name] + | _ -> []) program.prog_decls + in List.fold_left (fun acc import -> let* () = acc in match import with @@ -775,21 +801,47 @@ and resolve_imports_with_loader (* Resolve and type-check the module *) begin match resolve_and_typecheck_module loader loaded_mod with | Ok (mod_symbols, mod_type_ctx) -> - (* Import all public symbols *) - Hashtbl.iter (fun _id sym -> - match sym.Symbol.sym_visibility with - | Public | PubCrate -> - let _ = Symbol.register_import ctx.symbols sym None in - Option.iter (fun scheme -> - Hashtbl.replace type_ctx.Typecheck.var_types sym.Symbol.sym_id scheme; - Hashtbl.replace type_ctx.Typecheck.name_types sym.Symbol.sym_name scheme - ) (lookup_source_scheme - mod_type_ctx.Typecheck.var_types - mod_type_ctx.Typecheck.name_types - sym) - | _ -> () - ) mod_symbols.all_symbols; - Ok () + let module_name = String.concat "::" path_strs in + let exported_names = + match Module_loader.get_module loader path_strs with + | Some loaded -> public_decl_names loaded.mod_program + | None -> [] + in + (* A glob/glob collision is ambiguous even when the two + declarations happen to have compatible types. Refuse it at + the import site instead of silently choosing the first or + last implementation. *) + (match List.find_map (fun name -> + match Hashtbl.find_opt glob_exports name with + | Some previous -> Some (name, previous) + | None -> None) exported_names with + | Some (name, previous) -> + let id = List.hd (List.rev path) in + Error + (ImportError + (Printf.sprintf + "glob import collision for `%s`: exported by `%s` and `%s`; use an item import or alias" + name previous module_name), + id.span) + | None -> + List.iter (fun name -> + Hashtbl.replace glob_exports name module_name) + exported_names; + (* Import all public symbols. *) + Hashtbl.iter (fun _id sym -> + match sym.Symbol.sym_visibility with + | Public | PubCrate -> + let _ = Symbol.register_import ctx.symbols sym None in + Option.iter (fun scheme -> + Hashtbl.replace type_ctx.Typecheck.var_types sym.Symbol.sym_id scheme; + Hashtbl.replace type_ctx.Typecheck.name_types sym.Symbol.sym_name scheme + ) (lookup_source_scheme + mod_type_ctx.Typecheck.var_types + mod_type_ctx.Typecheck.name_types + sym) + | _ -> () + ) mod_symbols.all_symbols; + Ok ()) | Error e -> Error e end | Error (Module_loader.ModuleNotFound _) -> diff --git a/runtime/src/alloc.rs b/runtime/src/alloc.rs index 52c6a7e2..cbc4b020 100644 --- a/runtime/src/alloc.rs +++ b/runtime/src/alloc.rs @@ -149,6 +149,8 @@ pub unsafe extern "C" fn reallocate( /// Get allocation statistics #[no_mangle] pub extern "C" fn alloc_stats() -> (usize, usize, usize) { + // SAFETY: the runtime exposes one allocator state for the single linear + // memory instance; this read is confined to that runtime state. unsafe { ( ALLOCATOR.allocated, diff --git a/runtime/src/ffi.rs b/runtime/src/ffi.rs index 28cae994..27028e80 100644 --- a/runtime/src/ffi.rs +++ b/runtime/src/ffi.rs @@ -41,11 +41,15 @@ impl FfiString { /// Get as byte slice pub unsafe fn as_bytes(&self) -> &[u8] { + // SAFETY: the caller's contract for this unsafe method guarantees + // that `ptr` is valid for `len` bytes for the returned lifetime. slice::from_raw_parts(self.ptr, self.len) } /// Get as str (unchecked) pub unsafe fn as_str(&self) -> &str { + // SAFETY: this method is explicitly unchecked; the caller promises + // valid UTF-8 and the pointer/length contract of `as_bytes`. core::str::from_utf8_unchecked(self.as_bytes()) } } @@ -106,6 +110,8 @@ pub fn init() { /// True if registration succeeded #[no_mangle] pub extern "C" fn register_host_callback(id: u32, callback: HostCallback) -> bool { + // SAFETY: the id bounds check below limits access to the fixed-size + // callback registry; the host supplies a valid C-ABI function pointer. unsafe { if (id as usize) < HOST_REGISTRY.callbacks.len() { HOST_REGISTRY.callbacks[id as usize] = Some(callback); @@ -129,6 +135,8 @@ pub extern "C" fn register_host_callback(id: u32, callback: HostCallback) -> boo /// Result from host function #[no_mangle] pub extern "C" fn call_host(id: u32, arg: *const ()) -> *mut () { + // SAFETY: the registry lookup is bounds-checked by `get`; the host owns + // the callback and is responsible for the validity of its argument. unsafe { if let Some(callback) = HOST_REGISTRY.callbacks.get(id as usize).and_then(|c| *c) { callback(arg, core::ptr::null_mut()) @@ -187,6 +195,8 @@ mod js { /// Print to console #[no_mangle] pub extern "C" fn print(s: FfiString) { + // SAFETY: the imported host function consumes the caller-provided + // pointer and length without retaining them beyond the call. unsafe { console_log(s.ptr, s.len); } diff --git a/runtime/src/panic.rs b/runtime/src/panic.rs index 1606198a..17fe8020 100644 --- a/runtime/src/panic.rs +++ b/runtime/src/panic.rs @@ -90,6 +90,8 @@ pub fn init() { /// * `hook` - Function to call on panic #[no_mangle] pub extern "C" fn set_panic_hook(hook: PanicHook) { + // SAFETY: hook installation updates the runtime's single global callback + // slot; the caller supplies an extern function pointer with C ABI. unsafe { PANIC_HOOK = Some(hook); } @@ -121,6 +123,8 @@ pub extern "C" fn panic( column, }; + // SAFETY: panic handling reads the callback installed in the runtime's + // single global slot and invokes it only for the duration of this call. unsafe { if let Some(hook) = PANIC_HOOK { hook(&info); diff --git a/test/e2e/fixtures/GlobA.affine b/test/e2e/fixtures/GlobA.affine new file mode 100644 index 00000000..6a698d6f --- /dev/null +++ b/test/e2e/fixtures/GlobA.affine @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath +module GlobA; + +pub fn collide(x: Int) -> Int { + return x + 1; +} diff --git a/test/e2e/fixtures/GlobB.affine b/test/e2e/fixtures/GlobB.affine new file mode 100644 index 00000000..5c2a33c6 --- /dev/null +++ b/test/e2e/fixtures/GlobB.affine @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath +module GlobB; + +pub fn collide(x: Int) -> Int { + return x + 2; +} diff --git a/test/e2e/fixtures/glob_collision.affine b/test/e2e/fixtures/glob_collision.affine new file mode 100644 index 00000000..24f0345b --- /dev/null +++ b/test/e2e/fixtures/glob_collision.affine @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath +use GlobA::*; +use GlobB::*; + +pub fn main() -> Int { + return collide(0); +} diff --git a/test/test_e2e.ml b/test/test_e2e.ml index a3e91f84..46802d6a 100644 --- a/test/test_e2e.ml +++ b/test/test_e2e.ml @@ -1064,10 +1064,47 @@ let test_error_parse_missing_arrow () = | exception Lexer.Lexer_error _ -> () | _ -> Alcotest.fail "Expected parse error for missing arrow" +let test_total_is_valid_binding_name () = + let source = + "fn f() -> Int { let mut total = 0; total = total + 1; total }" in + let prog = + try Parse_driver.parse_string ~file:"" source + with + | Parse_driver.Parse_error (msg, _) -> + Alcotest.failf "`total` binding should parse: %s" msg + | Lexer.Lexer_error (msg, _) -> + Alcotest.failf "`total` binding should lex: %s" msg + in + match resolve_program prog with + | Error msg -> Alcotest.failf "`total` binding should resolve/typecheck: %s" msg + | Ok _ -> () + +let test_empty_match_arm_block_parses () = + let source = {|module EmptyArm; + enum Opt { SomeV(Int), NoneV } + pub fn f(o: Opt) -> Int { + match o { + SomeV(v) => { return v; } + NoneV => {} + } + return 0; + }|} in + try + ignore (Parse_driver.parse_string ~file:"" source) + with + | Parse_driver.Parse_error (msg, _) -> + Alcotest.failf "empty match-arm block should parse: %s" msg + | Lexer.Lexer_error (msg, _) -> + Alcotest.failf "empty match-arm block should lex: %s" msg + let error_tests = [ Alcotest.test_case "bad syntax" `Quick test_error_parse_bad_syntax; Alcotest.test_case "unclosed brace" `Quick test_error_parse_unclosed_brace; Alcotest.test_case "missing arrow" `Quick test_error_parse_missing_arrow; + Alcotest.test_case "`total` is valid let/assignment name (#682)" `Quick + test_total_is_valid_binding_name; + Alcotest.test_case "empty match arm block parses (#644)" `Quick + test_empty_match_arm_block_parses; ] (* ============================================================================ @@ -1172,12 +1209,34 @@ let test_python_face_transform_preview () = Alcotest.(check bool) "contains brace" true (String.contains canonical '{') +let test_python_loop_tail_statement_keeps_semicolon () = + let src = {| +def score(n: Int) -> Int: + let mut total = 0 + let mut i = 1 + while i <= n: + total = total + i + i = i + 1 + total +|} in + let canonical = Python_face.preview_transform src in + Alcotest.(check bool) "loop tail assignment is terminated" true + (try + ignore (Str.search_forward (Str.regexp_string "i = i + 1;") canonical 0); + true + with Not_found -> false); + match parse_python src with + | Ok _ -> () + | Error e -> Alcotest.failf "loop face source should parse: %s" e + let python_face_tests = [ Alcotest.test_case "def → fn" `Quick test_python_face_def_to_fn; Alcotest.test_case "if/elif/else chain" `Quick test_python_face_if_else; Alcotest.test_case "keyword substitution" `Quick test_python_face_keywords; Alcotest.test_case "fixture parses (3 fns)" `Quick test_python_face_fixture; Alcotest.test_case "transform preview" `Quick test_python_face_transform_preview; + Alcotest.test_case "loop tail statement keeps semicolon (#683)" `Quick + test_python_loop_tail_statement_keeps_semicolon; ] (* ============================================================================ @@ -3519,6 +3578,26 @@ let test_flatten_imports_dedup_local_wins () = threaded into the importer's environment by both paths (WASM via gen_imports, non-WASM via flatten_imports). *) +let test_glob_import_collision_is_rejected () = + let loader = Module_loader.create { + Module_loader.stdlib_path = "stdlib"; + search_paths = []; + current_dir = fixture_dir; + } in + match parse_fixture (fixture "glob_collision.affine") with + | Error e -> Alcotest.failf "parse failed: %s" e + | Ok prog -> + (match Resolve.resolve_program_with_loader prog loader with + | Ok _ -> Alcotest.fail "glob/glob collision should be rejected" + | Error (err, _) -> + let message = Resolve.show_resolve_error err in + Alcotest.(check bool) "diagnostic identifies the collision" true + (try + ignore (Str.search_forward (Str.regexp_string "glob import collision") + message 0); + true + with Not_found -> false)) + let test_flatten_imports_inlines_public_const () = let loader = Module_loader.create { Module_loader.stdlib_path = "stdlib"; @@ -3566,6 +3645,7 @@ let test_wasm_cross_module_const_compiles () = 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 "glob/glob import collision is rejected (#743)" `Quick test_glob_import_collision_is_rejected; 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; ]