← All skills

moonbit-scan

Scan ANY other MoonBit project with the three-inspection analyzer, without that project depending on anything. Pass a registry coordinate (author/module[@version]) or a filesystem path; the target is only read, never modified. Use this when asked to statically analyse, audit or lint a MoonBit codebase that is not the analyzer's own repository. For the analyzer's own repository prefer @pipeline.run / @moonfiles.* / @statecheck.audit directly, and for the analyzer's built-in demo use src/cli.

$ files
SKILL.md
SKILL.md

Scan another MoonBit project

riantr/moonbit_static_analysis is a three-inspection static-analysis pipeline (structural walk → type/symbol inference → abstract interpretation → merged report) covering every MoonBit toolchain file kind: .mbt, .mbtx, .mbti, .mbt.md, .mbtp.

Run it

moonx riantr/moonbit_static_analysis@latest riantr/moonbit_doubleML@latest

The target is a registry coordinate (author/module, optionally @version or @latest) or a filesystem path; omit it to scan the current directory. A coordinate is materialised with moon fetch into .repos/ and then walked.

The scanned project neither depends on this module nor is modified. Its sources are only READ, at scan time, and the dependency runs one-way: analyzer → registry → target source.

Pin a version when reproducibility matters:

moonx riantr/moonbit_static_analysis@0.4.28 riantr/moonbit_doubleML

moonx is the recommended invocation. It downloads the prebuilt wasm that mooncakes built for the published version, caches it under ~/.moon/registry/cache/assets/<author>/<module>/<version>/, and runs it with moonrun — no clone, no local build. The very same artifact is what the "Download wasm" button serves on skills.mooncakes.io.

moon runwasm … is deprecated (the toolchain says removal after 2026-09-14) but still works. Prefer moonx.

moonx with no package path resolves the module's ROOT package, which is why the command needs no /src/sa suffix.

Reading the output

The headline is parse coverage, not the finding count. A scan reports what it understood and what it refused to guess at:

EXCLUDE _qa_verify TARGET D:\src\...\ML\CI D:\src\...\ML\CI SCANNED 85 files under D:\src\...\ML\CI CLEAN .mbti D:\src\...\CI/pof/pkg.generated.mbti FOUND .mbt defects=0 notices=3 D:\src\...\CI/scm/dag.mbt PARTIAL .mbt defects=2 notices=1 read up to line 36, 57 after it not shown D:\src\...\CI/pof/aggregator.mbt 12:3 - error: ... (UndefinedName) [behavior] PARSED 32/107 files understood (29.9%); 75 more read only up to their first parse error NOTPARSE 0 files, 6769 finding(s) withheld — they measure what this analyzer does not understand, not what your code does 66x unexpected token e.g. D:\src\...\CI/scm/dag.mbt SUMMARY files=107 parsed=32 actionable=57 notices=211 unreliable=15759 withheld=6769 total=16027 (actionable+notices+unreliable = total; withheld ⊆ unreliable)

  • CLEAN / FOUND cover files the frontend read completely. PARTIAL covers files it read up to the first line it genuinely failed on; the findings from the tail are counted in unreliable and never shown, because they come from a tree that is already wrong.
  • Subset exclusions do not move that boundary. Skipping a test block leaves everything after it readable, so such a file is FOUND, not PARTIAL.
  • actionable excludes notices (a declaration form not analysed is not a defect) — otherwise the self-scan once reported "92 problems" where the true count was zero.
  • actionable + notices + unreliable = total, asserted in the line. withheld is reported alongside it but is a subset of unreliable (the post-boundary findings of partial files whose pre-boundary finding count was zero), so it is intentionally not part of the sum.

Read the output, not the exit code. MoonBit exposes no process-exit entry point in the available packages, so a target that could not be located still exits 0 — it prints ERROR<TAB>cannot locate source for target: <target>. A successful run always prints a SUMMARY line.

Excluding directories

moonx riantr/moonbit_static_analysis@latest <target> --exclude <dir> [--exclude <dir>...]

--exclude matches any path segment, and is repeatable. Use it when the tree keeps copies of other people's code under an ordinary directory name: on one such tree those copies were 1020 files and 126,687 findings — 88% of everything the scan reported, none of it the target's own code. Dot-directories (.mooncakes, .repos) and _build are already skipped.

Cross-file type resolution

A .mbt that uses a type from another package (e.g. Command from @argparse) would normally fire undefined name 'Command' because the single-file analyzer only sees the file it is reading. Two flags teach it the missing names:

moonx riantr/moonbit_static_analysis@latest moonbitlang/pdf2md@0.1.1 \ --extern-iface-dir .repos

--extern-iface-dir <dir> is repeatable. Every .mbti file under the named directory is parsed and its declared types + value signatures enter the cross-file symbol table the structural walk and the type lens consult before reporting FUndefinedName. The natural target is .repos/ after you have moon fetched the dependencies you want indexed. The target's own tree is always indexed.

By default the analyzer also reads the target's moon.mod and moon fetches each direct import (so the simple case needs no extra flags). Pass --no-auto-fetch-deps to skip the auto-fetch. Only the real import { … } block is read: a // comment that happens to contain the word "import" does not open one, and a URL is not mistaken for a coordinate. Each import that could not be fetched is named on its own line, because its types are then absent from the table.

Dot-directories under the target are skipped (they hold a dependency's code, not the target's), and the count is printed so a reader knows the table is not complete. Directories you name yourself are read in full, dot entries included — that is what pointing at .repos means.

The table is consulted by all three lenses, which is the point: the structural walk does not report the name, the type lens returns TUnknown for a type elsewhere and TFunc(name, [TUnknown], TUnknown) for a value, and the behavioral lens dispatches a known-but-unread callee as an unknown-returning call instead of calling it undefined. Names absent from the table still report, so the table never invents valid bindings — it only suppresses, never the other way around.

What the numbers mean, and what they do not

.mbt.md and .mbtp are audited properly: on a real project they report 0 findings unless something is actually wrong.

.mbti is NOT. It used to be claimed here alongside the other two, and that was wrong — measured, not suspected. On moonbitlang/core (1029 files, which moon check --target all accepts with 0 errors and 1 warning across 716 tasks) the 80 generated pkg.generated.mbti files produced 93 actionable findings, every one of them false. Three causes, and the first two are now fixed:

  • the lens had its own builtin list, 33 names, while the .mbt walk had a different one of three (print, len, abs) and no type names at all. MoonBit makes moonbitlang/core/builtin available without an import, so debug/pkg.generated.mbti uses Hasher, Iter2 and ArgsLoc bare with no import block in the file at all, and each was reported as an unknown type. All three frontends now share one list (@core.ambient_names, read out of the toolchain's own lib/core/builtin/pkg.generated.mbti). That alone removed 80 of the 93 — Iter 60, Iter2 12, ArgsLoc 4, Hasher, MutArrayView, UninitializedArray, Failure;
  • a const's default value used to be scanned for type references, so pub const MAX_VALUE : Byte = b'\xFF' reported unknown type 'b' and unknown type 'xFF'. Fixed;
  • still open: the lens has no cross-file awareness — it consults neither the cross-file symbol table nor the packages a .mbti imports. So the remaining 13 (Show 5, InspectError 3, SnapshotError 2, Logger, ToJson, BenchError) are types genuinely declared in a sibling file.

So an unknown type ... in interface signature finding on a .mbti should be treated as unverified, the same way a .mbt finding past the PARSED boundary should be — not as a defect to report to a user.

.mbt is different. The program frontend covers a subset of MoonBit — no match, no try bodies, no lambdas, and no full modelling of a string's interpolation: \{expr} is consumed and its names recorded as reads, but the expression itself is not parsed, so \{a + b} makes no claim about the addition. Method calls ARE modelled (EMethodCall). The subset boundary that actually costs the most is match: it parses, but its arms are skipped whole, which is why frame-scoped families are suppressed for any function containing one. On real code the frontend therefore still produces many findings that are all subset edges, not defects.

That is why the scan withholds them. An unparsed struct body turns its fields into undefined names and its * into operator mismatches, so the downstream families on an unread file are noise with a real-looking shape.

The families that could not be silenced that way are the ones whose verdict is about a whole function rather than a line: UnusedLocal, UnusedParam and ParamChanged are decided when the function's frame is audited, so a function the frontend truncated halfway cannot support "this was never read". Those are now skipped outright for the one function a read failure could have cut — the other functions in the same file keep their verdict. On moonbitlang/core that took actionable findings from 270 to 147; on this repository, from 11 to 0 (verified one by one: each was a binding read below the boundary). So: quote PARSED first, and treat actionable as the only count that describes the code. Do not report withheld to a user as "problems found".

The same argument extends past the function frame to the module frame, and that is worth stating as a general rule because it is easy to get backwards: "not found" and "does not exist" are different claims, and a report must distinguish them. Any assertion resting on a lookup has to ask whether the table doing the lookup is complete. On a file the frontend did not finish, the module frame is provably incomplete, so UndefinedName is withheld there — on a whole file it stays exactly as loud. Two name shapes also turned out not to be bindings at all: a Type::member path (@debug.Repr::opaque_(v)), which is a member reference for the same reason a field name and a method name are; and the name of an extern "c" fn, which was being skipped as an unmodelled declaration form, so every call site of an extern function read as unbound. Measured on moonbitlang/core: actionable 270 → 49, undefined names 64 → 0, parsed 305 → 320.

Two more "not found ≠ does not exist" boundaries

A substitution skip is a hole in the frame, even when the file parses fine. A match, a try, or an fn literal is a declared scope limit: it moves no error line, it costs no coverage, and the rest of the file stays readable. But the region it skipped is exactly where a binding's only assignment could be, so a function containing one cannot support "this was never read" either. Those functions now skip the frame audit too — a second boundary, independent of first_error_line, because it guards a different fact. This needs the function's whole extent, header to closing brace, not just its signature: that is why SFunc / SLocalFunc carry a body-covering span. On this repository it took actionable findings from 104 to 8.

A name read through a string interpolation is a read. \{x} is copied raw into the string payload, so before this the literal was opaque to every lens: a name read ONLY that way looked unread, and — worse — a name that does not exist inside an interpolation was never reported at all. TStr now carries the names its interpolations reference and the structural walk resolves them. All 8 findings that survived the previous step were this: mermaid.mbt anchor, pipeline.mbt i/s/r, parser.mbt what ×2, interp.mbt name, cli/main.mbt live. Actionable 8 → 0.

Two shapes that fail toward silence, not noise

Both measured on this analyzer's own source in 0.4.10, and both are the reason a clean scan of a multi-file package is not by itself proof of anything.

An if expression's branches are read and then discarded. let x = if c {… } else { … } is outside the subset; the notice says so. A name that does not exist inside such a branch is therefore never reported. 0.4.8's self-scan was clean partly for exactly this reason: two calls sat in a dropped if-expression, and moving them into a plain function turned the if into a statement — which is parsed in full — exposing two latent false negatives at once.

Package-private helpers in a sibling file: FIXED in 0.4.12. This used to be the single largest source of findings on real code — measured on riantr/pyroduct 0.1.29, 26 of the 31 actionable findings after every other false positive was fixed were one shape, each a private fn in a sibling file of the same package. Cross-file resolution went through .mbti alone, and a package-private fn is in none of them, so a call to one read as no visible binding. The only fix available from the CLI was making such a helper pub.

Since 0.4.12 the analyzer reads the target's own .mbt files instead: one symbol table per directory (a MoonBit directory is a package), carrying the top-level declarations its files make — the private ones included — and only files in that directory see it. The scan prints what it harvested as PRIVTAB packages=N names=M, so a table that found nothing and a harvest that never ran do not look alike.

Two consequences worth knowing:

  • The table is per-directory, so a private name in one package never suppresses a finding in another. If you see an undefined-name finding for a name you can see declared in a different directory, that is correct — cross-package access needs an import.
  • A name harvested this way resolves to "a value of unknown type", the same shape a name from another package gets. It silences an undefined-name finding and infers no type or arity it has not read.

list indices must be int now fires only on a real list (since 0.4.13). A Map[K, V] is indexed by its KEY, so m["k"] on a Map[String, Int] is correct code; before 0.4.13 it was reported as a type error at error: severity, which put findings on files moon check accepts with zero errors (measured: 2 on riantr/pyroduct 0.1.29, src/paths.mbt). The check is gated on the base being a known list, and Array[T] is read as one — the parser keeps the [...] and the type lens models it. A subscript on a base whose type was NOT read stays silent, which is the quiet direction: m["k"] on a Map and on a value of unknown type look identical from here, and neither is evidence of a defect.

So on Array/[T] bases the check is live and matches the compiler; on a Map, or on anything unannotated, expect silence. That asymmetry is intentional.

A _-prefixed binding is exempt from both usage findings (since 0.4.13). The bare _ always was; a leading underscore now is too, matching the toolchain. Probe: _f / _classes / _ok3 and a non-underscore plain control in one file, moon check warns on plain and on nothing else. Before 0.4.13 the analyzer exempted only the exact name _ and reported underscore-prefixed names, on the stated reasoning that "a name is not a discard pile" — an argument, never measured, and the compiler contradicted it (measured: 2 of the 5 findings remaining after 0.4.12, src/algebra.mbt and audit/mbti.mbt). FParamChanged is unaffected: an underscore-prefixed parameter that is assigned is still reported, which is a different question from whether it was read.

self inside an interpolation is a read (since 0.4.13). self is a conventional parameter name, not a keyword. The lexer used to list it as one, so "\{self.designation()}" recorded no read and a parameter read only inside an interpolation was reported as unused (measured: src/slot.mbt 127) while the same read in a plain expression was already silent. If you see no visible binding for a name used only inside \{...}, that one is real and worth reporting.

Numeric literals are read as numbers, not as token kinds (since 0.4.14). This matters most because the alternative is invisible: before 0.4.14 a float literal folded to its truncated integer part (1.5 → 1, 0.25 → 0, 3.14159 → 3), which emits no finding at all — the analyzer was simply wrong about every float it read, silently. Three literal forms were also unmodelled and produced false positives:

  • 0.0F (float32 suffix) lexed as 0.0 plus the name F, giving undefined name 'F' — 28 such findings on riantr/snn_mbt, the largest single bucket found on any corpus so far. 7F is a genuine parse error, so the suffix is only consumed on a literal that already has a fractional part.
  • 1.0e-12 lexed as 1.0 plus the name e, giving undefined name 'e' (and undefined name 'e300' for 1.0e-300, which is the tell that this is a tokenization split rather than a real reference). An e with no digits after it is still a name: 1.0echo is the float 1.0 followed by echo.
  • / was typed as float division. In MoonBit 7 / 2 is 3; the analyzer said float, which produced cannot assign 'float' into '[int]' on correct code (14 findings on riantr/moonbit_doubleML).

If you write an assertion about a numeric literal, read its value or its token count. Asserting only that the kind is float passes just as happily on a truncated one — that is how the value bug survived every previous release.

Known and deliberately unchanged: the constant-evaluation layer still types Int/Int as a float and does not fold it (walk.mbt, interp.mbt). Both were implemented and measured in 0.4.14; reverting either changed nothing across 933 files on four corpora and no test could distinguish them, so they were reverted rather than shipped as an undemonstrable fix. If you find code where that matters, that is the finding that would justify changing it.

A unit enum variant is not an undefined name (since 0.4.15). Layer::ReLU and LayerGrad::Empty were reported undefined name, while the payload variant beside them in the same enum — Layer::Conv2d(p) — resolved. The enum harvest stores the BARE name (ReLU) because it reads tokens, and the bare reference Layer::ReLU carries the whole path. A qualified path now falls back to its last segment, but only after the whole path has failed, so Colour::Purple (never declared) is still reported. Probe: enum Colour { Red Green } ... Colour::Green compiles with 0 errors; the analyzer reported 4 such findings on riantr/snn_mbt.

== / != between two Bools is legal (since 0.4.15). The type lens rejected it as "unsupported operand types for '==': 'bool' and 'bool'". Probe: 0 errors, 0 warnings. Ordering (<, >) between Bools is NOT extended and is still reported — the fix widened equality, not comparison.

A parameter of an extern "C" / extern "wasm" function is not unused (since 0.4.15). Its body lives in another language, so the analyzer never read it and reported "parameter 'x' may not be used" on 6 functions in riantr/snn_mbt. Note that "the body is empty" is NOT a usable proxy — a normal fn p7(z : Float) -> Float { } warns in moon check too, so the parser records extern names explicitly and the walker skips the frame audit for exactly those.

A cross-file const is not a function (since 0.4.16). The .mbti reader already told pub const X : T from pub fn f(...) -> T and discarded the difference. So 3.0F * hz, with hz a const hz : Float from another package, was reported as "unsupported operand types for '*': 'float' and 'fn hz (signature not read)". The Bool in IfaceSymTab.values is now is_const; a constant types as TUnknown and a function still types as TUnknownFn, which is what keeps the arity check off.

A braced suberror body is skipped whole (since 0.4.16). suberror has two shapes — suberror Timeout of String and pub suberror DecodeError { ... } — and both went to the "skip to end of line" path, so the braced body was parsed as an expression block and every payload type in it was reported undefined (String, four times, on riantr/moonbit_image).

A method of a builtin type is not an undefined function (since 0.4.17). Array::make(total, Byte::default()) on riantr/moonbit_labeler was reported as undefined function 'Array::make' on a line moon check accepts. The ambient list carried the builtin TYPES and the top-level builtins, and nothing in between. src/core/builtin_methods.mbt is a generated table of the 656 measured names — 642 read out of the toolchain's own moonbitlang/core/**/pkg.generated.mbti, plus 14 compiler builtins (Array::make, Map::new, StringBuilder::new, …) that no interface declares, each settled with moon check. It is a table and not the rule "a call qualified on a builtin type is a builtin method" because that rule also silences Array::mkae, and Array::mkae is still reported.

An enum variant in a SIBLING file is not an undefined name (since 0.4.17). The 0.4.15 fix resolved a unit variant written as a path, Layer::ReLU. A BARE reference from another file in the same package was still reported: canvas_view_test.mbt:4917 writes [Select, Rect, Polygon, Keypoint,EditBinding] for an enum Mode declared in app_mbt next door. The name harvest collects top-level declarations only, and a variant is brace-declared. Variants are now harvested from the enum body — but a payload's TYPE is not a variant, so Conv2d(Params) does not make Params package-visible.

A nested unknown callee hides the ones around it (known, as of 0.4.17). A call that reports returns ABottom and the enclosing call is not visited, so a chain of unknown callees in one expression yields ONE finding — the innermost. Reading that as "each fix reveals the next one forever" is a mistake: the family ends when the callees are known. But it does mean a single line can carry more than one real defect, and the count you see is a floor.

KNOWN GAP, CORRECTED: an unresolvable qualified call IS reported (as of 0.4.17). Earlier revisions of this file called it a known gap and wrote that qualified names were under-reported as the mirror of what 0.4.14–0.4.16 fixed. That was wrong, and the reason is worth knowing because it produces false conclusions about reachability: the fixture never CALLED the function, so the behavioral lens never visited the body. Reach it and Nope::also_missing() is reported like any other name. The behavioral lens analyzes a body only when a top-level statement reaches it — a large private helper nobody calls will produce far fewer behavior findings than its size suggests.

Six shapes that are real MoonBit and used to end the file (since 0.4.18). Measured on wzzc-dev/moui 0.1.12, a package moon check runs through:

  • &T in a type annotation is Ref[T], not a reference type, and T must be a trait — moon check reports The type Node is not a trait and types the binding @moonbitlang/core/ref.Ref[Node]. It reads as TUnknown, the same answer Map[K,V] gets.
  • ~ is the label sigil and has TWO sides: f~ : T declares a parameter that must be passed BY LABEL, and x~ is the shorthand for x=x. It never changes a parameter's name, so it never changes an arity.
  • fn[T], fn[T : Bound] and extern fn[T] carry a type-parameter list. It is consumed and discarded: the type variable is not a parameter type, so nothing downstream reads it.
  • b"..." is a byte-string literal. It looks like an identifier followed by a string, and the pair ended the file.
  • { ..base, f: v } is the struct update. Its base is pushed under an EMPTY field name, which keeps the reads (all three consumers destructure let (_, v) = f and traverse only the value) without inventing a field.
  • A . at STATEMENT position continues the PREVIOUS line's expression. This is by far the most common of the six — 1124 lines across 131 files.

KNOWN GAP: the shape harvest that feeds the SIBLING table is a second, separate name reader (as of 0.4.18). When a new declaration form is taught to the parser, the name harvest does not follow automatically. fn[Msg] feedback_panel( was parsed correctly but never entered the package-private table, so all four of its call sites in two other files were reported "no visible binding". If a scan reports a sibling function as undefined, check the FORM of its declaration before assuming the scope logic is wrong.

KNOWN GAP, and the cause is not the analyzer: a blocked file poisons its own package's names (as of 0.4.18). The sibling table is filled from files that were read successfully, so a name declared in a file that stopped at a parse error is invisible to every other file in the package. Raising parse coverage removes these findings as a side effect; fixing them one at a time does not.

KNOWN GAP, still open (as of 0.4.18): a leading is expression. is is matched by NAME and the parser requires a left operand, so an expression statement that is just is Some(_) — MoonBit's newline-tolerant matcher on the preceding line — is read as the bare name is. NOT settled as of 0.4.19: a minimal moon check probe could not reproduce a legal form of it, so no fix was written on an unverified reading. Treat the semantics as open.

The SECOND half of that entry is fixed in 0.4.19: parse_is_pattern no longer binds the literal in if x is Some(true). The test is @lexer.is_moonbit_keyword, not a private copy of the list — that table already holds true/false and documents why self is deliberately excluded, so if x is Some(self) still binds.

KNOWN GAP, still open (as of 0.4.18): an untyped self in impl ... with fn layout(self, context). The sentinel logic in skip_decl_braced gives up on the impl body and re-reads fn layout as a top-level declaration, so the method's own parameters are not bound. Typed parameters (self : Pos) do not hit this. As of 0.4.19 this is 4 of the 14 actionable findings in moui and covers 1047 declarations across 204 files — it is now the single largest false-positive class by declaration count.

A function type is a valid annotation (since 0.4.19). (A) -> B in a parameter annotation is 845 sites across 171 files of moui. The elements are read by type_annotation itself, so ((String) -> Msg)? nests with no special case. ann_to_ty returns TUnknown deliberately — the lens models no function types, and unknown is what keeps operations on one quiet.

KNOWN GAP, largest by finding count (as of 0.4.19): a named argument is matched POSITIONALLY. ECall(String, Array[Expr]) carries no label — the parser drops them — so a call that OMITS defaulted parameters and passes the rest by label is compared against the declaration in order. ActionCommand::new(intent=…, label=…, group=…, description=…, shortcut=…) has 7 declared parameters, the first 5 of the call skip enabled, and description (String) is checked against parameter 4 enabled (Bool). That is 5 false positives in moui from one declaration. A correct fix carries the label through the AST and matches by name.

KNOWN GAP (as of 0.4.19): expr as Type is a cast and is not modelled. as is a keyword, falls through, and is reported as an undefined name. 29 sites across 21 files in moui. There is no ECast node; adding one is the fix and it was not attempted.

NOT the analyzer (as of 0.4.19): a stale pkg.generated.mbti. moon test compiles from SOURCE; only the SELF-SCAN reads the git-tracked .mbti interface files, and neither moon update nor moon doc regenerates them. A newly exported symbol can therefore be absent with every test green — it bit 0.4.18 with TTilde. A self-scan reporting undefined name '<a name you just added>' is this, not a code bug.

An impl ... with fn method is not a top-level function (since 0.4.20). Two independent bugs, in two independent readers, and the same root cause twice.

  • In skip_decl_braced, the fn sentinel exists for pub impl Eq for T — no body, methods in a separate block. But impl T for U with fn m(...) { ... } puts a fn INSIDE the declaration, and the sentinel fired, leaving the body behind. with is the discriminator: it cannot appear in the bodiless form.
  • In collect_declared_names, the with fn header is written at depth 0 — the brace comes after the parameter list — so the harvest recorded the METHOD as a package-level value. Any call to that name anywhere in the package then resolved to "defined".

Note the mechanism that made the first test hollow: Parser::skip records a note and consumes nothing. Before the fix the top-level loop re-read the with fn as an ordinary top-level function, so self was bound all along and a test asserting "no undefined self" was green either way. The property that actually changed was the harvest.

A trait's own name is still a package-level type. Skipping the whole holder loses trait Marker's Marker, which is declared here — impl Eq for T and extend declare nothing, since Eq is the trait being implemented.

THE PATTERN, seen twice now (0.4.18 and 0.4.20): there are TWO name readers. The PARSER skips declarations; collect_declared_names is a separate token scanner that fills the sibling/private table. Teaching a new declaration form to one does nothing for the other. Both times, the symptom was a name that was either missing from the package or present when it should not be — and neither showed up in the actionable count, because a fabricated binding is a false NEGATIVE and a missing one is masked by a re-read. When you change how a declaration form is skipped, check whether a gate covers the harvest too.

guard COND else { ... } is a statement, not an identifier (since 0.4.21). guard is NOT in the lexer's keyword list, so it was read as an ordinary IDENTIFIER and reported as an undefined name — 550 sites across 126 files. It is modelled as an SIf with an empty then, which is the honest mapping for a frame model that is not flow-scoped: the condition is walked FIRST, so an is pattern in it binds in the current frame exactly where a guard binding belongs. The else block is analyzed like any other block.

A derive DECLARATION HAS NO BODY (since 0.4.28). derive(Debug, Eq, Clone) is a parenthesised trait list, not a braced declaration. It used to be skipped by the braced skipper, which scans forward for { and so consumed a LATER declaration's braces as its own — meaning that declaration's "outside the analyzed subset" notice was never emitted. Across the six corpora that hid 418 declaration exclusions; the notice count rose from 5,026 to 5,399 when it was fixed. A rising ParseError count here is the fix working, not a regression.

The notice itself is still correct and still emitted: derived implementations are not modelled. Only the claim that there was an unanalyzed body was false, and that is gone.

A PARENTHESISED FUNCTION TYPE IN RETURN POSITION IS NOT A TUPLE (since 0.4.27). There are two spellings and the analyzer now reads both:

-> (Int) -> String the `->` is AFTER the matching `)` -> (() -> Int) the `->` is INSIDE the parens

The second used to fall into the tuple arm, which consumes to the first ) with no comma preceding it — in (() -> Int) that is the paren closing the empty parameter list. The annotation ended at () and -> Int) stayed in the token stream, so the next thing the parser looked for was {, found ->, and reported

expected '{'

and the rest of the file died. The message named a brace that was never the problem.

The test is whether a -> is associated with the parens, which has two answers — inside them, or right after the matching ) — and one nesting-tracking scan covers both. Real code writes this constantly: all 24 sites in the moui corpus are -> (() -> Unit)? at the end of a multi-line parameter list.

A RETURN ANNOTATION IS NEVER OPTIONAL. -> { with no annotation is a real syntax error (unexpected token '{', you may expect return type annotation, plus 4041) and is still reported. If a parse of a signature fails, that is the finding — not evidence that the function has no declared type.

A TAIL EXPRESSION IS THE FUNCTION'S RETURN VALUE (since 0.4.26). MoonBit's tail-value style is not a stylistic extra: fn f() -> Int { 1 } returns 1. Before 0.4.26 a tail expression was an ordinary SExpr, and the behaviour lens evaluates an SExpr and throws the value away (let _ = self.eval(e, env)), so nothing was recorded, join_vals([]) answered None, and that None went into the CALL CACHE. A caller doing arithmetic on the result then got

no method matching +(None, int)

for a program moon check accepts with zero errors. The analyzer's own signature line claimed f() -> None as well.

A tail expression exists only in a FUNCTION body. In a loop body, an if arm or a bare block the same trailing expression is a discarded statement whose value goes nowhere — fn f() -> Int { while c { 1 } } returns nothing. Parser::parse_block takes fn_body for exactly that reason, and produces the STail node only there.

Two facts about the language, both settled rather than assumed: a tail expression REQUIRES a declared return type (fn f() { 1 } is 4074), and a loop body's trailing expression may only be silently discarded if it is Unit (while c { "b" } is 4139).

MEASURED across six corpora and 12,428 files: every scan byte-identical before and after. A correctness fix with a reproducer, not a cleanup — 305 corpus functions have a tail-expression body, and the reason none of them produced a finding is that the lens only analyses a body REACHED from a top-level statement.

An if's branches are JOINED, not chained (since 0.4.25). This is the other half of the guard mapping above, and it is where guard was paying for it. An unknown condition's branches are explored ONE AFTER ANOTHER in the same Interp, so before 0.4.25 the flow state simply carried over between them: a branch that terminated made the NEXT branch's first statement look unreachable, and whatever the LAST branch left behind was what survived past the whole if. With guard's empty then-branch, guard g is Some(v) else { return 0 } followed by return v reported a FALSE unreachable code — the else terminates, the then does not, and the else's flow won the race. It reproduces with no raise anywhere.

Each branch now resets self.flow = FNormal before running and contributes falls = self.flow == FNormal after; a missing else is always falls = true; the join is self.flow = if falls { FNormal } else { FReturn }. exec_alt returns Bool and joins the same way. So every branch terminating is STILL reported as dead code — the fix makes false positives go away without making the check unfireable.

MEASURED across six corpora and 12,428 files: every scan byte-identical before and after, Unreachable 0 → 0. A correctness fix with a reproducer, not a cleanup. An ABottom condition keeps its old meaning (does not terminate, falls through) — whether a bottom condition should suppress everything downstream is a separate open question this does not answer in either direction.

A pattern in is binds EVERY name, at EVERY depth (since 0.4.21). The rules collapse into one predicate, Parser::pattern_binding_at(j), called from both the tuple branch and the single branch — a second reader is how 0.4.18 and 0.4.20 each lost a cycle, so it takes an index and cannot drift:

  • not _, not a keyword
  • the next token is not ( — a nested CONSTRUCTOR, not a binding
  • neither neighbour is . — @core.Role::Checkbox is a qualified path (18 sites)
  • the first : ends it — the rest is a type annotation; :: lands on the same rule

The two branches do NOT collect at the same depth, and that is not an oversight. The tuple branch's outer ( is a GROUPING paren, so its bindings live at depth >=2. Without that, is (Gone | PageUnavailable) — an or-pattern in proton internal/bridge_admission_test/main.mbt:93 — bound both constructors and reported two nonexistent unused locals. One of them was NEW: the old reader stopped after the first name, so it had only ever bound Gone. A finding-level differential over the corpora is what found this; reasoning about it did not.

Corollary for gates: n_parse == 0 is not evidence. A shape that leaves the token stream in step produces a file that PARSES with no error and a tree whose every later name is wrong (0.4.21's newline continuation: 15 findings, none reported as unreliable because nothing failed). Assert on names AFTER the construct. The matching control is that the construct does not swallow the rest of the body — zzz_after must still be reported — because a swallowed statement cannot produce a finding and would leave every count green.

A short lambda's BODY is skipped, never analyzed. at_lambda_head / skip_lambda_head consume the parameters and the =>, then the caller skips the body. So a fixture for a named-argument lambda can assert n_parse == 0 and that the REST of the argument list still parses, but cannot assert anything about names inside the lambda body — they were never walked.

KNOWN GAP (as of 0.4.21): calling a FUNCTION-TYPED PARAMETER reports undefined function. let f = inc then f(1) does it, with no lambda anywhere in the file. It arrives with 0.4.19's function-type annotations and is unrelated to them being an is-pattern issue. Gate fixtures that would trip it must have the callee read a different parameter.

KNOWN GAP (as of 0.4.21): a TYPED pattern, is Some(x : Int), is unverified. Zero sites corpus-wide — all 18 is X(...) matches containing a colon are qualified paths like @core.Role::Checked. The : rule is in place, but nothing in the corpus exercises it, so do not claim it covered without settling one first.

The moon check settle recipe. A package is a directory with a moon.pkg file, and the sources live under src/; without moon.pkg, moon check reports "no work to do" and looks like a pass. Put the probe OUTSIDE any enclosing project — moon walks up and resolves the outer moon.mod, and an inner moon.mod is ignored.

The CI toolchain is UNPINNED and the formatter is not stable across moon releases. Both workflows install latest via install/unix.sh. On 2026-10-12 the v0.4.21 tag went red at moon fmt --check while moon check was green, and the already-shipped v0.4.20 tag was red too — so when a gate step fails, test the previous tag before blaming your change. Pinning is NOT the fix: MOONBIT_INSTALL_VERSION is honoured by the installer, but old Linux binaries have already been pruned from the CDN and Install moon fails outright. The formatter's rewrite is not whitespace either — it changed options("proof-enabled": true,) to proof_enabled = true and Int to Int32. Measure the effect before accepting it: the un-reformatted source still passes check and every test under the new moon, and afterwards all six corpora were byte-identical.

A mutation script's multi-line patterns must normalise line endings. moonfmt flipped every .mbt from LF to CRLF in that release, and the gate script itself stayed LF because it is not MoonBit source. Every multi-line pattern then matched nothing and six of eight mutations reported NO-OP. NO-OP counts as a failure, so the gate still failed loudly — but a gate that silently stops matching is one edit from being decorative. Normalise both sides, write back in the file's existing style, and keep the SHA256 restore assertion: it is what proves the round-trip.

If you are scanning a package whose dependencies you care about, pass --extern-iface-dir .repos. Without it the analyzer cannot see cross-package names at all and says so in its own output: measured on riantr/moonbit_labeler, it goes from 5 findings to 2.

The same discipline applies to actionable itself: a finding is only as good as the oracle it was checked against. Every actionable finding the analyzer reported on moonbitlang/core was a defect in the analyzer, found by running moon check over the same tree and comparing. A large project the toolchain accepts is the only oracle that catches a lens inventing a claim about code it could not read.

Instead of the CLI

For the analyzer's own repository, or when you want structured output, depend on the module and call the API — @pipeline.run, @moonfiles.literate / .iface / .proof, @statecheck.audit. See the adjacent README.md and EXTENSIONS.md.

Naming the artifact: what was read, by what, and when

@sa.provenance_for(target, root) collects four facts and @sa.scan_artifact_name(prov, root) lays them out — <package>_<version>_<moonbit>_<UTC stamp>.mmd, e.g. moonbit_static_analysis_0.4.7_0.1.20260920_20261009T155222Z.mmd.

Read that name as provenance, not decoration: it is what distinguishes two scans of the same tree. package is the coordinate, or the resolved directory for a path. version comes from the coordinate's @version or the target's moon.mod. moonbit is the toolchain that did the reading, from moon version — it cannot be recovered from the tree, and it is the field that decides whether two results are comparable, because this analyzer's .mbt subset moved from 158 to 173 files on an unchanged checkout when method calls were modelled. unknown in any slot means it could not be determined — not that the value was 0, and not that the segment may be dropped, because with four fields a dropped segment SHIFTS every later one. The same facts are repeated as %% header comments inside the document, because files get renamed.

Rendering the result as a state diagram

@sa.mermaid_states(verdicts, summary, prov, per_file, max_files) emits a mermaid stateDiagram-v2 built for an agent to read: each file is a box whose spine is the trust chain (lexed → structural → type → behavioral), each finding is its own state hung off the inspection that reported it, and each one carries a WHY: / HOW: note from @core.Family::advice saying why it was judged wrong and what to change.

To produce one from the CLI instead of writing a program, pass --mmd <path> or --mmd-auto. Both also write the file and print MMD<TAB><path>; the text report on stdout is unchanged and stays authoritative. Treat the drawing as a supplement, never a replacement: it is lossy by construction, ranking files by actionable count and stopping at its own cap, and it says so in a %% THIS DRAWING IS A SUBSET comment. All four provenance fields are real on this path; only the toolchain lookup can degrade, to unknown, if the moonversion subprocess fails.

Two caveats travel inside those notes and must not be skipped: on .mbti / .mbtp / .mbt.md a ParseError is usually a frontend rule rather than a parse boundary, and UndefinedName cannot see a #doc(hidden) API (it appears in no .mbti) or a @pkg.fn(1, 2) top-level reference (not folded into a callable name).

per_file and max_files cap the drawing (0 = no cap) and whatever is dropped is stated in the diagram itself, so a truncated diagram never reads as a clean one. summary always describes the whole scan while the drawing may be a subset; files are ranked by ACTIONABLE count, because the largest raw-finding file on moonbitlang/core is a 402-finding test file that is all notices and zero defects.

Powered by MoonBit

Site sourceReport issueModulesSkillsBuild queueStatistics

© 2026 mooncakes.io