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.
Summary
Functions
Turns a diff into likely-ADR-violation findings.
The Appendix D function names ADR-0002 requires the interpreter to keep.
Reads the diff the guard needs out of git.
Types
@type finding() :: %{ file: String.t(), line: pos_integer() | nil, severity: String.t(), check: String.t(), message: String.t() }
Functions
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.
@spec appendix_d_names() :: [String.t()]
The Appendix D function names ADR-0002 requires the interpreter to keep.
@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.