1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
//! Bytecode stack-depth verifier — the third `--strict` check from #347 A2.
//!
//! Walks every function's instruction stream, tracking the abstract stack
//! depth through each opcode and branch. Reports a `StackError` when two
//! paths into the same program counter carry different depths, which would
//! mean a prior match arm leaked (or over-consumed) values and left the
//! stack in an inconsistent state for subsequent arms.
//!
//! The check is lightweight: it is a single linear pass with a small
//! worklist. No allocation beyond `Vec` is needed.
//!
//! # Known sound over-approximation
//!
//! `Return` and `Panic` terminate the function; their successors are not
//! added to the worklist. `TailCall` is treated like `Return`. This means
//! dead code after a `Return` / `Panic` is not checked — intentional.
use crate::op::Op;
use crate::program::Function;
#[derive(Debug, Clone, PartialEq)]
pub struct StackError {
pub fn_name: String,
pub pc: usize,
pub depth_a: i32,
pub depth_b: i32,
}
impl std::fmt::Display for StackError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"stack depth mismatch in `{}` at pc {}: path A leaves depth {}, path B leaves depth {}",
self.fn_name, self.pc, self.depth_a, self.depth_b
)
}
}
/// Verify all functions in a slice. Returns one error per inconsistent
/// merge point found.
pub fn verify_program(functions: &[Function]) -> Vec<StackError> {
let mut errors = Vec::new();
for func in functions {
verify_function(func, &mut errors);
}
errors
}
/// Verify a single function. Appends to `errors`.
pub fn verify_function(func: &Function, errors: &mut Vec<StackError>) {
let n = func.code.len();
if n == 0 {
return;
}
// `depths[pc]` = known stack depth at that pc, or `None` if not yet visited.
let mut depths: Vec<Option<i32>> = vec![None; n];
// Worklist: (pc, stack_depth_on_entry_to_this_instruction)
let mut worklist: Vec<(usize, i32)> = vec![(0, 0)];
while let Some((pc, depth)) = worklist.pop() {
if pc >= n {
continue;
}
// Merge-point check.
if let Some(prev) = depths[pc] {
if prev != depth {
errors.push(StackError {
fn_name: func.name.clone(),
pc,
depth_a: prev,
depth_b: depth,
});
}
// Already processed from this depth (or recorded mismatch).
continue;
}
depths[pc] = Some(depth);
let op = &func.code[pc];
let delta = stack_delta(op);
let next_depth = depth + delta;
match op {
// Unconditional jumps: only the target is a successor.
Op::Jump(off) => {
let target = (pc as i32 + 1 + off) as usize;
worklist.push((target, next_depth));
}
// Conditional jumps: fall-through and jump target are both successors.
// Note: JumpIf / JumpIfNot pop the Bool before branching, so `delta`
// already accounts for that (-1). Both successors start at next_depth.
Op::JumpIf(off) | Op::JumpIfNot(off) => {
let target = (pc as i32 + 1 + off) as usize;
worklist.push((pc + 1, next_depth));
worklist.push((target, next_depth));
}
// Terminators: no successors.
Op::Return | Op::TailCall { .. } | Op::Panic(_) => {}
// Slice-2 superinstruction (#461) owns 4 slots: the fused
// op + 3 tombstones (original PushConst + IntAdd +
// StoreLocal). The trailing tombstones' deltas don't
// cancel (+1, -1, -1 = -1), so we can't let the verifier
// walk them as live — it'd drift the depth at pc+4 vs the
// pre-fusion form. Skip directly to pc+4 with the
// unfused-equivalent depth.
Op::LoadLocalAddIntConstStoreLocal { .. } => {
worklist.push((pc + 4, next_depth));
}
// Slice-5 superinstructions (#461) — jump-aware fusion of
// `LoadLocal + LoadLocal|PushConst + IntLt + JumpIfNot`.
// 4-slot window like slice 2 but with TWO successors
// (fall-through and branch target). Tombstones' deltas
// (+1 LoadLocal/PushConst, -1 IntLt, -1 JumpIfNot = -1
// total) don't cancel — skip past in the same shape as
// slice 2's worklist override. The branch target's
// offset is relative to the JumpIfNot's `pc + 1`, which
// in the fused position is `pc + 4`.
Op::LoadLocalEqIntConstJumpIfNot { jump_offset, .. } => {
let target = (pc as i32 + 4 + jump_offset) as usize;
worklist.push((pc + 4, next_depth));
worklist.push((target, next_depth));
}
// Slice 6 owns a 6-slot window (this op + 5 tombstones).
// Same two-successor shape as slice 5 but with `pc + 6`
// arithmetic. Net stack delta is 0 — original was
// LoadLocal + StoreLocal + slice5 = +1 + -1 + 0.
Op::LoadLocalStoreEqIntConstJumpIfNot { jump_offset, .. } => {
let target = (pc as i32 + 6 + jump_offset) as usize;
worklist.push((pc + 6, next_depth));
worklist.push((target, next_depth));
}
// All other ops: single sequential successor.
_ => {
worklist.push((pc + 1, next_depth));
}
}
}
}
/// Returns the net change in stack depth caused by `op`.
///
/// Positive = pushes more than it pops.
/// Negative = pops more than it pushes.
fn stack_delta(op: &Op) -> i32 {
match op {
// Stack manipulation
Op::PushConst(_) => 1,
Op::Pop => -1,
Op::Dup => 1,
// Locals
Op::LoadLocal(_) => 1,
Op::StoreLocal(_) => -1,
// Record / tuple / list construction
Op::MakeRecord { field_count, .. } => -(*field_count as i32) + 1,
// #464 step 2: same stack-effect shape as MakeRecord (pops
// field_count, pushes 1). The verifier doesn't need to know
// about the stack-record arena — it walks bytecode shape only.
Op::AllocStackRecord { field_count, .. } => -(*field_count as i32) + 1,
// #463 slice 2a: same stack-effect shape as MakeRecord
// (verifier sees only bytecode shape, not the slab choice).
Op::AllocArenaRecord { field_count, .. } => -(*field_count as i32) + 1,
Op::MakeTuple(n) => -(*n as i32) + 1,
// #464 tuple codegen: same stack-effect shape as MakeTuple.
Op::AllocStackTuple { arity } => -(*arity as i32) + 1,
// #463 slice 2a: same stack-effect shape as MakeTuple.
Op::AllocArenaTuple { arity } => -(*arity as i32) + 1,
Op::MakeList(n) => -(*n as i32) + 1,
Op::MakeVariant { arity, .. } => -(*arity as i32) + 1,
// Field/element access: pop 1, push 1
Op::GetField { .. } => 0,
Op::GetElem(_) => 0,
Op::GetListElem(_) => 0,
Op::GetListLen => 0,
// Variant ops: pop 1, push 1
Op::TestVariant(_) => 0,
Op::GetVariant(_) => 0,
Op::GetVariantArg(_)=> 0,
// Binary list ops: pop 2, push 1
Op::ListAppend => -1,
Op::GetListElemDyn => -1,
// Jumps: delta handled in the control-flow logic above; use 0 here
// so that next_depth = depth + 0 is the "effective post-instruction depth"
// before branching. The successor depths are added by the control-flow arms.
Op::Jump(_) | Op::JumpIf(_) | Op::JumpIfNot(_) => {
// JumpIf/JumpIfNot pop the Bool.
match op {
Op::JumpIf(_) | Op::JumpIfNot(_) => -1,
_ => 0,
}
}
// Calls: pop arity args, push 1 result
Op::Call { arity, .. } => -(*arity as i32) + 1,
Op::TailCall { arity, .. } => -(*arity as i32) + 1,
Op::CallClosure { arity, .. }=> -(*arity as i32 + 1) + 1, // also pops closure
Op::EffectCall { arity, .. } => -(*arity as i32) + 1,
// Closure construction: pop captures, push closure
Op::MakeClosure { capture_count, .. } => -(*capture_count as i32) + 1,
// Higher-order ops: pop list + fn, push result list
Op::SortByKey { .. } => -1,
Op::ParallelMap { .. }=> -1,
// #464 native list builders. map/filter pop [xs, f] push 1;
// fold pops [xs, init, f] push 1.
Op::ListMap { .. } => -1,
Op::ListFilter { .. } => -1,
Op::ListFold { .. } => -2,
// Terminators
Op::Return => -1, // pop return value
Op::Panic(_)=> 0, // does not matter (no successor)
// Arithmetic / comparison — all binary except Neg/Not
Op::IntAdd | Op::IntSub | Op::IntMul | Op::IntDiv | Op::IntMod => -1,
Op::IntEq | Op::IntLt | Op::IntLe => -1,
Op::IntNeg => 0,
Op::FloatAdd | Op::FloatSub | Op::FloatMul | Op::FloatDiv => -1,
Op::FloatEq | Op::FloatLt | Op::FloatLe => -1,
Op::FloatNeg => 0,
Op::NumAdd | Op::NumSub | Op::NumMul | Op::NumDiv | Op::NumMod => -1,
Op::NumEq | Op::NumLt | Op::NumLe => -1,
Op::NumNeg => 0,
Op::BoolAnd | Op::BoolOr => -1,
Op::BoolNot => 0,
Op::StrConcat => -1,
Op::StrLen => 0,
Op::StrEq => -1,
Op::BytesLen => 0,
Op::BytesEq => -1,
// Superinstructions (#461). The fused op contributes its
// net delta (+1, same shape as a bare LoadLocal). The two
// inert primitive ops the peephole leaves at pc+1 / pc+2
// are walked as if live: their deltas (+1 PushConst, -1
// IntAdd) cancel, so the depth at pc+3 matches what the
// unfused sequence would have produced.
Op::LoadLocalAddIntConst { .. } => 1,
// Slice-2 fused op: src → dest with no net stack effect.
// Tombstones at the next 3 slots are *not* walked (see the
// control-flow successor logic in `verify_function`).
Op::LoadLocalAddIntConstStoreLocal { .. } => 0,
// Slice-3 fused op: LoadLocal(lhs) + LoadLocal(rhs) + IntAdd.
// Net delta +1 (pushes the sum). The trailing tombstones
// (LoadLocal + IntAdd) have deltas +1 and -1 — they cancel
// when walked as live, mirroring slice 1's shape.
Op::LoadLocalAddLocal { .. } => 1,
// Slice-4 fused ops: identical shape to slice 3, just with
// IntSub / IntMul as terminator. Net delta +1; trailing
// LoadLocal + IntSub|IntMul tombstones cancel when walked.
Op::LoadLocalSubLocal { .. } | Op::LoadLocalMulLocal { .. } => 1,
// Slice-5 fused ops: 4-slot window with net stack delta 0
// (original sequence had +1, +1, -1, -1). Worklist override
// above pushes both fall-through and branch successors with
// this depth; tombstones are not walked as live.
Op::LoadLocalEqIntConstJumpIfNot { .. }
| Op::LoadLocalStoreEqIntConstJumpIfNot { .. } => 0,
// Slice-7/8 fused ops (#461): net +1, same as bare LoadLocal.
// Trailing GetField (delta 0) + IntAdd/IntSub/IntMul (delta
// -1) tombstones cancel to -1 when walked, leaving depth at
// pc+3 matching the unfused [LoadLocal, GetField, <op>]
// sequence's overall delta of 0.
Op::LoadLocalGetFieldAdd { .. }
| Op::LoadLocalGetFieldSub { .. }
| Op::LoadLocalGetFieldMul { .. } => 1,
// Slice-9 fused op (#461): net +1 (LoadLocal +1, GetField 0),
// same as bare LoadLocal. The single trailing GetField
// tombstone (delta 0) leaves depth at pc+2 matching the
// unfused [LoadLocal, GetField] pair.
Op::LoadLocalGetField { .. } => 1,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::op::Op;
use crate::program::Function;
fn make_fn(name: &str, code: Vec<Op>) -> Function {
Function {
name: name.to_string(),
arity: 0,
locals_count: 4,
code,
effects: vec![],
body_hash: crate::program::ZERO_BODY_HASH,
refinements: vec![],
field_ic_sites: 0,
}
}
#[test]
fn clean_match_no_errors() {
// Simulates a two-arm match that is properly balanced:
// LoadLocal(0) ; push scrutinee depth=1
// Dup ; dup depth=2
// TestVariant("Ok") ; pop+push Bool depth=2
// JumpIfNot(+3) ; pop Bool, fall or jump depth=1
// Pop ; pop scrutinee depth=0
// PushConst(0) ; push result depth=1
// Jump(+2) ; to end depth=1
// Pop ; pop scrutinee (wildcard arm) depth=0
// PushConst(1) ; push result depth=1
// Return ; end depth=0
let code = vec![
Op::LoadLocal(0), // pc 0, depth 0→1
Op::Dup, // pc 1, depth 1→2
Op::TestVariant(0), // pc 2, depth 2→2
Op::JumpIfNot(3), // pc 3, depth 2→1; target=pc7
Op::Pop, // pc 4, depth 1→0
Op::PushConst(0), // pc 5, depth 0→1
Op::Jump(2), // pc 6, depth 1→1; target=pc9
Op::Pop, // pc 7, depth 1→0 (wildcard arm)
Op::PushConst(1), // pc 8, depth 0→1
Op::Return, // pc 9, depth 1→0
];
let f = make_fn("clean", code);
let mut errs = Vec::new();
verify_function(&f, &mut errs);
assert!(errs.is_empty(), "expected no errors, got: {errs:?}");
}
#[test]
fn leaked_scrutinee_detected() {
// Two paths reach pc6 at different depths — mismatch detected.
// Fall path: pc2→pc3→pc6 at depth 1.
// Jump path: pc4→pc5→pc6 at depth 2 (extra push leaks).
let mismatch2 = vec![
Op::PushConst(0), // pc0 depth 0→1
Op::JumpIfNot(2), // pc1 depth 1→0; fall=pc2 depth0, jump=pc4 depth0
Op::PushConst(0), // pc2 depth 0→1
Op::Jump(2), // pc3 target=pc6, depth=1
Op::PushConst(0), // pc4 depth 0→1
Op::PushConst(0), // pc5 depth 1→2
Op::Return, // pc6: reached at depth=1 (from pc3) AND depth=2 (from pc5+fall)
];
let f2 = make_fn("mismatch", mismatch2);
let mut errs2 = Vec::new();
verify_function(&f2, &mut errs2);
assert!(!errs2.is_empty(), "expected stack mismatch error");
assert_eq!(errs2[0].fn_name, "mismatch");
}
}