Say what a package is now, since the answer changed three times

NEXT.md described a packaging system with no visibility, no nesting and a link
that ignored the program, and explained sand's two files by it. All four are
now wrong. The Packages section says what the rules are; a new section says how
the link is decided and why the pruning has to take the functions as well as
the flags; and the sand section keeps the part that still stands — the headless
test needs no window on any target, which is a reason for two entry points and
never was a reason for two files.

The comments in load.ml and session.ml that used sim.flan to explain package
qualification now use vendor/agent, which is the package left with a defn in
it.
This commit is contained in:
Joseph Ferano 2026-09-11 20:12:34 +07:00
parent 259cf3b3e2
commit f78c935b95
3 changed files with 87 additions and 52 deletions

131
NEXT.md
View File

@ -146,7 +146,6 @@ reader ✅ → parse ✅ → load ✅ → check ✅ → emit ✅ → clang ✅
| `vendor/raylib/` | **the raylib package: `raylib.flan`, `shim.c`, `link`** | | `vendor/raylib/` | **the raylib package: `raylib.flan`, `shim.c`, `link`** |
| `vendor/agent/` | **the dev agent: a socket, a loader thread, install at a frame boundary** | | `vendor/agent/` | **the dev agent: a socket, a loader thread, install at a frame boundary** |
| `emacs/` | **`flan-mode.el`, `flan-dev.el`, `flan-repl.el`: the editor half of the dev loop** | | `emacs/` | **`flan-mode.el`, `flan-dev.el`, `flan-repl.el`: the editor half of the dev loop** |
| `sand-sim/` | **the falling-sand simulation, with no raylib in it** |
| `bin/main.ml` | `flan read \| parse \| check \| emit \| build \| run \| reload \| dev` | | `bin/main.ml` | `flan read \| parse \| check \| emit \| build \| run \| reload \| dev` |
| `test/test_flan.ml` | reader, parser and checker | | `test/test_flan.ml` | reader, parser and checker |
| `test/test_acceptance.ml` | expression/result pairs + whole programs + the traps | | `test/test_acceptance.ml` | expression/result pairs + whole programs + the traps |
@ -340,64 +339,100 @@ slash.
A package may also carry the C it binds to: every `.c` file in the directory is A package may also carry the C it binds to: every `.c` file in the directory is
compiled into the build, and a file named `link` lists extra linker arguments. compiled into the build, and a file named `link` lists extra linker arguments.
Whether those reach the build at all is decided *after* checking — see below.
This is not a module system yet. No visibility (hence `rl/get-color-raw` being **A package may be a single `.flan` file** named outright, rather than a
callable), no cycle detection, and a package cannot import another one. directory. That is for the program that is also a library: `sand.flan` shares
the repository root with three other loose programs, so naming its directory
would import all four. A file carries no `.c` and no `link` file; those belong
to a directory.
## sand.flan is two programs **A package may import a package.** The qualification flattens to the *inner*
alias — raylib imported by a package that is itself imported is still `rl/…`,
never `sand/rl/…` — because a directory reached along two routes has to arrive
under one set of names or the checker sees every declaration twice. A directory
is keyed by its real path and read once, which is also what ends a cycle: a
package that imports itself meets its own entry and contributes nothing the
second time, and the namespace being flat, mutually dependent packages simply
work. The same directory under two *different* aliases is refused.
**The stated reason below is narrower than it reads, and the real fix is a **Visibility is one rule: `main` is not exported.** A package carrying one
one-line change to `Load` rather than two files.** Two claims got run together: would collide with the importer's the moment anything imported it, so a program
could never be a package; and `main` is a reachability root, so an imported one
would keep everything it calls alive. Writing `sand/main` is refused at the
line that wrote it, with the reason — left to the checker it would be "unknown
name", which is true and useless.
- *raylib does not work on wasm* — false. It works through emscripten, which Still missing: a package-private marker for anything other than `main`, which
plan.org says itself. What is true is that it does not work on the **wasi** is why `rl/get-color-raw` is callable.
path, which is what the headless table targets, and which has no GL and no
browser.
- *a game loop cannot be expressed on wasm* — false. The browser cannot be
blocked, so a web build drives the loop with `emscripten_set_main_loop`
instead of a `while`. That is a different `main`, not a different program,
and nothing about it requires the simulation to live in its own package.
What actually justifies the split is smaller and stands on its own: **the ## The link follows the program
headless test needs no window and no input on any target.** `sand.flan` could
not be that test even natively — with no mouse the grid stays empty and
`settle` and `move-grain` never run on real data.
What makes the split *mandatory* rather than chosen is the packaging `Load` used to hand a package's `.c` files and `link` arguments to the build
limitation: **`Load` collects a package's C sources and link flags whether or the moment it was imported, whatever the importing program did with them. So
not anything references the package.** Make that conditional and the two files anything naming `vendor:raylib` linked libraylib on every target, and on wasm32
become a preference. That is the thing to fix, and it is not large.
plan.org wants sand tested twice — interactive at 120 fps, and headless over N
frames with the grid hashed, the version CI runs on native *and* wasm32. Those
cannot be one binary: `Load` collects a package's C sources and linker
arguments unconditionally, so anything importing the raylib package links
libraylib on every target regardless of what its `main` does, and on wasm32
that link cannot succeed. that link cannot succeed.
So the simulation moved to `sand-sim/`, which imports nothing. `sand.flan` `lib/reach.ml` answers it from the checked program instead. Start at `main` and
imports it as `sim/` and adds the window, the mouse and the drawing; at the globals that run before it, follow every call — including the `Handled`
`test/programs/sand-headless.flan` imports it and adds a seed, four frames, where a lifted handler clause is reached by address and by nothing else
deterministic clouds, 40 frames and an FNV-1a hash. One copy of the physics. — and keep what is reached. A package none of whose externs survive contributes
no C and no linker argument.
The headless case is what actually *verifies* milestone 4 — running the Dropping the flags alone would only move the failure: the bodies that called
interactive build only proves it enters its loop, because with no mouse input into raylib would still be emitted and `wasm-ld` would fail on the symbols
the grid stays empty and `paint-at`, `settle` and `move-grain` never execute on rather than on the argument list. So the same walk prunes **functions and
real data. Measured through the probe: 168 grains painted around row 48, still externs** from the program. Only those. Globals, structs and unions stay,
168 after 40 frames, lowest occupied row 68. Grains fall, and none are lost. because an unreferenced global is bytes in BSS and a dropped one is a silently
different program.
**Three edits were made to sand.flan's own text**, and they are language **Dev builds are not pruned.** What a REPL may redefine next is not a function
decisions rather than fixes: of what has been called so far.
The filtering happens at the call sites — `bin/main.ml`, the tests — because
`Build.executable` receives `csrcs` and `lflags` from its caller and never sees
the import list. `Reach.link` returns the pruned program and its C and linker
arguments together, so a caller cannot take one without the other.
## sand.flan is one program
It was two files, and only ever for the reason above: the headless run is the
one CI does on native *and* wasm32, and a program that imported raylib linked
libraylib whatever its `main` did. So the simulation lived in `sand-sim/` and
both drivers imported it.
Now `sand.flan` holds the simulation *and* the raylib front-end, and
`test/programs/sand-headless.flan` imports `sand.flan` itself — window, raylib
bindings, dev agent and all — and still builds for wasm32. Nothing it calls
reaches raylib; `sand.flan`'s `main` is not exported, so the only `main` is the
headless one; and the hash is unchanged on both targets at `-O2` and `-O0`,
which is the point. A refactor that moved that number would have moved the
simulation.
What still justifies *two entry points* is smaller and stands on its own: **the
headless test needs no window and no input on any target.** `sand.flan` could
not be that test even natively — with no mouse the grid stays empty and
`settle` and `move-grain` never run on real data. Measured through the probe:
168 grains painted around row 48, still 168 after 40 frames, lowest occupied
row 68. Grains fall, and none are lost.
Two claims that got run together in an earlier note, for the record:
- *raylib does not work on wasm* — false. It works through emscripten. What is
true is that it does not work on the **wasi** path, which is what the
headless table targets, and which has no GL and no browser.
- *a game loop cannot be expressed on wasm* — false. The browser cannot be
blocked, so a web build drives the loop with `emscripten_set_main_loop`
instead of a `while`. That is a different `main`, not a different program.
**Three edits were made to sand.flan's own text** when it was ported, and they
are language decisions rather than fixes:
- `(defconst gravity 0.05)``(defconst gravity f32 0.05)`. An untyped float - `(defconst gravity 0.05)``(defconst gravity f32 0.05)`. An untyped float
constant is `f64`, `velocity` is `[f32]`, and there is no implicit widening. constant is `f64`, `velocity` is `[f32]`, and there is no implicit widening.
- `(defvar current-color u32)``i32`. It is an index into `colors`, and - `(defvar current-color u32)``i32`. It is an index into `colors`, and
`(len colors)` is an `i32`. `(len colors)` is an `i32`.
- The file was split as above, so its body now says `sim/rows` and so on. - `(defn main [])` is unchanged — the short form, as plan.org says.
`(defn main [])` is unchanged — the short form, as plan.org says.
Painting is on **hold left mouse button** rather than on space, since the mouse Painting is on **hold left mouse button** rather than on space, since the mouse
bindings exist now. Space is still what cycles the colour, on release, which is bindings exist now. Space is still what cycles the colour, on release, which is
@ -503,7 +538,7 @@ enforces, and a later change could quietly drop it.
from the folding pass's value, because a global's initialiser has to be a from the folding pass's value, because a global's initialiser has to be a
compile-time constant and only that pass knows this one is. Its range check compile-time constant and only that pass knows this one is. Its range check
is therefore its own call to `in_range`; there is a regression test. is therefore its own call to `in_range`; there is a regression test.
- A `let` binding takes no type annotation, which is why `sand-sim` names its - A `let` binding takes no type annotation, which is why `sand.flan` names its
FNV constants instead of writing them inline. FNV constants instead of writing them inline.
- `(defn f [] f65 0.0)` still says *unknown name* rather than *did you mean - `(defn f [] f65 0.0)` still says *unknown name* rather than *did you mean
f64*: with a single body form the parser cannot tell a return type from the f64*: with a single body form the parser cannot tell a return type from the
@ -804,8 +839,8 @@ all the folding back; `Tast.global.gfolded` is what tells the two apart, because
nothing downstream of the checker could. nothing downstream of the checker could.
**A form typed into a file that is imported as a package is qualified the way **A form typed into a file that is imported as a package is qualified the way
the import qualified it.** `settle` in `sand-sim/sim.flan` becomes `sim/settle`, the import qualified it.** `poll` in `vendor/agent/agent.flan` becomes
and its call to `move-grain` becomes `sim/move-grain` — through `Load`'s own `agent/poll`, and its call to `poll-raw` becomes `agent/poll-raw` — through `Load`'s own
`qualify_decl`, so the rule cannot drift from the one used at import time. `qualify_decl`, so the rule cannot drift from the one used at import time.
Without this the form spliced as a brand-new unrelated name: the evaluation Without this the form spliced as a brand-new unrelated name: the evaluation
answered `ok`, and the running program went on calling the `sim/settle` it answered `ok`, and the running program went on calling the `sim/settle` it
@ -1596,7 +1631,7 @@ The tests assert on the reason, not just on the failure.
`calc-me` and `sand`, the executables `flan build` drops beside their sources, `calc-me` and `sand`, the executables `flan build` drops beside their sources,
are now in `.gitignore` — anchored (`/calc-me`, `/sand`) so the patterns cannot are now in `.gitignore` — anchored (`/calc-me`, `/sand`) so the patterns cannot
also match `sand-sim/` or anything nested. match anything nested.
`old-ocaml/` — the pre-rewrite menhir/ocamllex frontend, kept as reference and `old-ocaml/` — the pre-rewrite menhir/ocamllex frontend, kept as reference and
excluded from the build by the root `dune` file. Its contents are also in git excluded from the build by the root `dune` file. Its contents are also in git

View File

@ -43,8 +43,8 @@ type t = {
(* Which alias each package's directory was imported under, and the names it (* Which alias each package's directory was imported under, and the names it
owns. A file on disk does not say what it is called from outside the owns. A file on disk does not say what it is called from outside the
*importer* chooses that so this is the only place the answer exists, and *importer* chooses that so this is the only place the answer exists, and
a REPL editing a package's source needs it to know that [settle] typed in a REPL editing a package's source needs it to know that [poll] typed in
sand-sim/sim.flan means [sim/settle] to the running program. *) vendor/agent/agent.flan means [agent/poll] to the running program. *)
pkgs : pkg list; pkgs : pkg list;
} }

View File

@ -67,8 +67,8 @@ let create ~file =
(* Which package a file being edited belongs to, if any. (* Which package a file being edited belongs to, if any.
A form typed into sand-sim/sim.flan declares [settle], but the running A form typed into vendor/agent/agent.flan declares [poll], but the running
program only ever knew it as [sim/settle]: the alias is chosen by whatever program only ever knew it as [agent/poll]: the alias is chosen by whatever
imported the directory, and is written nowhere in the file itself. Without imported the directory, and is written nowhere in the file itself. Without
this the form splices as a brand-new unrelated name, the evaluation reports this the form splices as a brand-new unrelated name, the evaluation reports
success, and nothing changes the exact failure this whole design is meant success, and nothing changes the exact failure this whole design is meant