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:
0no errors (warnings allowed unless--deny-warnings);1one or more errors, or any warning under--deny-warnings;2invalid usage, unreadable input, or internal failure. Notes never affect the exit code — a file whose only findings are notes exits0even under--deny-warnings(0.63.0; pinned by CLI tests, because--deny-warningsis a live fleet deploy gate). - Diagnostics go to stdout; CLI errors go to stderr.
--deny-warningschanges only the exit decision, never a severity. --require-version(0.95.0) reportsMIX-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-warningsto make a missing header fail a gate.-reads stdin (at most once); relativerequire()paths from stdin resolve against the current directory.--allow-global NAME/--allow-function NAMEdeclare names an embedder or environment provides (repeatable). A file can declare its own with a-- lint: globalspragma.--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 printsapplied N fix(es)to stderr. Only mechanical rewrites carry one (todayMIX-W2312, barewordnull→nil), and each edit is checked against the text at its position before it is made. Stdin is never rewritten.- The
mixbinary's shell extensions (ai,ai_diagnose,context) are known callables: calling one is neverMIX-E1102, and defining a function with one of those names isMIX-W2313. - If script parsing fails, lint tries the same strict-data parser as
load_data(). A successful fallback exits cleanly and printsvalidated as strict data (not as a script). If both parsers fail, an explicit top-levelkey: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:
| code | what it flags |
|---|---|
MIX-D3001–D3005 | the 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-D3006 | RETIRED 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-D3007 | RETIRED 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-D3012 | an 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–D3011 | the 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-D3014 | write_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-D3013 | a 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-D3016 | a 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-D3017 | an 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-D3018 | ssh_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-D3019 | a -- 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-D3015 | a 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
$nameread 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-injectedrc/result/status/event/_. -
MIX-E1102 resolves bareword calls against builtins, HOFs, evaluator special forms, every
functiondefinition in the file, the embedded prelude,--allow-functionnames, AND any assigned variable (a bareword call can dispatch to a function-valued variable). Calls insideaddress ... endblocks are sends and are never flagged;MethodCall/ValueCallare 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,sortnumeric-then-lexicographic,filterlist-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_encodeis the nearest spelling ofjson_decodeand means the opposite. A user-definedfn get/set/findis 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-aritymakes 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 literalnilis judged too: it satisfies only a shape that declares it (any_of(…, nil)for the optional args that treat nil as omission, orany), sowrite_file(nil, "x")is flagged exactly as the runtime refuses it. -
MIX-E1501 flags a discarded
push/pop/shiftwhose 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:pushreturns the appended list, so assign it back ($m["a"] = push($m["a"], $v));pop/shiftreturn 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
--agentprofile (runmix lint --agent, orMIX_LINT=agent): a function name stored as a value ($f = bumpbinds 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 trueis exempt), and afnbody writing an outer variable (which silently creates a local). Ordinary lint and the fleet's--deny-warningsgates 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 raisesTYPE_ERRORthere 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 anssh_mixbody, or on a branch a local run never takes. Useconcat(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 returnnil; addreturn. 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-builtStmtKind::Chainis an assignment. Ordinary Mix source cannot reach this warning because the parser rejects the same shape first asMIX-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.rcno longer suggestsok. Dynamic keys, generic maps and result shapes without declared fields are deliberately silent, and the runtime is unchanged: a missing field readsnil. -
MIX-W2305 flags
index_of()/byte_index_of()/bytes_find()(added v0.64.0) used bare as a truth value. They return-1for "not found" and0for "found at the first position", and Mix treats0as falsy and every non-zero number —-1included — 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 absentCompare explicitly (
index_of(..) >= 0), or usecontains()for the yes/no question — except forbyte_find/bytes_find, whose bytes subjectcontains()rejects, so those take the>= 0comparison only. Their 1-based twins —pos,lastpos,byte_pos,byte_lastpos— are safe in the same position because their not-found sentinel is0and therefore falsy; that asymmetry is exactly what makes the trap easy to walk into, and why the rule exists. Fires inif/elif,while,break if/continue if, expression-positionif, the ternary condition, and throughnot/and/or. Any explicit comparison is already correct code and stays silent. -
MIX-W2306 flags a literal command passed to
ssh_runorssh_mustwhen 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 withssh_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 asprintf.*precision consume one operand each, sofmt("%*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,addressblock orsource/includecould shadow are silent too. -
MIX-W2312 (0.117.0) warns on a bareword
null,None,undefined,NULLornullptrused as a value. Mix's only null isnil; every other bareword is a string literal, so{open: null}sends{"open":"null"}andif $x == Nonecompares 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??, areturnoperand) the finding carries a quick fix tonil(mix lint --fix); anywhere else, such asprint("a") && nullwherenullmay call afn 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
mixbinary's shell extensions (ai,ai_diagnose,context). An extension beats a user function at a bareword call, socontext()runs the shell's extension. A member call on arequire()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 lintgate; 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-parameterhelperis 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$varthat may hold a function, a function this file defines, the prelude, an extension, an enclosingaddressblock) — except for a statement$m.nosuch(), which calls the field value and has no UFCS fallback. An export bound once to a literalrequire()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-levelreturnreplaces the auto-export or that usessource/include, and a file that does. -
MIX-E1601 (0.117.0) refuses a malformed
-- lint:pragma: a bareignoreorignore-file, an unknown code or directive, aglobalswith no valid names. -
MIX-W2401: one
source/includeanywhere disables the undefined-name checks for the whole file (the loaded file can define anything) — reported once so you know analysis is degraded. Preferrequire(): it is isolated, statically resolvable, and E1401/E1402 verify literal-path modules parse. -
MIX-W2402 warns when a heredoc literal contains bare
$NAMEandNAMEis 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$NAMEstill evaluates to literal$NAME, and intentional literal output requires no change. In a heredoc that ships as anssh_mixbody it stays silent for the names the remote program owns: the call'sbindings/envkeys 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-W2403from 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-rolledends_withduplicating the builtin, the other anfn 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 anssh_mixbody 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-levelfn:mix --checkis syntax-only, so a script that skippedmix lintused to run on and fail far from the cause (fn run($argv)died asrun(): 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,printfand its stdio siblings, and the Bus formsquit,reply,subscribeand the rest — which beat a same-named function just the same:fn quit() return 1 end; print(quit())printednilwith no diagnostic before. Since 0.92.0 the runtime agrees in operand position too: the binary-operator fast path used to call a userfn printfforprintf("B") .. "|"while a bareprintf("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"printedisn\x27tand nothing said so, which is how areplace()wrote that into a committed journal entry. 0.90.0 added\xHH(exactly two hex digits),\0and\a \b \f \v, so what remains is genuinely unrecognised — including\xwith fewer than two hex digits and\'. A deliberate backslash is\\, so the warning has a clean escape. An unbraced\uis exempt: that literal is documented design (it protects embedded JSON andC:\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 lintsupplies it, and so does the nested analysis of anssh_mixbody, but an embedder callinganalyze()without settingAnalyzerConfig::sourcesimply 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.
globalsis the in-file--allow-global: each name counts as bound, so a remote body whose caller binds$shalints clean on its own.ignorecovers the next line holding code and nothing more (eslint'sdisable-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 asMIX-W2312) carry the line of the offending word. Written after code on the same line,ignorecovers 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: ignoreis 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-D3019at 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, soprint(1) | echo -- lint: ignoreholds 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'sMIX-D3016, and an embedder that callsanalyze()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. Suspiciousnilcoercion into paths/hostnames remains out of scope.MIX-W2302also stays silent for a mixed-return function even when one fallthrough path may still yieldnil. Shell-vs-argv advice remains out of scope — usevalidateat boundaries for the nil class today. - No reachability model for
E1102/E1101beyond 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