tree-police
A ripgrep-style linter that patrols source code for forbidden AST shapes, using tree-sitter queries baked into a single static binary. Built to run as a pre-commit hook (a prebuilt PyPI wheel, or a Docker image) — no Rust/Nix/tree-sitter toolchain required in the repos it scans.
Adapted from an internal hackathon proof of concept.
Credits
Most of this crate's Rust architecture is built on top of, and adapted from,
tree-grepper (archived) by
Brian Hicks — a general-purpose structural code search tool that this project
turns into a rule-bundling linter. tree-grepper is the source and foundation
for the parallel single-file-per-thread scan (ignore + rayon), git-ignore
awareness via the ignore crate, the embedded-grammar-per-language approach,
and the _-prefix-for-internal-captures capture convention used throughout
src/.
License note: tree-grepper is licensed under the
Hippocratic License 2.1,
not a standard OSS license — it carries its own attribution and
notice-of-changes requirements. No source from tree-grepper is copied into
this repo (the architecture is reimplemented independently, following its
design), so this project's own MIT OR Apache-2.0 license is unaffected, but
the design debt to tree-grepper is real and this note exists so it stays
visible.
Pre-commit quickstart
# .pre-commit-config.yaml
repos:
- repo: https://github.com/imochoa/tree-police
rev: v0.1.0 # pin a tag; see releases
hooks:
- id: tree-police
This installs the prebuilt tree-police wheel from PyPI into the hook's
environment, so it only needs Python: no Docker and no Rust toolchain. It
works with both pre-commit and prek. The hook
then runs against every changed file in the commit; files whose extension
isn't registered (see src/registry.rs) are silently skipped. For the Docker
image instead, use id: tree-police-docker. Override severity/format via args,
e.g. args: ["--min-severity", "warning"].
See docs/pre-commit-example.md for a full
worked example: expected output on a failing commit, scoping to specific
rules/categories, and troubleshooting.
Standalone CLI
Install it from PyPI (the tree_police wheel built from
python/pyproject.toml). The wheel only wraps the native binary, so there's no
Python code involved and no Rust toolchain is needed:
uv tool install tree-police # or: pipx install tree-police / pip install tree-police
tree-police --list-rules
Wheels are built for Linux (x86_64/aarch64, glibc and musl) and macOS (Intel/Apple Silicon). On any other platform pip falls back to the sdist, which needs Rust and a C/C++ compiler to build.
Or download a binary directly. Every release attaches prebuilt binaries for Linux (amd64/arm64, static musl) and macOS (Intel/Apple Silicon), plus a checksums file and a CycloneDX SBOM. Every grammar and query file is statically linked into these same binaries — there's no separate grammars folder to download or manage, on any platform.
curl -LO https://github.com/imochoa/tree-police/releases/latest/download/tree-police-aarch64-apple-darwin.tar.gz
tar xzf tree-police-aarch64-apple-darwin.tar.gz
./tree-police --list-rules
(swap the filename for your platform: x86_64-unknown-linux-musl,
aarch64-unknown-linux-musl, x86_64-apple-darwin, or
aarch64-apple-darwin.)
Repo-local rules
Drop extra <label>-<code>.scm files (same convention as queries/, see
below) in a .tree-police/ directory at your repo root and they're merged
with the built-in ruleset automatically — no fork, no image rebuild:
; .tree-police/no-foo-py.scm
((identifier) @no_foo
(#eq? @no_foo "foo")
(#set! severity "warning")
(#set! description "Don't use the identifier `foo`"))
Override the lookup path with --rules-dir <dir> if you'd rather keep it
somewhere else.
Config file
By default every rule runs on every file of its language. A
.tree-police/config.toml (next to any repo-local rules) restricts where
rules run and overrides their severity per path:
# Only run the temporal rules under src/workflows/.
[[scope]]
rules = ["category:temporal"]
paths = ["src/workflows/"]
# Run forbidden_print everywhere except tests and scripts.
[[scope]]
rules = ["forbidden_print"]
exclude = ["tests/**", "scripts/"]
# Downgrade security findings to `log` in legacy code (still reported).
[[override]]
rules = ["category:security"]
paths = ["legacy/**"]
severity = "log"
rulestakes rule IDs,category:<name>, orfile:<query-file-name>(e.g.file:no-foo-py.scm). Unknown names are an error (exit 2), so a typo can't silently match nothing; see--list-rules.[[scope]]:pathsis an allowlist andexcludea denylist (at least one of them is required). If several scopes select the same rule, a finding has to pass all of them.[[override]]:severityis required.paths/excludepick which files the override covers, but they never stop a rule from running. When several overrides match, the last one wins.- Languages are handled for you: a rule only ever runs on files of its own
language, so globs don't need extensions (
src/**is enough). A glob without**also matches everything under it, like.gitignore(tests=tests/=tests/**). - Globs are relative to the directory that contains
.tree-police/(normally the repo root), whatever directory you run from. If you pass--config FILEinstead, globs are relative toFILE's directory. - The config only filters findings. Every rule still runs, so
--rule,--categoryand--min-severitystill apply on top of it.
Suppressing findings
For one-off exceptions, put a directive in a comment. It works in any language's comment syntax:
print(x) # tree-police: ignore[forbidden_print] -- CLI entry point
# tree-police: ignore-next-line[category:security]
eval(trusted_expr)
# tree-police: ignore-file[todo]
| Directive | Suppresses findings… |
|---|---|
tree-police: ignore |
that start on the comment's own line |
tree-police: ignore-next-line |
that start on the line after the comment |
tree-police: ignore-file |
anywhere in the file, wherever the comment is |
The [...] list takes the same selectors as the config file. Leave it out
to suppress every rule. Anything after the directive, such as a reason, is
ignored. Only real comments count, so the same text inside a string literal
does nothing. An unknown selector prints a warning on stderr and suppresses
nothing; it never falls back to "every rule". The pretty report's summary
says how many findings were suppressed.
What's covered
Rules exist today for Python (forbidden, security, temporal, web
categories) and Terraform/HCL (naming, conventions, security). Both
rule sets are a small illustrative starting point, not an exhaustive policy —
extend them or add your own via a repo-local .tree-police/ directory (see
above).
The binary also bakes in grammars for 92 languages total with no rules
yet beyond those two — every tree-sitter grammar crate on crates.io whose
tree-sitter core dependency is compatible with this crate's pinned version
(a handful of popular languages are excluded because their published crate's
tree-sitter pin can't coexist with ours in one binary, or because the
published crate itself is broken — see the comment above
registry::LANGUAGES for specifics and how to recheck). Covering a new
language is just a new query file, no Cargo/registry change; unrecognized
extensions are silently skipped, so a pre-commit hook can pass every changed
file with no file-type allowlist of its own.
ada, adl, agda, bash, bicep, c, clojure, cmake, commonlisp,
cpp, crystal, csharp, css, cuda, d, dart, dbscheme,
devicetree, diff, elisp, elixir, elm, erb, erlang, fortran,
fsharp, gdscript, gleam, glsl, go, godotresource, graphql,
groovy, haskell, heex, html, ini, java, javascript, jinja2,
json, jsonnet, julia, kdl, llvm, lua, luau, make, matlab,
netlinx, nim, nix, objc, ocaml, ocamllex, odin, pascal, pgn,
php, powershell, prolog, properties, proto, python, ql,
qmljs, r, razor, rst, ruby, rust, scala, scheme, slang,
slint, solidity, sparql, swift, t32, templ, tera, terraform,
tlaplus, tsx, typescript, vcl, verilog, vhdl, wesl, xml,
yaml, zig.
This list (name, registry code, file extensions, and the grammar crate
behind each) lives in one place: src/registry.rs — treat
it, not this README, as the source of truth, since it's regenerated from
code rather than hand-maintained prose. Run tree-police --list-rules (or
--list-rules --format json) for the live rules catalog, grouped by
category (a much shorter list — most languages above have no rules yet).
Query file naming
queries/ is flat: <label>-<code>.scm, where <code> is the routing key
(py, tf, …) defined once in src/registry.rs and <label> is a free-form
readable tag. See queries/README.md for the full
layout and rule-authoring conventions, and
src/registry.rs for the language list.
New to writing tree-sitter queries? See
docs/writing-queries.md for a from-scratch
syntax reference (fields, captures, quantifiers, anchors, predicates) and
tips for inspecting a file's AST before writing one.
Local development
direnv allow # or: nix develop
just build # cargo build --release
just scan-bin . # scan the current tree (pretty report)
just scan-bin --format json src/ # machine-readable output
just scan-bin --list-rules # embedded rule catalog, grouped by category
just test # unit + black-box CLI + query-case tests
just docker-build # nix build .#tree-police-static, then podman build
just docker-run . # run the built image against a mounted dir
Key flags: --format pretty|json|jsonl, --min-severity log|warning|error,
--rule <id> / --category <name> (repeatable, scope to specific
rules/categories — see --list-rules for valid values), --rules-dir <dir>
(default .tree-police), --fail-on none|log|warning|error (exit-code
threshold, default warning), --show-tree <file> (print an indented AST
instead of scanning — see docs/writing-queries.md),
--no-ignore, --hidden, -j/--threads, -v/-vv. Exit codes: 0 = no
findings at/above --fail-on, 1 = findings at/above the threshold, 2 =
error.
Ad-hoc queries
--query '<scm>' (or --query-file <path>) runs a one-off tree-sitter
query instead of the embedded ruleset — no .scm file to write first.
Language is inferred from paths when that's unambiguous (a single file,
or a directory that's all one language); pass --lang <name> yourself for
a mixed-language directory:
$ tree-police --query '(call_expression function: (identifier) @fn)' src/main.rs
src/main.rs
117:23 @fn
117 | if let Err(err) = init_tracing(args.verbose) {
^^^^^^^^^^^^
121:11 @fn
121 | match run(args) {
^^^
[... more matches ...]
Summary: 16 match(es) in 1 file(s) (scanned 1).
Unlike ruleset scanning, this reports every non-_ capture per match
(tree-grepper's convention — see docs/writing-queries.md),
not one rule-id capture per pattern, and it's a search, not a lint gate: exit
code is always 0, and --fail-on/--min-severity/--rule/--category
don't apply (none have meaning without a severity/rule-id/category).
--format json/jsonl both work, for scripting.
See AGENTS.md for the full agent-facing reference (adding a
rule, adding a language, query-authoring gotchas).
This repo's own pre-commit checks
Separate from .pre-commit-hooks.yaml (which makes this repo usable as a
hook source for others), .pre-commit-config.yaml
lints tree-police's own Rust, Nix, and Markdown, running every tool straight
off the Nix devShell's PATH (language: system — no separate per-hook
install):
direnv allow # or: nix develop -- provides cargo fmt/clippy, alejandra, statix, deadnix, markdownlint-cli2, pre-commit itself
pre-commit install # one-time
pre-commit run --all-files # cargo fmt --check, cargo clippy --deny warnings, alejandra --check, statix, deadnix, markdownlint-cli2
.markdownlint-cli2.jsonc disables line-length (MD013, docs have long
inline links/code) and excludes the generated THIRD-PARTY-LICENSES.md.
Publishing
.github/workflows/release.yml cross-compiles musl-static binaries for both
x86_64 and aarch64 via Nix (nix build .#tree-police-static-x86_64 /
.#tree-police-static-aarch64 — no QEMU, these are genuine cross builds, not
emulated), then publishes a single multi-arch ghcr.io/imochoa/tree-police
manifest (linux/amd64 + linux/arm64) via docker buildx on every push to
main (tag latest) and version tag (tag vX.Y.Z). docker pull/pre-commit
picks the right architecture automatically — nothing arch-specific to
configure on the consuming side.
.github/workflows/cli-release.yml runs only on a version tag (a GitHub
Release is a deliberate act, unlike :latest's continuous republishing) and
attaches the standalone binaries described above: the same Nix musl builds
for Linux, plus two native cargo build --release jobs on GitHub's Intel and
Apple Silicon macOS runners (no cross-compiling or universal binaries — each
runner just builds for itself), a checksums.txt, and the CycloneDX SBOM.
renovate.json keeps the Rust (Cargo.lock), Nix
(flake.lock — beta Renovate manager, needs a nix binary on the Renovate
runner to actually rewrite the lock file, not just open a stale-looking PR),
and Docker/GitHub Actions build tooling versions current.
SBOM
nix build .#sbom (or just sbom) reproducibly generates a CycloneDX SBOM
of the exact dependency set in Cargo.lock, using
cargo-cyclonedx. The
release workflow builds it the same way and attaches it to the published
image as an OCI referrer via cosign attach sbom — pull it with
cosign download sbom ghcr.io/imochoa/tree-police@<digest> (or any
OCI-referrers-aware scanner) without needing a separate download URL.
License
Dual-licensed MIT OR Apache-2.0, at your
option — the Rust ecosystem convention, and compatible with every dependency
in Cargo.lock (all MIT, Apache-2.0, Unicode-3.0, or Unlicense; none copyleft
— see about.toml's accepted list). THIRD-PARTY-LICENSES.md
bundles every dependency's license text, generated with
cargo-about via
just third-party-licenses — regenerate it after any dependency change
(cargo about fails the build if a new dependency's license isn't in
about.toml's accepted list, so this can't silently drift).
See "Credits" above for the separate, non-dependency question of tree-grepper's Hippocratic License.
Metadata
Release files for tree-police 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| tree_police-0.2.0.tar.gz | 263.2 kB | Details |
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| tree_police-0.2.0-py3-none-musllinux_1_2_x86_64.whl | Python 3 | none | Linux musl 1.2+ x86-64 | Details |
| tree_police-0.2.0-py3-none-musllinux_1_2_aarch64.whl | Python 3 | none | Linux musl 1.2+ ARM64 | Details |
| tree_police-0.2.0-py3-none-manylinux_2_28_x86_64.whl | Python 3 | none | Linux glibc 2.28+ x86-64 | Details |
| tree_police-0.2.0-py3-none-manylinux_2_28_aarch64.whl | Python 3 | none | Linux glibc 2.28+ ARM64 | Details |
| tree_police-0.2.0-py3-none-macosx_11_0_arm64.whl | Python 3 | none | macOS 11.0+ ARM64 | Details |
| tree_police-0.2.0-py3-none-macosx_10_13_x86_64.whl | Python 3 | none | macOS 10.13+ x86-64 | Details |
Total release size: 80.8 MB
Release files / tree_police-0.2.0.tar.gz
| Download URL | tree_police-0.2.0.tar.gz |
|---|---|
| Size | 263.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
095e75d0f7f8e00810d9af7f61c7f9f80fc608f8a3e77d07f5694978681e0d9c
|
|
BLAKE2b-256 checksum How to use checksums |
ff44ae08ffef2646a1960bc93f69f80af5498b2984b03b7ea4f951bb1bd5ea8f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.
Transparency logRelease files / tree_police-0.2.0-py3-none-musllinux_1_2_x86_64.whl
| Download URL | tree_police-0.2.0-py3-none-musllinux_1_2_x86_64.whl |
|---|---|
| Size | 13.5 MB |
| Tags | Linux musl 1.2+ x86-64 Python 3 |
|
SHA-256 checksum How to use checksums |
18484e74daf867771be4b65e4171d3315a9bff25ee6c22912e0ce6df67967d4e
|
|
BLAKE2b-256 checksum How to use checksums |
59a22fdc702bdcd4ba5b7c52734de553b5af1f9c3ee7b916e4f8e264ab60d991
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.
Transparency logRelease files / tree_police-0.2.0-py3-none-musllinux_1_2_aarch64.whl
| Download URL | tree_police-0.2.0-py3-none-musllinux_1_2_aarch64.whl |
|---|---|
| Size | 13.1 MB |
| Tags | Linux musl 1.2+ ARM64 Python 3 |
|
SHA-256 checksum How to use checksums |
de18a3187163a365116bdce93495c42bf4a3ae36708e3b7667b45347b76d29de
|
|
BLAKE2b-256 checksum How to use checksums |
6db1ca2934156806ea13218b06ea6d7afa10cb79348585366c325730c6be75de
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.
Transparency logRelease files / tree_police-0.2.0-py3-none-manylinux_2_28_x86_64.whl
| Download URL | tree_police-0.2.0-py3-none-manylinux_2_28_x86_64.whl |
|---|---|
| Size | 13.4 MB |
| Tags | Linux glibc 2.28+ x86-64 Python 3 |
|
SHA-256 checksum How to use checksums |
05667b38c4679ea4906dab800622844dc8be0f1e929a6f8f3cf497ee1180dcca
|
|
BLAKE2b-256 checksum How to use checksums |
279215164affb54d9401dd1e1b4b23cad7e5ab03fdf93ede370de6101e154276
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.
Transparency logRelease files / tree_police-0.2.0-py3-none-manylinux_2_28_aarch64.whl
| Download URL | tree_police-0.2.0-py3-none-manylinux_2_28_aarch64.whl |
|---|---|
| Size | 13.2 MB |
| Tags | Linux glibc 2.28+ ARM64 Python 3 |
|
SHA-256 checksum How to use checksums |
3423669e73ab3f421ceca99d65f0fa6ebc142a8dd57b3f6eacce885e6c785b42
|
|
BLAKE2b-256 checksum How to use checksums |
35514de82a6bc856c5ecc993d78ba9837b70684201598c37edabee463c5d24af
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.
Transparency logRelease files / tree_police-0.2.0-py3-none-macosx_11_0_arm64.whl
| Download URL | tree_police-0.2.0-py3-none-macosx_11_0_arm64.whl |
|---|---|
| Size | 14.1 MB |
| Tags | Python 3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
75910338e715da3afa42f37325b6993b969ed10bbe41f5a719f35a4860f4f57c
|
|
BLAKE2b-256 checksum How to use checksums |
9086561d9a48c7b0d1712249419b6f24722feb7b9047ae2e5196b42857b5c443
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.
Transparency logRelease files / tree_police-0.2.0-py3-none-macosx_10_13_x86_64.whl
| Download URL | tree_police-0.2.0-py3-none-macosx_10_13_x86_64.whl |
|---|---|
| Size | 13.2 MB |
| Tags | Python 3 macOS 10.13+ x86-64 |
|
SHA-256 checksum How to use checksums |
46862726b46e2f22106579be4bcf53be4ed961ad349f242ffce34f54e3f4100d
|
|
BLAKE2b-256 checksum How to use checksums |
5e84996396e434b5a6dc0b0a7a28d638854ba59a1c6a6e1d27b8a28cd99816c1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.
Transparency log