← 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.7 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.

A package-private helper in another file is invisible. Cross-file resolution goes through .mbti files, and a package-private fn is in none of them, so one file calling a sibling file's private helper reads as no visible binding. That is a real binding the tool cannot see; making such a helper pub puts it in the interface and is the only fix available from the CLI.

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