Mix.Statifier.AdrGuard (Statifier v2.0.0)

Copy Markdown View Source

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

adr_index()

@type adr_index() :: %{
  :files => [String.t()],
  :readme => String.t() | nil,
  optional(:base_files) => [String.t()]
}

finding()

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

source()

@type source() :: %{
  :diff => String.t(),
  optional(:files) => %{required(String.t()) => String.t()},
  optional(:adr) => adr_index()
}

Functions

analyze(source)

@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()

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

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

collect(opts)

@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.