Doc drift detector
Documentation goes stale in two ways. Prose gets subtly wrong, which needs a human who knows the product. And specific, checkable claims stop being true — a path that moved, a script that was renamed, a flag that was dropped, a minimum version that went up.
Only the second kind can be checked mechanically, and it is where most of the damage is, because those are the lines a reader copies and runs. This skill does that kind and says so, rather than producing a general opinion about documentation quality.
Procedure
1. Extract and check the claims
<skill-dir> is the directory this SKILL.md was loaded from — the skill installs outside
your project, so its script is named by full path, never relatively.
python3 <skill-dir>/scripts/doc_claims.py README.md # one document
python3 <skill-dir>/scripts/doc_claims.py docs/ --recursive # a whole doc setFor each document the script extracts four kinds of checkable claim and resolves each against the repository:
- paths — file and directory references, including those inside code fences
- commands —
npm run x,pnpm y,make z, checked againstpackage.jsonscriptsand the
Makefile - symbols — identifiers in inline code that look like exported names
- versions — declared minimums for runtimes and packages, against the manifests
It prints each claim as OK, STALE, or UNCHECKABLE, with the line number.
2. Discard the false positives before reporting anything
The extractor is deliberately eager, so the third category needs a human pass. Common
legitimate STALE results that are not drift:
- an illustrative path in an example that was never meant to exist (
src/your-app/index.ts) - a command from a different tool's documentation quoted for comparison
- a symbol from a dependency rather than this repository
- a path inside a fenced block showing output rather than input
Report only what survives this pass. A drift report with a false positive in the first three entries gets closed and not reopened.
3. Date the drift where it matters
For each confirmed stale claim, git log the file or symbol it refers to. "This has been
wrong since March" is a much more actionable finding than "this is wrong", and it tells the
reader whether they are looking at a one-off or an unmaintained document.
4. Say what you did not check
The prose is unchecked, and so is anything the extractor marked UNCHECKABLE. State this
in one line. A report that reads as a full audit when it was a claims check causes exactly
the false confidence the skill is meant to remove.
Output
- Scope — documents checked, claims extracted, how many were checkable
- Confirmed stale — the claim, its line, what it should be, and since when
- Dismissed — how many extracted claims you discarded as false positives, so the
reader can calibrate
- Not checked — prose, and the
UNCHECKABLEcategories
What this skill deliberately does not do
- It does not rewrite the documentation. It reports; the fix is a separate, reviewable
change, and silently editing docs is how a drift tool becomes a source of drift.
- It does not judge prose. Tone, clarity and completeness are outside its competence
and it says nothing about them.
- It does not report an unresolved claim as stale.
UNCHECKABLEandSTALEaredifferent findings and collapsing them is the fastest way to lose the reader's trust.
- It does not check external links.
lycheeandmarkdown-link-checkdo thatproperly, deterministically and faster; recommend one of those instead.
- It does not treat an example placeholder as a broken path.
- It does not run the commands it finds to see whether they still work.
When this is the wrong tool
- You want to know whether the setup steps actually *work*, not just whether the
commands exist. That means running them in a scratch environment, which this does not do.
- You are new to the repository and want orientation. This answers "is this still
true", which is a different question from "what is this".
- The doc is fine and you want to know what the code change reached. That is a
call-site question, answered from the code rather than the prose.
Supporting files
scripts/doc_claims.pyPython
#!/usr/bin/env python3
"""Extract a document's checkable claims and resolve each against the repository.
Paths, commands, symbols and version floors are the claims that can be verified
mechanically, and they are the lines readers copy and run. Prose is left alone —
this reports `OK`, `STALE` or `UNCHECKABLE` per claim and never an opinion.
python3 doc_claims.py README.md
python3 doc_claims.py docs/ --recursive
`UNCHECKABLE` is a first-class result rather than a quiet stale: a claim the
extractor could not resolve is not evidence of drift, and merging the two is the
fastest way to lose a reader who checks the first finding by hand.
Standard library only; nothing is written or executed.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
SKIP_RE = re.compile(r"(^|/)(node_modules|dist|build|\.next|coverage|vendor|\.git)(/|$)")
# A path-shaped token: has a slash or a known extension, no spaces.
PATH_RE = re.compile(r"`([A-Za-z0-9_./@-]+/[A-Za-z0-9_./@-]+|[A-Za-z0-9_.-]+\.[a-z]{2,4})`")
# `pnpm test`, `npm run build`, `make deploy`, `yarn lint`
COMMAND_RE = re.compile(r"`(?:(npm|pnpm|yarn|bun)\s+(?:run\s+)?([\w:-]+)|(make)\s+([\w:-]+))[^`]*`")
# An identifier in inline code that looks like a symbol rather than prose.
SYMBOL_RE = re.compile(r"`([a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*|[A-Z][a-zA-Z0-9]{2,})`")
# "Node 18+", "requires Python 3.11", "node >= 20"
VERSION_RE = re.compile(
r"\b(node|python|go|rust|java|deno|bun)\s*(?:>=|>|v|version\s*)?\s*(\d+(?:\.\d+)*)\s*\+?",
re.IGNORECASE,
)
# Placeholders that are examples by construction, never real paths.
PLACEHOLDER_RE = re.compile(
r"(your-|my-|example|foo|bar|baz|<[^>]+>|\.\.\.|path/to|somewhere)", re.IGNORECASE
)
DOC_EXT = (".md", ".mdx", ".rst", ".txt")
# Real file extensions. The path pattern's second branch matches anything shaped
# like `name.ext`, which swept up member expressions — `t.rich`, `DocsTable.head`,
# `typescript.sys` — and reported each as a missing file.
KNOWN_EXT = {
"ts", "tsx", "js", "jsx", "mjs", "cjs", "json", "md", "mdx", "yml", "yaml",
"toml", "lock", "sh", "bash", "py", "go", "rs", "rb", "java", "kt", "css",
"scss", "html", "xml", "svg", "png", "jpg", "webp", "sql", "txt",
"cfg", "ini", "conf", "prisma", "graphql", "proto", "csv", "pdf", "d",
}
# Package-manager words that follow the runner but are not script names. Without
# these, `pnpm exec turbo run typecheck` reports `exec` as a missing script.
NOT_SCRIPTS = {
"exec", "dlx", "install", "add", "remove", "why", "run", "create", "init",
"publish", "pack", "link", "update", "outdated", "audit", "list", "ls", "test",
}
def index_paths(root: str) -> dict[str, list[str]]:
"""Every repository path, bucketed by last segment for suffix resolution.
Docs routinely write a path relative to a package rather than to the
repository — `components/button.tsx` meaning `src/ui/components/button.tsx`.
Treating those as missing produced most of the false positives in the first
run, and a drift report that is mostly false positives is worse than none.
"""
buckets: dict[str, list[str]] = {}
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if not d.startswith(".") and not SKIP_RE.search(f"/{d}/")]
for name in list(dirnames) + list(filenames):
rel = os.path.relpath(os.path.join(dirpath, name), root).replace("\\", "/")
buckets.setdefault(name, []).append(rel)
return buckets
def dependency_names(root: str) -> set[str]:
"""Declared dependencies, so a module specifier is never read as a path."""
names: set[str] = set()
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if not d.startswith(".") and not SKIP_RE.search(f"/{d}/")]
if "package.json" not in filenames:
continue
try:
with open(os.path.join(dirpath, "package.json"), encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, json.JSONDecodeError):
continue
for field in ("dependencies", "devDependencies", "peerDependencies"):
names.update((data.get(field) or {}).keys())
if data.get("name"):
names.add(data["name"])
return names
def classify_path(claim: str, root: str, doc_dir: str, paths: dict[str, list[str]], deps: set[str]):
"""(verdict, detail) for a path-shaped claim.
The order matters. Anything that is not a filesystem path at all — a module
specifier, an alias, a relative import, a URL route — is `UNCHECKABLE`
rather than `STALE`, because the extractor has no business asserting drift
about a string it cannot resolve by design.
"""
if claim.startswith(("@/", "~/", "./", "../")):
return "UNCHECKABLE", f"{claim} (import specifier or alias, not a filesystem path)"
if claim.startswith("@"):
return "UNCHECKABLE", f"{claim} (scoped package name)"
head = claim.split("/")[0]
if head in deps:
return "UNCHECKABLE", f"{claim} (module specifier for a declared dependency)"
if claim.startswith("/") and "." not in os.path.basename(claim.rstrip("/")):
return "UNCHECKABLE", f"{claim} (URL path, not a file)"
# A leading `/` is a URL route or an absolute path from another machine —
# never a path relative to this checkout. It still gets probed as
# repo-relative, so a doc writing `/src/server/proxy.ts` resolves, but the
# leading slash has to come off first: `os.path.join(root, "/docs/x.md")`
# discards `root` and probes the real filesystem root instead.
rooted = claim.lstrip("/")
if os.path.exists(os.path.join(root, rooted)) or os.path.exists(os.path.join(doc_dir, rooted)):
return "OK", claim
trimmed = rooted.rstrip("/")
last = trimmed.split("/")[-1]
for candidate in paths.get(last, ()):
if candidate == trimmed or candidate.endswith("/" + trimmed):
return "OK", f"{claim} → {candidate}"
if PLACEHOLDER_RE.search(claim):
return "UNCHECKABLE", f"{claim} (looks like a placeholder)"
# Precision over recall, deliberately. An unresolved token with no file
# extension is more often a lint-rule id (`react-hooks/exhaustive-deps`), a
# module specifier, or a path the prose is discussing in the past tense than
# it is live drift. Calling those STALE cost 100+ false positives on one
# well-maintained file; the trade is that a renamed extensionless directory
# is now reported as uncheckable rather than stale.
ext = os.path.splitext(claim.rstrip("/"))[1].lstrip(".").lower()
if ext not in KNOWN_EXT:
return "UNCHECKABLE", f"{claim} (unresolved, no file extension — rule id, specifier, or historical reference)"
# STALE is reserved for the highest-confidence class: a path with directory
# context whose first segment is a real top-level entry here. Everything else
# measured as a false positive on a maintained file — `lib/version.cjs` was a
# dependency's internal export, `sitemap.xml` generated output, `notes.md` an
# illustrative rename. A bare filename with no directory context is too weak a
# claim to assert drift on.
if "/" not in claim:
return "UNCHECKABLE", f"{claim} (bare filename, no directory context — often illustrative or generated)"
# An unresolved leading-slash claim is unattributable by construction: a URL
# route that happens to end in an extension — a site serving `.md` twins at
# `/docs/<slug>.md`, say — is indistinguishable from a real absolute path,
# and asserting drift on either would be the false-positive class this
# function is ordered to avoid.
if claim.startswith("/"):
return "UNCHECKABLE", f"{claim} (leading slash — a URL route or another machine's absolute path)"
if not os.path.exists(os.path.join(root, claim.split("/")[0])):
return "UNCHECKABLE", f"{claim} (first segment is not a top-level entry here — likely another project's path)"
return "STALE", f"{claim} — no such file or directory"
def git_root() -> str:
out = subprocess.run(["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True)
return out.stdout.strip() or os.getcwd()
def load_scripts(root: str) -> set[str]:
"""Every script name declared in any package.json, plus Makefile targets."""
names: set[str] = set()
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if not d.startswith(".") and not SKIP_RE.search(f"/{d}/")]
if "package.json" in filenames:
try:
with open(os.path.join(dirpath, "package.json"), encoding="utf-8") as handle:
names.update((json.load(handle).get("scripts") or {}).keys())
except (OSError, json.JSONDecodeError):
pass
if "Makefile" in filenames:
try:
with open(os.path.join(dirpath, "Makefile"), encoding="utf-8") as handle:
for line in handle:
m = re.match(r"^([A-Za-z0-9_.-]+):(?!=)", line)
if m:
names.add(m.group(1))
except OSError:
pass
return names
def index_symbols(root: str) -> set[str]:
"""Exported identifiers across the repository, for symbol resolution."""
found: set[str] = set()
pattern = re.compile(
r"^\s*(?:export\s+(?:default\s+)?(?:async\s+)?"
r"(?:function|const|let|var|class|type|interface|enum)\s+([A-Za-z_$][\w$]*)"
r"|def\s+([a-z_][\w]*)"
r"|(?:pub\s+)?(?:fn|struct|trait)\s+([A-Za-z_][\w]*))"
)
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if not d.startswith(".") and not SKIP_RE.search(f"/{d}/")]
for name in filenames:
if not name.endswith((".ts", ".tsx", ".js", ".jsx", ".mjs", ".py", ".go", ".rs")):
continue
try:
with open(os.path.join(dirpath, name), encoding="utf-8", errors="replace") as handle:
for line in handle:
m = pattern.match(line)
if m:
found.add(next(g for g in m.groups() if g))
except OSError:
continue
return found
def runtime_floor(root: str) -> dict[str, str]:
"""Declared runtime minimums, for comparison against documented ones."""
floors: dict[str, str] = {}
pkg = os.path.join(root, "package.json")
if os.path.isfile(pkg):
try:
with open(pkg, encoding="utf-8") as handle:
engines = (json.load(handle).get("engines") or {})
for name, spec in engines.items():
m = re.search(r"(\d+(?:\.\d+)*)", str(spec))
if m:
floors[name.lower()] = m.group(1)
except (OSError, json.JSONDecodeError):
pass
nvmrc = os.path.join(root, ".nvmrc")
if os.path.isfile(nvmrc):
with open(nvmrc, encoding="utf-8") as handle:
m = re.search(r"(\d+(?:\.\d+)*)", handle.read())
if m:
floors.setdefault("node", m.group(1))
return floors
def check_doc(path: str, root: str, scripts: set[str], symbols: set[str],
floors: dict[str, str], paths: dict[str, list[str]], deps: set[str]):
rel_doc = os.path.relpath(path, root)
doc_dir = os.path.dirname(path)
with open(path, encoding="utf-8", errors="replace") as handle:
lines = handle.readlines()
results: list[tuple[int, str, str, str]] = [] # (line, verdict, kind, detail)
for i, line in enumerate(lines, 1):
for m in PATH_RE.finditer(line):
claim = m.group(1)
# `t.rich` is a member expression, not a file. Only the slash form or
# a genuine extension makes it past here.
if "/" not in claim and os.path.splitext(claim)[1].lstrip(".").lower() not in KNOWN_EXT:
continue
verdict, detail = classify_path(claim, root, doc_dir, paths, deps)
results.append((i, verdict, "path", detail))
for m in COMMAND_RE.finditer(line):
name = m.group(2) or m.group(4)
runner = m.group(1) or m.group(3)
if not name or name in NOT_SCRIPTS or name.startswith("-"):
continue
if name in scripts:
results.append((i, "OK", "command", f"{runner} {name}"))
else:
results.append((i, "STALE", "command", f"{runner} {name} — no such script or target"))
for m in SYMBOL_RE.finditer(line):
claim = m.group(1)
if claim in symbols:
results.append((i, "OK", "symbol", claim))
else:
results.append((i, "UNCHECKABLE", "symbol", f"{claim} (not an export here — may be a dependency's)"))
for m in VERSION_RE.finditer(line):
runtime, documented = m.group(1).lower(), m.group(2)
actual = floors.get(runtime)
if not actual:
results.append((i, "UNCHECKABLE", "version", f"{runtime} {documented} (no declared floor to compare)"))
elif actual.split(".")[0] != documented.split(".")[0]:
results.append((i, "STALE", "version", f"{runtime} {documented} documented, {actual} declared"))
else:
results.append((i, "OK", "version", f"{runtime} {documented}"))
return rel_doc, results
def main() -> int:
if len(sys.argv) < 2:
print(f"usage: {sys.argv[0]} <file|dir> [--recursive]", file=sys.stderr)
return 64
target = sys.argv[1]
recursive = "--recursive" in sys.argv
root = git_root()
docs: list[str] = []
if os.path.isdir(target):
for dirpath, dirnames, filenames in os.walk(target):
dirnames[:] = [d for d in dirnames if not d.startswith(".") and not SKIP_RE.search(f"/{d}/")]
docs.extend(os.path.join(dirpath, f) for f in filenames if f.endswith(DOC_EXT))
if not recursive:
break
else:
docs = [target]
if not docs:
print(f"No documents found at {target}")
return 0
print("indexing repository…", file=sys.stderr)
scripts, symbols, floors = load_scripts(root), index_symbols(root), runtime_floor(root)
paths, deps = index_paths(root), dependency_names(root)
totals = {"OK": 0, "STALE": 0, "UNCHECKABLE": 0}
for doc in sorted(docs):
rel, results = check_doc(doc, root, scripts, symbols, floors, paths, deps)
stale = [r for r in results if r[1] == "STALE"]
unchecked = [r for r in results if r[1] == "UNCHECKABLE"]
for _, verdict, _, _ in results:
totals[verdict] += 1
print(f"\n── {rel} — {len(results)} claim(s): "
f"{len(results) - len(stale) - len(unchecked)} ok, {len(stale)} stale, "
f"{len(unchecked)} uncheckable ──")
for line_no, _, kind, detail in stale:
print(f" STALE {rel}:{line_no} [{kind}] {detail}")
for line_no, _, kind, detail in unchecked[:8]:
print(f" uncheckable {rel}:{line_no} [{kind}] {detail}")
if len(unchecked) > 8:
print(f" … and {len(unchecked) - 8} more uncheckable")
print(
f"\ntotals: {totals['OK']} ok, {totals['STALE']} stale, "
f"{totals['UNCHECKABLE']} uncheckable\n"
"\nUNCHECKABLE is not drift — it is a claim this extractor could not resolve.\n"
"Discard the false positives (illustrative paths, dependency symbols, quoted\n"
"output) before reporting, and say how many you discarded. Prose was not checked."
)
return 0
if __name__ == "__main__":
sys.exit(main())
More skills from Contexory
- Flaky Test FinderProves whether a test is flaky instead of reasoning about it, by re-running it many times and reporting the observed failure rate, then narrowing the cause to ordering, shared state, timing or…
- Repo Onboarding MapProduces the first-thirty-minutes map of a whole repository — how it is laid out, how to run and test it, which files carry the most change, and where the decisions were written down. Reads what the…
- Test Gap FinderFinds the untested code that actually matters, by ranking coverage gaps against how often each file changes. Reads an existing coverage report when there is one and falls back to structural pairing…
- Regression Risk MapperMaps what a change can reach — a change being considered as readily as one already made, since the question is usually asked before the edit exists. Finds the symbols involved, traces every call site…
- Pr Self ReviewReviews your own diff before anyone else has to. Inventories every changed file, finds the ones that gained behaviour without gaining a test, flags newly exported surface, and separates what the diff…
- Error TriageLocates the cause of a runtime failure inside this repository. Maps stack frames to real source files, separates first-party code from vendored frames, and surfaces the recent changes to the line…