ecko check
ecko check file.ecko
ecko check src/
A directory means every .ecko file under it, skipping vendor/, target/, node_modules/ and dot-directories.
Static analysis. It is also run automatically before every ecko file.ecko, where an error-level finding stops the program before its first line.
Syntax errors
A file that does not parse is reported with every syntax error in it, not just the first. After a mistake, ecko check skips to where the next statement starts and keeps reading, so one pass shows each broken function instead of one per run:
$ ecko check app.ecko
app.ecko: Parse error: Expected ')' here, but found '}' (line 4, col 1)
app.ecko: Parse error: Expected ']' here, but found 'print' (line 11, col 1)
A mistake that breaks the lines after it, such as a lost bracket or comma, is reported once at the line that made it, not again on each line it affects. The lines inside a broken fn or for header belong to that header and are skipped with it, so a second mistake there shows up on the next run. Past 25 errors the check stops and says so. The other checks below need a file that parses, so they run once the syntax errors are fixed.
What it finds
Errors (these stop a run):
- Undefined names, with a did-you-mean suggestion for near misses.
- Wrong arity - calling a function with too few or too many arguments, including the built-ins (a piped value counts as the first argument). A
fn(...)written inline formap,filter,reduce,sort_with,retry,sql.transaction,csv.eachand the other functions that call it with a set number of values is checked too:sql.transaction(db, fn() ...)is refused, since the transaction is passed in. - An options map passed positionally (
positional-options) -proc.run(cmd, args, { timeout_ms: 5000 }). Built-ins take their options by name:proc.run(cmd, args, timeout_ms: 5000). An option name the built-in does not have is refused too, with a did-you-mean. - A literal that cannot fit a typed parameter (
param-type-mismatch) -inc("5")againstfn inc(x: Int), by position or by name, or a default that does not fit its own annotation. A value only known at run time is checked when the function is entered instead. See Typed parameters. - A literal return that cannot fit a declared return type (
return-type-mismatch) -fn f() -> Int = "x", a branch's final value, orreturn "x". Anything else is checked when the function returns. See Return types. - Use before definition at the top level.
- A number in an interpolation hole (
literal-interpolation)."^[A-Z]{3}$"is the string^[A-Z]3$, because{3}is a hole. Use a raw string,r"^[A-Z]{3}$", or escape the brace,"\{3}". - Reassigning an immutable binding (
immutable-reassign) -x = 2after a barex = 1, a function writing a top-level name (including the name of afn), orm.key = v, where the binding was not declaredmut. See Variables & mutability;ecko fix --migrate --only=mutadds themutfor you. - A literal where a builtin needs a function (
callback-type-mismatch) -reduce(xs, {}, fn(acc, k) ...)passes a map where the function goes. The message says when the arguments look swapped. - Imported files.
ecko check file.ecko, and the check that runs beforeecko file.ecko, also analyze every local.eckofile the file imports, and report each finding under the path of the file it is in. Vendored packages are not followed. - Non-exhaustive
match(non-exhaustive-match) - amatchover a declared type with a variant that has no arm, and no_. An error since 0.63: before, a program with one started, and failed only when the missing variant turned up. Add the arm (your editor offers to add every missing one) or a_ =>. - A misspelling that declared a new name (
likely-typo). Inside a loop or branch,totl = total + ndoes not updatetotal: it declares a new local namedtotl, and the program prints the wrong total. When a name assigned there is never read and is one slip from amutin scope, that is an error naming themutit probably meant. A name that is read later, starts with_, or is near nomutis an ordinary local. - Unknown struct or variant members, including a field read off a binding that holds a struct:
s = Sq(2)thens.sidewhenSqhas noside. (Amutbinding may hold something else later, so it is not judged.) A member astdmodule does not export -json.parse- stops the run before it starts, as it does inecko check.
Warnings (reported, do not stop a run):
- Unused bindings, scope-aware, so a name used only inside a nested closure is not flagged.
- Unused imports.
- Shadowing a built-in, which is legal and occasionally deliberate - the
core.*escape hatch exists for exactly that case. - Assigning to an imported name (
shadows-import) -json = xafterimport std.json, at the top level or inside a function. It makes a new binding that hides the module from there on, so a laterjson.encodefails.let json = xsays the shadow is deliberate and is not reported. - Deprecated syntax, with the migration (
ecko fmt) named. - A condition that is not a Bool (
non-bool-condition) -if count { }wherecountis plainly a number, string, list or map. It skips at zero or empty, which may not be what you meant; writecount != 0,name != ""orlen(xs) > 0. See Conditions. - Unwrapped credentials - a credential read from the environment that does not flow into
secret(...). A name ending_KEY,_TOKEN,_SECRET,_APIKEY, or containingPASSWORD, is treated as one. Testing for presence is exempt, because it never reads the value:
Comparing against anything other thanif os.env("MY_API_KEY") != null { ... } # fine if is_null(os.env("MY_API_KEY")) { ... } # fine key = secret(os.env("MY_API_KEY")) # fine print(os.env("MY_API_KEY")) # flaggednullor""is a read, and still flagged -os.env("K_KEY") == "sk_live_x"is both a leak and a timing-unsafe comparison. - A
/that should be//(exact-division). Insideif x % 2 == 0 { ... },x / 2is always a whole number, but/always returns a Float, soxstops being an Int and everything after it runs in floating point - same answer, slower, no error. Writex // 2. Only a/the code proves exact is flagged; anywhere else a fraction may be what you want. - A
/where only an Int can go (float-bound): a range or slice bound, or the count passed torange,take,droporslice./always returns a Float, sotake(xs, len(xs) / 2)fails every time it runs. Write//. - A regex pattern written as a plain string.
re.test("^[A-Z]{3}$", s)looks like a regex and is not one:{3}is an interpolation hole, so the engine receives^[A-Z]3$and returns the wrong answer with no error. Write every pattern as a raw string,r"^[A-Z]{3}$". - An error kind a
trycan throw that itscatchdoes not handle, and an arm for a kind thetrycannot throw.ecko checkworks out which kinds eachtrycan raise - from what the standard library throws, your own functions, and eacherror({ kind: "...", .. })- and compares them with amatch get(e, "kind")(ormatch e.kind) in thecatch:
Atry { json.decode(body) } catch (e) { match get(e, "kind") { "net" => retry() # unreachable-error-kind: nothing here throws net } # unhandled-error-kind: json.decode can throw parse }_arm handles everything else, and_ => error(e)is the way to say "pass anything unexpected on". It says nothing where it cannot be sure - a callback, a function in a variable,py()- and never asks forlimit,budget,cancelledorcapability. See Checking your handlers.
Why a dynamic language gates on this
Ecko has no static type system, and adding one is not the plan. But the errors above are not type errors - they are definitely wrong regardless of types. A misspelled function name is never correct. A match missing a variant is never correct. Finding those before execution costs nothing and removes the most common way a script fails halfway through doing something it cannot undo.
The same analysis backs ecko lsp, so an editor shows these findings as you type.
Exit status
0 when there is nothing at error level. Non-zero otherwise. Warnings alone do not change the exit status, so ecko check is usable as a CI gate without forcing warning-free code.
Warnings are still printed, and a run that ends with warnings and no errors says so, so a 0 beside printed findings does not read as something being missed.
ecko check src/*.ecko # errors fail, warnings inform
ecko check --strict src/*.ecko # any finding fails
--strict is for a project that controls all of its own source and wants a clean board. Leave it off where a warning is a considered choice, since there is no way to silence a single finding.
One note on shadows-builtin: a package that exports get, set, push or find is naming its API, not making a mistake, so an exported function is exempt. The rule fires on a private helper that quietly displaced a builtin, which is the case worth hearing about.
What the native compiler covers
--native adds a report on the experimental native compiler, which ECKO_JIT_ENABLE=1 switches on. For each function it says whether the function compiles to machine code and, if it does, which of its local variables the compiler knows the type of, which ones it does arithmetic on without knowing, and which kinds of operation in it still run on the bytecode VM, with the line where each first appears:
ecko check --native src/job.ecko
src/job.ecko: native compiler
<main>: compiled; on the VM: DefFn (line 3), Call (line 23)
spin: compiled; typed: total (Int), i (Int); untyped: n (line 5)
fib: compiled (calls itself)
rate: compiled (called from compiled code)
pick: compiled (called by value from compiled code)
helper: not compiled (has a default parameter)
label: not compiled (runs BuildString on the VM (line 30))
The compiler is opt-in: ECKO_JIT_ENABLE=1.
A function compiles when it has a loop. A function without a loop also compiles when it calls itself, or when a compiled function calls it, as long as it can be called directly (below); the report says which of those two applies. A call from one compiled function to another stays in machine code when the called function has no default parameter values, no try, and, on the way through, uses only operations the compiler covers. Type annotations on its parameters and result are fine: they are checked in machine code, so a typed function is called as fast as an untyped one. Calling a built-in such as len is fine too; calling a module function such as math.sqrt, or a function held in a variable, rules out the direct call, though the function can still compile if it has a loop. Otherwise the call is made the ordinary way, and a function without a loop runs on the VM.
A compiled function can also call through a name you reassign, a value (ops.inc(x), an item of a list) or a parameter (f(x)). Such a call looks at what it calls each time, as the VM does: a built-in or module function runs in place, a compiled function of your program is entered in machine code, and anything else is called the ordinary way. A function or lambda that could be called directly and captures no variables compiles for such a call when the call can find it: when it is stored in a variable that a call names (mut pick = fn(x) x * 2, then pick(i) in a loop), or, if your program calls through a parameter or local anywhere, when it is a lambda not stored under a name. The report lists it as called by value from compiled code; when the VM calls it, as map does, it runs as before. A function stored under a second name, or in a list or a map, and called from there is not compiled for that call.
For a function without a loop that does not compile, the report gives the first reason that applies:
| reason | what it means |
|---|---|
has a default parameter | a call may leave a parameter to its default, so it is never called directly |
has a try | a try in it keeps it from being called directly |
captures variables | a lambda that uses a variable from around it, which a call through a value does not enter |
calls through a value (line N) | it calls through a variable, a value or a parameter, which may need the VM on any call |
runs <Op> on the VM (line N) | that operation has no machine code yet, so every call would go back to the VM |
not called from compiled code | it could compile, but only code running on the VM calls it, or compiled code reaches it only under a second name or out of a list or map |
A file whose top level has no loop shows <main>: not compiled (no loop at the top level). A small function that the VM copies into the places that call it, before anything compiles, is also reported as not called from compiled code: no call to it is left, and its body runs as part of its callers.
Inside a compiled function, an operation the compiler has no machine code for yet is handed to the VM, and the function carries on as machine code after it, so a loop with few such operations gains the most. A small loop that would hand an operation to the VM on every pass runs on the VM altogether, because going in and out of machine code each time would cost more than it saves; the report then lists every operation in that loop. A try inside a loop's body is one such operation.
A local listed as typed is one the compiler knows holds an Int (or a Float) wherever the function's loops use it, so it keeps it in a machine register and skips the checks. The type comes from a number it starts as (mut total = 0), from arithmetic on typed values, or from a parameter's annotation (fn spin(n: Int)). A local listed as untyped is one a loop adds, multiplies or compares without knowing its type, which costs a check each time; the line says where. Annotating the parameter it comes from usually fixes it. A call to another function inside the loop makes every local untyped again after the call, so in such a loop even a plain counter is listed. A Float parameter accepts an Int too, so its annotation does not make it typed.
The report is decided by the source alone, so it is the same on every run. Results, errors and exit codes are the same whether a function compiles or not. The flag adds output only and does not change the exit status.