166 Commits

Author SHA1 Message Date
40d62e7643 builtin/name, the spelling a shadow cannot take away
A defn named after a builtin wins for its whole file, and until now that
was the end of it: the builtin had no remaining spelling, so a defn that
meant to wrap one was unbounded recursion. builtin/len is the builtin len
wherever it is written, shadowed or not.

The qualifier is the package one's, and builtin is reserved rather than
resolved: Load refuses it as an import alias, Check refuses it as a
declaration's name, and those two doors are the only ways a qualifier can
be made. named_call and var each strip the prefix and re-enter with a flag
that the shadowing guard consults, so every arm below sees the bare name
and refuses in the builtin's own words.

The shadow warning now names the escape in its second half.
2026-09-20 20:17:17 +07:00
a983a46ea5 The review's follow-ups: a file decides the shadow, and a set answers the membership
The leak review found: an importer's (defn len ...) reached inside an
imported package's (defvar sz i32 (len "abcd")) and made it 999. A global
initialiser is checked with no enclosing function, so the qualified name the
first cut asked about was not there to ask. The file the definition was
written in is what the shadow follows now, which is what FIX.org had already
named as the fix if it ever mattered. It mattered.

builtin_set beside builtin_names: the guard is the first arm of the dispatch
and ran a linear walk of eighty-odd strings at every named call. The list
stays for the did-you-mean, whose order is its order.

Pinned: a shadowed operator warns and lowers to a Call, and a call carrying
another file's name reaches the builtin. The corpus program grew both cases
and the package grew the initialiser that demonstrated the leak.

And the int/float section's sentence about "the arity precedent, where the
builtin wins" now says that the precedent was deleted the same day, since
this lane is what deleted it.
2026-09-20 19:56:17 +07:00
e03028819c A defn named after a builtin now wins, and says so once
The author's rule: "allow shadowing but warn". A user (defn get ...) is
legal, the user's definition wins at every call site in the file that wrote
it, and the compiler warns once at the definition.

Builtin-wins was never a rule anybody wrote: named_call is one match on the
name, the builtin arms are string literals, and the three arms that look a
name up are the last three in it. So a guard goes first, the trailing three
are factored into ordinary_call, and both routes into it resolve a name the
same way.

The shadow stops at the file that declared it. An imported package's names
were qualified at the import, so a get written inside one is the builtin's
and stays the builtin's; the prelude is excluded by its file for the same
reason. programs/shadow-builtin.flan is both halves at once.

The warning prints from build_program, which is what every command and the
dev daemon's reload go through, in the shape --warn-memory established:
file:line:col, the squiggle, and an exit status that does not move.

And the message that described the old world is gone — the builtin-arity
note said a defn does not replace a builtin, which is no longer true and is
no longer reachable.
2026-09-20 19:46:43 +07:00
bbacbcb666 dead-beef's byte arm learns arity's new ctx argument 2026-09-20 19:27:06 +07:00
0a4c52d5ee filled and dead-beef, the two byte fills
# Conflicts:
#	DISCUSS.org
#	FIX.org
#	test/test_acceptance.ml
2026-09-20 19:25:08 +07:00
26242388b9 int and float are the machine types, not a second name for them
The author's exception to the foreign-spelling list: int is i32 and float
is f32, and nothing else on that list moves.

Spelled in Types.ikind_of_name and Types.fkind_of_name rather than as two
prelude defaliases, because Check.is_cast asks those two functions and never
the alias table — a prelude alias would have left (int x) with no reading
while (i32 x) had one. Both names join primitive_names for the same reason
one layer down: that list is what decides (vec-new int) and the three-element
(defvar x int).

Nothing reverses: ikind_name still says i32, so every message, signature,
inspector line and DWARF name shows the machine type whichever spelling was
written.

A defalias restating the builtin is the no-op it says it is; one pointing the
name anywhere else is refused, since the alias table is never consulted and
the declaration would otherwise mean i32 in silence.
2026-09-20 19:09:26 +07:00
2240100ee9 Review follow-ups: the default's payload, four wrong reasons, a doubled prefix
1. The bare (dead-beef) built its default with Int64.of_int32, which
   sign-extends 0xDEADBEEF to -559038737 on a node tagged u32 — where the
   spelled-out literal arrives as 3735928559, because in_range admits it as
   the unsigned value it is. Masked to 32 bits, so the two spellings really
   do carry one payload; verified by diffing the emitted bodies of (dead-beef)
   and (dead-beef 0xDEADBEEF), which are now identical instruction for
   instruction.

2. The refusal's catch-all told a union and a function value that they
   'carry a tag that names a case'. Neither does: env.unions is the untagged
   unions, and an Fn is a code address. Split into one arm per reason —
   union, Fn, enum, Option, data type — and each is now pinned, so they
   cannot quietly re-merge. Same correction in FIX.org's bullet.

3. js.ml prefixed its own message with 'js: ', which bin/main.ml prepends
   too, giving 'js: js: ...'. Dropped, and the message now names the builtin
   it refuses, which its comment already claimed it did.

4. FIX.org said x86.ml reads both pattern helpers out of Emit. It reads only
   word_of_pattern; the tail walks rax with shr.
2026-09-20 18:58:32 +07:00
5ea6884d2c The diagnostics pass: every message shows, explains, and names the fix
# Conflicts:
#	FIX.org
2026-09-20 18:49:12 +07:00
78d9a0f051 The review's fixes: a suggestion that does not compile, and a confident wrong guess
F1 was the blocker and it was the worst kind of fault this pass can have: the
condition message told the reader to write (not= x 0), and not= does not
exist — the operator is !=. Applying the compiler's own advice got 'unknown
function not= — did you mean not?'. Both branches say != now, and all three
— the named form, the float zero, and the unnamed one — were checked by
compiling the sentence the compiler prints.

F5: a typo of a declared capitalised name got the generics lecture. (Piont 1
2) with Point declared was told that a capitalised name given type arguments
is milestone 5 work, which is a confident answer about a feature nobody was
reaching for. The did-you-mean runs first and, for a capitalised head only,
asks the type tables as well; the generics sentence is left for a head that
resembles nothing.

F2: flan_dyn_cast_kind had the site live and passed NULL on the trapping
path — the one entry point on this side that had a location and threw it
away. The acceptance row now pins the prefix it prints.

F3: the case-typo row used (data ...), which is not a top-level form, so it
refused as an unknown top-level form and the needle 'unknown' matched that
rather than the rule. Rewritten with defdata, and as a pair: a capitalised
head gets no accessor advice, a lowercase one does. Both halves were checked
to fail when perturbed.

F4: an end-to-end pin for the headline. programs/dyn-trap-site.flan is
compiled, run, and its stderr read for the file:line:col in front of the
sentence, on both backends and at -O0. Proven live: three failures when the
expected line is wrong.

F8: usize and size_t stay off the foreign-spelling list, and the comment now
says why — the honest answer is pointer-width, which is u64 here and u32 on
wasm32, and a tree that builds both cannot name one of them.

F10 pins the fourth dot shape. F6 moves the not-reached reasons out of the
commit bodies and into FIX.org, where they can be read without git.
2026-09-20 18:46:48 +07:00
e807986622 The dogfood batch: empty forms, comment, inc and dec, guards, limits, shorthand
# Conflicts:
#	FIX.org
2026-09-20 18:34:13 +07:00
7bd2c99353 sentinel-filled is now dead-beef, and takes the pattern
The author's revision. The name says what it writes, and the pattern is the
program's to choose: (dead-beef) is DEADBEEF, (dead-beef 0xBAADF00D) is
BA AD F0 0D. One byte-order rule covers both — a pattern's ascending bytes
are its big-endian bytes, which is how the hex literal reads left to right —
so every candidate DISCUSS.org listed is now spellable without the compiler
naming any of them.

The bare form is not a case a backend knows about: the checker writes
Tast.dead_beef_default in where the argument would have been, so
(dead-beef) and (dead-beef 0xDEADBEEF) are the same node and an acceptance
row prints both to say so.

The operand is an ordinary u32 expression, which is what the byte arm
already accepts for its byte. A literal is byte-reversed at compile time and
still reaches the loop as an immediate; a computed one is reversed at run
time, by llvm.bswap.i32 on one backend and bswap on the other, after which
the tail shifts its bytes out of the word rather than folding them. The
program runs a computed pattern over lengths 6 and 7 deliberately: that is
the case a constant-only implementation would pass by accident.

filled is untouched, and so is the fill boundary.
2026-09-20 18:33:05 +07:00
99f519ba6f Two byte fills: (filled BYTE) and (sentinel-filled)
DISCUSS.org's sentinel-fill idea, built as two builtins because the author
asked for both: a memset with a byte the program picks, and the fixed
DE AD BE EF pattern a hex dump reads as DEADBEEF.

Both are spelled the way (zeroed) is — the value of whatever type is
expected of them — so (set grid (filled 0xFF)) fills a place and there is
no second, place-taking form beside set.

What may be filled is numbers, and structs and fixed arrays built out of
them. Everything else is refused by name: a filled dyn is a collector root
pointing at nothing, a filled Vec header frees a wild address, a filled
slice length is a bounds check that passes, and a filled bool is an i1 to
LLVM and a whole byte to x86, which is the one divergence this feature
cannot have.

The byte fill is llvm.memset / rep stosb. The four-byte pattern cannot be
a memset on either side — the intrinsic takes one repeated i8 — so it is a
counted dword loop in emit.ml and rep stosd in x86.ml, with the pattern
bytes and their little-endian word living once, in Emit. A size that is
not a multiple of four ends on DE, DE AD, or DE AD BE.
2026-09-20 18:15:19 +07:00
16f90a7ef5 The dot advice says assign where the form is an assignment
(set p.x 1) was being told that a field is read with an accessor, which is a
sentence that does not apply to the form it is printed under. The place
spelling is (set (.x p) 1), checked to be a real form.
2026-09-20 18:14:58 +07:00
c1b18cb181 Two struct spellings the parser was never able to decide
A bare {.field v} had its refusal in Parse.expr, before any checking, so a
defn whose return type was the only place the struct's name appeared could
not build one. The refusal moves to Check: Parse builds an Ast.Bare out of
the same struct_fields the named form uses, and check_bare reads the type
name off the expectation and hands that very list to check_struct. ZII, the
unknown-field refusal and the duplicate-field refusal are therefore not
copies of the named form's rules but the named form's rules.

Braces at a dyn want are the dyn map literal and stay exactly that. A
.field-keyed brace was never part of that spelling, and at a dyn want it is
refused by name rather than given a second meaning.

(Cell 1 2) is the other half, and it is character-for-character an ordinary
call, so only the symbol table separates them. It is decided on the last arm
of named_call, after a local of function type, a generic and the function
table -- so a defclass constructor, which is a real defn, resolves above it
and is untouched. Arity is exact: ZII is what the braces do, and a positional
list cannot say which field it left out, so it is not allowed to leave one
out. The refusal names the first field it did not reach and points at the
spelling that does mean "zero the rest".

Both are gone before any backend sees them -- Tast.Make either way -- and the
three acceptance rows print the same lines to say so.
2026-09-20 18:12:45 +07:00
fa2b56ba5a Empty fn bodies, the pins for all six items, and the FIX.org entry 2026-09-20 18:11:20 +07:00
859e3aa7f4 The shadowed builtin, and's misdirected caret, and the expansion count
Ranks 7, 10 and 19.

A defn whose name is a builtin's is silently unreachable — the dispatch
reaches every builtin arm before it looks in the function table — and the
arity refusal that followed measured the call against the builtin while
pointing at a call the reader had written for their own. The count stays the
builtin's, because the builtin is what runs; the message says so and notes
the definition that is not being reached. The shadowing itself is not
refused: that is a language decision and not a fix pass's to make.

FIX.org recorded and's misdirected caret with three rejected fixes and one
accepted — check_if preferring the arm that is not a compiler temp — and said
it was a check.ml change nobody owned. and's last operand is its then arm and
the sentinel carrying the previous operand's location is its else arm, so the
mismatch landed one operand early. Only and needs it: in an or the chain is
already in the else arm, and with an expectation in hand neither arm is
checked against the other.

'expanding this declaration produced 2 of them' had no antecedent once read
cold. The head of the expanded form is the macro's name and says what
expanded, and the expression form names (do ...).
2026-09-20 18:11:16 +07:00
ea84394dd0 Three more: the return slot, the case payload, the missing collection
Ranks 13, 14 and 9.

A body form in the return slot was blamed at whatever leaf the type parser
gave up on — the [1] in [(defn f [x i32] (+ x 1))], three forms deep — where
the mistake is that the whole form is in the slot. The slot is blamed now and
the parser's own reason keeps its span as a note. Where the parser gave up on
the slot form itself the old shape stands, because a message like 'unit is
written (), not Unit' already names the right thing and leading with it is
better than restating it. The literal -- is an em dash now, like everything
else in the tree.

A one-field case binds the payload itself, so [(match s (Circle c) (.r c))]
reached for a field of an f64 and got a type fact. The binding carries what
the pattern made it, in words, derived at the arm from the case and the
subject; the refusal says the value is already in hand. Set by that path and
nowhere else, so every other binding's refusal says exactly what it said.

The missing collection asserted a thing and contradicted it in the same
sentence — 'is a directory named nosuch somewhere above X, and there is
none'. It states the rule and the two ends of the search instead. The bare
/. it printed was Filename.concat of a directory and a dot, and is cleaned
where the path is made absolute.
2026-09-20 18:06:13 +07:00
8cba440aca Four more from the worst-20: the constant, the let annotation, the quote, the operand
Ranks 15, 6, 11 and 18, all of them the same fault in different words — the
message states a fact the reader already had and leaves out the half only the
compiler can see.

'k is a constant' was four words. It says what a constant is, names defvar,
and notes the defconst — [no_container_defconst] is the house's shape for
this and [declared_note]'s is the note's.

(let [x i32 5] ...) is what everyone arriving from a typed language writes,
and let has no annotation slot, so the i32 became x's value and the 5 was
left over: 'binding 5 has no value', which reads as if they had miscounted.
The annotation is blamed now, at its own span. Checked only when the vector
was about to be refused anyway, and the test for 'this names a type' is
syntactic because nothing resolves at parse time.

The unterminated string had one column on the opening quote and no note,
alone among this reader's three two-place errors. It has the same note its
neighbours have.

'+ takes numbers, found string' pointed at the whole form when the operand
was right there — the same whole-form-vs-operand fault the condition work
fixed once already. Text gets the extra clause it was reaching for, naming
concat and join without a call shape: the spelling that builds a slice of
byte slices out of string literals is not a clause in a sentence, and a
message that guessed at one would be wrong.
2026-09-20 18:01:08 +07:00
8add0093ba The defvar follow-ups, the spellings other languages use, and two register warts
The four the defvar review left behind, plus the two the author's dogfooding
notes name.

A three-element defvar that is neither a type nor a value gets a paragraph
about the fork it stands at, and the paragraph is right for the name that
genuinely could have been either. Three names cannot: a data case, which is
a third thing with its own spelling; a name another language uses for a type
this one has; and a plain type typo, where a confident one-edit suggestion
was turning a line into four. Each answers first now.

A bracket form never reaches that fork at all — the parser gives it the type
reading outright — so a value name inside one landed in [resolve_name] and
came back as a lecture about generic code. Both readings at the element that
decided it, and the dyn spelling it offers is checked to be a real form.

[int] is two edits from [i32] and so outside the one-edit net, correctly:
two edits is a guess. But the name is not a guess, it is what four other
languages call the default integer, so a short list answers it by name.
Nothing goes on that list without one honest answer — [char] and [void] are
off it, and the comment says why.

A parameter called [i] is not a mistyped [i8]. The machine types size
themselves in the name, so a typo keeps the digits and a parameter name has
none; that is the rule that stopped (defn idx [v i] dyn ...) being refused.

A type written in a two-element defconst was reported as an unknown name,
because that form has no type slot and the brackets read as an array
literal. A type name inside one is unambiguous — a type and a value cannot
share a name here — so it says what happened and names defvar.

Two register warts alongside: no message cites a repo filename at the reader
any more (plan.org in three, spec-memory.md in one).
2026-09-20 17:55:59 +07:00
db2f6c69b9 The call argument names the parameter, and the condition states the rule
Two of the audit's top five, and both are the same complaint: the message
states a type fact and stops where the reader needed the other half.

A call argument's refusal now says which argument of which function it is and
notes the parameter's own declaration, which is [declared_note]'s shape moved
to the place the audit calls the most-hit message in the compiler. [fns]
threw the parameter names and locations away when it resolved the types, so
[fparams] keeps the vector as written; a foreign declare and a generic copy
have no entry and degrade to the message alone rather than a wrong pointer.
The enrichment is conditional on the refusal being raised against the
argument's own span, so a mismatch deeper inside it is not misattributed, and
the rekind stops a nested call being named twice.

A condition that is neither bool nor dyn now states the rule instead of the
fact, with the comparison spelled out using the condition's own name where it
has one. Two things it does not do: it does not offer a comparison for a type
that has no zero, and it does not say anything about the dyn side, where
Clojure's truthiness means 0 is true. The re-check at bool still runs first
and its answer is kept wherever it knows more — a literal names itself, and
[None] names itself, and both beat a type name.
2026-09-20 17:44:11 +07:00
2b5d793d7a The dot habit, and a did-you-mean over values
Two of the audit's four cheapest structural wins, and they share a raise
point. [p.x] is how C, Go and Odin spell field access, and it arrived here as
the symbol [p.x] and left as 'unknown name p.x' — true, and no use to anyone.
The head is looked up now, so the refusal can say what [p] actually is, and
the struct's declaration comes along as a note. A capitalised head is left
alone: [Shape.Circle] is a real spelling and a typo in one is a mistyped case.

[near_miss] was written, tested and wired to the type tables alone, so a
mistyped value name got the bare refusal. [one_edit] is hoisted out of it so
the value side matches on the same rule rather than a second one that would
drift, and the candidate list at a value position is the scope, the globals
and the functions — plus, at a call, the builtin names, which live in no
table the checker keeps and reach the raise through a forward reference.

Two repairs alongside: the data-type message's format string carried eleven
stray spaces from a wrapped line, and (Pair i32) in a defvar had lost the
type fork's 'generics are milestone 5' answer when it started falling down
the value fork.
2026-09-20 17:40:45 +07:00
6d82221978 A dyn trap that says where: the nine trapping operators take a site
The dyn arithmetic and ordering entry points printed their sentence with no
file, no line and no column, which in a dynamic-first language is the type
error arriving from nowhere. flan_rt.c's bounds and arithmetic traps have
taken an emitter-threaded (loc, loclen) pair since they were written, and
[flan_dyn_cast_kind] is the fresh precedent on the dyn side; this is the same
pair, threaded through [arith], [want_nums] and [order] to the five
arithmetic and four ordering entry points. [eq] never traps and takes none.

The three trap printers take the pair and print nothing for a NULL loc, so
every other call site in the file — and test/dyn_ops.c, which calls the
runtime directly and has no source position — keeps its sentence byte for
byte. [trap_oom] is left alone: it is reached from [gc_alloc], which has no
site to be given and would have had to grow one on every allocation path in
the file for no reader's benefit.
2026-09-20 17:36:10 +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
c4e07256db A three-element defvar reads its third element: a type is zeroed static, anything else is dyn
# Conflicts:
#	FIX.org
2026-09-20 15:14:56 +07:00
Joseph Ferano
9321547822 A defvar's third element decides: a type is the static, anything else is dyn 2026-09-20 15:00:15 +07:00
49ee8774d3 The case inside a struct literal is the same unwritable value as a bare one
const_defconst_init matched MakeCase at the top level only, so a case nested in
a struct literal got the general computed message, whose advice nobody could
follow. Emit.const found it by recursing; this finds it the same way, left to
right and first offender wins, which is the order the emitter spelled fields
in. FIX.org's claim that the messages did not change is rewritten to say what
did change about the general one.
2026-09-20 14:47:19 +07:00
ce93ac7622 A defconst's value is what the linker writes, on both backends
The refusal moves to the checker: Emit.const refused a computed defconst by
name while the x86 backend ran it through the startup function behind an
.init~once. flag, like a defvar, so the two backends disagreed about the same
program. One refusal in Check.const_defconst_init ends that, and it is the only
place that can name the way through.

The accepted set is unchanged: Tast.const_init's, which is Emit.const's and the
x86 data_sym path's, plus the integer arithmetic collect's folding pass has
already turned into an Int before the initialiser is looked at.

Emit.const's two refusals become a failwith no program reaches; emit_global's
gconst || const_init loses its left half; x86 needed no edit, since it never
classified by the form. test_flan's infers probe asks Check.expression now that
a defconst can no longer wrap an arbitrary expression.
2026-09-20 14:47:19 +07:00
e6ac833626 The follow-ups the day's reviews left behind, each re-verified
flan.abi.require was spelled by hand in both backends, which is the one
job Mangle has. Moved; emit and x86 produce byte-identical output on the
reload path either way.

Four comments in the dyn-cast code asserted things that are not true.
The warning's location prefix now reads like every other loc-bearing
runtime diagnostic instead of inventing a shape. widen's contract says
what cast_dyn actually does with it. The thread-safety note names the
torn {ptr,len} overread rather than a duplicated line, and says why no
lock. The site table's borrowed loc pointer names what keeps it valid.

The memory op's note claimed a completeness it does not have: dyn push
and put may allocate and are deliberately silent. Said so, in the note,
in the classifier, and in FIX.org where the decision belongs.

The documented flycheck form only matched warnings, so a real error
made it say the checker returned non-zero and found nothing.

flan-clear-memory cleared one buffer where the toggle clears all.

Two comments claimed test/dyn_ops.c calls every function flan_dyn.h
declares; six are declared and never called there.

flan_dyn_stub.c's deadness is written into FIX.org for the author to
decide on. Not deleted here.
2026-09-20 14:32:17 +07:00
b6ea14f930 A numeric cast opens a dyn box itself 2026-09-20 13:52:27 +07:00
f030c5f7f1 Memory diagnostics on demand: gc and native allocation sites, faintly
# Conflicts:
#	FIX.org
2026-09-20 12:57:30 +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
001f17b8bf Four copies in the checker become four callers
get and map-remove asked the runtime the same question through two
identical bodies; only the symbol differed, so map_lookup takes it and
both entry points keep their own preambles and their own prose.

invented_ctx existed and six sites still wrote the sixteen-field literal
out by hand. All six go through it now — three verbatim, three with the
two or three fields that make them a written body rather than an
invented one — so a field added to the record is one edit.

dyn_descriptors and dyn_sites each walked every place a value can live,
and the two walks had already drifted: an unnamed frame slot was "a
local of f" in one and "a local in f" in the other. One value_sites
walker now, with the callers' filters kept on their side; the surviving
text is "a local in f", because a named slot has always read "n in f"
and neither spelling was pinned by a test.

The duplicate-field scan appeared three times and the ZII fill twice —
check_case's own comment conceded the copy. given_once and zii_fill.
The union keeps its unknown-member pass ahead of the scan rather than
folding it in, because that ordering is what it reports today.
2026-09-20 11:50:19 +07:00
eec9efc94c M2 item 3: typed containers cross into dyn as views of permanent storage
# Conflicts:
#	lib/emit.ml
#	runtime/flan_dyn.h
2026-09-20 11:18:18 +07:00
c765aad70f A slice level after the first index was invisible to the lifetime rule
(at g i j) is one Tast node carrying the whole index list, so the At
arm's guard on target.ty settled level zero and nothing after it. A
global [2 [[3 i64]]] indexed twice reached a slice's element, crossed
into dyn as a view, and printed a returned frame's contents with exit
0 (ASan: stack-use-after-scope in view_box). The arm now steps each
index the way [indexed] does and demands an array at every level;
the Field and Slice arms inherit the fix by recursing into it.
2026-09-20 10:51:22 +07:00
bddc8fc5dd and hands back its deciding operand too, and both locs get sharper
(and a b) desugared to (if a b false), so it answered the last operand
only when every operand was truthy; a falsey one came back as a bare
false, where Clojure answers the falsey operand itself. It now uses the
same expansion or got in ad0f1fb -- (let [t a] (if t b t)) against or's
(let [t a] (if t t b)) -- so the operand that decided the form is the
answer, and the test is still evaluated exactly once.

The temp binding and its if now carry the operand's own loc rather than
the whole form's, which the or fix had lost: (or (vec-new i32) v) blamed
the enclosing form at 3:13 and now points at 3:18, the operand, and and's
second operand gained the same precision.

The parse pins in test_flan.ml now tie the bound name to the temp the if
tests and the bound value to the first operand, so a desugaring that
dropped the temp and wrote the operand into the arm twice no longer
passes; and has its own pin. dyn-if-truthy.flan grows the falsey-nil and
falsey-false answers, 0 and "" as truthy operands, one- and zero-operand
forms, and a printing operand that proves both the short circuit and the
single evaluation.

One behaviour that used to compile changed: with both arms of the
desugared if now holding real values, (and dyn-value typed-bool) unifies
on the typed arm and a non-bool dyn decider traps at the strict bool
boundary -- (and (box nil) some-bool) printed false and now traps, the
mirror of what (or false (box "s")) already did on dev-loop. Recorded in
FIX.org as the author's call on how check_if should join a bool arm and a
dyn arm.
2026-09-20 10:36:05 +07:00
633a7b2025 A global slice's element is not permanent, and four comments that were not true
The [At] arm of [permanent_root] recursed through any indexed target, so an
element of a global SLICE answered permanent the way an element of a global
ARRAY does. An array's elements are inside the global's storage; a slice's
are ptr+len pointing wherever, which can be a frame already returned — the
program that stashes (slice local 0 2) in a global slice and views an element
compiled and segfaulted with no diagnostic. The arm now recurses only when
the target's own type is an Array.

With it, the refusal/acceptance pair in test_flan.ml (one word apart) and a
view over an element of a global array in dyn-view.flan's mode 0.

The element check now runs before the lifetime check in all three container
arms: a local (Vec string) was told to make it a global, and a global
(Vec string) is refused anyway, so the advice was a dead end.

And the four strings that claimed more than the code does. flan_dyn.h
already had the honest version — a view is exactly as stale-safe as the
thing it is a view of — so the refusal message, box's comment and FIX.org
now say that instead of promising a dyn value can never dangle; a global
[i64] cut from a dead frame still passes and still reads it (ASan:
stack-use-after-scope in view_box). The element message no longer tells a
(Vec string) that string is not the case the restriction exists for.
dyn_ops.c's hand_vec comment no longer says flan_rt.c is unlinked when it
calls two of its functions; flan_rt.c said the same thing and is fixed too.
FIX.org's arena paragraph now separates the header's lifetime (compile time,
already covered) from releasing the arena under a live view: free-all traps
cleanly on the epoch, arena-destroy is a heap-use-after-free in
view_vec_check, the same gap flan_vec_check has on the typed side.
2026-09-20 10:34:23 +07:00
3f7c42257f Review found the hazard relocation-safety missed: a view can outlive its frame
Relocation was proved sound and stayed sound — a Vec view holding the
header's own address survives a push that grows and moves it, because
there is no snapshot to invalidate. That was never the whole of the hazard.
Refusing every container into dyn outright, before this lane, meant a
dangling view was unreachable; the moment box stopped refusing, three
routes opened at once — a view returned from the function whose frame the
Vec lived in, one stashed in a dyn global and read after that frame is
gone, and one left behind when a condition transfer unwinds it. All three
are stack-use-after-return, reachable for the first time.

The rule: a typed container crosses into dyn as a view only when its own
storage is permanent — a global's. On the dynamic side Flan follows Clojure
and Common Lisp, where holding a value can never hand you garbage; treating
a view as a bare pointer and calling the lifetime the programmer's problem
is the Odin answer, and it is the wrong trade on this side of the language.
check.ml's permanent_root walks the checked expression back to its root: a
global is permanent, a field or an array element of one is permanent at the
same fixed offset, and a slice cut directly from one at the call site
inherits it — the trace is what a slice carries, and it is lost the moment
the slice is bound to a name first, so that case is refused too rather than
guessed at. Everything else answers false: a local, a parameter, a
temporary, and anything reached through a (Ptr T), because a heap-durable
pointer and a frame's own are the same type and the checker cannot tell
them apart — admitting one admits the other, which is the whole hazard this
closes. An arena-held header turns out not to be a separate case at all: an
arena changes where a Vec's elements live, never where its own header — the
binding — lives, so it is already covered by the storage-class check above.
Both directions of the F1 escape were reproduced before the fix (a genuine
ASan stack-use-after-return, reproduced by building the pre-fix tree) and
confirmed refused at check time after it, for all three routes.

Three more findings, all in the runtime rather than the boundary:

view_vec_check, on finding a stale container, rendered the very view it had
just declared unsafe to read — which called back into the same check,
unconditionally, an infinite recursion rather than the intended trap. Fixed
by never rendering the container in the stale message at all; the sentence
names the two epochs and nothing else, which is everything a reader needs
and the one thing that was safe to read.

dyn_equal's VEC arm read x->len and x->u.v.items regardless of kind, which
for a view answers 0 and the union's other member reinterpreted as dyn
words: two views with different contents compared equal, a view and an
equal heap vec compared unequal, and a map keyed by any view collided with
every other view, silently. vecish_len and vecish_at read either shape
correctly and the arm now goes through them. obj_words gets the same
explicit OBJ_VIEW case on the same reasoning, unreachable today only
because mark_push's own gate already excludes the kind — this is the belt
next to that brace.

The three restatements of flan_vec's layout — flan_rt.c's real struct,
flan_dyn.c's mirror, and dyn_ops.c's hand-built one — had a comment
claiming a reorder would not compile or link, which was never true of a
void*-typed forward declaration. flan_vec_layout and
flan_dyn_vec_hdr_layout each report their struct's size and field offsets;
dyn_ops.c's new "layout" mode compares both against offsetof on its own
hand_vec, so a disagreement is a FAIL line in dune test instead of a
silent corruption at whichever view reads through the wrong offset next.

Also: the survey program's comment excusing a by-value parameter's view as
"value semantics, not a hole" was wrong on its own terms — a write through
such a view does reach the caller's storage, only growth diverges — but the
question is moot now: every container the program views is a global, and
the file was rewritten around that rather than patched. And an i32 element
does not cross into a view either, but the refusal used to say why in words
that were true only of a string element; it now says what i32 actually is
and what the restriction is actually for.

Rebased onto dev-loop's item-4 landing (221df5a).
2026-09-20 10:10:52 +07:00
ad0f1fbd6a or hands back its deciding operand instead of a bare bool, review pass on item 7
The dyn if truthiness review turned up that or's answer position, unlike
and's, still traps on a non-bool dyn value: or's short-circuit sentinel
sat in the then arm of its own if, the one check_if types first, so that
sentinel decided the whole expression's type and a later non-bool dyn
answer hit the strict bool boundary and unboxed itself into a trap
rather than surviving as itself. (or nil "x") — the canonical Clojure
(or x default) idiom — crashed instead of answering "x", identical on
all three backends.

or now binds its test to a temp and answers the temp itself, exactly the
way Clojure's own or macro expands: (or a b) becomes (let [t a] (if t t
b)), not (if a true b). The temp evaluates a once and lets the answer be
a without writing it a second time as the then arm; it is the temp's own
type check_if sees first, so or hands back the actual truthy operand the
same way and always has. Verified real output, unchanged, on LLVM, -O0
and --x86, and the survey program now exercises the case its own header
used to exclude for being unsafe: a non-bool value stopping or and being
handed back as-is.

check_truthy also gets three corrections a closer look found. Its own
[loc] used to come from the enclosing if/while/not rather than from the
condition itself, so the rt call and cast it builds carried the wrong
column in an --x86 disassembly or the dev inspector whenever the
condition was not the form's first token; it now takes loc from the
scrutinee's own AST node, confirmed against a real --x86 dump. A comment
now names the precondition its exception-swallowing retry rests on: none
of check.ml's save-restore sites (barrier, in_frames, in_defer, loops,
scope) are exception-safe, which is harmless only because the retry
always either succeeds cleanly or re-raises and aborts the compile
before ctx is read again — and would stop being harmless the day some
want-sensitive elaboration on this path could succeed differently on
retry. And a bare keyword condition, which used to be checked with
want:Bool from the start and refused by the keyword arm's enum-or-refuse
case, now resolves as the dyn keyword instead and is unconditionally
truthy — a deliberate loss of that diagnostic, the author's call, pinned
in test_flan.ml so it does not regress by accident.

The two typed-refusal messages captured before this pass (a float
literal condition, an i32 while condition) are unchanged, checked again
against the same baseline. test_flan.ml's parser test for or's shape is
updated to match the new let-bound desugaring.
2026-09-20 09:56:34 +07:00
29a9441f12 Typed containers into dyn as views — M2 item 3
A (Vec T), a slice or a fixed array crossing into dyn no longer refuses; it
is a view, one word in the box, over the container's own storage. Reads box
the element on the way out; writes tag-check the dyn value's tag against the
element type on the way in and trap, by name, on a mismatch, never coercing
or silently storing.

The open question the decision left — whether the descriptor points at the
container or snapshots pointer and length beside it — is settled by kind. A
Vec view holds the address of the Vec's own header (flan_rt.c's flan_vec,
restated in flan_dyn.c under the file's standing "if either table changes,
change both" rule) and reads ptr and len live on every operation, so a push
that reallocates cannot leave it stale: flan_vec_grow overwrites that same
header in place, and there is nothing captured at the crossing for the
growth to invalidate. A slice and a fixed array cannot grow, so a flat view
snapshots data and length once; pointing it at the value's own slot instead
would be worse, since a slot's lifetime is not the slice's.

The element set is i64, f64 and bool, not everything box already handles
typed-to-dyn. A string element's dyn form is a pointer into the collector's
heap, and a typed container's storage is arena or stack memory the collector
never scans — a wider set would let a write plant a live reference nothing
ever traces, which no care at the write site closes. (Vec string) and a
typed (Map K V) keep the "does not cross into dyn yet" refusal, now for that
reason.

flan_dyn.c gains a fourth object kind, OBJ_VIEW, and flan_dyn_len/at/set_at/
push and the printer each grow one branch for it beside the existing vec
one. A view's own stale-container check is the runtime's own spelling
(flan_trap, park-and-inspect) rather than flan_rt.c's rt_die, per the
duplicity doctrine; growing a Vec through a view calls flan_rt.c's own
flan_vec_push rather than re-implementing doubling and allocator adoption a
second time. (set (at target i) x) against a dyn target — a plain dyn vec or
a view alike — was a hole in the base dyn milestone rather than something
item 3 introduced; it is wired to flan_dyn_set_at here because a view's
writes needed it to exist at all.

Both backends: emit.ml and x86.ml both already passed a Vec or a Map to a
runtime call by address rather than by value; a fixed array crossing into a
view needed the same arm added in both, for the same reason — a copy would
view the copy and never see a write to the caller's own array.

test/dyn_ops.c drives the runtime directly with a hand-built Vec header and
a plain C array, ahead of any compiler involvement: reads, writes on both
element kinds, the tag-check refusal on every element kind, the range
refusal, and the push that grows and moves a hand-built header out from
under the view watching it. test_flan.ml turns the old "does not cross into
dyn yet" refusal into acceptances for Vec/slice/array, keeps it for a string
element and for Map, and adds the element-restriction refusal by name.
test/programs/dyn-view.flan is the compiler-level survey: a Vec view mutated
through both sides including the grow-and-move case, a fixed array's and a
slice's views, a bool Vec's view, and its own two trapping modes for the
acceptance rows to run against. test_sanitize.ml carries the survey's happy
path; test_dyn.ml's new refusals are the runtime's own.
2026-09-20 09:19:17 +07:00
264765a6a5 Dyn if tests truthiness — M2 queue item 7
A dyn scrutinee is no longer required to already be a bool: it is tested
for truthiness, Clojure's rule, not C's or Python's — nil and false are
the only falsey values, and everything else, including 0, "", an empty
vec, an empty map and a keyword, is truthy. A typed scrutinee is
unchanged and keeps needing a strict bool.

The runtime side is one new entry point, flan_dyn_truthy
(runtime/flan_dyn.c/.h), reading the tag directly rather than unboxing —
it never traps, unlike flan_dyn_need_bool. Both backends reach it the
same generic way flan_dyn_need_bool already did: check.ml emits an
ordinary Rt call plus the existing i32-to-bool Cast, so emit.ml only
needed the LLVM declare added and x86.ml needed nothing at all.

check.ml's check_truthy is the one funnel every boolean position in the
language goes through: if's own condition, while's, and not's argument.
when and cond reach it for free because they desugar to Ast.If in
parse.ml, and so does and's condition; or's condition does too, but its
answer position is a separate story — its short-circuit sentinel is the
then arm of its own if, which check_if types before anything else, so a
non-bool dyn value reaching that position still meets the strict bool
boundary. and's sentinel sits in the else arm instead, so the real
value's type wins and and hands back the actual last operand,
Clojure-style; or does not get that for the reason above, and reordering
it is a decision for another day, not this one. shortcircuit in parse.ml
carries the note.

check_truthy checks the scrutinee with no expectation first, so a dyn
value takes the truthy path and everything else takes the strict one. A
refusal on that second path is re-checked with the old want:Bool rather
than reported from the bare check, because a bare integer or float
literal, or a bare None, answers "what type is this" differently than
"is this a bool" — check.ml's own arms only give the nicer sentence
("expected bool, found the integer literal 5", "expected bool, found
None") when asked the second way, and that sentence is preserved exactly,
letter for letter, against what a typed if already said.

test/programs/dyn-if-truthy.flan surveys every falsey and truthy case —
nil, false, true, 0, a nonzero number, an empty and nonempty string, an
empty and nonempty vec, an empty and nonempty map, a keyword — through
if, when, cond, and, or, not and while, with real output pinned in
test_acceptance.ml across LLVM, -O0 and --x86. test_flan.ml covers the
checker side directly: a typed if still takes a bare bool and still
refuses a non-bool scrutinee and a bare None with their original
messages, a dyn if/not/while/when/cond/and/or all accept a non-bool dyn
condition. test/dyn_ops.c gets a matching set of direct calls to
flan_dyn_truthy, keeping the header's own contract with the C side.
2026-09-20 09:16:21 +07:00
221df5af1c Three review findings on M2 item 4, none blocking
(Some nil) at an already-(Option T) want reported expect's bare-T
sentence instead of its own: checking the argument against inner's
element type routed a literal nil through expect's "wrap the type in
Option" refusal before Some's own is_nil_lit guard ever ran, and the
advice was nonsense there — the type already is one. The argument is
now checked with no want when it is syntactically nil, which is what a
bare nil resolves against on its own, so it arrives at Some's own
check still dyn and still nil.

no_fallback_slots cannot see the slot unbox_option mints: %dx/%ax are
the pool-ran-dry fallback for a temporary root_plan counted, and a
named local root_plan never counted emits neither mark. The comment
where nil-option.flan and some-nil.flan were added to that list said
otherwise; corrected to say what the check does and does not cover,
and to record that the slot was verified by reading the IR directly
instead — flan.unbox-opt's dyn slot is pushed, flan.as-dyn's is a
plain alloca correctly, since its element is always scalar there.

dyn_offsets falls through Option/Vec/Map with no arm of its own,
correct today only because Check.hidden_dyn refuses a dyn inside any
of them at every storage site first. Commented at the fallthrough,
naming hidden_dyn as the gate and the typed-container view (M2 item 3,
in review now) as the kind of change that could relax it for Vec/Map
without anything here pointing back.
2026-09-20 07:41:55 +07:00
3c1fb1b31e nil <-> None at (Option T) boundaries, and (Some nil) unconstructible — M2 queue item 4
Both directions of the boundary go through expect, the way every other
dyn crossing does. A dyn's tag decides which case an (Option T) becomes
on the way in; an Option's own tag decides nil or a boxed payload on the
way out. box_option/unbox_option build the same If-over-a-tag shape get
and map-remove already build for the same reason, reading an Option's
tag and payload with the raw Field access Render's structural printer
already uses — nothing new for either backend to lower. A literal
Some/None skips the runtime check entirely, since the checker already
knows which case it is.

A bare T has no None to become. The literal nil the checker can see is
refused right there, at compile time, in expect itself — the author's
decision to do both halves rather than settle for the runtime trap
alone. Everything one step removed from the syntax — a dyn that only
turns out to be nil once the program runs — reaches flan_dyn_need_i64's
existing DynType trap, unchanged; there is no dataflow in this checker
for it to be otherwise (see "Ownership tracking repealed").

(Some nil) is refused the same way: the literal at compile time, with a
message saying why nil and None would collide; a dyn that turns out to
be nil only at run time through the new flan_dyn_need_not_nil, which
traps by the same route flan_dyn_need_i64 does.

(Option (Option T)) does not cross either direction — boxing Some of an
inner None would box it as nil, indistinguishable from the outer None,
the same ambiguity (Some nil) is refused for. The type itself stays
legal on the typed side; only the crossing does not exist for it.

(Option dyn) needs no case of its own in the boundary code — the
payload is already dyn, so box_option/unbox_option treat it as the
identity — but it is not yet a value a program can hold anywhere. The
per-type-descriptor pass (M2 item 2) refuses it at every storage site
today, the same way it refuses (Vec dyn), because a struct's dyn fields
are marked by byte offsets and (Option dyn)'s payload has none. Item 4
does not lift that gate; it only makes the boundary already correct for
the day items 2/3 do.

expect grew a ctx parameter to build the fresh slot the two new
crossings need — every call site threaded through, one context
mismatch caught and fixed in check_fn's tail-expression case along the
way. var's None case grew a direct Dyn arm: None at a dyn want is nil
outright, with nothing to build.

nil-option.flan carries the crossings that succeed and ends on the
bare-T trap; some-nil.flan is (Some nil)'s run-time half, kept in its
own file the way dyn-boundary.flan is one trap per program. Both are
in no_fallback_slots and test_sanitize.ml: the new dyn temporary
unbox_option's tag test mints is rooted, and reads its Option's tag and
payload through ASan clean, --sanitize matching the unsanitized run
byte for byte.
2026-09-20 07:20:27 +07:00
70f87d90b2 A visited set crossed a question it was never asked
The foreign boundary walks two questions, and they are not the same question:
one counts a dyn reachable only through a further pointer, the other counts
any dyn at all.  They shared a [seen] set across the crossing between them, so
a type name marked visited on the way down was pruned from the walk below the
pointer — and the shape that hits it is a struct with a pointer to its own
name.  [(defstruct Node [next (Ptr Node) x dyn])] at a [(Ptr Node)] parameter
was accepted, while exactly the same thing unrolled into two types was
refused, which is the tell.  A cycle of two, [A {b (Ptr B), x dyn}] and
[B {a (Ptr A)}], went the same way.

C hung a malloc'd node off [next], put a dyn in it, and Flan allocated a
hundred thousand times: heap-use-after-free in flan_dyn_tag out of c_peek,
freed by gc_sweep out of flan_gc_collect.  The checker accepted it and the
collector freed a live value, which is the one failure this boundary exists to
prevent.

The set does not travel across the crossing now.  It still terminates — each
walk's own set guards its own recursion over names, and a crossing starts a
separate finite walk — and it does not start refusing a recursive shape with
no dyn under it, which has rows of its own here because that is the way a fix
like this goes wrong.

Neither recursive shape had a row.  That gap is why it took four passes over
this code to surface, so both are pinned now, alongside the two acceptances
that say termination held.  And the three return rows stop sharing one needle:
each names its own type, because three rows against the common half of one
sentence would all pass on a message that named the wrong shape.

dune test --force: green, 0 failures.
2026-09-20 00:25:49 +07:00
a1111f7b3c The foreign boundary is about ownership, and it was reading shape
Three shapes hand a dyn word into memory nothing roots, and the check written
last round caught one of them.  A (Ptr S) return was refused; (Ptr (Ptr S))
and (Ptr [S]) were not, because the predicate it asked was dyn_anywhere, which
had just been taught to stop at a pointer.  Stopping there is right — a
pointer is a view of storage something else roots, and that is what lets a
(Vec (Ptr Cond)) be written — but it is the wrong question at a boundary where
nothing roots the far side at all.  So there are two predicates now.
dyn_anywhere is the storage-shaped one and is unchanged; dyn_through follows
pointers and slices, and only the foreign boundary asks it.  The second of
those shapes matters more than it looks: (Ptr [S]) is what shim.ml's own
advice tells people to write when C returns an aggregate.

Neither was a regression.  At the commit before last there was no walk over
the externs at all and all three were accepted; what landed was one level of
a check that wanted to be recursive.

And the direction.  The refusal was justified by what C hands back and then
applied to parameters as well, which made the ordinary read-only borrow
unexpressible: a foreign parameter receives the address of a *place*, and a
place is a frame slot, a global or an array inside one, every one of them
rooted with its descriptor and marked for the whole call.  So (Ptr S) and [S]
as parameters are borrows and stay writable.  One level down the storage is
C's again — a (Ptr (Ptr S)) parameter is an out-parameter and what C writes
into it is a pointer of C's own — so the parameter question is asked below the
outermost level and the return question is asked from the top.

Evidence, all three shapes with the check lifted, ASan: heap-use-after-free in
flan_dyn_tag, freed by gc_sweep out of flan_gc_collect, allocated by the Flan
frame that stored it.  With the check back, all three refused by name.  And
the allowed direction proved rather than assumed: a C read-only borrow of a
(Ptr S) and a [S] slice argument, called before and after twenty thousand
allocations, dyn fields intact and clean under ASan.

A Tast.extern carries its declare's location now, so these refusals point at
the line rather than at <unknown>:0:0.

Last, the one arm of the saturation that did not saturate: a negative array
length reached the multiplication as a small number, which is the exact shape
the cap must never be handed.  Out of range in either direction saturates.

dune test --force: green, 0 failures.
2026-09-19 23:02:26 +07:00
27b672a3d2 Five back from review, and the first one was the mangle eating a type
A descriptor's symbol was the type's printed form with every character an
assembler would refuse replaced by a dot, and the table was keyed by that.
The mangle is many-to-one — a Flan name may hold -, +, *, ? and / — so row-a
and row+a were one entry, the second of them was pushed with the first's
descriptor, and the collector read at another type's offsets: past the end of
the object when the first was the larger, and never where the second's dyn
actually sat.  ASan named it, a stack-buffer-overflow inside gc_mark_all.  It
is the same corruption root_plan pools its temporaries to avoid, arriving
through the name rather than through the supply, which is a lesson about where
identity lives: the table is keyed by Types.to_string now, which is an
identity, and the symbol carries a counter so two types cannot collide however
they mangle.  dyn-struct.flan grows the pair, held live across the churn, and
an acceptance assertion asks the emitter directly how many descriptors it
wrote under that label — two, or the two are sharing one.  That assertion is
the half with teeth: whether an overread off the end of a frame slot lands on
anything is luck, and the run's own output was not red under the defect.

The cap on a flattened array's offsets was bypassable by the thing it was
meant to stop.  [4611686018427387904 S] wrapped the multiplication negative,
so the test read as under the cap, the declaration was accepted, and the
emitter then sat building the offset list until something killed it.  A
refusal that overflows into an acceptance is worse than no refusal.  The count
saturates at one past the cap now and the message says more-than rather than a
figure that came out of a wrap.

dyn_ops.c's second assertion had no teeth: a marker never writes through a
root, so "the word at a non-dyn offset is untouched" passed under any marker
at all.  What discriminates offset-driven from word-driven is a dyn word the
descriptor leaves out, holding five hundred objects, that must NOT survive —
and it is checked by adding its offset to the table and watching the line go
red.

dyn_anywhere descended through Ptr and Slice, so (Vec (Ptr Cond)) was refused
with a sentence about a dyn inside a type whose storage contains none.  A
vector of pointers to condition structs is an ordinary thing to write.  It
stops at a pointer now, which is the line hidden_dyn already took for a bare
(Ptr S) and the line the whole argument rests on: a pointer is a view of
storage something else roots.

Which leaves the one honest hole, and it is named at the boundary where it
opens rather than left in a comment.  Storage C hands back was never rooted
and never will be, so a (Ptr S) crossing a declare with a dyn anywhere under S
is refused by name — the same sentence a bare dyn already gets there, one
level down.

dune test --force: green, 0 failures.  dyn-struct.flan clean under ASan and
UBSan and identical at -O2, -O0 and --x86.
2026-09-19 22:53:16 +07:00
f6ab3b62fc A struct's dyn fields become markable, so the refusal comes off
The crux was never where to put a descriptor; it was how an instance finds
one.  A bare struct on the stack has no header to hang a pointer off, and
giving it one would change the layout C interop agrees on, change the stride
of an array and change what embedding a struct in another costs.  So it has
none.  The instance never carries a pointer to its type and the collector
never derives one from the bytes: the pairing of an address with a descriptor
is made at the *push*, by the code that put the value there and therefore
knows its static type.  That is the same trick the shadow stack has always
used, and it makes the stack case the easy one rather than the impossible one.

A descriptor is the size of an instance, a count, and a table of byte offsets,
emitted once per type as private static data.  Flattened, not a graph — a
struct held by value contributes its offsets shifted by where it sits, and a
fixed array contributes its element's once per element — so nesting costs
nothing at run time and there is no recursion in the marker.  The offsets of a
big array would be a big table, and that is capped with a sentence rather than
half of the repeat form item 3 will bring.

Four places a value of such a type can live, and all four are rooted: a frame
slot, a global, the temporary a call's by-value return is spilled into, and
the slot a condition that is not a place is evaluated into.  The last two are
new and are the ones that were not obvious.  A callee roots its dyn words and
pops them in its epilogue, so between the return and the caller's store the
only copy is a register, which a collector that finds its roots by address
cannot see; the same hole was open for a Flan call answering a bare dyn and is
closed here too.  And a condition crosses as a pointer into the signalling
frame while a handler allocates, which is exactly what the original refusal
said could not be made safe.

dyn_roots grows into root_plan and both backends read it, which is what the
older note about one counter deciding both ends was always for.  The aggregate
temporaries are pooled by type rather than handed out in mint order: a
positional supply that drifted would pair an address with another type's
descriptor, and marking arbitrary offsets off a base is corruption where a
missed root is only a bug.  Pooled, the worst a drift can do is run out.

What is still refused is a dyn no static offset can reach — inside a typed
container, in a data type's payload or a union's members where the cases
overlay, or under an Option where the payload exists only beneath the tag.
A (Ptr S) and a [S] are deliberately not on that list: neither owns storage,
and the only storage this compiler hands out for such a type is a frame slot,
a global or a fixed array in one, all of them already rooted.  That is what
lets a handler clause take its (Ptr Cond) and read a dyn payload.

test/programs/dyn-struct.flan is the evidence.  It runs forty thousand rows
past flan_dyn.c's one-megabyte floor, so marks and sweeps really happen, and
it holds live values through them in all four places at once.  It has teeth:
with the descriptor walk stubbed out of the marker, the kept vector's length
comes back 24 instead of 628 and its first element is a stale word.  Clean
under ASan and UBSan, same output at -O2, -O0 and --x86.  dyn_ops.c grows an
aggregate-root mode so the runtime half can be wrong on its own, with a
header word holding a bit pattern that looks boxed and is not a dyn slot.

--no-gc still refuses, and had to be told how: a struct with a dyn field is a
collected value even when no expression in the program ever has the type dyn,
because a zeroed one still has a word the collector is asked to mark.

dune test --force: green, 0 failures across every suite.
2026-09-19 22:53:16 +07:00
7c1a818001 The defer counter costs something on the warm path, and the valgrind row rests on one half
Three notes, all of them about a comment that was true as far as it
went. The cost sentence on defer_slot said one i64 and one compare on
a path that is already unwinding, which is the unwind's share and not
the whole bill: every function with a defer also pays a store of zero
at entry and a store of an ordinal at each defer, on the ordinary path,
whether anything transfers or not. Small, correct, and now written
down as what it is.

init-conditions.flan is in test_valgrind.ml, and only the game-data
half earns that row -- it is the one that reaches a real (free src)
over a slot slurp transferred out of. The note half is output-only:
revert the fix and its trace changes, but a wrong integer in a global
is not a memory error and memcheck stays quiet. A later edit trimming
the edn dependency would leave the row green and blind, so the header
says so.

And register_defer saves in_defer rather than clearing it. Unreachable
today, because defer_ok is false inside a defer and nothing can nest
one; written so that the flag comes back rather than being dropped on
the day that changes.
2026-09-19 21:59:11 +07:00
4c667cf062 A defer the text never reached is not cleanup the unwind owes
The report was that handler-case segfaults in a top-level global
initialiser. It does not, and never did. What crashes is one frame
further down, in a position that has nothing to do with startup:

    (defn read-file [path string] dyn
      (let [src (slurp path (heap-allocator))]
        (defer (free src))
        (read (as-slice src))))

slurp signals FileError, a handler further out unwinds, and the
transfer leaves this frame through its defers -- all of them. But src
was never written: the form that would have written it is the form
that transferred. free then reads whatever the stack held under that
slot, which at -O0 in a small program is zero and at -O2 is a live
pointer, which is why the same program looked like an optimiser bug
from one direction and a startup bug from the other.

return never had this. The checker splices the defers registered above
it and no others, and says so where it does it. The transfer exit took
the whole list, because it is one landing block per function and
nothing in the IR said where each defer had come into being.

So the count is kept. The first defer in a function mints an i64 slot
zeroed at the top of the body; each defer leaves a store of its own
number where it was written; and fdefers -- the transfer path's copy,
and only that copy -- tests the count before running each one. The
normal paths are untouched and still need no test. It is all in the
checker: what reaches a backend is a slot, a store and an if, so
neither emitter learned anything and the x86 one needed no frame of
its own.

test/programs/init-conditions.flan is the survey. The top half is the
part of the report that was never true: handler-case with its
condition firing and with its body completing, handler-bind, and a
restart-case, all four in a global initialiser, all four answering
what they answer anywhere. The bottom half is the part that was: a
defer below the signalling form, which must not run, beside one above
it, which must -- a fix that took the unregistered one off by taking
them all off would have traded the crash for a leak, and 302/2 is the
line that would catch it. Three acceptance rows, LLVM, -O0 and --x86.

The detector was valgrind, not ASan: reading a stack slot nobody wrote
is not ASan's bug class and it reported nothing on the broken binary,
while memcheck named the conditional jump in flan_vec_free with the
unwinding frame directly above it. The program is in both lists --
test_valgrind.ml because that is what saw it, test_sanitize.ml because
that is where the transfer exit's frames are already watched.

spec-conditions.md section 5 now says which defers a transfer runs.
The author's (defvar game-data dyn (handler-case (edn/read-file ...)
[(FileError [c] nil)])) works.
2026-09-19 21:57:00 +07:00
c3e0703642 Five more, and the pattern held: the messages were right, the prose wasn't
check_struct's comment claimed a nonempty map is never mistaken for a
struct literal; is_struct_map (parse.ml) says otherwise for any size of
.field-first map, not just {} -- (g {.x 1}) on a function g still reported
bare "unknown struct g" with none of the help {} gets. Extended the
helpful message to that shape too rather than only fixing the comment,
since it is the same trap one shape over. The two shapes need different
advice, not the same one reworded: an empty map can be bound to a variable
and passed as an ordinary argument, (let [m {}] (g m)); a .field-keyed one
cannot, because expr itself refuses a bare {.field v} outside a
struct-literal position, so there is no let-binding that rescues it. The
message for that shape says so instead of repeating advice that would not
work.

Watchdog.is_dying is gone. The reviewer's own probe settled it: forcing
watchdog.ml's dying to flush_all and Unix._exit 2 directly, bypassing
at_exit entirely, gives the same exit 2 with no shared flag, no LIFO
assumption, and no window for a raise between setting a flag and exiting
to leave failures recorded and the process at exit 0. test_acceptance.exe
has no other at_exit registration (grep confirms), so nothing depends on
the watchdog path running through that chain.

test_acceptance.ml's own comment claimed calling exit inside an at_exit
handler recurses through do_at_exit. Checked directly against this
compiler (OCaml 5.2.0): it does not -- each handler gets a run-once guard
since 4.14, two stacked handlers with the inner one calling exit 7 both
ran exactly once and exited 7. Unix._exit is kept, but for the true
reason: it is not about correctness, it is about being the last word --
_exit terminates immediately and skips whatever the rest of the at_exit
chain would otherwise still do, so a handler this file grows later cannot
change the outcome underneath this one.

test_flan.ml's loose needle "keyword/value pairs" matched both the
odd-number-of-forms message and the wrong-key message; tightened to
"keyword/value pairs — found", which only the row's actual message
contains.

And {:where 1} as a defn's whole single-form dyn-map body now refuses,
where {:a 1} and {} in the same position do not -- :where is peeled
unconditionally, with no single-form exception, because that is what lets
it catch a moved closing paren leaving a stray predicate as ordinary body
code. Deliberate, and now pinned, so the next edit to this arm has to
notice it is choosing to narrow the language again rather than finding out
from a bug report.

Verified every case by compiling, including the two-handler exit(7) probe
run directly against this OCaml. dune test --force: exit 0, clean grep for
FAIL and Fatal error.
2026-09-19 21:51:31 +07:00
87aeb7b0da Six defects back from review, fixed rather than reworded around
F3: the unknown-struct-vs-function message overgeneralized a one-case
parser quirk into a language rule that does not exist. (g {:a 1}) compiles
fine -- only the empty map is stolen by is_struct_map's Map [] -> true.
The message now names {} specifically and says why: it is read as the
zero-field struct literal, not "a map literal cannot be passed as an
argument".

F2: the ordering refusal named machine numbers only, when Types.is_comparable
also admits enums and enum-compare.flan orders one with (< k :mid). Both
messages -- equality and ordering -- now name every type each actually
covers.

F1 and F5, together, since both live in the same parse.ml arm: the
rest = [] carve-out that let a single-form dyn map body through
reintroduced the exact bug the earlier fix was for. (defn mx [a $t b $t]
$t {:where (ordered? $t)}) -- a :where clause with nothing after it, what
a moved closing paren produces -- no longer matched, fell through to expr,
and printed "unknown function ordered?" from inside what was meant as a
predicate. A :where map is peeled unconditionally again, whether or not
anything follows it; every other keyword, plus the empty map and any
non-keyword key, is still peeled only when the body has more after it, so
a real single-form dyn map body is still left alone. The comment
justifying the discarded-map refusal claimed a map literal has no side
effects; it does -- {:a (println "hi")} prints, confirmed by running it --
so the reasoning now names the actual problem, a value going unused, not
a false claim about purity.

Closing that F1 hole this way opened two more, both traced by rebuilding
the pre-fix parse.ml and diffing real compiler output rather than
reasoning about it: {.x 1} at a defn body's head used to get its own
message, "a bare map is not an expression", and briefly started getting
"a constraint map is keyword/value pairs" instead, because the broadened
guard no longer required a keyword and started catching the struct-field
shape too. Excluded explicitly, with a comment saying why, so expr's
dedicated diagnostic fires again. And (defn main [] i32 {} 0) and
(defn main [] i32 {"a" 1} 0) -- the empty map and the non-keyword-keyed
map, the two siblings a keyword-only guard could never have caught --
now get their own refusals alongside the keyword case, each pinned with
a rejects_check row, along with the where-with-no-body case and the two
legitimate single-form bodies that must keep compiling.

F6: the acceptance runner's tail already caught every failure on every
path through the binary -- there is no skip branch that bypasses it, and
the no-clang branch runs zero rows -- so last round's at_exit guard and
the FIX.org note both overstated what was broken. Both now say what was
actually true: the exit status was already trustworthy, the guard is
insurance against a future case leaving past the tail instead of through
it, and the other nine test binaries were already sound the same way.
The guard also had a real bug of its own: forcing exit 1 whenever
failures was nonzero would stomp the watchdog's own exit 2 if a hang
followed a few already-failed rows, since Stdlib.exit runs at_exit
handlers LIFO. watchdog.ml now flags when it is the one unwinding, and
test_acceptance.ml's guard defers to it -- shared state for a single
caller, justified by there being no other way for one at_exit handler to
know a sibling handler is already mid-exit with a code of its own to
protect.

Verified every case in this commit by compiling and, where it mattered,
running the actual program -- not by inspecting the arm and assuming.
dune test --force: exit 0, clean grep for FAIL and Fatal error.
2026-09-19 21:35:08 +07:00