///|
/// Which input failed structural parsing or function-size collection.
pub enum ComparisonError {
Baseline(WasmError)
Current(WasmError)
}
///|
/// Symbol changes describe binary identities, not source additions/deletions.
/// Ambiguous and missing symbols have one record per function per side.
pub enum FunctionMatchStatus {
Matched
AddedSymbol
RemovedSymbol
AmbiguousSymbol
MissingSymbol
} derive(Eq)
///|
pub struct FunctionSizeChange {
status : FunctionMatchStatus
raw_name : String?
baseline : FunctionStat?
current : FunctionStat?
/// Current total_size minus baseline total_size; absent sides contribute zero.
delta : Int
}
///|
/// Best-effort package attribution includes every local function, even unmatched.
pub struct PackageSizeChange {
name : String
baseline_functions : Int
current_functions : Int
baseline_size : Int
current_size : Int
delta : Int
}
///|
pub struct FunctionCoverage {
count : Int
bytes : Int
}
///|
/// Disjoint per-side categories. Missing/ambiguous are the unmatched reasons;
/// neither category is also counted as an added or removed symbol.
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
}
///|
pub struct CodeSizeSummary {
code_size : Int
function_count : Int
function_bytes : Int
/// Section header and function-count encoding; not attributed to a function.
overhead : Int
}
///|
/// 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.
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
}
///|
fn symbols_by_name(
stats : Array[FunctionStat],
) -> Map[String, Array[FunctionStat]] {
let symbols : Map[String, Array[FunctionStat]] = Map([])
for stat in stats {
if stat.raw_name is Some(name) {
match symbols.get(name) {
Some(items) => items.push(stat)
None => symbols.set(name, [stat])
}
}
}
symbols
}
///|
fn symbol_count(
symbols : Map[String, Array[FunctionStat]],
name : String,
) -> Int {
match symbols.get(name) {
Some(items) => items.length()
None => 0
}
}
///|
fn stat_size(stat : FunctionStat?) -> Int {
match stat {
Some(stat) => stat.total_size
None => 0
}
}
///|
fn size_change(
status : FunctionMatchStatus,
baseline : FunctionStat?,
current : FunctionStat?,
) -> FunctionSizeChange {
{
status,
raw_name: match baseline {
Some(stat) => stat.raw_name
None =>
match current {
Some(stat) => stat.raw_name
None => None
}
},
baseline,
current,
delta: stat_size(current) - stat_size(baseline),
}
}
///|
fn change_status_text(status : FunctionMatchStatus) -> String {
match status {
Matched => "matched"
AddedSymbol => "added-symbol"
RemovedSymbol => "removed-symbol"
AmbiguousSymbol => "ambiguous"
MissingSymbol => "missing-symbol"
}
}
///|
fn delta_magnitude(delta : Int) -> Int {
if delta < 0 {
-delta
} else {
delta
}
}
///|
fn raw_identity_compare(a : String?, b : String?) -> Int {
match (a, b) {
(Some(a), Some(b)) => diff_identity_compare(a, b)
(None, None) => 0
(None, _) => -1
(_, None) => 1
}
}
///|
/// Case-sensitive UTF-16 lexical ordering, independent of locale and Map order.
/// The toolchain's String::compare orders by length first, so spell this out.
fn diff_identity_compare(a : String, b : String) -> Int {
let mut i = 0
while i < a.length() && i < b.length() {
if a[i] < b[i] {
return -1
}
if a[i] > b[i] {
return 1
}
i += 1
}
a.length() - b.length()
}
///|
fn change_index(stat : FunctionStat?) -> Int {
match stat {
Some(stat) => stat.index
None => -1
}
}
///|
fn compare_function_changes(
a : FunctionSizeChange,
b : FunctionSizeChange,
) -> Int {
let size = delta_magnitude(b.delta) - delta_magnitude(a.delta)
if size != 0 {
return size
}
let identity = raw_identity_compare(a.raw_name, b.raw_name)
if identity != 0 {
return identity
}
let status = String::compare(
change_status_text(a.status),
change_status_text(b.status),
)
if status != 0 {
return status
}
// Index is only a final ordering key within an identity/status, never a match.
let baseline = change_index(a.baseline) - change_index(b.baseline)
if baseline != 0 {
baseline
} else {
change_index(a.current) - change_index(b.current)
}
}
///|
fn category_coverage(
changes : Array[FunctionSizeChange],
status : FunctionMatchStatus,
baseline : Bool,
) -> FunctionCoverage {
let mut count = 0
let mut bytes = 0
for row in changes {
if row.status == status {
let stat = if baseline { row.baseline } else { row.current }
if stat is Some(stat) {
count += 1
bytes += stat.total_size
}
}
}
{ count, bytes, }
}
///|
fn code_size_summary(
parsed : WasmModule,
stats : Array[FunctionStat],
) -> CodeSizeSummary {
let code_size = match find_sections(parsed).code {
Some(section) => section.total_size
None => 0
}
let mut function_bytes = 0
for stat in stats {
function_bytes += stat.total_size
}
{
code_size,
function_count: stats.length(),
function_bytes,
overhead: code_size - function_bytes,
}
}
///|
fn compare_packages(
baseline : Array[FunctionStat],
current : Array[FunctionStat],
) -> Array[PackageSizeChange] {
let before : Map[String, ModuleStat] = Map([])
let after : Map[String, ModuleStat] = Map([])
let names : Set[String] = Set([])
for row in aggregate_by_module(baseline) {
before.set(row.name, row)
names.add(row.name)
}
for row in aggregate_by_module(current) {
after.set(row.name, row)
names.add(row.name)
}
let rows : Array[PackageSizeChange] = []
for name in names {
let (baseline_functions, baseline_size) = match before.get(name) {
Some(row) => (row.functions, row.total_size)
None => (0, 0)
}
let (current_functions, current_size) = match after.get(name) {
Some(row) => (row.functions, row.total_size)
None => (0, 0)
}
rows.push({
name,
baseline_functions,
current_functions,
baseline_size,
current_size,
delta: current_size - baseline_size,
})
}
rows.sort_by(fn(a, b) {
let delta = delta_magnitude(b.delta) - delta_magnitude(a.delta)
if delta != 0 {
delta
} else {
diff_identity_compare(a.name, b.name)
}
})
rows
}
///|
/// 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.
pub fn compare_modules(
baseline_data : Bytes,
current_data : Bytes,
) -> Result[ModuleSizeDiff, ComparisonError] {
let baseline = match parse_module(baseline_data) {
Ok(parsed) => parsed
Err(error) => return Err(Baseline(error))
}
let current = match parse_module(current_data) {
Ok(parsed) => parsed
Err(error) => return Err(Current(error))
}
let before = match collect_function_stats(baseline, baseline_data) {
Ok(stats) => stats
Err(error) => return Err(Baseline(error))
}
let after = match collect_function_stats(current, current_data) {
Ok(stats) => stats
Err(error) => return Err(Current(error))
}
let before_names = symbols_by_name(before)
let after_names = symbols_by_name(after)
let changes : Array[FunctionSizeChange] = []
for stat in before {
match stat.raw_name {
None => changes.push(size_change(MissingSymbol, Some(stat), None))
Some(name) =>
if symbol_count(before_names, name) > 1 ||
symbol_count(after_names, name) > 1 {
changes.push(size_change(AmbiguousSymbol, Some(stat), None))
} else if symbol_count(after_names, name) == 0 {
changes.push(size_change(RemovedSymbol, Some(stat), None))
} else {
changes.push(
size_change(Matched, Some(stat), Some(after_names[name][0])),
)
}
}
}
for stat in after {
match stat.raw_name {
None => changes.push(size_change(MissingSymbol, None, Some(stat)))
Some(name) =>
if symbol_count(before_names, name) > 1 ||
symbol_count(after_names, name) > 1 {
changes.push(size_change(AmbiguousSymbol, None, Some(stat)))
} else if symbol_count(before_names, name) == 0 {
changes.push(size_change(AddedSymbol, None, Some(stat)))
}
}
}
changes.sort_by(compare_function_changes)
let matched_baseline = category_coverage(changes, Matched, true)
let matched_current = category_coverage(changes, Matched, false)
Ok({
baseline_size: baseline.file_size,
current_size: current.file_size,
delta: current.file_size - baseline.file_size,
baseline_code: code_size_summary(baseline, before),
current_code: code_size_summary(current, after),
functions: changes,
packages: compare_packages(before, after),
matching: {
matched_baseline,
matched_current,
added_symbols: category_coverage(changes, AddedSymbol, false),
removed_symbols: category_coverage(changes, RemovedSymbol, true),
ambiguous_baseline: category_coverage(changes, AmbiguousSymbol, true),
ambiguous_current: category_coverage(changes, AmbiguousSymbol, false),
missing_baseline: category_coverage(changes, MissingSymbol, true),
missing_current: category_coverage(changes, MissingSymbol, false),
matched_delta: matched_current.bytes - matched_baseline.bytes,
},
})
}