LunaFlow

    Trace-driven three-proof-strength control-flow, taint, value-range, slicing and contract analysis for MoonBit modules, as one Markdown report with Mermaid diagrams

    static-analysis
    dynamic-analysis
    taint
    program-slicing
    control-flow
    contracts
    mermaid
    mooncakes
    Download zip
    Author
    Version
    0.1.12
    License
    MIT
    Last updated
    16 hours ago
    Downloads
    119

    No dependencies

    #LunaFlow

    LunaFlow

    mooncakes release license tests

    Trace-driven, three-proof-strength control-flow, taint, value-range, slicing and contract analysis for MoonBit modules published on mooncakes.io. The output is one Markdown report with embedded Mermaid diagrams — no server, no HTML, no images.

    The analyzer never compiles the module it analyses. It parses the MoonBit source it is pointed at, executes a model of it, and writes the evidence out: every finding carries the method that produced it, a confidence, and a file:line.

    One analyzer · three evidence strengths · one Markdown report.

    #Features

    • A funnel, not a mode switch

      --level 1|2|3 is cumulative and --funnel runs all three. L1 deduplicates its findings, L2 splits its results into confirmed and new, and L3 is applied only to functions with an L1/L2 finding plus every function that already carries a contract. Functions escalated without a contract are reported as specification gaps — the funnel's explicit to-do list. When a target has no annotations, L3 reports 0 VCs and the gaps, not invented proofs.

    • A control-flow graph you can argue with

      The CFG is built from the parsed statement tree: branch nodes with true and false edges, loop heads with back edges, merge points, and dead nodes for statements after an unconditional exit. Execution paths come from the trace and are rendered beside the diagram. Dead code and unreachable nodes are static findings; non-termination is a static + dynamic finding; path coverage is dynamic and exact only for the executed trials. Each finding carries its method and confidence.

    • Taint with stated rules

      Sources are entry-function parameters (and @env.args when the frontend sees it). Propagation follows recorded definitions and the variables each statement read; array reads inherit container taint; length() inherits receiver taint; a tainted loop condition taints the numeric loop counters (implicit flow). Sinks are array indices and allocation sizes. The rules and their imprecision — field- and index-insensitive, no alias analysis, depth-6 calls, unsupported match arms — are printed in every report.

    • Values at every point

      L1 records every def event keyed by function, source line and variable, so report §6.1 is a per-program-point value table and the JSONL trace has the full stream. L2 adds may-ranges from the abstract fixpoint. The distinction is explicit: observed samples are not a proof, and the abstract interval is an over-approximation at widening.

    • Slices over the trace

      The dynamic dependence graph is built from def/cond/index/call events. Backward slicing walks to sources, forward slicing walks to sinks and returns. Criteria are <var>@<fn>, <fn>:<line> or a bare <var>. A slice is exact for the recorded trace, not a static closure; --function ensures the target function is executed before slicing.

    • Contracts that admit ignorance

      Specifications are MoonBit comments: //@ requires, //@ ensures, //@ invariant, //@ assigns. L3 generates WP-style verification conditions for index bounds, allocation sizes, division, assertions and postconditions at every return and tail expression. Each VC is proved, failed or unknown; unknown is an honest third state, not a failure. This is a three-state interval prover, not an SMT-backed WP.

    • Finding families that state their own limits

      Every finding kind has a code (DEAD-CODE, TAINT-INDEX, …) and a contract: the property it asserts, the assumption under which that is sound, and what it does not cover. --explain-all prints the roster, --explain <code> prints one contract, and --families / --exclude-families select by code. --metrics prints the ADEQUACY block — what was read, what was understood, what was entered, what was found — and every line in it carries its own coverage.

    • An evolved value analysis, and the memory-safety classes it reaches

      The value analysis asks what a value's range is; the evolved value analysis asks what happens when the value is used. It finds the index that escapes its array, the divisor that may be zero, the integer operation that leaves its type's range, the Option that may be None where it is unwrapped, the element read before it was written, and the conversion that saturates silently. Each class was mapped onto what MoonBit actually does by running the real toolchain — and MoonBit's integer arithmetic wraps instead of trapping, so an overflow there is a silent wrong value rather than a crash. The classes MoonBit's type system makes impossible — use after free, pointer arithmetic, memory leaks, format strings — are in the roster as RESERVED families whose contracts say why they never fire.

    • A memory layout model, in the terms the language actually has

      MoonBit has no addresses: no pointer type, no address space, no size_of, and the byte order is spelled out in the API rather than inherited from the machine. So the model is not a smaller hardware model — it is the placement question. The global region is evaluated once, in dependency order, because MoonBit resolves top-level bindings by dependency and not by file order. Every allocation site is classified as global, frame, heap or foreign by a static escape analysis, so the answer does not depend on which inputs a run sampled. And --stability re-samples the analysis and says, row by row, which findings depend on the sample — the abstract ones do not, and the observed ones do, and the report says so instead of letting a reader assume otherwise.

    • Markdown is the UI

      Reports use flowchart TD for CFGs and slices, flowchart LR for taint and the funnel. Labels are sanitized, node counts are capped (8 CFGs, 90 nodes per diagram, 40 taint/slice nodes), and every node and table row carries a file:line. The artifacts are diff-friendly and render on GitHub, GitLab and VS Code.

    • Version pinning you can reproduce

      A bare coordinate or @latest is resolved once by moon fetch, then pinned: the report records the resolved version, the source root (.repos/<author>/<module>/<version>) and the declared target. The analyzer never compiles the target, so heavy modules are analyzable without their dependencies. The optional Cobertura bridge is the only path that needs a buildable target.

    #What you get

    The module is a library plus an executable, so "what you get" is a set of data structures and reports, not a running service:

    You callYou get backShape
    LunaFlow analyze <coord>report, trace, summary.md, _trace.jsonl, _summary.json
    LunaFlow analyze --funnelthe same three artefacts plus L2 alarms and L3 VCsreport §3 / §5.2 / §6.3 / §8
    LunaFlow analyze --level 2L1 baseline + abstract may-analysisreport §5.2, §6.3
    LunaFlow analyze --level 3L1 + L2 + contract VCsreport §8
    LunaFlow analyze --slice "<crit>"dynamic backward/forward sliceMermaid graph + statement table
    LunaFlow analyze --coverage cov.xmlcompiled-suite coverage mapped onto CFG nodesreport §4.4
    LunaFlow analyze --list-functionsfunction, file, statement countFUNCTION<TAB>FILE<TAB>STATEMENTS
    LunaFlow analyze --max-cfg-functions <n>how many functions the CFG pass covers (default 600)report §2 states what was not covered
    LunaFlow analyze --families <codes>only the finding families named, by codethe same report, filtered
    LunaFlow analyze --exclude-families <codes>everything except the families namedthe same report, filtered
    LunaFlow analyze --exclude <name>skip a directory name while walking (repeatable)the same report
    LunaFlow analyze --metricsthe ADEQUACY block: read, understood, entered, foundfour METRICS lines
    LunaFlow analyze --per-functionevery function entered as an entry point, the --max-functions cap liftedthe same report, more of the module
    LunaFlow analyze --stabilityre-sample the analysis and report which findings depend on the samplereport §5.6
    LunaFlow analyze --level 2 + the EVAthe memory-safety and undefined-behaviour classesreport §5.3
    LunaFlow --explain <code>one family's contract — property, assumption, exclusionsthree clauses, no scan
    LunaFlow --explain-allthe roster of every family and the property it assertsone block, no scan
    @app.load_module(root, coord, version, limit)the parsed module and its ingestion statistics(ModuleAst, ScanStats)
    @app.check_contracts(ast, ["f"])the verification conditions of a functionProofResult
    @app.run_abstract(ast, max_functions, max_steps)the L2 findings of a moduleAbsResult

    Every one of those is a pure function or an explicit data value. There is no hidden global state and no network: the same inputs give the same bytes, which is why moon test can assert on them and why the gate can check the repository's own claims.

    The three artefacts are the point of the tool. The report is the human artefact — ten sections, Mermaid diagrams, file:line on every claim. The trace is the audit evidence: one JSON object per event, 34 MB for a doubleML run, and the reason the numbers above are checkable rather than asserted. The summary is the machine artefact: counters a CI gate can read without parsing prose.

    #Install

    moon add riantr/LunaFlow@0.1.12

    That is a module dependency, and it belongs in the consumer's moon.mod. moon add writes it for you, so the file ends up holding:

    import { "riantr/LunaFlow@0.1.12", }

    The library is the package riantr/LunaFlow/src/app, and the package import belongs in the consumer's moon.pkg:

    ///|
    import {
    "riantr/LunaFlow/src/app",
    }

    The two blocks look alike and are not the same thing. moon.mod names a module and pins the version it resolved; moon.pkg names a package inside a module and takes no version at all. A version in a moon.pkg import is not a newer spelling — it is an import the toolchain cannot find:

    Cannot find import 'riantr/LunaFlow/src/app@0.1.0' in my/app/src/thing@0.1.0

    The src/ in the package path is part of the package's name, not a prefix the toolchain strips. A module's library packages live under src/ and its executables under cmd/; the registry lists them under exactly those paths — so riantr/LunaFlow/src/app is a package and riantr/LunaFlow/app is not found.

    For the same reason there is no @latest: the version is parsed as semver, so latest, ^0.1.0, >=0.1.0, 0.1 and v0.1.0 are all rejected. A bare moon add riantr/LunaFlow resolves the newest release and pins the concrete version it found — that is the "latest" this toolchain has.

    The package can be given a shorter name, which is the closest thing here to Python's from x import y as z:

    ///|
    import {
    "riantr/LunaFlow/src/app" @lf,
    }

    so that the call is @lf.load_module(...). The alias belongs to the package, not to a file: every file in that package sees it, and an import statement inside a .mbt file is rejected outright — "Invalid import declaration here. Move this declaration to moon.pkg."

    src/cli is the executable in the same module — the command line over the same library. Registry entry: mooncakes.io/docs/riantr/LunaFlow@0.1.12.

    From a checkout of the module directory, the same executable runs directly:

    moon run src/cli -- analyze <author>/<module>[@<version>|@latest]

    moonx runs this module. The registry runner executes a published package without installing it, and the natural self-check is:

    moonx riantr/LunaFlow@latest riantr/LunaFlow@latest

    That works, and it is what the root package is for: moonx resolves the ROOT package of a module coordinate, so a module with no root executable fails with "Prebuilt wasm asset does not exist". This module has one — a thin forwarder to src/command — and the registry publishes its wasm artifact, because moonx --target accepts no other value.

    The CLI also accepts a bare target, which is what moonx passes as the program's first argument:

    moonx riantr/LunaFlow@latest riantr/LunaFlow@latest # the bare coordinate IS the target moon run src/cli -- riantr/LunaFlow@latest # the same thing, locally moonx riantr/LunaFlow@latest . # a path IS the target too moonx riantr/LunaFlow@latest # no target at all: the current directory

    The target slot follows the peer analyzer's rule, so the two tools take the same invocation:

    TargetWhat it isWhat happens
    author/module[@version]a coordinateit is fetched through moon fetch and analysed
    ./path, /abs/path, C:\patha pathit is analysed where it stands
    nothingthe defaultthe current directory is analysed

    A bare coordinate is an implicit analyze. A path is a path — but a path that LOOKS like a coordinate (fixtures/vuln_demo) is a coordinate, and the registry is asked for it. The peer analyzer resolves it the same way, and it is why a local path needs the ./.

    #What the release was checked on

    moon 0.1.20261104, carrying moonc v0.10.20+65efd5b93 — the toolchain every number in this file was measured on, and the one the CI runner installs. The older moon 0.1.20261102, carrying moonc v0.10.19+bee578715, is still supported and is what the core-compatibility contract is measured against, so both build this module. The reason that is checked rather than hoped for is under The core compatibility contract.

    The module is native, wasm and js. The analyzer reaches the filesystem and spawns moon fetch, and the async runtime implements both on native and wasm only — so the fs and process call sites sit behind #cfg guards with stubs for the backends that have no filesystem, and the js build is a REAL build rather than a skipped one. supported_targets is +native+wasm+js, and preferred_target is wasm because the registry publishes ONE artifact per version and moonx --target takes wasm as its one possible value.

    The wasm backend really does work, and it is not a degraded build:

    moon run . --target wasm -- analyze riantr/moonbit_static_analysis@latest --out reports

    That fetches the target, analyses it and writes the report from the wasm artifact — the same numbers as the native run.

    wasm-gc is not supported, and it is not a choice. MEASURED, and the reason is now precise. The upstream moonbitlang/core/async/fs and moonbitlang/core/async/process ship native and wasm implementations only, and their whole import block is guarded by #cfg(any(target="native", target="wasm")) — on every other backend those packages compile to nothing, and each call site becomes "value not found in package". On the pristine tree that was 11 errors in src/app/scan.mbt (process.run, fs.readdir, fs.exists, fs.kind, fs.Unknown, fs.Directory, fs.Regular, fs.read_text) and, once those are gone, 3 more in src/command/command.mbt (fs.write_text, fs.read_text, fs.mkdir).

    Those guards are what this release adds, and they take the library all the way to wasm-gc: moon check src/app --target wasm-gc reports 0 errors and moon test src/app --target wasm-gc runs 73/73. The executable still cannot be built there, and the blocker is upstream: the core's wasm-gc async stub has no run_async_main, so

    Error: [4021] Value run_async_main not found in package `moonbitlang/core/async`.

    async fn main is the only way to reach the async runtime, and a plain fn main may not call an async function — Error: [4149], on every backend alike. So wasm-gc stays out of the published set, and it is upstream's to fix.

    #The host contract

    The published wasm is not self-contained. It imports 108 host symbols, and the HOST has to provide every one of them when the module is instantiated: moonx supplies them from the async runtime bundled with the user's MoonBit toolchain.

    Import moduleSymbolsServed by
    moonbitlang/async95the toolchain's bundled core
    __moonbit_fs_unstable12the toolchain's bundled core
    wasi_snapshot_preview11the host runtime's WASI

    The ABI module name is still "moonbitlang/async" even though the package path is now moonbitlang/core/async: a wasm import's module name comes from the FFI declaration in the source, and the source still declares it that way. What changed is the SET of symbols, and the set grew — so the floor is a toolchain, not a pinned dependency, and a host that still carries the standalone moonbitlang/async module cannot instantiate this artifact.

    MEASURED against the toolchain this release was verified on: moonbitlang/core 0.10.19+bee578715 declares 272 host symbols and covers 107 of 107 of the artifact's core-extern imports. The contract is measured from the artifact's own import section by scripts/qa/host_requirements.mjs and recorded in docs/host_requirements.txt.

    #The core compatibility contract

    The runtime comes from the toolchain, so the version of moonbitlang/core this module builds against is the user's toolchain, not a pin here. That is the point of the swap — and it is also a hazard, because the core's API is allowed to change shape between toolchains, and this module has to build against all of them.

    MEASURED, the hard way: the first attempt at this release passed on moon 0.1.20261102 (core 0.10.19) and failed on the CI runner, which installs the newest MoonBit — moon 0.1.20261104, core 0.10.20:

    Error: [4014] ╭─[ src/app/scan.mbt:284:12 ] 284 │ stdout=@process.read_from_process(), │ ──────────────┬───────────── │ ╰─────────────── Expr Type Mismatch has type : (ReadFromProcess, SharedProcessOutput) wanted : ProcessOutput

    The capture call was written against the older core. Between the two:

    Declarationcore 0.10.19core 0.10.20
    read_from_process()-> &ProcessOutput-> (ReadFromProcess, SharedProcessOutput) raise
    run's stdout~&ProcessOutput = @stdio.stdoutProcessOutput = Inherit

    So the fix is not a patch to one call — it is to use only the part of the API that is the same in both, and to let the filesystem be the authority:

    • the fetch runs moon fetch --quiet, so it prints nothing on success and its error reaches the user directly;
    • the version comes from the directories the fetch wrote, not from parsing a log;
    • the child inherits the parent's streams, so nothing has to be captured.

    That is also a better design than the one it replaced. The version used to be scanned out of the moon fetch log, and that scan was the source of two separate defects — a garbage version on the Linux runner (a green run over an empty tree) and a version of "." on Windows. With the log gone, that whole class of bug is gone by construction.

    The contract is checked, not hoped for. scripts/qa/core_compat.mjs fetches the registry's newest moonbitlang/core, finds every core on the machine, and compares the declarations this module actually uses — the parameters it passes and the return type, because a parameter it does not pass may change shape without breaking anything, while a return type that changes always does. MEASURED: 12 declarations, identical in 3 cores (registry 0.1.20261105, local 0.10.20, local 0.10.19).

    The release is CI-driven. Pushing the release tag runs .github/workflows/publish.yml, which re-runs this gate, checks that the README names the released version, publishes to mooncakes with the MOONCAKES_RIANTR_TOKEN secret, and creates the GitHub Release. Nothing is published from a workstation.

    There is no dependency pin any more. The async runtime comes from the toolchain-bundled moonbitlang/core module, so moon.mod has no import block at all — there is nothing that can drift from the registry, and nothing that can disagree with the host that instantiates the artifact. The CI check enforces the swap itself: a moonbitlang/async dependency coming back is an error, not a warning.

    The trade is a real one, and it is worth stating plainly. moonbitlang/core/async is still marked experimental by the core team, so every package that uses it says so in options("allow-experimental-packages": ...). And the minimum host toolchain moved up with the swap, because the artifact now asks the host for more symbols than the standalone module provided.

    #What changed in 0.1.12

    The memory layout: the global region, the allocation classes, and a self-check that says which conclusions depend on the run.

    The request behind this release was a hardware memory model — stack, heap, global region and physical addresses; per-architecture word size, alignment, endianness and memory maps; MMIO registers and address spaces; and a guarantee that no conclusion depends on the layout of one lucky run. MoonBit has none of the hardware, and that is not an opinion — it was measured:

    The questionWhat the toolchain says
    Is there a pointer type?No. No unsafe_ptr, no Pointer, no address arithmetic anywhere in the core.
    Is there an address space?No. No MMIO, no physical address, no mmap.
    Is the word size the target's?No. Int is 32 bits on native and on wasm: it wraps after 2^30 on both. The toolchain's own Why3 prelude pins the same range.
    Is the byte order the machine's?No. Only _be and _le variants exist — the program chooses the order.
    Is there layout introspection?No. No size_of, no align_of, no reflection.
    Is there an ARM or RISC-V target?No. The targets are wasm, wasm-gc, js, native, llvm.
    What is there?A GC, immutable values, and one route to a raw address: extern "C" with #external type, which the wasm backend rejects.

    So the honest translation is not a smaller hardware model — it is the placement question, which has a real answer in MoonBit, plus the soundness question, which is the part that actually mattered.

    1. The global region. Top-level let and const bindings were not parsed at all: they were skipped, so every read of a global was an unknown value. They are now parsed, and the region is evaluated once, in dependency order, because MoonBit resolves top-level bindings by dependency and not by file order. The fixture proves it:

    let derived : Int32 = base + 2 // reads a global declared LATER
    let base : Int32 = 10

    derived is 12, not unknown — and the order in the file does not matter. The global region's values are computed outside the trials, so a global's value does not depend on the input the analyzer happened to sample.

    A top-level binding is immutable: let mut at the top level is a parse error (E3002), measured against the real compiler. So a global is a constant of the module. The analyzer still leaves a global unknown when the module assigns to it, because the analyzer does not compile the target and cannot know that the assignment is an error.

    And the globals are where a real defect shows up. A global whose initializer leaves the type's range is now reported at the initializer:

    The globalWhat MoonBit computesWhat the analyzer says
    let overflowing : Int32 = 2147483647 + 1-2147483648INTEGER-OVERFLOW, at the global's line

    2. The allocation classes. Every allocation site is classified by where it can live, and the classification is static — computed over the AST, so it does not depend on which inputs a run sampled:

    ClassMeaning
    GLOBALbound to a top-level name: it lives in the module's global region.
    FRAMEcreated in a function and never leaves it: a frame allocation would be sound for it.
    HEAPescapes — returned, stored into another object, or handed to a call the analyzer cannot see into.
    FOREIGNcame from outside the analysed module: its region is not the analyzer's to name.

    The escape rules are the interesting part, and two of them are easy to get wrong: a.set(0, 7) mutates the allocation and does not hand it over, while consume(b) does — and a call nested inside a larger expression (consume(b) + x) hands its arguments over too. The fixture carries all three shapes, and the analyzer puts each one where it belongs.

    3. The layout-stability self-check. This is the part that answers "does the conclusion depend on one run's luck?", and it is now a measurement rather than a promise. --stability re-runs the analysis at several sample sizes and compares the findings row by row:

    FindingEngineRowsIn every runDepends on the sample?
    TAINT-INDEXL2-abstract11no
    UNREACHABLEL2-abstract1818no
    TAINT-INDEX-RANGEdynamic+symbolic20yes

    That last row is the whole point: on LunaFlow's own source the TAINT-INDEX-RANGE class appears only in the main run, so it is a property of the sample, not of the program — and the report says so instead of letting a reader take it for a finding. The abstract layer's classes do not depend on the sample, and the check confirms it: they are a may-analysis over the whole input abstraction, not a sample of it.

    4. A tail value, and a frontend fact that came with it. A MoonBit function body ends with its last expression, and that expression is the function's value: fn f() -> Int { 42 } returns 42. The analyzer returned Unit for every such function, which hid the globals behind them. Now the tail value is the function's value — and the value is looked up by the statement's id, so the expression is not evaluated twice and its side effects do not run twice.

    5. The parse issues are listed, not just counted. The report printed | Parse issues | 3 | and nothing else, so a reader could not tell what the frontend could not model — and the FFI declarations are exactly the constructs a reader needs to know about. They are listed now, with the location and the reason.

    The numbers. Tests 93 → 107; the gate's CLI-surface checks 41 → 51; the library is 17 files and the test suite 13 files. The self-scan row moved because the analyzer is one of the modules it analyses.

    #What changed in 0.1.11

    A self-check with the published 0.1.10 against LunaFlow's own source found a filing bug, and this release fixes it.

    The self-check ran the published 0.1.10 module — moonx over the released coordinate — over this repository's source, not the local build. Two results came out of it. The first is the good one: the published wasm build's report is byte-identical to the local native build's report, down to the finding list — only the wall-clock line differs. The second is a real inconsistency, and it is in the EVA itself.

    INDEX-OOB was filed in the wrong section. The family roster declares six EVA families, and INDEX-OOB is one of them. But its finding was produced by the interval proof and filed with the mode L2-abstract, so the report put it in §5.2 L2 abstract findings — while §5.3, the section that documents what the EVA covers, listed only five of the six classes it claims. A reader auditing "does this tool find out-of-bounds accesses?" would look at the EVA section and not find it.

    The fix is the filing, not the proof. The interval comparison is unchanged — the array's length and the index's range are both known, so it covers the whole input abstraction — and the finding now carries the mode L2-EVA and is listed with the other evolved value findings:

    beforeafter
    classes listed in report §5.356
    EVA findings on the fixture911
    INDEX-OOB rows in §5.2 (classic)20

    The proof is still the interval one; the check now lives with the other five in src/app/eva.mbt, and the report says so in one sentence rather than leaving the reader to infer it.

    Two tests changed, and one of them was wrong. The new test pins the filing contract: an out-of-bounds index carries the mode L2-EVA and no longer appears among the classic findings. The other one asserted the mode of every finding in a loop instead of the mode of the finding it meant to check, so it only passed while the out-of-bounds class happened to be filed the classic way. It now asserts what it says: the tainted-index finding's mode.

    The QA gate grew two checks that would have caught this: every class the roster declares as evolved must appear inside the evolved section of the report, and no evolved class may appear in the classic one. The old check scanned the whole report for the code, which is why it passed while the class sat in the wrong section.

    #What changed in 0.1.10

    The capability boundary now reaches the EVOLVED VALUE ANALYSIS: the memory-safety and undefined-behaviour classes, mapped onto what MoonBit actually does.

    The value analysis that existed before this one asked ONE question about a value: what is its range? The evolved value analysis — EVA — asks the second question, which is the one a defect actually turns on: what happens when this value is USED? An index that escapes its array, a divisor that may be zero, an integer operation that leaves its type's range, an Option that may be None where it is unwrapped, an element read before it was written, a conversion that saturates — every one of those is decidable from the ranges the abstract interpreter already computes, so EVA is not a second engine: it rides on the L2 fixpoint and asks the extra questions where the values are computed.

    Every class was mapped by RUNNING the real toolchain, not by reading it. The list you asked for is the classic C memory-safety taxonomy, and MoonBit is not a C-like language, so each class had to be settled against what MoonBit really does:

    The classWhat MoonBit does — MEASUREDThe family
    buffer overflowBuffer GROWS, so there is no fixed capacity to overflow; a fixed-size array's bounds are checkedINDEX-OOB
    out-of-bounds read/writea[5] on a three-element array is RuntimeError: unreachable at Array::at; so is a[-1]INDEX-OOB
    integer overflowinteger arithmetic WRAPS silently — Int::MAX_VALUE + 1 is Int::MIN_VALUE, with no panic and no diagnosticINTEGER-OVERFLOW
    division by zero100 / 0 is RuntimeError: divide by zero, and 100 % 0 is RuntimeError: remainder by zeroDIVISION-BY-ZERO
    null pointer dereferencethere are no null pointers; None.unwrap() is RuntimeError: unreachable at Option::unwrapNONE-DEREF
    uninitialized memory readUninitializedArray::make(3) read before set returns an indeterminate value and does NOT panicUNINITIALIZED-READ
    use-after-freeno raw pointers, the collector owns the storage, values are immutableRESERVED
    illegal pointer arithmeticno pointer type in safe MoonBitRESERVED
    memory leakthe collector reclaims unreachable storage; there is no manual freeRESERVED
    format stringthere are no format strings: the language interpolates with a typed syntax and the core has no printf-style formattingRESERVED
    invalid type castas is a trait-object downcast and nothing else — a numeric as is a compile error; Double::to_int saturates silentlyINVALID-CAST

    The four RESERVED families are in the roster on purpose. A roster that silently omitted them would read as "this tool does not look for that", which is a different claim from "that cannot happen here". So --explainUSE-AFTER-FREE answers, and its contract says why it never fires:

    USE-AFTER-FREE UseAfterFree property a value may be used after the storage it referred to was released assumption NOT APPLICABLE IN MOONBIT: safe MoonBit has no raw pointers, the collector owns the storage, and values are immutable -- there is nothing to free and nothing to dangle excluded nothing -- and note the kind is RESERVED: safe MoonBit cannot express the defect

    The roster went from ten families to twenty, and every one of them has a contract.

    The one that is worth more here than elsewhere. INTEGER-OVERFLOW is the class MoonBit makes WORSE than a trapping language. Int::MAX_VALUE + 1 is Int::MIN_VALUE in MoonBit, silently: the program keeps running with a value the author did not intend. In a language that traps, the bug is a crash; here it is silent corruption, which is exactly the kind of defect an analyzer is for. MoonBit's Int is a 32-bit signed integer — measured, not assumed: Int::MAX_VALUE + 1 is -2147483648 — and Int64 is 64-bit.

    The fixture carries the defects AND the ordinary shapes next to them. fixtures/eva_demo/ has a positive half and a negative half, and it compiles and runs under the real toolchain: every claim below was executed, not inferred.

    The fixture's functionWhat the real runtime didWhat the analyzer said
    overflow_literalreturned -2147483648INTEGER-OVERFLOW, confidence 0.95
    divide_by_zeroRuntimeError: divide by zeroDIVISION-BY-ZERO, confidence 0.95
    index_past_endRuntimeError: unreachable at Array::atINDEX-OOB, confidence 0.95
    unwrap_noneRuntimeError: unreachable at Option::unwrapNONE-DEREF
    read_uninitializedreturned 13036 — the garbage in the slot, silentlyUNINITIALIZED-READ
    saturating_castreturned 2147483647INVALID-CAST
    safe_sum, safe_divisor, safe_index, safe_some, safe_some_on_both_branches, safe_initialized, safe_castall ran and returned the right answernothing reported

    The negative half is the half that matters: a check that fires on safe_some_on_both_branches is a false positive, and a check that does not fire on read_uninitialized is a check that does not work.

    The facts are FLOW-SENSITIVE, and that was a bug first. The idiomatic mutable Option is

    let mut x : Int32? = None
    if flag { x = Some(1) } else { x = Some(2) }
    x.unwrap()

    A monotone accumulation reports that as a None dereference, because None went in. It is not one: x is Some on both paths out. So the EVA facts are joined with the abstract state, branch by branch, and the join is of the two BRANCHES and not of the branches and the state before them.

    And the EVA work uncovered a real frontend bug. The type annotation used to run on through the initializer: let x : Int32 = 5 recorded the type as "Int32 = 5" and the initializer was dropped. Every let with a type annotation therefore bound an unknown value — and an unknown value makes the interpreter explore every branch, so the trace was full of paths that could not happen. Measured on one identical tree:

    beforeafter
    statements understood3,2833,341
    statements unsupported183171
    frontend coverage94.7 %95.1 %
    trace events15,65611,865

    The coverage went UP and the event count went DOWN, and both directions are right: the analyzer now understands more of the tree, and it stops inventing paths through values it could not see.

    There is one published-signature change, and it is unavoidable. FindingKind gained ten variants, so a consumer that matches on it exhaustively must add a case. There is no way to add a finding family without a new kind — the kind IS the code the family is addressed by. Every other published shape is unchanged: load_module, run_module and render_report grew optional arguments, and no published struct gained a field.

    The EVA findings carry their own mode, L2-EVA, so report §5.3 lists them apart from the classic abstract findings. The test suite is 92 tests, up from 73, and the gate's CLI-surface checks are 39, up from 31.

    #What changed in 0.1.9

    The five options the peer analyzer has and this one did not: --exclude, --families, --explain, --metrics, --per-function.

    0.1.8 aligned the input surface with riantr/moonbit_static_analysis. This release finishes the alignment by closing the option surface. The peer could be told which directories to skip, which finding families to report, what a family actually means, how much of the target it really understood, and it could be pushed into every function instead of the first few. Now this one can do the same, under the same rules.

    The families are the codes the report already shows. There is no second vocabulary to learn: a family is addressed by the code that appears on the finding's own row.

    moonx riantr/LunaFlow@latest --explain-all

    families: code, title, and the property each one asserts DEAD-CODE DeadCode -- a statement is unreachable after an unconditional exit UNREACHABLE Unreachable -- no execution reaches this statement: the flow arriving at it has already left the block INFINITE-LOOP InfiniteLoop -- the loop cannot terminate: its condition holds for every input and it has no break TAINT-INDEX TaintedIndex -- a tainted value may reach an array index or a buffer offset TAINT-INDEX-RANGE TaintedIndexInRange -- a tainted value reaches an index that is in range on every observed path TAINT-ALLOC TaintedAllocation -- a tainted value may control an allocation size RANGE-VIOLATION RangeViolation -- a value's range may be violated: an assertion may fail, or a size may be negative PARSE-GAP ParseGap -- a construct the frontend could not model, folded into the statement it belongs to BUDGET-EXCEEDED BudgetExceeded -- an analysis budget was exhausted, so the rest of the function was not analysed UNSUPPORTED UnsupportedSink -- a tainted value may reach abort/panic

    The peer addresses its families by an MSA-NNN id; this one uses the finding's own code. The stability rule the peer wants is kept either way — a code is never renamed, because the code is what a reader sees in the report and what a CI gate matches on.

    Each family carries a contract, and the contract is what --explain prints. Three clauses, and the third one is the interesting one: it says what the finding does not cover.

    moonx riantr/LunaFlow@latest --explain TAINT-INDEX

    TAINT-INDEX TaintedIndex property a tainted value may reach an array index or a buffer offset assumption the taint source set was complete, and the sink was reached on an observed path excluded an index the runtime clamps, and a value the source model did not mark tainted

    PARSE-GAP is declared and never produced: the frontend folds an unmodelled construct into the statement it belongs to and counts it as unsupported instead, and the contract says so rather than pretending the family fires.

    Two profile rules, and they are the peer's. A code the roster does not know is a WARNING and the scan still runs — a typo in a filter must not silently produce an empty report:

    WARNING unknown family code in --exclude-families: TAINT-* (the roster is DEAD-CODE, UNREACHABLE, ...)

    A code in both the include and the exclude set is an ERROR and the scan does not run at all — the two rules contradict each other, and which one won would otherwise be an accident of the order of two ifs:

    ERROR the profile includes and excludes the same family code: DEAD-CODE

    Neither the peer nor this one supports a glob in a family list, and the peer rejects TAINT-* in exactly the same way. The codes are literal.

    A CONTRACT REQUEST IS NOT A SCAN. --explain and --explain-all answer and stop before the profile is validated and before a single file is read, so a contract request cannot be broken by the tree it is run in — and it needs no target at all.

    --metrics prints the ADEQUACY block: how much of the target was really understood. Four TAB-separated lines, and every one of them carries its own coverage — a number without its coverage is half a number.

    METRICS read files=1 parsed=1 partial=0 understood=100.0% METRICS stmts statements=32 understood=32 unsupported=0 understood-share=100.0% METRICS funcs functions=12 entered=12 skipped=0 entered-share=100.0% METRICS findings total=12 error=6 warning=2 info=4

    The findings total counts the deduped reportable set — the CFG findings, the dynamic infinite-loop and budget findings, the taint sinks (already deduped by site) and the abstract findings. It is not the raw observation list, which holds one entry per trial and per loop iteration and would report dozens of findings where the report shows twelve.

    --per-function lifts the --max-functions cap. The peer's --per-function switches the behavioural lens from the module to each function. LunaFlow's L1 already enters functions rather than executing module-level statements, so there is no module-mode to toggle — the counterpart is the cap, and the option lifts it: every function becomes an entry point. Measured on this module's own source at --trials 4:

    entered (of 428 extracted)trace eventswall
    capped (default 150)150 (35.0 %)13,1950.15 s
    --per-function353 (82.4 %)74,6450.23 s

    The cost is real and it was measured on a module that is not this one: on riantr/moonbit_doubleML@0.164.64 (2,520 functions) the capped run takes 4.1 s and the uncapped one 65 s, with 2,150,934 trace events instead of 218,391. The cap is what keeps a large module bounded, so this stays opt-in.

    --exclude skips a directory name, and it is repeatable. It is the same built-in skip list plus yours: _build, .git, .mooncakes, .repos, target and node_modules are always skipped, and --exclude vendor adds a name of your own. Measured on a two-directory tree, the files read went 2 → 1 and the functions extracted 2 → 1.

    The public API grew by optional parameters and new functions only. The published signatures are unchanged for a caller that does not use the new arguments: load_module takes an optional exclude list, run_module an optional per_function flag, render_report an optional per_function flag. No field was added to a published struct, so nothing that already compiles against this module stops compiling.

    The gate moved with it. scripts/qa/root_package.mjs grew from 21 checks to 31: the roster and one contract, an unknown code, families accepted, an unknown code in a family list, the both-sets error, an excluded directory present before and gone after, the metrics block, and --per-function entering more functions than the cap allows. The analyser itself did not change behaviourally — the same 73 tests pass, the wasm artifact is unchanged, and the host contract is still 108 imports, 107 covered.

    #What changed in 0.1.8

    The input surface now matches riantr/moonbit_static_analysis, and the default target is the current directory.

    The peer analyzer takes one positional target and defaults it to .. This release adopts that rule, so the two tools take the same invocation:

    TargetWhat it isWhat happens
    author/module[@version]a coordinatefetched through moon fetch and analysed
    ./path, /abs/path, C:\patha pathanalysed where it stands
    nothingthe defaultthe current directory is analysed

    moonx riantr/LunaFlow@latest . # the current directory moonx riantr/LunaFlow@latest ./src/app # a tree, where it stands moonx riantr/LunaFlow@latest riantr/LunaFlow@latest # a coordinate, fetched moonx riantr/LunaFlow@latest # no target: the current directory

    What changed in the parser. The target slot used to accept a coordinate or nothing: a path was refused, and a run with no target at all printed the usage text. It now accepts a coordinate or a path, and it defaults to .. The resolution rule is the peer's, and it was settled by measurement rather than by reading: moonx riantr/moonbit_static_analysis@latest fixtures/vuln_demo resolves that string as a COORDINATE and fails in the registry, while moonx riantr/moonbit_static_analysis@latest ./fixtures/vuln_demo scans the directory. This analyzer now answers the same way for both.

    --dir still names a tree and analyze is still accepted as a subcommand — both are in the published documentation and in every QA harness, so neither was removed. The report says which of the two it analysed: the pinning line now reads "the current directory, by default" when the target was defaulted, and "explicit local directory" when it was named.

    The gate moved with it. root_package.mjs used to assert that a local path is REFUSED; that assertion encoded the old contract, and it was replaced by the new one: the report's own root line has to name the tree that was analysed, so a tree mistaken for another one cannot pass. The step count went from 16 to 21 — four new checks for the default target, the . target, the positional path and the --dir spelling, and one for the usage text naming the default.

    The analyser itself did not change: the same 73 tests pass, the wasm artifact is unchanged, and the host contract is still 108 imports, 107 covered.

    #What changed in 0.1.7

    The module now builds and tests for JavaScript, and the library reaches wasm-gc as well.

    supported_targets is +native+wasm+js. The question this release answers is whether the analyzer's backends can be widened without touching what it analyses, and the answer turned out to be: yes, and the change is small — but removing @fs/@process is necessary, not sufficient. Two separate things stood in the way, and neither is about the filesystem.

    • The filesystem and process calls sit behind #cfg guards now. There are 13 such call sites, in two files: ten in src/app/scan.mbt and three in src/command/command.mbt. Each one moves into a guarded declaration with a stub for the backends that have no filesystem, because #cfg guards declarations — a statement inside a function body cannot be guarded, so the @fs.mkdir call had to move into a helper of its own. The counters moved into ScanStats::add_file for the same reason: left in the caller, the mut fields would be unused on js and the compiler would reject the build.
    • On Node, the command line arrives one element later. env.args() is process.argv there, so the node executable AND the script path come first: [node, script.js, "analyze", ...] where every other backend gives [exe, "analyze", ...]. The CLI read args[1] as the command, so on js it read the script path and printed the usage text instead of analysing. It is one helper now, and it is the only js-specific line in the source.

    The result, measured rather than asserted:

    nativewasmjswasm-gc
    moon check (whole module)0 errors0 errors0 errors1 error
    moon check src/app0 errors0 errors0 errors0 errors
    moon test src/app73/7373/7373/7373/73

    The wasm-gc column is measured with the target set widened past what the module publishes. The libraries compile there and their 73 tests pass; the EXECUTABLES do not, and the reason is upstream:

    Error: [4021] Value run_async_main not found in package `moonbitlang/core/async`.

    The core's wasm-gc async stub has no run_async_main, and async fn main is the only way to reach the async runtime — a plain fn main may not call an async function (Error: [4149], on every backend alike). So wasm-gc is left out of the published set: adding it would break the build.

    What js actually buys. The js build compiles, runs under Node, and the library's 73 tests pass there. But js has no filesystem in the core, so the js CLI can only analyse what it is handed in-process; it cannot walk a directory or write a report. The value is to library consumers: riantr/LunaFlow/src/app is the analyzer — text in, CFG / taint / slice / contracts out — and it now runs inside a Node or wasm-gc host as well as a MoonBit one.

    The wasm artifact is unchanged: moon check --target wasm still reports 131 warnings and 0 errors, and the host contract is still 108 imports, 107 of them covered by the bundled core.

    #What changed in 0.1.6

    This release is what the peer analyzer found when it was pointed at this module.

    moonx riantr/moonbit_static_analysis@latest riantr/LunaFlow@latest scans LunaFlow's own source. On 0.4.45 — and unchanged on 0.4.46 — it reported 3 actionable findings against 229 rows: an unused parameter and two unused locals. All three were true, and the MoonBit compiler's own unused_value warning confirmed every one of them.

    • A contract clause carried the wrong line. parse_spec_expr took a line and never used it. A clause is parsed out of ONE line of a file, so every contract expression claimed to be on line 1 of the file — and a reader who followed the pointer landed on the top of the file. The lexer now takes the base line (lex_at), and each clause of a contract carries the line it was actually written on.
    • Two unread bindings. The if expression read its condition and its then-block into two locals it never used. They are discarded explicitly now; the parse is unchanged.

    Running our own analyzer over our own source then found five more false positives, and the MoonBit compiler contradicted every one of them: it reports unreachable code in the fixture and nowhere else. Two causes, both fixed:

    • A slice a[i:j] was not modelled. return line[i + 1:j].to_string() became a return with an unmodelled expression plus three shredded fragments, and the fragments that followed the exit inherited its deadness. Slices are modelled now (ESlice), and because a slice is clamped by the runtime it carries the taint of its parts but raises no out-of-bounds claim.
    • A while true whose body holds a construct the parser does not model was called infinite. The break inside such a construct is invisible to the CFG, so the loop looked non-terminating and the code after it looked dead. The analyzer now claims only what it can support: a loop with an unmodelled construct in its body is not proven infinite, and the code after it is not proven unreachable. A fully modelled while true is still reported — refusing to guess must not cost the real finding.

    Measured on this module's own source: dead-code findings 8 → 3, unreachable 2 → 1, infinite loops 2 → 1 — and the 3 / 1 / 1 that remain are the vulnerability fixture's, which exists to be found. At that point on doubleML and on the peer module every number was unchanged, and the committed doubleML artefact was byte-identical apart from the wall clock.

    The second peer run, pointed at this working tree, and the one file neither side could read. moonx riantr/moonbit_static_analysis@latest . asks the peer to scan the tree as it stands rather than the published module. It reports 0 actionable findings against 243 rows — 232 notices and 11 unreliable, nothing withheld: all three defects above are gone. The row left worth acting on is the file it cannot parse — src/command/command.mbt, expected ')' — and turning that one around found a defect on our side as well, in the same construct:

    • A MoonBit multiline string (#|) was lexed as a string that ended at the end of its first line. The construct is a string literal, not a comment: #| opens it, the text after the marker is the first chunk, every following line whose first non-blank characters are #| is one more chunk, and the value is the chunks joined with "\n". The old lexer took the rest of the first line and left the rest of the literal in the token stream, so usage() was shredded into thirty-odd statements, the function was not recognised as a function at all, and the statements around it were counted as unsupported. The literal is modelled now, and so is the newline that ends its last chunk line: without that newline the statement after the literal was parsed as part of it, which is how one let in a test file became one unsupported statement instead of three understood ones.

    Measured after it: on this module's own source the statements understood went 3,346 → 3,093, the unsupported statements 195 → 175, and the functions extracted 395 → 396 — the usage() function is a function again. (The measured-runs table above re-measures the tree after this release's own test file was added, and again after the 0.1.7 backend guards, the 0.1.8 argument parser and the 0.1.9 family roster, so it reads 30 files and 3,283 statements.) On the peer module, which writes its own block strings, the statements understood went 12,997 → 12,970 and the unsupported statements 611 → 595; on doubleML every number is unchanged, and the committed doubleML artefact is byte-identical apart from the wall clock. The test suite is 73 tests, up from 59.

    The host floor moved with the toolchain, and the contract was re-measured. The published artifact imports 108 host symbols now, one more than 0.1.5: fd_util/pipe. That symbol comes from the toolchain's own runtime, not from this module's source — the module itself did not grow a single host call — so the floor is still a toolchain and not a pinned dependency. What changed is that the set grew, and a host that carries the older core cannot instantiate this artifact. The contract is measured, not asserted: against the core the release was verified on (moonbitlang/core 0.10.19+bee578715) 107 of 107 of the artifact's core-extern imports are covered, and docs/host_requirements.txt records the list.

    #What changed in 0.1.5

    This release fixes the parser, and the false findings it produced.

    riantr/moonbit_static_analysis runs LunaFlow against its own source and reports what it finds. On 0.4.45 that produced 136 findings — 37 dead-code, 98 taint-index, 1 budget — and every one of them was a false positive. All of them had the same cause in LunaFlow's frontend: the parser shredded a construct it does not model into one statement per fragment, and the fragments that followed a return inherited its deadness.

    • Seven root causes, all in the frontend. A struct literal was not modelled, so { a: x, b: 2, } became one statement per field — and it ate the enclosing block's closing brace, which truncated the function. A binary expression did not continue across a newline. A match expression leaked its arms. :: was lexed but not parsed, so return CART::Leaf(...) left its tail behind. A string or character literal whose content was { was counted as a brace, and the block ran on past the end of the function, taking the functions after it with it. A for that destructures left its loop body behind as a block. And an if without an else was treated as an unconditional exit, so the code after a guarded return was called unreachable.
    • The repair is two layers. The constructs above are now modelled — the qualified path, the qualified record literal, the destructuring for, the named argument, the if expression, the function literal. And the parser no longer lets an unmodelled construct shred itself: a tail it has no rule for is folded into the statement it belongs to and counted as unsupported. The second layer generalises — without it the next construct the parser does not know would shred itself again.
    • Taint sinks are deduplicated by site, not by whole-finding equality; the observed value is in the evidence, so one read observed in many trials became dozens of rows. And a tainted index whose observed value was inside the array is now a warning that says so; only an index witnessed out of range, or one whose bounds cannot be resolved, stays an error.
    • "No findings" no longer reads as "clean". The CFG pass covers the first 600 functions in file order, so the rest of a large module can carry no CFG finding. The report now says so in its ingestion table, and --max-cfg-functions makes the window configurable.

    Measured on identical trees: dead-code findings on the static-analysis module 37 → 0 and on doubleML 137 → 0; taint sinks on the static-analysis module 98 → 5. The statement count for doubleML fell from 43,822 to 29,853, because the old number counted the shredded fragments and the truncated function bodies. The full adjudication, including the one place the peer report's own attribution was wrong, is in The cross-check against another analyzer's module.

    #What changed in 0.1.4

    This release moves the runtime to the toolchain and makes the module runnable from the registry.

    • The runtime comes from the toolchain. The dependency on moonbitlang/async is gone; the async runtime is moonbitlang/core/async, part of the moonbitlang/core module the toolchain already bundles. moon.mod has no import block any more. The trade is stated above: the package is experimental, and the minimum host toolchain moved up, because the artifact asks the host for a bigger set of symbols than the standalone module provided. The set is measured, not asserted — 108 host imports, 107 of them covered by the bundled core, and the contract is in docs/host_requirements.txt.
    • The module has a root package, and moonx runs it. moonx riantr/LunaFlow@latest riantr/LunaFlow@latest is the self-check, and it works. That needed three things: a root package that is an executable (without one, moonx says "Prebuilt wasm asset does not exist"), a wasm artifact for the registry to publish (preferred_target = "wasm"), and a CLI that treats a bare coordinate as an implicit analyze.
    • The module builds and runs for wasm, and the library also builds for js. supported_targets is +native+wasm+js, and the wasm artifact fetches, analyses and writes the report. The js build is real — the library is checked and its 73 tests run there — but it has no filesystem, so the js library is what gains: text in, analysis out. wasm-gc is still excluded, and the reason is upstream's — see The host contract.
    • The fetch log is gone, and with it a whole class of bug. The version used to be scanned out of the moon fetch log — free-form text whose shape differs between platforms. It is now taken from the directories the fetch wrote. The log scan was the source of two separate defects (a garbage version on the Linux runner, a green run over an empty tree; a version of "." on Windows), and removing it removes both by construction. The Windows case is the sharper one: the fetch log prints the resolved path, and when the module name is a SUFFIX of the repository directory name — moonbit_LunaFlow for the module LunaFlow — the text after the name starts with \.repos\. A dot is a version character, so the token came out as ".". That candidate was then accepted because base + "/." exists: it resolves to the base directory itself. Analysing the module with itself therefore pinned the analysis to the BASE directory, and the report claimed the resolved version was ".".
    • The module builds against every core a user may have. The runtime comes from the toolchain, so the core's API is allowed to change shape between toolchains — and it did, between core 0.10.19 and 0.10.20, in the middle of this release. The details are under The core compatibility contract; the short version is that the fetch no longer captures the child's output, so it uses only the API that is the same in both, and scripts/qa/core_compat.mjs checks the rest.

    The package layout changed with the root package. The CLI implementation moved out of src/cli and into src/command, because the module now has two executables (the root package and src/cli) and two main packages cannot import each other — the toolchain's own message says "Move reusable APIs into a non-main package and keep main packages as entrypoints". moon run src/cli and moon run . both work, and there is one implementation behind them.

    #What changed in 0.1.1

    0.1.0 was published, and this release fixes what it exposed. 0.1.0 carried a README that said the module "is not published yet" — a sentence that was true when it was written and false the moment the CI published it — and test counts from before the CI fixes. The registry serves the README as the module's documentation page, so a stale README there is a defect in the product, not in the repository.

    This release also carries the three defects that the Linux CI runner found in 0.1.0, which are described below.

    #What changed in 0.1.0

    This is the first release, and it is the first release under this name. The module was developed as moonflow/moonflow and was published nowhere — the registry has no such coordinate and no consumer depended on it — so the rename to riantr/LunaFlow breaks nobody. It also aligns the coordinate with the product name and with the sibling modules in this account. The module's repository is github.com/riantr/LunaFlow, where the release artefacts and the CI live.

    The interesting part of this release is not the rename but the deep QA pass that preceded it, which found and fixed two defects that mattered for a tool whose whole job is reading untrusted modules:

    • A hostile module could abort the process. 5,000 nested if statements and 2,000 nested parentheses exhausted the native stack (0xC0000409, STATUS_STACK_BUFFER_OVERRUN). The parser now refuses constructs nested deeper than 256 levels and records them as a parse gap instead: the region is skipped whole and counted as one unsupported statement, so the frontend-coverage number in the report stays honest rather than reading 100 % on a file that was skipped. This is pinned by hardening_test.mbt and by scripts/qa/depth_sweep.mjs.
    • A hostile module could steer the report path. The output file name is built from the analysed module's own moon.mod, and only / was neutralised, so a version field of ..\..\..\..\escaped wrote the report, the trace and the summary into an arbitrary directory. The stem now goes through a whitelist sanitiser, and scripts/qa/path_traversal.mjs is the regression.
    • A --dir scan could describe someone else's code. The source walker skipped _build, .git, .repos, target and node_modules, but not .mooncakes — the directory the toolchain vendors a module's dependencies into. A self-scan of this repository therefore reported 384 parsed files, 362 of them from the vendored moonbitlang/async module: every file, function and statement count in that report described a third party's code. .mooncakes is skipped as well now, and scripts/qa/vendor_isolation.mjs plants one file behind every skipped directory name and asserts that exactly the module's own source is parsed.

    Three smaller fixes came out of the same pass: --out with no value wrote into the working directory instead of failing; //@ assigns \nothing recorded a target literally named \nothing; and a function whose only annotation was //@ invariant produced no verification conditions at all, because the contract was considered absent. The published surface grew by those fixes and nothing else changed shape.

    Three more defects surfaced when the release ran on the Linux CI runner, and they are worth naming because each one produced a green result that meant nothing:

    • The version was scanned out of the moon fetch log, which is free-form text whose shape differs between platforms. On Linux the scan took everything up to the next " to " and produced the version 0.164.64\nSuccess\nSuccessfully fetched … — a name that matches no directory. The analyzer then read an empty tree and reported 0 statements, 0 coverage, 0 findings, exit 0. The filesystem is now the authority: the version is cut at the first character that cannot be part of a version, and a candidate is only accepted when the directory it names really exists.
    • The harness looked for the built CLI under the wrong name. The MoonBit native target names the executable cli.exe on every platform, Linux included, where the file is an ELF binary with a Windows extension. The resolver picked the name by platform, so on the runner the CLI never ran and the failure surfaced three steps later as a missing output file.
    • The vendored-tree regression asserted the wrong thing. It checked that the report does not contain the string _build/, but the report records the analysed root and the harness's scratch directory lives under _build/. It now asserts on ingested content — a foreign file name and a foreign function — and it was checked against a reverted fix to prove it can go red.

    A green result that describes nothing is worse than a red one, because it is indistinguishable from a real one. That is the whole reason the release is gated in CI rather than run by hand: the runner is a different machine, and these three defects were invisible on the machine that wrote the code.

    #Packages and targets

    PackageKindNativeWasmWasm-GCJS
    riantr/LunaFlow (root)executableyesyes—yes
    riantr/LunaFlow/src/applibraryyesyesyesyes
    riantr/LunaFlow/src/commandlibraryyesyesyesyes
    riantr/LunaFlow/src/cliexecutableyesyes—yes

    A — means the runtime does not implement what the package needs there. The analyzer needs the filesystem and process spawning, and the async runtime implements both on native and wasm only — on js and on wasm-gc the fs and process packages compile to nothing, so those call sites sit behind #cfg guards and the package builds without them.

    The wasm-gc column is measured with the target set widened past what the module publishes: the LIBRARIES compile and their 73 tests pass there, but the EXECUTABLES cannot, because the core's wasm-gc async stub has no run_async_main. So supported_targets is +native+wasm+js and wasm-gc is not published — publishing it would break the build.

    #Layout

    LunaFlow.png the mark this README shows LunaFlow_Logo.png the same artwork at 1024x1024 LICENSE MIT moon.mod module metadata (native, wasm and js) moon.pkg the ROOT package: the executable moonx resolves main.mbt the root entrypoint, a forwarder to src/command src/app/ 17 library files: parser, CFG, L1 interpreter, L2 abstract interpreter, L3 contracts, funnel, taint/slice, family roster, evolved value analysis, memory layout model, report renderer src/app/*_test.mbt 107 tests in 13 files src/command/ the CLI implementation, as a library src/cli/main.mbt the published CLI entrypoint fixtures/vuln_demo/ taint / dead-code / infinite-loop / contract fixture fixtures/eva_demo/ the evolved value analysis fixture: the defect classes and the ordinary shapes next to them fixtures/memory_demo/ the memory layout fixture: the global region, the allocation classes and the tail value features/ acceptance scenarios in Gherkin docs/design.md design, comparison, acceptance criteria (A1-A18) docs/qa/REPORT.md deep QA report (security, coverage, metrics) docs/host_requirements.txt the published wasm's host contract, measured reports/ generated reports for the validation modules scripts/verify.mjs dependency-free acceptance checker scripts/qa/ dependency-free QA harnesses

    The library has no external dependency. The filesystem and process access the CLI needs come from moonbitlang/core/async, part of the moonbitlang/core module the toolchain already bundles — so there is no version to pin, and no way for the build and the host to disagree about which runtime is in play. The frontend is a structural parser for the MoonBit subset the analyses need; match arms, impl bodies and functional for are counted as gaps, not silently dropped. A construct the parser has no rule for at all is folded into the statement it belongs to and counted as unsupported, so it cannot shred itself into statements of its own. A slice a[i:j] is modelled — it is one expression, and because the runtime clamps it, it raises no out-of-bounds claim.

    #Verification

    The gate, and the result it produced for this version:

    CommandResult
    moon fmt --checkexit 0
    moon check --target nativeexit 0, 142 warnings, 0 errors
    moon check --target wasmexit 0, 142 warnings, 0 errors
    moon check --target jsexit 0, 167 warnings, 0 errors
    moon test src/app --target native93 passed, 0 failed
    moon test src/app --target wasm93 passed, 0 failed
    moon test src/app --target js93 passed, 0 failed
    moon build src/cli --target nativeexit 0
    moon build src/cli --target jsexit 0
    moon build . --target wasmexit 0
    moon build . --target jsexit 0
    node scripts/verify.mjs87 passed, 0 failed
    node scripts/qa/gherkin.mjs7 passed, 0 failed
    node scripts/qa/e2e.mjs56 passed, 0 failed
    node scripts/qa/fuzz.mjs110 passed, 0 failed
    node scripts/qa/path_traversal.mjs7 passed, 0 failed
    node scripts/qa/deep_nesting_probe.mjs2 passed, 0 failed
    node scripts/qa/vendor_isolation.mjs13 passed, 0 failed
    node scripts/qa/cli_resolver.mjs3 passed, 0 failed
    node scripts/qa/root_package.mjs41 passed, 0 failed
    node scripts/qa/host_requirements.mjs108 imports, 107/107 covered
    node scripts/qa/core_compat.mjs12 declarations identical in 3 cores
    node scripts/qa/verify_published.mjs 0.1.114 passed, 0 failed
    moon 0.1.20261104, native backend, Windows 11, warm .repos cache. The measured runs committed with the reports:

    ModuleFilesStatementsL1 eventsL2 findingsL3 VCsDeadTaint sinksTime
    riantr/moonbit_doubleML@latest → 0.164.6422029,853218,3911660 (no specs)0734.1 s
    riantr/moonbit_static_analysis@latest → 0.4.4619912,97014,296430 (no specs)061.0 s
    fixtures/vuln_demo132604112 / 1 / 3364 ms
    LunaFlow itself (--dir .)333,70612,739312 / 1 / 3330.17 s

    Reproduction status (2026-11-14). The rows above are the committed artefacts, pinned to the versions resolved on the day they were generated. They were regenerated for this release, and every number in them moved: the previous rows said 43,822 statements, 137 dead-code findings and 102 taint sinks for doubleML, and 14,505 / 48 / 103 for the static-analysis module. The reason is in the next section — the old numbers counted the shredded fragments of constructs the parser did not model, and a shredded fragment ate the closing brace of its function, so whole function bodies were truncated. The committed numbers are now the honest ones: neither module has an unreachable statement, and the analyzer says so.

    Only LunaFlow's OWN row has moved since then, and only because the analyzer is one of the modules it analyses: 0.1.7 moved the statement count, 3,126 → 3,145, when the #cfg guards and the stubs behind them became statements, the 0.1.8 release moved it again to 3,150 and the L1 events from 12,560 to 12,552, because the analyzer now reads its own source through the rewritten argument parser, and 0.1.10 moved it once more — to 3,687 statements and 12,651 events — because the family roster and the evolved value analysis are new source, and because the type-annotation fix in the frontend recovered 58 statements that the parser had been dropping. The 0.1.11 patch moved it once more, to 3,706 statements and 12,739 events, because the out-of-bounds check moved into the evolved value engine; the two extra findings are the analyzer reporting its own new branches as unreachable for the abstract input. The 0.1.12 release moved it again, to 4,076 statements and 13,295 events, because the memory-layout model is new source and because the tail-value fix changed what the analyzer understands. The evolved value analysis now reports exactly one finding on LunaFlow's own tree, and it is the deliberate overflow in the memory fixture's global initializer — the library's own source has none. The other three rows have not moved at all.

    scripts/verify.mjs validates the committed artefacts by pinned file name; scripts/qa/e2e.mjs validates fresh runs against the same thresholds.

    The artefacts are regenerated with this recipe, and the slice criterion has to be spelled exactly — a criterion that names no function yields an empty slice and one fewer function run, so every number in the report moves while nothing looks broken:

    moon run src/cli --target native -- analyze --dir fixtures/vuln_demo \ --out reports --funnel --trials 4 --max-functions 120 --slice "i@copy_prefix" moon run src/cli --target native -- analyze riantr/moonbit_static_analysis@latest \ --out reports --funnel --trials 4 --max-functions 120 --slice "buf@render_reports" moon run src/cli --target native -- analyze riantr/moonbit_doubleML@latest \ --out reports --funnel --trials 4 --max-functions 120 --slice "theta_hat@aggregate_coef_se"

    The module version is part of the artefact's file name, so a new release of a validation module means a new file and a new pin in scripts/verify.mjs. One more trap: a run that starts while moon fetch is still extracting the module measures a partial tree, so the numbers are re-measured after the fetch settles.

    scripts/qa/ also carries the rest of the deep-QA tooling: warn_census.mjs (warning triage), metrics.mjs (size/complexity plus line coverage from the Cobertura report), dup.mjs (clone detection), load.mjs (peak memory and parallel throughput), depth_sweep.mjs (the nesting-degradation curve), vendor_isolation.mjs (a --dir scan must not ingest the vendored dependency tree), cli_resolver.mjs (the built CLI has to be found under either name the toolchain produces), root_package.mjs (the root package moonx resolves has to be an executable, has to build for wasm, and has to accept a bare coordinate), host_requirements.mjs (the published wasm's host imports have to be covered by the toolchain's bundled core), core_compat.mjs (every declaration this module uses has to be the same in every core a user may have) and verify_published.mjs (install the published version from a clean consumer and call its API — the check that a green gate cannot give you, because the gate checks the commit and this checks what the registry serves). The report of the last deep QA pass is docs/qa/REPORT.md — it covers 0.1.3 and is dated 2026-11-10, so it is a record of that pass and not a claim about this version.

    Two things deliberately not claimed. No production-load testing has been done, and no head-to-head comparison against another analyzer on the same workload has been run — the two analyzers were pointed at different modules, so the numbers are not comparable. The comparison baseline in docs/design.md §8 covers Frama-C / TrustInSoft Analyzer, Joern, CodeQL, SVF and Soot as a design comparison, not as a measured head-to-head.

    #The cross-check against another analyzer's module

    riantr/moonbit_static_analysis runs LunaFlow against its own source and reports what it finds. On 0.4.45 that was 136 findings: 37 dead-code, 98 taint-index, 1 budget. Every one of them was a false positive, and the adjudication is worth recording because the reasons are not what the report said.

    What the report got right. The 21 rows in src/flow/laws_test.mbt were multi-line return expressions whose continuation lines were parsed as statements of their own, and the ones after the return inherited its deadness. That was true, and it was LunaFlow's bug.

    What the report got wrong. The 16 rows in src/parser/parser.mbt were attributed to a nested return being treated as an exit of the enclosing block. They were not: the cause was a struct literal being shredded into one statement per field. And the observation that the verdict "is not a function of the file's own bytes" had no mechanism until the positional 600-function CFG window was found — a file's findings depended on how many functions preceded it, and the window is now disclosed in the report and configurable with --max-cfg-functions.

    The five root causes, all in the frontend.

    1. A struct literal was not modelled, so { a: x, b: 2, } became one statement per fragment, and it ate the enclosing block's closing brace — which truncated the function.
    2. A binary expression did not continue across a newline, so each continuation line became its own statement.
    3. A match expression leaked its arms into the enclosing statement list.
    4. :: was lexed but not parsed, so return CART::Leaf(...) left the tail behind as statements. :: is not a list-cons operator in MoonBit — 1 :: [2] is a parse error in the real toolchain — so it is only ever a qualified path.
    5. A string or character literal whose content was { was counted as a brace. match rsplit_at(seg, "{") { stopped the arm scanner at the string, the block ran on past the end of the function, and the functions after it were lost.

    Two more were found while fixing those: a for that destructures (for k, v in entries) left its loop body behind as a block, and an if without an else was treated as an unconditional exit, so the code after a guarded return was called unreachable.

    The repair is two layers. The constructs above are now modelled — the qualified path, the qualified record literal, the destructuring for, the named argument, the if expression, the function literal. And the parser no longer lets an unmodelled construct shred itself: a tail it has no rule for is folded into the statement it belongs to and counted as unsupported, instead of becoming statements of its own. The second layer is the one that generalises — without it the next construct the parser does not know would shred itself again, and the dead-code verdict would come back with it.

    Measured, on identical trees.

    published 0.1.40.1.5
    moonbit_static_analysis@0.4.45 — dead code370
    moonbit_static_analysis@0.4.45 — taint sinks985
    moonbit_doubleML@0.164.64 — dead code1370
    moonbit_doubleML@0.164.64 — statements43,82229,853
    moonbit_doubleML@0.164.64 — frontend coverage99.87 %98.94 %

    The statement count went down because it was wrong before: the old number counted the shredded fragments, and a shredded fragment ate the closing brace of its function, so whole function bodies were truncated. A per-function cross-check against the published build confirms it — the two builds agree on all 2,514 functions of doubleML, and the 184 functions where the new count is higher are the ones the old build truncated (sensitivity_analysis was 3 statements, it is 13).

    The taint sinks fell for two different reasons, and the two are worth telling apart. 93 of the 98 rows on the peer module came from just two sites: the sink list was deduplicated by whole-finding equality, and the observed value is in the evidence, so one read observed in many trials became dozens of rows. The sinks are now deduplicated by site. The other 5 were a bounds question the old analysis did not ask: a tainted index whose observed value was inside the array is now a warning that says so, and only an index that was witnessed out of range — or one whose bounds could not be resolved — stays an error.

    What is still not covered. The CFG pass builds the CFG for the first 600 functions in file order, so the other 1,920 functions of doubleML can carry no CFG finding; the report says so in the ingestion table rather than letting "no findings" read as "clean". The taint synthesizer's string candidates carry no :: or / shapes, so branches that depend on those shapes are not explored. Nested function declarations are consumed as one unit and are not analysed on their own. And the peer's own attribution of the 16 parser rows was wrong in a way that would have sent the next fix in the wrong direction — which is why the correction belongs here and not only in the fix.

    And the other direction: the peer, pointed at this module. The same command with the arguments the other way round — moonx riantr/moonbit_static_analysis@latest riantr/LunaFlow@latest — asks the peer to scan this source. It reports 3 actionable findings against 229 rows: an unused parameter and two unused locals, all three real, all three confirmed by the MoonBit compiler. They are fixed in the revision above, and 0.4.46 reports them exactly as 0.4.45 did. The remaining 215 rows are the peer's own scope notes, not defects: 215 of them say that an if expression, a match arm, a struct, an enum or a test declaration is outside the subset the peer analyses — and the if expression is a construct this module models and the peer does not. The peer also cannot parse one file of this module (src/command/command.mbt), and it says so rather than guessing.

    That one file is a shared blind spot, and both sides were wrong about it. MoonBit's multiline string literal -- the #| form -- is a string, not a comment: #| opens it, the text after the marker is the first chunk, every following line whose first non-blank characters are #| is one more chunk, and the value is the chunks joined with "\n". There is no |# terminator (a bare # is a lexing error in MoonBit), and a bare #| line is an empty chunk, not the end of the literal. The peer's model trims the chunks and joins them with a space, and reads the bare #| as the closer, so it stops the literal two lines into usage() and reads the rest as code. This module's model was wrong in a different way: it took the rest of the first line and left the rest of the literal in the token stream, so usage() was shredded into thirty-odd statements and the function was not recognised at all. The semantics were settled against the toolchain by value -- "does moon check accept it" measures nothing here, because the toolchain accepts every shape of this construct -- and usage()'s value is now byte-identical to the toolchain's (1569 characters, same hash).

    #What this is not

    • Not sound. L1 reports what happened on synthesized inputs. L2 reports what the abstract input set cannot exclude. L3 proves individual VCs from specifications. Absence of a finding is never a proof.
    • Not a full MoonBit frontend. Unsupported syntax is measured and reported (1.06 % of statements on doubleML, 4.39 % on static_analysis). Expression and statement nesting beyond 256 levels is refused as a recorded parse gap rather than risked on the native stack.
    • Not alias-aware. Taint is field- and index-insensitive, with a depth-6 interprocedural cutoff; loop-counter implicit taint is deliberately conservative and can over-report.
    • Not trace-complete. Slices and ranges cover the recorded trace only. Function names are not package-qualified, so a name-only criterion can match several packages (three packages define render_reports in static_analysis).
    • Not SMT-backed. L3 is a WP-style VC generator with an interval decision procedure. Loop invariants are not yet placed per loop, so loop-dependent goals are havocked to unknown. The //@ syntax is ACSL-inspired but adapted to MoonBit comments; ACSL itself targets C.
    • Not a database. Reports, traces and summaries are plain files: diffable, human-readable, reproducible. riantr/tesseract was evaluated as a storage dependency — 0.1.17 raises the durable-memory ceiling to 9999 entries, but its dependency footprint is ~28 MB, so it is not adopted. If cross-run queries are needed, an optional SQLite sidecar is the smaller change.

    #Quick Start

    # analyze a published module; @latest is resolved once and pinned moon run src/cli --target native -- analyze riantr/moonbit_doubleML@latest --out reports # the full funnel, with a slice on a variable moon run src/cli --target native -- analyze riantr/moonbit_doubleML@latest \ --out reports --funnel --slice "theta_hat@aggregate_coef_se" # a local checkout, no registry resolution moon run src/cli --target native -- analyze --dir fixtures/vuln_demo \ --out reports --funnel --slice "i@copy_prefix" # focus on one function in a large module moon run src/cli --target native -- analyze riantr/moonbit_static_analysis@latest \ --function render_reports --slice "buf@render_reports" --out reports # compiled coverage, when the target builds moon coverage analyze moon coverage report -f cobertura -o cov.xml moon run src/cli --target native -- analyze <coord> --coverage cov.xml --out reports

    The binary is _build/native/debug/build/src/cli/cli.exe, and the root package's is _build/native/debug/build/LunaFlow.exe. From the registry, there is no install step at all:

    moonx riantr/LunaFlow@latest riantr/LunaFlow@latest

    #Example

    The funnel section is computed, not narrated. This is the fixture run:

    L1 concrete: 12 findings L2 abstract: 11 findings, 4 new L3 contracts: 2 proved / 1 failed / 3 unknown

    flowchart LR L1["L1 concrete: 12 findings"] -->|"dedup, escalate"| L2["L2 abstract: 11 findings, 4 new"] L2 -->|"critical functions"| L3["L3 contracts: 2 proved / 1 failed / 3 unknown"]

    The contract table for the same run (excerpt):

    FunctionVC kindStatusGoal
    get_checkedindex-boundsproved((n >= 0) && (n < values.length()))
    get_checkedensuresunknown(values[n] >= 0)
    get_unsafeindex-boundsunknown((n >= 0) && (n < values.length()))
    get_negativeindex-boundsfailed((n >= 0) && (n < values.length()))
    doubleensuresproved((n + n) >= 0)

    get_negative's contract says n < 0, which refutes 0 <= n; that is a failed proof, reported as one. get_unsafe has no upper bound, so the index condition is unknown rather than guessed.

    #Gates and drift detectors

    • moon test pins the fixture's static findings: 3 dead, 1 unreachable, 1 infinite loop, and the L3 proved/failed/unknown split.
    • scripts/verify.mjs fails on missing sections, invalid Mermaid structure, empty slices, missing L2/L3 counters, coverage below 95 %, or a runtime above 60 s.
    • scripts/qa/gherkin.mjs states the acceptance criteria as executable scenarios, including the two hostile-input regressions.
    • docs/design.md §10 lists the quantified acceptance table (A1–A18); the measured column is updated from real runs, not from intent.
    • Determinism: two runs produce identical summary.json except elapsed_ms, and a byte-identical trace.

    #Used By

    • src/cli — the executable in this module, the command line over the same library.
    • src/command — the CLI implementation, shared by src/cli and the root package that moonx resolves.
    • scripts/qa/ — twenty dependency-free harnesses and tools; thirteen of them drive the built binary as a real process against the fixture, the two registry modules and a hostile-input corpus.
    • docs/design.md — the design record for the implementation in src/app.

    Outside this repository: none that the registry knows about. The registry lists 0 dependents for riantr/LunaFlow, and nothing is claimed here that cannot be named.

    #Attribution

    • License: MIT (see LICENSE).
    • The documentation layout of this README follows the riantr/tesseract module, whose release notes are a model of stating exactly what changed and what did not.
    • The comparison baseline in docs/design.md §8 covers Frama-C / TrustInSoft Analyzer, Joern, CodeQL, SVF and Soot. Every place this module answers differently from those tools is a stated limitation in What this is not, not an omission.

    Source Files