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
3349def 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-
8290def 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\n deps = []\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+
99142def 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