Skip to content

Commit 649526a

Browse files
guillaume-dequennesonartech
authored andcommitted
SONARPY-4108 Implement S8503: Membership tests should not use empty collections (#1070)
GitOrigin-RevId: f752954d92186ed151b82c3c52f91df153b06786
1 parent 1c81a49 commit 649526a

7 files changed

Lines changed: 263 additions & 0 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* You can redistribute and/or modify this program under the terms of
7+
* the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonar.python.checks;
18+
19+
import org.sonar.check.Rule;
20+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
21+
import org.sonar.plugins.python.api.SubscriptionContext;
22+
import org.sonar.plugins.python.api.tree.CallExpression;
23+
import org.sonar.plugins.python.api.tree.DictionaryLiteral;
24+
import org.sonar.plugins.python.api.tree.Expression;
25+
import org.sonar.plugins.python.api.tree.InExpression;
26+
import org.sonar.plugins.python.api.tree.ListLiteral;
27+
import org.sonar.plugins.python.api.tree.Tree;
28+
import org.sonar.plugins.python.api.tree.Tuple;
29+
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
30+
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
31+
32+
@Rule(key = "S8503")
33+
public class EmptyCollectionMembershipTestCheck extends PythonSubscriptionCheck {
34+
35+
private static final String MESSAGE = "Remove this membership test on an empty collection; it will always be the same value.";
36+
37+
private static final TypeMatcher EMPTY_COLLECTION_CONSTRUCTOR = TypeMatchers.any(
38+
TypeMatchers.isType("builtins.set"),
39+
TypeMatchers.isType("builtins.tuple"),
40+
TypeMatchers.isType("builtins.frozenset")
41+
);
42+
43+
@Override
44+
public void initialize(Context context) {
45+
context.registerSyntaxNodeConsumer(Tree.Kind.IN, EmptyCollectionMembershipTestCheck::checkInExpression);
46+
}
47+
48+
private static void checkInExpression(SubscriptionContext ctx) {
49+
InExpression inExpression = (InExpression) ctx.syntaxNode();
50+
Expression rightOperand = inExpression.rightOperand();
51+
if (isEmptyCollection(rightOperand, ctx)) {
52+
ctx.addIssue(inExpression, MESSAGE);
53+
}
54+
}
55+
56+
private static boolean isEmptyCollection(Expression expression, SubscriptionContext ctx) {
57+
if (expression instanceof ListLiteral listLiteral) {
58+
return listLiteral.elements().expressions().isEmpty();
59+
}
60+
if (expression instanceof DictionaryLiteral dictionaryLiteral) {
61+
return dictionaryLiteral.elements().isEmpty();
62+
}
63+
if (expression instanceof Tuple tuple) {
64+
return tuple.elements().isEmpty();
65+
}
66+
if (expression instanceof CallExpression callExpression) {
67+
return callExpression.arguments().isEmpty()
68+
&& EMPTY_COLLECTION_CONSTRUCTOR.isTrueFor(callExpression.callee(), ctx);
69+
}
70+
return false;
71+
}
72+
}

python-checks/src/main/java/org/sonar/python/checks/OpenSourceCheckList.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ public Stream<Class<?>> getChecks() {
213213
EmailSendingCheck.class,
214214
EmptyAlternativeCheck.class,
215215
EmptyCollectionConstructorCheck.class,
216+
EmptyCollectionMembershipTestCheck.class,
216217
EmptyGroupCheck.class,
217218
EmptyFunctionCheck.class,
218219
EmptyNestedBlockCheck.class,
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<p>This rule raises an issue when a membership test using <code>in</code> or <code>not in</code> is performed on an empty collection literal such as
2+
<code>[]</code>, <code>{}</code>, <code>set()</code>, <code>tuple()</code>, or <code>frozenset()</code>.</p>
3+
<h2>Why is this an issue?</h2>
4+
<p>In Python, the membership operators <code>in</code> and <code>not in</code> test whether a value exists in a collection. When the collection is
5+
empty, these tests always produce predictable results:</p>
6+
<ul>
7+
<li><code>x in []</code> is always <code>False</code> because the value cannot exist in an empty collection</li>
8+
<li><code>x not in []</code> is always <code>True</code> because the value is indeed not in an empty collection</li>
9+
</ul>
10+
<p>This pattern typically indicates one of three problems:</p>
11+
<ul>
12+
<li><strong>dead code</strong>: the check serves no purpose and should be removed</li>
13+
<li><strong>logic error</strong>: the collection should have been populated before the membership test</li>
14+
<li><strong>incomplete implementation</strong>: the code was started but never finished</li>
15+
</ul>
16+
<p>Consider this example:</p>
17+
<pre>
18+
if user_id in []:
19+
grant_access()
20+
</pre>
21+
<p>This condition will never be true, so <code>grant_access()</code> will never be called. The code is either incomplete, meaning the list should
22+
contain authorized user IDs, or the check is unnecessary and should be removed.</p>
23+
<p>The issue applies to all Python collection types:</p>
24+
<ul>
25+
<li>empty list: <code>[]</code></li>
26+
<li>empty dict: <code>{}</code>, which creates an empty dictionary, not a set. When used with <code>in</code>, it tests keys.</li>
27+
<li>empty set: <code>set()</code></li>
28+
<li>empty tuple: <code>()</code> or <code>tuple()</code></li>
29+
<li>empty frozenset: <code>frozenset()</code></li>
30+
</ul>
31+
<h3>What is the potential impact?</h3>
32+
<p>This issue can lead to bugs where conditions never evaluate as expected, causing:</p>
33+
<ul>
34+
<li><strong>dead code paths</strong>: code that can never be reached</li>
35+
<li><strong>security vulnerabilities</strong>: authorization checks that never pass or always pass</li>
36+
<li><strong>logic errors</strong>: incorrect program behavior due to conditions that do not work as intended</li>
37+
</ul>
38+
<p>The severity depends on the context. An authorization check that always fails could be a critical security issue, while a redundant check might
39+
only affect code maintainability.</p>
40+
<h2>How to fix it</h2>
41+
<p>If the collection should contain values, populate it with the appropriate elements before performing the membership test.</p>
42+
<h3>Code examples</h3>
43+
<h4>Noncompliant code example</h4>
44+
<pre data-diff-id="1" data-diff-type="noncompliant">
45+
if user_id in []: # Noncompliant: empty list
46+
grant_access()
47+
</pre>
48+
<h4>Compliant solution</h4>
49+
<pre data-diff-id="1" data-diff-type="compliant">
50+
if user_id in ["admin", "user123", "moderator"]:
51+
grant_access()
52+
</pre>
53+
<h2>Resources</h2>
54+
<h3>Documentation</h3>
55+
<ul>
56+
<li>Python Documentation - <a href="https://docs.python.org/3/reference/expressions.html#membership-test-operations">Membership test
57+
operations</a></li>
58+
<li>Python Documentation - <a href="https://docs.python.org/3/tutorial/datastructures.html">Data Structures</a></li>
59+
</ul>
60+
<h3>Related rules</h3>
61+
<ul>
62+
<li>{rule:python:S2583} - Conditionals should not always evaluate to the same value</li>
63+
<li>{rule:python:S1145} - Useless "if(true) {…​}" and "if(false){…​}" blocks should be removed</li>
64+
</ul>
65+
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"title": "Membership tests should not use empty collections",
3+
"type": "BUG",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "5 min"
8+
},
9+
"tags": [
10+
"pitfall",
11+
"suspicious"
12+
],
13+
"defaultSeverity": "Major",
14+
"ruleSpecification": "RSPEC-8503",
15+
"sqKey": "S8503",
16+
"scope": "All",
17+
"quickfix": "unknown",
18+
"code": {
19+
"impacts": {
20+
"RELIABILITY": "MEDIUM",
21+
"MAINTAINABILITY": "MEDIUM"
22+
},
23+
"attribute": "LOGICAL"
24+
}
25+
}

python-checks/src/main/resources/org/sonar/l10n/py/rules/python/Sonar_way_profile.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,7 @@
332332
"S8493",
333333
"S8494",
334334
"S8495",
335+
"S8503",
335336
"S8504",
336337
"S8505",
337338
"S8507",
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* You can redistribute and/or modify this program under the terms of
7+
* the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonar.python.checks;
18+
19+
import org.junit.jupiter.api.Test;
20+
import org.sonar.python.checks.utils.PythonCheckVerifier;
21+
22+
class EmptyCollectionMembershipTestCheckTest {
23+
24+
@Test
25+
void test() {
26+
PythonCheckVerifier.verify("src/test/resources/checks/emptyCollectionMembershipTest.py", new EmptyCollectionMembershipTestCheck());
27+
}
28+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
def empty_literals(x):
2+
if x in []: ... # Noncompliant {{Remove this membership test on an empty collection; it will always be the same value.}}
3+
# ^^^^^^^
4+
if x in {}: ... # Noncompliant
5+
# ^^^^^^^
6+
if x in (): ... # Noncompliant
7+
# ^^^^^^^
8+
if x not in []: ... # Noncompliant
9+
# ^^^^^^^^^^^
10+
if x not in {}: ... # Noncompliant
11+
if x not in (): ... # Noncompliant
12+
13+
14+
def empty_constructor_calls(x):
15+
if x in set(): ... # Noncompliant
16+
# ^^^^^^^^^^
17+
if x in tuple(): ... # Noncompliant
18+
if x in frozenset(): ... # Noncompliant
19+
if x not in set(): ... # Noncompliant
20+
if x not in tuple(): ... # Noncompliant
21+
if x not in frozenset(): ... # Noncompliant
22+
23+
24+
def outside_conditions(x, values):
25+
# The rule fires on any membership test, not only those used as a condition.
26+
result = x in [] # Noncompliant
27+
flag = x not in set() # Noncompliant
28+
values = [y for y in range(10) if y in ()] # Noncompliant
29+
30+
31+
def compliant_non_empty_literals(x):
32+
if x in [1, 2, 3]: ...
33+
if x in {"a": 1}: ...
34+
if x in {1, 2}: ...
35+
if x in (1,): ...
36+
if x in (1, 2): ...
37+
if x not in [1, 2, 3]: ...
38+
39+
40+
def compliant_constructor_calls_with_arguments(x, iterable):
41+
if x in set(iterable): ...
42+
if x in tuple(iterable): ...
43+
if x in frozenset(iterable): ...
44+
if x in list(iterable): ...
45+
if x in dict(iterable): ...
46+
47+
48+
def compliant_list_dict_builtin_calls(x):
49+
# list() / dict() are not covered by this rule. Use [] / {} instead.
50+
if x in list(): ...
51+
if x in dict(): ...
52+
53+
54+
def compliant_variables(x, values):
55+
if x in values: ...
56+
my_list = []
57+
if x in my_list: ... # Empty but through a variable - out of scope here
58+
59+
60+
def compliant_unpacking(x, p1, p2):
61+
if x in [*p1, *p2]: ...
62+
if x in {**p1}: ...
63+
if x in {*p1}: ...
64+
if x in (*p1,): ...
65+
66+
67+
def compliant_shadowed_builtins(x):
68+
def set(): # user-defined function, shadows builtin
69+
return [1, 2]
70+
71+
if x in set(): ... # Not the builtin set

0 commit comments

Comments
 (0)