Skip to content

Configuration

poly is configured by a single poly.toml. It discovers the nearest one, layers poly.local.toml over it for machine-local overrides, and — in a monorepo — cascades nested configs from the workspace root down.

poly.toml
[defaults]
line_length = 120
line_ending = "lf"
final_newline = true
trim_trailing_whitespace = true
[discovery]
# Gitignore-style globs pruned from the file walk on every `poly lint` / `poly fmt`
# run, on top of .gitignore and the built-in prune set. The file-scoped builtin
# hooks (lint, fmt, file_safety) inherit these, so a repo states them once.
exclude = ["test_apps/**", "docs/snippets/**", "artifacts/**"]
# Whether a file or directory named on the command line honors `exclude`.
# Defaults to true, which is what a hook — always handed explicit staged paths —
# needs. --force-exclude / --include-excluded override it for one run.
force_exclude = true
# Directory names to keep despite the built-in prune set, for a repo where one of
# those names is ordinary source rather than build output.
no_prune = ["build", "dist"]
# Whether poly lints and formats machine-generated files. Defaults to true.
generated = true
[fmt.python.ruff]
docstring_code_format = true
docstring_code_line_length = 120
[lint.python.ruff]
select = ["E", "F", "W"]
[lint.php.mago]
select = ["correctness", "security"] # categories or rule codes
ignore = ["no-else-clause"]
php_version = "8.2"
# A per-rule `level` override works for every backend — poly applies it after
# linting, so it does not depend on the tool having its own severity config.
[lint.php.mago.rules.cyclomatic-complexity]
level = "warning" # error | warning | info | hint
# Any *other* key in a rules table is forwarded to the backend as a tool
# parameter, where the backend supports it — today oxlint, rumdl and sqruff.
[lint.javascript.oxc.rules.max-params]
max = 6
# Suppress specific rules per path glob (lint-only), across every backend.
[per-file-ignores]
"tests/**" = ["F401"]
"**/*.generated.php" = ["correctness"]
[hooks]
stages = ["pre-commit", "commit-msg"]
[hooks.builtin]
lint = true
fmt = true
commit = { stages = ["commit-msg"] }
file_safety = true
cargo = true

Most linter backends accept a uniform select / extend_select / ignore triple, taking rule codes and — where the backend has them — category names: ruff, oxc, mago, rumdl, sqruff, biome, dockerfile, dotenv, ini, and the ast-grep rule engine. extend_select adds to the defaults; select replaces them. typos, quality and uncomment take their own flat keys instead, and taplo’s lint pass takes no options at all.

The Python (ruff) and JavaScript/TypeScript (oxlint) backends select rules beyond each tool’s own out-of-the-box default — widening coverage while keeping an unconfigured run green.

poly selects F, E4, E7, E9 (ruff’s own default set) plus W6, I, UP, B, plus:

Category Codes Covers
Typing ANN Every function signature carries type annotations.
Functional style SIM, C4, RET, FURB, PERF Comprehensions over map/filter, no needless else or assign-before-return, modern idioms, avoidable per-iteration work.
Error handling TRY, BLE, S110 raise/except discipline, bare except Exception:, silently swallowed try/except/pass.
Complexity C90, PLR Cyclomatic complexity, Pylint’s too-many-args/branches/returns/statements family.
Hygiene T20, TC, PTH, RUF, ARG Stray print(), imports that belong behind TYPE_CHECKING, os.path calls pathlib does better, ruff’s own rules, unused arguments.

oxlint ran only the correctness category by default; poly also enables suspicious, pedantic, and four named rules: typescript/no-explicit-any, typescript/no-non-null-assertion, no-console, and complexity. The last sits in oxlint’s (off) restriction category, but its default threshold is exactly 20 — the same cyclomatic-complexity budget poly applies to every other language — so JS/TS gets the metric from oxlint rather than from a separate implementation.

Promote one you want enforced with a per-rule override:

[lint.python.ruff.rules.ANN201]
level = "error"

A handful of rules stay off even though their category is selected. Each was measured against a multi-repo corpus and found to fire on legitimate code rather than a defect:

Rule Category Why it’s off
B008 flake8-bugbear Flags the FastAPI/typer Depends(...) pattern — a deliberate call in a default.
RUF100 ruff Fires on any # noqa code poly does not select; measures config distance, not code quality.
ANN002 / ANN003 flake8-annotations Only Any ever satisfies them, which ANN401 (on by default) then flags anyway.
ARG002 flake8-unused-arguments Fires on fixed-signature overrides and callbacks (*args/**kwargs).
PLR2004 Pylint Overwhelmingly HTTP status codes in test assertions.
TRY003 tryceratops Demands a dedicated exception subclass for every raise ValueError("…").

Re-enable any of them with extend_select = ["<code>"] under [lint.python.ruff]. The EM category (exception message assigned to a variable before raise) is not selected at all.

For oxlint, no-underscore-dangle, max-lines-per-function, max-lines and max-classes-per-file are turned back off for the same reason — a universal private-field convention; describe() blocks that always exceed the 50-line default; a 300-line-per-file default poly does not endorse elsewhere; and a one-class-per-file rule that fires on error taxonomies, test mocks, .d.ts stubs and cohesive module groups rather than defects. Re-enable with extend_select under [lint.javascript.oxc] / [lint.typescript.oxc].

Mago ships six default-enabled rules at error level that measure a metric rather than detect a defect — cyclomatic-complexity (over 15), excessive-parameter-list (over 5), too-many-methods (over 10), too-many-properties (over 10), too-many-enum-cases (over 20), and kan-defect. poly reports these at warning, so a complexity budget never fails CI, while mago’s correctness, safety and security rules (no-ffi, no-eval, no-literal-password, tainted-data-to-sink, no-unsafe-finally, no-empty, …) keep error severity. Promote one back with:

[lint.php.mago.rules.cyclomatic-complexity]
level = "error"

For the CSS/SCSS/Less (malva), HTML/Vue/Svelte (markup_fmt), GraphQL and YAML backends, [defaults] line_length and [defaults] line_ending supply print_width and line_break only when you have not set those keys yourself in the engine’s own table — your config is always the top layer.

Some tools expose no such setting at all. Nix (alejandra) and Ruby (rubyfmt) are zero-configuration formatters, so [fmt.nix.alejandra] and [fmt.ruby.rubyfmt] declare no keys — anything written there is reported as unknown-config-key. TOML (taplo) always trims trailing whitespace regardless of [defaults] trim_trailing_whitespace.

[per-file-ignores] is the right tool for a whole file or a class of files. For a single justified exception inside an otherwise-normal file, write the directive in the file itself, in that language’s own comment syntax:

import os # poly: allow[F401] re-exported for backwards compatibility
  • poly: allow[RULE, RULE2] reason — covers the line it trails. On a line that is entirely a comment, it covers the next non-blank line instead.
  • poly: allow-file[RULE] reason — covers the whole file, wherever in the file it appears. This is the only form that can suppress a diagnostic with no span.
  • Rule codes are comma-separated and matched exactly or as a code-family prefix, the same way [per-file-ignores] matches them: allow[F] covers F401 but not FOO1. allow[*] covers every rule.
  • It works for every backend, because the runner applies it centrally — ruff, oxlint, typos, the tree-sitter tier, and any backend added later, with no per-engine wiring.

poly recognizes the directive after any of the comment openers //, #, --, ;, /*, *, <!--, %, !, dnl and rem — no per-language configuration. Quotes are deliberately not openers, so a directive-shaped string literal never suppresses.

Run poly from a monorepo root and each sub-project’s poly.toml cascades over the root, the way ruff and eslint resolve config. A nested config declares only the diff — it inherits [defaults], the [lint.*] / [fmt.*] rule tables, and [per-file-ignores] from its ancestors, up to the workspace root.

repo/poly.toml — the workspace root
[workspace]
root = true # stops the upward cascade here (a repo's .git dir is an
# implicit boundary too, so this is optional in a repo)
[defaults]
line_length = 120
[lint.python.ruff]
select = ["E", "F", "W"]
repo/frontend/poly.toml — governs repo/frontend/** only
[defaults]
line_length = 100 # overrides the root; ruff select is inherited
[per-file-ignores]
"*.spec.ts" = ["no-console"] # glob is relative to repo/frontend/

Resolution rules:

  • Which config governs a file does not depend on how you invoked poly. poly fmt ., poly fmt frontend and poly fmt frontend/src/app.ts all apply frontend/poly.toml to that file — so a pre-commit hook, which is always handed explicit staged paths, gates on exactly what a whole-repo run gates on.
  • Rules and defaults cascade (root to child, deep-merged; the nearest config wins).
  • [discovery] exclude globs are additive across the tree — each config’s excludes prune its own subtree, so a parent exclude already covers its children.
  • [per-file-ignores] globs are relative to the directory of the config that declares them.
  • --config <path> pins one config for the whole run and bypasses nested resolution.

Independently of exclude, poly never walks into a directory with one of these names, at any depth:

node_modules vendor deps target dist build .git
.venv venv .tox .gradle .next .nuxt coverage
__pycache__ .mypy_cache .ruff_cache .pytest_cache .polylint

These hold vendored code, build output or tool caches, and they are frequently tracked, so .gitignore alone does not exclude them. Every run reports what this pruned, so a directory you did not expect to lose is visible rather than silently missing:

Terminal window
$ poly fmt --check .
All formatted. (2239 file(s) checked, 26 director(ies) skipped by the built-in prune set)
26 director(ies) skipped by the built-in prune set (e.g. src/cli/pipeline/commands/build, node_modules)
these were not walked, so the files inside them are not counted; keep one with [discovery] no_prune

build and dist are build-output conventions in most ecosystems and ordinary domain nouns in some. Where one of them is real source, name it in no_prune:

[discovery]
no_prune = ["build", "dist"]

These are bare directory names, not globs — the built-in set is a name list and no_prune subtracts from it. To prune more paths, use exclude.

A file whose opening lines carry a DO NOT EDIT, @generated or Code generated … banner is linted, formatted and fixed like any other file. That is deliberate: a generator can emit a defect, and poly reporting it is how anyone finds out. All three phases agree — there is no shape of file that poly fmt reformats but poly lint --fix refuses to touch.

The one exception is narrower than a banner, and is a correctness guard rather than a preference. When the header stamps a content hash over the body — <project>:hash:<digest>, the shape a generator later verifies — poly still reports on the file but never writes to it. Reformatting the body invalidates the hash, so the generator’s verify step reports drift on a file no human touched and the only remedy is a regen that throws the change away. poly fmt reports those as skipped … hash-stamped generated file, poly lint --fix reports the diagnostics and says how many fixes it withheld, and --fix-generated opts back in.

To keep generated files out of poly entirely — the repo whose generated output is not its to fix — turn them off once, for both phases:

[discovery]
generated = false

They are then reported as skipped, not silently dropped: they stay out of the N file(s) linted count, they appear in the --format json / toon payload, and they count against --deny-skips / --max-skips, so a gate cannot quietly stop covering a tree.

Terminal window
$ poly lint --verbose .
Nothing was linted. (0 file(s) linted, 1 skipped (machine-generated file ([discovery] generated = false)))
skipped bindings/api.py: machine-generated file ([discovery] generated = false)

The key is read from the config nearest each file, so a nested poly.toml can opt out one subtree without touching the rest. --skip-generated and --include-generated override it in either direction for a single run, and beat every config in the tree.

A top-level extends list inherits any section of poly.toml[defaults], [lint.*] / [fmt.*], [tools.*], [per-file-ignores], [hooks.*] and so on — from local or pinned remote base configs, so an org can maintain one baseline instead of copy-pasting it into every repo. Entries use the same path / git / revision vocabulary as [[hooks.sources]]:

extends = [
{ git = "https://github.com/acme/poly-baseline", revision = "<40-hex-oid>", file = "poly.toml" },
"./poly.overrides.toml", # later entry = higher precedence
]

Bases are deep-merged underneath this file, in listed order; this poly.toml and then poly.local.toml always win on top.

exclude lists accumulate; every other key replaces. A repo that adds one glob of its own keeps every glob it inherited — and keeps receiving later changes to the base — instead of having to restate the base’s list and freeze a copy of it:

# base: [discovery] exclude = ["vendor/**", "target/**"]
extends = ["../baseline/poly.toml"]
[discovery]
exclude = ["generated/**"] # effective: vendor/**, target/**, generated/**

To drop what you inherited and state the whole list yourself, add exclude_mode = "replace" next to the exclude in that table. The same rule governs [discovery] exclude to [hooks.builtin.*] inheritance, and it applies to exclude only — rule selections, [rules] dirs, clippy_args and every other array still replace.

A git base pinned to a full commit OID needs no lock; a branch or tag ref requires running poly config update first, which resolves it into poly-config.lock and prints the [hooks] / [tools] the base introduces. extends is forbidden in poly.local.toml.

poly config show prints the effective, fully-merged result — every layer applied, every key as poly resolved it — so diffing it against your own poly.toml answers “what did poly actually keep?”. See the CLI reference for its output formats.

Opt into tools from the embedded catalog only when you want them:

[tools.prettier]
enabled = true
files = "**/*.{js,ts}"
[tools.black]
enabled = true
files = "**/*.py"

Catalog tools are capability-probed on PATH; a missing binary is skipped instead of making the whole run fail. The full list of 348 tools is on the tool catalog page.

Write your own lint rules — and codemods — as ast-grep YAML, in any of the 300+ languages poly can parse. Custom rules run in-process alongside the native backends on every poly lint, and poly lint --fix applies any fix: rewrites they declare. No plugin, no fork, no extra toolchain: rules run on the same tree-sitter grammars poly already bundles.

Point [rules] dirs at one or more directories of rule files. Paths are resolved relative to the poly.toml that declares them, so a rule set works from any working directory:

[rules]
dirs = [".poly/rules"] # default; set to [] to disable custom rules
builtin = true # set false to disable poly's own 26-rule pack wholesale

Each rule is a standard ast-grep YAML document. The language: field names a tree-sitter grammar; any metavariable used in fix: must be bound by the rule: pattern:

.poly/rules/python/use-is-none.yml
id: use-is-none
language: python
severity: warning
message: Use `is None` rather than `== None`.
rule:
pattern: $X == None
fix: $X is None

For languages where a bare fragment is not valid at file top level (Go, for instance), use ast-grep’s context / selector pattern form.

A rule may ship a companion <name>-test.yml holding valid snippets (must not match) and invalid snippets (must match). An invalid entry can also assert the rule’s autofix output by giving code plus fixed instead of a bare string:

.poly/rules/python/use-is-none-test.yml
id: use-is-none
valid:
- x is None
invalid:
- x == None # must match; fix output unchecked
- code: result == None # must match AND autofix to `result is None`
fixed: result is None

Run the checks with poly rules test (exits non-zero on any failed snippet) and list the resolved rules with poly rules list. Both default to the configured [rules] dirs, or accept explicit directories as arguments.

poly rules list covers poly’s built-in rule pack — 26 rules across C#, Elixir, Go, Java, Kotlin, Python, Ruby, Rust and Swift — as well as your own, marking each row builtin or user, and reflects the config that governs them: [rules] builtin = false, [lint.astgrep] select / extend_select / ignore, and [lint.astgrep.rules.<id>] level.

poly lint measures a handful of structural properties straight off the tree-sitter parse, for languages that have no linter of their own as much as for those that do. Every finding is a warning, so none of them fail CI on their own.

Rule Default On by default
file-too-long 1000 lines yes
function-too-long 80 lines yes
type-too-long 300 lines yes
too-many-parameters 6 yes
nesting-too-deep 4 yes
cyclomatic-complexity 20 yes
lazy-ignore yes
magic-number allows -1, 0, 1, 2, 10, 100 no
law-of-demeter depth 3 no

lazy-ignore reports a suppression written for another tool with no reason attached — a bare # noqa, a // biome-ignore with nothing after the colon, or an // eslint-disable* / // oxlint-disable* with nothing after the conventional -- separator. Rust’s #[allow(..)] is deliberately not among them: it belongs to the built-in ast-grep rule allow-attribute-without-reason, which reads reason = "..." and a preceding comment correctly and ships off by default. Opt in with extend_select = ["allow-attribute-without-reason"].

Configure the tier with flat keys — a boolean toggle plus a threshold:

[lint.quality]
function_too_long_lines = 120 # raise the budget everywhere
magic_number = true # opt in to a rule that ships off
[lint.go.quality]
function_too_long_lines = 200 # per-language override wins

enabled = false disables the tier entirely.

Where a backend already reports the same measurement, poly defers to it rather than reporting it twice — Python keeps ruff’s C901 for complexity, JavaScript and TypeScript keep oxlint’s max-depth.

The uncomment backend strips comments across every language it recognizes, guided by tree-sitter and a set of preservation rules (shebangs, ~keep, TODO/FIXME, documentation, and your own patterns). It is a lint backend: poly lint reports each removable comment block as a warning (which never fails CI), and poly lint --fix removes them. By default it reports only comments that look like commented-out code; set code_only = false to report every removable comment.

It is off by default. Enable it, and tune what it keeps, with a language-agnostic [lint.uncomment] block plus optional per-language overrides:

[lint.uncomment]
enabled = true # required — the backend is opt-in
remove_todos = false # keep TODO comments (default)
remove_fixme = false # keep FIXME comments (default)
remove_docs = false # keep documentation comments / docstrings (default)
use_default_ignores = true # keep the built-in directive allow-list (default)
code_only = true # only report comments that look like commented-out code (default)
preserve_patterns = ["HACK", "NOTE"] # keep comments containing these substrings
# Per-language override: strip Python docstrings but keep them elsewhere.
[lint.python.uncomment]
remove_docs = true

Per-language booleans override the global value; preserve_patterns are unioned with the global list. A language uncomment does not recognize is simply left untouched.