tl;dr: Vary's check engine treats 'why does this rule exist' as a first-class output. Every rule carries structured metadata: rationale, bad/good examples, auto-fix logic, and related rules. The same rule object powers CLI fixes, JSON and SARIF output, vary explain docs, and LSP code actions.


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:

FieldPurpose
Stable IDVCI001, VCS002, etc. Never changes once assigned
Categoryidioms, safety, contracts, testing, mutation, performance
RationaleHuman-readable explanation of why the pattern is problematic
Bad-code exampleShows the flagged pattern
Good-code exampleShows the fix
Related rule IDsOther rules in the same category
Auto-fixWhether 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:

PrefixCategoryWhat it catches
VCIIdiomsCleaner alternatives to correct but noisy patterns
VCSSafetyLikely bugs: silent error swallowing, division by zero, infinite loops
VCTTestingWeak tests: literal asserts, duplicate names, empty test blocks
VCCContractsMissing or trivial contracts on public functions
VCMMutationCode that is hard for the mutation engine to analyse effectively
VCPPerformanceKnown 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.

More articles

What's new in Vary v122-alpha.1 v122-alpha.1 is out. The headline is vary var, a new top-level command that runs check, test, mutation, and review under a cost budget. The mutation engine was rewritten around reachability tracing, kill-first scheduling, and a hot-swap backend. Frugal, a native PEG parser library ported from Parsimonious, also lands.
Vary mutation testing speed: comparing to AST and PIT Vary now measures mutation-testing performance directly on real benchmark programs, including a project-scale parser workload and a PIT-style comparison fixture, and the current results are strong enough to talk about in concrete terms.