Skip to main content

forge/mutation/
type_analysis.rs

1//! Type-aware filtering for operator mutations.
2//!
3//! This module records replacements that are type-invalid. It deliberately does not filter
4//! replacements merely because they return the same value for every input: different operators can
5//! still compile to gas-distinct bytecode.
6
7use std::{
8    collections::{HashMap, HashSet},
9    ops::ControlFlow,
10    path::{Path, PathBuf},
11};
12
13use eyre::Result;
14use foundry_cli::opts::configure_pcx_from_compile_output;
15use foundry_compilers::{ProjectCompileOutput, compilers::multi::MultiCompiler};
16use foundry_config::Config;
17use solar::{
18    ast::{BinOpKind, LitKind, UnOpKind},
19    interface::{Session, source_map::FileName},
20    sema::{
21        Compiler, CompilerRef, Gcx,
22        hir::{self, ElementaryType, ExprKind, Visit},
23        ty::TyKind,
24    },
25};
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
28enum ReplacementOperator {
29    Assignment(AssignmentReplacement),
30    Binary(BinOpKind),
31    Unary(UnOpKind),
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
35pub enum AssignmentReplacement {
36    Zero,
37    Negate,
38}
39
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
41pub struct MutationExclusion {
42    lo: u32,
43    hi: u32,
44    new_op: ReplacementOperator,
45}
46
47impl MutationExclusion {
48    pub fn assignment(span: solar::ast::Span, replacement: AssignmentReplacement) -> Self {
49        Self {
50            lo: span.lo().0,
51            hi: span.hi().0,
52            new_op: ReplacementOperator::Assignment(replacement),
53        }
54    }
55
56    pub fn binary(span: solar::ast::Span, new_op: BinOpKind) -> Self {
57        Self { lo: span.lo().0, hi: span.hi().0, new_op: ReplacementOperator::Binary(new_op) }
58    }
59
60    pub fn unary(span: solar::ast::Span, new_op: UnOpKind) -> Self {
61        Self { lo: span.lo().0, hi: span.hi().0, new_op: ReplacementOperator::Unary(new_op) }
62    }
63}
64
65pub type MutationExclusionSet = HashSet<MutationExclusion>;
66pub type MutationExclusionsByPath = HashMap<PathBuf, MutationExclusionSet>;
67
68pub fn collect_mutation_exclusions(
69    config: &Config,
70    output: &ProjectCompileOutput<MultiCompiler>,
71) -> Result<MutationExclusionsByPath> {
72    let mut compiler = Compiler::new(Session::builder().with_silent_emitter(None).build());
73    compiler.enter_mut(|compiler| {
74        let mut pcx = compiler.parse();
75        configure_pcx_from_compile_output(&mut pcx, config, output, None)?;
76        pcx.parse();
77
78        let Ok(ControlFlow::Continue(())) = compiler.lower_asts() else {
79            return Ok(MutationExclusionsByPath::new());
80        };
81        Ok(analyze_and_collect(compiler))
82    })
83}
84
85fn analyze_and_collect(compiler: &CompilerRef<'_>) -> MutationExclusionsByPath {
86    let Ok(ControlFlow::Continue(())) = compiler.analysis() else {
87        return MutationExclusionsByPath::new();
88    };
89    if compiler.dcx().has_errors().is_err() {
90        return MutationExclusionsByPath::new();
91    }
92    collect_from_gcx(compiler.gcx())
93}
94
95fn collect_from_gcx<'gcx>(gcx: Gcx<'gcx>) -> MutationExclusionsByPath {
96    let mut by_path = MutationExclusionsByPath::new();
97    for source_id in gcx.hir.source_ids() {
98        let source = gcx.hir.source(source_id);
99        let FileName::Real(path) = &source.file.name else { continue };
100        let mut collector = MutationExclusionCollector {
101            gcx,
102            // HIR uses offsets in the compiler-wide source map, while the mutation visitor parses
103            // each target independently and uses offsets relative to that file.
104            source_start: source.file.start_pos.0,
105            mutations: MutationExclusionSet::new(),
106        };
107        let _ = collector.visit_nested_source(source_id);
108        if !collector.mutations.is_empty() {
109            by_path.insert(normalize_path(path), collector.mutations);
110        }
111    }
112    by_path
113}
114
115pub fn normalize_path(path: &Path) -> PathBuf {
116    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
117}
118
119struct MutationExclusionCollector<'hir> {
120    gcx: Gcx<'hir>,
121    source_start: u32,
122    mutations: MutationExclusionSet,
123}
124
125impl<'hir> Visit<'hir> for MutationExclusionCollector<'hir> {
126    type BreakValue = ();
127
128    fn hir(&self) -> &'hir hir::Hir<'hir> {
129        &self.gcx.hir
130    }
131
132    fn visit_expr(&mut self, expr: &'hir hir::Expr<'hir>) -> ControlFlow<Self::BreakValue> {
133        if let ExprKind::Assign(destination, op, value) = &expr.kind
134            && let Some(ty) = self.gcx.type_of_expr(destination.id)
135        {
136            if let Some(op) = op {
137                self.collect_compound_assignment(value, ty, op.kind);
138            } else {
139                self.collect_assignment(value, ty);
140            }
141        }
142        if let ExprKind::Binary(left, op, right) = &expr.kind
143            && (op.kind.is_cmp() || matches!(op.kind, BinOpKind::And | BinOpKind::Or))
144        {
145            self.collect_comparison(expr, left, op.kind, right);
146        }
147        if let ExprKind::Unary(_, operand) = &expr.kind
148            && let Some(kind) = unary_operand_kind(self.gcx, operand)
149            && let Some(span) = self.local_span(expr.span)
150        {
151            match kind {
152                UnaryOperandKind::SignedInteger => {}
153                UnaryOperandKind::UnsignedInteger => {
154                    self.mutations.insert(MutationExclusion::unary(span, UnOpKind::Neg));
155                }
156                UnaryOperandKind::FixedBytes => {
157                    for op in [
158                        UnOpKind::PreInc,
159                        UnOpKind::PreDec,
160                        UnOpKind::PostInc,
161                        UnOpKind::PostDec,
162                        UnOpKind::Neg,
163                    ] {
164                        self.mutations.insert(MutationExclusion::unary(span, op));
165                    }
166                }
167            }
168        }
169        if let ExprKind::Unary(_, operand) = &expr.kind
170            && is_non_storage_push_call(self.gcx, operand)
171            && let Some(span) = self.local_span(expr.span)
172        {
173            for op in [UnOpKind::PreInc, UnOpKind::PreDec, UnOpKind::PostInc, UnOpKind::PostDec] {
174                self.mutations.insert(MutationExclusion::unary(span, op));
175            }
176        }
177        self.walk_expr(expr)
178    }
179
180    fn visit_var(&mut self, var: &'hir hir::Variable<'hir>) -> ControlFlow<Self::BreakValue> {
181        if let Some(value) = var.initializer {
182            self.collect_assignment(value, self.gcx.type_of_hir_ty(&var.ty));
183        }
184        self.walk_var(var)
185    }
186}
187
188impl MutationExclusionCollector<'_> {
189    fn collect_assignment(&mut self, value: &hir::Expr<'_>, destination: solar::sema::ty::Ty<'_>) {
190        let Some(span) = self.local_span(value.span) else { return };
191        let Some(kind) = assignment_destination_kind(destination) else { return };
192
193        if !kind.accepts_zero() {
194            self.mutations.insert(MutationExclusion::assignment(span, AssignmentReplacement::Zero));
195        }
196        if !kind.accepts_negation() {
197            self.mutations
198                .insert(MutationExclusion::assignment(span, AssignmentReplacement::Negate));
199        }
200    }
201
202    fn collect_compound_assignment(
203        &mut self,
204        value: &hir::Expr<'_>,
205        destination: solar::sema::ty::Ty<'_>,
206        op: BinOpKind,
207    ) {
208        let Some(span) = self.local_span(value.span) else { return };
209        let Some(destination) = assignment_destination_kind(destination) else { return };
210        let Some(value_accepts_negation) = assignment_value_accepts_negation(self.gcx, value)
211        else {
212            return;
213        };
214
215        if op.is_shift() || !destination.accepts_negation() || !value_accepts_negation {
216            self.mutations
217                .insert(MutationExclusion::assignment(span, AssignmentReplacement::Negate));
218        }
219    }
220
221    fn collect_comparison(
222        &mut self,
223        expr: &hir::Expr<'_>,
224        left: &hir::Expr<'_>,
225        original: BinOpKind,
226        right: &hir::Expr<'_>,
227    ) {
228        let Some(span) = self.local_span(expr.span) else { return };
229
230        for candidate in [
231            BinOpKind::Lt,
232            BinOpKind::Le,
233            BinOpKind::Gt,
234            BinOpKind::Ge,
235            BinOpKind::Eq,
236            BinOpKind::Ne,
237            BinOpKind::Or,
238            BinOpKind::And,
239        ] {
240            if candidate != original
241                && is_type_invalid_replacement(self.gcx, left, original, right, candidate)
242            {
243                self.mutations.insert(MutationExclusion::binary(span, candidate));
244            }
245        }
246    }
247
248    fn local_span(&self, span: solar::ast::Span) -> Option<solar::ast::Span> {
249        let lo = span.lo().0.checked_sub(self.source_start)?;
250        let hi = span.hi().0.checked_sub(self.source_start)?;
251        Some(solar::ast::Span::new(solar::interface::BytePos(lo), solar::interface::BytePos(hi)))
252    }
253}
254
255#[derive(Clone, Copy)]
256enum AssignmentDestinationKind {
257    SignedNumber,
258    UnsignedNumber,
259    FixedBytes,
260    Other,
261}
262
263impl AssignmentDestinationKind {
264    const fn accepts_zero(self) -> bool {
265        !matches!(self, Self::Other)
266    }
267
268    const fn accepts_negation(self) -> bool {
269        matches!(self, Self::SignedNumber)
270    }
271}
272
273fn assignment_destination_kind(ty: solar::sema::ty::Ty<'_>) -> Option<AssignmentDestinationKind> {
274    match ty.peel_refs().kind {
275        TyKind::Elementary(ElementaryType::Int(_) | ElementaryType::Fixed(..)) => {
276            Some(AssignmentDestinationKind::SignedNumber)
277        }
278        TyKind::Elementary(ElementaryType::UInt(_) | ElementaryType::UFixed(..)) => {
279            Some(AssignmentDestinationKind::UnsignedNumber)
280        }
281        TyKind::Elementary(ElementaryType::FixedBytes(_)) => {
282            Some(AssignmentDestinationKind::FixedBytes)
283        }
284        TyKind::Udvt(..) | TyKind::Err(_) => None,
285        _ => Some(AssignmentDestinationKind::Other),
286    }
287}
288
289fn assignment_value_accepts_negation(gcx: Gcx<'_>, value: &hir::Expr<'_>) -> Option<bool> {
290    if matches!(&value.peel_parens().kind, ExprKind::Lit(lit) if matches!(lit.kind, LitKind::Number(_)))
291    {
292        return Some(true);
293    }
294
295    let ty = gcx.type_of_expr(value.peel_parens().id)?;
296    match ty.peel_refs().kind {
297        TyKind::Elementary(ElementaryType::Int(_) | ElementaryType::Fixed(..)) => Some(true),
298        TyKind::Elementary(
299            ElementaryType::UInt(_) | ElementaryType::UFixed(..) | ElementaryType::FixedBytes(_),
300        ) => Some(false),
301        TyKind::Udvt(..) | TyKind::Err(_) => None,
302        _ => Some(false),
303    }
304}
305
306fn is_type_invalid_replacement(
307    gcx: Gcx<'_>,
308    left: &hir::Expr<'_>,
309    original: BinOpKind,
310    right: &hir::Expr<'_>,
311    candidate: BinOpKind,
312) -> bool {
313    match candidate {
314        BinOpKind::And | BinOpKind::Or => matches!(
315            comparison_operand_kind(gcx, left, right),
316            Some(ComparisonOperandKind::Function | ComparisonOperandKind::Other)
317        ),
318        BinOpKind::Lt | BinOpKind::Le | BinOpKind::Gt | BinOpKind::Ge => {
319            matches!(original, BinOpKind::And | BinOpKind::Or)
320                || matches!(
321                    comparison_operand_kind(gcx, left, right),
322                    Some(ComparisonOperandKind::Bool | ComparisonOperandKind::Function)
323                )
324        }
325        _ => false,
326    }
327}
328
329#[derive(Clone, Copy, Debug, PartialEq, Eq)]
330enum ComparisonOperandKind {
331    Bool,
332    Function,
333    Other,
334}
335
336fn comparison_operand_kind(
337    gcx: Gcx<'_>,
338    left: &hir::Expr<'_>,
339    right: &hir::Expr<'_>,
340) -> Option<ComparisonOperandKind> {
341    let left = gcx.type_of_expr(left.id)?;
342    let right = gcx.type_of_expr(right.id)?;
343    match (left.peel_refs().kind, right.peel_refs().kind) {
344        (TyKind::Elementary(ElementaryType::Bool), TyKind::Elementary(ElementaryType::Bool)) => {
345            Some(ComparisonOperandKind::Bool)
346        }
347        (TyKind::Fn(_), TyKind::Fn(_)) => Some(ComparisonOperandKind::Function),
348        (TyKind::Err(_), _) | (_, TyKind::Err(_)) => None,
349        _ => Some(ComparisonOperandKind::Other),
350    }
351}
352
353#[derive(Clone, Copy)]
354enum UnaryOperandKind {
355    SignedInteger,
356    UnsignedInteger,
357    FixedBytes,
358}
359
360fn unary_operand_kind(gcx: Gcx<'_>, expr: &hir::Expr<'_>) -> Option<UnaryOperandKind> {
361    let ty = gcx.type_of_expr(expr.peel_parens().id)?;
362    match ty.peel_refs().kind {
363        TyKind::Elementary(ElementaryType::Int(_)) => Some(UnaryOperandKind::SignedInteger),
364        TyKind::Elementary(ElementaryType::UInt(_)) => Some(UnaryOperandKind::UnsignedInteger),
365        TyKind::Elementary(ElementaryType::FixedBytes(_)) => Some(UnaryOperandKind::FixedBytes),
366        _ => None,
367    }
368}
369
370fn is_non_storage_push_call(gcx: Gcx<'_>, expr: &hir::Expr<'_>) -> bool {
371    let ExprKind::Call(callee, args, _) = &expr.peel_parens().kind else { return false };
372    let ExprKind::Member(receiver, member) = &callee.peel_parens().kind else { return false };
373    if member.as_str() != "push" || !args.is_empty() {
374        return false;
375    }
376
377    !gcx.type_of_expr(receiver.id).is_some_and(|ty| {
378        matches!(
379            ty.kind,
380            TyKind::Ref(inner, location)
381                if location.is_storage()
382                    && matches!(
383                        inner.kind,
384                        TyKind::DynArray(_) | TyKind::Elementary(ElementaryType::Bytes)
385                    )
386        )
387    })
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use solar::interface::BytePos;
394
395    fn collect(source: &str) -> MutationExclusionSet {
396        let path = PathBuf::from("Test.sol");
397        let mut compiler = Compiler::new(Session::builder().with_silent_emitter(None).build());
398        compiler.enter_mut(|compiler| {
399            let mut pcx = compiler.parse();
400            let file =
401                pcx.sess.source_map().new_source_file(path.clone(), source).expect("source file");
402            pcx.add_file(file);
403            pcx.parse();
404            assert!(matches!(compiler.lower_asts(), Ok(ControlFlow::Continue(()))));
405            analyze_and_collect(compiler).remove(&path).unwrap_or_default()
406        })
407    }
408
409    fn mutation(source: &str, expression: &str, new_op: BinOpKind) -> MutationExclusion {
410        let lo = source.find(expression).expect("expression") as u32;
411        MutationExclusion::binary(
412            solar::ast::Span::new(BytePos(lo), BytePos(lo + expression.len() as u32)),
413            new_op,
414        )
415    }
416
417    fn unary_mutation(source: &str, expression: &str, new_op: UnOpKind) -> MutationExclusion {
418        let lo = source.find(expression).expect("expression") as u32;
419        MutationExclusion::unary(
420            solar::ast::Span::new(BytePos(lo), BytePos(lo + expression.len() as u32)),
421            new_op,
422        )
423    }
424
425    fn assignment_mutation(
426        source: &str,
427        value: &str,
428        replacement: AssignmentReplacement,
429    ) -> MutationExclusion {
430        let lo = source.rfind(value).expect("value") as u32;
431        MutationExclusion::assignment(
432            solar::ast::Span::new(BytePos(lo), BytePos(lo + value.len() as u32)),
433            replacement,
434        )
435    }
436
437    #[test]
438    fn excludes_assignments_invalid_for_destination_type() {
439        let source = r#"
440enum Choice { A, B }
441
442contract Test {
443    function check(address account, bool flag, uint256 unsigned, Choice choice) external pure {
444        address accountCopy = account;
445        bool flagCopy = flag;
446        uint256 unsignedCopy = unsigned;
447        Choice choiceCopy = choice;
448    }
449}
450"#;
451        let mutations = collect(source);
452
453        for value in ["account", "flag", "choice"] {
454            assert!(mutations.contains(&assignment_mutation(
455                source,
456                value,
457                AssignmentReplacement::Zero,
458            )));
459            assert!(mutations.contains(&assignment_mutation(
460                source,
461                value,
462                AssignmentReplacement::Negate,
463            )));
464        }
465        assert!(!mutations.contains(&assignment_mutation(
466            source,
467            "unsigned",
468            AssignmentReplacement::Zero,
469        )));
470        assert!(mutations.contains(&assignment_mutation(
471            source,
472            "unsigned",
473            AssignmentReplacement::Negate,
474        )));
475    }
476
477    #[test]
478    fn uses_lhs_type_for_assignments() {
479        let source = r#"
480contract Test {
481    function check(address account, bool flag) external pure {
482        account = account;
483        flag = flag;
484    }
485}
486"#;
487        let mutations = collect(source);
488
489        for value in ["account", "flag"] {
490            assert!(mutations.contains(&assignment_mutation(
491                source,
492                value,
493                AssignmentReplacement::Zero,
494            )));
495            assert!(mutations.contains(&assignment_mutation(
496                source,
497                value,
498                AssignmentReplacement::Negate,
499            )));
500        }
501    }
502
503    #[test]
504    fn excludes_invalid_compound_assignment_negation() {
505        let source = r#"
506contract Test {
507    uint256 unsignedTotal;
508    int256 signedTotal;
509    bytes32 word;
510
511    function check(
512        uint256 unsignedAmount,
513        int256 signedAmount,
514        bytes32 mask,
515        uint8 shift
516    ) external {
517        unsignedTotal += unsignedAmount;
518        signedTotal += signedAmount;
519        word &= mask;
520        signedTotal <<= shift;
521        signedTotal += 11;
522        signedTotal &= 12;
523        signedTotal <<= 13;
524        signedTotal >>= 14;
525    }
526}
527"#;
528        let mutations = collect(source);
529
530        for value in ["unsignedAmount", "mask", "shift"] {
531            assert!(mutations.contains(&assignment_mutation(
532                source,
533                value,
534                AssignmentReplacement::Negate,
535            )));
536        }
537        assert!(!mutations.contains(&assignment_mutation(
538            source,
539            "signedAmount",
540            AssignmentReplacement::Negate,
541        )));
542        for value in ["11", "12"] {
543            assert!(!mutations.contains(&assignment_mutation(
544                source,
545                value,
546                AssignmentReplacement::Negate,
547            )));
548        }
549        for value in ["13", "14"] {
550            assert!(mutations.contains(&assignment_mutation(
551                source,
552                value,
553                AssignmentReplacement::Negate,
554            )));
555        }
556        for value in ["unsignedAmount", "signedAmount", "mask", "shift"] {
557            assert!(!mutations.contains(&assignment_mutation(
558                source,
559                value,
560                AssignmentReplacement::Zero,
561            )));
562        }
563    }
564
565    #[test]
566    fn preserves_valid_signed_and_fixed_bytes_assignments() {
567        let source = r#"
568contract Test {
569    function check(int256 signed, bytes32 word) external pure {
570        int256 signedCopy = signed;
571        bytes32 wordCopy = word;
572    }
573}
574"#;
575        let mutations = collect(source);
576
577        for replacement in [AssignmentReplacement::Zero, AssignmentReplacement::Negate] {
578            assert!(!mutations.contains(&assignment_mutation(source, "signed", replacement)));
579        }
580        assert!(!mutations.contains(&assignment_mutation(
581            source,
582            "word",
583            AssignmentReplacement::Zero,
584        )));
585        assert!(mutations.contains(&assignment_mutation(
586            source,
587            "word",
588            AssignmentReplacement::Negate,
589        )));
590    }
591
592    #[test]
593    fn preserves_udvt_assignments() {
594        let source = r#"
595type Amount is uint256;
596
597contract Test {
598    function check(Amount amount) external pure {
599        Amount copy = amount;
600    }
601}
602"#;
603        let mutations = collect(source);
604
605        for replacement in [AssignmentReplacement::Zero, AssignmentReplacement::Negate] {
606            assert!(!mutations.contains(&assignment_mutation(source, "amount", replacement)));
607        }
608    }
609
610    #[test]
611    fn preserves_all_mutations_when_analysis_has_errors() {
612        let source = r#"
613contract Test {
614    function check(address account) external pure {
615        address copy = account;
616        unresolved = unresolved;
617    }
618}
619"#;
620
621        assert!(collect(source).is_empty());
622    }
623
624    #[test]
625    fn preserves_gas_distinct_unsigned_boundary_mutations() {
626        let source = r#"
627contract Test {
628    function check(uint256 x) external pure returns (bool) {
629        return x == 0;
630    }
631}
632"#;
633        let mutations = collect(source);
634
635        assert!(!mutations.contains(&mutation(source, "x == 0", BinOpKind::Le)));
636        assert!(!mutations.contains(&mutation(source, "x == 0", BinOpKind::Lt)));
637    }
638
639    #[test]
640    fn excludes_negation_only_for_unsigned_unary_operands() {
641        let source = r#"
642contract Test {
643    function check(uint256 unsigned, int256 signed) external pure {
644        unsigned++;
645        ++unsigned;
646        signed++;
647    }
648}
649"#;
650        let mutations = collect(source);
651
652        assert!(mutations.contains(&unary_mutation(source, "unsigned++", UnOpKind::Neg)));
653        assert!(mutations.contains(&unary_mutation(source, "++unsigned", UnOpKind::Neg)));
654        assert!(!mutations.contains(&unary_mutation(source, "signed++", UnOpKind::Neg)));
655    }
656
657    #[test]
658    fn excludes_lvalue_mutations_for_user_defined_push() {
659        let source = r#"
660contract Test {
661    function push() external pure returns (int256) {
662        return 1;
663    }
664
665    function check() external view returns (int256) {
666        return -this.push();
667    }
668}
669"#;
670        let mutations = collect(source);
671
672        for op in [UnOpKind::PreInc, UnOpKind::PreDec, UnOpKind::PostInc, UnOpKind::PostDec] {
673            assert!(mutations.contains(&unary_mutation(source, "-this.push()", op)));
674        }
675    }
676
677    #[test]
678    fn preserves_lvalue_mutations_for_storage_push() {
679        let source = r#"
680contract Test {
681    int256[] values;
682
683    function check() external returns (int256) {
684        return -values.push();
685    }
686}
687"#;
688        let mutations = collect(source);
689
690        for op in [UnOpKind::PreInc, UnOpKind::PreDec, UnOpKind::PostInc, UnOpKind::PostDec] {
691            assert!(!mutations.contains(&unary_mutation(source, "-values.push()", op)));
692        }
693    }
694
695    #[test]
696    fn excludes_invalid_fixed_bytes_unary_mutations() {
697        let source = r#"
698contract Test {
699    function check(bytes32 word) external pure returns (bytes32) {
700        return ~word;
701    }
702
703    function checkNumber(uint256 number) external pure returns (uint256) {
704        return ~number;
705    }
706}
707"#;
708        let mutations = collect(source);
709
710        for op in [
711            UnOpKind::PreInc,
712            UnOpKind::PreDec,
713            UnOpKind::PostInc,
714            UnOpKind::PostDec,
715            UnOpKind::Neg,
716        ] {
717            assert!(mutations.contains(&unary_mutation(source, "~word", op)));
718        }
719        assert!(!mutations.contains(&unary_mutation(source, "~word", UnOpKind::BitNot)));
720
721        for op in [UnOpKind::PreInc, UnOpKind::PreDec, UnOpKind::PostInc, UnOpKind::PostDec] {
722            assert!(!mutations.contains(&unary_mutation(source, "~number", op)));
723        }
724        assert!(mutations.contains(&unary_mutation(source, "~number", UnOpKind::Neg)));
725    }
726
727    #[test]
728    fn excludes_invalid_fixed_bytes_storage_push_mutations() {
729        let source = r#"
730contract Test {
731    bytes32[] values;
732
733    function check() external returns (bytes32) {
734        return ~values.push();
735    }
736}
737"#;
738        let mutations = collect(source);
739
740        for op in [
741            UnOpKind::PreInc,
742            UnOpKind::PreDec,
743            UnOpKind::PostInc,
744            UnOpKind::PostDec,
745            UnOpKind::Neg,
746        ] {
747            assert!(mutations.contains(&unary_mutation(source, "~values.push()", op)));
748        }
749    }
750
751    #[test]
752    fn preserves_overloaded_negation_for_unsigned_udvt() {
753        let source = r#"
754type Amount is uint256;
755
756function negate(Amount amount) pure returns (Amount) {
757    return Amount.wrap(type(uint256).max - Amount.unwrap(amount));
758}
759
760function complement(Amount amount) pure returns (Amount) {
761    return Amount.wrap(~Amount.unwrap(amount));
762}
763
764using {negate as -, complement as ~} for Amount global;
765
766contract Test {
767    function check(Amount amount) external pure returns (Amount) {
768        return ~amount;
769    }
770}
771"#;
772        let mutations = collect(source);
773
774        assert!(!mutations.contains(&unary_mutation(source, "~amount", UnOpKind::Neg)));
775    }
776
777    #[test]
778    fn preserves_negation_for_unresolved_operand() {
779        let source = r#"
780contract Test {
781    function check() external pure {
782        unresolved++;
783    }
784}
785"#;
786        let mutations = collect(source);
787
788        assert!(!mutations.contains(&unary_mutation(source, "unresolved++", UnOpKind::Neg)));
789    }
790
791    #[test]
792    fn excludes_logical_replacements_for_numeric_comparisons() {
793        let source = r#"
794contract Test {
795    function check(uint256 left, uint256 right) external pure returns (bool) {
796        return left == right;
797    }
798}
799"#;
800        let mutations = collect(source);
801
802        assert!(mutations.contains(&mutation(source, "left == right", BinOpKind::And)));
803        assert!(mutations.contains(&mutation(source, "left == right", BinOpKind::Or)));
804        assert!(!mutations.contains(&mutation(source, "left == right", BinOpKind::Lt)));
805        assert!(!mutations.contains(&mutation(source, "left == right", BinOpKind::Ne)));
806    }
807
808    #[test]
809    fn excludes_ordered_replacements_for_boolean_equality() {
810        let source = r#"
811contract Test {
812    function check(bool left, bool right) external pure returns (bool) {
813        return left == right;
814    }
815}
816"#;
817        let mutations = collect(source);
818
819        for candidate in [BinOpKind::Lt, BinOpKind::Le, BinOpKind::Gt, BinOpKind::Ge] {
820            assert!(mutations.contains(&mutation(source, "left == right", candidate)));
821        }
822        for candidate in [BinOpKind::Ne, BinOpKind::And, BinOpKind::Or] {
823            assert!(!mutations.contains(&mutation(source, "left == right", candidate)));
824        }
825    }
826
827    #[test]
828    fn excludes_ordered_replacements_for_logical_operations() {
829        let source = r#"
830contract Test {
831    function check(bool left, bool right) external pure returns (bool) {
832        return left && right;
833    }
834}
835"#;
836        let mutations = collect(source);
837
838        for candidate in [BinOpKind::Lt, BinOpKind::Le, BinOpKind::Gt, BinOpKind::Ge] {
839            assert!(mutations.contains(&mutation(source, "left && right", candidate)));
840        }
841        for candidate in [BinOpKind::Eq, BinOpKind::Ne, BinOpKind::Or] {
842            assert!(!mutations.contains(&mutation(source, "left && right", candidate)));
843        }
844    }
845
846    #[test]
847    fn excludes_non_equality_replacements_for_function_comparisons() {
848        let source = r#"
849contract Test {
850    function check(
851        function() external left,
852        function() external right
853    ) external pure returns (bool) {
854        return left == right;
855    }
856}
857"#;
858        let mutations = collect(source);
859
860        for candidate in [
861            BinOpKind::Lt,
862            BinOpKind::Le,
863            BinOpKind::Gt,
864            BinOpKind::Ge,
865            BinOpKind::And,
866            BinOpKind::Or,
867        ] {
868            assert!(mutations.contains(&mutation(source, "left == right", candidate)));
869        }
870        assert!(!mutations.contains(&mutation(source, "left == right", BinOpKind::Ne)));
871    }
872
873    #[test]
874    fn preserves_reversed_and_upper_boundary_mutations() {
875        let source = r#"
876contract Test {
877    function lower(uint256 x) external pure returns (bool) {
878        return 0 == x;
879    }
880
881    function upper(uint8 x) external pure returns (bool) {
882        return x == type(uint8).max;
883    }
884}
885"#;
886        let mutations = collect(source);
887
888        assert!(!mutations.contains(&mutation(source, "0 == x", BinOpKind::Ge)));
889        assert!(!mutations.contains(&mutation(source, "0 == x", BinOpKind::Gt)));
890        assert!(!mutations.contains(&mutation(source, "x == type(uint8).max", BinOpKind::Ge)));
891    }
892
893    #[test]
894    fn preserves_member_and_typed_constant_boundary_mutations() {
895        let source = r#"
896contract Test {
897    function empty(bytes memory value) external pure returns (bool) {
898        return value.length == 0;
899    }
900
901    function zero(address value) external pure returns (bool) {
902        return value == address(0);
903    }
904}
905"#;
906        let mutations = collect(source);
907
908        assert!(!mutations.contains(&mutation(source, "value.length == 0", BinOpKind::Le)));
909        assert!(!mutations.contains(&mutation(source, "value == address(0)", BinOpKind::Le)));
910    }
911}