Repository: MarketSquare/robotframework-robocop
Pull request #940 (2023-08-28 10:03:19)

Revert "Revert "Add first community rule (#918)""
Reverts MarketSquare/robotframework-robocop#935

After releasing 4.1.0, we can revert back 5.0.0 changes to master branch.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/MarketSquare/robotframework-robocop/pull/940?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MarketSquare) Report
Attention: `10 lines` in your changes are missing coverage. Please review.
> Comparison is base [(`d14af7d`)](https://app.codecov.io/gh/MarketSquare/robotframework-robocop/commit/d14af7dd414e53ae273ef6b8316485b5d901a665?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MarketSquare) 96.75% compared to head [(`44d4ddf`)](https://app.codecov.io/gh/MarketSquare/robotframework-robocop/pull/940?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MarketSquare) 96.66%.

| [Files](https://app.codecov.io/gh/MarketSquare/robotframework-robocop/pull/940?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MarketSquare) | Patch % | Lines |
|---|---|---|
| [robocop/checkers/\_\_init\_\_.py](https://app.codecov.io/gh/MarketSquare/robotframework-robocop/pull/940?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MarketSquare#diff-cm9ib2NvcC9jaGVja2Vycy9fX2luaXRfXy5weQ==) | 82.92% | [7 Missing :warning: ](https://app.codecov.io/gh/MarketSquare/robotframework-robocop/pull/940?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MarketSquare) |
| [robocop/checkers/community\_rules/keywords.py](https://app.codecov.io/gh/MarketSquare/robotframework-robocop/pull/940?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MarketSquare#diff-cm9ib2NvcC9jaGVja2Vycy9jb21tdW5pdHlfcnVsZXMva2V5d29yZHMucHk=) | 96.55% | [1 Missing :warning: ](https://app.codecov.io/gh/MarketSquare/robotframework-robocop/pull/940?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MarketSquare) |
| [robocop/config.py](https://app.codecov.io/gh/MarketSquare/robotframework-robocop/pull/940?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MarketSquare#diff-cm9ib2NvcC9jb25maWcucHk=) | 50.00% | [1 Missing :warning: ](https://app.codecov.io/gh/MarketSquare/robotframework-robocop/pull/940?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MarketSquare) |
| [robocop/rules.py](https://app.codecov.io/gh/MarketSquare/robotframework-robocop/pull/940?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MarketSquare#diff-cm9ib2NvcC9ydWxlcy5weQ==) | 94.73% | [1 Missing :warning: ](https://app.codecov.io/gh/MarketSquare/robotframework-robocop/pull/940?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MarketSquare) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #940      +/-   ##
==========================================
- Coverage   96.75%   96.66%   -0.10%     
==========================================
  Files          36       37       +1     
  Lines        4444     4521      +77     
==========================================
+ Hits         4300     4370      +70     
- Misses        144      151       +7     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/MarketSquare/robotframework-robocop/pull/940?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MarketSquare).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MarketSquare).


Patch:
diff --git a/docs/conf.py b/docs/conf.py
index f1625c3..3aa3222 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -73,7 +73,7 @@ def get_checker_docs():
     Load rules for dynamic docs generation
     """
     checker_docs = defaultdict(list)
-    for module_name, rule in robocop.checkers.get_rules():
+    for module_name, rule in robocop.checkers.get_builtin_rules():
         module_name = module_name.title()
         severity_threshold = rule.config.get("severity_threshold", None)
         robocop_version = rule.added_in_version if rule.added_in_version else "\\-"
diff --git a/docs/releasenotes/unreleased/most_important.1.rst b/docs/releasenotes/unreleased/most_important.1.rst
new file mode 100644
index 0000000..57a9dc1
--- /dev/null
+++ b/docs/releasenotes/unreleased/most_important.1.rst
@@ -0,0 +1,5 @@
+New community rules
+-------------------
+
+<description here, together with first community rule (working as example)>
+<also describe somewhere robocop --list COMMUNITY and robocop --list ALL>
diff --git a/tests/atest/rules/naming/mixed-task-test-settings/__init__.py b/docs/releasenotes/unreleased/rules.1.rst
similarity index 100%
rename from tests/atest/rules/naming/mixed-task-test-settings/__init__.py
rename to docs/releasenotes/unreleased/rules.1.rst
diff --git a/robocop/checkers/__init__.py b/robocop/checkers/__init__.py
index 42f4854..6bada9b 100644
--- a/robocop/checkers/__init__.py
+++ b/robocop/checkers/__init__.py
@@ -32,6 +32,7 @@ import importlib.util
 import inspect
 from collections import defaultdict
 from importlib import import_module
+from itertools import chain
 from pathlib import Path
 from typing import Dict, List, Tuple
 
@@ -163,23 +164,38 @@ def is_checker(checker_class_def: Tuple) -> bool:
 class RobocopImporter:
     def __init__(self, external_rules_paths=None):
         self.internal_checkers_dir = Path(__file__).parent
+        self.community_checkers_dir = self.internal_checkers_dir / "community_rules"
         self.external_rules_paths = external_rules_paths
         self.imported_modules = set()
         self.seen_modules = set()
         self.seen_checkers = defaultdict(list)
 
     def get_initialized_checkers(self):
-        for module in self.modules_from_paths([self.internal_checkers_dir, *self.external_rules_paths]):
+        yield from self._get_checkers_from_modules(self.get_internal_modules(), is_community=False)
+        yield from self._get_checkers_from_modules(self.get_community_modules(), is_community=True)
+        yield from self._get_checkers_from_modules(self.get_external_modules(), is_community=False)
+
+    def get_internal_modules(self):
+        return self.modules_from_paths([file for file in self.internal_checkers_dir.iterdir()], recursive=False)
+
+    def get_community_modules(self):
+        return self.modules_from_paths([self.community_checkers_dir], recursive=True)
+
+    def get_external_modules(self):
+        return self.modules_from_paths([*self.external_rules_paths], recursive=True)
+
+    def _get_checkers_from_modules(self, modules, is_community):
+        for module in modules:
             if module in self.seen_modules:
                 continue
             for _, submodule in inspect.getmembers(module, inspect.ismodule):
                 if submodule not in self.seen_modules:
-                    yield from self._get_initialized_checkers_from_module(submodule)
-            yield from self._get_initialized_checkers_from_module(module)
+                    yield from self._get_initialized_checkers_from_module(submodule, is_community)
+            yield from self._get_initialized_checkers_from_module(module, is_community)
 
-    def _get_initialized_checkers_from_module(self, module):
+    def _get_initialized_checkers_from_module(self, module, is_community):
         self.seen_modules.add(module)
-        for checker_instance in self.get_checkers_from_module(module):
+        for checker_instance in self.get_checkers_from_module(module, is_community):
             if not self.is_checker_already_imported(checker_instance):
                 yield checker_instance
 
@@ -194,12 +210,12 @@ class RobocopImporter:
         self.seen_checkers[checker_name].append(sorted(checker.rules.keys()))
         return False
 
-    def modules_from_paths(self, paths):
+    def modules_from_paths(self, paths, recursive=True):
         for path in paths:
             path_object = Path(path)
             if path_object.exists():
                 if path_object.is_dir():
-                    if path_object.name in {".git", "__pycache__"}:
+                    if not recursive or path_object.name in {".git", "__pycache__"}:
                         continue
                     yield from self.modules_from_paths(list(path_object.iterdir()))
                 elif path_object.suffix == ".py":
@@ -248,8 +264,9 @@ class RobocopImporter:
             except ImportError:
                 pass
 
-    def get_imported_rules(self):
-        for module in self.modules_from_paths([self.internal_checkers_dir]):
+    @staticmethod
+    def get_imported_rules(rule_modules):
+        for module in rule_modules:
             module_name = module.__name__.split(".")[-1]
             for rule in getattr(module, "rules", {}).values():
                 yield module_name, rule
@@ -259,11 +276,19 @@ class RobocopImporter:
         module_rules = getattr(module, "rules", {})
         if not isinstance(module_rules, dict):
             return {}
-        return {rule.name: rule for rule in getattr(module, "rules", {}).values()}
-
-    def get_checkers_from_module(self, module) -> List:
+        rules = {}
+        for rule_id, rule in module_rules.items():
+            if rule_id != rule.rule_id:
+                raise ValueError(
+                    f"Rule id in rules dictionary does not match defined Rule id. {rule_id} != {rule.rule_id}"
+                )
+            rules[rule.name] = rule
+        return rules
+
+    def get_checkers_from_module(self, module, is_community: bool) -> List:
         classes = inspect.getmembers(module, inspect.isclass)
         checkers = [checker for checker in classes if is_checker(checker)]
+        category_id = getattr(module, "RULE_CATEGORY_ID", None)
         module_rules = self.get_rules_from_module(module)
         checker_instances = []
         for checker in checkers:
@@ -276,7 +301,10 @@ class RobocopImporter:
                         # empty rules are silently ignored when rules and checkers are imported separately
                         raise RuleReportsNotFoundError(reported_rule, checker_instance) from None
                     continue
-                checker_instance.rules[reported_rule] = module_rules[reported_rule]
+                rule = module_rules[reported_rule]
+                rule.community_rule = is_community
+                rule.category_id = category_id
+                checker_instance.rules[reported_rule] = rule
             if valid_checker:
                 checker_instances.append(checker_instance)
         return checker_instances
@@ -288,7 +316,15 @@ def init(linter):
         linter.register_checker(checker)
 
 
-def get_rules():
+def get_builtin_rules():
     """Get only rules definitions for documentation generation."""
     robocop_importer = RobocopImporter()
-    yield from robocop_importer.get_imported_rules()
+    rule_modules = robocop_importer.get_internal_modules()
+    yield from robocop_importer.get_imported_rules(rule_modules)
+
+
+def get_community_rules():
+    """Get only community rules definitions for documentation generation."""
+    robocop_importer = RobocopImporter()
+    rule_modules = robocop_importer.get_community_modules()
+    yield from robocop_importer.get_imported_rules(rule_modules)
diff --git a/robocop/checkers/comments.py b/robocop/checkers/comments.py
index 737b55a..ee7d085 100644
--- a/robocop/checkers/comments.py
+++ b/robocop/checkers/comments.py
@@ -20,6 +20,8 @@ def regex(value):
         raise ValueError(f"Regex error: {regex_err}")
 
 
+RULE_CATEGORY_ID = "07"
+
 rules = {
     "0701": Rule(
         RuleParam(
diff --git a/robocop/checkers/community_rules/__init__.py b/robocop/checkers/community_rules/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/robocop/checkers/community_rules/keywords.py b/robocop/checkers/community_rules/keywords.py
new file mode 100644
index 0000000..965a849
--- /dev/null
+++ b/robocop/checkers/community_rules/keywords.py
@@ -0,0 +1,91 @@
+from robot.api import Token
+from robot.utils.robottime import timestr_to_secs
+
+from robocop.checkers import VisitorChecker
+from robocop.rules import Rule, RuleParam, RuleSeverity
+from robocop.utils.misc import normalize_robot_name
+
+RULE_CATEGORY_ID = "00"
+
+rules = {
+    "10001": Rule(
+        RuleParam(
+            name="max_time", default=0, converter=timestr_to_secs, desc="Maximum amount of time allowed in Sleep"
+        ),
+        rule_id="10001",
+        name="sleep-keyword-used",
+        msg="Sleep keyword with '{{ duration_time }}' sleep time found",
+        severity=RuleSeverity.WARNING,
+        added_in_version="5.0.0",
+        enabled=False,
+        docs="""
+        Avoid using Sleep keyword in favour of polling.
+        
+        For example::
+        
+            *** Keywords ***
+            Add To Cart
+                [Arguments]    ${item_name}
+                Sleep    30s  # wait for page to load
+                Element Should Be Visible    ${MAIN_HEADER}
+                Click Element    //div[@name='${item_name}']/div[@id='add_to_cart']
+        
+        Can be rewritten to::
+        
+            *** Keywords ***
+            Add To Cart
+                [Arguments]    ${item_name}
+                Wait Until Element Is Visible    ${MAIN_HEADER}
+                Click Element    //div[@name='${item_name}']/div[@id='add_to_cart']
+        
+        It is also possible to report only if ``Sleep`` exceeds given time limit using ``max_time`` parameter::
+        
+            robocop -c sleep-keyword-used:max_time:1min .
+
+        """,
+    )
+}
+
+
+class SleepKeywordUsedChecker(VisitorChecker):
+    """
+    Find and report use of the Sleep keyword in tests and keywords.
+
+    If max_time is configured, it can be used to only report Sleep with time higher than configured value.
+
+    Sleep in Run Keyword variants and with BDD are ignored.
+    """
+
+    reports = ("sleep-keyword-used",)
+
+    def visit_KeywordCall(self, node):  # noqa
+        if not node.keyword:  # Keyword name can be empty if the syntax is invalid
+            return
+        # Robot Framework ignores case, underscores and whitespace when searching for keywords
+        # It will match sleep, Sleep, BuiltIn.Sleep or S_leep. That's why we need to normalize name first
+        normalized_name = normalize_robot_name(node.keyword, remove_prefix="builtin.")
+        if normalized_name != "sleep":
+            return
+        # retrieve sleep time: get first argument-like token from keyword node. Returns None if token does not exist
+        time_token = node.get_token(Token.ARGUMENT)
+        allowed_time = self.param("sleep-keyword-used", "max_time")
+        if allowed_time:
+            if not time_token:  # Sleep without time
+                return
+            try:
+                time_from_sleep = timestr_to_secs(time_token.value)
+            except ValueError:
+                # ignore invalid or not recognized time string
+                return
+            if allowed_time >= time_from_sleep:  # if Sleep time is less than allowed maximum, we can ignore issue
+                return
+        # node can be multiline, ie Sleep ...  1 min -> report either just Sleep, or multi-line report
+        duration_time = time_token.value if time_token else ""
+        name_token = node.get_token(Token.KEYWORD)
+        self.report(
+            "sleep-keyword-used",
+            duration_time=duration_time,
+            node=name_token,
+            col=name_token.col_offset + 1,
+            end_col=name_token.end_col_offset + 1,
+        )
diff --git a/robocop/checkers/documentation.py b/robocop/checkers/documentation.py
index 462471b..a85f602 100644
--- a/robocop/checkers/documentation.py
+++ b/robocop/checkers/documentation.py
@@ -10,6 +10,8 @@ from robocop.checkers import VisitorChecker
 from robocop.rules import Rule, RuleParam, RuleSeverity
 from robocop.utils.misc import str2bool
 
+RULE_CATEGORY_ID = "02"
+
 rules = {
     "0201": Rule(
         rule_id="0201",
diff --git a/robocop/checkers/duplications.py b/robocop/checkers/duplications.py
index cff873d..934c223 100644
--- a/robocop/checkers/duplications.py
+++ b/robocop/checkers/duplications.py
@@ -32,6 +32,8 @@ def configure_sections_order(value):
     return sections_order
 
 
+RULE_CATEGORY_ID = "08"
+
 rules = {
     "0801": Rule(
         rule_id="0801",
diff --git a/robocop/checkers/errors.py b/robocop/checkers/errors.py
index 419f663..c3f8ca2 100644
--- a/robocop/checkers/errors.py
+++ b/robocop/checkers/errors.py
@@ -14,6 +14,8 @@ from robocop.checkers import VisitorChecker
 from robocop.rules import Rule, RuleSeverity
 from robocop.utils import ROBOT_VERSION, find_robot_vars
 
+RULE_CATEGORY_ID = "04"
+
 rules = {
     "0401": Rule(
         rule_id="0401",
diff --git a/robocop/checkers/lengths.py b/robocop/checkers/lengths.py
index 12cc9d7..eda6743 100644
--- a/robocop/checkers/lengths.py
+++ b/robocop/checkers/lengths.py
@@ -24,6 +24,8 @@ from robocop.checkers import RawFileChecker, VisitorChecker
 from robocop.rules import Rule, RuleParam, RuleSeverity, SeverityThreshold
 from robocop.utils import get_section_name, normalize_robot_name, pattern_type, str2bool
 
+RULE_CATEGORY_ID = "05"
+
 rules = {
     "0501": Rule(
         RuleParam(name="max_len", default=40, converter=int, desc="number of lines allowed in a keyword"),
diff --git a/robocop/checkers/misc.py b/robocop/checkers/misc.py
index dae7015..2504103 100644
--- a/robocop/checkers/misc.py
+++ b/robocop/checkers/misc.py
@@ -37,6 +37,8 @@ from robocop.utils import (
 from robocop.utils.misc import find_escaped_variables
 from robocop.utils.variable_matcher import VariableMatches
 
+RULE_CATEGORY_ID = "09"
+
 rules = {
     "0901": Rule(
         rule_id="0901",
diff --git a/robocop/checkers/naming.py b/robocop/checkers/naming.py
index e3c04d5..44ab586 100644
--- a/robocop/checkers/naming.py
+++ b/robocop/checkers/naming.py
@@ -29,6 +29,8 @@ from robocop.utils.misc import remove_nested_variables
 from robocop.utils.run_keywords import iterate_keyword_names
 from robocop.utils.variable_matcher import VariableMatches
 
+RULE_CATEGORY_ID = "03"
+
 rules = {
     "0301": Rule(
         RuleParam(
diff --git a/robocop/checkers/spacing.py b/robocop/checkers/spacing.py
index 68ff5f1..7aef97d 100644
--- a/robocop/checkers/spacing.py
+++ b/robocop/checkers/spacing.py
@@ -21,6 +21,8 @@ from robocop.checkers import RawFileChecker, VisitorChecker
 from robocop.rules import Rule, RuleParam, RuleSeverity, SeverityThreshold
 from robocop.utils import get_errors, get_section_name, str2bool, token_col
 
+RULE_CATEGORY_ID = "10"
+
 rules = {
     "1001": Rule(
         rule_id="1001",
diff --git a/robocop/checkers/tags.py b/robocop/checkers/tags.py
index ef28309..9b5be70 100644
--- a/robocop/checkers/tags.py
+++ b/robocop/checkers/tags.py
@@ -8,6 +8,8 @@ from robot.api import Token
 from robocop.checkers import VisitorChecker
 from robocop.rules import Rule, RuleSeverity
 
+RULE_CATEGORY_ID = "06"
+
 rules = {
     "0601": Rule(
         rule_id="0601",
diff --git a/robocop/config.py b/robocop/config.py
index 8bd02fa..2e4eddb 100644
--- a/robocop/config.py
+++ b/robocop/config.py
@@ -18,7 +18,7 @@ from robocop.exceptions import (
     TomlFileNotFoundError,
 )
 from robocop.files import DEFAULT_EXCLUDES, find_file_in_project_root, find_project_root
-from robocop.rules import RuleSeverity
+from robocop.rules import RuleFilter, RuleSeverity
 from robocop.utils import RecommendationFinder
 from robocop.version import __version__
 
@@ -58,7 +58,7 @@ class SetRuleThreshold(argparse.Action):
 
 class SetListOption(argparse.Action):
     def __call__(self, parser, namespace, values, option_string=None):
-        pattern = values if values else "*"
+        pattern = values if values else RuleFilter.EMPTY_PATTERN
         if "*" in pattern:
             pattern = translate_pattern(pattern)
         setattr(namespace, self.dest, pattern)
diff --git a/robocop/reports/file_stats_report.py b/robocop/reports/file_stats_report.py
index 48f9bb1..b1fa5cd 100644
--- a/robocop/reports/file_stats_report.py
+++ b/robocop/reports/file_stats_report.py
@@ -2,7 +2,7 @@ from typing import Dict
 
 import robocop.reports
 from robocop.rules import Message
-from robocop.utils.misc import get_string_diff
+from robocop.utils.misc import get_plural_form, get_string_diff
 
 
 class FileStatsReport(robocop.reports.ComparableReport):
@@ -29,17 +29,13 @@ class FileStatsReport(robocop.reports.ComparableReport):
     def persist_result(self):
         return {"files_count": self.files_count, "files_with_issues": len(self.files_with_issues)}
 
-    @staticmethod
-    def get_plural(count):
-        return "s" if count != 1 else ""
-
     def get_report(self, prev_results: Dict) -> str:
         if self.compare_runs and prev_results:
             return self.get_report_with_compare(prev_results)
         return self.get_report_without_compare()
 
     def get_report_with_compare(self, prev_results: Dict) -> str:
-        plural_files = self.get_plural(self.files_count)
+        plural_files = get_plural_form(self.files_count)
         prev_files_count = prev_results["files_count"]
         prev_files_with_issues = prev_results["files_with_issues"]
         if not self.files_count:
@@ -57,7 +53,7 @@ class FileStatsReport(robocop.reports.ComparableReport):
                     f"{processed_files_summary} but no issues were found. "
                     f"Previously there were {prev_files_with_issues} files with issues."
                 )
-        plural_files_with_issues = self.get_plural(len(self.files_with_issues))
+        plural_files_with_issues = get_plural_form(len(self.files_with_issues))
         prev_count = f" ({get_string_diff(prev_files_with_issues, len(self.files_with_issues))})"
         return (
             f"{processed_files_summary} from which {len(self.files_with_issues)}{prev_count} "
@@ -67,11 +63,11 @@ class FileStatsReport(robocop.reports.ComparableReport):
     def get_report_without_compare(self) -> str:
         if not self.files_count:
             return "\nNo files were processed."
-        plural_files = self.get_plural(self.files_count)
+        plural_files = get_plural_form(self.files_count)
         processed_files_summary = f"\nProcessed {self.files_count} file{plural_files}"
         if not self.files_with_issues:
             return f"{processed_files_summary} but no issues were found."
-        plural_files_with_issues = self.get_plural(len(self.files_with_issues))
+        plural_files_with_issues = get_plural_form(len(self.files_with_issues))
         return (
             f"{processed_files_summary} from which {len(self.files_with_issues)} "
             f"file{plural_files_with_issues} contained issues."
diff --git a/robocop/reports/rules_by_severity_report.py b/robocop/reports/rules_by_severity_report.py
index 1943d7e..1273c69 100644
--- a/robocop/reports/rules_by_severity_report.py
+++ b/robocop/reports/rules_by_severity_report.py
@@ -2,7 +2,7 @@ from collections import defaultdict
 
 import robocop.reports
 from robocop.rules import Message, RuleSeverity
-from robocop.utils.misc import get_string_diff
+from robocop.utils.misc import get_plural_form, get_string_diff
 
 
 class RulesBySeverityReport(robocop.reports.ComparableReport):
@@ -33,10 +33,6 @@ class RulesBySeverityReport(robocop.reports.ComparableReport):
             "info": self.severity_counter[RuleSeverity.INFO],
         }
 
-    @staticmethod
-    def get_plural(count):
-        return "s" if count != 1 else ""
-
     def get_report(self, prev_results) -> str:
         if self.compare_runs and prev_results:
             return self.get_report_with_compare(prev_results)
@@ -46,10 +42,10 @@ class RulesBySeverityReport(robocop.reports.ComparableReport):
         issues_count = sum(self.severity_counter.values())
         if not issues_count:
             return "\nFound 0 issues."
-        report = f"\nFound {issues_count} issue{self.get_plural(issues_count)}: "
+        report = f"\nFound {issues_count} issue{get_plural_form(issues_count)}: "
         warning_types = []
         for severity, count in self.severity_counter.items():
-            warning_types.append(f"{count} {severity.name}{self.get_plural(count)}")
+            warning_types.append(f"{count} {severity.name}{get_plural_form(count)}")
         report += ", ".join(warning_types)
         report += "."
         return report
@@ -61,11 +57,11 @@ class RulesBySeverityReport(robocop.reports.ComparableReport):
             diff = get_string_diff(prev_issues, issues_count)
             return f"\nFound 0 ({diff}) issues."
         diff = f" ({get_string_diff(prev_results['all_issues'], issues_count)})"
-        report = f"\nFound {issues_count}{diff} issue{self.get_plural(issues_count)}: "
+        report = f"\nFound {issues_count}{diff} issue{get_plural_form(issues_count)}: "
         warning_types = []
         for severity, count in self.severity_counter.items():
             diff = f" ({get_string_diff(prev_results[severity.name.lower()], count)})"
-            warning_types.append(f"{count}{diff} {severity.name}{self.get_plural(count)}")
+            warning_types.append(f"{count}{diff} {severity.name}{get_plural_form(count)}")
         report += ", ".join(warning_types)
         report += "."
         return report
diff --git a/robocop/rules.py b/robocop/rules.py
index 0b660a5..1a50ab0 100644
--- a/robocop/rules.py
+++ b/robocop/rules.py
@@ -96,6 +96,31 @@ class RuleSeverity(Enum):
         return {"I": 3, "W": 2, "E": 1}.get(self.value, 4)
 
 
+class RuleFilter:
+    EMPTY_PATTERN = "EMPTY_PATTERN"
+    ALL_PATTERN = "ALL"
+    ENABLED_PATTERN = "ENABLED"
+    DISABLED_PATTERN = "DISABLED"
+    COMMUNITY_PATTERN = "COMMUNITY"
+
+    def get_filtered_rules(self, rules, pattern):
+        if pattern in (self.ENABLED_PATTERN, self.DISABLED_PATTERN):
+            rule_by_id = {
+                rule.rule_id: rule
+                for rule in rules.values()
+                if pattern.lower() in rule.get_enabled_status_desc() and not rule.community_rule
+            }
+        elif pattern == self.COMMUNITY_PATTERN:
+            rule_by_id = {rule.rule_id: rule for rule in rules.values() if rule.community_rule}
+        elif pattern == self.EMPTY_PATTERN:
+            rule_by_id = {rule.rule_id: rule for rule in rules.values() if not rule.community_rule}
+        elif pattern == self.ALL_PATTERN:
+            rule_by_id = {rule.rule_id: rule for rule in rules.values()}
+        else:
+            rule_by_id = {rule.rule_id: rule for rule in rules.values() if rule.matches_pattern(pattern)}
+        return sorted(rule_by_id.values(), key=lambda x: int(x.rule_id))
+
+
 class RuleParam:
     """
     Parameter of the Rule.
@@ -298,6 +323,8 @@ class Rule:
         self.supported_version = version if version else "All"
         self.enabled_in_version = self.supported_in_rf_version(version)
         self.added_in_version = added_in_version
+        self.community_rule = False
+        self.category_id = None
 
     @property
     def severity(self):
diff --git a/robocop/run.py b/robocop/run.py
index 6c0d1e1..f5ac06d 100644
--- a/robocop/run.py
+++ b/robocop/run.py
@@ -13,10 +13,10 @@ from robocop import checkers, reports
 from robocop.config import Config
 from robocop.files import get_files
 from robocop.reports import is_report_comparable, load_reports_result_from_cache, save_reports_result_to_cache
-from robocop.rules import Message
+from robocop.rules import Message, RuleFilter
 from robocop.utils import DisablersFinder, FileType, FileTypeChecker, RecommendationFinder, is_suite_templated
 from robocop.utils.file_types import check_model_type, get_resource_with_lang
-from robocop.utils.misc import ROBOCOP_RULES_URL
+from robocop.utils.misc import ROBOCOP_RULES_URL, get_plural_form
 
 
 class Robocop:
@@ -179,13 +179,7 @@ class Robocop:
                 "\n    E / error\n    W / warning\n    I / info\n"
             )
         pattern = self.config.list if self.config.list else self.config.list_configurables
-        if pattern in ("ENABLED", "DISABLED"):
-            rule_by_id = {
-                rule.rule_id: rule for rule in self.rules.values() if pattern.lower() in rule.get_enabled_status_desc()
-            }
-        else:
-            rule_by_id = {rule.rule_id: rule for rule in self.rules.values() if rule.matches_pattern(pattern)}
-        rule_by_id = sorted(rule_by_id.values(), key=lambda x: x.rule_id)
+        rule_by_id = RuleFilter().get_filtered_rules(self.rules, pattern)
         severity_counter = Counter({"E": 0, "W": 0, "I": 0})
         for rule in rule_by_id:
             if self.config.list:
@@ -197,12 +191,12 @@ class Robocop:
                     print(f"{rule}\n" f"    {params}")
                     severity_counter[rule.severity.value] += 1
         configurable_rules_sum = sum(severity_counter.values())
-        plural = "" if configurable_rules_sum == 1 else "s"
+        plural = get_plural_form(configurable_rules_sum)
         print(
             f"\nAltogether {configurable_rules_sum} rule{plural} with following severity:\n"
-            f"    {severity_counter['E']} error rule{'' if severity_counter['E'] == 1 else 's'},\n"
-            f"    {severity_counter['W']} warning rule{'' if severity_counter['W'] == 1 else 's'},\n"
-            f"    {severity_counter['I']} info rule{'' if severity_counter['I'] == 1 else 's'}.\n"
+            f"    {severity_counter['E']} error rule{get_plural_form(severity_counter['E'])},\n"
+            f"    {severity_counter['W']} warning rule{get_plural_form(severity_counter['W'])},\n"
+            f"    {severity_counter['I']} info rule{get_plural_form(severity_counter['I'])}.\n"
         )
         print(f"Visit {ROBOCOP_RULES_URL.format(version='stable')} page for detailed documentation.")
         sys.exit()
diff --git a/robocop/utils/misc.py b/robocop/utils/misc.py
index 0efe2be..9d43176 100644
--- a/robocop/utils/misc.py
+++ b/robocop/utils/misc.py
@@ -337,3 +337,7 @@ def get_string_diff(prev_count, count):
     diff = count - prev_count
     prefix = "+" if diff >= 0 else ""
     return prefix + str(diff)
+
+
+def get_plural_form(container):
+    return "" if container == 1 else "s"


Test patch:
diff --git a/tests/atest/rules/community_rules/keywords/sleep_keyword_used/__init__.py b/tests/atest/rules/community_rules/keywords/sleep_keyword_used/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/atest/rules/community_rules/keywords/sleep_keyword_used/expected_output.txt b/tests/atest/rules/community_rules/keywords/sleep_keyword_used/expected_output.txt
new file mode 100644
index 0000000..c32857c
--- /dev/null
+++ b/tests/atest/rules/community_rules/keywords/sleep_keyword_used/expected_output.txt
@@ -0,0 +1,4 @@
+test.robot:8:5 [W] 10001 Sleep keyword with '10s' sleep time found
+test.robot:11:9 [W] 10001 Sleep keyword with '${var}' sleep time found
+test.robot:14:9 [W] 10001 Sleep keyword with '1 min' sleep time found
+test.robot:17:5 [W] 10001 Sleep keyword with '' sleep time found
\ No newline at end of file
diff --git a/tests/atest/rules/community_rules/keywords/sleep_keyword_used/expected_output_1min.txt b/tests/atest/rules/community_rules/keywords/sleep_keyword_used/expected_output_1min.txt
new file mode 100644
index 0000000..e69de29
diff --git a/tests/atest/rules/community_rules/keywords/sleep_keyword_used/expected_output_1s.txt b/tests/atest/rules/community_rules/keywords/sleep_keyword_used/expected_output_1s.txt
new file mode 100644
index 0000000..0bf27bd
--- /dev/null
+++ b/tests/atest/rules/community_rules/keywords/sleep_keyword_used/expected_output_1s.txt
@@ -0,0 +1,2 @@
+test.robot:8:5 [W] 10001 Sleep keyword with '10s' sleep time found
+test.robot:14:9 [W] 10001 Sleep keyword with '1 min' sleep time found
\ No newline at end of file
diff --git a/tests/atest/rules/community_rules/keywords/sleep_keyword_used/test.robot b/tests/atest/rules/community_rules/keywords/sleep_keyword_used/test.robot
new file mode 100644
index 0000000..b2569df
--- /dev/null
+++ b/tests/atest/rules/community_rules/keywords/sleep_keyword_used/test.robot
@@ -0,0 +1,17 @@
+*** Settings ***
+# Use Sleep in Suite stuff later
+
+
+*** Test Cases ***
+Test with sleeps
+    Log    Let's sleep for some time.
+    Sleep    10s
+    Log    It may not be enough, let's do it in loop.
+    FOR    ${var}    IN RANGE    1    10
+        Sleep    ${var}  # non-parsable time strings are ignored
+    END
+    IF    ${var} == 10
+        Sleep
+        ...    1 min
+    END
+    Sleep
diff --git a/tests/atest/rules/community_rules/keywords/sleep_keyword_used/test_rule.py b/tests/atest/rules/community_rules/keywords/sleep_keyword_used/test_rule.py
new file mode 100644
index 0000000..6efb17a
--- /dev/null
+++ b/tests/atest/rules/community_rules/keywords/sleep_keyword_used/test_rule.py
@@ -0,0 +1,18 @@
+from tests.atest.utils import RuleAcceptance
+
+
+class TestRuleAcceptance(RuleAcceptance):
+    def test_rule(self):
+        self.check_rule(src_files=["test.robot"], expected_file="expected_output.txt")
+
+    def test_rule_1s(self):
+        self.check_rule(
+            config="-c sleep-keyword-used:max_time:1s", src_files=["test.robot"], expected_file="expected_output_1s.txt"
+        )
+
+    def test_rule_1min(self):
+        self.check_rule(
+            config="-c sleep-keyword-used:max_time:1min",
+            src_files=["test.robot"],
+            expected_file="expected_output_1min.txt",
+        )
diff --git a/tests/atest/rules/naming/mixed_task_test_settings/__init__.py b/tests/atest/rules/naming/mixed_task_test_settings/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/atest/rules/naming/mixed-task-test-settings/expected_output_pre_rf6.txt b/tests/atest/rules/naming/mixed_task_test_settings/expected_output_pre_rf6.txt
similarity index 100%
rename from tests/atest/rules/naming/mixed-task-test-settings/expected_output_pre_rf6.txt
rename to tests/atest/rules/naming/mixed_task_test_settings/expected_output_pre_rf6.txt
diff --git a/tests/atest/rules/naming/mixed-task-test-settings/expected_output_rf6.txt b/tests/atest/rules/naming/mixed_task_test_settings/expected_output_rf6.txt
similarity index 100%
rename from tests/atest/rules/naming/mixed-task-test-settings/expected_output_rf6.txt
rename to tests/atest/rules/naming/mixed_task_test_settings/expected_output_rf6.txt
diff --git a/tests/atest/rules/naming/mixed-task-test-settings/golden_task.robot b/tests/atest/rules/naming/mixed_task_test_settings/golden_task.robot
similarity index 100%
rename from tests/atest/rules/naming/mixed-task-test-settings/golden_task.robot
rename to tests/atest/rules/naming/mixed_task_test_settings/golden_task.robot
diff --git a/tests/atest/rules/naming/mixed-task-test-settings/golden_test.robot b/tests/atest/rules/naming/mixed_task_test_settings/golden_test.robot
similarity index 100%
rename from tests/atest/rules/naming/mixed-task-test-settings/golden_test.robot
rename to tests/atest/rules/naming/mixed_task_test_settings/golden_test.robot
diff --git a/tests/atest/rules/naming/mixed-task-test-settings/mixed_tasks.robot b/tests/atest/rules/naming/mixed_task_test_settings/mixed_tasks.robot
similarity index 100%
rename from tests/atest/rules/naming/mixed-task-test-settings/mixed_tasks.robot
rename to tests/atest/rules/naming/mixed_task_test_settings/mixed_tasks.robot
diff --git a/tests/atest/rules/naming/mixed-task-test-settings/mixed_tests.robot b/tests/atest/rules/naming/mixed_task_test_settings/mixed_tests.robot
similarity index 100%
rename from tests/atest/rules/naming/mixed-task-test-settings/mixed_tests.robot
rename to tests/atest/rules/naming/mixed_task_test_settings/mixed_tests.robot
diff --git a/tests/atest/rules/naming/mixed-task-test-settings/tasks_with_test_settings.robot b/tests/atest/rules/naming/mixed_task_test_settings/tasks_with_test_settings.robot
similarity index 100%
rename from tests/atest/rules/naming/mixed-task-test-settings/tasks_with_test_settings.robot
rename to tests/atest/rules/naming/mixed_task_test_settings/tasks_with_test_settings.robot
diff --git a/tests/atest/rules/naming/mixed-task-test-settings/test_rule.py b/tests/atest/rules/naming/mixed_task_test_settings/test_rule.py
similarity index 100%
rename from tests/atest/rules/naming/mixed-task-test-settings/test_rule.py
rename to tests/atest/rules/naming/mixed_task_test_settings/test_rule.py
diff --git a/tests/atest/rules/naming/mixed-task-test-settings/tests_with_task_settings.robot b/tests/atest/rules/naming/mixed_task_test_settings/tests_with_task_settings.robot
similarity index 100%
rename from tests/atest/rules/naming/mixed-task-test-settings/tests_with_task_settings.robot
rename to tests/atest/rules/naming/mixed_task_test_settings/tests_with_task_settings.robot
diff --git a/tests/e2e/test_e2e.py b/tests/e2e/test_e2e.py
index 8d2eac1..e9ce511 100644
--- a/tests/e2e/test_e2e.py
+++ b/tests/e2e/test_e2e.py
@@ -1,5 +1,6 @@
 """ General E2E tests to catch any general issue in Robocop """
 
+import re
 import sys
 from pathlib import Path
 from unittest import mock
@@ -368,3 +369,59 @@ class TestTranslatedRobot:
         robocop_instance.reload_config()
         without_version = {rule.name for rule in robocop_instance.rules.values() if not rule.added_in_version}
         assert without_version == set()
+
+    def test_all_builtin_rules_should_be_enabled_by_default(self, robocop_instance):
+        robocop_instance.reload_config()
+        builtin_rules = [rule for rule in robocop_instance.rules.values() if not rule.community_rule]
+        assert builtin_rules != []
+        disabled_builtin = {rule.name for rule in builtin_rules if not rule.enabled and rule.enabled_in_version}
+        assert disabled_builtin == set()
+
+    def test_all_community_rules_should_be_disabled_by_default(self, robocop_instance):
+        robocop_instance.reload_config()
+        community_rules = [rule for rule in robocop_instance.rules.values() if rule.community_rule]
+        assert community_rules != []
+        enabled_community = {rule.name for rule in community_rules if rule.enabled}
+        assert enabled_community == set()
+
+    def test_builtin_rules_id_match_pattern(self, robocop_instance):
+        robocop_instance.reload_config()
+        builtin_rules_id = {rule.rule_id for rule in robocop_instance.rules.values() if not rule.community_rule}
+        builtin_rule_id_pattern = re.compile("[0-9]{4}")
+        invalid_ids = {rule_id for rule_id in builtin_rules_id if not builtin_rule_id_pattern.fullmatch(rule_id)}
+        assert invalid_ids == set()
+
+    def test_community_rules_id_match_pattern(self, robocop_instance):
+        robocop_instance.reload_config()
+        community_rules_id = {rule.rule_id for rule in robocop_instance.rules.values() if rule.community_rule}
+        community_rule_id_pattern = re.compile("1[0-9]{4}")
+        invalid_ids = {rule_id for rule_id in community_rules_id if not community_rule_id_pattern.fullmatch(rule_id)}
+        assert invalid_ids == set()
+
+    def test_builtin_rules_have_matching_category_id(self, robocop_instance):
+        robocop_instance.reload_config()
+        builtin_rules = [rule for rule in robocop_instance.rules.values() if not rule.community_rule]
+        assert builtin_rules != []
+        errors = []
+        for rule in builtin_rules:
+            if rule.category_id is None:
+                raise ValueError(f"Missing category_id for rule {rule.rule_id} {rule.name}")
+            if not rule.rule_id.startswith(rule.category_id):
+                errors.append(
+                    f"Rule {rule.rule_id} {rule.name} does not start with correct category id ({rule.category_id})"
+                )
+        assert errors == []
+
+    def test_community_rules_have_matching_category_id(self, robocop_instance):
+        robocop_instance.reload_config()
+        community_rules = [rule for rule in robocop_instance.rules.values() if rule.community_rule]
+        assert community_rules != []
+        errors = []
+        for rule in community_rules:
+            if rule.category_id is None:
+                raise ValueError(f"Missing category_id for rule {rule.rule_id} {rule.name}")
+            if not rule.rule_id.startswith("1" + rule.category_id):
+                errors.append(
+                    f"Rule {rule.rule_id} {rule.name} does not start with correct category id ({rule.category_id})"
+                )
+        assert errors == []
diff --git a/tests/utest/test_listing_rules.py b/tests/utest/test_listing_rules.py
index f026d44..6c920e6 100644
--- a/tests/utest/test_listing_rules.py
+++ b/tests/utest/test_listing_rules.py
@@ -4,7 +4,7 @@ import pytest
 
 import robocop.config
 from robocop.checkers import VisitorChecker
-from robocop.rules import Rule, RuleParam, RuleSeverity
+from robocop.rules import Rule, RuleFilter, RuleParam, RuleSeverity
 from robocop.utils import ROBOT_VERSION
 
 TEST_DATA = Path(__file__).parent.parent / "test_data" / "ext_rules"
@@ -70,6 +70,15 @@ def msg_0101():
     return {"0101": Rule(rule_id="0101", name="some-message", msg="Some description", severity=RuleSeverity.WARNING)}
 
 
+@pytest.fixture
+def community_rule():
+    rules = {
+        "19999": Rule(rule_id="19999", name="community-rule", msg="Some description", severity=RuleSeverity.WARNING)
+    }
+    rules["19999"].community_rule = True
+    return rules
+
+
 @pytest.fixture
 def msg_0102_0204():
     return {
@@ -102,9 +111,10 @@ def init_empty_checker(robocop_instance_pre_load, rule, exclude=False, **kwargs)
 
 
 class TestListingRules:
-    def test_list_rule(self, robocop_pre_load, msg_0101, capsys):
-        robocop_pre_load.config.list = robocop.config.translate_pattern("*")
+    def test_list_rule(self, robocop_pre_load, msg_0101, community_rule, capsys):
+        robocop_pre_load.config.list = RuleFilter.EMPTY_PATTERN
         init_empty_checker(robocop_pre_load, msg_0101)
+        init_empty_checker(robocop_pre_load, community_rule)
         with pytest.raises(SystemExit):
             robocop_pre_load.list_checkers()
         out, _ = capsys.readouterr()
@@ -209,6 +219,24 @@ class TestListingRules:
         assert all(msg in out for msg in exp_msg)
         assert not_exp_msg not in out
 
+    @pytest.mark.parametrize("pattern", [robocop.config.translate_pattern("*"), "ALL"])
+    def test_list_rule_filtered_and_community(self, pattern, robocop_pre_load, msg_0101, community_rule, capsys):
+        robocop_pre_load.config.list = pattern
+        init_empty_checker(robocop_pre_load, msg_0101)
+        init_empty_checker(robocop_pre_load, community_rule)
+        with pytest.raises(SystemExit):
+            robocop_pre_load.list_checkers()
+        out, _ = capsys.readouterr()
+        assert (
+            out == "Rule - 0101 [W]: some-message: Some description (enabled)\n"
+            "Rule - 19999 [W]: community-rule: Some description (enabled)\n\n"
+            "Altogether 2 rules with following severity:\n"
+            "    0 error rules,\n"
+            "    2 warning rules,\n"
+            "    0 info rules.\n\n"
+            "Visit https://robocop.readthedocs.io/en/stable/rules_list.html page for detailed documentation.\n"
+        )
+
     def test_list_configurables(self, robocop_pre_load, msg_0101_config_meta, capsys):
         robocop_pre_load.config.list_configurables = robocop.config.translate_pattern("*")
         init_empty_checker(robocop_pre_load, msg_0101_config_meta, conf_param=1001)