Skip to content

Nothing checks the repo paths and self-links named in Python source #84

Closed

Description

@lesnik512

Follow-up to #82, which made the offline gate check absolute self-links in Markdown.

The gap #82 left

#82's own motivating example is still unchecked. Its text says:

a URL in a docstring rendered on PyPI has no checkout to be relative to, which is precisely how the faststream-concurrent-aiokafka instance arose. So the gate still needs to cover what remains.

The --remap fix covers what remains in Markdown. The gate's input glob is unchanged at '**/*.md', so nothing in .py is scanned — including the instance that motivated the issue.

Widening the glob would not reach it either. The URL is split across two adjacent string literals:

"https://github.com/modern-python/faststream-concurrent-aiokafka/blob/main/docs/adr/"
"0003-control-signals-not-honoured.md."

A text-level checker sees a directory that exists and a filename fragment that is not a link, so it would report green while the interesting half goes unverified. This is the one that is user-visible on the package page.

It is wider than that one URL

Every repo's AGENTS.md already states the limitation as a known fact:

A relative link to an ADR is checked — CI runs lychee --offline over every .md — but a path named in a docstring or a comment is not.

Measured across all non-archived repos at HEAD, by extracting docs/adr/NNNN-*.md from tracked *.py and testing each against the working tree:

Repo References
compose2pod 5
db-retry 1
faststream-concurrent-aiokafka 1
modern-di 1
modern-di-arq 1
modern-di-starlette 1
modern-di-typer 1
.github 2

13 ADR paths named in Python source across 8 repos, all resolving today, none checked by anything. Plus the one absolute URL above. Nothing is broken right now — which is exactly the position #82 described before the planning/ migration broke seven links at once.

These are load-bearing: INVARIANT: docstrings cite the ADR that explains why the invariant exists, so a renamed or pruned ADR silently strips the rationale from the test that depends on it.

Options, none costed yet

  1. Widen the glob to '**/*.py'. Cheapest, and lychee extracts bare URLs from plain text. Catches nothing today: the only .py URL is the split one, and bare docs/adr/... paths in prose are not links lychee resolves. Close to vacuous on its own.
  2. Join the split literal, then widen the glob. Makes the PyPI-visible URL checkable by (1). Small, targeted, and fixes the specific instance The offline link gate never checks absolute self-links #82 cared about.
  3. A test per repo asserting cited ADR paths exist. Covers all 13 and the URL, in the repos' own idiom (semvertag/tests/_descriptor_gate.py is the precedent for a config-shape test). Cost: a test copied into 8 repos, which is the machinery The offline link gate never checks absolute self-links #82 avoided needing.
  4. Accept and document. The AGENTS.md sentence already says it; make that the settled answer and close this.

(1) and (2) compose. (3) is the only one that covers the bare-path references.

Scope

The 8 repos above, or fewer depending on which option wins. Deciding the option is the work here; the edit is small either way.

Activity

  1. lesnik512 commented on Sep 7, 2026

    @lesnik512
    MemberAuthor

    This was generated by AI during triage.

    Agent Brief

    Category: enhancement
    Summary: Add a per-repo test asserting every ADR path cited in Python source resolves, in all 26 non-template repos

    Decision: option (3), a test per repo. Options (1), (2) and (4) are rejected below with measurements.


    Why (1) and (2) lost

    Both were measured against lychee 0.24.2, the version lychee-action@v2 pins, with the remap block exactly as it shipped in #82.

    Widening the glob to '**/*.py' is not "close to vacuous". It is exactly vacuous.
    Across all 26 repos carrying the gate, widening the glob changes the checked-link count by +1, in one repo. lychee --dump over every Python file in faststream-concurrent-aiokafka extracts exactly one link:

    file:///…/docs/adr/
    

    The directory. Renaming the ADR out from under it leaves the gate green. Bare docs/adr/… paths in prose are not extracted at all: a synthetic file citing docs/adr/9999-does-not-exist.md produces zero errors.

    Widening the glob turns db-retry red today, and no flag fixes it.
    Nine errors, from PostgreSQL DSN strings in its source and tests:

    Error building URL for "postgresql://login:password@/db_placeholder?host=host1&host=host2"
      → Cannot parse … empty host
    

    The failure happens at URL construction, before any filtering, so it cannot be suppressed: --scheme https, --exclude 'postgresql', --exclude '^postgresql' and --exclude 'postgresql.*' each still report 9 errors. The only escape is --exclude-path naming those files, which puts a repo-specific parameter back into the gate block and revives exactly the drift argument #82's resolution dissolved. Option (2) inherits this, since it needs the same widened glob.

    Option (2)'s source edit is unnecessary. Python concatenates adjacent string literals at parse time, so an ast walk already sees the joined URL that no text-level tool can:

    from ast Constants: ['docs/adr/0003-control-signals-not-honoured.md']
    from raw text     : []
    

    Option (2)'s only unique contribution is therefore free under (3), and its coverage is otherwise a strict subset.

    Why not (4). The AGENTS.md sentence option (4) rests on no longer exists: it was removed from every repo in the #85 sweep, so there is now no written record of the gap at all. Even while it stood, it documented the gap without holding it closed. These citations are load-bearing: an INVARIANT: docstring names the ADR carrying the rationale, so a renamed or pruned ADR silently strips the reason from the test that depends on it, with nothing going red.


    Current behavior

    Nothing verifies a docs/adr/NNNN-<slug>.md path named in Python source. The offline link gate reads Markdown only, and its --remap covers absolute self-links in Markdown.

    Measured across every non-archived repo at HEAD: 14 citation sites in Python across 8 repos (compose2pod 5, .github 2, faststream-concurrent-aiokafka 2, and one each in db-retry, modern-di, modern-di-arq, modern-di-starlette, modern-di-typer). All 14 resolve today. None is checked by anything.

    Desired behavior

    Each in-scope repo carries a test that fails when a docs/adr/NNNN-<slug>.md path cited anywhere in its own tracked Python source does not resolve against the repo root. The failure message names every unresolved path together with the file citing it, so one run reports the whole blast radius of a rename rather than the first casualty.

    A repo with no citations, or with no docs/adr/ directory, passes. That is the normal state for most repos and must not be an error or a skip.

    Key interfaces

    Citation pattern. docs/adr/ followed by four digits, a hyphen, a lowercase-and-hyphen slug, and .md. Repo-root-relative; resolution is a filesystem check against the repo root, never a network call.

    Two extraction sources, unioned. Neither alone is sufficient, and both gaps are live today:

    • AST string constants. Catches URLs and paths split across adjacent string literals, which the parser joins. This is the only way to reach the nack guard message in faststream-concurrent-aiokafka, where the directory and the filename sit in separate literals: the instance The offline link gate never checks absolute self-links #82 cited and left uncovered.
    • Raw file text. Catches citations in # comments, which the AST discards. compose2pod has one.

    Taking either source alone drops one real citation. The union is the contract.

    Input set. The repo's own tracked Python files. Vendored, generated, and virtualenv trees are not the repo's citations and must not be scanned.

    Self-verification. The scanner must be exercised against a fixture whose expected result is known, including at least one deliberately unresolvable citation, so a regex that silently stops matching fails the suite rather than passing empty. The idiom to follow is semvertag's descriptor gate: a validator module shared between the CI step and its own regression tests, precisely so the gate and its tests cannot drift.

    Test shape. Per AGENTS.md: the test name is the claim, the docstring opens INVARIANT:, and a second paragraph names what breaks it: the design rationale, not a report of what this test catches.

    Acceptance criteria

    • Every in-scope repo has a test that fails when an ADR path cited in its tracked Python source does not resolve
    • The test passes on every in-scope repo at HEAD (all 14 current citations resolve, so no repo goes red on adoption)
    • Deleting or renaming any ADR that is cited from Python turns its repo's suite red
    • A citation split across adjacent string literals is caught, verified against the faststream-concurrent-aiokafka nack guard message, which a text-level scan cannot see
    • A citation inside a # comment is caught, verified against the compose2pod instance, which an AST-only scan cannot see
    • The failure message names every unresolved path and the file citing it, not just the first
    • Repos with no citations pass, and repos with no docs/adr/ directory pass; neither reports a skip
    • A fixture with a known-unresolvable citation fails the scanner, so an empty scan cannot masquerade as a green one
    • Each repo's 100%-coverage guard stays green
    • Each repo's existing lint and type-check recipes stay green, including the mypy and pyrefly additions in that-depends

    Scope

    In scope: 26 repos. Every non-archived repo in the org except fastapi-sqlalchemy-template and litestar-sqlalchemy-template.

    The templates are excluded following #66, which kept the link gate out of them: a template's test suite is copied into downstream user projects, where an org-internal ADR-citation check is noise a user has to delete. Neither template has a docs/adr/ directory, so nothing is lost.

    that-depends has no docs/adr/ directory either and is still in scope. So is every repo with zero citations. The test is deliberately inert where there is nothing to check. A citation added tomorrow is covered from the moment it lands, which is the whole point of a rename guard.

    Out of scope

    • Widening the lychee glob beyond '**/*.md'. Measured above: no coverage gained, db-retry broken, no flag to fix it.
    • Joining the split string literal in the faststream-concurrent-aiokafka nack guard. The AST reads it as it stands.
    • Adding --exclude-path, --scheme or --exclude to any repo's gate block. The block stays byte-identical across repos; The offline link gate never checks absolute self-links #82's resolution depends on it holding no parameter to copy wrong.
    • Checking any citation other than ADR paths. Not module paths, not issue references, not URLs to other repos' trees.
    • Rewriting existing citations. Every one resolves; this change adds a guard, it does not migrate anything.
    • A shared package or reusable workflow to hold the scanner. Copying it, as Add the offline link gate to the 18 green repos, and unpublish the ADRs in the two that have them #66 copied the gate, is the settled idiom.

    One correction to the record

    Both #82 and this issue describe the faststream-concurrent-aiokafka URL as user-visible on the package page. It is not. It lives in a runtime guard-message dict and surfaces in a traceback when a user calls message.nack() under the middleware. What appears on the PyPI page is README.md, which cites the same ADR relatively and is therefore already covered by the existing gate.

    The URL is still load-bearing, since a user who trips the guard gets a link they are meant to follow, so it belongs in the covered set either way. But the "package page" framing overstated the urgency in a way that argued for option (2), and option (2) is the one the measurements rule out.

  2. lesnik512 commented on Sep 7, 2026

    @lesnik512
    MemberAuthor

    This was generated by AI during triage.

    The AGENTS.md sentence this issue quotes no longer exists

    #85's sweep removed it. The longer form quoted in the body:

    A relative link to an ADR is checked, CI runs lychee --offline over every .md, but a path
    named in a docstring or a comment is not.

    existed in exactly two files, modern-di-typer/AGENTS.md and .github/AGENTS.md, and both are
    gone. The shorter Nothing enforces that docstring shape; it is read at review time. was removed
    from 21 further repos.

    The outcome does not change. Option (3) won on measurement, not on that sentence. But two things
    here now cite a file that no longer says what they quote:

    • The body's "Every repo's AGENTS.md already states the limitation as a known fact" is no longer
      true. I have not edited the body.
    • Option (4) has lost its basis entirely. It read "the AGENTS.md sentence already says it;
      make that the settled answer and close this." There is now no written record of the gap anywhere
      in the org.

    The agent brief above has been edited to say so.

    The gap is now undocumented as well as unchecked

    Before the sweep, an agent reading AGENTS.md at least learned that a path named in a docstring is
    not checked. That sentence is gone, and no test replaced it. This issue is the only remaining record
    that the gap exists, which argues for doing the work rather than deferring it again.

    A live instance occurred during #85's work

    Renumbering 0004-flask-horn-historical-artwork-grant.md to 0003 in .github required updating a
    citation inside brand/build/symbols.py, a bare backticked path in a docstring. Nothing in CI would
    have caught it had it been missed. Its sibling citation in AGENTS.md was a real Markdown link, and
    the gate would have failed on that one.

    Same rename, same ADR, two citations, one checked and one not. That is this issue's thesis met in the
    course of a routine change, rather than argued from a hypothetical.

  3. lesnik512 commented on Sep 15, 2026

    @lesnik512
    MemberAuthor

    This was generated by AI during triage.

    Landed in all 26 in-scope repos as tests/test_adr_citations.py, byte-identical everywhere, one PR per repo. Verified against the brief before merging: AST constants and raw text are unioned, the split-literal and hash-comment cases each have a test, a fixture with a known-missing ADR fails the scanner, repos without docs/adr/ pass without a skip, and the failure message lists every unresolved citation with its file. Every PR was green at merge time.

    Two repos needed an unrelated lint fix first, because the install recipe runs uv lock --upgrade and CI resolved ty 0.0.81: faststream-outbox#177 (two unused ty: ignore comments) and modern-di#491 (a slotted-attribute assignment ty now checks statically). Same drift as faststream-redis-timers#77 earlier today.

    Merged: .github#103, chat-app#31, compose2pod#99, db-retry#47, eof-fixer#43, faststream-concurrent-aiokafka#73, faststream-outbox#176, faststream-redis-timers#78, httpware#127, lite-bootstrap#219, modern-di#489, modern-di-aiogram#23, modern-di-aiohttp#27, modern-di-arq#21, modern-di-celery#22, modern-di-fastapi#50, modern-di-faststream#51, modern-di-flask#22, modern-di-grpc#20, modern-di-litestar#52, modern-di-pytest#49, modern-di-starlette#27, modern-di-taskiq#20, modern-di-typer#46, semvertag#71, that-depends#247.

  4. lesnik512 commented on Sep 15, 2026

    @lesnik512
    MemberAuthor

    Option (3) is implemented: one test module, tests/test_adr_citations.py, byte-identical in all 26 in-scope repos. Merged in modern-python/faststream-concurrent-aiokafka#73; the other 25 are open:

    compose2pod#99, db-retry#47, eof-fixer#43, faststream-outbox#176, faststream-redis-timers#78, httpware#127, lite-bootstrap#219, modern-di#489, modern-di-aiogram#23, modern-di-aiohttp#27, modern-di-arq#21, modern-di-celery#22, modern-di-fastapi#50, modern-di-faststream#51, modern-di-flask#22, modern-di-grpc#20, modern-di-litestar#52, modern-di-pytest#49, modern-di-starlette#27, modern-di-taskiq#20, modern-di-typer#46, semvertag#71, that-depends#247, chat-app#31, .github#103.

    Three corrections to the brief, all measured at origin/main

    • 13 citation sites in 7 repos, not 14 in 8. modern-di has no docs/adr/ path in any tracked Python file. The other seven match the brief's counts.
    • The slug pattern must allow digits. Three real ADR names contain them: 0004-4xx-does-not-trip-the-circuit-breaker.md, 0004-keep-py310-vendored-assert-never.md, 0013-httpx2-is-the-public-surface.md. A "lowercase-and-hyphen" regex would skip citations of those silently. The module uses docs/adr/\d{4}-[a-z0-9-]+\.md.
    • The input set is a directory walk, not git ls-files. Five repos run just test inside a python-slim container with no git binary. A walk from the repo root that prunes dot-directories and node_modules yields exactly the tracked Python set in all 28 org repos today, so the walk is the input set. It also avoids a subprocess call under select = ["ALL"].

    What was verified before opening the PRs

    • In each of the seven citing repos, git mv of a cited ADR turns the invariant test red naming the citing file and the path; restoring goes green. The faststream-concurrent-aiokafka proof names middleware.py, the split-literal instance this issue was opened on.
    • Each repo's own lint recipe is clean on the new file. faststream-redis-timers fails ty check on origin/main already, with two unused ty: ignore comments in router.py; that is unrelated and left for its own fix.
    • Full test-ci is green locally with the 100% floor intact in the 20 repos whose suite runs without Docker. The four docker-backed suites and modern-di-aiogram run in CI.
    • The scanner is exercised against tmp_path fixtures with known results, including a missing ADR, a split literal, a # comment, a tree with nothing to check, and a dot-directory that must not be walked. Fixture paths are built through f-strings so the scanner cannot match its own test file.

    One deviation from the brief's "validator module shared between the CI step and its tests" idiom: no CI step other than pytest consumes this scanner, so it lives at the top of the test module rather than in a sibling _module.py. Both forms are measured by --cov=.; the single file has one fewer thing to keep in step.

    All 25 open PRs are green in CI at the time of writing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestready-for-agentFully specified, ready for an AFK agent

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions