Sign in

    moonbit_static_analysis

    Three-inspection (structural / type / behavior) static analysis for MoonBit programs, every toolchain file kind (.mbt / .mbtx / .mbti / .mbt.md / .mbtp) and state-machine tables

    static-analysis
    linter
    state-machine
    abstract-interpretation
    file-kinds
    mbti
    literate-markdown
    formal-verification
    Download zip
    Author
    Version
    0.4.33
    License
    MIT
    Last updated
    yesterday
    Downloads
    966

    #moonbit_static_analysis

    English | 中文

    riantr/moonbit_static_analysis — a three-inspection (structural / type / behavior) static-analysis pipeline for MoonBit. One infrastructure, two uses:

    1. Program-code revision — analyze programs in the fast-evolving MoonBit language (undefined names, unused bindings, type mismatches, dead branches, unreachable code);
    2. Static state revision — a generic machine-table audit for multi-layer state machines (src/statecheck), where the audited subject calls this module and feeds its machine tables in as plain data.

    It reads every MoonBit toolchain file kind — .mbt / .mbtx / .mbti / .mbt.md / .mbtp — and it never modifies the target: the analysed repository neither depends on this module nor is touched by it.

    Measured on moonbitlang/core (1029 files): 957 parsed, 33 actionable — and every one of the 33 is a construct this frontend does not read or an operator it does not model, not a defect in the analysed code. On that same tree moon check --target all reports exactly one error, in a .mbt.md doc file.

    #Contents

    #Features

    • ✅ Three inspections, one report — structural walk → types/symbols → abstract interpretation, merged so one defect is one finding whose lens tag is the union of the lenses that saw it. The merge is lossless: the counts before and after are both kept.
    • ✅ Every MoonBit file kind — .mbt / .mbtx / .mbti / .mbt.md / .mbtp, including the three the compiler does not audit at all.
    • ✅ Reads, never writes — the analysed repository neither depends on this module nor is modified by it. The tool only ever reads the target's files, at scan time.
    • ✅ Cross-file symbol resolution — --extern-iface-dir indexes .mbti declarations, so a type used from another package is not reported as undefined.
    • ✅ Honest coverage — a file the frontend could not finish is reported PARTIAL, and its post-boundary findings are counted and not shown.
    • ✅ State-machine audit — the same three inspections over plain-data machine tables: states as bindings, drive slots as a type contract, the course as abstract execution.
    • ✅ Mermaid diagram output — --mmd / --mmd-auto, opt-in, and a truncated diagram says so in its own header.
    • ✅ Provenance in the filename — package, version, toolchain and timestamp, so a scan can be compared with a later one.
    • ⚠️ --per-function — a second entry point for the behavioral lens. Read Coverage and limits before using its numbers.

    #Installation

    moon add riantr/moonbit_static_analysis@0.4.33

    Then import it in your moon.mod:

    import {
    "riantr/moonbit_static_analysis@0.4.33",
    }

    #Quick start

    #Scan another project — no dependency, no changes

    moonx riantr/moonbit_static_analysis@latest riantr/moonbit_doubleML@latest

    moonx downloads the prebuilt wasm that mooncakes built for the published version, caches it, and runs it with moonrun — no clone, no local build.

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

    #Use it as a library

    // 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))

    #Run the demo

    moon run src/cli # 6 samples × 3 inspections, the file-kind demos, a sample machine

    #Use 1: program-code revision

    The three inspections are assembled so that each later one consumes the previous one's tables:

    InspectionPackageResponsibilityOutput
    Structuralsrc/walkassignment-as-binding, unused/undefined/param-reassigned, constant-folding pruningbinding tables fn_bindings
    Typesrc/typesannotations-as-contracts, inference, assignment/argument/condition checks (declarations come from the binding tables)signature tables sigs
    Behaviorsrc/interpabstract interpretation over a lattice, signature-dispatched methods, virtual stacks (the method table comes from the signature tables)behavior findings
    Mergesrc/pipelineone defect's echoes across inspections → one report, union of lensesrender_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)

    One defect seen by all three inspections → one report whose tag is the union. Dead branches are pruned twice (structural const_eval + the behavioral ABool lattice value), and a call to a nonexistent typo_fn produces zero findings.

    #Use 2: static state revision

    src/statecheck accepts a plain-data specification of any state machine — a MachineSpec (states / initial / terminal / transitions / trigger-slot placements / Block reasons / course). The three inspections read machine tables exactly like they read programs:

    InspectionMachine-side semanticsWhat it checks
    Structuralstates 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
    Typethe 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
    Behaviorthe 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

    pyroduct is the audited subject, not a dependency: this module does not depend on pyroduct; the direction is pyroduct (its audit package) building a MachineSpec from its real tables and calling this module. One-way: machine → analyzer.

    pyroduct's real self-audit (moon run cmd/main -- audit) prints:

    规格 | 计数 ---|--- 状态 | 034 迁移 | 053 触发→槽 | 049 → 8 无路组合(带理由) | 228 修习历程 | 031 站 未预期发现:**0 条**

    The "0 unexpected findings" line is a tripwire: any new structural / type / behavior finding on the real tables fails pyroduct's tests and forces a re-review. The same run also lists the four known-design findings it always reports (two states that are only ever left, never entered) — they are omitted above because they are the model, not a defect, and because the line that matters is the last one.

    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.

    #API reference

    #Program analysis — src/pipeline

    functionsignature
    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

    run enters the behavioral lens at the module; run_functions enters it at each function (see Coverage and limits). The two views are separate, not additive.

    #Machine tables — src/statecheck

    functionsignature
    audit(spec : MachineSpec) -> Array[@report.Report]
    render(spec : MachineSpec) -> String
    pyroduct_flavored() -> MachineSpec — the 8-state sample machine

    #Diagrams and provenance — src/sa

    functionsignature
    mermaid_states(verdicts : Array[Verdict], summary : Summary, prov : ScanProvenance, per_file : Int32, max_files : Int32) -> String
    provenance_forasync (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

    mermaid_states returns valid mermaid stateDiagram-v2. Two constraints it is written around, both measured rather than assumed: a transition label is everything after the first :, so it may not contain one — locations and messages therefore live in the quoted state label; and MoonBit has no operator continuation, so a source line beginning with + is a parse error, which is why the document is emitted one write_string at a time.

    per_file and max_files cap the drawing, 0 meaning no cap. Whatever a cap leaves out is stated in the diagram, in the header and in a note on the file, so a truncated diagram cannot be read as a clean one. Files are ranked by descending actionable count, not raw findings: on moonbitlang/core the largest raw-finding file is builtin/int64_test.mbt at 1022 findings — every one of them a notice, and none of them a defect — and ranking by raw count put it first in a diagram whose job is to lead with what to fix.

    #Provenance — the artifact name

    A scan result is a claim about a specific tree, read by a specific toolchain, at a specific moment. All three go in the filename — <package>_<version>_<moonbit>_<stamp>.mmd:

    moonbit_static_analysis_0.4.33_0.1.20260920_20261009T155222Z.mmd moonbitlang-core_0.1.20260920+7d59c7ec9_0.1.20260920_20261009T051925Z.mmd tree_unknown_0.1.20260920_20261009T051511Z.mmd # version not determined

    fieldquestionsource
    packagewhat was readthe coordinate, or the resolved directory
    versionwhich revision of itthe target's moon.mod (moon.mod.json too)
    moonbitwhich toolchain read itmoon version, run as a subprocess
    stampwhen@async.now(), UTC

    moonbit is the field that cannot be recovered from the tree, and it is the one that decides whether a scan can be compared with a later one. If the moon version subprocess fails, that field reads unknown rather than being wrong.

    Three things this deliberately does not do: it does not omit what it could not determine (a missing version, toolchain or clock is the literal token unknown — with four fields, dropping the segment would shift the others); it does not produce a dotfile (a name starting with . is invisible to ls, and an artifact nobody can list is not one anybody will find again); and it does not trust the filename as the record — the same three facts are repeated as %% header comments inside the document, because a file gets renamed and once it does its name is no longer evidence of anything.

    #Reports — src/report

    functionsignature
    Report::render(self : Report) -> String
    render_reports(reports : Array[Report]) -> String

    Each finding carries a note with WHY: (the rule that fired and the evidence it fired on) and HOW: (what to change), taken from @core.Family::advice.

    #CLI reference

    moonx riantr/moonbit_static_analysis@latest <target> [flags]

    flagmeaning
    <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-depsby 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-autothe same, but pick the name and write it to the working directory — never inside the analysed tree.
    --per-functionenter the behavioral lens at each function rather than at the module.
    --findings-shown-per-file Nhow 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.

    --mmd requires its path and --mmd-auto is a separate flag, on purpose: an optional value is ambiguous, because in <target> --mmd <path> the bare word after --mmd could equally be a second, unexpected target, and there is no way to tell the two apart.

    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.

    #Understanding the output

    SCANNED 107 files under ...\ML\CI CLEAN .mbti ...\CI/pof/pkg.generated.mbti PARTIAL .mbt defects=2 notices=1 read up to line 36, 57 after it not shown ...\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 SUMMARY files=107 parsed=32 actionable=57 notices=211 unreliable=15759 withheld=6769 total=16027 (actionable+notices+unreliable = total; withheld ⊆ unreliable)

    • PARSED is the headline. The frontend covers a SUBSET of MoonBit, so what it cannot read measures the tool's blind spot, not your code.
    • A partly-understood file is still worth reading. The parser records the first line it genuinely failed on; findings before it are reported (PARTIAL), and everything after is counted in unreliable and never shown — it comes from a tree that is already wrong, so presenting it as results would reintroduce the exact failure this tool started with.
    • Skipped declarations do not move that boundary. "This test block is not analysed" leaves the code after it readable, so such a file is FOUND.
    • actionable excludes notices. A declaration form we chose not to analyse is not a defect in your code.
    • Unparsed files are grouped BY REASON rather than listed one per line.
    • actionable + notices + unreliable = total, asserted in the line. withheld is reported alongside it but is a subset of unreliable, so it is intentionally not part of the sum.

    Cross-file type symbol resolution — a .mbt that uses a type declared in another package (e.g. Command from @argparse) would otherwise fire undefined name'Command', because a single-file analyzer only sees the file it is reading. --extern-iface-dir <dir> (repeatable) walks the named directory for .mbti files and loads their declared types and value signatures into a cross-file symbol table the structural walk and the type lens consult before reporting FUndefinedName.

    The table only suppresses, it never invents valid bindings: names absent from the table still report. A type hit returns TUnknown; a value hit returns TUnknownFn — a known value whose signature was not read — so a call like Command(...) neither fires FOpMismatch nor has its arity checked against an invented one. Lookups strip a leading @pkg. first: a .mbti writes the declaration bare (pub fn T::m) while a use site must write @pkg.T::m, so with exact matching the two spellings never met and the only common way of naming another package never hit.

    #Coverage and limits

    #What this tool does not compete with

    On .mbt, this tool does not compete with the toolchain's compiler: moon check has a real parser and reads the whole language. On the same moonbitlang/core it reports one error and 251 warnings of two kinds; this tool's .mbt frontend reads a subset and parses 957 of 1029 files (93.0%); .mbti and .mbt.md are 100%.

    What this tool can do that the compiler cannot: the three file kinds the compiler does not audit at all — .mbti, .mbt.md, .mbtp — plus cross-package semantics.

    #The constructs the frontend reads past

    Every declaration or expression form it reads past without analysing is reported as a notice, and the notice is a statement about the tool, not about your code. Measured on this project's own source (170 files):

    noticennoticen
    test declaration1175anonymous function literal10
    match expression299const declaration6
    if expression67short lambda2
    struct declaration58try expression1
    derive declaration52
    enum declaration24

    Removing one of those rows needs an AST node for that form, which is why they are features and not notices to be reworded. The distinction that matters: an expression-level exclusion (379 of them) sits inside a function body and can switch that body's audit off; a declaration-level exclusion (1315) sits outside every function extent and cannot.

    #The two blind spots in UndefinedName

    Both are known and both say so in the finding's own note:

    • a #doc(hidden) API appears in no .mbti, so it cannot be indexed from the dependency's source;
    • a top-level cross-package reference written @pkg.fn(1, 2) is not folded into a callable name by the parser, so it is reported even when the package is indexed.

    #--per-function: the behavioral lens at each function

    --per-function analyzes every function as if it were the program's entry point, with its parameters bound to fresh abstract values of their declared types. The declared type is the only sound choice for an entry point: the parameter's runtime value is unknown, so the lens must assume the declared type and nothing more.

    The two views are separate, not additive, so they can be compared without either hiding the other. The default (module) view is unchanged: the same corpora produce byte-identical numbers with and without the flag.

    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 58 actionable findings, where the module view reports 33. Not one of the 58 is a defect in the analysed code.

    familynwhat the lens claimed
    A12a hex float literal (0x1.0p1023) is not read, so its exponent part is reported as an undefined name
    B5a BigInt literal's N suffix (0N) is not read
    C6== between two arrays
    D3Int + Char and List + List
    E3* on a struct
    F9a 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
    G11unreachable code — the lens's own claim about the code
    H2a local assigned and never read — the lens's own claim about the code
    I1abs on a Double
    J1* on two function values
    K3a local call with a named argument
    L2the 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 twenty-four 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 — 24 of 24.

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

    #File kinds

    suffixstatic analysis here
    .mbtthree-inspection program analysis
    .mbtxthree-inspection program analysis + import-block audit
    .mbtiinterface 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.mdliterate: 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.
    .mbtplogic-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 / workspacerecorded only, no static analysis — configuration is not code

    The full taxonomy is in EXTENSIONS.md.

    #Project structure

    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 bridge

    The root package is a thin forwarder; the implementation is src/sa, a library. Both src/sa and the root declare supported_targets = "+wasm+native", because file IO and subprocesses come from moonbitlang/async and only some backends have a working async runtime; the other backends SKIP those two packages instead of failing.

    #Verification

    moon check --target all --deny-warn # 0 errors, 0 warnings (js / native / wasm / wasm-gc) moon test --deny-warn # 1163/1163 on wasm; 1083/1083 on js and wasm-gc moon run src/cli # program demo + file-kind demo + sample machine audit

    --deny-warn is deliberate: without it nothing enforces the "0 warnings" claim — a warning regression passes CI silently. CI (.github/workflows/gate.yml) runs moon update, moon check --deny-warn, moon fmt --check, moon test --target js --deny-warn and moon build --target wasm.

    The per-target test counts differ on purpose: src/sa and src/jsoncli declare supported_targets = "+wasm+native", so the js and wasm-gc runs SKIP those two packages rather than failing them. A test count that claims one number for every target is therefore wrong, and the wasm number is the only one that covers the renderer and the scanner.

    #The one release entry point

    moon check and moon test are the floor, and this project does not rely on the floor alone. Everything above it is in ONE script:

    powershell -NoProfile -ExecutionPolicy Bypass -File _verify\release.ps1

    _verify/release.ps1 runs: the harness's own self-test; moon fmt --check; moon check --deny-warn; all three test targets; moon build --target wasm; the grammar probe's own verdicts; the own-source self-scan; this README's own links, anchors, versions and counts; the mutation-gate roster and a self-test that shows it can go red; the published-package self-scan's own self-test; the corpus comparison against the pinned baselines; the per-function census and its control; and then every mutation gate — 79 of them.

    The roster is checked, not asserted: a gate that exists on disk but is missing from the list would never run, and a gate that does not run is indistinguishable from a gate that passes. _verify/check_gate_roster.ps1 compares the two rosters in both directions, and its -SelfTest injects six defects into a copy of _verify — a gate the list does not name, a gate in the list that is not on disk, a gate off the shared harness, a guarantee deleted from the harness, a --filter in the harness, a stale count in the harness header — and requires each to be caught. All six are.

    Two switches change what it does: -SkipMutations runs everything except the mutation gates, and -SelfTest exercises the harness's own stage function against a failing body, a throwing body and a passing body. A guard that has never been shown to go red is not a guard, and this is where that is shown.

    #Formal verification (moon prove)

    src/core is the proof subject: src/core/moon.pkg sets proof_enabled = true, which enables MoonBit 2026's experimental formal verification, and src/core/core_proof.mbtp is the logic side — predicates + 19 lemmas. The 19 lemmas were measured 21/21 goals VALID (unsat) on 2026-10-03, each machine-checked with cvc5.

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

    #Release history

    The release-by-release history — every version's changes, the measurement behind each change, and the correction when one was needed — is in CHANGELOG.md (中文).

    #Contributing

    1. Fork the repository and create a feature branch.
    2. Add tests for new functionality.
    3. Run the release gate and make it green:

      powershell -NoProfile -ExecutionPolicy Bypass -File _verify\release.ps1

    4. Open a pull request.

    The gate is not optional decoration: it runs 79 mutation gates, each of which mutates the source, requires a test to go red, and proves the restore byte-identically. 389 of the 426 live mutations expect a kill; 312 of them also name the test that must go red, so the gate log says which test was the guard and not merely that something was.

    #License

    MIT License. See LICENSE for details.

    #References

    Source Files