///|
pub struct FunctionCycleComponent {
component_id : Int
members : Array[FunctionPathNode]
internal_edges : Array[CallEdge]
node_count : Int
internal_edge_count : Int
direct_edges : Int
reference_edges : Int
indirect_edges : Int
local_encoded_bytes : Int
}
///|
pub struct FunctionCyclesReport {
file_size : Int
sha256 : String
function_count : Int
imported_function_count : Int
local_function_count : Int
cyclic_function_count : Int
non_cyclic_function_count : Int
component_count : Int
components : Array[FunctionCycleComponent]
local_encoded_bytes : Int
cyclic_local_encoded_bytes : Int
other_local_encoded_bytes : Int
graph_complete : Bool
unresolved_count : Int
diagnostics : Array[GraphDiagnostic]
graph_warning : String
modeling_scope : String
}
///|
pub enum FunctionCyclesError {
CyclesInputError(WasmError)
InvalidCyclesEvidence(Int, Int, String)
}
///|
pub fn describe_function_cycles_error(error : FunctionCyclesError) -> String {
match error {
CyclesInputError(error) => describe_error(error)
InvalidCyclesEvidence(index, offset, reason) =>
"invalid cycle graph evidence for function \{index} at file offset \{offset}: " +
reason
}
}
///|
fn function_cycles_scope() -> String {
"Strongly connected components in the current reference model, including unreachable functions; only multi-node components or singletons with self-edges are shown. " +
"Direct call/return_call, ref.func references taken and indirect static candidates all participate; even direct-only cycles do not prove runtime recursion. " +
"Roots do not create body edges. Unmodeled reference calls have diagnostics, not guessed targets. Diagnostics cover the entire module and may be outside every component. " +
"Counts are model edges, not execution counts or indirect call-site counts. An empty result or graph_complete does not prove absence of recursion, host callbacks or full semantics. " +
"Local encoded bytes include body length prefixes and bodies, excluding code header/count and other sections; imports have no local encoded size. Disjoint component weights may be added, but are not retained size or deletion/recompile/gzip benefit. " +
"Component IDs are minimum node indices within this input only. Top limits displayed components, not members, complete JSON or graph/SCC costs. " +
graph_modeling_scope()
}
///|
/// Nonrecursive Kosaraju. Only topology is deduplicated; every evidence edge is
/// bucketed once after SCC assignment, including parallel positions and kinds.
fn cycles_components(
inventory : Array[FunctionPathNode],
edges : Array[CallEdge],
file_size : Int,
) -> Result[Array[FunctionCycleComponent], FunctionCyclesError] {
let count = inventory.length()
let forward_sets : Array[Set[Int]] = Array::makei(count, fn(_) { Set([]) })
let reverse_sets : Array[Set[Int]] = Array::makei(count, fn(_) { Set([]) })
for edge in edges {
for index in [edge.caller, edge.callee] {
if index < 0 || index >= count {
return Err(
InvalidCyclesEvidence(
index,
edge.offset,
"index outside function inventory",
),
)
}
}
if inventory[edge.caller].imported {
return Err(
InvalidCyclesEvidence(
edge.caller,
edge.offset,
"imported caller has no local body",
),
)
}
if edge.offset < 0 || edge.offset >= file_size {
return Err(
InvalidCyclesEvidence(
edge.caller,
edge.offset,
"instruction offset outside input",
),
)
}
forward_sets[edge.caller].add(edge.callee)
reverse_sets[edge.callee].add(edge.caller)
}
let forward = forward_sets.map(fn(set) {
let nodes = set.to_array()
nodes.sort()
nodes
})
let reverse = reverse_sets.map(fn(set) {
let nodes = set.to_array()
nodes.sort()
nodes
})
let visited : Array[Bool] = Array::make(count, false)
let next_edge : Array[Int] = Array::make(count, 0)
let finished : Array[Int] = []
for start in 0.. 0 {
remaining -= 1
let start = finished[remaining]
if component_of[start] >= 0 {
continue
}
let id = groups.length()
let members : Array[Int] = []
let stack = [start]
component_of[start] = id
while !stack.is_empty() {
let node = stack.pop().unwrap()
members.push(node)
for predecessor in reverse[node] {
if component_of[predecessor] < 0 {
component_of[predecessor] = id
stack.push(predecessor)
}
}
}
members.sort()
groups.push(members)
}
let internal : Array[Array[CallEdge]] = Array::makei(groups.length(), fn(_) {
[]
})
for edge in edges {
if component_of[edge.caller] == component_of[edge.callee] {
internal[component_of[edge.caller]].push(edge)
}
}
let components : Array[FunctionCycleComponent] = []
for group_index, indices in groups {
let evidence = internal[group_index]
if indices.length() == 1 && evidence.is_empty() {
continue
}
evidence.sort_by(fn(a, b) {
if a.caller != b.caller {
return a.caller - b.caller
}
if a.offset != b.offset {
return a.offset - b.offset
}
if a.callee != b.callee {
return a.callee - b.callee
}
path_edge_rank(a.kind) - path_edge_rank(b.kind)
})
let mut direct = 0
let mut reference = 0
let mut indirect = 0
for edge in evidence {
match edge.kind {
Direct => direct += 1
Reference => reference += 1
Indirect => indirect += 1
}
}
let members = indices.map(fn(index) { inventory[index] })
let mut size = 0
for node in members {
size += node.encoded_size.unwrap_or(0)
}
components.push({
component_id: indices[0],
members,
internal_edges: evidence,
node_count: indices.length(),
internal_edge_count: evidence.length(),
direct_edges: direct,
reference_edges: reference,
indirect_edges: indirect,
local_encoded_bytes: size,
})
}
components.sort_by(fn(a, b) {
if a.local_encoded_bytes != b.local_encoded_bytes {
b.local_encoded_bytes - a.local_encoded_bytes
} else {
a.component_id - b.component_id
}
})
Ok(components)
}
///|
/// One inventory and one graph build; no analyze, retained, BFS or opcode
/// evidence recovery. build owns decoding; SCC consumes its original edges.
pub fn function_cycles(
data : Bytes,
) -> Result[FunctionCyclesReport, FunctionCyclesError] {
let parsed = match parse_module(data) {
Ok(value) => value
Err(error) => return Err(CyclesInputError(error))
}
let inventory = match path_inventory(data, parsed) {
Ok(value) => value
Err(error) => return Err(CyclesInputError(error))
}
let graph = match build(data) {
Ok(value) => value
Err(error) => return Err(CyclesInputError(error))
}
let components = match
cycles_components(inventory, graph.edges, data.length()) {
Ok(value) => value
Err(error) => return Err(error)
}
let mut imported = 0
let mut local_bytes = 0
for node in inventory {
if node.imported {
imported += 1
} else {
local_bytes += node.encoded_size.unwrap_or(0)
}
}
let mut cyclic_nodes = 0
let mut cyclic_bytes = 0
for component in components {
cyclic_nodes += component.node_count
cyclic_bytes += component.local_encoded_bytes
}
Ok({
file_size: data.length(),
sha256: experiment_sha256(data),
function_count: inventory.length(),
imported_function_count: imported,
local_function_count: inventory.length() - imported,
cyclic_function_count: cyclic_nodes,
non_cyclic_function_count: inventory.length() - cyclic_nodes,
component_count: components.length(),
components,
local_encoded_bytes: local_bytes,
cyclic_local_encoded_bytes: cyclic_bytes,
other_local_encoded_bytes: local_bytes - cyclic_bytes,
graph_complete: is_graph_complete(graph),
unresolved_count: graph.unresolved_indirect.length(),
diagnostics: sorted_diagnostics(graph),
graph_warning: render_graph_warning(graph),
modeling_scope: function_cycles_scope(),
})
}