AnyLearn
All lessons
Programmingintermediate

Linting from first principles

What a linter actually is, what its rules enforce, how AST-based analysis works, the cost of auto-fix, the ecosystem (ESLint, Ruff, Clippy, golangci-lint), where to wire it in (LSP / pre-commit / CI), why false-positive rate is the real budget, and when a custom rule pays for itself.

Not signed in — your progress and quiz score won't be saved.
Lesson progress1 / 8

What a linter actually is

A linter is a static analyzer for code: it reads the source, builds a model of it (usually an AST), runs a set of rules against the model, and reports rule violations. Static means it never executes the code — which is why a linter can't catch every bug, and also why it can run on every commit in seconds.

The original lint was a 1978 C tool from Bell Labs that found classes of bug the early compilers missed: uninitialized variables, dead code, type confusion. Modern linters extend the lineage in three directions:

  • Wider rule sets. Style consistency, naming, library-specific anti-patterns, security smells.
  • Faster runtimes. Incremental, parallel, often written in Rust or Go to clear millions of lines in seconds.
  • Tighter editor integration. Most ship an LSP server so diagnostics appear inline as you type.

The trade-off in one sentence: a linter is anything that catches mistakes without running the code. The further you push toward catching real bugs, the slower and noisier it gets. The further toward style, the cheaper and quieter — but the less load-bearing for correctness.

Full lesson text

All 8 steps on one page — for reading, reference, and search.

Show

1. What a linter actually is

A linter is a static analyzer for code: it reads the source, builds a model of it (usually an AST), runs a set of rules against the model, and reports rule violations. Static means it never executes the code — which is why a linter can't catch every bug, and also why it can run on every commit in seconds.

The original lint was a 1978 C tool from Bell Labs that found classes of bug the early compilers missed: uninitialized variables, dead code, type confusion. Modern linters extend the lineage in three directions:

  • Wider rule sets. Style consistency, naming, library-specific anti-patterns, security smells.
  • Faster runtimes. Incremental, parallel, often written in Rust or Go to clear millions of lines in seconds.
  • Tighter editor integration. Most ship an LSP server so diagnostics appear inline as you type.

The trade-off in one sentence: a linter is anything that catches mistakes without running the code. The further you push toward catching real bugs, the slower and noisier it gets. The further toward style, the cheaper and quieter — but the less load-bearing for correctness.

2. The four kinds of rules

Linter rules cluster into four bands by what they enforce and what a violation actually costs:

KindExampleWhy it matters
FormattingIndentation, trailing commas, line lengthReading consistency; diffs free of style noise
Style / namingcamelCase vs snake_case, unused importsCodebase coherence; defaults the team stops arguing about
CorrectnessUnreachable code, comparison-to-NaN, missing awaitCatches latent bugs before runtime sees them
SecuritySQL string concatenation, hard-coded secrets, unsafe deserializationBlocks OWASP-style classes

Most modern tools (ESLint, Ruff, Clippy) bundle plugins covering multiple bands. The clean architectural split — formatters (Prettier, Black, gofmt) own formatting; linters own everything else — has eroded as Ruff, Biome, and others absorb both jobs.

Not every rule deserves to be on by default. A rule that produces 10× more noise than value is removed faster than it is fixed. False positives are the single biggest reason teams disable rules and stop trusting the tool — more on this in step 7.

3. Auto-fix vs report-only

Auto-fix splits along two axes: how confident the rule is that the fix is correct, and whether the fix preserves behavior.

# Always auto-fixable — pure formatting; behavior unchanged.
x=1+2          # before
x = 1 + 2      # after

# Safely auto-fixable — unused import; no side effects on removal.
import os      # never referenced elsewhere

# Conditional fix — preserves behavior in most cases.
if not (x == None):    # before
if x is not None:      # after — semantically identical when x is None or a value

# Never auto-fixed — would change behavior.
# Rule: "this function should be async"
def fetch(): ...       # turning into async def changes every call site

ESLint classifies rules with fixable: "code" | "whitespace" | null. Ruff splits --fix (safe) from --unsafe-fixes (may alter semantics). The principle is invariant: a fix that might break code under any reasonable interpretation must be opt-in. Erode that and developers stop trusting --fix — at which point the productivity gain inverts.

4. How a linter sees your code — the AST

Naive linters use regex; serious ones parse the source into an Abstract Syntax Tree and walk it. The difference matters because regex can't tell console.log inside a comment from a real call.

// What ESLint sees for: const x = 5
{
  type: "Program",
  body: [{
    type: "VariableDeclaration",
    kind: "const",
    declarations: [{
      type: "VariableDeclarator",
      id: { type: "Identifier", name: "x" },
      init: { type: "Literal", value: 5 }
    }]
  }]
}

A rule like no-var is a visitor: walk every VariableDeclaration node, report when kind === "var". A rule like no-unused-vars needs more: build a scope graph, mark every declared identifier as unused until something references it, report the leftovers at end-of-scope.

AST-based rules are also why linters can auto-fix: a fix means rewriting the AST and printing it back to source. Regex-based replacements break formatting and miss context; AST-based replacements preserve both.

5. The ecosystem — and why Ruff matters

The major linters by language. The implementation-language column matters because it largely sets the speed-of-runtime, and that decides which workflows are practical:

LanguageToolImplNotes
JS / TSESLintJSThe default; flat config in v9
JS / TSBiomeRustFast successor; lint + format in one
PythonRuffRust~10–100× faster than flake8 + pylint
PythonpylintPythonOlder, deeper semantic analysis
RustClippyRustBundled with the rustc toolchain
Gogolangci-lintGoMeta-runner over many sub-linters
RubyRuboCopRubyBoth linter and formatter
ShellShellCheckHaskellCatches POSIX/Bash bugs; widely embedded
YAMLyamllintPythonSchema validation is separate

Ruff is the case study in how speed reshapes adoption. flake8 + pylint + isort on a large codebase took 30–60 seconds; Ruff does the same work in under a second. That moves the lint from "CI step" to "every keystroke" and turns rules that were impractical to run pre-commit into the default.

6. Where linting plugs in

Three integration points, each with a different cost/feedback trade-off:

  • Editor / LSP. Diagnostics inline as you type. Tightest loop, lowest cost — the bug surfaces before the commit. Ruff, ESLint, Clippy, and golangci-lint all ship LSP servers.
  • Pre-commit hook. Runs on staged files before each commit. Cheap because the file set is small. Catches issues before they reach review. The pre-commit framework or per-repo lefthook/husky are the usual carriers.
  • CI gate. Runs on every PR. Authoritative — what the team treats as clean. Slow to iterate against if it is the only signal.

The right shape is usually all three: LSP for fast feedback, pre-commit to keep noisy commits out, CI as the final source of truth. Each layer must run the same rule set — divergent configs between editor and CI is a top cause of works on my machine, fails in CI. Commit the lint config to the repo, install the editor extension that reads it, and gate CI on the same binary.

One caveat: pre-commit hooks should be fast. A 10-second hook trains developers to use --no-verify and ignore the layer entirely.

7. False positives are the budget

The hardest thing about linting is not writing rules — it's deciding which to keep on. A rule with a high false-positive rate burns trust at every diagnostic the developer ignores. Once trust is gone, the team begins disabling rules in batches and the tool becomes decorative.

A working heuristic: a rule should be on by default only when its signal-to-noise ratio is above roughly 10:1 — fewer than one false positive per ten real catches. Below that, gate it behind opt-in or drop its severity.

Three patterns that contain false-positive damage:

  • Inline disable comments. # noqa: E501, // eslint-disable-next-line, #[allow(clippy::needless_return)]. Cheap escape hatch; visible in code review.
  • Severity tiers. error vs warning vs info. CI fails on errors only; warnings are noise the developer can triage when convenient.
  • Repository-scoped overrides. Turn rules off in tests/, vendored code, generated files. Stops the tool fighting parts of the codebase that aren't first-party.

The principle: a noisy linter is worse than no linter, because the developer learns to ignore all diagnostics. Tuning is the work.

8. Custom rules — when, and what they cost

Most teams eventually reach for custom rules. The trigger is usually a class of mistake that keeps re-appearing in code review and the team wants to mechanize the catch.

// Custom ESLint rule: ban direct calls to fetch() in favor of the app's httpClient.
module.exports = {
  meta: {
    type: "problem",
    docs: { description: "Use httpClient, not fetch" },
    messages: { useClient: "Use httpClient.get/post instead of fetch()." },
  },
  create(context) {
    return {
      CallExpression(node) {
        if (node.callee.type === "Identifier" && node.callee.name === "fetch") {
          context.report({ node, messageId: "useClient" });
        }
      },
    };
  },
};

The honest costs:

  • Maintenance. Every language/framework upgrade may change AST node shapes; the custom rule becomes someone's recurring chore.
  • Discoverability. The rule's existence has to be findable; otherwise new hires hit the diagnostic and have no idea what convention they violated.
  • False-positive risk. Your custom rules are not battle-tested across thousands of repos like the stock ones. Expect to tune.

A custom rule beats a documentation page when the violation is a mechanizable AST pattern. When the rule needs runtime context ("this should call X if inside a transaction"), it is usually outside what linting can express — it belongs in the type system or a code-review checklist.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. What does the word *static* mean in the phrase "static analysis"?
    • The analyzer runs only on stable, released code, never on work-in-progress
    • The analyzer reads the source without executing it
    • The analyzer's rule set is hard-coded and cannot be customized
    • The analyzer runs on a single CPU thread
  2. Why is regex-based linting fundamentally weaker than AST-based linting?
    • Regex engines are slower than parsers on most files
    • Regex cannot distinguish a token inside a comment or string literal from the same token in real code, and cannot reason about scopes
    • Regex matches are case-insensitive by default and miss capitalized identifiers
    • Regex cannot match across multiple lines
  3. Auto-fixing the pattern `if not (x == None)` → `if x is not None` should be classified as which kind of fix?
    • Always safe — the meaning is identical for every type of `x`
    • Conditional (opt-in) — preserves behavior in most cases but could differ for objects with custom `__eq__`
    • Never auto-fixed — would always change behavior
    • A formatting-only fix
  4. Ruff's speed advantage over flake8 + pylint changes adoption because it:
    • Allows the linter to run on every keystroke and pre-commit without breaking the development loop
    • Discovers a fundamentally different class of bug
    • Reduces the false-positive rate by an order of magnitude
    • Makes auto-fix safe for all rule classes
  5. A working signal-to-noise heuristic for keeping a rule on by default is roughly:
    • 1:1 — accept one false positive per real catch
    • 10:1 — fewer than one false positive per ten real catches
    • 100:1 — false positives are tolerable only at one in a hundred
    • There is no useful heuristic — keep all rules on and rely on disable comments

Related lessons

Programming
advanced

Parsing: Tokens to ASTs

The parser takes a flat token stream and recovers the hierarchical structure the programmer intended. Learn context-free grammars, recursive-descent parsing, operator precedence, the difference between a parse tree and an AST, and when LL versus LR parsers matter — including the classic dangling-else ambiguity.

9 steps·~14 min·audio
Programming
advanced

Dynamic analysis and debuggers

Reverse engineering by running the binary. Why dynamic analysis sees what static cannot, how debuggers and breakpoints actually work (INT3 vs hardware vs page-fault), tracing (strace, ltrace, dtrace, eBPF), dynamic binary instrumentation with Frida and PIN, the common anti-debug tricks, sandboxing with Unicorn and Qiling, and the static-dynamic loop that does the real work.

8 steps·~12 min
Programming
advanced

Disassembly and decompilation

Reading machine code back into something a human can reason about. Instruction decoding (linear sweep vs recursive descent), x86-64 calling conventions, stack frames, control-flow graph recovery, what decompilers actually do and what they fundamentally cannot recover, and the practical tool landscape (IDA, Ghidra, Binary Ninja, radare2).

8 steps·~12 min
Programming
advanced

Binary formats and what the loader does

The first layer of reverse engineering — the file format on disk and the loader that maps it into memory. ELF, PE, and Mach-O as variants of the same idea; sections vs segments; symbol tables and what stripping actually removes; PLT/GOT/IAT and dynamic linking; packers like UPX; and the triage you do before opening a disassembler.

8 steps·~12 min