ruff SIM208
labelled unknown
Analysis Nemotron 3 Super
Flags expressions containing two consecutive 'not' operators applied to the same subexpression.
Fix: Removes the outer 'not' and its surrounding parentheses if present, leaving only the inner expression after the inner 'not'.
10 hazards
- Rewriting 'not (not x)' when 'x' is a property with a side-effecting __get__ method changes behavior because the property is accessed twice in the original but once in the fix.
- If 'x' is a function call, the original evaluates it twice while the fix evaluates it once, altering side effects and performance.
- When 'x' is an indexing operation like 'a[b]', the original may raise IndexError twice (if the first access succeeds but the second fails on a mutated object), whereas the fix only risks it once.
- In 'not (not obj.method())', if 'method' returns a mutable object that changes state on first call, the second call operates on a different state than the fix assumes.
- For 'not (not x)' where 'x' is a conditional expression like 'a if b else c', the original evaluates the condition twice, potentially observing different values if 'b' is mutable between evaluations.
- If the inner 'not' applies to a comma-separated tuple like 'not (not (a, b))', removing the outer 'not' and parentheses alters the expression's structure and may break tuple display or unpacking expectations.
- When the double negation appears inside a larger expression like 'f(not (not x))', the fix changes the number of times 'x' is evaluated, affecting side effects visible to 'f'.
- If the code relies on the traceback depth for debugging or testing, removing the extra 'not' frame alters the call stack.
- A comment placed between the 'not' tokens like 'not # comment (not x)' would be lost if the autofix removes the inner 'not' and whitespace without preserving comment placement.
- In code using 'not (not x)' to deliberately force a boolean context (e.g., for truthiness testing in a numeric setting), the fix may change the type of the result in edge cases involving numpy arrays or custom __bool__ implementations.
Programs
| id | result | title | hazard |
|---|---|---|---|
| r1-p001 | fix ok | Context manager without close(): resource leak risk | context managers that are not files, objects with write() but no close() |
| r1-p002 | fix ok | Mutable default argument aliasing after rewrite | mutable default arguments and aliasing after a rewrite |
| r1-p003 | fix ok | Function call evaluated twice vs once: side effect divergence | If 'x' is a function call, the original evaluates it twice while the fix evaluates it once, altering side effects and performance. |
| r1-p004 | fix ok | Evaluation order and side effects in merged expressions | evaluation order and side effects when expressions are merged, duplicated or deleted |
| r1-p005 | fix ok | Double negation inside function call alters evaluation count | When the double negation appears inside a larger expression like 'f(not (not x))', the fix changes the number of times 'x' is evaluated, affecting side effects visible to 'f'. |
| r1-p006 | fix ok prediction off | Boolean forcing with numpy-like truthiness altered by fix | In code using 'not (not x)' to deliberately force a boolean context (e.g., for truthiness testing in a numeric setting), the fix may change the type of the result in edge cases involving numpy arrays |
| r1-p007 | fix ok | Class vs instance attribute with property side effect | class attributes vs instance attributes, descriptors, properties with side effects |
| r1-p008 | fix ok | Star-imports and module-level side effects | star-imports, module-level side effects, __all__ |
| r2-p001 | fix ok | Double Negation Inside Function Call Alters Evaluation Count | When the double negation appears inside a larger expression like 'f(not (not x))', the fix changes the number of times 'x' is evaluated, affecting side effects visible to 'f'. |
| r2-p002 | fix ok | Indexing Operation May Raise IndexError Twice vs Once | When 'x' is an indexing operation like 'a[b]', the original may raise IndexError twice (if the first access succeeds but the second fails on a mutated object), whereas the fix only risks it once. |
| r2-p003 | invalid | Conditional Expression Evaluates Condition Twice | For 'not (not x)' where 'x' is a conditional expression like 'a if b else c', the original evaluates the condition twice, potentially observing different values if 'b' is mutable between evaluations. |
| r2-p004 | fix ok prediction off | Comment Between Not Tokens Would Be Lost | A comment placed between the 'not' tokens like 'not # comment\n(not x)' would be lost if the autofix removes the inner 'not' and whitespace without preserving comment placement. |
| r2-p005 | fix ok | Pathlib Vs Os.Path Behavior On Relative Paths Etc | pathlib vs os.path behavior on relative paths, symlinks, missing files, bytes paths |
| r2-p006 | fix ok | Method Returns Mutable Object That Changes State | In 'not (not obj.method())', if 'method' returns a mutable object that changes state on first call, the second call operates on a different state than the fix assumes. |
| r2-p007 | fix ok | Property With Side-Effecting __Get__ Accessed Twice | Rewriting 'not (not x)' when 'x' is a property with a side-effecting __get__ method changes behavior because the property is accessed twice in the original but once in the fix. |
| r2-p008 | fix ok | Keyword-Only Positional-Only *Args/**Kwargs Forwarding Defaults Evaluated Once | keyword-only, positional-only, *args/**kwargs forwarding, defaults evaluated once |
Sources
r1-p001 · Context manager without close(): resource leak risk
1class Writer:
2 def __init__(self, name):
3 self.name = name
4 self.log = []
5 def write(self, data):
6 self.log.append(data)
7 print(f"write: {data}")
8
9w = Writer('test')
10def use_writer():
11 return w
12
13if not (not use_writer()):
14 use_writer().write('hello')
15print('log:', Writer('test').log)
--- orig.py 2026-10-17 16:34:33.988907100 -0400 +++ fixed.py 2026-10-17 16:34:34.164907420 -0400 @@ -10,6 +10,6 @@ def use_writer(): return w -if not (not use_writer()): +if use_writer(): use_writer().write('hello') print('log:', Writer('test').log)
write: hello log: []
r1-p002 · Mutable default argument aliasing after rewrite
1def f(x=[]):
2 x.append(1)
3 return x
4
5def g():
6 return f()
7
8if not (not g()):
9 print('first:', g())
10else:
11 print('unreachable')
12print('second:', g())
--- orig.py 2026-10-17 16:34:34.267907608 -0400 +++ fixed.py 2026-10-17 16:34:34.439907921 -0400 @@ -5,7 +5,7 @@ def g(): return f() -if not (not g()): +if g(): print('first:', g()) else: print('unreachable')
first: [1, 1] second: [1, 1, 1]
r1-p003 · Function call evaluated twice vs once: side effect divergence
1counter = 0
2
3def inc():
4 global counter
5 counter += 1
6 print(f'inc called, counter={counter}')
7 return counter > 0
8
9if not (not inc()):
10 print('branch taken')
11print('final counter:', counter)
--- orig.py 2026-10-17 16:34:34.540908105 -0400 +++ fixed.py 2026-10-17 16:34:34.703908401 -0400 @@ -6,6 +6,6 @@ print(f'inc called, counter={counter}') return counter > 0 -if not (not inc()): +if inc(): print('branch taken') print('final counter:', counter)
inc called, counter=1 branch taken final counter: 1
r1-p004 · Evaluation order and side effects in merged expressions
1side = []
2
3def log(msg):
4 side.append(msg)
5 return True
6
7result = [log('A'), log('B')]
8if not (not (result[0] and result[1])):
9 side.append('taken')
10print('side effects:', side)
--- orig.py 2026-10-17 16:34:34.808908593 -0400 +++ fixed.py 2026-10-17 16:34:34.971908889 -0400 @@ -5,6 +5,6 @@ return True result = [log('A'), log('B')] -if not (not (result[0] and result[1])): +if result[0] and result[1]: side.append('taken') print('side effects:', side)
side effects: ['A', 'B', 'taken']
r1-p005 · Double negation inside function call alters evaluation count
1calls = []
2
3def get_val():
4 calls.append(len(calls))
5 return len(calls) % 2 == 0
6
7def f(arg):
8 return arg, len(calls)
9
10out = f(not (not get_val()))
11print('result:', out)
12calls.clear()
13out2 = f(get_val())
14print('after fix simulation:', out2)
--- orig.py 2026-10-17 16:34:35.071909072 -0400 +++ fixed.py 2026-10-17 16:34:35.237909374 -0400 @@ -7,7 +7,7 @@ def f(arg): return arg, len(calls) -out = f(not (not get_val())) +out = f(bool(get_val())) print('result:', out) calls.clear() out2 = f(get_val())
result: (False, 1) after fix simulation: (False, 1)
r1-p006 · Boolean forcing with numpy-like truthiness altered by fix
1class BoolLike:
2 def __init__(self, val):
3 self.val = val
4 def __bool__(self):
5 return bool(self.val)
6 def __repr__(self):
7 return f'BoolLike({self.val})'
8
9x = BoolLike(0)
10forced = not (not x)
11direct = x
12print('forced type:', type(forced), forced)
13print('direct type:', type(direct), direct)
14print('are they same object?', forced is direct)
--- orig.py 2026-10-17 16:34:35.343909568 -0400 +++ fixed.py 2026-10-17 16:34:35.504909861 -0400 @@ -7,7 +7,7 @@ return f'BoolLike({self.val})' x = BoolLike(0) -forced = not (not x) +forced = bool(x) direct = x print('forced type:', type(forced), forced) print('direct type:', type(direct), direct)
forced type: <class 'bool'> False direct type: <class '__main__.BoolLike'> BoolLike(0) are they same object? False
r1-p007 · Class vs instance attribute with property side effect
1class Counter:
2 count = 0
3 @property
4 def value(self):
5 Counter.count += 1
6 return Counter.count > 0
7
8obj = Counter()
9if not (not obj.value):
10 print('first access:', Counter.count)
11else:
12 print('unreachable')
13print('second access:', Counter.count)
--- orig.py 2026-10-17 16:34:35.605910046 -0400 +++ fixed.py 2026-10-17 16:34:35.768910343 -0400 @@ -6,7 +6,7 @@ return Counter.count > 0 obj = Counter() -if not (not obj.value): +if obj.value: print('first access:', Counter.count) else: print('unreachable')
first access: 1 second access: 1
r1-p008 · Star-imports and module-level side effects
1import sys
2mod = sys.modules[__name__]
3if not hasattr(mod, '_init'):
4 mod._init = True
5 mod.state = 0
6
7def side_effect():
8 mod.state += 1
9 return mod.state > 0
10
11if not (not side_effect()):
12 print('state after:', mod.state)
13else:
14 print('unreachable')
15print('final state:', mod.state)
--- orig.py 2026-10-17 16:34:35.869910527 -0400 +++ fixed.py 2026-10-17 16:34:36.034910828 -0400 @@ -8,7 +8,7 @@ mod.state += 1 return mod.state > 0 -if not (not side_effect()): +if side_effect(): print('state after:', mod.state) else: print('unreachable')
state after: 1 final state: 1
r2-p001 · Double Negation Inside Function Call Alters Evaluation Count
1def f(arg):
2 global counter
3 counter += arg
4 return arg
5counter = 0
6result = f(not (not True))
7expected_calls = 2
8actual_calls = counter
9print(f'{expected_calls=} {actual_calls=}')
--- orig.py 2026-10-17 16:35:52.976062087 -0400 +++ fixed.py 2026-10-17 16:35:53.144062439 -0400 @@ -3,7 +3,7 @@ counter += arg return arg counter = 0 -result = f(not (not True)) +result = f(bool(True)) expected_calls = 2 actual_calls = counter print(f'{expected_calls=} {actual_calls=}')
expected_calls=2 actual_calls=1
r2-p002 · Indexing Operation May Raise IndexError Twice vs Once
1lst = [1] # length 1
2idx_list = []
3def get_idx():
4 idx_list.append(0)
5 val = len(idx_list)
6 lst.clear()
7 return val
8try:
9 result = not (not lst[get_idx()])
10except IndexError as e:
11 passes = len([i for i in range(len(idx_list)) if i < len(lst)])
12 print(f'Exception caught: {type(e).__name__}: {e}, index evaluations: {len(idx_list)}')
13else:
14 print(f'Result: {result}')
--- orig.py 2026-10-17 16:35:53.244062650 -0400 +++ fixed.py 2026-10-17 16:35:53.407062992 -0400 @@ -6,7 +6,7 @@ lst.clear() return val try: - result = not (not lst[get_idx()]) + result = bool(lst[get_idx()]) except IndexError as e: passes = len([i for i in range(len(idx_list)) if i < len(lst)]) print(f'Exception caught: {type(e).__name__}: {e}, index evaluations: {len(idx_list)}')
Exception caught: IndexError: list index out of range, index evaluations: 1
r2-p003 · Conditional Expression Evaluates Condition Twice (generated program does not parse: Traceback (most recent call last):)
1cond = [True]
2def get_cond():
3 val = cond[0]
4 cond.clear()
5 cond.append(False)
6 return val
7branch_taken = None
8x = 1 if get_cond() else 2
9# Simulate what not(not(x)) does internally
10first_eval = branch_taken := (1 if get_cond() else 2)
11second_eval = branch_taken := (1 if get_cond() else 2)
12print(f'First branch eval: {first_eval}, Second branch eval: {second_eval}')
13# Actual code under test
14try:
15 result = not (not (1 if get_cond() else 2))
16except Exception as e:
17 print(f'Error: {e}')
18else:
19 print(f'Result: {result}')
r2-p004 · Comment Between Not Tokens Would Be Lost
1# This line tests comment preservation in double negation
2# Ruff may drop internal comments when fixing not(not x)
3# We cannot observe comment loss directly, so we use a marker
4marker = 'preserved'
5val = not (
6 # comment that should stay
7 not marker
8)
9print(f'Marker value: {marker}, Result: {val}')
--- orig.py 2026-10-17 16:35:53.654063511 -0400 +++ fixed.py 2026-10-17 16:35:53.821063862 -0400 @@ -2,8 +2,5 @@ # Ruff may drop internal comments when fixing not(not x) # We cannot observe comment loss directly, so we use a marker marker = 'preserved' -val = not ( - # comment that should stay - not marker -) +val = bool(marker) print(f'Marker value: {marker}, Result: {val}')
Marker value: preserved, Result: True
r2-p005 · Pathlib Vs Os.Path Behavior On Relative Paths Etc
1import os
2name = 'testfile'
3with open(name, 'w') as f:
4 f.write('data')
5try:
6 # Using os.path.exists - not(not(...)) form
7 exists = not (not os.path.exists(name))
8 print(f'os.path: {exists}')
9 os.remove(name)
10 # Second check after delete - would differ if duplicated
11 exists2 = not (not os.path.exists(name))
12 print(f'os.path after remove: {exists2}')
13except Exception as e:
14 print(f'OS error: {e}')
15finally:
16 if os.path.exists(name):
17 os.remove(name)
--- orig.py 2026-10-17 16:35:53.921064072 -0400 +++ fixed.py 2026-10-17 16:35:54.104064456 -0400 @@ -4,11 +4,11 @@ f.write('data') try: # Using os.path.exists - not(not(...)) form - exists = not (not os.path.exists(name)) + exists = bool(os.path.exists(name)) print(f'os.path: {exists}') os.remove(name) # Second check after delete - would differ if duplicated - exists2 = not (not os.path.exists(name)) + exists2 = bool(os.path.exists(name)) print(f'os.path after remove: {exists2}') except Exception as e: print(f'OS error: {e}')
os.path: True os.path after remove: False
r2-p006 · Method Returns Mutable Object That Changes State
1class MutableReturn:
2 def __init__(self):
3 self.state = 'original'
4 def mutate(self):
5 self.state = 'mutated'
6 return self
7class Obj:
8 def __init__(self):
9 self.call_count = 0
10 def method(self):
11 self.call_count += 1
12 return MutableReturn().mutate() if self.call_count == 1 else MutableReturn()
13obj = Obj()
14try:
15 # First call: returns mutated object
16 # Second call: returns fresh object
17 result = not (not obj.method())
18 print(f'Method calls: {obj.call_count}, Result truthiness: {bool(result)}')
19except Exception as e:
20 print(f'Error: {e}')
--- orig.py 2026-10-17 16:35:54.205064669 -0400 +++ fixed.py 2026-10-17 16:35:54.373065023 -0400 @@ -14,7 +14,7 @@ try: # First call: returns mutated object # Second call: returns fresh object - result = not (not obj.method()) + result = bool(obj.method()) print(f'Method calls: {obj.call_count}, Result truthiness: {bool(result)}') except Exception as e: print(f'Error: {e}')
Method calls: 1, Result truthiness: True
r2-p007 · Property With Side-Effecting __Get__ Accessed Twice
1class SideEffectProp:
2 def __init__(self):
3 self.access_count = 0
4 @property
5 def prop(self):
6 self.access_count += 1
7 return self.access_count == 1 # True only on first access
8obj = SideEffectProp()
9try:
10 # Original: accesses prop twice -> first True, second False -> not(not(...)) = False
11 # Fix: accesses once -> True -> not(False) = True? Wait: not(not(x)) where x=prop
12 # First access: prop=True -> not(True)=False -> not(False)=True
13 # But second access happens in original: prop becomes False on second access
14 # So: not(not(obj.prop)) evaluates obj.prop twice
15 first = obj.prop # access_count=1, returns True
16 second = obj.prop # access_count=2, returns False
17 inner = not second # not False = True
18 result = not inner # not True = False
19 print(f'Access count: {obj.access_count}, Expected result: {result}')
20 # Now test actual expression
21 obj2 = SideEffectProp()
22 actual = not (not obj2.prop)
23 print(f'Actual access count: {obj2.access_count}, Actual result: {actual}')
24except Exception as e:
25 print(f'Error: {e}')
--- orig.py 2026-10-17 16:35:54.475065237 -0400 +++ fixed.py 2026-10-17 16:35:54.641065586 -0400 @@ -19,7 +19,7 @@ print(f'Access count: {obj.access_count}, Expected result: {result}') # Now test actual expression obj2 = SideEffectProp() - actual = not (not obj2.prop) + actual = bool(obj2.prop) print(f'Actual access count: {obj2.access_count}, Actual result: {actual}') except Exception as e: print(f'Error: {e}')
Access count: 2, Expected result: False Actual access count: 1, Actual result: True
r2-p008 · Keyword-Only Positional-Only *Args/**Kwargs Forwarding Defaults Evaluated Once
1def process(a, /, b, *, c=[]):
2 c.append(a + b)
3 return c
4call_log = []
5def make_k():
6 call_log.append('eval')
7 return [99]
8try:
9 # This will trigger SIM208 on the inner not(not(...) if we had it
10 # Instead we test defaults in context
11 result = process(1, 2, c=not (not make_k()))
12 print(f'Call log length: {len(call_log)}, Result: {result}')
13except Exception as e:
14 print(f'Error: {e}')
--- orig.py 2026-10-17 16:35:54.741065797 -0400 +++ fixed.py 2026-10-17 16:35:54.905066142 -0400 @@ -8,7 +8,7 @@ try: # This will trigger SIM208 on the inner not(not(...) if we had it # Instead we test defaults in context - result = process(1, 2, c=not (not make_k())) + result = process(1, 2, c=bool(make_k())) print(f'Call log length: {len(call_log)}, Result: {result}') except Exception as e: print(f'Error: {e}')
Error: 'bool' object has no attribute 'append'