mix lint — semantic analysis

mix --check is a syntax check: it lexes and parses, nothing more. It happily passes a script that reads an undefined variable, calls a function that exists nowhere, or hands substr one argument. mix lint (0.29.0) is the semantic layer: it builds the scope universe, resolves every call against the builtin contract metadata and the file's own definitions, and reports machine-readable diagnostics with stable codes — designed for an agent to consume without parsing prose, and biased hard toward zero false positives on Mix's dynamic seams.

mix lint [--json | --data] [--deny-warnings] [--require-version] [--fix]
         [--allow-global NAME]... [--allow-function NAME]...
         FILE...
  • Exit codes: 0 no errors (warnings allowed unless --deny-warnings); 1 one or more errors, or any warning under --deny-warnings; 2 invalid usage, unreadable input, or internal failure. Notes never affect the exit code — a file whose only findings are notes exits 0 even under --deny-warnings (0.63.0; pinned by CLI tests, because --deny-warnings is a live fleet deploy gate).
  • Diagnostics go to stdout; CLI errors go to stderr. --deny-warnings changes only the exit decision, never a severity.
  • --require-version (0.95.0) reports MIX-D3016 (a script with no -- version: header) as a warning instead of a note — the one flag that moves a severity, and only for that code. Pair it with --deny-warnings to make a missing header fail a gate.
  • - reads stdin (at most once); relative require() paths from stdin resolve against the current directory.
  • --allow-global NAME / --allow-function NAME declare names an embedder or environment provides (repeatable). A file can declare its own with a -- lint: globals pragma.
  • --fix (0.117.0) applies each finding's quick fix to the file and lints the result again, so the report shows what is left; it prints applied N fix(es) to stderr. Only mechanical rewrites carry one (today MIX-W2312, bareword null → nil), and each edit is checked against the text at its position before it is made. Stdin is never rewritten.
  • The mix binary's shell extensions (ai, ai_diagnose, context) are known callables: calling one is never MIX-E1102, and defining a function with one of those names is MIX-W2313.
  • If script parsing fails, lint tries the same strict-data parser as load_data(). A successful fallback exits cleanly and prints validated as strict data (not as a script). If both parsers fail, an explicit top-level key: shape (or a conventional strict-data suffix as a tiebreak) gets the data error; otherwise the original script error is preserved.

The rules (v1)

Codes are permanent — never reused, never repurposed — from three namespaces: MIX-E1xxx errors, MIX-W2xxx warnings (born warnings), and MIX-D3xxx deprecations and release-transition advisories (0.63.0) — a severity-independent namespace: a deprecation starts at severity note and may later be promoted to warning with the code unchanged (only the wire severity field moves), so tooling that suppresses or greps by code keeps working across the promotion.

Notes (severity note, 0.63.0)

Rendered with a note: prefix, sorted after errors and warnings, counted in their own summary field, and never gating. Current D-codes:

codewhat it flags
MIX-D3001–D3005the five pattern-first legacy names regex_match regex_find regex_replace regex_split grep — use the subject-first re_match re_find re_replace re_split grep_lines. The legacy names were DELETED in release B (0.73.0) after the fleet-wide inventory read zero, so a surviving call also gets MIX-E1102 (undefined function) and fails at runtime; these notes stay as the pointer to the replacement
MIX-D3006RETIRED in 0.68.0. Watch note for the map-binding flip, which has landed: a two-variable loop over a MAP now binds (key, value). Code permanently spent, never reused
MIX-D3007RETIRED in 0.68.0. Watch note for the equality flip, which has landed: ==/!= with a map or list on both sides now raises TYPE_ERROR naming deep_eq. The shipped rule is narrower than this note's "either operand" wording — a collection compared to a scalar still answers, so $m[$k] == nil keeps working. Code permanently spent, never reused
MIX-D3012an ssh_mix body that could not be analysed (0.69.0) — a non-literal second argument (a variable, a concatenation, an interpolated string, a read_file), or a literal that does not parse as Mix. Says so explicitly rather than passing silently, because an unreadable body counted as clean is exactly how an inventory reads zero while live sites exist
MIX-D3008–D3011the REXX-style pos lastpos byte_pos byte_lastpos family, declared legacy — with a sharper message when composed as substr(.., pos(..)) in one expression (the 1-based/0-based off-by-one). These stay notes until their own fleet count reads zero; they are NOT deleted in release B
MIX-D3014write_file() of an unchecked replace() result (0.90.0) — the edit-a-file idiom with nothing anywhere in the file that could have noticed the needle was absent. replace() returns the subject unchanged when it misses, so the input is written straight back and the run reports success. Use replace_must()/re_replace_must(), which raise NEEDLE_ABSENT (and assert the site count with {count: n}). Conservative: only a write_file whose written value is a replace call or a variable the same straight-line block assigned from one, and any guard spelling anywhere in the file (contains, pos, index_of, count_of, re_match, a _must twin) silences it for the whole file
MIX-D3013a hand-rolled padding loop (0.74.0) — while len($o) < $n … $o = $o .. " " — pointing at lpad/rpad (and the display-cell lpad_w/rpad_w). Four independent sessions wrote this loop while the builtins sat in the binary; the note is the discoverability fix that reaches the author at authoring time. Narrow by design: only a </<= comparison of len/length of the same variable the body self-appends a string literal to
MIX-D3016a script with no -- version: X.Y.Z header in its leading comment region (within the first 32 lines) (0.95.0), so mix SCRIPT --version and script_version() can only say unversioned — every binary and every Mix script answers --version (Mark, 2026-09-25). Emitted by the lint driver, only for files run as scripts, decided by a deliberately simple heuristic over the path as given, in this order: (1) line 1 is a #! shebang — a script; (2) a lib, _lib, tests or test directory component — never reported: a library is loaded by require, and a test script must stay header-less, because a header would make it the entry script whose record script_version() returns; (3) a bin, _bin, scripts or build directory component — a script; (4) a serve citizen — a top-level on <verb> handler at column 0, or --serve mentioned in the leading comment region. mix lint deploy.mix from inside _bin/ has no directory component, so lint from the repo root. Known false positive, Wontfix: a library under an absolute /…/bin/… path with no lib component is reported. The mirror holds too: any /…/lib/… or /…/tests/… ancestor in an absolute path exempts a script beneath it. Only the leading comment region counts as the header (see invocation), so a -- version: inside a heredoc does not satisfy it. A header line whose value is not X.Y.Z is reported on its own line, since the runtime treats it as absent. A note by default; --require-version makes it a warning, code unchanged
MIX-D3017an unprovable variable read inside an ssh_mix body with dynamic bindings — the name is bound nowhere in the body, but the call's opts are unreadable so bindings/env may supply it at runtime, and it is NOT an error. Fires only when the name is one edit away from something the body DOES bind (a parameter or local), where the typo is the likelier explanation: '$pott' … did you mean '$port'? One edit is one edit — an adjacent transposition ($prot) is TWO under plain Levenshtein, and the short-name budget stays at one so unrelated dynamic bindings keep their silence. A name with no near neighbour is an ordinary dynamic binding and stays silent
MIX-D3018ssh_mix opts are not a statically readable map — the injected names are unknown, so undefined-variable checks stand down for that body (dynamic bindings may supply any free DATA name). The boundary is split, not blanket: undefined-function checks still run (strict-data bindings cannot create a callable, so a function name the body does not define is undefined no matter what the opts hold). A variable bound exactly once to a map literal IS resolved ($o = {bindings: {…}}), so the fleet opts-variable shape keeps full checks
MIX-D3019a -- lint: suppression that matches nothing (0.117.0) — an ignore CODE whose next statement no longer reports CODE, or an ignore-file CODE in a file that reports none. Reported at the pragma's line so stale suppressions get deleted. See in-file pragmas
MIX-D3015a bare $NAME in a double-quoted string (0.90.0) where NAME is bound in the same file. Double quotes interpolate ${NAME} only; the bare form is literal BY DESIGN, which is the opposite of bash — four occurrences in one file passed lint and all four failed at runtime. \$NAME and a single-quoted '…' string are the two clean spellings and neither is reported. The lexer drops the whole batch for a MULTI-LINE string and for one whose spelling contains \": both mark NESTED source (an ssh_mix body, a mix -c program), where a bare $rc is the inner program's variable and correctly literal. A note where the heredoc twin MIX-W2402 is a warning, and the asymmetry is measured — over 785 fleet scripts W2402 costs 4 findings and this one an order of magnitude more even after those exclusions, so a warning would fail --deny-warnings on scripts that are not wrong. D3xxx is the promotable namespace precisely so that can change once the residue is worked off

Member-call spellings are covered too: a builtin-named .name( desugars to the same call at parse time.

MIX-E1001  lexical error                    MIX-E1301  duplicate function parameter
MIX-E1002  script parse error               MIX-E1302  duplicate function definition in one scope
MIX-E1003  strict-data parse error          MIX-E1303  function name shadows a builtin
MIX-E1101  undefined variable               MIX-E1401  require() target missing/unreadable
MIX-E1102  undefined function               MIX-E1402  require() target invalid Mix
MIX-E1201  builtin arity mismatch           MIX-E1501  dead mutation (write is lost)
MIX-E1203  literal contradicts contract type
MIX-E1202  user-function arity mismatch     MIX-E1502  discarded pure transform
                                            MIX-E1503  fn name stored as a value (--agent)
                                            MIX-E1504  assignment from nil-returning builtin (--agent)
                                            MIX-E1505  constant-truthy condition (--agent)
                                            MIX-E1506  write to an outer variable in fn (--agent)
                                            MIX-E1507  shell command written as a bare string
                                            MIX-E1508  assign-back of an in-place mutator
                                            MIX-E1601  malformed `-- lint:` pragma
                                            MIX-W2101  unreachable statement
                                            MIX-W2201  discarded must-use result
                                            MIX-W2301  `+` on a proven list/map raises
                                            MIX-W2302  used implicit-nil function result
                                            MIX-W2303  assignment operand in hand-built chain AST
                                            MIX-W2304  unknown builtin-result key
                                            MIX-W2305  -1-sentinel builtin as a truth value
                                            MIX-W2306  escaped quotes in ssh command source
                                            MIX-W2307  send result never checked
                                            MIX-W2309  builtin-named map member unreachable via dot-call
                                            MIX-W2310  proven-missing lookup on a literal collection
                                            MIX-W2311  fmt/sprintf surplus operands
                                            MIX-W2312  bareword null/None/undefined is a string
                                            MIX-W2313  function name collides with a shell extension
                                            MIX-W2314  require() export: wrong argument count
                                            MIX-W2315  require() module has no such export
                                            MIX-W2401  source/include defeats analysis
                                            MIX-W2402  bare bound variable in heredoc
                                            MIX-W2405  unknown escape kept literally

MIX-W2403 is retired (0.90.0), never reused: the builtin-shadowing definition check was promoted from warning to error and therefore had to move, since a code's letter fixes its severity permanently. It is now MIX-E1303.

  • MIX-E1003 means the source was recognisably intended as strict data but failed the literal-data grammar. It is distinct from a broken executable script (MIX-E1002). A valid data file under any filename is recognised by content; the suffix is only a tiebreak when neither grammar succeeds.

  • MIX-E1101 flags a $name read only when the name is bound nowhere in its visible universe — function bodies see params + their own binders + everything bound anywhere at file level (Mix has no block scoping and no read-before-assign rule, so lexical order is deliberately ignored). ${name} interpolation is never flagged (it falls back to the process environment), nor are $1-style positionals or the runtime-injected rc / result / status / event / _.

  • MIX-E1102 resolves bareword calls against builtins, HOFs, evaluator special forms, every function definition in the file, the embedded prelude, --allow-function names, AND any assigned variable (a bareword call can dispatch to a function-valued variable). Calls inside address ... end blocks are sends and are never flagged; MethodCall/ValueCall are dynamic dispatch and are skipped. The hint carries the same "did you mean" the runtime prints for that name. One suggester serves both, so lint (where an agent looks first) and the failing run cannot disagree. It checks, in order: the deleted-name pointers (regex_match → re_match(s, pattern)), then the form table for the foreign get/set/find whose answer is Mix syntax, not a builtin name (get → $m[key] / get_or/require_key; set → $m[key] = value; find → index_of/filter, semantics as in collections — range inclusive, sort numeric-then-lexicographic, filter list-first), then a foreign-name synonym table for the names a python/bash/JS habit reaches for first (json_decode/json_loads → json_parse, json_dumps → json_encode, str → to_string, trim_end/rstrip → rtrim, len_bytes → byte_length, getenv → env, …), and only then edit distance. The order matters: json_encode is the nearest spelling of json_decode and means the opposite. A user-defined fn get/set/find is a known callable and never reaches the suggester.

  • MIX-E1201 checks calls against the structured contract metadata (mix builtins --json), including non-contiguous exact-arity sets — random(1) is an error, random()/random(min, max) are not. The contract is the documented surface; some older builtins tolerate surplus arguments at runtime, and lint is deliberately stricter (mix --strict-arity makes the runtime agree).

  • MIX-E1203 (0.103.3) flags a literal argument whose type cannot satisfy the contract's declared shape — mkdir({a: 1}), exists([1, 2]), len(3). The runtime raises TYPE_MISMATCH on the same call (0.103.1), so the rule makes lint agree with the runtime; a wrong-typed path/target literal is a side effect on the wrong target. Variables and expressions are not judged — only a literal proves the type. A literal nil is judged too: it satisfies only a shape that declares it (any_of(…, nil) for the optional args that treat nil as omission, or any), so write_file(nil, "x") is flagged exactly as the runtime refuses it.

  • MIX-E1501 flags a discarded push/pop/shift whose first argument is not a bare variable — push($m["a"], $v), push($m.a, $v), $m["a"].push($v). These builtins mutate through the variable slot, so given any other expression they append to a temporary copy and the write is lost in silence. It is an ERROR, not a warning: the statement does nothing while reading as though it did. The fix differs by builtin: push returns the appended list, so assign it back ($m["a"] = push($m["a"], $v)); pop/shift return the removed element, not the list, so assigning that back replaces the list with the element (data corruption) — hoist first instead ($l = $m[$k]; $x = pop($l); $m[$k] = $l). For maps of maps, write the nested assignment directly. A by-value parameter is a bare variable, so that case stays with its own definition-time dead-push warning and is not double-reported.

  • MIX-E1502 flags a discarded delete / merge — both are pure (they return a new container and change nothing in place), so a bare call is a no-op. Assign it back: $m = delete($m, "k").

  • MIX-E1503–E1506 are the --agent profile (run mix lint --agent, or MIX_LINT=agent): a function name stored as a value ($f = bump binds the string), an assignment from a nil-returning builtin ($n = write_file(...) binds nil), a constant-truthy condition (if "false", a bare process-result map — while true is exempt), and a fn body writing an outer variable (which silently creates a local). Ordinary lint and the fleet's --deny-warnings gates run without them.

  • MIX-W2201 fires when an operation whose failure signal lives in its RETURN VALUE (effects.must_use: run_rc, run_argv, run_pipeline, run_parallel, ssh_run, ssh_exec, ssh_mix, http_*, kill, run_stream) is a bare expression statement — the bug class where a failed remote step silently vanishes. Bind the result and branch on it; some have a fail-fast twin that raises (run_argv→run_argv_must, run_pipeline→run_pipeline_must, ssh_run→ssh_must). The last statement of a block is exempt (it may be the block's value).

  • MIX-W2301 warns that + is not defined for lists or maps. Since 0.90.0 the runtime raises TYPE_ERROR there rather than silently stringifying, so this fires ahead of the run for a list/map literal operand, or a variable proven by straight-line analysis to hold a directly assigned one — which is the only gate there is on an ssh_mix body, or on a branch a local run never takes. Use concat(a, b) for lists, merge(a, b) for maps, push(list, value) to append, .. to build text. It stays a warning, not an error: the proven-value facts are straight-line, so a reassigned variable can make the prediction wrong.

  • MIX-W2302 warns when the result of a uniquely defined named function is consumed, its block body's final statement is a bare expression, and the body contains no value-returning return. Block functions implicitly return nil; add return. A discarded call is quiet, as are mixed-return bodies, terminating final expressions, and calls whose name can be redirected through a variable.

  • MIX-W2303 is defence-in-depth for Rust embedders that construct the public AST directly and pass it to analyze(): it warns if any operand of a hand-built StmtKind::Chain is an assignment. Ordinary Mix source cannot reach this warning because the parser rejects the same shape first as MIX-E1002. The code remains reserved for this public-API path and is not repurposed.

  • MIX-W2304 checks a literal field/index key against the builtin's documented result-map fields from mix builtins --json. It works on a direct builtin call or a variable proven by straight-line assignment to hold that result. The message names the declared shape and says what the read does: process_result (from run_argv()) has no field 'code' — reading it is silently nil. The hint is a did-you-mean (0.117.0): the bash, Python and Node spellings map to the field they mean first (code, rc, status, returncode → exit_code; status_code → status; success → ok; output → stdout), then a field ending in _key, then the nearest by edit distance, so $r.rc no longer suggests ok. Dynamic keys, generic maps and result shapes without declared fields are deliberately silent, and the runtime is unchanged: a missing field reads nil.

  • MIX-W2305 flags index_of() / byte_index_of() / bytes_find() (added v0.64.0) used bare as a truth value. They return -1 for "not found" and 0 for "found at the first position", and Mix treats 0 as falsy and every non-zero number — -1 included — as truthy. So a bare call in a condition is wrong on both branches:

    if index_of("abc", "z") then …   -- -1 is TRUTHY  → absent reads as present
    if index_of("abc", "a") then …   --  0 is FALSY   → found-at-0 reads as absent
    

    Compare explicitly (index_of(..) >= 0), or use contains() for the yes/no question — except for byte_find/bytes_find, whose bytes subject contains() rejects, so those take the >= 0 comparison only. Their 1-based twins — pos, lastpos, byte_pos, byte_lastpos — are safe in the same position because their not-found sentinel is 0 and therefore falsy; that asymmetry is exactly what makes the trap easy to walk into, and why the rule exists. Fires in if/elif, while, break if/continue if, expression-position if, the ternary condition, and through not/and/or. Any explicit comparison is already correct code and stays silent.

  • MIX-W2306 flags a literal command passed to ssh_run or ssh_must when its source spelling contains \". That escape is the high-signal mark of nested Mix source which the remote shell will parse again. Ship the source verbatim with ssh_mix + a heredoc. Simple command strings, computed commands, ssh_exec, ssh_mix, and single-quoted strings containing ordinary " stay quiet.

  • MIX-W2311 warns on a direct, unshadowed fmt()/sprintf() call whose template is a string literal that parses cleanly under the runtime grammar, when the call provides more operands than the template consumes — fmt("%s", 1, 2) provides two operands for one placeholder, and both builtins silently ignore the surplus (they are variadic, so the generic arity gate cannot see it). %% consumes nothing; a * width and a sprintf .* precision consume one operand each, so fmt("%*s", 5, "x") is exact and stays quiet. Invalid or unknown templates (which would raise at runtime), dynamically built templates, deficits (the runtime's own "not enough arguments" error), and calls a user function, variable, address block or source/include could shadow are silent too.

  • MIX-W2312 (0.117.0) warns on a bareword null, None, undefined, NULL or nullptr used as a value. Mix's only null is nil; every other bareword is a string literal, so {open: null} sends {"open":"null"} and if $x == None compares against text. A map key ({null: 1}), a field ($x.null), a call name, a statement head, a Bus command word and a quoted "null" stay silent. Where the word is surely a value (a call argument, a list element, a map value, the right side of $x =, an operand of a comparison or ??, a return operand) the finding carries a quick fix to nil (mix lint --fix); anywhere else, such as print("a") && null where null may call a fn null(), it warns without one. There is no runtime alias, by decision: the language keeps one null. Like D3015 it needs the source text.

  • MIX-W2313 (0.117.0) warns at the definition of a function named after one of the mix binary's shell extensions (ai, ai_diagnose, context). An extension beats a user function at a bareword call, so context() runs the shell's extension. A member call on a require() exports map ($m.context()) does reach the function, in every mode.

  • MIX-W2314 / MIX-W2315 (0.117.0) are warnings while the check is new, so a false positive can never fail a mix lint gate; the call they flag fails at run time, and they may become errors with the type work. They check member calls into a module bound once to a literal path, $m = require("./lib.mix"), against what the module exports: its top-level functions (with their arity) and $vars, minus _-private names. $m.helper(1) for a two-parameter helper is W2314; $m.nosuch(2) is W2315, with the nearest export as a did-you-mean. W2315 stays silent when anything else can answer the call (an exported $var that may hold a function, a function this file defines, the prelude, an extension, an enclosing address block) — except for a statement $m.nosuch(), which calls the field value and has no UFCS fallback. An export bound once to a literal require() resolves too, next to its own module ($a.b.fn()), and a require cycle cannot loop the check. Both stand down silently for a dynamic path, a rebound $m (or one that is also a parameter), a module whose top-level return replaces the auto-export or that uses source/include, and a file that does.

  • MIX-E1601 (0.117.0) refuses a malformed -- lint: pragma: a bare ignore or ignore-file, an unknown code or directive, a globals with no valid names.

  • MIX-W2401: one source/include anywhere disables the undefined-name checks for the whole file (the loaded file can define anything) — reported once so you know analysis is degraded. Prefer require(): it is isolated, statically resolvable, and E1401/E1402 verify literal-path modules parse.

  • MIX-W2402 warns when a heredoc literal contains bare $NAME and NAME is bound somewhere in the same visible universe. Heredocs interpolate ${NAME}, not $NAME, so the bare form often means a generated config was silently corrupted. It does not fire for ${NAME}, $( command substitution, explicitly escaped \$NAME, all-digit names such as $1, unknown names, or ordinary double-quoted strings. The warning is lint-only: bare $NAME still evaluates to literal $NAME, and intentional literal output requires no change. In a heredoc that ships as an ssh_mix body it stays silent for the names the remote program owns: the call's bindings/env keys and whatever the body binds itself. There, bare is exactly right, and ${NAME} would splice the local value into the remote source. Any other bound name still warns.

  • MIX-E1303 (0.90.0; was MIX-W2403 from 0.74.0) errors at the definition of a function whose name is a builtin: the builtin wins at every call site (a builtin-named dot-call even desugars at parse time), so the definition is unreachable by name. The worst shape this produces is a script that keeps running while its own function quietly stops being called, and every release that adds a builtin name arms it again for older scripts. It was a warning on the theory that a compat shim for an older mix is legitimate authoring; the fleet refuted that — two sites across 785 scripts, neither a shim: one a hand-rolled ends_with duplicating the builtin, the other an fn mix_version() in a pre-commit hook written to report a named interpreter's version and silently answering with the running one's, a live wrong answer that sat behind a warning for sixteen releases. Lint is also the only gate an ssh_mix body passes through, and a warning stops nothing by default. The builtin still wins at a call site; the fix is to rename. Since 0.118.0 the runtime refuses the definition as well (FN_SHADOWS_BUILTIN), before the first statement for a top-level fn: mix --check is syntax-only, so a script that skipped mix lint used to run on and fail far from the cause (fn run($argv) died as run(): argument 1 (cmd) must be string). Since 0.118.0 the name set also includes the higher-order builtins (count, map, filter, sort_by, …), which slipped past this check before. Since 0.91.0 the check also covers the evaluator's inline forms that sit outside the builtin table's dispatch gate — serve_name, printf and its stdio siblings, and the Bus forms quit, reply, subscribe and the rest — which beat a same-named function just the same: fn quit() return 1 end; print(quit()) printed nil with no diagnostic before. Since 0.92.0 the runtime agrees in operand position too: the binary-operator fast path used to call a user fn printf for printf("B") .. "|" while a bare printf("B") called the builtin; now the builtin wins in both.

  • MIX-W2405 warns when a double-quoted literal contains a backslash escape the lexer does not recognise, so the backslash is kept: "isn\x27t" printed isn\x27t and nothing said so, which is how a replace() wrote that into a committed journal entry. 0.90.0 added \xHH (exactly two hex digits), \0 and \a \b \f \v, so what remains is genuinely unrecognised — including \x with fewer than two hex digits and \'. A deliberate backslash is \\, so the warning has a clean escape. An unbraced \u is exempt: that literal is documented design (it protects embedded JSON and C:\users). Single-quoted strings and heredocs keep their own rules and are not scanned.

  • MIX-D3015 and MIX-W2405 ask how a literal was spelled, which the token stream deliberately forgets ('$sp/x' and "$sp/x" lex to the same token). They therefore need the source text: mix lint supplies it, and so does the nested analysis of an ssh_mix body, but an embedder calling analyze() without setting AnalyzerConfig::source simply does not get these two rules.

In-file pragmas (0.117.0)

A comment starting -- lint: (or # lint:) tells lint something the code cannot, so the knowledge lives with the file instead of in a per-invocation flag. There are three, and they have no runtime effect:

-- lint: globals sha, target
-- lint: ignore MIX-W2402
-- lint: ignore-file MIX-D3015

The first declares names injected by ssh_mix bindings, an embedder or a source; the second suppresses MIX-W2402 on the next line of code only; the third suppresses MIX-D3015 everywhere in the file.

  • globals is the in-file --allow-global: each name counts as bound, so a remote body whose caller binds $sha lints clean on its own.
  • ignore covers the next line holding code and nothing more (eslint's disable-next-line): blank and comment lines between are skipped, every statement on that line is covered, and a finding on a later line of the same statement is not, so put the pragma right above the line that reports the code. Most findings carry their statement's first line; a few (such as MIX-W2312) carry the line of the offending word. Written after code on the same line, ignore covers that line.
  • Every suppression names its code (several may be listed, comma- or space-separated; the MIX- prefix and case are optional). A bare -- lint: ignore is refused (MIX-E1601), as is an unknown code or directive, so a typo cannot silently disable a check.
  • A suppression that matches nothing is reported as the note MIX-D3019 at the pragma's line, so stale ones get cleaned up. Notes never gate.
  • The text after a | on a line is shell text for the external command, so print(1) | echo -- lint: ignore holds no pragma.
  • Pragmas are read from comments only: a -- lint: line inside a string or heredoc (generated Mix source) belongs to that source, not to this file. They apply to the analyzer's findings, not to the lexer/parser errors (MIX-E1001–E1003) or the driver's MIX-D3016, and an embedder that calls analyze() without the source text gets none.

mix explain MIX-XXXX — the offline diagnostics explainer

Every human-readable lint run that reports anything ends with one trailer line:

explain any code with: mix explain MIX-XXXX

mix explain MIX-W2305 (the MIX- prefix is optional, case-insensitive — mix explain w2305 works too) prints the code's full story — what it flags, why the rule exists, the shape it catches, and the fix — from a registry embedded in the binary, so an agent that hits a code it has never seen gets the whole rationale in one call without leaving the terminal or reaching the network. An unknown but code-shaped argument lists the known codes rather than failing blankly; a non-code argument (mix explain round) falls through to the AI builtin explainer. The registry is the same prose as this page; a build-time test asserts every code the analyzer can emit has a record, so a new diagnostic cannot ship without its explanation.

Machine output

--json uses D3 schema 3: parsed functions metadata is added to schema 2. Every existing key keeps its meaning, including "note" severity and summary.notes introduced in schema 2:

{
  "schema_version": 3,
  "tool": "mix lint",
  "mix_version": "0.63.0",
  "files": ["worker.mix"],
  "strict_data_files": [],
  "diagnostics": [{
    "code": "MIX-E1101", "severity": "error", "file": "worker.mix",
    "line": 412, "column": null,
    "message": "undefined variable '$DOMAIN' (assigned nowhere in this file)",
    "hint": "assign it, use env(\"DOMAIN\") for environment values, or declare an injected name with `-- lint: globals DOMAIN` (or pass --allow-global DOMAIN)",
    "fix": null
  }],
  "capabilities": ["fs-read", "network", "process"],
  "handlers": [],
  "functions": [],
  "summary": {"errors": 1, "warnings": 0, "notes": 0, "denied_warnings": false}
}

fix (0.117.0, additive) is null, or the quick fix --fix would apply: {"line": 1, "column": 6, "replace": "null", "with": "nil"} (1-based line and character column).

--data emits the same report as strict-data Mix source (parse with data_parse). line/column are 1-based or null. Most diagnostics are statement-level and carry a null column. Lexical and parse errors generally do carry one — the assignment-chain error, for instance, points at the offending &&/||.

capabilities is the inventory of capability classes the script's calls exercise — data, not warnings. strict_data_files lists inputs validated by the strict-data fallback rather than by the script analyzer.

handlers (introduced in schema 2) lists each on <command> declaration the parser built, across every input file, in source order: {"command": "ping.one", "file": "ping.mix", "line": 2}. file is the path as given, or "-" for stdin. The field is always present: it is [] for strict-data inputs, for a file that failed to parse, and for a script with no on statement. Duplicate declarations are kept.

handlers is structural evidence only. It comes from the native AST, so an on counts wherever the parser places it (top level, a fn body, a block lambda, an if branch or a control block). It does not prove that the handler runs or is reachable. Text inside comments, quoted strings and heredocs is never a declaration. Bodies of ssh_mix remote programs are not included either, because they are literal payloads, not local source. Lint never executes the file to collect them.

functions lists named fn/function definitions as {"name": "esc", "file": "lib.mix", "line": 4}. It uses the same AST walker and file attribution as handlers, keeps nested and duplicate declarations, and excludes comments, strings, heredocs and remote programs. It is always present, including an empty list for strict data or a parse failure. Like handler metadata, it records declarations rather than proving reachability. Embedders can derive external function names from declared context files; services/webd/vhosts/embed.conf.mix is the webd source gate's declaration.

Remote bodies — lint sees inside ssh_mix (0.69.0)

ssh_mix(host, source[, opts]) ships its second argument to a remote mix -. That argument is Mix source, and since 0.69.0 lint treats it as such: when it is a literal it is parsed and analysed, and its diagnostics are reported against the enclosing file with a [inside ssh_mix body] prefix and the line mapped into the outer file.

A literal is a plain string, an all-literal heredoc written inline (the headline idiom), or a variable whose sole binding anywhere in the file is one of those. The last is the fleet shape: the program bound once, shipped from a loop over hosts.

$probe = <<END
print(length($base))
END
for $h in $hosts do
  $r = ssh_mix($h, $probe, {bindings: {base: $base}})
end

"Sole" is strict. A second assignment, a loop variable or a parameter of the same name anywhere in the file makes the value unknowable, and the body is reported as unanalysable instead. The binding must also be visible at the call: at top level, in the same function, or in an enclosing function reached through lambdas (a lambda is a closure; a named nested fn is not). Findings in a bound heredoc point at the heredoc's own lines. A heredoc shipped by several calls reports each finding once. Calls are found at any depth: in loops, branches, functions and lambdas.

deploy_thing.mix:283: MIX-D3001 note: [inside ssh_mix body] `regex_match` is
  pattern-first legacy: use `re_match(s, pattern)` (subject first)

Before this, a deploy script's entire remote half was one opaque string — invisible to lint and, more consequentially, to every inventory built from lint. That is not a hypothetical: the MIX-D3006 inventory that gated 0.68.0's map-binding flip reported zero sites for deploy_vhost.mix, locally and on 27/27 fleet nodes, while line 283 of that file is a two-variable loop over a map living inside such a body.

A body that cannot be analysed says so — MIX-D3012, for a non-literal argument (a variable not bound once to a literal, concatenation, interpolation, read_file) or a literal that does not parse as Mix. This is the rule that matters more than the analysis: an unreadable body silently counted as clean is precisely how an inventory reads zero while live sites exist.

A body interpolating ${name} is reported by name. That splice puts the local value into the remote source text, which is the classic remote bug; pass the value through bindings and write it bare instead.

Names resolve against the body's own universe. A remote body is a separate program. It sees its own binders, the builtins and prelude, and the names the call injects: the keys of bindings and of env, both prepended to the shipped source as assignments. It does not see the enclosing file's functions or variables. So a bare $base passed as a binding is clean, while $notbound, or a call to a helper defined only in the outer file, is MIX-E1101/MIX-E1102 with the body prefix.

The injected names are read from the opts argument when it is a map literal, or a variable bound exactly once to one ($o = {bindings: {…}} — the same sole-definition rule as the body itself, so the fleet opts-variable shape keeps full checks). When they cannot be read — a call, a concatenation, a multi-bound name — the boundary is split, not blanket (MIX-D3018 at the call): dynamic bindings may supply any free DATA variable, so undefined-variable checks stand down (a read one edit away from a name the body binds still gets the MIX-D3017 typo note), but a function name cannot ride in through strict-data bindings, so undefined-function checks and arity checks keep running. The enclosing file keeps all of its own name checks. Everything else — legacy-name notes, arity, the truthiness trap — applies normally, and errors from inside a body gate exactly as they would anywhere else.

Diagnostics map onto the enclosing file's real lines. mix lint reads the source text, and the lexer records — while decoding each literal — which physical line every decoded line was written on. So a double-quoted body whose lines are \n ESCAPES reports everything on the one line the literal physically occupies, a physically multi-line literal and a heredoc map line-for-line (a \n escape inside a heredoc lands on the escape's line, not an invented one), and a parse/lex error inside a body is mapped by the error's own inner line.

Which opener a body is paired with is decided by the parser, not by matching text. With the source at hand, lint re-parses the file with literal-origin recording on: the parser records, in source order, one entry per literal expression it builds, and pairs those entries positionally with the same tree's literal expressions, verifying variant and decoded text per pair. Map keys (bare or quoted), parse delimiters and on names/doc-strings are not expressions, so identical text there can never steal a body's opener — a body opened on its own physical line reports there. If the recording and the tree disagree anywhere, the whole mapping is refused: a mapping can be missing, but it is never misattached.

An embedder calling analyze() without AnalyzerConfig::source gets the documented linear estimate instead (first_line + N - 1, exact for the $x = ssh_mix($HOST, ' and $p = <<END shapes), never invented accuracy.

ssh_mix and ssh_mix_many carry remote Mix source this way. run accepts shell command text; run_argv and run_pipeline execute argv specifications. Lint does not infer embedded source from those process arguments. --serve runs a script file, which lint can inspect directly.

What lint deliberately does NOT do (v1)

  • Straight-line facts do not cross branches, loops, function-call boundaries, source/include, or function frames. This deliberately misses lists and builtin-result maps produced indirectly, returned by helpers, assigned in a branch, or reached through a dynamic key. Suspicious nil coercion into paths/hostnames remains out of scope. MIX-W2302 also stays silent for a mixed-return function even when one fallthrough path may still yield nil. Shell-vs-argv advice remains out of scope — use validate at boundaries for the nil class today.
  • No reachability model for E1102/E1101 beyond the universe rules above.
  • Dynamic require(expr) paths, extension calls, and Bus dispatch are never hard errors.

See also

  • invocation — mix --check (syntax only), --strict-arity
  • errors — the structured errors the flagged code will raise at runtime
  • builtins — the contract metadata lint checks against

This page as Markdown · This page describes the current main branch of MixOS.