Teach your linter your own rules
Let boa-restrictor speak your project’s dialect — and keep your coding agents in line
Teach your linter your own rules
Let boa-restrictor speak your project’s dialect — and keep your coding agents in line
TL;DR
- 🐍 boa-restrictor now lets you register your own rule classes on top of the built-in ones, right from your
pyproject.toml. - 🤖 Besides enforcing project-specific conventions, it’s a neat, deterministic harness to stop your AI coding agents from repeating the same slop — without involving a human.
- ⚠️ Custom rules import and execute your code, so read the trust model before pointing it at anything you don’t own.
Why custom rules?
When I introduced boa-restrictor a while back, I described it as a custom, highly opinionated Python and Django linter that ships a fixed set of rules. The whole point was to codify the code-quality discussions we kept having in pull request after pull request, so we’d stop missing them and stop nitpicking each other.
That works great — as long as your team agrees with our opinions. But every project has its own little conventions that no off-the-shelf linter will ever cover. The ones you explain to every new colleague, that live in a wiki page nobody reads, and that you flag by hand in review for the hundredth time.
At the DjangoCologne meetup, someone suggested that every repo should be able to define its own rules as a plain Python class, run on a per-project basis. I called that “a very boa-esque idea” at the end of my last article. Well, here it is. As of v1.14, you can register your own rule classes alongside the built-in ones.
Photo by Olha Vilkha 🇺🇦 on Unsplash
A harness for your coding agents
Before we get to the code, let me sell you on the use case I’m most excited about.
If you’re letting an agent write code these days, you’ve probably noticed it has habits. It keeps reaching for the same patterns, the same imports, the same little flourishes you don’t want in your codebase. And you keep correcting them — in the chat, in the diff, in review. Every. Single. Time.
I made an observation in the last article that “people tend to complain about rules more in a PR than in the pipeline.” It turns out machines don’t complain at all. A custom rule in your pre-commit pipeline is a hard, deterministic signal: the agent runs the hook, sees the failure, and fixes it on its own. No human in the loop, no re-explaining your conventions for the hundredth time, no interpersonal friction — because there’s no person on the other side.
So instead of repeating yourself, you write the convention down once, as a rule, and let the pipeline enforce it forever.
Let’s talk code
Here’s a convention I’m tired of seeing: __future__ imports. Coding agents love to sprinkle a from __future__ import annotations (and friends) at the top of every new file out of pure habit. On the Python versions we target, most of them do nothing at all. They're just noise.
A custom rule is a class that subclasses boa_restrictor.common.rule.Rule, sets a RULE_ID and a RULE_LABEL, and implements a check() method. Inside check(), you walk Python's Abstract Syntax Tree (AST) — the same mechanism boa-restrictor uses under the hood — and return an Occurrence for every violation you find.
A from __future__ import ... statement parses into an ast.ImportFrom node whose module is "__future__". We could stop there and flag the whole line, but let's be a little more precise and report which feature was imported, so the developer (or the agent) gets a useful message:
import ast
from boa_restrictor.common.rule import Rule
from boa_restrictor.projections.occurrence import Occurrence
class NoFutureImportRule(Rule):
RULE_ID = "MYP001"
RULE_LABEL = "Do not use __future__ imports."
def check(self) -> list[Occurrence]:
occurrences = []
for node in ast.walk(self.source_tree):
if isinstance(node, ast.ImportFrom) and node.module == "__future__":
# One occurrence per imported feature, so the message is specific
for alias in node.names:
occurrences.append(
Occurrence(
rule_id=self.RULE_ID,
rule_label=f'Do not use the "__future__" import "{alias.name}".',
filename=self.filename,
file_path=self.file_path,
identifier=alias.name,
line_number=node.lineno,
)
)
return occurrences
That’s the whole rule. Notice how iterating over node.names instead of just matching the module gives you the actual feature name for free — that's the kind of detail the AST hands you if you bother to look.
Now register it in your pyproject.toml with a dotted import path:
[tool.boa-restrictor]
custom_rules = [
"myproject.linting.NoFutureImportRule",
]
A couple of things worth knowing about rule IDs:
- The
PBRandDBRprefixes are reserved for the built-in rules, so pick your own — I went withMYP. - Every loaded rule must have a unique
RULE_ID. A duplicate (against another custom rule or a built-in) aborts the run and names both culprits. - Validation is eager: a misconfigured
custom_rulesentry fails before a single file is linted, so you won't get a green run built on a broken config. - If your
check()raises, the run halts. Treat exceptions inside your rule as bugs in your rule.
Wiring it into pre-commit (the part that trips people up)
Here’s the catch nobody expects. The standard pre-commit hook installs boa-restrictor into its own isolated virtualenv — which means it can’t see your project code, and therefore can’t import myproject.linting. You have two ways out.
Option A — language: system (simplest). Run boa-restrictor in the environment you installed it into, typically your project venv. You give up pre-commit's automatic version management, so pin boa-restrictor in your dev requirements instead.
- repo: local
hooks:
- id: boa-restrictor
name: boa-restrictor
entry: boa-restrictor
language: system
types: [python]
args: [--config=pyproject.toml]
Option B — additional_dependencies (preferable if your project is pip-installable). Keeps pre-commit's hermetic environment and installs your package into the hook's venv.
- repo: https://github.com/ambient-innovation/boa-restrictor
rev: v1.14.0
hooks:
- id: boa-restrictor
args: [--config=pyproject.toml]
additional_dependencies: [".", "boa-restrictor"]
One more gotcha if you’re on Django: boa-restrictor does not bootstrap Django before importing your rule module. So if your rule needs anything from django.conf or django.db, import it inside check(), not at module level — otherwise you'll be greeted by an ImproperlyConfigured error during loading.
Playing nice with the rest
Your custom rules are first-class citizens. They honour the same exclusion mechanisms as the built-ins — global exclude, per-file-excludes, and inline # noqa: MYP001.
If you use ruff (and you probably do), remember to tell it about your prefix. Otherwise ruff will happily strip your # noqa: MYP001 comments, since it doesn't know the code exists:
[tool.ruff.lint]
external = ["PBR", "DBR", "MYP"]
Photo by Sembilan 4 on Unsplash
Critical review
As always, let me disclose the rough edges instead of pretending there aren’t any.
First, an honest caveat about my own example: from __future__ import annotations is not a pure no-op. It switches annotation evaluation to lazy strings (PEP 563), which was once slated to become the default and then got deferred indefinitely. Some codebases rely on that behaviour to avoid circular-import gymnastics in type hints. The other __future__ imports — print_function, division, and the rest — genuinely do nothing on Python 3. My rule flags all of them on purpose, to keep the example simple, but this is exactly the kind of decision you should make deliberately for your project. If you depend on lazy annotations, whitelist annotations in your check(). That's the whole point of project-specific rules: you decide.
Second, and more importantly, the trust model. Listing a path under custom_rules causes boa-restrictor to import and execute that module at lint time. There is no sandbox. If you run boa-restrictor against contributors' branches in CI — say, pull requests from forks — you have to assume that whoever can edit pyproject.toml can run arbitrary code in your CI environment. Only point this at code you trust.
Third, writing AST rules has a small learning curve. The ast module is well documented and you'll get the hang of it quickly, but your first rule will involve a bit of fiddling around in the debugger to see what the tree actually looks like. The good news from our own experience: once you're comfortable, a new rule usually takes well under two hours from idea to release.
And to be clear, none of this is trying to replace ruff. boa-restrictor fills the niche ruff doesn’t — the opinionated, project-specific patterns that no general-purpose tool will ever ship. Custom rules just widen that niche to include your opinions, not only ours.
Conclusion
I genuinely think this is the feature that makes boa-restrictor click for a lot of teams. The built-in rules show you the idea; custom rules let you make it yours. And in a world where more and more code is written by agents, having your conventions encoded as a hard guardrail in the pipeline — rather than a wiki page they might consult or a comment you type for the hundredth time — feels less like a nice-to-have and more like basic hygiene. An agent can ignore your docs, but it can’t ignore a failing check.
boa-restrictor is available on PyPI, the source lives on GitHub, and the full configuration docs are here.
I’d love to hear which custom rules you end up writing — especially the ones aimed at taming your coding agents. Drop me a comment or two; I’m eager to get some feedback!
메타데이터
- post_id
- b4f6bb563df7
- slug
- teach-your-linter-your-own-rules-b4f6bb563df7
- url
- https://medium.com/ambient-innovation/teach-your-linter-your-own-rules-b4f6bb563df7
- canonical_url
- https://medium.com/ambient-innovation/teach-your-linter-your-own-rules-b4f6bb563df7
- author_url
- https://medium.com/@ronny.vedrilla
- status
- ok
- fetched_at
- 2026-06-24 04:09:36