# Haskell Quality Gates

> Configure Haskell formatting, linting, performance defaults, pre-commit hooks, and CI checks. Covers ormolu, hlint, cabal-gild, the .pre-commit-config.yaml template, CI workflow steps, strict-by-default field policy, Text/ByteString/Vector/Map preferences, profiling flags, and the "before declaring work done" checklist. Use when wiring up linting/formatting automation, setting up CI for a Haskell project, debugging space leaks, or running the final pre-commit checks.

- Skill: `ivelten/haskell-quality-gates` (Agent Skill)
- Install (CLI): `npx skillmds@latest add ivelten/haskell-quality-gates`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ivelten/haskell-quality-gates/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Productivity
- Author: ivelten (https://skillmd.com/u/ivelten)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/ivelten/haskell-quality-gates

---


# Haskell Quality Gates

This skill covers the automation that keeps a Haskell project tidy: formatter, linter, performance defaults that prevent common pitfalls, and the pre-commit / CI / done-checklist that hold the line.

## Formatter and linter

- **`ormolu`** — zero-config, opinionated. No style debates. Always run with `--mode inplace` before commits, or `--mode check` in CI.
- **`hlint`** — apply its suggestions by default unless they conflict with `effectful` patterns or clarity.
- **`cabal-gild`** — formats `.cabal` files. Keeps build configs readable and consistently structured. Prefer over `cabal-fmt`.

The conventions are not negotiable: disagreement is settled by the tool, not by review.

**Where these binaries come from.** All three must be on `PATH` before any hook or CI step can call them — the pre-commit hooks below use `language: system`, which means "already installed". On the native path `ormolu` is pinned in `mise.toml` while `hlint` comes from Homebrew and `cabal-gild` from `cabal install` (see `haskell-toolchain-mise`); the devcontainer image ships `ormolu` and `cabal-gild` but **not** `hlint` or `pre-commit`, so install those inside the container on first use. Keep the `ormolu` CLI in the series HLS bundles, or editor formatting and the CLI will disagree.

## Performance defaults

These are the type-and-strictness choices that prevent the most common Haskell performance pitfalls. Apply them by default; deviate only with a reason.

- **`Text`, never `String`** — for anything human-readable. `Data.Text` strict by default; `Data.Text.Lazy` only for streaming.
- **`ByteString` for bytes** — strict by default, same logic.
- **`Vector` for indexed/numeric data** — `Data.Vector` boxed for arbitrary types, `Data.Vector.Unboxed` for primitives.
- **`Map` / `HashMap` instead of association lists** — `containers` for ordered, `unordered-containers` for hash-based.
- **Strict fields by default** in data types — `data User = User { userId :: !UserId, userEmail :: !Email }`. Laziness in fields is a frequent source of space leaks; opt into it explicitly with rare exceptions.
- **`BangPatterns`** when a let-bound intermediate result should be forced.
- **Profile with `-prof -fprof-auto`** and visualize with `ghc-prof-flamegraph`. Look at maximum residency with `+RTS -s`.

## Pre-commit hooks

Use [pre-commit](https://pre-commit.com/) (the Python framework, language-agnostic) to enforce formatting and linting locally. Commit `.pre-commit-config.yaml` to the repo:

```yaml
# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: ormolu
        name: ormolu
        entry: ormolu --mode inplace
        language: system
        files: \.hs$

      - id: hlint
        name: hlint
        entry: hlint
        language: system
        files: \.hs$

      - id: cabal-gild
        name: cabal-gild
        entry: cabal-gild
        language: system
        files: \.cabal$
```

Onboarding step (in README): `pip install pre-commit && pre-commit install`. After that, every `git commit` runs the hooks. `ormolu --mode inplace` reformats files; if anything changes, the commit is blocked and the user re-stages with `git add`.

## CI safety net

Pre-commit hooks can be skipped with `git commit --no-verify`, and not every contributor will install them. Run the same checks in CI as a final gate:

```yaml
# .github/workflows/ci.yml fragment
- name: Check formatting
  run: ormolu --mode check $(git ls-files '*.hs')

- name: Check cabal formatting
  run: cabal-gild --mode=check $(git ls-files '*.cabal')

- name: Lint
  run: hlint src test

- name: Build
  run: cabal build --ghc-options=-Werror

- name: Test (hspec + doctest)
  run: cabal test
  env:
    HSPEC_OPTIONS: --color

- name: Documentation gate (coverage + references)
  run: ./scripts/check-haddock.sh
```

Note `-Werror` is in CI but **not** in the `.cabal` file: locally, warnings should be visible but non-fatal during exploration; CI is where the line is held.

## Before declaring work "done"

Always run, in order:

1. **`cabal build`** — clean build, no warnings. Warnings are configured in the `.cabal` file (see `haskell-project-setup`). Note that Haddock coverage is **not** enforced here: GHC has no `-Wmissing-docs`, so it is step 3's job (see `haskell-documentation`).
2. **`cabal test`** — all tests pass, including hspec, properties, **and doctests**. Output formatting is configured in `cabal.project` and `.hspec` (see `haskell-testing`).
3. **`./scripts/check-haddock.sh`** — Haddock generates cleanly at 100% coverage, with no broken references, malformed markup, or unresolved links. The script exists because neither `-Wmissing-docs` (GHC) nor `-Werror` (Haddock 2.31.1) is available on the pinned toolchain (see `haskell-documentation`).
4. **`hlint src test`** — no lint warnings (or all consciously suppressed with reason).

If pre-commit hooks are installed (`pre-commit install`), step 4 happens automatically on `git commit`. Ormolu formatting is handled automatically by the PostToolUse hook on each file edit.

## When the user is stuck

Common debugging cues, with what to ask for next:

- **Type errors**: ask for the exact error message and a minimal reproducer. The error is almost always more useful than they realize once decoded. Translate it for them — GHC's vocabulary (`expected ... actual ...`, "rigid type variable", "ambiguous occurrence") is dense but principled.
- **"Could not deduce"**: usually a missing constraint. Walk through where the constraint comes from — the compiler reports exactly which constraint is needed.
- **`Show`/`Eq` not in scope**: derived instances missing, or wrong import.
- **Space leaks**: ask about `+RTS -s` output. Look for non-strict accumulators in folds (use `foldl'`), missing strict fields, lazy `Map.insertWith` (use `Map.insertWith'`).
- **Library API unfamiliar or version-drifted**: tell the user you'll check Hackage rather than guessing — Haskell library APIs drift and guessing wastes their time.

## Related

- The warning set enforced in CI is defined in the `.cabal` file — see `haskell-project-setup`.
- The Haddock coverage gate (and why `-Wmissing-docs`/`haddock -Werror` are unavailable), doctest as a test suite: `haskell-documentation`.
- `cabal test` output formatting (`test-show-details: direct`, `.hspec`): `haskell-testing`.

