226 Commits

Author SHA1 Message Date
3e15328acd Where the web target stops, including the one that is a missing include
BUILT.md gains the section on the third target and corrects the claim it
already carried: emscripten_set_main_loop had the browser fact right and drew
the wrong conclusion, because asyncify answers the same fact without cutting
main in half.

NEXT.md gets the four holes. sand.flan has no web build, and the proximate
cause is that vendor/agent/flan_agent.c:426 uses struct timeval without
pulling in sys/time.h, which glibc gives it transitively and emscripten does
not; sand's main calls agent/start unconditionally so Reach cannot prune it.
Beneath the include is the decision worth making rather than patching around:
the agent is a socket server and the browser has no sockets, so the honest fix
is to refuse vendor:agent on a web target the way --dev is refused.

Assets are two questions and only the easy one is about emscripten.
--embed-file is a linker argument and so already expressible as an @web line.
The hard one is that the file doing (rl/load-texture "brush.png") is
structurally the one file that cannot say so: Load hands out lflags only for a
directory package, and main is not exported, so a program can never be one. No
flag was invented for it.

And nothing has been opened in a browser, asyncify's cost is quoted rather
than measured, and audio and threads on web are untried.
2026-09-12 10:45:42 +07:00
8d048123ca What a headless test can honestly say about a page
test_web.ml never opens a browser and never will. What it asserts is the shape
a browser needs — three files, a module that starts with the wasm magic, a
page that references its own JS and carries the canvas — plus the one
execution available without a DOM: node runs the emitted JS and gets "ok".

For raylib it builds core-basic-window.flan unchanged, which is the claim, and
then reads the module for the two things that would be false if the mechanism
were wrong: an asyncify_start_unwind export, and a glViewport import that can
only have come from raylib's web platform. Import and export names are plain
strings in the binary, so this needs no wasm reader.

Both halves probe rather than assume, the way the wasm32 case does: emscripten
may not be installed and the raylib archive is not in the tree, and a missing
piece is a skip with the reason.

The four refusals are asserted by name — --dev, --debug, --sanitize,
Build.shared, and flan run --target=web from the CLI — because "it falls out
of the existing predicate" is the kind of thing that stops being true quietly.
2026-09-12 10:45:42 +07:00
f51559030a raylib for the browser is built rather than installed
No emscripten port provides raylib — emcc --show-ports offers contrib.glfw3
and nothing else nearby — so build-web.sh clones raylib at the 5.5 tag and
compiles its seven modules with -DPLATFORM_WEB -DGRAPHICS_API_OPENGL_ES2 into
one archive under vendor/raylib/web, which is gitignored along with the
checkout it came from.

5.5 because that is the tag whose .so.550 the host links. raylib.flan carries
raylib's struct layouts and enum values, and two targets built from different
raylibs would disagree about them without saying so.

rglfw.c is not among the modules: the web platform uses emscripten's own GLFW
port, which is why link carries @web -sUSE_GLFW=3. No headers are installed,
for the same reason the host build needs none — the generated shim declares
the prototypes it uses.

link now names the host library under @native and the archive under @web,
through ${FLAN_RAYLIB_WEB}, so a web build with the variable unset is refused
with the name of the variable rather than a page of undefined GLFW symbols.
The one thing this costs: a wasi build that reaches raylib now fails on
undefined symbols instead of on the missing -l:libraylib.so.550.
2026-09-12 10:45:27 +07:00
c2dc4d4244 The browser is a third target, and emcc is its driver
flan build --target=web produces a page, its JS and a .wasm. The two wasm
targets share the word and almost nothing else, so is_wasi and is_web are
separate predicates and is_wasm is their union — the union is exactly the
facts about the machine, 32-bit pointers and no dlopen, which is what the
refusals are about.

Everything the wasi target has to find by hand is what emcc already is: no
sysroot, no builtins archive, no shadow resource directory, and no
__main_argc_argv shim, because emscripten's start code calls main under that
name. target_flags for web is empty and the only thing checked is that emcc
exists. The one fact this rests on is that emcc takes a .ll on its command
line, so Emit's output needs no change.

The main loop is -sASYNCIFY rather than emscripten_set_main_loop, which
BUILT.md predicted. The prediction had the browser right and the cost wrong:
set_main_loop wants the loop body as a callback, so every example that writes
(until (rl/window-should-close?) ...) would be split by hand into an init and
a tick and would stop being the native program. raylib's web platform is built
for asyncify instead — WindowShouldClose on PLATFORM_WEB is an
emscripten_sleep(16) that returns false — so the loop yields at a call it
already makes and no example changed a character. Asyncify goes on every web
link, because whether a program blocks is not a question Build can answer and
a per-program flag set is a per-program cache key.

A link line may now be addressed to one target — @native, @wasi, @web — and
${NAME} expands from the environment. The selection is here and not in Load,
which reads the file, because Load resolves imports before a target is chosen.

The object cache now keys on whichever compiler the target uses, so an emcc
object and a clang one of the same source cannot collide. The refusals name
the target that was asked for; --sanitize on web says the weaker truth, that
emscripten ships an ASan and nothing here has ever run it.
2026-09-12 10:45:18 +07:00
3afce2aeac Never restarting is the thesis; say where the holes in it are
The manual described the signature-change refusal as though it were the
design. It is not, and session.ml already said so at the refusal: a
signature change should make a new version, leave old callers on the old
one, and warn at the stale sites. plan.org calls it signature generations
and stale-caller warnings, and it is milestone 7's unfinished half.

The struct-layout rejection is the decided one and stays. Conflating them
made a placeholder look like a rule.
2026-09-12 09:38:26 +07:00
50ed2cbef0 Merge branch 'sanitize' into dev-loop
ASan was instrumenting none of the Flan half: it is an LLVM pass that
only touches functions carrying sanitize_address, which clang's C
frontend adds and hand-written IR does not. Globals get redzones either
way, which is why it looked right. emit.ml puts the attribute on every
define now, and a control asserts the report.

UBSan reaches no Flan code and no flag changes that -- its checks are
frontend-emitted branches, not a pass -- so shift UB and the NaN cast are
not answerable this way. Left as a compiler question, pinned by a control
that must not report.
2026-09-12 09:38:11 +07:00
3c7fdfc076 The left-redzone claim was inferred, not measured, and was wrong
A negative index into a global is silent in bounds.flan, which is
measured. "Because a global has no left redzone" was the explanation
put on it, and it does not survive the obvious test: declare another
defvar in front of arr and arr[-1] is caught, landing in that global's
right redzone. Underflow detection is a question about what the linker
put in front of the object, not about the access. Corrected in
test_sanitize, BUILT.md and NEXT.md.

NEXT.md's entry also goes back to its stated size. It had grown to 78
lines saying what BUILT.md says in the same commit range -- the
attribute, the -O0 decision, the bounds.flan table -- which is the
half-build-log the file's own header warns about. What stays here is
what is next: the UBSan gap as an undecided compiler question, the four
daemon-path buffers the corpus never reaches, and Valgrind.
2026-09-12 09:34:05 +07:00
c806125742 What the sanitizer sweep found, and what a clean run does not prove
NEXT.md's queued section becomes a landed one. The headline is not the
flag: ASan reaches Flan code only because Emit now attributes every
define, and UBSan reaches none of it and has no lever that would, so the
shift-UB and float-cast items that section listed are still open and are
a compiler feature rather than a flag.

The clean result is written with its reach. println.flan pushes a
1100-character string through escaped[1024] on purpose, so that buffer
is genuinely covered; scratch[64] never sees more than 20 characters;
and the 4K result cap, the dev registry guard, SNAP_MAX/SNAP_NAMES and
condition_name[128] are on the daemon path and not in the corpus at all
-- read, not tested. Two defects fixed, both found by reading. Three of
bounds.flan's six out-of-bounds cases caught with the checks off, with
the other three tabulated and explained, and the caveat that ASan sees
out-of-object and not out-of-subobject access, so three of six is a
ceiling and not a measurement.

BUILT.md gets the durable half: the attribute, the absent UBSan lever,
why --sanitize does not force -O0 when --debug does, and the -O0/-O2
divergence that earned it.
2026-09-12 09:31:32 +07:00
64ce9ec05b A manual for the Emacs side, in plain language
The site's Emacs section is a key table and four paragraphs -- a
reference for someone who already knows the shape. This is the other
document: how to set it up, what the loop actually is, what each buffer's
own keys do, and what to do when something is refused.

The three buffer keymaps were never written down anywhere a user would
look; they were only in the define-key calls.
2026-09-12 09:31:27 +07:00
5810fa286f A negative slice length read 63 bytes off the end of whatever it pointed at
(slice s 2 1) has length 2 - 1 - 2 = -1. flan_bytes_to_i64 and
flan_bytes_to_f64 both wrote their clamp as (size_t)n < sizeof buf - 1,
and (size_t)(-1) is 18446744073709551615, which is not less than 511 --
so k took the cap and the memcpy copied 63 or 511 bytes out of a
five-byte string constant. ASan calls it a global-buffer-overflow in
flan_bytes_to_i64; the regression case is in test_sanitize.

Every other (ptr, len) entry point in the runtime already guarded the
negative case -- flan_write_stdout tests n > 0, flan_escape_bytes and
flan_dev_emit both fold a negative length to zero -- so this was two
exceptions rather than a missing convention. A checked build traps on
the reversed slice before reaching either, which is why it took an
--no-bounds-checks run to show.

Also clamps the three snprintf shims that publish scratch as a slice.
snprintf returns what it would have written, not what it did, so a
format that overran the 64-byte buffer would hand out a length past its
end. No format here can: %g is 13 characters and %lld is 20. Found by
reading, and the sweep could not have found it -- nothing in forty
programs prints a number that long.
2026-09-12 09:27:46 +07:00
c41c5812a9 The corpus a second time, under the sanitizers, behind @sanitize
Twenty-eight programs built twice -- once plain, once sanitized -- and
compared on output and exit status, plus two positive controls that are
the only reason a clean result means anything: an out-of-bounds read
that must report, and a shift by the width of the type that must not,
because UBSan cannot see hand-written IR and this file would otherwise
be claiming coverage it does not have.

Its own alias rather than dune test. A sanitized program is a statically
linked 1.8MB binary and takes tens of seconds to link; the sweep is nine
minutes against the existing suite's seconds, and a test nobody will
wait for is a test nobody runs. dune build --root . @sanitize.

The checked sweep is clean. The unchecked variant -- ASan alone, with
Flan's own bounds checks off -- catches three of bounds.flan's six
deliberate out-of-bounds cases and is listed with why for the other
three: a global has a right redzone and nothing to its left, so arr[-1]
is invisible; a read past a string constant folds away entirely at -O2
and is caught only at -O0; and a reversed slice reads nothing at all.
ASan is not a substitute for the bounds checks, and now there is a table
saying which half it covers.
2026-09-12 09:25:43 +07:00
d803078699 Merge branch 'ergonomics' into dev-loop
sin and cos in the prelude rather than copied per file, with the caveat
sqrt does not have: IEEE-754 makes sqrt correctly rounded and requires
nothing of the kind for sine, so these are the one place the prelude may
disagree bit for bit between native and wasm32. A program hashing output
across targets must not route the hash through one.

Arithmetic folds left over as many operands as you write, and so does the
constant folder, which otherwise refused (defconst n (* 2 3 4)) after the
checker had accepted it. One operand is refused by name: there is no unary
minus, and the message points at (- 0 x), which is what the prelude writes.

The typed let binding is a grammar question and is written up rather than
guessed at. The break banner premise had gone stale -- check.sh already
runs that demo under a timeout and keeps what it printed.
2026-09-12 09:13:25 +07:00
aa0b98f2e5 Merge branch 'enum-convert' into dev-loop
An enum and an integer convert both ways when you write the conversion
down, and not otherwise. Zero instructions in both directions -- emit's
cast already reduces an enum to its i32 before choosing an opcode -- so
what this costs is only that you have to say it.

The property worth keeping is kept: a bare integer is still refused for
an enum parameter, so :spcae is still an error at the call site. What is
gone is the wall, where an index could not reach an enum parameter at all
and the second declare-c escape was closed too.
2026-09-12 09:12:49 +07:00
9864f41aca Why a typed let binding is a grammar question, written down not guessed
Looked at annotating a let binding and stopped at the surface syntax,
which is the whole of the problem. Everything underneath is already
built: bindings carry a type, load renames through it, and the checker
consumes it as the want for the value. What is missing is a way to write
it that a parser with no types can read -- let is a flat list of pairs,
so it cannot disambiguate by argument count the way defvar and defconst
do, and [4 rl/Vector2] is a perfectly good array literal.

So NEXT.md gets the three candidate surfaces and a recommendation rather
than a commit picking one: give zeroed its type as an argument. It is one
branch in the checker, no new syntax, and it answers the case that
actually hurt -- a fixed array with nothing to infer from -- without
contradicting plan.org's "annotate function signatures, infer locals".

The two items beside it in the same ranked list are marked fixed.
2026-09-12 09:11:53 +07:00
d2bd022094 An enum and an integer convert, both ways, when you say so 2026-09-12 09:11:34 +07:00
387ceb7a2e Fold the constant folder over as many operands as the checker does
defconst's folder matched a call of exactly two arguments, so once
arithmetic went n-ary a length written (* 2 3 4) type-checked as an
expression and was then refused as "not a compile-time integer
constant" -- a form that looks constant, is constant, and was told it
was not. Same left fold, same operators, and % stays at two because it
does in the checker.
2026-09-12 09:11:13 +07:00
1898be6cb0 Repin the break banner, which has numbered its restarts since 4a6a8fa
The .out file and the two places that quote it in prose had the banner
from before restarts were numbered, so check.sh had been red on
breakdemo since that commit. The .out is regenerated from the same
build --dev and timeout run check.sh does, rather than typed: the leading
blank line and the three spaces before each number are part of what is
compared.

The page gets a sentence it was missing. A number in front of a restart
is not decoration -- a restart is taken by position, because an inner one
can shadow an outer one of the same name -- and the banner showed the
numbers without the page ever saying what they were for.
2026-09-12 09:08:35 +07:00
ac5c7e9c2b A --sanitize flag, and the attribute without which it measures nothing
ASan is an LLVM pass but instruments only functions carrying
sanitize_address, which clang's C frontend adds and nothing adds to IR
written by hand. Passing -fsanitize=address to the clang run over the
.ll therefore instruments flan_rt.c and not one instruction of Flan: an
out-of-bounds read of a defvar array, built --no-bounds-checks, printed
its garbage and exited 0. With Emit naming an attribute group on every
define, the same program reports global-buffer-overflow in flan.main.

UBSan has no such lever. Its checks are branches the C frontend emits to
__ubsan_handle_*, not a pass, so -fsanitize=undefined covers the runtime
and nothing else; (<< 1 32) still goes unremarked. Recorded where it
will be read rather than discovered again.

The flag does not force -O0 the way --debug does -- the UB worth finding
is what the optimiser does with it -- and it does pull in -g, since a
report with no line costs more than the build. compile_c's cache key now
digests the same cflags list the command line uses, because an
unsanitized flan_rt.o served out of the cache links fine and reports
nothing.
2026-09-12 09:08:27 +07:00
838548b548 Merge branch 'allocator-decisions' into dev-loop
The four things spec-memory.md never said, settled before any of Vec is
written: storage is released by the allocator and never by a scope, drop
takes a pointer and runs only inside free, alignment is a property of the
type computed at the call site, and allocation failure signals
StorageExhausted with a retry restart.

The interaction is the payoff: release fires only at free and at region
release, region release refuses drop-types, so drop fires at exactly one
place. And the premise behind the first was stronger than thought --
(defer (free v)) for a let-bound v is not expressible at all today, since
defer is refused anywhere but a function body's top level.
2026-09-12 09:08:15 +07:00
86c598773d Say when storage dies, and what dies with it 2026-09-12 09:07:26 +07:00
255367c6dc (+ a b c) and the rest of the operators that fold
Arithmetic, min/max and the three bitwise combining operators take two
operands or more now and fold left, which is what the examples were
already writing. The first pair still goes through `binary`, so the rule
about which side decides the type is unchanged for every call that was
already legal, and each operand after it is checked against that type.

min and max fold their own way: every step puts both sides in slots, the
accumulated pick included, so three operands are two nested lets and each
is still evaluated exactly once. Reusing the previous `if` as an operand
of the next would have copied everything inside it.

Three things stay at two operands, each for its own reason. A chain of
remainders is not something anyone writes on purpose; a chain of shifts
would pass two counts that are each legal for the width and still shift
the value away entirely. And a single operand is refused rather than
guessed: there is no unary minus in this language -- the prelude writes
every negation as (- 0 n) -- and no reciprocal, so both say so and name
the form to write instead.
2026-09-12 09:07:10 +07:00
5d65dcf1c8 sin and cos in the prelude, with the caveat sqrt does not have
The gestures testbed declared sinf and cosf at the top of its own file,
which is a copy in every file that wants an angle. The reason sqrt is a
declare does not transplant: IEEE-754 makes sqrt correctly rounded and
requires nothing of the kind for sinf, so these two are the one place in
the prelude where native and wasm32 may disagree bit for bit. That is
written down beside them, along with what the fix would be if a program
ever needs trig that agrees across targets.

Float abs stays unwrapped for the reason integer abs is -- it is
(max x (- 0.0 x)) over two builtins. The integer caveat does not carry
over and the note says so: -0.0 answers +0.0 and a NaN answers a NaN,
both checked.
2026-09-12 09:04:25 +07:00
3e3d3b28f0 Merge branch 'print-sweep' into dev-loop
print and println are the whole printing surface now. About 500 call
sites across 47 files, and the site documents either of them for the
first time.

Two pinned outputs moved and both are corrections. sand-headless hashes
to 15595743031174623232 rather than -2851001042534928384 -- the same 64
bits, printed unsigned now that hash-grid's u64 no longer goes through an
(i64 ...) cast, which is the bug the family's explicit widening invited.
And a trap column shifted because the call it names got shorter.
2026-09-12 05:42:57 +07:00
127aef75f5 Say in plan.org that write-stdout is all that is under print 2026-09-12 05:38:57 +07:00
b8019a96d5 Give the page a section on the two printers it never mentioned
print and println had no line anywhere on index.html — a builtin the reader
meets in the first example and is never told about. There is a section for
them now, between arrays and the prelude: what the walk covers, that an enum
comes back as its name and a Ptr does not get followed, that a string is raw
at the top and quoted inside a structure, and that the depth and span caps
are what keep a grid from printing a screenful. printing.flan is beside the
other examples so check.sh has been green on every line of it.

The prelude's table loses its output row, because the prelude has no output
functions left, and the fifty-odd example blocks follow the files they quote.

Two pinned things moved. sand-headless prints 15595743031174623232 where it
printed -2851001042534928384: same bits, read unsigned, because the cast that
made it signed is gone. bounds.flan's trap moved from column 28 to 19, which
is where (at xs i) now starts on that line.

And the indirection-cell illustration is defer.flan rather than hello.flan.
hello.flan cannot show a cell any more: its one call was to a prelude
function, and println is compiler-provided, so the smallest program makes no
Flan-to-Flan call at all. defer.flan's main calls work, and is already on the
page a few sections up.
2026-09-12 05:37:56 +07:00
96ab4c9cf0 Retire the per-type printers, since print says all of it
print-str, print-i64, print-f64, print-bytes, print-line and newline leave
the prelude. print and println are the whole printing surface now, and print
is the better call at every one of the sites that used them: it is the same
structural walk without the newline, so the no-newline case the family was
kept for is covered, and it takes the value as it is. The old print-i64
forced an explicit (i64 x) at every call site, because this language widens
nothing implicitly; that cast is gone from 127 places.

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

test-flan-dev.el and test_session.ml both reached for print-line as "a name
the prelude has"; they reach for rand-seed instead.
2026-09-12 05:32:25 +07:00
d1da17ff69 Split the log out of the plan
NEXT.md was 1,738 lines and about half of it narrated work that was
finished. BUILT.md takes that half — the reload primitive, cells, the
agent, the session, the daemon, the Emacs client, conditions, the FFI
shim, the layout, and the order it was all built in. Not deleted,
because the reasons in it are load-bearing and would have to be derived
again.

NEXT.md keeps what is left, and says at the top what the two files are
for, so it does not become a log a second time. The struck-through
milestone checklist goes with the log; its one live item, the milestone
4 loose ends, stays. Start here was stale in three places and is
rewritten: conditions are three steps of four, a restart is taken by
position, and find-restart is blocked on a type rather than on effort.

The raylib gap list and the reasons break was declined are written down
where the next session will look for them, rather than living in a
commit message.
2026-09-12 05:25:52 +07:00
b23bd8e377 Say that two formatted numbers cannot be held at once 2026-09-12 05:22:12 +07:00
cb47b98100 Merge branch 'string-of-bytes' into dev-loop
A [u8] and a string are the same 16 bytes at run time, so (string b)
is a reinterpretation with no instructions. What it buys is that a
number can reach draw-text at all, which five of the ten examples
wanted and none could have.
2026-09-12 05:21:58 +07:00
4001c3246c Merge branch 'dwarf-names' into dev-loop
A let-bound local is its own name under lldb now, and a redefinition
module carries DWARF when the daemon was asked for it.

Resolved against the println track in session.ml: the thunk keeps the
render walk's appended slots and gains the names beside them, the walk's
own scratch having none to keep.
2026-09-12 05:19:38 +07:00
421e09e0d6 A number can reach draw-text now
(string b) is the mirror of (bytes s) and costs nothing: emit.ml already
lowers Types.String and Types.Slice _ to the same %slice, 16 bytes at
align 8, so a string and a [u8] are the identical value at run time and
both directions emit as the argument itself. What changes is only what
the checker will let the value be passed to — which was the whole gap.

Two decisions, both written into check.ml's comment.

It does not check UTF-8, because `string` does not claim UTF-8. The
prelude settles it: valid-utf8? is an ordinary function you call when you
care, decode-rune / rune-at / rune-count all take [u8] and not string,
and decode-rune answers {:ok false :width 1} on a malformed byte rather
than assuming well-formed input. The one place the runtime treats a
string differently from a byte slice is flan_escape_bytes, for a string
nested in a printed structure, and that is a byte-wise escape table with
no decoding in it. A check here would be the only enforcement point in
the language, which is a claim the rest of it does not make.

It does not widen the literal-write hole. That hole is the other
direction — (bytes "Hi") hands back a writable-looking slice over
constant data — and this direction only loses the ability to write, so
the result reaches strictly fewer stores than its argument could.
Provenance is still what the other direction needs; nothing here waits
on it.

The one sharp edge is not new but is easier to trip over now, and is
recorded in both the checker and digits.flan: i64->bytes, f64->bytes and
u64->bytes all view the same static buffer in the runtime, overwritten
by the next call, and calling it a string does not copy it. Format, draw,
then format the next one.

examples/digits.flan keeps its three signatures and loses its middle: the
[10 string] table, the per-glyph pen and the digit arithmetic are gone,
and draw-int is one draw-text. What survives is the part (string ...)
does not answer — i64->bytes has no field width, so "%03i" is still
assembled, and f64->bytes is "%g", so fixed decimal places are still a
split into two integers. core-input-multitouch and
core-input-virtual-controls ignored the width they were given, so both
inline the draw and stop importing digits.flan entirely.

test/programs/string-of-bytes.flan at -O2 and -O0: a number round-tripped,
an empty slice, sub-views whose length is not the underlying storage's,
and the result across a declare-c boundary. The last is the one that
could have been wrong — "hello world" cut to five bytes has a space where
C wants a NUL, so a shim that trusted the bytes would print all eleven.
2026-09-12 05:19:23 +07:00
10fcde8ce9 Half of this file is a log; say where it should go 2026-09-12 05:17:49 +07:00
4723e49e4d Ask the object, not the text, whether the daemon built with -g
test_session.ml asserts that a debug session emits the metadata, which is
the unpassed-argument defect itself. It cannot see the other half: it is
Build.shared that turns the flag into -g and -O0 on the module, and a
daemon that dropped Build.debug from its opts would still emit perfect IR
and then compile it away — llvm.dbg.declare describes an alloca and
mem2reg deletes the alloca, so the symptom would be a module that looks
right in every text assertion and has no locals in the debugger.

So this drives a real `flan dev --debug`, sends one redefinition, and runs
llvm-dwarfdump over the .so the daemon actually wrote. The line table is
the needle because it is what a breakpoint in a .flan buffer resolves
against, and it names the file the form was typed in rather than anything
on disk. Skipped where there is no llvm-dwarfdump.
2026-09-12 05:17:13 +07:00
c05edef985 Write down that nothing has ever run under a sanitizer 2026-09-12 05:15:22 +07:00
d0a8339bb5 DWARF in a redefinition, and one flag that means it everywhere
Emit.redefinition has taken ~debug since it was written and was tested
with it; Session.eval never passed it, so every body installed by C-c C-c
lost its debug info in the running process.

Passing it alone would have been half a fix. Build.shared is what forces
-O0, and dev.ml built modules at -O2, so the llvm.dbg.declares would have
been emitted and then deleted by mem2reg: a line table, and no locals.
And a module with DWARF loaded into a host without it lines up against
nothing. So it is one flag — flan dev --debug and flan reload --debug —
and it sets the host build, the module builds and the emitted metadata
together. Off by default: a debug build is an -O0 build, and quietly
making every reloaded body -O0 changes the frame time of the one function
you are iterating on, in the loop whose point is watching that number.

What a dlopen'd module does to a breakpoint, measured against the reload
fixture rather than reasoned about:

  - lldb reads the new module's DWARF on the dlopen and says so: "1
    location added to breakpoint 3".
  - A breakpoint set by NAME gains a second location either way, so
    dlopen was never the difficulty. What the line table buys is that it
    stops with source instead of disassembly.
  - A FILE AND LINE breakpoint on the new body resolves only with it;
    without, it sits at locations = 0 (pending) forever.
  - A FILE AND LINE breakpoint on the HOST's copy stays pinned at
    locations = 1. That is correct, not stale: the old body is still
    mapped and every call site that has not gone through its cell again
    still reaches it.
  - The stack crosses intact — a frame in the reloaded .so and the one
    below it in the host each name their own .flan file.

    (lldb) frame variable
    (long) step = 10
    (long) prior = 11

The transcripts are in flan-dape.el, replacing the note that said the
module carries no DWARF yet.

flan-cnr.el's stack pane was refusing for the wrong reason. DWARF was
never its gap; nothing is attached to the stopped program, and a socket
cannot read another process's frames. Reworded to say that.

Source interleaving in the disassembly buffer is unblocked and not done:
objdump -dS interleaves a --debug module's Flan source correctly, so
Dev.asm_of needs the -S and a parse_listing that tolerates source lines.
2026-09-12 05:14:03 +07:00
afec482722 Ten raylib examples, and what they could not say
The first ten of raylib's core list, ported. Seven new bindings and the
named colour palette; nothing else was added, because a binding called
by nothing is the same as not having bound it.

The gaps they found are the point. No number reaches draw-text: i64->bytes
answers [u8], draw-text wants a string, and nothing bridges — five of the
ten wanted TextFormat and got a glyph table instead. And an enum parameter
cannot be driven by a loop variable: the index is an i32, the parameter is
an enum, neither converts, and a second declare-c with an i32 face is
refused because one C function gets one binding. Two correct rules that
compose into a wall.

None of the gaps expected blocked anything: no generics, no allocator, no
Vec, no escaping closure, no block-scoped defer. These are input-and-draw
programs over fixed-size state, which is the shape the language has.
2026-09-12 05:06:01 +07:00
e6594fd554 The name the source gave a local, all the way to the debugger
A let-bound local printed as s0 under lldb. Parameters were fine, because
the driver recovered their names from the AST and handed them down in
pnames; everything else was a slot index, since Check knew the name in its
scope list and dropped it at allocation.

Tast.fn now carries snames beside slots, Check fills it in at bind, and
Emit prefers it over pnames. A slot the compiler invented keeps s<index>:
fresh_slot takes the name as an optional argument, so dotimes' hidden
bound and the pair min and max evaluate into say nothing and get None
without any of their call sites changing. Naming those something plausible
would put a variable in the debugger that is not in the file.

Shadowing needed deciding rather than assuming. Every DILocalVariable is
scoped to the subprogram — the typed IR has no block structure to build a
DILexicalBlock from — so two slots called v landed in one flat scope, and
lldb answered p v with the outer one while the body computed with the
inner, which it did not list at all. A debugger confident and wrong is the
one outcome worse than s0, so a repeat of a name already bound in this
function gets a ~2 suffix: ~ is the reader's delimiter and cannot occur in
a source symbol, so v~2 is unambiguous and visibly the compiler's. It is a
way of not lying, not a way of being right; scoping properly means a
lexical block per Let and the declares moved out of the entry block.

  (lldb) breakpoint set --file debug.flan --line 20
  (lldb) frame variable
  (Cell *) c = 0x00007fffffffd970
  (int) n = 41
  (int) bump = 42

The test breaks after the binding on purpose. A name breakpoint stops on
the function's first line, before the let has stored anything, and a
variable is nominally in scope from entry — so the name is checked there
and the value only where it means something.
2026-09-12 05:05:40 +07:00
2f8436018c Merge branch 'restart-at' into dev-loop
A restart the innermost frame shadows could be seen and not taken;
it is taken by position now, off a snapshot that stopped moving under
the break loop. The editor half this was briefed as building already
existed — the stale line that said otherwise is fixed.
2026-09-12 05:04:30 +07:00
0c9f043bb1 Stamp a choice with the break it was chosen from
The snapshot made the listing stand still; it did not make the handoff safe.
A choice is validated against the snapshot on top when the request lands and
resolved against the snapshot on top when the game thread next looks, and
between those the loop runs evaluations — one that errors pushes a break of
its own, whose loop reaches the flag first and takes its own index 2 for the
one somebody picked off the outer list. That is the failure this change exists
to end, arriving by a different door.

So each snapshot carries a generation, a choice carries the generation it was
validated against, and a loop claims only what is addressed to it. A mismatch
is left set rather than dropped: the listener already answered ok, so the break
it was meant for must still be able to take it. Depth could not do this — an
outer break resuming and a new one starting reuses the number. The snapshot is
also popped before the depth comes down now, so the two never describe
different breaks.

The client's own new path gets tested too: the candidate table is pure, so the
shapes a real daemon will not easily produce are checked directly, and the
break-and-resume test now goes through restart-at rather than by name.
flan-dev-unreachable-restarts was dead on arrival — flan-break reads
:unreachable off its own reply — and is gone.
2026-09-12 05:02:14 +07:00
2474397d30 Merge branch 'println' into dev-loop
nth removed, and the REPL's structural walk lifted into a println that
shares it. The example of the narrowing-index rule used nth, which no
longer exists.
2026-09-12 05:00:19 +07:00
91d1368279 Say in the specs what the break loop actually does now
spec-conditions.md §4 gains the rule the shadowing bug was hiding: a handler
matches by name, a debugger identifies by position, and the two are not the
same question. With it, the snapshot — a position means nothing against a
stack that moves — and the fact that a visible restart may still be
unreachable, which §6's explicit lowering makes possible.

§3 records the open one: a clause should carry a report string.
is what invoke-restart needs and not what a person reading a list needs. It
wants settling before restarts with parameters, which is where a bare name is
least sufficient.

conditions.org had the break loop under "Not yet", which it has not been for
some time, and now says why find-restart and compute-restarts still are: they
are blocked on a Restart type and a list to return one in, not on effort.
2026-09-12 04:58:42 +07:00
e4db079c57 Pin the escape buffer's bound, and say what it reserves
The guard reserves 9 bytes but the comment explained 5, which is the
longest escape alone -- it did not account for the three writes after the
loop (the ellipsis and the closing quote), so the next person to touch
the escape table would have preserved the wrong invariant.

Swept every length to 1300 against \x01, a quote, a backslash and 'a'
under ASan with a red zone past the buffer: no write past 1024, worst
output 1021. Correct, but by three bytes, which is exactly why the
reserve is now written down as the four things it is spent on.

Nothing exercised truncation -- the longest nested string in the fixture
was 18 bytes -- so println.flan now prints a struct with an 1100-byte
string field, and the expected output spells the surviving count out as
a number so a change to the buffer shows up as one.
2026-09-12 04:58:16 +07:00
ac7d4a0e95 The prompt numbers its choices, because a name could not say which
now lists the restarts by position and sends the position, with the
name alongside as the receipt the program checks. A restart below the
evaluation the break is inside is shown marked rather than hidden: someone who
can see a restart in their own source and not on this list has been told
nothing, and the refusal carries the reason.
2026-09-12 04:56:25 +07:00
4a6a8fa0f7 Take a restart by its position, off a list that stopped moving
Two frames offering `retry` put both on the break loop's list and only the
inner one within reach: §4's walk takes the first frame offering a name, by
definition, so the outer clause was drawn, offered, and unreachable. The old
prompt showed `retry` twice and sent the string either way. An index is the
only thing that can say which one, which is why SBCL identifies them
positionally too.

An index is worthless against a stack that moves, though, and this one moves:
the break loop is the poll loop, so every restart-case an evaluation enters
pushes and pops the same global list between the listing and the choice. So
the list is read once on entry and copied — names into the agent's own buffer,
frames as the addresses a transfer carries — and every answer comes from that.
The name still travels with the index as a receipt, checked against the
snapshot and refused if the two have drifted, so a bare integer can be wrong
out loud.

And the third state. A restart below the thunk a break is inside was accepted,
announced, and silently not taken: `flan_reload_call` holds its own transfer
channel and drops it on return, so the unwind stops at the thunk. The boundary
is now recorded where it is made, at the call — frames a restart-case inside
the thunk pushes are above it and still work — and such a restart is listed,
marked, and refused with the reason.

`break.flan` grew the shadowed pair, and 900 is a value no by-name lookup in
that file can produce.
2026-09-12 04:56:18 +07:00
93231e8c9e println, the structural printer, shared with the REPL
session.ml already had this: a compile-time walk over a Tast type that
emits the calls to print a value of it, handling every concrete type the
language has. It was dev-build-only and went to flan_dev_emit, and
prelude.ml justified the per-type print-* functions by saying a real
println had to wait for milestone 5 and generics. It did not. plan.org
specifies println as compiler-provided and per concrete type, which is
not overloading: there is nothing to dispatch on at run time and no
user-supplied printer to choose between, so no type variables appear.

The walk moves to render.ml, parameterised on an emitter and a slot
allocator. The emitter is five functions rather than five extern names
because the two sides are not both extern calls -- the REPL's are, and
stdout's compose a conversion with a write. The slot allocator differs
too: the REPL builds a thunk's frame, println takes slots from the
enclosing function being checked, once per call site.

Two runtime shims, both only reachable from the walk. flan_u64_to_bytes,
because routing u64 through the signed printer makes 0xFFFF...F read as
-1, which is the one way println could disagree with the REPL about a
value both can hold. flan_escape_bytes, so a string nested in a printed
structure is quoted and escaped -- same table as flan_dev_emit_str, noted
in both, because the REPL and println must not disagree about what a
struct looks like.

A string at top level prints raw and nested prints quoted. Not a conflict:
(println "hello") has to print hello, and a struct's string field has to
be distinguishable from the punctuation around it. The split is top-level
vs nested, so it lives in check.ml and not in the walk.

Found on the way: a field of an Option had no gep in emit.ml, so the
walk's Option arm had never run -- the REPL would have failed on one too.
Option is { i8, T } with no declared name, so its layout is now spelled
out. Nothing in the surface language reaches a field of an Option; the
printer does, to read the tag without unwrapping a None.

The print-* functions stay. They print without a newline, which println
cannot express -- slices.flan's show prints elements separated by spaces
-- and they are raw where print is structural.

println.flan covers every arm at -O0 and -O2: the u64, the raw/quoted
split, both Option arms, the depth and span caps, and the slice arm's
loop twice over plus once inside a dotimes, which is where per-call-site
slot allocation would show if it were per-iteration.
2026-09-12 04:55:42 +07:00
000264bb29 Four tracks at once, written down so none of them starts twice 2026-09-12 04:51:05 +07:00
5ea0bcae84 Remove nth, the alias that was not one
nth and at were documented as the same operation, and as reads they were:
check.ml matched "at" | "nth" in one arm. But a place is recovered in two
other spots -- parse.ml for (set ...) and place_of_expr for (addr ...) --
and both match only Sym "at". So (set (nth a i) x) and (addr (nth a i))
were refused while the at forms worked.

Two names said to be identical that disagree about writing is worse than
one name, and the asymmetry is not worth fixing in three places to keep a
synonym. at is the indexing operation; nth is gone.

The six call sites were all reads, so they rewrite directly. get/put stay
the Map pair: get returns (Option V) and is deliberately not a place.

nth-gone.flan pins the removal -- it has to fail as a name nobody defined,
not quietly resolve to at again.

destructure~nth is compiler-generated and unrelated.
2026-09-12 04:46:41 +07:00
e416f28567 The site's key table, after the keymap moved under it
The prose lane branched before the CIDER buffers landed, so the page still
described C-c C-b as the minibuffer prompt. It is the conditions buffer now, the
prompt moved to C-c C-M-b, and C-c C-i, C-c C-a and C-c C-g are missing
entirely.

Checked against the real keymap rather than against the source: loading
flan-mode in a batch Emacs and asking key-binding what each one resolves to.
That is what caught C-c C-g being unbound - flan-dape.el registers it from its
own file so that flan-mode still works without dape installed - so the row says
so rather than claiming a binding that is not there.
2026-09-12 04:25:58 +07:00
692403998b Merge branch 'web-prose' into dev-loop 2026-09-12 04:24:46 +07:00
4e89987a83 Stop explaining the significance of the sentence just written
Forty-odd clauses of the shape "— which is what makes X work" and "that is the
point of Y". Each one restates in the abstract what the sentence before it had
just said concretely, and a reader who followed the first does not need the
second. The facts are unchanged; the examples are untouched.
2026-09-12 04:23:35 +07:00