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.
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).
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.
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.
effort=max with a threshold that keeps signal high; expect a triage pass to suppress the false-positive families your codebase provokes.They overlap little; mature Java builds run Error Prone at compile, SpotBugs+PMD in CI.
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.
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:
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.
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.