adr-check 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
adr_check/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Checks architecture decision records (ADRs) against the ADR rules."""
adr_check/adr.py ADDED
@@ -0,0 +1,334 @@
1
+ """Parse ADR files and run the checks that need only the files in front of you."""
2
+
3
+ import re
4
+ from collections import defaultdict
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from datetime import date
8
+ from pathlib import Path
9
+
10
+ import yaml
11
+
12
+ STATUSES = ("proposed", "accepted", "rejected", "superseded")
13
+ KEYS = ("status", "date", "supersedes", "superseded_by")
14
+ SECTIONS = ("Context", "Considered options", "Decision", "Consequences")
15
+
16
+ _FILENAME = re.compile(r"^(\d{4})-([a-z0-9]+(?:-[a-z0-9]+)*)\.md$")
17
+ _H1 = re.compile(r"^(\d{4})\. \S")
18
+ _ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
19
+ _COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Meta:
24
+ """Validated frontmatter of one ADR."""
25
+
26
+ status: str
27
+ date: date
28
+ supersedes: tuple[int, ...]
29
+ superseded_by: int | None
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class AdrFile:
34
+ """One ADR file, parsed as far as it parses.
35
+
36
+ `number` comes from the filename and is None when the name is not `NNNN-kebab-title.md`.
37
+ `raw` is the frontmatter as written, and `meta` is None when it failed validation.
38
+ """
39
+
40
+ path: str
41
+ name: str
42
+ number: int | None
43
+ raw: dict[str, object] | None
44
+ meta: Meta | None
45
+ body: str
46
+
47
+
48
+ Resolver = Callable[[AdrFile, int, str], AdrFile | None]
49
+
50
+
51
+ def is_candidate(name: str) -> bool:
52
+ """Return whether a file in the ADR directory is meant to be an ADR.
53
+
54
+ The directory may also hold a README or other non-Markdown files. Every other Markdown file is
55
+ checked, so a badly named ADR fails rather than being ignored.
56
+ """
57
+ return name.endswith(".md") and name.lower() != "readme.md"
58
+
59
+
60
+ def number_of(name: str) -> int | None:
61
+ """Return the number in an ADR filename, or None if the name is not well formed."""
62
+ match = _FILENAME.match(name)
63
+ return int(match.group(1)) if match else None
64
+
65
+
66
+ def slug_of(name: str) -> str | None:
67
+ """Return the kebab-case title in an ADR filename, or None if the name is not well formed."""
68
+ match = _FILENAME.match(name)
69
+ return match.group(2) if match else None
70
+
71
+
72
+ def split_document(text: str) -> tuple[str | None, str]:
73
+ """Split a document into its frontmatter text and body.
74
+
75
+ The frontmatter is None when the file does not open with a closed `---` block.
76
+ """
77
+ lines = text.replace("\r\n", "\n").split("\n")
78
+ if lines[0].rstrip() != "---":
79
+ return None, "\n".join(lines)
80
+ for index in range(1, len(lines)):
81
+ if lines[index].rstrip() == "---":
82
+ return "\n".join(lines[1:index]), "\n".join(lines[index + 1 :])
83
+ return None, "\n".join(lines)
84
+
85
+
86
+ def _is_int(value: object) -> bool:
87
+ return isinstance(value, int) and not isinstance(value, bool)
88
+
89
+
90
+ def _validate_meta(raw: dict[str, object]) -> tuple[Meta | None, list[str]]:
91
+ problems: list[str] = []
92
+ for key in KEYS:
93
+ if key not in raw:
94
+ problems.append(f"frontmatter is missing the key `{key}`")
95
+ problems.extend(
96
+ f"frontmatter has the key `{key}`, which is not one of {', '.join(KEYS)}"
97
+ for key in raw
98
+ if key not in KEYS
99
+ )
100
+ if problems:
101
+ return None, problems
102
+
103
+ status = raw["status"]
104
+ if status not in STATUSES:
105
+ problems.append(f"`status` must be one of {', '.join(STATUSES)}, not {status!r}")
106
+
107
+ raw_date = raw["date"]
108
+ parsed: date | None = None
109
+ if type(raw_date) is date:
110
+ parsed = raw_date
111
+ elif isinstance(raw_date, str) and _ISO_DATE.match(raw_date):
112
+ try:
113
+ parsed = date.fromisoformat(raw_date)
114
+ except ValueError:
115
+ parsed = None
116
+ if parsed is None:
117
+ problems.append(f"`date` must be an ISO date such as 2026-03-12, not {raw_date!r}")
118
+
119
+ raw_supersedes = raw["supersedes"]
120
+ supersedes: tuple[int, ...] = ()
121
+ if raw_supersedes is None:
122
+ pass
123
+ elif isinstance(raw_supersedes, list) and all(_is_int(item) for item in raw_supersedes):
124
+ supersedes = tuple(item for item in raw_supersedes if isinstance(item, int))
125
+ else:
126
+ problems.append("`supersedes` must be a list of ADR numbers such as [7], or empty")
127
+
128
+ superseded_by = raw["superseded_by"]
129
+ if superseded_by is not None and not _is_int(superseded_by):
130
+ problems.append("`superseded_by` must be one ADR number such as 15, or empty")
131
+
132
+ if problems or parsed is None or not isinstance(status, str):
133
+ return None, problems
134
+ by = superseded_by if isinstance(superseded_by, int) else None
135
+ return Meta(status, parsed, supersedes, by), []
136
+
137
+
138
+ _FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
139
+ _COMMENT_START = re.compile(r"^ {0,3}<!--")
140
+
141
+
142
+ def _headings(body: str) -> list[tuple[int, str]]:
143
+ headings: list[tuple[int, str]] = []
144
+ fence: tuple[str, int] | None = None
145
+ in_comment = False
146
+ for line in body.split("\n"):
147
+ if in_comment:
148
+ # CommonMark: an HTML comment block runs to the first line holding `-->`, or to EOF.
149
+ in_comment = "-->" not in line
150
+ continue
151
+ if fence is None and _COMMENT_START.match(line):
152
+ in_comment = "-->" not in line[line.index("<!--") :]
153
+ continue
154
+ line = _COMMENT.sub("", line)
155
+ match = _FENCE.match(line)
156
+ if match:
157
+ run, rest = match.groups()
158
+ if fence is None:
159
+ # CommonMark: a backtick fence's info string cannot hold a backtick.
160
+ if run[0] != "`" or "`" not in rest:
161
+ fence = (run[0], len(run))
162
+ continue
163
+ if run[0] == fence[0] and len(run) >= fence[1] and not rest.strip():
164
+ fence = None
165
+ continue
166
+ if fence:
167
+ continue
168
+ heading = re.match(r"^(#{1,6}) +(.*?) *#*$", line)
169
+ if heading:
170
+ headings.append((len(heading.group(1)), heading.group(2)))
171
+ return headings
172
+
173
+
174
+ def parse_text(path: str, text: str) -> tuple[AdrFile, list[str]]:
175
+ """Parse one ADR and return it with the problems found in that file alone."""
176
+ name = path.rsplit("/", 1)[-1]
177
+ number = number_of(name)
178
+ problems: list[str] = []
179
+ if number is None:
180
+ problems.append("filename must look like NNNN-kebab-title.md, for example 0007-use-x.md")
181
+
182
+ front, body = split_document(text)
183
+ raw: dict[str, object] | None = None
184
+ meta: Meta | None = None
185
+ if front is None:
186
+ problems.append("file must open with a frontmatter block between `---` lines")
187
+ else:
188
+ try:
189
+ loaded: object = yaml.safe_load(front)
190
+ except (yaml.YAMLError, ValueError, OverflowError) as error:
191
+ # PyYAML raises ValueError for an unquoted impossible date such as 2026-02-30.
192
+ loaded = None
193
+ problems.append(f"frontmatter is not valid YAML: {str(error).partition('\n')[0]}")
194
+ else:
195
+ if isinstance(loaded, dict) and all(isinstance(key, str) for key in loaded):
196
+ raw = {str(key): value for key, value in loaded.items()}
197
+ meta, meta_problems = _validate_meta(raw)
198
+ problems.extend(meta_problems)
199
+ else:
200
+ problems.append("frontmatter must be a mapping of the four keys")
201
+
202
+ problems.extend(_check_body(number, body))
203
+ return AdrFile(path, name, number, raw, meta, body), [f"{path}: {p}" for p in problems]
204
+
205
+
206
+ def _check_body(number: int | None, body: str) -> list[str]:
207
+ problems: list[str] = []
208
+ headings = _headings(body)
209
+ h1s = [text for level, text in headings if level == 1]
210
+ if not h1s:
211
+ problems.append("missing the H1 title, for example `# 0007. Use managed identities`")
212
+ else:
213
+ match = _H1.match(h1s[0])
214
+ if not match:
215
+ problems.append(f"H1 must look like `# 0007. Title`, not `# {h1s[0]}`")
216
+ elif number is not None and int(match.group(1)) != number:
217
+ problems.append(
218
+ f"H1 number {match.group(1)} does not match filename number {number:04d}"
219
+ )
220
+ present = {text for level, text in headings if level == 2}
221
+ problems.extend(f"missing the `## {s}` section" for s in SECTIONS if s not in present)
222
+ return problems
223
+
224
+
225
+ def load_tree(adr_dir: Path, display_dir: str) -> tuple[list[AdrFile], list[str]]:
226
+ """Read every ADR in `adr_dir` and return them with the per-file problems."""
227
+ files: list[AdrFile] = []
228
+ problems: list[str] = []
229
+ if not adr_dir.is_dir():
230
+ return files, problems
231
+ for entry in sorted(adr_dir.iterdir()):
232
+ path = f"{display_dir}/{entry.name}"
233
+ if entry.is_symlink() and is_candidate(entry.name):
234
+ problems.append(f"{path}: is a symlink. An ADR must be a regular file")
235
+ continue
236
+ if not entry.is_file() or not is_candidate(entry.name):
237
+ continue
238
+ try:
239
+ text = entry.read_text(encoding="utf-8")
240
+ except UnicodeDecodeError:
241
+ problems.append(f"{path}: file is not valid UTF-8")
242
+ continue
243
+ adr, found = parse_text(path, text)
244
+ files.append(adr)
245
+ problems.extend(found)
246
+ return files, problems
247
+
248
+
249
+ def check_tree(files: list[AdrFile]) -> list[str]:
250
+ """Check numbering and supersession across every ADR in the working tree."""
251
+ problems: list[str] = []
252
+ by_number: dict[int, list[AdrFile]] = defaultdict(list)
253
+ for adr in files:
254
+ if adr.number is not None:
255
+ by_number[adr.number].append(adr)
256
+ duplicated = {n for n, group in by_number.items() if len(group) > 1}
257
+ for number in sorted(duplicated):
258
+ names = ", ".join(adr.name for adr in by_number[number])
259
+ problems.append(f"{number:04d} is used by more than one ADR: {names}")
260
+
261
+ def resolve(adr: AdrFile, target: int, field: str) -> AdrFile | None:
262
+ if target in duplicated:
263
+ problems.append(
264
+ f"{adr.path}: `{field}` names {target:04d}, which is a duplicated number, "
265
+ "so it is ambiguous"
266
+ )
267
+ return None
268
+ if target not in by_number:
269
+ problems.append(f"{adr.path}: `{field}` names {target:04d}, which does not exist")
270
+ return None
271
+ return by_number[target][0]
272
+
273
+ for adr in files:
274
+ meta = adr.meta
275
+ if meta is None or adr.number is None:
276
+ continue
277
+ for target in meta.supersedes:
278
+ _check_predecessor(adr, adr.number, target, resolve, problems)
279
+ _check_successor(adr, adr.number, meta, resolve, problems)
280
+ return problems
281
+
282
+
283
+ def _check_predecessor(
284
+ adr: AdrFile, number: int, target: int, resolve: Resolver, problems: list[str]
285
+ ) -> None:
286
+ if target == number:
287
+ problems.append(f"{adr.path}: `supersedes` names the ADR itself")
288
+ return
289
+ old = resolve(adr, target, "supersedes")
290
+ if old is None or old.meta is None:
291
+ return
292
+ if old.meta.status != "superseded" or old.meta.superseded_by != number:
293
+ problems.append(
294
+ f"{adr.path}: supersedes {target:04d}, but {old.name} must have `status: superseded` "
295
+ f"and `superseded_by: {number}`"
296
+ )
297
+
298
+
299
+ def _check_successor(
300
+ adr: AdrFile, number: int, meta: Meta, resolve: Resolver, problems: list[str]
301
+ ) -> None:
302
+ if meta.status == "superseded" and meta.superseded_by is None:
303
+ problems.append(f"{adr.path}: `status: superseded` needs `superseded_by`")
304
+ if meta.superseded_by is None:
305
+ return
306
+ if meta.status != "superseded":
307
+ problems.append(
308
+ f"{adr.path}: `superseded_by` is set, so `status` must be `superseded`, "
309
+ f"not `{meta.status}`"
310
+ )
311
+ if meta.superseded_by == number:
312
+ problems.append(f"{adr.path}: `superseded_by` names the ADR itself")
313
+ return
314
+ successor = resolve(adr, meta.superseded_by, "superseded_by")
315
+ if successor is None or successor.meta is None:
316
+ return
317
+ if successor.meta.status == "rejected":
318
+ problems.append(
319
+ f"{adr.path}: superseded by {successor.name}, which is rejected. A rejected ADR "
320
+ "leaves the ADR it proposed to replace untouched"
321
+ )
322
+ if number not in successor.meta.supersedes:
323
+ problems.append(
324
+ f"{adr.path}: superseded by {successor.name}, whose `supersedes` must include {number}"
325
+ )
326
+
327
+
328
+ def check_no_proposed(files: list[AdrFile]) -> list[str]:
329
+ """Fail every ADR still `proposed`, for a run that merges to main."""
330
+ return [
331
+ f"{adr.path}: `status: proposed` cannot reach main. Set accepted, rejected or superseded"
332
+ for adr in files
333
+ if adr.meta is not None and adr.meta.status == "proposed"
334
+ ]
adr_check/ci.py ADDED
@@ -0,0 +1,234 @@
1
+ """Checks that compare the working tree with the base branch."""
2
+
3
+ import re
4
+ import subprocess
5
+ from collections import defaultdict
6
+ from collections.abc import Collection
7
+ from pathlib import Path
8
+
9
+ from adr_check.adr import (
10
+ AdrFile,
11
+ is_candidate,
12
+ number_of,
13
+ parse_text,
14
+ slug_of,
15
+ )
16
+
17
+ CORRECTION_LABEL = "adr-correction"
18
+
19
+
20
+ class BaseRefError(Exception):
21
+ """The base ref cannot be used, so the history checks cannot run."""
22
+
23
+
24
+ def _git(repo: Path, *args: str, errors: str = "strict") -> str:
25
+ result = subprocess.run( # noqa: S603 # fixed git binary, arguments are not shell-parsed
26
+ ["git", "-C", str(repo), *args], # noqa: S607 # git is resolved from PATH on purpose
27
+ capture_output=True,
28
+ encoding="utf-8",
29
+ errors=errors,
30
+ check=False,
31
+ )
32
+ if result.returncode != 0:
33
+ raise BaseRefError(result.stderr.strip() or f"git {args[0]} failed")
34
+ return result.stdout
35
+
36
+
37
+ def repo_root() -> Path:
38
+ """Return the top of the git repository containing the current directory."""
39
+ try:
40
+ return Path(_git(Path.cwd(), "rev-parse", "--show-toplevel").strip())
41
+ except BaseRefError as error:
42
+ raise BaseRefError("not inside a git repository, which ci mode needs") from error
43
+
44
+
45
+ def _numbers_at(repo: Path, rev: str, rel_dir: str) -> dict[int, list[str]]:
46
+ out = _git(repo, "ls-tree", "--name-only", "-z", rev, f"{rel_dir}/")
47
+ numbers: dict[int, list[str]] = defaultdict(list)
48
+ for path in out.split("\0"):
49
+ name = path.rsplit("/", 1)[-1]
50
+ number = number_of(name)
51
+ if number is not None:
52
+ numbers[number].append(name)
53
+ return numbers
54
+
55
+
56
+ def _is_top_level(path: str, rel_dir: str) -> bool:
57
+ """Return whether `path` is directly in the ADR directory, as `load_tree` reads it."""
58
+ return Path(path).parent == Path(rel_dir)
59
+
60
+
61
+ def _changes(repo: Path, base_ref: str, rel_dir: str) -> list[tuple[str, str, str]]:
62
+ """Return (status letter, old path, new path) for each changed ADR candidate."""
63
+ out = _git(repo, "diff", "--name-status", "-M", "-z", f"{base_ref}...HEAD", "--", f"{rel_dir}/")
64
+ tokens = out.split("\0")
65
+ changes: list[tuple[str, str, str]] = []
66
+ index = 0
67
+ while index < len(tokens) and tokens[index]:
68
+ letter = tokens[index][0]
69
+ width = 2 if letter in "RC" else 1
70
+ paths = tokens[index + 1 : index + 1 + width]
71
+ index += 1 + width
72
+ old, new = paths[0], paths[-1]
73
+ in_old, in_new = _is_top_level(old, rel_dir), _is_top_level(new, rel_dir)
74
+ if in_old and not in_new:
75
+ letter, new = "D", old # moved into a subdirectory: the ADR is gone from the list
76
+ elif in_new and not in_old:
77
+ letter, old = "A", new # moved out of a subdirectory: it is a new ADR
78
+ elif not in_old:
79
+ continue
80
+ if is_candidate(old.rsplit("/", 1)[-1]) or is_candidate(new.rsplit("/", 1)[-1]):
81
+ changes.append((letter, old, new))
82
+ return changes
83
+
84
+
85
+ def _show(repo: Path, rev: str, path: str) -> AdrFile:
86
+ # load_tree reports a file that is not UTF-8, so compare a best-effort decoding here.
87
+ adr, _ = parse_text(path, _git(repo, "show", f"{rev}:{path}", errors="replace"))
88
+ return adr
89
+
90
+
91
+ def _as_list(value: object) -> list[object]:
92
+ if value is None:
93
+ return []
94
+ return list(value) if isinstance(value, list) else [value]
95
+
96
+
97
+ def _is_retarget(old: object, new: object, renumbered: dict[int, int]) -> bool:
98
+ """Return whether `new` only repoints renumbered ADRs in `old` at their new numbers."""
99
+ before, after = _as_list(old), _as_list(new)
100
+ if len(before) != len(after):
101
+ return False
102
+ return all(
103
+ b == a or (isinstance(b, int) and renumbered.get(b) == a)
104
+ for b, a in zip(before, after, strict=True)
105
+ )
106
+
107
+
108
+ def _frontmatter_change_allowed(
109
+ old: dict[str, object], new: dict[str, object], renumbered: dict[int, int]
110
+ ) -> bool:
111
+ keys = old.keys() | new.keys()
112
+ flip_keys = {"status", "superseded_by"}
113
+ rest_same = all(old.get(k) == new.get(k) for k in keys - flip_keys)
114
+ if (
115
+ rest_same
116
+ and old.get("status") == "accepted"
117
+ and new.get("status") == "superseded"
118
+ and old.get("superseded_by") is None
119
+ and isinstance(new.get("superseded_by"), int)
120
+ ):
121
+ return True
122
+ pointer_keys = {"supersedes", "superseded_by"}
123
+ return (
124
+ bool(renumbered)
125
+ and all(old.get(k) == new.get(k) for k in keys - pointer_keys)
126
+ and all(_is_retarget(old.get(k), new.get(k), renumbered) for k in pointer_keys)
127
+ )
128
+
129
+
130
+ def _renumber_problems(old: AdrFile, new: AdrFile, mb_numbers: dict[int, list[str]]) -> list[str]:
131
+ """Return why a rename is not the renumbering repair, or nothing when it is."""
132
+ if old.number is None or new.number is None:
133
+ return [f"{new.path}: renamed from {old.path}, which is not an ADR rename the check allows"]
134
+ where = f"{new.path}: renamed from {old.name}"
135
+ if old.number == new.number or slug_of(old.name) != slug_of(new.name):
136
+ return [f"{where}. Only the number in the filename may change, and ADRs are never renamed"]
137
+ if len(mb_numbers.get(old.number, [])) < 2:
138
+ return [f"{where}, but {old.number:04d} is not a duplicated number on main"]
139
+ if old.raw is None or new.raw is None or old.raw != new.raw:
140
+ return [f"{where}. A renumbering repair changes no frontmatter"]
141
+ renumbered = re.sub(
142
+ rf"^# {old.number:04d}\.", f"# {new.number:04d}.", old.body, count=1, flags=re.MULTILINE
143
+ )
144
+ if renumbered != new.body:
145
+ return [f"{where}. A renumbering repair changes only the H1 number"]
146
+ return []
147
+
148
+
149
+ def check_against_base(
150
+ repo: Path,
151
+ rel_dir: str,
152
+ base_ref: str,
153
+ labels: Collection[str],
154
+ ) -> list[str]:
155
+ """Compare the ADRs in HEAD with `base_ref` and return every rule they break.
156
+
157
+ Raises:
158
+ BaseRefError: `base_ref` is missing or shares no history with HEAD.
159
+ """
160
+ try:
161
+ _git(repo, "rev-parse", "--verify", "--quiet", f"{base_ref}^{{commit}}")
162
+ except BaseRefError as error:
163
+ raise BaseRefError(
164
+ f"base ref {base_ref!r} not found. Check out the caller repo with `fetch-depth: 0` "
165
+ "so the base branch is available"
166
+ ) from error
167
+ try:
168
+ merge_base = _git(repo, "merge-base", base_ref, "HEAD").strip()
169
+ except BaseRefError as error:
170
+ raise BaseRefError(
171
+ f"HEAD and {base_ref!r} share no history. Check out with `fetch-depth: 0`"
172
+ ) from error
173
+
174
+ tip_numbers = _numbers_at(repo, base_ref, rel_dir)
175
+ mb_numbers = _numbers_at(repo, merge_base, rel_dir)
176
+ changes = _changes(repo, base_ref, rel_dir)
177
+ problems: list[str] = []
178
+
179
+ renumbered: dict[int, int] = {}
180
+ for letter, old_path, new_path in changes:
181
+ if letter != "R":
182
+ continue
183
+ if (repo / new_path).is_symlink():
184
+ continue # load_tree reports it, and reading it would follow the link
185
+ old, new = _show(repo, merge_base, old_path), _read(repo, new_path)
186
+ found = _renumber_problems(old, new, mb_numbers)
187
+ problems.extend(found)
188
+ if not found and old.number is not None and new.number is not None:
189
+ renumbered[old.number] = new.number
190
+
191
+ for letter, old_path, new_path in changes:
192
+ name = new_path.rsplit("/", 1)[-1]
193
+ if letter == "D":
194
+ problems.append(f"{old_path}: ADRs are never deleted")
195
+ elif letter in "AR":
196
+ number = number_of(name)
197
+ if number in tip_numbers:
198
+ problems.append(
199
+ f"{new_path}: number {number:04d} is already used on {base_ref} by "
200
+ f"{', '.join(tip_numbers[number])}. Renumber this ADR"
201
+ )
202
+ elif letter == "M" and not (repo / new_path).is_symlink():
203
+ problems.extend(_modified_problems(repo, merge_base, new_path, labels, renumbered))
204
+ if letter not in "ADRM":
205
+ problems.append(f"{new_path}: unexpected change type {letter!r}")
206
+ return problems
207
+
208
+
209
+ def _read(repo: Path, path: str) -> AdrFile:
210
+ return _show(repo, "HEAD", path)
211
+
212
+
213
+ def _modified_problems(
214
+ repo: Path, merge_base: str, path: str, labels: Collection[str], renumbered: dict[int, int]
215
+ ) -> list[str]:
216
+ old, new = _show(repo, merge_base, path), _read(repo, path)
217
+ if old.raw is not None and old.raw.get("status") == "proposed":
218
+ return []
219
+ if old.raw is None:
220
+ return [f"{path}: the version on main has unreadable frontmatter, so it cannot be compared"]
221
+ problems: list[str] = []
222
+ if old.body != new.body and CORRECTION_LABEL not in labels:
223
+ problems.append(
224
+ f"{path}: the body of an ADR on main never changes. Write a superseding ADR, or for "
225
+ f"a typo or link fix label the PR `{CORRECTION_LABEL}`"
226
+ )
227
+ if new.raw is not None and old.raw != new.raw:
228
+ if not _frontmatter_change_allowed(old.raw, new.raw, renumbered):
229
+ problems.append(
230
+ f"{path}: frontmatter of an ADR on main may change only from `accepted` to "
231
+ "`superseded` with `superseded_by`, or to retarget a renumbered ADR. The "
232
+ f"`{CORRECTION_LABEL}` label does not allow it"
233
+ )
234
+ return problems
adr_check/cli.py ADDED
@@ -0,0 +1,87 @@
1
+ """Command line entry point for adr-check."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from collections.abc import Sequence
7
+ from pathlib import Path
8
+
9
+ from adr_check.adr import check_no_proposed, check_tree, load_tree
10
+ from adr_check.ci import BaseRefError, check_against_base, repo_root
11
+
12
+ DEFAULT_BASE_REF = "origin/main"
13
+
14
+
15
+ def parse_labels(value: str) -> list[str]:
16
+ """Read labels from a JSON array, as `toJSON` writes them, or a comma-separated list."""
17
+ text = value.strip()
18
+ if text.startswith("["):
19
+ return [str(item) for item in json.loads(text)]
20
+ return [item.strip() for item in text.split(",") if item.strip()]
21
+
22
+
23
+ def build_parser() -> argparse.ArgumentParser:
24
+ """Return the argument parser for the adr-check command."""
25
+ parser = argparse.ArgumentParser(
26
+ prog="adr-check",
27
+ description="Check architecture decision records. Exits 1 if any rule is broken.",
28
+ )
29
+ parser.add_argument(
30
+ "--mode",
31
+ choices=("local", "ci"),
32
+ default="local",
33
+ help="local checks the working tree only; ci also compares with the base branch",
34
+ )
35
+ parser.add_argument("--adr-dir", default="docs/adr", help="ADR directory (default: docs/adr)")
36
+ parser.add_argument(
37
+ "--base-ref",
38
+ help=f"ci only: ref to compare with (needs full history) (default: {DEFAULT_BASE_REF})",
39
+ )
40
+ parser.add_argument(
41
+ "--labels",
42
+ help="ci only: PR labels, as a JSON array or comma-separated; `adr-correction` allows a "
43
+ "body change",
44
+ )
45
+ parser.add_argument(
46
+ "--merging-to-main",
47
+ action="store_true",
48
+ help="this run is a PR into main or a push to main, so `proposed` fails",
49
+ )
50
+ return parser
51
+
52
+
53
+ def main(argv: Sequence[str] | None = None) -> int:
54
+ """Run the checks, print each problem to stderr and return the process exit code."""
55
+ parser = build_parser()
56
+ args = parser.parse_args(argv)
57
+ mode: str = args.mode
58
+ adr_dir: str = args.adr_dir.rstrip("/")
59
+ base_ref: str | None = args.base_ref
60
+ labels_arg: str | None = args.labels
61
+ merging: bool = args.merging_to_main
62
+ if mode == "local" and (base_ref or labels_arg):
63
+ parser.error("--base-ref and --labels need --mode ci")
64
+ try:
65
+ labels = parse_labels(labels_arg) if labels_arg else []
66
+ except ValueError as error:
67
+ parser.error(f"--labels is not a JSON array or a comma-separated list: {error}")
68
+
69
+ files, problems = load_tree(Path(adr_dir), adr_dir)
70
+ problems.extend(check_tree(files))
71
+ if merging:
72
+ problems.extend(check_no_proposed(files))
73
+ if mode == "ci":
74
+ try:
75
+ root = repo_root()
76
+ rel_dir = Path(adr_dir).resolve().relative_to(root.resolve()).as_posix()
77
+ problems.extend(check_against_base(root, rel_dir, base_ref or DEFAULT_BASE_REF, labels))
78
+ except (BaseRefError, ValueError) as error:
79
+ print(f"adr-check: {error}", file=sys.stderr)
80
+ return 2
81
+
82
+ for problem in problems:
83
+ print(problem, file=sys.stderr)
84
+ if problems:
85
+ print(f"adr-check: {len(problems)} problem(s) found", file=sys.stderr)
86
+ return 1
87
+ return 0
adr_check/py.typed ADDED
File without changes
@@ -0,0 +1,278 @@
1
+ Metadata-Version: 2.5
2
+ Name: adr-check
3
+ Version: 0.1.0
4
+ Summary: Checks architecture decision records (ADRs) in docs/adr
5
+ Project-URL: Homepage, https://github.com/agentic-delivery-au/adr-check
6
+ Project-URL: Repository, https://github.com/agentic-delivery-au/adr-check
7
+ Project-URL: Issues, https://github.com/agentic-delivery-au/adr-check/issues
8
+ Project-URL: Changelog, https://github.com/agentic-delivery-au/adr-check/blob/main/CHANGELOG.md
9
+ Author: Stuart Langley
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: adr,architecture-decision-records,markdown,pre-commit
13
+ Requires-Python: >=3.14
14
+ Requires-Dist: pyyaml==6.0.3
15
+ Description-Content-Type: text/markdown
16
+
17
+ # adr-check
18
+
19
+ Checks the architecture decision records (ADRs) in a repository's `docs/adr/` directory. It keeps
20
+ the records uniform, links superseded decisions to their replacements, and, in CI, stops anyone
21
+ rewriting a decision that has already been merged.
22
+
23
+ An ADR is a short Markdown file that records one significant decision and the reasons for it. The
24
+ format this tool checks is a small variant of [MADR](https://adr.github.io/madr/): Michael Nygard's
25
+ sections plus a list of the options considered.
26
+
27
+ ## What it checks
28
+
29
+ Files in the ADR directory that are not ADRs do not fail the check. It skips `README.md` and any
30
+ file that does not end in `.md`, and it ignores subdirectories. Every other Markdown file is
31
+ checked, so a badly named ADR fails instead of disappearing. A symlink in place of an ADR is an
32
+ error, because the check would otherwise validate the file it points at.
33
+
34
+ ### In the working tree
35
+
36
+ These checks need only the files in front of you. They run in both modes.
37
+
38
+ - **Filename.** `NNNN-kebab-title.md`: four digits, then lowercase words joined by hyphens, for
39
+ example `0007-use-managed-identities.md`.
40
+ - **Frontmatter.** A YAML block between `---` lines holding exactly four keys, and no others:
41
+ - `status`: one of `proposed`, `accepted`, `rejected` or `superseded`.
42
+ - `date`: an ISO date such as `2026-03-12`.
43
+ - `supersedes`: a list of ADR numbers, such as `[7]`, or empty.
44
+ - `superseded_by`: one ADR number, or empty.
45
+ - **Title.** An H1 of the form `# 0007. Title`, where the number matches the filename.
46
+ - **Sections.** Four H2 headings: `## Context`, `## Considered options`, `## Decision` and
47
+ `## Consequences`. Headings inside code fences and HTML comments do not count.
48
+ - **Supersession links.** They must agree in both directions. If `0009` supersedes `0007`, then
49
+ `0007` has `status: superseded` and `superseded_by: 9`. An ADR with `superseded_by` must have
50
+ `status: superseded`, and a `superseded` ADR must name its successor. A link to a missing ADR, to
51
+ the ADR itself, or to a rejected successor fails.
52
+ - **Duplicate numbers.** Two files may not share a number. A `supersedes` or `superseded_by` that
53
+ names a number two ADRs share also fails, because it is ambiguous.
54
+
55
+ ### Against the base branch
56
+
57
+ `--mode ci` adds checks that compare your branch with the branch it merges into. Merged decisions
58
+ are a record of what the team knew at the time, so they stay put.
59
+
60
+ - **Merged ADRs are immutable.** The body of an ADR on the base branch may not change, and the
61
+ frontmatter may change only from `accepted` to `superseded` with `superseded_by` set. A pull
62
+ request labelled `adr-correction` may fix a typo or a link in the body. The label does not allow a
63
+ frontmatter change.
64
+ - **`proposed` ADRs may change.** A `proposed` ADR on the base branch is not settled, so these rules
65
+ do not protect it.
66
+ - **No deletions or renames.** ADRs are never deleted, and never renamed. The one exception is the
67
+ renumbering repair. If two branches took the same number, one ADR may change its filename number
68
+ and its H1 number, and nothing else. ADRs that point at it may then update their `supersedes` or
69
+ `superseded_by` to the new number.
70
+ - **No reused numbers.** A new ADR may not take a number the base branch already uses, including one
71
+ the base took after you branched.
72
+ - **Symlinks on main need an override.** A symlinked ADR already on the base branch cannot be
73
+ repaired through this check. Replacing it with a regular file reports a type change, and deleting
74
+ it reports a deletion. Neither is allowed, and `adr-correction` lifts neither, so a person must
75
+ override the required check for that pull request.
76
+ - **No `proposed` on the main branch.** With `--merging-to-main`, any ADR with `status: proposed`
77
+ fails. `proposed` exists only on a branch.
78
+
79
+ ## Usage
80
+
81
+ Pick the form that suits the repository. All three run the same checks. Replace the placeholder
82
+ versions with a current release.
83
+
84
+ ### Pre-commit hook
85
+
86
+ The repository ships a [pre-commit](https://pre-commit.com) hook. Add it to
87
+ `.pre-commit-config.yaml`:
88
+
89
+ ```yaml
90
+ repos:
91
+ - repo: https://github.com/agentic-delivery-au/adr-check
92
+ rev: vX.Y.Z
93
+ hooks:
94
+ - id: adr-check
95
+ ```
96
+
97
+ The hook runs when a file under `docs/adr/` changes. It passes no filenames, because the rules look
98
+ across the whole directory. It sets `language_version: python3.14`, because the package needs Python
99
+ 3.14 or newer and pre-commit would otherwise build it on its own interpreter. The machine needs a
100
+ `python3.14` executable on `PATH`.
101
+
102
+ If you would rather install a released package than clone this repository, use a local hook:
103
+
104
+ ```yaml
105
+ repos:
106
+ - repo: local
107
+ hooks:
108
+ - id: adr-check
109
+ name: adr-check
110
+ entry: adr-check
111
+ language: python
112
+ language_version: python3.14
113
+ additional_dependencies: [adr-check==X.Y.Z]
114
+ files: ^docs/adr/
115
+ pass_filenames: false
116
+ ```
117
+
118
+ The hook checks the working tree only. It cannot see the base branch, so it cannot catch an edit to
119
+ a merged ADR, a deleted file or a number that the base branch already holds. CI catches those.
120
+
121
+ ### In CI
122
+
123
+ Run the package from PyPI with [uv](https://docs.astral.sh/uv/). The checkout must hold the full
124
+ history, because the check compares your branch with its base:
125
+
126
+ ```bash
127
+ uvx adr-check==X.Y.Z --mode ci --base-ref origin/main --labels "$LABELS" --merging-to-main
128
+ ```
129
+
130
+ On GitHub, the composite action wraps that command and fills in its inputs from the event:
131
+
132
+ ```yaml
133
+ name: adr-check
134
+
135
+ on:
136
+ pull_request:
137
+ types: [opened, reopened, synchronize, edited, labeled, unlabeled]
138
+
139
+ permissions:
140
+ contents: read
141
+
142
+ jobs:
143
+ adr-check:
144
+ runs-on: ubuntu-latest
145
+ steps:
146
+ - uses: actions/checkout@v7
147
+ with:
148
+ fetch-depth: 0
149
+ - uses: agentic-delivery-au/adr-check@<sha> # vX.Y.Z
150
+ ```
151
+
152
+ Pin the action to a full commit SHA, as you would any third-party action. Four details matter:
153
+
154
+ - **`fetch-depth: 0`.** A shallow checkout has no base branch to compare with, and the action fails
155
+ with a message that says so.
156
+ - **`labeled` and `unlabeled`.** Without them, adding `adr-correction` does not re-run the check,
157
+ and the pull request stays red until the next push.
158
+ - **No `paths:` filter.** A filtered workflow does not report on a pull request that touches no ADR,
159
+ so a required check never arrives.
160
+ - **Avoid triggering on `push`.** A push carries no pull request labels, so a history comparison
161
+ would fail every merged `adr-correction`. If a push does trigger the action, it runs only the
162
+ checks on the files and the `proposed` check.
163
+
164
+ The action installs the tool from its own checkout (the files the runner fetched for `uses:`), so
165
+ the code that runs is the code at the commit you pinned. It uses
166
+ [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) to provide uv.
167
+
168
+ #### Action inputs
169
+
170
+ Every input has a default taken from the `github` context. You normally set none of them.
171
+
172
+ | Input | Default | Purpose |
173
+ | ----------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------- |
174
+ | `adr-dir` | `docs/adr` | Directory that holds the ADRs |
175
+ | `base-ref` | `origin/<base branch>` on a pull request, empty on a push | Ref to compare with. A push ignores it |
176
+ | `merging-to-main` | `true` for a pull request into the default branch or a push to it, else false | Whether `status: proposed` fails the run |
177
+ | `labels` | The pull request's label names, as JSON | Labels on the PR. `adr-correction` allows a body change |
178
+
179
+ Set `merging-to-main` yourself only to override the default, for example in a repository whose
180
+ release branch is not the default branch.
181
+
182
+ ### Command line
183
+
184
+ ```bash
185
+ adr-check [--mode {local,ci}] [--adr-dir DIR] [--base-ref REF] [--labels LABELS] [--merging-to-main]
186
+ ```
187
+
188
+ | Flag | Default | Purpose |
189
+ | ------------------- | ------------- | --------------------------------------------------------------------- |
190
+ | `--mode` | `local` | `local` checks the working tree. `ci` also compares with the base ref |
191
+ | `--adr-dir` | `docs/adr` | ADR directory |
192
+ | `--base-ref` | `origin/main` | `ci` only. Ref to compare with. It needs full history |
193
+ | `--labels` | none | `ci` only. PR labels as a JSON array or a comma-separated list |
194
+ | `--merging-to-main` | off | Fail any ADR with `status: proposed` |
195
+
196
+ The command prints one line per problem to stderr. It exits 0 when every rule holds, 1 when a rule
197
+ is broken and 2 when the base ref is missing or the directory is not in a git repository.
198
+
199
+ In `ci` mode, the no-reused-numbers check compares with the tip of the base ref. The other history
200
+ checks compare with the merge base of the base ref and `HEAD`. Git finds renames with
201
+ `git diff --name-status -M`, which detects a rename only when the file is mostly unchanged. A
202
+ renumbering repair always is.
203
+
204
+ ## Development
205
+
206
+ Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then:
207
+
208
+ ```bash
209
+ uv sync # create .venv and install the package with its dev dependencies
210
+ pre-commit install # one-time, sets up the pre-commit and commit-msg hooks
211
+ ```
212
+
213
+ `uv sync` also installs an interpreter if no suitable one is present. `requires-python` is a lower
214
+ bound rather than a pin, so 3.14 is the floor and uv may select something newer. A project that
215
+ needs an exact interpreter pins it in a `.python-version` file, which uv reads.
216
+
217
+ | Task | Command |
218
+ | -------------------------- | ---------------------------- |
219
+ | Run the tests | `uv run pytest` |
220
+ | Lint | `uv run ruff check .` |
221
+ | Format | `uv run ruff format .` |
222
+ | Type check | `uv run pyrefly check src` |
223
+ | Run the tool | `uv run adr-check --help` |
224
+ | Everything the hooks check | `pre-commit run --all-files` |
225
+
226
+ ### Tests and coverage
227
+
228
+ `uv run pytest` runs the suite with coverage already on, so the command above and the one
229
+ `tests.yml` runs in CI are the same run. Coverage is measured over `src/` and reported to
230
+ [Codecov](https://about.codecov.io). `pytest` never fails on a coverage number. The targets live in
231
+ `codecov.yml`: the project must not regress by more than 1%, and a pull request's changed lines need
232
+ 80% coverage.
233
+
234
+ Type checking names the path, `pyrefly check src`, so it skips `tests/` (deliberately unannotated)
235
+ and `scripts/` (repository tooling that the template owns and Ruff also skips).
236
+
237
+ ### Repository settings
238
+
239
+ `scripts/bootstrap_repo.py` applies the branch protection, merge settings and Actions permissions
240
+ that release-please needs. It prints a plan and changes nothing until you pass `--apply`. Read the
241
+ plan first, because it replaces an existing ruleset wholesale.
242
+
243
+ ```bash
244
+ python3 scripts/bootstrap_repo.py # what it would change
245
+ python3 scripts/bootstrap_repo.py --apply # change it
246
+ ```
247
+
248
+ `release-please.yml` authenticates as a GitHub App, because a pull request opened with
249
+ `GITHUB_TOKEN` triggers no workflows and the required checks would never report on the release PR.
250
+ Create an App with Contents, Issues and Pull requests write permissions, install it on the
251
+ repository, then store its client ID as the variable `RELEASE_BOT_CLIENT_ID` and its private key as
252
+ the secret `RELEASE_BOT_PRIVATE_KEY`. There is deliberately no fallback to `GITHUB_TOKEN`.
253
+
254
+ ## Releases
255
+
256
+ [release-please](https://github.com/googleapis/release-please) reads the Conventional Commits landed
257
+ on `main` and keeps an open release PR that bumps the version and updates `CHANGELOG.md`. Merging
258
+ that PR tags the version and publishes the GitHub release. The `publish` job in `release-please.yml`
259
+ then builds the package and uploads it to PyPI.
260
+
261
+ The release type is `python`, so release-please bumps the version in `pyproject.toml`, and an
262
+ `extra-files` entry keeps the project's own version in `uv.lock` in step. Without it the release PR
263
+ would fail `uv sync --locked`. The filter matches the package by name (`adr-check`), so rename the
264
+ package and it has to change in the same commit.
265
+
266
+ Tags have no component prefix (`v0.2.0`, not `adr-check-v0.2.0`), because `include-component-in-tag`
267
+ is `false`.
268
+
269
+ ### Publishing
270
+
271
+ The `publish` job uses [trusted publishing](https://docs.pypi.org/trusted-publishers/), so no PyPI
272
+ token is stored. Register a trusted publisher on PyPI for this repository with the workflow name
273
+ `release-please.yml` and the environment `pypi`, and create a `pypi` environment on GitHub. Renaming
274
+ either side breaks the match.
275
+
276
+ ## Licence
277
+
278
+ [MIT](LICENSE).
@@ -0,0 +1,10 @@
1
+ adr_check/__init__.py,sha256=2psc3BBitujve3D73P90W8djdeRHJ2pmOC6BcxTvlAQ,73
2
+ adr_check/adr.py,sha256=Si2l87y4ZDvvsrdNzQocFbTDj573oB1e4RMwMSjDnv8,12568
3
+ adr_check/ci.py,sha256=2YEvn1DgvukHHs-fvfduULDrKxB0ru8UxjnFKZDqbaI,9286
4
+ adr_check/cli.py,sha256=qrqR39iZRGsTf8Qra8RIFZZrSX323jxEH29lzwIeJSU,3167
5
+ adr_check/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ adr_check-0.1.0.dist-info/METADATA,sha256=V4RQ6S_iCch5T_nt68sakmWsfs9TKYhXVKFOjSn6Mug,13841
7
+ adr_check-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ adr_check-0.1.0.dist-info/entry_points.txt,sha256=AqE_6yAv_ljEATfYorePyaYt8fJHg1ncBBfaTDUxGOs,49
9
+ adr_check-0.1.0.dist-info/licenses/LICENSE,sha256=iW0aDoFzBy2RiDWWAAvk1_tZuUCR9eOOfOKk60G6pIA,1071
10
+ adr_check-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ adr-check = adr_check.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stuart Langley
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.