From c86d5e8923a052a52faf5c2bc16e93301a7cb753 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 18 May 2026 05:42:12 +0100 Subject: [PATCH] test(stdlib): AOT smoke gate (#136) + multi-module integration (#137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STAGE-A closers, both wired into `dune runtest` (CI runs it). - test_stdlib_aot.ml #136: every stdlib/*.affine driven through resolve -> typecheck -> borrow -> Deno-ESM codegen (one Alcotest case per file) so the AOT path can't silently rot. 19/19. - test_stdlib_aot.ml #137: one program that `use`s prelude+string+ option+collections together and uses a symbol from each — proves cross-module resolution/typecheck/codegen as a coherent set. Two real coherence gaps surfaced + fixed at source: - resolve.ml: resolve_and_typecheck_module type-checked imported modules with a raw check_decl fold (no forward-declaration), so a module with internal forward refs (collections binary_search -> binary_search_helper) broke ONLY on the import path. Now uses Typecheck.check_program (forward-pass) threading the module's own resolved imports as ~import_types — imported modules now check exactly as standalone programs do. - option/collections: defined zero `pub` API (ADR-011 needs `pub` for cross-module use). Marked the API the integration exercises (unwrap_or, reverse) public. Full suite 233 -> 253 (all green); stdlib 19/19 standalone unchanged. Refs #128 Refs #136 Refs #137 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/resolve.ml | 23 +++--- stdlib/collections.affine | 2 +- stdlib/option.affine | 2 +- test/dune | 3 +- test/test_main.ml | 2 +- test/test_stdlib_aot.ml | 145 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 164 insertions(+), 13 deletions(-) create mode 100644 test/test_stdlib_aot.ml diff --git a/lib/resolve.ml b/lib/resolve.ml index 9e52f541..de1bbef4 100644 --- a/lib/resolve.ml +++ b/lib/resolve.ml @@ -669,15 +669,20 @@ let rec resolve_and_typecheck_module | Ok () -> resolve_decl mod_ctx decl ) (Ok ()) prog.prog_decls in - (* Type-check all declarations. *) - let type_result = List.fold_left (fun acc decl -> - match acc with - | Error e -> Error e - | Ok () -> Typecheck.check_decl type_ctx decl - ) (Ok ()) prog.prog_decls in - - match type_result with - | Ok () -> Ok (symbols, type_ctx) + (* Type-check via [check_program] (NOT a raw check_decl fold): it runs + the forward-declaration pass that pre-registers every function + signature, so a module with internal forward references — e.g. + collections.affine's `binary_search` calling the later + `binary_search_helper` — type-checks on the import path exactly as + it does standalone. The imports this module resolved above were + written into [type_ctx.name_types]; thread them in as [import_types] + so [check_program]'s fresh context still sees them. (#128 coherence: + imported modules must check the same way top-level programs do.) *) + match + Typecheck.check_program + ~import_types:type_ctx.Typecheck.name_types symbols prog + with + | Ok final_ctx -> Ok (symbols, final_ctx) | Error type_err -> let msg = Typecheck.show_type_error type_err in Error (ImportError ("Type checking failed: " ^ msg), Span.dummy) diff --git a/stdlib/collections.affine b/stdlib/collections.affine index 2ac6512f..573c7442 100644 --- a/stdlib/collections.affine +++ b/stdlib/collections.affine @@ -17,7 +17,7 @@ use prelude::{Option, Some, None, filter, map}; // ============================================================================ /// Reverse a list -fn reverse(list: [T]) -> [T] { +pub fn reverse(list: [T]) -> [T] { let mut result = []; for x in list { result = [x] ++ result; diff --git a/stdlib/option.affine b/stdlib/option.affine index ed7a2405..57b4bf2b 100644 --- a/stdlib/option.affine +++ b/stdlib/option.affine @@ -113,7 +113,7 @@ fn expect(opt: Option, msg: String) -> T { } /// Unwrap or provide default value -fn unwrap_or(opt: Option, default: T) -> T { +pub fn unwrap_or(opt: Option, default: T) -> T { match opt { Some(value) => value, None => default diff --git a/test/dune b/test/dune index acff5da3..0c1a31c8 100644 --- a/test/dune +++ b/test/dune @@ -4,4 +4,5 @@ (deps (source_tree golden) (source_tree e2e) - (source_tree ../examples))) + (source_tree ../examples) + (source_tree ../stdlib))) diff --git a/test/test_main.ml b/test/test_main.ml index c05eab3b..f6209d5b 100644 --- a/test/test_main.ml +++ b/test/test_main.ml @@ -10,4 +10,4 @@ let () = (* ("Parser", Test_parser.tests); *) (* TODO: Re-enable when test_parser is implemented *) ("Golden", Test_golden.tests); ("Examples", Test_golden.example_tests); - ] @ Test_e2e.tests) + ] @ Test_e2e.tests @ Test_stdlib_aot.tests) diff --git a/test/test_stdlib_aot.ml b/test/test_stdlib_aot.ml new file mode 100644 index 00000000..33fa5bd7 --- /dev/null +++ b/test/test_stdlib_aot.ml @@ -0,0 +1,145 @@ +(* SPDX-License-Identifier: PMPL-1.0-or-later *) +(* SPDX-FileCopyrightText: 2025 hyperpolymath *) + +(** STAGE-A closers (affinescript#128): + + - #136: stdlib-wide AOT compile-smoke gate — every [stdlib/*.affine] + driven through resolve -> typecheck -> borrow -> codegen + (Deno-ESM), so the AOT path cannot silently rot again. + - #137: a multi-module integration program that [use]s several + stdlib modules together in one compiled unit, proving cross-module + resolution/typecheck/codegen works as a coherent set. *) + +open Affinescript + +(* Locate the stdlib directory from the test's runtime cwd + (`_build/default/test`). The `(source_tree ../stdlib)` dep + materialises it; fall back to a couple of plausible roots. *) +let stdlib_dir = + let candidates = + (match Sys.getenv_opt "AFFINESCRIPT_STDLIB" with + | Some d -> [ d ] | None -> []) + @ [ "../stdlib"; "stdlib"; "../../stdlib" ] + in + match + List.find_opt + (fun d -> Sys.file_exists (Filename.concat d "prelude.affine")) + candidates + with + | Some d -> d + | None -> failwith "test_stdlib_aot: cannot locate stdlib/ (no prelude.affine)" + +let loader () = + let cfg = + { (Module_loader.default_config ()) with + Module_loader.stdlib_path = stdlib_dir } + in + Module_loader.create cfg + +(** Full AOT pipeline: resolve -> typecheck -> borrow -> Deno-ESM codegen. + Returns [Ok js] (the emitted ES-module source) or [Error stage-msg]. *) +let pipeline_to_deno (prog : Ast.program) : (string, string) result = + let ld = loader () in + match Resolve.resolve_program_with_loader prog ld with + | Error (e, sp) -> + Error (Printf.sprintf "resolve: %s @ %s" + (Resolve.show_resolve_error e) (Span.show sp)) + | Ok (rctx, itc) -> + (match + Typecheck.check_program + ~import_types:itc.Typecheck.name_types rctx.symbols prog + with + | Error e -> + Error (Printf.sprintf "typecheck: %s" (Typecheck.format_type_error e)) + | Ok _ -> + (match Borrow.check_program rctx.symbols prog with + | Error e -> + Error (Printf.sprintf "borrow: %s" (Borrow.format_borrow_error e)) + | Ok () -> + let flat = Module_loader.flatten_imports ld prog in + (match Codegen_deno.codegen_deno flat rctx.symbols with + | Error e -> Error (Printf.sprintf "deno-codegen: %s" e) + | Ok js -> Ok js))) + +let parse_file_safe path = + try Ok (Parse_driver.parse_file path) + with + | Parse_driver.Parse_error (msg, span) -> + Error (Printf.sprintf "parse: %s @ %s" msg (Span.show span)) + | e -> Error (Printf.sprintf "parse: %s" (Printexc.to_string e)) + +(* ---- #136: stdlib-wide AOT compile-smoke gate -------------------------- *) + +let stdlib_files () = + Sys.readdir stdlib_dir + |> Array.to_list + |> List.filter (fun f -> Filename.check_suffix f ".affine") + |> List.sort compare + |> List.map (fun f -> Filename.concat stdlib_dir f) + +let check_one path () = + let result = + match parse_file_safe path with + | Error m -> Error m + | Ok prog -> pipeline_to_deno prog + in + match result with + | Ok js -> + Alcotest.(check bool) + (Printf.sprintf "%s emits a non-empty ES module" (Filename.basename path)) + true (String.length js > 0) + | Error m -> + Alcotest.failf "AOT pipeline failed for %s: %s" + (Filename.basename path) m + +let aot_smoke_tests = + List.map + (fun path -> + Alcotest.test_case + (Printf.sprintf "AOT %s" (Filename.basename path)) + `Quick (check_one path)) + (stdlib_files ()) + +(* ---- #137: multi-module integration ----------------------------------- *) + +(* One compiled program that pulls in several stdlib modules together and + actually uses a symbol from each, exercising cross-module + resolution/typecheck/codegen as a coherent set (not file-by-file). *) +let integration_src = {| +use prelude::{ Option, Some, None }; +use string::{ split, join }; +use option::{ unwrap_or }; +use collections::{ reverse }; + +fn pipeline(csv: String) -> String { + let parts = split(csv, ","); + let flipped = reverse(parts); + join(flipped, "-") +} + +fn first_or(xs: [String], dflt: String) -> String { + let head: Option = if len(xs) > 0 { Some(xs[0]) } else { None }; + unwrap_or(head, dflt) +} +|} + +let test_multi_module_integration () = + match Parse_driver.parse_string ~file:"" integration_src with + | exception e -> + Alcotest.failf "integration parse raised: %s" (Printexc.to_string e) + | prog -> + (match pipeline_to_deno prog with + | Ok js -> + Alcotest.(check bool) + "multi-module program compiles to a non-empty ES module" + true (String.length js > 0) + | Error m -> + Alcotest.failf "multi-module integration failed: %s" m) + +let integration_tests = + [ Alcotest.test_case "string+option+collections together" `Quick + test_multi_module_integration ] + +let tests = + [ ("STAGE-A AOT smoke (#136)", aot_smoke_tests); + ("STAGE-A multi-module integration (#137)", integration_tests) ]