FP2: Local Lint Rules

By default, Fixit expects rules to be referenced by their fully-qualified module and/or class name. For example, fixit.rules refers to the bundled set if lint rules shipped with Fixit. Third party rules available in the environment can similarly be referenced by their module name, as long as they are installed and importable by Fixit at runtime. These will be collectively referred to as “global rules” here for sake of clarity.

However, in many cases, it is also useful for projects using Fixit to build custom lint rules specific to the project being linted. It is beneficial for those rules to be defined within the codebase being linted, without needing to first install those lint rules into the environment before running Fixit.

These are defined as “local rules”, and will require dedicated behavior from Fixit to discover and import these rules at runtime, as well as special configuration syntax for enabling these local rules in a project.

Configuration

When configuring the set of enabled or disabled rules, any local rules must be marked with a single leading period, and referred to using their path relative to the configuration file’s directory.

For example, a configuration file at project/fixit.toml could include rules defined in project/some/local/rules.py with the following:

[tool.fixit]
enable = [".some.local.rules"]

References to local rules should be accepted in either enable or disable options, including overrides, to provide the same selection criteria available to global rules:

[tool.fixit]
enable = [".rules"]

[[tool.fixit.overrides]]
path = "project1"
disable = [".rules:PickyRule"]
enable = [".project1.rules"]

Implementation

When gathering enabled/disabled rules from configuration, Fixit currently just records the module name (qualname) and does set operations when merging configs/overrides. In order to differentiate and manage local rules correctly, without affecting the behavior of merging/overrides, one possible option is to replace the simple string with a tuple of path and module.

For example, where a global rule would be tracked as just "fixit.rules", local rules from foo/bar/fixit.toml could be tracked as (Path('foo/bar'), ".local.rules")), allowing set operations while still tracking their origin, and preventing collisions from different directories.

In this implementation, the path object should match the parent directory containing the configuration file that enables or disables these local rules. Options in both a pyproject.toml and fixit.toml from the same exact directory would use the same path object, as would any local rules referenced by overrides in those files.

When discovering and loading rules, the system should attempt to make sure that it is loading rules from the local path, rather than accidentally loading rules from the outside environment. This can be done either by a temporarily restricted path when importing, or with a custom import loader.

The former may be a simpler starting point, and can be handled with a temporary override of sys.path to include the local directory — and nothing else? — and then importing the module as normal, with the leading period removed.

This could look something like:

with temporary_sys_path(parent_path):
    name = name.lstrip(".")
    module = importlib.import_module(name)

Once loaded, local modules and rules can be handled and traversed the same as for global rules, though the logic for filtering out disabled local rules may require more nuance.

Long term, we almost certainly need a custom loader, to prevent potential conflicts between local namespaces. For example, project1 and project2 both referece a local .rules module, which would clash in sys.modules.

Before a final public release, the following considerations must be handled:

  • Importing multiple local rules with the same local path and/or module name, or names that conflict with system modules. Eg, two different configs specifying ".local.rules", or loading a module named ".sys".

  • Relative imports from within local rules modules, such also from .foo import Bar or worse from ..foo import Bar.

Limitations

For simplicity of implementation, it makes sense to disallow local rules from outside of the configuration file’s parent directory. In other words, project/subdir/fixit.toml can not reference local rules from project/other/rules.py.

Enabling (or disabling) local rules with more than one preceding period, such as ..rules, is not supported, and should be rejected during config validation.

To apply rules from a different subdirectory to another subdirectory, a configuration located in a common parent can use configuration overrides. For instance, project/fixit.toml could specify an override for the subdir path to enable ".local.rules".


Also for simplicity of implementation (and explanation to users), it makes sense to disallow filtering of local rules from outside the file (or exact parent directory) that originally enabled them.

For example, this would be considered invalid, or at least would not accomplish what the user may expect:

# foo/fixit.toml

[tool.fixit]
enable = [".local.rules"]
# foo/bar/fixit.toml

[tool.fixit]
disable = [".local.rules"]

Rather, the expected way to make this work would be with subpath overrides in the parent directory’s fixit.toml file:

# foo/fixit.toml

[tool.fixit]
enable = [".local.rules"]

[[tool.fixit.overrides]]
path = "bar"
disable = [".local.rules"]