Skip to main content
tree-police logo

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"
  • rules takes rule IDs, category:<name>, or file:<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]]: paths is an allowlist and exclude a denylist (at least one of them is required). If several scopes select the same rule, a finding has to pass all of them.
  • [[override]]: severity is required. paths/exclude pick 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 FILE instead, globs are relative to FILE's directory.
  • The config only filters findings. Every rule still runs, so --rule, --category and --min-severity still 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)

Source distribution for tree-police 0.2.0
File Size Uploaded
tree_police-0.2.0.tar.gz 263.2 kB Details

Built distributions (wheels)

Table of built distributions (wheels) for tree-police 0.2.0
File
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 log

Release 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 log

Release 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 log

Release 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 log

Release 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 log

Release 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 log

Release 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

Release history Release notifications | RSS feed

This release

0.2.0 This release

7 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page