# Stages

What each stage runs, what it reports, and where its threshold comes from.

A run has three phases. **Format** fixes what it can. **Compile** gates the
rest: analysing code that does not build wastes the wait. Everything else runs
in parallel and prints as it finishes.

## Format

Runs `mix format`, having first checked with `--check-formatted` so it can
report how many files it changed.

```
✓ Format: Formatted 3 files (0.2s)
✓ Format: No changes needed (458ms)
```

A project with no `.formatter.exs` is reported as skipped rather than failed:
it never had the config file, so there is nothing to enforce. A `mix format`
that fails - a syntax error, say - is reported as a failure, not as a green
tick over a file that could not be parsed.

By default this is the only stage that modifies code, which is right for the
interactive loop and has a consequence for CI: formatting can never fail a
run, so drift on one branch goes green and lands as reflow noise in the next
branch to touch those files. `format: [check: true]` in `.quality.exs` makes
the stage check instead - drift fails the stage with the file list and
nothing is written:

```
✗ Format: 3 files need formatting
```

The stage keeps the name `Format` in both modes, so nothing that routes on
the report has to know which produced it. The stats key is the tell:
`files_formatted` in the default mode, `files_needing_format` in check mode,
because "3 files were rewritten" and "3 files would be" are different claims.
In check mode run `mix format` yourself before committing - the gate no
longer does it for you.

## Compile

Compiles dev and test in parallel, with `--warnings-as-errors` by default.

```
✓ Compile: dev + test compiled (warnings as errors) (325ms)
✗ Compile: dev compilation failed
```

A failure here stops the run. The analysis stages that were never reached are
reported as skipped, with `compile failed` as the reason, so they do not read
as stages that passed.

Set `compile: [warnings_as_errors: false]` to allow warnings.

Set `compile: [force: true]` to compile with `--force`, so the local run matches
the cold build CI does. An incremental build misses a whole class of breakage:
remove a public function and its callers are not recompiled, because a remote
call is not a compile-time dependency, so nothing warns locally and CI fails.
The cost is a full recompile of the project's own apps on every run, which is
why it is off by default and why it is worth turning on for a pre-push gate.

```
✓ Compile: dev + test compiled (forced, warnings as errors) (12.1s)
```

## Credo

Runs `mix credo --format json`, so an issue is never dropped for having an
unfamiliar line shape.

```
✓ Credo: No issues (775ms)
✗ Credo: 5 issues (2 readability, 3 design) (0.4s)
```

Each issue becomes a finding naming the check that produced it:

```
lib/user.ex
  42:3  [info] Modules should have a @moduledoc tag. (Credo.Check.Readability.ModuleDoc)
```

`credo: [strict: true]` is the default. Configure credo itself in `.credo.exs`.

### More than one config

A `.credo.exs` can declare several configs, and a check that lives in a second
one does not run under the default. That happens when a set of files sits
outside credo's default `files.included` - migrations are the usual case -
because folding them into the default config would subject them to the whole
default check set.

```elixir
credo: [configs: ["default", "migrations"]]
```

Each name is one `mix credo` invocation, in the order given, and the results
merge into a single stage result. Findings are deduplicated on file, line,
column and check, so two configs with overlapping `files` globs do not double
a count. A run that fails without reporting issues names the config it came
from, because the likeliest cause is a name `.credo.exs` does not define:

```
✗ Credo: Check failed in "migrations" (see output)
```

The runs are serial. `mix credo` can compile, and two concurrent mix runs
against the same `_build` is exactly what the writer/reader split exists to
prevent. The stage as a whole still runs in parallel with the other analysis
stages.

Without `configs`, one run happens with no `--config-name`, which is credo's
own default.

## Dialyzer

Runs `mix dialyzer --no-compile --format short --format dialyxir`. The short
form of each warning becomes a finding naming the warning (`no_return`,
`pattern_match`); dialyxir's long explanation is kept alongside it, in the
stage's output and in the JSON report.

```
✓ Dialyzer: No warnings (32.1s)
✗ Dialyzer: 2 warnings (12.4s)
```

Dialyzer analyses against a PLT, a cache of every module it has already seen.
Building one takes minutes, analysing against a warm one takes seconds. A run
that has to build it says so while it happens, rather than looking hung:

```
⋯ Dialyzer: building PLT (this is a one-time cost)
✓ Dialyzer: No warnings (PLT built this run) (252.4s)
```

`mix quality.plt` builds it outside a run, so a container image or CI job can
cache it. See [ci.md](ci.md). `--quick` skips this stage.

## Dependencies

Two checks in one stage. `mix deps.unlock --check-unused` always; and
`mix deps.audit --format json` when `:mix_audit` is installed.

```
✓ Dependencies: No unused dependencies (0.3s)
✗ Dependencies: 1 vulnerability (1 moderate) (2.5s)
```

Each vulnerability becomes a finding against the lockfile, naming the advisory,
the version in use and the version that fixes it. Unused dependencies become
findings too.

```
mix.lock
  -  [error] decimal 2.3.0: Unbounded exponent in `Decimal.new` enables
     unauthenticated DoS (moderate severity, patched in 3.0.0) (GHSA-rhv4-8758-jx7v)
```

## Doctor

Runs `mix doctor`, which enforces the documentation coverage thresholds in the
project's `.doctor.exs`.

```
✓ Doctor: Passed (519ms)
✗ Doctor: Documentation coverage below threshold
```

`doctor: [summary_only: true]` prints only the summary.

## Docs

Builds the documentation with `mix docs` and fails on any ExDoc warning - a
reference to a function that does not exist, a link that resolves nowhere, an
undefined anchor. `mix docs` exits 0 despite them on most versions, so
warnings that fail no build accumulate; this stage is the ratchet that keeps a
project at zero once it gets there.

```
✓ Docs: No warnings (2.1s)
✗ Docs: 3 warnings (1.9s)
○ Docs: skipped (opt-in; set docs: [enabled: :auto] in .quality.exs)
```

Each warning becomes a finding at the `file:line` ExDoc reports; a warning
without a location falls back to the tool's full output, so nothing is hidden
behind a parse.

**This stage is opt-in**, unlike the other tool-backed stages. Nearly every
published package depends on `:ex_doc` to build its docs, so enabling on
detection would turn currently-green gates red on upgrade. Enable it in
`.quality.exs`:

```elixir
docs: [enabled: :auto]   # on when :ex_doc is installed (recommended)
docs: [enabled: true]    # forced; errors if :ex_doc is missing
```

With `enabled: :auto` a project without `:ex_doc` reports the stage as skipped
(`:ex_doc not installed`), the way Doctor and Sobelow do.

The build runs with one formatter (`html` - the epub build repeats its
warnings) into a temporary directory that is deleted afterwards, so the
project's own `doc/` output is untouched and the repository stays clean.

## Doc links

Checks the relative links in the published Markdown against where they are
published. ExDoc says nothing about most of the cases below, so the Docs stage
cannot catch them:

- ExDoc rewrites a relative `.md` link to its `.html` page only when the
  target is itself one of the `extras`, and it looks the target up by basename
  alone. Every other relative link stays a raw `href`: it works on GitHub and
  answers 404 on HexDocs. For a Markdown, `.txt` or extension-less target
  ExDoc warns that the file "does not exist", even when it is on disk; for any
  other target, such as `mix.exs` or a source file, it says nothing. A link
  whose basename matches some other extra is rewritten to *that* extra
  without a word - a link to `docs/adr/README.md` lands on the package's
  front page.
- The hex.pm package page renders the README from the package tarball, so a
  relative link or image in the README answers 404 there unless the file ships.

```
✓ Doc links: 42 links checked (0.1s)
✗ Doc links: 3 problems (0.1s)
○ Doc links: skipped (opt-in; set doc_links: [enabled: :auto] in .quality.exs)
```

It fails on four rules, each a finding at the `file:line` of the link (the
finding's `check` names the rule):

| Rule | Fails when |
|---|---|
| `readme_not_packaged` | a relative link or image in `README.md` targets a file `package: [files: ...]` does not cover. With no `files:`, Hex's default list applies (`lib`, `priv`, `.formatter.exs`, `mix.exs`, `README*`, `LICENSE*` and `CHANGELOG*` with their lowercase forms, `src`, `c_src`, `Makefile*`) |
| `not_an_extra` | a relative link in a Markdown extra targets a file that is not itself an extra. A link into a directory `assets:` copies is fine |
| `duplicate_extra` | two extras share a basename and the second has no `filename:`; the finding is at the second one's line in `mix.exs` |
| `rewritten_to_other_extra` | ExDoc would rewrite a relative link to a different extra than the file it names: the basenames match, and ExDoc takes the last extra declared with that basename |

`filename:` gives the second extra its own page, but ExDoc still resolves a
link by the source file's basename, so a link to either of two same-named
extras lands on the last one; the fourth rule reports that link.

It reads the project's own config, not built output: `extras` from the `docs`
config (a keyword list, or a zero-arity function returning one) and `files`
from the `package` config. Absolute URLs, `mailto:` links, ExDoc's own `e:`
and backticked forms, anchors and absolute paths are ignored, and an anchor or
query on a relative link is stripped before the check. Links in code spans and
fenced code blocks are not links. Links in moduledocs and function docs are the
Docs stage's: ExDoc warns on those. It builds nothing and runs no tool.

**This stage is opt-in**, like Docs and for the same reason. Enable it in
`.quality.exs`:

```elixir
doc_links: [enabled: :auto]   # on when :ex_doc is installed (recommended)
doc_links: [enabled: true]    # forced, with or without :ex_doc
```

## Gettext

Reads the project's `.po` files for untranslated and fuzzy entries, under every
umbrella child app as well as the root, and reports each one at its `msgid`.

```
✓ Gettext: All translations complete (12 files) (30ms)
✗ Gettext: 4 missing, 2 fuzzy translations
○ Gettext: skipped (no .po files found)
```

Files in the source locale are not checked, because the source locale is
untranslated by definition. It is `"en"` unless `gettext: [source_locale: ...]`
says otherwise, and `errors.po` is excluded for the same reason. A run left
with nothing to read reports itself as skipped rather than complete.

This stage does not run `mix gettext.extract --merge`. That task writes: it
rewrites `.pot` and `.po` files, and it compiles the project to do it, which
changes the build the other stages are reading. `gettext: [extract: true]` opts
back in, and the run then serialises the stage rather than running it alongside
the readers.

No other stage can write to your repository.

## Custom stages

A project's own check runs as a stage rather than beside it, declared under
`custom:` in `.quality.exs`. It prints, times and reports like a built-in one:

```
✓ Nullability: No unsound claims (1.1s)
✗ Nullability: 2 unsound claims (0.9s)
○ Nullability: skipped (--skip nullability)
```

The entry forms, the option table and the validation rules are in
[configuration.md](configuration.md#custom-stages). Two things belong here.

### The reader/writer declaration is load-bearing

The analysis phase runs its stages concurrently, and that is only safe while
every one of them is a reader. A command that recompiles the project rewrites
the beams the other stages are part-way through reading, and the stage that
notices reports a failure about the build rather than about the code.

`kind: :reader` is the default, because most custom checks read source or query
a database. **If your command compiles, generates, or writes anything under
`_build` or the repository, declare `kind: :writer`.** A writer runs on its
own, before the readers, the same way the Compile stage is a serialized gate.

`MIX_ENV=test mix <task>` is normally still a reader here, because the Compile
stage has already built dev and test before the analysis phase starts. That is
the most common shape a custom command takes and it looks like a writer, which
is why it is worth saying.

### The JSON finding contract

A command that wants per-finding routing rather than a wall of text prints one
JSON document on stdout:

```json
{
  "summary": "2 unsound claims",
  "stats": {"finding_count": 2},
  "findings": [
    {
      "file": "apps/web/lib/web/contacts/contact.ex",
      "line": 14,
      "column": null,
      "app": "web",
      "severity": "error",
      "check": "unsound",
      "message": "field :email is typed non-nil but the column is nullable"
    }
  ]
}
```

Only `file` and `message` are required per finding. `app` may be omitted and is
inferred from the path. `severity` is `error`, `warning` or `info`. `summary`
and `stats` are optional; without them the stage counts its own findings.

Anything that does not parse falls through to `output` verbatim, which is the
rule the printer and the report already follow everywhere else. `parse: :none`
skips the attempt for a command known to print prose, so a tool that happens to
emit JSON for some other reason is not misread.

### Not applicable

A custom check often has a prerequisite ExQuality cannot know about: a migrated
test database, a running service, a generated file. Without a way to say "not
applicable" the stage fails with an error that reads like a code problem.

`skip_exit_code: 2` lets the command exit 2 and have the stage report itself as
skipped, with its own reason:

```
○ Nullability: skipped (test database is not migrated)
```

Which keeps the invariant a run depends on: a stage that says nothing would
read as a stage that passed.

The reason is the document's `summary` when the command wrote one, and the
first line of output otherwise. **Write the summary.** A first line is hostage
to whatever the toolchain prints ahead of the command's own output, and `mix`
is the common offender: it emits `==> app` headers for an umbrella, and a
build-lock notice when another stage holds the lock. Either turns a reason
somebody can act on into one they cannot:

```
○ Nullability: skipped (==> admin)
```

## Aliased tasks

ExQuality shells out to the real `mix credo`, `mix dialyzer`, `mix docs`,
`mix format`, `mix sobelow`, `mix deps.unlock` and `mix test.coverage`, and
reads what they print. Mix resolves aliases before tasks, so a project that defines an alias
with one of those names silently changes what the stage measures.

A stage checks before shelling out, and refuses rather than reporting a number
about a command it did not issue:

```
✗ Format: mix format is aliased in mix.exs
✗ Sobelow: mix sobelow is aliased in mix.exs
```

Rename the alias (`sobelow.all` is the usual choice) and point your own scripts
at the new name.

`mix test` is the deliberate exception: a `test:` alias that runs migrations
first is near-universal, and running it is what the suite needs.

## Sobelow

Runs `mix sobelow` on a Phoenix project.

Sobelow reports findings at three confidence levels, but only those at or above
the project's `exit:` threshold block a build. ExQuality renders those and
reports the rest as a count:

```
✗ Sobelow: 2 blocking findings (1 high, 1 medium), 3 informational not shown
```

The threshold comes from `.sobelow-conf` when that file sets one, because what
blocks a build is a security decision that belongs with the security config.
`sobelow: [exit: "high"]` in `.quality.exs` only supplies a default for a
project without one; the default when neither says is `"medium"`.

Pass `--verbose`, or set `sobelow: [show_informational: true]`, to render the
findings below the threshold as well.

ExQuality will never suggest editing `.sobelow-conf` to make a run pass. A tool
that silences its own findings to go green is a regression dressed as a pass.

## Tests

Runs `mix test`, or `mix coveralls` when coverage is being measured.

```
✓ Tests: 345 of 345 passed (4.1s)
✓ Tests: 248 passed, 87.3% coverage (5.2s)
✗ Tests: 3 of 4,180 failed (web: 3)
```

Extra arguments reach the test command via `--` or `test: [args: [...]]`. See
[configuration.md](configuration.md).

### Running only part of the suite

`--test-scope changed` runs only the test files covering the code that changed,
which on a large suite is the difference between a check you can run between
edits and one you cannot:

```
✓ Tests: 12 of 12 passed (scope changed, 3 files vs origin/main, no coverage) (2.8s)
```

A scope that resolves to no test files runs the whole suite rather than reporting
a green run of nothing, and says so:

```
✓ Tests: 4,180 of 4,180 passed (scope changed fell back to the full suite: no test files map to the changed files) (56.8s)
```

Coverage on a scoped run is absent, not lower. See
[Test scope](configuration.md#test-scope) for the mapping rules and
[Reports](reports.md#the-tests-stage) for what the report carries.

## Coverage

Coverage is part of the Tests stage. `--quick` turns it off, and a scoped run has
none to turn off.

**The threshold is not configured in ExQuality.** It is read from whichever
coverage tool the project already uses, so there is one source of truth:

| Tool | Threshold read from |
|---|---|
| `:excoveralls` (`mix coveralls`) | `coveralls.json` → `coverage_options.minimum_coverage`, or `mix.exs` → `test_coverage: [minimum_coverage: 80.0]` |
| Elixir's own (`mix test --cover`) | `mix.exs` → `test_coverage: [summary: [threshold: 90]]` |

Without excoveralls, coverage is measured only when the project states a
threshold that way. Elixir applies a default of 90% whether or not a project
has ever thought about coverage, and ExQuality will not turn a green run red
over a number nobody chose. To measure anyway:

```elixir
# .quality.exs
test: [coverage: true]   # or false to never measure
```

When the threshold is missed, the modules under it are reported as findings,
rather than the whole per-module table:

```
✗ Tests: Coverage 62.5% (required: 90.0%)

lib/my_app/mailer.ex
  -  [error] MyApp.Mailer is 0.0% covered (threshold 90.0%)
lib/my_app/thing.ex
  -  [error] MyApp.Thing is 33.3% covered (threshold 90.0%)
```

Lowering the threshold to make a run pass is a decision for a human, not a fix.
