From 7f3198cff09befb926533324d7db7ca206c36375 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 28 Jul 2026 18:18:43 +0300 Subject: [PATCH] Bump planning convention 2.0.0 -> 2.2.0 Adds planning/links.py, a Markdown link and anchor checker, and runs it in lint-ci. Nothing else here validates these files: this repo has no docs site, so every .md is read on GitHub, where a rotted link stays invisible until someone clicks it. - planning/links.py: verbatim copy of the canonical 2.2.0 file. - Justfile: check-links recipe, plus a planning/links.py step in lint-ci. - planning/README.md: the 2.1.0 prose delta. --- Justfile | 7 + planning/.convention-version | 2 +- planning/README.md | 6 +- .../2026-07-10.01-arq-di-integration.md | 4 +- planning/decisions/.gitkeep | 0 planning/links.py | 148 ++++++++++++++++++ 6 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 planning/decisions/.gitkeep create mode 100644 planning/links.py diff --git a/Justfile b/Justfile index 57833ef..984d85a 100644 --- a/Justfile +++ b/Justfile @@ -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: @@ -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 }} diff --git a/planning/.convention-version b/planning/.convention-version index 227cea2..ccbccc3 100644 --- a/planning/.convention-version +++ b/planning/.convention-version @@ -1 +1 @@ -2.0.0 +2.2.0 diff --git a/planning/README.md b/planning/README.md index 74f9222..22adc94 100644 --- a/planning/README.md +++ b/planning/README.md @@ -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 diff --git a/planning/changes/2026-07-10.01-arq-di-integration.md b/planning/changes/2026-07-10.01-arq-di-integration.md index e70cabe..1abd2a0 100644 --- a/planning/changes/2026-07-10.01-arq-di-integration.md +++ b/planning/changes/2026-07-10.01-arq-di-integration.md @@ -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`, @@ -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 diff --git a/planning/decisions/.gitkeep b/planning/decisions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/planning/links.py b/planning/links.py new file mode 100644 index 0000000..c11f861 --- /dev/null +++ b/planning/links.py @@ -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())