docrot
Deterministic doc↔code drift checker for Python repositories.
docrot finds references in your prose documentation — READMEs, docs/,
contributor guides, and agent context files like CLAUDE.md/AGENTS.md —
that no longer resolve against your codebase: symbols that were removed or
renamed, file paths that moved, make/tox/npm targets that are gone,
and APIs that were documented but never shipped.
No LLM. No side-store. Every finding is backed by static analysis and git history, and comes with provenance: the commit where the claim was written, and the commit where it broke.
$ docrot
docs/advanced/transports.md:178:53 PY003 error `httpx.Mounts` is documented but has never existed
written at 3faa4a8 (2024-02-14)
docrot: 187 references | 82 resolved | 85 unknown (skipped) | 1 findings (1 error)
That output is real: on its first run against httpx
(~14k stars), docrot found that the official transports documentation shows a
complete usage example for httpx.Mounts — a class that has never existed in
any commit of the codebase. On requests and flask, the same run produced
zero findings — not because docrot checked less, but because their docs
are actually in sync.
Why
A 2023 Empirical Software Engineering study
(DOCER) measured that 28.9% of the
top-1000 GitHub projects currently document a code element that no longer
exists, and 82.3% have been in that state at some point. Since then the
problem got teeth: AI coding agents read AGENTS.md/CLAUDE.md as ground
truth on every session, so one stale path silently misdirects every run.
Adjacent tools deliberately stop short of this: doc example runners (sybil, xdoctest, pytest-examples) check fenced code blocks; docstring linters (docvet, pydoclint) check docstrings against signatures; link checkers (lychee) check URLs; AGENTS.md linters check file structure. None of them validate what your prose claims about your code.
How it stays quiet: the two-gate design
False positives are what killed every previous attempt at this, so docrot only reports when two independent gates agree:
Gate 1 — resolution (static analysis). References are resolved against a
griffe-backed index of your
package that understands star-import re-exports, alias chains
(requests.Response → requests.models.Response), class inheritance
(flask.Flask.route lives two base classes away), and instance attributes
assigned in __init__. Every reference gets a tri-state verdict — RESOLVED,
BROKEN, or UNKNOWN — and UNKNOWN never becomes a finding. External
packages, dynamic attributes, tutorial placeholders: silence, not noise.
Gate 2 — history (git). A broken reference is only drift if it once resolved. docrot blames the doc line to the commit where the claim was made, re-resolves the reference at that commit (by parsing file blobs — no checkouts), and only then decides:
| resolved when written? | resolves now? | verdict |
|---|---|---|
| yes | no | drift — reported, with the breaking commit bisected |
| no | no | fiction (a file the reader creates, pseudo-code) — silent |
| no, and it's your own package's namespace | no | documented API that never shipped — reported |
The result on these three: requests 0 findings, flask 0 findings, httpx 1 finding, a real bug (709 references checked in total, a few seconds per repository). The full corpus run is in BENCHMARKS.md.
Install & run
pip install docrot
docrot # zero config: checks tracked *.md, *.rst + agent files
docrot --format json # machine-readable, stable schema
docrot --show-unknown # audit what was skipped and why
docrot explain PY002 # rule documentation
Requires Python 3.10+ and git. Without git history docrot still runs, but it says so and drops every finding to medium confidence, because the drift-versus-fiction evidence is exactly what history provides.
Command reference
| Invocation | Effect |
|---|---|
docrot / docrot check |
check the whole repository (the default command) |
docrot check docs/ README.md |
restrict the scan to those paths |
docrot explain PY003 |
what a rule means and how to suppress it |
docrot anchors update |
recompute anchor hashes after reviewing prose |
| Flag | Effect |
|---|---|
--format text|json|sarif|github |
output format (default text) |
--rules core,agents,anchors |
enable only these rule packs |
--fail-on error|warning|info |
lowest severity that fails the run |
--strict |
medium-confidence findings also fail |
--no-temporal |
skip the git gate (faster, lower confidence) |
--show-unknown |
list every skipped reference and why |
--changed-only |
only docs changed against HEAD (pre-commit mode) |
--root PATH |
repository root (default: cwd) |
Rules
| Rule | Fires when |
|---|---|
PY001 |
Own-package symbol doesn't resolve (no git history to confirm) |
PY002 |
Symbol resolved when the doc line was written — and no longer does |
PY003 |
Own-package symbol is documented but never existed |
PATH001 |
Documented path existed and is now gone |
PATH002 |
File moved; docs still point at the old location |
LINK001 |
Relative markdown link target missing |
CALL001 |
Bare func() resolved when written, no longer resolves |
AG001 |
Documented command target is gone (make/tox/nox/poe/npm/just/entry points/python -m) |
AG002 |
Broken path in an agent context file (agents act on these) |
AN001/AN002 |
Opt-in anchors: code changed under anchored prose |
Suppress inline with <!-- docrot: ignore[PY002] -->; suppressions are
counted in the report, never silent.
CI
GitHub Action (findings become PR annotations, optional SARIF upload):
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history = full provenance
- uses: dager23/docrot@v0
pre-commit:
repos:
- repo: https://github.com/dager23/docrot
rev: v0.1.0
hooks:
- id: docrot # runs --changed-only for speed
Exit codes: 0 clean, 1 findings at/above fail_on severity, 2 usage
error. Medium-confidence findings (no git history) fail only under
--strict.
Configuration
Everything is optional. Use [tool.docrot] in pyproject.toml, or a
standalone docrot.toml (same keys, no table header, takes precedence):
[tool.docrot]
packages = ["mypkg"] # default: auto-detected
exclude_docs = ["docs/archive/**"] # changelogs are always excluded
severity = { PATH002 = "ignore" }
fail_on = "error" # error | warning | info
temporal = "auto" # auto | on | off
external_packages = ["werkzeug"] # opt-in cross-package resolution
ignore_refs = ["flask.request.*"] # glob-match references to skip
docs = ["**/*.md", "**/*.rst"] # what counts as a doc
rules = { enable = ["core", "agents"], disable = ["LINK001"] }
strict = false
Unknown keys are reported and ignored rather than fatal. Changelogs, migration guides and upgrade notes are always skipped: naming removed APIs is their entire purpose.
Anchors
For prose whose meaning depends on specific code (not just references), bind a region to a symbol's implementation:
<!-- docrot:anchor symbol=mypkg.auth.DigestAuth hash=sha256:9f2ab8... -->
Digest auth performs two requests: the first receives the challenge...
<!-- /docrot:anchor -->
The hash covers the AST-normalized body, so formatting and comment churn
never invalidate it — only structural change does. docrot anchors update
recomputes hashes after you review the prose. Anchors are strictly opt-in:
an absent anchor asserts nothing.
What docrot deliberately does not do
- No LLM calls, ever. Semantic truth of prose isn't deterministically checkable; references and anchored claims are. docrot never guesses.
- No docstring linting (see docvet, pydoclint), no example execution (see sybil, pytest-examples), no URL checking (see lychee). docrot is designed to sit alongside all of them.
Status
Alpha (0.x). Symbol resolution is Python-only; doc formats are Markdown,
reStructuredText, and agent context files. Multi-language resolution
(tree-sitter), CLI flag introspection, --fix rename suggestions, and an
MCP server are planned.
Measured against 25 mature open-source projects: 6,073 references checked, 10 findings, every one confirmed by hand as a real documentation defect, and 139 of 139 seeded scenarios correct. The numbers and the method are in BENCHMARKS.md.
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file docrot-0.1.0.tar.gz.
File metadata
- Download URL: docrot-0.1.0.tar.gz
- Upload date:
- Size: 68.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6574d002b59973c0a609f1d371e23e878bd4c6a3eb88e46650355311639eb6a1
|
|
| MD5 |
7e8cc4397164c581a02fdfe04ddf9a48
|
|
| BLAKE2b-256 |
14e965549ae1cd1982a1fe1bc20ccae00bdf62488bf6df717f4d63880bff9574
|
Provenance
The following attestation bundles were made for docrot-0.1.0.tar.gz:
Publisher:
ci.yml on dager23/docrot
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
docrot-0.1.0.tar.gz -
Subject digest:
6574d002b59973c0a609f1d371e23e878bd4c6a3eb88e46650355311639eb6a1 - Sigstore transparency entry: 2816204566
- Sigstore integration time:
-
Permalink:
dager23/docrot@7a93efc966dfb109f921c138c27271cf3cd7ad93 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/dager23
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@7a93efc966dfb109f921c138c27271cf3cd7ad93 -
Trigger Event:
push
-
Statement type:
File details
Details for the file docrot-0.1.0-py3-none-any.whl.
File metadata
- Download URL: docrot-0.1.0-py3-none-any.whl
- Upload date:
- Size: 49.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bcaef698e28ce4ba0e993ceca98b8da11c02f258e498a8b2c260f4fddbb2f2eb
|
|
| MD5 |
a2315189d446881078a5cb6f567c5493
|
|
| BLAKE2b-256 |
e357e31dd78213c09e6ab76934a59375c27d525b6da18fd9b2667198ee8d7145
|
Provenance
The following attestation bundles were made for docrot-0.1.0-py3-none-any.whl:
Publisher:
ci.yml on dager23/docrot
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
docrot-0.1.0-py3-none-any.whl -
Subject digest:
bcaef698e28ce4ba0e993ceca98b8da11c02f258e498a8b2c260f4fddbb2f2eb - Sigstore transparency entry: 2816204630
- Sigstore integration time:
-
Permalink:
dager23/docrot@7a93efc966dfb109f921c138c27271cf3cd7ad93 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/dager23
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@7a93efc966dfb109f921c138c27271cf3cd7ad93 -
Trigger Event:
push
-
Statement type: