59 Commits

Author SHA1 Message Date
fd8620516c A char literal is a typed char unless typed code wants a number, and a char compares, hashes, converts with (i32 c) and a checked (char n), and crosses into dyn as a dyn char. 2026-09-26 13:49:02 +07:00
c24b19e7b1 Merge master into the dyn char lane 2026-09-26 13:09:28 +07:00
f35a2b240a Merge master into the dyn char lane 2026-09-26 12:51:41 +07:00
659f4d8c6c Merge branch 'master' into worktree-agent-a26dff0627201f77b 2026-09-26 12:29:59 +07:00
9c47d3bacc A dyn goes into every integer width range-checked, a char where its code point fits, and the trap names the width and what it found. 2026-09-26 12:27:40 +07:00
d5437c3f22 A non-ASCII char literal is refused where a byte is wanted, a dyn int or char goes into an i32 range-checked, a cast on a dyn char gives its code point, and C1 controls print as \uXXXX. 2026-09-26 12:15:04 +07:00
5cc661d6ab Merge branch 'master' into worktree-agent-a26dff0627201f77b 2026-09-26 12:07:11 +07:00
db67ab19b4 String is owned, growable, always-valid UTF-8 text over a (Vec u8), and the prelude's text builders return it. 2026-09-26 12:07:11 +07:00
66ea0b3abe Merge branch 'master' into worktree-agent-ab88d16e8e6fbfe15 2026-09-26 12:05:38 +07:00
6d8ced2ba3 A dyn text becomes a str as its own bytes, a dyn view becomes its own storage, and a dyn vec or map becomes a checked copy in a [const T], a fixed array or a struct, while a writable [T] is never a copy. 2026-09-26 11:48:01 +07:00
1d09f2fd3d Merge master into the dyn char lane 2026-09-26 11:11:42 +07:00
71f2b0b9b6 Dyn has a char that prints as its literal, dyn text counts characters, and chars and text convert between a text and a vec of chars. 2026-09-26 11:11:42 +07:00
c4452fc500 Merge master into the bit operators lane 2026-09-26 11:04:32 +07:00
473bf0a2b7 The bit operators are && || ^^ ~~ in .fln at Python's precedence, take integers only on typed and dyn values, and popcount, leading-zeros, trailing-zeros and the rotations exist on both backends. 2026-09-26 10:49:54 +07:00
0a05d83b3b A release build's dyn view carries no dev record, the dev registry grows instead of dropping notes, a container compared with itself still traps on a stale view inside it, has-key? traps at its site, and a trap clears the walk site it leaves. 2026-09-26 10:13:01 +07:00
d519ca81dd Merge master into the views lane 2026-09-26 09:29:15 +07:00
7953376e4e Any typed container of numbers, bools, str, structs, arrays, slices or Vecs crosses into dyn as a view from any storage, and a dev build traps on a view whose frame returned or whose block was released. 2026-09-26 06:10:11 +07:00
4eefd25df9 A class instance refuses a key its class does not declare, read or written, through get, put, .field and [:key]. 2026-09-26 06:02:51 +07:00
605eb97e75 A dyn value's .field and [:key] read as get and assign as put. 2026-09-26 05:52:56 +07:00
70b9040512 type-of answers a dyn value's kind as a keyword, and a class cannot be named after a built-in kind 2026-09-25 22:29:26 +07:00
e80411d283 type-of answers a value's kind as a keyword or an instance's class name, and a class named like a kind is refused. 2026-09-25 22:18:36 +07:00
4f060bb648 Dyn and typed containers print as [1 2 3] and {:a 1}, a refused dyn view names the value and a dyn fix in the file's own syntax, and a dyn function whose body gives no value says so by name 2026-09-25 22:01:40 +07:00
a13c2f9ec3 The Map header mirror the collector reads is checked against flan_rt.c's, array key pairs cannot share a name, and a program holding only a Map of dyn sets up the collector 2026-09-25 15:52:57 +07:00
6e29c19097 The collector walks a Map's full slots, so a Map may hold closures and dyn values and keeps them alive 2026-09-25 15:31:50 +07:00
a41e43cbba (slice d ...) over a dyn text answers a text, in the three spellings the typed slice has 2026-09-25 15:19:39 +07:00
5a78cdd202 Merge master 2026-09-25 13:36:54 +07:00
f3d969618d A class slot can carry a type, and set writes a declared slot 2026-09-25 13:32:16 +07:00
53880eb685 A class slot's type names its own package's class first, an int widens into a float slot exactly when it round-trips, and a constructor's refusal names the call that was wrong 2026-09-25 13:29:30 +07:00
639ff1859c Aborting an expression that trapped in the park abandons it by a jump back to the poll that called it, and the session stays 2026-09-25 13:28:16 +07:00
3e09a1f722 Merge branch 'master' into worktree-agent-ac750a5e83cab251b 2026-09-25 13:18:42 +07:00
9aeee9d676 Merge branch 'master' into worktree-agent-a6ffe579d55d1892b 2026-09-25 13:16:58 +07:00
16427f8d07 A class slot may be a class or an Option, an int widens into a float slot when exact, a gained typed slot starts at its zero, and a migration never re-enters itself 2026-09-25 12:55:25 +07:00
c6cb018b9f Only a closure that outlives its frame takes a collector environment, and the collector reads a Vec's elements only through a block it knows is live 2026-09-25 12:12:25 +07:00
e0af3c9b1d A method of update-instance-for-redefined-class runs on each instance as it migrates, and one that signals offers migrate-by-name 2026-09-25 11:55:40 +07:00
3892f1ed34 Merge master 2026-09-25 11:50:29 +07:00
2682214499 A defclass slot may declare a type that every store into it checks, and set writes a declared slot 2026-09-25 11:40:08 +07:00
3cb6cebdbe (- x) negates a typed number, a type variable and a dyn, and a float's negation of zero is -0.0 2026-09-25 11:37:56 +07:00
389e93ff09 A capturing fn's environment belongs to the collector, so a closure may outlive the frame that made it 2026-09-25 11:13:57 +07:00
5581bafacb A dyn at, set-at or push that traps names the file, line and column it was written at 2026-09-25 10:25:28 +07:00
aa5052e401 A dyn value an expression answers is the reply's value, not the program's output 2026-09-25 07:14:13 +07:00
57fe91f303 Five records become one, and every citation lands somewhere
FIX.org, NEXT.md, DISCUSS.org, docs/DISCUSS.md and the session handoff at the
root are one TODO.org now: 293 entries under seven subsystem headings, each
carrying an org keyword that says where it stands. A DONE entry is a few lines
saying what was decided and what that rules out; the reasoning that would not
compress — the embedding spike and the four reports the hand-written x86
backend was built from — moved into docs/BUILT.md instead, and its entries
point there in one line.

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

Every comment that cited one of the five by name now cites a heading that
exists, in TODO.org or in docs/BUILT.md. The session reports under
docs/handoffs/ keep naming the files they worked on, because rewriting them
would falsify what those sessions did; each carries a note saying where the
content went.
2026-09-21 21:05:48 +07:00
7f92136401 Old instances of a redefined class now follow the class
CLHS 4.3.6's update protocol, minus the user hook, on the dyn side's
defclass. Redefining a class used to be silent: a class is sugar for a
constructor defn, so the edit replaced a body and the instances already in
the program kept their old keys for ever.

Three pieces. A registry in flan_dyn.c holding each class's current slot
list and a generation, made only of interned kw_entry pointers so the
collector has nothing to trace in it and no root to push for it. A uint32
generation on the instance, fitted into the padding kind and mark leave in
front of len's alignment — sizeof(flan_obj) is 48 with it and was 48
without, and flan_dyn_obj_size is there so a later field that moves it
fails a test. And a registration thunk per reload, run by the agent
through flan_reload_call after the module's bodies are published: it has
to be a thunk, because the case this exists for is a class redefined and
not constructed.

Migration is lazy, at want_map, len's map arm and dyn_equal's. Slots kept
by name, gained slots nil, dropped slots gone, identity preserved, entries
rebuilt in the class's order so a migrated instance is indistinguishable
from a fresh one. Equality migrates both operands first, so it is over the
class as it is now.

The session had to stop refusing the constructor's signature change, and
does so only for a defclass and only when no compiled caller is left
behind. The checker gets there first in practice; the walk in eval holds
the reason locally rather than inheriting it.

The registry is advisory: a class instance is an open map, so a key a raw
put wrote that the class never declared is dropped by the next migration.
FIX.org says that plainly rather than pretending enforcement.
2026-09-20 19:46:57 +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
b6ea14f930 A numeric cast opens a dyn box itself 2026-09-20 13:52:27 +07:00
9eed4cbd8c The dyn header claimed a guarantee the linker does not give
test/dyn_ops.c includes the header and calls most of what it declares, so a
renamed function fails to link. It does not catch a signature that drifts
while the name stays -- C links on names, not types -- and it does not reach
flan_dyn_map_get, flan_dyn_map_set, flan_dyn_map_contains, flan_dyn_is_nil or
flan_dyn_need_not_nil at all, none of which dyn_ops.c mentions. The comment
now says that, and says which hole is closable from here.
2026-09-20 13:00:55 +07:00
e441d62874 Eight emitters were four renderings, and the escape table was three copies
The dev runtime rendered a value twice over: [flan_dev_emit_{u64,i64,f64,str}]
into the result buffer an evaluation is read back from, and
[flan_dev_watch_emit_*] into the current watch slot, with the same four bodies
either side and the sink as the only difference. [flan_dev_result_end] and
[flan_dev_watch_end] were the same ellipsis-and-generation close, comment for
comment, over two buffers.

So the rendering takes the sink as a parameter and the eight entry points are
eight one-line calls into four statics. The exports stay eight: the compiler
emits four of them by name (Session.externs) and a program reaches the watch
four through declare-c. ABI does not collapse because the bodies did.

The escape table is now [flan_escape_char] in flan_rt.c, once: what one byte
reads as inside a quoted string, into a caller's four bytes. A table and not a
printer, because the framings are genuinely different — [flan_escape_bytes]
builds a capped slice to hand back and the dev pair streams into a buffer it
does not own the end of — and the framing is the part that is each caller's.
The dyn printer keeps its own copy, which is docs/SPIKE-DUPLICITY.md §9's one
defended repeat: it is inside the runtime that owns the storage it walks. Its
comment, and flan_rt.c's, no longer tell a reader to change the other two.

Same for the NaN rule, which was spelled four times: [flan_f64_format] is
flan_rt.c's [flan_f64_to_bytes] without the slice, and the REPL emitter and the
watch table call it rather than restating "%g, and nan unsigned".

Byte-identical, checked two ways. A session driven over the daemon's socket
before and after — every arm of the emit family, the escapes, and a string long
enough to reach the truncating close — diffs empty. And a harness linking both
trees' flan_rt.c + flan_dev.c compares 8173 renderings: every byte 0..255
through both string emitters and through flan_escape_bytes, every length across
both caps and the ellipsis either side of them, both NaN signs, both infinities,
i64 and u64 at their extremes. Identical.

Dead code, each verified by its own grep before removal. These are exported C
symbols, so a program could reach one through declare-c; the evidence is that
nothing in the tree does, including the docs that write the surface down.

  flan_dev_watch_u64 (flan_dev.c) — one occurrence repo-wide, its own
  definition. The i64/f64/str siblings are declare-c'd in
  test/programs/dev-watch.flan and written down in emacs/MANUAL.md; this one
  appears in neither, and in no other file.

  flan_break_resume (flan_rt.c) — the only non-prose reference was a stale
  extern in vendor/agent/flan_agent.c with no call under it. Both gone.
  [flan_name_id] stays: the bounds and arithmetic conditions still hash through
  it. [flan_restart_take]'s comment no longer points at a function that is not
  there.

  flan_dev_watch_enabled (flan_dev.c) — prototyped in flan_agent.c, never
  called. [watch_on] is still read directly by the three sites that gate on it.

  clang_stamp (lib/build.ml) — a [lazy] never forced; one occurrence.

  marshal (lib/expand.ml) — no reference anywhere. [write], which it wrapped,
  is called twice in [call], so the [let rec] group is demoted to keep it.

  is_bytes (lib/js.ml) — dead within js.ml. Nothing else in that file is
  touched: the JS backend is parked, not dead.

Kept on purpose: [Loc.forget_sources], documented in docs/BUILT.md as
deliberately retained, and [flan_dev_watch_num_f64], which emacs/MANUAL.md
declares as public surface.

Two comments in lib/dev.ml argued the orphan grace in terms of elisp symbols
from before the rename — [flan-dev--open], [flan-dev--connection],
[flan-dev-poll-interval], and a file called emacs/flan-dev.el. None of those
exist. Re-spelled as [flan--open], [flan--connection], [flan-poll-interval] and
emacs/flan.el, which is where they are; the reasoning is load-bearing and is
unchanged.

dune test: exit 0. 59 lines of code out, 24 lines net of the prose that says
why.
2026-09-20 11:57:35 +07:00
4f060d07db The park's root reset stops at the globals' watermark
# Conflicts:
#	test/test_dyn.ml
2026-09-20 11:24:46 +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
e6af2d3f77 The park kept the frames' roots off and the globals' on
flan_merged_park called flan_dyn_root_reset, which emptied the collector's
root stack. The frames' roots had to go — main is left by longjmp, so they
name stack the next run overwrites — but the dyn globals' roots are on that
same stack, pushed once by the emitted main and never popped, and the park
took them with the frames.

The park is not a quiet state. It services evaluated thunks, a thunk
allocates, and an allocation collects. So a program with (defvar config dyn)
answered (get config :s) with its string before any thunk ran and with nil
after one that allocated past the heap's floor — a read of memory the sweep
had freed, answering nil by luck of what the freed words decoded as.

The emitted main now brackets its global pushes: flan_dyn_root_globals_begin
empties the stack, the pushes go on, flan_dyn_root_globals_end records how
many of them there are, and the park resets to that line instead of to zero.
Nothing between the two allocates, which is what keeps the globals from being
swept in the window where they are unrooted — and [begin] emptying the stack
rather than adding to it is what makes a re-entered main re-root the same
globals rather than push a second copy of each, which also closes the other
half: a re-run used to re-push roots over slots left dangling by the park.

Both emitters, because the dev loop's default backend is x86 and a fix in one
lowering is not a fix. A program with no dyn globals emits neither call and
its root stack still resets to empty, which is what an empty push list should
leave behind.

flan_dyn_root_pop now clamps at the globals rather than at zero. An
over-popping frame eating the globals is the one way that clamp could turn a
miscount into this same use-after-free.

Covered twice. test/dyn_ops.c's park mode is the runtime's half — a run, a
park with a collecting thunk in it, and another run, three times over,
asserting both that the global survives and that the frame's five hundred
objects do not. Under ASan the old reset reports heap-use-after-free in
flan_dyn_tag with the free in gc_sweep; under memcheck it reports 24 errors
and still prints the right answer, which is the shape of the bug. test_dev.ml
drives the whole daemon over its socket on both backends against
programs/dev-dyn-global.flan.

Not touched, and it wants a decision rather than a patch: a re-run re-enters
flan_program_main, which re-runs the lifted startup function, so every global
with a computed initialiser is reset by a re-run. That contradicts dev.ml's
own note and FIX.org item 1. It is independent of this — the roots are right
whether or not the values are re-initialised.

Nor is this the reload path. A defvar added by an evaluation gets its storage
from flan_dev_global (emit.ml's new_globals, x86.ml's counterpart) and there
is no flan_dyn_root_push anywhere on that path in either backend, so a dyn
global added to a live session is unrooted. That is a separate defect with a
separate fix, and nothing here makes it better or worse.
2026-09-20 11:06:14 +07:00