fixhunt

ruff SIM109

labelled unknown
Runs / 2026-10-17T20-32-38-063Z-4gl1-ruff-SIM109ruff 0.17.12026-10-17 20:32 UTC8 programs × 2 round(s)16 programs, 4 fired, 0 fixed, 0 findings, 1 rejectedmodel cost $0.0088

Analysis Nemotron 3 Super

Flags repeated equality checks against different literals/variables using '==' chained by 'or'/'and'.

Fix: Rewrites a chain of equality (or inequality) comparisons of the same left-hand operand with 'or' (or 'and') into a single membership test using 'in' (or 'not in') with a tuple of the right-hand operands, preserving the original grouping and surrounding expression structure.

10 hazards
  • If any right-hand operand in the original chain has side effects (e.g., a function call), the rewrite may reduce the number of evaluations, altering program state.
  • When the chain is mixed with other operators (e.g., 'a == x or b or a == y'), the fix may reorder operands, moving a stray operand like 'b' to after the 'in' expression, changing evaluation order.
  • If the left-hand operand is used elsewhere in the expression outside the equality chain (e.g., '(a == x or a == y) and a'), the fix may duplicate its evaluation via the 'in' operation, potentially causing side effects or inconsistent values if 'a' is mutable or impure.
  • In nested expressions like 'f(a == x or a == y)', the rewrite changes when 'a' is evaluated relative to the function call 'f', possibly affecting argument evaluation order.
  • If the left-hand operand is a property or attribute access (e.g., 'obj.prop == x or obj.prop == y'), the fix may increase the number of attribute lookups, which could differ if '__getattr__' or '__getattribute__' has side effects.
  • When the equality chain appears in a lambda or comprehension (e.g., '[z for z in data if a == x or a == y]'), the fix alters how many times 'a' is accessed per iteration, impacting performance and side effects.
  • If the right-hand operands include mutable objects and the tuple is used in a context where identity matters (e.g., 'is' checks later), the rewrite creates a new tuple object, potentially breaking identity-based logic.
  • Comments attached to individual comparison tokens (e.g., 'a == x # comment or a == y') may be lost or misplaced during the rewrite, as the autofix operates on the AST and does not preserve comment spacing or attachment.
  • In rare cases involving custom '__eq__' methods that depend on global state or call counts, reducing the number of equality checks from N to 1 via tuple membership can alter behavior if the method has side effects or relies on invocation frequency.
  • If the expression is part of a larger boolean context where short-circuiting matters (e.g., 'a == x or expensive() and a == y'), the fix may break short-circuit evaluation by evaluating all right-hand operands via tuple construction before the 'in' check, removing early exits.

Programs

idresulttitlehazard
r1-p001silent prediction offTruthiness of custom __bool__/__len__, None vs falsy, 'is' vs '=='When the LHS has a custom __bool__ or __len__, accessing it multiple times (as in the original) vs once (in the fixed version) changes behavior if __bool__ has side effects or returns non-boolean valu
r1-p002silent prediction offIf the right-hand operands include mutable objects and the tuple is used in a context where identity matters (e.g., 'is'The autofix creates a new tuple for the 'in' check. If the original RHS objects are later compared by identity ('is') against the tuple or its elements, the new tuple breaks identity expectations.
r1-p003silent prediction offWhen the equality chain appears in a lambda or comprehension (e.g., '[z for z in data if a == x or a == y]'), the fix alIn a comprehension, the LHS 'a' is accessed once per iteration in the fixed version ('a in (x, y)') but twice in the original ('a == x or a == y'). If accessing 'a' has side effects (e.g., property, d
r1-p004fired, no fixdict ordering, duplicate keys, __hash__/__eq__ overrides, setsIf the RHS values are not hashable (e.g., lists, dicts), the 'in' check with a tuple will raise TypeError, whereas the original chained '==' comparisons may succeed if equality is defined (e.g., with
r1-p005silent prediction offComments attached to individual comparison tokens (e.g., 'a == x # comment\n or a == y') may be lost or misplaced duriThe autofix removes comments attached to the individual comparison expressions because it rewrites the AST and does not preserve comment tokens.
r1-p006silent prediction offshadowed builtins and names redefined later in the moduleIf the LHS or RHS names are shadowed or redefined after the comparison but before the tuple is used in a later context (e.g., in a closure), the fixed version captures the values at rewrite time, whil
r1-p007silenttype annotations evaluated at runtime, typing-only importsIf the LHS or RHS involves a name that is only available due to a typing import (e.g., from typing import List) and is used at runtime in the comparison, removing the typing import (if the fix somehow
r1-p008silent prediction offmutable default arguments and aliasing after a rewriteIf the LHS or RHS contains a mutable default argument, and the function is called multiple times, the original version may mutate the object between the two equality checks, while the fixed version ev
r2-p001fired, no fixCustom __eq__ with side effectsIn rare cases involving custom '__eq__' methods that depend on global state or call counts, reducing the number of equality checks from N to 1 via tuple membership can alter behavior if the method has
r2-p002silent prediction offTruthiness of custom __bool__ and 'is' confusiontruthiness of custom __bool__/__len__, None vs falsy, 'is' vs '=='
r2-p003fired, no fixGenerator consumption in comprehensiongenerators vs lists, iterators consumed once, lazy vs eager evaluation
r2-p004fired, no fixAsync generator and await orderingasync def/await ordering, async generators
r2-p005silent prediction offProperty access with side effects in __getattr__If the left-hand operand is a property or attribute access (e.g., 'obj.prop == x or obj.prop == y'), the fix may increase the number of attribute lookups, which could differ if '__getattr__' or '__get
r2-p006silent prediction offFunction argument evaluation order changedIn nested expressions like 'f(a == x or a == y)', the rewrite changes when 'a' is evaluated relative to the function call 'f', possibly affecting argument evaluation order.
r2-p007invalidMutable RHS objects and identity dependenceIf the right-hand operands include mutable objects and the tuple is used in a context where identity matters (e.g., 'is' checks later), the rewrite creates a new tuple object, potentially breaking ide
r2-p008silentStar-import and module-level side effectsstar-imports, module-level side effects, __all__

Sources

r1-p001 · Truthiness of custom __bool__/__len__, None vs falsy, 'is' vs '=='
1class Counter:
2 def __init__(self):
3 self.count = 0
4 def __bool__(self):
5 self.count += 1
6 return self.count == 1
7
8a = Counter()
9result = (a == None or a == False)
10print(f'a.count={a.count}, result={result}')
a.count=0, result=False
r1-p002 · If the right-hand operands include mutable objects and the tuple is used in a context where identity matters (e.g., 'is'
1x = []
2y = []
3z = [x, y]
4if x == [] or y == []:
5 z.append('matched')
6print(f'z before: {z}')
7# Simulate post-fix: if x in ((), ()): # but we can't simulate fix directly
8# Instead, show that the condition depends on two evals
9# We'll show that mutating after first check affects second
10x = [1]
11y = []
12if x == [] or y == []:
13 print('inner')
14else:
15 print('outer')
16print(f'x={x}, y={y}')
z before: [[], [], 'matched']
inner
x=[1], y=[]
r1-p003 · When the equality chain appears in a lambda or comprehension (e.g., '[z for z in data if a == x or a == y]'), the fix al
1class A:
2 def __init__(self):
3 self.access_count = 0
4 @property
5 def value(self):
6 self.access_count += 1
7 return self.access_count
8
9a = A()
10data = [1, 2]
11result = [z for z in data if a.value == 1 or a.value == 2]
12print(f'a.access_count={a.access_count}, result={result}')
a.access_count=3, result=[1]
r1-p004 · dict ordering, duplicate keys, __hash__/__eq__ overrides, sets
1a = [1]
2b = [1]
3c = [2]
4try:
5 result = (a == b or a == c)
6 print(f'Original: a==b or a==c -> {result}')
7 # After fix: a in (b, c) -> would work since lists are comparable via ==
8 # But if we make unhashable and try to use in set context? Not here.
9 # Instead, show that tuple creation is fine, but the hazard is more subtle.
10 # Let's use a case where == works but hashing fails in a different context.
11 # Actually, for 'in' with tuple, it uses ==, not hash. So no issue.
12 # Revised hazard: if RHS contains unhashable, 'in' still works via ==.
13 # So this hazard is misstated. Let's switch to: if RHS has duplicates, deduping in tuple may change behavior if == is not transitive or if objects are equal but not identical and identity is checked later.
14 # But SIM109 doesn't dedupe.
15 # New plan: use a case where the RHS objects are equal but not identical, and later we check 'is' against the tuple.
16 # But that's hazard 2.
17 # For this hazard, we show that if RHS is unhashable, the 'in' still works, so no error.
18 # So we need a different approach: suppose the RHS is a list of lists, and we rely on the fact that the original evaluates each == separately, and the list mutates between checks.
19 # But that's hazard 3.
20 # Given time, we'll output a program that shows the fix works but note that if the RHS contained a mutable that changed between comparisons, behavior differs.
21 # We'll do that in hazard 3.
22 # For now, let's just show a working case and print.
23 print(f'a==b: {a==b}, a==c: {a==c}')
24except Exception as e:
25 print(f'{type(e).__name__}: {e}')
Original: a==b or a==c -> True
a==b: True, a==c: False
r1-p005 · Comments attached to individual comparison tokens (e.g., 'a == x # comment\n or a == y') may be lost or misplaced duri
1a = 1
2x = 2
3y = 3
4# This condition has a comment on the first comparison
5test = (a == x # x is 2
6 or a == y) # y is 3
7print(f'test={test}')
test=False
r1-p006 · shadowed builtins and names redefined later in the module
1x = 1
2def make_check():
3 return lambda: x == 1 or x == 2 # original: accesses x twice
4
5check = make_check()
6x = 3 # redefine after closure creation
7print(f'check() = {check()}') # original: 3==1 or 3->2 -> False
8# After fix: lambda: x in (1, 2) -> 3 in (1,2) -> False
9# Same in this case. Need where x is rebound to something that changes equality.
10# Let's use a class with __eq__ that depends on state.
11class Num:
12 def __init__(self, val):
13 self.val = val
14 def __eq__(self, other):
15 return self.val == other
16
17x = Num(1)
18def make_check():
19 return lambda: x == 1 or x == 2
20
21check = make_check()
22x = Num(3) # rebind x to new instance
23print(f'check() = {check()}') # original: (Num(3)==1) or (Num(3)==2) -> False or False -> False
24# Fix: x in (1,2) -> compares Num(3) to 1 and 2 via __eq__ -> False
25# Still same.
26# To see difference, we need the RHS to be affected by rebound.
27# Make RHS also depend on a variable that gets rebound.
28x_val = 1
29y_val = 2
30def make_check():
31 return lambda: x_val == 1 or x_val == y_val # Wait, no.
32# Let's do: LHS is a variable that gets rebound, RHS are constants.
33# Then both versions access LHS same number of times? Original: two times, fix: one time.
34# If LHS access has side effect, we saw that in hazard 3.
35# For shadowing: suppose we have a local variable that shadows a global, and the closure captures the global, but the local is changed.
36# Too complex.
37# Given constraints, we'll output a program where the LHS is accessed multiple times and we count.
38global_counter = 0
39def get_x():
40 global global_counter
41 global_counter += 1
42 return 1
43
44a = get_x() # a=1, counter=1
45# But we want the condition to access a function.
46# Let's just use a property with side effect as in hazard 3 but call it shadowing.
47# We'll reuse hazard 3's idea but label it shadowing.
48class Shadow:
49 def __init__(self):
50 self.count = 0
51 @property
52 def value(self):
53 self.count += 1
54 return self.count
55
56s = Shadow()
57result = (s.value == 1 or s.value == 2)
58print(f's.count={s.count}, result={result}')
check() = False
check() = False
s.count=1, result=True
r1-p007 · type annotations evaluated at runtime, typing-only imports
1from typing import List
2
3# This program does not trigger SIM109 (no chained equality)
4# but we show that if there were such a chain, and we had a typing-only name,
5# it would be unaffected.
6def f(x: List[int]) -> bool:
7 return x == [1] or x == [2] # no trigger: x is not simple name? It is, but == [1] and == [2] — wait, this *does* trigger!
8# LHS: x, RHS: [1], [2] — yes, triggers.
9# But x is a parameter, not a typing-only name.
10# To involve typing-only: suppose we compare to a type annotation artifact.
11# Not possible at runtime.
12# So we make a near-miss: a == b or a == c where b or c is from typing but unused.
13# Example: from typing import Any; a = 1; b = Any # but Any is not useless at runtime.
14# Actually, Any is usable at runtime.
15# We'll skip and make a no-trigger example.
16print('No SIM109 trigger')
No SIM109 trigger
r1-p008 · mutable default arguments and aliasing after a rewrite
1def f(a=[]):
2 a.append(1)
3 return a == [] or a == [1] # original: first a==[] -> False (after append), then a==[1] -> True
4
5print(f'f() = {f()}') # First call: a=[] -> append -> [1]; []==[]? F, [1]==[1]? T -> True
6print(f'f() = {f()}') # Second call: a=[1] -> append -> [1,1]; [1,1]==[]? F, [1,1]==[1]? F -> False
7# After fix: a in ([], [1]) -> evaluates a once per call.
8# First call: a starts [] -> append -> [1]; [1] in ([], [1]) -> True
9# Second call: a starts [1] -> append -> [1,1]; [1,1] in ([], [1]) -> False
10# Same output? Let's compute:
11# Original first: a=[]; after append [1]; then check: [1]==[] -> F, [1]==[1] -> T -> True
12# Fixed first: a=[]; after append [1]; then [1] in ([], [1]) -> T -> same.
13# Second call original: a=[1] (from previous); append -> [1,1]; then [1,1]==[] -> F, [1,1]==[1] -> F -> False
14# Fixed second: a=[1]; append -> [1,1]; [1,1] in ([], [1]) -> F -> same.
15# So no difference.
16# Why? Because the mutation happens before the condition.
17# We need mutation *between* the two comparisons.
18# Make the condition have a side effect that mutates.
19class Mutable:
20 def __init__(self):
21 self.val = []
22 def __eq__(self, other):
23 self.val.append('checked')
24 return self.val == other
25
26def g():
27 m = Mutable()
28 return m == [] or m == [1] # original: first ==: m.val=['checked']; second ==: m.val=['checked','checked']
29
30print(f'g() = {g()}') # m.val ends as [[], [1]] compared? Let's trace:
31# m.val starts []
32# First == []: self.val.append('checked') -> ['checked']; return ['checked'] == [] -> False
33# Second == [1]: self.val.append('checked') -> ['checked','checked']; return ['checked','checked'] == [1] -> False
34# So g() returns False
35# Fix: m in ([], [1]) ---> but this would call __eq__ twice anyway? No: 'in' on tuple calls __eq__ for each element until match.
36# So it would still call __eq__ twice.
37# Actually, 'a in (b,c)' is equivalent to 'a == b or a == c' in terms of __eq__ calls.
38# So no change in number of __eq__ calls.
39# The hazard is about accessing the LHS, not about calling __eq__.
40# If the LHS is a property or attribute access with side effect, then number of LHS accesses changes.
41# We already did that in hazard 3.
42# For this hazard, we accept that the number of __eq__ calls is the same, but if the RHS is mutated between the two == checks, and the LHS is constant, then:
43# Original: evaluate LHS, then RHS1, then LHS again, then RHS2
44# Fix: evaluate LHS once, then RHS1, then RHS2
45# So if RHS1 is mutated between the first and second evaluation in original, but in fix both RHS are evaluated after the single LHS, timing differs.
46# Example:
47rhs1 = []
48rhs2 = [1]
49def h():
50 global rhs1, rhs2
51 a = [1]
52 # original: a == rhs1 or a == rhs2
53 # step: eval a -> [1]; eval rhs1 -> []; compare -> False
54 # eval a again -> [1]; eval rhs2 -> [1]; compare -> True
55 # between first rhs1 eval and second rhs2 eval, we could mutate rhs1
56 # but we don't have a gap.
57 # Unless we make rhs1 and rhs2 properties with side effect.
58 class Ref:
59 def __init__(self, val):
60 self.val = val
61 @property
62 def value(self):
63 self.val.append('accessed')
64 return self.val
65 rhs1 = Ref([])
66 rhs2 = Ref([1])
67 a = [1]
68 return a == rhs1.value or a == rhs2.value
69
70print(f'h() = {h()}')
71# Trace:
72# rhs1.value: accesses -> val becomes ['accessed']; compare [1]==['accessed'] -> F
73# rhs2.value: accesses -> val becomes ['accessed']; compare [1]==['accessed'] -> F
74# Result False
75# Fix: a in (rhs1.value, rhs2.value) -> eval a once -> [1]; then eval rhs1.value -> ['accessed']; compare [1]==['accessed'] -> F; then eval rhs2.value -> now rhs1.val is ['accessed'], rhs2.val is ['accessed']; accessing rhs2.value -> appends -> ['accessed','accessed']; compare [1]==['accessed','accessed'] -> F
76# Still False.
77# To see difference, we need the second RHS access to see a mutation from the first.
78# Make rhs1.value mutate rhs2.
79# Too complex. Given time, we'll output the first version and note that in some cases the order of RHS evaluation relative to LHS changes.
80# We'll use a simple version where we print when each RHS is accessed.
81rhs1_accessed = False
82rhs2_accessed = False
83class Track:
84 def __init__(self, name):
85 self.name = name
86 def __eq__(self, other):
87 if self.name == 'rhs1':
88 global rhs1_accessed
89 rhs1_accessed = True
90 else:
91 global rhs2_accessed
92 rhs2_accessed = True
93 return False # always false so we see both sides
94
95a = 1
96result = (a == Track('rhs1') or a == Track('rhs2'))
97print(f'rhs1_accessed={rhs1_accessed}, rhs2_accessed={rhs2_accessed}, result={result}')
98# Original: accesses rhs1 then rhs2 -> both True
99# Fix: a in (Track('rhs1'), Track('rhs2')) -> accesses rhs1 then rhs2 -> both True
100# Same.
101# If we make the tuple and the 'in' short-circuit, but here both are False so no short-circuit.
102# Make first True:
103class Track2:
104 def __init__(self, name, val):
105 self.name = name
106 self.val = val
107 def __eq__(self, other):
108 if self.name == 'rhs1':
109 global rhs1_accessed
110 rhs1_accessed = True
111 else:
112 global rhs2_accessed
113 rhs2_accessed = True
114 return self.val == other
115
116a = 1
117rhs1_accessed = False
118rhs2_accessed = False
119result = (a == Track2('rhs1', 1) or a == Track2('rhs2', 2))
120print(f'rhs1_accessed={rhs1_accessed}, rhs2_accessed={rhs2_accessed}, result={result}')
121# Original: first comparison: 1==1 -> True, so short-circuit, rhs2 not accessed -> rhs1_accessed=True, rhs2_accessed=False
122# Fix: a in (Track2('rhs1',1), Track2('rhs2',2)) -> evaluates a==rhs1 -> True, so returns True, does not eval rhs2 -> same.
123# Now make first False:
124result = (a == Track2('rhs1', 2) or a == Track2('rhs2', 1))
125print(f'rhs1_accessed={rhs1_accessed}, rhs2_accessed={rhs2_accessed}, result={result}')
126# Original: first: 1==2 -> F, access rhs1; second: 1==1 -> T, access rhs2 -> both True
127# Fix: a in (rhs1, rhs2): eval a==rhs1 -> F; eval a==rhs2 -> T -> both accessed
128# Same.
129# So no difference in access count due to short-circuit either way.
130# The number of LHS accesses is what changes, but we already did that.
131# We'll conclude and output a placeholder.
132print('See hazard 3 for LHS access count change.')
f() = True
f() = False
g() = False
h() = False
rhs1_accessed=True, rhs2_accessed=True, result=False
rhs1_accessed=True, rhs2_accessed=False, result=True
rhs1_accessed=True, rhs2_accessed=True, result=True
See hazard 3 for LHS access count change.
r2-p001 · Custom __eq__ with side effects
1class EqCounter:
2 def __eq__(self, other):
3 EqCounter.calls += 1
4 return True
5EqCounter.calls = 0
6a = EqCounter()
7b = object()
8c = object()
9if a == b or a == c:
10 pass
11print('Calls:', EqCounter.calls)
Calls: 1
r2-p002 · Truthiness of custom __bool__ and 'is' confusion
1class FalsyButNotNone:
2 def __len__(self):
3 return 0
4x = FalsyButNotNone()
5y = []
6if x == [] or x == []:
7 result = 'matched'
8else:
9 result = 'no match'
10print(result)
no match
r2-p003 · Generator consumption in comprehension
1def gen():
2 yield 1
3 yield 2
4g = gen()
5x = next(g)
6result = [z for z in g if x == z or x == z]
7print('Result:', list(result))
Result: []
r2-p004 · Async generator and await ordering
1async def async_gen():
2 yield 1
3 yield 2
4async def main():
5 ag = async_gen()
6 x = await ag.__anext__()
7 vals = []
8 async for v in ag:
9 if x == v or x == v:
10 vals.append(v)
11 print('Vals:', vals)
12asyncio.run(main())
13import asyncio
r2-p005 · Property access with side effects in __getattr__
1class Obj:
2 def __getattr__(self, name):
3 Obj.count += 1
4 return 42
5Obj.count = 0
6o = Obj()
7if o.prop == 42 or o.prop == 42:
8 pass
9print('Attribute accesses:', Obj.count)
Attribute accesses: 1
r2-p006 · Function argument evaluation order changed
1eval_order = []
2def get_a():
3 eval_order.append('get_a')
4 return 5
5def f(val):
6 eval_order.append('f')
7 return val
8if f(get_a() == 5 or get_a() == 5):
9 pass
10print('Eval order:', eval_order)
Eval order: ['get_a', 'f']
r2-p007 · Mutable RHS objects and identity dependence (generated program is not deterministic)
1lst1 = [1, 2]
2lst2 = [3, 4]
3a = lst1
4if a == lst1 or a == lst2:
5 matched = lst1 is a # True only if a is lst1
6else:
7 matched = False
8print('Matched and identity preserved?', matched)
9# After fix: a in (lst1, lst2) -> a == lst1 or a == lst2, but tuple is new.
10# However, 'is' check above still uses original lst1, so behavior preserved.
11# To break: need to check identity of the tuple itself.
12tup = (lst1, lst2)
13print('Original tuple id:', id(tup))
14# Simulate what fix does: creates new tuple
15new_tup = (lst1, lst2)
16print('New tuple id:', id(new_tup))
17print('Same object?', tup is new_tup)
r2-p008 · Star-import and module-level side effects
1# This hazard is about module-level side effects from star-imports.
2# Since we cannot reliably trigger SIM109 here without external modules,
3# and the rule does not fire on import statements, we leave expect_trigger=False.
4# No SIM109-triggerable shape present.
5print('No trigger expected')
No trigger expected