Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ lint-ci:
uv run ruff check --no-fix
uv run ty check
uv run python planning/index.py --check
uv run python planning/links.py

# Print the planning change index (flat, newest-first) to stdout.
index:
Expand All @@ -25,6 +26,12 @@ index:
check-planning:
uv run python planning/index.py --check

# Check every relative Markdown link and heading anchor. Nothing else validates them:
# every .md here is read on GitHub, where a rotted link stays invisible until someone
# clicks it.
check-links:
uv run python planning/links.py

test *args:
uv run --no-sync pytest {{ args }}

Expand Down
2 changes: 1 addition & 1 deletion planning/.convention-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.0.0
2.2.0
6 changes: 4 additions & 2 deletions planning/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ realized result, and run `just check-planning` before pushing.

> This is the portable convention, sourced from the canonical repo
> [`lesnik512/planning-convention`](https://github.com/lesnik512/planning-convention)
> (applied version in [`.convention-version`](.convention-version)). To update
> (applied version in `.convention-version`, beside this file). To update
> it, run that repo's `APPLY.md` flow. The generated change index (`just index`)
> and the `## Other` pointers below are repo-local.
> and the `## Other` pointers below are repo-local. `just check-links` validates every
> relative Markdown link and heading anchor in the repo, including the trees a site
> builder never sees.

### Two axes, never mixed

Expand Down
4 changes: 2 additions & 2 deletions planning/changes/2026-07-10.01-arq-di-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ summary: New modern-di integration for arq (async Redis job queue) — the root
Ship the official [modern-di](https://modern-di.modern-python.org) integration
for [arq](https://arq-docs.helpmanual.io) (async Redis task/job queue),
following the core repo's
[Writing an integration](../../../modern-di/docs/integrations/writing-integrations.md)
[Writing an integration](https://modern-di.modern-python.org/integrations/writing-integrations/)
contract along the **decorator path** (arq tasks are plain coroutines with no
native DI seam — the async analog of `modern-di-celery`/`-typer`). One module
(`modern_di_arq/main.py`) exposes `setup_di`, `fetch_di_container`, `FromDI`,
Expand Down Expand Up @@ -240,7 +240,7 @@ reads a task signature) and **does** use `functools.wraps` (arq reads

Real Redis via `docker-compose` (a `redis:8` service) locally and a GitHub
Actions `redis:8` service in CI, mirroring
[`faststream-redis-timers`](../../../faststream-redis-timers): a `redis_client`/
[`faststream-redis-timers`](https://github.com/modern-python/faststream-redis-timers): a `redis_client`/
pool fixture reads `REDIS_URL` and `pytest.skip`s if Redis is unreachable, with
uuid-suffixed queue names for isolation. Tests drive a **real burst Worker**
(`burst=True`, `handle_signals=False`, injected `redis_pool`) so the full arq
Expand Down
Empty file added planning/decisions/.gitkeep
Empty file.
148 changes: 148 additions & 0 deletions planning/links.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# ruff: noqa: INP001 # planning/ is not a Python package (this file is vendored into consumers' planning/)
"""Check every relative Markdown link and heading anchor in the repository.

Run via ``just check-links``. Exists because a site builder only validates the
directory it publishes: a repo's ``architecture/`` and ``planning/`` trees usually
sit outside it, are read on GitHub, and rot silently. In the repo this convention
came from, anchors in ``architecture/`` broke three times in one week, each caught
only by a human re-deriving slugs by hand.

Slugs follow **GitHub's** algorithm, because that is where these files are read —
including the ones a site builder also publishes. Where the two disagree, the fix
is to change the heading rather than to teach this checker both dialects: a heading
containing an em dash yields ``a--b`` on GitHub (the dash is dropped, both spaces
become hyphens) and ``a-b`` under python-markdown (the whitespace run collapses).

External links are not fetched; this checks the repository's internal consistency.
A relative link that resolves outside the repository is reported rather than followed:
it is a 404 on GitHub, and whether it resolves on disk depends on what the author
happens to have cloned next to the repo — a verdict a lint gate must never depend on.
"""

import argparse
import collections
import pathlib
import re
import sys


SKIP_DIRS = frozenset({".git", ".venv", ".tox", "site", "node_modules", "__pycache__", ".ruff_cache", ".superpowers"})
FENCE = re.compile(r"^\s*(```|~~~)")
INLINE_CODE = re.compile(r"(`+).+?\1") # any run of backticks delimits a span: `x`, ``a`b``
HEADING = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
LINK = re.compile(r"\[[^\]]*\]\(\s*([^)\s]+)(?:\s+\"[^\"]*\")?\s*\)")
EXTERNAL = re.compile(r"^(?:[a-z][a-z0-9+.-]*:|//)", re.IGNORECASE)


def repo_root(start: pathlib.Path) -> pathlib.Path:
"""Nearest ancestor holding ``.git``, else ``start``.

Found rather than computed because this file has two homes: the canonical repo's
root, and a consumer's ``planning/`` — a fixed relative depth is wrong in one of them.
"""
for candidate in [start, *start.parents]:
if (candidate / ".git").exists():
return candidate
return start


def strip_fences(text: str) -> str:
"""Blank out fenced blocks, keeping line count, so code is never read as a heading."""
out, fenced = [], False
for line in text.splitlines():
if FENCE.match(line):
fenced = not fenced
out.append("")
continue
out.append("" if fenced else line)
return "\n".join(out)


def link_lines(text: str) -> list[str]:
"""Lines with fenced blocks and inline spans removed — what to scan for real links.

Only link scanning strips inline spans. A page documenting the markup an author should
copy is not linking anywhere, while a heading's backticked content is part of its slug.
"""
return [INLINE_CODE.sub("", line) for line in strip_fences(text).splitlines()]


def slugify(heading: str) -> str:
"""GitHub's heading slug: drop formatting and punctuation, lowercase, spaces to hyphens."""
text = re.sub(r"`([^`]*)`", r"\1", heading)
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
# `*` and `~` are emphasis; `_` is kept because GitHub keeps it and headings name
# identifiers (`bound_type`) far more often than they use underscore-italics.
text = re.sub(r"[*~]", "", text)
text = "".join(ch for ch in text.lower() if ch.isalnum() or ch in " -_")
return text.strip().replace(" ", "-")


def anchors(text: str) -> set[str]:
"""Every anchor a reader can target, including GitHub's ``-1``/``-2`` duplicate suffixes."""
seen: collections.Counter[str] = collections.Counter()
found: set[str] = set()
for line in strip_fences(text).splitlines():
match = HEADING.match(line)
if not match:
continue
base = slugify(match.group(2))
found.add(base if not seen[base] else f"{base}-{seen[base]}")
seen[base] += 1
return found


def check(root: pathlib.Path) -> list[str]:
"""Return one message per broken link; empty means every internal link resolves."""
root = root.resolve()
files = sorted(p for p in root.rglob("*.md") if not SKIP_DIRS & set(p.relative_to(root).parts))
cache: dict[pathlib.Path, set[str]] = {}
violations: list[str] = []
for path in files:
text = path.read_text(encoding="utf-8")
for line_no, line in enumerate(link_lines(text), 1):
for target in LINK.findall(line):
if EXTERNAL.match(target):
continue
rel, _, fragment = target.partition("#")
# A bare `#frag` targets this same file — the anchor is still checkable,
# and a same-page link rots exactly like a cross-page one.
dest = (path.parent / rel).resolve() if rel else path
where = f"{path.relative_to(root)}:{line_no}"
if dest != root and root not in dest.parents:
# Judged before existence: a sibling repo cloned alongside this one makes
# ../../../other-repo/… resolve on one machine and nowhere else, and it is
# a 404 on GitHub either way. The verdict must not depend on the checkout layout.
violations.append(f"{where}: leaves the repository -> {rel}")
continue
if not dest.exists():
violations.append(f"{where}: no such file -> {rel}")
continue
if not fragment or dest.suffix != ".md":
continue
if dest not in cache:
cache[dest] = anchors(dest.read_text(encoding="utf-8"))
if fragment.lower() not in cache[dest]:
violations.append(f"{where}: no such anchor -> {target}")
return violations


def main(argv: list[str] | None = None, root: pathlib.Path | None = None) -> int:
"""Report every broken link; return 1 if any, else 0."""
parser = argparse.ArgumentParser(description="Check Markdown links and heading anchors.")
parser.add_argument("--root", type=pathlib.Path, default=None)
args = parser.parse_args(sys.argv[1:] if argv is None else argv)

target = args.root or root or repo_root(pathlib.Path(__file__).resolve().parent)
violations = check(target)
if violations:
sys.stderr.write(f"links: {len(violations)} broken\n")
for violation in violations:
sys.stderr.write(f" - {violation}\n")
return 1
sys.stdout.write("links: OK\n")
return 0


if __name__ == "__main__":
raise SystemExit(main())