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 for map, filter, reduce, sort_with, retry, sql.transaction, csv.each and 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") against fn 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, or return "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 = 2 after a bare x = 1, a function writing a top-level name (including the name of a fn), or m.key = v, where the binding was not declared mut. See Variables & mutability; ecko fix --migrate --only=mut adds the mut for 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 before ecko file.ecko, also analyze every local .ecko file 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) - a match over 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 + n does not update total: it declares a new local named totl, and the program prints the wrong total. When a name assigned there is never read and is one slip from a mut in scope, that is an error naming the mut it probably meant. A name that is read later, starts with _, or is near no mut is an ordinary local.
  • Unknown struct or variant members, including a field read off a binding that holds a struct: s = Sq(2) then s.side when Sq has no side. (A mut binding may hold something else later, so it is not judged.) A member a std module does not export - json.parse - stops the run before it starts, as it does in ecko 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 = x after import std.json, at the top level or inside a function. It makes a new binding that hides the module from there on, so a later json.encode fails. let json = x says 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 { } where count is plainly a number, string, list or map. It skips at zero or empty, which may not be what you meant; write count != 0, name != "" or len(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 containing PASSWORD, is treated as one. Testing for presence is exempt, because it never reads the value:
    if 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"))               # flagged
    Comparing against anything other than null or "" 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). Inside if x % 2 == 0 { ... }, x / 2 is always a whole number, but / always returns a Float, so x stops being an Int and everything after it runs in floating point - same answer, slower, no error. Write x // 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 to range, take, drop or slice. / always returns a Float, so take(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 try can throw that its catch does not handle, and an arm for a kind the try cannot throw. ecko check works out which kinds each try can raise - from what the standard library throws, your own functions, and each error({ kind: "...", .. }) - and compares them with a match get(e, "kind") (or match e.kind) in the catch:
    try { 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
    }
    A _ 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 for limit, budget, cancelled or capability. 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.