132 Commits

Author SHA1 Message Date
90d3d6694e A package struct can be a return type
The parser decides "return type or first body form?" from the set of type
names the file declares, and an import is resolved after parsing - so a
package's structs cannot be in that set by construction. (defn mk [] rl/Vector2
...) therefore read the return type as the body and failed with "unknown name
rl/Vector2", which names the symptom and not the cause.

The signal is the alias plus the capital, and both halves are needed. An alias
is syntactically obvious and the same pre-pass collects it. A bare capitalised
symbol is never a value in this language - a struct or union constructor is
(Name {...}), a List, and an enum member is a keyword - so the hazard the
surrounding comment warns about, a body form eaten as a return type, has no
form of this shape to eat. A lowercase qualified name stays an expression,
which is what rl/get-color has to be.

Found by the raylib lane, which hit it on rl/Vector2 and reported it rather
than reaching into a file it did not own.
2026-09-12 03:59:57 +07:00
71877a5baa Merge branch 'worktree-agent-a065a2101ee7d8007' into dev-loop 2026-09-12 03:55:50 +07:00
a53a3603ee Say what each case does not catch, not only what it does
Two claims in these comments were stronger than the permutation runs
behind them. The WAV round trip catches sample-size against channels
and leaves frame-count against sample-rate entirely green — the crop
and the reformat are what catch that pair, and a reader who trusted the
round trip would drop exactly the wrong case. The font file listed what
it pins and never said that glyph-padding, offset-y and three of each
atlas rectangle's four fields are read by nothing here at all.

Two more permutations run and recorded while fixing it: GlyphInfo's
image moved to the front, which shifts the four ints 24 bytes and
collapses the glyph search, and Rectangle's x with width, which moves
"measure ABC" to 39 and confirms the advance-0 fallback is the only
thing reading a width out of the recs array.
2026-09-12 03:55:06 +07:00
245ad60fd8 Give the un-assertable bindings somewhere to be looked at
A binding nothing calls is a binding nothing checks, and that was
already true of key-released? and mouse-button-pressed? before this
lane added sixty more. Audio, render textures, gamepads, touch and
gestures cannot be in the acceptance table — a sound needs a device, a
framebuffer needs a GL context, and with no pad attached every gamepad
predicate answers what a wrapper with its arguments crossed would — so
they go here, where running the program is the check.

Each read-out is built to be asymmetric: the world is drawn through a
render texture with the negative source height raylib's bottom-up
framebuffer requires, so a missing flip is an upside-down world rather
than a subtle one; the stick dot is offset by x and y separately; the
two trigger bars are different lengths. The tone is generated in Flan
rather than shipped as an asset, which is also what gives export-wave
and load-music-stream a call site outside a test.
2026-09-12 03:51:08 +07:00
50f5ea4db8 Destructuring in let, desugared in the parser
{:keys [x y]} and {inner :field} over a struct, [a b] and [a & rest] over a
fixed array, nesting through each other. All of it becomes Let plus Field plus
at plus slice in parse.ml, so nothing downstream learns a pattern exists - the
same shape dotimes already has.

The constraint turned out to be stronger than "do not add IR". load.ml matches
Ast.pattern exhaustively with no wildcard and shim.ml builds Ast.binding as a
full record literal, and both files belong to other agents this session, with
warning 8 an error - so no new frontend shape was available either. The
desugaring is what fits through that, and it is the better answer anyway.

The value goes into a temporary named destructure~N. The tilde is a reader
delimiter, so no source symbol can collide with one, and (let [{a :a} a] ...)
therefore reads the old a. The one thing the parser cannot settle is arity, so
that travels to check.ml as a call to destructure~nth, which knows the array's
length - a name in call position is an open namespace check.ml already owns and
dispatches, which is why that is not the same compromise as tagging a pattern.

Sequential patterns over a *slice* are refused rather than lowered to a
bounds-checked at. [a b] over [2 f32] is a claim the checker settles; over [T]
it is a claim about a number that does not exist until runtime, and lowering it
would turn a compile-time-checkable pattern into a program that type checks and
then traps.

match over enums is left unshipped on the same reasoning, and that restraint is
worth recording: it is fully desugarable and wanted, but a keyword needs a case
in Ast.pattern, and the alternative - tagging Pctor (":lo", []) - puts a second
meaning into a field another file destructures as a constructor name. One line
in load.ml unblocks it for whoever owns that file. The old refusal blamed
milestone 2, which was never the reason; both paths now name the enum and say
what actually stops it.
2026-09-12 03:50:52 +07:00
4428c864cf Say why match stops at Option, since the milestone was never the reason
Two ways to write a match over an enum and two different refusals, neither
of them true. (match k :lo ...) died in the parser with "expected a pattern,
found :hi" — which arm it named depended on cons evaluation order, and it
never mentioned enums. (match k lo ...) died in the checker blaming milestone
2, which is not what stands in the way.

What stands in the way is worth writing down, because the feature is close.
An enum is an i32 at run time and its members are all known, so the arms are
a chain of (= k :member) and the exhaustiveness check falls out of env.enums
— a desugaring, no new IR node, the same shape as everything else this lane
landed. What is missing is a case in Ast.pattern for a keyword, and load.ml
matches that type exhaustively with no wildcard, so the variant cannot be
added from a session that does not own the file. One line, for whoever does.

That is also why destructuring went through a call to an unspellable name
instead: a name in call position is an open namespace check.ml already owns,
whereas tagging Pctor with ":lo" would put a second meaning into a field
another file destructures as a constructor.

The struct-tail case in the acceptance program is unrelated housekeeping: the
corpus slices arrays of i32, u8 and f32 and nothing wider, so nothing else
proves the desugared (slice xs n (len xs)) gets a struct's stride right.
2026-09-12 03:48:46 +07:00
34b6543e58 Assert audio and fonts headlessly, which both were said to be impossible
Audio was written off as needing a device. That is true of Sound and
Music and false of Wave: copy, crop, reformat, export, load and decode
are all CPU work, and wave-format is the same scalars-in/fields-out
shape gen-image-color is, with the frame count computed rather than
handed over. Cropping to a single frame before decoding puts raylib's
byte-offset arithmetic in front of the decoder, which is what tells
sample-size from channels — an axis discriminator, not a mirror.

Fonts were said to have no headless test. They do, once the program
stops asking raylib for a font and builds one out of Flan arrays: text
measuring reads every field and computes. Both cases were verified red
by permuting the defstructs; the permutations are recorded in the
comments so the next reader need not rediscover which ones bite.
2026-09-12 03:45:41 +07:00
0b8564828b The temporary and the reference to it come back together, so they cannot drift 2026-09-12 03:44:16 +07:00
540b2aaadd The sand hash moves with the grid it is over
screen-width and screen-height went from 1400x1000 to 900x600, and the first
colour changed. Those are defconsts the checker consumes to size rows and cols,
so the grid is a different shape and the hash over it is a different number.
Updated rather than reverted: the change is deliberate and the hash is a
regression test for the simulation being reproducible, not for it being any
particular size.

Checked the way the old number was: -2851001042534928384 on native and on
wasm32, byte for byte. A hash that moved on only one target would mean the
change had broken reproducibility rather than the grid, which is the thing this
case exists to catch.
2026-09-12 03:43:18 +07:00
f7009fcd34 Tests that assert the reason, and one that notices a doubled call
The checker tests pin the reason rather than the failure: an array pattern
over a slice has to fail *because a slice's length is a runtime value*, not
because something went wrong. The four map-destructuring keys Clojure has and
this does not are each named individually, because "unexpected form" leaves
the author guessing which of the four they wrote is the missing one.

The acceptance program exists for the case none of the above can see. A
pattern is desugared away entirely, so there is nothing in the typed IR to
inspect; the only way to tell that the value was bound once is to destructure
something with a side effect and print how often it ran. Four names, two
calls. A desugaring that re-evaluated the initialiser per name prints 4, and
every other line in the program stays green through the mistake.
2026-09-12 03:42:47 +07:00
5170746de5 Reflow the note, autoload the client, and three todos
NEXT.md rewrapped to a wider column - a reflow, not a rewrite. The three TODO
entries in it are the substance: live disassembly of what is actually installed
in a cell, error overlays that vanish on the next thing you do rather than
surviving until an evaluation is accepted, and CL-style interactive recovery
where a stopped program offers a typed restart and the editor asks for the
value before invoking it.

flan-mode's declare-functions become real autoloads. A declare-function only
quiets the byte compiler; it does not load anything, so a user who had loaded
only flan-mode could not invoke M-x flan-dev at all.
2026-09-12 03:41:54 +07:00
826c62a1e9 Bind the parts of raylib a game needs and this one refused
Audio, render textures, fonts, gamepads, touch and gestures were all
absent, and a game cannot ship without the first of them. Fonts were
refused by name last time because a Font drags in two more aggregates
and two owned arrays with nothing headless to check them against; the
generator takes all of it unchanged now, and the check turned out to
exist — raylib measures text with pure CPU arithmetic over every field.

SetGamepadVibration stays unbound for two reasons at once: its arity
differs between the 5.1 and 6.1 headers with no 5.5 header to settle
it, and the symbol in libraylib.so.550 disassembles to a TraceLog stub
that touches no motor.
2026-09-12 03:39:10 +07:00
ce1426d1e9 Clojure's destructuring, because a binding vector is where it is missed
plan.org says Flan is Clojure's brackets and a small slice of its API, and
(let [{:keys [x y]} p] ...) is one of the most-used parts of that surface.
A struct is Flan's map, so {:keys [x y]} and {inner :field} read fields off
one; [a b] and [a b & rest] read a fixed array.

It desugars in parse.ml into the Let bindings and Field accesses that already
exist — the same trade dotimes makes. Ast.binding carries a name and nothing
else, so nothing downstream learns that a pattern exists: not Load's renaming,
not Check, not a backend. That is not only taste. Load matches Ast.pattern
exhaustively and Shim builds Ast.binding literally, and neither file is
editable from here, so an AST variant was never on the table.

The value goes into a temporary first. A pattern over a call must call it
once, and (let [{:keys [p]} p] ...) must read the old p rather than the one
it is halfway through rebinding. The temporaries are named with a ~, which
the reader treats as a delimiter, so no source symbol can collide with one.

The arity is the one thing the parser cannot settle — it is a type — so the
pattern's shape travels to check.ml as destructure~nth, which knows how many
elements the value has and lowers to an ordinary at.
2026-09-12 03:38:52 +07:00
a5980734dc Tests for the cleanup paths nothing was watching
From a mutation-testing pass: about sixty small, plausible changes to the
compiler and runtime, each applied, run and restored. Nineteen of them left the
whole suite green. The compiler was right in every case - what was missing was
anything that looked.

The two programs here close the severe cluster. cleanup.flan covers six claims:
an early return runs the defers registered above it, and runs them innermost
first; a defer that calls something, which is what puts a guard inside a defer
on the transfer path; a transfer out of a handler-bind pops its frames; a
two-clause handler-bind pops both; and a signal stops once a handler has
answered it by transferring. The numbers differ per failure, so a wrong answer
names its own cause rather than just being wrong.

signedness.flan covers the ashr/lshr and slt/ult choices. Either could have been
hardcoded to one arm and nothing would have noticed, because no program in the
corpus shifted a negative integer right or compared an unsigned value above
2^31 - where a signed compare answers the other way on every operator.

Each was verified able to fail, with the numbers the report predicted: hardcode
lshr and -4 becomes 9223372036854775804; drop the defers from the return path
and 21 becomes 0; reverse them and it becomes 12; let the signal walk continue
past a handler that transferred and the outer handler runs too.

The ones left open are recorded for the next pass: Reach's walk of index
expressions, addr places and restart clause bodies; the dev registry's
size-change guard; a local shadowing an imported name; and the 4K result cap,
which has no coverage at all rather than a missing assertion.
2026-09-11 21:01:53 +07:00
5f0bde8149 A thunk that holds a string keeps its mapping
The transient marker said nothing outside the module points into it once the
call returns - true of its text, silent about its data. A string literal is
emitted into the evaluating module's own image and an expression may store one
anywhere: C-x C-e on (set msg "tuned") left a program global pointing into the
mapping the agent was about to drop. The next thunk can be mapped at the same
address, so what comes back is silent garbage rather than a fault, and nothing
in the compiler refused it.

The third condition is that the module emitted no string constants. Then there
is nothing in its image anyone could still be pointing at. One that did keeps
its mapping, which costs a page and is the bargain every redefinition already
makes.

Found by reading jank, which has met the neighbouring hazard from the other
side: its notes are explicit that nothing is ever unloaded, and the one place
Flan makes an exception is the one place the rule had a hole.
2026-09-11 20:50:23 +07:00
fe1237ccea Four ways the break loop lied about the program's state
Found by a concurrency audit that demonstrated three of them against a running
program rather than reasoning about them.

The break state was a flag, not a depth. A C-x C-e thunk may itself error, and
the break loop that catches it nests inside the first - so the inner loop's
resume stored broken = 0 while the outer one was still stopped. Every verb that
could rescue the program then answered "not stopped", status answered "running",
and the outer loop spun forever with no protocol path out. Only kill recovered
it, and Emacs' modeline read live throughout. The audit showed it with ticks
frozen at 0 beside :stopped nil. It is a depth now, capped, and past the cap the
program says so and exits rather than grinding. The condition name is saved and
restored per frame for the same reason.

An idle connection wedged the whole listener. The accept loop is single-threaded
and serves each connection inline on a blocking read, so a client that connected
and sent nothing - an editor killed mid-request - blocked every later request
including the abort that ends a stopped program. Worse, requests the client had
already given up on were served when its socket finally closed, so an abandoned
abort could kill the program minutes later against a state that had moved on.
Two seconds is generous for one line.

chosen_ready was cleared after the resume attempt, so a restart arriving in that
window was answered ok and then erased. It is claimed into a local and cleared
first now, which also keeps strlen off a buffer the listener may be writing.

And aborting was sticky: an abort that passed its check just as the program
resumed stayed armed and would have killed it at the next unhandled error,
minutes later, in unrelated code, giving nobody the chance to choose.
2026-09-11 20:40:57 +07:00
17ef50898d The FFI shim is generated, and goes where its package goes
vendor/raylib has no C in it any more: shim.c is deleted and its 84 wrappers
are emitted from declare-c, which names the library's function in the library's
own signature. The reason the shim exists is unchanged - a small struct's
calling convention is a per-target classification and clang reproduces it for
free - but writing it by hand has stopped.

declare-c is a second form rather than a change to declare, because the two make
opposite claims about the same shape: (declare start-raw [path string] ...) says
the symbol takes ptr+len, and (declare-c init-window [... title string] ...)
says it takes a NUL-terminated char*. No structural rule separates them, so the
author says which.

The merge needed two fixes that neither lane could have found alone.

Load's uses-walker matches decl_kind exhaustively and did not know DeclareC, so
the reachability work and the generator did not compile together.

And the generated C is now emitted in parts keyed by the wrapper's own C symbol,
not as one translation unit. Reach.link drops the bindings nothing reachable
calls; a single TU holding every wrapper referenced every raylib symbol, so
sand-headless - which deliberately links no libraylib, and is the reason Reach
exists - failed at the link with undefined references to GetTime and its
neighbours. The first attempt keyed the parts by Flan name and broke the other
way, dropping a wrapper that was called: the flattened declaration is named
foo-c when a Flan wrapper is generated over it and foo when none is needed, so
the Flan name is not one thing. The wrapper's C symbol is what the declaration
binds in both branches.

Worth recording how close that came to passing: the acceptance suite died with
an exception rather than printing FAIL, so a grep for failures counted zero and
the suite looked green. Only the count of reporting suites - ten where there had
been eleven - showed it.
2026-09-11 20:38:17 +07:00
e08b3914fb Padding is a closed case, and a made-up name can still collide
Two gaps in what was claimed. The first is prose: "the typedef follows the
defstruct" answers field order and field types but says nothing about
padding, which reads like the remaining hazard. It is not one. Every field
type the generator admits has the same layout under LLVM as under C, and
emit.ml writes no datalayout, so clang applies the target's own rules to
both halves; everything where they could diverge — an array, a slice, an
Option, a map, a union — is already refused at the field.

The second is real. The flattened declaration's name is invented by
appending -c, so a hand-written foo-c beside (declare-c foo ...) came out
as the checker complaining that a name not in the file was declared twice.
Refused now where it happens, naming both and saying to rename one.
2026-09-11 20:31:55 +07:00
fe4d2e1b15 Launch, restart and document, from inside Emacs
M-x flan-dev builds, launches and connects in one command, so a terminal is no
longer part of the loop. It waits for a connection rather than for the socket
file: the daemon unlinks a stale socket before binding, so waiting on the file
either succeeds against nothing or races the unlink. A daemon that dies before
binding - a program that does not compile, which is the failure people will
actually hit - pops its buffer and refuses by name, because the compiler's
reason lives only there.

flan-dev-restart-program is elisp rather than a daemon op, and that is a design
answer rather than a shortcut: a session's struct layouts describe a process
only if that session compiled it, so restart the program, keep the session is
not a coherent thing to offer. It waits the old daemon out before starting the
new one, or the old one's exit unlinks its successor's socket.

Quit says the program may have outlived the daemon when it has to kill rather
than close, instead of reporting success - a killed daemon never runs the
cleanup that signals its child.

The restart test proves itself by what it discards: a name installed into the
old program is absent from the new one. Both quit and restart were verified
able to fail by mutating the client.

Two places now show that Tast.global and Tast.extern carry no Loc - M-. refuses,
and the doc buffer says the daemon reports no location - which is the same gap
recorded twice rather than papered over.
2026-09-11 20:30:25 +07:00
61ca469a7c A restart that stopped at the quit has not restarted anything 2026-09-11 20:29:48 +07:00
f925a79475 Say at the top that the terminal is optional now 2026-09-11 20:28:13 +07:00
fc3cd2361a Point at the reason rather than naming the buffer it is in
A program that does not compile kills the daemon before it binds, which is
the failure anyone starting one from Emacs will actually hit. Showing that
buffer is the difference between a message and an answer.

The prompt also offers the program last started: a restart after a quit is
the common case, and it is rarely the buffer you happen to be reading when
you decide on it. C-c C-x does the restart without the prompt at all.
2026-09-11 20:27:50 +07:00
aee8a032b1 Some changes are not a reload, and saying so is the feature
A struct whose layout moved cannot be installed into a program built with the
old one, and the daemon says so. There is no smaller answer than a rebuild: a
session's layouts and global types describe a process only if that session
compiled it, so the program and everything in its memory go too. That is the
cost, and it is why this is its own command and not something C-c C-c falls
back to.

Emacs owns the daemon now, so this is stop-and-start rather than a new op.
The old one is waited out first: it unlinks the socket as it leaves and would
otherwise take its successor's with it.

Also: quitting a daemon that would not close now says its program may have
outlived it, because killing the daemon skips the cleanup that signals the
child — and C-c C-v rather than C-c C-h for the doc buffer, which was
shadowing the way anyone discovers what is under C-c.
2026-09-11 20:26:36 +07:00
dbf5748c56 Assert the reasons, and say what a permutation proves
Every refusal is by name with the reason, so the tests assert on the
reasons and weakening one to a bare "cannot" breaks them: a slice, an
Option, a union, a fixed array, a map, a returned string, a callback, an
unknown type, a struct field C cannot hold, and two Flan names for one C
symbol.

The rest is text about text, which is the honest scope: what a wrapper
does is settled by clang, and what is worth checking in OCaml is the
shape of what clang is handed. Two cases assert the typedef's field
order against a defstruct and against the same defstruct permuted,
because only the pair rules out a generator that sorts — and sorting is
exactly the mutation the raylib cases cannot see, since every raylib
struct is fields of one size and a rename changes no offset.

What the raylib cases do see is a permuted defstruct, and that was run:
Rectangle width/height, Vector2 x/y, Image width/height, Image with data
moved last, Texture2D id/format, Color r/a and Camera2D offset/target
all go red. Texture2D width/mipmaps stays green, which is what NEXT.md
already says headless cannot pin — the one green is the control, not a
gap.
2026-09-11 20:26:11 +07:00
a3e06ce3d4 raylib says what it takes, and shim.c stops existing
All 84 bindings migrated, so the package is raylib.flan and link and no
C at all. Two keep a wrapper and both wrappers are Flan, not C:
collision-point-poly? takes a slice and collision-lines answers with an
Option, and neither is raylib's signature. A slice in a declare-c is
refused by name — the length crosses as i64 and the type of the C count
parameter beside the pointer is not recoverable from [T] — so that one
declares (Ptr Vector2) with an explicit count and the Flan wrapper hands
over (addr (at points 0)) and (len points), answering an empty polygon
itself rather than reading out of bounds.

What this buys and what it costs, stated rather than assumed.
Guaranteed: the C typedef and the Flan struct are made from one
defstruct, so they cannot disagree — permute the defstruct and both
permute. Trusted: that the defstruct is raylib's real struct and that
the declare-c is raylib's real signature. No header is read, on purpose,
so the build needs libraylib linkable and not raylib-devel, and nothing
here can check either half. A _Static_assert on sizeof and offsetof
would have both sides coming from the same field list, so it was left
out rather than mistaken for evidence.

The sharper edge is the prototype: it is generated from the declaration
now, so f64 where raylib says float emits double and raylib reads
garbage, where before clang narrowed it at the hand-written call site.
Every one of the 84 was diffed against the prototypes in the shim.c
being deleted, which was the ground truth, and they agree.

Strings are sized here and not per call site, because a generator has no
call site to look at. 256 bytes on the stack, the heap past that, freed
after the call; the only truncation left is on malloc failure. The old
wrappers truncated at 256, PATH_MAX and 512 by hand.
2026-09-11 20:26:00 +07:00
d2bc2bd714 The wrapper per binding was always mechanical, so write it here
84 hand-written C wrappers is the shape of a job the compiler should be
doing. The reason the shim exists is unchanged and is not negotiable: a
small aggregate's calling convention is a per-target classification, not
part of its layout, and reproducing x86-64, arm64 and wasm32 inside
emit.ml is three classifiers to keep correct forever, where a mistake
reads as a field full of garbage rather than as a link error. clang does
it, per target, for free. So the C stays; the typing of it stops.

declare-c names the library's own function in the library's own
signature, and Shim emits the typedefs, the extern prototype, the
flattening wrapper and the flattened declaration the Flan side calls.

It is a second form rather than a change to declare because no
structural rule can separate them: (declare start-raw [path string] i32
"flan_agent_start") means the symbol takes ptr+len, and (declare-c
init-window [w i32 h i32 title string] "InitWindow") means it takes a
NUL-terminated char *. Same shape, opposite claims. declare is
untouched, so sqrtf and vendor/agent keep working unedited.

The generated C rides on Tast.program rather than beside it, so the CLI,
the REPL and the acceptance table all carry it without being told about
it. `flan shim` prints it, because a wrong binding is wrong in a wrapper
that is otherwise on no disk anywhere.
2026-09-11 20:26:00 +07:00
76f84071df A signature in the echo area is gone the moment you type
C-c C-h puts what the daemon knows about a name in a buffer instead: kind,
signature, and a button on the place it is written. No new protocol — defs
has carried all four facts since it existed.

Where there is no location it says so in M-.'s own words rather than leaving
the line out, because a missing line reads as "this name has no home" and
the truth is that Tast.global carries no Loc.

imenu and which-function come with it, and neither needs a program running:
they read the buffer, so they work on a file nobody has built yet and keep
working while it is stopped. Anchored at column 0, so a defn inside a let is
not offered as a definition of anything.
2026-09-11 20:23:00 +07:00
99e59dba9f The reader learns quasiquote, and defmacro says why it does nothing
Clojure's backtick, tilde and tilde-at rather than Common Lisp's comma forms:
is_delimiter already treats a comma as whitespace and every binding vector in
the corpus assumes it, so freeing the comma would rewrite more of the language
than macros are worth. They read as (quasiquote x), (unquote x) and
(unquote-splicing x), the way 'x already reads as (quote x) - the reader stays
dumb and the meaning is resolved later.

The backtick previously read as an ordinary symbol character, which is exactly
the failure the reader's own header warns about for the apostrophe. Both sigils
are delimiters now, so a~b is two things and can never be one name.

defmacro validates its shape before refusing, because a malformed one and a
well-formed one are different mistakes and deserve different sentences. The
three new reader names are refused by name too, or they would fall through to
Call and come back as unknown name quasiquote. unquote outside a quasiquote is
refused as a mistake rather than as a milestone, since the reader cannot know
where it is.

Nothing is stored: no Ast.Defmacro and no macro table. A new decl variant would
have forced edits to four files other agents hold this session, and a
process-global registry spanning the prelude parse, the package parses and
hundreds of test snippets would make results order-dependent. The storage shape
is the expander author's first decision anyway.

The design note records what the expander needs, and the blocker worth knowing:
a macro is [Form] -> Form, so Form has to be a Flan union whose layout the
compiler and the loaded macro agree on exactly, and union values are milestone
6.
2026-09-11 20:22:03 +07:00
4f0b1012e8 Three ways to pass the checker and die afterwards
Found by a read-only audit of emit.ml's failwith sites, each of which is a claim
that the checker guarantees something. Three of those claims were false, and
every one failed in the shape NEXT.md calls the worst available: type checks,
then dies with no source location.

An enum comparison is lowered now rather than refused. Types.is_comparable
already admits an enum, so the checker was stating an intent the backend never
honoured - (= k :a) is the first thing anyone writes with an enum, and it raised
Failure("comparison on K"). An enum is an i32 at run time, so all six
operators are an icmp. Signed, because (defenum K [a -1]) is accepted and an
unsigned compare would call -1 the largest member.

A union in a type position is refused instead. Constructing a union value and
reading a field of one were already refused, so nothing could ever be done with
such a value - only the declaration got through, and it reached clang as a
reference to an undefined %"U", which is a link error naming an emitted symbol
with the source location long gone.

A function type annotation is refused too. The function *value* was refused
where it is written; the annotation was refused nowhere, so (defn f [g (Fn []
i32)]) died with "no layout for". It now sits beside the Map line directly
above it, which is the same shape of not-yet.

The audit also found the sentence that covered the last two: NEXT.md and
check.ml's header both claim unions and function values are rejected by name.
That is true of values and false of types, which is exactly the gap the two
findings lived in.
2026-09-11 20:21:33 +07:00
a311664a08 The delimiter half of the backtick fix was observed by nothing
Every sigil in the corpus test sat in leading position, where read_form
handles it before is_delimiter is ever consulted — so reverting the
is_delimiter line alone left every case green. a`b now has the case a~b
already had, and the corpus carries both, which is what makes the class
guard cover the delimiter change rather than only the read branches.

The handoff note hedged on the one thing it exists to decide: expansion
runs over Form before Parse, not over Ast. There is no Ast.Defmacro, so
an Ast pass would have nothing to read. Says milestone 6 for union
values because that is the number check.ml itself gives.
2026-09-11 20:20:24 +07:00
f590436ed3 Write down the expander so the next lane inherits a decision, not a table
The front half is here and the back half is not, and the reason is that
running a macro means compiling it and dlopening it into the compiler —
which is Emit.redefinition plus Build.shared, already measured at ~19ms,
pointed at our own process instead of the program's.

The part worth recording is what blocks it: a macro is [Form] -> Form,
so Form has to be a Flan union with a layout the compiler and the loaded
macro agree on exactly. That is milestone 6 work landing before
milestone 5's, and it is bigger than the expander.

Nothing is stored on purpose. No macro table and no Ast.Defmacro: a
table nothing reads is where a design rots, and the storage shape is the
expander author's first decision rather than one to inherit from a lane
that could not test it.
2026-09-11 20:17:12 +07:00
ab2a31d002 A dev loop should not need a terminal window
The daemon owns the program's lifetime, so the terminal it was started in
was also the only place that program could be stopped from. M-x flan-dev
builds, launches and connects; M-x flan-dev-quit ends it.

It waits for a connection rather than for the socket file to appear: the
daemon unlinks a stale socket before binding, so waiting on the file either
succeeds instantly against nothing or races the unlink. And when the daemon
dies before binding — which for a program that does not compile is the
ordinary failure — the refusal names its buffer, because that is where the
compiler's reason is and nothing this end sees says it.
2026-09-11 20:17:08 +07:00
1b2533b41e Merge branch 'pkg-visibility' into dev-loop 2026-09-11 20:16:18 +07:00
d4cef99718 The house rule had a hole at the top level, and the reader just widened it
(defmacro m [x] x) answered "unknown top-level form (defmacro ...)" —
refused, but not by name and with no reason, because the refusal list
only covered expressions. Now it checks the shape and then refuses,
which are two different mistakes and get two different reasons: a
defmacro with no body is a typo, a defmacro with a body is a feature
that is not here.

The reader's new sigils made this urgent rather than tidy. quasiquote,
unquote and unquote-splicing are now real heads arriving at the parser,
and without a case each they would fall through to Call and come back
from the checker as "unknown name quasiquote" — which tells you nothing
about what is missing. unquote and unquote-splicing are refused as
mistakes rather than as milestones: they mean nothing outside a
quasiquote and the reader cannot notice, because it does not track
where it is.

gensym is neither a reader token nor a special form — it is a function a
macro body calls while the macro runs, and there is nowhere for it to
run. Refused by name so it does not arrive as an unknown one.
2026-09-11 20:16:13 +07:00
fcebe03576 A file that is a package is still a package to the editor
package_of matched the file being edited against a package's directory, which
is right for every package that is one — and answers "not a package" for one
that is a single file, because the file's directory is not the file. A form
typed into sand.flan with the headless driver running would have spliced as a
bare step, the evaluation would have said ok, and the program would have gone
on calling the step it already had. That is the exact silent failure the
function exists to prevent, so it now matches the file too.
2026-09-11 20:15:31 +07:00
9eb87e486a A backtick was a name character, which is how the apostrophe used to be
`(a b) came back as the unknown name "`" — precisely the failure the
reader's own header warns about for the apostrophe, one sigil over and
still open. Same fix: the sigil reads as a wrapper and the reader stays
dumb about what it means.

Clojure's ` ~ ~@ rather than Common Lisp's ` , ,@ because a comma is
already whitespace here, and every binding vector in the corpus is
written assuming that. Changing is_delim to free up the comma would
rewrite more of the language than macros are worth.

Backtick and tilde join is_delimiter so that a~b is two things and can
never be one name. No symbol in the corpus contains either character, so
closing the class costs nothing now and would cost a migration later.
2026-09-11 20:13:32 +07:00
f78c935b95 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.
2026-09-11 20:12:34 +07:00
259cf3b3e2 sand is one program again
The simulation was in a package of its own for one reason: importing raylib
linked libraylib on every target, so the headless run could not name the
package the interactive one needs. That reason is gone, and the split was
never anything else — the physics is the same code either way.

So sim.flan is back inside sand.flan, and test/programs/sand-headless.flan
imports sand.flan itself: window, raylib bindings, dev agent and all. It builds
for wasm32 anyway. Nothing it calls reaches raylib, so no shim is compiled, no
-lraylib is passed, and the front-end's functions are never emitted; sand.flan's
main is not exported, so the only main is the headless one. The hash is
unchanged on both targets at both optimisation levels, which is the point —
a refactor that moved the number would have moved the simulation.

The new cases cover what made it possible rather than only the result: a
package nothing calls into, native and wasm32; raylib reached both directly and
through sand.flan and read once; and the three refusals — sand/main, one
directory under two aliases, and two mains.

test_session's package-qualification case moves to vendor/agent, which is now
the package in the tree with a defn in it.
2026-09-11 20:11:01 +07:00
d59c72af60 A package may import a package, and may be one file
Three limitations, and the same program wanted all three gone.

An imported package's own imports were refused by name. They are resolved now,
and the qualification flattens to the inner alias: raylib imported by a package
that is itself imported is still rl/..., never sand/rl/.... That is forced, not
chosen — a directory reached along two routes has to arrive under one set of
names or the checker sees every declaration twice — and it is what lets the
dedupe work. A directory is keyed by its real path and read once, which also
ends a cycle: a package that imports itself meets its own entry and contributes
nothing the second time, and since the namespace is flat, mutually dependent
packages simply work. The same directory under two different aliases is
refused, because both cannot be true at once.

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 root, so an imported one keeps everything it calls reachable — for a
raylib front-end, the whole library, on the target that cannot link it. Writing
sand/main is refused at the line that wrote it rather than left to the checker,
which would only say the name is unknown. That is true and useless: the name is
missing on purpose and the message should say which purpose. A package that
calls its own main is refused too — it would silently get the importer's.

And a package may be a single .flan file named outright. sand.flan shares the
repository root with three other loose programs, so naming its directory would
import all four; moving it into a directory of its own would be arranging the
tree around a limitation. A file carries no .c and no link file — those belong
to a directory, and a package that needs them has one.
2026-09-11 20:11:01 +07:00
da38a3db5f An EDN tokenizer, as a package
vendor/edn rather than the prelude: the prelude is prepended to every program
and everything in it is emitted, so a reader nobody imports would be a cost
every build pays.

The tokenizer only. A type-directed reader - the compiler emitting a parser
from a walk over a struct's fields, the dual of the printer C-x C-e already has
- lands in check.ml and emit.ml and is not this. What a caller writes today is
a struct reader by hand against the cursor, and the acceptance program carries
one, because that is what proves the API is usable rather than present.

Every token is a slice into the source, so nothing allocates and the buffer has
to outlive the tokens. That contract is stated at the top of the package,
because it is the kind of thing found the hard way.

Escaped strings are refused rather than half-supported: unescaping needs a copy
and there is nowhere to put one, and handing back the raw bytes would return a
three-byte string as four with a backslash in it. Each other refusal carries its
own sentence - #inst and #uuid separately from tagged literals, because a file
is most likely to contain those two and being told tagged literals are refused
would not say that the timestamp is the thing to delete.

Errors live on the cursor, a code and a byte offset, not in the return type: an
Option loses the position, which is the whole point for an editor. A failed
cursor is poisoned so a caller's loop terminates on a malformed file rather than
spinning.

# Conflicts:
#	test/test_acceptance.ml
2026-09-11 20:06:47 +07:00
ad092d7d02 The fixed stack needs a case, and .5 needs a decision
err-too-deep was the one error code nothing observed. The message is the least
of it: the plausible wrong version is `>` where the guard wants `>=`, which
writes one element past a [32 i32] and traps at exit 134 rather than answering
anything. 33 opening brackets is the input that separates them, and it is the
whole justification for a fixed array instead of a growable stack — the place
this lane pushes hardest against having no allocator.

`.5` reads as a float here and does not in EDN, where a number must start with
a digit and `.` is a legal symbol-start byte. That makes it a reinterpretation
of a token that is already legal as something else, which is exactly what the
house rule says to name rather than leave to be discovered, so it is written
beside the refusals.

Also: every symbol in the table was lowercase, so the A-Z half of alpha? was
unexercised and a version missing it passed. Enemy/Goblin in an existing dump
rather than a new case. And a line under "Internal helpers" saying the heading
is intent and not enforcement — a package has no visibility, so edn/scan-atom
is as callable as edn/next, the same way rl/get-color-raw is.

Both new cases verified by mutation: the depth guard traps, and alpha? without
its uppercase range fails Enemy/Goblin.
2026-09-11 20:04:29 +07:00
0e88954664 The link follows the program, not the import list
A package handed over its .c files and its `link` arguments the moment it was
imported, whatever the importing program did with it. That is what made sand's
two halves two files: anything naming vendor:raylib linked libraylib on every
target, and on wasm32 that link cannot succeed, so the headless run could not
so much as mention the package the interactive one needs.

Reach.link 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.

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. So the same walk prunes the functions and externs
too. Only those — globals, structs and unions stay, because an unreferenced
global is bytes in BSS and a dropped one is a silently different program.

Dev builds keep everything. What a REPL may redefine next is not a function of
what has been called so far.
2026-09-11 20:02:21 +07:00
7e7f77f2da The struct reader is what proves the cursor is usable
read-enemy in test/programs/edn.flan is the worked example the API is for: the
map opened, the keys looped over, each known one dispatched onto its field and
the rest skipped, written by hand because the compiler cannot emit it yet. It
is there rather than in a doc comment because an API only a compiler could
call would be present without being usable, and writing one out is the only
way to find out which it is. Two things came back from writing it — that
float-of has to accept an integer token, since a config file writing `:speed 2`
for an f32 field is not making a mistake, and that a caller needs `fail` on the
cursor, because a reader's own "expected an integer here" has nowhere else to
get a position from.

The expected output is a raw literal. The dump is brackets and quotes end to
end, and escaping it into an ordinary OCaml string would put a second reader
between the test and what the program printed.

Every case was checked by breaking the tokenizer and watching it go red;
sixteen of them, each restored afterwards. The ones worth naming, because they
are the ones that could have been quietly unobservable: dropping the escape
refusal, accepting `#{`, and collapsing every refusal onto one message — that
last is the shape where a table asserting only "it failed" stays green while
observing nothing. Also: a semicolon no longer ending an atom, a comment scan
that does not test for end of input (which traps rather than differing, on the
comment with no trailing newline), the ratio rule widened to any atom
containing a slash (which takes foo/bar with it), text slices left including
the quote and the colon, a closer counted but not matched, any byte accepted as
a symbol start, a comma not counted as whitespace, and skip-value consuming one
token instead of a whole collection.
2026-09-11 20:01:21 +07:00
d07d6fb4db vendor:edn has to be a build dependency of the tests
An import reads the directory at build time, so a package that dune has not
copied under the test's build dir does not resolve — and the failure is a
missing collection, not a missing file, which reads like a bug in Load.
2026-09-11 19:58:16 +07:00
19be614f22 A tokenizer is what fits without an allocator
The type-directed half — (read-edn Enemy bytes), a parser emitted from a
compile-time walk over a struct — is the compiler's work and is not here.
What a running program can have today is the half underneath it, and the
shape of that half is decided entirely by there being no heap: a token is a
slice of the input, so reading a file costs one buffer and nothing else, and
the cost is a lifetime contract the types cannot state. It is stated in the
header instead, because a dangling [u8] is otherwise found from a corrupted
string several frames later.

A package and not the prelude. The prelude is prepended to every program and
everything in it is emitted, so a reader nobody imports would be a tax on
every build.

Token kinds are i32 constants rather than a defenum, which reads like a
downgrade and is not one: an Enum value cannot be compared with `=` (emit
fails) and a keyword is not a pattern (`match` refuses one), so a defenum here
is FFI-only and a caller could not branch on a kind at all. Both fixes live in
check.ml and emit.ml, which this lane does not touch.

Errors land on the cursor — a code and a byte offset — rather than in an
(Option Token). None says something went wrong; an editor needs to know where,
and a second out-parameter for the position is the same two fields with a
worse shape. A failed cursor is poisoned so a caller's while loop stops
instead of spinning. error-message turns a code into the sentence, and every
refusal gets its own: escapes, sets, tagged literals, #inst and #uuid
separately, metadata, ratios and characters each name themselves and say why,
so a file using one fails with what to remove rather than with a number.

Escapes are the refusal that had to be a refusal. Unescaping needs somewhere
to put the copy and there is nowhere; returning the raw bytes would hand back
a three-byte string as four, with a backslash in it, and nothing would say so.

Balance is checked in `next` against a fixed [32 i32] stack in the cursor,
because `[1 2}` is malformed in a way only the tokenizer has the position for,
and a growable stack is another thing there is no allocator for. Past 32 the
answer is err-too-deep rather than a closer that quietly went unchecked.

Symbol starts are a list and not "anything that is not a delimiter". Without
that, `@` and a backtick read as one-character symbols instead of being
reported; the ratio test is likewise digit-started only, so foo/bar stays a
namespaced symbol.
2026-09-11 19:58:04 +07:00
66cd83d2a1 wasm32 runs the table, and the hash matches
sand-headless prints 2256461126764447066 on native and on wasm32, at -O2 and at
-O0, in one dune test run. That is the whole point of the exercise and the
reason rand-f32 is written in Flan rather than bound to libc.

The old note said the builtins archive has to come from a wasi-sdk release. It
does not: emscripten builds the same compiler-rt and it links correctly under
the other name. It is a different triple built by a different clang, so it is a
substitution rather than the real article, and both the code and the note say so
- nobody should read "wasm32 works" without knowing which joint is glued.

Two findings the note did not have. The entry point is __main_argc_argv, not
main, and the link succeeds before trapping on a signature-mismatched weak stub.
And the target has to reach the C compiles as well as the link, since flan_rt.c
includes stdio.h.

Also corrects why sand is two programs. Two claims had been run together: raylib
does work on wasm through emscripten, and a game loop is expressible there with
emscripten_set_main_loop - a different main, not a different program. What
justifies the split is only that a headless test needs no window on any target.
What makes it mandatory is Load collecting a package's C and link flags whether
or not anything references the package, and that is the thing to fix.
2026-09-11 19:51:33 +07:00
32e20f03da Merge branch 'wasm32' into dev-loop 2026-09-11 19:48:51 +07:00
8a175ebec5 Read wasi-sdk's version instead of guessing it, and pin the one ABI path left
The wasi-sdk candidate had an LLVM version in it, which moves release to
release — so the path advertised as the proper article would have matched only
by coincidence, while the emscripten one beside it was derived. Both are
derived now.

calc-me on wasm32 covers what the other three cases cannot: flan_argv hands
Flan an array of flan_slice built in C, so what it pins is the element stride
of a ptr+len pair — 16 bytes native, 12 on wasm32 — rather than a field
offset. It is also the claim in this file's own header, that the table runs on
the second target, honoured for the first time.

flan emit refuses --target rather than stripping it. The IR really is
target-free, so ignoring it is correct and silence about it is not.
2026-09-11 19:48:04 +07:00
e4586b55c7 The Image family, and 22 shapes
Image first and deliberately: it is CPU-side, so it is the only large piece of
raylib that can be asserted headlessly rather than looked at. gen-image-color,
the pixel reads, both flips, a PNG round trip through export and load, and the
resize and crop dimensions and contents are all in the table at -O2 and -O0.
The shapes, text and timing calls are observed only, by running sand under Xvfb
and looking, and the program and NEXT.md both say which is which.

Five permutations were run red and restored: Image's width against height and
mipmaps against format, GetImageColor's two indices, the two flip wrappers
bound to each other, and the crop rectangle's width against height. The third
of those also broke the export and load lines, which is what makes the PNG
round trip verified rather than merely plausible.

Two corrections to the brief it was given. MeasureText is not headless material
- it measures with the default font, which only InitWindow loads, and a C probe
returns 0 - and the same is true of the frame-time and screen-size calls. And
the raylib.h on this machine is 5.1-dev while the linked library is 5.5, so
every signature was checked against nm -D instead: IsImageValid rather than
IsImageReady, and DrawRectangleRoundedLines takes no thickness.

Font loading is refused by name. A Font carries a Texture2D, a Rectangle* and a
GlyphInfo*, and a GlyphInfo carries an Image - two more aggregates and two owned
arrays, for something with no headless test.
2026-09-11 19:48:03 +07:00
cb11fdf272 Write down what the Image family taught, where the next lane will look
The section on what a headless FFI test can and cannot pin was written before
anything CPU-side was bound, so it had no example of the one shape that beats
store-and-return: scalars in and struct fields out, with nothing for a
permuted layout to cancel against.

It also did not say that MeasureText answers 0 without a window, which is the
assumption this lane started with and had to measure its way out of. Two
lanes have now guessed the same thing.
2026-09-11 19:47:08 +07:00