From 63e4956f6430af36d72c53418115129f83fec8c0 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 27 Sep 2026 21:55:59 +0300 Subject: [PATCH 1/2] ci: fetch the ADR citation check from .github instead of keeping a copy --- .github/workflows/main.yml | 2 + justfile | 10 ++++ tests/test_adr_citations.py | 111 ------------------------------------ 3 files changed, 12 insertions(+), 111 deletions(-) delete mode 100644 tests/test_adr_citations.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 43bb24d..1f6c7e4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,6 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + - uses: extractions/setup-just@v4 - uses: astral-sh/setup-uv@v8.2.0 - run: uv python install 3.14 - run: uv python pin 3.14 @@ -23,6 +24,7 @@ jobs: uv run ruff format . --check uv run ruff check . --no-fix uv run ty check + - run: just adr-check pytest: runs-on: ubuntu-latest diff --git a/justfile b/justfile index 087c211..c5f5b62 100644 --- a/justfile +++ b/justfile @@ -34,3 +34,13 @@ lint: uv run ruff format . uv run ruff check . --fix uv run ty check + +adr_check_source := "https://raw.githubusercontent.com/modern-python/.github/main/tests/test_adr_citations.py" + +# Tracks main on purpose: the shared check is unpinned. +adr-check: + #!/usr/bin/env sh + set -eu + file="$(mktemp -d)/test_adr_citations.py" + curl -fsSL "{{ adr_check_source }}" -o "$file" + uv run --no-sync pytest --rootdir=. "$file" diff --git a/tests/test_adr_citations.py b/tests/test_adr_citations.py deleted file mode 100644 index 7f7706c..0000000 --- a/tests/test_adr_citations.py +++ /dev/null @@ -1,111 +0,0 @@ -import ast -import os -import pathlib -import re -import typing - - -_REPO_ROOT: typing.Final = pathlib.Path(__file__).resolve().parent.parent -_ADR_DIR: typing.Final = "docs/adr/" -_CITATION: typing.Final = re.compile(r"docs/adr/\d{4}(?:-[a-z0-9-]+\.md)?") -_UNWALKED_DIR: typing.Final = "node_modules" - - -def _python_files(root: pathlib.Path) -> list[pathlib.Path]: - found: list[pathlib.Path] = [] - for dirpath, dirnames, filenames in os.walk(root): - dirnames[:] = sorted(name for name in dirnames if not name.startswith(".") and name != _UNWALKED_DIR) - found.extend(pathlib.Path(dirpath, name) for name in sorted(filenames) if name.endswith(".py")) - return found - - -def _citations(source: str) -> set[str]: - texts = [source] - texts.extend( - node.value - for node in ast.walk(ast.parse(source)) - if isinstance(node, ast.Constant) and isinstance(node.value, str) - ) - return {cited for text in texts for cited in _CITATION.findall(text)} - - -def unresolved_citations(root: pathlib.Path) -> list[tuple[str, str]]: - return sorted( - (file.relative_to(root).as_posix(), cited) - for file in _python_files(root) - for cited in _citations(file.read_text(encoding="utf-8")) - if not (root / cited).is_file() - ) - - -def test_every_adr_path_cited_from_python_resolves() -> None: - """INVARIANT: a `docs/adr/NNNN-.md` path named anywhere in this repo's Python exists. - - Broken by renaming, renumbering or pruning an ADR without following its citations. The - offline link gate reads Markdown only, so a path in a docstring, a comment or a guard message - is otherwise checked by nothing, and an `INVARIANT:` docstring that names its ADR silently - loses the rationale the test depends on. A user who trips a guard is handed a link to follow. - A bare `docs/adr/NNNN` is reported as well: it names no file, so it would survive the same - rename or drop unnoticed and point at whatever record holds that number next. - """ - unresolved = unresolved_citations(_REPO_ROOT) - - assert unresolved == [], "\n".join(f"{file} cites {cited}" for file, cited in unresolved) - - -def test_a_citation_of_a_missing_adr_is_reported_with_its_citing_file(tmp_path: pathlib.Path) -> None: - """The scanner is exercised against a known result, so an empty scan cannot pass as a green one.""" - (tmp_path / _ADR_DIR).mkdir(parents=True) - (tmp_path / _ADR_DIR / "0001-kept.md").write_text("# kept\n", encoding="utf-8") - (tmp_path / "pkg").mkdir() - (tmp_path / "pkg" / "mod.py").write_text( - f'"""Explained in {_ADR_DIR}0001-kept.md and {_ADR_DIR}9999-missing.md."""\n', - encoding="utf-8", - ) - - assert unresolved_citations(tmp_path) == [("pkg/mod.py", f"{_ADR_DIR}9999-missing.md")] - - -def test_a_short_form_citation_is_reported_even_when_the_adr_exists(tmp_path: pathlib.Path) -> None: - """`docs/adr/NNNN` with no slug names nothing on disk, so a rename or a drop never breaks it.""" - (tmp_path / _ADR_DIR).mkdir(parents=True) - (tmp_path / _ADR_DIR / "0002-kept.md").write_text("# kept\n", encoding="utf-8") - (tmp_path / "pkg").mkdir() - (tmp_path / "pkg" / "mod.py").write_text( - f'"""Argued in {_ADR_DIR}0002 and {_ADR_DIR}0002-kept.md."""\n', encoding="utf-8" - ) - - assert unresolved_citations(tmp_path) == [("pkg/mod.py", f"{_ADR_DIR}0002")] - - -def test_a_citation_split_across_adjacent_string_literals_is_found(tmp_path: pathlib.Path) -> None: - """Python joins adjacent literals at parse time, which is what the `nack` guard message relies on.""" - (tmp_path / "guard.py").write_text( - f'MESSAGE = (\n "See https://example.invalid/blob/main/{_ADR_DIR}"\n "0003-split.md."\n)\n', - encoding="utf-8", - ) - - assert unresolved_citations(tmp_path) == [("guard.py", f"{_ADR_DIR}0003-split.md")] - - -def test_a_citation_inside_a_hash_comment_is_found(tmp_path: pathlib.Path) -> None: - """Comments never reach the AST, so the raw text is scanned as well.""" - (tmp_path / "graph.py").write_text(f"# The rule is one-way, see {_ADR_DIR}0009-comment.md\n", encoding="utf-8") - - assert unresolved_citations(tmp_path) == [("graph.py", f"{_ADR_DIR}0009-comment.md")] - - -def test_a_tree_with_no_citations_and_no_adr_directory_reports_nothing(tmp_path: pathlib.Path) -> None: - (tmp_path / "plain.py").write_text("X = 1\n", encoding="utf-8") - - assert unresolved_citations(tmp_path) == [] - - -def test_files_under_dot_directories_are_not_scanned(tmp_path: pathlib.Path) -> None: - """A virtualenv or a cache is not this repo's citations.""" - (tmp_path / ".venv" / "lib").mkdir(parents=True) - (tmp_path / ".venv" / "lib" / "vendored.py").write_text(f"# {_ADR_DIR}0001-elsewhere.md\n", encoding="utf-8") - (tmp_path / _UNWALKED_DIR).mkdir() - (tmp_path / _UNWALKED_DIR / "dep.py").write_text(f"# {_ADR_DIR}0002-elsewhere.md\n", encoding="utf-8") - - assert unresolved_citations(tmp_path) == [] From 8a3642214d743e838b09cd1378424b5314d0f0bb Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 27 Sep 2026 22:44:48 +0300 Subject: [PATCH 2/2] ci: fetch the ADR check into the repo and run it without the repo's conftests or addopts --- justfile | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/justfile b/justfile index c5f5b62..00b11e8 100644 --- a/justfile +++ b/justfile @@ -41,6 +41,7 @@ adr_check_source := "https://raw.githubusercontent.com/modern-python/.github/mai adr-check: #!/usr/bin/env sh set -eu - file="$(mktemp -d)/test_adr_citations.py" - curl -fsSL "{{ adr_check_source }}" -o "$file" - uv run --no-sync pytest --rootdir=. "$file" + dir="$(mktemp -d .adr-check.XXXXXX)" + trap 'rm -rf "$dir"' EXIT + curl -fsSL "{{ adr_check_source }}" -o "$dir/test_adr_citations.py" + uv run --no-sync pytest --rootdir=. --noconftest -o addopts= "$dir/test_adr_citations.py"