Skip to main content

charon_lib/transform/resugar/
reconstruct_fallible_operations.rs

1//! # Micro-pass: remove the dynamic checks for array/slice bounds, overflow, and division by zero.
2//! Note that from a semantic point of view, an out-of-bound access or a division by zero
3//! must lead to a panic in Rust (which is why those checks are always present, even when
4//! compiling for release). In our case, we take this into account in the semantics of our
5//! array/slice manipulation and arithmetic functions, on the verification side.
6
7use std::collections::HashSet;
8
9use derive_generic_visitor::*;
10
11use crate::ast::ullbc_ast_utils::StmtLoc;
12use crate::ast::*;
13use crate::ids::IndexVec;
14use crate::transform::TransformCtx;
15use crate::ullbc_ast::{BlockId, ExprBody, Statement, StatementKind};
16
17use crate::transform::ctx::UllbcPass;
18
19type LocalUses = IndexVec<BlockId, HashSet<LocalId>>;
20
21/// Compute for each block the locals that are assumed to have been initialized/defined before entering it.
22fn compute_uses(body: &ExprBody) -> LocalUses {
23    #[derive(Visitor)]
24    struct UsedLocalsVisitor<'a>(&'a mut HashSet<LocalId>);
25
26    impl VisitBody for UsedLocalsVisitor<'_> {
27        fn visit_place(&mut self, x: &Place) -> ::std::ops::ControlFlow<Self::Break> {
28            if let Some(local_id) = x.as_local() {
29                self.0.insert(local_id);
30            }
31            self.visit_inner(x)
32        }
33    }
34
35    body.body.map_ref(|block| {
36        let mut uses = HashSet::new();
37        let mut visitor = UsedLocalsVisitor(&mut uses);
38
39        // do a simple live variable analysis by walking the block backwards
40        for statement in block.statements.iter().rev() {
41            match &statement.kind {
42                StatementKind::Assign(place, rval) => {
43                    // We clear the assigned place, but it may be re-added
44                    // if it's used in rval
45                    if let Some(local_id) = place.as_local() {
46                        visitor.0.remove(&local_id);
47                    }
48                    let _ = rval.drive_body(&mut visitor);
49                }
50                StatementKind::StorageLive(local) | StatementKind::StorageDead(local) => {
51                    // A `StorageLive` re-sets the local to be uninitialised,
52                    // so any usage after this point doesn't matter
53                    // Similarly, a `StorageDead` means the local is de-initialised,
54                    // so we can ignore any usage after this point
55                    visitor.0.remove(local);
56                }
57                _ => {
58                    let _ = statement.drive_body(&mut visitor);
59                }
60            }
61        }
62
63        uses
64    })
65}
66
67/// Whether the value uses the given local in a place.
68fn uses_local<T: BodyVisitable>(x: &T, local: LocalId) -> bool {
69    struct FoundIt;
70    struct UsesLocalVisitor(LocalId);
71
72    impl Visitor for UsesLocalVisitor {
73        type Break = FoundIt;
74    }
75    impl VisitBody for UsesLocalVisitor {
76        fn visit_place(&mut self, x: &Place) -> ::std::ops::ControlFlow<Self::Break> {
77            if let Some(local_id) = x.as_local()
78                && local_id == self.0
79            {
80                return ControlFlow::Break(FoundIt);
81            }
82            self.visit_inner(x)
83        }
84
85        fn visit_ullbc_statement(
86            &mut self,
87            x: &ullbc_ast::Statement,
88        ) -> ::std::ops::ControlFlow<Self::Break> {
89            match x.kind {
90                StatementKind::StorageDead(_) | StatementKind::StorageLive(_) => {
91                    ControlFlow::Continue(())
92                }
93                _ => self.visit_inner(x),
94            }
95        }
96    }
97
98    x.drive_body(&mut UsesLocalVisitor(local)).is_break()
99}
100
101fn make_binop_overflow_panic<T: BodyVisitable>(
102    x: &mut [T],
103    matches: impl Fn(&BinOp, &Operand, &Operand) -> bool,
104) -> bool {
105    let mut found = false;
106    for y in x.iter_mut() {
107        y.dyn_visit_in_body_mut(|rv: &mut Rvalue| {
108            if let Rvalue::BinaryOp(binop, op_l, op_r) = rv
109                && matches(binop, op_l, op_r)
110            {
111                *binop = binop.with_overflow(OverflowMode::Panic);
112                found = true;
113            }
114        });
115    }
116    found
117}
118
119fn make_unop_overflow_panic<T: BodyVisitable>(
120    x: &mut [T],
121    matches: impl Fn(&UnOp, &Operand) -> bool,
122) -> bool {
123    let mut found = false;
124    for y in x.iter_mut() {
125        y.dyn_visit_in_body_mut(|rv: &mut Rvalue| {
126            if let Rvalue::UnaryOp(unop, op) = rv
127                && matches(unop, op)
128            {
129                *unop = unop.with_overflow(OverflowMode::Panic);
130                found = true;
131            }
132        });
133    }
134    found
135}
136
137/// Check if the two operands are equivalent: either they're the same constant, or they represent
138/// the same place (regardless of whether the operand is a move or a copy)
139fn equiv_op(op_l: &Operand, op_r: &Operand) -> bool {
140    match (op_l, op_r) {
141        (Operand::Copy(l) | Operand::Move(l), Operand::Copy(r) | Operand::Move(r)) => l == r,
142        (Operand::Const(l), Operand::Const(r)) => l == r,
143        _ => false,
144    }
145}
146
147/// A shift overflow check whose shift operation is not in the same block: rustc evaluates the
148/// shift operands (which is when it emits the overflow check) before computing the destination
149/// place, so when that computation involves a function call (e.g. `out[i] = x >> y` through an
150/// overloaded `IndexMut`), the check ends up separated from the shift by the call terminator.
151/// See issue #1041. These asserts are only ever emitted by the compiler, so instead of following
152/// the control flow we record the check and find the matching shift over the whole body in
153/// [resolve_pending_shift_check].
154struct PendingShiftCheck {
155    /// The block containing the check.
156    block: BlockId,
157    /// The checked shift amount; used to find the shift.
158    amount: Operand,
159    /// The comparison result asserted by the check. Identifies the check's statements to remove.
160    cond_local: LocalId,
161    /// The `y as uN` cast temporary, when the amount is cast before the comparison; also removed.
162    cast_local: Option<LocalId>,
163}
164
165/// Rustc inserts dynamic checks during MIR lowering. They all end in an `Assert` statement (and
166/// this is the only use of this statement).
167fn remove_dynamic_checks(
168    _ctx: &mut TransformCtx,
169    uses: &LocalUses,
170    block_id: BlockId,
171    locals: &mut Locals,
172    statements: &mut [Statement],
173    pending_shift_checks: &mut Vec<PendingShiftCheck>,
174) {
175    // Whether this local was used in another block
176    let used_outside_block = |local: LocalId| {
177        uses.iter_enumerated()
178            .any(|(bid, used)| bid != block_id && used.contains(&local))
179    };
180
181    // We return the statements we want to keep, which must be a prefix of `block.statements`.
182    let statements_to_keep = match statements {
183        // Bounds checks for slices. They look like:
184        //   l := use(copy a.metadata)
185        //   b := copy x < copy l
186        //   assert(move b == true)
187        [
188            Statement {
189                kind: StatementKind::Assign(len, Rvalue::Use(Operand::Copy(len_op), _)),
190                ..
191            },
192            Statement {
193                kind:
194                    StatementKind::Assign(
195                        is_in_bounds,
196                        Rvalue::BinaryOp(BinOp::Lt, _, Operand::Copy(lt_op2)),
197                    ),
198                ..
199            },
200            Statement {
201                kind:
202                    StatementKind::Assert {
203                        assert:
204                            Assert {
205                                cond: Operand::Move(cond),
206                                expected: true,
207                                ..
208                            },
209                        ..
210                    },
211                ..
212            },
213            rest @ ..,
214        ] if lt_op2 == len
215            && cond == is_in_bounds
216            && let Some((_, ProjectionElem::PtrMetadata)) = len_op.as_projection() =>
217        {
218            rest
219        }
220        // Sometimes that instead looks like:
221        //   a := &raw const *z
222        //   l := use(copy a.metadata)
223        //   b := copy x < copy l
224        //   assert(move b == true)
225        [
226            Statement {
227                kind:
228                    StatementKind::Assign(
229                        reborrow,
230                        Rvalue::RawPtr {
231                            kind: RefKind::Shared,
232                            ..
233                        },
234                    ),
235                ..
236            },
237            Statement {
238                kind: StatementKind::Assign(len, Rvalue::Use(Operand::Copy(len_op), _)),
239                ..
240            },
241            Statement {
242                kind:
243                    StatementKind::Assign(
244                        is_in_bounds,
245                        Rvalue::BinaryOp(BinOp::Lt, _, Operand::Copy(lt_op2)),
246                    ),
247                ..
248            },
249            Statement {
250                kind:
251                    StatementKind::Assert {
252                        assert:
253                            Assert {
254                                cond: Operand::Move(cond),
255                                expected: true,
256                                check_kind: Some(BuiltinAssertKind::BoundsCheck { .. }),
257                            },
258                        ..
259                    },
260                ..
261            },
262            rest @ ..,
263        ] if lt_op2 == len
264            && cond == is_in_bounds
265            && let Some((slice_place, ProjectionElem::PtrMetadata)) = len_op.as_projection()
266            && reborrow == slice_place =>
267        {
268            rest
269        }
270
271        // Zero checks for division and remainder. They look like:
272        //   b := copy y == const 0
273        //   assert(move b == false)
274        //   ...
275        //   res := x {/,%} move y;
276        //   ... or ...
277        //   b := const y == const 0
278        //   assert(move b == false)
279        //   ...
280        //   res := x {/,%} const y;
281        //
282        // This also overlaps with overflow checks for negation, which looks like:
283        //   is_min := x == INT::min
284        //   assert(move is_min == false)
285        //   ...
286        //   res := -x;
287        [
288            Statement {
289                kind:
290                    StatementKind::Assign(
291                        is_zero,
292                        Rvalue::BinaryOp(BinOp::Eq, y_op, Operand::Const(_zero)),
293                    ),
294                ..
295            },
296            Statement {
297                kind:
298                    StatementKind::Assert {
299                        assert:
300                            Assert {
301                                cond: Operand::Move(cond),
302                                expected: false,
303                                check_kind:
304                                    Some(
305                                        BuiltinAssertKind::DivisionByZero(_)
306                                        | BuiltinAssertKind::RemainderByZero(_)
307                                        | BuiltinAssertKind::OverflowNeg(_),
308                                    ),
309                            },
310                        ..
311                    },
312                ..
313            },
314            rest @ ..,
315        ] if cond == is_zero => {
316            let found = make_binop_overflow_panic(rest, |bop, _, r| {
317                matches!(bop, BinOp::Div(_) | BinOp::Rem(_)) && equiv_op(r, y_op)
318            }) || make_unop_overflow_panic(rest, |unop, o| {
319                matches!(unop, UnOp::Neg(_)) && equiv_op(o, y_op)
320            });
321            if found {
322                rest
323            } else {
324                return;
325            }
326        }
327
328        // Overflow checks for signed division and remainder. They look like:
329        //   is_neg_1 := y == (-1)
330        //   is_min := x == INT::min
331        //   has_overflow := move (is_neg_1) & move (is_min)
332        //   assert(move has_overflow == false)
333        // Note here we don't need to update the operand to panic, as this was already done
334        // by the previous pass for division by zero.
335        [
336            Statement {
337                kind: StatementKind::Assign(is_neg_1, Rvalue::BinaryOp(BinOp::Eq, _y_op, _minus_1)),
338                ..
339            },
340            Statement {
341                kind: StatementKind::Assign(is_min, Rvalue::BinaryOp(BinOp::Eq, _x_op, _int_min)),
342                ..
343            },
344            Statement {
345                kind:
346                    StatementKind::Assign(
347                        has_overflow,
348                        Rvalue::BinaryOp(
349                            BinOp::BitAnd,
350                            Operand::Move(and_op1),
351                            Operand::Move(and_op2),
352                        ),
353                    ),
354                ..
355            },
356            Statement {
357                kind:
358                    StatementKind::Assert {
359                        assert:
360                            Assert {
361                                cond: Operand::Move(cond),
362                                expected: false,
363                                check_kind: Some(BuiltinAssertKind::Overflow(..)),
364                            },
365                        ..
366                    },
367                ..
368            },
369            rest @ ..,
370        ] if and_op1 == is_neg_1 && and_op2 == is_min && cond == has_overflow => rest,
371
372        // Overflow checks for right/left shift. They can look like:
373        //   a := y as u32; // or another type
374        //   b := move a < const 32; // or another constant
375        //   assert(move b == true);
376        //   ...
377        //   res := x {<<,>>} y;
378        [
379            Statement {
380                kind: StatementKind::Assign(cast, Rvalue::UnaryOp(UnOp::Cast(_), y_op)),
381                ..
382            },
383            Statement {
384                kind:
385                    StatementKind::Assign(
386                        has_overflow,
387                        Rvalue::BinaryOp(BinOp::Lt, Operand::Move(lhs), Operand::Const(..)),
388                    ),
389                ..
390            },
391            Statement {
392                kind:
393                    StatementKind::Assert {
394                        assert:
395                            Assert {
396                                cond: Operand::Move(cond),
397                                expected: true,
398                                check_kind: Some(BuiltinAssertKind::Overflow(..)),
399                            },
400                        ..
401                    },
402                ..
403            },
404            rest @ ..,
405        ] if cond == has_overflow
406            && lhs == cast
407            && let Some(cast_local) = cast.as_local()
408            && !rest.iter().any(|st| uses_local(st, cast_local)) =>
409        {
410            let found = make_binop_overflow_panic(rest, |bop, _, r| {
411                matches!(bop, BinOp::Shl(_) | BinOp::Shr(_)) && equiv_op(r, y_op)
412            });
413            if found {
414                rest
415            } else {
416                // The shift is not in this block; it may live in a later one (see
417                // [PendingShiftCheck]). Record it and resolve it over the whole body afterwards.
418                if let Some(cond_local) = has_overflow.as_local() {
419                    pending_shift_checks.push(PendingShiftCheck {
420                        block: block_id,
421                        amount: y_op.clone(),
422                        cond_local,
423                        cast_local: Some(cast_local),
424                    });
425                }
426                return;
427            }
428        }
429        // or like:
430        //   b := y < const 32; // or another constant
431        //   assert(move b == true);
432        //   ...
433        //   res := x {<<,>>} y;
434        //
435        // this also overlaps with out of bounds checks for arrays, so we check for either;
436        // these look like:
437        //   b := copy y < const _
438        //   assert(move b == true)
439        //   ...
440        //   res := a[y];
441        [
442            Statement {
443                kind:
444                    StatementKind::Assign(
445                        has_overflow,
446                        Rvalue::BinaryOp(BinOp::Lt, y_op, Operand::Const(..)),
447                    ),
448                ..
449            },
450            Statement {
451                kind:
452                    StatementKind::Assert {
453                        assert:
454                            Assert {
455                                cond: Operand::Move(cond),
456                                expected: true,
457                                check_kind:
458                                    check_kind @ Some(
459                                        BuiltinAssertKind::Overflow(..)
460                                        | BuiltinAssertKind::BoundsCheck { .. },
461                                    ),
462                            },
463                        ..
464                    },
465                ..
466            },
467            rest @ ..,
468        ] if cond == has_overflow => {
469            // look for a shift operation
470            let mut found = make_binop_overflow_panic(rest, |bop, _, r| {
471                matches!(bop, BinOp::Shl(_) | BinOp::Shr(_)) && equiv_op(r, y_op)
472            });
473            if !found {
474                // otherwise, look for an array access
475                for stmt in rest.iter_mut() {
476                    stmt.dyn_visit_in_body(|p: &Place| {
477                        if let Some((_, ProjectionElem::Index { offset, .. })) = p.as_projection()
478                            && equiv_op(offset, y_op)
479                        {
480                            found = true;
481                        }
482                    });
483                }
484            }
485
486            if found {
487                rest
488            } else {
489                // The shift may be in a later block (see [PendingShiftCheck]). This only applies
490                // to overflow checks: a bounds check never guards a shift.
491                if matches!(check_kind, Some(BuiltinAssertKind::Overflow(..)))
492                    && let Some(cond_local) = has_overflow.as_local()
493                {
494                    pending_shift_checks.push(PendingShiftCheck {
495                        block: block_id,
496                        amount: y_op.clone(),
497                        cond_local,
498                        cast_local: None,
499                    });
500                }
501                return;
502            }
503        }
504
505        // Overflow checks for addition/subtraction/multiplication. They look like:
506        // ```text
507        //   r := x checked.+ y;
508        //   assert(move r.1 == false);
509        //   ...
510        //   z := move r.0;
511        // ```
512        // We replace that with:
513        // ```text
514        // z := x panic.+ y;
515        // ```
516        //
517        // But sometimes, because of constant promotion, we end up with a lone checked operation
518        // without assert. In that case we replace it with its wrapping equivalent.
519        [
520            Statement {
521                kind:
522                    StatementKind::Assign(
523                        tuple,
524                        Rvalue::BinaryOp(
525                            binop @ (BinOp::AddChecked | BinOp::SubChecked | BinOp::MulChecked),
526                            _,
527                            _,
528                        ),
529                    ),
530                ..
531            },
532            rest @ ..,
533        ] if let Some(tuple_local_id) = tuple.as_local()
534            && !used_outside_block(tuple_local_id) =>
535        {
536            // Check if the result boolean is used in any other way than just getting the integer
537            // result.
538            let mut uses_of_tuple = 0;
539            let mut uses_of_integer = 0;
540            if *tuple == locals.return_place() {
541                uses_of_tuple += 1; // The return place counts as a use.
542            }
543            for stmt in rest.iter_mut() {
544                stmt.dyn_visit_in_body(|p: &Place| {
545                    if p == tuple {
546                        uses_of_tuple += 1;
547                    }
548                    if let Some((sub, ProjectionElem::Field(FieldProjKind::Tuple(..), fid))) =
549                        p.as_projection()
550                        && fid.index() == 0
551                        && sub == tuple
552                    {
553                        uses_of_integer += 1;
554                    }
555                });
556            }
557            // Check if the operation is followed by an assert.
558            let followed_by_assert = if let [
559                Statement {
560                    kind:
561                        StatementKind::Assert {
562                            assert:
563                                Assert {
564                                    cond: Operand::Move(assert_cond),
565                                    expected: false,
566                                    check_kind: Some(BuiltinAssertKind::Overflow(..)),
567                                },
568                            ..
569                        },
570                    ..
571                },
572                ..,
573            ] = rest
574                && let Some((sub, ProjectionElem::Field(FieldProjKind::Tuple(..), fid))) =
575                    assert_cond.as_projection()
576                && fid.index() == 1
577                && sub == tuple
578            {
579                true
580            } else {
581                false
582            };
583            if uses_of_tuple != uses_of_integer && !followed_by_assert {
584                // The tuple is used either directly or for the overflow check; we change nothing.
585                return;
586            }
587
588            if followed_by_assert {
589                // We have a compiler-emitted assert. We replace the operation with one that has
590                // panic-on-overflow semantics.
591                *binop = binop.with_overflow(OverflowMode::Panic);
592                // The failure behavior is part of the binop now, so we remove the assert.
593                rest[0].kind = StatementKind::Nop;
594            } else {
595                // The tuple is used exclusively to access the integer result, so we replace the
596                // operation with wrapping semantics.
597                *binop = binop.with_overflow(OverflowMode::Wrap);
598            }
599            // Fixup the local type.
600            let result_local = &mut locals.locals[tuple_local_id];
601            result_local.ty = result_local.ty.as_tuple().unwrap()[0].clone();
602            // Fixup the place type.
603            let new_result_place = locals.place_for_var(tuple_local_id);
604            // Replace uses of `r.0` with `r`.
605            for stmt in rest.iter_mut() {
606                stmt.dyn_visit_in_body_mut(|p: &mut Place| {
607                    if let Some((sub, ProjectionElem::Field(FieldProjKind::Tuple(..), fid))) =
608                        p.as_projection()
609                        && sub == tuple
610                    {
611                        assert_eq!(fid.index(), 0);
612                        *p = new_result_place.clone()
613                    }
614                });
615            }
616            *tuple = new_result_place;
617            return;
618        }
619
620        _ => return,
621    };
622
623    // Remove the statements we're not keeping.
624    let keep_len = statements_to_keep.len();
625    let removed_len = statements.len() - keep_len;
626    for i in 0..removed_len {
627        // If the statement we're removing assigns to a local that
628        // is used elsewhere (in the leftover statements or in another block),
629        // we don't remove it.
630        if let StatementKind::Assign(place, _) = &statements[i].kind
631            && let Some(local) = place.as_local()
632            && let mut statements_to_keep = statements[removed_len..].as_ref().iter()
633            && (used_outside_block(local) || statements_to_keep.any(|st| uses_local(st, local)))
634        {
635            continue;
636        };
637        statements[i].kind = StatementKind::Nop;
638    }
639}
640
641/// Resolve a [PendingShiftCheck] whose shift landed in a different block. We remove the check and
642/// compute the shift early, promoted to panic-on-overflow, into a fresh local placed where the
643/// check used to be; the fresh local then carries the result to the original destination.
644///
645/// This is correct because rustc has already evaluated both shift operands by the time control
646/// reaches the check: the only effects of the shift are that evaluation and the panic-on-overflow,
647/// and the overflow check already performs both at that point. Storing the result in a fresh local
648/// lets us keep the write to the (not-yet-computed) destination place at the original shift site.
649fn resolve_pending_shift_check(body: &mut ExprBody, check: PendingShiftCheck) {
650    let PendingShiftCheck {
651        block,
652        amount,
653        cond_local,
654        cast_local,
655    } = check;
656
657    // Locate the assert we will repurpose to hold the early, promoted shift. Pairing it with the
658    // shift below (via `zip`) means we bail before mutating anything if either is unexpectedly
659    // absent, so the shift is never rewired to a fresh local we never assigned.
660    let assert_pos = body.body[block].statements.iter().position(|stmt| {
661        matches!(
662            &stmt.kind,
663            StatementKind::Assert {
664                assert: Assert {
665                    cond: Operand::Move(cond),
666                    ..
667                },
668                ..
669            } if cond.as_local() == Some(cond_local)
670        )
671    });
672
673    // rustc only emits these asserts for its own shifts, so we don't follow the control flow: we
674    // look for the (still-wrapping) shift by its amount anywhere in the body.
675    let Some(((shift_loc, dest, lhs, rhs, panic_binop, ty), assert_pos)) = body
676        .body
677        .iter_enumerated()
678        .find_map(|(sb, block_data)| {
679            block_data
680                .statements
681                .iter()
682                .enumerate()
683                .find_map(|(i, stmt)| {
684                    if let StatementKind::Assign(dest, Rvalue::BinaryOp(binop, lhs, rhs)) =
685                        &stmt.kind
686                        && matches!(
687                            binop,
688                            BinOp::Shl(OverflowMode::Wrap) | BinOp::Shr(OverflowMode::Wrap)
689                        )
690                        && equiv_op(rhs, &amount)
691                    {
692                        Some((
693                            StmtLoc::new(sb, i),
694                            dest.clone(),
695                            lhs.clone(),
696                            rhs.clone(),
697                            binop.with_overflow(OverflowMode::Panic),
698                            dest.ty.clone(),
699                        ))
700                    } else {
701                        None
702                    }
703                })
704        })
705        .zip(assert_pos)
706    else {
707        return;
708    };
709
710    // Every mutation below now happens together, so the fresh local is always assigned and used.
711    let fresh = body.locals.new_var(None, ty);
712
713    // Drop the check's comparison and optional cast; they are dead once the assert is gone.
714    body.body[block].statements.iter_mut().for_each(|stmt| {
715        let is_check_temp = if let StatementKind::Assign(place, _) = &stmt.kind {
716            let local = place.as_local();
717            local == Some(cond_local) || cast_local.is_some_and(|c| local == Some(c))
718        } else {
719            false
720        };
721        if is_check_temp {
722            stmt.kind = StatementKind::Nop;
723        }
724    });
725
726    // Compute the shift early, promoted to panic-on-overflow, where the check used to be.
727    body[StmtLoc::new(block, assert_pos)].kind =
728        StatementKind::Assign(fresh.clone(), Rvalue::BinaryOp(panic_binop, lhs, rhs));
729
730    // Forward the result at the original shift site.
731    body[shift_loc].kind =
732        StatementKind::Assign(dest, Rvalue::Use(Operand::Move(fresh), WithRetag::No));
733}
734
735pub struct Transform;
736impl UllbcPass for Transform {
737    fn should_run(&self, options: &crate::options::TranslateOptions) -> bool {
738        options.reconstruct_fallible_operations
739    }
740
741    fn transform_body(&self, ctx: &mut TransformCtx, b: &mut ExprBody) {
742        let local_uses: LocalUses = compute_uses(b);
743        let mut pending_shift_checks = Vec::new();
744        b.transform_sequences_fwd(|id, locals, seq| {
745            remove_dynamic_checks(ctx, &local_uses, id, locals, seq, &mut pending_shift_checks);
746            Vec::new()
747        });
748        // Resolve the checks whose shift lives in a later block (see [PendingShiftCheck]).
749        pending_shift_checks
750            .into_iter()
751            .for_each(|check| resolve_pending_shift_check(b, check));
752    }
753}