Source code for tests.test_readme

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

"""Tests for documentation sync with code."""

from __future__ import annotations

import re
from pathlib import Path

import pytest
import tomlrt

from repomatic.components import COMPONENTS_BY_NAME
from repomatic.config import GitignoreConfig
from repomatic.tooling.registry import TOOL_REGISTRY, NativeFormat

REPO_ROOT = Path(__file__).parent.parent
BENCHMARK_MD = REPO_ROOT / "docs" / "benchmark.md"
CLAUDE_CODE_PLUGIN_MD = REPO_ROOT / "docs" / "claude-code-plugin.md"
CLI_MD = REPO_ROOT / "docs" / "cli.md"
CONFIGURATION_MD = REPO_ROOT / "docs" / "configuration.md"
README_MD = REPO_ROOT / "readme.md"
TOOL_RUNNER_MD = REPO_ROOT / "docs" / "tool-runner.md"

IMAGE_SRC_RE = re.compile(r"""<img[^>]*\ssrc=["']([^"']+)["']|!\[[^\]]*\]\(([^)\s]+)""")
"""Every image reference in a Markdown file, HTML tag or Markdown syntax."""


def _parse_tool_runner_table() -> dict[str, str]:
    """Parse the hand-written `[tool.X]` support table in docs/tool-runner.md.

    Returns a dict mapping display name to support column content, for rows
    that list either 'repomatic bridge' or 'Native' support. This table is
    hand-curated prose (unlike the registry-rendered `{python:render}`
    blocks), so it needs a conformance test against the registry.
    """
    tool_runner_text = TOOL_RUNNER_MD.read_text(encoding="UTF-8")
    result = {}
    for line in tool_runner_text.splitlines():
        if not line.startswith("| "):
            continue
        cols = [c.strip() for c in line.split("|")]
        if len(cols) < 5:
            continue
        tool_col = cols[1]
        support_col = cols[4]
        if "repomatic bridge" not in support_col and "Native" not in support_col:
            continue
        m = re.match(r"\[([^\]]+)\]", tool_col)
        if m:
            result[m.group(1)] = support_col
    return result


[docs] def test_readme_images_are_absolute() -> None: """Every image in `readme.md` must be an absolute URL. `pyproject.toml` sets `readme = "readme.md"`, so this file ships verbatim as the PyPI long description. GitHub resolves a relative path against the repository; PyPI has no such base and renders a broken image. The logo banner shipped relative and 404'd on every release page until `7.7.1`. Local paths stay correct on GitHub, which is what makes this invisible in review: the only place the breakage shows is the published project page. """ offenders = [ html_src or md_src for html_src, md_src in IMAGE_SRC_RE.findall( README_MD.read_text(encoding="UTF-8") ) if not (html_src or md_src).startswith(("https://", "http://", "data:")) ] assert not offenders, ( f"readme.md carries {len(offenders)} relative image reference(s): " f"{offenders}. readme.md is the PyPI long description, which cannot " "resolve repository-relative paths. Use the absolute " "https://raw.githubusercontent.com/… URL." )
[docs] def test_docs_cli_reference_uses_tree_directive() -> None: """docs/cli.md must render the CLI reference through ``{click:tree}``. The directive walks the live command tree at build time, so per-command coverage is guaranteed by construction. This canary only guards against the directive block being dropped from the page. """ cli_text = CLI_MD.read_text(encoding="UTF-8") assert "```{click:tree} repomatic" in cli_text
[docs] def test_docs_config_reference_uses_config_directive() -> None: """docs/configuration.md must render the reference through ``{click:config}``. The directive documents the live `Config` schema at build time, from the same `schema_field_infos()` records `show-config` renders, so option coverage is guaranteed by construction. This canary only guards against the directive block being dropped from the page. """ config_text = CONFIGURATION_MD.read_text(encoding="UTF-8") assert "```{click:config} repomatic" in config_text
[docs] def test_docs_bridge_table_covers_registry() -> None: """The tool-runner.md table must list every registry tool that supports translation. A tool supports ``[tool.X]`` translation when it does not natively read ``pyproject.toml`` (``reads_pyproject=False``) and can receive a translated config via either a ``config_flag``, a ``native_config_files`` target in a non-editorconfig format (editorconfig files are shared across tools and not suitable as single-tool bridge targets), or a ``FLAGS`` translation to CLI options (like Nuitka). """ tool_table = _parse_tool_runner_table() documented = { name for name, support in tool_table.items() if "repomatic bridge" in support } bridgeable = { name for name, spec in TOOL_REGISTRY.items() if not spec.reads_pyproject and ( spec.config_flag or spec.native_format is NativeFormat.FLAGS or ( spec.native_config_files and spec.native_format is not NativeFormat.EDITORCONFIG ) ) } missing = bridgeable - documented assert not missing, ( f"Tools with [tool.X] bridge support missing from tool-runner.md table: " f"{sorted(missing)}. Add them to the tool table in docs/tool-runner.md." ) extra = documented - bridgeable assert not extra, ( f"Tools listed in tool-runner.md bridge rows but not bridgeable in registry: " f"{sorted(extra)}. Remove them or update the registry." )
[docs] def test_docs_tip_table_covers_registry() -> None: """The tool-runner.md table lists every tool that reads pyproject.toml natively. Tools with ``reads_pyproject=True`` in the registry should appear in the tool table with 'Native' support. The table may also list non-registry tools (like coverage.py, pytest, uv) that the workflows use and that natively read ``[tool.*]`` sections. """ tool_table = _parse_tool_runner_table() documented = {name for name, support in tool_table.items() if "Native" in support} native_readers = { name for name, spec in TOOL_REGISTRY.items() if spec.reads_pyproject } # Registry tool names may differ from display names (e.g., "bump-my-version" # is the package name while the registry key is also "bump-my-version"). Map # registry names to the display names used in the table. display_names = { TOOL_REGISTRY[name].package or TOOL_REGISTRY[name].name for name in native_readers } missing = display_names - documented assert not missing, ( f"Tools with reads_pyproject=True missing from tool-runner.md table: " f"{sorted(missing)}. Add them to the tool table in docs/tool-runner.md." )
[docs] @pytest.mark.parametrize( "renderer", ["tool_summary", "tool_reference"], ) def test_docs_tool_runner_uses_render_blocks(renderer: str) -> None: """docs/tool-runner.md must render its registry content live. Each `repomatic.tooling.registry` renderer is embedded through a `{python:render}` block, so registry coverage is by construction. These canaries only guard against a block being dropped from the page. """ tool_runner_text = TOOL_RUNNER_MD.read_text(encoding="UTF-8") assert f"from repomatic.tooling.registry import {renderer}" in tool_runner_text
# Packages sphinx-apidoc walks. The data/, templates/ and awesome-template # subpackages hold only bundled assets, so they carry no importable module. _APIDOC_ROOTS = ("repomatic", "tests") # sphinx-apidoc emits a page for every module except `__main__`, an entry-point # shim it skips. A package `__init__` is documented by the package's own page, # which is named for the package rather than for the file. _APIDOC_SKIP = frozenset({"__main__"}) def _expected_module_pages() -> dict[str, str]: """Map each module's dotted name to the page `--separate` gives it. A package `__init__.py` collapses onto the package page (`repomatic.cli`), every other module gets its own (`repomatic.cli.main`). """ pages = {} for root in _APIDOC_ROOTS: for path in sorted((REPO_ROOT / root).rglob("*.py")): if path.stem in _APIDOC_SKIP: continue parts = path.relative_to(REPO_ROOT).with_suffix("").parts if parts[-1] == "__init__": parts = parts[:-1] pages[".".join(parts)] = ".".join(parts) return pages
[docs] @pytest.mark.parametrize("module", sorted(_expected_module_pages())) def test_every_module_has_a_docs_automodule(module: str) -> None: """Every module has its own reference page, carrying its automodule. `[tool.repomatic] docs.apidoc-extra-args` passes `--separate`, so the reference is one page per module rather than one page per package. That keeps a page's length tied to the module it documents, and it is why this checks for a page named after the module rather than for an entry on a package page. These pages are write-once: {func}`click_extra.convert_rst_files_in_directory` keeps an existing `.md` and discards the regenerated `.rst`, so a module added later silently goes undocumented (Sphinx then warns "document isn't included in any toctree"). The `update-docs` drift test cannot catch it, since the generator never rewrites a page that already exists. To rebuild one, delete it and run `repomatic update-docs`. :param module: Dotted module name that must own a reference page. """ page = REPO_ROOT / "docs" / f"{module}.md" assert page.is_file(), ( f"{module} has no docs/{module}.md page. " f"Run `repomatic update-docs` to generate it." ) documented = set( re.findall(r"automodule:: ([\w.]+)", page.read_text(encoding="UTF-8")) ) assert module in documented, ( f"docs/{module}.md carries no automodule for {module}. " f"Delete it and run `repomatic update-docs` to regenerate it." )
[docs] @pytest.mark.parametrize( ("page", "count_re", "component"), ( pytest.param( CLAUDE_CODE_PLUGIN_MD, r"\b(\d+) skills\b", "skills", id="plugin-skills" ), pytest.param( CLAUDE_CODE_PLUGIN_MD, r"\b(\d+) subagents\b", "subagents", id="plugin-subagents", ), pytest.param( BENCHMARK_MD, r"\b(\d+) reusable workflows\b", "workflows", id="benchmark-workflows", ), ), ) def test_docs_asset_counts_match_the_registry( page: Path, count_re: str, component: str ) -> None: """A count of bundled assets a page states in prose equals the registry's. Retiring a skill or adding a workflow changes the registry, and nothing rewrites a number typed into a sentence. :param page: The page stating the count. :param count_re: Matches each count of *component* the page states. :param component: The registry component the count is of. """ text = page.read_text(encoding="UTF-8") counts = {int(count) for count in re.findall(count_re, text)} assert counts, f"{page.name} states no count matching {count_re!r}." shipped = len(COMPONENTS_BY_NAME[component].files) assert counts == {shipped}, ( f"{page.name} counts {sorted(counts)} {component}, and the registry" f" ships {shipped}." )
[docs] def test_docs_gitignore_sample_quotes_the_shipped_default() -> None: """The `gitignore.extra-content` sample quotes the default, then adds one entry. The key replaces its default, so the page tells a reader to repeat the shipped entries, and calls everything above the last entry of its sample that default, quoted verbatim. A shipped entry missing there is one that a reader copying the sample loses. """ blocks = re.findall( r"^```toml\n(.*?)^```$", CONFIGURATION_MD.read_text(encoding="UTF-8"), re.DOTALL | re.MULTILINE, ) # The sample sets the key at the top level of its block: the page also # shows it nested in a whole `[tool.repomatic]` table, with other content. samples = [ str(sample) for block in blocks if "gitignore.extra-content" in block and (sample := tomlrt.loads(block).get("gitignore", {}).get("extra-content")) ] assert len(samples) == 1, samples *quoted, _own = samples[0].strip().split("\n\n") assert "\n\n".join(quoted) == GitignoreConfig().extra_content