diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c8d3906 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,98 @@ +name: Release + +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + python-version: "3.12" + + - name: Build sdist and wheel + run: uv build + + # Catches a malformed README or broken metadata before anything is + # uploaded — a bad upload cannot be replaced, only yanked. + - name: Validate distributions + run: uvx twine check dist/* + + # The tag is the release's identity; if it disagrees with the version in + # pyproject.toml the wheel would be published under a name the tag does + # not describe. Fail here rather than after upload. + - name: Tag must match the declared version + if: startsWith(github.ref, 'refs/tags/v') + run: | + declared=$(python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])") + tagged="${GITHUB_REF_NAME#v}" + echo "pyproject: $declared / tag: $tagged" + test "$declared" = "$tagged" + + # The package is not only Python. The client JS, its vendored Idiomorph + # copy, the stylesheet, and the PEP 561 marker all live inside the + # package so hatchling picks them up with no explicit include — which is + # exactly what would make a regression here quiet. A wheel missing + # component-client.js installs and imports cleanly and then serves no + # interactivity; a wheel missing py.typed types as `Any` everywhere with + # no error at all. + - name: Wheel must contain the client assets and the typing marker + run: | + python - <<'PY' + import pathlib, sys, zipfile + + wheel = next(iter(sorted(pathlib.Path("dist").glob("*.whl")))) + names = zipfile.ZipFile(wheel).namelist() + print(f"{wheel.name}: {len(names)} entries") + + missing = [ + required + for required in [ + "component_framework/py.typed", + "component_framework/static/component_framework/js/component-client.js", + "component_framework/static/component_framework/js/component-client.d.ts", + "component_framework/static/component_framework/js/vendor/idiomorph.js", + "component_framework/static/component_framework/css/component-framework.css", + ] + if not any(n.endswith(required) for n in names) + ] + if missing: + sys.exit("wheel is missing shipped content:\n " + "\n ".join(missing)) + + print("ok: client assets and py.typed present") + PY + + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + # Gates the upload behind a named environment, so protection rules can + # require a review before anything reaches PyPI. + environment: + name: pypi + url: https://pypi.org/p/component-framework + permissions: + # Required for trusted publishing: the job mints a short-lived OIDC + # token that PyPI exchanges for an upload token. No API token is stored. + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index dcf02cd..68879f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] - 2026-07-30 + +First release published to PyPI. No behavioural change from `0.6.0b0` — the +beta is promoted to a final release so that dependents can require a stable +version. `cf-ui` declares `component-framework>=0.4`, and a specifier without +a pre-release marker only resolves to a pre-release when no final release +exists; relying on that fallback would mean the resolution changed silently +the first time any stable version appeared. + +The rest of this entry is what an audit of the built wheel turned up: the +package was installable, but not yet fit to *hand to someone*. + +### Added + +- Trusted publishing to PyPI via GitHub Actions OIDC on a `v*` tag (#47). No + API token is stored in the repository. The build job also refuses to hand + off a wheel missing the client assets or `py.typed` — those ship inside the + package with no explicit include, so a packaging regression would produce a + wheel that installs and imports cleanly and then serves no interactivity. +- **`py.typed` (#49).** The codebase is type-checked in CI and ships + `component-client.d.ts` for the JS, but without the PEP 561 marker every + Python consumer running mypy or pyright saw the whole package as untyped. + The types existed; they were not advertised. +- **A `testing` extra (#49)** declaring pytest. + `component_framework.testing` imports pytest at module scope — it ships + fixtures and a pytest-style base class — but pytest was only reachable via + `dev-base`, so following the README's testing sample after + `pip install component-framework[fastapi]` raised `ModuleNotFoundError`. +- **Classifiers** for the license (which is what PyPI's sidebar reads), the + frameworks the adapters target, and `Typing :: Typed`. + +### Fixed — documentation that did not survive contact with the package (#49) + +Found by building the wheel, installing it into a clean venv, and checking +every documented import against the *installed* package rather than the +source tree. + +- **The README described an install nobody could perform.** Every instruction + was `pip install -e ".[extra]"` — an editable install from a checkout — and + the section opened with "Not on PyPI yet". The README *is* the PyPI landing + page, so the one line a visitor arriving there needed was the one that was + missing. It now leads with `pip install "component-framework[fastapi]"`, + documents the quoting (bare brackets are glob syntax in zsh), shows the + missing-extra `ImportError` a reader will actually meet, and demotes the + editable install to a contributor note. +- **The README's composition example was invented.** It imported + `SlotComponent` and `CompositeComponent` from `core.composition`, which + exports neither, and set a `components = {...}` attribute nothing reads. + The real API is a `Component` with a `slots` ClassVar, assembled with + `compose()`. +- **The README's testing example used methods that do not exist** + (`mount_component`, `dispatch_event`, and `assert_state` with a positional + component argument), and omitted the required `component_class`. +- **`docs/LOCKED_FIELDS.md`** imported `Component` and `registry` from the + top-level package, which exports only `CorruptStateError` and + `StateSigner`. +- **`docs/CBV_GUIDE.md`** imported `RateLimitMixin` from + `adapters.django_views`; it lives in `adapters.django_ratelimit`, as the + README said all along. +- **Two `docs/examples/ecommerce.md` samples did not parse** — a bare `...` + inside a list literal, and a method whose body was only a comment. + +### Added — a test that reads the docs (#49) + +`tests/test_docs_samples.py` parses every fenced `python` block in the +README, CONTRIBUTING, and `docs/`, and resolves every +`from component_framework… import …` against the real package. Nothing here +read the documentation before, which is why all six defects above shipped; +the guard goes red on every one of them when run against the previous text. +It also fails if the README ever again claims the package is not on PyPI. +Ported from cf-ui's `test_docs_samples.py`, which exists because +`ComponentCatalog` and `` sat in *that* README for two releases. + ## [0.6.0b0] - 2026-07-20 ### Added diff --git a/README.md b/README.md index e6d18b4..6940f66 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Component Framework -> **Beta** — the core lifecycle, permissions, composition, and testing utilities are stable, but the public API can still change before 1.0. Not yet published to PyPI (see [Installation](#installation)). +> **Beta** — the core lifecycle, permissions, composition, and testing utilities are stable, but the public API can still change before 1.0. See [Installation](#installation). Server-driven UI components for Python web frameworks, in the style of Phoenix LiveView and Laravel Livewire: state and event handling live on the server, and [HTMX](https://htmx.org/) handles the client-side wiring instead of a JavaScript framework. @@ -64,28 +64,45 @@ A few things are Django-only today even though the underlying hook is framework- ## Installation -**Not on PyPI yet** — install from source: - ```bash -git clone https://github.com/fsecada01/component-framework.git -cd component-framework - # uv (recommended) -uv pip install -e ".[fastapi]" # or [django] / [litestar] / [flask] / [all] +uv add "component-framework[fastapi]" # or [django] / [litestar] / [flask] # or with pip -pip install -e ".[fastapi]" +pip install "component-framework[fastapi]" ``` -`pydantic>=2.0` is the only mandatory dependency; everything else — FastAPI, Django, Litestar, Flask, JinjaX, Channels — is an optional extra so you only pull in what you use. +Pick the extra for the web framework you're on. `pydantic>=2.0` is the only mandatory dependency; everything else — FastAPI, Django, Litestar, Flask, JinjaX, Channels — is optional, so you only pull in what you use. ```bash -pip install -e ".[fastapi]" # single adapter -pip install -e ".[fastapi,django,litestar,flask]" # several -pip install -e ".[all]" # everything, including dev-adjacent websockets extra +pip install "component-framework[fastapi]" # single adapter +pip install "component-framework[fastapi,django,litestar,flask]" # several +pip install "component-framework[all]" # every adapter, plus the websockets extra +pip install "component-framework[fastapi,testing]" # + the pytest helpers, see Testing below ``` -> Extras were made optional in 0.3.0 — if you're on an older checkout that assumed `fastapi`/`uvicorn`/`jinjax` installed by default, see [CHANGELOG.md](https://github.com/fsecada01/component-framework/blob/master/CHANGELOG.md). +The quotes matter in most shells — bare brackets are glob syntax in zsh and get eaten before pip sees them. + +Import an adapter whose extra you skipped and you get a deliberate error naming the fix, not a bare `ModuleNotFoundError`: + +``` +ImportError: 'jinjax' is not installed. Install the 'fastapi' extra: +pip install 'component-framework[fastapi]' +``` + +> Extras were made optional in 0.3.0 — if you're upgrading from before that and assumed `fastapi`/`uvicorn`/`jinjax` came by default, see [CHANGELOG.md](https://github.com/fsecada01/component-framework/blob/master/CHANGELOG.md). + +### From a checkout + +For hacking on the framework itself: + +```bash +git clone https://github.com/fsecada01/component-framework.git +cd component-framework +uv pip install -e ".[dev]" +``` + +See [CONTRIBUTING.md](https://github.com/fsecada01/component-framework/blob/master/CONTRIBUTING.md). --- @@ -204,17 +221,27 @@ class ContactForm(FormComponent): ``` ```python -# Composition: slots + a composite parent -from component_framework.core.composition import SlotComponent, CompositeComponent +# Composition: a parent declares named slots, children fill them +from component_framework.core import Component, registry +from component_framework.core.composition import compose @registry.register("card") -class Card(SlotComponent): +class Card(Component): template_name = "card.html" - slots = ["header", "body", "footer"] - -@registry.register("product_page") -class ProductPage(CompositeComponent): - components = {"card": Card, "cart": CartComponent} + slots = ["header", "body", "footer"] # omit to accept any slot name + +@registry.register("cart_summary") +class CartSummary(Component): + template_name = "cart_summary.html" + +# Assemble in one call. Each child's rendered HTML lands in the parent's +# template context under `slots`, keyed by slot name. +page = compose( + Card, + params={"title": "Your order"}, + body=CartSummary(), +) +result = page.dispatch() ``` ```python @@ -233,15 +260,21 @@ class OrderEditor(DjangoModelComponent): ``` ```python -# Testing a component without an HTTP server +# Testing a component without an HTTP server. +# Needs the `testing` extra: pip install "component-framework[testing]" from component_framework.testing import ComponentTestCase class TestCounter(ComponentTestCase): + component_class = Counter # a MockRenderer is installed per test + + def test_initial_state(self): + result = self.mount() + assert result["state"]["count"] == 0 + def test_increment(self): - component = self.mount_component("counter") - self.assert_state(component, count=0) - self.dispatch_event(component, "increment", amount=5) - self.assert_state(component, count=5) + self.mount() + self.dispatch("increment", {"amount": 5}) + self.assert_state(count=5) ``` More worked examples: [`docs/examples/ecommerce.md`](https://github.com/fsecada01/component-framework/blob/master/docs/examples/ecommerce.md) (real-time cart), [`docs/examples/wizard.md`](https://github.com/fsecada01/component-framework/blob/master/docs/examples/wizard.md) (multi-step FastAPI wizard), and the runnable apps under [`examples/`](https://github.com/fsecada01/component-framework/tree/master/examples/). diff --git a/docs/CBV_GUIDE.md b/docs/CBV_GUIDE.md index e30efe2..fbcd8bf 100644 --- a/docs/CBV_GUIDE.md +++ b/docs/CBV_GUIDE.md @@ -316,7 +316,8 @@ class CachedView(CacheMixin, ComponentView): Add rate limiting (requires django-ratelimit). ```python -from component_framework.adapters.django_views import RateLimitMixin, ComponentView +from component_framework.adapters.django_ratelimit import RateLimitMixin +from component_framework.adapters.django_views import ComponentView class RateLimitedView(RateLimitMixin, ComponentView): rate_limit_key = "component" diff --git a/docs/LOCKED_FIELDS.md b/docs/LOCKED_FIELDS.md index acdb6d1..c6b6c75 100644 --- a/docs/LOCKED_FIELDS.md +++ b/docs/LOCKED_FIELDS.md @@ -19,7 +19,7 @@ client must never influence, in either mode. ```python from typing import ClassVar -from component_framework import Component, registry +from component_framework.core import Component, registry @registry.register("account_panel") diff --git a/docs/examples/ecommerce.md b/docs/examples/ecommerce.md index 683184b..1f585f7 100644 --- a/docs/examples/ecommerce.md +++ b/docs/examples/ecommerce.md @@ -79,7 +79,7 @@ pip install component-framework django django-channels ```python # settings.py INSTALLED_APPS = [ - ... + ..., "channels", "component_framework", ] @@ -611,7 +611,7 @@ serialisation/deserialisation. class CartComponent(Component): def on_add_item(self, product_id: int, size: str): - # ... 10 lines of plain Python (see above) ... + ... # 10 lines of plain Python, see above def get_optimistic_patch(self, event: str, payload: dict) -> dict | None: if event == "add_item": diff --git a/pyproject.toml b/pyproject.toml index b52a25f..821b6b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "component-framework" -version = "0.6.0b0" +version = "0.6.0" description = "Framework-agnostic server components with LiveView-style interactivity" readme = "README.md" requires-python = ">=3.11" @@ -12,11 +12,21 @@ keywords = ["components", "server-components", "liveview", "htmx", "fastapi", "d classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", + # PyPI's sidebar reads the license from this classifier, not from the + # `license` field. + "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: Implementation :: CPython", + "Framework :: Django", + "Framework :: FastAPI", + "Framework :: Flask", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Typing :: Typed", ] dependencies = [ @@ -47,6 +57,13 @@ flask = [ websockets = [ "websockets>=12.0", ] +# `component_framework.testing` imports pytest at module scope — it ships +# pytest fixtures and a pytest-style base class. Without this extra a +# consumer following the README's testing sample hit ModuleNotFoundError, +# because pytest was only reachable through `dev-base` (#49). +testing = [ + "pytest>=7.4.0", +] dev-base = [ "pytest>=7.4.0", "pytest-asyncio>=0.21.0", diff --git a/src/component_framework/py.typed b/src/component_framework/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_docs_samples.py b/tests/test_docs_samples.py new file mode 100644 index 0000000..1d7b15e --- /dev/null +++ b/tests/test_docs_samples.py @@ -0,0 +1,279 @@ +"""The documentation's code samples are checked against the real package (#49). + +Prose rots silently, and nothing here read the docs until this file existed. +Two dead samples were sitting in the tree when it was written, both of which +read perfectly: + +* ``from component_framework.core.composition import SlotComponent, + CompositeComponent`` — neither name exists. ``composition`` exports + ``compose`` and ``SlotRenderer``. The surrounding README example was + invented wholesale, down to a ``components = {...}`` attribute nothing + reads. +* ``from component_framework import Component, registry`` — the top-level + ``__init__`` exports only ``CorruptStateError`` and ``StateSigner``. Both + names live in ``component_framework.core``. + +Neither is the kind of thing review catches, because both are what you would +guess the API looks like. So the docs are parsed and their claims executed: +every ``python`` block must parse, and every name imported from +``component_framework`` must actually exist on the module it is imported +from. + +Ported from cf-ui's ``tests/unit/test_docs_samples.py``, which exists for the +same reason — ``ComponentCatalog`` and ```` sat in that README for +two releases. +""" + +from __future__ import annotations + +import ast +import importlib +import re +import textwrap +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).parent.parent + +#: Fenced blocks, including indented ones inside list items or admonitions — +#: the closing fence must match the opening fence's indent. +FENCE = re.compile(r"^([ \t]*)```(\w+)?[^\n]*\n(.*?)^\1```", re.M | re.S) + +#: Docs that are published to a reader. ``docs/reports/`` is deliberately +#: excluded: those are point-in-time build records, not instructions, and +#: pinning their samples would freeze history rather than protect a reader. +DOC_GLOBS = ["README.md", "CONTRIBUTING.md", "docs/*.md", "docs/examples/*.md"] + + +def _doc_files() -> list[Path]: + seen: dict[Path, None] = {} + for pattern in DOC_GLOBS: + for path in sorted(REPO_ROOT.glob(pattern)): + seen[path] = None + return list(seen) + + +def _rel(path: Path) -> str: + return str(path.relative_to(REPO_ROOT)).replace("\\", "/") + + +DOC_FILES = _doc_files() + + +def test_the_doc_set_the_guard_walks_is_not_empty(): + """A guard over an empty glob passes. Pin what it is supposed to cover.""" + names = {_rel(p) for p in DOC_FILES} + assert "README.md" in names + for expected in ("docs/LOCKED_FIELDS.md", "docs/STATE_SIGNING.md", "docs/CBV_GUIDE.md"): + assert expected in names, f"{expected} missing from the guarded doc set" + + +def _python_blocks() -> list[tuple[Path, str]]: + out = [] + for path in DOC_FILES: + text = path.read_text(encoding="utf-8") + for match in FENCE.finditer(text): + if (match.group(2) or "") in ("python", "py"): + out.append((path, textwrap.dedent(match.group(3)))) + return out + + +PYTHON_BLOCKS = _python_blocks() + + +@pytest.mark.parametrize( + ("path", "source"), + PYTHON_BLOCKS, + ids=[f"{_rel(p)}:{i}" for i, (p, _) in enumerate(PYTHON_BLOCKS)], +) +def test_every_python_sample_parses(path: Path, source: str): + try: + ast.parse(source) + except SyntaxError as exc: + pytest.fail(f"{_rel(path)}: sample does not parse: {exc}\n\n{source}") + + +def _imported_names() -> list[tuple[Path, str, str]]: + """(file, module, name) for every documented ``from component_framework…``. + + Blocks that do not parse are skipped here rather than raising at + collection time — ``test_every_python_sample_parses`` is what reports + those, and a collection error would hide every other finding in this + module behind it. + """ + out = [] + for path, source in PYTHON_BLOCKS: + try: + tree = ast.parse(source) + except SyntaxError: + continue + for node in ast.walk(tree): + if not (isinstance(node, ast.ImportFrom) and node.module and node.level == 0): + continue + if node.module != "component_framework" and not node.module.startswith( + "component_framework." + ): + continue + for alias in node.names: + out.append((path, node.module, alias.name)) + return out + + +IMPORTED_NAMES = _imported_names() + + +@pytest.mark.parametrize( + ("path", "module", "name"), + IMPORTED_NAMES, + ids=[f"{_rel(p)}:{m}.{n}" for p, m, n in IMPORTED_NAMES], +) +def test_every_documented_import_exists(path: Path, module: str, name: str): + """``SlotComponent`` is the bug this test exists for. + + An adapter whose extra is not installed raises a deliberate, well-worded + ``ImportError`` ("Install the 'django' extra: …"). That is the package + working as designed, not a doc defect, so it is skipped — + ``test_the_import_check_is_not_all_skips`` keeps that from hollowing the + check out. + """ + try: + mod = importlib.import_module(module) + except ImportError as exc: + pytest.skip(f"optional dependency missing for {module}: {exc}") + + assert hasattr(mod, name), ( + f"{_rel(path)} documents `from {module} import {name}`, but {module} " + f"exposes no such name. Available: " + f"{', '.join(sorted(n for n in dir(mod) if not n.startswith('_'))[:12])}…" + ) + + +def test_the_import_check_is_not_all_skips(): + """At least the core imports must have been really checked. + + Every one of these resolves with no optional extra installed, so a skip + here means the sample stopped being documented, not that the environment + is thin. + """ + checked = {(m, n) for _, m, n in IMPORTED_NAMES} + for required in [ + ("component_framework.core", "Component"), + ("component_framework.core", "registry"), + ]: + assert required in checked, ( + f"no doc sample imports {required[1]} from {required[0]} any more — " + "either the docs regressed or this list is stale." + ) + + +def test_the_fence_regex_finds_the_blocks_it_claims_to(): + """Pin the extractor: a regex that stops matching reports clean forever.""" + doc = ( + "text\n\n```python\nx = 1\n```\n\n" + "- item:\n\n ```python\n y = 2\n ```\n\n" # indented fence + "```bash\nls\n```\n" + ) + found = [(m.group(2), textwrap.dedent(m.group(3))) for m in FENCE.finditer(doc)] + assert found == [("python", "x = 1\n"), ("python", "y = 2\n"), ("bash", "ls\n")] + assert len(PYTHON_BLOCKS) >= 20, f"only {len(PYTHON_BLOCKS)} python samples found" + + +# ── The README's install instructions must describe the published package ── + + +#: Every phrasing of "you cannot install this from PyPI" that was in the file. +#: Matched loosely on purpose — the first cut of this guard pinned the exact +#: string "Not on PyPI" and sailed straight past a second, differently-worded +#: claim in the very first line of the same README. +NOT_ON_PYPI = re.compile(r"not (?:yet )?(?:on|published to) PyPI", re.I) + + +def test_the_readme_documents_installing_from_pypi(): + """The README *is* the PyPI landing page. + + Before #49 every install line was `pip install -e ".[extra]"` — an + editable install from a checkout — and the file said twice that the + package was not on PyPI. A visitor arriving from PyPI found no + instruction that applied to them. + """ + readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + + stale = NOT_ON_PYPI.search(readme) + assert stale is None, ( + f"the README still claims the package is not on PyPI ({stale.group(0)!r}); " + "it is published now" + ) + assert re.search(r"(?:pip|uv) (?:pip )?(?:install|add) ['\"]?component-framework\[", readme), ( + "the README never shows `pip install component-framework[extra]` — the one " + "line a reader arriving from the PyPI page needs." + ) + + +def test_the_not_on_pypi_guard_matches_both_phrasings_that_were_in_the_file(): + """Pin the loosened pattern against the two real sentences it replaced.""" + for phrasing in [ + "**Not on PyPI yet** — install from source:", + "the public API can still change before 1.0. Not yet published to PyPI (see …).", + ]: + assert NOT_ON_PYPI.search(phrasing), phrasing + assert NOT_ON_PYPI.search("This package is on PyPI.") is None + + +# ── README links have to work off GitHub too ─────────────────────────────── + +BLOB = "https://github.com/fsecada01/component-framework/blob/master/" +TREE = "https://github.com/fsecada01/component-framework/tree/master/" + + +def _readme_links() -> list[tuple[str, str]]: + readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + return re.findall(r"\[([^\]]+)\]\(([^)]+)\)", readme) + + +def test_every_readme_link_is_absolute(): + """The README renders on PyPI, where a relative link is a dead link. + + The docs site cannot cover for it either: that site is a pdoc API + reference and does not publish the markdown guides at all. Anchors are + fine — they resolve on both surfaces. + """ + relative = [ + (label, target) + for label, target in _readme_links() + if not target.startswith(("http://", "https://", "#", "mailto:")) + ] + assert not relative, ( + "README links must be absolute so they work from the PyPI page: " + + ", ".join(f"[{label}]({target})" for label, target in relative) + ) + + +def test_every_absolute_readme_link_into_this_repo_points_at_a_real_file(): + """An absolute link to a moved file 404s silently — worse than relative. + + Checked against ``git ls-files`` rather than the filesystem, because a + path that exists locally but is untracked still 404s on github.com. + """ + import subprocess + + tracked = set( + subprocess.run( + ["git", "ls-files"], cwd=REPO_ROOT, capture_output=True, text=True, check=True + ).stdout.split() + ) + assert tracked, "git ls-files returned nothing; this guard would pass vacuously" + + checked, dead = 0, [] + for _, url in _readme_links(): + for prefix in (BLOB, TREE): + if not url.startswith(prefix): + continue + path = url[len(prefix) :].split("#")[0].rstrip("/") + checked += 1 + # A directory target is fine if anything tracked lives under it. + if path not in tracked and not any(t.startswith(path + "/") for t in tracked): + dead.append(url) + + assert checked >= 15, f"only {checked} in-repo README links found; is the prefix stale?" + assert not dead, "README links point at paths git does not track:\n " + "\n ".join(dead)