|
| 1 | +# Copyright (c) 2026 dexpace and Omar Aljarrah. |
| 2 | +# Licensed under the MIT License. See LICENSE.md in the repository root for details. |
| 3 | + |
| 4 | +"""Docstring-style guardrail: no Sphinx cross-reference roles. |
| 5 | +
|
| 6 | +The project convention (CLAUDE.md) mandates Google-style docstrings with plain |
| 7 | +backticks for code/type references — never Sphinx roles such as |
| 8 | +``:class:`Foo```. Neither ``ruff`` nor ``mypy`` inspects docstring prose, so a |
| 9 | +one-time conversion sweep would silently rot without a mechanical guard. This |
| 10 | +test scans every package source and test file and fails if any role reappears, |
| 11 | +keeping the convention enforceable rather than aspirational. |
| 12 | +""" |
| 13 | + |
| 14 | +from __future__ import annotations |
| 15 | + |
| 16 | +import re |
| 17 | +from pathlib import Path |
| 18 | + |
| 19 | +_REPO_ROOT = Path(__file__).resolve().parents[3] |
| 20 | +_SELF = Path(__file__).resolve() |
| 21 | + |
| 22 | +# Assembled from parts (and an explicit backtick via ``chr(96)``) so this file |
| 23 | +# contains no literal Sphinx role and never flags itself. |
| 24 | +_ROLE_NAMES = ("class", "meth", "func", "data", "attr", "mod", "exc", "obj", "paramref", "term") |
| 25 | +_ROLE_RE = re.compile(r":(?:" + "|".join(_ROLE_NAMES) + r"):" + chr(96)) |
| 26 | + |
| 27 | + |
| 28 | +def _python_sources() -> list[Path]: |
| 29 | + """Return every ``.py`` file under each package's ``src`` and ``tests``.""" |
| 30 | + files: list[Path] = [] |
| 31 | + packages = _REPO_ROOT / "packages" |
| 32 | + for pkg in sorted(packages.iterdir()): |
| 33 | + for sub in ("src", "tests"): |
| 34 | + root = pkg / sub |
| 35 | + if root.is_dir(): |
| 36 | + files.extend(p for p in root.rglob("*.py") if p.resolve() != _SELF) |
| 37 | + return files |
| 38 | + |
| 39 | + |
| 40 | +def test_no_sphinx_roles_in_docstrings() -> None: |
| 41 | + """No source or test file may use a Sphinx cross-reference role.""" |
| 42 | + offenders: list[str] = [] |
| 43 | + for path in _python_sources(): |
| 44 | + for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): |
| 45 | + if _ROLE_RE.search(line): |
| 46 | + offenders.append(f"{path.relative_to(_REPO_ROOT)}:{lineno}: {line.strip()}") |
| 47 | + assert not offenders, ( |
| 48 | + "Sphinx cross-reference roles are forbidden; use plain backticks " |
| 49 | + "(e.g. ':class:`Foo`' becomes '`Foo`'). Offenders:\n" + "\n".join(offenders) |
| 50 | + ) |
| 51 | + |
| 52 | + |
| 53 | +def test_guard_detects_a_role() -> None: |
| 54 | + """The matcher itself works (so a green run means real coverage).""" |
| 55 | + assert _ROLE_RE.search(":meth:" + chr(96) + "Foo.bar" + chr(96)) is not None |
| 56 | + assert _ROLE_RE.search("plain `Foo` reference") is None |
0 commit comments