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(_)) => Some(AssignmentDestinationKind::SignedNumber),
276        TyKind::Elementary(ElementaryType::UInt(_)) => {
277            Some(AssignmentDestinationKind::UnsignedNumber)
278        }
279        TyKind::Elementary(ElementaryType::FixedBytes(_)) => {
280            Some(AssignmentDestinationKind::FixedBytes)
281        }
282        TyKind::Udvt(..) | TyKind::Err(_) => None,
283        _ => Some(AssignmentDestinationKind::Other),
284    }
285}
286
287fn assignment_value_accepts_negation(gcx: Gcx<'_>, value: &hir::Expr<'_>) -> Option<bool> {
288    if matches!(&value.peel_parens().kind, ExprKind::Lit(lit) if matches!(lit.kind, LitKind::Number(_)))
289    {
290        return Some(true);
291    }
292
293    let ty = gcx.type_of_expr(value.peel_parens().id)?;
294    match ty.peel_refs().kind {
295        TyKind::Elementary(ElementaryType::Int(_)) => Some(true),
296        TyKind::Elementary(ElementaryType::UInt(_) | ElementaryType::FixedBytes(_)) => Some(false),
297        TyKind::Udvt(..) | TyKind::Err(_) => None,
298        _ => Some(false),
299    }
300}
301
302fn is_type_invalid_replacement(
303    gcx: Gcx<'_>,
304    left: &hir::Expr<'_>,
305    original: BinOpKind,
306    right: &hir::Expr<'_>,
307    candidate: BinOpKind,
308) -> bool {
309    match candidate {
310        BinOpKind::And | BinOpKind::Or => matches!(
311            comparison_operand_kind(gcx, left, right),
312            Some(ComparisonOperandKind::Function | ComparisonOperandKind::Other)
313        ),
314        BinOpKind::Lt | BinOpKind::Le | BinOpKind::Gt | BinOpKind::Ge => {
315            matches!(original, BinOpKind::And | BinOpKind::Or)
316                || matches!(
317                    comparison_operand_kind(gcx, left, right),
318                    Some(ComparisonOperandKind::Bool | ComparisonOperandKind::Function)
319                )
320        }
321        _ => false,
322    }
323}
324
325#[derive(Clone, Copy, Debug, PartialEq, Eq)]
326enum ComparisonOperandKind {
327    Bool,
328    Function,
329    Other,
330}
331
332fn comparison_operand_kind(
333    gcx: Gcx<'_>,
334    left: &hir::Expr<'_>,
335    right: &hir::Expr<'_>,
336) -> Option<ComparisonOperandKind> {
337    let left = gcx.type_of_expr(left.id)?;
338    let right = gcx.type_of_expr(right.id)?;
339    match (left.peel_refs().kind, right.peel_refs().kind) {
340        (TyKind::Elementary(ElementaryType::Bool), TyKind::Elementary(ElementaryType::Bool)) => {
341            Some(ComparisonOperandKind::Bool)
342        }
343        (TyKind::Fn(_), TyKind::Fn(_)) => Some(ComparisonOperandKind::Function),
344        (TyKind::Err(_), _) | (_, TyKind::Err(_)) => None,
345        _ => Some(ComparisonOperandKind::Other),
346    }
347}
348
349#[derive(Clone, Copy)]
350enum UnaryOperandKind {
351    SignedInteger,
352    UnsignedInteger,
353    FixedBytes,
354}
355
356fn unary_operand_kind(gcx: Gcx<'_>, expr: &hir::Expr<'_>) -> Option<UnaryOperandKind> {
357    let ty = gcx.type_of_expr(expr.peel_parens().id)?;
358    match ty.peel_refs().kind {
359        TyKind::Elementary(ElementaryType::Int(_)) => Some(UnaryOperandKind::SignedInteger),
360        TyKind::Elementary(ElementaryType::UInt(_)) => Some(UnaryOperandKind::UnsignedInteger),
361        TyKind::Elementary(ElementaryType::FixedBytes(_)) => Some(UnaryOperandKind::FixedBytes),
362        _ => None,
363    }
364}
365
366fn is_non_storage_push_call(gcx: Gcx<'_>, expr: &hir::Expr<'_>) -> bool {
367    let ExprKind::Call(callee, args) = &expr.peel_parens().kind else { return false };
368    let ExprKind::Member(receiver, member) = &callee.peel_parens().kind else { return false };
369    if member.as_str() != "push" || !args.is_empty() {
370        return false;
371    }
372
373    !gcx.type_of_expr(receiver.id).is_some_and(|ty| {
374        matches!(
375            ty.kind,
376            TyKind::Ref(inner, location)
377                if location.is_storage()
378                    && matches!(
379                        inner.kind,
380                        TyKind::DynArray(_) | TyKind::Elementary(ElementaryType::Bytes)
381                    )
382        )
383    })
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use solar::interface::BytePos;
390
391    fn collect(source: &str) -> MutationExclusionSet {
392        let path = PathBuf::from("Test.sol");
393        let mut compiler = Compiler::new(Session::builder().with_silent_emitter(None).build());
394        compiler.enter_mut(|compiler| {
395            let mut pcx = compiler.parse();
396            let file =
397                pcx.sess.source_map().new_source_file(path.clone(), source).expect("source file");
398            pcx.add_file(file);
399            pcx.parse();
400            assert!(matches!(compiler.lower_asts(), Ok(ControlFlow::Continue(()))));
401            analyze_and_collect(compiler).remove(&path).unwrap_or_default()
402        })
403    }
404
405    fn mutation(source: &str, expression: &str, new_op: BinOpKind) -> MutationExclusion {
406        let lo = source.find(expression).expect("expression") as u32;
407        MutationExclusion::binary(
408            solar::ast::Span::new(BytePos(lo), BytePos(lo + expression.len() as u32)),
409            new_op,
410        )
411    }
412
413    fn unary_mutation(source: &str, expression: &str, new_op: UnOpKind) -> MutationExclusion {
414        let lo = source.find(expression).expect("expression") as u32;
415        MutationExclusion::unary(
416            solar::ast::Span::new(BytePos(lo), BytePos(lo + expression.len() as u32)),
417            new_op,
418        )
419    }
420
421    fn assignment_mutation(
422        source: &str,
423        value: &str,
424        replacement: AssignmentReplacement,
425    ) -> MutationExclusion {
426        let lo = source.rfind(value).expect("value") as u32;
427        MutationExclusion::assignment(
428            solar::ast::Span::new(BytePos(lo), BytePos(lo + value.len() as u32)),
429            replacement,
430        )
431    }
432
433    #[test]
434    fn excludes_assignments_invalid_for_destination_type() {
435        let source = r#"
436enum Choice { A, B }
437
438contract Test {
439    function check(address account, bool flag, uint256 unsigned, Choice choice) external pure {
440        address accountCopy = account;
441        bool flagCopy = flag;
442        uint256 unsignedCopy = unsigned;
443        Choice choiceCopy = choice;
444    }
445}
446"#;
447        let mutations = collect(source);
448
449        for value in ["account", "flag", "choice"] {
450            assert!(mutations.contains(&assignment_mutation(
451                source,
452                value,
453                AssignmentReplacement::Zero,
454            )));
455            assert!(mutations.contains(&assignment_mutation(
456                source,
457                value,
458                AssignmentReplacement::Negate,
459            )));
460        }
461        assert!(!mutations.contains(&assignment_mutation(
462            source,
463            "unsigned",
464            AssignmentReplacement::Zero,
465        )));
466        assert!(mutations.contains(&assignment_mutation(
467            source,
468            "unsigned",
469            AssignmentReplacement::Negate,
470        )));
471    }
472
473    #[test]
474    fn uses_lhs_type_for_assignments() {
475        let source = r#"
476contract Test {
477    function check(address account, bool flag) external pure {
478        account = account;
479        flag = flag;
480    }
481}
482"#;
483        let mutations = collect(source);
484
485        for value in ["account", "flag"] {
486            assert!(mutations.contains(&assignment_mutation(
487                source,
488                value,
489                AssignmentReplacement::Zero,
490            )));
491            assert!(mutations.contains(&assignment_mutation(
492                source,
493                value,
494                AssignmentReplacement::Negate,
495            )));
496        }
497    }
498
499    #[test]
500    fn excludes_invalid_compound_assignment_negation() {
501        let source = r#"
502contract Test {
503    uint256 unsignedTotal;
504    int256 signedTotal;
505    bytes32 word;
506
507    function check(
508        uint256 unsignedAmount,
509        int256 signedAmount,
510        bytes32 mask,
511        uint8 shift
512    ) external {
513        unsignedTotal += unsignedAmount;
514        signedTotal += signedAmount;
515        word &= mask;
516        signedTotal <<= shift;
517        signedTotal += 11;
518        signedTotal &= 12;
519        signedTotal <<= 13;
520        signedTotal >>= 14;
521    }
522}
523"#;
524        let mutations = collect(source);
525
526        for value in ["unsignedAmount", "mask", "shift"] {
527            assert!(mutations.contains(&assignment_mutation(
528                source,
529                value,
530                AssignmentReplacement::Negate,
531            )));
532        }
533        assert!(!mutations.contains(&assignment_mutation(
534            source,
535            "signedAmount",
536            AssignmentReplacement::Negate,
537        )));
538        for value in ["11", "12"] {
539            assert!(!mutations.contains(&assignment_mutation(
540                source,
541                value,
542                AssignmentReplacement::Negate,
543            )));
544        }
545        for value in ["13", "14"] {
546            assert!(mutations.contains(&assignment_mutation(
547                source,
548                value,
549                AssignmentReplacement::Negate,
550            )));
551        }
552        for value in ["unsignedAmount", "signedAmount", "mask", "shift"] {
553            assert!(!mutations.contains(&assignment_mutation(
554                source,
555                value,
556                AssignmentReplacement::Zero,
557            )));
558        }
559    }
560
561    #[test]
562    fn preserves_valid_signed_and_fixed_bytes_assignments() {
563        let source = r#"
564contract Test {
565    function check(int256 signed, bytes32 word) external pure {
566        int256 signedCopy = signed;
567        bytes32 wordCopy = word;
568    }
569}
570"#;
571        let mutations = collect(source);
572
573        for replacement in [AssignmentReplacement::Zero, AssignmentReplacement::Negate] {
574            assert!(!mutations.contains(&assignment_mutation(source, "signed", replacement)));
575        }
576        assert!(!mutations.contains(&assignment_mutation(
577            source,
578            "word",
579            AssignmentReplacement::Zero,
580        )));
581        assert!(mutations.contains(&assignment_mutation(
582            source,
583            "word",
584            AssignmentReplacement::Negate,
585        )));
586    }
587
588    #[test]
589    fn preserves_udvt_assignments() {
590        let source = r#"
591type Amount is uint256;
592
593contract Test {
594    function check(Amount amount) external pure {
595        Amount copy = amount;
596    }
597}
598"#;
599        let mutations = collect(source);
600
601        for replacement in [AssignmentReplacement::Zero, AssignmentReplacement::Negate] {
602            assert!(!mutations.contains(&assignment_mutation(source, "amount", replacement)));
603        }
604    }
605
606    #[test]
607    fn preserves_all_mutations_when_analysis_has_errors() {
608        let source = r#"
609contract Test {
610    function check(address account) external pure {
611        address copy = account;
612        unresolved = unresolved;
613    }
614}
615"#;
616
617        assert!(collect(source).is_empty());
618    }
619
620    #[test]
621    fn preserves_gas_distinct_unsigned_boundary_mutations() {
622        let source = r#"
623contract Test {
624    function check(uint256 x) external pure returns (bool) {
625        return x == 0;
626    }
627}
628"#;
629        let mutations = collect(source);
630
631        assert!(!mutations.contains(&mutation(source, "x == 0", BinOpKind::Le)));
632        assert!(!mutations.contains(&mutation(source, "x == 0", BinOpKind::Lt)));
633    }
634
635    #[test]
636    fn excludes_negation_only_for_unsigned_unary_operands() {
637        let source = r#"
638contract Test {
639    function check(uint256 unsigned, int256 signed) external pure {
640        unsigned++;
641        ++unsigned;
642        signed++;
643    }
644}
645"#;
646        let mutations = collect(source);
647
648        assert!(mutations.contains(&unary_mutation(source, "unsigned++", UnOpKind::Neg)));
649        assert!(mutations.contains(&unary_mutation(source, "++unsigned", UnOpKind::Neg)));
650        assert!(!mutations.contains(&unary_mutation(source, "signed++", UnOpKind::Neg)));
651    }
652
653    #[test]
654    fn excludes_lvalue_mutations_for_user_defined_push() {
655        let source = r#"
656contract Test {
657    function push() external pure returns (int256) {
658        return 1;
659    }
660
661    function check() external view returns (int256) {
662        return -this.push();
663    }
664}
665"#;
666        let mutations = collect(source);
667
668        for op in [UnOpKind::PreInc, UnOpKind::PreDec, UnOpKind::PostInc, UnOpKind::PostDec] {
669            assert!(mutations.contains(&unary_mutation(source, "-this.push()", op)));
670        }
671    }
672
673    #[test]
674    fn preserves_lvalue_mutations_for_storage_push() {
675        let source = r#"
676contract Test {
677    int256[] values;
678
679    function check() external returns (int256) {
680        return -values.push();
681    }
682}
683"#;
684        let mutations = collect(source);
685
686        for op in [UnOpKind::PreInc, UnOpKind::PreDec, UnOpKind::PostInc, UnOpKind::PostDec] {
687            assert!(!mutations.contains(&unary_mutation(source, "-values.push()", op)));
688        }
689    }
690
691    #[test]
692    fn excludes_invalid_fixed_bytes_unary_mutations() {
693        let source = r#"
694contract Test {
695    function check(bytes32 word) external pure returns (bytes32) {
696        return ~word;
697    }
698
699    function checkNumber(uint256 number) external pure returns (uint256) {
700        return ~number;
701    }
702}
703"#;
704        let mutations = collect(source);
705
706        for op in [
707            UnOpKind::PreInc,
708            UnOpKind::PreDec,
709            UnOpKind::PostInc,
710            UnOpKind::PostDec,
711            UnOpKind::Neg,
712        ] {
713            assert!(mutations.contains(&unary_mutation(source, "~word", op)));
714        }
715        assert!(!mutations.contains(&unary_mutation(source, "~word", UnOpKind::BitNot)));
716
717        for op in [UnOpKind::PreInc, UnOpKind::PreDec, UnOpKind::PostInc, UnOpKind::PostDec] {
718            assert!(!mutations.contains(&unary_mutation(source, "~number", op)));
719        }
720        assert!(mutations.contains(&unary_mutation(source, "~number", UnOpKind::Neg)));
721    }
722
723    #[test]
724    fn excludes_invalid_fixed_bytes_storage_push_mutations() {
725        let source = r#"
726contract Test {
727    bytes32[] values;
728
729    function check() external returns (bytes32) {
730        return ~values.push();
731    }
732}
733"#;
734        let mutations = collect(source);
735
736        for op in [
737            UnOpKind::PreInc,
738            UnOpKind::PreDec,
739            UnOpKind::PostInc,
740            UnOpKind::PostDec,
741            UnOpKind::Neg,
742        ] {
743            assert!(mutations.contains(&unary_mutation(source, "~values.push()", op)));
744        }
745    }
746
747    #[test]
748    fn preserves_overloaded_negation_for_unsigned_udvt() {
749        let source = r#"
750type Amount is uint256;
751
752function negate(Amount amount) pure returns (Amount) {
753    return Amount.wrap(type(uint256).max - Amount.unwrap(amount));
754}
755
756function complement(Amount amount) pure returns (Amount) {
757    return Amount.wrap(~Amount.unwrap(amount));
758}
759
760using {negate as -, complement as ~} for Amount global;
761
762contract Test {
763    function check(Amount amount) external pure returns (Amount) {
764        return ~amount;
765    }
766}
767"#;
768        let mutations = collect(source);
769
770        assert!(!mutations.contains(&unary_mutation(source, "~amount", UnOpKind::Neg)));
771    }
772
773    #[test]
774    fn preserves_negation_for_unresolved_operand() {
775        let source = r#"
776contract Test {
777    function check() external pure {
778        unresolved++;
779    }
780}
781"#;
782        let mutations = collect(source);
783
784        assert!(!mutations.contains(&unary_mutation(source, "unresolved++", UnOpKind::Neg)));
785    }
786
787    #[test]
788    fn excludes_logical_replacements_for_numeric_comparisons() {
789        let source = r#"
790contract Test {
791    function check(uint256 left, uint256 right) external pure returns (bool) {
792        return left == right;
793    }
794}
795"#;
796        let mutations = collect(source);
797
798        assert!(mutations.contains(&mutation(source, "left == right", BinOpKind::And)));
799        assert!(mutations.contains(&mutation(source, "left == right", BinOpKind::Or)));
800        assert!(!mutations.contains(&mutation(source, "left == right", BinOpKind::Lt)));
801        assert!(!mutations.contains(&mutation(source, "left == right", BinOpKind::Ne)));
802    }
803
804    #[test]
805    fn excludes_ordered_replacements_for_boolean_equality() {
806        let source = r#"
807contract Test {
808    function check(bool left, bool right) external pure returns (bool) {
809        return left == right;
810    }
811}
812"#;
813        let mutations = collect(source);
814
815        for candidate in [BinOpKind::Lt, BinOpKind::Le, BinOpKind::Gt, BinOpKind::Ge] {
816            assert!(mutations.contains(&mutation(source, "left == right", candidate)));
817        }
818        for candidate in [BinOpKind::Ne, BinOpKind::And, BinOpKind::Or] {
819            assert!(!mutations.contains(&mutation(source, "left == right", candidate)));
820        }
821    }
822
823    #[test]
824    fn excludes_ordered_replacements_for_logical_operations() {
825        let source = r#"
826contract Test {
827    function check(bool left, bool right) external pure returns (bool) {
828        return left && right;
829    }
830}
831"#;
832        let mutations = collect(source);
833
834        for candidate in [BinOpKind::Lt, BinOpKind::Le, BinOpKind::Gt, BinOpKind::Ge] {
835            assert!(mutations.contains(&mutation(source, "left && right", candidate)));
836        }
837        for candidate in [BinOpKind::Eq, BinOpKind::Ne, BinOpKind::Or] {
838            assert!(!mutations.contains(&mutation(source, "left && right", candidate)));
839        }
840    }
841
842    #[test]
843    fn excludes_non_equality_replacements_for_function_comparisons() {
844        let source = r#"
845contract Test {
846    function check(
847        function() external left,
848        function() external right
849    ) external pure returns (bool) {
850        return left == right;
851    }
852}
853"#;
854        let mutations = collect(source);
855
856        for candidate in [
857            BinOpKind::Lt,
858            BinOpKind::Le,
859            BinOpKind::Gt,
860            BinOpKind::Ge,
861            BinOpKind::And,
862            BinOpKind::Or,
863        ] {
864            assert!(mutations.contains(&mutation(source, "left == right", candidate)));
865        }
866        assert!(!mutations.contains(&mutation(source, "left == right", BinOpKind::Ne)));
867    }
868
869    #[test]
870    fn preserves_reversed_and_upper_boundary_mutations() {
871        let source = r#"
872contract Test {
873    function lower(uint256 x) external pure returns (bool) {
874        return 0 == x;
875    }
876
877    function upper(uint8 x) external pure returns (bool) {
878        return x == type(uint8).max;
879    }
880}
881"#;
882        let mutations = collect(source);
883
884        assert!(!mutations.contains(&mutation(source, "0 == x", BinOpKind::Ge)));
885        assert!(!mutations.contains(&mutation(source, "0 == x", BinOpKind::Gt)));
886        assert!(!mutations.contains(&mutation(source, "x == type(uint8).max", BinOpKind::Ge)));
887    }
888
889    #[test]
890    fn preserves_member_and_typed_constant_boundary_mutations() {
891        let source = r#"
892contract Test {
893    function empty(bytes memory value) external pure returns (bool) {
894        return value.length == 0;
895    }
896
897    function zero(address value) external pure returns (bool) {
898        return value == address(0);
899    }
900}
901"#;
902        let mutations = collect(source);
903
904        assert!(!mutations.contains(&mutation(source, "value.length == 0", BinOpKind::Le)));
905        assert!(!mutations.contains(&mutation(source, "value == address(0)", BinOpKind::Le)));
906    }
907}