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/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** |
| `sand-sim/` | **the falling-sand simulation, with no raylib in it** |
| `bin/main.ml` | `flan read \| parse \| check \| emit \| build \| run \| reload \| dev` |
| `test/test_flan.ml` | reader, parser and checker |
| `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
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
callable), no cycle detection, and a package cannot import another one.
**A package may be a single `.flan` file** named outright, rather than a
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
one-line change to `Load` rather than two files.** Two claims got run together:
**Visibility is one rule: `main` is not exported.** A package carrying one
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
plan.org says itself. 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,
and nothing about it requires the simulation to live in its own package.
Still missing: a package-private marker for anything other than `main`, which
is why `rl/get-color-raw` is callable.
What actually justifies the split 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.
## The link follows the program
What makes the split *mandatory* rather than chosen is the packaging
limitation: **`Load` collects a package's C sources and link flags whether or
not anything references the package.** Make that conditional and the two files
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
`Load` used to hand a package's `.c` files and `link` arguments to the build
the moment it was imported, whatever the importing program did with them. So
anything naming `vendor:raylib` linked libraylib on every target, and on wasm32
that link cannot succeed.
So the simulation moved to `sand-sim/`, which imports nothing. `sand.flan`
imports it as `sim/` and adds the window, the mouse and the drawing;
`test/programs/sand-headless.flan` imports it and adds a seed, four
deterministic clouds, 40 frames and an FNV-1a hash. One copy of the physics.
`lib/reach.ml` answers it from the checked program instead. Start at `main` and
at the globals that run before it, follow every call — including the `Handled`
frames, where a lifted handler clause is reached by address and by nothing else
— 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
interactive build only proves it enters its loop, because with no mouse input
the grid stays empty and `paint-at`, `settle` and `move-grain` never execute 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.
Dropping the flags alone would only move the failure: the bodies that called
into raylib would still be emitted and `wasm-ld` would fail on the symbols
rather than on the argument list. So the same walk prunes **functions and
externs** from the program. Only those. Globals, structs and unions stay,
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
decisions rather than fixes:
**Dev builds are not pruned.** What a REPL may redefine next is not a function
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
constant is `f64`, `velocity` is `[f32]`, and there is no implicit widening.
- `(defvar current-color u32)``i32`. It is an index into `colors`, and
`(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
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
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.
- 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.
- `(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
@ -804,8 +839,8 @@ all the folding back; `Tast.global.gfolded` is what tells the two apart, because
nothing downstream of the checker could.
**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`,
and its call to `move-grain` becomes `sim/move-grain` — through `Load`'s own
the import qualified it.** `poll` in `vendor/agent/agent.flan` becomes
`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.
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
@ -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,
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
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
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
a REPL editing a package's source needs it to know that [settle] typed in
sand-sim/sim.flan means [sim/settle] to the running program. *)
a REPL editing a package's source needs it to know that [poll] typed in
vendor/agent/agent.flan means [agent/poll] to the running program. *)
pkgs : pkg list;
}

View File

@ -67,8 +67,8 @@ let create ~file =
(* Which package a file being edited belongs to, if any.
A form typed into sand-sim/sim.flan declares [settle], but the running
program only ever knew it as [sim/settle]: the alias is chosen by whatever
A form typed into vendor/agent/agent.flan declares [poll], but the running
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
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