Three-inspection (structural / type / behavior) static analysis for MoonBit programs, every toolchain file kind (.mbt / .mbtx / .mbti / .mbt.md / .mbtp) and state-machine tables
moon add riantr/moonbit_static_analysis@0.4.38import {
"riantr/moonbit_static_analysis@0.4.38",
}moonx riantr/moonbit_static_analysis@latest riantr/moonbit_doubleML@latest// Use 1: revise a MoonBit (current-subset) program
let result : @pipeline.PipelineResult =
@pipeline.run(source, "main.mbt", @core.IfaceSymTab::empty())
println(@pipeline.render_result(result))
// Use 2: audit a state machine (machine tables come in as plain data)
let spec : @statecheck.MachineSpec = { name: "主体", states: [...], ... }
println(@statecheck.render(spec))moon run src/cli # 6 samples × 3 inspections, the file-kind demos, a sample machine| Inspection | Package | Responsibility | Output |
|---|---|---|---|
| Structural | src/walk | assignment-as-binding, unused/undefined/param-reassigned, constant-folding pruning | binding tables fn_bindings |
| Type | src/types | annotations-as-contracts, inference, assignment/argument/condition checks (declarations come from the binding tables) | signature tables sigs |
| Behavior | src/interp | abstract interpretation over a lattice, signature-dispatched methods, virtual stacks (the method table comes from the signature tables) | behavior findings |
| Merge | src/pipeline | one defect's echoes across inspections → one report, union of lenses | render_result |
=== undefined.mbt ===
2:10 - error: undefined variable 'missing' (UndefinedName) [structural+type+behavior]
in main() at undefined.mbt:4
in g at undefined.mbt:1
Summary: 1 finding(s) (before merge: structural 1, type 2, behavior 1)| Inspection | Machine-side semantics | What it checks |
|---|---|---|
| Structural | states are bindings ("assignment-as-binding" lifted to machine tables) | every state must be bound by at least one transition (in or out); out-only → never entered; reachability closure from the initial position; the terminal must be reachable — the machine must be able to finish |
| Type | the drive-slot contract ("annotations-as-contracts") | every trigger must be placed into a known slot, no empty slots; a Block must carry a reason — a road-less step must say so out loud, never silently |
| Behavior | the course as abstract execution (virtual stacks) | every course step must be carried by some transition; a stranded step carries a virtual stack from entry frame → error frame |
let spec : @statecheck.MachineSpec = { name: "主体", states: [...], ... }
let findings : Array[@report.Report] = @statecheck.audit(spec)
let text : String = @statecheck.render(spec) // merged text report规格 | 计数
---|---
状态 | 034
迁移 | 053
触发→槽 | 049 → 8
无路组合(带理由) | 228
修习历程 | 031 站
未预期发现:**0 条**The pyroduct-shaped sample machine printed at the end of moon run src/cli is this module's own 8-state toy (statecheck.pyroduct_flavored()), not pyroduct's real tables. It carries coordinates like 182:12; those are stable pseudo-spans hashed from the state name, not positions in any source file.
| function | signature |
|---|---|
| run | (src : String, file : String, extern_iface : @core.IfaceSymTab) -> PipelineResult |
| run_functions | (src : String, file : String, extern_iface : @core.IfaceSymTab) -> PipelineResult |
| render_result | (r : PipelineResult) -> String |
| merge_findings | (all : Array[@report.Report]) -> Array[@report.Report] |
| merge_group | (g : Array[@report.Report]) -> @report.Report |
| function | signature |
|---|---|
| audit | (spec : MachineSpec) -> Array[@report.Report] |
| render | (spec : MachineSpec) -> String |
| pyroduct_flavored | () -> MachineSpec — the 8-state sample machine |
| function | signature |
|---|---|
| mermaid_states | (verdicts : Array[Verdict], summary : Summary, prov : ScanProvenance, per_file : Int32, max_files : Int32) -> String |
| provenance_for | async (target : String, root : String) -> ScanProvenance |
| scan_artifact_name | (p : ScanProvenance, root : String) -> String |
| aggregate | (verdicts : Array[Verdict]) -> Summary |
| stamp_utc | (ms : Int64) -> String |
| moon_mod_version | (src : String) -> String |
moonbit_static_analysis_0.4.38_0.1.20261023_20261024T060000Z.mmd
moonbitlang-core_0.1.20260920+7d59c7ec9_0.1.20260920_20261009T051925Z.mmd
tree_unknown_0.1.20260920_20261009T051511Z.mmd # version not determined| field | question | source |
|---|---|---|
| package | what was read | the coordinate, or the resolved directory |
| version | which revision of it | the target's moon.mod (moon.mod.json too) |
| moonbit | which toolchain read it | moon version, run as a subprocess |
| stamp | when | @async.now(), UTC |
| function | signature |
|---|---|
| Report::render | (self : Report) -> String |
| render_reports | (reports : Array[Report]) -> String |
moonx riantr/moonbit_static_analysis@latest <target> [flags]| flag | meaning |
|---|---|
| <target> | registry coordinate (author/module[@version]) or a path. Omit it to scan the current directory. |
| --exclude <dir> | a directory name to skip. Repeatable. It exists because a dot-directory rule cannot catch everything: a project may keep vendored registry snapshots under an ordinary name. |
| --extern-iface-dir <dir> | a directory to scan for .mbti files whose declared types/values are treated as valid in every .mbt file under analysis. Repeatable. The natural target is .repos/ after moon fetch of the dependencies you want indexed. The target's own tree is always indexed. |
| --no-auto-fetch-deps | by default the analyzer reads the target's moon.mod and moon fetches each direct import. Pass this to skip the auto-fetch. |
| --mmd <path> | also write the scan as a mermaid stateDiagram-v2 to <path>, and print MMD<TAB><path> on the last line. Opt-in on purpose: stdout stays the authoritative text report. |
| --mmd-auto | the same, but pick the name and write it to the working directory — never inside the analysed tree. |
| --per-function | enter the behavioral lens at each function rather than at the module. |
| --findings-shown-per-file N | how many findings are printed per file (default 40). A printing limit, not an analysis limit: the SUMMARY counts come from every finding, so raising it reveals findings that were already counted and changes no summary number. |
| --show-notices | print every notice per line, not only the ones that can hide a finding. By default a notice is printed only when its region lies inside a function's extent -- that is, when a conclusion may have been withheld because of it -- and every other notice is counted by shape in one NOTICES table. This flag restores the whole per-line list, which is what the tool printed before 0.4.36, and it changes no count: the SUMMARY's numbers are the same either way. |
| --families MSA-007,MSA-002 | report only these families, by their stable id. The ids are the MSA-NNN codes every rendered finding and every JSON record carries; the full roster is in Confidence, ids, profiles and nosec. An id this version does not know is a WARNING and the scan still runs -- a family added in a later release must not make an older analyzer's profile unusable, and a typo must be loud rather than fatal. |
| --exclude-families MSA-007 | report everything except these families. The same id vocabulary and the same rules. An id that is both included and excluded is an ERROR and the scan does not run at all: the two rules contradict each other, and which one won would be decided by the order of two ifs inside the scanner. |
| --honor-nosec | honour // nosec comments in the analysed source: // nosec silences every finding on that line, and // nosec MSA-007 silences that one family on that line. Off by default, and the default is a decision rather than an omission: a tool that stops reporting a finding because of a comment in the analysed source has an output the reader cannot trust without reading that source first. A // nosec that suppresses nothing is reported as a finding of its own -- a stale suppression is a fact about the source, and the source is what this tool analyses. |
The CLI cannot exit non-zero. MoonBit exposes no process-exit entry point in the packages available here, so a target that cannot be located prints ERROR and still exits 0 — a caller must read the output, not the exit code.
SCANNED 107 files under ...\ML\CI
CLEAN .mbti unchecked=0 ...\CI/pof/pkg.generated.mbti
PARTIAL .mbt defects=1 notices=2 unchecked=17 read up to line 36, 57 after it not shown ...\CI/pof/aggregator.mbt
12:3 - notice: ... (UndefinedName) [behavior, MSA-002, low]
PARSED 32/107 files understood (29.9%); 75 more read only up to their first parse error
SUMMARY files=107 parsed=32 actionable=56 notices=212 unreliable=15759 withheld=6769 unchecked=2841 total=16027 (actionable+notices+unreliable = total; withheld ⊆ unreliable; unchecked calls are not findings)NOTICES TOTAL notices=10114 printed=1162 aggregated=8952 1162 of them are printed per line: the ones that can hide a finding, and the low-confidence ones, which name a place. The other 8952 are counted by shape below
NOTICES count=6148 test declaration is outside the analyzed subset; its body was not analyzed12:3 - notice: no visible binding for 'cmd' (UndefinedName) [structural, MSA-002, low]| confidence | the lens… | the family | rendered |
|---|---|---|---|
| high | had the declaration, the signature or the type in front of it | everything else | its own severity |
| low | was reasoning from a name alone | UndefinedName (MSA-002) — the binding it could not find may be in a file, or in a package, it was never given | notice: |
| undecided | could not decide at all | a declared scope limit — the analyzer's own blind spot | notice: |
| id | family | id | family |
|---|---|---|---|
| MSA-001 | ParseError | MSA-007 | ArgTypeMismatch |
| MSA-002 | UndefinedName | MSA-008 | OperatorMismatch |
| MSA-003 | UnusedLocal | MSA-009 | MethodError |
| MSA-004 | UnusedParam | MSA-010 | NonBooleanCond |
| MSA-005 | ParamChanged | MSA-011 | Unreachable |
| MSA-006 | AssignTypeMismatch | MSA-012 | StaleNosec |
let x = cmd() // nosec -- silences every finding on this line
let y = cmd() // nosec MSA-002 -- silences that one family on this line| notice | n | notice | n | ||
|---|---|---|---|---|---|
| test declaration | 1427 | anonymous function literal | 18 | ||
| if expression | 104 | const declaration | 6 | ||
| struct declaration | 62 | short lambda | 2 | ||
| derive declaration | 54 | try expression | 1 | ||
| enum declaration | 25 |
| what it is | rendered | counted as |
|---|---|---|
| a declared scope limit | notice: | a notice — excluded from actionable |
| a low-confidence finding (since 0.4.38) | notice: | a notice — excluded from actionable |
| a genuine defect in the file | error: | actionable |
This view is not yet sound, and the flag is opt-in for that reason. It is measured across all seven pinned corpora by _verify/census_perfunction.ps1, and the release gate pins the number. Today it reports 29 actionable findings, where the module view reports 10. 28 of the 29 are constructs this frontend does not read or operators it does not model; the twenty-ninth is a genuine finding — a local assigned and never read in moonbit_labeler, and the first true defect this tool has published on any corpus.
family n what the lens claimed C 6 == between two arrays D 3 Int + Char and List + List E 3 * on a struct G 11 unreachable code — the lens's own claim about the code H 1 a local assigned and never read — the lens's own claim about the code, and the one finding in the census that is a real defect I 1 abs on a Double J 1 * on two function values K 3 a local call with a named argument AND 21 MORE ARE DEMOTED. Since 0.4.38 a finding the analyzer does not stand behind is rendered notice: and counted as a notice rather than as actionable, and the census keeps the demoted set in its own table and its own total — a set of findings that leaves the census is a set that can go missing silently. Every one of the 21 carries the confidence low, and every one of them is a name the analyzer could not resolve.THE MODULE VIEW COUNTS 18 OF THEM, AND IT IS NOT THE SAME 18. The two views are two analyses — this one takes every function as an entry point, the module view takes the module — so they do not find the same names: in moonbit_core this view reports 19 and the module view 18, and only 15 of those are the same site. That is the same difference as the 29 against the 10 above, and the two views stay separate, not additive.
demoted family n what the lens claimed A 12 a hex float literal (0x1.0p1023) is not read, so its exponent part is reported as an undefined name B 5 a BigInt literal's N suffix (0N) is not read F 2 a name the analyzer cannot see — a sibling-file helper, a type parameter's method, an internal API absent from the .mbti index, a module qualifier L 2 the documented #doc(hidden) limit "Accepted" is measured, not assumed. moon check --target all on moonbitlang/core reports exactly one error, in json/README.mbt.md, and it is not one of the sixteen files that carry a finding. That is only half of it: a file with no diagnostic is indistinguishable from a file nobody opened, so _verify/control_perfunction_census.ps1 appends a deliberate type error to each of those files in a copy and requires the error back — 16 of 16.The two in family L are the documented limit rather than a defect: a cross-package #doc(hidden) function cannot be indexed from the dependency's source — only from its .mbti — so @locale.LocalePreferences::resolve is still reported as an undefined function in proton 0.3.4. Answering them would mean growing the analyzer into a source indexer, which is a different project. Use the view to see the lens, not to gate a build on it.(This note said 116 → 2 until 0.4.32, and the "2" was the census of six corpora — moonbit_core was not in it, and it carries more findings than all six put together. The number was true of the corpora that were measured and false of the ones that were not. The history is in CHANGELOG.md.)
| suffix | static analysis here |
|---|---|
| .mbt | three-inspection program analysis |
| .mbtx | three-inspection program analysis + import-block audit |
| .mbti | interface audit: malformed lines / duplicate signatures / unknown type references. The line grammar matches what moon info actually emits, so a generated file raises no malformed line. The unknown-type check consults the module-wide interface table and measures 0 on moonbitlang/core. A residual finding can still name a type declared outside the scan — treat it as unverified, not as a defect. |
| .mbt.md | literate: only fences the toolchain actually compiles — mbt check / mbt test and the moonbit spellings. A bare mbt, a bare moonbit and nocheck are display blocks and are skipped. |
| .mbtp | logic-side proof lint (string constants in bodies, banned !/↔ forms, cross-package calls, lemma without proof_ensure) — not a replacement for moon prove |
| moon.mod / moon.pkg / workspace | recorded only, no static analysis — configuration is not code |
src/core Span/Severity/Lens/Family(merge_group contract)/Frame + Family::advice
src/report Report struct and rendering (union lens tags, vst frames)
src/sa scan entry + aggregate() + mermaid_states() (public, library)
src/lexer MoonBit lexer frontend
src/parser MoonBit syntax → ast
src/ast MoonBit abstract syntax
src/walk structural inspection: binding tables + structural findings
src/types type inspection: Ty/Ty?/Sig inference
src/interp behavior inspection: AbsVal lattice + dispatch + virtual stacks
src/pipeline assembly: run the three inspections + merge_group merge
src/moonfiles the other file kinds: .mbt.md per-block three-inspection / .mbti
interface audit / .mbtp proof lint
src/statecheck use 2: generic machine-table audit (plain-data MachineSpec bridge)
src/samples demo samples (embedded MoonBit source)
src/cli executable entry (program demo + file-kind demo + sample machine audit)
src/jsoncli the DeepSeek Harness JSON bridgemoon check --target all --deny-warn # 0 errors, 0 warnings (js / native / wasm / wasm-gc)
moon test --deny-warn # 1330/1330 on wasm; 1204/1204 on js and wasm-gc
moon run src/cli # program demo + file-kind demo + sample machine auditpowershell -NoProfile -ExecutionPolicy Bypass -File _verify\release.ps1The command does not run green today, and the reason is measured. moon prove src/core --why3-config .why3.conf fails with unbound type symbol 'map'. src/core grew IfaceSymTab on 2026-10-08 — a struct whose fields are Map[String, Bool] — and the toolchain's proof prelude has no Map theory, so the generated Why3 file declares map int bool and neither moon prove nor why3 can load it. The 19 lemmas never touch IfaceSymTab: it is the package that is proved, and the package carries the type. The fix is to move IfaceSymTab out of the proof subject, and that changes this package's public API (@core.IfaceSymTab is exported), so it is a version-line decision rather than a patch.Two #proof_pure annotations were also false: on path_tail and unqualified, whose bodies build a StringBuilder, which the prover rejects (error 4207). One false annotation stops the whole file from proving, and both of those arrived on 2026-10-09. They are removed, and _verify/check_prove_contracts.ps1 now fails the release if any #proof_pure body is one the prover rejects.
powershell -NoProfile -ExecutionPolicy Bypass -File _verify\release.ps1Install
Download zipThree-inspection (structural / type / behavior) static analysis for MoonBit programs, every toolchain file kind (.mbt / .mbtx / .mbti / .mbt.md / .mbtp) and state-machine tables