adr-lens
v0.4.0
Published
Make architecture decision records readable. Discover, browse, and audit ADRs from the terminal.
Maintainers
Readme
adr-lens
Make architecture decision records readable.
ADRs are easy to write and hard to read. Once a service has eighty of them, the corpus stops being documentation and becomes an archive: you cannot tell which decisions still hold, which were quietly replaced, or which three you actually need before touching the code.
adr-lens reads a directory of ADRs and answers the questions you actually have.
adr-lens web --open # a readable web page for the whole corpus
adr-lens map # where do I start?
adr-lens list --live # what is currently true?
adr-lens show 65 # read one, with its citations
adr-lens browse # explore interactively
adr-lens lint # what is broken or stale?Try it without a repo of your own
The repo ships an invented 13-record corpus:
git clone https://github.com/Alexanderdunlop/adr-lens && cd adr-lens
npx adr-lens web -C examples/billing-service --openIt covers the awkward cases on purpose: both title layouts, YAML frontmatter, a hard-wrapped status, a full supersession chain and a partial one, a rejected decision, tables, a mermaid diagram, and a link to a non-record file.
Install
npx adr-lens web --open # no install
npm install -g adr-lens # or keep it aroundRequires Node 24+.
Everything runs on your machine and reads your local files. Nothing is uploaded,
and there is no service or account involved — which is the point, since ADRs
usually live in private repos. The page it writes is a single file with no
external requests, so where that file goes afterwards is entirely your choice:
open it locally, commit it, or host it. (adr-lens web --serve runs a server, but
a local one, bound to loopback, and only while you leave it running.)
What it does
Finds your records without configuration. It walks the tree looking for
directories named adr, adrs, decisions, decision-records,
architecture-decisions, or architecture-decision-records, and skips
node_modules and friends. Point it at one repo or at a folder full of them.
Parses the formats people actually use. The adr-tools layout (# 2. Title
with a ## Status section), the bold-field layout (# ADR-001: Title with
**Status:** Accepted), and YAML frontmatter all work. Status is normalised from
free text, so Accepted, accepted ✅, and Proposed → Accepted all land in the
same place.
Extracts the decision. Every listing can show one sentence answering "what
did we decide?", lifted from the ## Decision section — skipping the sub-headings
and bolded pseudo-labels that decision sections tend to open with.
Builds the citation graph. Cross-references between records become a graph,
so adr-lens map can lead with the most-cited decisions. In a corpus of ninety
records, the handful everything else cites is the reading list.
Distinguishes partial supersession. "Superseded in part by ADR-0065" is not
the same as "Superseded by ADR-0065" — the first record is still in force. Both
phrasings are recognised, along with supersedes parts of, partially superseded
by, and the pattern where a status names what died and adds that the rest
"remains in force". Relations are mirrored, so only one side has to say it.
Commands
web — the readable page
Writes the whole corpus as one self-contained HTML file: no build step, no server, no external requests. Open it from disk, commit it, or publish it.
adr-lens web --open # write <scope>-decisions.html and open it
adr-lens web --serve --open # keep it open while writing; the tab reloads itself
adr-lens web -o docs/decisions.html
adr-lens web -C ~/code --open # every repo under a directory, grouped--watch rewrites the page whenever a record changes. It reloads the whole
corpus each time rather than patching one record, because adding a record changes
the citation graph and the rankings for every other record. Reload the page to
see a rebuild — your scroll position is restored, so you land where you were.
--serve adds a small localhost server and pushes that reload for you, so saving
a record updates the tab. It is additive: the file is still written to disk, and
the reload client exists only in the copy served over HTTP — what you commit or
send to someone is the same plain page as before. It listens on loopback only,
prints its URL, takes a free port if 4230 is busy (--port to choose), implies
--watch, and stops with ctrl-c.
The page has two halves:
- The register — every record as one row: number, status, title, the date, and
the one-sentence decision. Sorted newest first, with a toggle for
NumberorMost cited; undated records always sort last. Type to filter across titles, decisions, and body text; filter to current only or by status./focuses the search,j/kstep through results. - The reading view — the decision stated once, up front, before any context. Then the record itself: proper measure and line-height, tables that scroll in their own container, mermaid diagrams drawn as diagrams, and cross-references as links you can click. Replaced records say so at the top and point at what replaced them; partly-replaced records say that instead, because they are still in force.
Sharing one decision. Sending someone the whole page and asking them to find ADR-0009 in it does not work. Each record carries four ways to send just itself:
| | |
|---|---|
| Copy link | the record's own URL — the hash route already identifies it |
| Copy markdown | the record's source, for pasting into Slack, a ticket, or a review |
| Copy citation | ADR-0009 Single-store idempotency gate (accepted, 2026-04-18) |
| Download PDF | adr-0009-single-store-idempotency-gate.pdf, in one click |
The PDF. One click, straight to a file — no print dialog, which is aimed at a
printer and cannot be scripted. The page carries its own PDF writer, so this
works offline, from file://, with no dependency and nothing to install.
It is a real document, not a screenshot: the text is selectable and searchable,
files are tens of kB, and it uses the PDF base-14 fonts, so nothing has to be
embedded. Headings, lists, tables, code, and block quotes all come across.
External URLs are spelled out in the text, because a link in a PDF someone was
emailed is not necessarily clickable — in-page routes are not, since ADR-0009
already says everything the route would. A table never splits a row and repeats
its header across a page break; long code lines and URLs wrap rather than being
cut at the paper edge. Every page is numbered and carries the service and the
record number in its footer, which is the one thing paper needs that the screen
does not. Mermaid diagrams come out as their source text.
⌘P still works, and has its own stylesheet — the register and the controls drop away, and it comes out black-on-white even if you were reading in the dark theme. The overview prints too, as a one-page summary of the corpus.
There is no "download markdown" button, deliberately: it would hand you a file
you already have, by way of a file manager. Copy markdown does it in one step.
It adapts to light and dark, collapses to one column on a phone, and needs no network. A 100-record corpus is about 2 MB.
map — where to start
Leads with the most-cited records, then supersession chains, then the records nothing references.
$ adr-lens map -C ../billing-service
Decision map
════════════
42 records · 37 current · 48,010 words · ~218 min to read all
By status
● Accepted 34 ██████████████████████████████
◐ Proposed 3 ███
⊘ Superseded 5 ████
Start here — most cited
These are the decisions everything else builds on.
14 ← ● 0002 Idempotent batch settlement
We adopt a single-pass settlement model built on Lambda + SQS.
9 ← ● 0024 Single-store idempotency gate
buildInvoiceId(tenantId, customerId, periodId?) produces a …
Supersession chains
Where the thinking changed. Read the last one.
0007 → 0012 → 0031 Retry conflicting writes
0019 → 0024 Single-store idempotency gatelist — triage
One line per record: number, status, title, inbound citations, age. Superseded titles are struck through.
adr-lens list # everything, by number
adr-lens list --live --sort influence -V # what's current, most-cited first, with decisions
adr-lens list retry # only records matching "retry"
adr-lens list -s proposed # only proposed
adr-lens list -g # grouped by source directoryshow — read one
Renders a single record to the terminal: headings, wrapped prose, lists, tables, code blocks, and blockquotes — plus its relations and everything that cites it.
adr-lens show 65 # by number
adr-lens show idempotency-gate # by filename fragment
adr-lens show 65 --summary # header and decision line only
adr-lens show 65 --sections decision,consequences
adr-lens show 65 -u # print link destinationsbrowse — explore
An interactive two-pane browser: filterable list on the left, rendered record on the right.
| key | action |
| --- | --- |
| ↑ ↓ / j k | move between records |
| space / b | page down / up |
| g / G | first / last |
| enter | focus the record pane |
| tab | switch pane |
| esc | back to the list |
| / | filter — type, enter to keep, esc to drop |
| c | clear the filter |
| s | cycle status filter |
| L | toggle current-only |
| x | jump to a related record |
| [ ] | back / forward through jumps |
| e | open in $EDITOR |
| ? | key help |
| q | quit |
lint — audit
Exits non-zero only on errors, so it is safe in CI.
| rule | severity | what it catches |
| --- | --- | --- |
| duplicate-number | error | two records claiming the same number in one directory |
| broken-link | error | a local link pointing at a file that does not exist |
| supersession-cycle | error | two records superseding each other |
| dangling-reference | warn | a supersession naming a record that cannot be found |
| stale-status | warn | reads as accepted, but another record supersedes it |
| superseded-without-target | warn | marked superseded with no identifiable replacement |
| no-status | warn | missing or unrecognised status |
| no-date | info | no date |
| number-gap | info | gaps in numbering — usually a deleted record |
| missing-section | info | no ## Context or ## Decision |
| orphan | info | cites nothing and is cited by nothing |
adr-lens lint # errors and warnings
adr-lens lint --all # include informational checks
adr-lens lint --json # machine-readablesearch — rank by relevance
adr-lens search retry counter # every term must match
adr-lens search 34 # exact number match ranks firstOptions
-C, --root <path> directory to search (default: cwd)
--depth <n> how deep to look for ADR directories (default 6)
-d, --dir <text> only records whose path contains <text>
-s, --status <list> accepted, proposed, rejected, deprecated, superseded, unknown
--live hide superseded records
-w, --width <n> columns to render into (default: terminal width, capped at 100)
--sort <key> number | influence | date | title | length
-n, --limit <n> show at most n records
-V, --verbose add the decision line under each row (list)
-g, --group group by source directory (list)
-u, --urls print link destinations (show)
--summary header and decision line only (show)
--sections <l> comma-separated section names (show)
--top <n> entries per section (map)
-a, --all include informational findings (lint)
--json machine-readable output
--no-color disable colourProgrammatic use
The core carries no terminal concerns, so it can back other surfaces — a web view, an editor plugin, or an MCP server exposing the corpus to an agent.
import { loadCorpus, decisionLine, lintCorpus } from 'adr-lens';
const corpus = await loadCorpus('./');
for (const adr of corpus.adrs) {
console.log(adr.number, adr.status, decisionLine(adr));
}
const findings = lintCorpus(corpus, { all: true });loadCorpus returns records with resolved relations, inbound and outbound
citations, and full-supersession fields. renderMarkdown is exported separately
if you want the terminal renderer without the CLI.
Development
pnpm install
pnpm dev map -C ../some-repo # run from source
pnpm test # vitest
pnpm check-types # tsc --noEmit
pnpm lint # biome
pnpm build # tsdown → dist/Releasing
Versions are managed by changesets. Add a note in the same pull request as the change:
pnpm changesetOn merge to main, CI opens a "chore: version packages" pull request that
bumps the version and writes the changelog. Merging that pull request publishes
to npm. A change with no changeset ships no release — correct for refactors,
tests, and docs.
prepublishOnly runs types, tests, and the build before anything leaves, so a
red build cannot publish.
Requires an NPM_TOKEN repository secret with publish rights.
Licence
MIT
