260 Commits

Author SHA1 Message Date
69ea66deca An x86 redefinition emitter, and GOT addressing for the host's symbols 2026-09-13 22:38:48 +07:00
e725a5aab4 Merge branch 'worktree-agent-af9064091c0602dc8' into dev-loop 2026-09-13 21:24:12 +07:00
81f7e464ec The indirection cell on the x86 backend, and --x86 --dev with it
FnAddr (Fnval n) emitted the symbol, which is right for a whole-program build
and wrong the instant anything is redefined into it. It now reads the cell,
and so does every direct call, which is what emit.ml's body_of does and is the
half that matters: a redefinition is one store, and it has to reach call sites
that already exist.

What is emitted, all of it behind dev:

  - one cell per function in .data, .globl, initialised to the body this build
    compiled. Spelled exactly as Emit.cellname spells it, because the point of
    having one here is that an LLVM-built module binds
    @"flan.cell.<n>" = external global ptr against it. nm -D over the two
    builds of the same program gives identical sets of 68 cell symbols.
  - the cell load placed after the arguments, which emit.ml has as a
    load-bearing comment: a redefinition landing between two calls must not
    land in the middle of one. CallPtr stays the other way round.
  - the flan_dev_reg_enable constructor, which arms the allocation registry.

Not emitted: Emit.cellptr, the deeper spelling for a name the host was never
built with. It cannot arise in a whole-program build and belongs with the
redefinition module that would introduce one.

The --x86 --dev refusal is relaxed, and the argument is that flan dev never
reaches this fork: --x86 is read only by flan build, and the daemon builds
host and modules through Build.executable / Build.shared without it. So the
flag means a host whose call sites are redefinable, and nothing claims the
module that would redefine through them exists.

Two things were needed to believe any of that. First, the corpus with --dev on
both sides: 97 MATCH, 0 DIFFER, same as without it. Before the constructor was
added that read 96/1 — registry.flan asks (live? ...) and got four zeroes,
which is the whole of what a dev host does differently besides the cells.

Second, and the corpus cannot do this one: a dev build starts with every cell
pointing at the body this build compiled, so it prints what a release build
prints whether anything reads the cell or not. spike/x86/cells.sh preloads a
shared object whose constructor dlsyms flan.cell.twice and stores a different
body there -- the one store a redefinition ends in, done from outside, no
compiler involved. Both dev builds then print the new answer for a direct call
and for a function value, and both release builds are unchanged, which is what
says the change came from the indirection and not from symbol interposition.

One thing the later lane inherits, now written in both headers rather than
left to be discovered. x86.ml licenses its own calling convention on the
grounds that a dev build is compiled entirely here and a release build
entirely by LLVM, so the two never meet in one process. A cell an LLVM-built
module can store into is the first thing that could make that false: the
conventions agree on scalars and disagree on every aggregate, so an
Emit.redefinition module dlopened into an --x86 host would be right until the
first redefined function took or returned a struct. The answer is a
redefinition emitter here, not a classifier.
2026-09-13 21:17:39 +07:00
9534d5c077 The printed form round-trips, and the macro is named before the walk replaces it
Two things the first pass left on unspecified ground.

Form.to_source is the only field in the protocol carrying arbitrary literal
data -- a macro may build any literal at all -- and Wire.quote escapes only
the quote and the backslash, on the stated ground that both readers take
everything else as itself. test_repl checks that ground now: every escape the
reader knows, both byte-literal spellings to_string would have written raw,
and a whole float, each sent through the printer and the socket and back.

And expand_all read the macro's name out of a tuple beside the walk that
replaces it. OCaml does not promise which half runs first; the failure would
have been the wrong macro named, never an error.
2026-09-13 21:09:50 +07:00
3dd9f61b7d A macro call says what it expands to, and a Form learns to print itself
C-c C-m. One step on the bare key, the fixpoint under C-u: a macro may
quasiquote a call to another macro, and Loc.from_macro is outermost-wins, so
by the time a full expansion settles the intermediate name is gone. One step
is the only thing that can say which macro produced what.

The expansion runs against the macros the *session* holds -- the prelude's,
its imports', and every defmacro evaluated since it started -- and writes
nothing back: a defmacro handed to C-c C-m does not join the session by having
been looked at.

Both non-termination refusals stay refusals, and only where they are needed.
One step makes one call and does not look at the answer, so (s/spin) one-
stepped answers with itself; all the way hits the fuel and names the macro,
inside Dev.serve's guard, so the daemon replies rather than hanging. Macro's
module handling is a Fun.protect now -- a build that raised was a process
about to exit, and the daemon is not that process.

No printer for a Form existed. Form.to_string is an error-message renderer and
is what Macro.key digests, so it is untouched; Form.to_source round-trips
floats, strings and bytes through the reader, and Form.pretty decides where
the line breaks go and leaves the columns to flan-mode.

The answer is a read-only flan-mode buffer shaped like the disassembly one,
with cnr's idea in it: m expands the form at point one more step in place.
Three inherited keys refuse by name -- an expansion is in no file. The text is
sent padded onto its own line and its own column, unlike C-x C-e, so the
refusal lands on the call and not at the start of its line.
2026-09-13 21:06:58 +07:00
8ff8a71de8 The aggregate-return refusal cannot fire, and the reason is in check.ml
Item 17 left this as a loose end: flan_vec_as_slice returns a slice by value
and should hit the "aggregate return" refusal, bounds-condition.flan exercises
it in and out of bounds and matches, and nobody traced why.

It is the first of the two possibilities that report named — the refusal is
narrower than it reads, and nothing is going right by accident.
flan_vec_as_slice's Flan-level return type is Unit. check.ml builds it as
[rt loc Types.Unit "flan_vec_as_slice"] and flan_rt.c writes the two words
through a [void *out] parameter, so [is_void rty] answers first and the
[is_agg rty] test below it is never reached.

That is not one symbol's accident, it is the convention. Every aggregate-valued
runtime result crosses through an out-pointer the checker allocates; every
other [rt] builder in check.ml answers Unit, an Int, a Ptr, an Alloc or a
Handle. And the other user of this path, a [declare]d C function, is covered by
[crossable], which admits String and Slice only as a parameter and refuses an
aggregate return outright.

So there is no sret convention to build for Rt, and building one would be worse
than the refusal: the C boundary wants SysV classification — a 16-byte slice
comes back in rax:rdx — and not the hidden-pointer convention this backend uses
internally. There is no classifier in the file and nothing to test one against.
The line stays as a guard against those two rules changing, and now says which
rules and what the work would actually be.

With it goes the rest of item 16's claim that the container runtime is
unexercised. It is: Vec and Map through vec.flan, vec-of-vec.flan, maps.flan
and map-iter.flan, and Pool through registry.flan, handles.flan,
generics.flan and pool-stale-region.flan. All match.
2026-09-13 20:36:56 +07:00
b9f5b5c44c A promise the compiler cannot check gets its own refusal, and a session expands its buffer's macros
Two loose ends from NEXT.md.

slice-from-ptr's run-time refusal borrowed @flan_slice_error and reported a
range and a length the caller never wrote. It has flan_slice_promise_error
now: signals BoundsError, walks the handlers, offers the break loop, falls
through to a message and a status like the two beside it. The sentence names
what was promised and what was passed, and a second line says what is not
checked. The condition fields stay (0, n, 0) — the violated condition as a
range, and not (0, n, n), which reads as in bounds.

And a session now holds the buffer's own defmacros: seeded in Session.create
from the same read that produced decls, and added by Session.eval so a
defmacro typed at the editor joins the set the way a defn does. Not a re-read
of the file, which would put unsaved-versus-saved skew inside expansion. The
commit stays below the checker. Macro.program dedupes the ambient set against
the forms being parsed, left-wins, because unqualified names can now collide.
2026-09-13 20:33:47 +07:00
bdecd2b8f2 slice-from-ptr on the x86 backend, and the check that has to be signed
Another lane landed (slice-from-ptr p n) while this backend was not looking,
and it arrived as two refusals rather than one: slice-from-ptr.flan and
bounds.flan both stopped building through --x86. Neither is a new obstacle —
a Slice _ is {ptr, i64} here exactly as it is in emit.ml, so the form is one
store of the pointer and one of the length and no new representation at all.

The half worth writing down is the check. There is nothing to compare the
length against — only the caller knows how many elements live behind that
pointer — so what is checked is that the promise is not absurd, and that test
is *signed*. check_slice's own compares are unsigned, and a negative i32
sign-extended to 64 bits is a huge unsigned value that an unsigned "hi <= len"
waves through; the result would be a slice about 2^64 long that reads as a
pass and faults somewhere else entirely.

Nothing in the corpus walks that path: every length in slice-from-ptr.flan is
a literal, and a negative literal is refused by check.ml before any code is
emitted. So spike/x86/p7-slice-from-ptr.flan takes the length as a parameter
and runs it through a restart-case, which puts the condition's low/high/length
on stdout and compares them against the LLVM build.
2026-09-13 20:27:44 +07:00
af3daaabe2 Merge branch 'worktree-agent-a1c682523850eafa6' into dev-loop 2026-09-13 19:44:13 +07:00
3775aaf6c3 Merge branch 'worktree-agent-a744a6fee4839672c' into dev-loop 2026-09-13 19:44:13 +07:00
fef6ae04f6 A failed build is a refusal, and a refusal leaves the session standing
The daemon caught Loc.Error at each op and nothing else. That was survivable
while the frontend was the only thing that could refuse a form; it is not now
that expansion is part of evaluating. Both C-c C-c and C-x C-e run a clang
driver through Build.macro_module, which answers with an exit status and a
Failure, and a dlopen that finds no symbol answers with another one. Neither
is a Loc.Error, so neither was answered, and an exception past serve is not a
refused evaluation — it is a dead daemon with the program still on screen and
a closed socket waiting for the editor's next request.

The boundary is now one place, around the whole of a request, rather than a
new arm at each of the dozens of calls. Out_of_memory, Stack_overflow and
Sys.Break go through it: those say the process cannot continue, and answering
"error" to them would claim a session survived something it did not.
Everything else is about the form that was sent, and the message it carries
is the one the user can act on, so a clang exit status reaches :message
instead of being flattened to "internal error".

The session's own state goes with it. Session.eval wrote the imported macro
set above the checker, so a form that did not check left the session holding
a package's macros and none of its declarations; it is held and committed at
the bottom with decls, program and env. Session.eval_expr committed the
generic copies it had instantiated before emitting the module that carries
them, which is the session believing it holds a body nothing was written for;
that assignment moved below Emit.

Both are pinned. test_session drives the two rollbacks in process, and
test_dev drives a real daemon whose macro module cannot be built — the
expression path and the redefinition path, each followed by the same
evaluation succeeding and by the session still knowing the program.
2026-09-13 19:43:18 +07:00
e0e5c1e645 The whole corpus goes through the hand-written backend
The transfer exit returned whatever the return temporary held where
emit.ml returns zero. Meaningless to a caller -- its guard sees the
channel set and never looks -- but main is a caller with no guard, and
what it finds in rax is the process exit status.

The survey compares stderr as well now, which is where every message
the new machinery produces goes: the bounds and slice errors, the
three restart refusals, the transfer failure. Each carries a location
this backend emits by hand as a .rodata label and a length in a
register, and an exit status of 134 with the wrong text beside it is
exactly the failure that reads as a match. It also walks spike/x86's
own probes.

p6-transfer.flan is the two re-propagation branches the corpus does
not reach. Every transfer in restarts.flan stops at a restart-case
inside the handler-bind's extent, so the handler frames never come off
on the transfer path; and in nested and shadowed the inner frame
offers the name, so a restart-case the transfer is not aimed at never
has to put the target back. allocators.flan already covers the third.

  89 MATCH  0 DIFFER  0 refused, over test/programs and spike/x86,
  comparing stdout, stderr and the exit status.

DISCUSS.md item 17 is the report.
2026-09-13 18:41:13 +07:00
63d9b87b7a Conditions on the x86 backend, and bounds checks with them
The transfer channel was the only thing between 41 programs and the
corpus. It is there now: a guard after every Flan call, a landing pad
per restart-case, handler-bind and with-allocator, a transfer exit per
function that runs its fdefers, and check_at and check_slice, which
could not exist until the guard did.

Measured by what the programs print and what they exit with, never by
reading bytes. spike/x86/survey.sh builds every program in
test/programs both ways and diffs stdout and the exit status; it did
not exist, so it is here too, and it is the progress meter.

  before  41 MATCH   1 DIFFER  41 refused by name
  after   83 MATCH   0 DIFFER   0 refused by name

The one DIFFER was bounds.flan, and it was the honest answer to
"--x86 is silently a --no-bounds-checks build". It is not one any
more: check_at and check_slice signal through the channel exactly as
emit.ml's do, so a bounds violation signals, a restart-case catches
it, and an unhandled one exits 134 on both backends. The transitional
refusal that would have said so retired before it was written.

check_no_transfer is not removed, it is narrowed to the one place the
argument still holds: a global's initialiser runs from
flan..init-globals, before main and before anything can handle
anything, so a transfer out of it has nowhere to go.

Four bugs, and three of them are the shape item 16 predicted -- code
that reads correctly and answers wrong, found by output and not by
objdump:

- The body fell through into the transfer exit, so every fdefer ran
  twice on a normal return. emit.ml cannot have this bug: its ret
  terminates the block.
- A Vec crossed to the runtime as the address of a *copy*, so pushes
  grew the copy and an in-bounds (at v 1) signalled against a length
  of zero.
- ucomis sets CF, ZF and PF together for a NaN, so sete answered true
  for (= x x) and the prelude's NaN test never fired: (/ 0.0 0.0)
  formatted as -9223372036854775808. Flan's comparisons are LLVM's
  ordered ones, so < and <= swap and =, != take a setnp beside them.
- A union read field 0 through the struct table and was refused by
  name rather than laid out as a tag and a payload.

And one that could not have been found later: emit_globals_init stored
a null *into* the channel slot rather than a cell address into it,
which is a null pointer for every callee to write through. Harmless
while nothing could transfer; a fault the first time a guard loaded
through it.
2026-09-13 18:05:08 +07:00
b50f42db15 Merge branch 'worktree-agent-a102fd968fb5e7922' into dev-loop 2026-09-13 17:59:04 +07:00
8ca63a7717 The pause wrap takes the expanded expression's location
C-u C-x C-e was never tried on a macro call. Ast.pause_call takes the
expanded loc, which Loc.from_macro has stamped -- it sets a name and
leaves file, line and column the call site's, so the frame the break
loop reports is the line the reader is looking at. Asserted rather than
argued.

Also: the ring rule stated generally (refused at the parse of whichever
file first has both members in scope, always before a session exists),
and the declaration refusal's sentence made build-neutral, since the arm
fires in an ordinary file parse too.
2026-09-13 17:58:36 +07:00
6bc4726ddd C-x C-e expands, and a declaration is not an expression
Parse.expr never ran the expander, so a macro call typed as a bare
expression was an unknown name -- a package's and the prelude's alike,
which is what said the gap was older than importable macros. It is the
wrap Parse.decl already had, applied to the other entry point, with
Parse.with_imported in front of it in Session.eval_expr because the one
expression an editor sends carries no import.

The decision that was waiting: an expression that expands to a
declaration is refused by name, in the head dispatch rather than in a
walk over what the expander answered, so a nested one and a hand-typed
one get the same sentence. A quasiquoted declaration is still a value.

The spin refusal fires on this path; the ring cannot reach it, because a
ring is refused while its own package is parsed. Expansion happens
before the thunk is built, so the 5s three-way wait is untouched.
2026-09-13 17:54:36 +07:00
fbc9d3d314 Merge branch 'worktree-agent-a638cd779b2075b7d' into dev-loop
# Conflicts:
#	NEXT.md
2026-09-13 17:46:24 +07:00
185d162124 A pointer from C can state its length, and then it is a slice
`indexed` took an Array or a Slice, so a `(Ptr T)` that came back
from C was readable at element 0 through `deref` and nowhere else.
The length is not missing from the world — for `font.recs` it is in
the struct, one field over — it was missing from the language.

`(slice-from-ptr p n)` is the form that says it. No marker on the
name: `!` here means mutates and `?` means asks, and `zeroed`, the
nearest neighbour, carries neither; `ptr` is the marker, because a
`(Ptr T)` only ever arrives from a `declare-c`.

Nothing new in the representation. A slice is already {ptr, i64} in
both backends, so this is two insertvalues; `x86.ml` takes the new
constructor on its existing `unsupported` arm.

It refuses a first argument that is not a pointer, a negative literal
length at check time, and a negative computed one at run time — that
last through `signal_block` and `@flan_slice_error`, reused rather
than growing the runtime a function, and *signed*, because
`check_slice` compares unsigned and a negative i32 sign-extends to a
huge u64 that walks through it. Behind `f.md.checks` like the other
two: on at -O0 and -O2, off only when checks were asked off.

It owns nothing and needed no analysis to say so — a slice is not
move-only and carries no allocator, so `free` refuses it by the rule
that already refuses `(as-slice v)`.

`rl/font-recs` and `rl/font-glyphs` are where the promise is written,
beside raylib's own invariant rather than at every call site, and
they are the shape a count-naming binding directive could never have
covered. `examples/text-rectangle-bounds.flan` is the port that
motivated this and it runs; `test/programs/slice-from-ptr.flan`
covers the form with no raylib and no window.
2026-09-13 17:40:59 +07:00
dd3611f97d An edited package macro reloads as its new body, not its old one
Both unions kept the wrong side. macro_union keeps the left on a name
collision, and both callers had the older set on the left: Load.program
put the ambient set ahead of the packages it had just resolved, and
Session put the copy it had been holding since creation ahead of what
Load handed back. So editing a macro in a package and reloading the file
that imports it went on expanding the old body -- and said nothing,
which in this area is the failure that costs the most to find.

Two tests, because the two unions are reached by different paths: the
reload itself, and the C-c C-c after it, which reads the set the session
kept rather than the one Load just supplied. Each fails on its own if
only the other order is put back.

Also written down, and not fixed: C-x C-e expands no macros at all.
Parse.expr never calls the expander, so (unless ...) as a bare
expression is as much an unknown name as (mac/twice 4) -- the prelude
fails there too, which is what says it is an older gap. Changing it
changes what an expression evaluation means.

And the cost note is cut back to what was measured. Four macro modules
where the file's own macros leave two is what the cache shows; why four
is not settled and no longer claimed.
2026-09-13 17:40:05 +07:00
86174531b7 A package's macros survive the reload, and the suite runs them
The feature was built and never tested. Three things were missing.

The two packages holding a ring of macros and a macro that never settles
were not dependencies of the test stanza, so both non-termination
refusals failed on "no package at ..." rather than on their own reason.
They fire, and now the suite sees them fire.

The positive half of the rule had no acceptance case at all -- only the
refusal that pins the bare name. pkg-macro.flan is asserted at three opt
levels and on the dev path, which is where six package macros and the
program's own coexist in one file.

And the dev loop was broken in exactly the way that matters most here.
Session held the imported macro set but *replaced* it on every
evaluation, and the one form C-c C-c sends carries no import -- so
(mac/twice 4) compiled on the build and came back "unknown function" on
the first reload. It unions now. test_session drives two evaluations,
because one proves nothing: the first could have re-supplied the set.

BUILT.md said the expander collects from the prelude and the file being
compiled. It collects from imported packages too, and the refusal's old
reasoning -- that this needed a second import resolver -- was wrong for a
reason worth keeping written down.

Cold build cost roughly doubles for a program importing a package that
declares macros: a macro module is built per round and the package's
rounds are its own. Warm is unchanged at ~70ms.
2026-09-13 17:35:04 +07:00
1657b87e3d Merge branch 'dev-loop' into worktree-agent-adf9086e00872ff67 2026-09-13 17:24:36 +07:00
1898a3157d Macros come from a package now, and the refusal's reason was wrong
Load.program takes forms: it reads the import forms, resolves them with the
one resolver it always had, and parses the file with the packages' macros in
front of it. The refusal said this needed a second import resolver at the Form
level. It did not notice that the file being compiled is parsed before Load
runs too, so no shape of the feature could have left import resolution where
it was.

Names arrive qualified, as a defn's do. (mac/twice 4) is a call and (twice 4)
is an unknown name.

Stopped mid-task: dune test was never run and the acceptance wiring is
unfinished. HANDOFF-macros.md has what is left.
2026-09-13 15:40:08 +07:00
861f591bb0 Merge branch 'worktree-agent-ac5ad16091bc3a40e' into dev-loop 2026-09-13 15:33:52 +07:00
fe858811cb An address answers with a type, and a killed program is asked rather than hooked
The allocation registry had a recording side and half a reader. This is the
rest of the reader: point at any heap address, a breakdown by type, what is
still held, and the test that stops dev-ptr.flan's header from being read by
hand.

The recorded name, back to a type. The table records a string and has to —
the note is built where the concrete type exists and what crosses into the
runtime is bytes. What closes it is that the string is Types.to_string, which
is the source spelling, so the round trip is the language's own reader,
Parse.texpr and Check.resolve. No table of spellings is written down, so
nothing can fall behind Types.to_string, and a name that is not a type —
"pool slots" — is refused with the name quoted rather than defaulted.

The address root renders a (Ptr T) and not the pointee, which puts it through
render.ml's pointer arm: permission is asked in one place in the compiler, and
an address root and a slot root reach the same two answers by the same code.
Flan has no integer-to-pointer cast, so flan_dev_reg_addr is an extern beside
flan_agent_frame_slot, for the same reason.

One walk and two questions: a leak report is a breakdown with the dead left
out, so flan_dev_reg_by_type is one function and the agent formats it.

"At exit" is not a hook. A program killed by a signal runs no handler, which
is how a game under the editor ends, so (:op "leaks") is the authoritative
reader and can be asked at any moment including the one before the kill. The
atexit hook is for the program that returns from main, is registered from
inside flan_dev_reg_enable rather than by a file-scope destructor so that a
release build does not grow a third not-free place, and is off unless
FLAN_DEV_LEAKS is set because the acceptance table reads stderr.

The memcheck half of item 6 is deliberately not here.
2026-09-13 15:27:31 +07:00
16226c71d0 Merge branch 'worktree-agent-a721d74291e5f212c' into dev-loop 2026-09-13 15:26:43 +07:00
8b79cae837 Merge branch 'worktree-agent-a4778b00512de90d3' into dev-loop 2026-09-13 15:24:04 +07:00
762bc988fa The map operations are deferred, and the clause is what pays for it
hashable? gated the type and not the operations: a generic could take and
return a (Map $t V) and could not get or put into one. The hash and the
equality are emitted as concrete symbols chosen from the key type, and
while $t is a variable there is no symbol to name.

The five arms that reach the pair - put, get, has-key?, reserve, clone -
now check their arguments and return a placeholder of the operation's own
type when the key is a type variable: Unit for put and reserve, None for
get so the (Option V) around it still checks, false for has-key?, a zeroed
map for clone. The node is thrown away with the rest of the abstract pass
and the real one is built in the copy, exactly as println's is.

What makes that different from print's free ride is the clause. A map
operation can fail at a concrete type; it is deferred anyway because
{:where (hashable? $t)} is in the signature, so the refusal lands at the
call that asked for the type, against a requirement the author wrote down.
A generic that declares nothing gets no deferral - deferred_key checks
first, and map_type has usually refused the signature already. So the rule
for the allow-list is not a headcount: either the operation cannot fail
after substituting, or a declared predicate gives its failure somewhere to
land. The comment at the print arm says that now instead of "stays two
long".

The instantiation-time refusal names the call site, the type it asked for,
the predicate and the clause, rather than repeating the generic's name
twice.
2026-09-13 15:23:37 +07:00
58b1f49cf2 A discarded value was being stored over the return address
edn.flan crashed by jumping into .rodata, several statements after the
mistake, and the assembly at the jump read correctly. Item 15 said this
is how hand-encoding fails, and it is: the crash and the cause were in
different functions.

The cause is one line of design. A form whose value is thrown away was
handed the sink, and the sink was spelled as an address — rbp+0. That is
the saved rbp, and rbp+8 is the return address, so a non-void form in
statement position stored its value straight over both. A 16-byte slice
did it in one rep movsb.

The sink is now compared by identity and never used as an address:
anything with a value that is handed it gets a frame temporary instead,
reclaimed immediately. The point is not the temporary, it is that the
store has somewhere legal to go.

edn.flan matches the LLVM build now — 60 lines of a hand-written EDN
reader, unions, options, nested collections and all.
2026-09-13 15:08:38 +07:00
fcdaa105af Unions and a two-index (at), which the corpus asked for by name
The sweep over test/programs named its own next two nodes. (at grid r c)
is one node with two indices and not two nodes — an array of arrays is
contiguous, so the second index walks into the element the first landed
on — and machine.flan is the program that says so.

Then unions: MakeCase, CaseField and Match. The payload offset comes from
lay_fields over the same two fields Emit.lay measures a union as, and a
case's field offsets from lay_fields over that case's own fields, so
there is still one layout calculator and this file is still a caller of
it. match reads the tag and compares, an Option reads an i8 at offset 0
and a declared union an i32, and everything past the tag and the binds is
shared — the arrangement emit.ml settled on, for the same reason.

An exhausted match falls through to ud2 rather than to whatever follows.
The checker proved it cannot happen; a defined SIGILL at the instruction
that fell through costs two bytes and is the cheap half of item 15's
question 4.

machine.flan, bytes2.flan, array-ctor.flan and destructure.flan all agree
with the LLVM build now.
2026-09-13 15:06:31 +07:00
786656dfee (at a i) on the left of a set has to reach the array
The corpus sweep found it, and it found it the way item 15 said this work
fails: array-ctor.flan crashed, and the assembly around the crash read
correctly. (set (.x (at pts 0)) 1.5) went through lvalue, lvalue had no
case for At, and the fallback evaluates — so the store landed in a copy of
the element and the array kept its zeros.

emit.ml has this as addr's own At case. One line here, and the program
matches the LLVM build.

Two more programs beside the fizz: one for the internal calling
convention the fizz does not touch at all — a struct argument, a struct
return through the hidden pointer, f32 in the SSE half, eight integer
arguments so two go on the stack, and a slice by pointer — and one for
the rest of the core: a global with an initialiser, recursion, break,
continue, the bitwise family, unsigned shifts and the conversions both
ways. Both agree with LLVM.

al is now zero at every call this backend makes, including the three in
main that were reaching flan_rt_init, flan_argv and flan_exit without it.
Inert on a fixed callee; the point is that there is no exception to the
rule to remember.
2026-09-13 15:03:41 +07:00
eebd5d6d2c Merge branch 'dev-loop' into worktree-agent-ab63ab2e0656f837e 2026-09-13 15:02:17 +07:00
b3cb657992 hashable? gates the type and not the operations, and say so where it bites
A map keyed by a type variable cannot be put into inside a generic body:
the hash and the equality are concrete symbols chosen from the concrete
key type, and there is none until the copy exists. The refusal now says
that, and says what hashable? does buy - taking and returning a
(Map $t V) - rather than leaving the reader to infer it.

Closing the hole means adding the map operations to the list of forms
the abstract pass defers to instantiation. That list is print and
println and nothing else, and every member is a place where a refusal
moves from the definition to a call site, which is what the abstract
pass exists to prevent. Two is short enough to hold in your head.

Also written down: four of the prelude's copyable? declarations are
convention rather than checker-enforced. The move analysis tracks
locals, not reads out of a slice, so swap! and friends check without it
- and would still duplicate a header at [(Vec i32)].
2026-09-13 15:01:49 +07:00
70af1966a2 Braces are no longer a type: (Map K V) is the only spelling
The author's decision, and it removes the one syntax question generics
had. A return type can no longer be written in braces, so a {...} after
the signature is unambiguously the constraint map and there is no
structural rule to explain.

The reasons for the record: the brace's value meaning and its type
meaning do not correspond the way the bracket's do - [1 2 3] is a value
whose type is [3 i32], but {.x 1} is a value whose type is a name, and a
map value is built by map-new with no braces anywhere - and dropping it
reserves {} in type position for anonymous struct types.

Braces in a type are refused with the surviving spelling named rather
than falling through to "expected a type". Types.to_string and
Cimport's source printer both print (Map K V) now, and Shim refuses the
application spelling where it used to refuse only Ast.Tmap.
2026-09-13 14:58:27 +07:00
2155c41465 A whole program goes through the hand-written backend and runs
x86.ml was an encoder and a frame model with nothing calling it. It now
lowers a whole Tast.program to an assembly file, and `flan build --x86`
hands that file to the same clang invocation the LLVM path uses, against
the same runtime objects. The flag is off by default; LLVM stays the
release backend and the default one.

Three programs, built both ways and compared by what they print and what
they exit with rather than by reading bytes: exit 0; a dotimes that
prints; and a fizz over a call, an if, a remainder and two string
literals. All three agree with the LLVM build.

The measurement decided the target. hist.ml over the fizz program shows
no Signal, no Handled, no RestartCase — a loop that prints does not drag
conditions in. What does is the bounds check and the allocator, and
neither is in the reachable set of a program that prints a number.

That is why there is no transfer guard here, and check_no_transfer is
what makes the omission sound rather than hopeful: if nothing reachable
can write the channel, no call can return with it set. It is a
whole-program property, so it is checked once per build and the build
stops with the node's name when it fails.
2026-09-13 14:56:14 +07:00
de93ffc89e A cast to a type variable, and the container builtins over one
(t x) is not a name is_cast knows - t is not a machine type - so it is
its own arm, admitted by numeric? because a cast produces a number.
vec-new, pool-new and map-new all reach the one list of what names a
type, so the spike's line for vec-new had already covered the other two;
zeroed takes its type from the position it is written in. All four are
pinned in programs/generics.flan.
2026-09-13 14:52:17 +07:00
dad725afe4 The prelude's per-type families collapse: 22 functions become 10, 27 become 16
swap!, reverse!, sort!, sort-by!, index-of, min-of, max-of, map!,
reduce and filter, each written once over $t. Every call site in the
corpus moves with them.

min-of and max-of are not min and max because min and max are builtins
over two or more numbers and nothing shadows a builtin. These reduce a
slice, which is a different operation at a different arity.

sort-bytes! did not collapse into sort!, and the reason is the point of
the predicates: a [u8] is not ordered? and cannot be, because < is an
instruction and comparing two slices lexicographically is a loop. It is
sort-by! with bytes<? written in, one line, keeping its name and its
stability note. sum-i32/sum-f32 and append-i64!/append-f64! stay for the
reasons the spike gave.

Not what the notes predicted: none of the ten collapses on a signature
change alone. filter and reduce need copyable? because the checker
demands it - reduce's accumulator at (Vec i32) is a double move - and
the rest declare it because a slice of owning elements would have them
duplicating headers.
2026-09-13 14:49:11 +07:00
b438a71031 C-c C-c on a generic installs its copies, and a refusal about one says where it came from
A generic defn produces no Tast.fn, so the editor was told nothing had
been installed and nothing had gone wrong. eval now expands a redefined
generic name to its copies, and picks up any copy the running process
was never built with - which is how a redefined caller reaching a
generic at a new element type gets that copy built and loaded.

C-x C-e is the path that could really go stale, and did: it checks
against the live environment, so an expression naming a generic at an
unused type generated a copy that existed in no program and the thunk
called a symbol nothing defined. Marked and spliced.

There was no cache to invalidate. program_with_env builds a fresh env
every evaluation, so the instantiation cache cannot survive one; the
test pins that rather than inventing machinery for it.

A signature change reaches the session as a refusal about put!-i32, a
name the source does not contain. It now says which generic it is a
copy of, at which types, and that every copy changed together.
2026-09-13 14:37:55 +07:00
68625a535e A bindings gap stops a build, and says which file to edit 2026-09-13 14:34:19 +07:00
7f86f32699 where predicates admit operators, and a type variable is move-only until it says otherwise
The spike proved the shape; this makes it the feature. A generic body is
still checked abstractly once, but now it may be told what to assume:
{:where (ordered? $t)} at the head of the body, Clojure's {:pre [...]}
spelling, with five predicates - ordered?, equal?, hashable?, numeric?
and copyable?.

The syntax catch settled structurally: {K V} is still a legal return
type, and a constraint map is told from one by its leading keyword. A
keyword is not a type anywhere in the language, so the slot after the
return type is unambiguous and {K V} did not have to go.

A type variable is move-only by default, with copyable? the opt-out.
Move is the stricter rule, so assuming it can only refuse a valid
program, never admit a bad one. That is Rust's T: Copy and not Odin's
anything - Odin has no move semantics at all.

The runaway refusal no longer names a depth. It names the chain: a
generic already on the instantiation stack, asked for again at a type
built around the one it had before, is growing and will not stop.
2026-09-13 14:33:45 +07:00
70e19753dd Merge branch 'worktree-agent-afcd2406f3660629b' into worktree-agent-ab63ab2e0656f837e 2026-09-13 14:22:33 +07:00
b5d2b5eabd Merge branch 'worktree-agent-a638e5d0f0de7a058' into dev-loop 2026-09-13 14:20:56 +07:00
4ff3e9a922 A finding about the bindings file is not a reason to stop a build
check_constants makes two kinds of finding and they were treated alike.
A value that does not match, or a C name the header does not have, is
the library contradicting the package and stops a build the way a
permuted defstruct does. An enum nobody mapped and a rule that reaches
nothing are about the package's own bindings file -- real, and worth
fixing, but telling a lane that added a defenum to go and edit a config
in a message shaped like "your layout is wrong" is the wrong thing to
fail a build with. Those gate generate-c, where that file is edited.

Also: a const prefix now counts as reaching a name before an explicit
constant line is consulted, so a rule whose every match is also spelled
out by hand is not reported as matching nothing.
2026-09-13 14:16:57 +07:00
9223c9002a An enum is four bytes, and the header check now reads the constants
Two gaps the raylib examples hit.

The layout check compared a Flan enum against the header's `int` and
called it a disagreement. It is not one: Shim.cty lowers a defenum to
int32_t in a struct field exactly as it does in a parameter, which is
what the signature check already knew and the layout check did not. One
predicate now serves both, symmetric, and tolerant of a 32-bit integer
and nothing else -- f64 against the library's float still fails, in the
very struct whose other field is an enum. Camera3D.projection is a
CameraProjection again and rl/camera-projection is gone with it, so
`.projection :perspective` resolves at the construction site.

And generate-c's claim said nothing about a defconst or a defenum
member, so a wrong flag bit was completely silent. `bindings` gained
`enum`, `const` and `constant` lines saying what a Flan constant is
called in C -- the prefix is nowhere in the Flan name, so it is declared
rather than guessed. Nothing goes quiet in either direction: a name the
rule builds and the header lacks is reported, a rule that reaches
nothing is reported, and a defenum with no line is itself a finding,
because otherwise the silence just moves up one level.

clang's dump gives anonymous EnumDecls for every raylib enum and no
value at all for an enumerator written without `= n`, so the constants
are one flat table and the values are counted the way C counts them.
cache_format bumped with the dump type.
2026-09-13 14:11:35 +07:00
cdcdd70c4e The object cache outlives the run, and the await says which wait it was
Build.cachedir sat under TMPDIR, which dune makes private per run, so no
test run ever reused an object and every build in the suite was cold. It
moves to $XDG_CACHE_HOME/flan/objcache (FLAN_CACHE_DIR overrides), which
is safe because the keys are total: compile_c digests the source text,
the compiler's stamp and every flag; wasm_resource_dir digests the
builtins archive; compiler_object digests flan.cmxa and flan.a. Writes
were already .tmp-then-rename, so concurrent dune jobs are fine.

Macro.key was the one key that was not total -- prelude text plus the
call's forms, and nothing about the compiler whose codegen produced the
.so it names, which is dlopened straight back into this binary. Under a
per-run TMPDIR that never showed; under a durable cache it is a stale
expander that crashes rather than a compile error. It carries the
compiler's stamp now, handed across start_merged's exec in
FLAN_COMPILER_STAMP because a merged dev binary lives at a per-session
path and keying on that rebuilt a macro module every dev start.

Measured on dev-repl.flan, launch to bound socket: 2.0s cold against
0.48s warm. Whole-program flan build: 1.44s against 0.06s. Full dune
test 25.7s/30.1s before, 24.0s after, user CPU ~50s down to ~34s.

And the await: one timer covered two waits, a build then a bind, so
'the daemon never listened' was a wrong diagnosis of a build that had
not finished. listening now polls the process alongside the socket and
says which -- exited with a status, or still running and therefore still
building. A daemon that dies fails in milliseconds instead of costing
the whole timeout. Thirty seconds, down from a minute, because the build
it waits on is warm now.
2026-09-13 14:04:50 +07:00
eec0dfd1c3 A runaway instantiation refuses instead of hanging the editor 2026-09-13 13:32:33 +07:00
0749913420 A generic filter allocates its Vec, and the sweep says what a rebuild costs 2026-09-13 13:24:45 +07:00
50798aac89 A generic sort takes its comparison as a value, and an operator over a variable is refused 2026-09-13 13:20:12 +07:00
cb56fc14b1 Generic functions instantiated at their call sites, spiked 2026-09-13 13:13:10 +07:00
f2be0a62dd A pause is waited for by name, and a build is not a socket
Two follow-ups to the marking commit.

`Dev.eval_expr`'s new wait matched `Stopped _`, which fires on the first
iteration when the program is already parked on something else — the
break loop allows evaluating, so that is reachable — and answers for a
thunk that has not run yet, on a reply whose own `:condition` names the
other condition. It now waits for `Stopped "Pause"`, which the agent
reports under a nested break because `condition_name` is overwritten on
the way in and restored on the way out. `dev-pause.flan` grows a
`Missing` and a `boom` so the test can park the program on something
else first and tell the two apart.

And the flake NEXT.md had as "seen once and unexplained": `the daemon
never listened` is not a race, it is an llc-and-link of the whole
program before `flan dev` binds — ~600ms idle, measured at 6.6s and 6.8s
with the rest of the suite beside it, against a 5s and 8s await. All
three test binaries now wait a minute; the watchdog is what bounds the
run. Two consecutive full runs green.
2026-09-13 13:07:25 +07:00
5791faee4e A breakpoint is a function call, and the editor only says where
Finishes DISCUSS.md §9's `pause` marking: the daemon half was already
built, this is the editor half plus the one daemon path it was missing.

`C-u C-c C-c` marks the form point is inside, `C-u C-u C-c C-c` the
top-level form (stop on entry), `C-u C-x C-e` the expression before
point. The buffer is never edited — the position rides beside the code
and the `(pause)` call goes into the tree after parsing, so no source
location moves.

`C-x C-e`'s path needed the daemon: its 5s `wait` answered "the program
did not reach a frame boundary", which is exactly what a thunk parked at
a breakpoint looks like from out here. `wait` is now three-way and asks
`state t = Stopped` only when a pause was requested, so the no-pause
shape `test_dev.ml` pins is unchanged.

The overlay is an annotation and not feedback, so unlike an error marker
it survives `pre-command-hook`; what takes it down is an accepted
evaluation with no `:pause` on it, which is the same thing that takes
the mark itself down.

Tests: a `test_dev.ml` block over the new `dev-pause.flan` that marks,
stops, re-evaluates plainly and then polls half a second confirming it
does not stop again — one sample after `continue` proves nothing, the
resumed frame is still in the old body — and an `emacs/test-flan-dev.el`
block for which form a prefix picks, the byte column, the overlay's
lifetime, and one live round trip.
2026-09-13 12:51:06 +07:00