71 Commits

Author SHA1 Message Date
5274f83f88 One fn form holds all of a name's arities, and evaluating it replaces them all. 2026-09-26 18:30:11 +07:00
ae41ec8454 An install reply's :fns and flan reload's summary name each fn once, as it is written. 2026-09-26 18:29:54 +07:00
5d5018011e Vendor packages, examples/*, and sand.flan are ported from .flan to .fln, and flan generate-c writes generated.fln. 2026-09-26 17:07:04 +07:00
72c73117d6 A .fln file writes a type alias as type Row = Vec(i32), and flan check prints nothing for a file that checks unless given --defs. 2026-09-26 05:39:59 +07:00
c1beb602a7 A .fln let is always flat, and flan convert writes flat lets, renaming a shadowed name instead of nesting 2026-09-25 23:01:11 +07:00
bdda16a461 flan run's program dies with flan, and a program killed by a signal ends flan run with 128 plus its number 2026-09-25 22:57:02 +07:00
55ab1a98ae A macro whose definition splices its body into a do, and comment, count as bodies run in order where the .fln printer writes a let flat, and a struct-pattern let renames and flattens like a plain one 2026-09-25 22:39:50 +07:00
2b35085358 flan run removes its executable and work directory when it is sent SIGTERM or SIGHUP, and the one-shot commands' empty work directory is gone from TODO.org since the exit handler already removes it 2026-09-25 22:10:07 +07:00
020392b064 Merge branch 'master' into worktree-agent-a55c4be9d199b8eb4 2026-09-25 16:22:58 +07:00
8200120bf3 flan convert keeps comments and number spellings in both directions, and a .fln message quotes the text as written, with let-bound match, if and block calls read as values 2026-09-25 15:47:29 +07:00
d0fc036bba flan dev --sanitize builds the host under ASan and UBSan on LLVM, refuses --x86 by name, and the sanitize sweep drives a real session through reloads and breaks 2026-09-25 15:39:00 +07:00
074b2da6f3 The indented reader refuses a continuation line that is not deeper than its statement, takes one-line statements in arms, then/else and defer, typed lets and bare-name blocks, and names the shape it wanted where it used to say two values cannot sit side by side 2026-09-25 15:30:52 +07:00
82b02b6584 Merge branch 'master' into worktree-agent-ab75070e065de0e53 2026-09-25 15:21:12 +07:00
d7016f91c7 A .fln file is read by an indented reader that yields the paren reader's forms, every program entry point picks the reader by extension, and flan convert prints either syntax as the other 2026-09-25 15:04:32 +07:00
610e9fcf9d A parent's handler reads a name and message made in context/temp, whole and good until the frame ends, and the handler-case landing no longer holds a buffer of its own 2026-09-25 14:36:46 +07:00
35f455acb3 The's refusal of a dyn says what the boundary does at that type, elements that cannot become a dyn are refused against each other, two literal arms meet at the wider type, and the renamed prelude function and a second startup warning stay out of sight 2026-09-25 12:49:02 +07:00
73dfaacbc8 A condition type may name a parent and a handler for Error catches every built-in error, a signal passes one descriptor carrying its chain, name, sentence and site, and the break loop shows the sentence the runtime wrote 2026-09-25 12:05:13 +07:00
0b9ae9318e flan build and flan run of a file with no main say so and show one, rather than failing at the link 2026-09-25 11:26:13 +07:00
066fb18940 Every Flan form heads the code it produced in the emitted IR, the x86 listing and both disassembly buffers, and the objects are the same with or without it 2026-09-25 10:34:07 +07:00
7d75cc0a5d The CLI and the tests load, check and link a program through one module in the library 2026-09-25 07:30:55 +07:00
57fe91f303 Five records become one, and every citation lands somewhere
FIX.org, NEXT.md, DISCUSS.org, docs/DISCUSS.md and the session handoff at the
root are one TODO.org now: 293 entries under seven subsystem headings, each
carrying an org keyword that says where it stands. A DONE entry is a few lines
saying what was decided and what that rules out; the reasoning that would not
compress — the embedding spike and the four reports the hand-written x86
backend was built from — moved into docs/BUILT.md instead, and its entries
point there in one line.

Every entry was checked against the tree before it got a keyword, and the
prose was wrong in both directions. Things the deleted files called open were
built: the first-evaluation stall, main being redefinable, macro parameter
lists, the type-limit constants, the array constructors, the byte fills,
inc/dec, the discard's fontification, the Emacs buffers, rt_die's _exit, the
backtrace surface, and the acceptance failure that could print and still exit
zero. Things they called done were not: the backend reports' no-plan buckets
had gone stale in the other direction, the value-dependent defvar was
superseded rather than built, and macro-expansion source locations are on an
unmerged lane, so that entry is NEXT and names the branch.

Every comment that cited one of the five by name now cites a heading that
exists, in TODO.org or in docs/BUILT.md. The session reports under
docs/handoffs/ keep naming the files they worked on, because rewriting them
would falsify what those sessions did; each carries a note saying where the
content went.
2026-09-21 21:05:48 +07:00
a64bee6d96 Review follow-ups: a new def's image, the keyword that cannot change, and the sweep
Three defects, all from lifting every def initialiser, none of which the
suite caught:

A def typed fresh into a live session came up zero and stayed zero. The
image flan_dev_global copies on the allocation is the only value a new
global ever gets — the host's .init-globals never calls its initialiser —
and both backends chose that image with Tast.const_init, which a def's
lifted Call fails by construction. Emit.initial_image reads the constant
back out of the lifted body; the x86 twin had the same bug.

Changing a global between def and defonce was silently ineffective: the
guard lives in the startup function compiled into the host, which a reload
cannot republish. Session.compatible refuses both directions and says to
restart; editing the value stays allowed.

And global/<n> no longer leaks into the signature refusal when a def is
retyped — the global loop names the same fact in words a reader can act on.

flan check prints def, defonce or defconst off grerun; (defvar) with no
arguments names the shapes rather than offering (defonce ); the docs,
plan.org, runtime comments and valgrind.supp are swept; BUILT.md states
the release-build cost and the uninit caveat.
2026-09-21 07:19:33 +07:00
a4c6b996ff def re-runs its initialiser, and defvar is renamed defonce
The trio the author decided on 2026-09-20 is now all built: def is CL's
defparameter — its initialiser runs on every daemon re-run, unguarded, so
an edited initialiser repaints the same storage on C-c C-c plus re-run —
defonce (Clojure's name for CL's defvar, per the author) initialises once
behind the .init~once. flag, and defconst stays the image.

One parse arm reads both forms; the difference is Ast.reinit, carried to
Tast.global's grerun. Emit.startup_plan gives a def no guard flag, and
Check.check_global lifts every def initialiser — zero and literal
included — into global/<n>, so the host's startup reaches it through the
function cell and a re-evaluated def swaps it (Session's def_inits;
Emit.redefinition declares the cell for a non-sibling target). The old
defvar spelling is refused with the rename and both compiling spellings,
and every program, test, doc and editor list is swept — except sand.flan,
the author's live WIP, whose seven defvar lines are flagged in FIX.org
and keep its three dependent tests red on this branch.
2026-09-21 07:12:04 +07:00
5a62770e52 Classes and generic functions, milestone 2's last item
A defclass is a named dyn map with a shape tag, and a generic function
dispatches on it two ways: CLOS's, where the dispatch value is the class
of the first argument, and Clojure's, where a body computes it. They are
one mechanism and not two — a class dispatcher is (class-of arg0) as the
dispatch function, which is what lets a method written for the class
point and one written for the value :point be the same branch.

    (defclass point [x y])
    (point 3 4)                 ; the constructor, positional
    (class-of p)                ; :point, or nil for anything else
    (defgeneric area [self] dyn)
    (defmethod area point [p] (* (get p :x) (get p :y)))
    (defmulti describe [x] dyn (get x :kind))
    (defmethod describe :square [s] ...)
    (defmethod describe :else [s] ...)

A slot is a key in the instance's own map, so get, put and has-key? are
how one is read and written and no operation was added for any of it.
What the class adds is the tag, and the tag lives in the object's header
rather than in a reserved entry — the queue's note said a reserved key
and this departs from it, because a key would be counted by len, walked
by the renderer and compared by equality, so every instance would answer
a length one larger than its slot count and print a key nobody wrote. A
header field cannot be reached by get or put at all, so no user key can
collide with it. It costs nothing: the map arm of flan_obj's union grows
to the size the view arm already had, and sizeof(flan_obj) is unchanged.
It needs no tracing either — the tag is an interned keyword entry, which
is immortal and is not a collector object.

The tag shows up in exactly three places: class-of answers it, equality
compares it (two instances of one class compare by their slots; an
instance and a plain map with the same entries do not, which is
Clojure's answer for a record beside a map), and both renderers print it
— #point{ :x 1 :y 2}, Clojure's own spelling.

None of the four forms reaches the checker. lib/classes.ml turns the
whole declaration list into ordinary defns at the top of build_program,
the way Shim.expand already turns a declare-c into a declare plus a
defn: a class becomes its constructor, a generic becomes one function
whose body binds the dispatch value and compares it down a chain, and a
method becomes a branch of that chain. It is a pass and not a macro
because a macro sees one form and the generic's body is not decidable
until every method is in hand — a method may be written above its
generic, below it, or arrive at a reload an hour later.

That last case is why the method bodies are inlined rather than lifted.
A generic is exactly one top-level name, so adding a method to a running
program is the ordinary redefinition of one function, through the cell
every call site already goes through. session.ml names the generic
alongside the method's own declaration name for that reason. The cost,
recorded rather than hidden: a method is not separately callable and is
not a frame of its own.

A dispatch that finds no method signals NoMethod, a prelude struct
carrying the generic's name and the dispatch value that missed. A
condition and not a trap, because a miss is something a program can be
written to answer, and handler-case around the call is the shape. Its
value field is dyn, the first condition here with one; the per-type
descriptor an item-2 struct carries is what the collector reaches it by.
No restart is established at the miss, which is BoundsError's decision
taken for BoundsError's reason.

Both backends, identically: the two new runtime entry points are
declared in emit.ml and the x86 backend needs nothing, since a dyn call
is a dyn call there. Deferred and written down in FIX.org: inheritance,
multi-argument dispatch, :before/:after/:around, named-slot
construction, unknown-slot checking, and computed dispatch values.
2026-09-20 15:36:13 +07:00
Joseph Ferano
5b0062219e Ask which lines allocate, and believe the runtime over the enumeration
[Check.memory_sites] is the pass [Check.no_gc]'s shape: it runs over the
finished program, answers a diagnostic list, and tells nothing downstream
that it ran. Two classes on the diagnostic's kind — the collector's heap
and an allocator the program named — so the CLI, the daemon and the editor
dispatch on one field and none of them reads a message to find the class.

[--warn-memory] on check and build prints them where errors go, in the
shape flycheck parses, without moving the exit status. [(:op "memory")]
answers the same list over a session's last checked program, needing no
program on the far end. [M-x flan-check-memory] paints it two faces fainter
than an error's, cleared by an edit or by asking again.

Two of the spec's own examples turned out not to allocate, and the
precision rule outranks the enumeration: (vec-new T) passes a capacity of
literal zero to flan_vec_init, which returns before the grow, and
flan_map_init takes no block at all and says so in its own comment. The
block arrives at the first push, which is the line that is marked. The
classifier reads the capacity argument rather than the symbol, which is
what lets slurp be marked through the same entry point vec-new is silent
through. FIX.org has the rest of the evidence.
2026-09-20 11:58:47 +07:00
7c7586ebc6 --no-gc is a pass, not a flag the emitter can see
The promise is that this program carries no collector, and the way to keep it is
to refuse every dyn rather than to emit a different program: a dyn value is one
the runtime allocates and the collector owns, and there is no smaller version to
fall back to. So it runs between checking and emission, answers unit or raises,
and hands the very same program on. Emit has no field to branch on and is told
nothing.

That is what makes the byte-identity claim true rather than approximate, and it
is tested by compiling three annotated programs twice and comparing the text. A
field, a mode, or a comment that mentioned the flag would break it on something
incidental, a long way from anything to do with dyn.

Every site is named, the way the global cycle refusal names the whole ring: a
reader who has to annotate their program wants the list, not the first one and
then another compile. Globals and signatures as well as body values -- the two
files it is tested against report nine sites each, and the floors are set under
that so an added line does not fail the test and a pass that named one site and
stopped would.

The four programs run at -O2 and -O0. dyn-boundary is asserted on its exit
status as well as its output, because the boundary is only interesting in that
it can fail and a test that showed it working would be testing the easy half.
The x86 survey skips them by name: a REFUSED there means a node that backend has
stopped lowering, which is a regression, and this is the opposite -- a lane that
has not started. Take a name off llvmonly when the lowering arrives and the
survey will say whether it works. 128 match, 0 differ, 0 refused.

Checked while writing these: a dyn function with an early return pops its roots
on both paths, and one with a defer pops on the transfer path too.
2026-09-19 06:33:28 +07:00
14416a877f The dev loop is the x86 backend, and --llvm is how to leave it
flan dev takes the hand-written backend unasked now: it is what that
backend was written for, it halves the C-c C-c round trip, and nothing it
builds outlives the session. Every other command is LLVM by default and
unmoved, which is what keeps lib/x86.ml's calling convention licensed.

--debug picks LLVM on its own rather than landing on the refusal: a
redefinition module from this backend carries no line table, so the one
flag someone types to get a debugger must not choose the backend without
one. --x86 --debug together is still refused, and now says which to drop.

What the flip costs is more than the forms this backend refuses. It
pushes no shadow stack, so a session built by it stops on an error and
cannot say where -- and the agent answered that with "this program was
not built with --dev", which is false of an x86 dev host. Dev.ask
rewrites it; the IR view refuses by name instead of failing to find a
define in a .s file; and a refusal at the host build or at C-c C-c names
--llvm rather than stopping at "unsupported".

flan-daemon-args carries the flag from Emacs, spliced into the one
argument list the daemon buffer's first line is now printed from.
2026-09-19 04:01:39 +07:00
0fd83dc95e The driver refuses in sentences, and CI exists 2026-09-17 22:23:19 +07:00
96e5fab77f flan run does not offer --dev, and says so rather than building one
--dev builds a program whose call sites go through indirection cells so
something can attach and redefine through them. Nothing can attach to a
process this command builds, execs, waits for and deletes, so the flag had no
meaning here -- and an --x86 --dev route through run would have falsified
Build's own statement that flan dev never reaches that fork because --x86 is
read only by flan build. It falls into the refusal arm with a sentence
instead.
2026-09-17 22:12:54 +07:00
dfb02ee26a A Flan struct is a JS object, and the memory model does not come along
lib/js.ml lowers the same checked Tast the other two backends take to one
CommonJS file, by object mapping rather than linear memory: docs/DISCUSS.md
item 5 settled that fork before this was written, and item 5's consequence is
the whole shape of the file. Object mapping means the host's collector owns
every value, so there is no (Ptr T), no free, no arena and no allocator, and a
program that uses one is refused by name with a location rather than compiled
badly.

flan build --target=js leaves Build.executable through its own two lines,
before anything that assumes a clang: there is no object to compile and no
linker to run. --dev, --debug, --sanitize and --x86 are refused there rather
than swallowed. Js.Unsupported exits 3 beside X86.Unsupported, so a sweep can
count refused-by-name apart from did-not-compile.

What runs end to end: integer and float arithmetic with the normalisation each
width needs, let, if, while with break and continue, calls, function values,
structs, fixed arrays, slices, unions, options, match, and println through the
same structural printer the other backends walk.

Value semantics is the trap the object mapping sets and the reason the header
carries a section on it. A Flan struct and a fixed array copy on assignment and
a JS object does not, so every site emit.ml memcpys emits a generated
Point$copy here. Fable's JS backend faces the same question for F# structs and
answers it the other way -- it inserts no clone, and its Rust backend does --
so the divergence is deliberate and the survey pins it.
2026-09-17 22:05:25 +07:00
652361e169 A missing file is a sentence, and flan run's two argument lists are told apart
with_errors had no Sys_error arm, so flan check nosuch.flan ended in OCaml's
default handler; the daemon has had that arm since before the CLI did. A
Not_found backstop joins it — nothing reaches it today, and the day something
does the failure should name the file rather than say nothing at all.

flan run handed every flag it did not understand to the compiled program:
flan run game.flan --debug built at -O2 and gave the game a --debug. Build
flags are now the build's, -- ends them, and an unknown dash argument before
-- is refused by name with -- named as the way to mean it for the program.

-O0 through -O3 get a spelling on build and run, which they did not have at
all: Build.default pinned -O2 and --debug was the only route to anything
else. Four levels and not five, because -Os is clang's and llc rejects it,
and the same string reaches both. --debug with a higher level is refused
rather than quietly overruled by Build's own -O0.
2026-09-17 21:44:55 +07:00
6cc94e00d6 defunion is C's union, and reading the member you did not write is defined
The name freed up by the rename now means what C means by it: the members
overlay one storage, the size is the largest of them, the alignment the
strictest, and nothing anywhere records which one was written. It serves
two things that wanted it. Binding a C header means holding the union the
library holds and reading whichever member the library's own tag says is
live -- a tag Flan cannot see, because the rule relating them is prose in
a manual. Overlaying an f32 on a u32 to look at its bits is the other,
and it is the same read.

So that read is defined rather than refused. This is the one place in the
checker where bytes win over safety on purpose, and the alternative was
not a safer language, it was no feature: type punning *is* reading the
member that was not written. The promise is the one C's implementations
make and C's standard does not -- the layout is the target's, the bytes
are the bytes, a read is a reinterpretation of them -- and what is not
promised is anything about bytes nobody wrote, where a member wider than
the one last stored reads a tail that is indeterminate exactly as a
struct's padding is. ZII narrows that to almost nothing: a union starts
all-bytes-zero unless uninit says otherwise.

uninit on one is allowed, unlike on a defdata. The refusal there was
never about garbage; it is that a tag steers, and a tag no case names
falls past every comparison in a match into a block LLVM may treat as
unreachable. An untagged union steers nothing.

Which is also why three things are refused, each for a reason that does
not expire with a milestone. No move-only member: nothing knows which
member is live, so nothing can tear one down, and unlike the struct and
defdata refusals this is not waiting on recursive teardown -- there is no
fact for teardown to read. No bool at any depth: an i1 loaded from a byte
that is neither 0 nor 1 is a value the optimiser may assume cannot exist,
and a union is the only type that can produce one. No defdata at any
depth, for the reason uninit gives, arriving the other way round. An
Option member is fine and the walk says why: its match is a tag test and
a branch, not a chain with an unreachable tail.

Two members in one literal, a match on a union, a union map key and a
member written into a global initialiser are each refused by name.

A union is a field list whose every offset is zero, so it travels as a
Tast.structure and the checker, the emitter and the x86 backend each grow
one table rather than one shape. A value is a zeroed temporary and a
store -- Set over Pfield, which every backend already has -- so there is
no new IR node and no layout rule spelled out a second time per backend.
The LLVM type is the blob clang gives a union, the DWARF is
DW_TAG_union_type with every member at zero, and the printer names the
type and does not walk it: it cannot know which member is live, and one
of them may be a pointer.

cimport can now check what it could not. A C record holding a union
member was not recorded at all, so the defstruct beside it went unchecked
rather than checked wrongly; a named union member resolves to a defunion
now and the whole record is compared field by field. The defunion itself
is compared against the header's union as a set and not in order --
every member is at offset zero, so a permuted one is the same type and
reporting it would be a finding that is not one -- while a member the
header has and Flan lacks is reported, because that is what changes the
size. A defunion against a C struct, or a defstruct against a C union,
is reported in both directions. An anonymous union member is still
skipped, and the comment now says that the gap is on the Flan side:
there is nothing to declare.
2026-09-17 19:54:32 +07:00
ff2c949361 The tagged sum is defdata, and the old spelling is an error by name
Flan's tagged sum has been spelled defunion since it landed, which was
accurate right up until the language wanted C's untagged union as well.
Both cannot be called the same thing, and the tagged one is the one with
an alternative name that says what it is: a case, its fields, and a tag
that steers which case is live is a data type, not a union.

So the form is defdata everywhere -- the parser, the AST, the checker,
both backends, the prelude's Form, the editor's font-locking and imenu,
the docs and every .flan file in the tree. The internal vocabulary moves
with it: Tast.union is Tast.data, uname is dname, the tables the checker
and the emitter keep are datas. Leaving them would have inverted the
words permanently, with surface defunion meaning one thing and
env.unions meaning the other, which is exactly the kind of drift the
comments in those files exist to prevent. What did not move is case,
variant and vfields: a tagged sum still has cases, and it still has one
live at a time.

defunion is not kept as an alias. An alias would compile the day the
untagged form lands and mean the opposite of what it used to -- the same
silent misparse that made defn's return type mandatory, and worse,
because the reader would have no reason to look. The old spelling is a
named refusal instead, parse/defunion-renamed, which says what it is now
called and that the name is reserved for something else. It fires on the
head alone, so (defunion U [A B]) -- which would otherwise have parsed
cleanly as one field A of type B -- is refused with the rest.
2026-09-17 19:03:27 +07:00
238f65db59 A listing says which form it came from, and what each slot is
`flan emit --x86` printed a three-line header and then nothing but .byte
blobs. The information was all there and none of it was written down.

Each run of bytes is now headed by the Flan form that produced it, with the
position it was written at, indented by how deeply the form nests. The
headings are queued rather than written, so a form that emits nothing does
not leave its heading on the next form's bytes; atoms queue none at all,
because a literal operand would otherwise steal the heading standing above
the imul that consumes it.

Above each function is a frame map, which is the half no disassembly
recovers: every value in this backend lives in a frame temporary, so
-0x20(%rbp) is the whole vocabulary of the listing and nothing says what it
means. It is read out of what emit_fn already keeps, so it cannot drift.
Beside it, where the arguments arrived and whether there is a hidden sret.

And the bookkeeping is named where it appears -- the transfer guard, the
bounds triple, the arithmetic guards, rep movsb, the dev indirection cell --
with each explained once in a legend at the top rather than at every site.

Always on for `emit --x86`, which exists to be read, and never for a build,
whose .s is a temp file handed to clang. spike/x86/annot.sh is the check that
this costs no byte: emit both ways, assemble both, compare every section.
342 SAME / 0 DIFFER over the corpus in default, --dev and --debug. dump.sh
now shows the annotated listing beside objdump's disassembly -- why beside
what, which is the pairing that answers the mnemonics question.

survey.sh has not been run on this; see the handoff.
2026-09-14 11:54:36 +07:00
f77216212e Every lowering of one program, side by side 2026-09-14 11:05:35 +07:00
f182fb4728 flan dev --x86: the host and its modules, chosen together
Item 3, and the reason the backend was written. Until now --x86 was read only
by flan build's argument list; the daemon built both halves through LLVM, so
none of this reached the dev loop at all.

The choice is a session setting, not a per-command flag, and it is spelled
exactly as [debug] already is -- one field on Session.t, set once in Dev.start,
carried on every change the session emits. session.ml's comment on [debug]
already gives the reason and it is the same one: the modules have to match the
process they are loaded into. Session.redefinition is the single place that
picks a backend, so the six call sites cannot disagree and the refusal has one
home. Session.change carries the answer beside the text, so the builder and the
text can never come from two different decisions.

There is no fallback and there must not be one. X86.redefinition refusing a form
is reported to the editor; quietly building an LLVM module instead is precisely
the crossed pair flan.abi.x86 exists to refuse at dlopen. A refusal reaches the
editor as a diagnostic like any other -- X86.Unsupported is re-raised as a
Loc.Error at the form it is about, because every caller already handles that and
none handled the other, and a session that died on the first unsupported form
would be worse than one that says so and stays up.

flan reload got the same flag at the same time. A command that could build a
module for a host the other backend compiled is how the crossed pair was
reachable from the CLI at all; the aggregate handoff's two-line reproduction no
longer has a second half.

And the finding: flan dev --x86 refuses the merged daemon. A merged build is the
program and the compiler in one process, and the compiler expands macros by
dlopening a module Build.macro_module made through Emit.program, cached on disk
by the macro source rather than by the backend. The merged host is linked
-rdynamic so a redefinition module can reach its cells, which also exports every
flan.* body it has -- so the macro module's own copy of a prelude function is
interposed by the host's. With an LLVM host nobody notices. With an --x86 host
the caller is LLVM and the body it lands in is this backend's, and the process
dies inside flan.[clamp] during the first macro expansion, before the program
has started. flan.abi.x86 does not catch it and was never meant to: a macro
module deliberately neither defines nor requires a marker. The honest fix is
hidden visibility on a macro module's Flan bodies, which changes the cached
object for both backends and wants a lane of its own. Until then the refusal
names the mechanism and the remedy, and --two-process has no such meeting.

start_merged keeps its --x86 plumbing, unreachable for now, because it is the
half that is right and will be wanted the day the macro module is fixed.

test_dev.ml drives an --x86 daemon through C-c C-c, C-x C-e, a literal, a new
defvar with a value of its own and a new defn, and asserts (twice fresh) is 82 --
which only holds if both registry lookups resolved. The merged refusal is
asserted there too. bin/main.ml learned to print a bare Failure as a sentence
rather than an uncaught exception and its backtrace.
2026-09-14 10:34:49 +07:00
9d5689ffa2 Every citation of a moved document now resolves from where it is written 2026-09-14 07:12:27 +07:00
3775aaf6c3 Merge branch 'worktree-agent-a744a6fee4839672c' into dev-loop 2026-09-13 19:44: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
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
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
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
324d1c6c60 The generated bindings are committed, and the hand-written three stay excluded from them 2026-09-13 08:27:17 +07:00
85ef56f657 The bindings are committed, and regeneration is what checks them
generated.flan carries the 253 declarations the importer reads out of raylib's
header, so a build needs libraylib linkable and no header at all. The opt-in
no longer decides how many bindings a package has — every build now gets all
425, they are greppable, and they diff when raylib moves.

What that gives up is the build-time check, so `flan generate-c` is the only
thing that writes the file and it compares first: every defstruct against the
header's record, every hand-written declare-c against the header's signature,
and it writes nothing when they disagree. Against the 5.1-dev header on this
machine that is ten real differences and no write.

The 172 hand-written lines stay, and not out of caution. Everything the
generator emits agrees with the header by construction, so diffing generated
output against its own source is a tautology; the hand-written lines were
transcribed by a person, so they are the only thing here a header can
contradict. All ten of those differences came from them.

`bindings` beside `headers` is what survives regeneration, because a hand-edit
to a committed generated file does not. Two directives: `exclude` drops
raylib's three allocator entry points, and `name` gives the 19 generated
predicates the `?` spelling the hand-written ones already use.
2026-09-13 08:07:40 +07:00
41b60d2e4a The daemon cannot be handed a list by accident
Loc.Errors is a second exception, and the handlers in the session and the
daemon name only Loc.Error — so a list reaching them is an unhandled
exception and a dead session, which is the one thing the dev loop exists to
prevent. A flag on the function the session already calls left that one
label away from happening. Parse.program_all and Check.program_all are
separate names, so the session's call site has to be edited by a person for
its behaviour to change, and the guarantee stops being a default argument.

Placeless diagnostics now sort last rather than first. A wrong main signature
is raised against unknown, which is line 0, and sorting on the number alone
put it above every error that can actually be clicked. It is a real error and
it is not anywhere, so it goes after the ones that are.
2026-09-13 08:04:45 +07:00
2efed1630f The compiler finishes the file before it reports
A sink collects what a pass found so the pass can go on to the next thing.
It is switched on by the caller, not by the code that raises, which is what
leaves the interactive path untouched: the daemon checks one form, asks for
a sink that is off, and still gets one exception.

Two resync points, and both are places the work already had a boundary. In
the parser it is a top-level form — the reader found where each declaration
ends, so skipping a bad one cannot lose its place, while inside a
declaration there is no such landmark and one bad defn stays one error. In
the checker it is the two passes: pass one, which builds every name and
signature, still stops at the first refusal, because a signature it could
not make sense of leaves a hole that pass two would report once per mention.
Thirty unknown-name lines under one wrong signature are not thirty errors.

Pass two is where the volume is and where collecting pays, and by then every
signature is sound, so a body that fails cannot make the next body fail.
That is what makes a declaration a resync point needing no resynchronising.
2026-09-13 07:56:17 +07:00
2f1d20dfc3 The span gets drawn: the source line, with the thing underlined
The first line of an entry is still exactly file:line:col: message, because
that is the GNU format compilation-mode already parses and the whole of the
editor story. Everything under it is indented, which compilation-mode
ignores, so the underline is free. A note gets an entry of its own rather
than being folded into the error's block — that is what makes the second
place somewhere next-error can go, and is the reason notes carry locations.

Every part of it degrades to the bare first line: a location the checker
invented has line 0, the prelude and the REPL have names that are not paths,
and a file can change under us between being read and being blamed. An error
printer that can raise is worse than one that prints less.
2026-09-13 07:51:37 +07:00
86296dd99a An error stops being a location and a string
Loc.Error now carries a diagnostic: a stable kind, a span, notes that each
have their own span and severity, and the macro expansion it came from. The
notes are the part that was actually missing — "this is wrong here" plus
"because of that, over there" is two places and two explanations, and a
single string can state only one of them.

The compatibility story for the daemon, which was the open question: the
single-diagnostic exception stays the single-diagnostic exception. Session
and dev evaluate one form and have one failure to report, so they take a
location and a message out of it with Loc.summary and are otherwise
unchanged. A second exception carries a list, and only a driver that
compiles a whole file raises it, so nothing interactive has to know it is
there.

No message text changed.
2026-09-13 07:49:44 +07:00
1a1486a17b The compiler moves into the program, and the socket does not move at all
`flan dev` now builds one binary that is the compiled Flan program and holds
the whole OCaml compiler, and execs it. The program keeps main() — macOS needs
the window there — and caml_startup happens on a pthread beside it, next to the
listener flan_agent.c already starts. The editor's socket and the wire protocol
are untouched: Emacs cannot tell the difference.

Two rules are written into lib/dev.ml rather than discovered later. The game
thread must never call into OCaml, because a native thread has no safe points
and so can never be stopped by the collector — which is exactly why a frame is
never paused, and exactly what one convenient direct call would undo. And no
OCaml value may be stored in Flan memory without caml_register_global_root,
which is the way the spike's "the GC does not touch the arenas" measurement
stops being true.

The link is spelled in dev.ml out of Build's existing public pieces rather than
as a mode of Build.executable: lib/build.ml belongs to another lane this week.
It should collapse into Build once that lands.

A Flan main does not return — Emit ends it with flan_exit and an unreachable —
so in one process that call would take the compiler down with a program that
merely finished. flan_rt.c grows a hook, null in every other build, that the
merged entry point uses to flush, close stdout and park. The compiler then
learns the program is done the same way the daemon did: the pipe reads EOF.

--two-process keeps the old shape for a machine that cannot build the compiler
object, and nothing has been deleted.
2026-09-12 21:44:57 +07:00