How to add a lint rule

This guide walks through adding a rule to delta-engine lint — for example, max-columns to cap how many columns a table declares.

Everything a rule needs lives in one file: src/delta_engine/lint/rules.py. The config parser, the known-settings check, and the rule’s default severity all derive from the registry in that file, so no other production code changes.

1. Define the rule

Add a frozen dataclass to src/delta_engine/lint/rules.py:

@dataclass(frozen=True, slots=True)
class MaxColumnsRule:
    """Every table stays within a column budget."""

    limit: int
    name: ClassVar[str] = "max-columns"
    enabled_by_default: ClassVar[bool] = False

    def __post_init__(self) -> None:
        if self.limit < 1:
            raise ValueError("'limit' must be a positive integer")

    def evaluate(self, table: DesiredTable) -> tuple[str, ...]:
        """Report the table when it declares more columns than the limit."""
        if len(table.columns) <= self.limit:
            return ()
        return (f"table declares {len(table.columns)} columns; the limit is {self.limit}",)

Note: max-columns is a hypothetical example showing the pattern.

A rule satisfies the LintRule protocol: a name ClassVar[str] — the id used in config keys and in output — an enabled_by_default ClassVar[bool] — whether the rule runs when the config does not mention it — and evaluate(table: DesiredTable) -> tuple[str, ...]. Messages state facts only, one per violation; the runner pairs each message with the rule id, the table, and the configured severity. This is the same separation the safety rules keep: rules state what is wrong, policy decides how much it matters.

The dataclass fields are the rule’s config parameters. Validate them in __post_init__ and raise ValueError; the config parser reports it as a configuration error prefixed with the rule name (max-columns: 'limit' must be a positive integer). A rule with no parameters — most rules — needs no fields and no __post_init__.

2. Register it

Add the class to ALL_RULES at the bottom of the same file:

ALL_RULES: Final = (
    TableCommentRule,
    ColumnCommentRule,
    PrimaryKeyRule,
    NamingConventionRule,
    RequiredTagRule,
    MaxColumnsRule,
)

Registration is all the wiring. From the registry, config parsing derives:

  • max-columns is now a valid key in [tool.delta-engine.lint]; unknown keys stay configuration errors.

  • A bare value sets the severity: max-columns = "warning".

  • An inline table is severity plus constructor keyword arguments: max-columns = { limit = 50, severity = "warning" }. severity is a reserved name, so no rule may have a field called severity.

  • The default when the key is absent: enabled at error if the rule’s enabled_by_default is True, off otherwise. MaxColumnsRule sets it to False because the rule needs a limit to be useful, so max-columns stays off until it is configured.

3. Write tests

Add tests in:

  • tests/lint/test_rules.py — the rule’s facts against DesiredTables built with the file’s build_table helper: a compliant table yields no messages, each violation yields its message. If the rule has parameters, invalid ones raise ValueError at construction.

  • tests/lint/test_config.py — if the rule is enabled by default (enabled_by_default = True), add it to the default-policy expectation in TestDefaults. That mapping is pinned exactly on purpose: a new default-on rule changes what a bare delta-engine lint enforces, and the test makes that a conscious choice.

Run:

uv run pytest tests/lint tests/cli

4. Document it

Add a row to the rule table in the CLI reference.