Skip to content

API reference

ADR domain model: statuses, relations, errors and parsing.

TRANSITIONS = frozenset({(Status.PROPOSED, Status.ACCEPTED), (Status.PROPOSED, Status.REJECTED), (Status.ACCEPTED, Status.DEPRECATED), (Status.ACCEPTED, Status.SUPERSEDED)}) module-attribute

Status changes allowed without --force, as (from, to) pairs.

Adr dataclass

One parsed ADR file.

meta is the round-trip frontmatter that gets written back; relations always has the five STORED_RELATIONS keys. text is the whole file as read and body what follows the frontmatter; number and slug come from the filename, title from the first # heading.

Source code in src/adrinaline/model.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
@dataclass
class Adr:
    """One parsed ADR file.

    `meta` is the round-trip frontmatter that gets written back; `relations` always has the five
    `STORED_RELATIONS` keys. `text` is the whole file as read and `body` what follows the
    frontmatter; `number` and `slug` come from the filename, `title` from the first `# ` heading.
    """

    number: int
    slug: str
    path: Path
    title: str
    status: Status
    date: dt.date
    tags: list[str]
    scope: list[str]
    relations: dict[str, list[int]]
    meta: CommentedMap
    body: str
    text: str
    review_by: dt.date | None = None
    reviewed: dt.date | None = None
    revisit_if: str | None = None

    @property
    def id(self) -> str:
        return f"{self.number:04d}"

    @property
    def baseline(self) -> dt.date:
        """Start of the drift window: the later of date and reviewed (SPEC §8)."""
        return max(self.date, self.reviewed) if self.reviewed else self.date

baseline property

Start of the drift window: the later of date and reviewed (SPEC §8).

AdrError

Bases: Exception

A domain error with a stable machine-readable code (exit code 3).

Source code in src/adrinaline/model.py
51
52
53
54
55
56
57
58
class AdrError(Exception):
    """A domain error with a stable machine-readable code (exit code 3)."""

    def __init__(self, code: str, message: str, **details: Any) -> None:
        super().__init__(message)
        self.code = code
        self.message = message
        self.details = details

Frontmatter

Bases: BaseModel

Validated frontmatter. Hyphenated YAML keys fill the snake_case fields; the snake_case spellings are not read and fall into the extra keys. Other unknown keys are kept as well.

Source code in src/adrinaline/model.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
class Frontmatter(BaseModel):
    """Validated frontmatter. Hyphenated YAML keys fill the snake_case fields; the snake_case
    spellings are not read and fall into the extra keys. Other unknown keys are kept as well."""

    model_config = ConfigDict(extra="allow")

    status: Status
    date: dt.date
    tags: list[str] = []
    scope: list[str] = []
    review_by: dt.date | None = Field(default=None, alias="review-by")
    reviewed: dt.date | None = None
    revisit_if: str | None = Field(default=None, alias="revisit-if")
    supersedes: list[int] = []
    superseded_by: list[int] = Field(default=[], alias="superseded-by")
    amends: list[int] = []
    depends_on: list[int] = Field(default=[], alias="depends-on")
    relates_to: list[int] = Field(default=[], alias="relates-to")

    @field_validator("scope")
    @classmethod
    def _relative_globs(cls, globs: list[str]) -> list[str]:
        for glob in globs:
            if glob.startswith("/") or ".." in PurePosixPath(glob).parts:
                raise ValueError(f"scope glob must be repository-relative: {glob}")
        return globs

parse_adr(path, text)

Parse one ADR; path supplies the number and slug. Raises AdrError with code:

bad-filename; no-frontmatter (missing, unterminated, invalid YAML or not a mapping); bad-date (unparseable date, or a bad date, review-by or reviewed value); missing-field; bad-status; bad-scope; bad-field (any other invalid field); no-title. Only the first validation error is reported.

Source code in src/adrinaline/model.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
def parse_adr(path: Path, text: str) -> Adr:
    """Parse one ADR; `path` supplies the number and slug. Raises `AdrError` with code:

    `bad-filename`; `no-frontmatter` (missing, unterminated, invalid YAML or not a mapping);
    `bad-date` (unparseable date, or a bad `date`, `review-by` or `reviewed` value);
    `missing-field`; `bad-status`; `bad-scope`; `bad-field` (any other invalid field); `no-title`.
    Only the first validation error is reported.
    """
    match = FILENAME_RE.match(path.name)
    if not match:
        raise AdrError("bad-filename", f"{path.name}: expected NNNN-slug.md", path=str(path))
    try:
        meta, body = split(text)
    except FrontmatterDateError as exc:
        raise AdrError("bad-date", f"{path.name}: {exc}", path=str(path)) from exc
    except FrontmatterError as exc:
        # a BOM or blank line before a real `---` block is not an import: no hint
        has_block = text.lstrip("\ufeff \t\r\n").startswith("---")
        hint = (
            f"; {_import_hint(text)}"
            if isinstance(exc, FrontmatterMissing) and not has_block
            else ""
        )
        raise AdrError("no-frontmatter", f"{path.name}: {exc}{hint}", path=str(path)) from exc
    try:
        front = Frontmatter.model_validate(dict(meta))
    except ValidationError as exc:
        first = exc.errors()[0]
        where = ".".join(str(part) for part in first["loc"])
        raise AdrError(
            _error_code(first), f"{path.name}: {where}: {first['msg']}", path=str(path)
        ) from exc
    title = next((line[2:].strip() for line in body.splitlines() if line.startswith("# ")), "")
    if not title:
        raise AdrError("no-title", f"{path.name}: no '# ' title heading", path=str(path))
    relations = {name: list(getattr(front, name.replace("-", "_"))) for name in STORED_RELATIONS}
    return Adr(
        number=int(match[1]),
        slug=match[2],
        path=path,
        title=title,
        status=front.status,
        date=front.date,
        tags=front.tags,
        scope=front.scope,
        relations=relations,
        meta=meta,
        body=body,
        text=text,
        review_by=front.review_by,
        reviewed=front.reviewed,
        revisit_if=front.revisit_if,
    )

Locate the ADR directory, load ADRs, resolve references and write files.

AdrSet dataclass

Parsed ADRs sorted by (number, slug), the findings for files that did not parse, and every number seen in a valid filename (including files that failed to parse).

Source code in src/adrinaline/repo.py
43
44
45
46
47
48
49
50
@dataclass
class AdrSet:
    """Parsed ADRs sorted by (number, slug), the findings for files that did not parse, and every
    number seen in a valid filename (including files that failed to parse)."""

    adrs: list[Adr]
    problems: list[Finding]
    numbers: set[int]

Location dataclass

Where ADRs live: dir, git toplevel root (None outside git), template, and the drift-commits threshold from config (0 turns stale-decision off).

Source code in src/adrinaline/repo.py
32
33
34
35
36
37
38
39
40
@dataclass(frozen=True)
class Location:
    """Where ADRs live: `dir`, git toplevel `root` (None outside git), `template`, and the
    `drift-commits` threshold from config (0 turns `stale-decision` off)."""

    dir: Path
    root: Path | None
    template: Path
    drift_commits: int = 0

load(loc, at=None)

Read and parse the ADR directory, or its state at git revision at.

Raises no-adr-dir when the directory is missing.

Source code in src/adrinaline/repo.py
161
162
163
164
165
166
167
168
169
170
171
def load(loc: Location, at: str | None = None) -> AdrSet:
    """Read and parse the ADR directory, or its state at git revision `at`.

    Raises `no-adr-dir` when the directory is missing.
    """
    if at is not None:
        return parse_all(git.read_dir_at(loc.root, at, loc.dir))
    if not loc.dir.is_dir():
        raise no_dir(loc)
    paths = sorted(p for p in loc.dir.iterdir() if p.is_file())
    return parse_all((p, _read(p)) for p in paths)

locate(cwd, env, explicit=None)

Find the ADR directory. First hit wins: explicit (--dir), ADR_DIR, config dir, .adr-dir, then <git root or cwd>/docs/adr.

Config is the nearest .adrinaline.toml or pyproject.toml with [tool.adrinaline], searched from cwd up to the git root; its paths are relative to the file. --dir and ADR_DIR are relative to cwd. Raises bad-config for an unreadable or mistyped config.

Source code in src/adrinaline/repo.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
def locate(cwd: Path, env: Mapping[str, str], explicit: Path | None = None) -> Location:
    """Find the ADR directory. First hit wins: `explicit` (--dir), `ADR_DIR`, config `dir`,
    `.adr-dir`, then `<git root or cwd>/docs/adr`.

    Config is the nearest `.adrinaline.toml` or `pyproject.toml` with `[tool.adrinaline]`, searched
    from `cwd` up to the git root; its paths are relative to the file. `--dir` and `ADR_DIR` are
    relative to `cwd`. Raises `bad-config` for an unreadable or mistyped config.
    """
    root = git.toplevel(cwd)
    table: dict = {}
    base = cwd
    for directory in _ancestors(cwd, root):
        found = _config_table(directory)
        if found is not None:
            table, base = found, directory
            break
    adr_dir_file = next(
        (d / ".adr-dir" for d in _ancestors(cwd, root) if (d / ".adr-dir").is_file()), None
    )
    for key in ("dir", "template"):
        if key in table and not isinstance(table[key], str):
            raise AdrError("bad-config", f"config key {key!r} must be a string")
    drift_commits = table.get("drift-commits", 0)
    if isinstance(drift_commits, bool) or not isinstance(drift_commits, int) or drift_commits < 0:
        raise AdrError("bad-config", "config key 'drift-commits' must be a non-negative integer")
    if explicit is not None:
        directory = cwd / explicit
    elif env.get("ADR_DIR"):
        directory = cwd / env["ADR_DIR"]
    elif "dir" in table:
        directory = base / table["dir"]
    elif adr_dir_file is not None:
        directory = adr_dir_file.parent / adr_dir_file.read_text(encoding="utf-8").strip()
    else:
        directory = (root or cwd) / "docs" / "adr"
    directory = directory.resolve()
    template = (base / table["template"]) if "template" in table else directory / ".template.md"
    return Location(directory, root, template.resolve(), drift_commits)

next_number(loc)

The next free number and the numbers it skipped.

One more than the highest number in the directory or on any other branch or remote-tracking ref; skipped maps numbers above the local maximum that another ref already holds to that ref. A misnamed NNNN-*.md file also holds its number.

Source code in src/adrinaline/repo.py
324
325
326
327
328
329
330
331
332
333
334
335
336
337
def next_number(loc: Location) -> tuple[int, dict[int, str]]:
    """The next free number and the numbers it skipped.

    One more than the highest number in the directory or on any other branch or remote-tracking
    ref; `skipped` maps numbers above the local maximum that another ref already holds to that
    ref. A misnamed `NNNN-*.md` file also holds its number.
    """
    top = max(numbers_in(loc.dir), default=0)
    remote = git.numbers_on_refs(loc.root, loc.dir) if loc.root else {}
    skipped = {n: ref for n, ref in remote.items() if n > top}
    number = max([top, *skipped]) + 1
    while _taken(loc.dir, number):  # a misnamed NNNN-*.md file holds its number too
        number += 1
    return number, skipped

parse_all(items)

Parse (path, text) pairs. Dotfiles and README.md are skipped silently; other names that are not NNNN-slug.md give a bad-filename warning; a parse error becomes an error finding.

Source code in src/adrinaline/repo.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
def parse_all(items: Iterable[tuple[Path, str]]) -> AdrSet:
    """Parse `(path, text)` pairs. Dotfiles and `README.md` are skipped silently; other names that
    are not `NNNN-slug.md` give a `bad-filename` warning; a parse error becomes an error finding.
    """
    adrs: list[Adr] = []
    problems: list[Finding] = []
    numbers: set[int] = set()
    for path, text in items:
        name = path.name
        if name.startswith(".") or name == "README.md":
            continue
        match = FILENAME_RE.match(name)
        if not match:
            problems.append(
                Finding("bad-filename", "warning", str(path), f"{name}: not NNNN-slug.md, ignored")
            )
            continue
        numbers.add(int(match[1]))
        try:
            adrs.append(parse_adr(path, text))
        except AdrError as exc:
            problems.append(Finding(exc.code, "error", str(path), exc.message))
    adrs.sort(key=lambda adr: (adr.number, adr.slug))
    return AdrSet(adrs, problems, numbers)

renumber(s, adr, to)

Rename adr to number to; return the new path and the other ADRs rewritten.

Relations in other ADRs that point at the old number are rewritten, and NNNN-slug.md links in every body are updated. If the old number is shared by several ADRs, the numeric relations are left alone (they cannot tell which file is meant) and only the filename links change. Raises exists if to is taken.

Source code in src/adrinaline/repo.py
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
def renumber(s: AdrSet, adr: Adr, to: int) -> tuple[Path, list[Adr]]:
    """Rename `adr` to number `to`; return the new path and the other ADRs rewritten.

    Relations in other ADRs that point at the old number are rewritten, and `NNNN-slug.md` links
    in every body are updated. If the old number is shared by several ADRs, the numeric relations
    are left alone (they cannot tell which file is meant) and only the filename links change.
    Raises `exists` if `to` is taken.
    """
    if to in s.numbers:
        raise AdrError("exists", f"{to:04d} is already taken", number=to)
    old_name = adr.path.name
    new_path = adr.path.with_name(f"{to:04d}-{adr.slug}.md")
    unique = sum(1 for a in s.adrs if a.number == adr.number) == 1
    changed: list[Adr] = []
    for other in s.adrs:
        touched = False
        if unique and other is not adr:
            for name in STORED_RELATIONS:
                if adr.number in other.relations[name]:
                    set_relation(
                        other, name, [to if n == adr.number else n for n in other.relations[name]]
                    )
                    touched = True
        if old_name in other.body:
            other.body = other.body.replace(old_name, new_path.name)
            touched = True
        if touched and other is not adr:
            save(other)
            changed.append(other)
    atomic_write(new_path, render(adr.meta, adr.body))
    adr.path.unlink()
    return new_path, changed

resolve(adrs, ref)

Find the one ADR a reference names. All digits match the number; anything else is slugified (Use PostgreSQL is use-postgresql) and matches an exact slug, else a slug that contains it.

Raises not-found (with close matches as candidates) or ambiguous.

Source code in src/adrinaline/repo.py
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
def resolve(adrs: list[Adr], ref: str) -> Adr:
    """Find the one ADR a reference names. All digits match the number; anything else is
    slugified (`Use PostgreSQL` is `use-postgresql`) and matches an exact slug, else a slug that
    contains it.

    Raises `not-found` (with close matches as candidates) or `ambiguous`.
    """
    ref = ref.strip()
    if not ref:
        raise AdrError("not-found", "empty ADR reference", ref=ref)
    if ref.isascii() and ref.isdigit():
        hits = [adr for adr in adrs if adr.number == int(ref)]
    else:
        needle = _slug(ref)  # "Use PostgreSQL" == use-postgresql
        hits = [adr for adr in adrs if adr.slug == needle] or [
            adr for adr in adrs if needle and needle in adr.slug
        ]
    if not hits:
        by_slug = {a.slug: a.path.name for a in reversed(adrs)}  # first file per slug wins
        names = [by_slug[s] for s in difflib.get_close_matches(_slug(ref), by_slug, n=3)]
        hint = f"; did you mean {', '.join(n.removesuffix('.md') for n in names)}?" if names else ""
        raise AdrError("not-found", f"no ADR matches {ref!r}{hint}", ref=ref, candidates=names)
    if len(hits) > 1:
        names = [adr.path.name for adr in hits]
        raise AdrError(
            "ambiguous", f"{ref!r} matches {', '.join(names)}", ref=ref, candidates=names
        )
    return hits[0]

set_status(adr, status, today)

Set the status and reset date to today, in memory only; the caller saves.

Source code in src/adrinaline/repo.py
235
236
237
238
239
240
def set_status(adr: Adr, status: Status, today: dt.date) -> None:
    """Set the status and reset `date` to `today`, in memory only; the caller saves."""
    adr.status = status
    adr.date = today
    adr.meta["status"] = status.value
    adr.meta["date"] = today

supersede(graph, old, new, *, force, today)

Make new supersede old: update both relation lists, set old to superseded (its date is reset) and write both files.

Raises self-relation, invalid-transition (old is not accepted, unless force) or cycle.

Source code in src/adrinaline/repo.py
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
def supersede(graph: AdrGraph, old: Adr, new: Adr, *, force: bool, today: dt.date) -> None:
    """Make `new` supersede `old`: update both relation lists, set `old` to superseded (its
    `date` is reset) and write both files.

    Raises `self-relation`, `invalid-transition` (`old` is not accepted, unless `force`) or `cycle`.
    """
    if old.number == new.number:
        raise AdrError("self-relation", f"{old.id} cannot supersede itself")
    if old.status is not Status.ACCEPTED and not force:
        raise AdrError(
            "invalid-transition",
            f"{old.id} is {old.status}; only accepted ADRs can be superseded "
            "(use --force to repair)",
            current=old.status.value,
            requested=Status.SUPERSEDED.value,
        )
    check_acyclic(graph, "supersedes", new.number, old.number)
    set_relation(new, "supersedes", [*new.relations["supersedes"], old.number])
    set_relation(old, "superseded-by", [*old.relations["superseded-by"], new.number])
    set_status(old, Status.SUPERSEDED, today)
    save(new)
    save(old)

transition(adr, to, *, force=False, today)

set_status (so date is reset) if TRANSITIONS allows it or force, else raise invalid-transition. Does not save.

Source code in src/adrinaline/repo.py
251
252
253
254
255
256
257
258
259
260
261
def transition(adr: Adr, to: Status, *, force: bool = False, today: dt.date) -> None:
    """`set_status` (so `date` is reset) if `TRANSITIONS` allows it or `force`, else raise
    `invalid-transition`. Does not save."""
    if not force and (adr.status, to) not in TRANSITIONS:
        raise AdrError(
            "invalid-transition",
            f"{adr.id}: cannot change status from {adr.status} to {to} (use --force to repair)",
            current=adr.status.value,
            requested=to.value,
        )
    set_status(adr, to, today)

In-memory ADR graph: typed edges, derived inverses, traversal and rendering.

AdrGraph

Typed edges between ADRs, built from their stored relations.

A stored superseded-by becomes a supersedes edge from the other ADR, so each pair appears once. Edges may point at numbers with no ADR. With duplicate numbers, the first ADR is the node. Inverse names (superseded-by, amended-by, dependency-of) exist only in related.

Source code in src/adrinaline/graph.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
class AdrGraph:
    """Typed edges between ADRs, built from their stored relations.

    A stored `superseded-by` becomes a `supersedes` edge from the other ADR, so each pair appears
    once. Edges may point at numbers with no ADR. With duplicate numbers, the first ADR is the
    node. Inverse names (`superseded-by`, `amended-by`, `dependency-of`) exist only in `related`.
    """

    def __init__(self, adrs: list[Adr]) -> None:
        self.adrs = adrs
        self.nodes: dict[int, Adr] = {}
        for adr in adrs:
            self.nodes.setdefault(adr.number, adr)
        edges = [
            Edge(adr.number, n, relation)
            for adr in adrs
            for relation in FORWARD
            for n in adr.relations.get(relation, [])
        ]
        edges += [
            Edge(n, adr.number, "supersedes")
            for adr in adrs
            for n in adr.relations.get("superseded-by", [])
        ]
        self.edges = list(dict.fromkeys(edges))

    def rank(
        self, damping: float = 0.85, tolerance: float = 1e-9, max_iterations: int = 100
    ) -> dict[int, float]:
        """PageRank by number; the ranks sum to 1 (`{}` for an empty graph).

        Counted edges: `depends-on` and `amends`, from the depending ADR to the one it depends on
        or amends, and `relates-to` in both directions. `supersedes` is excluded, so replacing an
        ADR does not move rank. Edges to numbers with no ADR are ignored.
        """
        nodes = sorted(self.nodes)
        if not nodes:
            return {}
        size = len(nodes)
        targets: dict[int, list[int]] = {n: [] for n in nodes}
        for edge in self.edges:
            if edge.src not in targets or edge.dst not in targets:
                continue
            if edge.relation in IMPACT:
                targets[edge.src].append(edge.dst)
            elif edge.relation == "relates-to":
                targets[edge.src].append(edge.dst)
                targets[edge.dst].append(edge.src)
        ranks = dict.fromkeys(nodes, 1 / size)
        for _ in range(max_iterations):
            dangling = sum(ranks[n] for n in nodes if not targets[n])
            fresh = dict.fromkeys(nodes, (1 - damping) / size + damping * dangling / size)
            for n in nodes:
                for target in targets[n]:
                    fresh[target] += damping * ranks[n] / len(targets[n])
            change = sum(abs(fresh[n] - ranks[n]) for n in nodes)
            ranks = fresh
            if change < tolerance:
                break
        return ranks

    def label(self, number: int) -> str:
        """`0003 Title`, or `0003 (missing)` when no ADR has that number."""
        adr = self.nodes.get(number)
        return f"{number:04d} {adr.title}" if adr else f"{number:04d} (missing)"

    def status(self, number: int) -> str:
        """The status value, or `missing` when no ADR has that number."""
        adr = self.nodes.get(number)
        return adr.status.value if adr else "missing"

    def related(self, number: int) -> dict[str, list[int]]:
        """Relation name to sorted numbers, seen from `number`: its own relations plus the derived
        inverses (`superseded-by`, `amended-by`, `dependency-of`)."""
        found: dict[str, set[int]] = defaultdict(set)
        for edge in self.edges:
            if edge.src == number:
                found[edge.relation].add(edge.dst)
            if edge.dst == number:
                found[INVERSE[edge.relation]].add(edge.src)
        return {name: sorted(numbers) for name, numbers in sorted(found.items())}

    def in_force(self) -> list[Adr]:
        """Accepted ADRs that nothing supersedes, by number. Judged from the edges, so a
        one-sided `supersedes` already counts."""
        return [
            adr
            for number, adr in sorted(self.nodes.items())
            if adr.status is Status.ACCEPTED and not self.related(number).get("superseded-by")
        ]

    def successor(self, number: int) -> int:
        """Follow `superseded-by` to the end of the chain; `number` itself if never superseded.

        With several successors it takes the last (highest-numbered). A cycle stops the walk at the
        first number seen twice.
        """
        seen = {number}
        while successors := self.related(number).get("superseded-by"):
            number = successors[-1]
            if number in seen:
                break
            seen.add(number)
        return number

    def cycle(self, relation: str, extra: Edge | None = None) -> list[int] | None:
        """A cycle in `relation` as a list of numbers, else None. `extra` is a hypothetical edge."""
        sorter: TopologicalSorter[int] = TopologicalSorter()
        for edge in [*self.edges, *([extra] if extra else [])]:
            if edge.relation == relation and edge.src != edge.dst:
                sorter.add(edge.src, edge.dst)
        try:
            sorter.prepare()
        except CycleError as exc:
            return list(exc.args[1])
        return None

    def reachable(
        self,
        start: int,
        relations: tuple[str, ...] = FORWARD,
        *,
        reverse: bool = False,
        depth: int | None = None,
    ) -> Subgraph:
        """Breadth-first walk from `start` over `relations`, each edge visited once.

        `reverse` walks edges backwards and names each step by its inverse (`superseded-by`, ...).
        `relates-to` is followed both ways either way. `depth` caps the levels (None: no cap).
        `Subgraph.steps` are `(parent, child, relation as seen from the parent)`.
        """
        sub = Subgraph([start], [], [])
        seen = {start}
        queue = deque([(start, 0)])
        while queue:
            current, level = queue.popleft()
            if depth is not None and level >= depth:
                continue
            for edge in self.edges:
                if edge.relation not in relations or edge in sub.edges:
                    continue
                ahead, behind = (edge.src, edge.dst) if reverse else (edge.dst, edge.src)
                if behind == current:
                    child, name = ahead, INVERSE[edge.relation] if reverse else edge.relation
                elif edge.relation == "relates-to" and ahead == current:
                    child, name = behind, "relates-to"
                else:
                    continue
                sub.edges.append(edge)
                sub.steps.append((current, child, name))
                if child not in seen:
                    seen.add(child)
                    sub.nodes.append(child)
                    queue.append((child, level + 1))
        return sub

    def governing(self, path: str, *, include_proposed: bool = False) -> list[Adr]:
        """ADRs in force whose `scope` globs match `path` (`PurePosixPath.full_match`, so `**`
        spans directories). `include_proposed` appends matching proposed ADRs after them."""
        candidates = self.in_force()
        if include_proposed:
            candidates += [a for _, a in sorted(self.nodes.items()) if a.status is Status.PROPOSED]
        target = PurePosixPath(path)
        return [adr for adr in candidates if any(target.full_match(g) for g in adr.scope)]

cycle(relation, extra=None)

A cycle in relation as a list of numbers, else None. extra is a hypothetical edge.

Source code in src/adrinaline/graph.py
139
140
141
142
143
144
145
146
147
148
149
def cycle(self, relation: str, extra: Edge | None = None) -> list[int] | None:
    """A cycle in `relation` as a list of numbers, else None. `extra` is a hypothetical edge."""
    sorter: TopologicalSorter[int] = TopologicalSorter()
    for edge in [*self.edges, *([extra] if extra else [])]:
        if edge.relation == relation and edge.src != edge.dst:
            sorter.add(edge.src, edge.dst)
    try:
        sorter.prepare()
    except CycleError as exc:
        return list(exc.args[1])
    return None

governing(path, *, include_proposed=False)

ADRs in force whose scope globs match path (PurePosixPath.full_match, so ** spans directories). include_proposed appends matching proposed ADRs after them.

Source code in src/adrinaline/graph.py
190
191
192
193
194
195
196
197
def governing(self, path: str, *, include_proposed: bool = False) -> list[Adr]:
    """ADRs in force whose `scope` globs match `path` (`PurePosixPath.full_match`, so `**`
    spans directories). `include_proposed` appends matching proposed ADRs after them."""
    candidates = self.in_force()
    if include_proposed:
        candidates += [a for _, a in sorted(self.nodes.items()) if a.status is Status.PROPOSED]
    target = PurePosixPath(path)
    return [adr for adr in candidates if any(target.full_match(g) for g in adr.scope)]

in_force()

Accepted ADRs that nothing supersedes, by number. Judged from the edges, so a one-sided supersedes already counts.

Source code in src/adrinaline/graph.py
116
117
118
119
120
121
122
123
def in_force(self) -> list[Adr]:
    """Accepted ADRs that nothing supersedes, by number. Judged from the edges, so a
    one-sided `supersedes` already counts."""
    return [
        adr
        for number, adr in sorted(self.nodes.items())
        if adr.status is Status.ACCEPTED and not self.related(number).get("superseded-by")
    ]

label(number)

0003 Title, or 0003 (missing) when no ADR has that number.

Source code in src/adrinaline/graph.py
95
96
97
98
def label(self, number: int) -> str:
    """`0003 Title`, or `0003 (missing)` when no ADR has that number."""
    adr = self.nodes.get(number)
    return f"{number:04d} {adr.title}" if adr else f"{number:04d} (missing)"

rank(damping=0.85, tolerance=1e-09, max_iterations=100)

PageRank by number; the ranks sum to 1 ({} for an empty graph).

Counted edges: depends-on and amends, from the depending ADR to the one it depends on or amends, and relates-to in both directions. supersedes is excluded, so replacing an ADR does not move rank. Edges to numbers with no ADR are ignored.

Source code in src/adrinaline/graph.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def rank(
    self, damping: float = 0.85, tolerance: float = 1e-9, max_iterations: int = 100
) -> dict[int, float]:
    """PageRank by number; the ranks sum to 1 (`{}` for an empty graph).

    Counted edges: `depends-on` and `amends`, from the depending ADR to the one it depends on
    or amends, and `relates-to` in both directions. `supersedes` is excluded, so replacing an
    ADR does not move rank. Edges to numbers with no ADR are ignored.
    """
    nodes = sorted(self.nodes)
    if not nodes:
        return {}
    size = len(nodes)
    targets: dict[int, list[int]] = {n: [] for n in nodes}
    for edge in self.edges:
        if edge.src not in targets or edge.dst not in targets:
            continue
        if edge.relation in IMPACT:
            targets[edge.src].append(edge.dst)
        elif edge.relation == "relates-to":
            targets[edge.src].append(edge.dst)
            targets[edge.dst].append(edge.src)
    ranks = dict.fromkeys(nodes, 1 / size)
    for _ in range(max_iterations):
        dangling = sum(ranks[n] for n in nodes if not targets[n])
        fresh = dict.fromkeys(nodes, (1 - damping) / size + damping * dangling / size)
        for n in nodes:
            for target in targets[n]:
                fresh[target] += damping * ranks[n] / len(targets[n])
        change = sum(abs(fresh[n] - ranks[n]) for n in nodes)
        ranks = fresh
        if change < tolerance:
            break
    return ranks

reachable(start, relations=FORWARD, *, reverse=False, depth=None)

Breadth-first walk from start over relations, each edge visited once.

reverse walks edges backwards and names each step by its inverse (superseded-by, ...). relates-to is followed both ways either way. depth caps the levels (None: no cap). Subgraph.steps are (parent, child, relation as seen from the parent).

Source code in src/adrinaline/graph.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
def reachable(
    self,
    start: int,
    relations: tuple[str, ...] = FORWARD,
    *,
    reverse: bool = False,
    depth: int | None = None,
) -> Subgraph:
    """Breadth-first walk from `start` over `relations`, each edge visited once.

    `reverse` walks edges backwards and names each step by its inverse (`superseded-by`, ...).
    `relates-to` is followed both ways either way. `depth` caps the levels (None: no cap).
    `Subgraph.steps` are `(parent, child, relation as seen from the parent)`.
    """
    sub = Subgraph([start], [], [])
    seen = {start}
    queue = deque([(start, 0)])
    while queue:
        current, level = queue.popleft()
        if depth is not None and level >= depth:
            continue
        for edge in self.edges:
            if edge.relation not in relations or edge in sub.edges:
                continue
            ahead, behind = (edge.src, edge.dst) if reverse else (edge.dst, edge.src)
            if behind == current:
                child, name = ahead, INVERSE[edge.relation] if reverse else edge.relation
            elif edge.relation == "relates-to" and ahead == current:
                child, name = behind, "relates-to"
            else:
                continue
            sub.edges.append(edge)
            sub.steps.append((current, child, name))
            if child not in seen:
                seen.add(child)
                sub.nodes.append(child)
                queue.append((child, level + 1))
    return sub

related(number)

Relation name to sorted numbers, seen from number: its own relations plus the derived inverses (superseded-by, amended-by, dependency-of).

Source code in src/adrinaline/graph.py
105
106
107
108
109
110
111
112
113
114
def related(self, number: int) -> dict[str, list[int]]:
    """Relation name to sorted numbers, seen from `number`: its own relations plus the derived
    inverses (`superseded-by`, `amended-by`, `dependency-of`)."""
    found: dict[str, set[int]] = defaultdict(set)
    for edge in self.edges:
        if edge.src == number:
            found[edge.relation].add(edge.dst)
        if edge.dst == number:
            found[INVERSE[edge.relation]].add(edge.src)
    return {name: sorted(numbers) for name, numbers in sorted(found.items())}

status(number)

The status value, or missing when no ADR has that number.

Source code in src/adrinaline/graph.py
100
101
102
103
def status(self, number: int) -> str:
    """The status value, or `missing` when no ADR has that number."""
    adr = self.nodes.get(number)
    return adr.status.value if adr else "missing"

successor(number)

Follow superseded-by to the end of the chain; number itself if never superseded.

With several successors it takes the last (highest-numbered). A cycle stops the walk at the first number seen twice.

Source code in src/adrinaline/graph.py
125
126
127
128
129
130
131
132
133
134
135
136
137
def successor(self, number: int) -> int:
    """Follow `superseded-by` to the end of the chain; `number` itself if never superseded.

    With several successors it takes the last (highest-numbered). A cycle stops the walk at the
    first number seen twice.
    """
    seen = {number}
    while successors := self.related(number).get("superseded-by"):
        number = successors[-1]
        if number in seen:
            break
        seen.add(number)
    return number

Edge dataclass

src has relation to dst. Only forward relations are stored; inverses are derived.

Source code in src/adrinaline/graph.py
16
17
18
19
20
21
22
@dataclass(frozen=True)
class Edge:
    """`src` has `relation` to `dst`. Only forward relations are stored; inverses are derived."""

    src: int
    dst: int
    relation: str

Subgraph dataclass

Result of AdrGraph.reachable: nodes start with the start ADR, in discovery order.

Source code in src/adrinaline/graph.py
25
26
27
28
29
30
31
@dataclass
class Subgraph:
    """Result of `AdrGraph.reachable`: `nodes` start with the start ADR, in discovery order."""

    nodes: list[int]
    edges: list[Edge]
    steps: list[tuple[int, int, str]]  # (parent, child, relation as seen from the parent)

Validation rules for adr check. Each rule returns Finding objects.

run_checks(s, *, template_exists, tracked=None, changed=None, taken_elsewhere=None, today=None, commits=None, drift_commits=0, range_commits=None, uncommitted=None, skills=None)

Run every rule and return the findings sorted by (path, code).

Optional inputs switch rules on: tracked (files in the repo) enables scope-matches-nothing; changed (files changed against a base) enables scoped-change, minus files whose commits in range_commits cite or exempt the ADR (unless still in uncommitted); skills maps an installed skill dir to its version; taken_elsewhere numbers are not reported as gaps.

commits feeds stale-decision. None means no git history was read: the threshold is forced to 0, which disables the rule. [] means history was read and found nothing (or the rule was disabled upstream); the threshold stays, but no ADR has churn, so the result is the same. review-overdue does not depend on commits.

Source code in src/adrinaline/check.py
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
def run_checks(
    s: AdrSet,
    *,
    template_exists: bool,
    tracked: list[str] | None = None,
    changed: list[str] | None = None,
    taken_elsewhere: set[int] | None = None,
    today: dt.date | None = None,
    commits: list[Commit] | None = None,
    drift_commits: int = 0,
    range_commits: list[Commit] | None = None,
    uncommitted: list[str] | None = None,
    skills: dict[str, str | None] | None = None,
) -> list[Finding]:
    """Run every rule and return the findings sorted by (path, code).

    Optional inputs switch rules on: `tracked` (files in the repo) enables `scope-matches-nothing`;
    `changed` (files changed against a base) enables `scoped-change`, minus files whose commits in
    `range_commits` cite or exempt the ADR (unless still in `uncommitted`); `skills` maps an
    installed skill dir to its version; `taken_elsewhere` numbers are not reported as gaps.

    `commits` feeds `stale-decision`. None means no git history was read: the threshold is forced
    to 0, which disables the rule. `[]` means history was read and found nothing (or the rule was
    disabled upstream); the threshold stays, but no ADR has churn, so the result is the same.
    `review-overdue` does not depend on `commits`.
    """
    graph = AdrGraph(s.adrs)
    cited, traced = _trailers(graph, range_commits or [])
    traced -= set(uncommitted or [])
    if changed is not None:
        changed = [p for p in changed if p not in traced]
    in_force = graph.in_force()
    findings = [
        *s.problems,
        *_duplicates(s),
        *_gaps(s, taken_elsewhere or set()),
        *_relations(s, graph),
        *_unknown_keys(s),
        *_scopes(s, graph, tracked, changed),
        *_stale_skills(skills or {}),
        *cited,
        *drift.review_findings(
            in_force,
            drift.compute(in_force, commits or []),
            today or dt.date.today(),
            drift_commits if commits is not None else 0,
        ),
    ]
    if not template_exists:
        findings.append(
            Finding("no-template", "warning", "", "template is missing; `adr init` recreates it")
        )
    return sorted(findings, key=lambda f: (f.path, f.code))

Agent briefing for adr prime: decisions in force, proposals and usage rules.

Briefing dataclass

The briefing Markdown plus the parts it was built from.

Source code in src/adrinaline/prime.py
80
81
82
83
84
85
86
87
88
89
@dataclass
class Briefing:
    """The briefing Markdown plus the parts it was built from."""

    markdown: str
    shown: list[Adr]
    omitted: int
    proposed: list[Adr]
    counts: dict[str, int]
    review: list[Finding]

briefing(graph, dir_label, changed, budget=None, review=None)

Build the adr prime Markdown: ADRs in force, review findings, proposed ADRs and rules.

With a budget (words) the in-force list is cut by select, ranked against the changed paths (SPEC §12); without one, all are shown and changed is unused. Proposed ADRs are never cut. dir_label is the ADR directory as shown to the reader.

Source code in src/adrinaline/prime.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def briefing(
    graph: AdrGraph,
    dir_label: str,
    changed: list[str],
    budget: int | None = None,
    review: list[Finding] | None = None,
) -> Briefing:
    """Build the `adr prime` Markdown: ADRs in force, `review` findings, proposed ADRs and rules.

    With a `budget` (words) the in-force list is cut by `select`, ranked against the `changed`
    paths (SPEC §12); without one, all are shown and `changed` is unused. Proposed ADRs are never
    cut. `dir_label` is the ADR directory as shown to the reader.
    """
    in_force = graph.in_force()
    proposed = [adr for _, adr in sorted(graph.nodes.items()) if adr.status is Status.PROPOSED]
    if budget is None:
        shown, omitted = in_force, 0
    else:
        shown, omitted = select(graph, in_force, changed, budget)
    counter = Counter(adr.status.value for adr in graph.adrs)
    counts = {s.value: counter[s.value] for s in Status if counter[s.value]}
    summary = ", ".join(f"{n} {status}" for status, n in counts.items()) or "none yet"
    parts = [
        "# ADR briefing",
        f"ADRs live in `{dir_label}`: {summary}.",
        "## Decisions in force",
        "\n".join(entry(graph, adr) for adr in shown) or "None.",
    ]
    if omitted:
        parts.append(f"{omitted} more in force: `adr list --status accepted`")
    if review:
        parts += ["## Needs review", "\n".join(f"- {f.message}" for f in review)]
    parts += [
        "## Proposed",
        "\n".join(entry(graph, adr) for adr in proposed) or "None.",
        "## Using adr",
        RULES,
    ]
    return Briefing("\n\n".join(parts), shown, omitted, proposed, counts, review or [])

scores(graph, adrs, changed)

Score each ADR in adrs from rank, a scope hit on a changed path and recency; weights and formula in SPEC §12. Rank is normalised by the highest rank in the whole graph, recency by the dates within adrs (1.0 when they are all equal).

Source code in src/adrinaline/prime.py
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def scores(graph: AdrGraph, adrs: list[Adr], changed: list[str]) -> dict[int, float]:
    """Score each ADR in `adrs` from rank, a scope hit on a `changed` path and recency; weights
    and formula in SPEC §12. Rank is normalised by the highest rank in the whole graph, recency
    by the dates within `adrs` (1.0 when they are all equal)."""
    ranks = graph.rank()
    top = max(ranks.values(), default=0.0) or 1.0
    days = [adr.date.toordinal() for adr in adrs]
    low, high = min(days, default=0), max(days, default=0)
    paths = [PurePosixPath(p) for p in changed]
    result: dict[int, float] = {}
    for adr in adrs:
        hit = any(p.full_match(glob) for p in paths for glob in adr.scope)
        recency = (adr.date.toordinal() - low) / (high - low) if high > low else 1.0
        result[adr.number] = 0.5 * ranks.get(adr.number, 0.0) / top + 0.3 * hit + 0.2 * recency
    return result

select(graph, adrs, changed, budget)

Fill a budget of words with adrs by descending scores (ties: lower number first).

The top-scored ADR is always taken, even over budget; after that an ADR that does not fit is skipped and smaller ones may still fill the space. Returns the chosen ADRs sorted by number and how many were left out.

Source code in src/adrinaline/prime.py
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
def select(
    graph: AdrGraph, adrs: list[Adr], changed: list[str], budget: int
) -> tuple[list[Adr], int]:
    """Fill a `budget` of words with `adrs` by descending `scores` (ties: lower number first).

    The top-scored ADR is always taken, even over budget; after that an ADR that does not fit is
    skipped and smaller ones may still fill the space. Returns the chosen ADRs sorted by number
    and how many were left out.
    """
    points = scores(graph, adrs, changed)
    heap = [(-points[adr.number], adr.number, i) for i, adr in enumerate(adrs)]
    heapq.heapify(heap)
    chosen: list[Adr] = []
    used = 0
    while heap:
        _, _, i = heapq.heappop(heap)
        cost = _words(entry(graph, adrs[i]))
        if not chosen or used + cost <= budget:
            chosen.append(adrs[i])
            used += cost
    chosen.sort(key=lambda adr: adr.number)
    return chosen, len(adrs) - len(chosen)

Split, parse and render Markdown files with YAML frontmatter (round-trip safe).

FrontmatterDateError

Bases: FrontmatterError

A date field in the frontmatter could not be parsed.

Source code in src/adrinaline/frontmatter.py
19
20
class FrontmatterDateError(FrontmatterError):
    """A date field in the frontmatter could not be parsed."""

FrontmatterError

Bases: ValueError

The file has no valid YAML frontmatter mapping.

Source code in src/adrinaline/frontmatter.py
11
12
class FrontmatterError(ValueError):
    """The file has no valid YAML frontmatter mapping."""

FrontmatterMissing

Bases: FrontmatterError

The file does not start with a frontmatter block.

Source code in src/adrinaline/frontmatter.py
15
16
class FrontmatterMissing(FrontmatterError):
    """The file does not start with a frontmatter block."""

flow(items)

A list that renders in flow style ([1, 2]) instead of one item per line.

Source code in src/adrinaline/frontmatter.py
61
62
63
64
65
def flow(items: Iterable) -> CommentedSeq:
    """A list that renders in flow style (`[1, 2]`) instead of one item per line."""
    seq = CommentedSeq(items)
    seq.fa.set_flow_style()
    return seq

render(meta, body)

Inverse of split: comments, key order and quoting of meta survive.

Source code in src/adrinaline/frontmatter.py
52
53
54
55
56
57
58
def render(meta: CommentedMap, body: str) -> str:
    """Inverse of `split`: comments, key order and quoting of `meta` survive."""
    if not meta:
        return f"---\n---\n{body}"
    buffer = StringIO()
    _yaml().dump(meta, buffer)
    return f"---\n{buffer.getvalue()}---\n{body}"

split(text)

Split a file into its frontmatter mapping and the body after the closing ---.

Source code in src/adrinaline/frontmatter.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def split(text: str) -> tuple[CommentedMap, str]:
    """Split a file into its frontmatter mapping and the body after the closing `---`."""
    lines = text.splitlines(keepends=True)
    if not lines or lines[0].rstrip("\r\n") != "---":
        raise FrontmatterMissing("missing frontmatter (file must start with '---')")
    for end, line in enumerate(lines[1:], start=1):
        if line.rstrip("\r\n") == "---":
            try:
                data = _yaml().load("".join(lines[1:end]))
            except ValueError as exc:
                raise FrontmatterDateError(f"invalid YAML: {exc}") from exc
            except YAMLError as exc:
                raise FrontmatterError(f"invalid YAML: {exc}") from exc
            if data is None:
                data = CommentedMap()
            if not isinstance(data, CommentedMap):
                raise FrontmatterError("frontmatter is not a mapping")
            return data, "".join(lines[end + 1 :])
    raise FrontmatterError("unterminated frontmatter (no closing '---')")

Read-only wrappers around the git executable. Never stage, commit or fetch.

commits_in(root, revs, files=True)

Non-merge commits of a revision or range (HEAD, base..HEAD), newest first, with trailers and (unless files=False) the files each one touched. [] when git fails.

Source code in src/adrinaline/git.py
195
196
197
198
def commits_in(root: Path, revs: str, files: bool = True) -> list[Commit]:
    """Non-merge commits of a revision or range (`HEAD`, `base..HEAD`), newest first, with
    trailers and (unless files=False) the files each one touched. [] when git fails."""
    return _commit_log(root, [revs], files)

commits_since(root, since, rev=None)

Non-merge commits reachable from rev (default HEAD) since a date, newest first, with the files each one touched. [] when git fails (for example no commits yet).

Source code in src/adrinaline/git.py
185
186
187
188
189
190
191
192
def commits_since(root: Path, since: dt.date, rev: str | None = None) -> list[Commit]:
    """Non-merge commits reachable from rev (default HEAD) since a date, newest first, with
    the files each one touched. [] when git fails (for example no commits yet)."""
    # --since is compared in the committer's own timezone, so a commit made early the next day
    # in a far-east zone can look older than the baseline; start a day early and let
    # drift.compute do the exact committer-day comparison.
    start = since - dt.timedelta(days=1)
    return _commit_log(root, [f"--since={start.isoformat()}", rev or "HEAD"])

log(root, path, rev=None)

Commits that touched path, following renames, newest first, from rev (default HEAD).

Each row has commit, date, author and subject. Raises no-git without a repository; [] when git fails.

Source code in src/adrinaline/git.py
129
130
131
132
133
134
135
136
137
138
139
140
141
def log(root: Path | None, path: Path, rev: str | None = None) -> list[dict[str, str]]:
    """Commits that touched `path`, following renames, newest first, from `rev` (default HEAD).

    Each row has `commit`, `date`, `author` and `subject`. Raises `no-git` without a repository;
    `[]` when git fails.
    """
    if root is None:
        raise AdrError("no-git", "adr log needs a git repository")
    fmt = "--format=%H%x1f%as%x1f%an%x1f%s"
    # NUL-separated: str.splitlines() would also split a subject on U+2028, U+0085, \f, \x1c-\x1e
    out = run(["log", "-z", "--follow", fmt, rev or "HEAD", "--", str(path)], root) or ""
    keys = ("commit", "date", "author", "subject")
    return [dict(zip(keys, rec.split("\x1f"), strict=True)) for rec in out.split("\0") if rec]

numbers_on_refs(root, directory)

ADR numbers in directory on local branches and remote-tracking refs, each mapped to the first ref (in refname order) that has it. The current branch is skipped. {} if directory is outside root or the refs cannot be listed.

Source code in src/adrinaline/git.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
def numbers_on_refs(root: Path, directory: Path) -> dict[int, str]:
    """ADR numbers in `directory` on local branches and remote-tracking refs, each mapped to the
    first ref (in refname order) that has it. The current branch is skipped. `{}` if `directory`
    is outside `root` or the refs cannot be listed."""
    rel = _rel(root, directory)
    refs = run(["for-each-ref", "--format=%(refname:short)", "refs/heads", "refs/remotes"], root)
    if rel is None or refs is None:
        return {}
    # Exclude the current branch - we only care about numbers on OTHER branches
    current = run(["rev-parse", "--abbrev-ref", "HEAD"], root)
    current_branch = current.strip() if current else None
    found: dict[int, str] = {}
    for ref in refs.split():
        if current_branch and ref == current_branch:
            continue
        listing = run(["ls-tree", "--name-only", ref, "--", f"{rel}/"], root) or ""
        for line in listing.splitlines():
            if match := FILENAME_RE.match(PurePosixPath(line).name):
                found.setdefault(int(match[1]), ref)
    return found

read_dir_at(root, rev, directory)

(path, text) for each file directly in directory at rev, as repo.parse_all takes.

Subdirectories and missing objects are skipped; undecodable bytes are replaced. Raises no-git (no repository or git), bad-revision, or no-adr-dir (outside the repository or absent at rev).

Source code in src/adrinaline/git.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
def read_dir_at(root: Path | None, rev: str, directory: Path) -> list[tuple[Path, str]]:
    """`(path, text)` for each file directly in `directory` at `rev`, as `repo.parse_all` takes.

    Subdirectories and missing objects are skipped; undecodable bytes are replaced. Raises
    `no-git` (no repository or git), `bad-revision`, or `no-adr-dir` (outside the repository or
    absent at `rev`).
    """
    if root is None:
        raise AdrError("no-git", "--at needs a git repository")
    verify_rev(root, rev)
    rel = _rel(root, directory)
    if rel is None:
        raise AdrError(
            "no-adr-dir",
            f"ADR directory {directory} is outside the git repository; --at needs it inside",
            rev=rev,
        )
    listing = run(["ls-tree", "--name-only", rev, "--", f"{rel}/"], root)
    names = [n for n in (listing or "").splitlines() if n]
    if not names:
        raise AdrError("no-adr-dir", f"no ADR directory {rel} at {rev}", rev=rev)
    try:
        proc = subprocess.run(
            ["git", "cat-file", "--batch"],
            cwd=root,
            input="".join(f"{rev}:{name}\n" for name in names).encode(),
            capture_output=True,
            check=True,
            env=_ENV,
        )
    except FileNotFoundError as exc:
        raise AdrError("no-git", "git executable not found") from exc
    except subprocess.CalledProcessError as exc:
        raise AdrError("bad-revision", f"cannot read {rev}: git cat-file failed", rev=rev) from exc
    data, items, pos = proc.stdout, [], 0
    for name in names:
        end = data.index(b"\n", pos)
        header = data[pos:end].split()
        pos = end + 1
        if len(header) < 3:  # "<object> missing"
            continue
        size = int(header[2])
        if header[1] == b"blob":
            items.append((root / name, data[pos : pos + size].decode("utf-8", errors="replace")))
        pos += size + 1
    return items

run(args, cwd, stdin=None)

Run git and return stdout, or None if git exits non-zero or is not installed. The two cases look the same to the caller; it never raises.

Source code in src/adrinaline/git.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def run(args: list[str], cwd: Path, stdin: str | None = None) -> str | None:
    """Run git and return stdout, or None if git exits non-zero or is not installed. The two
    cases look the same to the caller; it never raises."""
    try:
        proc = subprocess.run(
            ["git", "-c", "core.quotePath=false", *args],
            cwd=cwd,
            input=stdin,
            capture_output=True,
            text=True,
            env=_ENV,
        )
    except FileNotFoundError:
        return None
    return proc.stdout if proc.returncode == 0 else None

verify_rev(root, rev)

Fail with bad-revision unless rev names a commit; --end-of-options keeps a rev such as --output=f from reaching git as an option.

Source code in src/adrinaline/git.py
71
72
73
74
75
76
77
78
def verify_rev(root: Path, rev: str) -> None:
    """Fail with bad-revision unless rev names a commit; `--end-of-options` keeps a rev such as
    `--output=f` from reaching git as an option."""
    if (
        run(["rev-parse", "--verify", "--quiet", "--end-of-options", f"{rev}^{{commit}}"], root)
        is None
    ):
        raise AdrError("bad-revision", f"unknown git revision: {rev}", rev=rev)

Human and JSON output, the error envelope and exit codes.

Out

Output channel. result prints JSON in json_mode, else the text unless quiet; note writes a hint to stderr unless quiet; rel shortens a path relative to base; paint adds ANSI colour only when color is set.

Source code in src/adrinaline/output.py
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
class Out:
    """Output channel. `result` prints JSON in `json_mode`, else the text unless `quiet`; `note`
    writes a hint to stderr unless `quiet`; `rel` shortens a path relative to `base`; `paint` adds
    ANSI colour only when `color` is set."""

    def __init__(self, *, json_mode: bool, quiet: bool, base: Path, color: bool = False) -> None:
        self.json_mode = json_mode
        self.quiet = quiet
        self.base = base.resolve()
        self.color = color

    def paint(self, text: str, style: str) -> str:
        return f"\x1b[{_STYLES[style]}m{text}\x1b[0m" if self.color else text

    def status(self, status: Status, width: int = 0) -> str:
        return self.paint(f"{status:<{width}}", STATUS_STYLE.get(status, "dim"))

    def rel(self, path: Path) -> str:
        try:
            return path.resolve().relative_to(self.base).as_posix()
        except ValueError:
            return str(path)

    def result(self, data: Any, text: str) -> None:
        if self.json_mode:
            sys.stdout.write(dumps(data) + "\n")
        elif not self.quiet and text:
            sys.stdout.write(text if text.endswith("\n") else text + "\n")

    def note(self, text: str) -> None:
        if not self.quiet:
            print(text, file=sys.stderr)

adr_dict(adr, out, *, relations=None, body=False, **extra)

The JSON object for one ADR.

relations defaults to the stored relations that are not empty; pass one to show others, such as derived inverses. meta holds the frontmatter keys that have no field of their own. body adds the Markdown body; extra is merged last and wins on a clash.

Source code in src/adrinaline/output.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
def adr_dict(
    adr: Adr,
    out: Out,
    *,
    relations: dict[str, list[int]] | None = None,
    body: bool = False,
    **extra: Any,
) -> dict[str, Any]:
    """The JSON object for one ADR.

    `relations` defaults to the stored relations that are not empty; pass one to show others, such
    as derived inverses. `meta` holds the frontmatter keys that have no field of their own. `body`
    adds the Markdown body; `extra` is merged last and wins on a clash.
    """
    data: dict[str, Any] = {
        "number": adr.number,
        "id": adr.id,
        "slug": adr.slug,
        "title": adr.title,
        "status": adr.status.value,
        "date": adr.date.isoformat(),
        "path": out.rel(adr.path),
        "tags": list(adr.tags),
        "scope": list(adr.scope),
        "review-by": adr.review_by.isoformat() if adr.review_by else None,
        "reviewed": adr.reviewed.isoformat() if adr.reviewed else None,
        "revisit-if": adr.revisit_if,
        "relations": relations
        if relations is not None
        else {name: nums for name, nums in adr.relations.items() if nums},
        "meta": {k: v for k, v in adr.meta.items() if k not in _KNOWN_KEYS},
    }
    if body:
        data["body"] = adr.body
    return data | extra

error(err, json_mode)

Report err: a JSON envelope on stdout, or adr: code: message on stderr. Returns the domain exit code (3).

Source code in src/adrinaline/output.py
147
148
149
150
151
152
153
154
155
def error(err: AdrError, json_mode: bool) -> int:
    """Report `err`: a JSON envelope on stdout, or `adr: code: message` on stderr. Returns the
    domain exit code (3)."""
    if json_mode:
        envelope = {"error": {"code": err.code, "message": err.message, "details": err.details}}
        sys.stdout.write(dumps(envelope) + "\n")
    else:
        print(f"adr: {err.code}: {err.message}", file=sys.stderr)
    return EXIT_DOMAIN

github_annotation(row)

One GitHub Actions workflow command for a check finding.

Source code in src/adrinaline/output.py
46
47
48
49
50
def github_annotation(row: dict[str, str]) -> str:
    """One GitHub Actions workflow command for a `check` finding."""
    props = [f"file={_gh_prop(row['path'])}", "line=1"] if row["path"] else []
    props.append(f"title={_gh_prop(row['code'])}")
    return f"::{_GITHUB_LEVEL[row['severity']]} {','.join(props)}::{_gh_data(row['message'])}"

gitlab_report(rows, fallback)

A GitLab Code Quality report for check findings; set-wide ones point at fallback.

Fingerprints hash code, path and occurrence index, not the message, which carries volatile text such as commit hashes and churn counts.

Source code in src/adrinaline/output.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
def gitlab_report(rows: list[dict[str, str]], fallback: str) -> str:
    """A GitLab Code Quality report for `check` findings; set-wide ones point at `fallback`.

    Fingerprints hash code, path and occurrence index, not the message, which carries
    volatile text such as commit hashes and churn counts.
    """
    seen: dict[tuple[str, str], int] = {}
    issues = []
    for r in rows:
        key = (r["code"], r["path"])
        seen[key] = n = seen.get(key, -1) + 1
        issues.append(
            {
                "description": r["message"],
                "check_name": r["code"],
                "fingerprint": hashlib.sha256(f"{key[0]}\0{key[1]}\0{n}".encode()).hexdigest(),
                "severity": _GITLAB_SEVERITY[r["severity"]],
                "location": {"path": r["path"] or fallback, "lines": {"begin": 1}},
            }
        )
    return dumps(issues)

Decision health: churn over an ADR's scope since its baseline, and the review findings.

Drift dataclass

Commits that touched one ADR's scope since its baseline.

Source code in src/adrinaline/drift.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@dataclass
class Drift:
    """Commits that touched one ADR's scope since its baseline."""

    adr: Adr
    commits: list[Commit]  # newest first, as git log prints them

    @property
    def files(self) -> list[str]:
        return sorted({f for c in self.commits for f in c.files if _in_scope(f, self.adr)})

    @property
    def last(self) -> Commit | None:
        return self.commits[0] if self.commits else None

compute(adrs, commits)

One Drift per ADR with a scope, highest churn first. Churn counts commits whose committer date is a later day than the baseline (SPEC §8); ADRs without a scope are skipped.

Source code in src/adrinaline/drift.py
36
37
38
39
40
41
42
43
44
45
46
47
def compute(adrs: list[Adr], commits: list[Commit]) -> list[Drift]:
    """One Drift per ADR with a scope, highest churn first. Churn counts commits whose committer
    date is a later day than the baseline (SPEC §8); ADRs without a scope are skipped."""
    result = []
    for adr in adrs:
        if not adr.scope:
            continue
        hits = [
            c for c in commits if c.date > adr.baseline and any(_in_scope(f, adr) for f in c.files)
        ]
        result.append(Drift(adr, hits))
    return sorted(result, key=lambda d: (-len(d.commits), d.adr.number))

earliest_baseline(adrs)

Start of the one git log call that serves every scoped ADR.

Source code in src/adrinaline/drift.py
31
32
33
def earliest_baseline(adrs: list[Adr]) -> dt.date | None:
    """Start of the one git log call that serves every scoped ADR."""
    return min((adr.baseline for adr in adrs if adr.scope), default=None)

review_findings(adrs, drifts, today, threshold)

review-overdue for every ADR whose review-by has passed; stale-decision for every drift at or above the threshold (threshold 0 disables it). Order: overdue first, then stale in drift order.

Source code in src/adrinaline/drift.py
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
def review_findings(
    adrs: list[Adr], drifts: list[Drift], today: dt.date, threshold: int
) -> list[Finding]:
    """`review-overdue` for every ADR whose review-by has passed; `stale-decision` for every
    drift at or above the threshold (threshold 0 disables it). Order: overdue first, then
    stale in drift order."""
    found = [
        Finding(
            "review-overdue",
            "warning",
            str(adr.path),
            f"{adr.id} {adr.title}: review-by {adr.review_by} has passed; "
            f"run `adr review {adr.number}` after revisiting it",
        )
        for adr in adrs
        if adr.review_by and adr.review_by < today
    ]
    if threshold > 0:
        found += [
            Finding(
                "stale-decision",
                "warning",
                str(d.adr.path),
                f"{d.adr.id} {d.adr.title}: {len(d.commits)} commits touched its scope since "
                f"{d.adr.baseline}; run `adr review {d.adr.number}` after revisiting it",
            )
            for d in drifts
            if len(d.commits) >= threshold
        ]
    return found

Repairs for mechanical check findings (ADR 0010).

Fix dataclass

One repair made by apply: the finding code, the file path and a short message.

Source code in src/adrinaline/fix.py
12
13
14
15
16
17
18
@dataclass(frozen=True)
class Fix:
    """One repair made by `apply`: the finding `code`, the file `path` and a short `message`."""

    code: str
    path: str
    message: str

apply(s, loc)

Repair the codes ADR 0010 lists, write the files, return what changed.

Renamed keys return early; call again on a reloaded set until it returns nothing.

Source code in src/adrinaline/fix.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
def apply(s: AdrSet, loc: Location) -> list[Fix]:
    """Repair the codes ADR 0010 lists, write the files, return what changed.

    Renamed keys return early; call again on a reloaded set until it returns nothing.
    """
    fixes: list[Fix] = []
    changed: dict[int, Adr] = {}

    def done(adr: Adr, code: str, message: str) -> None:
        changed[id(adr)] = adr
        fixes.append(Fix(code, str(adr.path), message))

    for adr in s.adrs:
        for key in [k for k in adr.meta if k in SNAKE_KEYS]:
            new = key.replace("_", "-")
            if new in adr.meta:
                continue
            position = list(adr.meta).index(key)
            adr.meta.insert(position, new, adr.meta.pop(key))
            done(adr, "unknown-relation-key", f"{adr.id} `{key}` -> `{new}`")
    if changed:  # relations are parsed at load; the caller reloads and calls again
        for adr in changed.values():
            repo.save(adr)
        return fixes
    nodes = AdrGraph(s.adrs).nodes
    for adr in s.adrs:
        for name, inverse in (("supersedes", "superseded-by"), ("superseded-by", "supersedes")):
            for n in adr.relations[name]:
                other = nodes.get(n)
                if (
                    other
                    and other.number != adr.number
                    and adr.number not in other.relations[inverse]
                ):
                    repo.set_relation(other, inverse, [*other.relations[inverse], adr.number])
                    done(
                        other,
                        "asymmetric-relation",
                        f"{other.id} {inverse}: {other.relations[inverse]}",
                    )
    for adr in s.adrs:
        if adr.relations["superseded-by"] and adr.status is not Status.SUPERSEDED:
            adr.status = Status.SUPERSEDED  # keeps `date`: the decision date stands
            adr.meta["status"] = Status.SUPERSEDED.value
            done(adr, "status-mismatch", f"{adr.id} status: superseded")
    for adr in changed.values():
        repo.save(adr)
    if not loc.template.is_file():
        repo.atomic_write(loc.template, repo.data_text("template.md"))
        fixes.append(Fix("no-template", str(loc.template), "recreated from the bundled template"))
    return fixes