tree-police 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. tree_police-0.2.0/.dockerignore +3 -0
  2. tree_police-0.2.0/.envrc +1 -0
  3. tree_police-0.2.0/.github/workflows/cli-release.yml +112 -0
  4. tree_police-0.2.0/.github/workflows/pypi-release.yml +152 -0
  5. tree_police-0.2.0/.github/workflows/release.yml +81 -0
  6. tree_police-0.2.0/.gitignore +12 -0
  7. tree_police-0.2.0/.markdownlint-cli2.jsonc +16 -0
  8. tree_police-0.2.0/.pre-commit-config.yaml +57 -0
  9. tree_police-0.2.0/.pre-commit-hooks.yaml +24 -0
  10. tree_police-0.2.0/AGENTS.md +191 -0
  11. tree_police-0.2.0/Cargo.lock +1947 -0
  12. tree_police-0.2.0/Cargo.toml +131 -0
  13. tree_police-0.2.0/Dockerfile +4 -0
  14. tree_police-0.2.0/LICENSE-APACHE +202 -0
  15. tree_police-0.2.0/LICENSE-MIT +21 -0
  16. tree_police-0.2.0/PKG-INFO +365 -0
  17. tree_police-0.2.0/README.md +346 -0
  18. tree_police-0.2.0/THIRD-PARTY-LICENSES.md +2520 -0
  19. tree_police-0.2.0/about.hbs +28 -0
  20. tree_police-0.2.0/about.toml +14 -0
  21. tree_police-0.2.0/docs/logo.jpg +0 -0
  22. tree_police-0.2.0/docs/pre-commit-example.md +132 -0
  23. tree_police-0.2.0/docs/writing-queries.md +261 -0
  24. tree_police-0.2.0/flake.lock +61 -0
  25. tree_police-0.2.0/flake.nix +104 -0
  26. tree_police-0.2.0/justfile +130 -0
  27. tree_police-0.2.0/pyproject.toml +36 -0
  28. tree_police-0.2.0/python/LICENSE-APACHE +202 -0
  29. tree_police-0.2.0/python/README.md +346 -0
  30. tree_police-0.2.0/python/THIRD-PARTY-LICENSES.md +2520 -0
  31. tree_police-0.2.0/python/pyproject.toml +36 -0
  32. tree_police-0.2.0/queries/README.md +110 -0
  33. tree_police-0.2.0/queries/rules-py.scm +258 -0
  34. tree_police-0.2.0/queries/rules-tf.scm +240 -0
  35. tree_police-0.2.0/renovate.json +29 -0
  36. tree_police-0.2.0/src/config.rs +425 -0
  37. tree_police-0.2.0/src/lib.rs +22 -0
  38. tree_police-0.2.0/src/main.rs +301 -0
  39. tree_police-0.2.0/src/registry.rs +1007 -0
  40. tree_police-0.2.0/src/report.rs +383 -0
  41. tree_police-0.2.0/src/rules.rs +341 -0
  42. tree_police-0.2.0/src/scan.rs +432 -0
  43. tree_police-0.2.0/src/severity.rs +73 -0
  44. tree_police-0.2.0/src/suppress.rs +144 -0
  45. tree_police-0.2.0/src/tree_view.rs +64 -0
  46. tree_police-0.2.0/tests/cli.rs +438 -0
  47. tree_police-0.2.0/tests/fixtures/python/clean.py +5 -0
  48. tree_police-0.2.0/tests/fixtures/python/security_violations.py +1 -0
  49. tree_police-0.2.0/tests/fixtures/python/violations.py +2 -0
  50. tree_police-0.2.0/tests/fixtures/terraform/clean.tf +33 -0
  51. tree_police-0.2.0/tests/fixtures/terraform/convention_violations.tf +76 -0
  52. tree_police-0.2.0/tests/fixtures/terraform/naming_violations.tf +23 -0
  53. tree_police-0.2.0/tests/packaging.rs +18 -0
  54. tree_police-0.2.0/tests/query-cases/python/forbidden-patterns.py +49 -0
  55. tree_police-0.2.0/tests/query-cases/python/security-patterns.py +28 -0
  56. tree_police-0.2.0/tests/query-cases/python/temporal-patterns.py +26 -0
  57. tree_police-0.2.0/tests/query-cases/python/web-patterns.py +25 -0
  58. tree_police-0.2.0/tests/query_cases.rs +165 -0
@@ -0,0 +1,3 @@
1
+ *
2
+ !dist/amd64/tree-police
3
+ !dist/arm64/tree-police
@@ -0,0 +1 @@
1
+ use flake
@@ -0,0 +1,112 @@
1
+ name: CLI release
2
+
3
+ # Only on a version tag -- unlike the Docker image (release.yml), which
4
+ # republishes `:latest` on every push to main, a GitHub Release should be a
5
+ # deliberate, versioned act.
6
+ on:
7
+ push:
8
+ tags: ["v*"]
9
+
10
+ permissions:
11
+ contents: write
12
+
13
+ jobs:
14
+ build-linux:
15
+ # Cross-compiled via Nix (not Docker buildx/QEMU) on one amd64 runner --
16
+ # same musl-static binaries release.yml builds for the Docker image.
17
+ # Everything (all ~90 grammars, the embedded query files) is statically
18
+ # linked in; there's no separate grammars folder to ship.
19
+ runs-on: ubuntu-latest
20
+ strategy:
21
+ matrix:
22
+ include:
23
+ - nix_package: tree-police-static-x86_64
24
+ target: x86_64-unknown-linux-musl
25
+ - nix_package: tree-police-static-aarch64
26
+ target: aarch64-unknown-linux-musl
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - uses: DeterminateSystems/nix-installer-action@main
30
+ - uses: DeterminateSystems/magic-nix-cache-action@main
31
+
32
+ - name: Build (musl cross)
33
+ run: nix build ".#${{ matrix.nix_package }}" -o result
34
+
35
+ - name: Package
36
+ run: |
37
+ mkdir -p dist
38
+ install -m755 result/bin/tree-police tree-police
39
+ tar czf "dist/tree-police-${{ matrix.target }}.tar.gz" tree-police
40
+
41
+ - uses: actions/upload-artifact@v4
42
+ with:
43
+ name: tree-police-${{ matrix.target }}
44
+ path: dist/*.tar.gz
45
+
46
+ build-macos:
47
+ # Native builds, one job per arch -- GH's macos-15-intel runner is
48
+ # Intel, macos-14 is Apple Silicon (GH's "-latest"/bare "macos-NN"
49
+ # labels default to Apple Silicon now; Intel needs the explicit
50
+ # "-intel" suffix), so `cargo build --release` with no explicit
51
+ # --target already produces the right binary for each; no cross-compile
52
+ # or universal-binary step needed. Not Nix/musl: macOS has no musl
53
+ # equivalent, dynamic linking against system libs is normal here.
54
+ strategy:
55
+ matrix:
56
+ include:
57
+ - os: macos-15-intel
58
+ target: x86_64-apple-darwin
59
+ - os: macos-14
60
+ target: aarch64-apple-darwin
61
+ runs-on: ${{ matrix.os }}
62
+ steps:
63
+ - uses: actions/checkout@v4
64
+ - uses: dtolnay/rust-toolchain@stable
65
+
66
+ - name: Build
67
+ run: cargo build --release
68
+
69
+ - name: Package
70
+ run: |
71
+ mkdir -p dist
72
+ install -m755 target/release/tree-police tree-police
73
+ tar czf "dist/tree-police-${{ matrix.target }}.tar.gz" tree-police
74
+
75
+ - uses: actions/upload-artifact@v4
76
+ with:
77
+ name: tree-police-${{ matrix.target }}
78
+ path: dist/*.tar.gz
79
+
80
+ release:
81
+ needs: [build-linux, build-macos]
82
+ runs-on: ubuntu-latest
83
+ steps:
84
+ - uses: actions/checkout@v4
85
+ - uses: DeterminateSystems/nix-installer-action@main
86
+ - uses: DeterminateSystems/magic-nix-cache-action@main
87
+
88
+ # Same CycloneDX SBOM attached to the Docker image (release.yml) --
89
+ # describes the Cargo dependency graph, which is identical across all
90
+ # four binaries above, so one SBOM covers the whole release.
91
+ - name: Build SBOM (nix)
92
+ run: |
93
+ nix build .#sbom -o sbom-result
94
+ cp sbom-result/bom.json tree-police.cdx.json
95
+
96
+ - uses: actions/download-artifact@v4
97
+ with:
98
+ path: dist
99
+ pattern: tree-police-*
100
+ merge-multiple: true
101
+
102
+ - name: Checksums
103
+ run: (cd dist && sha256sum -- *.tar.gz > checksums.txt)
104
+
105
+ - name: Create release
106
+ run: |
107
+ gh release create "${{ github.ref_name }}" \
108
+ dist/*.tar.gz dist/checksums.txt tree-police.cdx.json \
109
+ --title "${{ github.ref_name }}" \
110
+ --generate-notes
111
+ env:
112
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,152 @@
1
+ name: PyPI release
2
+
3
+ # Wheels that wrap the `tree-police` binary (maturin `bindings = "bin"`, see
4
+ # python/pyproject.toml), so `pip install tree-police` / `uv tool install
5
+ # tree-police` works without Docker or a Rust toolchain. Published only from
6
+ # a version tag (same trigger as cli-release.yml). Pull requests that touch
7
+ # the packaging, and manual runs, build and smoke-test every wheel without
8
+ # publishing.
9
+ on:
10
+ push:
11
+ tags: ["v*"]
12
+ pull_request:
13
+ paths:
14
+ - pyproject.toml
15
+ - python/**
16
+ - .pre-commit-hooks.yaml
17
+ - .github/workflows/pypi-release.yml
18
+ workflow_dispatch:
19
+
20
+ permissions:
21
+ contents: read
22
+
23
+ jobs:
24
+ linux:
25
+ # manylinux wheels are for glibc distros, musllinux ones for Alpine & co.
26
+ # Not the Nix musl-static build cli-release.yml uses: pip only installs a
27
+ # wheel whose platform tag matches the host, so glibc hosts need a
28
+ # manylinux-tagged build from maturin's manylinux containers. aarch64
29
+ # builds natively on GitHub's arm runner rather than cross-compiling the
30
+ # C/C++ grammars.
31
+ strategy:
32
+ fail-fast: false
33
+ matrix:
34
+ include:
35
+ - { runner: ubuntu-latest, target: x86_64, manylinux: "2_28" }
36
+ - { runner: ubuntu-24.04-arm, target: aarch64, manylinux: "2_28" }
37
+ - { runner: ubuntu-latest, target: x86_64, manylinux: musllinux_1_2 }
38
+ - { runner: ubuntu-24.04-arm, target: aarch64, manylinux: musllinux_1_2 }
39
+ runs-on: ${{ matrix.runner }}
40
+ steps:
41
+ - uses: actions/checkout@v4
42
+
43
+ - name: Build wheel
44
+ uses: PyO3/maturin-action@v1
45
+ with:
46
+ target: ${{ matrix.target }}
47
+ manylinux: ${{ matrix.manylinux }}
48
+ working-directory: python
49
+ args: --release --out ../dist
50
+ sccache: "true"
51
+
52
+ # The runner is glibc, so only the manylinux wheel installs on it.
53
+ - name: Smoke test
54
+ if: matrix.manylinux != 'musllinux_1_2'
55
+ run: |
56
+ python3 -m venv .venv
57
+ .venv/bin/pip install --no-index --find-links dist tree-police
58
+ .venv/bin/tree-police --version
59
+ .venv/bin/tree-police --list-rules >/dev/null
60
+
61
+ # The `tree-police` hook (.pre-commit-hooks.yaml) installs the root
62
+ # pyproject.toml stub, which pins this version on PyPI. Point pip at the
63
+ # wheel just built instead, so the hook is tested before publishing.
64
+ - name: Pre-commit hook smoke test
65
+ if: matrix.target == 'x86_64' && matrix.manylinux == '2_28'
66
+ env:
67
+ PIP_FIND_LINKS: ${{ github.workspace }}/dist
68
+ run: |
69
+ .venv/bin/pip install pre-commit
70
+ .venv/bin/pre-commit try-repo . tree-police \
71
+ --files tests/fixtures/python/clean.py tests/fixtures/terraform/clean.tf
72
+
73
+ - uses: actions/upload-artifact@v4
74
+ with:
75
+ name: wheels-linux-${{ matrix.target }}-${{ matrix.manylinux }}
76
+ path: dist/*.whl
77
+
78
+ macos:
79
+ # Same native Intel / Apple Silicon split as cli-release.yml.
80
+ strategy:
81
+ fail-fast: false
82
+ matrix:
83
+ include:
84
+ - { runner: macos-15-intel, target: x86_64 }
85
+ - { runner: macos-14, target: aarch64 }
86
+ runs-on: ${{ matrix.runner }}
87
+ env:
88
+ # Otherwise the wheel is tagged for the build host's macOS (a local
89
+ # build on macOS 14 came out as macosx_14_0) and pip refuses it on
90
+ # anything older. 11.0 is the first Apple Silicon release.
91
+ MACOSX_DEPLOYMENT_TARGET: ${{ matrix.target == 'x86_64' && '10.13' || '11.0' }}
92
+ steps:
93
+ - uses: actions/checkout@v4
94
+
95
+ - name: Build wheel
96
+ uses: PyO3/maturin-action@v1
97
+ with:
98
+ target: ${{ matrix.target }}
99
+ working-directory: python
100
+ args: --release --out ../dist
101
+ sccache: "true"
102
+
103
+ - name: Smoke test
104
+ run: |
105
+ python3 -m venv .venv
106
+ .venv/bin/pip install --no-index --find-links dist tree-police
107
+ .venv/bin/tree-police --version
108
+ .venv/bin/tree-police --list-rules >/dev/null
109
+
110
+ - uses: actions/upload-artifact@v4
111
+ with:
112
+ name: wheels-macos-${{ matrix.target }}
113
+ path: dist/*.whl
114
+
115
+ sdist:
116
+ # Fallback for platforms without a wheel (needs Rust + a C/C++ compiler
117
+ # on the installing machine).
118
+ runs-on: ubuntu-latest
119
+ steps:
120
+ - uses: actions/checkout@v4
121
+
122
+ - name: Build sdist
123
+ uses: PyO3/maturin-action@v1
124
+ with:
125
+ command: sdist
126
+ working-directory: python
127
+ args: --out ../dist
128
+
129
+ - uses: actions/upload-artifact@v4
130
+ with:
131
+ name: sdist
132
+ path: dist/*.tar.gz
133
+
134
+ publish:
135
+ # PyPI trusted publishing (OIDC) -- no API token stored in the repo. Needs
136
+ # a one-time trusted-publisher entry on PyPI for this repo, this workflow
137
+ # file and the `pypi` environment.
138
+ if: startsWith(github.ref, 'refs/tags/v')
139
+ needs: [linux, macos, sdist]
140
+ runs-on: ubuntu-latest
141
+ environment:
142
+ name: pypi
143
+ url: https://pypi.org/project/tree-police/
144
+ permissions:
145
+ id-token: write
146
+ steps:
147
+ - uses: actions/download-artifact@v4
148
+ with:
149
+ path: dist
150
+ merge-multiple: true
151
+
152
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,81 @@
1
+ name: Build and publish image
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ tags: ["v*"]
7
+
8
+ permissions:
9
+ contents: read
10
+ packages: write
11
+
12
+ jobs:
13
+ publish:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - uses: DeterminateSystems/nix-installer-action@main
19
+ - uses: DeterminateSystems/magic-nix-cache-action@main
20
+
21
+ # Cross-compiled via Nix, not Docker buildx/QEMU: each is a genuine
22
+ # musl-static binary for its target, built once on this amd64 runner.
23
+ - name: Build static binaries (musl cross, both arches)
24
+ run: |
25
+ nix build .#tree-police-static-x86_64 -o result-amd64
26
+ nix build .#tree-police-static-aarch64 -o result-arm64
27
+
28
+ - name: Stage build context
29
+ run: |
30
+ mkdir -p dist/amd64 dist/arm64
31
+ install -m755 result-amd64/bin/tree-police dist/amd64/tree-police
32
+ install -m755 result-arm64/bin/tree-police dist/arm64/tree-police
33
+
34
+ # CycloneDX SBOM of the exact dependency set in Cargo.lock, built the
35
+ # same reproducible way as the binaries (see flake.nix's `sbom`
36
+ # package). One SBOM covers both architectures -- it describes the
37
+ # Cargo dependency graph, not per-arch binary contents.
38
+ - name: Build SBOM (nix)
39
+ run: nix build .#sbom -o sbom-result
40
+
41
+ - uses: docker/login-action@v3
42
+ with:
43
+ registry: ghcr.io
44
+ username: ${{ github.actor }}
45
+ password: ${{ secrets.GITHUB_TOKEN }}
46
+
47
+ - uses: docker/metadata-action@v5
48
+ id: meta
49
+ with:
50
+ images: ghcr.io/${{ github.repository }}
51
+ tags: |
52
+ type=raw,value=latest,enable={{is_default_branch}}
53
+ type=semver,pattern={{version}}
54
+
55
+ # Only sets up the container driver so buildx can assemble a
56
+ # multi-platform manifest list; no QEMU needed since the Dockerfile
57
+ # just COPYs a prebuilt static binary per `TARGETARCH` -- no RUN step
58
+ # ever executes in the target architecture.
59
+ - uses: docker/setup-buildx-action@v3
60
+
61
+ - uses: docker/build-push-action@v6
62
+ id: push
63
+ with:
64
+ context: .
65
+ platforms: linux/amd64,linux/arm64
66
+ push: true
67
+ tags: ${{ steps.meta.outputs.tags }}
68
+ labels: ${{ steps.meta.outputs.labels }}
69
+
70
+ - uses: sigstore/cosign-installer@v3
71
+
72
+ # Attaches to the exact digest just pushed (not a mutable tag), as an
73
+ # OCI referrer in the same registry -- `cosign download sbom
74
+ # ghcr.io/<repo>@<digest>` (or any OCI-referrers-aware scanner) finds
75
+ # it from the image alone, no separate URL to track.
76
+ - name: Attach SBOM to published image
77
+ run: |
78
+ cosign attach sbom \
79
+ --type cyclonedx \
80
+ --sbom sbom-result/bom.json \
81
+ "ghcr.io/${{ github.repository }}@${{ steps.push.outputs.digest }}"
@@ -0,0 +1,12 @@
1
+ .direnv/
2
+ result
3
+ sbom-result
4
+
5
+ # Rust build artifacts (Cargo.lock is committed)
6
+ /target/
7
+
8
+ # docker-build staging dir (dereferenced copy of result/bin/tree-police)
9
+ /dist/
10
+
11
+ # generated by `just sbom` (see flake.nix's `sbom` package)
12
+ /sbom.cdx.json
@@ -0,0 +1,16 @@
1
+ {
2
+ // THIRD-PARTY-LICENSES.md is generated (`just third-party-licenses`) from
3
+ // upstream crates' own license texts -- not hand-authored prose, and not
4
+ // ours to reformat.
5
+ "ignores": ["THIRD-PARTY-LICENSES.md"],
6
+ "config": {
7
+ // Hard 80-column wrapping doesn't suit docs full of inline code, links,
8
+ // and tables; line content matters more than column count here.
9
+ "MD013": false,
10
+ // README.md's logo <img> comes before the H1 on purpose (float it
11
+ // right of the title); both rules exist to catch accidental HTML/
12
+ // missing titles, not this deliberate pattern.
13
+ "MD033": false,
14
+ "MD041": false
15
+ }
16
+ }
@@ -0,0 +1,57 @@
1
+ # This repo's own dev-workflow checks (not to be confused with
2
+ # .pre-commit-hooks.yaml, which is what makes *this repo* usable as a
3
+ # pre-commit hook source for other repos' Python/Terraform/etc. scanning).
4
+ #
5
+ # All hooks use `language: system` and run straight off the Nix devShell's
6
+ # PATH (see flake.nix) -- no separate per-hook virtualenv/npm install, and
7
+ # no tool version drifts from what `direnv allow`/`nix develop` already
8
+ # pins. Run `direnv allow` (or `nix develop`) once, then `pre-commit install`.
9
+ repos:
10
+ - repo: local
11
+ hooks:
12
+ - id: cargo-fmt
13
+ name: cargo fmt
14
+ language: system
15
+ entry: cargo fmt -- --check
16
+ files: \.rs$
17
+ pass_filenames: false
18
+
19
+ - id: cargo-clippy
20
+ name: cargo clippy (deny warnings)
21
+ language: system
22
+ entry: cargo clippy --all-targets --release -- --deny warnings
23
+ files: \.rs$
24
+ pass_filenames: false
25
+
26
+ - id: nix-fmt
27
+ name: alejandra (nix fmt, check only)
28
+ language: system
29
+ entry: alejandra --check
30
+ files: \.nix$
31
+
32
+ - id: nix-statix
33
+ name: statix (nix lint)
34
+ language: system
35
+ entry: statix check
36
+ files: \.nix$
37
+ pass_filenames: false
38
+
39
+ - id: nix-deadnix
40
+ name: deadnix (nix dead code)
41
+ language: system
42
+ entry: deadnix --fail
43
+ files: \.nix$
44
+ pass_filenames: false
45
+
46
+ - id: markdownlint
47
+ name: markdownlint-cli2
48
+ language: system
49
+ entry: markdownlint-cli2
50
+ files: \.md$
51
+
52
+ - id: actionlint
53
+ name: actionlint (GitHub Actions workflows)
54
+ language: system
55
+ entry: actionlint
56
+ files: ^\.github/workflows/.*\.ya?ml$
57
+ pass_filenames: false
@@ -0,0 +1,24 @@
1
+ # Installs the prebuilt `tree-police` wheel from PyPI via the stub package in
2
+ # the root pyproject.toml -- needs only Python, no Docker, no Rust toolchain.
3
+ - id: tree-police
4
+ name: tree-police (AST pattern scan)
5
+ description: >-
6
+ Scan source files for forbidden AST patterns via tree-sitter queries.
7
+ Drop extra rules in a repo-local .tree-police/ directory to extend the
8
+ built-in ruleset with no image rebuild.
9
+ entry: tree-police --fail-on error
10
+ language: python
11
+ pass_filenames: true
12
+ # The binary already scans files in parallel; one process per batch of
13
+ # filenames is plenty.
14
+ require_serial: true
15
+
16
+ # Same scan via the ghcr.io image, for machines that prefer Docker over a
17
+ # Python environment.
18
+ - id: tree-police-docker
19
+ name: tree-police (AST pattern scan, Docker)
20
+ description: >-
21
+ Same as `tree-police`, run from the ghcr.io/imochoa/tree-police image.
22
+ entry: ghcr.io/imochoa/tree-police:latest --fail-on error
23
+ language: docker_image
24
+ pass_filenames: true
@@ -0,0 +1,191 @@
1
+ # AGENTS.md (tree-police)
2
+
3
+ Guide for AI coding agents working on `tree-police`. Human docs live in
4
+ [`README.md`](README.md) and [`queries/README.md`](queries/README.md); this
5
+ file is the fast path.
6
+
7
+ ## What this is
8
+
9
+ A declarative linter, embeddable as a pre-commit `docker_image` hook: `.scm`
10
+ query files in `queries/` are compiled and embedded into the `tree-police`
11
+ binary at build time (`include_dir!` in `src/rules.rs`). Parses each file
12
+ once, in parallel (`ignore` + `rayon`), with severity levels and
13
+ pretty/JSON/JSONL output.
14
+
15
+ Rules currently cover Python and Terraform/HCL. `src/registry.rs` also bakes
16
+ in 90 other grammars (92 languages total) with no rule files yet — every
17
+ tree-sitter grammar crate on crates.io compatible with this crate's pinned
18
+ `tree-sitter` core version (see the comment above `registry::LANGUAGES` for
19
+ what's excluded and why). A new rule file is all that's needed to start
20
+ covering one of them, no Cargo/registry change required, and a pre-commit
21
+ hook can pass every changed file without its own file-type allowlist
22
+ (unrecognized extensions are just skipped).
23
+
24
+ ## Getting started
25
+
26
+ ```bash
27
+ direnv allow # or: nix develop
28
+ ```
29
+
30
+ The dev shell provides the Rust toolchain (`rustc`, `cargo`, `clippy`,
31
+ `rustfmt`), a C/C++ compiler (the grammar crates compile their grammars via
32
+ their own `build.rs`), and `just`.
33
+
34
+ ## Commands
35
+
36
+ All commands are recipes in the [`justfile`](justfile). Exit codes: `0` =
37
+ no findings, `1` = findings detected (suitable for CI gates), `2` = internal error.
38
+
39
+ | Command | Purpose |
40
+ | --- | --- |
41
+ | `just build` | `cargo build --release`. |
42
+ | `just scan-bin [args]` | Run `tree-police`. Args pass through, e.g. `just scan-bin --format json src/`. |
43
+ | `just query-test` | `cargo test --test query_cases` — per-rule annotated fixtures; see below. |
44
+ | `just test` | `cargo test` — unit tests + black-box CLI tests (`tests/cli.rs`) + query-case tests. |
45
+ | `just docker-build` | `nix build .#tree-police-static`, then `podman build` a `FROM scratch` image. |
46
+ | `just docker-run [args]` | Run the built image (`-t` for color; see the recipe comment). |
47
+ | `just wheel` | `maturin build --release`: the binary as a Python wheel in `target/wheels/` (see `python/pyproject.toml`). |
48
+ | `just third-party-licenses` | Regenerate `THIRD-PARTY-LICENSES.md` from `Cargo.lock`. |
49
+ | `just sbom` | Build a CycloneDX SBOM (`sbom.cdx.json`) via Nix. |
50
+ | `just release X.Y.Z` | Bump the version, run the test/lint gate, commit, tag `vX.Y.Z`, and push — triggers the Docker, GitHub Release and PyPI workflows. Requires a clean `main` with no existing tag of that name. |
51
+
52
+ ## Layout
53
+
54
+ ```text
55
+ .
56
+ ├── flake.nix # devShell: Rust toolchain; tree-police-static (musl) package
57
+ ├── Cargo.toml / Cargo.lock # crate manifest (lockfile committed)
58
+ ├── Dockerfile / .dockerignore
59
+ ├── pyproject.toml # pre-commit hook stub: only pins the `tree-police` PyPI wheel
60
+ ├── python/pyproject.toml # maturin `bindings = "bin"`: ships the binary as a PyPI wheel
61
+ ├── .pre-commit-hooks.yaml # `tree-police` (python, PyPI wheel) + `tree-police-docker` hooks
62
+ ├── .github/workflows/ # image (ghcr.io), CLI tarballs (GitHub Release), wheels (PyPI)
63
+ ├── src/ # main, registry, rules, scan, report, severity, config, suppress
64
+ ├── tests/cli.rs # black-box CLI tests
65
+ ├── tests/query_cases.rs # per-rule annotated fixture tests (see below)
66
+ ├── .envrc # direnv: `use flake`
67
+ ├── justfile # build / scan-bin / test / docker-* commands
68
+ ├── queries/ # flat: <label>-<code>.scm, see queries/README.md
69
+ │ ├── rules-py.scm # categories: forbidden, security, temporal, web
70
+ │ └── rules-tf.scm # categories: naming, conventions, security
71
+ ├── tests/fixtures/ # whole-file good/bad samples for tests/cli.rs
72
+ │ ├── python/
73
+ │ └── terraform/
74
+ ├── tests/query-cases/ # per-rule annotated fixtures for `just query-test`
75
+ │ └── python/
76
+ ├── README.md
77
+ └── queries/README.md # rule conventions
78
+ ```
79
+
80
+ ## Adding a rule
81
+
82
+ 1. Add a pattern to `queries/<label>-<code>.scm` (`<code>` from
83
+ `src/registry.rs`, e.g. `py`, `tf`); embedded on the next `cargo build`.
84
+ 2. Give each pattern exactly **one** non-`_` `@capture` — it is the rule ID in output (e.g. `@s3_bucket_missing_sf_prefix`). Prefix every predicate-only helper capture with `_` (e.g. `@_block_type`) so `tree-police` doesn't report it. Declare severity with `(#set! severity "error|warning|log")` (defaults to `warning`); optionally add `(#set! category "…")` (groups `--list-rules` output) and `(#set! description "…")` (shown per-finding and in `--list-rules`).
85
+ 3. Add fixtures under `tests/fixtures/<language>/`:
86
+ - `*_violations.<ext>` — code that must trigger captures.
87
+ - `clean.<ext>` — code that must produce zero captures.
88
+ 4. Add an assertion to `tests/cli.rs` for the new rule.
89
+ 5. Add a `tests/query-cases/<language>/<topic>.<ext>` fixture (see below) and run `just query-test`.
90
+
91
+ ## Query-case tests
92
+
93
+ `tests/query-cases/<lang>/<topic>.<ext>` are annotated fixtures, grouped by
94
+ topic/category rather than by `.scm` file (there's one `.scm` file per
95
+ language), checked by `tests/query_cases.rs` (`just query-test`, wired into
96
+ `just test`). A comment names the capture expected on the line directly
97
+ above it:
98
+
99
+ ```python
100
+ print("debug")
101
+ # <- @forbidden_print
102
+ ```
103
+
104
+ Unlike `tree-sitter query --test` (same comment syntax, but it mis-pairs
105
+ assertions once a query file defines more than one distinct capture name —
106
+ confirmed by testing, not a guess), this harness checks each assertion
107
+ against the actual capture spans directly, and — the other direction —
108
+ fails if any capture fires on a line with no matching assertion. That gives
109
+ both "should match" and "should not match" coverage from one file, which
110
+ whole-file `tests/fixtures/*` checks don't: a rule firing on the wrong line
111
+ of a big fixture still passes a file-level `assert_clean`/`assert_match`.
112
+
113
+ An unannotated line is an *implicit* "nothing should fire here" assertion —
114
+ there's no separate negative-assertion syntax, and no need for one. Put each
115
+ rule's positive example directly above its negative counterexample (not in
116
+ a separate "clean" section at the bottom); see any file under
117
+ `tests/query-cases/python/` for the pattern.
118
+
119
+ It runs the **compiled `tree-police` matcher** directly (via
120
+ `tree_police::rules::load()`), not a subprocess. It caught a real bug when
121
+ this engine's rule files were consolidated to one per language: because
122
+ every rule now applies to every fixture, a rule's negative example in one
123
+ topic's fixture started tripping an unrelated rule from another category —
124
+ see the cross-category caveat in `queries/README.md`.
125
+
126
+ ## Adding a language
127
+
128
+ 1. Add the `tree-sitter-<lang>` crate to `Cargo.toml` and one `LangSpec`
129
+ entry (`name`, `code`, `extensions`, `LANGUAGE.into()`) to
130
+ [`src/registry.rs`](src/registry.rs).
131
+ 2. Add `queries/<label>-<code>.scm` rule file(s), matching the new `code`.
132
+ 3. Add fixtures and `tests/cli.rs` assertions as above.
133
+
134
+ Adding just the grammar + registry entry with no rule file yet is fine and
135
+ already done for several languages (see "What this is") — it costs nothing
136
+ at scan time (files with no rules for their language are skipped) and means
137
+ a future rule file needs no Cargo/registry change.
138
+
139
+ ## Path scoping and suppressions
140
+
141
+ `src/config.rs` loads `.tree-police/config.toml` (or `--config FILE`). In it,
142
+ `[[scope]]` entries restrict the selected rules to `paths` and/or keep them
143
+ out of `exclude`, and `[[override]]` entries change severity per path.
144
+ `src/suppress.rs` reads `tree-police: ignore` / `ignore-next-line` /
145
+ `ignore-file` directives from comment nodes. Both run per file inside
146
+ `scan::scan_file`, after matching. Selectors (`rule_id`,
147
+ `category:<name>`, `file:<query-file-name>`) are checked against
148
+ `config::Catalog`. An unknown selector is a hard error in the config but
149
+ only a warning in a comment. User docs are in README "Config file" and
150
+ "Suppressing findings".
151
+
152
+ Renaming a rule ID or category also breaks any config file or suppression
153
+ comment that refers to it, which is one more reason to keep them stable.
154
+
155
+ ## Gotchas
156
+
157
+ - **The root `pyproject.toml` is not the real package.** pre-commit/prek install a `language: python` hook with `pip install .` on the repo root, so the root holds a stub that only depends on `tree-police==<version>` from PyPI. That way hook users get the prebuilt wheel instead of compiling 92 grammars. The maturin config lives in `python/pyproject.toml` (README and licenses are symlinked in). `just release` bumps the stub's pin, and `tests/packaging.rs` fails if it drifts from `Cargo.toml`. A new tag's hook can only be installed once `pypi-release.yml` has published that version.
158
+
159
+ - **No regex lookaheads.** Rust's `regex` crate rejects `(?!...)` / `(?=...)`. Split into separate `#not-match?` / `#match?` predicates on the same capture instead.
160
+ - **A predicate can't reference the capture on its own enclosing node.** A node's capture is only declared at its closing paren, so a predicate that's one of that node's *own children* comes before the capture exists, textually — `(node (child) (#pred? @x) ) @x` fails to compile ("Invalid capture name"). Wrap the captured node and the predicate together in one more pair of parens instead — `((node (child)) @x (#pred? @x ...))` — so they become siblings at the outer level, where ordering works. See `missing_route_validation` in `queries/rules-py.scm` (and `bare_except`/`todo`, which use the same wrapping).
161
+ - **Only literal strings are visible to queries.** Interpolated expressions (`"${var.env}-thing"`) aren't matched against string predicates — those need runtime validation elsewhere.
162
+ - **`#set!` severity + `_`-helper captures are load-bearing.** The one non-`_` capture per pattern is the reported rule ID; severity comes from `(#set! severity …)` (default `warning`).
163
+ - **Non-core predicates don't just go unenforced — they zero out the whole match.** `#not-has-ancestor?` and similar are silently treated as unsatisfied by the embedded `tree_sitter::QueryCursor` matcher, dropping the match entirely rather than ignoring the predicate. Confirmed by testing (`tests/query_cases.rs`), not documented upstream. Avoid them.
164
+ - **Rule IDs and `category` values are stable identifiers** — referenced by CI/config elsewhere, don't rename without a coordinated change.
165
+ - **A query filename's code must match a registered `LangSpec::code`, or the binary fails to build the ruleset** — both for embedded queries and for `--rules-dir` repo-local ones. This is deliberate (fail fast on a typo) rather than silently dropping the file.
166
+ - **`--list-rules`'s catalog-building text scan skips `;` comments and `"..."` string literals, nothing else.** `rules.rs::primary_capture_in` recovers a pattern's rule ID by scanning the raw source slice from one pattern's start byte to the *next* pattern's start byte for the last `@word`-shaped mention. Two confirmed bugs from this, both fixed with regression tests in `rules.rs`: a trailing comment reading "isn't @activity/@workflow decorated" got its `@workflow` mistaken for the *preceding* pattern's rule ID (misreported `os_popen_usage` as `workflow`); a `(#not-match? ... "@validate\\(")` regex argument got its `@validate` mistaken the same way (misreported `missing_route_validation` as `validate`). Both are skipped now — but nothing else is, so a future `@word`-shaped mention anywhere else non-obvious could reproduce this. If `--list-rules` ever shows a rule ID that doesn't match the capture you wrote, check here first.
167
+
168
+ ## Conventions when editing queries
169
+
170
+ - Prefer declarative predicates (`#eq?`, `#match?`, `#not-match?`) over broad structural matches.
171
+ - Capture names are the rule ID surfaced to users — make them descriptive (`hardcoded_provider_credentials`, not `bad1`).
172
+ - Keep one logical rule per capture name; combine predicates rather than duplicating patterns.
173
+ - Terraform rules are illustrative examples of naming/convention/security checks (e.g. a placeholder `acme-` bucket prefix, `-role`/`-sg` suffixes) — swap them for your own org's conventions rather than assuming they apply as-is.
174
+
175
+ ## Credits
176
+
177
+ This crate's Rust architecture (parallel `ignore`+`rayon` scanning,
178
+ git-ignore awareness, embedded-grammar-per-language design, the `_`-prefix
179
+ capture convention) is built on and adapted from
180
+ [tree-grepper](https://github.com/BrianHicks/tree-grepper) (archived) by
181
+ Brian Hicks — it is the source and foundation for most of `src/`'s shape.
182
+ Note tree-grepper is Hippocratic-licensed, not standard OSS; see the
183
+ "Credits"/"License" sections in [`README.md`](README.md).
184
+
185
+ ## References
186
+
187
+ - [`README.md`](README.md) — setup, pre-commit quickstart, repo-local rules, and credits/license.
188
+ - [`queries/README.md`](queries/README.md) — rule conventions.
189
+ - [Tree-sitter query syntax](https://tree-sitter.github.io/tree-sitter/using-parsers/queries/)
190
+ - [tree-sitter-python grammar](https://github.com/tree-sitter/tree-sitter-python)
191
+ - [tree-sitter-hcl grammar](https://github.com/tree-sitter-grammars/tree-sitter-hcl)