205 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
e0947adb2a An empty needle matched everything, so half the quote checks were decoration
`case $x in *""*)` is always true, so every check whose text came from a grep
went green the moment the line it greps for was renamed — which is exactly the
case those checks exist for, and they guard files other lanes are editing.
Verified by pointing one grep at a string that is not there: ok before, FAIL
after. sqrt-f32 was overstated in the same spirit; it is a declare, not Flan.
2026-09-12 03:58:42 +07:00
5f5cc8bee9 lldb already speaks DAP; Emacs only needs to be told how to build
No DAP implementation here, and there should not be one. `flan build --debug'
puts DWARF in the executable, lldb reads it, lldb-dap speaks the protocol — so
what was actually missing was a dape-configs entry that knows to build a .flan
file first and where the binary lands.

The build goes through dape's own `compile' key rather than a shell-out, so a
rejected program lands in a compilation buffer and next-error walks it. Flan's
diagnostics are already file:line:col.

`flan-debug' goes through `dape--config-eval' and not `alist-get'. `dape'
takes a config whose forms are already evaluated — that is what M-x dape does
after reading one — and handing it the stored entry would pass the list
(flan-dape--binary (flan-dape--source)) to lldb as a program name. Driven
headlessly to prove it: a breakpoint set by line in the .flan buffer, hit,
reported as flan.tick at debug.flan:19 with c and n in scope.

The keybinding is registered from here rather than in flan-mode.el, so this
file is the only thing anyone has to load to get it and flan-mode keeps
working for someone who never installs dape.

The two frictions are written down at the bottom of flan-dape.el from lldb
transcripts, not from reasoning about what ought to happen, because the guess
I started from was wrong. Across a reload a breakpoint set by *name* gains a
second location and both stay live — the old body is still mapped and still
what old call sites reach. One set by *file and line* stops firing, and not
because dape pinned it to an address: the redefinition module has no line
table to resolve against. Given one, lldb does re-resolve on dlopen.

Which names the gap: Emit.redefinition takes ~debug and Session.eval does not
pass it, so `flan reload' and the `flan dev' daemon build modules without
DWARF. lib/session.ml is the dev loop's file, not this lane's.

test-flan-dape.el is not in dune test. It wants Emacs, dape, lldb-dap and a
built flan at once, and wiring four optional things into the acceptance table
would make that table's failures mean less, not more.
2026-09-12 03:58:26 +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
c1a612abfb The rejection goes away when you do anything else
An overlay that lasted until the next accepted evaluation was a durable
annotation on the source, which is not what it is: it is feedback about the
action that just failed, and the moment you move, type or evaluate it is
describing a program state nobody is in any more.

pre-command-hook rather than post-command-hook, which fires at the end of
the failing command and would take the overlay down before redisplay ever
drew it. Buffer-local and installed only while an overlay exists, so a
session of twenty buffers does not end up running this on every keystroke
in all of them.
2026-09-12 03:56:41 +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
8eb6de15c5 Ask from the editor, and put the caveat above the code
C-c C-a on a name, C-u for the IR. The header is SBCL's habit of saying
which function and out of what before a line of code, with one line it does
not have: what the answer claims. A reader looking at a listing will assume
it is what the program is running, and for a delivered-but-not-yet-installed
body that is exactly the thing that is not known — so the daemon's own
account of it sits above the first instruction rather than nowhere.
2026-09-12 03:55:51 +07:00
71877a5baa Merge branch 'worktree-agent-a065a2101ee7d8007' into dev-loop 2026-09-12 03:55:50 +07:00
bbda5e4cd7 Point the reader at the two scripts, so the page's claim about itself is testable
"Every program below was run" is the kind of assurance nobody can act on. Naming
check.sh and quotes.sh turns it into something a reader can re-run, and says
plainly that a disagreement makes one of them go red.
2026-09-12 03:55:06 +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
c688992ac9 Show the code a name last compiled to, and say what that claims
An editor could see the IR of a whole file and nothing at all of what the
running process is executing. The daemon built every module it sent, so
objdump on the right object is the disassembly and the retained .ll is the
IR; the only hard part is which module owns a name after N reloads, and a
table filled on accepted delivery answers it.

What it deliberately does not claim is that the code shown is installed.
The agent takes a module path and answers ok when it has queued one; there
is no verb that reads a cell back, so :basis spells out which of the three
things is true — the host's body, still certain because nothing was ever
delivered; queued and awaiting a frame boundary; or queued while the
program is stopped and therefore certainly not installed yet.

From SBCL: offsets from the function's start rather than addresses into a
file, and L0.. labels on branch targets. Not source interleaving, which
needs line tables this build does not emit, so the reply says so.
2026-09-12 03:54:22 +07:00
4d1a0c7807 Build sand for wasm32 and compare the hash, rather than repeat the number
It is the project's headline cross-target claim and the page was asserting it
second-hand. Both targets print 2256461126764447066 on this machine, so the
transcript is now what the page shows.
2026-09-12 03:53:38 +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
23601f382d Say that break and continue do not exist, since a loop section implies them
plan.org settled the loop story as "while/for with break/continue and return",
so a reader will reach for them; they are not implemented and, unlike the rest,
not refused by name either — they come back as unknown function.
2026-09-12 03:51:53 +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
6c34d4a66e Quote the compiler's own words for the index rule, and pin the Emacs keys
The paraphrase of why a wide index is refused was shorter and said less than
the message; and the keybinding table came from NEXT.md, which is two keys
behind flan-mode.el, so it now reads the keymap instead.
2026-09-12 03:50:48 +07:00
bd5892eccd Keep the text each body was built from
A module's .ll is deleted by the build and the host's lives in a working
directory named after the process rather than the module, so ten reloads
in there is nothing left on disk that says what a given function was
compiled from. The daemon owns the build and is the only thing that could
have kept it, so it keeps it: one .ll beside each .so, and a table from
function name to the last module that carried a body for it.
2026-09-12 03:50:44 +07:00
2acb70b8db Decoding is the only part of a string library that needs no allocator
Odin's core/strings and all of core/fmt take an allocator; core/unicode/utf8
does not, because decoding is classification and every answer is a number.
That line is where the port stops, and the refusals at the foot of the file
say so by name rather than leaving a caller to find out.

The accept_sizes table becomes a cond over the lead byte. Its four awkward
rows are the ones a hand-written decoder gets wrong one at a time, so they are
written out: 0xc0/0xc1 lead nothing, 0xe0 and 0xf0 have a raised second-byte
floor against overlongs, 0xed has a lowered ceiling against the surrogates.

Two divergences from Odin, both the parse-i64 argument again. A malformed
sequence carries ok:false instead of decoding to U+FFFD, which is a real code
point a caller cannot tell from a failure; and encode-rune! answers None
rather than silently substituting U+FFFD for a rune it was not given. Width
stays 1 on a bad byte, which is Odin's rule and load-bearing: every loop here
advances by it, and a 0 would hang rather than answer wrong.

split cannot return a sequence it would have to own, so the cursor is what
survives. It follows the allocating strings.split rather than Odin's own
iterator, which drops a trailing empty field and disagrees with it.

Case conversion is byte-wise and not in place: a literal is emitted into
read-only memory, so lowering (bytes "Hi") would type check and segfault.
2026-09-12 03:49:52 +07:00
fc47489802 Show the bounds check failing, because "checked" without a message says little
The claim worth making is not that there is a check but that a failure names
the line, and the only way to show that is to trip one.
2026-09-12 03:49:41 +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
1d206518cf Colour the primitive type names too, since only the capitalised ones showed
The rule was "capitalised is a type", which leaves i32 and string looking like
ordinary names in the one position — a signature — where the reader is there
to see the types.
2026-09-12 03:48:30 +07:00
a5e0c01224 Check the quoted blocks too, since a paraphrase reads exactly like a quotation
The blocks that are not programs were the ones that had drifted: the usage text
had lost its indentation and the refusal table had trimmed "(see plan.org)" off
every message, so the page was showing wording the compiler does not print.
2026-09-12 03:47:09 +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
0b8564828b The temporary and the reference to it come back together, so they cannot drift 2026-09-12 03:44:16 +07:00
86ef557433 Run the break loop rather than quote it, since the restart order is a claim
NEXT.md prints the banner with the restarts in source order; the walk is
innermost-first, so it is the other way round. A --dev build under timeout is
enough to settle that, and settles the two place and global snippets with it.
2026-09-12 03:43:24 +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
dfd64d89ea The examples are files that run, not prose, so the page cannot drift from them
Copying a snippet into HTML is where a documented language stops being the
real one. Each block on the page is a program here with its recorded output
beside it, and check.sh is what says the page is still true after a change.
2026-09-12 03:40:03 +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
2ecfac7561 A page to point someone at, so the language is readable before it is installed
Everything here is checked against the compiler rather than against plan.org:
the design documents describe a language larger than the one that runs, and a
page that documented the plan would mislead the first person to try it.
2026-09-12 03:38:55 +07:00
ba2f5bc9bb Debugging is its own axis, not a mode of --dev or of -O0
--debug is a third flag beside --dev and the optimisation level because it
answers a third question. --dev is "can I redefine this while it runs";
--debug is "can I stop it and read it". Either is useful without the other,
and a REPL session that is not being stepped should not pay for DWARF.

Not implied by -O0 in particular, for a reason already written down in this
file: the acceptance table runs the same programs at -O0 and -O2 to compare
the emitted IR against what mem2reg makes of it. If -O0 pulled in debug info,
every one of those comparisons would be against a different module.

It does imply -O0 downwards, and sets it. The whole mechanism is an
llvm.dbg.declare hanging off an alloca, and mem2reg deletes the alloca.

Refused for wasm32 by name. The member offsets in the DWARF are computed for
the host — ptr is 8 bytes — and wasm32's pointer is 4, so a slice's len sits
at byte 8 there and byte 16 here. Emitting the host numbers would hand a
debugger a confident wrong answer for every slice and every struct holding
one, which is the exact failure this project keeps meeting at the FFI
boundary. Silence would be worse than the refusal.

-g reaches the C compiles too, and joins compile_c's digest key with it, or
an object built without it would be served to a build that asked for it.
2026-09-12 03:38:53 +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
3b8a0cb553 The positions were always there; write them out
Every Tast node carries a Loc and nothing ever used one outside an error
message, so a Flan program under a debugger was a wall of addresses. This
emits DWARF for them.

The reason it is a few hundred lines and not a few thousand is the layout.
A Flan struct is its C struct, every slot is an alloca and there are no tag
words, so there is nothing to describe *about Flan* — DW_LANG_C99 and the
machine types are the honest answer, and lldb's own C support is then exactly
right for a Flan value.

Two things are load-bearing and neither is obvious:

Debug Info Version in llvm.module.flags. Without it LLVM drops every scrap of
debug metadata with no diagnostic at all, so the build succeeds and the
debugger shows nothing and there is no thread to pull.

A !dbg on every instruction, not only the ones that want a line. The verifier
rejects a call without a location inside a function that has debug info, and
this file emits calls from a dozen places — the bounds failure, the handler
push and pop, the transfer guards — none of which would have remembered to
ask. So the location lives on the per-function state and `ins` appends it.

The member offsets are computed here rather than handed to LLVM, which is the
one place in this backend that happens and so the one place a layout bug can
hide. !DIDerivedType takes offset: as an integer literal; the ptrtoint-of-gep
form this file uses elsewhere for a size is not accepted in metadata. The
acceptance test therefore checks each one against LLVM's own getelementptr
answer for the same struct type, not against a table written by the same hand.

Local names are the gap. The typed IR refers to slots by index and records no
names — Check has them and drops them — so a parameter gets its source name,
recovered by the driver from declarations already in hand, and everything else
gets s<index>, which is the slot it actually is. Closing that means Tast
carrying the name.
2026-09-12 03:38:42 +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