Diagnostics
Every code the compiler can emit, what each one means, and what to do about it.
The diagnostic set is closed. Every rejection heklang can produce is one of the codes below, and the compiler enforces that: a variant it cannot reach is a category it claims to have and does not.
That closure is what makes recovering from a rejection a lookup rather than an invention, which matters as much for a person as it does for an agent.
Reading one
a.hk:2:40 [type-mismatch] expected String, found String?
|
2 | emit @order.placed { order_id, name: text }
| ^^^^
= `unwrap_or` gives it a fallback, or a branch that proves it present makes it a
String without one
The header is file:line:col [code] message. Under it goes the source line with the extent
drawn, then the hint, then every related location as a = note. Lines and columns are
1-based and the column counts characters.
Message and hint are separate on purpose. The message says what is wrong; the hint says what to do. A related note is a second place worth looking, like the first of two declarations sharing a name.
Three things about how they arrive
A syntax error abandons its declaration; a semantic one does not. A token the grammar cannot take means there is nothing left to read there, so the whole declaration is stepped over and the next one is parsed as if nothing happened. Everything else parsed, so the rest of the body is still checked.
A rejected value becomes a poison. Its type is unknown and an unknown type is never
checked, so let x = text.trm() followed by two uses of x is one diagnostic rather than
three. Fix the first and re-run.
Reporting stops at the end of the pass that found any, and the whole-program checks report one at a time. So a clean run after a fix can still surface something new. Keep running the checker until it says nothing.
Lexical and syntactic
These six abandon the declaration.
| Code | Means | Fix |
|---|---|---|
bad-number |
a numeric literal the scanner could not finish | check for a stray . or a missing digit |
unterminated-string |
a " or """ that ran to the end of the file |
close it; the position points at the opening quote |
unknown-escape |
the set is \n \t \" \\ \{ \} |
use one of those, or a raw """ string |
bad-path |
an @ with no name after it |
write the event path, @thing.happened |
unexpected-character |
a character with no token in the language | delete it |
expected-token |
a token the grammar cannot take here | the message names what was wanted |
Names
| Code | Means | Fix |
|---|---|---|
declared-twice |
a name, field, variant, arm or annotation given twice, or a subject taking a name an enum or a record already has | names are global across files; the related note points at the first. enum, record and subject share one space, because a type name resolves to one declaration |
not-declared |
spelled fine, declared nowhere | declare it, or fix the spelling. Usually an event path |
not-in-scope |
declared somewhere, not visible here | an effect-local fn is visible only in its effect; an entity or projector enum only in its projector |
unknown-member |
a field, method, parameter, variant or verb the receiver has not got | check the standard library; the commonest pair is is_empty() asked of a String? and is_none() asked of a String |
unknown-type |
a type name that names no type, including a subject’s under naming something that is not one |
Response and Outcome are spellable only in a fn signature; a parent has to be a subject, so that a key can be wrapped under its key |
Types and values
| Code | Means | Fix |
|---|---|---|
type-mismatch |
a value that does not fill a declared type | T? does not fill T: use unwrap_or, or a branch that proves it present |
bad-operands |
an operator applied to a pair it does not take | scales never meet; Money and Decimal do not add; there is no + on String; an optional does not order, though == and != take one |
bad-literal |
a literal that cannot be the type its position declares | usually more decimal places than the target holds, or a malformed uuid or RFC 3339 string |
bad-type |
a type spelled wrong, or a subject declared in a shape it does not take |
a scale above 18, a Map key that does not order, a subject id that is not Int, String or Uuid, or an under chain that closes a cycle |
needs-target-type |
a value whose type nothing decides | [], Map.empty, Money.parse and Decimal.parse take their type from the target; a let is not one |
not-a-value |
a statement written where a value was wanted | a call to a void effect-local fn is a statement, so it cannot be bound with let |
arity |
a call with the wrong number of arguments, or a refusal’s fields written with the wrong delimiter | a third positional argument to http.* is a timeout, which is configuration; a refusal with fields takes braces, and one without takes neither braces nor parens |
missing-field |
something that has to be given whole was not | emit, put, given, invoke, a record literal, reject with fields and guard Name { .. } are all written whole |
duplicate-field |
one given twice |
Annotations and declaration shape
| Code | Means | Fix |
|---|---|---|
unknown-annotation |
an annotation that does not exist | events take @subject, @max, @absent, @no_index; entities @key, @index, @max; records @max, @absent; enum variants @default; an effect arm’s trigger destructure @key |
bad-annotation |
a known annotation in a place or shape it does not take | @max bounds a String; a record field cannot be @subject; a @subject id must be a declared subject, and may not be optional or itself sealed; an event sealing under a subject carries every ancestor of it; @absent takes neither an optional nor a sealed field, nor a literal past the field’s @max; an optional column may not default to none |
empty-declaration |
a declaration whose body would be empty | |
arm-shape |
an effect arm with no @key |
every arm names the trigger field that identifies its lane |
entity-shape |
no @key, more than one, an unorderable key, or an index on a field it has not got |
|
event-shape |
a multi-path arm over event types with nothing in common | a field is shared only when its type, its @subject and its @absent match on every listed path |
refusal-shape |
a refusal named or written so its derived code could not survive | start with a capital, use no underscore, and let the message name every field and nothing else |
stage-shape |
a fold or guard written where or how it does not go |
declarations come before the first statement of their stage, never inside an if or a for |
no-zero-value |
a patch that would materialise a row it cannot fill |
Uuid, Timestamp and a subject id have no zero, because the nil uuid, epoch zero and customer 0 read as data. Give the column a default, make it optional, or make the write an update |
Context and purity
| Code | Means | Fix |
|---|---|---|
wrong-context |
a statement in a declaration kind that does not have it | see the matrix in the standard library; an on latest arm may not invoke |
impure-fn |
a module fn doing something a pure function cannot |
move the call to the caller and pass the result in, or make it effect-local |
fold-restriction |
a fold calling out, invoking, decrypting or reading a clock |
a fold has to reproduce without a journal; do it in the body and pass it in |
arm-only |
an effect-local fn doing what stays in the arm |
reveal, erase, now() and fold stay in the arm; pass the value or the moment in |
return-shape |
a return that does not match the signature it is in, an answer written with a return or with parens, or an answer’s message written as a value |
write reject <Name>, invalid "..." and fail "..." bare, and put a value in a hole rather than passing it; a module fn must declare a return type and return on every path |
unreachable |
a statement after the declaration’s answer | an answer ends the declaration, so nothing below it runs; delete it or move it above |
Seals
| Code | Means | Fix |
|---|---|---|
seal-boundary |
sealed content leaving without reveal |
move it, ask .is_some(), or reveal it in an effect arm. A sealed field cannot be an arm’s @key either, and a sealed record, list or map is opened whole rather than read part by part |
erase-subject |
an erase, an erased or an expect erase(...) that does not name a subject and an id |
the statement takes a value whose type is a declared subject, so a Uuid, an Int or a bare literal is refused; the test forms take the subject’s declared name and a text id of its declared shape |
erase-order |
a reveal reachable from an erase |
move the reveal above the erase, or into a branch the erase cannot reach. Inside a for body, any erase reaches every reveal |
secret-boundary |
a credential reaching something that observes it | send it instead: a url, a header value, a body member. log, fail, emit, invoke, a comparison and every method are all out |
Whole-program
These are checked after the passes and report one at a time.
| Code | Means | Fix |
|---|---|---|
recursive-fn |
a fn that calls itself, directly or through a chain |
the error names the cycle; rewrite as a for over a finite container |
recursive-guard |
a guard that names itself | a guard is copied into what names it, so a cycle has no end |
max-tightening |
a bounded position tighter than a field written into it | widen the target’s @max, or narrow the source’s. A computed value is not this defect |
self-trigger |
an effect that can trigger itself through invoked commands | break the cycle, or have the command refuse a replay so the chain ends |
const-cycle |
a const that names itself |
the error names the chain |
Tests
| Code | Means | Fix |
|---|---|---|
test-shape |
a test body out of order, or an expectation its action cannot produce | order is given, then respond/erased, then exactly one action, then expect |
What still reaches run time
Three things are not statically decidable, and each is data-dependent rather than a gap:
Arithmetic that cannot be answered exactly. Money(n) / Int and
Money(n) * Decimal(s) type-check and then fail at run time when the result is not exact,
naming div or mul. Use .mul(rate, Rounding) and .div(n, Rounding) where you want a
rounding rule rather than a failure.
Arithmetic that overflows. Int and Money operators and the Timestamp.add_* family
answer an overflow error rather than wrapping or saturating, because a wrong number is worse
than a stopped handler.
Int.pad(width) past 4096 characters. pad is the only method that makes a string
longer, and its width is an ordinary expression, so the bound is checked where the width
arrives rather than where it was written.
This is why a green check is necessary and not sufficient, and why the tests are worth writing.