# `Mix.Statifier.AdrGuard`
[🔗](https://github.com/riddler/statifier-ex/blob/v2.0.0/lib/mix/statifier/adr_guard.ex#L1)

Flags likely violations of the mechanically-checkable ADRs in `docs/adr/`.

Covers ADR-0002 (Appendix D naming), ADR-0003 (pure core with effects),
ADR-0004 (predicator as the datamodel, so no `Code.eval_*`), ADR-0008
(generated identifier formats), ADR-0018 (process artifacts are not code
comments), and ADR-0058 (ADR number collisions). Each of the first five
checks is a name or call-site pattern over the lines a diff adds -
deliberately not an AST pass - so a false positive is cleared the way this
project already clears an Appendix D deviation: an inline comment on or
above the flagged line naming an ADR or the word "deviation".

ADR-0058's tree-local checks, `adr-0058-duplicate-number` and
`adr-0058-readme-index`, are different in kind from all five: they are
invariants over the working tree's `docs/adr/` listing and its README table,
not patterns over added diff lines. A finding from either carries `line:
nil` - there is no line in a diff to point at, because the defect is a
filename or a missing table row, not a line of code - and **neither clears
on the `ADR-0\d{3}|deviation` escape hatch**. There is no such thing as a
justified duplicate ADR number, and an ADR citation is not a filename: the
fix is a renumber and a README row move, never a suppression comment.

ADR-0058 adds a third check, `adr-0058-base-number`: a branch-added
`docs/adr/NNNN-*.md` whose number already exists on the base ref under a
different filename. It differs from the two tree-local checks in a way
ADR-0058 decision 2 states directly: "a finding from this half is always
real (a collision it can see is a collision), but a pass from it promises
nothing when `origin/main` is stale." The guarantee against a collision
lives in the tree-local checks above, which run at the post-fetch,
post-rebase gate `wurk:mr` performs. Because the base listing is taken at
the merge-base, not the ref tip, a fetch alone never lets this check see a
record that landed on main after the branch diverged - it fires once the
rebase has put that record on the branch's base, by which point the
tree-local checks fire too. What it alone catches is a branch that renames
or renumbers an on-main record: one file per number in the tree keeps the
duplicate check silent, while the merge-base still holds the number under
the old filename (ADR-0058 decision 2 as amended 2026-08-19). **No
document, skill, or report may cite a bare-gate ADR guard pass as evidence
that no collision exists on the remote.**

The ADR-0018 check is the exception to that escape hatch, on purpose. It
flags a bead ID (`st-` plus the id, including a dotted child suffix) added
in a comment, `@moduledoc`, `@doc`, `@typedoc` or test description under
`lib/` or `test/`. It does not look for phase numbers,
decision numbers, or plan filenames - ADR-0018 point 2 keeps the unnumbered
word "phase" legal and a regex cannot separate a numbered process reference
from ordinary English, so those stay a review matter. It also does not clear
on `@escape_pattern`: an ADR-number or "deviation" citation is exactly the
kind of line ADR-0018's Consequences show clearing itself by accident (a
legitimate ADR citation that happens to sit beside an unrelated bead
mention), so this check has its own marker, `ADR-0018-exempt`, and nothing
else clears it. Being line-based rather than AST-based, it can only tell a
comment/doc line from a code line by shape: a `#` line, a single-line
`@moduledoc`/`@doc`/`@typedoc "..."`, a `test "..." do` description, or a
line inside a `"""` doc heredoc. A doc heredoc's extent is read from the
file's post-image text carried on `source` (populated by `collect/1`), not
only from the diff hunk itself, so a body line added below an unchanged
opening delimiter is still classified as doc text.

Deliberately not covered: ADR-0015's banned-operation list for
`.claude/scripts/`. That rule was an absolute whole-tree ban, and this
guard's shape (added diff lines only, a citation escape hatch, a skip when
no base ref resolves) was wrong for it; `.claude/scripts/test/contract_test.rb`
was its permanent enforcement site until both the tree and that test were
removed once the kit's mechanics moved to another repo - see
ADR-0015's Consequences for the historical detail.

The naming check is the one with judgment in it. An exactly-spelled Appendix D
name is compliant and an unrelated helper is none of this check's business;
what it looks for is the shape ADR-0002's context describes, a spec function
independently re-derived under a heuristic name - close to a canonical name
without being it.

`analyze/1` is pure - it takes a diff and returns findings. For the ADR-0018
check, the file text a heredoc's extent is read from also arrives as plain
data on `source`, rather than being fetched by `analyze/1` itself.
`collect/1` is the part that talks to git and the filesystem, and takes
`opts[:runner]` and `opts[:reader]` so tests never need a fixture
repository.

# `adr_index`

```elixir
@type adr_index() :: %{
  :files =&gt; [String.t()],
  :readme =&gt; String.t() | nil,
  optional(:base_files) =&gt; [String.t()]
}
```

# `finding`

```elixir
@type finding() :: %{
  file: String.t(),
  line: pos_integer() | nil,
  severity: String.t(),
  check: String.t(),
  message: String.t()
}
```

# `source`

```elixir
@type source() :: %{
  :diff =&gt; String.t(),
  optional(:files) =&gt; %{required(String.t()) =&gt; String.t()},
  optional(:adr) =&gt; adr_index()
}
```

# `analyze`

```elixir
@spec analyze(source :: source()) :: [finding()]
```

Turns a diff into likely-ADR-violation findings.

A finding is dropped when the line it fires on, or the line above it in the
same hunk, cites an ADR number or the word "deviation" - the same inline
justification CLAUDE.md already asks for.

# `appendix_d_names`

```elixir
@spec appendix_d_names() :: [String.t()]
```

The Appendix D function names ADR-0002 requires the interpreter to keep.

# `collect`

```elixir
@spec collect(opts :: keyword()) ::
  {:ok, source()} | {:no_base_ref, source()} | {:error, String.t()}
```

Reads the diff the guard needs out of git.

Base ref resolution is `opts[:base]`, then `origin/main`, then `main`. When
none of them resolves there is nothing to diff against, so this returns
`{:no_base_ref, source}` rather than guessing a base - the source still
carries the tree-local `:adr` index (an empty `:diff`), because that half
needs no base ref and the task runs it regardless; the diff-based and
base-ref halves are what the task turns into a skipped stage.

`opts[:runner]` replaces the `git` shell-out with a function of an argument
list returning `{output, status}`, mirroring `Mix.Statifier.GateGuard`.
`opts[:reader]` replaces the `File.read/1` call used to populate `:files`
with a function of a path returning `{:ok, content} | {:error, reason}`,
the same shape as `File.read/1` itself. `opts[:lister]` replaces the
`File.ls/1` call used to gather `source.adr` - the `docs/adr/` numbering
invariant's directory listing - with the same `{:ok, entries} | {:error,
reason}` shape. Unlike the diff and the file reads, the `:adr` index is
gathered unconditionally, even when no base ref resolves: it is a
filesystem read, not a git one, so it costs nothing to compute up front and
it is what lets the tree-local numbering checks run without a base ref.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
