Mix.StatifierBlocks.AdrCites (StatifierBlocks v0.26.0)

Copy Markdown View Source

Resolves the line-number citations this repository's documents make into the decision records, and reports the ones that no longer point at the text they were written against.

A document cites a record in docs/adr/ by path and line - for example docs/adr/0002-block-type-behaviour.md:4108 - often followed by more line numbers for the same file (`:4121-4122`, `:4126`). Nothing about a line number survives an edit above it: an insert or a re-wrap in the cited record silently moves every line below it, and the citing document, on main and untouched, now points at different text. That failure is invisible to every other stage of the gate, which is why this one exists.

What it reads

Two roles, and they are not the same set of files. The targets are the records under docs/adr/, and only a citation into one of them is checked - a citation into lib/ or test/ is left to the compiler and the suite. The sources are the documents scanned for citations, and they are wider than the records, because a plan or CLAUDE.md cites a record by line exactly as a record does and drifts in exactly the same way. The default globs are docs/adr/*.md, docs/plans/*.md and CLAUDE.md; analyze/2 takes a :sources option that replaces them, which is how a test scans a fixture tree and how another document root would join the check.

What it checks

Three layers, two of which fail the gate:

  1. Resolvable (fails). The cited file exists under docs/adr/ and the cited range is non-empty and inside it.
  2. Unmoved (fails). The cited range's text still hashes to what the recorded baseline says it did. This is the layer that catches the drift: a line inserted above a cited range changes the text at that range, and the digest changes with it.
  3. Anchored (warns). The citing paragraph quotes something - a phrase in quotation marks, or a multi-word backticked span - that is still found inside one of the ranges the paragraph cites. A paragraph that quotes nothing is reported as unanchored rather than failed: most of the corpus cites a section heading and paraphrases it, and treating a paraphrase as drift would falsify history rather than protect it.

The baseline

Layer 2 reads docs/adr/.cite-baseline.json, which records one entry per citation: the normalized text at the cited range and its SHA-256. An entry is keyed by the citing document's base name and the range it cites, not by the citing document's path. Two sources sharing a base name therefore share a key - harmlessly, because an entry's value is the text at the cited range and says nothing about the source, so the colliding entries are identical. Keying by path instead would rewrite every recorded key the moment the sources widened, and every citation would fall back to the warning layer for a cycle: the protection would lapse exactly when it was being extended. It is regenerated with mix adr.cites --update, and the regenerated file belongs in the same request as the record edit that moved the line.

A citation the baseline has never seen warns rather than fails. Records land serially and a new citation arrives with the request that writes it; failing on an unrecorded citation would red the gate of a request that had done nothing wrong. Protection therefore accrues at the next --update rather than at the moment the citation is written, which is the deliberate trade this layer makes.

Nothing here rewrites a record. Merged records keep the line-number citations they were written with.

Summary

Functions

Runs every layer over the documents under root that the source globs name, resolving what they cite into root's docs/adr/.

Path of the baseline file for a project root.

Reads the checked-in baseline, or an empty map when there is none yet.

Serializes baseline entries as the JSON document the tree checks in.

Types

finding()

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

report()

@type report() :: %{
  findings: [finding()],
  warnings: [finding()],
  entries: %{optional(String.t()) => map()}
}

Functions

analyze(root, opts \\ [])

@spec analyze(
  String.t(),
  keyword()
) :: report()

Runs every layer over the documents under root that the source globs name, resolving what they cite into root's docs/adr/.

Returns the failing findings, the advisory ones, and the baseline entries the current tree would record.

Options

  • :sources - globs, relative to root, of the documents to scan for citations. Replaces the default docs/adr/*.md, docs/plans/*.md and CLAUDE.md rather than adding to them.

baseline_path(root)

@spec baseline_path(String.t()) :: String.t()

Path of the baseline file for a project root.

read_baseline(root)

@spec read_baseline(String.t()) :: %{optional(String.t()) => map()}

Reads the checked-in baseline, or an empty map when there is none yet.

render_baseline(entries)

@spec render_baseline(%{optional(String.t()) => map()}) :: String.t()

Serializes baseline entries as the JSON document the tree checks in.