Skip to content

Commit 3285af8

Browse files
authored
test: adopt the stricter ADR citation check from lite-bootstrap (#120)
1 parent e96c350 commit 3285af8

1 file changed

Lines changed: 76 additions & 33 deletions

File tree

‎tests/test_adr_citations.py‎

Lines changed: 76 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,45 +9,65 @@
99

1010
_ADR_DIR: typing.Final = "docs/adr/"
1111
_CITATION: typing.Final = re.compile(r"docs/adr/\d{4}(?:-[a-z0-9-]+\.md)?")
12+
_NUMBER_PREFIX: typing.Final = "ADR-"
13+
_NUMBER_CITATION: typing.Final = re.compile(_NUMBER_PREFIX + r"\d{4}")
1214
_UNWALKED_DIR: typing.Final = "node_modules"
15+
# mkdocs build output: a second copy of docs/, whose citations are the originals'.
16+
_GENERATED_DIR: typing.Final = "site"
17+
_SCANNED_SUFFIXES: typing.Final = (".py", ".md", ".toml")
1318

1419

15-
def _python_files(root: pathlib.Path) -> list[pathlib.Path]:
20+
def _scanned_files(root: pathlib.Path) -> list[pathlib.Path]:
1621
found: list[pathlib.Path] = []
1722
for dirpath, dirnames, filenames in os.walk(root):
18-
dirnames[:] = sorted(name for name in dirnames if not name.startswith(".") and name != _UNWALKED_DIR)
19-
found.extend(pathlib.Path(dirpath, name) for name in sorted(filenames) if name.endswith(".py"))
23+
dirnames[:] = sorted(
24+
name for name in dirnames if not name.startswith(".") and name not in (_UNWALKED_DIR, _GENERATED_DIR)
25+
)
26+
found.extend(pathlib.Path(dirpath, name) for name in sorted(filenames) if name.endswith(_SCANNED_SUFFIXES))
2027
return found
2128

2229

23-
def _citations(source: str) -> set[str]:
30+
def _citations(file: pathlib.Path, source: str) -> set[str]:
2431
texts = [source]
25-
texts.extend(
26-
node.value
27-
for node in ast.walk(ast.parse(source))
28-
if isinstance(node, ast.Constant) and isinstance(node.value, str)
29-
)
30-
return {cited for text in texts for cited in _CITATION.findall(text)}
32+
if file.suffix == ".py":
33+
# Adjacent literals are joined at parse time, so a path split across them is one string
34+
# in the AST and two fragments in the raw text.
35+
texts.extend(
36+
node.value
37+
for node in ast.walk(ast.parse(source))
38+
if isinstance(node, ast.Constant) and isinstance(node.value, str)
39+
)
40+
return {cited for text in texts for pattern in (_CITATION, _NUMBER_CITATION) for cited in pattern.findall(text)}
41+
42+
43+
def _resolves(root: pathlib.Path, cited: str) -> bool:
44+
if cited.startswith(_ADR_DIR):
45+
return (root / cited).is_file()
46+
return any((root / _ADR_DIR).glob(f"{cited.removeprefix(_NUMBER_PREFIX)}-*.md"))
3147

3248

3349
def unresolved_citations(root: pathlib.Path) -> list[tuple[str, str]]:
3450
return sorted(
3551
(file.relative_to(root).as_posix(), cited)
36-
for file in _python_files(root)
37-
for cited in _citations(file.read_text(encoding="utf-8"))
38-
if not (root / cited).is_file()
52+
for file in _scanned_files(root)
53+
for cited in _citations(file, file.read_text(encoding="utf-8"))
54+
if not _resolves(root, cited)
3955
)
4056

4157

42-
def test_every_adr_path_cited_from_python_resolves(pytestconfig: pytest.Config) -> None:
43-
"""INVARIANT: a `docs/adr/NNNN-<slug>.md` path named anywhere in this repo's Python exists.
58+
def test_every_adr_citation_in_the_repo_resolves(pytestconfig: pytest.Config) -> None:
59+
"""INVARIANT: every ADR named in this repo resolves, by full path or by bare `ADR-NNNN` number.
4460
45-
Broken by renaming, renumbering or pruning an ADR without following its citations. The
46-
offline link gate reads Markdown only, so a path in a docstring, a comment or a guard message
47-
is otherwise checked by nothing, and an `INVARIANT:` docstring that names its ADR silently
48-
loses the rationale the test depends on. A user who trips a guard is handed a link to follow.
49-
A bare `docs/adr/NNNN` is reported as well: it names no file, so it would survive the same
50-
rename or drop unnoticed and point at whatever record holds that number next.
61+
Broken by renaming, renumbering or pruning an ADR without following its citations. The offline
62+
link gate reads Markdown links only, so a path in a docstring, a comment, a guard message or a
63+
`pyproject.toml` dependency rationale is otherwise checked by nothing, and neither is the bare
64+
number form, which is how most of them are written. A user who trips a guard is handed a link
65+
to follow.
66+
67+
The number form is checked for existence only: a citation renumbered onto a *different* live
68+
ADR still resolves, and nothing here can know it now names the wrong decision.
69+
A bare `docs/adr/NNNN` path is reported outright: it names no file, so it would survive a
70+
rename or a drop unnoticed and point at whatever record holds that number next.
5171
"""
5272
unresolved = unresolved_citations(pytestconfig.rootpath)
5373

@@ -67,18 +87,6 @@ def test_a_citation_of_a_missing_adr_is_reported_with_its_citing_file(tmp_path:
6787
assert unresolved_citations(tmp_path) == [("pkg/mod.py", f"{_ADR_DIR}9999-missing.md")]
6888

6989

70-
def test_a_short_form_citation_is_reported_even_when_the_adr_exists(tmp_path: pathlib.Path) -> None:
71-
"""`docs/adr/NNNN` with no slug names nothing on disk, so a rename or a drop never breaks it."""
72-
(tmp_path / _ADR_DIR).mkdir(parents=True)
73-
(tmp_path / _ADR_DIR / "0002-kept.md").write_text("# kept\n", encoding="utf-8")
74-
(tmp_path / "pkg").mkdir()
75-
(tmp_path / "pkg" / "mod.py").write_text(
76-
f'"""Argued in {_ADR_DIR}0002 and {_ADR_DIR}0002-kept.md."""\n', encoding="utf-8"
77-
)
78-
79-
assert unresolved_citations(tmp_path) == [("pkg/mod.py", f"{_ADR_DIR}0002")]
80-
81-
8290
def test_a_citation_split_across_adjacent_string_literals_is_found(tmp_path: pathlib.Path) -> None:
8391
"""Python joins adjacent literals at parse time, which is what the `nack` guard message relies on."""
8492
(tmp_path / "guard.py").write_text(
@@ -96,6 +104,41 @@ def test_a_citation_inside_a_hash_comment_is_found(tmp_path: pathlib.Path) -> No
96104
assert unresolved_citations(tmp_path) == [("graph.py", f"{_ADR_DIR}0009-comment.md")]
97105

98106

107+
def test_a_bare_adr_number_naming_no_file_is_reported(tmp_path: pathlib.Path) -> None:
108+
"""A renumber leaves `ADR-NNNN` prose behind; only the path form was ever checked."""
109+
(tmp_path / _ADR_DIR).mkdir(parents=True)
110+
(tmp_path / _ADR_DIR / "0001-kept.md").write_text("# kept\n", encoding="utf-8")
111+
(tmp_path / "smoke.py").write_text(
112+
f"# the constraint {_NUMBER_PREFIX}0001 records, unlike {_NUMBER_PREFIX}9999\n", encoding="utf-8"
113+
)
114+
115+
assert unresolved_citations(tmp_path) == [("smoke.py", f"{_NUMBER_PREFIX}9999")]
116+
117+
118+
def test_a_bare_adr_path_is_reported_even_when_the_adr_exists(tmp_path: pathlib.Path) -> None:
119+
"""`docs/adr/NNNN` with no slug names nothing on disk, so a rename or a drop never breaks it."""
120+
(tmp_path / _ADR_DIR).mkdir(parents=True)
121+
(tmp_path / _ADR_DIR / "0002-kept.md").write_text("# kept\n", encoding="utf-8")
122+
(tmp_path / "pkg").mkdir()
123+
(tmp_path / "pkg" / "mod.py").write_text(
124+
f'"""Argued in {_ADR_DIR}0002 and {_ADR_DIR}0002-kept.md."""\n', encoding="utf-8"
125+
)
126+
127+
assert unresolved_citations(tmp_path) == [("pkg/mod.py", f"{_ADR_DIR}0002")]
128+
129+
130+
def test_a_citation_outside_python_is_found(tmp_path: pathlib.Path) -> None:
131+
"""`pyproject.toml` explains dependency floors by citing ADRs, and Markdown cites them in prose."""
132+
(tmp_path / _ADR_DIR).mkdir(parents=True)
133+
(tmp_path / "pyproject.toml").write_text(f"# see {_ADR_DIR}0002-floor.md\ndeps = []\n", encoding="utf-8")
134+
(tmp_path / "AGENTS.md").write_text(f"Read {_NUMBER_PREFIX}0004 before editing.\n", encoding="utf-8")
135+
136+
assert unresolved_citations(tmp_path) == [
137+
("AGENTS.md", f"{_NUMBER_PREFIX}0004"),
138+
("pyproject.toml", f"{_ADR_DIR}0002-floor.md"),
139+
]
140+
141+
99142
def test_a_tree_with_no_citations_and_no_adr_directory_reports_nothing(tmp_path: pathlib.Path) -> None:
100143
(tmp_path / "plain.py").write_text("X = 1\n", encoding="utf-8")
101144

0 commit comments

Comments
 (0)