597 Commits

Author SHA1 Message Date
8e0e99f439 A stub handoff for the cost lane 2026-09-13 22:35:19 +07:00
b1cc67b36f Second handoff: the backend is correct and is not yet the dev backend 2026-09-13 21:27:03 +07:00
e725a5aab4 Merge branch 'worktree-agent-af9064091c0602dc8' into dev-loop 2026-09-13 21:24:12 +07:00
6363635bae DISCUSS item 18, and the handoff: the aggregate return that was never there
Item 17 left two correctness items and a loose end. The loose end is the
answer to the first item: flan_vec_as_slice's Flan-level return type is Unit,
so is_void answers before is_agg is ever tested, and that is the convention
rather than one symbol's accident. So there was no sret-for-Rt to build, and
building one would have been untestable and wrong.

The second item is built and tested by the only thing that can test it, a
preloaded dlsym, because a dev build with nothing yet redefined prints what a
release build prints whether or not anything reads a cell.

Survey: 93 MATCH / 0 DIFFER / 2 refused before, 97 / 0 / 0 after; 97 / 0 / 0
again with --dev on both sides.
2026-09-13 21:23:43 +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
9fc04193d9 The refused shape is defmacro at C-x C-e, and the union's boundary is defn's
The head dispatch already names it; test_repl pins it beside the defvar case,
and the editor-typed defmacro moved below the describe assertion that says an
evaluation changes nothing -- that one does, on purpose. BUILT.md gains the
boundary of the shape: the set only grows, a deleted defmacro still expands,
and that is exactly what a deleted defn already does.
2026-09-13 20:37:32 +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
801b374c3c What dune test actually says right now, and why
Four raylib fixtures fail, the same four on two consecutive runs:
images and audio, each at -O2 and -O0. They export to a hardcoded
/tmp path and get "Failed to export wave data" because /tmp is full,
and /tmp is full because of a runaway llc in another lane writing a
5.8 GB m4.o out of a 13 KB m4.ll. Nothing here writes to /tmp by a
fixed name and TMPDIR does not reach those fixtures, so item 17 says
that rather than claiming an unqualified green.
2026-09-13 18:43:00 +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
d9bc9e4190 The backend's list ends at a whole-program compiler, not a dev one 2026-09-13 18:14:49 +07:00
c291cb7715 The headers file said opt-in three lines above saying it is not 2026-09-13 18:09:11 +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
045b0a46b9 Merge branch 'worktree-agent-a1b90b5d487331391' into dev-loop 2026-09-13 18:04:54 +07:00
3e181b52b2 The docs described a language that stopped existing today
A consistency sweep, run by checking claims against the compiler rather
than reading for style. Every edit here corrects something that is false
now, or adds something the page had no way to say.

`web/index.html` was the worst of it, and the worst of that was control
flow: the page said there is no `loop`/`recur` and no `break` or
`continue`, and printed the refusal message for `break` as evidence. All
four are built, with loop labels. A live code example called
`index-of-i32`, which no longer exists and would not compile. The prelude
table was the pre-generics per-type families, a paragraph said there is
no `println` two paragraphs after calling `println` the compiler's, and
`sqrt-f32` was "the one `declare` in the file" when there are five. The
"Not implemented yet" table listed `Vec`, `Map`, `Handle`, `Fn`, `fn`,
unions, `defmacro` and quasiquote, all of which check; what is actually
left is `Result`, `try`, a quoted symbol as a value, `errdefer`, `await`,
`handler-case` and the restart-stack readers. Restarts take parameters
(`(invoke-restart 'use-value 21)` answers 42), `defer` in a `let` is
allowed, and there is both an allocator and a `context`.

Generics is a new section, because nobody had documented the syntax. The
brief for it was wrong in one place and the corpus settled it: `$t` goes
in *every* type position including the return type, and bare `t` is the
type-name argument in expression position — `(vec-new t)`, `(t x)`. It
says what a type variable is move-only by default means, since that is
the rule a reader hits first and it is not Odin's.

`FLAN_RAYLIB_H` is gone from every doc that claimed it still decided
something. The passages that say "this used to be opt-in" are kept and
labelled; the ones that said "this is opt-in" are not. `plan.org` had
`{string i32}` in the type list and four predicates where there are five.
`conditions.org` described `errdefer`'s behaviour without saying it is
refused by name. `REFERENCES.md` pointed at the gitignored copy of the
raylib header rather than the committed one, which is the exact trap that
made committing it necessary.

Found and not fixed, because it is not documentation: `vendor/raylib/headers`
still says a build reads it "when the variable happens to be set", which
contradicts the section below it in the same file and is false — moving
the header makes every build fail by name.
2026-09-13 18:04:24 +07:00
91ec235395 Merge branch 'worktree-agent-a788866b56165817f' into dev-loop 2026-09-13 18:01:35 +07:00
25f56c24a8 A begin/end pair that cannot come apart, and what it still cannot promise
vendor/raylib/modes.flan: five macros over the five pairs the package binds
-- with-drawing, with-mode-2d, with-mode-3d, with-texture-mode,
with-scissor-mode. A second file with no declare-c in it, split out on
vector.flan's reasoning: raylib.flan is the package's statement about C and
nothing here names C, so nothing here can be made wrong by raylib changing.

Each expands to (do (begin-... args) body... (end-...)) -- the calls the
author used to type, in the order they typed them. No let, no gensym: nothing
binds a name, so there is nothing for a caller's name to collide with.

What it removes is the End* that is missing, wrong, or no longer beside its
Begin*. What it cannot remove is a body leaving through the unwind path: a
return or an invoke-restart skips the rest of the do and the End* with it.
defer is the obvious fix and is refused inside a loop body, which is where a
pair always lives -- checked, not assumed. So sand.flan's discipline stays:
keep the restart boundary outside the pair.

35 call sites converted across examples/ and sand.flan. The one left is
core-scissor-test.flan, whose Begin and End sit in two separate `when`s with
the drawing between them -- a conditional pair is a shape a bracketing macro
cannot express.

test/programs/rl-with.flan covers with-scissor-mode, which no example can,
with a frame function unreachable from main so it needs no libraylib;
rl-with-reject.flan is the arity half.
2026-09-13 18:00:35 +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
2c6459ca8d Merge branch 'worktree-agent-a91f53d60aa4fd40d' into dev-loop
# Conflicts:
#	NEXT.md
2026-09-13 17:56:03 +07:00
d1464ee266 Memcheck is told an arena reset happened, and the agentless session is pinned
Two loose ends.

The arena was invisible to memcheck. free-all is retain-capacity, so from
malloc's point of view nothing died and round two of a reset arena could read
a byte it never wrote, print round one's value, and draw no report.
flan_arena_proc now issues memcheck's MAKE_MEM_UNDEFINED over the whole
capacity beside its registry call. Measured on the same machine: the control
produced ERROR SUMMARY 0 before and 6 errors from 4 contexts after, with
--track-origins naming the client request. It is a control in
test_valgrind.ml now rather than a printed note.

The macro is vendored, not included, and the argument is measurement: the
machine that runs the sweep has valgrind and not valgrind-devel, so a guarded
#include would compile to nothing exactly where it matters and the control
would go quiet with no diagnostic. There is also nowhere to put an -I --
flan_rt.c is cat'd into an OCaml string literal and handed to clang in a
scratch directory. The __x86_64__ guard is load-bearing: the same runtime is
built for wasm32-wasi and emscripten.

Cost outside valgrind: 23 instructions on the free-all path only, about 1ns
per reset over fifty million of them, against a run-to-run spread wider than
the effect. Nothing on alloc, resize or free. valgrind.supp still holds no
suppressions; the corpus stayed clean across the change, which is its own
finding.

merged_serve's warning path deserved a test and has one. The discriminating
fact is not the log line but the policy: two_process kills its child and
fails where merged_serve warns and serves anyway, and nothing held that
second answer in place. dev-noagent.flan plus the last block of test_dev.ml
assert the session still answers describe after the wait runs out. Verified
by reverting the policy: the block reports rather than passing. It costs the
full ten seconds and there is no way to spend less. HANDOFF-f1.md is deleted.
2026-09-13 17:55:00 +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
970fe8a278 Queue the slice-from-ptr message, which names a range nobody wrote 2026-09-13 17:51:36 +07:00
fbc9d3d314 Merge branch 'worktree-agent-a638cd779b2075b7d' into dev-loop
# Conflicts:
#	NEXT.md
2026-09-13 17:46:24 +07:00
fd873bb9d7 Mark the queue entry landed, and get the shim's cost right
NEXT.md had the new box as a sibling heading next to "Queued:", so a
reader scanning headings saw the same item twice and still queued.
Folded into one section with a Bound box, the way PORTING.md §1 does
it.

And the example's header claimed the GetCodepoint tail "costs a scan
rather than nothing". The shim's stack buffer is 256 bytes and this
message is 284, so the first thirty glyphs of each pass malloc. Said
so, because that is the kind of number the comments around it are
precise about.
2026-09-13 17:44:59 +07:00
e903e220fb The temporary package the reload case writes is removed again 2026-09-13 17:41:18 +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
888915402e The backend first, then a freeze; any and drop are stashed 2026-09-13 17:23:05 +07:00
3b15501c4f Where this is at the end of the day, and what is not merged 2026-09-13 16:11:57 +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
a9c0c3e9bf The listing buffers are malloc'd, and a followed pointer says why it stops
Three follow-ups to the registry reader.

The agent's "reg types" handler held its four row buffers as statics. The lock
argument for that was right — request_lock is held across the whole of
handle_line from both entry points — but the BSS was not: 8KB in every build
this package is linked into, including a release build of a game that imports
the agent and never writes a row. That is flan_dev.c's own argument against a
fixed table at a thirty-second of the size. Malloc'd and freed per request,
like the watch handler beside it.

A followed pointer parses as an opaque node, so the inspector refused it with
"no structure for this type" — false and unhelpful at once. It has structure,
it is drawn, and the reason you cannot step in is that the step would start
from the pointee. Said that way instead.

And the three new commands have elisp tests: the address root's wire, that a
path is refused rather than dropped, the listing's rows and totals, the
overflow warning, and a build with no registry refusing rather than showing an
empty table that reads like a program holding nothing.
2026-09-13 15:33:25 +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
95697d230f Merge branch 'worktree-agent-ac605f8a376484762' into dev-loop
# Conflicts:
#	NEXT.md
2026-09-13 15:24:28 +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
dec4bcb88b Say why the family argument in bindings does not reach the slice wrappers 2026-09-13 15:18:37 +07:00
5a1c2745b0 An idiomatic layer over the raylib bindings, and raymath in Flan
Three kinds of Flan face over the generated set, which stays honest to C
because that is what makes it checkable against the header.

A slice where C takes a pointer and a count: the eleven vector-array
drawing calls, all eleven rather than the three anybody calls, since a
subset has its hole where the next caller looks. Each guards the empty
slice, which is the part a hand-written call site gets wrong rather than
merely writes out -- raylib takes a count of 0 happily, but taking the
address of element 0 of an empty slice is out of bounds before raylib is
reached.

An Option where C signals with a sentinel: get-key-pressed and
get-char-pressed, raylib's two input queues, both of which say "empty"
with 0. What that buys is in text-input-box.flan, which read the queue in
two places -- once to prime the loop, once at the bottom of the body --
and now reads it in one.

Both of those use the `name` directive in bindings, so the generated
declaration keeps the symbol and gives up the name: nothing about the C
signature is hand-written and the generated half keeps its
agreement-by-construction with the header.

An enum where the header says int: key-up?, key-pressed-repeat?,
mouse-button-up?. These are NOT wrappers -- a C enum parameter has an
int's ABI, so the hand-written declare-c with the Flan type is the whole
fix. They were holes in families whose other halves already took a Key,
so (rl/key-down? :space) compiled and (rl/key-up? :space) did not.

Not built: with-drawing and with-mode-2d. A macro cannot live in a
package -- the expander collects defmacros from the prelude and from the
file being compiled, and one in an imported package is refused by name.
test/programs/pkg-macro.flan is that refusal.

And vendor/raylib/vector.flan, which is raymath written in Flan because
raymath is static inline and has no symbol to bind. A file of its own,
split on declare-c and not on "idiomatic": there is not one declaration
in it, so it is not part of the surface the header check reads, and
raylib.flan is 1300 lines already. clamp and lerp are deliberately absent
-- the prelude has both, and a second lerp would not even be the same
function, since the prelude writes (1-t)a + tb where raymath writes
a + t*(b - a).

Examples: the identical eight-expression box-around helper in
core-3d-picking and models-box-collisions is a half-extent subtracted and
added. shapes-following-eyes keeps its measurement and gives up one line
to v2-sub, which is the honest size of the gap in a file that is nothing
but vector maths.
2026-09-13 15:16:40 +07:00
fc4e51a106 The dev backend, wired — and conditions were never in the way
DISCUSS.md item 16, in item 15's form.

The headline is a measurement that contradicts what everyone expected,
this report included: a program that loops and prints carries no Signal,
no Handled and no RestartCase. Zero of each, measured with hist.ml before
a line was written. What drags conditions in is the bounds check and the
allocator — check_at signals, and signalling needs the channel and the
guard — and neither is a Tast node, which is why a histogram cannot see
either and why the guess was off.

41 of the 111 programs in test/programs build through --x86 and 40 print
exactly what the LLVM build prints. The 41st is bounds.flan and it
diverges on purpose: --x86 is silently a --no-bounds-checks build, and
that is named as a divergence rather than left in a code comment.

The other 40 are refused by name. check_no_transfer is what makes the
missing guard sound instead of hopeful — if nothing reachable can write
the channel, no call can return with it set — so the build stops with the
node and the function rather than compiling forty programs wrongly.

Both bugs found along the way are the shape item 15 predicted: the crash
and the cause in different functions, and correct-reading assembly beside
a wrong answer. The frame model is the thing that did not go wrong.
2026-09-13 15:14:33 +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