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.
This commit is contained in:
Joseph Ferano 2026-09-20 18:46:48 +07:00
parent 16f90a7ef5
commit 78d9a0f051
6 changed files with 215 additions and 28 deletions

63
FIX.org
View File

@ -1598,3 +1598,66 @@ because the code is the same.
Not fixed here, deliberately: the fix is to catch ~Closed~ in that poll and
read it as the program having ended, which is a claim about what those rows
mean and belongs to whoever owns them. Flagged rather than patched.
* The diagnostics pass, 2026-09-20
Worked from ~docs/DIAGNOSTICS-AUDIT.md~, which is tracked as of this lane's
first commit. Graded against the contract the audit sets out: show the code
with the caret, say what was understood, say what conflicts, name the fix.
** Reached
Worst-20 ranks 1, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
and the runtime half of 2. All four defvar follow-ups. Two from the author's
dogfooding notes in DISCUSS.org: the foreign-spelling list, which answers
~int~ with ~i32~ instead of a lecture about type variables, and the
two-element ~defconst~ whose bracketed type read as an array literal.
** Not reached, each with the reason
*** Rank 8: ~unhandled Boom~ has no location
Not a copy of the dyn-trap work, and the difference is the calling
convention. ~flan_error~ takes five integer arguments — the type id, the
condition, the channel, and the name as ptr+len — which is rdi through r8.
A ~(loc, loclen)~ pair makes seven, past x86-64's six argument registers, so
~lib/x86.ml~ would need stack-argument passing at a call site whose own
comment two hundred lines up says "the channel lands in r9 and the register
file is exactly full". The dyn entry points took the pair without any of
that because none of them was near the limit.
The rest of rank 8 — the condition's field values, and the handlers that
were in scope — is separate work again and has no ABI question in it.
The audit's gap 4 names a dev-side half of this: ~flan_trap_hook~ hands
control to a session that is in-process with the compiler and *can* read the
source, so a real caret at runtime belongs in ~lib/dev.ml~. That file is
another lane's and the audit already wrote it up as a hand-off.
*** Rank 20: the fn-literal arity message
Re-read and judged already satisfying. The audit asks it to name the
parameter list it was measured against; it prints the whole ~(Fn [T ...] R)~,
which is that list. Left alone rather than churned.
*** ~trap_oom~ in flan_dyn.c
The other three trap printers took the location pair. This one is reached
from ~gc_alloc~, which has no site to be given: every allocation path in the
file would have had to carry one for a sentence that is about the host
refusing memory rather than about the program. ~trap_range~ has the pair and
every caller passes NULL, so giving ~at~, ~set-at~ and ~push~ a site later is
a call-site change and not another round of signature churn.
*** The audit's structural gaps 3, 5 and 6
Printing the stable ~kind~ at the end of the first line, the
"understood / conflicted" clause order as a writing rule, and non-cascading
multiple errors through ~Loc.sink~. Each needs a decision from the author
rather than work, which is what the audit says about them too.
** Two behaviour changes, not only wording
~(defn idx [v i] dyn v)~ was *refused* and now compiles as two dyn
parameters. The rule is the digits: this language sizes its machine types in
the name, so a typo keeps them — ~f65~, ~i33~ — and a parameter called ~i~ or
~n~ has none. ~pair_params~'s own comment says that is what the feature is.
A ~defn~ whose name is a builtin's is still not refused. The builtin still
wins every call and the definition is still unreachable; what changed is that
the arity message says so and notes the definition. Refusing the shadowing is
a language decision and was left to the author.

View File

@ -260,7 +260,11 @@ let nearest cands n = List.find_opt (fun c -> c <> n && one_edit n c) cands
and this language has [u8] and rune functions, so there is nothing to
translate it to in three words. Nor [void]: it is a return type and the
answer there is the shape [()], which is [parse]'s message to give and not
this one's. *)
this one's. Nor [usize] and [size_t]: the honest answer is "as wide as a
pointer on this target", which is [u64] on x86-64 and [u32] on wasm32, and
a message that named one of them would be wrong half the time on a tree
that builds both. A name goes on this list when the answer does not depend
on anything. *)
let foreign_spelling = function
| "int" | "integer" -> Some "i32"
| "uint" | "unsigned" -> Some "u32"
@ -3915,7 +3919,7 @@ and check_truthy ctx c =
language. It is said only of the typed side, which is where they are.
The comparison is spelled with the condition's own name where there is
one, because [(not= x 0)] is a thing to type and [(not= 0)] is not.
one, because [(!= x 0)] is a thing to type and [(!= 0)] is not.
Anything more complicated than a name gets the operator and no
template: a reconstructed expression would be a guess at code the
reader can see for themselves. *)
@ -3930,8 +3934,8 @@ and check_truthy ctx c =
match c0.Tast.ty with Types.Int _ | Types.Float _ -> true | _ -> false
in
match c.Ast.e, comparable with
| Ast.Var n, true -> Printf.sprintf " — test it, as (not= %s %s)" n zero
| _, true -> Printf.sprintf " — test it against %s with not=" zero
| Ast.Var n, true -> Printf.sprintf " — test it, as (!= %s %s)" n zero
| _, true -> Printf.sprintf " — test it against %s with !=" zero
| _ -> ""
in
Loc.failk "check/condition-not-bool" loc
@ -6881,26 +6885,42 @@ and named_call ctx ~want loc name args =
else if String.contains name '/' then
unimplemented loc
(Printf.sprintf "the call %s into an imported package" name) 4
else if args <> []
&& name <> "" && name.[0] = Char.uppercase_ascii name.[0]
&& name.[0] <> Char.lowercase_ascii name.[0]
then
(* [(defvar p (Pair i32))]. A capitalised head with arguments is
somebody reaching for a parameterised type, which is what the type
resolver says about [(Pair i32)] when the same text lands in a type
position. Before defvar took either reading, that is the message
this text got; it says the same thing here so the answer does not
depend on which side of the fork the form fell down. *)
Loc.failk "check/unknown-function" loc
"unknown function %s. A capitalised name is a type, and a type given \
type arguments (%s ...) is generic code, which is milestone 5"
name name
else
(* The did-you-mean comes first, and for a capitalised head it is asked
of the *type* tables as well: [(Piont 1 2)] with [Point] declared is
a typo, and the generics sentence below would be a confident answer
about a feature nobody was reaching for. Only a capitalised head
consults the types a lowercase name written where a value goes
was not a mistyped struct, which is [value_candidates]' whole
point. *)
let capitalised =
name <> "" && name.[0] = Char.uppercase_ascii name.[0]
&& name.[0] <> Char.lowercase_ascii name.[0]
in
let guess =
match nearest (!builtin_names @ value_candidates ctx) name with
| Some _ as m -> m
| None -> if capitalised then near_miss ctx.env name else None
in
match guess with
| Some m ->
Loc.failk "check/unknown-function" loc
"unknown function %s — did you mean %s?" name m
| None -> Loc.failk "check/unknown-function" loc "unknown function %s" name
| None ->
if args <> [] && capitalised then
(* [(defvar p (Pair i32))]. A capitalised head with arguments and
no near miss anywhere is somebody reaching for a parameterised
type, which is what the type resolver says about [(Pair i32)]
when the same text lands in a type position. Before defvar took
either reading, that is the message this text got; it says the
same thing here so the answer does not depend on which side of
the fork the form fell down. *)
Loc.failk "check/unknown-function" loc
"unknown function %s. A capitalised name is a type, and a type \
given type arguments (%s ...) is generic code, which is \
milestone 5"
name name
else Loc.failk "check/unknown-function" loc "unknown function %s" name
(* ── A call to a generic function ───────────────────────────────────────
The whole of instantiation, and it is at the call site because the call

View File

@ -1330,7 +1330,10 @@ int32_t flan_dyn_cast_kind(flan_dyn v, const uint8_t *loc, int64_t loc_len,
: sizeof name - 1;
memcpy(name, target, n);
name[n] = '\0';
trap1(NULL, 0, TYPE_TRAP, name, "a number was wanted", v);
/* This one has a site: the cast's own, which the emitter already hands
* over for the cross-kind warning below. It was the first entry point on
* this side to take a location and it was not passing it on. */
trap1(loc, loc_len, TYPE_TRAP, name, "a number was wanted", v);
}
int32_t is_float = tag == FLAN_DYN_TAG_FLOAT ? 1 : 0;
if (is_float != (want_float ? 1 : 0) && site_first_time(loc, loc_len)) {

View File

@ -0,0 +1,28 @@
;;;; A dyn arithmetic trap says where it happened.
;;;;
;;;; In a dynamic-first language the dyn traps ARE the type errors, and until
;;;; the diagnostics pass they printed with no file, no line and no column:
;;;;
;;;; dyn +: int and text, and it takes two numbers — (+ 3 "hi")
;;;;
;;;; flan_rt.c's bounds and arithmetic traps have taken an emitter-threaded
;;;; (loc, loclen) pair since they were written, so the ABI precedent was
;;;; already there; the five arithmetic and four ordering entry points in
;;;; flan_dyn.c simply were never given one. They take it now, and the trap
;;;; prints it as the GNU "file:line:col: " prefix, which is what makes
;;;; next-error walk to a dyn failure the way it walks to a bounds failure.
;;;;
;;;; This program exists for the prefix and for nothing else. The line prints
;;;; first so that the test can tell "the program ran and then trapped" from
;;;; "the program did not start", and the operation is inside a defn so that
;;;; the site reported is the operator's own and not the call's — which is the
;;;; distinction that matters: the + is what failed, and the + is what the
;;;; caret should be under.
(defn add [x y] dyn
(+ x y))
(defn main [] ()
(print "before\n")
(print (add 3 "hi"))
(print "unreachable\n"))

View File

@ -3788,6 +3788,45 @@ level "1"
some_nil ~opt:"-O0" ();
some_nil ~x86:true ();
(* The dyn trap's own location, end to end: compiled, run, and read off
stderr. In a dynamic-first language these traps are the type errors,
and they printed with no file and no line at all. The site is a string
literal the emitter hands over exactly as it hands [flan_dyn_cast_kind]
its own, so this is asserted on both backends and at -O0: the argument
is an ordinary one and neither backend treats it specially, which is
the claim being pinned.
23:3 is the (+ x y) inside [add], not the (add 3 "hi") that called it,
and that is the point of the site being the operator's: the + is what
failed. If the file is edited above line 23 this number moves. *)
let trap_site_out = "before\n" in
let trap_site ?opt ?x86 () =
let exe = compile ?opt ?x86 "programs/dyn-trap-site.flan" in
let code, text = run exe None in
let name =
"dyn: an arithmetic trap says where"
^ (match opt with Some o -> ", " ^ o | None -> "")
^ (match x86 with Some true -> ", --x86" | _ -> "")
in
if code <> 134
|| not (contains text trap_site_out)
|| not (contains text
"dyn-trap-site.flan:23:3: dyn +: int and text, and it \
takes two numbers (+ 3 \"hi\")")
|| contains text "unreachable"
then begin
incr failures;
Printf.printf
"FAIL %s\n got: %S (exit %d)\n wanted: %S then the trap \
with its site (exit 134)\n"
name text code trap_site_out
end;
(try Sys.remove exe with Sys_error _ -> ())
in
trap_site ();
trap_site ~opt:"-O0" ();
trap_site ~x86:true ();
(* A numeric cast opening a dyn box — FIX.org 2026-09-20.
programs/dyn-cast.flan is one program because the three behaviours are
one story told in order: the same-kind casts print, the cross-kind ones
@ -3845,8 +3884,14 @@ level "1"
|| not (contains text "found a dyn holding an int, and converted it to f64")
|| not (contains text "found a dyn holding a float, and converted it to i64")
|| not (contains text "found a dyn holding an int, and converted it to f32")
(* The non-numeric box, in the runtime's own words. *)
|| not (contains text "bool, and a number was wanted")
(* The non-numeric box, in the runtime's own words — and with the
site in front of them. [flan_dyn_cast_kind] has taken the cast's
location since the warning below needed one, and was the one entry
point on the dyn side that had a location and threw it away on the
trapping path. 71:10 is the (i64 (as-dyn true)) at the end of
the program; if that file is edited above it, this number moves. *)
|| not (contains text "dyn-cast.flan:71:10: dyn i64: bool, and a \
number was wanted")
then begin
incr failures;
Printf.printf

View File

@ -1746,11 +1746,39 @@ let () =
"(defstruct P [x i32]) (defn f [] i32 (let [p (P {.x 1})] (set (.x p) 2) (.x p)))";
rejects_check "a dotted head that is not a struct says what it is"
"(defn f [] i32 (let [n 1] n.x))" ~needle:"n is i32, which has no fields";
(* The fourth shape: nothing is bound under the head either, so the message
claims nothing about what q is only that the dot is not the operator
the writer took it for. *)
rejects_check "and an unbound head claims nothing about it"
"(defn f [] i32 q.x)"
~needle:"unknown name q.x — nothing named q is in scope either. A field \
is reached through an accessor, (.x q), not with a dot";
(* A capitalised head keeps the case spelling it always had: [Shape.Circle]
is real here, so a typo in one is not the dot habit. *)
rejects_check "a capitalised dotted name is still a case"
"(data Shape (Circle [r f64])) (defn f [] Shape Shape.Crcle)"
~needle:"unknown";
(* Both sides of the rule, because only the pair says what it is. A
capitalised head is a real spelling here Shape.Circle so a typo in
one is a mistyped case and gets none of the accessor advice; the same
text with a lowercase head does. The earlier spelling of this row used
(data ...), which is not a top-level form at all, so it refused as an
unknown top-level form and the needle "unknown" matched that instead of
anything this rule does. *)
(match (try ignore (checked "(defdata Shape [(Circle [r f64])]) \
(defn f [] Shape Shape.Crcle)"); None
with Loc.Error d -> Some d) with
| Some d ->
check "a capitalised dotted name gets no accessor advice"
(contains d.Loc.dmsg "unknown name Shape.Crcle"
&& not (contains d.Loc.dmsg "accessor"))
| None -> check "a mistyped case is refused" false);
(match (try ignore (checked "(defdata Shape [(Circle [r f64])]) \
(defn f [] Shape shape.Crcle)"); None
with Loc.Error d -> Some d) with
| Some d ->
check "and a lowercase one does"
(contains d.Loc.dmsg
"nothing named shape is in scope either. A field is reached through \
an accessor, (.Crcle shape), not with a dot")
| None -> check "a lowercase dotted name is refused" false);
(* [(Pair i32)] in a defvar falls down the value fork now that the third
element takes either reading, and the generics answer the type fork gave
it has to be reachable from here too. *)
@ -3790,10 +3818,10 @@ let () =
is right, and with the condition's own name where it has one. *)
rejects_check "a non-bool condition states the rule"
"(defn f [] i32 (let [x 1] (if x 1 0)))"
~needle:"a condition is a bool or a dyn, and this is i32 — test it, as (not= x 0)";
~needle:"a condition is a bool or a dyn, and this is i32 — test it, as (!= x 0)";
rejects_check "and offers no template for a form it cannot name"
"(defn f [] i32 (if (+ 1 2) 1 0))"
~needle:"this is i32 — test it against 0 with not=";
~needle:"this is i32 — test it against 0 with !=";
rejects_check "and offers no comparison at all for a type that has none"
"(defstruct P [x i32]) (defn f [] i32 (let [p (P {.x 1})] (if p 1 0)))"
~needle:"a condition is a bool or a dyn, and this is P";