Repository: sqlfluff/sqlfluff

Issue:
Reorganize rule names
### Search before asking

- [X] I searched the [issues](https://github.com/sqlfluff/sqlfluff/issues) and found no similar issues.


### Description

Let's think about a nice way to rename rules/codes, so that we don't have to look up what the rule code is everytime we think about them 😵 

Here's an example of what `pylint` messages look like:

```sh
$ pylint etl.py 
************* Module etl
etl.py:20:61: C0303: Trailing whitespace (trailing-whitespace)
etl.py:1:0: C0114: Missing module docstring (missing-module-docstring)
etl.py:7:0: W0622: Redefining built-in 'compile' (redefined-builtin)
etl.py:7:0: E0401: Unable to import 'parse' (import-error)

------------------------------------------------------------------
Your code has been rated at 5.56/10 (previous run: 9.95/10, -4.40)
```

You can see that the have "common sense" names as the end of each linting message.

Here's an example of what the current output looks like for `sqlfluff lint`:

```sh
$ sqlfluff lint models/output/
=== [dbt templater] Sorting Nodes...                                                       
=== [dbt templater] Compiling dbt project...                                               
=== [dbt templater] Project Compiled.                                                      
== [models/output/redcap_import.sql] FAIL                                                  
L:   6 | P:   5 | L019 | Found leading comma. Expected only trailing.                      
L:   6 | P:   6 | L008 | Commas should be followed by a single whitespace unless
                       | followed by a comment.
L:   9 | P:  40 | L012 | Implicit/explicit aliasing of columns.
L:  17 | P:   6 | L003 | Expected 1 indentations, found 1 [compared to line 16]
```

For the record I think on the whole, our output is better organized/nicer to look at than pylint. But I think it would be greatly improved if we could implement common sense names like this:

```sh
$ sqlfluff lint models/output/
=== [dbt templater] Sorting Nodes...                                                       
=== [dbt templater] Compiling dbt project...                                               
=== [dbt templater] Project Compiled.                                                      
== [models/output/redcap_import.sql] FAIL                                                  
L:   6 | P:   5 | L019 | Found leading comma. Expected only trailing. (wrong-comma-style)                  
L:   6 | P:   6 | L008 | Commas should be followed by a single whitespace unless
                       | followed by a comment. (comma-missing-whitespace)
L:   9 | P:  40 | L012 | Implicit/explicit aliasing of columns. (wrong-column-alias-style)
L:  17 | P:   6 | L003 | Expected 1 indentations, found 1 [compared to line 16] (unmatched-indentation)
```

With this change, I could ideally also disable/enable rules according to their readable names and/or their codes, such as 

```sql
select
  field_1,
field_2, --noqa: unmatched-indentation
from my_table
```

You'll also notice from the above example that pylint codes have different prefixes (`W0622`, `E0401`, etc)

Here's their [definitions](https://github.com/PyCQA/pylint/blob/a57edea63fa7a1edfcb63074f9e2a373ebabb7cd/pylint/constants.py#L34-L41) for those, but basically `E = Error`, `W = Warning`, `R = Refactor`, `C = Convention`

And this brings us to the second part of this issue: what the codes should be. I think we can follow `pylint`'s example, and break up the rules into categories. Here are a few to start: 

`R = readability`
- Operators should follow a standard for being before/after newlines (L007 --> R001)
- Inconsistent capitalisation of keywords (L010 --> R002)

`C = Convention` (AKA Best Practices)
- Implicit/explicit aliasing of table (L011 --> C002)
- Table aliases should be unique within each clause (L020 --> C002)
- Trailing commas within select clause. (L038 --> C002)

`W = Whitespace`
- Indentation not consistent with previous lines (L003 --> W001)
- Operators should be surrounded by a single whitespace (L006 --> W002)

`D = dialect specific`
- SP_ prefix should not be used for user-defined stored procedures in T-SQL. (L056 --> D001)



### Use case

- [ ] Give all the rules "symbolic" names or "short" names
- [ ] Include these in the lint messages
- [ ] Allow these to be disabled in the config by short name and/or rule code
- [ ] Consider if rules should be disabled by default (I'm looking at you `L052`. And yes I'm not going to say what rule that is here in order to prove a point on why this is an important change to make 😜 )
- [ ] Should we more tightly integrate these will rule groups? Should these replace rule groups, and all just become implicit rule groups?

### Dialect

All

### Are you willing to work on and submit a PR to address the issue?

- [X] Yes I am willing to submit a PR!

### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](https://github.com/sqlfluff/sqlfluff/blob/main/CODE_OF_CONDUCT.md)


Discussion:
coveralls: ## Pull Request Test Coverage Report for [Build 4194520495](https://coveralls.io/builds/57005881)

* **18** of **18**   **(100.0%)**  changed or added relevant lines in **3** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage remained the same at **100.0%**

---



|  Totals | [![Coverage Status](https://coveralls.io/builds/57005881/badge)](https://coveralls.io/builds/57005881) |
| :-- | --: |
| Change from base [Build 4153485301](https://coveralls.io/builds/56847763): |  0.0% |
| Covered Lines: | 17179 |
| Relevant Lines: | 17179 |

---
##### 💛  - [Coveralls](https://coveralls.io)

codecov[bot]: # [Codecov](https://codecov.io/gh/sqlfluff/sqlfluff/pull/4399?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sqlfluff) Report
Base: **100.00**% // Head: **100.00**% // No change to project coverage :thumbsup:
> Coverage data is based on head [(`2af1711`)](https://codecov.io/gh/sqlfluff/sqlfluff/pull/4399?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sqlfluff) compared to base [(`684a30e`)](https://codecov.io/gh/sqlfluff/sqlfluff/commit/684a30e45f589f7ece4f4ee7ccbf2e6160140f89?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sqlfluff).
> Patch coverage: 100.00% of modified lines in pull request are covered.

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


```diff
@@            Coverage Diff            @@
##              main     #4399   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          201       201           
  Lines        15365     15368    +3     
=========================================
+ Hits         15365     15368    +3     
```


| [Impacted Files](https://codecov.io/gh/sqlfluff/sqlfluff/pull/4399?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sqlfluff) | Coverage Δ | |
|---|---|---|
| [src/sqlfluff/cli/commands.py](https://codecov.io/gh/sqlfluff/sqlfluff/pull/4399?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sqlfluff#diff-c3JjL3NxbGZsdWZmL2NsaS9jb21tYW5kcy5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/sqlfluff/core/plugin/hookspecs.py](https://codecov.io/gh/sqlfluff/sqlfluff/pull/4399?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sqlfluff#diff-c3JjL3NxbGZsdWZmL2NvcmUvcGx1Z2luL2hvb2tzcGVjcy5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/sqlfluff/core/rules/base.py](https://codecov.io/gh/sqlfluff/sqlfluff/pull/4399?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sqlfluff#diff-c3JjL3NxbGZsdWZmL2NvcmUvcnVsZXMvYmFzZS5weQ==) | `100.00% <100.00%> (ø)` | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sqlfluff). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sqlfluff)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/sqlfluff/sqlfluff/pull/4399?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sqlfluff).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sqlfluff).


Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 6e163d8e3..ef386e404 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -109,9 +109,10 @@ choose one of the dbt environments.)
 
 Windows users should call `.venv\Scripts\activate` rather than `source .venv/bin/activate`.
 
-This virtual environment will already have the package installed in editable mode for you, as well as
-`requirements_dev.txt` and `plugins/sqlfluff-plugin-example`. Additionally if a dbt virtual environment
-was specified, you will also have `dbt-core`, `dbt-postgres`, and `plugins/sqlfluff-templater-dbt` available.
+This virtual environment will already have the package installed in editable
+mode for you, as well as `requirements_dev.txt` and `plugins/sqlfluff-plugin-example`.
+Additionally if a dbt virtual environment was specified, you will also have
+`dbt-core`, `dbt-postgres`, and `plugins/sqlfluff-templater-dbt` available.
 
 ### Wiki
 
diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst
index 206f732b2..4a8eb468b 100644
--- a/docs/source/configuration.rst
+++ b/docs/source/configuration.rst
@@ -181,11 +181,64 @@ For an overview of the most common rule configurations that you may want to
 tweak, see `Default Configuration`_ (and use :ref:`ruleref` to find the
 available alternatives).
 
+.. _ruleselection:
+
 Enabling and Disabling Rules
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-To disable individual rules, set :code:`exclude_rules` in the top level section
-of sqlfluff configuration. The value is a comma separated list of rule ids.
+The decision as to which rules are applied to a given file is applied on a file
+by file basis, by the effective configuration for that file. There are two
+configuration values which you can use to set this:
+
+* :code:`rules`, which explicitly *enables* the specified rules. If this
+  parameter is unset or empty for a file, this implies "no selection" and
+  so "all rules" is taken to be the meaning.
+* :code:`exclude_rules`, which explicitly *disables* the specified rules.
+  This parameter is applied *after* the :code:`rules` parameter so can be
+  used to *subtract* from the otherwise enabled set.
+
+Each of these two configuration values accept a comma separated list of
+*references*. Each of those references can be:
+
+* a rule *code* e.g. :code:`LN01`
+* a rule *name* e.g. :code:`layout.indent`
+* a rule *alias*, which is often a deprecated *code* e.g. :code:`L003`
+* a rule *group* e.g. :code:`layout` or :code:`capitalisation`
+
+These different references can be mixed within a given expression, which
+results in a very powerful syntax for selecting exactly which rules are
+active for a given file.
+
+.. note::
+
+    It's worth mentioning here that the application of :code:`rules` and
+    :code:`exclude_rules`, with *groups*, *aliases* and *names*, in projects
+    with potentially multiple nested configuration files defining different
+    rules for different areas of a project can get very confusing very fast.
+    While this flexibility is intended for users to take advantage of, we do
+    have some recommendations about how to do this is a way that remains
+    manageable.
+
+    When considering configuration inheritance, each of :code:`rules` and
+    :code:`exclude_rules` will totally overwrite any values in parent config
+    files if they are set in a child file. While the subtraction operation
+    between both of them is calculated *"per file"*, there is no combination
+    operation between two definitions of :code:`rules` (just one overwrites
+    the other).
+
+    The effect of this is that we recommend one of two approaches:
+
+    #. Simply only use :code:`rules`. This has the upshot of each area of
+       your project being very explicit in which rules are enabled. When
+       that changes for part of your project you just reset the whole list
+       of applicable rules for that part of the project.
+    #. Set a single :code:`rules` value in your master project config file
+       and then only use :code:`exclude_rules` in sub-configuration files
+       to *turn off* specific rules for parts of the project where those
+       rules are inappropriate. This keeps the simplicity of only having
+       one value which is inherited, but allows slightly easier and simpler
+       rollout of new rules because we manage by exception.
+
 
 For example, to disable the rules :class:`L022 <sqlfluff.core.rules.Rule_L022>`
 and :class:`L027 <sqlfluff.core.rules.Rule_L027>`:
@@ -204,10 +257,6 @@ For example, to enable :class:`L027 <sqlfluff.core.rules.Rule_L027>`:
     [sqlfluff]
     rules = L027
 
-If both :code:`exclude_rules` and :code:`rules` have non-empty value, then the
-excluded rules are removed from the rules list. This allows for example
-enabling common rules on top level but excluding some on subdirectory level.
-
 Rules can also be enabled/disabled by their grouping. Right now, the only
 rule grouping is :code:`core`. This will enable (or disable) a select group
 of rules that have been deemed 'core rules'.
diff --git a/docs/source/rules.rst b/docs/source/rules.rst
index 74f293bde..58cbdb15a 100644
--- a/docs/source/rules.rst
+++ b/docs/source/rules.rst
@@ -7,7 +7,8 @@ Rules Reference
 which work their way through the parsed structure of a query to evaluate
 a particular rule or set of rules. The intent is that the definition of
 each specific rule should be really streamlined and only contain the logic
-for the rule itself, with all the other mechanics abstracted away.
+for the rule itself, with all the other mechanics abstracted away. To
+understand how rules are enabled and disabled see :ref:`ruleselection`.
 
 Core Rules
 ----------
diff --git a/plugins/sqlfluff-plugin-example/README.md b/plugins/sqlfluff-plugin-example/README.md
new file mode 100644
index 000000000..5bc58bf06
--- /dev/null
+++ b/plugins/sqlfluff-plugin-example/README.md
@@ -0,0 +1,7 @@
+# Example rules plugin
+
+This example plugin showcases the ability to setup
+installable rule plugins.
+
+This interface is supported from version `0.4.0` of
+SQLFluff onwards.
diff --git a/plugins/sqlfluff-plugin-example/setup.py b/plugins/sqlfluff-plugin-example/setup.py
index cf78cad76..6d6ba1bc1 100644
--- a/plugins/sqlfluff-plugin-example/setup.py
+++ b/plugins/sqlfluff-plugin-example/setup.py
@@ -1,4 +1,4 @@
-"""Setup file for example plugin."""
+"""Setup file for an example rules plugin."""
 from setuptools import find_packages, setup
 
 # Change these names in your plugin, e.g. company name or plugin purpose.
@@ -6,19 +6,11 @@ PLUGIN_LOGICAL_NAME = "example"
 PLUGIN_ROOT_MODULE = "example"
 
 setup(
-    name="sqlfluff-plugin-{plugin_logical_name}".format(
-        plugin_logical_name=PLUGIN_LOGICAL_NAME
-    ),
+    name=f"sqlfluff-plugin-{PLUGIN_LOGICAL_NAME}",
+    version="1.0.0",
     include_package_data=True,
     package_dir={"": "src"},
     packages=find_packages(where="src"),
     install_requires="sqlfluff>=0.4.0",
-    entry_points={
-        "sqlfluff": [
-            "{plugin_logical_name} = {plugin_root_module}.rules".format(
-                plugin_logical_name=PLUGIN_LOGICAL_NAME,
-                plugin_root_module=PLUGIN_ROOT_MODULE,
-            )
-        ]
-    },
+    entry_points={"sqlfluff": [f"{PLUGIN_LOGICAL_NAME} = {PLUGIN_ROOT_MODULE}.rules"]},
 )
diff --git a/plugins/sqlfluff-plugin-example/src/example/rules.py b/plugins/sqlfluff-plugin-example/src/example/rules.py
index 2499ee4fe..9da0fb386 100644
--- a/plugins/sqlfluff-plugin-example/src/example/rules.py
+++ b/plugins/sqlfluff-plugin-example/src/example/rules.py
@@ -1,4 +1,7 @@
-"""An example of a custom rule implemented through the plugin system."""
+"""An example of a custom rule implemented through the plugin system.
+
+This uses the rules API supported from 0.4.0 onwards.
+"""
 
 from sqlfluff.core.plugin import hookimpl
 from sqlfluff.core.rules import (
diff --git a/src/sqlfluff/cli/commands.py b/src/sqlfluff/cli/commands.py
index 58dd42d85..4da7e6d5a 100644
--- a/src/sqlfluff/cli/commands.py
+++ b/src/sqlfluff/cli/commands.py
@@ -421,7 +421,20 @@ def rules(**kwargs) -> None:
     """Show the current rules in use."""
     c = get_config(**kwargs, dialect="ansi")
     lnt, formatter = get_linter_and_formatter(c)
-    click.echo(formatter.format_rules(lnt), color=c.get("color"))
+    try:
+        click.echo(formatter.format_rules(lnt), color=c.get("color"))
+    # No cover for clause covering poorly formatted rules.
+    # Without creating a poorly formed plugin, these are hard to
+    # test.
+    except (SQLFluffUserError, AssertionError) as err:  # pragma: no cover
+        click.echo(
+            OutputStreamFormatter.colorize_helper(
+                c.get("color"),
+                f"Error loading rules: {str(err)}",
+                color=Color.red,
+            )
+        )
+        sys.exit(EXIT_ERROR)
 
 
 @cli.command()
diff --git a/src/sqlfluff/core/plugin/hookspecs.py b/src/sqlfluff/core/plugin/hookspecs.py
index 98ada240a..e3cc27834 100644
--- a/src/sqlfluff/core/plugin/hookspecs.py
+++ b/src/sqlfluff/core/plugin/hookspecs.py
@@ -15,6 +15,7 @@ class PluginSpec:
     def get_rules(self):
         """Get plugin rules."""
 
+    @hookspec
     @abstractmethod
     def load_default_config(self) -> dict:
         """Loads the default configuration for the plugin."""
diff --git a/src/sqlfluff/core/rules/base.py b/src/sqlfluff/core/rules/base.py
index cca03a132..e98dc233e 100644
--- a/src/sqlfluff/core/rules/base.py
+++ b/src/sqlfluff/core/rules/base.py
@@ -16,6 +16,7 @@ missing.
 
 import bdb
 import copy
+from dataclasses import dataclass
 import fnmatch
 from itertools import chain
 import logging
@@ -30,15 +31,18 @@ from typing import (
     Tuple,
     Union,
     Any,
+    Dict,
+    Type,
+    DefaultDict,
 )
-from collections import namedtuple
+from collections import namedtuple, defaultdict
 
 from sqlfluff.core.config import FluffConfig
 
 from sqlfluff.core.linter import LintedFile, NoQaDirective
 from sqlfluff.core.parser import BaseSegment, PositionMarker, RawSegment
 from sqlfluff.core.dialects import Dialect
-from sqlfluff.core.errors import SQLLintError
+from sqlfluff.core.errors import SQLLintError, SQLFluffUserError
 from sqlfluff.core.parser.segments.base import SourceFix
 from sqlfluff.core.rules.context import RuleContext
 from sqlfluff.core.rules.crawlers import BaseCrawler
@@ -503,6 +507,15 @@ class BaseRule:
     # - On the first pass of the main phase
     # - In a second linter pass after the main phase
     lint_phase = "main"
+    # Groups attribute to be overwritten.
+    groups: Tuple[str, ...] = ()
+    # Name attribute to be overwritten.
+    # NOTE: for backward compatibility we should handle the case
+    # where no name is set gracefully.
+    name: str = ""
+    # Optional set of aliases for the rule. Most often used for old codes which
+    # referred to this rule.
+    aliases: Tuple[str, ...] = ()
 
     def __init__(self, code, description, **kwargs):
         self.description = description
@@ -930,6 +943,18 @@ class BaseRule:
         return [s.strip() for s in raw_str.split(",") if s.strip()]
 
 
+@dataclass(frozen=True)
+class RuleManifest:
+    """Element in the rule register."""
+
+    code: str
+    name: str
+    description: str
+    groups: Tuple[str]
+    aliases: Tuple[str]
+    rule_class: Type[BaseRule]
+
+
 class RuleSet:
     """Class to define a ruleset.
 
@@ -953,10 +978,10 @@ class RuleSet:
 
     """
 
-    def __init__(self, name, config_info):
+    def __init__(self, name, config_info) -> None:
         self.name = name
         self.config_info = config_info
-        self._register = {}
+        self._register: Dict[str, RuleManifest] = {}
 
     def _validate_config_options(self, config, rule=None):
         """Ensure that all config options are valid.
@@ -1024,22 +1049,20 @@ class RuleSet:
         rule_name_match = self.valid_rule_name_regex.match(cls.__name__)
         # Validate the name
         if not rule_name_match:  # pragma: no cover
-            raise ValueError(
-                (
-                    "Tried to register rule on set {!r} with unexpected "
-                    "format: {}, format should be: Rule_PluginName_L123 (for plugins) "
-                    "or Rule_L123 (for core rules)."
-                ).format(self.name, cls.__name__)
+            raise SQLFluffUserError(
+                f"Tried to register rule on set {self.name!r} with "
+                "unexpected format: {cls.__name__}. Format should be: "
+                "'Rule_PluginName_L123' (for plugins) or "
+                "`Rule_L123` (for core rules)."
             )
 
         plugin_name, code = rule_name_match.groups()
         # If the docstring is multiline, then we extract just summary.
         description = cls.__doc__.replace("``", "'").split("\n")[0]
-
         if plugin_name:
             code = f"{plugin_name}_{code}"
 
-        # Keep track of the *class* in the register. Don't instantiate yet.
+        # Check for code collisions.
         if code in self._register:  # pragma: no cover
             raise ValueError(
                 "Rule {!r} has already been registered on RuleSet {!r}!".format(
@@ -1047,61 +1070,41 @@ class RuleSet:
                 )
             )
 
-        try:
-            assert (
-                "all" in cls.groups
-            ), "Rule {!r} must belong to the 'all' group".format(code)
-            groups = cls.groups
-        except AttributeError as attr_err:
-            raise AttributeError(
-                (
-                    "Rule {!r} doesn't belong to any rule groups. "
-                    "All rules must belong to at least one group"
-                ).format(code)
-            ) from attr_err
-
-        self._register[code] = dict(
-            code=code, description=description, groups=groups, cls=cls
+        assert "all" in cls.groups, "Rule {!r} must belong to the 'all' group".format(
+            code
+        )
+
+        self._register[code] = RuleManifest(
+            code=code,
+            name=cls.name,
+            description=description,
+            groups=cls.groups,
+            aliases=cls.aliases,
+            rule_class=cls,
         )
 
         # Make sure we actually return the original class
         return cls
 
-    def _expand_config_rule_group_list(
-        self, rule_list: List[str], valid_groups: Set[str]
-    ) -> List[str]:
-        expanded_rule_list: List[str] = []
-        for r in rule_list:
-            if r in valid_groups:
-                rules_in_group = [
-                    rule
-                    for rule, rule_dict in self._register.items()
-                    if r in rule_dict["groups"]
-                ]
-                expanded_rule_list.extend(rules_in_group)
-            else:
-                expanded_rule_list.extend(r)
-
-        return expanded_rule_list
-
-    def _expand_config_rule_glob_list(self, glob_list: List[str]) -> List[str]:
-        """Expand a list of rule globs into a list of rule codes.
+    def _expand_rule_refs(
+        self, glob_list: List[str], reference_map: Dict[str, List[str]]
+    ) -> Set[str]:
+        """Expand a list of rule references into a list of rule codes.
 
         Returns:
-            :obj:`list` of :obj:`str` rule codes.
+            :obj:`set` of :obj:`str` rule codes.
 
         """
-        expanded_glob_list = []
+        expanded_rule_set: Set[str] = set()
         for r in glob_list:
-            expanded_glob_list.extend(
-                [
-                    x
-                    for x in fnmatch.filter(self._register, r)
-                    if x not in expanded_glob_list
-                ]
-            )
+            # Is it a direct reference?
+            if r in reference_map:
+                expanded_rule_set.update(reference_map[r])
+            # Otherwise treat as a glob expression on codes.
+            else:
+                expanded_rule_set.update(fnmatch.filter(self._register.keys(), r))
 
-        return expanded_glob_list
+        return expanded_rule_set
 
     def get_rulelist(self, config) -> List[BaseRule]:
         """Use the config to return the appropriate rules.
@@ -1115,36 +1118,93 @@ class RuleSet:
         """
         # Validate all generic rule configs
         self._validate_config_options(config)
-        # Find all valid groups for ruleset
-        valid_groups: Set[str] = set(
-            [group for attrs in self._register.values() for group in attrs["groups"]]
-        )
-        # default the allowlist to all the rules if not set
-        allowlist = config.get("rule_allowlist") or list(self._register.keys())
+
+        # Generate the master reference map. The priority order is:
+        # codes > names > groups > aliases
+        # (i.e. if there's a collision between a name and an
+        # alias - we assume the alias is wrong.)
+        valid_codes: Set[str] = set(self._register.keys())
+        reference_map: Dict[str, List[str]] = {code: [code] for code in valid_codes}
+
+        # Generate name map.
+        name_map: Dict[str, List[str]] = {
+            manifest.name: [manifest.code]
+            for manifest in self._register.values()
+            if manifest.name
+        }
+        # Check collisions.
+        name_collisions = set(name_map.keys()) & valid_codes
+        if name_collisions:
+            rules_logger.warning(
+                "The following defined rule names were found which collide "
+                "with codes. Those names will not be available for selection: %s",
+                name_collisions,
+            )
+        # Incorporate (with existing references taking precedence).
+        reference_map = {**name_map, **reference_map}
+
+        # Generate the group map.
+        group_map: DefaultDict[str, List[str]] = defaultdict(list)
+        for manifest in self._register.values():
+            for group in manifest.groups:
+                if group in reference_map:
+                    rules_logger.warning(
+                        "Rule %s defines group %r which is already defined as a "
+                        "name or code of %s. This group will not be available "
+                        "for use as a result of this collision.",
+                        manifest.code,
+                        group,
+                        reference_map[group],
+                    )
+                else:
+                    group_map[group].append(manifest.code)
+        # Incorporate after all checks are done.
+        reference_map = {**group_map, **reference_map}
+
+        # Generate the alias map.
+        alias_map: DefaultDict[str, List[str]] = defaultdict(list)
+        for manifest in self._register.values():
+            for alias in manifest.aliases:
+                if alias in reference_map:
+                    rules_logger.warning(
+                        "Rule %s defines alias %r which is already defined as a "
+                        "name, code or group of %s. This alias will "
+                        "not be available for use as a result of this collision.",
+                        manifest.code,
+                        alias,
+                        reference_map[alias],
+                    )
+                else:
+                    alias_map[alias].append(manifest.code)
+        # Incorporate after all checks are done.
+        reference_map = {**alias_map, **reference_map}
+
+        # The lists here are lists of references, which might be codes,
+        # names, aliases or groups.
+        # We default the allowlist to all the rules if not set (i.e. not specifying
+        # any rules, just means "all the rules").
+        allowlist = config.get("rule_allowlist") or list(valid_codes)
         denylist = config.get("rule_denylist") or []
-        valid_rules_and_groups = list(self._register) + list(valid_groups)
 
         allowlisted_unknown_rule_codes = [
             r
             for r in allowlist
             # Add valid groups to the register when searching for invalid rules _only_
-            if not fnmatch.filter(valid_rules_and_groups, r)
+            if not fnmatch.filter(reference_map.keys(), r)
         ]
         if any(allowlisted_unknown_rule_codes):
             rules_logger.warning(
-                "Tried to allowlist unknown rules: {!r}".format(
+                "Tried to allowlist unknown rule references: {!r}".format(
                     allowlisted_unknown_rule_codes
                 )
             )
 
         denylisted_unknown_rule_codes = [
-            r
-            for r in denylist
-            if not fnmatch.filter({**self._register, **dict.fromkeys(valid_groups)}, r)
+            r for r in denylist if not fnmatch.filter(reference_map.keys(), r)
         ]
         if any(denylisted_unknown_rule_codes):  # pragma: no cover
             rules_logger.warning(
-                "Tried to denylist unknown rules: {!r}".format(
+                "Tried to denylist unknown rules references: {!r}".format(
                     denylisted_unknown_rule_codes
                 )
             )
@@ -1152,39 +1212,40 @@ class RuleSet:
         keylist = sorted(self._register.keys())
 
         # First we expand the allowlist and denylist globs
-        expanded_allowlist = self._expand_config_rule_glob_list(
-            allowlist
-        ) + self._expand_config_rule_group_list(allowlist, valid_groups)
-        expanded_denylist = self._expand_config_rule_glob_list(
-            denylist
-        ) + self._expand_config_rule_group_list(denylist, valid_groups)
+        expanded_allowlist = self._expand_rule_refs(allowlist, reference_map)
+        expanded_denylist = self._expand_rule_refs(denylist, reference_map)
 
         # Then we filter the rules
         keylist = [
             r for r in keylist if r in expanded_allowlist and r not in expanded_denylist
         ]
 
-        # Construct the kwargs for instantiation before we actually do it.
-        rule_kwargs = {}
-        for k in keylist:
+        # Construct the kwargs for each rule and instantiate in turn.
+        instantiated_rules = []
+        # Fetch general config first:
+        generic_rule_config = config.get_section("rules")
+        # Keep only config which isn't a section (for specific rule) (i.e. isn't a dict)
+        # We'll handle those directly in the specific rule config section below.
+        generic_rule_config = {
+            k: v for k, v in generic_rule_config.items() if not isinstance(v, dict)
+        }
+        for code in keylist:
             kwargs = {}
-            generic_rule_config = config.get_section("rules")
-            specific_rule_config = config.get_section(
-                ("rules", self._register[k]["code"])
-            )
+            rule_class = cast(Type[BaseRule], self._register[code].rule_class)
+            specific_rule_config = config.get_section(("rules", code))
             if generic_rule_config:
                 kwargs.update(generic_rule_config)
             if specific_rule_config:
                 # Validate specific rule config before adding
-                self._validate_config_options(config, self._register[k]["code"])
+                self._validate_config_options(config, code)
                 kwargs.update(specific_rule_config)
-            kwargs["code"] = self._register[k]["code"]
+            kwargs["code"] = code
             # Allow variable substitution in making the description
-            kwargs["description"] = self._register[k]["description"].format(**kwargs)
-            rule_kwargs[k] = kwargs
+            kwargs["description"] = self._register[code].description.format(**kwargs)
+            # Instantiate when ready
+            instantiated_rules.append(rule_class(**kwargs))
 
-        # Instantiate in the final step
-        return [self._register[k]["cls"](**rule_kwargs[k]) for k in keylist]
+        return instantiated_rules
 
     def copy(self):
         """Return a copy of self with a separate register."""
diff --git a/tox.ini b/tox.ini
index 8048658fb..1d832cbc9 100644
--- a/tox.ini
+++ b/tox.ini
@@ -11,25 +11,30 @@ setenv =
     COVERAGE_FILE = .coverage.{envname}
     winpy: TMPDIR = temp_pytest
     # Constrain dbt versions
-    dbt{100,130}: PIP_CONSTRAINT=constraints/{envname}.txt
+    dbt{100,130,}: PIP_CONSTRAINT=constraints/{envname}.txt
 allowlist_externals =
     make
 pip_pre = false
 deps =
     -rrequirements_dev.txt
-    # Add the example plugin
-    plugins/sqlfluff-plugin-example
-    dbt{100,130}: dbt-core
-    dbt{100,130}: dbt-postgres
+    dbt{100,130,}: dbt-core
+    dbt{100,130,}: dbt-postgres
+    # Install the plugins as required
+    # dbt plugin if required.
+    dbt{100,130,}: {toxinidir}/plugins/sqlfluff-templater-dbt
+    # Add the example plugin.
+    # NOTE: The trailing comma is important because in the github test suite
+    # the python version is not specified and instead the "py" environment
+    # is invoked. Leaving the trailing comma ensures that this environment
+    # still installs the relevant plugins.
+    {py,winpy}{37,38,39,310,}: {toxinidir}/plugins/sqlfluff-plugin-example
 # Include any other steps necessary for testing below.
 # {posargs} is there to allow us to specify specific tests, which
 # can then be invoked from tox by calling e.g.
 # tox -e py35 -- project/tests/test_file.py::TestClassName::test_method
 commands =
-    # Install the plugins as required
-    dbt{100,130}: python -m pip install {toxinidir}/plugins/sqlfluff-templater-dbt
     # For the dbt test cases install dependencies.
-    dbt{100,130}: dbt deps --project-dir {toxinidir}/plugins/sqlfluff-templater-dbt/test/fixtures/dbt/dbt_project --profiles-dir {toxinidir}/plugins/sqlfluff-templater-dbt/test/fixtures/dbt
+    dbt{100,130,}: dbt deps --project-dir {toxinidir}/plugins/sqlfluff-templater-dbt/test/fixtures/dbt/dbt_project --profiles-dir {toxinidir}/plugins/sqlfluff-templater-dbt/test/fixtures/dbt
     # Clean up from previous tests
     python {toxinidir}/util.py clean-tests
     # Run tests


Test patch:
diff --git a/test/core/plugin_test.py b/test/core/plugin_test.py
index 8414aa2a2..c7c2ac0cd 100644
--- a/test/core/plugin_test.py
+++ b/test/core/plugin_test.py
@@ -1,4 +1,6 @@
 """Plugin related tests."""
+import pytest
+
 from sqlfluff.core.plugin.host import get_plugin_manager
 from sqlfluff.core.config import FluffConfig
 
@@ -9,27 +11,45 @@ def test__plugin_manager_registers_example_plugin():
     # The plugin import order is non-deterministic.
     # Use sets in case the dbt plugin (or other plugins) are
     # already installed too.
-    assert set(
+    installed_plugins = set(
         plugin_module.__name__ for plugin_module in plugin_manager.get_plugins()
-    ).issuperset(
+    )
+    print(f"Installed plugins: {installed_plugins}")
+    assert installed_plugins.issuperset(
         {
+            # Check that both the v1 and v2 example are correctly
+            # installed.
             "example.rules",
             "sqlfluff.core.plugin.lib",
         }
     )
 
 
-def test__plugin_example_rules_returned():
+@pytest.mark.parametrize(
+    "rule_ref",
+    # Check both V1 plugin
+    ["Rule_Example_L001"],
+)
+def test__plugin_example_rules_returned(rule_ref):
     """Test that the example rules from the plugin are returned."""
     plugin_manager = get_plugin_manager()
     # The plugin import order is non-deterministic
-    assert "Rule_Example_L001" in [
+    rule_names = [
         rule.__name__ for rules in plugin_manager.hook.get_rules() for rule in rules
     ]
+    print(f"Rule names: {rule_names}")
+    assert rule_ref in rule_names
 
 
-def test__plugin_default_config_read():
+@pytest.mark.parametrize(
+    "rule_ref,config_option",
+    # Check both V1 and V2 rule plugins.
+    [("Example_L001", "forbidden_columns")],
+)
+def test__plugin_default_config_read(rule_ref, config_option):
     """Test that the example plugin default config is merged into FluffConfig."""
     fluff_config = FluffConfig(overrides={"dialect": "ansi"})
     # The plugin import order is non-deterministic
-    assert "forbidden_columns" in fluff_config._configs["rules"]["Example_L001"]
+    print(f"Detected config sections: {fluff_config._configs['rules'].keys()}")
+    # Check V1
+    assert config_option in fluff_config._configs["rules"][rule_ref]
diff --git a/test/core/rules/rules_test.py b/test/core/rules/rules_test.py
index f51bf452f..34fc42215 100644
--- a/test/core/rules/rules_test.py
+++ b/test/core/rules/rules_test.py
@@ -87,6 +87,82 @@ def test__rules__user_rules():
     assert not any(rule[0] == "T042" for rule in linter.rule_tuples())
 
 
+@pytest.mark.parametrize(
+    "rules, exclude_rules, resulting_codes",
+    [
+        # NB: We don't check the "select nothing" case, because not setting
+        # the rules setting just means "select everything".
+        # ("", "", set()),
+        # 1: Select by code.
+        # NOTE: T012 uses T011 as it's name but that should be ignored
+        # because of the conflict.
+        ("T010", "", {"T010"}),
+        ("T010,T011", "", {"T010", "T011"}),
+        ("T010,T011", "T011", {"T010"}),
+        # 2: Select by name
+        # NOTE: T012 uses "fake_other" as it's group but that should be ignored
+        # because of the conflict.
+        ("fake_basic", "", {"T010"}),
+        ("fake_other", "", {"T011"}),
+        ("fake_basic,fake_other", "", {"T010", "T011"}),
+        # 3: Select by group
+        # NOTE: T010 uses "foo" as it's alias but that should be ignored
+        # because of the conflict.
+        ("test", "", {"T010", "T011"}),
+        ("foo", "", {"T011", "T012"}),
+        ("test,foo", "", {"T010", "T011", "T012"}),
+        ("test", "foo", {"T010"}),
+        # 3: Select by alias
+        ("fb1", "", {"T010"}),
+        ("fb2", "", {"T011"}),
+    ],
+)
+def test__rules__rule_selection(rules, exclude_rules, resulting_codes):
+    """Test that rule selection works by various means."""
+
+    class Rule_T010(BaseRule):
+        """Fake Basic Rule."""
+
+        groups = ("all", "test")
+        name = "fake_basic"
+        aliases = ("fb1", "foo")  # NB: Foo is a group on another rule.
+        crawl_behaviour = RootOnlyCrawler()
+
+        def _eval(self, **kwargs):
+            pass
+
+    class Rule_T011(Rule_T010):
+        """Fake Basic Rule.
+
+        NOTE: We inherit crawl behaviour and _eval from above.
+        """
+
+        groups = ("all", "test", "foo")
+        name = "fake_other"
+        aliases = ("fb2",)
+
+    class Rule_T012(Rule_T010):
+        """Fake Basic Rule.
+
+        NOTE: We inherit crawl behaviour and _eval from above.
+        """
+
+        # NB: "fake_other" is the name of another rule.
+        groups = ("all", "foo", "fake_other")
+        # No aliases, Name collides with the code of another rule.
+        name = "T011"
+        aliases = ()
+
+    cfg = FluffConfig(
+        overrides={"rules": rules, "exclude_rules": exclude_rules, "dialect": "ansi"}
+    )
+    linter = Linter(config=cfg, user_rules=[Rule_T010, Rule_T011, Rule_T012])
+    # Get the set of selected codes:
+    selected_codes = set(tpl[0] for tpl in linter.rule_tuples())
+    # Check selected rules
+    assert selected_codes == resulting_codes
+
+
 def test__rules__filter_uparsable():
     """Test that rules that handle their own crawling respect unparsable."""
     # Set up a linter with the user rule
@@ -178,13 +254,13 @@ def test_rule_must_belong_to_all_group():
     """Assert correct 'groups' config for rule."""
     std_rule_set = get_ruleset()
 
-    with pytest.raises(AttributeError):
+    with pytest.raises(AssertionError):
 
         @std_rule_set.register
         class Rule_T000(BaseRule):
             """Badly configured rule, no groups attribute."""
 
-            def _eval(self, segment, parent_stack, **kwargs):
+            def _eval(self, **kwargs):
                 pass
 
     with pytest.raises(AssertionError):
@@ -195,7 +271,7 @@ def test_rule_must_belong_to_all_group():
 
             groups = ()
 
-            def _eval(self, segment, parent_stack, **kwargs):
+            def _eval(self, **kwargs):
                 pass