107 Commits

Author SHA1 Message Date
cb47b98100 Merge branch 'string-of-bytes' into dev-loop
A [u8] and a string are the same 16 bytes at run time, so (string b)
is a reinterpretation with no instructions. What it buys is that a
number can reach draw-text at all, which five of the ten examples
wanted and none could have.
2026-09-12 05:21:58 +07:00
4001c3246c Merge branch 'dwarf-names' into dev-loop
A let-bound local is its own name under lldb now, and a redefinition
module carries DWARF when the daemon was asked for it.

Resolved against the println track in session.ml: the thunk keeps the
render walk's appended slots and gains the names beside them, the walk's
own scratch having none to keep.
2026-09-12 05:19:38 +07:00
421e09e0d6 A number can reach draw-text now
(string b) is the mirror of (bytes s) and costs nothing: emit.ml already
lowers Types.String and Types.Slice _ to the same %slice, 16 bytes at
align 8, so a string and a [u8] are the identical value at run time and
both directions emit as the argument itself. What changes is only what
the checker will let the value be passed to — which was the whole gap.

Two decisions, both written into check.ml's comment.

It does not check UTF-8, because `string` does not claim UTF-8. The
prelude settles it: valid-utf8? is an ordinary function you call when you
care, decode-rune / rune-at / rune-count all take [u8] and not string,
and decode-rune answers {:ok false :width 1} on a malformed byte rather
than assuming well-formed input. The one place the runtime treats a
string differently from a byte slice is flan_escape_bytes, for a string
nested in a printed structure, and that is a byte-wise escape table with
no decoding in it. A check here would be the only enforcement point in
the language, which is a claim the rest of it does not make.

It does not widen the literal-write hole. That hole is the other
direction — (bytes "Hi") hands back a writable-looking slice over
constant data — and this direction only loses the ability to write, so
the result reaches strictly fewer stores than its argument could.
Provenance is still what the other direction needs; nothing here waits
on it.

The one sharp edge is not new but is easier to trip over now, and is
recorded in both the checker and digits.flan: i64->bytes, f64->bytes and
u64->bytes all view the same static buffer in the runtime, overwritten
by the next call, and calling it a string does not copy it. Format, draw,
then format the next one.

examples/digits.flan keeps its three signatures and loses its middle: the
[10 string] table, the per-glyph pen and the digit arithmetic are gone,
and draw-int is one draw-text. What survives is the part (string ...)
does not answer — i64->bytes has no field width, so "%03i" is still
assembled, and f64->bytes is "%g", so fixed decimal places are still a
split into two integers. core-input-multitouch and
core-input-virtual-controls ignored the width they were given, so both
inline the draw and stop importing digits.flan entirely.

test/programs/string-of-bytes.flan at -O2 and -O0: a number round-tripped,
an empty slice, sub-views whose length is not the underlying storage's,
and the result across a declare-c boundary. The last is the one that
could have been wrong — "hello world" cut to five bytes has a space where
C wants a NUL, so a shim that trusted the bytes would print all eleven.
2026-09-12 05:19:23 +07:00
4723e49e4d Ask the object, not the text, whether the daemon built with -g
test_session.ml asserts that a debug session emits the metadata, which is
the unpassed-argument defect itself. It cannot see the other half: it is
Build.shared that turns the flag into -g and -O0 on the module, and a
daemon that dropped Build.debug from its opts would still emit perfect IR
and then compile it away — llvm.dbg.declare describes an alloca and
mem2reg deletes the alloca, so the symptom would be a module that looks
right in every text assertion and has no locals in the debugger.

So this drives a real `flan dev --debug`, sends one redefinition, and runs
llvm-dwarfdump over the .so the daemon actually wrote. The line table is
the needle because it is what a breakpoint in a .flan buffer resolves
against, and it names the file the form was typed in rather than anything
on disk. Skipped where there is no llvm-dwarfdump.
2026-09-12 05:17:13 +07:00
d0a8339bb5 DWARF in a redefinition, and one flag that means it everywhere
Emit.redefinition has taken ~debug since it was written and was tested
with it; Session.eval never passed it, so every body installed by C-c C-c
lost its debug info in the running process.

Passing it alone would have been half a fix. Build.shared is what forces
-O0, and dev.ml built modules at -O2, so the llvm.dbg.declares would have
been emitted and then deleted by mem2reg: a line table, and no locals.
And a module with DWARF loaded into a host without it lines up against
nothing. So it is one flag — flan dev --debug and flan reload --debug —
and it sets the host build, the module builds and the emitted metadata
together. Off by default: a debug build is an -O0 build, and quietly
making every reloaded body -O0 changes the frame time of the one function
you are iterating on, in the loop whose point is watching that number.

What a dlopen'd module does to a breakpoint, measured against the reload
fixture rather than reasoned about:

  - lldb reads the new module's DWARF on the dlopen and says so: "1
    location added to breakpoint 3".
  - A breakpoint set by NAME gains a second location either way, so
    dlopen was never the difficulty. What the line table buys is that it
    stops with source instead of disassembly.
  - A FILE AND LINE breakpoint on the new body resolves only with it;
    without, it sits at locations = 0 (pending) forever.
  - A FILE AND LINE breakpoint on the HOST's copy stays pinned at
    locations = 1. That is correct, not stale: the old body is still
    mapped and every call site that has not gone through its cell again
    still reaches it.
  - The stack crosses intact — a frame in the reloaded .so and the one
    below it in the host each name their own .flan file.

    (lldb) frame variable
    (long) step = 10
    (long) prior = 11

The transcripts are in flan-dape.el, replacing the note that said the
module carries no DWARF yet.

flan-cnr.el's stack pane was refusing for the wrong reason. DWARF was
never its gap; nothing is attached to the stopped program, and a socket
cannot read another process's frames. Reworded to say that.

Source interleaving in the disassembly buffer is unblocked and not done:
objdump -dS interleaves a --debug module's Flan source correctly, so
Dev.asm_of needs the -S and a parse_listing that tolerates source lines.
2026-09-12 05:14:03 +07:00
afec482722 Ten raylib examples, and what they could not say
The first ten of raylib's core list, ported. Seven new bindings and the
named colour palette; nothing else was added, because a binding called
by nothing is the same as not having bound it.

The gaps they found are the point. No number reaches draw-text: i64->bytes
answers [u8], draw-text wants a string, and nothing bridges — five of the
ten wanted TextFormat and got a glyph table instead. And an enum parameter
cannot be driven by a loop variable: the index is an i32, the parameter is
an enum, neither converts, and a second declare-c with an i32 face is
refused because one C function gets one binding. Two correct rules that
compose into a wall.

None of the gaps expected blocked anything: no generics, no allocator, no
Vec, no escaping closure, no block-scoped defer. These are input-and-draw
programs over fixed-size state, which is the shape the language has.
2026-09-12 05:06:01 +07:00
e6594fd554 The name the source gave a local, all the way to the debugger
A let-bound local printed as s0 under lldb. Parameters were fine, because
the driver recovered their names from the AST and handed them down in
pnames; everything else was a slot index, since Check knew the name in its
scope list and dropped it at allocation.

Tast.fn now carries snames beside slots, Check fills it in at bind, and
Emit prefers it over pnames. A slot the compiler invented keeps s<index>:
fresh_slot takes the name as an optional argument, so dotimes' hidden
bound and the pair min and max evaluate into say nothing and get None
without any of their call sites changing. Naming those something plausible
would put a variable in the debugger that is not in the file.

Shadowing needed deciding rather than assuming. Every DILocalVariable is
scoped to the subprogram — the typed IR has no block structure to build a
DILexicalBlock from — so two slots called v landed in one flat scope, and
lldb answered p v with the outer one while the body computed with the
inner, which it did not list at all. A debugger confident and wrong is the
one outcome worse than s0, so a repeat of a name already bound in this
function gets a ~2 suffix: ~ is the reader's delimiter and cannot occur in
a source symbol, so v~2 is unambiguous and visibly the compiler's. It is a
way of not lying, not a way of being right; scoping properly means a
lexical block per Let and the declares moved out of the entry block.

  (lldb) breakpoint set --file debug.flan --line 20
  (lldb) frame variable
  (Cell *) c = 0x00007fffffffd970
  (int) n = 41
  (int) bump = 42

The test breaks after the binding on purpose. A name breakpoint stops on
the function's first line, before the let has stored anything, and a
variable is nominally in scope from entry — so the name is checked there
and the value only where it means something.
2026-09-12 05:05:40 +07:00
2f8436018c Merge branch 'restart-at' into dev-loop
A restart the innermost frame shadows could be seen and not taken;
it is taken by position now, off a snapshot that stopped moving under
the break loop. The editor half this was briefed as building already
existed — the stale line that said otherwise is fixed.
2026-09-12 05:04:30 +07:00
e4db079c57 Pin the escape buffer's bound, and say what it reserves
The guard reserves 9 bytes but the comment explained 5, which is the
longest escape alone -- it did not account for the three writes after the
loop (the ellipsis and the closing quote), so the next person to touch
the escape table would have preserved the wrong invariant.

Swept every length to 1300 against \x01, a quote, a backslash and 'a'
under ASan with a red zone past the buffer: no write past 1024, worst
output 1021. Correct, but by three bytes, which is exactly why the
reserve is now written down as the four things it is spent on.

Nothing exercised truncation -- the longest nested string in the fixture
was 18 bytes -- so println.flan now prints a struct with an 1100-byte
string field, and the expected output spells the surviving count out as
a number so a change to the buffer shows up as one.
2026-09-12 04:58:16 +07:00
4a6a8fa0f7 Take a restart by its position, off a list that stopped moving
Two frames offering `retry` put both on the break loop's list and only the
inner one within reach: §4's walk takes the first frame offering a name, by
definition, so the outer clause was drawn, offered, and unreachable. The old
prompt showed `retry` twice and sent the string either way. An index is the
only thing that can say which one, which is why SBCL identifies them
positionally too.

An index is worthless against a stack that moves, though, and this one moves:
the break loop is the poll loop, so every restart-case an evaluation enters
pushes and pops the same global list between the listing and the choice. So
the list is read once on entry and copied — names into the agent's own buffer,
frames as the addresses a transfer carries — and every answer comes from that.
The name still travels with the index as a receipt, checked against the
snapshot and refused if the two have drifted, so a bare integer can be wrong
out loud.

And the third state. A restart below the thunk a break is inside was accepted,
announced, and silently not taken: `flan_reload_call` holds its own transfer
channel and drops it on return, so the unwind stops at the thunk. The boundary
is now recorded where it is made, at the call — frames a restart-case inside
the thunk pushes are above it and still work — and such a restart is listed,
marked, and refused with the reason.

`break.flan` grew the shadowed pair, and 900 is a value no by-name lookup in
that file can produce.
2026-09-12 04:56:18 +07:00
93231e8c9e println, the structural printer, shared with the REPL
session.ml already had this: a compile-time walk over a Tast type that
emits the calls to print a value of it, handling every concrete type the
language has. It was dev-build-only and went to flan_dev_emit, and
prelude.ml justified the per-type print-* functions by saying a real
println had to wait for milestone 5 and generics. It did not. plan.org
specifies println as compiler-provided and per concrete type, which is
not overloading: there is nothing to dispatch on at run time and no
user-supplied printer to choose between, so no type variables appear.

The walk moves to render.ml, parameterised on an emitter and a slot
allocator. The emitter is five functions rather than five extern names
because the two sides are not both extern calls -- the REPL's are, and
stdout's compose a conversion with a write. The slot allocator differs
too: the REPL builds a thunk's frame, println takes slots from the
enclosing function being checked, once per call site.

Two runtime shims, both only reachable from the walk. flan_u64_to_bytes,
because routing u64 through the signed printer makes 0xFFFF...F read as
-1, which is the one way println could disagree with the REPL about a
value both can hold. flan_escape_bytes, so a string nested in a printed
structure is quoted and escaped -- same table as flan_dev_emit_str, noted
in both, because the REPL and println must not disagree about what a
struct looks like.

A string at top level prints raw and nested prints quoted. Not a conflict:
(println "hello") has to print hello, and a struct's string field has to
be distinguishable from the punctuation around it. The split is top-level
vs nested, so it lives in check.ml and not in the walk.

Found on the way: a field of an Option had no gep in emit.ml, so the
walk's Option arm had never run -- the REPL would have failed on one too.
Option is { i8, T } with no declared name, so its layout is now spelled
out. Nothing in the surface language reaches a field of an Option; the
printer does, to read the tag without unwrapping a None.

The print-* functions stay. They print without a newline, which println
cannot express -- slices.flan's show prints elements separated by spaces
-- and they are raw where print is structural.

println.flan covers every arm at -O0 and -O2: the u64, the raw/quoted
split, both Option arms, the depth and span caps, and the slice arm's
loop twice over plus once inside a dotimes, which is where per-call-site
slot allocation would show if it were per-iteration.
2026-09-12 04:55:42 +07:00
5ea0bcae84 Remove nth, the alias that was not one
nth and at were documented as the same operation, and as reads they were:
check.ml matched "at" | "nth" in one arm. But a place is recovered in two
other spots -- parse.ml for (set ...) and place_of_expr for (addr ...) --
and both match only Sym "at". So (set (nth a i) x) and (addr (nth a i))
were refused while the at forms worked.

Two names said to be identical that disagree about writing is worse than
one name, and the asymmetry is not worth fixing in three places to keep a
synonym. at is the indexing operation; nth is gone.

The six call sites were all reads, so they rewrite directly. get/put stay
the Map pair: get returns (Option V) and is deliberately not a place.

nth-gone.flan pins the removal -- it has to fail as a name nobody defined,
not quietly resolve to at again.

destructure~nth is compiler-generated and unrelated.
2026-09-12 04:46:41 +07:00
13e8002d39 Merge branch 'cider-inspect' into dev-loop 2026-09-12 04:16:14 +07:00
d336da65e5 Merge branch 'disasm-overlay' into dev-loop 2026-09-12 04:10:54 +07:00
940d70b409 Two ways the disassembly said more than it knew
A stopped program was said not to have installed what was delivered. The
commonest way to stop is to install a body and have it error, so that
asserted non-installation in precisely the case where the body is running;
the daemon cannot read a cell back either way, and now says that. What is
certain is only that nothing further installs until it resumes.

And the source location came from the session rather than from the build it
was showing. Session.eval replaces the checked program the moment a form
checks — before the build, before delivery — so an evaluation that checked
and then failed to build left a reply showing the host's code, saying
nothing had been delivered, and pointing at a buffer whose code never
landed. A daemon whose llc is [false] is the whole test.
2026-09-12 04:10:07 +07:00
94b78a1a83 A value you can walk into, because the walk has a bound
C-x C-e renders once and stops at depth 4 and span 8. A field past either
comes back as "..." and nothing recovers it from the echo area. Re-rooting
the walk at that field renders it from depth 0, so the bound moves with you
— that, and not tidiness, is why an inspector is worth having beside the
expression evaluator.

CIDER keeps its inspector stack on the server because a JVM value can be
retained. Nothing here can: a Flan value has no header and the thunk that
rendered it is dlclosed the moment it returns. So the stack is a stack of
expressions on this side, and going into a field means sending a different
one — (.pos b) where the last one was b. It costs a re-evaluation per step,
which buys a view that is never stale and is why refresh is a key someone
presses rather than a timer.

Driven from fixtures, which is also the only way the cases a live program
will not hold still for get tested at all.
2026-09-12 04:08:13 +07:00
ee5abd40fc Merge branch 'strings-odin' into dev-loop
# Conflicts:
#	test/test_acceptance.ml
2026-09-12 04:03:35 +07:00
71bc492cad Merge branch 'dwarf-debug' into dev-loop
# Conflicts:
#	test/test_acceptance.ml
2026-09-12 04:03:06 +07:00
91185beb7b Also pin the two things SBCL's presentation was worth
A listing rebased to the function and branch targets that read as labels
are the whole difference between something you can follow and a dump; both
looked fine by eye and neither was checked by anything.
2026-09-12 04:01:40 +07:00
ecf3882fa2 Refusing to encode is a claim about the buffer, and nothing was checking it
encode-rune! says nothing is written when it answers None, and every None case
in the table passed that claim without testing it: an encoder that lays the
lead byte down and only then notices the buffer is short returns None exactly
as a correct one does. So a known byte goes into scratch, a refused encoding
is asked for, and the byte is read back. Storing before the length test turns
the line from 65 -1 65 -1 65 into 65 -1 0 -1 0.

The read-only claim beside lower-ascii was reasoned from the emitted linkage
rather than observed, and observing it was worse than the guess. With
(set (at (bytes "Hi") 0) \h): at -O0 the store is emitted against the constant
and the program takes SIGSEGV; at -O2 LLVM deletes it as undefined behaviour
and the program prints "Hi" and exits 0. The same source either dies or
silently does nothing depending on a flag. The comment now says that instead
of predicting a segfault.
2026-09-12 04:01:25 +07:00
e3f352321d Run the verifier, because string needles cannot see what breaks
A call without a !dbg inside a function that has debug info is a hard LLVM
rejection, not a warning — it turns every debug build into a clang error. So
is a DISubprogram the compile unit does not reach. Neither is visible to an
assertion about the text of the module, and both are the kind of thing that
appears when this file grows a new call from somewhere other than a Tast node.

The program the cases run through is chosen for those calls specifically: a
bounds check, a condition signalled and handled, a restart transferred to, a
defer on the way out. Every one of them is a call the backend invents.

Emit.redefinition is the half that needed this. It had only ever run at the
default debug:false, and it differs from Emit.program in exactly the places
metadata goes wrong: hidden bodies, the by-name cell and global lookups, and
flan_reload_install and flan_reload_call, which are raw defines with no
subprogram that nonetheless contain calls. Both directions of `known' are
covered, because they emit almost entirely different code.
2026-09-12 04:00:24 +07:00
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
0fdb7a7cb0 Pin the two claims the disassembly op makes
Which module owns a name after a delivery, and the fact that a delivered
body is not thereby an installed one. Its own daemon over its own program:
a generation counter checked against a session four other cases have been
driving proves nothing about the counter.
2026-09-12 03:58:15 +07:00
4957497d02 A mutation run found the four-byte overlong nobody was testing
Relaxing 0xf0's second-byte floor from 0x90 to 0x80 left the whole suite
green: every other row of the table had a case pinning it and that one did
not, so f0 80 80 af decoded happily as "/". The same smuggled slash the
two- and three-byte cases exist to catch, missed in the fourth width.

Seven mutations verified red after this: the lead-byte floor at 0xc2 and the
second-byte bounds on 0xe0, 0xed, 0xf0 and 0xf4, the truncated-sequence
width, and the split cursor dropping its trailing empty field. An eighth,
lower-ascii written as a bit-xor, is red on the bytes either side of the
letters — which is why those are in the table and the letters alone are not.
2026-09-12 03:56:40 +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
273dd18eb0 The valid decodes prove almost nothing; the malformed ones are the test
A decoder that only masks and shifts gets every well-formed character right,
so a corpus of real text passes it. What separates it from a correct one is
the second group here: an overlong two- and three-byte "/", a surrogate, a
code point past U+10FFFF, a lead byte that leads nothing, a lone continuation
byte, and a character truncated by the end of its slice. Each isolates one row
of the accept_sizes table, and each must answer width 1 so a scan advances.

The invalid sequences are byte arrays because no valid string contains them
and the reader has no \xNN escape to spell them with.

Encoding is checked by round trip. An encoder and a decoder wrong in the same
direction agree with each other, and expected bytes would not catch that.

The emoji line caught a use-after-return while this was being written: a
(defn whole [a [4 u8]] [u8] (slice a 0 4)) helper returns a slice into the
copy a [n T] parameter makes in the callee's frame. The compiler accepts it in
silence. The comment stays where the helper was.
2026-09-12 03:53:18 +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
67aa82457d Check the offsets against LLVM, not against the same hand that wrote them
A wrong DWARF member offset does not crash anything. It prints a plausible
value for the wrong field, which is the failure this project has met over and
over at the FFI boundary, and it is the only way the debug info can be wrong
without saying so.

A table of expected offsets written in this test would be wrong in exactly the
ways the code is wrong, so it checks against LLVM instead: ptrtoint of a
getelementptr through a null pointer, over the struct type text lifted out of
the emitted module, folded by llc into a .quad and read back. That is the same
idiom Emit already uses for the size it hands flan_dev_global — it is just not
expressible inside metadata, where offset: must be an integer literal.

Then the same struct again with its fields permuted, and an assertion that the
two disagree. A check that cannot come out differently is not checking
anything: an offset table that ignored declaration order would satisfy either
ordering alone.

It fails when it should. Making a slice 4-byte aligned moves Cell.name from 24
to 20; the test says so by name, and lldb — which is the point — prints
len = 21474836480 for a five-character string.

The lldb cases are the only ones that say a person can debug a Flan program
rather than that the metadata is self-consistent: a breakpoint on a Flan
function by name, a backtrace naming .flan files and lines, and locals with
their own types and values. Skipped where there is no lldb, since it is not a
build dependency.

The --dev case is there because "the stack goes missing under --dev" is the
sort of thing found late. It does not: a cell changes how the callee is found,
not how the frame is laid out.
2026-09-12 03:46:23 +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
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
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
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
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
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
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
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
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