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.