Repository: hyperskill/hyperstyle (language: python)

Issue:
Found line with high Jones Complexity: 15 > 14 - shouldn't be showing up
1. Open https://hyperskill.org/learn/step/7718
2. Submit the following code:
```
def condition(x, y, gt):
    return x > y if gt else x < y


def min_max_sort(elements):
    gt = 1
    for i in range(len(elements) - 1):
        index = i
        gt = (gt + 1) & 1

        for j in range(i + 1, len(elements)):
            if condition(elements[j], elements[index], gt):
                index = j
                
        elements[i], elements[index] = elements[index], elements[i]


ar = list(map(int, input().split()))
min_max_sort(ar)
print(*ar)
```
There is a code style error for `elements[i], elements[index] = elements[index], elements[i]`:
```
Found incorrect unpacking target 
Found incorrect unpacking target 
Found line with high Jones Complexity: 15 > 14 
```
that didn't show up 4 months ago. 


Discussion / hints:

Fix patch:
diff --git a/src/python/review/inspectors/flake8/.flake8 b/src/python/review/inspectors/flake8/.flake8
index 42b81a88..4d379e05 100644
--- a/src/python/review/inspectors/flake8/.flake8
+++ b/src/python/review/inspectors/flake8/.flake8
@@ -23,6 +23,9 @@ ignore=W291,  # trailing whitespaces
        WPS112, # Forbid private name pattern.
        WPS114, # Forbid names with underscored numbers pattern.
        WPS125, # Forbid variable or module names which shadow builtin names. TODO: Collision with flake8-builtins
+       # WPS: Complexity
+       WPS200, # Forbid modules with complex lines. (Jones Complexity)
+       WPS221, # Forbid complex lines. (Jones Complexity)
        # WPS: Consistency
        WPS303, # Forbid underscores in numbers.
        WPS305, # Forbid f strings.
@@ -39,6 +42,7 @@ ignore=W291,  # trailing whitespaces
        WPS362, # Forbid assignment to a subscript slice.
        # WPS: Best practices
        WPS404, # Forbid complex defaults. TODO: Collision with "B006"
+       WPS414, # Forbid tuple unpacking with side-effects. (controversial)
        WPS420, # Forbid some python keywords.
        WPS421, # Forbid calling some built-in functions.(e.g., print)
        WPS429, # Forbid multiple assignments on the same line.
diff --git a/src/python/review/inspectors/pylint/issue_types.py b/src/python/review/inspectors/pylint/issue_types.py
index 5df676b1..048f0c45 100644
--- a/src/python/review/inspectors/pylint/issue_types.py
+++ b/src/python/review/inspectors/pylint/issue_types.py
@@ -9,6 +9,7 @@
 
 CODE_TO_ISSUE_TYPE: Dict[str, IssueType] = {
     'C0200': IssueType.BEST_PRACTICES,  # consider using enumerate
+    'C0113': IssueType.BEST_PRACTICES,
     'C0415': IssueType.BEST_PRACTICES,  # import-outside-toplevel
     'W0101': IssueType.ERROR_PRONE,  # unreachable code
     'W0102': IssueType.ERROR_PRONE,  # dangerous default value
diff --git a/src/python/review/quality/evaluate_quality.py b/src/python/review/quality/evaluate_quality.py
index b6329653..f4eb3a9e 100644
--- a/src/python/review/quality/evaluate_quality.py
+++ b/src/python/review/quality/evaluate_quality.py
@@ -17,6 +17,7 @@
     CohesionRule,
     LANGUAGE_TO_COHESION_RULE_CONFIG,
 )
+from src.python.review.quality.rules.complexity_scoring import ComplexityRule, LANGUAGE_TO_COMPLEXITY_RULE_CONFIG
 from src.python.review.quality.rules.coupling_scoring import CouplingRule, LANGUAGE_TO_COUPLING_RULE_CONFIG
 from src.python.review.quality.rules.cyclomatic_complexity_scoring import (
     CyclomaticComplexityRule,
@@ -51,6 +52,7 @@ def __get_available_rules(language: Language) -> List[Rule]:
     return [
         ErrorProneRule(LANGUAGE_TO_ERROR_PRONE_RULE_CONFIG[language]),
         BestPracticesRule(LANGUAGE_TO_BEST_PRACTICES_RULE_CONFIG[language]),
+        ComplexityRule(LANGUAGE_TO_COMPLEXITY_RULE_CONFIG[language]),
         CyclomaticComplexityRule(LANGUAGE_TO_CYCLOMATIC_COMPLEXITY_RULE_CONFIG[language]),
         BooleanExpressionRule(LANGUAGE_TO_BOOLEAN_EXPRESSION_RULE_CONFIG[language]),
         FunctionLengthRule(LANGUAGE_TO_FUNCTION_LENGTH_RULE_CONFIG[language]),
diff --git a/src/python/review/quality/rules/best_practices_scoring.py b/src/python/review/quality/rules/best_practices_scoring.py
index 633387f2..4541ee34 100644
--- a/src/python/review/quality/rules/best_practices_scoring.py
+++ b/src/python/review/quality/rules/best_practices_scoring.py
@@ -14,7 +14,7 @@ class BestPracticesRuleConfig:
 
 
 common_best_practices_rule_config = BestPracticesRuleConfig(
-    n_best_practices_moderate=3,
+    n_best_practices_moderate=4,
     n_best_practices_good=1,
     n_files=1,
 )
diff --git a/src/python/review/quality/rules/boolean_length_scoring.py b/src/python/review/quality/rules/boolean_length_scoring.py
index 2a33c327..a338f081 100644
--- a/src/python/review/quality/rules/boolean_length_scoring.py
+++ b/src/python/review/quality/rules/boolean_length_scoring.py
@@ -14,20 +14,20 @@ class BooleanExpressionRuleConfig:
 
 
 common_boolean_expression_rule_config = BooleanExpressionRuleConfig(
-    bool_expr_len_bad=10,
-    bool_expr_len_moderate=7,
-    bool_expr_len_good=5,
+    bool_expr_len_bad=6,
+    bool_expr_len_moderate=5,
+    bool_expr_len_good=3,
 )
 
-java_boolean_expression_rule_config = BooleanExpressionRuleConfig(
-    bool_expr_len_bad=8,
-    bool_expr_len_moderate=6,
-    bool_expr_len_good=4,
+kotlin_boolean_expression_rule_config = BooleanExpressionRuleConfig(
+    bool_expr_len_bad=9,
+    bool_expr_len_moderate=7,
+    bool_expr_len_good=5,
 )
 
 LANGUAGE_TO_BOOLEAN_EXPRESSION_RULE_CONFIG = {
-    Language.JAVA: java_boolean_expression_rule_config,
-    Language.KOTLIN: common_boolean_expression_rule_config,
+    Language.JAVA: common_boolean_expression_rule_config,
+    Language.KOTLIN: kotlin_boolean_expression_rule_config,
     Language.PYTHON: common_boolean_expression_rule_config,
     Language.JS: common_boolean_expression_rule_config,
 }
diff --git a/src/python/review/quality/rules/cohesion_scoring.py b/src/python/review/quality/rules/cohesion_scoring.py
index 623fd2db..15de7a62 100644
--- a/src/python/review/quality/rules/cohesion_scoring.py
+++ b/src/python/review/quality/rules/cohesion_scoring.py
@@ -15,11 +15,11 @@ class CohesionRuleConfig:
 
 # TODO: Need testing
 # Cohesion plugin by default only shows issues where cohesion is less than 50%.
-# Therefore cohesion_lack_bad = 50. The other levels are set in steps of 20%.
+# Therefore cohesion_lack_good = 50. The other levels are set in steps of 20%.
 common_cohesion_rule_config = CohesionRuleConfig(
-    cohesion_lack_bad=50,
-    cohesion_lack_moderate=30,
-    cohesion_lack_good=10,
+    cohesion_lack_bad=90,
+    cohesion_lack_moderate=70,
+    cohesion_lack_good=50,
 )
 
 LANGUAGE_TO_COHESION_RULE_CONFIG = {
diff --git a/src/python/review/quality/rules/complexity_scoring.py b/src/python/review/quality/rules/complexity_scoring.py
new file mode 100644
index 00000000..31083074
--- /dev/null
+++ b/src/python/review/quality/rules/complexity_scoring.py
@@ -0,0 +1,68 @@
+from dataclasses import dataclass
+from typing import Optional
+
+from src.python.review.common.language import Language
+from src.python.review.inspectors.issue import IssueType
+from src.python.review.quality.model import QualityType, Rule
+
+
+@dataclass
+class ComplexityRuleConfig:
+    complexity_good: int
+    complexity_moderate: int
+    complexity_bad: int
+
+
+common_complexity_rule_config = ComplexityRuleConfig(
+    complexity_good=1,
+    complexity_moderate=3,
+    complexity_bad=6,
+)
+
+LANGUAGE_TO_COMPLEXITY_RULE_CONFIG = {
+    Language.JAVA: common_complexity_rule_config,
+    Language.PYTHON: common_complexity_rule_config,
+    Language.KOTLIN: common_complexity_rule_config,
+    Language.JS: common_complexity_rule_config,
+}
+
+
+class ComplexityRule(Rule):
+    def __init__(self, config: ComplexityRuleConfig):
+        self.config = config
+        self.rule_type = IssueType.COMPLEXITY
+        self.complexity: Optional[int] = None
+
+    def apply(self, complexity):
+        self.complexity = complexity
+        if complexity > self.config.complexity_bad:
+            self.quality_type = QualityType.BAD
+            self.next_level_delta = complexity - self.config.complexity_bad
+        elif complexity > self.config.complexity_moderate:
+            self.quality_type = QualityType.MODERATE
+            self.next_level_delta = complexity - self.config.complexity_moderate
+        elif complexity > self.config.complexity_good:
+            self.quality_type = QualityType.GOOD
+            self.next_level_delta = complexity - self.config.complexity_good
+        else:
+            self.quality_type = QualityType.EXCELLENT
+            self.next_level_delta = 0
+        self.next_level_type = self.__get_next_quality_type()
+
+    def __get_next_quality_type(self) -> QualityType:
+        if self.quality_type == QualityType.BAD:
+            return QualityType.MODERATE
+        elif self.quality_type == QualityType.MODERATE:
+            return QualityType.GOOD
+        return QualityType.EXCELLENT
+
+    def merge(self, other: 'ComplexityRule') -> 'ComplexityRule':
+        config = ComplexityRuleConfig(
+            min(self.config.complexity_bad, other.config.complexity_bad),
+            min(self.config.complexity_moderate, other.config.complexity_moderate),
+            min(self.config.complexity_good, other.config.complexity_good),
+        )
+        result_rule = ComplexityRule(config)
+        result_rule.apply(max(self.complexity, other.complexity))
+
+        return result_rule
diff --git a/src/python/review/quality/rules/error_prone_scoring.py b/src/python/review/quality/rules/error_prone_scoring.py
index 8df9c157..1ee57305 100644
--- a/src/python/review/quality/rules/error_prone_scoring.py
+++ b/src/python/review/quality/rules/error_prone_scoring.py
@@ -11,6 +11,10 @@ class ErrorProneRuleConfig:
     n_error_prone_bad: int
 
 
+# TODO: It is necessary to add subcategories where the following boundaries can be used:
+#       GOOD: 0
+#       MODERATE: 1
+#       BAD: 2
 common_error_prone_rule_config = ErrorProneRuleConfig(
     n_error_prone_bad=0,
 )
diff --git a/src/python/review/quality/rules/line_len_scoring.py b/src/python/review/quality/rules/line_len_scoring.py
index 50a767bf..4ae8cc67 100644
--- a/src/python/review/quality/rules/line_len_scoring.py
+++ b/src/python/review/quality/rules/line_len_scoring.py
@@ -8,11 +8,13 @@
 @dataclass
 class LineLengthRuleConfig:
     n_line_len_bad: float
+    n_line_len_moderate: float
     n_line_len_good: float
 
 
 common_line_length_rule_config = LineLengthRuleConfig(
-    n_line_len_bad=0.05,
+    n_line_len_bad=0.07,
+    n_line_len_moderate=0.05,
     n_line_len_good=0.035,
 )
 
@@ -38,6 +40,9 @@ def apply(self, n_line_len, n_lines):
         if self.ratio > self.config.n_line_len_bad:
             self.quality_type = QualityType.BAD
             self.next_level_delta = n_line_len - self.config.n_line_len_bad
+        if self.ratio > self.config.n_line_len_moderate:
+            self.quality_type = QualityType.MODERATE
+            self.next_level_delta = n_line_len - self.config.n_line_len_moderate
         elif self.ratio > self.config.n_line_len_good:
             self.quality_type = QualityType.GOOD
             self.next_level_delta = n_line_len - self.config.n_line_len_good
@@ -54,6 +59,7 @@ def __get_next_quality_type(self) -> QualityType:
     def merge(self, other: 'LineLengthRule') -> 'LineLengthRule':
         config = LineLengthRuleConfig(
             min(self.config.n_line_len_bad, other.config.n_line_len_bad),
+            min(self.config.n_line_len_moderate, other.config.n_line_len_moderate),
             min(self.config.n_line_len_good, other.config.n_line_len_good),
         )
         result_rule = LineLengthRule(config)
diff --git a/src/python/review/reviewers/utils/code_statistics.py b/src/python/review/reviewers/utils/code_statistics.py
index 2d9381d9..23e3ee8e 100644
--- a/src/python/review/reviewers/utils/code_statistics.py
+++ b/src/python/review/reviewers/utils/code_statistics.py
@@ -11,6 +11,7 @@
 class CodeStatistics:
     n_best_practices_issue: int
     n_error_prone_issues: int
+    n_complexity_issues: int
     n_line_len: int
 
     method_number: int
@@ -35,6 +36,7 @@ def issue_type_to_statistics_dict(self) -> Dict[IssueType, int]:
         return {
             IssueType.BEST_PRACTICES: self.n_best_practices_issue,
             IssueType.ERROR_PRONE: self.n_error_prone_issues,
+            IssueType.COMPLEXITY: self.n_complexity_issues,
             IssueType.LINE_LEN: self.n_line_len,
 
             IssueType.METHOD_NUMBER: self.method_number,
@@ -87,6 +89,7 @@ def gather_code_statistics(issues: List[BaseIssue], path: Path) -> CodeStatistic
         n_code_style_issues=issue_type_counter[IssueType.CODE_STYLE],
         n_best_practices_issue=issue_type_counter[IssueType.BEST_PRACTICES],
         n_error_prone_issues=issue_type_counter[IssueType.ERROR_PRONE],
+        n_complexity_issues=issue_type_counter[IssueType.COMPLEXITY],
         max_bool_expr_len=bool_expr_lens,
         max_func_len=func_lens,
         n_line_len=issue_type_counter[IssueType.LINE_LEN],


Test patch:
diff --git a/test/python/functional_tests/test_difficulty_levels.py b/test/python/functional_tests/test_difficulty_levels.py
index 5f758ca1..fcbdb1a1 100644
--- a/test/python/functional_tests/test_difficulty_levels.py
+++ b/test/python/functional_tests/test_difficulty_levels.py
@@ -49,7 +49,7 @@ def _get_output_json(local_command: LocalCommandBuilder, file_path: str, history
             'influence_on_penalty': {'EASY': 0, 'HARD': 0, 'MEDIUM': 0},
             'line': 'class BadClass:',
             'line_number': 4,
-            'text': 'class has low (50.00%) cohesion',
+            'text': 'class has low (20.00%) cohesion',
         },
     ],
 }
@@ -200,7 +200,7 @@ def _get_output_json(local_command: LocalCommandBuilder, file_path: str, history
             'influence_on_penalty': {'EASY': 0, 'HARD': 0, 'MEDIUM': 0},
             'line': 'class BadClass:',
             'line_number': 5,
-            'text': 'class has low (50.00%) cohesion',
+            'text': 'class has low (20.00%) cohesion',
         },
         {
             'category': 'CODE_STYLE',
@@ -262,7 +262,7 @@ def test_difficulty_levels(local_command: LocalCommandBuilder, file: str, expect
             'influence_on_penalty': {'EASY': 0, 'MEDIUM': 0, 'HARD': 100},
             'line': 'class BadClass:',
             'line_number': 4,
-            'text': 'class has low (50.00%) cohesion',
+            'text': 'class has low (20.00%) cohesion',
         },
     ],
 }
@@ -412,7 +412,7 @@ def test_difficulty_levels(local_command: LocalCommandBuilder, file: str, expect
             'influence_on_penalty': {'EASY': 0, 'MEDIUM': 0, 'HARD': 17},
             'line': 'class BadClass:',
             'line_number': 5,
-            'text': 'class has low (50.00%) cohesion',
+            'text': 'class has low (20.00%) cohesion',
         },
         {
             'category': 'CODE_STYLE',
diff --git a/test/python/inspectors/test_checkstyle_inspector.py b/test/python/inspectors/test_checkstyle_inspector.py
index 4d2c7889..c237c653 100644
--- a/test/python/inspectors/test_checkstyle_inspector.py
+++ b/test/python/inspectors/test_checkstyle_inspector.py
@@ -191,7 +191,7 @@ def test_output_parsing(file_name: str, expected_issues: List[CodeIssue]):
     # ("test_empty_lines_btw_members.java", 2),
     ('test_covariant_equals.java', 1),
     ('test_multi_statements.java', 7),
-    ('test_boolean_expr.java', 3),
+    ('test_boolean_expr.java', 4),
     ('test_code_with_comments.java', 2),
     ('test_too_long_method.java', 4),
     ('test_cyclomatic_complexity.java', 8),
diff --git a/test/python/inspectors/test_flake8_inspector.py b/test/python/inspectors/test_flake8_inspector.py
index 4d84a3c7..4f3b6c2a 100644
--- a/test/python/inspectors/test_flake8_inspector.py
+++ b/test/python/inspectors/test_flake8_inspector.py
@@ -11,16 +11,16 @@
 FILE_NAMES_AND_N_ISSUES = [
     ('case0_spaces.py', 5),
     ('case1_simple_valid_program.py', 0),
-    ('case2_boolean_expressions.py', 8),
+    ('case2_boolean_expressions.py', 7),
     ('case3_redefining_builtin.py', 2),
     ('case4_naming.py', 8),
     ('case5_returns.py', 1),
     ('case6_unused_variables.py', 3),
     ('case8_good_class.py', 0),
-    ('case7_empty_lines.py', 2),
+    ('case7_empty_lines.py', 1),
     ('case10_unused_variable_in_loop.py', 1),
     ('case13_complex_logic.py', 12),
-    ('case13_complex_logic_2.py', 3),
+    ('case13_complex_logic_2.py', 2),
     ('case11_redundant_parentheses.py', 0),
     ('case14_returns_errors.py', 4),
     ('case16_comments.py', 0),
@@ -34,7 +34,7 @@
     ('case33_commas.py', 14),
     ('case34_cohesion.py', 1),
     ('case35_line_break.py', 11),
-    ('case36_unpacking.py', 3),
+    ('case36_unpacking.py', 0),
     ('case37_wildcard_import.py', 1),
 ]
 
@@ -58,7 +58,7 @@ def test_file_with_issues(file_name: str, n_issues: int):
                                                     n_best_practices=4,
                                                     n_error_prone=1,
                                                     n_cc=8,
-                                                    n_other_complexity=2)),
+                                                    n_other_complexity=1)),
     ('case3_redefining_builtin.py', IssuesTestInfo(n_error_prone=2)),
     ('case4_naming.py', IssuesTestInfo(n_code_style=7, n_cc=5, n_cohesion=1)),
     ('case6_unused_variables.py', IssuesTestInfo(n_best_practices=3,
@@ -68,7 +68,7 @@ def test_file_with_issues(file_name: str, n_issues: int):
     ('case10_unused_variable_in_loop.py', IssuesTestInfo(n_best_practices=1,
                                                          n_cc=1)),
     ('case13_complex_logic.py', IssuesTestInfo(n_cc=5, n_other_complexity=10)),
-    ('case13_complex_logic_2.py', IssuesTestInfo(n_cc=2, n_other_complexity=2)),
+    ('case13_complex_logic_2.py', IssuesTestInfo(n_cc=2, n_other_complexity=1)),
     ('case14_returns_errors.py', IssuesTestInfo(n_best_practices=1,
                                                 n_error_prone=3,
                                                 n_cc=4)),
@@ -78,7 +78,7 @@ def test_file_with_issues(file_name: str, n_issues: int):
     ('case32_string_format.py', IssuesTestInfo(n_error_prone=28, n_other_complexity=6)),
     ('case33_commas.py', IssuesTestInfo(n_code_style=14, n_cc=4)),
     ('case34_cohesion.py', IssuesTestInfo(n_cc=6, n_cohesion=2)),
-    ('case36_unpacking.py', IssuesTestInfo(n_error_prone=2, n_cc=1, n_other_complexity=1)),
+    ('case36_unpacking.py', IssuesTestInfo(n_cc=1)),
     ('case37_wildcard_import.py', IssuesTestInfo(n_best_practices=1, n_cc=1)),
 ]
 
diff --git a/test/resources/functional_tests/difficulty_levels/file_with_all_difficulty_levels.py b/test/resources/functional_tests/difficulty_levels/file_with_all_difficulty_levels.py
index a2ddefa4..5a2b0659 100644
--- a/test/resources/functional_tests/difficulty_levels/file_with_all_difficulty_levels.py
+++ b/test/resources/functional_tests/difficulty_levels/file_with_all_difficulty_levels.py
@@ -10,3 +10,15 @@ def __init__(self, x: int, y: int):
     @staticmethod
     def Length(x: int, y: int) -> float:
         return sqrt(x ** 2 + y ** 2)
+
+    @staticmethod
+    def dot(self_x: int, self_y: int, other_x: int, other_y: int) -> int:
+        return self_x * other_x + self_y * other_y
+
+    @staticmethod
+    def plus(self_x: int, self_y: int, other_x: int, other_y: int) -> 'BadClass':
+        return BadClass(self_x + other_x, self_y + other_y)
+
+    @staticmethod
+    def minus(self_x: int, self_y: int, other_x: int, other_y: int) -> 'BadClass':
+        return BadClass(self_x - other_x, self_y - other_y)
\ No newline at end of file
diff --git a/test/resources/functional_tests/difficulty_levels/file_with_only_hard_issues.py b/test/resources/functional_tests/difficulty_levels/file_with_only_hard_issues.py
index ce35caf3..c63a944d 100644
--- a/test/resources/functional_tests/difficulty_levels/file_with_only_hard_issues.py
+++ b/test/resources/functional_tests/difficulty_levels/file_with_only_hard_issues.py
@@ -10,6 +10,18 @@ def __init__(self, x: int, y: int):
     def length(x: int, y: int) -> float:
         return sqrt(x ** 2 + y ** 2)
 
+    @staticmethod
+    def dot(self_x: int, self_y: int, other_x: int, other_y: int) -> int:
+        return self_x * other_x + self_y * other_y
+
+    @staticmethod
+    def plus(self_x: int, self_y: int, other_x: int, other_y: int) -> 'BadClass':
+        return BadClass(self_x + other_x, self_y + other_y)
+
+    @staticmethod
+    def minus(self_x: int, self_y: int, other_x: int, other_y: int) -> 'BadClass':
+        return BadClass(self_x - other_x, self_y - other_y)
+
 
 if __name__ == "__main__":
     print("Hello, World!")