From 9eed4cbd8c419ca951a32da4f40fc4bb09128cc9 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 20 Sep 2026 13:00:55 +0700 Subject: [PATCH 1/5] 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. --- runtime/flan_dyn.h | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/runtime/flan_dyn.h b/runtime/flan_dyn.h index 3d58cb4..6a82133 100644 --- a/runtime/flan_dyn.h +++ b/runtime/flan_dyn.h @@ -3,12 +3,21 @@ * This header is not compiled into a program. The build embeds the runtime's * .c files as strings and hands each one to clang on its own, with no include * path (see [Build.compile_c]), so runtime/flan_dyn.c declares everything it - * defines and this file declares it a second time. That second copy is not - * held honest by hand the way a repeated escape table would be: it is made - * mechanical, because test/dyn_ops.c includes this header and names every - * function below, so a signature that drifts from the implementation is a - * link error in `dune test` rather than a surprise at someone else's call - * site. + * defines and this file declares it a second time. What keeps that second + * copy honest is partial, and the exact shape of it is worth writing down + * rather than overclaiming: test/dyn_ops.c includes this header and calls + * most of what it declares, so a name that is spelled one way here and + * another way in flan_dyn.c fails to link in `dune test`. + * + * Two holes in that. A signature that drifts while the name stays is not + * caught at all — C links on names and not on types, so a changed parameter + * or return type compiles on both sides and goes wrong at the call site + * instead of failing the build. And five of the names below are never + * referenced by dyn_ops.c — [flan_dyn_map_get], [flan_dyn_map_set], + * [flan_dyn_map_contains], [flan_dyn_is_nil] and [flan_dyn_need_not_nil] — + * so not even the rename check reaches them. Calling those five from + * dyn_ops.c would close the second hole; nothing available from here closes + * the first. * * Who reads it: the compiler lane, which emits calls to these names, and the * C tests. The whole of the boundary is here. What is behind it — the value From 2daf7e9164f7885dea1980a518aeb97e67838227 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 20 Sep 2026 13:00:55 +0700 Subject: [PATCH 2/5] A short-circuit temp stops calling itself a destructuring one fresh_temp takes the purpose, so and/or mint and~N and or~N where they used to mint destructure~N. The names are shown -- the inspector lists a frame locals by name -- and nothing pinned the old spelling; destructure~nth is an unrelated compiler builtin. Confirmed in a --debug build DWARF. The caret wart on and last operand in a want-free position is documented at the site and in FIX.org rather than fixed: every candidate fix reads worse than what is there, and the real fix is check_if choosing which arm to blame. --- lib/parse.ml | 43 +++++++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/lib/parse.ml b/lib/parse.ml index 3d9128b..6e04863 100644 --- a/lib/parse.ml +++ b/lib/parse.ml @@ -14,15 +14,21 @@ let sym (f : Form.t) = | Sym s -> s | _ -> fail f "expected a name, found %s" (Form.to_string f) -(* Names for the temporaries a destructuring binding needs — the value is bound - once and every name in the pattern reads *that*, so a pattern over a call - calls it once. [~] is a delimiter in the reader, so no symbol anyone can - write contains one: these cannot collide with a source name and a source - name cannot shadow one. Reset per program so the names, and therefore the - slot numbering downstream, are the same every run. *) +(* Names for the temporaries this file mints — the value is bound once and + everything that needs it reads *that*, so a destructuring pattern over a + call calls it once and a short-circuit operand is evaluated once. [~] is a + delimiter in the reader, so no symbol anyone can write contains one: these + cannot collide with a source name and a source name cannot shadow one. + Reset per program so the names, and therefore the slot numbering + downstream, are the same every run. + + The purpose is part of the name because these names are shown: the + inspector lists a frame's locals by name, and a short-circuit temp called + [destructure~3] is a plain lie about where it came from. One counter across + all purposes, so a name is still unique whatever minted it. *) let temps = ref 0 -let fresh_temp () = incr temps; Printf.sprintf "destructure~%d" !temps +let fresh_temp what = incr temps; Printf.sprintf "%s~%d" what !temps (* Destructuring binds in [let] and nowhere else. Every other binding position — a [defn] parameter, a [defstruct] field, an [fn] parameter, a [dotimes] @@ -673,7 +679,7 @@ and bindings f (items : Form.t list) : Ast.binding list = the reference as well as the binding is what makes the two impossible to separate by accident. *) and temp (p : Form.t) (v : Ast.expr) : Ast.expr * Ast.binding = - let t = fresh_temp () in + let t = fresh_temp "destructure" in ({ Ast.e = Ast.Var t; loc = p.loc }, { Ast.bname = t; bty = None; bval = v; bloc = p.loc }) @@ -915,7 +921,24 @@ and cond f (args : Form.t list) : Ast.expr = because the sentinel it answered was a bool literal that boxed to fit whatever the real branch was; neither is now, and they are at least symmetric about it. Making bool and dyn arms join as dyn is a check_if - question, noted in FIX.org under item 7 and not decided here. *) + question, noted in FIX.org under item 7 and not decided here. + + One known wart, measured rather than guessed, and left alone deliberately. + In a want-free position — [(println (and true true (vec-new i32)))] — the + caret lands on the *second* [true] and not on the vec: the last operand is + the then arm, check_if types the then arm first, and the mismatch is + therefore reported against the else arm, which is the previous operand's + temp. An operand anywhere but last is a condition instead, so check_truthy + blames it at its own loc and the caret is right; [or] is right everywhere, + because there the chain and not the sentinel sits in the else arm. Giving + the else arm's [Var] node the *last* operand's loc moves the caret onto the + vec and makes the sentence read backwards — "expected (Vec i32), found + bool" under a caret on the thing that is the (Vec i32) — so it is not an + improvement; answering a bool literal again would revert the paragraph + above; and inverting the condition to move the last operand into the else + arm buys a [not] per operand and worse locs than it fixes. What would + actually fix it is check_if preferring the arm that is not a compiler temp + when it reports, which is check.ml's call. Written up in FIX.org. *) and shortcircuit f (args : Form.t list) ~is_and : Ast.expr = let mk e = { Ast.e; loc = f.loc } in let rec go = function @@ -923,7 +946,7 @@ and shortcircuit f (args : Form.t list) ~is_and : Ast.expr = | [ last ] -> expr last | x :: rest -> let ex = expr x in - let t = fresh_temp () in + let t = fresh_temp (if is_and then "and" else "or") in let tvar = { Ast.e = Ast.Var t; loc = ex.Ast.loc } in let bind = { Ast.bname = t; bty = None; bval = ex; bloc = ex.Ast.loc } in let rest = go rest in From 6461322aa96e1a760f4e50c7d0f52b95d94ab760 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 20 Sep 2026 13:00:55 +0700 Subject: [PATCH 3/5] The length write-back moves back inside the seqlock odd window end_value did the release store and left the caller to narrow its length afterwards, which is a length a reader is entitled to have missed. Split into truncate_value and close_value so flan_dev_watch_end can store its 32-bit length between them. Benign today -- only a full slot is truncated and a full slot length is already WATCH_VAL -- and not a rule anyone would keep. The two externs flan_dev.c borrows from flan_rt.c now get a value probe, once per process on the first render, the way flan_vec_layout ties the three statements of the vec header together: nothing else compares those prototypes and the link matches names, not types. --- runtime/flan_dev.c | 101 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 79 insertions(+), 22 deletions(-) diff --git a/runtime/flan_dev.c b/runtime/flan_dev.c index 00b2cb8..4d844ec 100644 --- a/runtime/flan_dev.c +++ b/runtime/flan_dev.c @@ -246,12 +246,6 @@ static void put_i64(sink out, int64_t x) { * level rather than on the number. */ extern int flan_f64_format(double x, char *buf, size_t cap); -static void put_f64(sink out, double x) { - char buf[64]; - flan_f64_format(x, buf, sizeof buf); - put(out, buf); -} - /* Quoted and escaped, in C, because doing it in the generated IR would be a * loop per string and the language has no allocator to build the result in. * A string whose content is not escaped does not round-trip and reads as a @@ -259,11 +253,55 @@ static void put_f64(sink out, double x) { * string would become a second row of the table on the wire. * * The table is flan_rt.c's [flan_escape_char], which is also what println's - * [flan_escape_bytes] escapes through. One table, three framings of it. */ + * [flan_escape_bytes] escapes through: one table, and the two callers differ + * only in how they frame what comes out of it. flan_dyn.c has a copy of the + * table rather than a call to it, deliberately and for a reason argued there + * and in docs/SPIKE-DUPLICITY.md §9. */ extern int flan_escape_char(unsigned char c, char *out); +/* The two [extern]s above are this file's hand copies of prototypes flan_rt.c + * owns, and nothing in the build compares the two: each translation unit is + * compiled on its own with no include path (see [Build.compile_c]), and the + * link that joins them matches names and not types. A parameter added on one + * side, a [size_t] cap that becomes an [int], a return that stops being a + * length — all of those build clean and then go wrong here, inside a render, + * with nothing pointing at the cause. + * + * So they are checked the way the tree checks its other cross-file agreement + * it cannot #include its way out of — by value, at run time, loudly. That is + * [flan_vec_layout] and test/dyn_ops.c's "layout" mode for the vec header; + * this is the same idea one function wide. Two calls with known answers, once + * per process, on the first value this file renders: if the callee is not the + * function these declarations describe, the answers do not come back right + * and the process stops here rather than emitting a wrong wire format. + * + * Not a constructor, because flan_dev.c is linked into every build and not + * only a dev one, and nothing should run in a release image that its program + * did not ask for. On the first render instead: the branch is one predictable + * test per value, and it runs in the dev paths only, which are the only paths + * that reach these two functions from here. */ +static void check_shared(void) { + static int checked; + char buf[64]; + char e[4]; + if (checked) return; + checked = 1; + if (flan_f64_format(1.5, buf, sizeof buf) != 3 || strcmp(buf, "1.5") != 0) + die("flan_f64_format is not the function this file declares", "1.5"); + if (flan_escape_char('\n', e) != 2 || e[0] != '\\' || e[1] != 'n') + die("flan_escape_char is not the function this file declares", "newline"); +} + +static void put_f64(sink out, double x) { + char buf[64]; + check_shared(); + flan_f64_format(x, buf, sizeof buf); + put(out, buf); +} + static void put_str(sink out, const uint8_t *bytes, int64_t len) { size_t n = len < 0 ? 0 : (size_t)len; + check_shared(); put(out, "\""); for (size_t i = 0; i < n; i++) { char e[4]; @@ -273,19 +311,20 @@ static void put_str(sink out, const uint8_t *bytes, int64_t len) { put(out, "\""); } -/* Closing a value: the ellipsis a full buffer earns, and the counter back to - * even. Shared by [flan_dev_result_end] and [flan_dev_watch_end], which is - * the same seqlock twice over two different buffers. +/* Closing a value, in the two halves the seqlock needs it in. Shared by + * [flan_dev_result_end] and [flan_dev_watch_end], which is the same seqlock + * twice over two different buffers. * - * Room for the ellipsis is made rather than assumed: the buffer is full by - * definition when [full] is set. + * The first half is the ellipsis a full buffer earns. Room for it is made + * rather than assumed: the buffer is full by definition when [full] is set. * - * The counter goes last, and back to even, so a reader that sees the new - * generation sees the whole value. [| 1] first for the same reason [begin] - * sets rather than increments: this must land on an even count whatever state - * an abandoned write left behind. */ -static void end_value(char *buf, size_t *len, size_t cap, int full, - uint64_t *gen) { + * They are two functions and not one because everything a reader will look at + * has to be stored before the generation is, and a caller may have a length + * of its own to write back — the watch slot keeps its length in 32 bits and + * so cannot pass its own field here. Splitting lets that write-back land + * inside the odd window where it belongs, rather than after the release + * store, where it would be a tear the day a length actually changed. */ +static void truncate_value(char *buf, size_t *len, size_t cap, int full) { if (full) { const char *ell = "..."; size_t k = strlen(ell); @@ -293,6 +332,14 @@ static void end_value(char *buf, size_t *len, size_t cap, int full, memcpy(buf + *len, ell, k); *len += k; } +} + +/* The second half: the counter last, and back to even, so a reader that sees + * the new generation sees the whole value. [| 1] first for the same reason + * [begin] sets rather than increments: this must land on an even count + * whatever state an abandoned write left behind. Nothing a reader reads may + * be written after this returns. */ +static void close_value(uint64_t *gen) { __atomic_store_n(gen, (*gen | 1) + 1, __ATOMIC_RELEASE); } @@ -305,7 +352,8 @@ void flan_dev_emit_str(const uint8_t *bytes, int64_t len) { } void flan_dev_result_end(void) { - end_value(result, &result_len, RESULT_MAX, result_full, &generation); + truncate_value(result, &result_len, RESULT_MAX, result_full); + close_value(&generation); } /* Copy the current value out, with the counter that says which one it is. @@ -563,12 +611,21 @@ void flan_dev_watch_end(void) { watch_slot *s = watch_cur; watch_cur = NULL; if (s == NULL) return; - /* [len] widened and narrowed around the shared close, which counts in + /* [len] widened and narrowed around the shared truncate, which counts in * size_t because the result buffer does; a slot's own length is 32 bits - * and [WATCH_VAL] is 192, so neither conversion can lose anything. */ + * and [WATCH_VAL] is 192, so neither conversion can lose anything. + * + * The narrowing write-back goes before [close_value] and not after it: a + * reader takes [len] and [val] together under the generation, so a length + * stored after the release store is a length the reader is entitled to + * have missed. It happens to write back the same bit pattern today — only + * a [full] slot is truncated, and a full slot's length is already + * [WATCH_VAL] — but that is a fact about the current cap arithmetic and + * not a rule anyone reading this would keep. */ size_t len = s->len; - end_value(s->val, &len, WATCH_VAL, s->full, &s->gen); + truncate_value(s->val, &len, WATCH_VAL, s->full); s->len = (uint32_t)len; + close_value(&s->gen); } /* ── Watching one scalar, with no compiler change ───────────────────── */ From 2d29528cad1041d3f6a815a943630f87837410cb Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 20 Sep 2026 13:00:55 +0700 Subject: [PATCH 4/5] Three escape tables became two, and the docs catch up SPIKE-DUPLICITY.md 9 and SPIKE-DYNAMIC.md both still counted three and quoted a flan_dyn.c comment that has since been rewritten. The typed side two are one flan_escape_char with a framing each; the dyn copy stays, with the ownership defence the section already accepted. NEXT.md named flan_break_resume, which does not exist -- flan_restart_frame and flan_restart_take are the pair a break loop resumes through. --- NEXT.md | 7 +++++-- docs/SPIKE-DUPLICITY.md | 33 +++++++++++++++++++-------------- docs/SPIKE-DYNAMIC.md | 10 ++++++---- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/NEXT.md b/NEXT.md index b59bf7e..57325ad 100644 --- a/NEXT.md +++ b/NEXT.md @@ -2395,8 +2395,11 @@ plan.org's single line on it (831) names a `for` the language does not have and What is missing is the half only an editor can do — the leverage SBCL lacks. `eval` already compiles and runs an expression inside the live program and the daemon already holds the struct layouts, so "ask the human, type-check the answer, hand it over" is a short hop, and it is the one path the runtime today *refuses*: a restart with - parameters taken from the break loop traps, because `flan_break_resume` and `flan_restart_take` aim the channel at - a frame and have nothing to fill its buffer with. What it needs, end to end: + parameters taken from the break loop traps, because the pair a break loop resumes through — `flan_restart_frame`, + which hands back the frame the human picked, and `flan_restart_take`, which aims the transfer channel at it — + reaches the frame and has nothing to fill its buffer with. (`flan_break_resume` is what this paragraph named + before; there is no such function, and `flan_break_hook` is the unrelated thing that gets the break loop *into* + the agent in the first place.) What it needs, end to end: - the frame already carries the arity and the signature as a string — `flan_restart_arity` and `flan_restart_sig` beside `flan_restart_name`, the same walk, so `restarts` can say what each one takes; - `:restarts` on the wire carries the signature per entry, so the minibuffer can show `use-value (i32)` rather diff --git a/docs/SPIKE-DUPLICITY.md b/docs/SPIKE-DUPLICITY.md index 352c844..0ee3e0a 100644 --- a/docs/SPIKE-DUPLICITY.md +++ b/docs/SPIKE-DUPLICITY.md @@ -425,23 +425,28 @@ and reload semantics are working." Under this reading the gating item changed. C ## 9. Print, hash, sort — one family already duplicated, two not yet -**Printing is duplicated three ways on one side, and it is not the dyn/typed split.** -`runtime/flan_dyn.c:308-313` says so itself: +**Printing was duplicated three ways on one side, and it is not the dyn/typed split. Two of the three are +now one.** This section found three escape tables; the runtime dedupe lane took the two that were on the +same side and made them one function with two framings, which is what this section asked for. What is left +is the split it argued was correct. -> A text inside a structure, quoted and escaped. The same table as `flan_rt.c`'s `flan_escape_bytes` and -> `flan_dev.c`'s `flan_dev_emit_str`... +The table lives once, as `flan_escape_char` (`runtime/flan_rt.c:388`), and both typed-side printers call +it: `flan_escape_bytes` (`:422`), which builds a capped slice for `println`, and `put_str` +(`runtime/flan_dev.c:302`), which streams into the inspector's fixed buffer so the REPL can parse the +printed form back. The framing is each caller's and is the part that is genuinely theirs; the switch is +shared, because two printers disagreeing about what a string looks like is two wire formats. -Three escape tables: `runtime/flan_rt.c:383` (`flan_escape_bytes`, with `:366-372` noting it is already "the -same escape table as `flan_dev_emit_str`"), `runtime/flan_dev.c:237` and again at `:551`, and -`runtime/flan_dyn.c:314` (`emit_escaped`). `SPIKE-DYNAMIC.md:329-332` calls its own "the third copy" and -leaves the instruction "If that table changes, change all three." +The dyn copy stays, as `emit_escaped` (`runtime/flan_dyn.c:472`), and says why at `:465-470`: -Two of those three are on the same side. `flan_rt.c`'s is `println`'s; `flan_dev.c`'s is the inspector's, so -the REPL can parse the printed form back. That pair predates dyn and is same-side duplication that dyn has -now made three-way. The *dyn* copy is the one with a defence, and `SPIKE-DYNAMIC.md:334-338` gives it: a -typed `Vec` prints as `` because the typed printer will not walk storage it does not own, while a dyn -vec's storage belongs to the collector and the printer is inside the runtime that owns it. That is correct -duplicity. Three tables is not. +> This copy is deliberate, and the argument for it is docs/SPIKE-DUPLICITY.md §9's: the dyn printer lives +> inside the runtime that owns the storage it walks, which is why it prints a dyn vec structurally where +> the typed printer answers ``. The whole printer is this side's; the table it shares with the other +> side is the part that must not drift. If that table changes, change this one. + +That is the defence `SPIKE-DYNAMIC.md` gives and this section accepted: a typed `Vec` prints as `` +because the typed printer will not walk storage it does not own, while a dyn vec's storage belongs to the +collector and the printer is inside the runtime that owns it. One copy per *side*, which is the doctrine. +Two tables is the answer; three was not. **Hashing is typed-only.** `flan_hash_fn`, `flan_hash_flat`, `flan_hash_str`, `flan_hash_combine` (`runtime/flan_rt.c:1807, 1911, 1939, 1954`), reached through `Types.keyable` and the checker's `key_pair` diff --git a/docs/SPIKE-DYNAMIC.md b/docs/SPIKE-DYNAMIC.md index f00d960..015c7ac 100644 --- a/docs/SPIKE-DYNAMIC.md +++ b/docs/SPIKE-DYNAMIC.md @@ -326,10 +326,12 @@ running program**, not read off `lib/render.ml` — that file is the REPL's insp | nil | — | `nil` | The leading space before every element is what `lib/render.ml`'s slice loop emits and what a Flan program -prints today; a tidier answer would diverge from an acceptance test. The escape table is the third copy -of the one in `flan_rt.c`'s `flan_escape_bytes` and `flan_dev.c`'s `flan_dev_emit_str` — those two are -already kept identical because the REPL parses the printed form back, and this one joins them. If that -table changes, change all three. +prints today; a tidier answer would diverge from an acceptance test. The escape table is the dyn side's +own copy of the typed side's. The typed side used to have two and now has one — `flan_escape_char` +(`runtime/flan_rt.c:388`), which `flan_escape_bytes` calls for `println` and `flan_dev.c`'s `put_str` +calls for the inspector, each framing the result its own way — so there are two tables in the tree, not +three, and the split that is left is the dyn/typed one argued just below. If that table changes, change +this one too. **A typed `Vec` prints as `` and a dyn vec does not.** That looks like a mismatch and is not. The typed printer's refusal is about walking storage it does not own — `render.ml` says so, and directs you to From dd239d6cd5806453a7ab9c5a2299feb29fd76fa6 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 20 Sep 2026 13:00:55 +0700 Subject: [PATCH 5/5] Two review findings this lane could not fix, written down --- FIX.org | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/FIX.org b/FIX.org index 6804dfd..2769a5f 100644 --- a/FIX.org +++ b/FIX.org @@ -858,3 +858,61 @@ skipping its stores on a re-run is safe against all of that because the pushes take the global's slot address, never its value, and they sit before the startup call on both backends — nothing in the bracket depends on an initialiser having run. + +* Review-batch findings, 2026-09-20 +Two things found in review that this lane could not fix in the files it owned. +Written down here so they are not lost with the branch. + +** The daemon leaves its temp directory behind, forever +Every session makes =/tmp/flan-dev-/= — lib/dev.ml:3516 for the +two-process daemon and lib/dev.ml:4415 for the merged one — and nothing ever +removes it. It holds the built =program=, the host's =host.ll= or =host.s=, +the reload modules and =agent.sock=: about 7MB a session. The review counted +873 of them, 5GB, on the morning of 2026-09-19; this lane counted 68 and 457MB +on 2026-09-20. Whatever removed the difference, nothing in the tree did, and +the count climbs again with every =M-x flan=. + +The fix is small and the merged daemon already has the one place for it. Its +session ends at lib/dev.ml:4395: [accept_loop] returns, the listening socket +closes, the editor socket is unlinked, and [Unix._exit 0] follows. A recursive +remove of [dir] belongs between the unlink and the flush — that one site +covers all three ways a session ends cleanly, because all three come back +through [accept_loop]: +- =close= from the editor (lib/dev.ml:3359, which returns [true] and ends the + loop); +- no editor connected for the grace period (lib/dev.ml:3472); +- the program finished and the parked process is let go. +The two-process daemon needs the same thing at its own session end. + +Two deliberate non-goals, and they are the reason this is worth spelling out +rather than just doing: +- *Not on a crash.* The sibling branch at lib/dev.ml:4393 is [accept_loop] + raising, and the directory is the post-mortem — the binary and the exact IR + it was built from. Only the clean return cleans up. +- *Not other sessions' directories.* A sweep of =/tmp/flan-dev-*= would delete + the working directory of a daemon that is still running, and a stale pid is + not proof of anything. Each session removes its own and no more. + +Not done here because lib/dev.ml belongs to another lane that has not merged. + +** and's last operand gets a misdirected caret in a want-free position +[shortcircuit] in lib/parse.ml documents this at the site; the summary is that +=(println (and true true (vec-new i32)))= reports "expected (Vec i32), found +bool" with the caret on the second =true=. The last operand of an =and= is the +then arm, check_if types the then arm first, and the mismatch is therefore +reported against the else arm, which carries the *previous* operand's loc. +Every other operand position is right, because an operand anywhere but last is +a condition and check_truthy blames it at its own loc; =or= is right +everywhere, because there the chain and not the sentinel sits in the else arm. +Compiled on the tree at every operand position of both forms, want-free and +want-ful; want-ful is right everywhere too, because the want reaches each arm +instead of the arms being unified against each other. + +Not fixed. Three candidate fixes were considered and rejected: giving the else +arm the last operand's loc makes the sentence read backwards ("expected (Vec +i32)" under a caret on the thing that is the (Vec i32)); answering a bool +sentinel again reverts the fix that made =(or nil "x")= answer ="x"=; and +inverting the condition to move the last operand into the else arm costs a +[not] per operand and worse locs than it buys. What would fix it is check_if +preferring the arm that is not a compiler temp when it decides which one to +blame — a change in check.ml, which this lane did not own.