Linters have a communication problem. They are good at telling you what is wrong. They are bad at telling you why it matters.
Run a typical linter on a codebase and you get a list of codes with terse descriptions. W0612: Unused variable 'x'. C0301: Line too long (142/120). If you want to understand why the rule exists, you leave your editor, search the documentation site, and hope someone wrote a page about that particular code. Often they did not.
We wanted something different for Vary's check engine. When vary check flags something, you should be able to understand the reasoning without leaving your terminal.
What a rule looks like inside
Every check rule in Vary is a Kotlin class that implements a standard interface. The detection logic is just pattern matching over the AST, and that part is not particularly interesting. The interesting part is the metadata that every rule must carry:
| Field | Purpose |
|---|---|
| Stable ID | VCI001, VCS002, etc. Never changes once assigned |
| Category | idioms, safety, contracts, testing, mutation, performance |
| Rationale | Human-readable explanation of why the pattern is problematic |
| Bad-code example | Shows the flagged pattern |
| Good-code example | Shows the fix |
| Related rule IDs | Other rules in the same category |
| Auto-fix | Whether the rule supports auto-fix, and whether the fix is safe without human review |
This metadata is not documentation that lives in a separate file. It lives on the rule object itself. The rule and its explanation are the same thing.
Four surfaces, one source
Once the metadata lives on the rule, you can project it in multiple directions without writing anything twice.
vary check --fix reads the auto-fix from the rule and applies it. Safe fixes rewrite the source file directly. Unsafe fixes show the suggestion without applying it.
vary check --json renders machine-readable JSON. vary check --sarif renders SARIF 2.1.0 for CI systems that understand that format. The rule ID, message, severity, file location, and suggested fix all land in structured output that other tools can consume.
vary explain VCI001 pulls the rationale, examples, and related rules from the rule object and prints them to the terminal:
VCI001 — None comparison
Severity: suggestion
Category: idioms
Why this rule exists:
'is None' expresses nil identity explicitly and avoids
generic equality phrasing.
Bad:
if user == None {
Good:
if user is None {
Related: other idiom rules
No separate documentation file generated this output. It came straight from the rule class.
LSP code actions use the same metadata. When the language server runs the check engine on a file you are editing, rules with auto-fix support surface as quick-fix actions. Click the lightbulb, the fix applies. The fix logic is identical to what --fix runs on the command line.
We did not plan for four consumers when we designed the rule interface. We planned for one (the CLI) and realized partway through that the metadata was rich enough to feed the others without any adapter code. That was a lucky accident, but also a consequence of putting the "why" on the rule object instead of in a docs folder somewhere.
Suppression that forces you to think
Most linters let you suppress a warning with a bare comment: # noqa or // nolint. The problem is that suppression comments accumulate and nobody remembers why they are there. Six months later you are afraid to remove them because maybe there was a good reason.
Vary's suppression syntax requires the specific rule codes you are suppressing. It also supports an optional reason: suffix.
# vary-ignore-next-line VCI001 reason: legacy interop style in this file
if user == None {
handle_missing()
}
A generic "suppress everything on this line" comment is rejected by the parser. You cannot write # vary-ignore-next-line with no codes. You have to name what you are suppressing. Adding a reason is optional, but it is a good habit.
This is a small friction that pays off when you read the code later. Every suppression is self-documenting. If the reason no longer applies, you know it is safe to remove.
The optional reason: suffix is still worth using. Even a short reason makes suppressions easier to revisit later.
Rules across multiple categories
The check engine ships with stable rule IDs across several categories. Each category has its own prefix so you can tell at a glance what kind of issue you are looking at:
| Prefix | Category | What it catches |
|---|---|---|
| VCI | Idioms | Cleaner alternatives to correct but noisy patterns |
| VCS | Safety | Likely bugs: silent error swallowing, division by zero, infinite loops |
| VCT | Testing | Weak tests: literal asserts, duplicate names, empty test blocks |
| VCC | Contracts | Missing or trivial contracts on public functions |
| VCM | Mutation | Code that is hard for the mutation engine to analyse effectively |
| VCP | Performance | Known costly patterns like string concatenation in loops |
Some of these categories are unusual for a linter. Most linters do not have rules about mutation testing or contracts because most languages do not have those features. Vary does, so the linter knows about them.
The incremental cache
Running the full rule set over a large codebase on every invocation would be slow. The CLI check command uses a per-file cache keyed on file content hash, compiler version, and config fingerprint. Unchanged files skip re-checking entirely, which keeps vary check fast on repeated runs.
The LSP uses the same checking engine and diagnostic model, but it does not reuse the CLI cache directly. The important part is that both surfaces run the same rules and produce matching diagnostics.
What we learned
The hard part of a linter is not finding problems. AST pattern matching is straightforward engineering. The hard part is explaining what you found in a way that helps someone fix it and understand why.
Treating the explanation as structured data on the rule object, rather than as prose in a separate docs folder, made it possible to surface the same guidance in every context where a developer might encounter the diagnostic. A rule that cannot explain itself is not finished.