Skip to content

Introduction

poly is a single binary that lints and formats every language in a repository. It exists because the alternative — ruff for Python, oxlint for TypeScript, rustfmt for Rust, gofmt for Go, prettier for CSS, a YAML linter, a TOML formatter, and a hook framework to orchestrate them — means a toolchain per ecosystem, a config file per tool, and a different report format from each.

poly compiles those tools in as Rust crate dependencies and runs them in-process. There is no subprocess, no node_modules, no virtualenv, and nothing to install beyond the binary itself.

Every file resolves through the same ladder. Nothing here is a fallback you should avoid — the lower tiers are the coverage mechanism, and a language can be promoted upward later without any change to how you invoke poly.

  1. Native Rust backends (tier 1). The highest-fidelity path: ruff for Python, oxc for JavaScript/TypeScript/JSON, taplo for TOML, rumdl for Markdown, sqruff for SQL, malva for CSS/SCSS/Less, mago for PHP, markup_fmt for HTML/Vue/Svelte, and more. All compiled in, all in-process.

  2. Tree-sitter generic tier (tier 2). The catch-all for everything else — Java, Elixir, C, C++, protobuf, and the long tail of 300+ grammars. Best-effort structural reindent, still pure Rust, still no system dependency. Tier 2 is formatting only: it establishes no lint coverage.

  3. Native-toolchain backends. For languages whose canonical formatter is a first-party CLI with no usable Rust library. rustfmt, gofmt and shellcheck run automatically when present on PATH; zig fmt, shfmt, google-java-format, ktfmt, swift-format, dart format, gleam format and styler are opt-in. When the tool is absent the language simply falls through to tier 2.

  4. Catalog tier. Any of 348 tools from the embedded catalog, enabled per tool with [tools.<name>] enabled = true. Probed on PATH; a missing binary is a no-op, never an error.

On top of all four, two cross-cutting lint tiers run on every language, both on by default at warning severity so adopting poly never reddens an unconfigured CI:

  • The native code-quality engine — file and function length, parameter count, nesting depth, cyclomatic complexity, and lazy ignore markers. It defers to a tier-1 backend wherever one already reports the same measurement, so no metric is reported twice.
  • The built-in ast-grep rule pack — 26 rules across C#, Elixir, Go, Java, Kotlin, Python, Ruby, Rust and Swift, embedded in the binary and loaded through the same parser as your own custom rules.

poly discovers files once, plans engines once per language, then runs the per-file work in parallel on a rayon pool.

flowchart LR
A["paths"]
B["discover<br/>gitignore aware"]
C["plan engines<br/>per language"]
D["rayon file loop"]
E["blake3 cache"]
F["lint / format<br/>reports"]
A --> B --> C --> D
D <-->|hit / miss| E
D --> F

Every backend returns the same diagnostic and format-output shapes, so reporting, caching and MCP output stay uniform no matter which tool produced a finding.

The result cache is keyed by the file’s bytes, the engine name, the engine version and the resolved engine config — so a tool upgrade or a config change invalidates exactly the entries it affects and nothing else. It lives in the per-user OS cache directory, never in the repository.

A linter that silently checks nothing is worse than one that fails. poly counts every file it declined and says why:

Terminal window
$ poly lint --verbose .
skipped main.zig: no lint rules for Zig

Skips have three sources: a language nothing in the run lints, no matching engine, or a file poly deliberately withholds a write from. All of them appear in the summary, in the --format json payload, and in the --deny-skips / --max-skips budget. A run that verified less than it claims exits 2, distinct from the 1 that means real findings.