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