Static Analysis Toolchain

Static analysis finds the bug class tests are worst at: the code path nobody wrote a test for, the type confusion that only manifests on the error branch, the dependency rule everyone forgot. A well-assembled toolchain runs in seconds, complains rarely and precisely, and frees code review for design questions machines can't answer. This page covers the current best-of-breed per ecosystem and — the part that decides adoption success — how to introduce analysis to a codebase that predates it.

What analysis catches that tests don't

Four distinct value layers, worth keeping mentally separate when choosing tools: style/consistency (formatting, naming — solved by formatters, zero review time), correctness lints (unused results, shadowed variables, suspicious equality, resource leaks), type checking (whole-program contract verification — the highest-leverage layer in dynamically typed languages), and architecture rules (dependency direction, layer isolation — the rules that otherwise erode invisibly).

Python: Ruff plus mypy

Ruff consolidated the fragmented Python lint stack (flake8 + plugins + isort + pyupgrade + much of pylint) into one Rust-speed tool — roughly 100x faster, one config, plus a drop-in formatter replacing Black:

[tool.ruff.lint]
select = ["E", "F", "B", "I", "UP", "SIM", "PTH"]   # errors, bugbear, imports, modernize

Start from E/F/B and widen; B (bugbear) is where the genuine bug-finders live (mutable default arguments, unused loop variables). mypy adds the type layer; its dial matters more than its presence — untyped code passes silently by default, so drive toward strict = true per-module, expanding module-by-module. Pyright is the stricter, faster alternative (and powers editor feedback even on mypy-checked projects). Type coverage in Python is a gradual investment with compounding returns: every annotated boundary hardens every caller.

JavaScript/TypeScript: type-aware ESLint on a strict compiler

The compiler itself is the primary analyzer — strict: true plus noUncheckedIndexedAccess in tsconfig.json eliminates whole bug families. On top, ESLint with typescript-eslint in type-checked mode (recommendedTypeChecked / strictTypeChecked configs) catches what syntax-only linting can't: floating promises (no-floating-promises alone justifies the setup cost in async codebases), unsafe any flows, and misused unions. Leave formatting to Prettier (or Biome, the fast integrated newcomer); lint rules that argue with formatters waste everyone's time.

Java: Error Prone, SpotBugs, PMD

They overlap little; mature Java builds run Error Prone at compile, SpotBugs+PMD in CI.

Architecture rules as tests: ArchUnit and import-linter

The rules that actually rot codebases — "domain must not import infrastructure", "nothing outside persistence touches the ORM" — live in no linter's default set because they are yours. Encode them as executable tests: ArchUnit (JVM) expresses them as JUnit assertions (noClasses().that().resideInAPackage("..domain..").should().dependOnClassesThat().resideInAPackage("..infra..")); import-linter (Python) and dependency-cruiser (JS) enforce the equivalent from config. Once a layering decision is a failing test instead of a wiki page, it stops being renegotiated one expedient import at a time.

Adopting on a legacy codebase: baselines and ratchets

Running a new analyzer on an old codebase yields thousands of findings, and the naive responses both fail: fixing everything first means never shipping the tool; ignoring the count means alert blindness. The pattern that works is baseline-and-ratchet:

  1. Baseline — snapshot current violations into a suppression file the tool respects (mypy per-module overrides, Ruff per-file-ignores, PMD/SpotBugs baseline files, ESLint bulk-suppressions). The build goes green today with zero fixes.
  2. Gate on new — CI fails on any violation not in the baseline. New code is held to the full standard from day one.
  3. Ratchet down — entries only ever leave the baseline (fix a file, delete its entries, commit both); periodic burn-down passes shrink it. Enforce direction mechanically — fail if the baseline grows.

This converts an unpayable debt into a monotone gradient, and it composes with technical-debt management planning: the baseline file is the debt register, greppable and counted.

Keeping it fast: the pre-commit / CI split

Speed decides whether analysis gets run or bypassed. The working division: formatters and fast linters (Ruff, ESLint on changed files, Error Prone in the compile) run locally via pre-commit hooks in sub-second time; whole-program analysis (mypy strict, SpotBugs, architecture tests) runs in CI where minutes are acceptable. Never make developers wait locally for what CI can check — and never let CI-only checks be the first time a developer hears about a formatting rule.

See Also