From ac7ee912e719b8116f50ab5139fcf5274cba23b8 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Thu, 17 Sep 2026 21:44:40 +0700 Subject: [PATCH 1/7] A wrong main signature points at the main that is wrong check_main raised against Loc.unknown, so both of its refusals opened with :0:0. env.locs is the table of where each type was declared and a function is not in it, so the location comes from the declaration list the caller already holds. A main that arrived without a defn keeps the unknown span rather than being given an invented one. --- lib/check.ml | 28 ++++++++++++++++++++++++---- test/test_flan.ml | 21 +++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/lib/check.ml b/lib/check.ml index 13c9203..8a86a79 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -6272,7 +6272,27 @@ let check_global env (d : Ast.decl) : Tast.global option = (* The entry point, plan.org: (defn main [args [string]] i32), with both the parameter and the return type optional. *) -let check_main env = +(* Where [main] was written. [env.locs] is the table of where each *type* was + declared — [collect] fills it for structs, data types, unions and enums and + for nothing else — so a function's own location is not in it and the + [find_opt] idiom the rest of this file uses does not apply here. The + declaration list does have it, and the caller is holding the list anyway. + + Without this both refusals below opened with :0:0, which tells a + reader that a rule exists and not where they broke it, and gives + [next-error] nothing to jump to. A [main] that arrived some other way — a + [declare], say — still has no [defn] to point at, so that case keeps the + unknown span rather than inventing one. *) +let main_loc (decls : Ast.decl list) = + let is_main (d : Ast.decl) = + match d.Ast.d with Ast.Defn fn -> fn.Ast.name = "main" | _ -> false + in + match List.find_opt is_main decls with + | Some { Ast.d = Ast.Defn fn; _ } -> fn.Ast.nloc + | _ -> Loc.unknown + +let check_main env decls = + let at = main_loc decls in match Hashtbl.find_opt env.fns "main" with | None -> () (* a library, or a file being checked on its own *) | Some (params, ret) -> @@ -6283,12 +6303,12 @@ let check_main env = | _ -> false in if not ok_params then - fail Loc.unknown + fail at "main takes no parameters or one [string], not (%s)" (String.concat " " (List.map Types.to_string params)); if not (Types.equal ret Types.Unit || Types.equal ret (Types.Int Types.I32)) then - fail Loc.unknown "main returns i32 or nothing, not %s" + fail at "main returns i32 or nothing, not %s" (Types.to_string ret) (* The environment as well as the program. A session needs it to check an @@ -6321,7 +6341,7 @@ let build_program ~keep_going (decls : Ast.decl list) : Tast.program * env = check_finite env; check_union_members env; let s = Loc.sink ~on:keep_going in - ignore (Loc.caught s (fun () -> check_main env)); + ignore (Loc.caught s (fun () -> check_main env decls)); (* Every generic body, checked once with its variables left abstract, and the result thrown away. This is the pass plan.org's rule needs and Odin has no equivalent of: Odin checks a polymorphic body only per diff --git a/test/test_flan.ml b/test/test_flan.ml index 4091203..9d13727 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -945,6 +945,27 @@ let () = ~needle:"main takes no parameters"; rejects_check "main returning the wrong type" "(defn main [] bool true)" ~needle:"main returns i32"; + (* And both of them point at the [main] that is wrong. They used to open with + :0:0 — the checker's rule about the entry point is the one place + that had a name and no span, because [env.locs] records where a *type* was + declared and a function is not in it. The span is the whole difference + between a message you can act on and a message you have to go looking for, + so it is pinned here rather than left to the reader of a report. *) + let main_at name src = + match checked src with + | _ -> + incr failures; + Printf.printf "FAIL %s: expected a type error\n" name + | exception Loc.Error { Loc.dloc; _ } -> + let got = Loc.to_string dloc in + if got <> ":1:7" then begin + incr failures; + Printf.printf "FAIL %s\n wanted: %s\n got: %s\n" + name ":1:7" got + end + in + main_at "a wrong main parameter points at main" "(defn main [n i32] ())"; + main_at "a wrong main return type points at main" "(defn main [] bool true)"; (* ── Unconstrained operators, and everything past milestone 2 ──── *) rejects_check "no built-in = on strings" From 223e282e440131ac16a435e75c59ace4f7d46a34 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Thu, 17 Sep 2026 21:44:46 +0700 Subject: [PATCH 2/7] An escaped diagnostic says which file and which line, not (_) Three suite runs left "Fatal error: exception Flan.Loc.Error(_)" on stderr: OCaml's default handler knows nothing about the diag record, so a process that does not catch prints a constructor name and none of the message. The trigger was a corpus file the test binaries check directly and nothing there wraps. Registering a Printexc printer changes no control flow and costs the drivers that do catch nothing; it only makes the corpse legible. --- lib/loc.ml | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/lib/loc.ml b/lib/loc.ml index d79c399..e5bacb2 100644 --- a/lib/loc.ml +++ b/lib/loc.ml @@ -359,8 +359,8 @@ let report (d : diag) = it found. *) let report_all (ds : diag list) = (* Source order, with the placeless ones last. A diagnostic the checker - raised against [unknown] — a wrong [main] signature is the one that - happens — has line 0, and sorting on the number alone would put it at the + raised against [unknown] — a rule about a declaration that is not in this + file to point at — has line 0, and sorting on the number alone would put it at the top of the list, above every error that can actually be clicked. It is a real error and it is not anywhere, so it goes after the ones that are. *) let placed (d : diag) = d.dloc.line > 0 in @@ -376,3 +376,24 @@ let report_all (ds : diag list) = let n = List.length ds in String.concat "\n" (List.map report ds) ^ Printf.sprintf "\n%d error%s" n (if n = 1 then "" else "s") + +(* The last line of defence, and the one nobody plans to reach. Every driver in + this tree catches [Error] and [Errors] and prints [report]; a process that + does not — a test binary walking the corpus, a tool written in an afternoon — + dies through OCaml's default handler, and the default handler knows nothing + about this record. What it prints is [Fatal error: exception + Flan.Loc.Error(_)]: the name of a constructor, an underscore, and not one + word of the diagnostic that was carefully built to say what was wrong and + where. That is how three suite runs came to leave a message-less fatal on + stderr while reporting that they had passed. + + Registering a printer costs nothing and cannot change control flow — the + exception still propagates and still kills whatever did not catch it. All it + changes is that the corpse says which file and which line, which is the + whole of what the diagnostic was for. Drivers that do catch are unaffected: + they never ask [Printexc] anything. *) +let () = + Printexc.register_printer (function + | Error d -> Some (report d) + | Errors ds -> Some (report_all ds) + | _ -> None) From 652361e16952c37af816bda8f095ad2e15b2c507 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Thu, 17 Sep 2026 21:44:55 +0700 Subject: [PATCH 3/7] A missing file is a sentence, and flan run's two argument lists are told apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit with_errors had no Sys_error arm, so flan check nosuch.flan ended in OCaml's default handler; the daemon has had that arm since before the CLI did. A Not_found backstop joins it — nothing reaches it today, and the day something does the failure should name the file rather than say nothing at all. flan run handed every flag it did not understand to the compiled program: flan run game.flan --debug built at -O2 and gave the game a --debug. Build flags are now the build's, -- ends them, and an unknown dash argument before -- is refused by name with -- named as the way to mean it for the program. -O0 through -O3 get a spelling on build and run, which they did not have at all: Build.default pinned -O2 and --debug was the only route to anything else. Four levels and not five, because -Os is clang's and llc rejects it, and the same string reaches both. --debug with a higher level is refused rather than quietly overruled by Build's own -O0. --- bin/main.ml | 131 +++++++++++++++++++++++++++++++++++++--- test/test_acceptance.ml | 65 ++++++++++++++++++++ 2 files changed, 188 insertions(+), 8 deletions(-) diff --git a/bin/main.ml b/bin/main.ml index 52ac753..5801d72 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -31,6 +31,31 @@ let with_errors path f = prerr_endline ("flan: " ^ m); ignore path; exit 1 + (* A file that is not there, or that cannot be read. The exception already + carries the path and the reason the operating system gave, which is the + whole of what anybody can act on, so it is passed through as it is — + [lib/dev.ml]'s [message_of_exn] does the same for the same reason, and + the daemon has had this arm since before the CLI did. Without it + [flan check nosuch.flan] ends in [Fatal error: exception Sys_error(...)], + which is the compiler telling the user it did not expect to be asked. *) + | Sys_error m -> + prerr_endline ("flan: " ^ m); + ignore path; + exit 1 + (* The backstop. Nothing in this binary reaches it today: every [Hashtbl.find] + on a path a command can take is guarded by a [find_opt]. It is here so + that the day one is not, the failure is a sentence naming the file being + worked on rather than a bare [Fatal error: exception Not_found] — which + says nothing at all, not even which file. Its own exit status, because a + compiler bug is not a refusal of the program and a script should be able + to tell the two apart. *) + | Not_found -> + prerr_endline + ("flan: internal error — a lookup failed with no name to report, while \ + working on " ^ path + ^ ". This is a bug in the compiler and not a fault in the program; \ + please report the file that provoked it."); + exit 4 let summarise (d : Flan.Ast.decl) = let open Flan.Ast in @@ -143,8 +168,43 @@ let flags = usage error. *) let target_prefix = "--target=" +(* The optimisation level, which until now had no spelling at all: [Build.default] + pinned -O2 and [--debug] was the only route to anything else. Four levels and + not five — -Os is clang's and llc rejects it outright ("invalid optimization + level"), and the same string reaches both ([Build] at the clang compile and + again at the llc step of the live loop), so offering a level one of the two + tools does not know would be a flag that works for [flan build] and breaks + [C-c C-c]. + + Last one wins, which is what every compiler does with a repeated -O and the + only rule that does not need explaining. *) +let opt_levels = [ "-O0"; "-O1"; "-O2"; "-O3" ] + +let opt_of args = + List.fold_left + (fun acc a -> if List.mem a opt_levels then Some a else acc) + None args + let is_flag a = - List.mem a flags || String.starts_with ~prefix:target_prefix a + List.mem a flags || List.mem a opt_levels + || String.starts_with ~prefix:target_prefix a + +(* [--debug] forces -O0 in [Build] and says why there: [llvm.dbg.declare] + describes an alloca and mem2reg deletes the alloca, so a debug build at -O2 + has a line table over code whose locals are gone. That is a good rule, and + it makes [--debug -O2] a request that cannot be honoured — so it is refused + here by name instead of being quietly overruled two modules away. *) +let check_opt_against_debug ~debug ~opt = + match (debug, opt) with + | true, Some o when o <> "-O0" -> + prerr_endline + ("flan: --debug and " ^ o + ^ " ask for opposite things — a debug build is -O0 because \ + llvm.dbg.declare describes an alloca and mem2reg at any higher level \ + deletes it, leaving a line table over locals that are not there. \ + Drop one of the two."); + exit 2 + | _ -> () let target_of args = List.find_map @@ -507,6 +567,8 @@ let () = let debug = List.mem debug_flag rest in let sanitize = List.mem sanitize_flag rest in let x86 = List.mem x86_flag rest in + let opt = opt_of rest in + check_opt_against_debug ~debug ~opt; let target = target_of rest in let out = match List.filter (fun a -> not (is_flag a)) rest with @@ -524,7 +586,8 @@ let () = | _ -> base) | _ -> prerr_endline - "usage: flan build [-o out] [--no-bounds-checks] \ + "usage: flan build [-o out] [-O0|-O1|-O2|-O3] \ + [--no-bounds-checks] \ [--dev] [--debug] [--sanitize] [--target=wasm32-wasi|web]"; exit 2 in @@ -538,7 +601,9 @@ let () = let p, csrcs, lflags = Flan.Reach.link ~dev l p in ignore (Flan.Build.executable ~opts:{ Flan.Build.default with checks; dev; debug; sanitize; - target; x86 } + target; x86; + opt = Option.value opt + ~default:Flan.Build.default.Flan.Build.opt } ~csrcs ~lflags ~pnames:(if debug then param_names l else []) p ~out)) (* The daemon an editor talks to: one session, the program it belongs to @@ -626,6 +691,48 @@ let () = this host can exec. Use flan build --target=... and a wasm runtime."; exit 2 | _ :: "run" :: path :: args -> + (* One command, two argument lists, and until now no rule saying which was + which: [flan run game.flan --debug] built at -O2 and handed the game a + [--debug] it had never heard of. Nothing reported that, because neither + side thought it had been given anything wrong. + + The rule, in one line: a build flag is the build's, [--] ends the build + flags, and everything after [--] is the program's whatever it looks + like. Before [--], an argument that starts with a dash and is not a + build flag this command offers is refused by name — not guessed at, + because guessing is the failure this exists to stop, and a program of + one's own that wants [-v] has [--] to ask for it. Plain arguments need + no ceremony: they were never ambiguous and they still go straight + through, so [flan run calc-me.flan "1+2"] is unchanged. *) + let run_flags = [ no_checks_flag; dev_flag; debug_flag; sanitize_flag; + x86_flag ] @ opt_levels in + let build_args, prog_args = + let rec split acc = function + | "--" :: rest -> (List.rev acc, rest) + | a :: rest when String.length a > 1 && a.[0] = '-' -> + if List.mem a run_flags then split (a :: acc) rest + else begin + prerr_endline + ("flan run: " ^ a + ^ " is not a flag this command offers, and it will not be \ + guessed at — a build flag belongs to the build and \ + anything else belongs to the program. Write it after -- to \ + send it to the program: flan run " + ^ Filename.basename path ^ " -- " ^ a); + exit 2 + end + | a :: rest -> let l, r = split acc rest in (l, a :: r) + | [] -> (List.rev acc, []) + in + split [] args + in + let checks = not (List.mem no_checks_flag build_args) in + let dev = List.mem dev_flag build_args in + let debug = List.mem debug_flag build_args in + let sanitize = List.mem sanitize_flag build_args in + let x86 = List.mem x86_flag build_args in + let opt = opt_of build_args in + check_opt_against_debug ~debug ~opt; with_errors path (fun () -> let exe = Filename.concat (Filename.get_temp_dir_name ()) @@ -633,10 +740,17 @@ let () = in let l = load path in let p = Flan.Check.program_all l.decls in - let p, csrcs, lflags = Flan.Reach.link l p in - ignore (Flan.Build.executable ~csrcs ~lflags p ~out:exe); + let p, csrcs, lflags = Flan.Reach.link ~dev l p in + ignore (Flan.Build.executable + ~opts:{ Flan.Build.default with checks; dev; debug; sanitize; + x86; + opt = Option.value opt + ~default:Flan.Build.default.Flan.Build.opt } + ~csrcs ~lflags ~pnames:(if debug then param_names l else []) + p ~out:exe); let code = - Sys.command (String.concat " " (List.map Filename.quote (exe :: args))) + Sys.command + (String.concat " " (List.map Filename.quote (exe :: prog_args))) in (try Sys.remove exe with Sys_error _ -> ()); exit code) @@ -645,9 +759,10 @@ let () = "usage: flan (read|parse|check|emit|shim) ...\n flan emit [--x86] [--dev] [--debug] [--no-bounds-checks]\n\ \ flan import-c [package.flan...] [clang flags...]\n\ \ flan generate-c \n\ - \ flan build [-o out] [--no-bounds-checks] [--dev] \ + \ flan build [-o out] [-O0|-O1|-O2|-O3] \ + [--no-bounds-checks] [--dev] \ [--debug] [--sanitize] [--x86] [--target=wasm32-wasi|web]\n\ - \ flan run [args...]\n\ + \ flan run [build flags...] [--] [program args...]\n\ \ flan reload [-o out.so] [--x86]\n\ \ flan dev [-s socket] [--x86]"; exit 2 diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index a99ab31..120c833 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -3670,6 +3670,71 @@ ERR@7 unexpected token: not the kind the caller was reading outputs ~opt:"-O0" "utf-8, splitting and ascii case, -O0" "programs/utf8.flan" utf8_out; + (* ── The driver's own refusals ───────────────────────────────────── + + Not about compiled code at all: about what the CLI does when it is + handed something it cannot do. Each of these was an escape before — + an OCaml exception printed by the default handler, or a build flag + silently forwarded to the program — and each is pinned here because + "it prints a sentence" is exactly the kind of claim that rots without + a test to hold it. *) + let cli args = + let out = Filename.concat scratch "flan-cli.out" in + let code = + Sys.command + (Printf.sprintf "../bin/main.exe %s > %s 2>&1" args (Filename.quote out)) + in + let text = In_channel.with_open_bin out In_channel.input_all in + (try Sys.remove out with Sys_error _ -> ()); + (code, text) + in + let cli_case name args ~code:want_code ~says = + let code, text = cli args in + let bad = + code <> want_code + || List.exists (fun n -> not (contains text n)) says + (* The point of half of these: no arm here may end in OCaml's default + handler, whatever else it does. *) + || contains text "Fatal error" + in + if bad then begin + incr failures; + Printf.printf "FAIL %s\n got: %S (exit %d)\n wanted exit %d with %s\n" + name text code want_code (String.concat ", " says) + end + in + (* A path that is not there. This used to be + [Fatal error: exception Sys_error("...")] — the compiler reporting that + it had not expected to be asked. *) + cli_case "check on a file that is not there" + "check no-such-file.flan" ~code:1 + ~says:[ "no-such-file.flan"; "No such file or directory" ]; + (* And every other front end takes the same route, since the arm is on the + one wrapper they all go through. *) + cli_case "build on a file that is not there" + "build no-such-file.flan -o /dev/null" ~code:1 + ~says:[ "no-such-file.flan" ]; + (* [flan run] and its two argument lists. -O0 is the build's, so calc-me + never sees it and answers the expression that follows; before the split + it was handed "-O0" as the expression and said it could not parse it. *) + cli_case "run keeps a build flag out of the program's argv" + "run ../calc-me.flan -O0 '1+2'" ~code:0 ~says:[ "3" ]; + (* -- hands the rest over whatever it looks like, which is what makes the + refusal below affordable. *) + cli_case "run passes everything after -- to the program" + "run ../calc-me.flan -O0 -- '3*4'" ~code:0 ~says:[ "12" ]; + (* And an unknown dash argument is refused by name rather than guessed at + in either direction. *) + cli_case "run refuses a flag it does not offer" + "run ../calc-me.flan --lint" ~code:2 + ~says:[ "--lint"; "will not be guessed at"; "--" ]; + (* The one pair of flags that cannot both be honoured: --debug is -O0 in + [Build] and says why, so asking for it alongside a higher level is a + request with two answers. *) + cli_case "--debug and an explicit -O are refused together" + "build ../calc-me.flan --debug -O2 -o /dev/null" ~code:2 + ~says:[ "--debug"; "-O2"; "Drop one of the two" ]; + if !failures = 0 then print_endline "acceptance: all tests passed" else begin Printf.printf "\n%d failure(s)\n" !failures; From 668268b6cc86b20eaa23a6753b7526bebedf4d09 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Thu, 17 Sep 2026 21:48:03 +0700 Subject: [PATCH 4/7] The wait for a reply is a setting, and the directory installs The 30s deadline in the reply reader was a literal and its message said only that nothing had arrived. It is flan-dev-reply-timeout now, and the message names the daemon buffer to look in -- a first compile on a cold cache is the case that legitimately runs long, and the build log is what says so -- and the setting to raise. Still never resent: a request the daemon took and died on may already have run. Package headers on all eight client files, so package-install-file on the directory works and the client is not reachable only by load-path. The daemon-buffer defcustom moves up beside the other buffer names, because the reply reader now names it and the byte-compiler reads a file in order. --- emacs/MANUAL.md | 25 ++++++++++++++++ emacs/flan-cnr.el | 19 +++++++++++++ emacs/flan-dape.el | 19 +++++++++++++ emacs/flan-dev.el | 66 +++++++++++++++++++++++++++++++++++++------ emacs/flan-inspect.el | 19 +++++++++++++ emacs/flan-lower.el | 19 +++++++++++++ emacs/flan-mode.el | 19 +++++++++++++ emacs/flan-repl.el | 19 +++++++++++++ emacs/flan-watch.el | 19 +++++++++++++ 9 files changed, 215 insertions(+), 9 deletions(-) diff --git a/emacs/MANUAL.md b/emacs/MANUAL.md index a33c335..d1a7e04 100644 --- a/emacs/MANUAL.md +++ b/emacs/MANUAL.md @@ -30,9 +30,34 @@ works without dape installed, and `C-c C-g` only exists once you load it. (require 'flan-dape) ; only if you have dape ``` +Or install it as a package, which gets you autoloads and byte-compiled files +without a `load-path` line. `package-install-file` names the package after the +directory, so the directory has to be called `flan-mode` — symlink it once and +point Emacs at the link: + +```sh +ln -s ~/Development/flan/emacs ~/.emacs.d/flan-mode +``` + +``` +M-x package-install-file RET ~/.emacs.d/flan-mode/ RET +``` + +Every file carries `Version:` and `Package-Requires: ((emacs "29.1"))`, which is +what package.el reads. dape is deliberately not in that list: `flan-dape.el` +reaches it through `declare-function`, so everything else installs and works +without it. + You also need the `flan` binary on your `PATH`. If it is somewhere else, set `flan-dev-command`. +Two settings worth knowing about before you need them. `flan-dev-start-timeout` +(60s) bounds the wait for a daemon to come up, and `flan-dev-reply-timeout` +(30s) bounds one request once it has. The second is the one a long first +compile can exhaust: the message says so and names `*flan-dev*`, where the +daemon's own build log is, so you can see whether it is still working before +you raise it. + --- ## Starting a program diff --git a/emacs/flan-cnr.el b/emacs/flan-cnr.el index 73e645f..b8fe4ec 100644 --- a/emacs/flan-cnr.el +++ b/emacs/flan-cnr.el @@ -1,5 +1,24 @@ ;;; flan-cnr.el --- What a stopped program is offering -*- lexical-binding: t; -*- +;; Author: Joseph Ferano +;; Version: 0.1.0 +;; Package-Requires: ((emacs "29.1")) +;; Keywords: languages, lisp, tools + +;; The headers above are what make this directory installable. M-x +;; package-install-file on it reads them, and a file with no Version: is not a +;; package as far as package.el is concerned -- until now the client was +;; reachable only by adding it to load-path by hand, which is a thing to +;; explain to every person who wants to try it. +;; +;; 29.1 is the floor because it is the oldest Emacs any of this has been run +;; against, not because some function here is known to need it. dape, which +;; flan-dape drives, asks for 29.1 as well and is a soft dependency: it is +;; reached through declare-function, so the rest of the client loads and works +;; without it and it is deliberately not listed above. The compiler this talks +;; to is not an Emacs package and cannot be listed here either -- emacs/MANUAL.md +;; says what has to be on PATH. + ;; `C-c C-b' is a `completing-read' over restart names. That is the whole UI ;; for the one moment the dev loop exists to make survivable, and it is thin in ;; a way that is worth being specific about: it shows the names and nothing diff --git a/emacs/flan-dape.el b/emacs/flan-dape.el index 9ab2b31..8371604 100644 --- a/emacs/flan-dape.el +++ b/emacs/flan-dape.el @@ -1,5 +1,24 @@ ;;; flan-dape.el --- Debug a Flan program with dape and lldb-dap -*- lexical-binding: t; -*- +;; Author: Joseph Ferano +;; Version: 0.1.0 +;; Package-Requires: ((emacs "29.1")) +;; Keywords: languages, lisp, tools + +;; The headers above are what make this directory installable. M-x +;; package-install-file on it reads them, and a file with no Version: is not a +;; package as far as package.el is concerned -- until now the client was +;; reachable only by adding it to load-path by hand, which is a thing to +;; explain to every person who wants to try it. +;; +;; 29.1 is the floor because it is the oldest Emacs any of this has been run +;; against, not because some function here is known to need it. dape, which +;; flan-dape drives, asks for 29.1 as well and is a soft dependency: it is +;; reached through declare-function, so the rest of the client loads and works +;; without it and it is deliberately not listed above. The compiler this talks +;; to is not an Emacs package and cannot be listed here either -- emacs/MANUAL.md +;; says what has to be on PATH. + ;; The other half of the dev loop. flan-dev.el is about a program that keeps ;; running while you change it; this is about stopping one and reading it. ;; diff --git a/emacs/flan-dev.el b/emacs/flan-dev.el index bbee9e7..1586a26 100644 --- a/emacs/flan-dev.el +++ b/emacs/flan-dev.el @@ -1,5 +1,24 @@ ;;; flan-dev.el --- Talk to a running Flan program -*- lexical-binding: t; -*- +;; Author: Joseph Ferano +;; Version: 0.1.0 +;; Package-Requires: ((emacs "29.1")) +;; Keywords: languages, lisp, tools + +;; The headers above are what make this directory installable. M-x +;; package-install-file on it reads them, and a file with no Version: is not a +;; package as far as package.el is concerned -- until now the client was +;; reachable only by adding it to load-path by hand, which is a thing to +;; explain to every person who wants to try it. +;; +;; 29.1 is the floor because it is the oldest Emacs any of this has been run +;; against, not because some function here is known to need it. dape, which +;; flan-dape drives, asks for 29.1 as well and is a soft dependency: it is +;; reached through declare-function, so the rest of the client loads and works +;; without it and it is deliberately not listed above. The compiler this talks +;; to is not an Emacs package and cannot be listed here either -- emacs/MANUAL.md +;; says what has to be on PATH. + ;; The editor half of Flan's dev loop. `flan dev program.flan' compiles the ;; program, launches it, and listens on .flan-dev.sock beside the source; this ;; connects to that socket and sends it forms. M-x flan-dev starts that @@ -77,6 +96,29 @@ that quietly did nothing, which is why it is on." "Buffer the running program's own output is appended to." :type 'string) +(defcustom flan-dev-daemon-buffer "*flan-dev*" + "Buffer the daemon's own output goes to. +This is where a build that failed says so: the daemon compiles the program +before it binds its socket, so a program that does not compile produces no +socket at all and this buffer is the only account of why. It is also where a +build that is merely slow can be watched, which is what the reply timeout +below points at." + :type 'string) + +(defcustom flan-dev-reply-timeout 30 + "Seconds to wait for one reply from the daemon before giving up. + +This bounds a single request, not a session. Thirty seconds is generous +for an evaluation and tight for a first compile of a large program on a +cold object cache, which is the one case that legitimately runs long — so +it is a setting rather than a constant. + +Giving up here never resends. If the daemon took the request and died +before replying the evaluation may well already have happened, and sending +it again would install a body twice or run a side-effecting expression +twice; reconnecting happens before a send and never after one." + :type 'number) + (defvar flan-dev--connection nil "The open connection, or nil.") @@ -149,7 +191,7 @@ Point is in the process buffer, and the frame is known to be complete." (defun flan-dev--read-reply (proc) "Block until PROC sends one complete framed message, and read it." (with-current-buffer (process-buffer proc) - (let ((deadline (+ (float-time) 30))) + (let ((deadline (+ (float-time) flan-dev-reply-timeout))) ;; The header first: digits up to a newline. (while (and (not (save-excursion (goto-char (point-min)) (re-search-forward "\\`\\([0-9]+\\)\n" nil t))) @@ -161,9 +203,17 @@ Point is in the process buffer, and the frame is known to be complete." ;; before replying, the evaluation may well have happened — sending it ;; again would install it twice, or run a side-effecting expression ;; twice. Reconnecting happens before a send, never after one. + ;; What a person does next, not just what failed. A live daemon that + ;; has not answered is nearly always still working — a first compile + ;; of a large program on a cold object cache is the case that runs + ;; long — and the daemon's own log says which step it is on, so the + ;; message names the buffer to look in and the setting to raise + ;; rather than leaving both to be discovered. (if (process-live-p proc) - (error "flan dev: no reply in 30s from %s" - (abbreviate-file-name (or flan-dev--socket "the daemon"))) + (error "flan dev: no reply in %ss from %s; the daemon may still be building — see %s for its log, and raise `flan-dev-reply-timeout' if this build is simply long" + flan-dev-reply-timeout + (abbreviate-file-name (or flan-dev--socket "the daemon")) + flan-dev-daemon-buffer) (error "flan dev: the daemon on %s closed the connection; not resent, because it may already have run" (abbreviate-file-name (or flan-dev--socket "?"))))) @@ -511,12 +561,10 @@ With no argument, look for `flan-dev-socket-name' up from this buffer." A name is looked up on `exec-path'; a path is used as given." :type 'string) -(defcustom flan-dev-daemon-buffer "*flan-dev*" - "Buffer the daemon's own output goes to. -This is where a build that failed says so: the daemon compiles the program -before it binds its socket, so a program that does not compile produces no -socket at all and this buffer is the only account of why." - :type 'string) +;; `flan-dev-daemon-buffer' belongs to this section and is declared with the +;; other buffer names at the top of the file instead, because the reply reader +;; -- which runs long before any of this -- names it in the message it gives +;; when a request times out, and the byte-compiler reads a file in order. (defcustom flan-dev-start-timeout 60 "Seconds to wait for a daemon started from Emacs to accept a connection. diff --git a/emacs/flan-inspect.el b/emacs/flan-inspect.el index c77d2e1..9e065bd 100644 --- a/emacs/flan-inspect.el +++ b/emacs/flan-inspect.el @@ -1,5 +1,24 @@ ;;; flan-inspect.el --- Navigate a running program's values -*- lexical-binding: t; -*- +;; Author: Joseph Ferano +;; Version: 0.1.0 +;; Package-Requires: ((emacs "29.1")) +;; Keywords: languages, lisp, tools + +;; The headers above are what make this directory installable. M-x +;; package-install-file on it reads them, and a file with no Version: is not a +;; package as far as package.el is concerned -- until now the client was +;; reachable only by adding it to load-path by hand, which is a thing to +;; explain to every person who wants to try it. +;; +;; 29.1 is the floor because it is the oldest Emacs any of this has been run +;; against, not because some function here is known to need it. dape, which +;; flan-dape drives, asks for 29.1 as well and is a soft dependency: it is +;; reached through declare-function, so the rest of the client loads and works +;; without it and it is deliberately not listed above. The compiler this talks +;; to is not an Emacs package and cannot be listed here either -- emacs/MANUAL.md +;; says what has to be on PATH. + ;; `C-x C-e' renders a value once and puts it in the echo area. This is the ;; interactive version of the same walk: the fields laid out one per line, RET ;; to go into one, `l' to come back, `g' to read it again. CIDER's inspector, diff --git a/emacs/flan-lower.el b/emacs/flan-lower.el index cd20c29..e289302 100644 --- a/emacs/flan-lower.el +++ b/emacs/flan-lower.el @@ -1,5 +1,24 @@ ;;; flan-lower.el --- Every lowering of one function, in one buffer -*- lexical-binding: t; -*- +;; Author: Joseph Ferano +;; Version: 0.1.0 +;; Package-Requires: ((emacs "29.1")) +;; Keywords: languages, lisp, tools + +;; The headers above are what make this directory installable. M-x +;; package-install-file on it reads them, and a file with no Version: is not a +;; package as far as package.el is concerned -- until now the client was +;; reachable only by adding it to load-path by hand, which is a thing to +;; explain to every person who wants to try it. +;; +;; 29.1 is the floor because it is the oldest Emacs any of this has been run +;; against, not because some function here is known to need it. dape, which +;; flan-dape drives, asks for 29.1 as well and is a soft dependency: it is +;; reached through declare-function, so the rest of the client loads and works +;; without it and it is deliberately not listed above. The compiler this talks +;; to is not an Emacs package and cannot be listed here either -- emacs/MANUAL.md +;; says what has to be on PATH. + ;; `spike/x86/dump.sh' prints four lowerings of one function side by side -- ;; the LLVM IR the frontend emits, what `llc' makes of it at -O0 and at -O2, ;; and what the hand-written x86 backend emits. Reading one against another is diff --git a/emacs/flan-mode.el b/emacs/flan-mode.el index 491c1ff..8309bd0 100644 --- a/emacs/flan-mode.el +++ b/emacs/flan-mode.el @@ -1,5 +1,24 @@ ;;; flan-mode.el --- Major mode for Flan -*- lexical-binding: t; -*- +;; Author: Joseph Ferano +;; Version: 0.1.0 +;; Package-Requires: ((emacs "29.1")) +;; Keywords: languages, lisp, tools + +;; The headers above are what make this directory installable. M-x +;; package-install-file on it reads them, and a file with no Version: is not a +;; package as far as package.el is concerned -- until now the client was +;; reachable only by adding it to load-path by hand, which is a thing to +;; explain to every person who wants to try it. +;; +;; 29.1 is the floor because it is the oldest Emacs any of this has been run +;; against, not because some function here is known to need it. dape, which +;; flan-dape drives, asks for 29.1 as well and is a soft dependency: it is +;; reached through declare-function, so the rest of the client loads and works +;; without it and it is deliberately not listed above. The compiler this talks +;; to is not an Emacs package and cannot be listed here either -- emacs/MANUAL.md +;; says what has to be on PATH. + ;; Derived from `prog-mode', borrowing `lisp-mode''s machinery for the parts ;; that are simply s-expressions: sexp motion, paren matching and ;; `beginning-of-defun' already do the right thing. diff --git a/emacs/flan-repl.el b/emacs/flan-repl.el index f66256a..762dd79 100644 --- a/emacs/flan-repl.el +++ b/emacs/flan-repl.el @@ -1,5 +1,24 @@ ;;; flan-repl.el --- A prompt for a running Flan program -*- lexical-binding: t; -*- +;; Author: Joseph Ferano +;; Version: 0.1.0 +;; Package-Requires: ((emacs "29.1")) +;; Keywords: languages, lisp, tools + +;; The headers above are what make this directory installable. M-x +;; package-install-file on it reads them, and a file with no Version: is not a +;; package as far as package.el is concerned -- until now the client was +;; reachable only by adding it to load-path by hand, which is a thing to +;; explain to every person who wants to try it. +;; +;; 29.1 is the floor because it is the oldest Emacs any of this has been run +;; against, not because some function here is known to need it. dape, which +;; flan-dape drives, asks for 29.1 as well and is a soft dependency: it is +;; reached through declare-function, so the rest of the client loads and works +;; without it and it is deliberately not listed above. The compiler this talks +;; to is not an Emacs package and cannot be listed here either -- emacs/MANUAL.md +;; says what has to be on PATH. + ;; A buffer to type expressions at, sent to the program `flan dev' is running ;; and answered with the value they had *there*. It adds no protocol and no ;; compiler support: every line goes through the same `eval-expr' request that diff --git a/emacs/flan-watch.el b/emacs/flan-watch.el index da26578..f0f6e0f 100644 --- a/emacs/flan-watch.el +++ b/emacs/flan-watch.el @@ -1,5 +1,24 @@ ;;; flan-watch.el --- Watched values, in a pinned buffer or inline -*- lexical-binding: t; -*- +;; Author: Joseph Ferano +;; Version: 0.1.0 +;; Package-Requires: ((emacs "29.1")) +;; Keywords: languages, lisp, tools + +;; The headers above are what make this directory installable. M-x +;; package-install-file on it reads them, and a file with no Version: is not a +;; package as far as package.el is concerned -- until now the client was +;; reachable only by adding it to load-path by hand, which is a thing to +;; explain to every person who wants to try it. +;; +;; 29.1 is the floor because it is the oldest Emacs any of this has been run +;; against, not because some function here is known to need it. dape, which +;; flan-dape drives, asks for 29.1 as well and is a soft dependency: it is +;; reached through declare-function, so the rest of the client loads and works +;; without it and it is deliberately not listed above. The compiler this talks +;; to is not an Emacs package and cannot be listed here either -- emacs/MANUAL.md +;; says what has to be on PATH. + ;; A HUD for a running Flan program: a buffer that always shows the current ;; frame's values and nothing else. Ported from the author's Clojure ;; `clj-watch', with one thing kept and one thing inverted. From ccb100d74c4f8774cebb44c54864d44af874e910 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Thu, 17 Sep 2026 21:50:33 +0700 Subject: [PATCH 5/7] The command list is all eleven, the environment is written down, and one promise is withdrawn README documented four subcommands of eleven. The seven missing ones are there now, with import-c and generate-c given a worked example each -- they are the most valuable thing here that nothing documented at all. An environment table, checked against the getenv sites rather than against a list: thirteen variables, each with where it is read, plus the llc/clang version coupling that breaks C-c C-c while flan build keeps working. The FLAN_DEV_* set that flan dev hands itself across its own exec is named as internal rather than left looking settable. DISCUSS.md's survey of what the x86 backend had no plan for still listed the whole condition family. x86.ml:1587-1615 lowers all of it and the survey is 104/104; the row is struck through and corrected in place, because other files cite that table by position. prelude.ml promised a core: package at milestone 3. Milestone 3 came and went and the package did not, so the docstring states the limit instead of promising a way out of it. The loader could carry one -- what is missing is the decision about what core: means for a program that imports nothing. --- README.md | 136 +++++++++++++++++++++++++++++++++++++++++++++--- docs/DISCUSS.md | 11 +++- lib/prelude.ml | 14 +++-- 3 files changed, 150 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index cf9d01c..46ffb41 100644 --- a/README.md +++ b/README.md @@ -118,17 +118,139 @@ be threaded through every caller. See ## Commands +Eleven of them, and the four anyone starts with: + ```text flan check type-check a program -flan run [args...] build and run it -flan build [-o out] [options] build a native executable -flan dev [-s socket] start a live development session +flan run [flags] [-- args...] build and run it +flan build [-o out] [flags] build a native executable +flan dev [-s socket] start a live development session ``` -Useful build options include `--debug`, `--sanitize`, `--no-bounds-checks`, -`--x86`, and `--target=wasm32-wasi|web`. `run` is native-only; cross-built -output should be run with an appropriate WASI runtime or browser. A `.wasm` -file is not a tiny native executable in a trench coat. +Useful build options include `-O0`…`-O3`, `--debug`, `--sanitize`, +`--no-bounds-checks`, `--x86`, and `--target=wasm32-wasi|web`. `run` takes the +same build flags and keeps them: everything after `--` goes to the program, and +a dash-argument before it that `run` does not offer is refused rather than +guessed at. `run` is native-only; cross-built output should be run with an +appropriate WASI runtime or browser. A `.wasm` file is not a tiny native +executable in a trench coat. + +The other seven. Four print a stage of the pipeline, which is how you find out +what the compiler thinks it was given: + +```text +flan read ... the forms the reader produced +flan parse ... one line per declaration +flan shim ... the C a (declare-c ...) generated +flan emit [--x86] [--dev] [--debug] [--no-bounds-checks] + LLVM IR, or x86-64 assembly under --x86 +``` + +One builds a redefinition module the way `flan dev` does, for scripting the +loop without an editor: + +```text +flan reload [-o out.so] [--debug] [--x86] +``` + +And two are the C binding tools, which are the most useful thing here that +nothing else documents. + +### `flan import-c` — read a header, print the bindings it implies + +It builds nothing and writes nothing. It reads a C header (through clang's own +parser), prints the `declare-c` forms it would generate, and then prints every +function it *refused* and the reason — which is the half that earns its keep, +because "this binding is missing" and "this binding cannot exist" are different +problems: + +```text +$ flan import-c test/headers/sample.h +(declare-c set-seed [seed u32] "set_seed") +(declare-c add-ints [a i32 b i32] i32 "add_ints") +(declare-c name-length [text string] i32 "name_length") +... +;; 8 imported, 12 refused, of 21 functions in test/headers/sample.h +;; refused name-of: name_of returns char *, and a string only crosses as a +;; parameter — a C function that returns one returns something Flan has no +;; owner for +;; refused printf-like: printf_like is variadic, and a wrapper cannot forward +;; an argument list it does not know the shape of +;; refused file-time: file_time long has a width that differs between this +;; project's own targets (64 bits on x86-64, 32 on wasm32), so no single +;; Flan type is right for it +``` + +Hand the package's `.flan` files along with the header and it diffs against +them too: a `defstruct` whose layout no longer matches the header's record is +named, which is the failure that otherwise shows up as a wrong pixel. + +```text +$ flan import-c vendor/raylib/raylib-5.5.h vendor/raylib/*.flan +``` + +Anything after the header that is not a `.flan` file is passed to clang, so +`-I` and `-D` work as they do anywhere else. + +### `flan generate-c` — write those bindings into the package + +The same machinery, committing its answer. It takes a package *directory*, not +a header: the header comes from the package's own `headers` file, at the +version its `link` file names, because which version may be read is a property +of the package and not of the command line. It reads every `.flan` in the +directory except the one it writes, so hand-written declarations keep winning, +and it writes `generated.flan`. + +```text +$ flan generate-c vendor/raylib +;; refused get-clipboard-text: GetClipboardText returns char *, and a string +;; only crosses as a parameter — ... +;; refused load-shader: LoadShader Shader is a struct the package does not +;; describe — add a defstruct for it, or keep a hand-written declare-c +``` + +It exits non-zero and writes nothing if the package and the header disagree. +That is the point of it: the committed file is the one thing in the package +with no second opinion, so the moment of writing it is the last moment at which +the installed library can contradict it. + +## Environment + +Thirteen variables the toolchain reads, none of which has to be set on a +machine with clang, LLVM and binutils on `PATH`. Each exists for a machine +where the thing is somewhere else, or is the wrong one. + +| Variable | What it replaces | Read at | +|---|---|---| +| `FLAN_CLANG` | `clang`, which compiles the IR and the runtime's C | `lib/build.ml:14` | +| `FLAN_LLC` | `llc`, used only by the live loop | `lib/build.ml:886` | +| `FLAN_LD` | `ld`, used only by the live loop | `lib/build.ml:887` | +| `FLAN_AS` | `as`, used only by `--x86` | `lib/build.ml:978` | +| `FLAN_OBJDUMP` | `objdump`, used only by the disassembly verb | `lib/dev.ml:2018` | +| `FLAN_OCAMLFIND` | `ocamlfind`, which a merged `flan dev` needs **at run time** | `lib/dev.ml:2898` | +| `FLAN_EMCC` | `emcc`, for `--target=web` | `lib/build.ml:23` | +| `FLAN_LIBDIR` | where `flan.cmxa` and `flan.a` are, if not beside the binary | `lib/dev.ml:3187` | +| `FLAN_CACHE_DIR` | the object cache, default `$XDG_CACHE_HOME/flan/objcache` | `lib/build.ml:84` | +| `FLAN_WASM_SYSROOT` | the wasi-libc sysroot, default `/usr/wasm32-wasi` | `lib/build.ml:239` | +| `FLAN_WASM_BUILTINS` | `libclang_rt.builtins-wasm32.a`, which is not in clang's resource directory on Fedora | `lib/build.ml:272` | +| `FLAN_WEB_SHELL` | the HTML shell a `--target=web` build wraps the module in | `lib/build.ml:458` | +| `FLAN_DEV_LEAKS` | set (to anything) to have a `--dev` build print what it still held at exit | `runtime/flan_dev.c:1084` | + +`${FLAN_RAYLIB_WEB}` is not read by the compiler: it is expanded inside +`vendor/raylib/link`, which is where a package writes a linker argument that +has to differ per target. Any `${NAME}` in a `link` file expands from the +environment and an unset one is refused by name. + +**`llc` and `ld` have to match `clang`.** The live loop does not call the clang +driver at all — it goes `llc` + `ld -shared` + `dlopen`, which is what makes +`C-c C-c` cost milliseconds. So an `llc` from a different LLVM release than +`clang` breaks the dev loop while `flan build` keeps working perfectly, which +is a confusing shape of failure to meet without warning. + +Variables beginning `FLAN_DEV_` other than `FLAN_DEV_LEAKS`, plus +`FLAN_AGENT_SOCKET` and `FLAN_COMPILER_STAMP`, are internal: `flan dev` sets +them across its own `exec` to hand the merged binary what it needs. Setting +them by hand is not supported. ## Checking it diff --git a/docs/DISCUSS.md b/docs/DISCUSS.md index b326f8f..1f05819 100644 --- a/docs/DISCUSS.md +++ b/docs/DISCUSS.md @@ -759,13 +759,22 @@ Against `tast.ml`'s `expr_kind`, in four buckets: | **Mechanical** | `While` `Break` `Continue` (the jump patching exists), `Global` `Str` `Zero` `Uninit`, the rest of `place`, `Field` `Deref` `Addr`, `Arr`, `Some_` `None_` `UnwrapSome`, `Match` on a tag | | **Bulky, not hard** | floats — a second register file, SSE encodings, `Cast`'s eight conversions, and the SSE half of the calling convention. Perhaps a third of the total instruction work for a small fraction of the programs | | **Fiddly** | aggregate copy on assignment (a struct `store` *is* the copy `spec-memory.md` requires), `Make` `MakeCase` `CaseField` over the payload blob, `CallPtr`, `FnAddr`'s three cases and the cell load behind `Fnval` | -| **No plan** | `Handled` `Signal` `RestartCase` `InvokeRestart` `WithAlloc`, the transfer-channel guard after every call, the landing pad, and `fdefers` on the transfer exit path | +| **~~No plan~~ Done** | `Handled` `Signal` `RestartCase` `InvokeRestart` `WithAlloc`, the transfer-channel guard after every call, the landing pad, and `fdefers` on the transfer exit path | The last row is the one to take seriously. The spike never emitted a guard or a pad, and the guard is on *every call site* in the real thing — `emit.ml`'s `guard`, `current_pad`, `emit_restart_case` and `emit_with_alloc` are several hundred lines of control flow that a second backend reimplements from the spec rather than copies. Conditions are not an advanced feature to defer: `spec-conditions.md` is load-bearing in the prelude already. +**Correction, and it matters because this row argues the opposite of the truth.** The last row was written before the +backend existed and it is no longer the state of anything. `x86.ml:1587-1615` lowers the whole condition family — +`Handled`, `Signal`, `RestartCase`, `InvokeRestart`, `WithAlloc`, the per-call-site transfer guard, the landing pad and +the defers on the transfer exit — and the parity survey is **104 / 104 MATCH, 0 DIFFER**. Item 16 below is where that +was built and item 17 is where conditions and bounds checks were taken through the whole corpus rather than a sample; +this row is kept struck through rather than deleted because other files cite this table by position. Anyone reading it +for a production decision should read items 16, 17 and 18 instead: the second backend refuses nothing this one does, +and "no plan" has not been true since. + ### Question 3 — the SysV boundary, and the obstacle nobody named **The C boundary is the easy half, and `BUILT.md` is why.** `check.ml` rejects an aggregate in a `declare` signature diff --git a/lib/prelude.ml b/lib/prelude.ml index 3ed65e6..157081e 100644 --- a/lib/prelude.ml +++ b/lib/prelude.ml @@ -5,9 +5,17 @@ above it is Flan. That is what keeps a second backend cheap — a primitive is the only thing implemented twice. - It lives here as a string rather than as a file because there is no package - loader yet; at milestone 3 it becomes an ordinary [core:] package and this - module goes away. The acceptance programs may call anything defined here. + It lives here as a string rather than as a file, and every program gets it + prepended by [Check.build_program] whether it asked for one or not. That is + a real limit and it is stated as one: the prelude cannot be read as Flan, + extended, or replaced without rebuilding the compiler. There *is* a package + loader now — [lib/load.ml], and [vendor/raylib] is a package that uses it — + so the obstacle is no longer the mechanism. What is missing is the decision + about what a [core:] package would mean for a program that imports nothing, + and until that is made this docstring does not promise one. (It used to say + the prelude became a [core:] package "at milestone 3". Milestone 3 came and + went; the package did not.) The acceptance programs may call anything + defined here. No printing function is here at all any more. [print] and [println] are the whole printing surface, and neither is a function: both are From 16498a4e609cb742993a491b90c78f7cfbc4a26e Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Thu, 17 Sep 2026 22:11:25 +0700 Subject: [PATCH 6/7] The alias that nobody ran now has something that runs it A GitHub Actions workflow on push: dune build, dune test --force, dune build @checks. @x86 parity is not under dune test, so the routine suite never protected it; both of this repository's silent failures would have been caught by one person typing one command, and the problem was never the command. The suite step keeps its log and greps it for Fatal error, because a suite that passes while leaving an unhandled exception on stderr is one that is telling you something and being ignored. FLAN_LLC is pinned to the llc matching clang's version rather than left to PATH order: the live loop goes llc + ld -shared + dlopen and never calls the clang driver, so a mismatch breaks every reload test while flan build keeps working, which is a bad failure to debug from a log. What an Ubuntu runner cannot cover -- raylib by exact Fedora soname, emscripten, a wasi sysroot, lldb -- is written in the workflow with the skip path each one already takes, so the tick does not read as more than it is. README.md and test/dune both said there was no CI; both now say what there is and what it misses. --- .github/workflows/checks.yml | 148 +++++++++++++++++++++++++++++++++++ README.md | 20 +++-- test/dune | 20 +++-- 3 files changed, 174 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/checks.yml diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..f8292f5 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,148 @@ +# The thing README.md and test/dune both said did not exist. +# +# `dune build @checks` on every push, which is the whole job. Both of this +# repository's silent failures — two x86 refusals that sat for a month, a page +# of examples that stopped compiling for two days — would have been caught by +# one person typing one command, and the argument for CI here is not process: +# it is that nobody typed it. @x86 parity in particular is *not* under +# `dune test`, so the routine suite does not protect it. +# +# `dune test` stays what it is: seconds, run constantly, unchanged by this +# file. The workflow adds a caller, not an alias. +# +# ── What this does not cover, and why ──────────────────────────────────── +# +# Stated plainly, because a green tick that quietly checks less than it looks +# like it does is worse than no tick. +# +# raylib. vendor/raylib/link names `-l:libraylib.so.550` by exact soname, +# which is a Fedora spelling; Ubuntu ships neither that soname nor 5.5. +# So every raylib program is skipped here, on three separate paths that +# all already existed: the acceptance cases probe with `ldconfig -p | +# grep libraylib` and print a skip line; survey.sh records a program that +# will not link as `does-not-compile`, which SURVEY_STRICT deliberately +# does not fail on; and web/examples/check.sh skips shimdemo.flan by name. +# Nothing had to be weakened to make this pass — but the graphics half of +# the corpus is unchecked here and is checked only on a machine with +# raylib installed. +# +# emscripten and the web target. test_web probes for emcc and a raylib +# archive built by vendor/raylib/build-web.sh and skips with the reason. +# Installing an emsdk per run is minutes for a target NEXT.md has +# deprioritised, so it is left out on purpose. +# +# wasm32-wasi. Needs a wasi-libc sysroot and a builtins archive that no +# Ubuntu package supplies. The acceptance case probes by building and +# running the smallest program and skips with what failed, so this is a +# gap in coverage and not a failure. +# +# lldb. The debug-information cases want lldb on PATH and say so when it is +# absent. The DWARF is still emitted and still checked for +# self-consistency; what is skipped is the half that says a person can +# actually sit in a debugger. +# +# @sanitize and @valgrind. Out of @checks by design — tens of minutes each +# — and out of here for the same reason. They are the thing to run before +# a release, by hand, not the thing to run on every push. +# +# One platform. ubuntu-latest only. macOS and Windows portability is +# recorded as real and not current (aligned_alloc, MSG_NOSIGNAL, +# stdatomic), and a matrix that goes red for known reasons teaches people +# to ignore red. + +name: checks + +on: + push: + pull_request: + +# A superseded run is wasted minutes and a confusing status on the commit it +# no longer describes. +concurrency: + group: checks-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - uses: actions/checkout@v4 + + # clang compiles the IR and the runtime's C; llc and ld are the live + # loop, which never calls the clang driver; as is the --x86 path; emacs + # is what test_emacs and test_cider drive the client with, and they skip + # silently without it, which would be four suites' worth of coverage + # disappearing without a word. + - name: Install the toolchain + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + clang llvm lld binutils emacs-nox + + # llc has to come from the same LLVM release as clang. The live loop + # goes llc + ld -shared + dlopen rather than through the clang driver, + # so a mismatch breaks `C-c C-c` and every reload test while `flan + # build` keeps working perfectly — which is a confusing shape of failure + # to debug from a CI log. Ubuntu installs llc under a version suffix and + # may or may not provide the bare name, so the version is read off clang + # and FLAN_LLC is pinned to match it rather than left to PATH order. + - name: Point FLAN_LLC at the llc that matches clang + run: | + set -eu + clang --version + major=$(clang --version | sed -n 's/.*version \([0-9][0-9]*\).*/\1/p' | head -1) + if command -v "llc-$major" > /dev/null; then + llc="llc-$major" + elif command -v llc > /dev/null \ + && llc --version | grep -q "LLVM version $major"; then + llc=llc + else + echo "no llc at LLVM $major; clang is $(clang --version | head -1)" >&2 + echo "what is on PATH: $(ls /usr/bin/llc* 2>/dev/null || echo none)" >&2 + exit 1 + fi + echo "FLAN_LLC=$llc" >> "$GITHUB_ENV" + "$llc" --version | head -3 + + - uses: ocaml/setup-ocaml@v3 + with: + ocaml-compiler: "5.2" + # There is no (package ...) stanza yet — the install story is Tier 2 + # of docs/REVIEW-production-readiness.md and undecided — so there is + # nothing to pin and nothing to resolve dependencies from. The + # library depends on unix and on nothing else. + opam-pin: false + dune-cache: true + + - run: opam install -y dune + + - name: Build + run: opam exec -- dune build + + # --force because the point of a CI run is to run the tests, not to + # discover that dune's cache already knew the answer. + # + # The log is kept and then read, because a suite that passes while + # leaving an unhandled exception on stderr is a suite telling you + # something and being ignored. That happened here — three suites left a + # message-less `Fatal error: exception Flan.Loc.Error(_)` while + # reporting zero failures — and it was found by a person reading a log, + # which is exactly the kind of finding that should not need a person. + - name: The suite + run: | + set -eu + set -o pipefail + opam exec -- dune test --force 2>&1 | tee suite.log + if grep -n 'Fatal error' suite.log; then + echo "the suite passed but left an unhandled exception above" >&2 + exit 1 + fi + + # The part `dune test` does not cover: the reference page's examples + # still print what the page says, the hand-written x86 backend still + # agrees with LLVM over the whole corpus, and a --dev build still calls + # through its indirection cells. + - name: Everything else that can fail + run: opam exec -- dune build @checks diff --git a/README.md b/README.md index 46ffb41..365c1fd 100644 --- a/README.md +++ b/README.md @@ -268,13 +268,19 @@ hand-written x86 backend still agrees with LLVM, and a `--dev` build still calls through its indirection cells. The two are separate on purpose. A suite that goes red because prose drifted -teaches you to skim past red. But `@checks` only helps if it is run, and nothing -runs it for you — there is no CI here. The convention that has to carry it is -that a lane's handoff quotes `@checks`, the way the x86 handoffs already quote -the survey's counts. Both of this repository's silent failures — two backend -refusals that sat for a month, a page of examples that stopped compiling for two -days — were found by accident, and neither would have survived one person -typing one command. +teaches you to skim past red. Both of this repository's silent failures — two +backend refusals that sat for a month, a page of examples that stopped +compiling for two days — were found by accident, and neither would have +survived one person typing one command. + +`.github/workflows/checks.yml` is now that person: it runs `dune build`, +`dune test --force` and `dune build @checks` on every push. What it cannot run +is written down in the workflow itself rather than left to be discovered — +raylib, emscripten, wasm32-wasi and lldb are all absent from an Ubuntu runner, +and every one of them was already a self-skip, so the tick is green over less +than it is on a machine with those installed. The habit that covers the rest is +still worth keeping: a lane's handoff quotes `@checks`, the way the x86 +handoffs already quote the survey's counts. ## Project map diff --git a/test/dune b/test/dune index a4d558b..08bb45d 100644 --- a/test/dune +++ b/test/dune @@ -319,13 +319,19 @@ ; Valgrind present, and folding them in would make @checks the thing you do not ; have time for -- which is the disease, not the cure. ; -; It is honest to say what this does not do. It does not run itself. Nothing -; here runs itself, because there is no CI, and a commit hook that costs two -; minutes gets switched off in a week. What it buys is that deciding to check -; and checking everything are now the same act, so the gap between "somebody -; wondered" and "everything was verified" is one command instead of five. The -; habit that closes the rest of the gap is written down in README.md: a lane's -; handoff quotes this alias, the way the x86 handoffs already quote survey.sh. +; What it buys is that deciding to check and checking everything are now the +; same act, so the gap between "somebody wondered" and "everything was +; verified" is one command instead of five. +; +; It used to say here that nothing runs this, because there was no CI. There is +; now: .github/workflows/checks.yml runs `dune build`, `dune test --force` and +; this alias on every push. That does not make the local run redundant, and the +; workflow says why in as many words — an Ubuntu runner has no raylib, no +; emscripten, no wasi sysroot and no lldb, so every one of those cases takes +; the skip path it already had and the tick is green over less than this alias +; covers here. The habit written down in README.md still carries the rest: a +; lane's handoff quotes this alias, the way the x86 handoffs already quote +; survey.sh. (alias (name checks) (deps From 96e5fab77f20d12c3c160646092eeb4ddc489152 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Thu, 17 Sep 2026 22:12:54 +0700 Subject: [PATCH 7/7] flan run does not offer --dev, and says so rather than building one --dev builds a program whose call sites go through indirection cells so something can attach and redefine through them. Nothing can attach to a process this command builds, execs, waits for and deletes, so the flag had no meaning here -- and an --x86 --dev route through run would have falsified Build's own statement that flan dev never reaches that fork because --x86 is read only by flan build. It falls into the refusal arm with a sentence instead. --- bin/main.ml | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/bin/main.ml b/bin/main.ml index 5801d72..4f8455c 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -704,8 +704,18 @@ let () = one's own that wants [-v] has [--] to ask for it. Plain arguments need no ceremony: they were never ambiguous and they still go straight through, so [flan run calc-me.flan "1+2"] is unchanged. *) - let run_flags = [ no_checks_flag; dev_flag; debug_flag; sanitize_flag; - x86_flag ] @ opt_levels in + (* --dev is deliberately not on this list, and its absence is the point: + a dev build's indirection cells exist so that something can attach and + redefine through them, and nothing can attach to a process this command + builds, execs, waits for and deletes. Leaving it off means it lands in + the refusal below with a sentence, rather than quietly producing a + spelling [Build] says does not exist — [flan build]'s --x86 arm records + that `flan dev` never reaches its fork because --x86 is read only by + [flan build], and an --x86 --dev route through here would have made that + sentence false. *) + let run_flags = + [ no_checks_flag; debug_flag; sanitize_flag; x86_flag ] @ opt_levels + in let build_args, prog_args = let rec split acc = function | "--" :: rest -> (List.rev acc, rest) @@ -727,7 +737,6 @@ let () = split [] args in let checks = not (List.mem no_checks_flag build_args) in - let dev = List.mem dev_flag build_args in let debug = List.mem debug_flag build_args in let sanitize = List.mem sanitize_flag build_args in let x86 = List.mem x86_flag build_args in @@ -740,9 +749,9 @@ let () = in let l = load path in let p = Flan.Check.program_all l.decls in - let p, csrcs, lflags = Flan.Reach.link ~dev l p in + let p, csrcs, lflags = Flan.Reach.link l p in ignore (Flan.Build.executable - ~opts:{ Flan.Build.default with checks; dev; debug; sanitize; + ~opts:{ Flan.Build.default with checks; debug; sanitize; x86; opt = Option.value opt ~default:Flan.Build.default.Flan.Build.opt }