17 Commits

Author SHA1 Message Date
3e68089cde A parameter with no type is dyn, decided where the type names are all known
The type itself, the ABI its operations call into, and the one decision the
feature could not avoid: (defn f [x y]) is one parameter or two, and which one
depends on whether y names a type.

Parse does not decide it. That lookup is the one its defn comment records being
removed for being wrong twice in one day -- the set of type names is incomplete
at parse time by construction, and macros generating definitions is what
widened the failure. So the vector is carried undecided, as Ast.pitems, and
paired in Check, after every file is loaded, every macro expanded and every
header imported. The set is complete there. It is not complete across time, and
the comment says so: a defstruct written later changes a signature with no edit
to the function.

The return slot stays mandatory and dyn is written out in it. The ambiguity
there has no syntactic resolution at all -- a capitalised head in a list is both
a type application and a struct literal -- so the third state the parameters
needed does not exist for the return type, and ret = None goes on meaning Unit.

What the feature costs, and what is taken back: a slot with no type used to be a
syntax error, so a mistyped type now reads as an extra parameter with no
diagnostic. A name within one edit of a type's gets the resolver's own
did-you-mean, and an unknown capitalised name is reported as the unknown type it
is -- not one parameter in the corpus is capitalised. A lowercase name
resembling no type is the feature working, and is the residual.

The x86 backend refuses dyn by name; both callers already name --llvm, and the
daemon takes that backend by default, so this is the first thing a user of dyn
sees. The JS dialect refuses it too, for the opposite reason -- every value
there is already dynamic and what is missing is only the lowering.

runtime/flan_dyn.h is the fixed ABI. flan_dyn_stub.c stands in until the real
collector lands and says in its header that it verifies nothing about roots.
2026-09-19 05:47:49 +07:00
6cc94e00d6 defunion is C's union, and reading the member you did not write is defined
The name freed up by the rename now means what C means by it: the members
overlay one storage, the size is the largest of them, the alignment the
strictest, and nothing anywhere records which one was written. It serves
two things that wanted it. Binding a C header means holding the union the
library holds and reading whichever member the library's own tag says is
live -- a tag Flan cannot see, because the rule relating them is prose in
a manual. Overlaying an f32 on a u32 to look at its bits is the other,
and it is the same read.

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

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

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

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

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

cimport can now check what it could not. A C record holding a union
member was not recorded at all, so the defstruct beside it went unchecked
rather than checked wrongly; a named union member resolves to a defunion
now and the whole record is compared field by field. The defunion itself
is compared against the header's union as a set and not in order --
every member is at offset zero, so a permuted one is the same type and
reporting it would be a finding that is not one -- while a member the
header has and Flan lacks is reported, because that is what changes the
size. A defunion against a C struct, or a defstruct against a C union,
is reported in both directions. An anonymous union member is still
skipped, and the comment now says that the gap is on the Flan side:
there is nothing to declare.
2026-09-17 19:54:32 +07:00
a9b7eb1556 The survey landed, and neither census comment states a number any more 2026-09-14 08:49:43 +07:00
37d94ed66f The pointer arm agrees promised, and the comment stops promising it 2026-09-14 08:37:30 +07:00
9d5689ffa2 Every citation of a moved document now resolves from where it is written 2026-09-14 07:12:27 +07:00
70af1966a2 Braces are no longer a type: (Map K V) is the only spelling
The author's decision, and it removes the one syntax question generics
had. A return type can no longer be written in braces, so a {...} after
the signature is unambiguously the constraint map and there is no
structural rule to explain.

The reasons for the record: the brace's value meaning and its type
meaning do not correspond the way the bracket's do - [1 2 3] is a value
whose type is [3 i32], but {.x 1} is a value whose type is a name, and a
map value is built by map-new with no braces anywhere - and dropping it
reserves {} in type position for anonymous struct types.

Braces in a type are refused with the surviving spelling named rather
than falling through to "expected a type". Types.to_string and
Cimport's source printer both print (Map K V) now, and Shim refuses the
application spelling where it used to refuse only Ast.Tmap.
2026-09-13 14:58:27 +07:00
7f86f32699 where predicates admit operators, and a type variable is move-only until it says otherwise
The spike proved the shape; this makes it the feature. A generic body is
still checked abstractly once, but now it may be told what to assume:
{:where (ordered? $t)} at the head of the body, Clojure's {:pre [...]}
spelling, with five predicates - ordered?, equal?, hashable?, numeric?
and copyable?.

The syntax catch settled structurally: {K V} is still a legal return
type, and a constraint map is told from one by its leading keyword. A
keyword is not a type anywhere in the language, so the slot after the
return type is unambiguous and {K V} did not have to go.

A type variable is move-only by default, with copyable? the opt-out.
Move is the stricter rule, so assuming it can only refuse a valid
program, never admit a bad one. That is Rust's T: Copy and not Odin's
anything - Odin has no move semantics at all.

The runaway refusal no longer names a depth. It names the chain: a
generic already on the instantiation stack, asked for again at a type
built around the one it had before, is growing and will not stop.
2026-09-13 14:33:45 +07:00
4ff3e9a922 A finding about the bindings file is not a reason to stop a build
check_constants makes two kinds of finding and they were treated alike.
A value that does not match, or a C name the header does not have, is
the library contradicting the package and stops a build the way a
permuted defstruct does. An enum nobody mapped and a rule that reaches
nothing are about the package's own bindings file -- real, and worth
fixing, but telling a lane that added a defenum to go and edit a config
in a message shaped like "your layout is wrong" is the wrong thing to
fail a build with. Those gate generate-c, where that file is edited.

Also: a const prefix now counts as reaching a name before an explicit
constant line is consulted, so a rule whose every match is also spelled
out by hand is not reported as matching nothing.
2026-09-13 14:16:57 +07:00
9223c9002a An enum is four bytes, and the header check now reads the constants
Two gaps the raylib examples hit.

The layout check compared a Flan enum against the header's `int` and
called it a disagreement. It is not one: Shim.cty lowers a defenum to
int32_t in a struct field exactly as it does in a parameter, which is
what the signature check already knew and the layout check did not. One
predicate now serves both, symmetric, and tolerant of a 32-bit integer
and nothing else -- f64 against the library's float still fails, in the
very struct whose other field is an enum. Camera3D.projection is a
CameraProjection again and rl/camera-projection is gone with it, so
`.projection :perspective` resolves at the construction site.

And generate-c's claim said nothing about a defconst or a defenum
member, so a wrong flag bit was completely silent. `bindings` gained
`enum`, `const` and `constant` lines saying what a Flan constant is
called in C -- the prefix is nowhere in the Flan name, so it is declared
rather than guessed. Nothing goes quiet in either direction: a name the
rule builds and the header lacks is reported, a rule that reaches
nothing is reported, and a defenum with no line is itself a finding,
because otherwise the silence just moves up one level.

clang's dump gives anonymous EnumDecls for every raylib enum and no
value at all for an enumerator written without `= n`, so the constants
are one flat table and the values are counted the way C counts them.
cache_format bumped with the dump type.
2026-09-13 14:11:35 +07:00
7332bed14a The acceptance case stops asking for a header it no longer needs
The imported-bindings program was skipped without FLAN_RAYLIB_H, which was
right when the bindings only existed if a header was read and is now the one
gate hiding the change. It runs on the same terms as every other raylib case:
libraylib linkable, no raylib-devel. A generated.flan regenerated empty or
stale is now caught on an ordinary machine rather than only on one with a
header exported.

Reach.link already answers the shim worry BUILT.md's cold-build attribution
would otherwise raise: sand.flan links 110 wrappers, not 425, because the
bindings nothing reachable calls are dropped. What is left is 65ms of frontend
on a cold build, against a header read that was 60-90ms of a fresh session and
15.5ms of every redefinition. Both numbers are in BUILT.md now.

Also a swallowed line continuation in the exclusion message.
2026-09-13 08:22:10 +07:00
85ef56f657 The bindings are committed, and regeneration is what checks them
generated.flan carries the 253 declarations the importer reads out of raylib's
header, so a build needs libraylib linkable and no header at all. The opt-in
no longer decides how many bindings a package has — every build now gets all
425, they are greppable, and they diff when raylib moves.

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

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

`bindings` beside `headers` is what survives regeneration, because a hand-edit
to a committed generated file does not. Two directives: `exclude` drops
raylib's three allocator entry points, and `name` gives the 19 generated
predicates the `?` spelling the hand-written ones already use.
2026-09-13 08:07:40 +07:00
36c3e5a56d The globals a frame names, checked the way its slots already were
The globals section attributed a frame by its slot fingerprint, which is the
wrong cut for it: a redefined body can name entirely different globals while
binding identical locals, so the check saw no change and the new body's
reference set went into the union under the old body's frame, with the frame
numbers beside an entry saying so.

So a second fingerprint. Reach.ref_fingerprint hashes the set of globals a body
names — sorted and deduplicated, because a reference set is not ordered, where
slot indices make the slot fingerprint order-sensitive on purpose — and it
travels the path the first one already cut: %fninfo, flan_dev_frame_refsig, the
agent's snapshot, the backtrace line, Dev.globals_op. Different means the frame
is skipped by name with its reason, and the rest of the stack still contributes.

Two numbers rather than one, because they are two facts. A frame whose slots
match and whose globals do not has locals that are perfectly readable and
attribution that is not, and a combined hash would make locals refuse a frame
with nothing wrong with it. locals still checks the slot fingerprint alone.

It lives in reach.ml because expr_refs is already the walk that answers what a
body refers to, and is the walk the union itself is built from. One consequence:
emit now reaches reach, which closes a cycle through Load if cimport calls
Build.cachedir, so the header cache spells the object cache directory itself.

test_dev.ml drives the exact case — a body that binds identical locals and names
untouched where the stopped frame names pressure. With the check disabled it
fails twice: the missing refusal, and untouched appearing under frame 0.
2026-09-12 16:59:44 +07:00
df1a43d3ac The header cost was never on the redefinition path, and here is the split
The 15.5ms attributed to re-reading the header on every reload is not that.
A timer around each stage says the cached dump reads in 0.33ms, the extraction
takes 3.3ms and the checks 0.55ms — about 4ms, once, in Session.create. The
rest of flan reload's delta is Load and Check over 256 more declarations, and
the +3.6ms a redefinition really pays is Check and Emit.redefinition against a
bigger program. A C-c C-c reads no header at all: eval's forms carry no import,
so no package is read.

Both cache levels anyway, because a long-lived process should pay nothing
twice. In the session, two tables: the dump by header, the declarations by
header and by what the package already declares. On disk, the existing cache
moved into the object cache directory beside the .o files. The in-memory key
is the path and the flags with no mtime, so a header edited mid-session is not
picked up until the session restarts — the rule a changed .c file follows, and
the rule that keeps new signatures from being checked against a process still
running the old layouts.

Measured: repeat import 3.65ms to nothing; flan reload unchanged, as it must
be, since it imports once per process.
2026-09-12 16:53:23 +07:00
ef7650ec99 A kebab collision takes every name in its group down
Two C functions whose names kebab to one Flan name used to resolve by order:
the first won the name, the second was refused. Which one that is depends on
the order the header happens to declare them in, so moving two lines in
somebody else's header would silently rebind a name a Flan program is already
calling — and the winner was left in the hidden list too, so using the name it
did get reported that it could not be had.

Neither takes it now. There is no reading of spin-2d that is obviously right
when the header offers both Spin2D and spin2d, so both are refused and both say
why; the author binds the one they want with a hand-written declare-c, which is
what that form is for. Found by test/headers/sample.h, which is why it is a
fixture rather than a raylib case.

raylib is unaffected: its 581 names are injective under the rule.
2026-09-12 16:03:07 +07:00
1fb208a991 The header is checked at build time, not only by a tool
Reading the header produced declarations and nothing else, so the gap the whole
thing exists to close — that nothing verifies a declaration against the library
— was closed by a command somebody could run rather than by a property the
build had. Now `import` runs both comparisons whenever a header resolves.

Build-stopping, not a note. The package named the header, so the header is the
package's own claim about what it binds; a defstruct that disagrees lays fields
out in the wrong order and reads as five plausible numbers rather than as a
link error. Continuing past a known-wrong layout to produce a program that will
read garbage is the shape the house rule against swallowing things exists to
prevent. Both messages point at the line in raylib.flan, not at the header.

Verified by breaking it on purpose: a permuted Texture2D stops the build naming
the field that moved, and `f64` where raylib says `float` stops it naming the
parameter — which is the hazard BUILT.md calls out by name and says only a test
can catch.

A set-but-wrong FLAN_RAYLIB_H used to be indistinguishable from not opting in:
the line was skipped and nothing was said. Unset still means off and silent; a
path that is not there is now an error naming it. That is the difference
between an opt-in and a trap.

test/headers/sample.h is one function per decision the importer makes. The
raylib case needs raylib installed, at the right version, with a variable set,
so it would skip everywhere and cover nothing; this one does not move. It also
found a bug, fixed next.

Reach still prunes with 256 extra declarations in play: a wasm32-wasi build of
a program that imports raylib and calls none of it links without libraylib,
which is the case Reach.link exists for.
2026-09-12 16:03:07 +07:00
4a78e50375 A package can name the headers it binds, and the import is nearly free
`headers` beside `link`, read the same way: a path, any clang flags that header
needs, ${NAME} expanded from the environment. What comes back is ordinary
declare-c declarations, generated before the package's names are qualified, so
they arrive as rl/… exactly like the hand-written ones and nothing downstream
can tell which is which. No new form, no new decl_kind, no reader or parser
change.

A leading `?` makes a line optional. vendor/raylib uses it, because "a build
needs libraylib linkable and not raylib-devel installed" is a property worth
keeping — requiring a header would take it from everyone to give the check to
whoever has one. Unset FLAN_RAYLIB_H and the build is exactly what it was; set
it and every signature is checked against raylib's own header.

A C symbol the package already binds by hand is left alone, so declare-c
remains the escape hatch and stays the thing that wins. A refused function
becomes a hidden name through Load.refuse_hidden, so writing rl/get-gamepad-name
says "GetGamepadName returns char *, and a string only crosses as a parameter"
rather than "unknown name".

Measured, because the cost is the whole argument for how much to import:

  release build   +14ms cold, +4ms warm   — Reach prunes the wrappers
  redefinition    31ms -> 46.5ms
  dev build       +333ms cold             — dev does not prune, 428 wrappers

Reach.link already drops a generated wrapper whose declaration nothing
reachable calls, and that is what makes a wholesale import cost nothing in a
release build. It does not prune dev builds, on purpose, so a dev build
compiles every wrapper once at session start; Build.shared compiles no C, so
redefinition does not pay that again.

Reading the header is cached — 64ms of a 72ms check, against 8ms for the whole
program without it. Keyed like the object cache, on everything that could
change the answer: the header's path, size and mtime, the full flag list, and a
format version, since the cached value is a marshalled dump. The extracted
signatures are cached rather than clang's JSON, because the parse is half the
cost. That takes the delta to 17ms.

Verified end to end and headless, using only imported declarations:
ColorToInt of {17,34,51,68} is 0x11223344 and ColorTint hands the four bytes
back separately, so field order is pinned by arithmetic rather than by a
round trip. TextLength of "hello" is 5, so the string crossing works.
2026-09-12 16:03:07 +07:00
19aa10158a Read the header instead of trusting the transcription
declare-c generates the wrapper, the typedefs and the prototype from one
declaration, so they cannot disagree with each other. What nothing checked was
whether the declaration matched the library — BUILT.md records that as trusted
rather than guaranteed, because no header was ever read.

This reads one. clang is asked for a JSON AST dump of the header and shelled
out to, not linked: -Xclang -ast-dump=json is the same binary on PATH that
every build already runs, which is plan.org's "Why LLVM IR as text" applied a
second time. Zig's old @cImport linked clang as a library and that is precisely
the dependency plan.org rejected.

cjson.ml is enough JSON to read the dump and no more, so this adds no opam
package to parse it.

What comes out of the header is signatures and nothing else — not structs, not
enums, not macros. The bound on how much is imported is the package's own
defstructs: a function whose signature mentions a struct the package has not
described is refused with that reason, so vendor/raylib describing thirteen
structs is what makes the import thirteen structs wide. Keeping the layouts
hand-written is also what makes checking them against the header's records
worth doing — a _Static_assert was rejected in BUILT.md as circular, and this
is not, because the two sides have different authors.

Refusals are demotions, taken from Zig's translator: it never drops a
declaration it cannot handle, it binds the name to a @compileError carrying the
reason so the failure lands at the use site. Load.refuse_hidden is already that
mechanism. So a returned char * does not kill the header — it makes one name
unavailable, with the reason attached.

flan import-c prints what it would produce, what it refused, how the package's
defstructs compare with the header's records, and how the hand-written
declare-c lines compare with the header's signatures.

Against raylib 5.5, the version whose .so vendor/raylib/link names: all 16
defstructs and all 172 hand-written declare-c agree exactly. Against the 5.1-dev
header installed in /usr/local it reports ten differences, nine functions that
version does not have and one that gained a parameter — so the check has teeth
and the clean run is not a vacuous one.
2026-09-12 16:03:07 +07:00