Documentation Audit skill for Codex
Find the wrong docs before the broken links. Codex inventories a Markdown or MDX docs folder with a bundled offline script that finds broken links and anchors, orphans, untitled and stale pages from git history. It then goes past mechanics: running code samples, diffing documented flags and API fields against the real tool or OpenAPI spec, and mining support tickets for questions no page answers, ending in a P0 to P3 fix list with evidence.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- documentation-audit Installs to ~/.agents/skills/documentation-audit/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install documentation-audit The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project documentation-audit Installs to .agents/skills/documentation-audit/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/documentation-audit.zipunzip -o documentation-audit.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) documentation-audit PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $documentation-audit, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Audits a documentation set for accuracy, coverage gaps, staleness and findability - inventories every page, checks broken links and anchors, finds orphans and stale pages from git history, tests code samples and CLI flags against the real product, maps docs to features and support tickets, and returns a prioritized fix list with owners. Use when asked to audit, review or clean up docs, find outdated or missing documentation, plan a docs overhaul, or check a Docusaurus, MkDocs, Starlight or plain Markdown docs folder before a release.
Use it when
- Docs feel outdated but nobody knows which pages are wrong
- You are planning a docs overhaul or migration and need an inventory
- Support keeps answering questions the docs should cover
- A major release changed commands, APIs or UI and docs must catch up
Use something else when
- You want to restructure the whole site into tutorials, how-tos, reference and explanation: use diataxis-docs
- You are editing a single page for clarity: use copy-editing
How to check the result
Each P0 and P1 finding cites evidence such as a failing command, spec diff or ticket count, and rerunning scripts/docs_audit.py shows zero broken links after fixes.
The mistake it prevents
Spending the audit on link counts and style while an install or authentication guide that no longer works stays at the top of search.
A docs audit is done when the team has a ranked list of problems that matter to readers, each with evidence and a proposed fix: wrong content first, then missing content, then content readers cannot find, then polish. Broken links are the easy part; the valuable findings are instructions that no longer work and questions users ask that no page answers.
Scope before scanning
Agree with the user:
- Audience and top tasks. Who reads these docs and what are the 5-10 things they come to do (install, authenticate, first API call, migrate, troubleshoot)? Ask, or derive from analytics, support tickets and the product’s main features.
- Boundaries. Which folders, sites, versions and languages are in scope.
- Sources of truth. Where the real behaviour lives: CLI
--help, OpenAPI spec, config schema, source code, changelog. - Evidence available. Docs analytics (top pages, exits, search terms with no results), support tickets or community questions, docs feedback widgets. Ask for exports; do not assume access.
Read AGENTS.md and the docs site config (docusaurus.config.*, mkdocs.yml,
astro.config.*, sidebars.*) to learn the structure, build command and nav source.
Workflow
- [ ] 1. Inventory: run the audit script, record page count and structure
- [ ] 2. Mechanical health: broken links, anchors, orphans, titles, TODOs
- [ ] 3. Freshness: stale pages vs recently changed product areas
- [ ] 4. Accuracy: test code samples, commands, flags and API fields
- [ ] 5. Coverage: map features and top tasks to pages; mine support questions
- [ ] 6. Findability: nav, titles, search terms, entry points
- [ ] 7. Classify content type (tutorial, how-to, reference, explanation)
- [ ] 8. Score, prioritize, and write the report
1-2. Inventory and mechanical health
The bundled script works offline on any Markdown or MDX folder:
python3 scripts/docs_audit.py docs/ --stale-days 365 --json audit.json
python3 scripts/docs_audit.py website/docs --ignore '**/versioned_docs/**' --max-lines 600
It reports broken relative links and heading anchors (GitHub-style slugs and {#custom-id}),
site-absolute links it cannot resolve, pages with no inbound links, pages without a title,
duplicate titles, TODO/FIXME/TBD markers, long pages, and pages untouched for N days
(from git history, falling back to file mtime). Exit code 1 means broken links exist.
Orphan results need a second look: pages listed only in a sidebar file or autogenerated nav are reachable. Grep the nav config before calling a page orphaned:
rg -n "<page-id-or-path>" sidebars.* mkdocs.yml astro.config.* _sidebar.md 2>/dev/null
External links need network. If allowed, lychee docs/ checks them (it also has
--offline for local-only checks). Report external failures separately; many are
transient or bot-blocked.
The site build itself is also a check: Docusaurus fails on broken links by default
(onBrokenLinks), and mkdocs build --strict turns warnings into errors.
3. Freshness
Age alone is not staleness: a stable concept page can be fine at three years old. Stale means “the product changed and the page did not”. Compare:
git log --since=12.months --name-only --format= -- src/ | sort | uniq -c | sort -nr | head -30
git log -1 --format='%cs %s' -- docs/guides/auth.md
Flag pages whose product area changed heavily after the page’s last edit. Also grep docs for version numbers, deprecated names and removed flags:
rg -n "v[0-9]+\.[0-9]+|deprecated|legacy|old-flag-name" docs/ | head -50
4. Accuracy
Accuracy problems hurt readers most and are invisible to link checkers.
| Check | How |
|---|---|
| Commands and flags | Extract shell blocks; compare each flag to <tool> --help output |
| Code samples | Run the top-task samples in a scratch directory (ask before installing packages or using network) |
| API reference | Diff documented endpoints, params and fields against the OpenAPI spec or route definitions |
| Config keys | Compare documented keys and defaults to the config schema or the code that reads them |
| Screenshots and UI labels | Compare label text to the UI strings file; note screenshots older than the last redesign |
| Versions and prerequisites | Compare to package.json, engines, pyproject.toml, supported-platform lists |
# list fenced shell blocks with their file and line, for manual or scripted testing
rg -n -A6 '^```(bash|sh|shell|console)' docs/ | head -80
Record each failure with the page, the line, what it says, what is true, and the source of truth used.
5. Coverage gaps
Build a feature-to-docs matrix:
| Feature / top task | Page(s) | Type | Status |
|---|---|---|---|
| Install on Windows | install.md#windows | how-to | Outdated (uses removed MSI) |
| Rotate API keys | - | - | Missing |
| Webhooks retries | webhooks.md | reference | Partial: no retry schedule |
Feature lists come from the product: CLI command list, API resources, settings pages, changelog entries for the last year. Then mine demand:
- Support tickets and forum questions: cluster by topic; any cluster with 5+ tickets and no page, or a page that does not answer it, is a gap.
- Docs search terms with zero results or high exit rate.
- Error messages from the product: does searching the docs for each one find an answer?
6. Findability
| Signal | Problem when |
|---|---|
| Nav depth | Top tasks more than 3 clicks from the docs home |
| Titles | Vague (“Overview”, “Advanced”), duplicated, or named after internal concepts readers do not know |
| First paragraph | Does not say what the page helps you do |
| Search | Common terms (from tickets) return the wrong page first |
| Landing page | Does not route the 3-4 main audiences to their first step |
| Cross-links | Reference pages do not link to the how-to that uses them, and vice versa |
7. Content type
Classify each important page as tutorial, how-to, reference or explanation. Pages mixing
types (a reference page with a 40-step tutorial inside) are hard to use and hard to
maintain; flag them for splitting. Restructuring a whole site by type belongs to
$diataxis-docs.
8. Score and prioritize
| Priority | Criteria | Examples |
|---|---|---|
| P0 | Wrong content on a top task; security or data-loss risk from following docs | Install command fails; docs show disabling TLS verification |
| P1 | Missing docs for a top task or high-ticket topic; broken links on top pages | No key-rotation guide; 14 tickets/month |
| P2 | Stale or partial pages, findability problems, orphans with value | Old screenshots; buried migration guide |
| P3 | Style, duplication, long pages, TODO markers | Inconsistent terminology |
Effort: S (under an hour), M (a day), L (needs SME input or new examples).
Report template
# Docs audit: <site/folder> - <YYYY-MM-DD>
**Scope:** <folders, versions> **Pages:** 214 **Method:** script, sample testing, ticket mining (Q3)
## Summary
<3-5 sentences: biggest risks, biggest gaps, overall state, recommended first sprint.>
## Findings
| # | Priority | Page | Problem | Evidence | Fix | Effort | Owner |
|---|---|---|---|---|---|---|---|
| 1 | P0 | install.md#windows | MSI installer removed in v3 | `acme --version` 3.2; releases page | Replace with winget steps | S | |
## Coverage matrix
## Mechanical health (counts from docs_audit.py)
## Pages proposed for archive or merge (needs confirmation)
## Not checked
<external links offline, versions not tested, areas without SME access>
Gotchas
- Fixing 300 broken links while the install guide is wrong. Rank by reader impact.
- Deleting “orphan” or “stale” pages that have external inbound links or search traffic. Propose redirects, and delete or archive pages only after the user confirms.
- Testing samples against a dev build instead of the released version readers have.
- Treating the audit as a rewrite: the deliverable is the prioritized list; fix only what the user asked you to fix in this pass.
Verification
- Every P0 and P1 finding cites evidence (command output, spec diff, ticket count).
- Rerun
docs_audit.pyafter fixes: broken links at zero, or the remainder explained. - The coverage matrix includes every top task the user named.
- The “Not checked” section is honest about what was out of reach in the sandbox.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Documentation Audit"
short_description: "Find the wrong docs before the broken links"
default_prompt: "$documentation-audit Audit our docs folder and tell me what is outdated or missing" scripts/docs_audit.py
#!/usr/bin/env python3
"""Mechanical audit of a Markdown/MDX docs folder.
Finds broken relative links and anchors, pages nothing links to, pages without a
title, stale pages (by last git commit, falling back to file mtime), TODO/FIXME
markers, duplicate titles and very long pages. Standard library only; works offline.
Usage:
python3 docs_audit.py DOCS_DIR [--stale-days 365] [--max-lines 800] [--json OUT.json]
[--ignore GLOB ...]
Exit code: 0 no broken links, 1 broken links or anchors found, 2 bad input.
"""
from __future__ import annotations
import argparse
import fnmatch
import json
import os
import re
import subprocess
import sys
import time
from collections import Counter, defaultdict
from pathlib import Path
EXTS = (".md", ".mdx")
INDEX_NAMES = ("index.md", "index.mdx", "README.md", "readme.md", "_index.md")
LINK = re.compile(r"(?<!!)\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+\"[^\"]*\")?\s*\)")
IMAGE = re.compile(r"!\[[^\]]*\]\(\s*<?([^)\s>]+)>?")
REFDEF = re.compile(r"^\s*\[[^\]]+\]:\s*(\S+)", re.M)
HREF = re.compile(r"""href=["']([^"']+)["']""")
HEADING = re.compile(r"^(#{1,6})\s+(.+?)\s*#*\s*$", re.M)
EXPLICIT_ID = re.compile(r"\{#([\w-]+)\}\s*$")
FENCE = re.compile(r"^(```|~~~).*?^\1", re.M | re.S)
FRONTMATTER = re.compile(r"\A---\n(.*?)\n---\n", re.S)
TODO = re.compile(r"\b(TODO|FIXME|TBD|XXX)\b")
def slugify(text: str) -> str:
text = re.sub(r"`|\*|_{2,}|\[|\]\([^)]*\)", "", text) # strip inline markup
text = re.sub(r"<[^>]+>", "", text).strip().lower()
text = re.sub(r"[^\w\- ]", "", text)
return text.replace(" ", "-")
def anchors_for(body: str) -> set[str]:
seen: Counter = Counter()
out = set()
for _, title in HEADING.findall(body):
m = EXPLICIT_ID.search(title)
if m:
out.add(m.group(1))
title = EXPLICIT_ID.sub("", title)
slug = slugify(title)
out.add(slug if seen[slug] == 0 else f"{slug}-{seen[slug]}")
seen[slug] += 1
out.update(re.findall(r"""<a\s+(?:name|id)=["']([^"']+)["']""", body))
return out
def git_dates(root: Path) -> dict[str, float]:
try:
top = subprocess.run(["git", "-C", str(root), "rev-parse", "--show-toplevel"],
capture_output=True, text=True, check=True).stdout.strip()
log = subprocess.run(["git", "-C", top, "log", "--name-only", "--format=@@%ct", "--", str(root.resolve())],
capture_output=True, text=True, check=True).stdout
except (OSError, subprocess.CalledProcessError):
return {}
dates: dict[str, float] = {}
ts = None
for line in log.splitlines():
if line.startswith("@@"):
ts = float(line[2:])
elif line.strip() and ts is not None:
dates.setdefault(str((Path(top) / line.strip()).resolve()), ts) # newest first
return dates
def resolve(src: Path, target: str, root: Path) -> Path | None:
base = root if target.startswith("/") else src.parent
cand = (base / target.lstrip("/")).resolve()
tries = [cand]
if not cand.suffix:
tries += [cand.with_suffix(e) for e in EXTS] + [cand / n for n in INDEX_NAMES]
elif cand.suffix == ".html":
tries += [cand.with_suffix(e) for e in EXTS]
for t in tries:
if t.is_file():
return t
return None
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("docs_dir", type=Path)
ap.add_argument("--stale-days", type=int, default=365)
ap.add_argument("--max-lines", type=int, default=800)
ap.add_argument("--json", type=Path, help="also write findings as JSON")
ap.add_argument("--ignore", action="append", default=["**/node_modules/**", "**/_build/**"])
args = ap.parse_args()
root = args.docs_dir.resolve()
if not root.is_dir():
print(f"error: {root} is not a directory", file=sys.stderr)
return 2
pages = sorted(p.resolve() for p in root.rglob("*") if p.suffix in EXTS and p.is_file()
and not any(fnmatch.fnmatch(str(p), g) for g in args.ignore))
if not pages:
print(f"error: no .md/.mdx files under {root}", file=sys.stderr)
return 2
dates = git_dates(root)
now = time.time()
texts, anchors, titles = {}, {}, {}
for p in pages:
raw = p.read_text(encoding="utf-8", errors="replace")
fm = FRONTMATTER.match(raw)
body = FENCE.sub("", raw[fm.end():] if fm else raw)
texts[p] = (raw, body)
anchors[p] = anchors_for(body)
title = None
if fm:
m = re.search(r"^title:\s*['\"]?(.+?)['\"]?\s*$", fm.group(1), re.M)
title = m.group(1) if m else None
if not title:
m = re.search(r"^#\s+(.+)$", body, re.M)
title = m.group(1).strip() if m else None
titles[p] = title
broken, unresolved_abs, inbound = [], [], defaultdict(set)
findings = defaultdict(list)
for p in pages:
raw, body = texts[p]
targets = LINK.findall(body) + IMAGE.findall(body) + REFDEF.findall(body) + HREF.findall(body)
for t in targets:
if re.match(r"^[a-z][a-z0-9+.-]*:", t, re.I) or t.startswith("//"):
continue # external, mailto:, tel:, etc. Not checked offline.
path_part, _, frag = t.partition("#")
if not path_part:
if frag and frag not in anchors[p]:
broken.append((p, t, "anchor not found on this page"))
continue
dest = resolve(p, path_part.split("?")[0], root)
if dest is None:
(unresolved_abs if path_part.startswith("/") else broken).append((p, t, "file not found"))
continue
if dest != p:
inbound[dest].add(p)
if frag and dest in anchors and frag not in anchors[dest]:
broken.append((p, t, f"anchor #{frag} not in {dest.relative_to(root)}"))
rel = str(p.relative_to(root))
lines = raw.count("\n") + 1
if not titles[p]:
findings["no_title"].append(rel)
if lines > args.max_lines:
findings["long_pages"].append(f"{rel} ({lines} lines)")
todos = len(TODO.findall(body))
if todos:
findings["todo_markers"].append(f"{rel} ({todos})")
ts = dates.get(str(p)) or p.stat().st_mtime
age = int((now - ts) / 86400)
if age > args.stale_days:
findings["stale"].append((age, rel))
for p in pages:
if p.name in INDEX_NAMES or p.parent == root and p.stem.lower() in ("index", "readme"):
continue
if not inbound[p]:
findings["orphans"].append(str(p.relative_to(root)))
dupes = [t for t, c in Counter(t for t in titles.values() if t).items() if c > 1]
findings["duplicate_titles"] = [f"{t}: " + ", ".join(str(p.relative_to(root)) for p in pages if titles[p] == t)
for t in dupes]
findings["stale"] = [f"{rel} ({age} days)" for age, rel in sorted(findings["stale"], reverse=True)]
src = "git history" if dates else "file mtime (no git history found)"
print(f"Docs audit: {root}\n{len(pages)} pages; staleness from {src}\n")
print(f"Broken links/anchors: {len(broken)}")
for p, t, why in broken:
print(f" {p.relative_to(root)} -> {t} ({why})")
if unresolved_abs:
print(f"\nSite-absolute links not found under docs root (check site routing): {len(unresolved_abs)}")
for p, t, _ in unresolved_abs[:50]:
print(f" {p.relative_to(root)} -> {t}")
labels = {
"orphans": "Pages with no inbound links (check sidebar/nav config before deleting)",
"stale": f"Not changed in > {args.stale_days} days",
"no_title": "Pages without a title (frontmatter title or H1)",
"duplicate_titles": "Duplicate titles",
"todo_markers": "Pages with TODO/FIXME/TBD markers",
"long_pages": f"Pages over {args.max_lines} lines",
}
for key, label in labels.items():
items = findings.get(key, [])
print(f"\n{label}: {len(items)}")
for item in items[:50]:
print(f" {item}")
if len(items) > 50:
print(f" ... {len(items) - 50} more (see --json)")
if args.json:
out = {k: v for k, v in findings.items()}
out["broken"] = [{"page": str(p.relative_to(root)), "target": t, "reason": w} for p, t, w in broken]
out["unresolved_absolute"] = [{"page": str(p.relative_to(root)), "target": t} for p, t, _ in unresolved_abs]
out["pages"] = len(pages)
args.json.write_text(json.dumps(out, indent=2), encoding="utf-8")
print(f"\nJSON written to {args.json}")
return 1 if broken else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.