# CarboniteLint
[](https://hex.pm/packages/carbonite_lint)
[](https://github.com/tommeier/carbonite_lint/actions/workflows/ci.yml)
[](https://opensource.org/licenses/MIT)
Catch missing [Carbonite](https://hex.pm/packages/carbonite) audit transactions before they hit production.
## The problem
Carbonite uses PostgreSQL triggers to enforce that every mutation on a tracked table
is preceded by an `INSERT INTO carbonite_default.transactions`. If you forget, the
trigger raises a `foreign_key_violation` at runtime.
But test suites disable these triggers (via `override_mode = 'ignore'`) so that
factory inserts don't need audit transactions. This creates a blind spot: a bare
`Repo.update()` on a tracked table passes all tests, then blows up in production.
## How it works
CarboniteLint catches these gaps with static analysis:
1. **Discovers** tracked tables by querying `information_schema.triggers` — the
database is the single source of truth. No configuration to maintain.
2. **Maps** table names to Ecto schema modules via `__schema__(:source)`.
3. **Parses** each source file's AST to find functions with bare `Repo.update/insert/delete`
calls whose arguments reference a tracked schema.
4. **Reports** functions that lack a `Carbonite.Multi.insert_transaction` call (or your
configured audit wrapper).
When you add a new Carbonite trigger via migration, CarboniteLint automatically checks
all write paths to that table. Nothing to update.
## Installation
Add `carbonite_lint` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:carbonite_lint, "~> 0.1", only: :test}
]
end
```
## Usage
Add a test that runs the lint check:
```elixir
defmodule MyApp.CarboniteLintTest do
use ExUnit.Case
test "all mutations on tracked tables have audit wrappers" do
assert [] =
CarboniteLint.run(
repo: MyApp.Repo,
otp_app: :my_app
)
end
end
```
That's it. If someone adds a bare `Repo.update` on a tracked table without wrapping
it in `Carbonite.Multi.insert_transaction`, the test fails with a clear message:
```
Found 1 unaudited Carbonite mutation.
Every Repo.update/insert/delete on a Carbonite-tracked table must be preceded
by a Carbonite.Multi.insert_transaction/2 call (or your configured audit wrapper).
lib/my_app/users.ex:42 update_email mutates MyApp.Users.User
```
### Custom audit wrappers
If you wrap Carbonite calls in your own module (e.g., `MyApp.Audit.multi/2`), add
the patterns:
```elixir
CarboniteLint.run(
repo: MyApp.Repo,
otp_app: :my_app,
audit_patterns: [
~r/Carbonite\.Multi\.insert_transaction\(/,
~r/Carbonite\.insert_transaction\(/,
~r/Audit\.multi\(/,
~r/Audit\.without_triggers\(/,
~r/Audit\.disable_triggers\(/
]
)
```
### Runtime enforcement
Use `with_enforcement/2` in individual tests to verify a specific function creates
an audit transaction with triggers enabled:
```elixir
test "update_user creates audit trail" do
user = UserFactory.insert!() # triggers disabled — factory works
CarboniteLint.with_enforcement(MyApp.Repo, fn ->
assert {:ok, _} = MyApp.Users.update_user(user, %{name: "New"})
end)
end
```
### Excluding paths
Skip generated code or vendored directories:
```elixir
CarboniteLint.run(
repo: MyApp.Repo,
otp_app: :my_app,
exclude_paths: ["lib/my_app_web/", "lib/my_app/generated/"]
)
```
## Options
| Option | Required | Default | Description |
|--------|----------|---------|-------------|
| `:repo` | yes | — | Your Ecto Repo module |
| `:otp_app` | yes | — | OTP application for module discovery |
| `:audit_patterns` | no | Carbonite defaults | List of regexes matching audit wrapper calls |
| `:paths` | no | `["lib/"]` | Source paths to scan |
| `:exclude_paths` | no | `[]` | Path substrings to skip |
| `:table_prefix` | no | `"public"` | PostgreSQL schema where tracked tables live |
## Limitations
- **`Repo.delete(variable)`** — when a tracked struct is passed as a bare variable
(not a changeset or struct literal), the scanner can't infer its type. In practice,
deletions on tracked tables should use `Ecto.Multi` + `Carbonite.Multi.insert_transaction`.
- **Indirect mutations** — if a changeset is created in one function and passed to
another that calls `Repo.update`, the scanner won't connect the two. The convention
of creating changesets and calling Repo in the same function covers most real-world code.
- **Ordering** — the scanner checks that a function has *both* an audit call and a
Repo mutation, but not that the audit call comes first. In practice, `Ecto.Multi`
pipelines enforce this naturally.
## Contributing
```bash
git clone https://github.com/tommeier/carbonite_lint
cd carbonite_lint
mix deps.get
mix test
```
Tests require PostgreSQL 13+ (user: `postgres`, password: `postgres`).
Tool versions are managed by [mise](https://mise.jdx.dev/) — run `mise install` to match CI.
## Releasing
1. Update `@version` in `mix.exs`
2. Update `CHANGELOG.md`
3. Update version in README installation section
4. Commit: `git commit -am "Release vX.Y.Z"`
5. Tag: `git tag vX.Y.Z`
6. Push: `git push origin main --tags`
7. GitHub Actions creates the release from the changelog
8. `mix hex.publish && mix hex.publish docs`
## License
MIT — see [LICENSE](LICENSE).