# Contributing to absentia

Conventions established through working on the project. Follow them
when adding code; depart from them only with explicit justification.

For project context (what absentia is, locked-in design decisions,
repo layout, dev scripts), see [CLAUDE.md](CLAUDE.md).

---

## 1. Progress UI for waiting operations

Anything that can run for more than ~1 second from a human's
perspective gets a progress indicator. Three flavors live in
`src/absentia/progress.py`:

| Use this | When |
|---|---|
| `ProgressBar(total=N, label="…")` | The work iterates over a known total — a per-file scan loop. Renders a percentage + ETA. |
| `StepIndicator(total_steps=N, prefix="[…]")` + `ticking()` context manager | A sequence of opaque sub-tasks where each is a black box (calibration's per-jobs and per-language scans). Renders `[idx/N] label… elapsed`. |
| `Spinner(label="…")` + `spinning()` context manager | Indeterminate work (tree walks, `rglob` over `$HOME`). Animated frame + elapsed time. |

Conventions:

- All three auto-skip on non-TTY (CI logs and piped output stay
  clean). Don't gate on TTY yourself — the progress class handles
  it.
- All three throttle redraws to ~10 Hz internally; callers can
  call `update`/`tick` once per item without flooding.
- All three write to **stderr**, so a piped stdout still gets
  progress visible to the human.
- Wrap a progress callback in `try/except` at the call site —
  progress UI must never break the underlying operation.

If you're tempted to do a multi-second silent operation, you're
probably wrong.

## 2. Commit messages

Enforced by `.githooks/commit-msg`. Rules:

1. Subject ≤ 72 characters, imperative mood.
2. Blank line between subject and body when a body exists.
3. Body lines ≤ 100 characters (matches ruff line-length).
4. **`Authored-by:` trailer required** identifying the human author.
5. **`Co-Authored-By:`** added when the commit was AI-assisted.
6. One commit per logical change. Don't amend published commits.

Example:

```
Add foo to bar

Body explaining the *why* of the change. Body lines are wrapped at
100 chars to match the ruff line-length.

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

Wire the hook into a fresh clone:

```bash
ln -sf ../../.githooks/commit-msg .git/hooks/commit-msg
# or globally (also picks up pre-push, see §4):
git config core.hooksPath .githooks
```

There's also a **`pre-push` hook** at `.githooks/pre-push` that runs
`scripts/local_ci.sh` (the same checks GitHub Actions runs: ruff,
mypy, pytest + coverage gate, mkdocs --strict). The
`git config core.hooksPath .githooks` install picks both up. See §4
*Local CI before commit / push* for the manual invocation form and
the skip-once escape hatch.

## 3. Destructive operations

Any flag or subcommand that deletes data follows the
`--purge` / `--purge-all` precedent:

1. **Print a disclaimer** *before* the prompt, listing exactly
   what will be deleted and what will be preserved.
2. **Default `[y/N]`** — capital N, so a bare Enter aborts.
3. **Refuse outright in non-TTY contexts** unless the user passed
   `--yes` / `-y` to skip the prompt explicitly.
4. **Sanity-check the target** before deleting. If a `.absentia/`
   directory doesn't have the expected `version` or `state.db`,
   refuse — it might be an unrelated user file.
5. Report per-deletion failures without aborting the rest of the
   batch.

Tests pass `confirm=False` to skip the prompt; never wire tests to
piping `y` through stdin.

## 4. Local CI before commit / push

Two ways to run the full check set (the union of what `ci.yml` runs
on every push + what `release-checks.yml` runs on tag — see §9
*Cutting a release* for the CI split):

```bash
# One-shot script — orders cheapest-first so iteration is fast.
bash scripts/local_ci.sh

# Or the four commands manually:
.venv/bin/python -m ruff check .
.venv/bin/python -m mypy src/absentia
.venv/bin/python -m pytest --cov --cov-report=term-missing -q
.venv/bin/python -m mkdocs build --strict
```

The pre-push hook at `.githooks/pre-push` runs `scripts/local_ci.sh`
automatically before every `git push`, so CI failures are caught
locally before they hit the remote. Install once via
`git config core.hooksPath .githooks` (see §2 — same install picks
up commit-msg and pre-push together).

Skip the pre-push hook for a single push when you genuinely need to:

```bash
git push --no-verify       # skips all client-side hooks
bash scripts/local_ci.sh --skip   # ad-hoc no-op (rarely useful)
```

But expect the next push (or rebase) to bounce on whatever CI
catches — only skip when you have a specific reason.

## 5. Editable install for development

```bash
python -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
pip install -e .[dev,docs]
```

`pip install absentia` doesn't work — the package isn't on PyPI yet.
For everyday changes, `git pull` is enough; the editable install
points at the working tree. Re-run `pip install -e .` only when
`pyproject.toml` adds a new dependency.

## 6. Doc-with-feature

When you ship a feature, update related docs **in the same commit**:

| Touched | Doc to update |
|---|---|
| New CLI flag or subcommand | `docs/reference/cli.md` + `--help` epilog |
| New config option | `docs/reference/absentia-toml.md` + `absentia.toml.example` |
| New TUI keybinding | `docs/reference/tui-keys.md` + in-app `?` help |
| New selector | `docs/reference/selectors.md` |
| Architectural shift | `docs/explanation/architecture.md` |
| User-visible change | `CHANGELOG.md` `[Unreleased]` section |

PRs that move code without the matching doc update get caught in
review.

## 7. Defensive UI hooks

User-supplied callbacks (progress callbacks, custom editor commands,
`on_open_editor` injections) must never break the underlying
operation. Wrap each invocation:

```python
if callback is not None:
    try:
        callback(*args)
    except Exception:
        pass  # UI hook must not break the work
```

The work is the contract; the UI is decoration.

## 8. Bump `EXTRACTOR_FINGERPRINT` when extractor output changes

The per-file content hash that drives absentia's incremental cache is
salted with `extractors.EXTRACTOR_FINGERPRINT` (a string constant
in `src/absentia/extractors/__init__.py`). Bumping the constant
invalidates every cached entry on the next scan, so users
automatically pick up new feature_kinds / entity kinds /
extractor-logic-fixes without having to know to `--cold` or
`--purge`.

Bump it whenever extractor *output* changes. Examples that DO need
a bump:
- new `feature_kind` in any extractor's FeatureSet
- new entity kind emitted by any extractor
- bug fix in extractor logic that changes the entity / feature shape
- new built-in extractor language

Examples that DON'T need a bump:
- comment / docstring changes inside extractor source
- pure refactor (e.g. extracting a helper) with no output change
- typo fixes
- changes to `src/absentia/mining.py`, `selectors.py`, `series.py`,
  or anything *not* under `src/absentia/extractors/` (mining runs
  from scratch every scan and doesn't read the cache)

CI gate: `scripts/check_fingerprint_bump.sh` runs in CI and fails
when any file under `src/absentia/extractors/` changed in a PR but
`EXTRACTOR_FINGERPRINT` didn't. The gate has a refactor escape
hatch — include the literal `[no-fingerprint-bump]` in any commit
message in the diff range and the check skips, recording the
exemption in git history.

The bump-history docstring on the constant itself is the authoritative
log of what each version absorbed. Keep the bump in the same commit
as the extractor change; reviewers can see both at once.

## 9. Cutting a release

Two GitHub Actions workflows split the verification load:

- **`ci.yml`** — runs on every push to `main` and every PR. Cheap +
  high-signal: `lint` (ruff) and `fingerprint-bump`. Fast feedback
  on the gates that earn their keep pre-merge.
- **`release-checks.yml`** — runs on tag push (`v*.*.*`) and
  `workflow_dispatch`. Heavy: `pytest` matrix across Python 3.13 /
  3.14, `mypy`, `mkdocs --strict`. Mirrors what `scripts/local_ci.sh`
  runs locally — redundant per-push, valuable as a release gate.
- **`wheels.yml`** — also tag-triggered. Builds mypyc wheels for
  the platform × Python matrix.

Use the release script rather than tagging manually:

```bash
bash scripts/release.sh           # interactive (validate / patch / minor / major / set / cancel)
bash scripts/release.sh --patch   # 0.1.0 → 0.1.1
bash scripts/release.sh --minor   # 0.1.0 → 0.2.0
bash scripts/release.sh --major   # 0.1.0 → 1.0.0
bash scripts/release.sh --set=1.0.0
```

The script bumps `pyproject.toml`, promotes the CHANGELOG's
`[Unreleased]` heading to `[X.Y.Z] - YYYY-MM-DD` (and inserts a
fresh empty `[Unreleased]` skeleton above it), commits with
`release: vX.Y.Z (bump-type)`, annotated-tags `vX.Y.Z`, and pushes
both. Each step rolls back on failure.

If you need to verify the heavy CI matrix passes *before* cutting a
real tag (e.g. you've changed something the local pre-push hook
can't reproduce — a Python-3.14-specific bug, a mkdocs issue under
strict mode), use:

```bash
bash scripts/release.sh --validate
```

That dispatches `release-checks.yml` on the current branch via
`gh workflow run` — no version bump, no commit, no tag, no push.
Requires the GitHub CLI (`gh`) authenticated.

## 10. Stable IDs across runs

Suppressions, follow-links, watchers, and external integrations
all depend on stable IDs.

- **Entities** are identified by their qualified name (e.g.
  `src/api/users.py::delete_user`), not by sequence numbers or
  database rowids.
- **Gaps** get a hash-derived short ID (`g-7c91234`) computed
  from `(rule_id, entity_id)`.
- **Rules** and **groups** likewise hash-derived from their
  defining tuple.

Don't introduce IDs that change across runs even when the input
doesn't. If the engine produces different output for byte-identical
inputs, that's a bug — see also the [why-no-LLM doc](docs/explanation/why-no-llm.md)
on determinism.

---

## When to update this file

Add a new section here when you establish a project-wide convention
during code review or while shipping a feature. Existing sections
can grow with examples; the goal is to keep "the way we do things
in this repo" findable.
