Every decision taken in the TODO review is recorded, and NEXT is a keyword the file declares

This commit is contained in:
Joseph Ferano 2026-09-25 09:34:47 +07:00
parent 795ba945d9
commit 92d63e3b36

130
TODO.org
View File

@ -1,5 +1,5 @@
#+TITLE: Flan
#+TODO: TODO WAIT | DONE CANCELLED
#+TODO: TODO NEXT WAIT | DONE CANCELLED
#+ARCHIVE: ::* Archive
Every decision, open question and known gap, one =**= heading each under the
@ -137,7 +137,8 @@ big-endian bytes, which is how the hex literal reads. Two and not one with a
wider operand because the intrinsic takes a single repeated byte: the byte fill
is one instruction and the four-byte pattern is a loop on both backends.
** TODO There is no literal for an infinity or a NaN
** NEXT There is no literal for an infinity or a NaN
Decided 2026-09-25: four constants the compiler supplies, =f64-inf=, =f64-nan=, =f32-inf=, =f32-nan=, beside =f64-max= and the rest. Negative infinity is =(- f64-inf)=. Rules out Clojure's =##Inf= reader literal.
=lib/reader.ml= has no literal for either, and =float_repr= prints =inf= and =nan=
as words the reader will not read back. =(/ 1.0 0.0)= is the only route to an
infinity, and the constant folder is integers only, so it cannot be a =defconst=.
@ -328,6 +329,13 @@ a =defer= in one always registers. A loop body and a branch are still refused by
name: =defer= is a compile-time construct with the cleanup copied into every exit
path, so "maybe registered" is not expressible.
** TODO A return runs its defers before it computes its value
=(return v)= is lowered as =Do (defers @ [Return v])= (=lib/check.ml= near 3474),
so a defer that changes what =v= reads changes the answer, and =(return x)= and
falling off the end with =x= disagree. All backends agree with each other. The
value is computed first and the defers run after, the order Odin, Go and Zig
use.
** DONE edn reads into a struct and answers a dynamic value
CLOSED: [2026-09-17]
Two projects, not one, and the dynamic half goes through an allocator rather than
@ -362,17 +370,17 @@ expressible and a build-time refusal would be unusable. =barf= on web signals
no-op — which is how a save file disappears with nothing said — and the
build-time refusal.
** TODO Conditions get a parent link, not class inheritance
** NEXT Conditions get a parent link, not class inheritance
Decided 2026-09-25: build it, with a root =Error= every built-in error descends from, so one handler catches any error. A catch-all handler gets the condition's name and the runtime's sentence, not its fields. =(pause)= and warnings are not under =Error=.
A condition type may name a parent where it is declared, and handler matching
walks that static chain. It buys the hierarchy conditions most lack — a catch-all
"any file error" handler — at compile-time cost only. Rules out the class answer:
a class condition allocates at the signal site, inverts the lifetime rule, and
lets a layout change under a standing handler frame. Not built.
** TODO Can a condition be a class?
Unanswered, and the shape that probably wins is both — a struct condition stays
what it is, a class condition allocates and matches by walking its class chain.
Three costs, one serious: signalling would allocate on the failure path.
** CANCELLED Can a condition be a class?
CLOSED: [2026-09-25]
A class condition allocates on the failure path. The parent link above gives the hierarchy without it.
** DONE handler-case
CLOSED: [2026-09-19]
@ -437,13 +445,15 @@ it. The alternative weighed — a per-binding declaration naming which argument
carries the count — cannot reach a count that is a sibling field. No marker on the
name; =ptr= is the marker, it owns nothing, and =free= refuses it.
** TODO A string cannot be returned from C
** NEXT A string cannot be returned from C
Decided 2026-09-25: copy the returned text into the context allocator at the boundary. =TextFormat= stays unbound, being variadic.
A string crosses as a parameter only — a C function that returns one returns
something Flan has no owner for. It is what makes =GetGamepadName= unbindable, and
the same rule refuses =TextFormat=, which is variadic and so has no honest
signature either.
** TODO Model, Mesh and FilePathList want a defstruct, and a callback wants the other direction
** NEXT Model, Mesh and FilePathList want a defstruct, and a callback wants the other direction
Decided 2026-09-25: =FilePathList= crosses by the same copy-at-the-boundary rule, in the lane with =Model= and =Mesh=. Callbacks wait until a program needs one.
=Ray= and =BoundingBox= have their defstructs now. =FilePathList= is a =char**=
and blocks the drop-files example; =Model= and =Mesh= are ordinary widening.
Function-pointer parameters — =SetTraceLogCallback=, the audio stream processors —
@ -479,7 +489,8 @@ an ordinary =defn= declares no environment and is byte-for-byte what it was. The
static side does not pay for the dynamic side. Rejected names: Closure, Proc, Fun,
Func, Fnptr.
** TODO Escaping closures, allocated on the GC side
** NEXT Escaping closures, allocated on the GC side
Decided 2026-09-25: start it. It must work on wasm32.
The second half of "do both". What changes is where the environment points — a
frame slot today, a collector allocation then — and the escape check goes away
with it, along with the refusals on returning, storing, pointing at and pushing a
@ -584,7 +595,8 @@ back to the call site. Costs about 2µs a call. Rules out putting a =loc= field
on the wire, and rules out structural matching of the expansion against the
arguments, which can pick the wrong one of two equal subtrees.
** TODO A declared name may carry the $ sigil
** NEXT A declared name may carry the $ sigil
Decided 2026-09-25: refuse =$= at the start of any declared name; the refusal says =$= marks a type variable.
=(defn $foo [x i32] i32 ...)= is accepted and =($foo 3)= calls it; so is
=(defstruct $S [a i32])=, whose type can then be written nowhere. The character is
reserved in every type position and in no name. Refusing it in a declared name
@ -629,7 +641,8 @@ A machine-type target needs =numeric?=; an enum target needs =integer?=;
by what it claims, not by the set it happens to denote this week — which is why
=ordered?= is refused even though every type it admits today converts.
** TODO There is now no generic enum to integer conversion
** NEXT There is now no generic enum to integer conversion
Decided 2026-09-25: build =enum?= as described.
Recorded as a loss. The one spelling that worked did so by not asking about the
operand at all, so removing it was still right. =enum?= is the eventual answer —
it would entail =ordered?= and =equal?= and not =numeric?=, so the cast rule
@ -695,7 +708,8 @@ depth it gave up at. The bare depth number is a backstop that also prints the
chain. Before any of it, the compiler hung rather than failed, which wedges =C-c
C-c= with nothing to show.
** TODO Generic types
** NEXT Generic types
Decided 2026-09-25: the freeze is lifted for this; build both type and length parameters.
=(defstruct Pair [a $t b $t])= cannot be spelled, and neither can a length
parameter. =Types.Named= is a bare string with no room for parameters; giving it
some changes the type, the layout calculator, both backends, the renderer and the
@ -704,7 +718,8 @@ not started — it is a language feature under a freeze, and it was stopped once
already for that reason. The motivating case is Odin's =Small_Array=: a
fixed-capacity array with a count and no allocation.
** TODO A value predicate over a length parameter
** WAIT A value predicate over a length parameter
Decided 2026-09-25: waits until a program wants one.
Odin's =where N >= 0= is a predicate over a value, not a type, and a =where=
clause here admits nothing but type predicates. Whether it should take value
predicates over a length parameter deserves answering deliberately rather than
@ -797,7 +812,8 @@ CLOSED: [2026-09-20]
typed conditions stay strict =bool=. =and= and =or= hand back the operand that
decided them, Clojure's rule, through a desugaring that evaluates each test once.
** TODO A bool arm and a dyn arm joining as dyn
** NEXT A bool arm and a dyn arm joining as dyn
Decided 2026-09-25: they join as =dyn=, the =bool= boxed — Clojure's rule, so =(or false (box "s"))= answers ="s"=.
With both arms of a desugared =and=/=or= holding real values, a non-bool =dyn= on
the losing side meets the strict =bool= boundary and traps —
=(or false (box "s"))= is the case. Whether a =bool= arm and a =dyn= arm should
@ -867,7 +883,8 @@ ordinary expressions and the builtin reads the type back out of one
type an expression cannot hold, such as =(Fn [i32] ())=, is parsed as
=Ast.TypeArg=. Rules out a type expression anywhere else in expression position.
** TODO An array literal cannot say it is [f32]
** NEXT An array literal cannot say it is [f32]
Decided 2026-09-25: the first element's type carries to the rest, so =[(f32 1.0) 2.5]= is an =[f32]=; that is refused today and is a bug. No =1.0f= suffix for now.
A float literal defaults to =f64=, an array literal has no context, and a =let=
has no annotation. Same shape as =(vec-new [u8])= and probably the same fix.
Not the same fix: a bracket literal has no argument to put a type in. Decision:
@ -882,13 +899,15 @@ blocker it was, since =(array 4 T)= answers the case that raised it. plan.org's
rule is "annotate function signatures, infer locals", so a general annotation is a
deliberate absence.
** TODO A read-only slice type
** NEXT A read-only slice type
Decided 2026-09-25: =[const u8]=, Zig's spelling in Flan's brackets. =bytes-view= answers one and a =set= through it is a compile error; a =[T]= converts to =[const T]= and not back, and the prelude's read-only functions take it. =const= is reserved as a name, since =[n T]= accepts a constant's name for =n=.
=bytes-view= is read-only by convention only — the type system cannot say a =[u8]=
may not be stored through, so a trap on read-only memory is the enforcement. A
read-only slice type, or provenance, is what would move that refusal to compile
time.
** TODO Writing through a string literal
** NEXT Writing through a string literal
Decided 2026-09-25: closed by the read-only slice type above.
=(let [s (bytes-view "Hi")] (set (at s 0) \h))= stores into read-only memory at
=-O0= and is deleted as undefined at =-O2= — same source, and which way it fails
depends on a flag. Narrowed when =(bytes s)= started copying, so the common
@ -966,7 +985,8 @@ at all, which is what the diagnosis predicted. A =map= that *changes* the elemen
type is the one shape that did not come with them: one copy per ordered pair of
types rather than per type.
** TODO CFn in a struct or a fixed array
** NEXT CFn in a struct or a fixed array
Decided 2026-09-25: allowed. A call through a null =CFn= is a named runtime condition on both backends, and parks in a dev build.
A zeroed function value is a null pointer, so a function value is refused in any
position zero-initialisation would conjure one — =CFn= included. An =(Option
(CFn ...))= field is already legal. A table of function pointers is exactly what
@ -1030,11 +1050,9 @@ compared as a constant rather than as a lowering. That is how the float =%= gap
survived. Two things would close it: an =-O0= pass of the sweep, and something
that walks the two backends' primitive match arms mechanically. Neither is queued.
** TODO (uninit) and unreachable differ between the backends, and the language has not said what they mean
=(uninit)= is stable garbage — whatever the stack slot held — rather than
=poison=, and an exhausted match is =ud2=, a defined SIGILL, rather than undefined
behaviour. Both are deliberate and both are now written down, but the *language*
still has not defined what reading an uninitialised value means, which is the item.
** DONE Reading (uninit) before writing it is undefined behaviour
CLOSED: [2026-09-25]
Reading an =(uninit)= value before writing it is undefined behaviour, and the backends may differ on it. An exhausted match stays =ud2= on x86.
** DONE f64 to i64 out of range, and INT64_MIN / -1
CLOSED: [2026-09-14]
@ -1067,6 +1085,10 @@ build included, reaches =emit_globals_init= as =Emit.startup_plan='s
straight into their symbol are =Tast.const_init= ones, which neither transfer
nor read anything. Nothing to change.
** TODO A u64 converted to f64 is signed on x86
=(f64 (u64 18446744073709551615))= prints =1.84467e+19= under LLVM and =-1= under
=--x86=, with a literal or a run-time =u64= alike.
** DONE An aggregate built in place never reads its own destination
CLOSED: [2026-09-25]
=assign= builds in place only when the right-hand side settles *and* reads no
@ -1093,7 +1115,8 @@ so a =declare-c= wrapper leaning on the courtesy is already backend-dependent as
well as slice-dependent. The contract is pointer and length, and nothing promised
otherwise.
** TODO Frame descriptions are gated on --debug
** NEXT Frame descriptions are gated on --debug
Decided 2026-09-25: emit the x86 backend's =.cfi= directives in every build. No runtime cost; the lane measures the =.eh_frame= size it adds.
They are correct in every build and free at runtime, and a release build is where
a crash would most want them. One =if= in three places.
@ -1103,11 +1126,9 @@ outer one. The disambiguating suffix makes both visible, which is not the same a
making the answer right. It needs block structure the typed IR does not carry, and
the variable declarations moved out of the entry block.
** TODO UBSan sees no Flan code, and no flag changes that
UBSan's checks are branches clang's C frontend emits inline, not a pass, so shift
undefined behaviour, alignment and a NaN float-to-int cast are unreached. Either
the emitter grows those checks behind the flag — a compiler feature of the same
shape as the bounds checks — or they belong to the checker. Not decided.
** CANCELLED UBSan sees no Flan code, and no flag changes that
CLOSED: [2026-09-25]
The language defines the cases UBSan would catch itself: a computed shift count is masked, and a float-to-int cast out of range or of a NaN signals =ArithError=. Flan code produces no misaligned access.
** DONE A JS backend is a dialect, not a second machine
CLOSED: [2026-09-17]
@ -1374,7 +1395,8 @@ Hiding it means a new field in the frame layout written out in both backends and
the runtime. Choosing it is refused loudly rather than answered wrongly, so this is
cosmetic.
** TODO A formatted number does not outlive its frame
** NEXT A formatted number does not outlive its frame
Decided 2026-09-25: the conversion's bytes are always copied into the context allocator, so the string outlives the frame. Rules out refusing the escape, which needs flow tracking.
The conversion buffer is one frame slot per call site, so returning a string built
from it returns a view of storage the return has just released, and pushing one
pushes an element aliasing that slot. Neither shape is refused. Copy the bytes for
@ -1392,7 +1414,8 @@ The opposite of what the escaping-alloca argument predicts, and the measurement
that first said otherwise was comparing a 40-frame binary with a 600-frame one.
That is why every number in =docs/BUILT.md= is a minimum of nine runs.
** TODO runtime/flan_dyn_stub.c is dead
** NEXT runtime/flan_dyn_stub.c is dead
Decided 2026-09-25: delete it, as part of a sweep for dead code across the repository, each removal checked unused first.
No dune rule mentions it, no module refers to it, no test links it, and it does
not compile — two conflicting-type errors against its own header. It is maintained
by accident: one lane added a function to it, which is duplicity on the same side
@ -1536,7 +1559,8 @@ only by an explicit call. A sentence about the shape of the gate rather than an
observed problem: only the daemon sets the variable and it never runs release
builds. The fix, if it is ever felt, is a narrower gate.
** TODO The daemon's "has not called (agent/start ...)" note is unreachable
** NEXT The daemon's "has not called (agent/start ...)" note is unreachable
Decided 2026-09-25: retire the note in the same dead-code sweep.
Unreachable, not merely unexercised: the one state it was true of is closed by the
constructor. Retiring it is the author's call over a lane that merged days ago, so
it is left in place saying a true thing about a state nothing can be in.
@ -1598,14 +1622,14 @@ Settled for conditions and for structs, because =Load= qualifies every declarati
at import. Still open for locals, where the debug information gives a bare name and
nothing qualifies it.
** TODO The render-thunk-per-inspection design
** NEXT The render-thunk-per-inspection design
Decided 2026-09-25: the inspector reads a value through the type layouts the compiler records, with no compile per inspection, which lets it hold a value.
An inspection still compiles a thunk per request. A redesign rather than a
deletion, and its own lane: it is what unblocks the inspector retaining a value.
** TODO The watch design is reopened
Push was chosen partly because polling costs a compile, and that premise weakened
when the processes merged. The table-and-read design stands; whether it should stay
pushed is open.
** DONE The watch table stays pushed
CLOSED: [2026-09-25]
The watch table stays pushed, and shares the push channel program output moves to.
** DONE A watch over a struct or a slice
CLOSED: [2026-09-25]
@ -1630,7 +1654,8 @@ Only instantiations reach the function list, so a generic name used to report th
it had installed nothing at all. The session expands to instantiations before
reporting.
** TODO Signature generations and stale-caller warnings
** NEXT Signature generations and stale-caller warnings
Decided 2026-09-25: SBCL's behaviour: the new definition always installs, the stale callers are listed by file and line, and a stale call stops in the break buffer naming its site. The check is a signature word the cell carries, compared at each dev-build call site; a release build has neither. Per-block control of runtime checks (Zig's =@setRuntimeSafety=, Odin's =#no_bounds_check=) is a separate question, not built.
The biggest hole in "you never restart the program". A changed signature is
refused outright today and that is a placeholder, not the design. It needs function
versions, a trampoline per version, and caller tracking good enough to name the
@ -1814,7 +1839,8 @@ specification's own branch — the flag is the command and the printed shape is
error pattern, so anyone who wants one has the four lines, and the manual carries
them.
** TODO defclass slots take types, checked on write
** NEXT defclass slots take types, checked on write
Decided 2026-09-25: slots are name/type pairs checked on write; an untyped slot stays legal and holds any =dyn=. A migration keeps a stored value that no longer fits the new type, warns once, and the next write is checked. One lane with the =set= entry below.
=(defclass State [pause bool step bool])= reads as four untyped slots and
reports a duplicate =bool=. Wanted: the slot list is name/type pairs, as CLOS
does it. The type is a declaration about the values and not a layout — an
@ -1826,14 +1852,16 @@ Open: what migration does with a stored value that no longer fits a changed
slot type, and whether an untyped slot stays legal (it should — =dyn= is a type
and writing nothing should mean it).
** TODO println takes up to a second to appear
** NEXT println takes up to a second to appear
Decided 2026-09-25: the daemon pushes program output on the editor's connection as it is written. Rules out a faster poll.
Output is drained by =flan--poll= at =flan-poll-interval=, 1.0s
(=emacs/flan.el:541=). A reply carries whatever was buffered when it was
composed, so anything the program prints after that waits for the next tick.
Polling faster costs a request a second for nothing most of the time; the
daemon pushing on its own connection is the other shape. Decide which.
** TODO set writes a class slot; put is for maps
** NEXT set writes a class slot; put is for maps
Decided 2026-09-25: as written; one lane with typed slots.
=put= exists because an absent map key has no location to store into, which is
why =(get m k)= is refused as a place (=lib/parse.ml:1159=). A class instance is
not in that situation: its slots are fixed by the =defclass=, so a declared slot
@ -1842,7 +1870,8 @@ always exists and =(set (get state :pause) true)= is a field store like
insertion is real. Writing an undeclared slot through =set= is then a refusal
naming the class.
** TODO update: change a place by applying a function to it
** NEXT update: change a place by applying a function to it
Decided 2026-09-25: every place evaluates each of its subexpressions once, C's compound-assignment rule, which also fixes =++= and =--=; =update= is built on that. Rules out refusing side effects in a place.
=(set (.velocity g) (inc (.velocity g)))= names the place twice. Clojure's
=update= would be a macro over the same two steps, for a struct field and a
class slot alike.
@ -1880,7 +1909,14 @@ motion states in that mode; every other key, including what =special-mode-map=
binds, stays Evil's. The other special-mode buffers (inspect, watch, doc, disassembly,
diagnostics, lower) have the same exposure and are not changed.
** TODO Eval in the frame, from the break loop
** NEXT Evil takes the keys in the other Flan buffers
The inspect, watch, doc, disassembly, diagnostics and lower buffers get the break
buffer's treatment: the keys each binds itself go to Evil's normal and motion
states, and every other key stays Evil's. Bindings stay as close as possible
between Evil and Emacs states.
** NEXT Eval in the frame, from the break loop
Decided 2026-09-25: SLIME's eval-in-frame, as described.
An expression is evaluated at a frame boundary, so it sees globals and not the
stopped frame's locals — which are the values anyone stopped there wants. Wants
SLIME's eval-in-frame: pick a frame, and the expression is checked and run with
@ -1896,7 +1932,8 @@ because =locals= and the inspector are asked by it. The innermost frame is
shown even when it is the prelude's, unless the stop is =(pause)=, because it
is where the program stopped. Rules out renumbering the visible frames.
** TODO There is no stepper
** NEXT There is no stepper
Decided 2026-09-25: stepping happens inside a stopped frame, so the game loop and its clock are frozen, as under =(pause)=.
=(pause)= stops and offers restarts, frames, locals and the inspector, but
nothing advances a form at a time. CIDER instruments a form and steps the
instrumented copy; the equivalent here is a dev-build-only instrumented
@ -1948,7 +1985,8 @@ daemon asks for the sink off (=lib/loc.ml:185=) and gets one exception, so a
function with three bad expressions takes three round trips. The sink is
per-phase; making it per-form would need a resync point inside a body.
** TODO A session should start before a program compiles
** NEXT A session should start before a program compiles
Decided 2026-09-25: SBCL/nREPL order: an empty host session, then a load-file op. Re-running with no =main= says there is none; a half-loaded file keeps what compiled and lists the errors. =C-c C-k= is load-file and the inspector moves to =C-c M-i=, CIDER's key.
=flan dev= builds the program first, so a =main= that does not compile gives no
session. Want SBCL/nREPL order: empty image, then load a file into it. Needs a
host built with no user program, and a load-file op — the reload machinery