# Commit message convention

Spanlens uses [Conventional Commits](https://www.conventionalcommits.org/) so
the changelog can be generated mechanically and `main`'s history reads cleanly
when squash-merged. This document is the project-specific layer on top of
that spec — what `type` and `scope` values we actually use, how the body
should be structured, and a handful of worked examples.

---

## Format

```
<type>(<scope>): <subject>

<body — wrap at ~72 cols, explain WHY>

<footer — refs, breaking changes, co-authors>
```

- **type** required, lowercase, single word from the list below
- **scope** required when it's not obvious, lowercase, single segment
- **subject** required, imperative mood ("add", not "added" / "adds"),
  ≤ 72 chars, no trailing period
- **body** optional but strongly preferred for non-trivial changes — describes
  the reasoning, not the diff
- **footer** optional; `Closes #N`, `BREAKING CHANGE:`, `Co-Authored-By:`

---

## Allowed types

| type | When |
|------|------|
| `feat` | New user-visible capability or new API surface |
| `fix` | Bug fix that changes runtime behavior |
| `refactor` | Code restructure with no behavior change |
| `perf` | Performance improvement |
| `test` | Test-only change (no production code change) |
| `docs` | Documentation, comments, runbooks |
| `chore` | Maintenance — dep bumps, CI tweaks, build config |
| `ci` | CI workflow changes specifically |
| `style` | Formatting, whitespace, no semantic change |
| `build` | Build system or external dependency changes |
| `revert` | Reverts a previous commit (auto-generated by `git revert`) |

If a change spans multiple types, pick the one that captures the customer
impact. A `feat` that includes its own tests is still `feat`, not `test`.

---

## Allowed scopes

Pick the closest match. The scope should narrow the diff to one mental area.

| scope | Area |
|-------|------|
| `web` | `apps/web` (Next.js dashboard, marketing pages) |
| `server` | `apps/server` (Hono REST + LLM proxy) |
| `proxy` | Specifically `apps/server/src/proxy/*` |
| `api` | Specifically `apps/server/src/api/*` |
| `sdk` | `packages/sdk` (TypeScript SDK) |
| `sdk-python` | `packages/sdk-python` |
| `cli` | `packages/cli` |
| `db` | Supabase migrations or schema changes |
| `clickhouse` | ClickHouse migrations or query patterns |
| `billing` | Paddle, plan logic, overage |
| `anomaly` | Anomaly detection pipeline |
| `auth` | Authentication / authorization middleware |
| `infra` | Vercel config, Docker, deployment scripts |
| `legal` | Privacy / ToS / DPA / Subprocessors |
| `docs` | `docs/`, `*.md` |
| `deps` | Dependency bumps that don't fit another scope |

A multi-area change can omit the scope: `feat: add organization invitations`.
But don't omit it just to avoid the choice — if you're touching `apps/server`
and nothing else, write `feat(server):`.

---

## Subject line

- Imperative mood. Pretend you're issuing a command to the codebase.
  - ✅ `feat(api): add cron auth helper`
  - ❌ `feat(api): added cron auth helper`
  - ❌ `feat(api): adding cron auth helper`
- No trailing period.
- Concrete enough that someone scanning `git log --oneline` understands what
  changed. `fix(server): handle stream timeout` beats `fix(server): bug fix`.

---

## Body

The body explains **why**. The diff already shows what. Aim for:

1. One sentence stating the problem the change solves.
2. A short paragraph on the approach, with the alternatives considered if the
   trade-off isn't obvious.
3. Anything a reviewer needs to know that isn't in the diff (config changes
   that need to land in production, a follow-up that's intentionally
   deferred, an existing test that exercises the new path).

Skip the body if the diff is trivially self-explanatory (a typo fix, a
README link update). Otherwise write it — six months from now it's the only
record of why the code looks this way.

---

## Worked examples

### Good

```
fix(clickhouse): skip unknown INSERT fields to survive deploy→migration window

P2.2 (PR #82) added a `truncated` field to every ClickHouse INSERT in
logger.ts. If the deploy lands before `002_add_truncated.sql` is applied,
every `requests` INSERT throws `Unknown field 'truncated'` and ALL logging
stops, streaming + non-streaming alike — a logging outage hidden behind
a "harmless" column add.

Fix: set `input_format_skip_unknown_fields: 1` in the singleton ClickHouse
client. Unknown columns sent on INSERT are dropped silently instead of
failing the row. Deploy → migration timing is now non-critical in both
directions.

Trade-off: a typo in a column name fails silently and the data never lands.
Mitigation: local `pnpm ch:migrate` + smoke INSERT before shipping any
new-column code. CLAUDE.md gotcha #21 documents this.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
```

### Good (small change)

```
docs(api): document /health/deep response shape
```

### Bad

```
fix: stuff
```

```
Update file
```

```
feat(server): WIP — don't merge
```

---

## Breaking changes

When a change breaks an existing consumer (SDK type signature, API response
shape, env-var name), say so in the footer:

```
feat(sdk): drop deprecated createOpenAIWithKey() helper

createOpenAI() with explicit api_key= keyword has been the recommended path
since 0.1.0 and is what the CLI generates.

BREAKING CHANGE: createOpenAIWithKey() is removed. Callers should pass
`api_key=` directly to createOpenAI().
```

Also bump the SDK version per [semver](https://semver.org/) — a breaking
change is a MAJOR bump.

---

## Squash-merge interaction

`main` only sees squash-merged commits, so the merge commit title becomes the
permanent log entry. GitHub uses the **PR title** as the commit title and the
**PR description** as the body. Write both in this format. The individual
commits on the branch can be messier; what lands on `main` is what matters.
