105 Commits

Author SHA1 Message Date
008165335d break crosses only sometimes, so the refusal is relative now
return is refused inside handler-bind and restart-case blanketly, and rightly:
a return always crosses the frames they pushed. A break does not. A loop
written wholly inside a restart-case body has a perfectly good local break, so
the rule is a barrier on the loop stack rather than a flag — a jump is refused
exactly when a barrier stands between it and the loop it names, and the message
says which construct. handler-bind and restart-case bodies are barriers, so is
a restart clause, so are a defer's forms; a handler clause is lifted into its
own function and needs no rule at all. in_frames is untouched: a return is the
special case where the target is always outside every barrier.

continue wanted the other blocker. check_dotimes folded its step onto the end
of the body, which a continue would jump past, so the counter would never
advance and the loop would hang. Tast.While carries a latch now — condition,
body, latch — the step goes there, and emit_while emits four blocks. A while's
latch is empty and folds away.

Labels are Odin's, in the head position: (while :outer c ...) and (break
:outer). A keyword there is unambiguous because a loop condition is never one,
so one label function serves while, until, dotimes, break and continue. It is
not a goto — the checker resolves a label against the loops the form is
lexically inside, so control can only leave a loop it is already in.

Break and Continue carry a relative depth rather than a name, because that is
what a backend already has: emit keeps one entry per While the way it keeps
one pad per frame, and indexes it.

Nothing in the prelude wants either. Every early exit there is a return from
the function, which break cannot replace; the sentinel-flag loop break exists
to remove does not appear in it. The two the compiler emits are that shape and
are the one place it cannot help — their sentinel is set inside a restart-case.

reach.ml and render.ml take the While arity change and nothing else.
2026-09-12 21:58:53 +07:00
9f4a59c56f A let binding has no type slot, so (array 4 T) is how you say it
[4 T] is the type syntax and is unchanged; it already works in a defvar, a
parameter, a field and a return. A let binding is the one position with no
type slot, and there the brackets are an array literal of two elements whose
second is a type name — which came back as "unknown name rl/Vector2" and cost
32 hand-written Vector2s in one raylib example.

(array COUNT TYPE) is a parser form rather than a builtin call, because the
second argument is a type and the parser's callers have none. Parse assembles
the Tarray itself, so the count takes a constant's name for free and a value
in the type position is refused by the type reader's own message. The checker
resolves it to Tast.Zero — no new backend node and no new type.

(zeroed [4 T]) was proposed first and rejected: the parser can tell, a person
cannot. zeroed keeps its job of being inferred; array is the one that is told.
2026-09-12 21:48:08 +07:00
49bb9b9c42 Macros expand, and unless is a prelude defmacro 2026-09-12 21:11:40 +07:00
545ef6e0ea A package may not declare a macro, and says so
The expander collects defmacros from the prelude and from the file being
compiled. Not from an imported package, and the reason is an ordering one:
Load learns a package's imports by parsing it, so reaching a package's macros
would mean resolving that package's own imports over Forms, before Load runs.
That is a second import resolver, and it is a bigger thing than this lane.

Refused by name, which is the rule that caught the two misparse bugs. Left
alone the call arrives at the checker as an unknown name -- true, and no help.
Refused where the defmacro is written rather than where it is called, because
that is where the fix goes.

The check has to sit in Load's read, because that is the only place that can
see one: by the time Parse is finished a defmacro is an ordinary Ast.Defn and
the word is gone.

Measured while here, since a prelude that grows a defmacro is a cost every
program pays or does not:

  - A build of a program that names no macro: 50ms, the same as before. The
    pass scans the top level, finds nothing, and no compiler runs.
  - A program that calls one: 310ms the first time, 70ms after. The 240ms is
    the clang driver building the macro module; it is cached under the object
    cache, keyed by the prelude's source and the file's defmacros, so it is
    paid once per change rather than once per build.
  - A hello-world's binary carries exactly one symbol out of all of this:
    flan.gensym-n, eight bytes. Reach.link drops unless, form-cons, form-nil,
    form-append, form-rest and gensym, because nothing reachable calls them.
2026-09-12 21:02:33 +07:00
f3a0e435fd unless is not a special form any more
plan.org milestone 5 says when, unless, until, cond and dotimes are special
forms only until macros land. This is the first one to stop being one, and
running test/programs/macro-unless.flan means the compiler built a shared
object, dlopened it into itself and called a Flan function to find out what
(unless c a b) means.

unless is the one that moved because it is the one nothing else needs: zero
uses in the prelude, so moving it cannot make the prelude depend on the
expander that compiles it. Its coverage is sand.flan, seven calls, compiled
through Session in test_session -- which is the in-process path and the reason
lib/dune now passes -linkall. Say plainly what that coverage is not: nothing
in test/programs used unless before today, so macro-unless.flan is a test
written after the feature. The corpus that was written before it is sand.flan
and web/examples/control.flan, and both compile unchanged.

lib/macro.ml is the half of expansion that has to compile something. Expand is
the image format and the quasiquote desugaring and depends on nothing above
Form; this needs Check, Build and Emit, so it sits above the parser it feeds
and arrives through Parse.expander.

What it does, in order:

- Collects every defmacro from the prelude and from the file. Not from an
  imported package: Load learns a package's imports by parsing it, so
  collecting from one means a second import resolver over Forms, and that is a
  bigger thing than this.

- Builds them in rounds, because a macro's body may call a macro and a body
  with an unexpanded call in it will not compile at all -- the call is a name
  nothing defines. Round 0 takes every macro that names no macro still
  waiting; round 1 expands the rest against round 0's module. A round that
  takes nothing while macros remain is a ring and is named. macros.flan has
  the round-1 case and macro-cycle.flan has the ring, and the distinction
  between them is the one thing here that is easy to get wrong: a call inside
  a quasiquote is *not* a compile-order dependency. It is part of what the
  macro answers, and the answer is expanded again after it returns. The first
  macro-cycle.flan written for this commit quasiquoted, and it was not a cycle
  at all -- it hit the fuel instead, correctly.

- Walks bottom up, so a macro never sees a call to another macro in what it is
  handed, and re-expands what comes back, so a macro that expands into a call
  to itself keeps going. That loop is bounded at 200 and says which macro ran
  out: macro-spin.flan.

- Skips all of it when the file names no macro, which is nearly every file.
  Otherwise every build in the suite would pay a clang driver to answer a
  question nobody asked. When it does build, the module is cached under the
  object cache and keyed by the prelude's source plus the file's defmacros, so
  a second process pays a dlopen.

lib/dune passes -linkall, which is the one line in another lane's file. The
module installs itself into Parse.expander at initialisation and nothing
references it, so without -linkall the linker drops it from every executable
that does not name the module -- bin/main.exe among them -- and a program
calling a macro fails with an unknown name. The alternative was an install
call at every entry point, including ones in files this lane must not touch.

The one thing a macro cannot do that parse.ml could is give a reason. A macro
runs inside the compiler and anything it signals aborts the compile with no
location, so a malformed (unless) answers a name nothing defines and the
report is "unknown name unless-takes-a-test-and-a-body" at the call site --
right place, wrong sentence. NEXT.md says so.

test_flan.ml's "unless -> if(not)" assertion is gone, because it asserted a
desugaring in a file that no longer does one. Nothing else in the suite
changed.
2026-09-12 20:59:12 +07:00
404c810958 Which frame the inspector answered from, asserted rather than reasoned about
The `inspect' verb had no coverage. The discriminating case is not a path
step, it is the frame: dev-inspect.flan gives `mark' to a global holding 99
and to a local of the OUTER frame holding a Point, so evaluating the name and
rooting at the frame answer differently and not even with the same type. One
`eval-expr' and one `inspect' of that name is the bug and the fix in a pair.

The slot index comes off the locals listing's fourth element rather than being
written as a literal, which exercises the field the editor depends on and
keeps the test from passing for the wrong reason if slot allocation shifts.

The rest is what a path can and cannot do: a struct field, an array element,
an option's payload and a union case's field — the last two having offsets but
no accessor form in the language — and four refusals, each checked for naming
the step and saying why. A `:path' of `nil' is read as the slot itself,
because Emacs has no other spelling for an empty list.

Two claims about the frame, since `stopped_frame' being shared is an assertion
about code rather than about behaviour until something proves it: the frame
whose body was redefined under it is refused, and so is the whole stack once
the program resumes.
2026-09-12 20:34:32 +07:00
1ee54d6b56 A union is a type name, and (vec-new) did not think so
The prelude's own (vec-new Form) was refused with "nothing here says what
(vec-new) is a Vec of" -- a message about a missing annotation, to a program
that had written one. The build went red the moment the Form declaration was
checked against anything, which is why the front half landed unmeasured.

The test a leading bare symbol has to pass was spelled out twice, once in
vec_new_elem and once in map_new_types, and both lists were written before
unions existed: primitives, structs, enums, aliases. resolve_name has known
about unions since they landed, so the two halves disagreed about what a type
name is. Now there is one list, read by both, so the next kind of type cannot
be added to one of them.

The case is in unions.flan rather than in a file of its own, because what it
asserts is that a union is an element type like any other -- (vec-new Shape),
(map-new string Shape) -- and that is a sentence about unions.

Also drops forms.so, a build artefact the last lane committed.
2026-09-12 20:27:19 +07:00
66c29dfa5f A union is a tag and room for the largest case 2026-09-12 17:04:18 +07:00
cd34c3fea9 A union recurses through a pointer, and not by value
check_finite already walked a union's cases, so a union containing itself by
value was refused before the emitter could try to lay it out -- which it would
have done forever, since payload_lay calls lay calls payload_lay. Asserted
both ways round: directly, and two unions through each other.

Through a pointer it works, and that is the shape a Form has, so it is in the
program rather than only in the prose: a Tree with a (Ptr Tree) field, matched
through a deref, summed recursively.

BUILT.md also records why match's fall-through is still unreachable rather
than a trap. It is only sound because no reachable program can hold a tag no
case names: Zero is tag 0, every construction writes a tag the checker
resolved, and uninit -- the one way to get bytes nobody wrote -- is refused on
a union for exactly this reason. The refusal is what pays for the unreachable.
2026-09-12 17:02:59 +07:00
03f7609201 The structural printer reads only the case in hand
print, the REPL inspector and the break buffer's locals all walk a concrete
type through render.ml, and a union fell through its Named arm to <Shape>.
It now recovers the case from the tag by a chain of comparisons -- the same
shape the enum arm already had, and for the same reason: the name is erased
before any backend sees it -- and reads the fields of that case only. Reading
the others would be reading a payload that is not there.

It prints (Shape.Dot {.x 1.5 .y -2.5}), which is what the source would write.

The union table has to reach the walk, so Render.ctx grew a field and its
three construction sites in session.ml and one in check.ml pass it. That is
the whole of the session.ml change.

test/programs/unions.flan is the program: a case with no fields, a case wider
than another, a case holding a string, a union in a struct, a union through a
call in both directions, ZII, reassignment, and printing. Its layout was
checked against clang's for the same declaration -- 32 bytes aligned 8 with
the payload at offset 8, and 40/8 for the struct holding it.
2026-09-12 16:53:26 +07:00
c604911ecb A ring of imports is refused by name, not swallowed
Loading a package kept one table, keyed by real path, and used it for two
different questions. Already loaded meant "skip", which is right for the second
route of a diamond and wrong for a ring: a package that imported itself round a
chain met its own entry, contributed nothing, and appeared to work. The comment
said so and called it a feature.

It is not one. A ring has no package order, and a definite package order is what
the macro expander needs — every defmacro has to be compiled before anything
that calls it. So the chain currently being read is now carried separately from
the set already finished. A directory found in the first is a cycle and is
refused; a directory found only in the second is still the diamond's second
route and still a no-op.

The refusal names the ring — a -> b -> c -> a — and only the ring, not the route
that led to it. "There is a cycle" leaves the reader to find which three imports
it was.

pkgs now comes back dependencies-first, which is the topological order the
acyclic rule buys. The declaration list is left alone: check.ml collects every
top-level name before it checks any body, so declarations are order-independent
by construction and sorting them would be churn in the field every test reads.

The tests are a real tree rather than a second copy of pkg-shared. pkg-diamond
builds a shape/Box inside area/ and hands it to a function declared inside
draw/, which only type-checks if the bottom package was read once — two copies
of one struct are two types. What proves it is the numbers, not the compile.
2026-09-12 16:46:30 +07:00
03e8a1fd1b A Map, open-addressed and Robin Hood, over the type-erased runtime 2026-09-12 16:35:01 +07:00
ea24461107 Cover a dev build and a map that leaves its let
Two gaps nothing in the suite reached.

A dev build, because the hash and equality pair emitted for a struct key
is a function nobody wrote, and the only other inhabitant of the lifted
list — a handler-bind clause — carries a parent this one cannot: the
pair is shared by every function that maps that key type, so it has no
single parent. A dev build puts every body behind an indirection cell
and is the build that would notice. It does not; maps.flan answers the
same nineteen ways at --dev as it does at -O2 and -O0.

And a map crossing a function boundary in both directions. Everything
else in the file lives and dies inside one let, so nothing would have
noticed if the 48-byte header travelled wrongly by value while every
runtime operation takes its address. Returning one and passing one are
both moves, which is the rule a Vec already follows — verified against a
Vec rather than assumed, since a refusal that fired for the wrong reason
would look the same.

has-key? is flagged in BUILT.md as what it is: an addition, not
something spec-memory.md names.
2026-09-12 16:34:25 +07:00
3325c41fb7 The globals a stopped stack touches, in a section of their own 2026-09-12 16:23:31 +07:00
635d12782d The globals a stopped stack reaches, in one section and not under a frame
A global is program state a frame happened to touch, not part of it, so
nesting it under one implies an ownership that is not there and repeats the
name once per frame that reads it. One section instead, holding the union of
the globals every frame on the stack references — the compiler does the
choosing, since Reach.expr_refs already answers a body's reference set, and
listing every global a program has would bury the one that matters under the
prelude's PRNG state.

Each entry says which frames touch it, by the index the stack section already
numbers them with, which recovers what per-frame nesting would have told you
at no cost in duplication. Ordered by the innermost frame that touches it:
a deep stack makes the union large and proximity to the error is what puts
the likely culprit on top.

Simpler than locals, because a global is reached by name rather than by
address. Emit.redefinition writes a global the host has as external, so the
thunk binds to the program's own storage and nothing is asked of the stopped
thread — no dev-slot round trip and no not-yet-bound case to refuse.

A frame that cannot be attributed contributes nothing and is named in
:skipped; the union being incomplete and the union being complete are
different answers. The hole in that is stated rather than papered over:
slot_fingerprint hashes a body's slots, which is the right cut for locals and
not for this, so a body that names different globals while binding the same
locals is not caught. The test drives the case that is.

MANUAL.md also loses a stale paragraph claiming the fingerprint check never
fires with a failing test pinned to it. It fires, and test_dev covers it.
2026-09-12 16:22:58 +07:00
e12e3e11c5 A table for the importer, against a header that does not move
Nothing in dune test exercised cimport.ml or cjson.ml. The raylib case is the
better evidence and the worse coverage: it needs raylib installed, at the
version whose .so is linked, with FLAN_RAYLIB_H set, so as the only test of
this it would skip everywhere and cover nothing.

test/headers/sample.h is one function per decision the importer makes, and the
table asserts on the reasons rather than the counts — a refusal that fires for
the wrong cause still refuses, and a count still matches. Accepted: an
aggregate in and out, const char * as a string, a pointer parameter, a second
typedef name for a record described once, a C enum against a defenum. Refused,
each by reason: a returned char *, a non-const char * C may write through, a
variadic, a callback, a long, a struct with no defstruct, and a kebab
collision. Plus that nothing is in both lists, which is the bug the collision
case found.

check_structs and diff_bound get a row each for agreeing, for a permuted field
order, for a widened field, and for a symbol the header does not have — the
last being how a package pinned to the wrong release announces itself. The
name rule and the JSON reader get their own rows.

Checked by breaking two of them on purpose and watching both fail.

test/programs/raylib-imported.flan is the end-to-end evidence, back and in the
new struct-literal spelling: four bindings the package does not bind by hand.
ColorToInt of {17,34,51,68} is 0x11223344 and ColorTint by white hands the four
bytes back separately, so field order is pinned by arithmetic and not by a
round trip, which is the trap BUILT.md records.
2026-09-12 16:09:48 +07:00
c45447a6d4 The Map's tests join the suite, and the epoch trap covers its half
maps.flan and map-exhausted.flan as fixed-output cases, the six refusals
by name, and map-stale-region.flan beside stale-region.flan.

The last one is not a line in the Vec's program because the two reach
the check by different routes. A Vec's operations check on the way in
and stop there. A map's get goes on to call a hash and an equality
function through pointers into the block, so a missing check there is
not a wrong number — it is a probe loop walking released memory. It
traps naming the site and exits 134, as the Vec's does.

{K V} resolves now, so the test that asserted it was milestone 6 is
replaced by the one that still holds: the arity, refused for the reason
Vec's arity is refused, because a near-miss would otherwise resolve to a
type variable and come back as generics.
2026-09-12 16:09:27 +07:00
1bc5161ee2 StorageExhausted holds over the Map's four allocating operations
One rule over every allocating operation, so it has to hold for map-new,
put, reserve and clone exactly as it holds for vec-new, push, reserve
and clone. put stays Unit and clone stays the container; nothing grows a
Result.

A map is the harder of the two and that is why it gets its own program.
A Vec's failing allocation leaves the Vec untouched, whereas a map's
growth allocates a whole new block, rehashes into it and only then
releases the old one — so a failure partway has to leave the map exactly
as it was or the retry re-attempts against a half-moved map. 300 entries
through several grows against a ceiling that is raised each time, then
every one of them read back: no entry lost, none doubled.
2026-09-12 16:04:59 +07:00
e0aedadd74 maps.flan, and a move refusal that names the type it is about
test/programs/maps.flan is seven claims over the Map, each one a
plausible wrong version gets wrong, with the numbers differing per
failure so a single wrong answer names its own cause: an integer key
past eight grows, a struct key whose padding must never be hashed, a
struct key holding a string, an enum key, clone's independence, upsert
not growing the length, and a map living in an arena.

The move refusal said "a Vec is move-only" whatever had been moved, so
moving a Map was reported as a fact about Vecs. It names the type now.
2026-09-12 15:59:49 +07:00
9e6c655116 A let has the function's extent, so a defer may be written in one 2026-09-12 15:18:39 +07:00
e041b2f26c A let has the function's extent, so a defer may be written in one
defer is a compile-time construct: the cleanup is copied into every exit
path of the function. That is why a loop body and a branch are refused —
a loop body's would fire once at function exit rather than once per
iteration, and a branch would have to express "maybe registered", which
a form copied into every exit path or into none cannot say.

A let is neither. It is not a frame here: its bindings are function slots
like any other and nothing is released at scope exit, so a let at the top
level of a function body has exactly the function's extent and a defer
written in it always registers. It was refused for a reason that does not
apply to it. A let nested inside such a let has the same extent and the
same permission; a let inside a while or an if has the loop's or the
arm's, and inherits the refusal.

The permission is granted again before every form of a body, never once
around the body: check withdraws it as it starts, so granting it once
would let the first defer through and refuse the second — and two
resources acquired in one let is the case this exists for. defer-let.flan
covers that one specifically, along with nesting, interleaved
registration order across the let boundary, and an early return.

The two refusals that stay now name what blocks them.
2026-09-12 14:54:52 +07:00
9a820d86cd Sweep every field label from the colon spelling to the dot
The script is in tools/ rather than thrown away, because two lanes are
writing Flan in the old spelling right now and their files need the same
pass at merge.

It works on forms, not on text: a keyword becomes a dot only where it sits
in a field-label position inside a brace, so an enum member in value
position, a map key inside an EDN string and a type-position {K V} are all
left alone. :keys keeps its colon -- it names no field.
2026-09-12 14:47:54 +07:00
a0e485f5fb A shadow stack, a backtrace, and a stopped frame's locals 2026-09-12 12:08:06 +07:00
0ff4ce56a5 The locals of a stopped frame, read where they live
The half the shadow stack was built for. A slot's entry in the frame is its
address, null until the binding that fills it has run, so "not bound yet at
this point" is a null and needs no liveness analysis. The daemon compiles a
thunk that renders the types it already knows -- Tast.fn.slots, with snames
beside them -- at the addresses the stopped program supplies, and reads the
text back the way C-x C-e does. Nothing is copied out, because a value with
no header is bytes with no meaning anywhere but in the program that holds
it.

That is render.ml's walk with its root changed, which is the pointer-rooted
thunk NEXT.md said this needed, and one new arm in the backend: a cast from
one pointer type to another, which emits nothing.

Only named slots are recorded. A recorded slot escapes and stops being
promotable, and the slots that would cost most are the ones with nothing to
show -- dotimes' bound, the temporaries min and max use, the walk's own
scratch. They are refused by name rather than shown under an invented one.
Recording every slot was built and timed and is inside the noise, so the
rule stands on what it shows.

Four refusals, each by name and with its reason: a slot nobody named, a
slot the program has not reached, a type the printer has no arm for, and
two whole frames -- an evaluation's thunk, and a frame running a body that
has been redefined since, where every slot index would be a guess.

Measured, minimum of nine runs: +61% on call-heavy code over globals
against +33% for the frames alone, 0.06% of a frame at 60fps.
2026-09-12 12:00:29 +07:00
fff4f5d985 One source, two outcomes: barf is refused on the web and says so
programs/web-files.flan is built for both targets from the same text and
neither build reads the target anywhere in parse.ml or check.ml. On the
desktop it writes the file and says so; in the browser barf signals a
FileError the program handles, naming the file and reason 4,
file-unsupported. The whole of the difference is one #ifdef in
flan_rt.c, which is where the host ABI is already implemented twice.

The web case is run under node rather than inspected. An artifact-shape
assertion would say nothing about what decision 2 actually bought —
that a program on the web is told its write did not happen instead of
quietly losing it — so the test asserts the refusal is printed and that
the desktop's success line is absent. A silent no-op would have taken
that branch, which is the outcome the decision rules out by name.

The same program embeds a file and prints it, because that is the half
needing no filesystem and no host ABI: the line is identical on both
targets and is the answer for assets a web build has to carry.
2026-09-12 11:41:24 +07:00
f88ce56073 slurp reads a whole file, barf writes one, and failure is a condition
Decisions 2 and 5. slurp allocates, which is why it waited for Vec, and
it follows spec-memory.md's rule exactly: no allocating operation
returns an error, so there is no Result here and no out-parameter. A
failure to allocate is StorageExhausted under retry; a failure to read
is FileError under retry and use-value. The two guards nest rather than
merge, because they are two different failures with two different
answerable questions — the handler that grows an arena is not the
handler that supplies another path.

The restarts are the pair Common Lisp establishes for a file-error.
use-value is a typed restart, the other thing that landed this session,
and this is the first one the compiler itself emits with a parameter.
Its parameter *is* the path slot the attempt reads, so the clause body
is empty: emit.ml's bind_params stores the invoker's argument into the
slot, the clause falls through, and the loop re-attempts against the new
path. Everything is inside that loop, so a use-value naming a different
file re-measures it and re-allocates for its size; the Vec is freed at
the top of each turn, which is why a retry does not leak.

The host ABI grows by three calls and one reason reader: flan_file_size,
flan_file_read, flan_file_write, flan_file_fail_reason. They are
POSIX-shaped and Vec-ignorant — no handle crosses the boundary and
nothing is held between calls — so a second target implements three
functions. flan_slurp_into is runtime glue on this side of the ABI
rather than a fourth call. These do touch paths, which is the widening
plan.org names as the #1 portability risk and which decision 2 took
knowingly; embed is the answer that does not touch them at all.
2026-09-12 11:38:24 +07:00
1d7f5e1c85 Assets are baked in at compile time, one file or one whole directory
Decision 1. Odin's #load and #load_directory are the model, spelled as
ordinary named calls — an s-expression language already has a head
position and does not need Odin's `#`. (embed "p") is a [u8], (embed "p"
string) is a string, and (embed-dir "d") is a [n EmbedFile] sorted by
name.

Two spellings rather than one that changes type with its context. Odin
threads a type_hint everywhere and can afford it; with structural
equality and no implicit widening, the same text meaning two types here
would be a wart. The path is a literal and resolves relative to the file
the form is written in, both of which are Odin's rules and for Odin's
reasons: the bytes must be in hand before any value exists, and a
package's assets must not depend on where flan was invoked from.

The bytes reach the program as a [Str] node typed [u8], not as a [Bytes]
prim over a string. [Bytes] is identity — emit.ml lowers String and
Slice _ to the same %slice — and wrapping the literal in a prim would
make the node non-constant, so an (embed-dir) bound with defconst could
not be an LLVM constant. Both string emitters take the bytes and ignore
the node's type, so it is the same constant either way and one a global
can hold. emit.ml's escape is byte-exact, so a PNG survives the .ll.

The directory lookup is a linear scan in the prelude over a slice of
EmbedFile. A directory embed is tens of entries out of cache-warm
.rodata, and a compile-time perfect hash would be a build-time map with
its own failure modes that nothing has asked for. Sorted because readdir
order is filesystem-dependent and an unsorted embed would make two
builds of identical sources emit different .ll.

The slice points into .rodata, so a store through it segfaults at -O0
and is deleted at -O2 — the same measured trap the prelude's ASCII-case
note describes for (bytes "Hi"). Inherited, not widened; clone into a
Vec for a mutable copy.
2026-09-12 11:36:01 +07:00
ce59f90707 An allocator, an arena, and a Vec that signals when storage runs out 2026-09-12 11:22:57 +07:00
67c9268907 Reach the two paths a new type can die on, and stop println consuming a Vec
The debug-info arm and the structural printer are each a separate path from
everything the suite was exercising: `outputs ~dev:true` goes through the cells,
not through DWARF, and no program printed a Vec or an allocator. That is
NEXT.md's landed item 2 exactly — field_addr took only Types.Named, so the
printer's Option arm had never run and would have died on the first (Option T)
pointed at it. Both arms work; both are now reached, and the DWARF row asserts
the composite's size as well as its name, because an element count that
disagreed with `lay` would print plausible values for the wrong fields.

Printing a Vec did not work: `println` checked its argument as an ordinary read,
so it moved, and every printing of a Vec would have been its last. Printing is a
borrow — the walk goes over the value and keeps nothing.

And `vec-new` with an explicitly named null allocator no longer substitutes the
heap for it. Adopting the context for a *zeroed* Vec is the documented rule;
quietly substituting for an allocator the program named is the same "released
the region / never made one" collapse free-all already traps for, except silent
and found later as a leak. The no-allocator-named case never arrives as null —
the checker passes flan_context_allocator(), which always answers one.
2026-09-12 11:20:56 +07:00
c6f276cbff Say what the two one-line refusal programs are refusing, and why 2026-09-12 11:15:58 +07:00
5aa6c16209 Ownership is not transitive yet, so refuse the three shapes that assume it is
spec-memory.md says ownership is structural: a struct containing a Vec is
itself move-only, free recurses into owning fields, and a field cannot be
freed on its own. None of that machinery exists — it is the recursive teardown
drop brings — and the move rule as written covered only the types Vec appears
in directly. Three ways past it, each of which hands out a second owner of one
buffer:

A struct field of Vec type. The struct copies its header on assignment and
nothing records a move.

A global of Vec type. The dead set is per function, so two functions each
freeing it is a double free nothing could see, and a global read does not go
through the move path at all — even the one-function case was accepted. Half a
rule is worse than none, so the type is refused where it is declared. A global
Allocator is not this and stays legal: an allocator is a copyable handle, and
it is what makes a handler that owns the arena expressible.

A Vec of a Vec. The runtime is type-erased and copies elements bytewise, so
clone would duplicate inner headers rather than copying what they own and free
would drop their buffers. Shipping the shallow answer under the deep name was
the alternative.

All three name drop as what they wait on.

Also: match arms shared one dead set, so `(match o (Some k) (free v) None
(free v))` reported the second arm as a use after the first arm's move — a
legal program refused, the same case that was already fixed for `if`. Arms are
alternatives, so each starts from the state before the match and the union
survives the join.

And a Vec reaching declare-c now says what to pass instead. It was already
refused, by the shim generator's catch-all for a type it does not know; the
reason it is refused is that handing a header that owns storage to C hands out
an owner, and that is worth saying at the declaration.
2026-09-12 11:12:49 +07:00
af8d291154 (Vec T) over a type-erased runtime, with StorageExhausted going in beside it
Two element types, one runtime, and the element type appears nowhere below
the call site: size_of and align_of are produced where the concrete type is
known, which without generics is simply the concrete call site. That is
Odin's arrangement and it is what spec-memory.md specifies. `at` and `len`
were already the names for a fixed array and a slice, so a Vec extends them
rather than adding a parallel pair — the asymmetry `nth` was removed for —
and the value form and the place form go through one helper so they cannot
drift apart.

StorageExhausted lands with step 2 rather than after it, because the
signatures depend on it: `push` and `reserve` are Unit, `clone` is the
container, and nothing grows a Result. It is built out of nodes that already
existed — a while, a restart-case and an error — so the backend learned
nothing about allocation. The restart is established at the failing
allocation, which spec-memory.md names as the exception to "restarts go at
the resync point, once", and the element a push was given is bound to a slot
before the loop so a retry re-attempts the allocation and not the expression.

Move-only is a dead set on the checker context, and it is flow-sensitive at
an `if`: both arms start from the same set and the union survives the join,
so `(if c (free v) (free v))` is legal and a one-armed free still kills the
binding. The case a dead set cannot answer is a move inside a loop — merged
once at the end of the body it counts one move, not two — so that is a rule,
refused with its reason.

Four decisions the spec did not settle:

The Vec header is six words in every build, not four in release. A layout
that changes with a build flag can disagree across the reload boundary
silently: a redefinition module is built by llc and ld against a host built
separately, and nothing makes the two agree on a struct size. The 32-byte
release layout is deferred on that.

A zeroed Vec has a null allocator, and the first operation needing storage
adopts the context allocator. Odin's behaviour. The alternative was refusing a
Vec-typed struct field until drop lands; shipping the null was a null deref on
the first push.

A Vec's length and index are i32, like every other length here. Widening
indices is one change across all the containers, not a Vec question.

`let` has no type annotation, so a local Vec has nowhere to say what it holds
and the element type is written at the call: `(vec-new i32)`. This is not the
explicit instantiation syntax the generics section rules out — nothing here is
generic and the name resolves as an ordinary type. Where the context says, it
may be left out.

The allocator grew a budget: a ceiling on live bytes, 0 for none. The retry
restart is only answerable by a handler that can make the *same* request
succeed, and for a fixed backing store the handler that works is the one that
raises the ceiling — releasing the region a container lives in invalidates
the container, which is what the epoch check catches. The spec's "grows the
arena and then invokes retry" needed something to grow.

The generation word is bumped on every reallocation and read by nothing. The
stale-slice trap it is for needs a slice that can carry the Vec's identity,
and a slice is ptr+len. Said plainly rather than implied by the word's
presence.
2026-09-12 11:07:57 +07:00
4a7eaaa425 The six blind spots a mutation pass found, each watched fail before it passed 2026-09-12 10:58:52 +07:00
74c6489020 Allocator is a builtin opaque type, so the arena needs nothing from milestone 5
spec-memory.md defines an allocator as a procedure plus an opaque data
pointer, which reads as a function value, which check.ml refuses four ways.
None of the four is anywhere near this: `Allocator` is a `Types.t` case with
no user-writable constructor, the way `string` is a builtin ptr+len, its
procedure is a C symbol the emitter names, and every operation is an ordinary
named call that `check_call` already routes through `named_call`. The one
thing that really does need milestone 5 is a *user-written* allocator — it
wants a defn's name in value position — and that is refused by name with that
reason rather than left to come back as an unknown function.

An `Allocator` value is a pointer to the runtime's struct and never a copy of
one. That is forced, not chosen: the capability set has to be readable from
wherever a container landed, and `free-all` bumps an epoch every container
made from the allocator has to observe. A copy would give each its own epoch
and the dev trap would never fire.

Two decisions the spec left to be made here, both announced in BUILT.md:

`free-all` is retain-capacity — offset = 0, the pages stay — and handing the
pages back is `arena-destroy`, a separate operation. Zig's reset takes a mode;
Odin's arena_free_all is already retain-capacity in effect. Taking the mode
would have grown the operation table the spec froze at four. The epoch is
bumped either way, because the pages being the same does not make a container
made before the reset valid.

`context/allocator` and `context/temp` are dynamic variables with save and
restore, not extra parameters. The spec calls the allocator part of the
calling convention; the literal reading touches every signature, the FFI shim,
the dev trampolines and the reload ABI for the same observable behaviour.

`with-allocator` is its own IR node rather than a let and two calls, because
the restore has to happen on the transfer path too. A body that errors leaves
through the landing pad, and a context allocator left pointing into a region
nobody outside the body has heard of would be wrong in the break loop, which
is exactly where something is about to allocate to render a condition. The
acceptance program asserts that path by taking a restart out of a body.

The backend grew one prim, `Rt of string`: a call into the runtime's C named
by symbol, with argument and result types read off the expression nodes. The
container runtime is type-erased and therefore *is* a list of C entry points,
so one arm covers all of them rather than one arm each.
2026-09-12 10:55:18 +07:00
e2bafec373 Four runtime defects, and the two buffers that now have evidence 2026-09-12 10:55:15 +07:00
aa0b799bb6 Retyping a global across a reload, which nothing had ever done
flan_dev_global hands back the allocation it made the first time a name
was asked for, and compares the size it recorded against the size it is
asked for. Nothing exercised the comparison: v5 is v4 with extra as an
i32, loaded on top of v3, and what it does is abort the process — so it
gets a host run of its own. The message is asserted alongside the exit
status, because a process that died for some other reason is not this
guard firing and the status alone cannot tell them apart.
2026-09-12 10:51:14 +07:00
7c1fcbff19 A name finds one frame; it does not search for one that fits
§4 meets §3, and the answer a reader will assume is the other one. An
inner (use-value [s string] ...) shadows an outer (use-value [v i32] ...),
so an i32 is refused there and the outer clause that would have taken it
is never consulted. Searching outward for a frame whose signature fits
would make which restart runs depend on the arguments, which is overload
resolution on a dynamic stack.

Also: neither of the new guards is a bounds check, so --no-bounds-checks
does not remove them. A wrong index is a wrong answer; a transfer into a
clause whose parameters were written to a different layout is not.
2026-09-12 10:51:00 +07:00
e22a8dba82 Two of the four buffers with no evidence now have some
The 4K result cap and condition_name[128] are on the agent's socket path, which
is why the sanitizer corpus cannot reach them: a program in the sweep has no
socket and nobody on the other end of it. test_agent has both.

A 5000-byte string literal evaluated into the running program comes back as
exactly 4096 bytes ending in the ellipsis result_end puts there to say it
clamped — and it comes back through the seqlock's copy, so the cap and the new
reader are pinned by the same case. The header also shows the generation as 1,
which is the count of complete values rather than the raw counter.

A condition class of 198 characters comes back from `status` as 127 and a
terminator. Aborting out of that break is what pins the exit status at 134 now
that the loop leaves with _exit rather than exit.

SNAP_MAX, SNAP_NAMES and the dev registry's overflow guard are still read
rather than tested. Sixty-five nested restart-cases and four thousand interned
names are a lot of program to write for a clamp each, and neither is on a path
this session changed.

flan_dev_result_cap() exists so the size is asked for rather than written down
in two files: "the copy is never truncated" is only true while the agent's
buffer and the runtime's bound agree, and the agent checks that where the copy
happens.

The pipe the queue program blocks on is close-on-exec, or the child inherits
the write end and its own stdin never reaches end of file — it sat in its last
read waiting for a byte only it could send.
2026-09-12 10:47:41 +07:00
468dab6e4c Restarts take parameters, and the check for them is where it has to be
spec-conditions.md §3's remaining half: a clause binds parameters, an
invoke-restart supplies them, and what a restart takes is compared at run
time because a restart is found by name on a dynamic stack — neither end
of the transfer can see the other.

The parameters live in a buffer the restart-case owns, not the invoker's
frame. A clause runs after every frame between the two has returned (§5),
so anything on the invoking side is gone by then; the invoker stores into
the target frame while both are still alive, which is the one moment they
are.

The frame carries the parameter count and a hash of how the types are
spelled, and every frame carries them whether it takes parameters or not:
a clause taking none has to refuse arguments as loudly as one taking two
of the wrong type. The count is not redundant with the hash — it is what
makes a 32-bit collision between two different signatures harmless — and
the spelling itself rides along so that a mismatch can say what was
wanted and what was given, which neither end alone knows.

The arguments are evaluated into slots before the invoke node rather than
hanging off it. An argument that transfers on its own is then guarded
before anything aims the channel, and a call written in an argument is on
the ordinary walk Reach and Load already do — a node they treat as a leaf
would have dropped the function and failed to link.

The other way a transfer starts is the break loop, which chooses by
position and has nothing to fill parameters in with. It reaches a clause
through the same channel, so nothing downstream could tell the two apart:
the frame is pushed with the buffer marked unfilled and a clause with
parameters checks that mark before reading it. Refused with the reason
rather than run on values no one supplied.

runtime/flan_rt.c gains two message functions and nothing else; the
restart frame's first four fields, which are the ones C declares, do not
move.
2026-09-12 10:46:24 +07:00
79a8142b78 A local shadowing an imported name, in an expression and in a place
Qualification rewrites a package's own names wherever they are used and
has to stop at a binding. Nothing refuses a renamer that does not: the
program builds, runs, and reads the top-level name instead. The package
in shadow-pkg.flan binds locals called limit and sink over its own
constant and var, and the four numbers separate the two halves —
dropping the shadowing check in the expression renamer gives 5, dropping
it in the place renamer moves the 20 onto the package's sink.
2026-09-12 10:40:47 +07:00
b54f24873e The job ring never looked at tail, and the comment described a drop it never did
publish() wrote queue[head % QUEUE] without consulting tail, so the 65th module
queued between two agent/poll calls landed on the slot the game thread was
reading — twenty-four bytes of function pointers copied field by field with no
atomic near them, so the consumer could take half of one job and half of
another and call it. The comment claimed the overflow dropped the oldest
request; nothing did that.

A full ring is refused now, at the sender, before the dlopen. Dropping loses a
reload the sender was told was ok, which is the same lie more quietly; blocking
stalls the accept loop, which serves connections inline, so a program that had
stopped polling would also stop answering status and abort — the dev loop would
have no way to reach a program that had stopped listening to it. The check is
separate from the store because there is one producer: room, once seen, cannot
be taken away.

Two smaller defects in the same file:

A module with no flan_reload_install was refused and its handle dropped on the
floor. Not an exception to "nothing is ever dlclosed" — that rule is about a
module something points into, and this one installed nothing, so no cell names
it. What leaked was the handle value rather than the mapping: dlopen refcounts
by path, so re-sending the same bad file raised a count nothing could lower.

exit(134) from the break loop runs the atexit chain and the ELF destructors,
which want the loader lock the listener thread may be holding inside dlopen. A
program asked to abort would hang instead of dying. _exit, with the streams
flushed by hand at each call site. The deadlock itself is read rather than
tested; what the tests pin is that the exit status is still 134.

programs/agent-queue.flan blocks on stdin so the window is held open by the
test rather than by a timer: it takes 64 modules, refuses the 65th with a
reason, and installs 64 when it finally polls. noinstall.c's destructor prints
while the program is still running, which is the only way to see the close — at
exit the loader runs every destructor whether anything was closed or not. Both
halves fail on the old code.
2026-09-12 10:39:46 +07:00
86d0c14a45 Three edges of Reach's walk that nothing called
An index expression inside a place, a place under addr, and a
restart-case clause body are each the only route to a function in
reach-walk.flan. Drop any one of the three from the walk and the
function is not emitted, so the program stops linking rather than
answering wrong; each mutation was planted and watched fail here. The
addr case goes through a deref place on purpose, so the index case
cannot stand in for it.
2026-09-12 10:38:40 +07:00
d2bd022094 An enum and an integer convert, both ways, when you say so 2026-09-12 09:11:34 +07:00
96ab4c9cf0 Retire the per-type printers, since print says all of it
print-str, print-i64, print-f64, print-bytes, print-line and newline leave
the prelude. print and println are the whole printing surface now, and print
is the better call at every one of the sites that used them: it is the same
structural walk without the newline, so the no-newline case the family was
kept for is covered, and it takes the value as it is. The old print-i64
forced an explicit (i64 x) at every call site, because this language widens
nothing implicitly; that cast is gone from 127 places.

Dropping it moves one answer. hash-grid returns u64, and the cast through
the signed printer showed sand-headless's hash as -2851001042534928384.
print routes a u64 through flan_u64_to_bytes, so it now prints
15595743031174623232 — the same 64 bits, read as the unsigned number they
are. The pinned expectation follows the correction.

test-flan-dev.el and test_session.ml both reached for print-line as "a name
the prelude has"; they reach for rand-seed instead.
2026-09-12 05:32:25 +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
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
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