Sign in

    moonsize

    A Wasm size profiler for MoonBit — section breakdown, function ranking, call graph and retained-size analysis

    wasm
    webassembly
    size
    profiling
    cli
    Download zip
    Version
    0.5.1
    License
    MIT
    Last updated
    3 days ago
    Downloads
    62

    #moonsize

    moon add BigSaltyMan/moonsize@0.5.1

    A size analyzer for WebAssembly, built for MoonBit's output. It reads a .wasm file, measures every section, attributes the code section to named functions, follows a reference graph, and highlights candidates for investigation. It does not predict the exact bytes a source deletion will save after rebuilding.

    The analysis model follows Twiggy: sizes are attributed to individual functions, functions are grouped by package, and reachability is calculated from the current model's roots. Read the retained-size boundaries and measured rebuild cases before interpreting a removal candidate.

    Chinese version: README.zh.md.

    Version 0.5.0 adds data-entry inspection, exact-payload group comparison and opt-in HTML details. It also changes report writes to exclusive creation; read the 0.5 migration guide for behavior and API changes. The 0.4 migration guide remains historical. The moon add command adds a library dependency; it does not install a standalone CLI. For the CLI, clone this source repository and run the build below.

    The review evidence index links measured rebuild cases, real PDFs and explicit model boundaries. The 0.4.0 release record records the exact source SHA, target Linux CI, registry checksum and clean consumer verification; historic devlog counts are snapshots.

    The source here prepares 0.5.1. Publication, exact-version consumption and the target CI result are recorded separately in the 0.5.1 release record when completed; this document is not a publication receipt. The published 0.4.0 HTML/graph writer retains its old behavior.

    Patch 0.5.1 removes the obsolete JSON-manifest dependency chain that blocked Mooncakes documentation for 0.4.0/0.5.0. It ships the pinned synchronous gzip implementation with source hashes and Apache-2.0 attribution, keeping its algorithm and defaults. See toolchain compatibility.

    #Building

    Reproducible CLI performance baseline: run the built native CLI against four bounded representative inputs, or use the two-input --smoke check. Raw times, hashes, failures and environment are retained. CI runs smoke only, without a millisecond gate; 50k functions and peak memory are not claimed as measured by this local baseline.

    moon build --target native --release ./_build/native/release/build/cmd/main/main.exe app.wasm

    The module declares supported_targets = "+native", so a project that depends on it builds with --target native as well.

    #Data segment encodings

    moonsize app.wasm --data-segments --top 20 moonsize app.wasm --data-json new-data.json moonsize app.wasm --html new-report.html --data-json new-data.json

    The 0.5.0 data-segment feature accounts for data-entry payload, encoding metadata and section overhead in file bytes. Active offset expressions are recorded without evaluation; no source attribution or deletion savings are inferred. Text Top does not truncate the JSON or totals. It supports ordinary text, strict, max-size and baseline (single-file details unless --diff-data is enabled), and rejects graph, experiment, path, budget and function-diff combinations. HTML accepts current-input details explicitly: a static first 20 rows remain readable without JavaScript, and the button appends 20 at a time. HTML and JSON share the same ledger. Printing includes the displayed subset; --top remains a text-only limit. HTML does not accept --diff-data. See encoding ledger, real sample and API migration.

    With --baseline before.wasm --diff-data, the text report also compares exact payload content groups, including duplicate counts and encoding overhead. Add --data-json new-diff.json for the independent moonsize-data-segments-diff format; without --diff-data, JSON keeps the original single-file format. Groups do not establish source identity, equivalent initialization or safe deduplication, and raw differences do not predict rebuild or gzip savings.

    #Visual report development

    Start the local HTTP server before visual acceptance, screenshots, printing, or browser tests:

    npm run dev

    Then open http://localhost:5173/. The root serves examples/report.html; use this HTTP URL for all browser-based checks rather than opening the report with file://.

    #Usage

    usage: moonsize <file.wasm> [--top <n>] [--retained] [--dead-code] [--compress] [--strict] [--call-graph <path>] [--html <path>] [--max-size <size>] [--baseline <path.wasm>] [--diff-functions] [--data-segments] [--diff-data] [--data-json <new-path.json>] [--budget <config.json>] [--budget-json <path.json>] [--budget-md <path.md>] [--experiment <report.html|report.json>] [--experiment-context <context.json>] moonsize <file.wasm> --why-index <n> [--why-root <n>] [--why-json <path.json>] [--strict] Report the section table of a WebAssembly binary, rank its heaviest functions, roll the bytes up by module, and follow the call graph to identify candidates for investigation. --top <n> how many functions to rank (default 10) --retained dominated function bytes in the current graph model --dead-code functions no root can reach --compress whole-file gzip and independent section gzip sizes --data-segments append data-entry file bytes (payload-ranked by --top) --data-json <path> enable details and write all entries to a new .json file offset expressions are recorded, not evaluated --diff-data compare exact payload content groups; requires --baseline --data-json then writes the data comparison format --strict refuse to report when the call graph is incomplete, instead of reporting it with a warning --call-graph <path> write a new .dot or .json call graph; refuses existing paths --html <path> write a new self-contained HTML report; refuses existing paths --data-segments or --data-json adds current-input data details --max-size <size> fail with exit code 3 above this size, where size is bytes or a KB/MB/GB count such as 10KB or 1.5MB --budget <path> validated multi-metric byte budgets; growth needs --baseline --budget-json <path> write budget JSON; requires --budget, refuses existing paths --budget-md <path> write CI Markdown summary; requires --budget, refuses existing paths --baseline <path> compare against another module and print the change; --max-size then bounds the growth, not the file --diff-functions append function and package changes; requires --baseline --experiment <path> write measured before/after results; requires --baseline refuses to overwrite an existing path --experiment-context <path> attach declared build settings and validation --why-index <n> explain a shortest model reference path to function n --why-root <n> search from one actual model root (requires --why-index) --why-json <path> write path JSON; refuses existing paths query mode allows only these flags and --strict element functions are conservative roots; paths are not execution traces or predictions of savings All report outputs refuse existing files, directories and link aliases. Targets are checked together; each file is created exclusively. Output errors use exit 1. Multiple files are not a transaction; a later failure may leave earlier new outputs. Exit codes: 0 completed, 1 input/query/output/unavailable measurement, 2 bad arguments/config, 3 over budget, incomplete strict graph, or path search limited.

    Without flags it prints the section table, the heaviest functions and the per-package roll-up. --retained and --dead-code add their sections and can be combined. --call-graph and --html each write a file instead of printing a report, so they are used on their own:

    moonsize app.wasm --retained --dead-code # the full text report moonsize app.wasm --html report.html # charts, opens in a browser moonsize app.wasm --call-graph graph.dot # for Graphviz

    All report destinations, including HTML and DOT/JSON graphs, refuse existing files, directories and link aliases. Output protection failures return 1. Targets are checked together after parsing and strict refusal; each write uses native exclusive creation. This prevents truncation if a target appears after preflight, but is not a multi-file transaction or crash-atomic write. Later failure can leave earlier new files or a partial new file; hostile parent-directory changes remain outside this guarantee. See protection and concurrency boundaries. Generate an update at a fresh staging path, verify it, then explicitly replace the published copy yourself. No production Node dependency or overwrite flag is added.

    #Optimization experiments

    moonsize after.wasm --baseline before.wasm --experiment experiment.html \ --experiment-context context.json --top 20

    The offline HTML report (or .json for CI) combines artifact SHA-256, measured file and whole-file gzip changes, section/function/package differences, graph diagnostics, and declared build/validation records. Different build settings, failed checks, and missing records remain visible; smaller output does not prove equivalent behavior. Context commands are recorded, never executed. Existing output paths are refused. See the format and reproducible workflow and sample report.

    #Multi-metric budgets and CI summaries

    Use a versioned JSON configuration to check several measured byte limits:

    moonsize after.wasm --baseline before.wasm --budget moonsize-budget.json \ --budget-json budget-result.json --budget-md budget-summary.md

    Version 1 has a nonempty rules array. Every rule needs a unique nonempty id, metric, mode and integer max_bytes (0 through 2147483647). Supported metrics are file.raw, whole-file file.gzip, complete code.raw, and package.raw; the last also requires an exact package label from the report. Unknown fields, fractional/exponent tokens and invalid values are rejected before reading Wasm. absolute checks current bytes; growth checks current minus baseline and requires --baseline. Equality passes and shrinkage retains its negative delta.

    Package bytes sum local function total_size under existing symbol attribution, including body size prefixes. Missing names or unresolved ownership make package rules unavailable. A fully attributed growth comparison can record a newly observed/disappeared label as zero on the missing side; a label absent on both sides is unavailable. Successful file/code/gzip measurements are retained. Package totals are not total source-package costs or deletion savings; gzip is one whole-file measurement with the current compressor defaults, not predicted network cost. Independent gzip streams are never added.

    Each rule reports passed, exceeded or unavailable, its measurements and reason. Exits are 0 for all passed, 3 for evaluable rules with an exceeded limit, 1 for input/measurement/attribution/output errors (taking precedence over exceeded), and 2 for arguments/configuration. Strict retains its existing refusal code 3 before normal reports are written. JSON/Markdown refuse existing destinations; they include artifact hashes, coverage and metric scope. Both outputs can be requested together. --budget conflicts with --max-size, path queries, experiments, HTML and graph export; ordinary text options remain available.

    Configuration, APIs, migration and exact semantics include a reproducible source demo and the actual demo configuration. Run it before this example, whose package label belongs to that demo:

    node scripts/demo-budget.mjs _build/native/release/build/cmd/main/main.exe _build/native/release/build/cmd/main/main.exe _build/budget-demo/after.wasm \ --baseline _build/budget-demo/before.wasm --budget examples/budget.json

    CI runs the budget regression and source/rollback demo, saves JSON/Markdown, and applies file/gzip/code budgets to the freshly built example. Its generated Markdown is also appended to the CI step summary.

    #Explaining a function's reference path

    After locating a large function's index in the report, ask which modeled entry can introduce it:

    moonsize app.wasm --why-index 47 moonsize app.wasm --why-index 47 --why-root 0 moonsize app.wasm --why-index 47 --why-json function-path.json --strict

    Indices above are illustrative. The query returns one deterministic path with the fewest edges. Roots are ordered by function index; outgoing edges by target index, then direct/reference/indirect kind, then original instruction offset. Every step retains its actual opcode (call, return_call, ref.func, call_indirect, or return_call_indirect) and file offset. Indirect edges are known candidates; ref.func takes a reference rather than proving a call. Unmodeled call_ref targets are not guessed, and graph diagnostics remain visible even when a path is found.

    The default searches all existing model roots: function exports (including all aliases), the start function, and conservative element references. If the target is itself an element root, a zero-edge path is expected and its source is shown. --why-root selects one actual model root without changing global reachability or retained sizes. Failure from that entry does not imply global unreachability. Root evidence records the section start and export/element ordinal; these are explicitly section positions, separate from instruction offsets.

    Results distinguish found, not-found-within-model, and search-limited. An incomplete graph may still yield either of the first two; an absent path is then particularly inconclusive. Imports have encoded_size: null because their bodies are outside this module. Path JSON has its own format moonsize-function-path and schema_version: 1, structured root reasons, ordered nodes/steps, diagnostics, limits and search counters. Existing JSON paths are refused. Strict graph refusal occurs before creating a report.

    Query mode accepts only --why-index, --why-root, --why-json and --strict; other report, comparison, experiment and budget flags conflict before input is read (including an explicit --top). Exit codes are 0 for a completed query (found or absent), 1 for input/index/root/output errors, 2 for invalid arguments, and 3 for a limited search or strict refusal of an incomplete graph.

    The library entry is explain_function(data, target_index, root?, limits?); render with render_function_path or render_function_path_json, and describe errors with describe_function_path_error. It builds the canonical graph from bytes without computing dominators. PathLimits::new() defaults to 100,000 nodes, 1,000,000 edges and depth 256. The node limit separately bounds prepared roots and visited nodes; the edge limit separately bounds root-source records, indexed edges and examined edges. Preparation limits return search-limited too. These limits do not bound binary parsing or construction of the existing call graph; sorting also has its own cost. Existing parsing/analysis signatures are unchanged; Options gains the three optional query fields. Clients inspecting options or matching the new status/error enums should handle the added fields/variants.

    This is a path in the current reference model, not an execution trace or a prediction of recompiled or gzip savings. Measure actual changes by rebuilding both complete artifacts.

    node scripts/test-function-path.mjs _build/native/release/build/cmd/main/main.exe node scripts/demo-function-path.mjs _build/native/release/build/cmd/main/main.exe

    The source demo discovers emitted indices instead of hardcoding them, queries an exported entry through path_upstream to path_target, removes the upstream call, rebuilds, writes an optimization experiment, then restores the source and checks exact bytes and path evidence. Base output is preserved; optional output is intentionally removed, so this is not a claim of full behavioral equivalence. Sources, paths, hashes, outputs and before/after experiment reports stay under _build/function-path-demo/; CI uploads this evidence. Path bytes are never summed as a deletion benefit.

    #Using the library

    The parsing, the analysis and every report are ordinary packages in this module, so a program can take the numbers instead of the text. moon addBigSaltyMan/moonsize and import it; Options::default is the way in, because Options is read-only and no other package may write one.

    fn main {
    let bytes : Bytes = read("app.wasm")
    match @moonsize.parse_module(bytes) {
    Ok(parsed) =>
    match @moonsize.analyze(bytes, parsed) {
    Ok(analysis) => {
    let options = @moonsize.Options::default("app.wasm")
    // What the five heaviest functions cost, as text or as numbers:
    // `analysis.stats` and `analysis.retained` are there to be read.
    println(@moonsize.render_top_functions(analysis.stats, analysis.data.length(), 5))
    println(@moonsize.render_graph_warning(analysis.graph))
    println(@moonsize.render_dead_code(analysis, 5))
    println(@moonsize.render_retained(analysis, 5))
    println(@moonsize.render_html(analysis, options.path))
    }
    Err(error) => println(@moonsize.describe_error(error))
    }
    Err(error) => println(@moonsize.describe_error(error))
    }
    }

    The library does no I/O, so reading the file and choosing an exit code stay with the caller. supported_targets = "+native" applies to a program that depends on this module too: build it with --target native.

    #Analysis

    Everything below is built on one idea: a function's bytes only matter if something can call it.

    #Symbol names

    Attribution starts with the names in the binary's name section, and MoonBit does not publish how it mangles them, so the rules here were read back from the name section of real builds (moonc 0.1.20260920, the wasm backend):

    • a symbol is _M0<kind> and then a path: F a function, M a method, I a trait implementation;
    • the package is B for the builtin package, C for moonbitlang/core, or P and an index — which runs into the first component's own length, so P55probe is index 5 followed by 5probe;
    • a component is its length and then its name, and the length counts the mangled name;
    • anything that cannot appear in an identifier is escaped as _ and the byte in hex, and a literal underscore is doubled: to__string_2einner is to_string.inner, _24default__impl is $default_impl, and a non-ASCII character is escaped one byte at a time, so 中 is _e4_b8_ad;
    • a generic instantiation carries G...E with one code per argument, where a builtin is a single letter, a tuple is U...E, and a named type is R and a path;
    • a closure is either an environment named __moonbit_<fn> or a body carrying C<id>l<line>, the id of the closure and the line it was written on;
    • a trait implementation names two packages, its type's and its trait's.

    The reports print the path through display_name. demangle_full is the fuller decoder, for callers that want escapes resolved, arguments spelled out and closures marked. Both are display aids rather than a codec: when one cannot decode a symbol exactly it returns it untouched, because a report that invents a name is worse than one that shows a raw symbol. The known limits are where that happens — a generic argument whose type code is not one of the letters a build produced (Int, Double, String, Bool, Char, Byte, Float, Int64, UInt64, Unit) is left undecoded rather than guessed, a named argument is only decoded as the last one in its list because its path has no terminator to separate it from what follows, and a symbol over 4096 bytes is refused outright so a forged name cannot drive the walk into a deep recursion.

    #The call graph

    The decoder walks every function body instruction by instruction — the full MVP instruction set, the 0xFC saturating and bulk-memory opcodes, the exception handling and tail-call opcodes MoonBit emits, and the 0xFB garbage-collection opcodes its wasm-gc backend uses. Each body contributes:

    • call x and return_call x — precise edges to x;
    • ref.func x — a reference, which counts as reachable because the function may run later as a closure;
    • call_indirect and return_call_indirect — conservative edges to static element candidates in the selected table;
    • call_ref and return_call_ref — recognised call sites with an UnmodeledReferenceCall diagnostic. Target analysis is not implemented, including when ref.func immediately precedes the call. No target is guessed.

    Static element segments supply known candidates, not proof of a complete target set. Imports, exports, runtime writes or unmodelled explicit initializers can make that set incomplete. This version conservatively diagnoses each such risk for ordinary and tail indirect calls, even when candidates are nonempty; known candidate edges remain in the graph. Calls with no static candidates also retain their separate diagnostic. Dead-code and retained-size results may be inaccurate. The report says so above everything else:

    WARNING: the call graph has 1 analysis gap. function 0, offset 42: call_ref: reference-call target analysis is not implemented. Dead-code and retained-size results may be inaccurate; do not use them as proof that a function can be removed.

    --dead-code and --retained repeat it under their titles, and --strict turns these recognised incomplete cases into a refusal — no report, exit code 3.

    Writes are collected from all functions before checking any table call: table.set, table.init, table.copy (destination only), table.grow and table.fill. Reads via table.get and table.size are not writes. A writer that appears after the call, or in an unreachable function, still causes a conservative refusal: this version does not prove whether that write executes. Imported tables precede local tables in their own index space, independently of function imports. Explicit initializer expressions are consumed completely and reported as unmodelled; their targets are not guessed.

    call_ref and return_call_ref make the graph incomplete even if every table call has candidates. analyze still returns Ok(Analysis): a modelling gap is distinct from malformed input. Diagnostics retain their category, full caller index, file offset, actual opcode, type index and optional table index.

    graph_complete means no gaps were detected within the current scope: analysis.graph.diagnostics is empty. Dynamic table writes, host-provided contents and explicit initializers are diagnosed but not solved. Other Wasm modelling limitations remain. A successful --strict run is not a completeness certificate.

    A function is a root if the outside world can reach it: it is exported, it is the start function, or it is referenced by an element segment. Reachability and retained sizes describe paths in the current graph model.

    #Parser bounds and Reader API

    Reader::read_u32_leb() returns Result[UInt, WasmError] across the full 0..4294967295 range. Non-shortest encodings remain accepted within five bytes; illegal fifth-byte high bits or continuation produce LebOverflow(offset). Lengths, counts, indices and integer instruction operands pass through one checked internal conversion and currently support 0..2147483647. Larger valid u32 encodings produce ValueOutOfRange(offset, value), an implementation limit rather than a claim that the unsigned encoding is invalid Wasm.

    Reader::new(data) still covers the whole buffer. The new Reader::new_range(data, start, length) returns Result[Reader, WasmError] and restricts reads to [start, start + length). Negative or out-of-buffer requests return InvalidReadRange(offset, length, start, end). seek(offset) now returns Result[Unit, WasmError]; handle that result when migrating callers. Seeking to the end is allowed; reading there returns UnexpectedEnd. Failed seek and read_bytes operations leave the cursor unchanged. Every reported offset is absolute within the file.

    Section payloads, function bodies, locals, instruction immediates and optional name subsections use bounded readers. The outer custom-section name is structural and must fit its payload. Optional name metadata remains tolerant of damaged entries, preserving earlier names without borrowing following bytes. Parsing and range failures return CLI exit code 1, including with --strict, and never create or overwrite JSON, DOT or HTML reports. They are distinct from call-graph gaps refused with code 3.

    parse_module accepts Core Wasm binary version 1 only and checks standard section uniqueness and order. Custom sections may repeat, including with the same name, anywhere between standard sections. The explicit order is Type, Import, Function, Table, Memory, Tag, Global, Export, Start, Element, DataCount, Code, Data; numeric section ids alone do not give this order.

    Local function declarations and code bodies must have equal counts; imports are excluded. Both payloads must be consumed completely, and every code body must fit its declared range. Missing sections count as zero. Current inputs and --baseline use the same checks before reports are printed or written. analyze(data, parsed) also revalidates the bytes and derives its section table from them, so stale or modified metadata cannot bypass these checks.

    These are the basic structure checks needed for size analysis. The tool does not yet provide full WebAssembly type and instruction-semantic validation. Type/call index validity, signed LEB numeric semantics and full 64-bit constant storage remain limited; dynamic table analysis also retains its documented limitations.

    Parsing function signatures are unchanged. External exhaustive matches on WasmError must handle five new variants:

    VariantFields
    UnsupportedVersionversion-field offset, raw unsigned UInt value
    DuplicateSectionsecond section's id-byte offset, kind
    InvalidSectionOrdercurrent id-byte offset, kind, preceding standard kind
    FunctionCodeCountMismatchrelated section's id-byte offset, kind, local declaration count, body count
    TrailingSectionBytesfirst unconsumed payload byte offset, kind

    Version errors point to offset 4; mismatch errors use the code section when present, otherwise the function section. These failures return input error 1, including under --strict, rather than graph-incompleteness code 3.

    #Diagnostic API and export migration

    build(data, bodies, elements) is replaced by build(data). Pass complete module bytes: the public entry parses canonical bodies, elements and table context, so callers cannot omit writers or imply completeness with an empty context. analyze(data, parsed) keeps its signature and uses the same internal rules. count_imported_functions still counts function imports only.

    CallGraph.diagnostics counts reason entries, not call sites. One call can have multiple reasons or distinct writer sources. Export aliases collapse to one host-exposure reason per table; runtime writers deduplicate by file offset. Analysis.unresolved_count is unchanged: only table calls with no static candidates, even when other reasons make the graph incomplete.

    External exhaustive matches on GraphDiagnosticKind need the new variants:

    VariantStable JSON code
    NoStaticTableTargetsno_static_table_targets
    UnmodeledReferenceCallunmodeled_reference_call
    ImportedTableContentsimported_table_contents
    ExportedTableContentsexported_table_contents
    RuntimeTableMutationruntime_table_mutation
    UnmodeledTableInitializerunmodeled_table_initializer
    UnknownTableContentsunknown_table_contents

    The last reason covers a target absent from the parsed context, without claiming full index/type validation. GraphDiagnostic.source : GraphDiagnosticSource? records provenance separately from the call's own caller, offset, opcode. Writer sources have their full function index, opcode and absolute file offset. Import/export sources point to their descriptor kind byte; initializer sources point to the expression start, with no function/opcode. Unsupported or malformed table encodings return InvalidTableEncoding(offset, reason) (CLI 1), while analysis gaps return Ok(Analysis) and strict refusal 3. Table metadata handles multi-byte reference types and i32 limits; table64/shared limits are explicitly unsupported rather than silently misread. Exhaustive WasmError matches must add InvalidTableEncoding.

    scan_calls now returns CallKind::Reference for call_ref and return_call_ref, with type_index = Some(type) and the original offset. External exhaustive matches on CallKind must add this branch. ref.func continues to create a reference edge, not a call site.

    Use render_graph_warning(analysis.graph), render_dead_code(analysis, top) and render_retained(analysis, top). The last two read diagnostics internally; the old separate arrays, file-size argument and optional unresolved count are removed. The library example above and _probe/ use these signatures.

    JSON exports retain module, functions, roots, edges and indirect_sites, and add schema_version: 1, graph_complete, modeling_scope and diagnostics. Each diagnostic has code, caller, offset, opcode, type_index and table_index (null for reference calls). Provenance-bearing reasons add source: {caller, offset, opcode}; metadata sources have null caller/opcode. Existing no-target/reference diagnostics retain their original shape. Complete graphs carry an empty array. Diagnostics sort by caller, call offset, stable reason code, then source offset. JSON and HTML keep schema version 1 with these additive reason/source fields.

    Non-strict JSON, DOT and HTML exports carry their own limitations: JSON contains structured diagnostics and scope, DOT has a graph-level warning label, and HTML shows diagnostics at the top and limits alongside dead-code results. Sharing an export preserves this information. Strict refusal occurs before writing a new file or changing an existing one, for all three formats.

    #Retained size

    SIZE is a defined function's encoded size, including its body-length prefix. RETAINED sums those encoded sizes for the reachable defined functions dominated by the selected function in the current graph/root model, including itself. DIES counts the other functions in that model set.

    The dominator computation answers a question about a fixed graph: every path from a root to a dominated function passes through the selected function. Its graph result does not model editing callers, recompiling, changing indices or removing data and metadata. Element functions are conservatively rooted even for private/passive/declarative segments. A clean diagnostic list or successful --strict therefore does not turn this result into exact deletion savings.

    Reading the table:

    • RETAINED == SIZE — the model attributes no other function's encoded bytes to this dominator; an actual source change may affect other sections and callers.
    • RETAINED ≫ SIZE with a high DIES — investigate the corresponding dependency branch, then rebuild to measure the actual effect.
    • IND — the function can be reached through a call_indirect, so removing it requires checking every dependent table entry and indirect call before removal.

    Actual raw-file reduction is before.wasm size minus rebuilt after.wasm size. Whole-file gzip reduction is measured separately on the two complete files with the same compressor settings. Neither is predicted by retained size or by summing independently compressed functions. Reproducible cases and the exact definitions are in the boundary report.

    #Dead code

    The list contains functions unreachable from roots within the current model. Inspect diagnostics and modelling limits before considering removal; an incomplete graph does not prove a function can be removed.

    MoonBit's compiler does dead-code elimination well, so a small program usually reports none — that is a finding too.

    #Compressed size

    --compress measures the complete Wasm file as one gzip stream using the current compressor defaults. Actual servers may use gzip with other settings, another content encoding, or no compression. Section measurements are independent gzip streams for observing local compressibility.

    Measured from examples/fib.wasm with --compress:

    COMPRESSED SIZE raw 10675 B gzip 5207 B (48.7% of raw, ratio 2.05x) BY SECTION — INDEPENDENT GZIP STREAMS SECTION RAW GZIP RATIO code 5166 2712 1.90x custom (name) 4975 1983 2.50x data 252 174 1.44x custom (producers) 71 95 0.74x type 59 66 0.89x function 50 63 0.79x import 37 61 0.60x export 21 45 0.46x global 13 34 0.38x element 8 32 0.25x table 7 31 0.22x memory 5 29 0.17x datacount 3 27 0.11x Section gzip sizes are measured independently and are not additive. They do not represent shares of the whole-file gzip size.

    Each section range includes its id, length prefix and payload, including a custom section's name. HTML function gzip covers the encoded function range including its length prefix (total_size / HTML size); its ratio uses that same raw range, not bodySize. RATIO is raw bytes divided by gzip bytes. Small streams may grow because each has its own header and trailer; their actual size and ratio below 1 remain visible.

    Independent streams are not additive and do not represent whole-file transfer shares or the bytes saved by removing a function. Gzip can exploit redundancy across ranges when compressing the whole file. To measure an optimization's benefit, compare the complete build artifacts before and after the change.

    A failed measurement stays a failure: text shows failed and a reason for the specific section; a failed whole-file card shows N/A with its own reason. Other successful measurements remain available. HTML sections, functions and treemap leaves pair gzip with gzipError: success is a number and null; failure is null and the local reason. This additive HTML data extension and its compression_scope explanation are separate from call-graph JSON exports. Public parsing and compression signatures are unchanged; compress_sections now returns Err for invalid metadata ranges instead of measuring empty bytes.

    #Judging what can be deleted

    1. Check graph diagnostics, root assumptions and the runtime integration contract. --dead-code lists functions unreachable in this model; it is not a safe-deletion list.
    2. Then look at --retained for large RETAINED values with DIES above zero. These identify modelled dependency branches to investigate, not promised savings.
    3. Treat IND rows with care: an indirect candidate can only be removed when the table entry and the call sites go with it.

    #Example output

    A 10,675-byte MoonBit program (--target wasm, debug), reduced to the analysis sections:

    Retained size # INDEX SIZE RETAINED DIES SHARE IND FUNCTION 1 47 158 5162 46 48.3% ____moonbit__main 2 39 301 1773 10 16.6% int::Int::to__string_2einner 3 37 24 887 8 8.3% println 4 34 9 819 5 7.6% moonbit.println 5 33 206 810 4 7.5% moonbit.fprintln 6 28 49 717 7 6.7% moonbit.decref 7 29 409 668 6 6.2% moonbit.gc.free 8 43 633 633 0 5.9% int__to__string__dec DIES counts the other functions that become unreachable with this one. IND marks a function an indirect call could reach. Dead code 0 of 47 functions are unreachable from the roots 0 bytes (0.0% of file) every function is reachable

    ____moonbit__main retains 5,162 bytes — 48.3% of the file, 46 functions — which is what a program with a single entry point looks like: everything hangs off it. int__to__string__dec is the largest single function at 633 bytes but retains only itself, so shrinking it is a compiler problem rather than a deletion.

    --call-graph graph.dot writes the same graph for Graphviz, with dead functions dashed and indirect edges dotted; --call-graph graph.json writes it with the roots, every edge and every indirect site's candidate set.

    #HTML report

    moonsize examples/fib.wasm --html new-report.html

    The original analysis page starts with the full input path, section navigation and four exact byte counts: File size, Code section, Whole-file gzip and Unreachable in model. The analysis status follows the cards. Detected graph gaps and their actual diagnostic positions remain visible; “No detected graph gaps” means only that no gaps were detected within this model. Model results identify investigation candidates, not guaranteed deletion savings.

    Then read the sections and module composition, Top 20 functions, module map, and expandable methods. Section and function bars have independent row heights. The section legend and keyboard-accessible comparison button share one byte axis for raw and independent gzip, including small streams that grow. Independent gzip sizes are not additive and are not whole-file transfer shares. Each failed measurement retains its own reason.

    Full function names without hovering. Expand or collapse the function table below the ranking. Its same Top 20 local functions expose index, full name/module, body bytes, encoded bytes, independent gzip and model reachability. Long names wrap and can be selected/copied; the table scrolls locally on narrow screens. Ranking uses body bytes, while independent gzip ratios use the full encoded range, including its size prefix. Function indices include preceding imports; the displayed function count explicitly counts local functions.

    Module shares and colors. The ring divides attributed function encodings, excluding code-section overhead. Its tooltip separately computes a share of the complete code section. The ring and module→function treemap use the same identity-based module colors, independent of rank. Treemap area remains raw function encoding; unreachable functions also have text and outlined cells, which do not imply removal savings.

    The native CLI always embeds pinned ECharts 5.6.0 from its executable, regardless of the working directory. It never reads a nearby assets/ file or downloads a chart library. The report contains its library, styles, scripts and data without remote fonts or sibling files. Copying that one HTML file is sufficient. The library API remains compatible: render_html without an echarts source still selects CDN mode; pass a source explicitly for an offline library-generated page. Missing/broken chart code still leaves a clear fallback and readable cards, function table and methods.

    A4 printing redraws charts at paper width, hides hover controls, opens required disclosures, repeats table headers and includes a complete module legend. It restores the screen layout and disclosure states afterwards. Actual relocated offline rendering and two browser-generated PDFs have been checked; see delivery evidence and reproduction. Node and browser test tools are not required to generate reports with the native CLI.

    The current generated example uses the unchanged stored input, SHA-256 0e6f10e56434b67adb162cdb56a46553ef38b8de1abd0d0deb1fe56d7173d10c. It measures 10,675 B file, 5,166 B code, 5,207 B whole-file gzip and 0 B unreachable local function encodings. Page usage and verification limits record the real HTTP-browser checks, screenshots and tested offline/print scope.

    Current report at 1440 px

    Current report at 390 px

    Both screenshots were captured from the generated report in a real browser at http://localhost:5173/.

    #CI integration

    --max-size turns the report into a gate, and --baseline turns it into a comparison, so a build can fail on growth instead of on a number somebody has to keep updating by hand.

    moonsize app.wasm --max-size 100KB # a ceiling on the file moonsize app.wasm --baseline main.wasm --max-size 5KB # at most 5 KB of growth

    With a baseline, --max-size bounds the change, not the file: the question a pull request asks is what it costs, not how big the program already was. A run with a baseline prints the comparison after the report, largest current group first, with the change as bytes and as a share of what it was:

    SIZE COMPARISON baseline current delta total 10675 11039 +364 (+3.4%) code 5166 5299 +133 (+2.5%) custom (name) 4975 5205 +230 (+4.6%) data 252 252 0 (0.0%) custom (producers) 71 71 0 (0.0%) ...

    Standard sections compare by kind. Custom sections group by their complete, case-sensitive names: repeated names produce one row containing their summed encoded sizes. Empty names are valid, and a custom section named code remains separate from the standard code section. Sizes include each section's actual id, length encoding and full payload, including the custom name encoding; legal non-shortest length encodings therefore count their extra bytes.

    For example, two x sections of 4 and 6 bytes compared with two of 5 and 8 bytes produce one row:

    baseline current delta custom (x) 10 13 +3 (+30.0%)

    This compares a name's total encoded size without attempting to pair individual same-name custom sections across builds. Reordering sections does not change the result. Ties use descending baseline size, then standard binary section order followed by custom name order (String::compare). Deleted groups remain visible against zero.

    total is the entire file, including its eight-byte header. Group increases and decreases may cancel, and equal group sizes do not imply equal content. With a baseline, --max-size still limits only the whole-file growth, including when some groups grow while the total is unchanged.

    #Function and package changes

    moonsize current.wasm --baseline previous.wasm --diff-functions --top 20

    This appends FUNCTION SIZE CHANGES, PACKAGE SIZE CHANGES, MATCHING SUMMARY and CODE OVERHEAD. Without --diff-functions, baseline output stays at the existing file/section comparison. The option requires --baseline and rejects --html or --call-graph with exit 2 before reading inputs. Other text options combine normally. --top 0 keeps totals and matching coverage but hides both detail rankings; positive limits apply independently to functions and packages.

    Matching uses the exact, nonempty original symbol only when it appears once among local functions on each side. Function indices and offsets only locate records within a build. Display names, sizes and order never establish identity. Unique one-sided symbols are added-symbol or removed-symbol; a duplicate on either side makes all related records ambiguous, even if absent on the other side. Missing or empty names are missing-symbol. Both unmatched categories retain separate per-side records and byte counts; neither is also called an addition/removal. The actual symbol (unnamed) remains distinct from no symbol. Added/removed symbols may reflect renames or compiler changes rather than source functions being added/deleted. Stripped binaries still have accurate totals and package summaries, with an explicit lack of function identity coverage.

    Function sizes include the body length prefix, locals and instruction bytes (total_size); package summaries independently aggregate all functions by the existing best-effort module_name, including (unnamed) and (top-level). Ranking uses absolute byte delta, then case-sensitive UTF-16 lexical symbol or package order; duplicate identity ties use status and per-build indices. It does not depend on Map traversal. Raw symbols are quoted and escaped in text.

    The structured results reconcile:

    package encoded-byte sums = local function total_size sums code section = function encoding + code overhead (header/count encoding) function delta = matched delta + added bytes - removed bytes + unmatched current bytes - unmatched baseline bytes

    Name/data and other section changes belong to the existing section comparison; function deltas do not sum to the whole-file delta. --max-size still budgets only whole-file growth, including growth outside code. Current-input strict behavior is unchanged; comparison itself does not build a baseline call graph. Both files are read once, and fallible comparison preparation finishes before any normal report is printed.

    Library callers use compare_modules(baseline_data, current_data) returning Result[ModuleSizeDiff, ComparisonError] (Baseline(WasmError) / Current(WasmError)). The result exposes file totals, baseline_code / current_code (CodeSizeSummary: section, function count/bytes, overhead), functions (FunctionSizeChange with status, raw symbol, optional per-side FunctionStat, delta), packages (PackageSizeChange) and matching (MatchingSummary, per-category count/byte coverage and matched delta). render_function_diff(diff,top) renders the same text. Validation covers basic module structure and bounded function/import parsing, without full Wasm type/instruction validation.

    API migration: FunctionStat.raw_name : String? preserves a nonempty symbol before display_name, alongside unchanged display, package and size fields. Internal record constructors must supply it; never reconstruct it from name. Malformed UTF-8 in optional function-name metadata stops name collection at that entry, retaining earlier names and leaving the damaged suffix unmatched; it is not lossily converted into a fabricated shared identity. Valid Unicode names, including a literal replacement character or BOM, retain their exact identity. Options adds diff_functions, defaulting to false. Existing parse, analyze, compression and budget signatures remain unchanged.

    For a source-built demonstration, run:

    moon build --target native --release bash scripts/demo-function-diff.sh _build/native/release/build/cmd/main/main.exe

    The script copies before.mbt and after.mbt into the same temporary project, builds both with --target wasm --debug --no-strip, verifies real name sections with V8, runs comparison, then restores the source and checks the complete artifact and symbol differences return to zero. Artifacts and measured reports remain under _build/function-diff-demo.*. With MoonBit 0.10.13+cbb11c36f, the measured example is 4,977 → 10,785 file bytes; classify is 29 → 55 encoded bytes, the new weighted_score symbol is 103 bytes, and demo::functiondiff is 29 → 158 bytes. All function encoding grows by 2,125 bytes, including runtime helpers; this differs from the 5,808-byte whole-file growth. These are measured demo results for that toolchain, not fixed cross-version expectations.

    The exit codes are 0 ok, 1 unreadable input, 2 bad command line and 3 over budget or refused by --strict, so a job can tell a broken input apart from a broken build.

    This repository gates itself in .github/workflows/ci.yml: formatting, moon check --target native --deny-warn, moon test, and the freshly built debug Wasm example held to a budget. The local setup action pins MoonBit 0.10.13+cbb11c36f to match local formatting and tests, and runs moon update before dependency resolution. A toolchain upgrade should update local checks and CI together.

    - uses: ./.github/actions/setup-moonbit - name: Build the example and CLI run: | moon build --target wasm moon build --target native --release - name: The example stays inside its budget env: EXAMPLE: _build/wasm/debug/build/examples/examples.wasm CLI: _build/native/release/build/cmd/main/main.exe run: | test -s "$EXAMPLE" printf 'budget input: %s\nbytes: %s\n' "$EXAMPLE" "$(wc -c < "$EXAMPLE")" sha256sum "$EXAMPLE" "$CLI" "$EXAMPLE" --max-size 100KB

    The budget job reads that build artifact directly; examples/fib.wasm remains the published example copy. Its reverse check uses 0B, explicitly captures the native CLI's status, and requires exactly 3 plus the budget-failure reason. Missing files, launch errors and command-line errors fail the job. Debug and release artifact paths must track the build mode if it changes.

    For another project that produces a .wasm, install the compatible toolchain with the official installer before applying its size budget:

    - name: Setup MoonBit run: | curl -fsSL https://cli.moonbitlang.com/install/unix.sh | bash -s '0.10.13+cbb11c36f' echo "$HOME/.moon/bin" >> "$GITHUB_PATH" - run: moon update - run: moon build --target wasm --release - name: Size budget run: moonsize _build/wasm/release/build/app/app.wasm --max-size 250KB

    To catch growth rather than an absolute size, build the base branch too and pass it as the baseline; --max-size then reads as the allowance:

    - name: Build the base branch run: | git worktree add "$RUNNER_TEMP/base" "origin/${{ github.base_ref }}" (cd "$RUNNER_TEMP/base" && moon build --target wasm --release) - name: Size budget run: | moonsize _build/wasm/release/build/app/app.wasm \ --baseline "$RUNNER_TEMP/base/_build/wasm/release/build/app/app.wasm" \ --max-size 5KB

    .github/workflows/size-check.yml does that on every pull request. Both sides use the same installed toolchain and default debug Wasm mode. Each build artifact must exist and be nonempty before copying; the workflow logs source paths, comparison paths, byte counts and SHA-256 hashes. The native CLI compares $RUNNER_TEMP/current.wasm against $RUNNER_TEMP/baseline.wasm. If a historical baseline has no examples/moon.pkg, the log explicitly says it uses the repository's historical example without rebuilding that branch's source. A baseline with that package must build successfully; build failures do not fall back to the stored binary.

    #Development

    The key decisions, the known limits of the symbol rules, the role AI tools played, and the provenance of everything vendored are written down in docs/devlog.md.

    moon check --target native --deny-warn moon test moon build --target native --release bash scripts/test-tail-calls.sh _build/native/release/build/cmd/main/main.exe bash scripts/test-graph-diagnostics.sh _build/native/release/build/cmd/main/main.exe bash scripts/test-parser-boundaries.sh _build/native/release/build/cmd/main/main.exe

    The diagnostic CLI script requires Node.js to validate its Wasm fixtures with V8 and assert fields in parsed JSON. The parser script also uses Node.js for a five-second timeout per CLI process, distinguishing timeouts, crashes and expected input errors. All three scripts run in Git Bash or Linux after building the native executable. Complete-module fixtures are cross-checked with V8; Reader-only and optional metadata tests are kept separate.

    The test suite covers the instruction table one opcode at a time — every entry carries a canonical encoding and the length the specification gives it — and pins three real MoonBit function bodies, including one whose locals use two-byte garbage-collection reference types, so a wrong immediate width fails a test instead of quietly producing a wrong call graph.

    #Regenerating the example

    The numbers quoted in examples/report.html and in this README — the file size, the section shares, the retained sizes — are read out of a real build rather than written by hand. Changing examples/fib.mbt or moving to a new toolchain makes them stale. Build, copy the artifact, then generate the report:

    moon build --target wasm stage=$(mktemp -d examples/report-update.XXXXXX) cp _build/wasm/debug/build/examples/examples.wasm "$stage/fib.wasm" moon run cmd/main -- \ --html "$stage/report.html" \ "$stage/fib.wasm" sha256sum _build/wasm/debug/build/examples/examples.wasm "$stage/fib.wasm"

    The build and staged input hashes must match. Check the measurements against that input before replacing either checked-in artifact. Start npm run dev, open http://127.0.0.1:5173/, then inspect the staged page at http://127.0.0.1:5173/report-update.XXXXXX/report.html (use the actual directory). The report records the actual staged input path. After accepting the new report, run these explicit replacement steps; the CLI never removes the old report:

    cp "$stage/fib.wasm" examples/fib.wasm cp "$stage/report.html" examples/report.html

    Update report-desktop.jpg (1440 × 1000) and report-mobile.jpg (390 × 844) only after reviewing the accepted page. Do not overwrite an unrelated report.png. Keep or explicitly clean up only your own staging directory after review.

    .moonignore keeps the rendered page out of the published package — it is 1.2 MB of what would otherwise be a 2.4 MB module, and nobody moon adds this for a screenshot — while the source, the example binary and the chart library all ship.

    Analysis

    pub struct Analysis {
    data : Bytes
    parsed : WasmModule
    stats : Array[FunctionStat]
    graph : CallGraph
    roots :
    Set
    [Int]
    reachable :
    Set
    [Int]
    retained : Array[RetainedSize]
    indirect :
    Set
    [Int]
    graph_complete : Bool
    unresolved_count : Int
    }

    A parsed module with its sizes, call graph and reachability resolved.

    BudgetArtifact

    pub struct BudgetArtifact {
    sha256 : String
    raw_bytes : Int
    code_bytes : Int
    gzip : Result[Int, String]?
    coverage : BudgetCoverage?
    }

    BudgetCoverage

    pub struct BudgetCoverage {
    functions : Int
    function_bytes : Int
    unattributed_functions : Int
    unattributed_bytes : Int
    }

    BudgetError

    pub enum BudgetError {
    BudgetCurrentInput(WasmError)
    BudgetBaselineInput(WasmError)
    BudgetBaselineRequired
    }

    BudgetProblem

    pub struct BudgetProblem {
    rule_index : Int?
    field : String
    reason : String
    }

    BudgetReport

    pub struct BudgetReport {
    config_version : Int
    current : BudgetArtifact
    baseline : BudgetArtifact?
    rules : Array[BudgetRuleResult]
    status : BudgetStatus
    exit_code : Int
    }

    BudgetRuleResult

    pub struct BudgetRuleResult {
    id : String
    metric : String
    package_name : String?
    mode : String
    current : Int?
    baseline : Int?
    delta : Int?
    max_bytes : Int
    status : BudgetStatus
    reason : String
    current_package_present : Bool?
    baseline_package_present : Bool?
    }

    BudgetStatus

    pub enum BudgetStatus {
    BudgetPassed
    BudgetExceeded
    BudgetUnavailable
    } derive(Eq)

    CallEdge

    pub struct CallEdge {
    caller : Int
    callee : Int
    kind : EdgeKind
    offset : Int
    }

    One edge of the call graph.

    CallGraph

    pub struct CallGraph {
    calls : Map[Int,
    Set
    [Int]]
    called_by : Map[Int,
    Set
    [Int]]
    indirect_sites : Array[IndirectSite]
    unresolved_indirect : Array[IndirectSite]
    diagnostics : Array[GraphDiagnostic]
    edges : Array[CallEdge]
    }

    The reference graph between functions.

    CallKind

    pub enum CallKind {
    Direct(Int)
    Indirect(Int)
    Reference
    }

    How a call site reaches its target.

    CallSite

    pub struct CallSite {
    kind : CallKind
    type_index : Int?
    offset : Int
    }

    One call found in a function body.

    CodeSizeSummary

    pub struct CodeSizeSummary {
    code_size : Int
    function_count : Int
    function_bytes : Int
    overhead : Int
    }

    ComparisonError

    pub enum ComparisonError {
    Baseline(WasmError)
    Current(WasmError)
    }

    Which input failed structural parsing or function-size collection.

    CompiledBudget

    type CompiledBudget

    Opaque validated rules. No rule array is exposed outside this package.

    DataByteRange

    pub struct DataByteRange {
    offset : Int
    size : Int
    } derive(Eq,
    Debug
    )

    Half-open encoded range in the original file, never a memory address.

    DataGroupTotals

    pub struct DataGroupTotals {
    count : Int
    payload_size : Int
    encoded_size : Int
    metadata_size : Int
    } derive(Eq,
    Debug
    )

    Totals on either side, or signed current-minus-baseline deltas.

    DataPayloadGroup

    pub struct DataPayloadGroup {
    id : Int
    sha256 : String
    payload_length : Int
    kind : DataPayloadGroupKind
    baseline_indices : Array[Int]
    current_indices : Array[Int]
    baseline : DataGroupTotals
    current : DataGroupTotals
    delta : DataGroupTotals
    }

    A content group, never an assertion of cross-build entry identity. The local id distinguishes even different byte groups with an identical full digest.

    DataPayloadGroupKind

    pub enum DataPayloadGroupKind {
    SharedPayload
    BaselineOnly
    CurrentOnly
    } derive(Eq,
    Debug
    )

    DataSegment

    pub struct DataSegment {
    index : Int
    mode : DataSegmentMode
    memory_index : Int?
    encoding : DataByteRange
    payload : DataByteRange
    offset_expression : DataByteRange?
    mode_size : Int
    memory_index_size : Int
    payload_length_size : Int
    } derive(Eq,
    Debug
    )

    Metadata only: no payload copy and no evaluation of the offset expression.

    DataSegmentMode

    pub enum DataSegmentMode {
    Active
    Passive
    } derive(Eq,
    Debug
    )

    DataSegmentsComparison

    pub struct DataSegmentsComparison {
    baseline_sha256 : String
    current_sha256 : String
    baseline : DataSegmentsReport
    current : DataSegmentsReport
    groups : Array[DataPayloadGroup]
    delta : DataSegmentsDelta
    }

    DataSegmentsDelta

    pub struct DataSegmentsDelta {
    section_size : Int
    payload_size : Int
    entry_metadata_size : Int
    section_overhead_size : Int
    } derive(Eq,
    Debug
    )

    DataSegmentsReport

    pub struct DataSegmentsReport {
    file_size : Int
    section : Section?
    datacount : Int?
    section_size : Int
    header_size : Int
    count_size : Int
    payload_size : Int
    entry_metadata_size : Int
    entries : Array[DataSegment]
    }

    DataCount is separate and excluded from the data-section byte ledger. Absent data has section=None and all ledger sizes zero; an empty section still has a header and an encoded zero count. Entries remain in file order.

    EdgeKind

    pub enum EdgeKind {
    Direct
    Indirect
    Reference
    }

    How one function refers to another.

    ElementSegment

    pub struct ElementSegment {
    table_index : Int
    passive : Bool
    functions : Array[Int]
    }

    One entry of the element section.

    Export

    pub struct Export {
    name : String
    kind : Int
    index : Int
    }

    One entry of the export section.

    FunctionBody

    pub struct FunctionBody {
    index : Int
    body_size : Int
    total_size : Int
    offset : Int
    }

    One function body decoded out of the code section.

    The spec stores a body as a size-prefixed blob, so an entry costs its own length prefix on top of the body itself. Both numbers are kept: the body is what a compiler can shrink, the total is what the file actually spends.

    FunctionCoverage

    pub struct FunctionCoverage {
    count : Int
    bytes : Int
    }

    FunctionMatchStatus

    pub enum FunctionMatchStatus {
    Matched
    AddedSymbol
    RemovedSymbol
    AmbiguousSymbol
    MissingSymbol
    } derive(Eq)

    Symbol changes describe binary identities, not source additions/deletions. Ambiguous and missing symbols have one record per function per side.

    FunctionPathError

    pub enum FunctionPathError {
    PathInputError(WasmError)
    InvalidPathTarget(Int, Int)
    InvalidPathRoot(Int)
    InvalidPathLimits(String)
    InvalidPathRootReference(Int, Int)
    InvalidPathEdgeIndex(Int, Int)
    InvalidPathEdgeEvidence(Int)
    }

    FunctionPathNode

    pub struct FunctionPathNode {
    index : Int
    name : String
    raw_name : String?
    imported : Bool
    encoded_size : Int?
    import_module : String?
    import_name : String?
    }

    FunctionPathReport

    pub struct FunctionPathReport {
    target : FunctionPathNode
    requested_root : Int?
    roots : Array[FunctionRoot]
    selected_roots : Array[Int]
    status : FunctionPathStatus
    path_root : Int?
    nodes : Array[FunctionPathNode]
    steps : Array[FunctionPathStep]
    graph_complete : Bool
    diagnostics : Array[GraphDiagnostic]
    graph_warning : String
    limits : PathLimits
    search_stats : PathSearchStats
    }

    FunctionPathStatus

    pub enum FunctionPathStatus {
    PathFound
    PathNotFound
    PathSearchLimited
    } derive(Eq)

    FunctionPathStep

    pub struct FunctionPathStep {
    caller : Int
    callee : Int
    kind : EdgeKind
    opcode : Opcode
    offset : Int
    type_index : Int?
    table_index : Int?
    }

    FunctionRoot

    pub struct FunctionRoot {
    index : Int
    reasons : Array[FunctionRootReason]
    }

    FunctionRootKind

    pub enum FunctionRootKind {
    ExportRoot
    StartRoot
    ElementRoot
    } derive(Eq)

    FunctionRootReason

    pub struct FunctionRootReason {
    function_index : Int
    kind : FunctionRootKind
    name : String?
    section_offset : Int
    ordinal : Int?
    table_index : Int?
    element_mode : String?
    }

    Positions are section starts, with export/segment ordinals when available; never presented as member positions or instruction offsets.

    FunctionSizeChange

    pub struct FunctionSizeChange {
    status : FunctionMatchStatus
    raw_name : String?
    baseline : FunctionStat?
    current : FunctionStat?
    delta : Int
    }

    FunctionStat

    pub struct FunctionStat {
    index : Int
    name : String
    raw_name : String?
    module_name : String
    body_size : Int
    total_size : Int
    offset : Int
    }

    One function's byte budget, with the name and package the binary gives it.

    GraphDiagnostic

    pub struct GraphDiagnostic {
    kind : GraphDiagnosticKind
    caller : Int
    offset : Int
    opcode : Opcode
    type_index : Int?
    table_index : Int?
    source : GraphDiagnosticSource?
    }

    One reason a call site's dependencies may be incomplete. A site can have multiple distinct reasons and multiple runtime writer positions.

    GraphDiagnosticKind

    pub enum GraphDiagnosticKind {
    NoStaticTableTargets
    UnmodeledReferenceCall
    ImportedTableContents
    ExportedTableContents
    RuntimeTableMutation
    UnmodeledTableInitializer
    UnknownTableContents
    }

    A stable category of a detected gap in call-target analysis.

    GraphDiagnosticSource

    pub struct GraphDiagnosticSource {
    caller : Int?
    offset : Int
    opcode : Opcode?
    }

    Provenance of a gap, distinct from the call instruction's own position.

    IndirectSite

    pub struct IndirectSite {
    caller : Int
    table_index : Int
    type_index : Int
    offset : Int
    candidates : Array[Int]
    }

    A call_indirect or return_call_indirect, with its table's candidates.

    Instruction

    pub struct Instruction {
    opcode : Opcode
    offset : Int
    size : Int
    operands : Array[Int]
    }

    A decoded instruction: what it is, where it starts and what it carries.

    MatchingSummary

    pub struct MatchingSummary {
    matched_baseline : FunctionCoverage
    matched_current : FunctionCoverage
    added_symbols : FunctionCoverage
    removed_symbols : FunctionCoverage
    ambiguous_baseline : FunctionCoverage
    ambiguous_current : FunctionCoverage
    missing_baseline : FunctionCoverage
    missing_current : FunctionCoverage
    matched_delta : Int
    }

    Disjoint per-side categories. Missing/ambiguous are the unmatched reasons; neither category is also counted as an added or removed symbol.

    ModuleSizeDiff

    pub struct ModuleSizeDiff {
    baseline_size : Int
    current_size : Int
    delta : Int
    baseline_code : CodeSizeSummary
    current_code : CodeSizeSummary
    functions : Array[FunctionSizeChange]
    packages : Array[PackageSizeChange]
    matching : MatchingSummary
    }

    All sizes are raw encoded bytes. Function/package sums explain local function encoding, not file deltas (name/data/etc. remain in the section comparison). Records are ranked by absolute delta, then raw symbol/package identity.

    Opcode

    pub enum Opcode {
    Unreachable
    Nop
    Block
    Loop
    If
    Else
    Try
    Catch
    Throw
    Rethrow
    ThrowRef
    End
    Br
    BrIf
    BrTable
    Return
    Call
    CallIndirect
    ReturnCall
    ReturnCallIndirect
    CallRef
    ReturnCallRef
    Drop
    Select
    Delegate
    CatchAll
    SelectT
    LocalGet
    LocalSet
    LocalTee
    GlobalGet
    GlobalSet
    TableGet
    TableSet
    I32Load
    I64Load
    F32Load
    F64Load
    I32Load8S
    I32Load8U
    I32Load16S
    I32Load16U
    I64Load8S
    I64Load8U
    I64Load16S
    I64Load16U
    I64Load32S
    I64Load32U
    I32Store
    I64Store
    F32Store
    F64Store
    I32Store8
    I32Store16
    I64Store8
    I64Store16
    I64Store32
    MemorySize
    MemoryGrow
    I32Const
    I64Const
    F32Const
    F64Const
    I32Eqz
    I32Eq
    I32Ne
    I32LtS
    I32LtU
    I32GtS
    I32GtU
    I32LeS
    I32LeU
    I32GeS
    I32GeU
    I64Eqz
    I64Eq
    I64Ne
    I64LtS
    I64LtU
    I64GtS
    I64GtU
    I64LeS
    I64LeU
    I64GeS
    I64GeU
    F32Eq
    F32Ne
    F32Lt
    F32Gt
    F32Le
    F32Ge
    F64Eq
    F64Ne
    F64Lt
    F64Gt
    F64Le
    F64Ge
    I32Clz
    I32Ctz
    I32Popcnt
    I32Add
    I32Sub
    I32Mul
    I32DivS
    I32DivU
    I32RemS
    I32RemU
    I32And
    I32Or
    I32Xor
    I32Shl
    I32ShrS
    I32ShrU
    I32Rotl
    I32Rotr
    I64Clz
    I64Ctz
    I64Popcnt
    I64Add
    I64Sub
    I64Mul
    I64DivS
    I64DivU
    I64RemS
    I64RemU
    I64And
    I64Or
    I64Xor
    I64Shl
    I64ShrS
    I64ShrU
    I64Rotl
    I64Rotr
    F32Abs
    F32Neg
    F32Ceil
    F32Floor
    F32Trunc
    F32Nearest
    F32Sqrt
    F32Add
    F32Sub
    F32Mul
    F32Div
    F32Min
    F32Max
    F32Copysign
    F64Abs
    F64Neg
    F64Ceil
    F64Floor
    F64Trunc
    F64Nearest
    F64Sqrt
    F64Add
    F64Sub
    F64Mul
    F64Div
    F64Min
    F64Max
    F64Copysign
    I32WrapI64
    I32TruncF32S
    I32TruncF32U
    I32TruncF64S
    I32TruncF64U
    I64ExtendI32S
    I64ExtendI32U
    I64TruncF32S
    I64TruncF32U
    I64TruncF64S
    I64TruncF64U
    F32ConvertI32S
    F32ConvertI32U
    F32ConvertI64S
    F32ConvertI64U
    F32DemoteF64
    F64ConvertI32S
    F64ConvertI32U
    F64ConvertI64S
    F64ConvertI64U
    F64PromoteF32
    I32ReinterpretF32
    I64ReinterpretF64
    F32ReinterpretI32
    F64ReinterpretI64
    I32Extend8S
    I32Extend16S
    I64Extend8S
    I64Extend16S
    I64Extend32S
    RefNull
    RefIsNull
    RefFunc
    RefEq
    RefAsNonNull
    BrOnNull
    BrOnNonNull
    I32TruncSatF32S
    I32TruncSatF32U
    I32TruncSatF64S
    I32TruncSatF64U
    I64TruncSatF32S
    I64TruncSatF32U
    I64TruncSatF64S
    I64TruncSatF64U
    MemoryInit
    DataDrop
    MemoryCopy
    MemoryFill
    TableInit
    ElemDrop
    TableCopy
    TableGrow
    TableSize
    TableFill
    StructNew
    StructNewDefault
    StructGet
    StructGetS
    StructGetU
    StructSet
    ArrayNew
    ArrayNewDefault
    ArrayNewFixed
    ArrayNewData
    ArrayNewElem
    ArrayGet
    ArrayGetS
    ArrayGetU
    ArraySet
    ArrayLen
    ArrayFill
    ArrayCopy
    ArrayInitData
    ArrayInitElem
    RefTest
    RefTestNull
    RefCast
    RefCastNull
    BrOnCast
    BrOnCastFail
    AnyConvertExtern
    ExternConvertAny
    RefI31
    I31GetS
    I31GetU
    }

    Every instruction the decoder recognises, one variant per mnemonic.

    The set covers the MVP, the sign-extension, saturating-truncation, bulk-memory and reference-type extensions MoonBit's linear-memory backend emits, and the garbage-collection instructions its wasm-gc backend emits.

    Opcode::name

    fn Opcode::name(self : Opcode) -> String

    The spec mnemonic for an opcode, spelled as the text format writes it.

    Opcode::operands

    fn Opcode::operands(self : Opcode) -> Operands

    The immediate operands each opcode carries.

    Operands

    pub enum Operands {
    None_
    BlockType
    Label
    LabelTable
    FunctionIndex
    CallIndirect
    LocalIndex
    GlobalIndex
    TableIndex
    TagIndex
    MemoryIndex
    MemoryArg
    I32Const
    I64Const
    F32Const
    F64Const
    HeapType
    RefType
    TypeIndex
    TypeFieldPair
    TypePair
    TypeCountPair
    TypeDataPair
    TypeElemPair
    DataIndex
    ElemIndex
    DataMemoryPair
    MemoryPair
    ElemTablePair
    TablePair
    CastPair
    TypeVector
    }

    How the bytes after an opcode are laid out.

    The shapes named <a><b>Pair read a then b, in encoding order. Naming them here rather than inlining the reads keeps every immediate width in one table that can be reviewed — and tested — against the specification.

    Options

    pub struct Options {
    path : String
    top : Int
    retained : Bool
    dead_code : Bool
    call_graph : String?
    html : String?
    max_size : Int?
    baseline : String?
    diff_functions : Bool
    experiment : String?
    experiment_context : String?
    why_index : Int?
    why_root : Int?
    why_json : String?
    budget : String?
    budget_json : String?
    budget_md : String?
    compress : Bool
    data_segments : Bool
    data_json : String?
    diff_data : Bool
    strict : Bool
    }

    What to analyze, how much of it to show, and which reports to produce.

    Options::default

    fn Options::default(path : String) -> Options

    The defaults parse_options starts from, with one flat report for the module at path. For callers that link this module as a library instead of running the CLI: every flag the command line can set is left at its absent value.

    PackageSizeChange

    pub struct PackageSizeChange {
    name : String
    baseline_functions : Int
    current_functions : Int
    baseline_size : Int
    current_size : Int
    delta : Int
    }

    Best-effort package attribution includes every local function, even unmatched.

    PathLimits

    pub struct PathLimits {
    max_nodes : Int
    max_edges : Int
    max_depth : Int
    }

    Limits apply separately to roots/visited nodes, root-source records, indexed edges and BFS edge examinations. Parsing/building the existing graph is not bounded by these query limits; deterministic sorting has its own cost.

    PathLimits::new

    fn PathLimits::new(max_nodes? : Int, max_edges? : Int, max_depth? : Int) -> PathLimits

    PathSearchStats

    pub struct PathSearchStats {
    root_sources : Int
    prepared_roots : Int
    indexed_edges : Int
    visited_nodes : Int
    checked_edges : Int
    max_depth : Int
    phase : String
    limit_reason : String?
    }

    Reader

    pub struct Reader {
    data : Bytes
    start : Int
    end : Int
    pos : Int
    }

    An absolute cursor restricted to [start, end) within a byte buffer.

    The shape follows jtenner/starshine's src/binary decoder, where a decode step takes (bytes, offset) and reports how far it advanced, so callers can walk a stream without hidden global state.

    Reader::at_end

    fn Reader::at_end(self : Reader) -> Bool

    Reader::new

    fn Reader::new(data : Bytes) -> Reader

    Reader::new_range

    fn Reader::new_range(data : Bytes, start : Int, length : Int) -> Result[Reader, WasmError]

    Construct a bounded reader, checking subtraction before any addition.

    Reader::position

    fn Reader::position(self : Reader) -> Int

    Reader::read_bits

    fn Reader::read_bits(self : Reader, count : Int) -> Result[Int, WasmError]

    Read a little-endian bit pattern of count bytes as a single integer.

    Floating point constants have no structural meaning for a size analyzer, so they are kept as the bits the file holds rather than converted and back.

    Reader::read_byte

    fn Reader::read_byte(self : Reader) -> Result[Int, WasmError]

    Read a single byte, or report the offset that ran out of input.

    Reader::read_bytes

    fn Reader::read_bytes(self : Reader, count : Int) -> Result[BytesView, WasmError]

    Read count raw bytes and advance past them.

    Negative or excessive counts leave the cursor unchanged. A view is enough: names are decoded straight from it and section payloads are skipped by size rather than copied.

    Reader::read_reftype

    fn Reader::read_reftype(self : Reader) -> Result[Int, WasmError]

    Consume a reference type.

    A reference type is either a one-byte shorthand such as funcref (0x70), or one of the two constructors 0x63/0x64 followed by a heap type. The shorthand byte is returned, or 0 when the two-byte form was used.

    Reader::read_signed_leb

    fn Reader::read_signed_leb(self : Reader, bits : Int) -> Result[Int, WasmError]

    Read a signed LEB128 integer of at most bits significant bits.

    Block types, heap types and the i32.const/i64.const immediates are all signed, and the sign lives in the last group's bit 6, so leaving it out would turn a small negative constant into a large positive one. The bits bound is what makes a non-terminating encoding malformed: a group may only continue while fewer than bits bits have been consumed.

    Reader::read_u32_leb

    fn Reader::read_u32_leb(self : Reader) -> Result[UInt, WasmError]

    Read an unsigned LEB128 integer of at most 32 significant bits.

    WebAssembly encodes section payload lengths, vector counts and custom section name lengths this way. Five groups of seven bits cover u32, so a fifth byte must terminate and may carry only four payload bits. Non-shortest encodings are accepted within these limits.

    Reader::read_valtype

    fn Reader::read_valtype(self : Reader) -> Result[Unit, WasmError]

    Consume a value type, whose width is not always one byte.

    Numeric and vector types are a single byte, but a garbage-collection reference type is the two-byte 0x63/0x64 form followed by a heap type. Reading one byte per type desynchronises the stream on the first struct-typed local, so every value type goes through here.

    Reader::remaining

    fn Reader::remaining(self : Reader) -> Int

    Reader::seek

    fn Reader::seek(self : Reader, offset : Int) -> Result[Unit, WasmError]

    Move to [start, end], including the end. Failure leaves the cursor unchanged.

    RetainedSize

    pub struct RetainedSize {
    index : Int
    own_size : Int
    retained : Int
    retained_functions : Int
    }

    Encoded function weights in a dominator subtree of the current graph model.

    Section

    pub struct Section {
    id : Int
    kind : SectionId
    offset : Int
    payload_size : Int
    total_size : Int
    custom_name : String?
    }

    One decoded section header, with the byte budget it accounts for.

    SectionCompression

    pub struct SectionCompression {
    label : String
    raw : Int
    gzip : Result[Int, String]
    }

    A section's complete encoding (id, length prefix and payload), measured as its own gzip stream. raw is the length of that same encoding.

    SectionId

    pub enum SectionId {
    Custom
    Type
    Import
    Function
    Table
    Memory
    Global
    Export
    Start
    Element
    Code
    Data
    DataCount
    Tag
    }

    The section identifiers defined by the WebAssembly core specification.

    SectionId::from_id

    fn SectionId::from_id(id : Int) -> SectionId?

    Map a raw section id byte to its meaning, or None when unassigned.

    SectionId::is_custom

    fn SectionId::is_custom(self : SectionId) -> Bool

    Custom sections are the only ones that carry a name, and they are worth telling apart from the numbered sections.

    SectionId::name

    fn SectionId::name(self : SectionId) -> String

    The lower-case name used by wasm-objdump and friends.

    Sections

    pub struct Sections {
    code : Section?
    imports : Section?
    names : Section?
    exports : Section?
    elements : Section?
    tables : Section?
    start : Section?
    }

    The sections the analysis passes need, picked out of the section table once.

    parse_module validates standard-section uniqueness before returning a module. Several custom sections are legal; the last name custom section is selected.

    TableType

    pub struct TableType {
    element_type : Int
    minimum : Int
    maximum : Int?
    }

    A table declared by the table section.

    WasmError

    pub enum WasmError {
    UnexpectedEnd(Int, Int)
    BadMagic
    InvalidSectionId(Int)
    LebOverflow(Int)
    InvalidReadRange(Int, Int, Int, Int)
    ValueOutOfRange(Int, UInt)
    UnsupportedVersion(Int, UInt)
    DuplicateSection(Int, SectionId)
    InvalidSectionOrder(Int, SectionId, SectionId)
    FunctionCodeCountMismatch(Int, SectionId, Int, Int)
    TrailingSectionBytes(Int, SectionId)
    InvalidTableEncoding(Int, String)
    InvalidDataEncoding(Int, String)
    InvalidImportKind(Int)
    UnknownOpcode(Int, Int)
    UnknownPrefixedOpcode(Int, Int, Int)
    }

    Every failure mode the decoder can report, with the offset that explains it.

    WasmModule

    pub struct WasmModule {
    version : Int
    file_size : Int
    sections : Array[Section]
    }

    A decoded module preamble plus its section table.

    DEFAULT_TOP

    let DEFAULT_TOP : Int

    How many functions --top ranks when the flag is absent.

    EXIT_INPUT

    let EXIT_INPUT : Int

    The input could not be read, or is not a WebAssembly module.

    EXIT_OK

    let EXIT_OK : Int

    The analysis ran and nothing was over budget.

    EXIT_OVER_SIZE

    let EXIT_OVER_SIZE : Int

    The run produced something the caller should not accept: the module is over the size budget the invocation set, or --strict found the call graph incomplete. Both mean "this run's answer is refused", which is why they share a code — a job branches on it either way.

    EXIT_USAGE

    let EXIT_USAGE : Int

    The command line did not make sense.

    analyze

    fn analyze(data : Bytes, _parsed : WasmModule) -> Result[Analysis, WasmError]

    Analyse actual bytes after revalidating their structure. The parsed argument is retained for API compatibility; stale or mutated metadata cannot bypass checks. Revalidation builds no analysis or call graph and no body records.

    budget_requires_baseline

    fn budget_requires_baseline(budget : CompiledBudget) -> Bool

    build

    fn build(data : Bytes) -> Result[CallGraph, WasmError]

    Build from complete module bytes, parsing canonical bodies, elements and table context internally. Callers cannot supply an empty table context or omit a writer body. Use analyze when sizes and retention are also needed.

    Every body is decoded once: call and return_call give precise edges, ref.func gives a reference edge, and call_indirect and return_call_indirect give conservative edges to static table candidates plus a record of each site, including sites with no candidates. Reference calls are kept as diagnostics without guessing a target, even when the preceding instruction is ref.func.

    check_experiment_context

    fn check_experiment_context(context : Json) -> Result[Unit, String]

    Validate optional context before reading Wasm inputs or creating output. Null means no context was recorded. Unknown fields fail rather than silently accepting a typo in a build setting or validation declaration.

    collect_function_stats

    fn collect_function_stats(parsed : WasmModule, data : Bytes) -> Result[Array[FunctionStat], WasmError]

    Read a module's function names and sizes.

    A convenience over analyze for callers that only want the size table; the two share function_stats, so they cannot disagree about attribution.

    compare_data_segments

    fn compare_data_segments(baseline : Bytes, current : Bytes) -> Result[DataSegmentsComparison, ComparisonError]

    Exact payload groups after safe inspection of both inputs. No offset evaluation, stable entry identity, semantic-equivalence or savings inference.

    compare_modules

    fn compare_modules(baseline_data : Bytes, current_data : Bytes) -> Result[ModuleSizeDiff, ComparisonError]

    Validate both inputs and collect raw symbols and encoded function sizes. This does not build either call graph or require graph completeness. It is basic structure checking, not full Wasm type/instruction validation.

    compile_budget

    fn compile_budget(config : Json) -> Result[CompiledBudget, Array[BudgetProblem]]

    Preflight all fields, reporting every configuration problem before I/O.

    compile_budget_text

    fn compile_budget_text(text : String) -> Result[CompiledBudget, Array[BudgetProblem]]

    JSON byte thresholds use decimal integer tokens. Check the source too: the generic JSON parser rounds tiny fractional parts when converting to Double.

    compress_gzip

    fn compress_gzip(data : Bytes) -> Result[Bytes, String]

    Compress data as a gzip stream.

    Keep the compressor's current defaults. A server may use another gzip configuration, another content encoding, or no compression at all.

    compress_sections

    fn compress_sections(data : Bytes, parsed : WasmModule) -> Array[SectionCompression]

    Compress each section of parsed on its own, out of the file it was read from, heaviest raw section first.

    Each section becomes its own gzip stream, so each pays its own container — roughly twenty bytes of magic, header, CRC and length. That is invisible for a section of kilobytes and dominant for one of a handful of bytes, which is why a tiny section can come back larger than it went in.

    This is what render_compression is handed instead of bare Sections: a Section records where its bytes are, not what they are, and a section cannot be compressed without them.

    compressed_size

    fn compressed_size(data : Bytes) -> Result[Int, String]

    The compressed size of data in bytes, or why the compressor would not take it.

    The failure comes back as a failure rather than being swallowed into the raw size. Returning data.length() would render as a ratio of 1.00x, which reads as "this stream does not compress" when the truth is that nothing was measured at all — a report that cannot tell those apart is worse than one that admits it is missing a number.

    count_imported_functions

    fn count_imported_functions(data : Bytes, section : Section) -> Result[Int, WasmError]

    Count the function imports of a module.

    Imported functions occupy the first slots of the function index space, so this count is what turns a code-section position into the index that the name section and every call instruction actually use.

    decode_instruction

    fn decode_instruction(bytes : Bytes, offset : Int) -> Result[Instruction, WasmError]

    Decode the instruction that starts at offset.

    An unassigned opcode is an error rather than a skip: silently stepping over one byte would turn a decoding bug into a wrong call graph, and every number downstream of this function is a size.

    decode_instructions

    fn decode_instructions(bytes : Bytes, body_offset : Int, body_size : Int) -> Result[Array[Instruction], WasmError]

    Skip a function body's local declaration and decode the expression.

    body_offset is the first byte after the body's size prefix, so the locals vector comes first. Locals and instructions share a bounded reader, so no immediate can consume bytes from the next body before reporting an error, which is what keeps a wrong immediate width from quietly becoming a wrong call graph.

    demangle_full

    fn demangle_full(symbol : String) -> String

    The full decoded name for a symbol: escapes resolved, generic arguments spelled out, lifted closures marked.

    This is a display transform, not a codec. Every step is allowed to give up, and when one does the original symbol is returned unchanged, because a size report that invents a name is worse than one that shows a raw symbol.

    Known limits, all of which fall back to the raw symbol: a type code outside the builtin letters, a named type argument that is not the last one in a list (its path has no terminator, so it cannot be told apart from what follows), and any symbol that is not mangled by MoonBit in the first place.

    describe_budget_error

    fn describe_budget_error(error : BudgetError) -> String

    describe_error

    fn describe_error(error : WasmError) -> String

    A one-line explanation for a failed parse.

    describe_function_path_error

    fn describe_function_path_error(error : FunctionPathError) -> String

    describe_size_failure

    fn describe_size_failure(options : Options, current_size : Int, baseline_size : Int?) -> String

    Why the size gate failed, in one line, and an empty string when it passed.

    It asks the gate rather than re-deriving the answer, so the message and the exit code can never disagree about whether the run was over budget.

    display_name

    fn display_name(name : String) -> String

    The name to print for a symbol: demangled when the binary mangles it, otherwise left exactly as the producer wrote it.

    evaluate_budget

    fn evaluate_budget(budget : CompiledBudget, current_data : Bytes, baseline_data? : Bytes) -> Result[BudgetReport, BudgetError]

    One evaluation per side/metric. No graph, dominators or independent gzip streams.

    experiment_report

    fn experiment_report(baseline_data : Bytes, current_data : Bytes, baseline_path : String, current_path : String, context : Json, top? : Int) -> Result[Json, String]

    Compare two artifacts, retaining both graph diagnostics and measured deltas. Deltas are current minus baseline. Function identity is raw-symbol based; missing/duplicate names remain unmatched. Context is explicitly self-reported. This neither rebuilds code nor establishes behavioral equivalence.

    explain_function

    fn explain_function(data : Bytes, target_index : Int, root? : Int, limits? : PathLimits) -> Result[FunctionPathReport, FunctionPathError]

    A deterministic shortest reference-model path. Builds a canonical graph from bytes without computing dominators. Never changes global roots or graph gaps.

    find_sections

    fn find_sections(parsed : WasmModule) -> Sections

    Pick the interesting sections out of a parsed section table.

    format_report

    fn format_report(parsed : WasmModule, path : String) -> String

    The user-facing report: one line per section, each with its byte count.

    The result carries no trailing newline, so the caller decides how to end it.

    function_stats

    fn function_stats(bodies : Array[FunctionBody], names : Map[Int, String]?) -> Array[FunctionStat]

    Attribute a body to a name and a package.

    Attribution is best effort by design: a release binary keeps no function names, and the sizes are still worth reporting, so unnamed functions come back labelled (unnamed) rather than failing the run.

    indirect_targets

    fn indirect_targets(graph : CallGraph) ->
    Set
    [Int]

    The functions any ordinary or tail indirect call could reach, for reports.

    inspect_data_segments

    fn inspect_data_segments(data : Bytes) -> Result[DataSegmentsReport, WasmError]

    Inspect data encodings after the ordinary module structure checks. This is not type/constant-expression validation, memory-index validation or an instantiation check. Expression ranges include their final end instruction.

    is_graph_complete

    fn is_graph_complete(graph : CallGraph) -> Bool

    Whether no gaps were detected within the current analysis scope.

    Missing edges can make dead-code and retained-size results inaccurate. Callers such as --dead-code, --retained and --strict report this hole. A true result does not prove every Wasm call mechanism is fully modelled. Dynamic table risks and reference calls are diagnosed, not solved.

    module_of

    fn module_of(name : String) -> String

    The package a symbol belongs to.

    Everything in the name except the final segment is the package, so demo::sample::src::fib lands in demo::sample::src and tlsf/searchBlock in tlsf. Names with no package at all are grouped under (top-level), which keeps the roll-up honest instead of inventing one bucket per function.

    parse_code_section

    fn parse_code_section(data : Bytes, section : Section, base_index? : Int) -> Result[Array[FunctionBody], WasmError]

    Decode the code section into one record per function body.

    base_index is the number of imported functions, so the returned index values line up with the function index space. Bodies are measured and skipped, never interpreted, which keeps this a structural pass like parse_module.

    parse_count

    fn parse_count(text : String) -> Int?

    Parse a non-negative decimal integer, or None for anything else.

    This toolchain's core library ships no string-to-Int parser, and the only number the CLI accepts is --top, so the grammar stays deliberately narrow: one or more ASCII digits, saturating into a rejection rather than an overflow.

    parse_element_section

    fn parse_element_section(data : Bytes, section : Section) -> Result[Array[ElementSegment], WasmError]

    Decode the element section.

    The eight encodings differ only in whether a table index, an offset expression, an element kind or a reference type is present, so the shared tail — a vector of function indices or of constant expressions — is read once after the mode byte has been handled.

    parse_export_section

    fn parse_export_section(data : Bytes, section : Section) -> Result[Array[Export], WasmError]

    Decode the export section.

    parse_module

    fn parse_module(data : Bytes) -> Result[WasmModule, WasmError]

    Decode the module preamble and walk every section header.

    Check version, standard-section uniqueness/order and function/code structure. Other payloads remain outside full type and instruction-semantic validation.

    parse_name_section

    fn parse_name_section(data : Bytes, section : Section) -> Map[Int, String]?

    Decode the function names out of a name custom section.

    The result is keyed by function index. None means the section is not a name section at all; Some of an empty map is a real answer, because names are optional producer metadata and a stripped binary simply has none. Subsection ids other than 1 (locals, labels, types) are skipped whole, and a malformed subsection ends the walk without discarding earlier names.

    parse_options

    fn parse_options(args : Array[String]) -> Result[Options, String]

    Parse a full argv, including the program name in slot zero.

    The grammar is moonsize <file.wasm> [--top <n>] [--retained] [--dead-code][--call-graph <path>] [--html <path>] [--max-size <size>] [--baseline<path.wasm>] [--diff-functions] [--compress] [--strict]: exactly one positional path, and a repeat of a flag keeps the last value. Anything else is reported as an error so a typo never silently analyses the wrong thing.

    --call-graph and --html each write a file rather than a report, so combining either with another output mode is rejected instead of quietly ignoring one of them.

    parse_size

    fn parse_size(text : String) -> Int?

    Parse a byte size: a decimal count with an optional KB, MB or GB suffix, so --max-size 10KB and --max-size 1.5MB both work.

    The suffixes are binary — a KB is 1024 bytes — which is what a size report means by the word. A fraction has to be followed by a suffix: --max-size1.5 is rejected, because half a byte is not a size.

    parse_start_section

    fn parse_start_section(data : Bytes, section : Section) -> Result[Int, WasmError]

    The function the module runs at instantiation, from the start section.

    parse_table_section

    fn parse_table_section(data : Bytes, section : Section) -> Result[Array[TableType], WasmError]

    Decode the table section.

    reachable_from_roots

    fn reachable_from_roots(graph : CallGraph, roots :
    Set
    [Int]) ->
    Set
    [Int]

    Everything reachable from roots, the roots included.

    An explicit stack rather than recursion: real modules nest thousands of calls deep, and the depth of the graph should not be the depth of the C stack.

    render_budget

    fn render_budget(report : BudgetReport) -> String

    render_budget_json

    fn render_budget_json(report : BudgetReport, current_path : String, baseline_path? : String) -> String

    render_budget_markdown

    fn render_budget_markdown(report : BudgetReport, current_path : String, baseline_path? : String) -> String

    render_compression

    fn render_compression(raw_size : Int, gzip : Result[Int, String], sections : Array[SectionCompression]) -> String

    Whole-file gzip and independent full-section streams. Each ratio divides that row's raw bytes by its own gzip bytes. Independent streams neither add up to nor represent shares of whole-file gzip. Failures retain their reasons.

    render_data_segments

    fn render_data_segments(report : DataSegmentsReport, top : Int) -> String

    Payload descending, then original index. Sorting and Top affect display only.

    render_data_segments_diff

    fn render_data_segments_diff(report : DataSegmentsComparison, baseline_path : String, current_path : String, top : Int) -> String

    Paths are display labels; complete file digests identify the compared bytes.

    render_data_segments_diff_json

    fn render_data_segments_diff_json(report : DataSegmentsComparison, baseline_path : String, current_path : String) -> String

    render_data_segments_json

    fn render_data_segments_json(report : DataSegmentsReport) -> String

    The original single-file format and signature are unchanged.

    render_dead_code

    fn render_dead_code(analysis : Analysis, top : Int) -> String

    The dead-code section: functions no root can reach.

    Unreachable means no path within the current model. Detected gaps are always shown, so a caller cannot omit the warning by forgetting an optional count.

    render_dot

    fn render_dot(stats : Array[FunctionStat], graph : CallGraph, reachable :
    Set
    [Int]) -> String

    Render the call graph as Graphviz DOT.

    Functions unreachable within the model are dashed. Diagnostics and modelling limits travel in the graph label. Indirect edges are dotted candidates.

    render_experiment_html

    fn render_experiment_html(report : Json) -> String

    Standalone HTML with no scripts or external resources. All supplied strings, including paths, raw symbols and context, are escaped before entering HTML.

    render_function_diff

    fn render_function_diff(diff : ModuleSizeDiff, top : Int) -> String

    Text-only first release. Ranking uses absolute delta then exact symbol/package identity; indices locate records within a build and never establish a match. top=0 hides both detail rankings while retaining all totals and coverage.

    render_function_path

    fn render_function_path(report : FunctionPathReport) -> String

    render_function_path_json

    fn render_function_path_json(report : FunctionPathReport) -> String

    Independent format version; existing call-graph JSON is unchanged.

    render_graph

    fn render_graph(path : String, analysis : Analysis, target : String) -> String

    Render a graph export, choosing the format from the target file's extension.

    The extension is the only signal available and the one a user already thinks in: .json means JSON, anything else means DOT, which is what Graphviz tooling expects from a file called graph.dot.

    render_graph_warning

    fn render_graph_warning(graph : CallGraph) -> String

    The warning a report carries when the call graph could not follow every call, or "" when there is nothing to warn about.

    It belongs above the whole report rather than beside the sections it undermines: those sections are spread through the output, and a reader who skips one of them still has to see this. Saying it is the point — a tool that silently reports an incomplete graph as a fact is how a function in use gets deleted.

    render_html

    fn render_html(analysis : Analysis, file_path : String, echarts? : String) -> String

    Render the whole analysis as one HTML document.

    echarts is the chart library's source. Passing it inlines the library, so the report opens from any directory with no network and no sibling file; leaving it empty selects the compatible library CDN mode. This library does no I/O. The CLI always supplies its compiled-in vendored source; a library caller can explicitly supply a source or retain the CDN default.

    render_html_with_data_segments

    fn render_html_with_data_segments(analysis : Analysis, file_path : String, report : DataSegmentsReport, echarts? : String) -> String

    Opt-in current-input details from inspect_data_segments(analysis.data). No parsing or extra compression measurements are performed here.

    render_json

    fn render_json(path : String, stats : Array[FunctionStat], graph : CallGraph, roots :
    Set
    [Int], reachable :
    Set
    [Int], retained : Array[RetainedSize]) -> String

    Render the call graph, the roots and the retained sizes as JSON.

    render_module_summary

    fn render_module_summary(stats : Array[FunctionStat], file_size : Int) -> String

    The per-package roll-up of every function in the binary.

    render_retained

    fn render_retained(analysis : Analysis, top : Int) -> String

    The retained-size table, computed within the current graph model.

    SIZE is the function's encoded bytes. RETAINED sums the encoded bytes of reachable defined functions it dominates in this graph/root model, including itself. DIES counts the other members of that model set. None of these numbers predicts a source edit's recompiled file or gzip reduction.

    render_size_comparison

    fn render_size_comparison(baseline : WasmModule, current : WasmModule) -> String

    The size comparison table: the whole file and every section group, against the module it is being compared with.

    Standard sections match by kind; custom sections group by full name. Sizes include actual headers. Current size, baseline size, then identity determine row order. The whole-file total comes first and remains the budget's input.

    render_top_functions

    fn render_top_functions(stats : Array[FunctionStat], file_size : Int, top : Int) -> String

    The Top-N function table, heaviest first.

    Shares are of the whole file, so a row here can be read against the section table above it. top is clamped to the number of functions available.

    retained_sizes

    fn retained_sizes(graph : CallGraph, sizes : Map[Int, Int], roots :
    Set
    [Int]) -> Array[RetainedSize]

    The retained size of every reachable function.

    Within the fixed graph/root model, removing a node disconnects its dominated nodes: those every path from a root has to pass through. This is a graph operation, not source removal, relinking or gzip prediction. Computing it with an explicit "remove it and see what dies" walk would be correct but quadratic, and it would still need cycle handling; the dominator tree answers the same question for every function at once, and cycles fall out of the fixpoint without a special case.

    roots_of

    fn roots_of(exports : Array[Export], elements : Array[ElementSegment], start : Int?) ->
    Set
    [Int]

    The roots of a module: what the outside world can reach without going through code the module already has.

    Exported functions and start are roots. Every function referenced by an element segment is also conservatively rooted, including private, passive and declarative segments. This does not assert that each can actually run.

    same_kind

    fn same_kind(a : SectionId, b : SectionId) -> Bool

    True when two section ids are the same variant.

    Section ids carry no payload and this toolchain derives no Eq for them, so the comparison is written out once here rather than spelled at each call site that has to pick a section out of the table.

    scan_calls

    fn scan_calls(bytes : Bytes, body_offset : Int, body_size : Int) -> Result[Array[CallSite], WasmError]

    Collect direct, table and reference calls, including tail calls, from a body.

    Collect call, return_call, call_indirect and return_call_indirect. Also collect call_ref and return_call_ref, without resolving their targets. ref.func is a reference rather than a call; the graph handles it separately.

    size_exit_code

    fn size_exit_code(options : Options, current_size : Int, baseline_size : Int?) -> Int

    The exit code a module of current_size bytes earns, measured against baseline_size when the invocation named a baseline.

    Without one, --max-size is a ceiling on the file. With one it is the growth the file may show over the baseline, because that is the question a CI job is asking when it compares a branch against the branch it merges into — an absolute ceiling there would be answering something nobody asked.

    strict_exit_code

    fn strict_exit_code(options : Options, graph_complete : Bool) -> Int

    The exit code --strict earns, or EXIT_OK when the graph is complete or the flag is absent. Completeness includes every unified graph diagnostic, including reference calls.

    It lives here rather than in the entry point so the rule can be tested without a process: a job that passes --strict is saying it cannot judge a warning itself, and the run has to fail rather than print one.

    usage

    fn usage() -> String

    Printed when the tool is invoked without a file, or with arguments it cannot make sense of.