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 | names are global across files; the related note points at the first |
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 | Response and Outcome are spellable only in a fn signature |
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 | a scale above 18, or a Map key that does not order |
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 | a third positional argument to http.* is a timeout, which is configuration; reject Gone takes no braces |
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, @no_index; entities @key, @index, @max; records @max; 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 may not be optional or itself sealed; 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 and its @subject 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 |
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 |
a guard returns only reject <Name> or invalid(...); a module fn must declare a return type and return on every path |
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 |
erase-subject |
an erase whose subject or id is not one |
the inferring form takes a trigger field; the named form takes a declared subject and a value of the id’s type |
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
One thing is not statically decidable, and it is worth knowing which:
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. That is data-dependent, so no static pass can cover it. Use
.mul(rate, Rounding) and .div(n, Rounding) where you want a rounding rule rather than a
failure.
This is why a green check is necessary and not sufficient, and why the tests are worth writing.