README.md

# CarboniteLint

[![Hex.pm](https://img.shields.io/hexpm/v/carbonite_lint.svg)](https://hex.pm/packages/carbonite_lint)
[![CI](https://github.com/tommeier/carbonite_lint/actions/workflows/ci.yml/badge.svg)](https://github.com/tommeier/carbonite_lint/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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).