///|
/// The process contract: what the exit code means, and the size gate that
/// decides it.
///
/// The library owns this so the decisions can be tested without a process
/// around them, and so a CI job reading an exit code is reading something that
/// was specified rather than inferred.
///|
/// The analysis ran and nothing was over budget.
pub const EXIT_OK : Int = 0
///|
/// The input could not be read, or is not a WebAssembly module.
pub const EXIT_INPUT : Int = 1
///|
/// The command line did not make sense.
pub const EXIT_USAGE : Int = 2
///|
/// 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.
pub const EXIT_OVER_SIZE : Int = 3
///|
/// 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.
pub fn size_exit_code(
options : Options,
current_size : Int,
baseline_size : Int?,
) -> Int {
match options.max_size {
None => EXIT_OK
Some(budget) =>
match baseline_size {
Some(baseline) =>
if current_size - baseline > budget {
EXIT_OVER_SIZE
} else {
EXIT_OK
}
None => if current_size > budget { EXIT_OVER_SIZE } else { EXIT_OK }
}
}
}
///|
/// 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.
pub fn strict_exit_code(options : Options, graph_complete : Bool) -> Int {
if options.strict && !graph_complete {
EXIT_OVER_SIZE
} else {
EXIT_OK
}
}
///|
/// 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.
pub fn describe_size_failure(
options : Options,
current_size : Int,
baseline_size : Int?,
) -> String {
if size_exit_code(options, current_size, baseline_size) == EXIT_OK {
return ""
}
match options.max_size {
None => ""
Some(budget) =>
match baseline_size {
Some(baseline) =>
"moonsize: grew by " +
plural(current_size - baseline, "byte") +
" over the baseline, budget " +
plural(budget, "byte")
None =>
"moonsize: " +
plural(current_size, "byte") +
" is over the budget of " +
plural(budget, "byte")
}
}
}
///|
/// Identity is separate from display: full, case-sensitive custom names cannot
/// collide with standard sections. Empty names are valid custom identities.
priv enum SectionKey {
StandardSection(Int)
CustomSection(String)
} derive(Eq, Hash)
///|
/// One row compares all encoded bytes for a section identity on either side.
priv struct SizeRow {
key : SectionKey
label : String
baseline : Int
current : Int
}
///|
/// Sum actual encoded sizes, including each repeated custom section's header.
fn summarize_sections(parsed : WasmModule) -> Map[SectionKey, Int] {
let sizes : Map[SectionKey, Int] = Map([])
for section in parsed.sections {
let key = if section.kind.is_custom() {
CustomSection(
match section.custom_name {
Some(name) => name
None => ""
},
)
} else {
StandardSection(section.id)
}
sizes.set(key, sizes.get(key).unwrap_or(0) + section.total_size)
}
sizes
}
///|
fn section_key_label(key : SectionKey) -> String {
match key {
StandardSection(id) =>
match SectionId::from_id(id) {
Some(kind) => kind.name()
None => "section " + id.to_string()
}
CustomSection(name) => "custom (" + name + ")"
}
}
///|
fn standard_section_rank(id : Int) -> Int {
match SectionId::from_id(id) {
Some(kind) => section_order(kind)
None => id
}
}
///|
/// Final tie-break: standard sections in binary order, then custom names in
/// String::compare order. Neither map iteration nor physical section order is
/// used, so rearranging same-size or repeated custom sections changes no rows.
fn compare_section_keys(a : SectionKey, b : SectionKey) -> Int {
match (a, b) {
(StandardSection(left), StandardSection(right)) =>
standard_section_rank(left) - standard_section_rank(right)
(StandardSection(_), CustomSection(_)) => -1
(CustomSection(_), StandardSection(_)) => 1
(CustomSection(left), CustomSection(right)) => String::compare(left, right)
}
}
///|
/// Independently sum both modules, union identities, then order by current size
/// descending, baseline size descending, and deterministic section identity.
fn size_rows(baseline : WasmModule, current : WasmModule) -> Array[SizeRow] {
let before = summarize_sections(baseline)
let after = summarize_sections(current)
let keys : Set[SectionKey] = Set([])
for key, _ in before {
keys.add(key)
}
for key, _ in after {
keys.add(key)
}
let rows : Array[SizeRow] = []
for key in keys {
rows.push({
key,
label: section_key_label(key),
baseline: before.get(key).unwrap_or(0),
current: after.get(key).unwrap_or(0),
})
}
rows.sort_by(fn(a, b) {
if a.current != b.current {
b.current - a.current
} else if a.baseline != b.baseline {
b.baseline - a.baseline
} else {
compare_section_keys(a.key, b.key)
}
})
rows
}
///|
/// A signed byte delta and the same change as a share of the baseline.
///
/// A section the baseline never had has no share to give — the change is
/// everything it is — so it says `new` rather than dividing by zero.
fn delta_text(delta : Int, baseline : Int) -> String {
let magnitude = if delta < 0 { -delta } else { delta }
let sign = if delta > 0 { "+" } else if delta < 0 { "-" } else { "" }
if baseline <= 0 {
if delta == 0 {
return "0"
}
return sign + magnitude.to_string() + " (new)"
}
sign +
magnitude.to_string() +
" (" +
sign +
share_text(magnitude, baseline) +
")"
}
///|
/// One comparison row, with the numbers right-aligned under their headings.
fn size_row_text(
label : String,
baseline : Int,
current : Int,
label_width : Int,
baseline_width : Int,
current_width : Int,
) -> String {
" " +
pad_right(label, label_width) +
" " +
pad_left(baseline.to_string(), baseline_width) +
" " +
pad_left(current.to_string(), current_width) +
" " +
delta_text(current - baseline, baseline) +
"\n"
}
///|
/// 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.
pub fn render_size_comparison(
baseline : WasmModule,
current : WasmModule,
) -> String {
let rows = size_rows(baseline, current)
let mut label_width = "total".length()
let mut baseline_width = number_width(baseline.file_size, 8)
let mut current_width = number_width(current.file_size, 7)
for row in rows {
if row.label.length() > label_width {
label_width = row.label.length()
}
baseline_width = number_width(row.baseline, baseline_width)
current_width = number_width(row.current, current_width)
}
let mut out = "SIZE COMPARISON\n\n " +
pad_right("", label_width) +
" " +
pad_left("baseline", baseline_width) +
" " +
pad_left("current", current_width) +
" delta\n" +
size_row_text(
"total",
baseline.file_size,
current.file_size,
label_width,
baseline_width,
current_width,
)
for row in rows {
out = out +
size_row_text(
row.label,
row.baseline,
row.current,
label_width,
baseline_width,
current_width,
)
}
out
}