Skip to content

Commit 23749a7

Browse files
Syndicclaude
andcommitted
feat(meta): number ADRs globally and enforce it with a check
Review call on #249: ADR numbers become unique across the whole repo rather than per directory, so meta/docs/adr/0001-... is renumbered to 0002 behind docs/adr/0001-.... This diverges from the /domain-modeling skill's ADR-FORMAT.md, which scans a single directory and would hand you a number that is already taken. docs/agents/domain.md is the durable place to say so -- the skill ships from a read-only plugin cache that npx skills update overwrites, and it is the file the skills are already pointed at. check_adr_numbers.py enforces uniqueness and the NNNN-kebab-slug.md filename shape. It takes check_go_work.py's profile -- CI job plus an on-save task, no pre-commit hook -- because it is the closest sibling: a tree walk over checked-in files with nothing to fix. The filename shape rides along because a malformed name has no number to compare, so silently skipping it would leave a hole in the very property being enforced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c00d695 commit 23749a7

10 files changed

Lines changed: 263 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,17 @@ jobs:
8888
python-version: "3.14"
8989
- run: python3 meta/scripts/check_go_work.py
9090

91+
adr-numbers-check:
92+
name: ADR number uniqueness check
93+
runs-on: ubuntu-latest
94+
steps:
95+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
96+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
97+
with:
98+
# renovate: datasource=python-version depName=python
99+
python-version: "3.14"
100+
- run: python3 meta/scripts/check_adr_numbers.py
101+
91102
secrets-check:
92103
name: Secrets check
93104
runs-on: ubuntu-latest

.vscode/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@
5252
"check: go work": [
5353
"**/go.mod",
5454
"go.work"
55+
],
56+
"check: adr numbers": [
57+
"**/docs/adr/*.md"
5558
]
5659
},
5760
"triggerTaskOnSave.showStatusBarToggle": true,

.vscode/tasks.json

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,33 @@
6262
}
6363
}
6464
},
65+
{
66+
"label": "check: adr numbers",
67+
"type": "shell",
68+
"command": "python3",
69+
"args": ["${workspaceFolder}/meta/scripts/check_adr_numbers.py"],
70+
"presentation": {
71+
"reveal": "silent",
72+
"panel": "dedicated",
73+
"showReuseMessage": false,
74+
"clear": true,
75+
"revealProblems": "onProblem"
76+
},
77+
"problemMatcher": {
78+
"owner": "check-adr-numbers",
79+
"source": "check_adr_numbers",
80+
"fileLocation": ["relative", "${workspaceFolder}"],
81+
"severity": "error",
82+
"pattern": {
83+
"regexp": "^(\\S+):(\\d+):(\\d+)-(\\d+):\\s+(.+)$",
84+
"file": 1,
85+
"line": 2,
86+
"column": 3,
87+
"endColumn": 4,
88+
"message": 5
89+
}
90+
}
91+
},
6592
{
6693
"label": "Bazel Build all",
6794
"detail": "bazel build //...",

CONTEXT-MAP.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ directories in the [README](README.md), not a directory under `src/`. See
55
[`docs/agents/domain.md`](docs/agents/domain.md) for how the engineering skills consume this file.
66

77
Each context owns its own `CONTEXT.md` (its glossary) and its own `docs/adr/` (decisions scoped to
8-
it). Repo-wide decisions live in `docs/adr/`.
8+
it). Repo-wide decisions live in `docs/adr/`. ADR *numbers* are unique across all of those
9+
directories rather than per directory — see [`docs/agents/domain.md`](docs/agents/domain.md).
910

1011
## Contexts
1112

docs/agents/domain.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ codebase.
1010
- **`docs/adr/`**: system-wide architectural decisions.
1111
- **`<context>/docs/adr/`**: decisions scoped to a single context.
1212

13+
**ADR numbers are unique repo-wide, not per directory.** A new ADR takes the next number across
14+
*every* `docs/adr/` directory in the repo, whichever one it lands in — so `docs/adr/0001-…` and
15+
`meta/docs/adr/0002-…` is correct and a second `0001` anywhere is not. This deliberately diverges
16+
from the `/domain-modeling` skill's `ADR-FORMAT.md`, which scans a single directory; follow this
17+
file, and note that a per-directory scan will hand you a number that is already taken. The
18+
directory still says whose decision it is: repo-wide, or one context's.
19+
20+
`meta/scripts/check_adr_numbers.py` enforces both the uniqueness and the `NNNN-kebab-slug.md`
21+
filename shape, as the `ADR number uniqueness check` CI job.
22+
1323
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest
1424
creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and
1525
`/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.

meta/docs/adr/0001-the-plumbing-contract-includes-the-host-presentation-shape.md renamed to meta/docs/adr/0002-the-plumbing-contract-includes-the-host-presentation-shape.md

File renamed without changes.

meta/scripts/BUILD.bazel

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,30 @@ py_test(
3838
],
3939
)
4040

41+
py_library(
42+
name = "check_adr_numbers_lib",
43+
srcs = ["check_adr_numbers.py"],
44+
deps = [":_workspace_lib"],
45+
)
46+
47+
py_binary(
48+
name = "check_adr_numbers",
49+
srcs = ["check_adr_numbers.py"],
50+
main = "check_adr_numbers.py",
51+
deps = [":_workspace_lib"],
52+
)
53+
54+
py_test(
55+
name = "test_check_adr_numbers",
56+
size = "small",
57+
srcs = ["test_check_adr_numbers.py"],
58+
main = "test_check_adr_numbers.py",
59+
deps = [
60+
":_workspace_lib",
61+
":check_adr_numbers_lib",
62+
],
63+
)
64+
4165
py_library(
4266
name = "check_modules_lib",
4367
srcs = ["check_modules.py"],

meta/scripts/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ without blocking).
1010
| `check_modules.py` | Go module matrix/config and Python workspace/lock invariants are consistent | `ci.yml`, `security.yml` || `check: modules` |
1111
| `check_go_work.py` | Every Go module in the repo is registered in `go.work` | `ci.yml` || `check: go work` |
1212
| `check_no_cgo.py` | No `import "C"` in our Go source and no transitive deps that compile C/C++/cgo/SWIG | `ci.yml` |||
13+
| `check_adr_numbers.py` | ADR numbers are unique repo-wide and filenames are `NNNN-kebab-slug.md` | `ci.yml` || `check: adr numbers` |
1314
| `check_secrets_dir.py` | `secrets/` contains no committed files other than `secrets.md` | `ci.yml` | `check-secrets-dir` ||
1415

15-
`_workspace.py` is a private shared helper for the four guards above (Bazel workspace discovery,
16+
`_workspace.py` is a private shared helper for the five guards above (Bazel workspace discovery,
1617
module enumeration). The leading underscore signals it's not a public API; `test__workspace.py`
1718
covers it directly.
1819

meta/scripts/check_adr_numbers.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Verifies that ADR numbers are unique across every decision-record directory in the repo:
4+
- Every file in a docs/adr/ directory is named NNNN-kebab-slug.md (README.md excepted)
5+
- No two ADRs anywhere in the repo share a number
6+
7+
Numbering is repo-global, not per-directory: a context's ADR takes the next number across
8+
*all* of docs/adr/ and <context>/docs/adr/. See docs/agents/domain.md.
9+
10+
Usage: ./meta/scripts/check_adr_numbers.py
11+
"""
12+
13+
import re
14+
import sys
15+
from collections import defaultdict
16+
from pathlib import Path
17+
18+
# When invoked as `python3 meta/scripts/check_adr_numbers.py` (the form used in CI and by the
19+
# editor task), the workspace root is not on sys.path, so `from meta.scripts.X` would fail.
20+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
21+
22+
from meta.scripts._workspace import find_files, workspace_root
23+
24+
# Four digits so a plain sort orders them; kebab slug so filenames stay greppable and
25+
# case-insensitive filesystems can't collide two ADRs onto one path.
26+
_ADR_NAME_RE = re.compile(r"^(\d{4})-[a-z0-9]+(?:-[a-z0-9]+)*\.md$")
27+
28+
# A directory-level readme is not a decision record. Nothing else earns an exemption:
29+
# an unrecognised file here is either a misnamed ADR or something in the wrong place.
30+
_EXEMPT = frozenset(["README.md"])
31+
32+
33+
def adr_files(root: Path) -> list[Path]:
34+
"""Every markdown file under a docs/adr/ directory, sorted, relative to root."""
35+
return sorted(
36+
p.relative_to(root)
37+
for p in find_files(root, "*.md")
38+
if p.parent.name == "adr" and p.parent.parent.name == "docs"
39+
)
40+
41+
42+
def violations(files: list[Path]) -> list[tuple[Path, str]]:
43+
"""(path, message) for every malformed name and every reused number."""
44+
found: list[tuple[Path, str]] = []
45+
by_number: dict[str, list[Path]] = defaultdict(list)
46+
47+
for path in files:
48+
if path.name in _EXEMPT:
49+
continue
50+
match = _ADR_NAME_RE.match(path.name)
51+
if match is None:
52+
found.append(
53+
(path, f"not a valid ADR filename: expected NNNN-kebab-slug.md, got {path.name}")
54+
)
55+
continue
56+
by_number[match.group(1)].append(path)
57+
58+
for number, paths in by_number.items():
59+
if len(paths) == 1:
60+
continue
61+
first, *rest = paths
62+
for path in rest:
63+
found.append((path, f"duplicate ADR number {number}: already used by {first}"))
64+
65+
return sorted(found)
66+
67+
68+
def main() -> int:
69+
root = workspace_root()
70+
files = adr_files(root)
71+
found = violations(files)
72+
73+
for path, message in found:
74+
# `path:line:startCol-endCol: message`, the format the editor's problem matcher reads.
75+
# The offence is the filename, so there is no interesting position inside the file.
76+
print(f"{path}:1:1-2: {message}")
77+
78+
if not found:
79+
print(f"ADR numbers are unique across {len(files)} decision record(s).")
80+
81+
return len(found)
82+
83+
84+
if __name__ == "__main__":
85+
sys.exit(main())
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
#!/usr/bin/env python3
2+
"""Unit tests for check_adr_numbers.py."""
3+
4+
import sys
5+
import tempfile
6+
import unittest
7+
from pathlib import Path
8+
9+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
10+
11+
from meta.scripts.check_adr_numbers import adr_files, violations
12+
13+
14+
def _paths(*names: str) -> list[Path]:
15+
return [Path(n) for n in names]
16+
17+
18+
class TestAdrFiles(unittest.TestCase):
19+
"""Discovery has to span every docs/adr/ in the tree, and nothing else."""
20+
21+
def _tree(self, *rel: str) -> Path:
22+
root = Path(tempfile.mkdtemp())
23+
for r in rel:
24+
p = root / r
25+
p.parent.mkdir(parents=True, exist_ok=True)
26+
p.write_text("# x\n")
27+
return root
28+
29+
def test_finds_root_and_context_directories(self):
30+
root = self._tree("docs/adr/0001-a.md", "meta/docs/adr/0002-b.md")
31+
self.assertEqual(adr_files(root), _paths("docs/adr/0001-a.md", "meta/docs/adr/0002-b.md"))
32+
33+
def test_ignores_markdown_outside_an_adr_directory(self):
34+
# A check that swept every *.md would flag the repo's ordinary docs.
35+
root = self._tree("docs/adr/0001-a.md", "docs/agents/domain.md", "README.md")
36+
self.assertEqual(adr_files(root), _paths("docs/adr/0001-a.md"))
37+
38+
def test_ignores_an_adr_directory_not_under_docs(self):
39+
root = self._tree("adr/0001-a.md", "docs/adr/0002-b.md")
40+
self.assertEqual(adr_files(root), _paths("docs/adr/0002-b.md"))
41+
42+
def test_skips_build_output_and_vcs_directories(self):
43+
# find_files' skip list is what keeps a bazel-* symlink from doubling every ADR.
44+
root = self._tree("docs/adr/0001-a.md", "bazel-out/docs/adr/0001-a.md")
45+
self.assertEqual(adr_files(root), _paths("docs/adr/0001-a.md"))
46+
47+
48+
class TestViolations(unittest.TestCase):
49+
def test_unique_numbers_pass(self):
50+
self.assertEqual(violations(_paths("docs/adr/0001-a.md", "meta/docs/adr/0002-b.md")), [])
51+
52+
def test_duplicate_across_directories_fails(self):
53+
# The case global numbering exists to prevent: per-directory numbering produces this.
54+
found = violations(_paths("docs/adr/0001-a.md", "meta/docs/adr/0001-b.md"))
55+
self.assertEqual(len(found), 1)
56+
path, message = found[0]
57+
self.assertEqual(path, Path("meta/docs/adr/0001-b.md"))
58+
self.assertIn("duplicate ADR number 0001", message)
59+
self.assertIn("docs/adr/0001-a.md", message)
60+
61+
def test_duplicate_within_one_directory_fails(self):
62+
found = violations(_paths("docs/adr/0001-a.md", "docs/adr/0001-b.md"))
63+
self.assertEqual(len(found), 1)
64+
65+
def test_a_third_use_is_reported_too(self):
66+
# Reporting only the first collision would let a fix land and still leave a duplicate.
67+
found = violations(_paths("docs/adr/0001-a.md", "docs/adr/0001-b.md", "docs/adr/0001-c.md"))
68+
self.assertEqual(len(found), 2)
69+
70+
def test_readme_is_exempt(self):
71+
self.assertEqual(violations(_paths("docs/adr/README.md", "docs/adr/0001-a.md")), [])
72+
73+
def test_malformed_names_are_reported(self):
74+
for name in (
75+
"1-a.md",
76+
"00001-a.md",
77+
"0001a.md",
78+
"0001-.md",
79+
"0001-Mixed-Case.md",
80+
"notes.md",
81+
):
82+
with self.subTest(name=name):
83+
found = violations(_paths(f"docs/adr/{name}"))
84+
self.assertEqual(len(found), 1, f"{name} should be rejected")
85+
self.assertIn("not a valid ADR filename", found[0][1])
86+
87+
def test_malformed_name_is_not_also_counted_as_a_number(self):
88+
# A file that fails the shape has no number to collide with; reporting both would
89+
# double-count one mistake.
90+
found = violations(_paths("docs/adr/0001-a.md", "docs/adr/0001a.md"))
91+
self.assertEqual(len(found), 1)
92+
self.assertIn("not a valid ADR filename", found[0][1])
93+
94+
def test_valid_slugs_with_digits_are_accepted(self):
95+
self.assertEqual(violations(_paths("docs/adr/0007-use-oauth2-for-sso.md")), [])
96+
97+
98+
if __name__ == "__main__":
99+
unittest.main()

0 commit comments

Comments
 (0)